资讯详情 Python Flask构建天气可视化分析系统:从数据采集到ECharts图表展示全流程
📅 2026/10/18 15:44:37
天气可视化分析系统在开发者的练手项目清单里属于永远不会过时的那一类。它的覆盖面很有意思数据采集、清洗入库、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-DDhigh_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_keyTrue) name db.Column(db.String(50), nullableFalse, uniqueTrue) province db.Column(db.String(50), nullableTrue) lon db.Column(db.Float, nullableTrue) lat db.Column(db.Float, nullableTrue) records db.relationship(WeatherRecord, backrefcity, lazyTrue) class WeatherRecord(db.Model): __tablename__ weather_record id db.Column(db.Integer, primary_keyTrue) city_id db.Column(db.Integer, db.ForeignKey(city.id), nullableFalse) date db.Column(db.String(10), nullableFalse) # YYYY-MM-DD high_tmp db.Column(db.Float, nullableTrue) low_tmp db.Column(db.Float, nullableTrue) humidity db.Column(db.Float, nullableTrue) precipitation db.Column(db.Float, nullableTrue) wind_direction db.Column(db.String(20), nullableTrue) wind_level db.Column(db.String(20), nullableTrue) aqi db.Column(db.Integer, nullableTrue) __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, encodingutf-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_idcity_id, datestr(row[date]), high_tmprow.get(high_tmp), low_tmprow.get(low_tmp), humidityrow.get(humidity), precipitationrow.get(precipitation), wind_directionrow.get(wind_direction), wind_levelrow.get(wind_level), aqirow.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: fimported {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(host0.0.0.0, port5000, debugTrue)CORS 在这里先加上了是为了避免以后把接口和前端页面拆开部署时遇到跨域问题。虽然目前模板和接口同源但提前做好配置属于低成本的防御手段。3.2 查询接口的写法与 SQL 细节接口层我设计了几个核心路由每一个都对应前端的一个图表模块GET/api/cities拉取城市列表用于下拉框填充。GET/api/weather/trend?city_id1start2023-01-01end2023-12-31返回某城市在指定日期区间的逐日温度数据。GET/api/weather/precipitation?city_id1start2023-01-01end2023-12-31返回降水量序列。GET/api/weather/compare?city_ids1,2,3year2023多城市同一年份的月均最高温度对比。GET/api/weather/summary?city_id1year2023返回年份维度的统计结果比如最高温、最低温、总降水量、平均 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, typeint) 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, typeint) year request.args.get(year, typeint) 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(nameitem[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 --port5001也可以用 Flask 提供的环境变量方式set FLASK_APPapp.py set FLASK_ENVdevelopment flask run --host0.0.0.0 --port5001开发模式开启debugTrue之后改代码会自动重启排查接口逻辑效率能高不少。但要记住上线部署时必须关闭 debug否则会暴露非常详细的报错信息。6. 常见问题与排查技巧实录6.1 高频问题速查表项目在开发和交付过程中我陆陆续续遇到过不少问题也帮几个同学排查过类似项目。下面这张表基本覆盖了高频问题按“问题现象、可能原因、解决办法”三个维度整理问题现象可能原因解决办法页面图表空白接口返回数据为空或接口报错打开浏览器开发者工具看 Network确认接口返回的 code 是否为 0重点检查 city_id 是否正确日期范围查询结果不对start/end 参数格式错误确认日期格式统一为 YYYY-MM-DD避免传入 YYYY/MM/DD中文字段显示乱码数据库连接字符串缺少字符集配置SQLite 通常不会乱码如果切换 MySQL连接串加?charsetutf8mb4端口被占用5000 端口已被其他进程占用换端口启动比如python app.py --port5001查询速度越来越慢缺少索引数据量大时全表扫描给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 报表”功能把聚合结果输出成文件甚至可以把城市表扩展成包含区县级别的区域数据把图表做成地图可视化。项目边界拉到哪一层取决于你想练哪块技术正好也是后续扩展的方向。