天气可视化分析系统,在开发者的练手项目清单里属于永远不会过时的那一类。它的覆盖面很有意思:数据采集、清洗入库、SQL聚合、接口封装、前端图表渲染,一条完整链路全都能碰到。我这次基于 Python + Flask 做了一套带源码、数据库和文档的版本,数据库用的 SQLite,图表用的 ECharts,前后端走的是典型的小型 B/S 架构。整个项目跑通之后最大的感受是:麻雀虽小,五脏俱全,非常适合用来练手,也可以直接当课程设计或者简历里的项目素材。
这套系统能做的事情很直接:管理多个城市的历史气象数据,按日期区间查看温度、湿度、降水量、空气质量的变化趋势,支持城市之间的数据对比,还能根据年份和月份做聚合统计。哪怕你手头没有任何气象数据,我也在项目里写了采集脚本,可以从公开的天气数据源拉取历史记录并自动入库。前端页面不依赖重型框架,用 Flask 自带模板配合原生 JavaScript 加 ECharts 就能画出很漂亮的折线图、柱状图和散点图。
适合看这篇文章的人,我猜大概是这几类:正在做期末项目或者毕设的学生,想用一个小而完整的项目补齐前后端开发闭环的初学者,以及打算把技术方案梳理成一套可交付文档的开发者。下面内容会按照我实际开发时的顺序来讲,从设计思路、数据库建模、后端接口,到前端渲染、完整跑通步骤,最后是排错心得,尽量把我踩过的坑都说清楚。
1. 项目整体设计与架构思路
1.1 技术选型:为什么是 Flask 而不是别的框架
后端框架选择上,我当时在 Flask 和 FastAPI 之间犹豫过一阵。FastAPI 的性能更好,自带接口文档,类型提示也舒服,但考虑到这个项目的定位是“源码 + 数据库 + 文档”的完整交付物,最终我还是选了 Flask。原因很简单:Flask 的模板渲染和路由组织在小型项目里更顺手,用 Jinja2 模板可以直接输出完整页面,不需要额外单独部署一个前端静态服务;而 FastAPI 做 API 更舒服,但当前端模板和接口要揉在一个项目里时,Flask 的生态和写法对新手更友好。
框架选型不是越新越好,衡量标准应该是“当前场景下谁的综合成本最低”。这个项目里 Flask 的优势体现在三点:
- 路由编写简单直观,几十行代码就能把核心接口写完。
- 官方模板引擎自带,页面跳转和参数传递天然方便,不用跨域联调。
- 社区案例非常多,遇到问题搜索解决方案特别快。
ORM 层面我用了 Flask-SQLAlchemy,一方面是为了写模型代码更高效,另一方面是将来的数据库迁移成本低。如果哪一天要从 SQLite 换成 MySQL,只需要改数据库连接串,模型本身不需要大改。
1.2 系统分层与功能模块划分
整个系统我按常规的四层结构来组织,每个层次各司其职:
- 数据接入层:负责获取历史气象数据。我写了一个采集脚本,可以从公开天气数据源拉取 JSON 数据,解析后做清洗,再写入数据库;也支持直接导入规范化 CSV 文件。
- 存储层:SQLite 数据库,通过 SQLAlchemy 管理两张核心表,一张城市表,一张天气记录表。
- 接口层:Flask 路由返回统一的 JSON 结构,前端只对接接口,不直接读数据库。
- 展示层:Jinja2 模板页面 + 原生 JavaScript + ECharts,负责数据可视化和交互。
功能模块上,我拆成了城市管理、历史天气查询、温度趋势分析、降水量对比、空气质量和湿度统计这几个主要部分。模块之间的边界很清晰:城市管理维护基础字典数据,查询类接口只处理读取和聚合,前端页面通过不同的接口组合完成展示。这样的拆分,就算后续有新人接手,也能很快找到对应的代码位置。
这个设计思路对于类似项目都有参考价值:先明确数据从哪里来、存到哪里去、对外提供什么接口、页面怎么展示,代码只是把这四个环节衔接起来。
2. 数据库设计与数据准备
2.1 表结构设计思路
数据库我设计了两张表,一张存城市基础信息,一张存每天的天气记录。不要小看这个建表环节,字段定义直接决定了后面接口写起来是否顺手。
城市表字段:
- id:主键
- name:城市名称,唯一索引
- province:所属省份
- lon/lat:经度和纬度,预留字段,后面做地理分布图或者计算距离时用
天气记录表字段:
- id:主键
- city_id:关联城市表的外键
- date:日期,统一存储为字符串,格式 YYYY-MM-DD
- high_tmp:最高温度
- low_tmp:最低温度
- humidity:相对湿度
- precipitation:降水量
- wind_direction:风向
- wind_level:风力等级
- aqi:空气质量指数
模型定义代码大致是这样的:
from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() class City(db.Model): __tablename__ = 'city' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(50), nullable=False, unique=True) province = db.Column(db.String(50), nullable=True) lon = db.Column(db.Float, nullable=True) lat = db.Column(db.Float, nullable=True) records = db.relationship('WeatherRecord', backref='city', lazy=True) class WeatherRecord(db.Model): __tablename__ = 'weather_record' id = db.Column(db.Integer, primary_key=True) city_id = db.Column(db.Integer, db.ForeignKey('city.id'), nullable=False) date = db.Column(db.String(10), nullable=False) # YYYY-MM-DD high_tmp = db.Column(db.Float, nullable=True) low_tmp = db.Column(db.Float, nullable=True) humidity = db.Column(db.Float, nullable=True) precipitation = db.Column(db.Float, nullable=True) wind_direction = db.Column(db.String(20), nullable=True) wind_level = db.Column(db.String(20), nullable=True) aqi = db.Column(db.Integer, nullable=True) __table_args__ = ( db.Index('idx_city_date', 'city_id', 'date'), )2.2 字段类型与索引设计的细节
有一类细节容易被忽略,但实际使用中特别影响体验。比如日期字段,我刻意没有用 SQLite 的 DATE 类型,而是存成了字符串。原因很实在:SQLite 对日期类型的支持本身比较弱,而 YYYY-MM-DD 这种格式的字符串在进行字典序比较时天然等价于时间顺序,范围查询直接用字符串比较也能得到正确结果,显示时还不用做任何转换。
再比如温度和降水量用 Float,因为气象数据里这些字段经常出现小数;风向和风力等级用 String,保存“东南风”“3级”这类文本;AQI 是整数,一般空气质量指数就是整数值。
索引设计上,我给weather_record表建了(city_id, date)的复合索引。如果直接查询某城市某时间段的数据,这个索引能显著减少扫描行数。数据量只有几百条的时候感受不明显,但一旦你把时间跨度拉长到五年九个城市,这个索引能带来十几倍的速度提升。
这里也顺带说一个建表的经验:索引不是越多越好,尤其是 SQLite 这种单文件数据库,过多索引会增加写入时的开销。像我的项目里读写比例大概是 1:10 甚至更低,读多写少,所以给常用查询字段建索引非常划算。
2.3 数据来源与导入流程
数据来源我用了两条路:自己写采集脚本从公开天气接口拉历史数据,以及直接从 CSV 数据文件批量导入。如果你对某个第三方数据源不熟悉,建议先用 CSV 导入跑通全流程,之后再去研究接口采集。
采集脚本的核心逻辑也不复杂,用 requests 请求接口,解析返回的 JSON,逐条清洗后写入数据库。需要特别处理的是空值字段,有些第三方接口在某个日期没有降水量数据时会返回 null,如果直接写入就会给后续图表渲染留坑,前端拿到 null 显示会出问题。我在清洗阶段统一做了一层过滤和默认值填充。
批量导入部分我用 pandas 读 CSV,再逐行插入数据库:
import pandas as pd from models import db, City, WeatherRecord def import_csv(filepath, city_id): df = pd.read_csv(filepath, encoding='utf-8') city = City.query.get(city_id) if not city: return {'code': 1, 'msg': 'city not found'} added = 0 for _, row in df.iterrows(): record = WeatherRecord( city_id=city_id, date=str(row['date']), high_tmp=row.get('high_tmp'), low_tmp=row.get('low_tmp'), humidity=row.get('humidity'), precipitation=row.get('precipitation'), wind_direction=row.get('wind_direction'), wind_level=row.get('wind_level'), aqi=row.get('aqi') if pd.notna(row.get('aqi')) else None ) db.session.add(record) added += 1 if added % 500 == 0: db.session.commit() db.session.commit() return {'code': 0, 'msg': f'imported {added} records'}每 500 条做一次批量 commit,是为了避免一次性插入上万条数据时数据库缓存膨胀,这也是我实际运行下来觉得比较稳妥的做法。
3. Flask 后端接口实现
3.1 项目目录结构与初始化
后端部分我采用了蓝图的组织方式。蓝图的好处是让路由模块化,不会把所有接口堆在一个文件里,项目结构更清晰。整个后端目录大致如下:
weather_system/ ├── app.py # 应用入口 ├── models.py # 数据库模型 ├── init_db.py # 初始化数据库 ├── collect_data.py # 数据采集脚本 ├── import_csv.py # CSV 导入脚本 ├── requirements.txt # 依赖清单 ├── api/ │ └── routes.py # 接口蓝图 ├── templates/ # Jinja2 模板 │ ├── index.html │ ├── city_detail.html │ └── compare.html ├── static/ │ ├── css/ │ └── js/ └── weather.db # SQLite 数据库文件app.py的初始化逻辑是:创建 Flask 应用,加载配置,注册蓝图,初始化数据库对象。一个简化版本大概是这样的:
from flask import Flask from flask_cors import CORS from models import db from api.routes import api_bp app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///weather.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db.init_app(app) CORS(app) app.register_blueprint(api_bp, url_prefix='/api') if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)CORS 在这里先加上了,是为了避免以后把接口和前端页面拆开部署时遇到跨域问题。虽然目前模板和接口同源,但提前做好配置属于低成本的防御手段。
3.2 查询接口的写法与 SQL 细节
接口层我设计了几个核心路由,每一个都对应前端的一个图表模块:
- GET
/api/cities:拉取城市列表,用于下拉框填充。 - GET
/api/weather/trend?city_id=1&start=2023-01-01&end=2023-12-31:返回某城市在指定日期区间的逐日温度数据。 - GET
/api/weather/precipitation?city_id=1&start=2023-01-01&end=2023-12-31:返回降水量序列。 - GET
/api/weather/compare?city_ids=1,2,3&year=2023:多城市同一年份的月均最高温度对比。 - GET
/api/weather/summary?city_id=1&year=2023:返回年份维度的统计结果,比如最高温、最低温、总降水量、平均 AQI。
温度趋势接口的实现,用 SQLAlchemy 写起来比较直接:
from flask import Blueprint, request, jsonify from models import WeatherRecord api_bp = Blueprint('api', __name__) @api_bp.route('/weather/trend') def weather_trend(): city_id = request.args.get('city_id', type=int) start = request.args.get('start', '1900-01-01') end = request.args.get('end', '2100-01-01') records = ( WeatherRecord.query .filter( WeatherRecord.city_id == city_id, WeatherRecord.date >= start, WeatherRecord.date <= end, ) .order_by(WeatherRecord.date.asc()) .all() ) data = [ { 'date': r.date, 'high': r.high_tmp, 'low': r.low_tmp, 'humidity': r.humidity, 'aqi': r.aqi, } for r in records ] return jsonify({'code': 0, 'msg': 'success', 'data': data})这里有两个细节值得说明。一是日期范围查询基于字符串比较,因为 YYYY-MM-DD 格式的原因,date >= start和date <= end能正确完成时间过滤。二是 ORM 查询返回的对象需要手动转换成字典再编码 JSON,直接在模板对象上 jsonify 会报序列化错误,这是初学者很常见的一个坑。
对比接口里涉及聚合逻辑,我用了函数式聚合查询:
from sqlalchemy import func @api_bp.route('/weather/summary') def weather_summary(): city_id = request.args.get('city_id', type=int) year = request.args.get('year', type=int) start = f'{year}-01-01' end = f'{year}-12-31' result = ( db.session.query( func.max(WeatherRecord.high_tmp).label('max_high'), func.min(WeatherRecord.low_tmp).label('min_low'), func.sum(WeatherRecord.precipitation).label('total_precip'), func.avg(WeatherRecord.aqi).label('avg_aqi'), ) .filter( WeatherRecord.city_id == city_id, WeatherRecord.date >= start, WeatherRecord.date <= end, ) .first() ) return jsonify({ 'code': 0, 'msg': 'success', 'data': { 'max_high': result.max_high, 'min_low': result.min_low, 'total_precip': round(result.total_precip or 0, 1), 'avg_aqi': round(result.avg_aqi or 0, 1), } })聚合函数的结果要通过.label()命名,否则取不出来;求和和平均值可能返回 None,所以外层做了or 0兜底。这些都是跑接口时比较容易埋雷的地方。
3.3 返回格式与前端对接约定
所有接口我都返回统一结构:
{ "code": 0, "msg": "success", "data": {} }前端只要判断code === 0就认为请求成功,再处理数据;非零 code 直接弹出 msg 提示。这个约定在前后端联调时节省了大量沟通成本,页面永远只处理一种成功形态。
统一返回格式并不是为了显得规范,而是为了减少前端的分支判断。如果有的接口直接返回数组,有的返回对象,有的报错时直接返回 500 页面,那前端每个接口都要写不同的容错代码。统一之后,前端封装一个请求函数就够了。
4. 前端数据可视化与交互
4.1 模板页面组织方式
前端我没有用前后端分离的 SPA 架构,而是用 Jinja2 模板组织页面,配合少量原生 JavaScript。这样做的最大好处是减少部署复杂度:一个 Flask 应用启动后,页面、静态资源、接口都在同一个服务下,不需要再配 Nginx 和 Node 环境。
页面我做了三个:
- 首页
index.html:城市总览,包含当前城市的年度温度曲线和最近30天湿度趋势。 - 城市详情页
city_detail.html:完整的全年逐日天气图,包含最高最低温度、降水量柱状图、AQI 与湿度散点图。 - 对比页
compare.html:多城市年度月均温度对比,用多条折线展示差异。
页面骨架用模板继承来写,公共头部和导航放基础模板,子页面只需要填内容块。这样如果后面要加一个“导出报表”页面,工作量很小。
4.2 图表类型选择与数据渲染
图表库选 ECharts 是因为它配置简单、图表交互做得好,折线图、柱状图、散点图直接引入一个 JS 文件就能用,而且对移动端的适配比一些轻量库更省心。
前端的数据渲染逻辑很统一:通过fetch请求后端接口,拿到 JSON 数据之后整理成图表需要的数组,再调用setOption渲染。以温度趋势图为例:
async function loadTrend(cityId, start, end) { const resp = await fetch( `/api/weather/trend?city_id=${cityId}&start=${start}&end=${end}` ); const result = await resp.json(); if (result.code !== 0) { showError(result.msg); return; } const dates = result.data.map(r => r.date); const highs = result.data.map(r => r.high); const lows = result.data.map(r => r.low); trendChart.setOption({ tooltip: { trigger: 'axis' }, legend: { data: ['最高温度', '最低温度'] }, xAxis: { data: dates }, yAxis: { type: 'value', name: '温度(°C)' }, series: [ { name: '最高温度', type: 'line', smooth: true, data: highs }, { name: '最低温度', type: 'line', smooth: true, data: lows } ] }); }图表类型的选择上有一些经验可以分享:
- 温度随日期的变化用折线图,因为重点看趋势和波动。
- 降水量按月汇总用柱状图,因为要比较不同月份的绝对量级。
- AQI 与湿度之间的关系用散点图,能够直观看出相关性。
- 多城市月均温度对比用多条折线,信息密度适中,交互简单。
没必要把图表风格搞得很花哨。ECharts 默认主题已经足够好看,真正要注意的是数据是否存在空值,以及时间轴的数据是否对齐。如果南北方城市在同一个图上对比,很多南方城市没有降雪数据,字段为空,图表会断线。数据处理阶段,我用空字符串替代 null 传给前端,前端判断后跳过断线点,显示效果会自然很多。
4.3 交互设计细节
交互设计上没有做很复杂的东西,核心就是三块:筛选条件、图表联动、加载状态。
筛选条件放在页面顶部,城市下拉框用接口返回的城市列表填充,旁边放日期选择器,默认展示最近一年数据。用户切换城市或者修改日期范围之后,页面重新请求接口并刷新图表。
图表联动方面,我做了两个小优化:一是在散点图上鼠标悬停时显示对应的日期和 AQI 数值,二是折线图和大图区域缩放联动,用户拖拽时间轴的缩放条时,下方总览图同步高亮对应区域。ECharts 里dataZoom配置就能实现,加上以后整个页面的专业感立刻就出来了。
加载状态也很重要。接口请求有延迟,刚开始数据量小不明显,但一旦查询五年数据,接口响应可能要几百毫秒。我在页面里加了一个半透明遮罩层,请求期间显示加载动画,数据返回后隐藏,避免用户反复点击造成重复请求。
5. 从零跑通整个项目的实操步骤
5.1 环境准备与依赖安装
如果你要把这个项目在自己的电脑上完整跑起来,按下面步骤走基本不会出错。
先确认 Python 版本。推荐 3.9 或以上版本,我这个项目在 3.8 上也能跑,但 3.8 以后的部分语法糖用着更舒服。装完 Python 之后,建议先用虚拟环境隔离项目依赖:
python -m venv venv # Windows venv\Scripts\activate # Linux / macOS source venv/bin/activate激活虚拟环境后安装依赖:
pip install flask flask-sqlalchemy flask-cors pandas requests把这些写进requirements.txt会更规范,别人拿到项目之后执行pip install -r requirements.txt就可以了。我的 requirements.txt 里还会带上pymysql,虽然项目默认用 SQLite,但如果有人想切 MySQL,这个依赖可以省得再装一次。
需要提醒一句:不要用系统全局环境直接跑这个项目。全局环境装多了包以后版本冲突几乎是必然的,虚拟环境隔离能省掉未来一堆麻烦。
5.2 初始化数据库并导入数据
依赖装好之后,第一步是初始化数据库和执行导入操作。init_db.py里面会创建所有数据表:
from models import db, City from app import app with app.app_context(): db.create_all() default_cities = [ {'name': '北京', 'province': '北京市', 'lon': 116.4, 'lat': 39.9}, {'name': '上海', 'province': '上海市', 'lon': 121.47, 'lat': 31.23}, {'name': '广州', 'province': '广东省', 'lon': 113.26, 'lat': 23.13}, {'name': '成都', 'province': '四川省', 'lon': 104.07, 'lat': 30.66}, ] for item in default_cities: if not City.query.filter_by(name=item['name']).first(): db.session.add(City(**item)) db.session.commit()跑初始化脚本:
python init_db.py接下来导入天气数据。如果你有现成的 CSV 文件,用import_csv.py导入;如果你想演示采集功能,在collect_data.py里配置好数据源之后执行:
python collect_data.py采集脚本默认会给每个配置好的城市各拉取一年的数据,拉取过程中自动跳过已存在的日期,避免重复入库。这一步跑完,数据库里就有真实可用的数据了。
5.3 启动调试与常见运行参数
启动服务:
python app.py然后在浏览器访问http://127.0.0.1:5000。如果看到首页能正常加载城市列表和图表,说明整个项目已经跑通了。
如果端口被占用,可以用环境变量指定端口:
python app.py --port=5001也可以用 Flask 提供的环境变量方式:
set FLASK_APP=app.py set FLASK_ENV=development flask run --host=0.0.0.0 --port=5001开发模式开启debug=True之后,改代码会自动重启,排查接口逻辑效率能高不少。但要记住,上线部署时必须关闭 debug,否则会暴露非常详细的报错信息。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
项目在开发和交付过程中,我陆陆续续遇到过不少问题,也帮几个同学排查过类似项目。下面这张表基本覆盖了高频问题,按“问题现象、可能原因、解决办法”三个维度整理:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 页面图表空白 | 接口返回数据为空,或接口报错 | 打开浏览器开发者工具看 Network,确认接口返回的 code 是否为 0;重点检查 city_id 是否正确 |
| 日期范围查询结果不对 | start/end 参数格式错误 | 确认日期格式统一为 YYYY-MM-DD,避免传入 YYYY/MM/DD |
| 中文字段显示乱码 | 数据库连接字符串缺少字符集配置 | SQLite 通常不会乱码;如果切换 MySQL,连接串加?charset=utf8mb4 |
| 端口被占用 | 5000 端口已被其他进程占用 | 换端口启动,比如python app.py --port=5001 |
| 查询速度越来越慢 | 缺少索引,数据量大时全表扫描 | 给weather_record(city_id, date)建复合索引 |
| 采集脚本报 JSON 解析错误 | 第三方数据源接口返回结构变化 | 打印返回文本,检查 data 字段层级,调整解析逻辑 |
| 前端 fetch 接口 500 | 后端接口内部报错,多半是序列化问题 | 查看 Flask 终端输出堆栈,优先排查日期和 Decimal 字段序列化 |
| 图表数据断线 | 字段存在空值 | 后端填充默认值,或前端用connectNulls: false配置断开空值点 |
6.2 我在实际调试中的几点心得
第一个心得是:接口联调阶段,一定先看后端返回的原始 JSON,再看图表。很多前端渲染问题其实是后端返回的数据结构不对,而不是图表配置不对。养成“先确认数据、再修渲染”的习惯,能少走很多弯路。
第二个心得是:SQLite 项目交付时,数据库文件一定要和源码、文档一起打包,并且文档里要写清楚数据库的版本和表结构。不然对方拿到源码后数据库打不开,或者能打开但表对不上,整个交付就变成了一种折磨。我在文档里会放一段SELECT * FROM weather_record LIMIT 5的示例输出,让接收方一眼就能确认数据库是否完整、字段是否符合预期。
第三个心得是关于 ECharts 性能的。如果数据量大到要渲染几千个点,建议打开sampling配置,让 ECharts 自动降采样,否则图表交互会变得卡顿。我为了让效果更顺滑,还设置了dataZoom只展示最近 30 天的数据,其余时间范围通过缩放查看,这样既保留了完整数据,又保证了首屏渲染速度。
当初做这套系统,我最深的体会是:这类全链路小项目真正难的不是某一个单独的技术点,而是把采集、清洗、存储、接口、可视化一条线串起来的过程中,每一步都可能出意外。数据格式不对、接口超时、字段为空、JSON 序列化报错、图表数据对不上,这些问题单独看都不难,但只有完整做一遍,才能对整套数据系统有比较接近真实的认识。
最后再分享一个可以继续扩展的思路。现在这套系统只能查静态历史数据,后续可以加一个定时任务,比如用系统自带的计划任务每天早上自动拉取前一天的数据入库;也可以在对比页增加“导出 Excel 报表”功能,把聚合结果输出成文件;甚至可以把城市表扩展成包含区县级别的区域数据,把图表做成地图可视化。项目边界拉到哪一层,取决于你想练哪块技术,正好也是后续扩展的方向。