
一直有人问我Python学到什么程度才算能做项目我通常不回答“学完语法”“看过框架文档”而是说“你能独立跑通一个完整链路的小系统就是一次及格。”基于Python Flask的天气可视化分析系统恰好就是这样一条再典型不过的完整链路从天气数据采集到数据库存储再到统计分析最后用图表展现在浏览器页面上。很多人第一眼看到“天气可视化”会觉得不过是个查天气的网站但真正动手把采集、入库、聚合查询和前端渲染串起来以后你会发现里面的门道远比想象中多。我这篇文章就把这套系统的设计思路、数据库结构、核心代码、部署跑通步骤以及我实际踩过的坑一次讲清楚给准备做课程设计、毕业设计或自学练手的朋友一条能直接“抄作业”的路线。1. 项目整体认知与方案选型1.1 这套系统到底在解决什么问题表面上看它做的是“天气数据展示”但换个角度理解它其实是一个小型的数据采集—存储—分析—展示闭环。以天气为题材是因为数据容易获取、字段通俗易懂、可视化效果直观而且天然适合拿来做统计分析。比如你想知道某个城市最近30天的最高温走势、哪一天最热、晴天占比多少、各风力等级的出现频率——这些在气象网站上不会直接给你答案但把历史天气数据存到自己的数据库里写几条统计SQL结果就出来了。这套系统的核心价值不在于“展示天气”而在于把一份结构化数据变成一组可交互的信息。对于学习者来说它覆盖了Python后端开发中最常被面试官问到的几件事如何设计表结构、如何通过ORM操作数据库、如何用Flask暴露HTTP接口、如何让前端通过AJAX拿到数据并绘制图表。对想进一步扩展的人来说它又是一个很好的底座后续可以加用户登录、定时爬取、城市对比、导入导出Excel等模块。1.2 为什么选Flask而不是Django或FastAPI很多初学者一上来就纠结框架选型。我的建议是先看项目的体量。如果只是一个“数据采集可视化展示”的单体应用Flask是成本最低的选择。它本身只负责路由和视图剩下的东西你愿意用什么就插什么学习曲线很平缓。Django自带ORM、Admin后台、认证系统功能确实全面但对这样一个偏展示类的项目来说太重了新手往往会被“应用目录结构”“中间件”“Django自带ORM的迁移机制”这些概念分散注意力反而不容易聚焦在数据可视化这条主线上。FastAPI这几年确实火型提示、自动生成接口文档都很香。但它对异步编程的要求偏高很多刚接触Web开发的读者对async/await还不够熟一旦在数据库查询或requests请求里用错方式排查起来会很吃力。相比之下Flask的普通函数写法最符合“同步思维”进来一个请求查一次库返回一份JSON逻辑一览无余。而且Flask的教程数量、社区问答覆盖度都是三者中最高的遇到问题搜索很容易找到答案。1.3 技术栈与整体数据流这套项目用到的核心组件如下层级技术选型主要职责采集层requests 解析模块获取天气数据清洗成结构化记录存储层SQLite SQLAlchemy ORM持久化天气数据提供增删改查业务层Flask提供页面渲染和JSON数据接口展示层HTML ECharts fetch加载接口数据使用图表进行可视化分析整体数据流是这样的采集脚本先从数据源拿到原始天气信息解析后写入SQLite数据库Flask应用启动后前端页面通过fetch请求访问/api/weather/trend这类接口Flask路由内部调用SQLAlchemy做聚合查询把结果封装成JSON返回浏览器拿到数据后交给ECharts绘制折线图、饼图等图表同时页面顶部用卡片组件展示统计指标。这个闭环的好处在于每一层都是独立的。接口返回的数据结构变了不影响数据库前端想换图表库也不需要动后端逻辑甚至把SQLite换成MySQL只要ORM模型和连接串改一下其他地方几乎不用动。这种解耦思路本身就是项目里值得学习的一点。2. 数据库设计与数据准备2.1 天气数据表结构设计数据库设计是这类系统最容易马虎、但又最关键的一步。我见过不少同学一上来就建一张宽表所有字段一股脑塞进去结果后面统计查询又慢又乱。合理的做法是先想清楚“我最终要在页面上展示哪些指标”再反推需要哪些字段。这套系统的核心展示需求有三个温度趋势、天气现象分布、常用统计指标。对应的表结构我建议这样设计from datetime import datetime from flask_sqlalchemy import SQLAlchemy db SQLAlchemy() class Weather(db.Model): __tablename__ weather id db.Column(db.Integer, primary_keyTrue, autoincrementTrue) city db.Column(db.String(50), nullableFalse, comment城市名) date db.Column(db.Date, nullableFalse, comment日期) weather db.Column(db.String(50), comment天气现象如晴/多云/小雨) temp_high db.Column(db.Integer, comment最高气温摄氏度) temp_low db.Column(db.Integer, comment最低气温摄氏度) wind_direction db.Column(db.String(50), comment风向) wind_power db.Column(db.String(20), comment风力等级) humidity db.Column(db.Integer, comment相对湿度百分比) __table_args__ ( db.UniqueConstraint(city, date, nameuk_city_date), )字段说明几点。date为什么不直接存字符串因为用ORM的Date字段做范围查询和排序非常方便前端展示时也可以通过strftime格式化。temp_high和temp_low用整型就够不需要浮点精度。humidity存百分比整型。city和date加上唯一约束保证同一个城市同一天只保留一条数据这是防止重复入库的关键。2.2 数据来源API、爬虫与离线造数天气数据有三种常见来源适用范围完全不同我分别说一下。第一种是开放天气预报API通常需要注册账号申请key然后通过HTTP请求获取JSON数据。这种方式最正规、数据质量最高缺点是大多数免费接口有每日调用次数限制而且返回字段各家不一需要仔细看文档。第二种是爬虫采集气象网站的历史天气页面用requests抓HTML再用解析库提取表格。这种方式优点是数据量大、能拿到跨年数据但风险也明显目标站点可能更新结构、有反爬策略、甚至不允许爬取。我的建议是如果只做学习演示不要对单站高频请求控制采集频率尊重目标网站的声明。第三种是离线造数也就是写脚本生成符合真实分布规律的模拟天气数据。别觉得它“不够硬核”在项目开发初期先用模拟数据把页面、接口、图表全部调试通再切换成真实数据源是效率最高的推进方式。造数也不是纯随机我一般会让温度呈现季节性变化比如冬天在-10度到5度之间夏天在22度到35度之间这样折线图看起来才可信。从实战角度看我会建议你先用离线造数跑通全部页面再根据动手能力替换成API或爬虫。这样既避免了一开始被数据源卡住后面升级数据源时也正好练习接口对接和数据处理能力。2.3 为什么把数据放在SQLite而不是MySQL很多人会问生产环境不都用MySQL吗确实但这里要分清场景。SQLite是嵌入式关系型数据库整个数据库就是一个文件不需要单独启动服务。对这套系统来说数据量撑死也就是“几个城市乘以几百天”几千行而已SQLite在查询性能上完全无压力。它的用户权限模型相对简单对学习者来说调试起来负担更小换了电脑只要把这个.db文件带上数据就跟着走。如果硬上MySQL你得先安装数据库服务、配置账号密码、处理客户端连接问题还要担心引擎选择、字符集设置……这些对“把一个可视化系统跑起来”这件事本身没有一点直接帮助。而且项目里用的是SQLAlchemy ORM它屏蔽了底层数据库差异。等你以后需要上MySQL只需要改一行配置app.config[SQLALCHEMY_DATABASE_URI] sqlite:///weather.db # 改成 app.config[SQLALCHEMY_DATABASE_URI] mysqlpymysql://root:passwordlocalhost/weather_db模型和查询代码基本不用动。这也是我坚持用ORM而不是裸SQL的原因ORM让这套系统具备了平滑迁移的能力。3. 核心功能模块实现3.1 数据采集层requests加解析采集模块的目标是“把一个城市的天气数据变成结构化记录并写入数据库”。下面这段代码演示了从HTTP接口获取数据并解析的核心骨架接口地址按你实际申请到的服务来替换import requests def fetch_weather_from_api(city_name, api_key): # 这里用示例地址真实项目替换为对应服务商接口 url https://api.example.com/v1/weather/history params { city: city_name, key: api_key, duration: 30d } resp requests.get(url, paramsparams, timeout10) resp.raise_for_status() payload resp.json() results [] for item in payload.get(data, []): results.append({ city: city_name, date: item[date], weather: item[text], temp_high: item[high], temp_low: item[low], wind_direction: item.get(wind_dir, ), wind_power: item.get(wind_level, ), humidity: item.get(humidity, 0) }) return results这里有几个容易踩的细节。timeout10必须写否则请求挂起会让采集脚本彻底卡住resp.raise_for_status()能在接口返回4xx/5xx时快速报错解析时尽量为可能缺失的字段设置默认值比如湿度字段可能不是每个接口都有。拿到结构化列表之后再循环调用数据库插入即可。插入时建议先判断城市和日期是否已存在已存在就跳过避免重复。3.2 Flask业务层路由、蓝图与接口Flask应用的职责很清晰渲染首页和提供数据接口。首页路由很简单就是用render_template返回index.html。数据接口则需要做几件事读取请求参数、查询数据库、组织返回结构。我习惯把返回结构统一成下面这种格式def api_response(dataNone, msgsuccess, code0): return jsonify({code: code, msg: msg, data: data})这样前后端约定明确后端接口不管返回的是列表还是字典前端都先判断code 0再处理data后面扩展登录态、错误提示都方便。以一个温度趋势接口为例from flask import Flask, render_template, request, jsonify from datetime import datetime, timedelta app.route(/api/weather/trend) def weather_trend(): city request.args.get(city, 北京) days int(request.args.get(days, 30)) start_date datetime.now().date() - timedelta(daysdays) rows (Weather.query .filter(Weather.city city, Weather.date start_date) .order_by(Weather.date.asc()) .all()) return api_response({ dates: [r.date.strftime(%m-%d) for r in rows], highs: [r.temp_high for r in rows], lows: [r.temp_low for r in rows], weather: [r.weather for r in rows] })city和days都是URL上的查询参数默认值保证了前端不传参时接口不会报错。注意查询时用date start_date限定范围配合唯一索引查询效率不会差。strftime(%m-%d)在前端展示时直接就是很干净的标签。3.3 可视化展示层ECharts与前端页面前端页面我推荐用最简单的单页布局顶部是标题和城市切换下拉框中间是若干统计卡片下方是温度趋势折线图、天气分布饼图和风力柱状图。图表库用ECharts既可以通过CDN引入也可以下载到static/js目录本地引入。考虑到有些环境无外网我更建议把echarts.min.js下载到本地。初始化核心图表的逻辑大致如下async function loadTrendChart(city, days) { const resp await fetch(/api/weather/trend?city${city}days${days}); const result await resp.json(); if (result.code ! 0) { console.error(result.msg); return; } const data result.data; const chart echarts.init(document.getElementById(trendChart)); chart.setOption({ tooltip: { trigger: axis }, legend: { data: [最高气温, 最低气温] }, xAxis: { type: category, data: data.dates }, yAxis: { type: value, name: 温度/℃ }, series: [ { name: 最高气温, type: line, data: data.highs, smooth: true }, { name: 最低气温, type: line, data: data.lows, smooth: true } ] }); }为什么温度趋势用折线图因为连续时间序列数据最适合用折线展示变化趋势和波动范围。为什么天气分布用饼图因为天气类别是离散的分类数据饼图能直观反映组成占比。选图表类型其实本身就是数据分析能力的一部分别不管什么数据都往折线图上堆。3.4 统计分析模块极值、均值与比例统计指标是让这套系统显得“有分析能力”的关键。页面顶部那几张卡片建议放四个指标近30天最高温、近30天最低温、平均最高温、晴天比例。前三个用聚合查询实现from sqlalchemy import func def get_city_stats(city, days): start_date datetime.now().date() - timedelta(daysdays) stats {} stats[max_high] (Weather.query .filter(Weather.city city, Weather.date start_date) .with_entities(func.max(Weather.temp_high)) .scalar()) stats[min_low] (Weather.query .filter(Weather.city city, Weather.date start_date) .with_entities(func.min(Weather.temp_low)) .scalar()) stats[avg_high] round((Weather.query .filter(Weather.city city, Weather.date start_date) .with_entities(func.avg(Weather.temp_high)) .scalar()) or 0, 1) return stats注意.scalar()返回的是单个值如果查询结果为空会返回None所以对avg_high做了一层or 0兜底避免前端拿到null导致显示异常。晴天比例左思右想还是拿出来说一说因为它要用分类统计本质上是group by weather再计算占比rows (db.session.query(Weather.weather, func.count(Weather.id)) .filter(Weather.city city, Weather.date start_date) .group_by(Weather.weather) .all())拿到[(天气现象, 天数), ...]之后在前端或者接口里算百分比都可以。我的习惯是在接口里就算好前端只负责展示数字这样前端逻辑更薄、也方便以后做接口复用。4. 关键代码与目录拆解4.1 项目目录与文件职责一个清晰的目录结构对后续维护和给他人讲解都很有帮助。我推荐的布局是weather_analysis/ ├── app.py # Flask 应用入口与路由 ├── models.py # SQLAlchemy ORM 模型 ├── spider.py # 天气数据采集脚本 ├── init_db.py # 初始化数据库与造数脚本 ├── requirements.txt # 依赖清单 ├── templates/ │ └── index.html # 页面模板 └── static/ ├── css/ │ └── style.css # 页面样式可选 └── js/ ├── echarts.min.js # 本地图表库 └── charts.js # 图表渲染逻辑这样拆分的核心原因只有一个每个文件只干一件事。models.py一旦变动影响面可以被限制在数据层spider.py想换数据源不影响路由和页面前端图表改样式后端代码一个字都不用动。课程答辩时你也能很清楚地讲出“这个项目是分层架构”的结论。4.2 数据库初始化与造数脚本init_db.py做两件事建表和填充数据。建表只需要一行from app import app from models import db with app.app_context(): db.create_all()为什么要在app.app_context()里面执行因为SQLAlchemy的create_all需要读取应用配置也就是要知道连接哪个数据库而配置挂在Flask应用上。离开上下文直接调用会报“Working outside of application context”的错误这是新手高频踩坑点。造数脚本里我建议用基于季节的随机生成策略import random from datetime import date, timedelta def generate_weather(city, start_date, days): rows [] for i in range(days): current start_date timedelta(daysi) month current.month if month in (12, 1, 2): high_base, low_base 3, -8 elif month in (6, 7, 8): high_base, low_base 32, 23 else: high_base, low_base 20, 10 temp_high high_base random.randint(-5, 5) temp_low low_base random.randint(-5, 5) temp_low min(temp_low, temp_high - 5) rows.append({ city: city, date: current, weather: random.choice([晴, 多云, 阴, 小雨, 中雨, 晴, 多云]), temp_high: temp_high, temp_low: temp_low, wind_direction: random.choice([东风, 南风, 西风, 北风, 东南风]), wind_power: random.choice([1级, 2级, 3级, 4级]), humidity: random.randint(30, 90) }) return rows注意我做了temp_low min(temp_low, temp_high - 5)保证最低温不会出现高于最高温的笑话数据。这个细节放在答辩时讲能明显体现你对数据质量的意识。4.3 Flask应用入口与页面渲染下面是一份可以直接跑通的最简app.py骨架from flask import Flask, render_template, jsonify, request from models import db, Weather from datetime import datetime, timedelta app Flask(__name__) app.config[SQLALCHEMY_DATABASE_URI] sqlite:///weather.db app.config[SQLALCHEMY_TRACK_MODIFICATIONS] False db.init_app(app) app.route(/) def index(): cities [r[0] for r in db.session.query(Weather.city).distinct().all()] return render_template(index.html, citiescities) # 这里省略 /api/weather/trend 等接口实现 # ... if __name__ __main__: app.run(debugTrue, port5000)首页路由里我查了一次城市的去重列表用来渲染下拉框选项。查询写法db.session.query(Weather.city).distinct().all()返回的是元组列表所以取r[0]。debugTrue在开发阶段很有用能自动热重载和显示报错页面但部署到公网环境时一定要关掉改成debugFalse并换一个待生产能力的服务器。4.4 前端图表渲染关键逻辑在charts.js里我习惯把公共逻辑抽成函数避免三次加载图表时重复代码。比如统一处理接口请求和异常async function getJSON(url) { const resp await fetch(url); if (!resp.ok) { throw new Error(HTTP ${resp.status}); } const result await resp.json(); if (result.code ! 0) { throw new Error(result.msg); } return result.data; }接下来每个图表的加载就变得非常清爽。以饼图为例async function loadWeatherPie(city, days) { const data await getJSON(/api/weather/pie?city${city}days${days}); const chart echarts.init(document.getElementById(weatherPie)); chart.setOption({ tooltip: { trigger: item, formatter: {b}: {c}天 ({d}%) }, series: [{ type: pie, radius: [40%, 70%], itemStyle: { borderRadius: 6, borderColor: #fff, borderWidth: 2 }, data: data.items }] }); }饼图的环形样式只是视觉优化可加可不加。但你只要在里面展示出“晴多少天、雨多少天、占比多少”的信息这个图就比只画一个默认饼图有说服力得多。图表加载完成后还要记得在窗口尺寸变化时调chart.resize()不然浏览器缩放后图表会失真。5. 从零跑通与真实避坑记录5.1 环境安装与项目启动步骤如果让别人拿到这份项目源码需要能快速跑起来。我会把启动步骤写进README并按照下面的顺序执行cd weather_analysis python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -r requirements.txt python init_db.py python app.py然后浏览器打开http://127.0.0.1:5000。这里每一步都有讲究。创建虚拟环境venv是为了隔离依赖防止污染系统Python环境requirements.txt内容至少要有Flask、Flask-SQLAlchemy、requests我一般在项目写完后用pip freeze requirements.txt导出一份完整清单不过自由导出时里面会带一堆间接依赖反而建议只写顶层依赖读者装起来更快。初始化数据库时如果重复运行init_db.pydb.create_all()不会重建已存在的表所以造数脚本要注意去重逻辑。如果一次运行插入了半年的数据再跑一次又插了一次唯一约束会帮忙拦截重复数据但最好还是在插入前主动判断。5.2 常见问题排查速查表实操过程中下面这张表里的问题出现频率最高我遇到过的概率排序大概也是这个顺序现象常见原因排查与解决访问5000端口提示被占用上一次运行的Flask进程还在换端口app.run(port5001)或者找系统进程结束掉打开页面只有骨架没有图表接口返回JSON中的字段名和前端对不上浏览器F12打开Network看API实际返回内容与charts.js里读取的字段名提示Working outside of application context数据库操作没放在应用上下文中在with app.app_context():块内执行初始化或查询中文乱码页面或接口没有声明UTF-8确认HTML头部有meta charsetUTF-8爬虫解析时设置响应编码SQLite报database is locked多线程并发写入冲突开发演示通常不会遇到如果遇到降低并发或改为串行写入下拉框里没有城市选项数据库还没写入数据先运行init_db.py确认weather表有数据图表数据看起来像折线断电JSON返回了null对查询结果做空值兜底前端过滤后再渲染5.3 我实操中的几点体会第一点代码跑通只算完成了40%。真正有价值的是你把每一个接口为什么这么设计、每一个字段为什么这样存储讲清楚。比如唯一约束是为了避免重复比如接口统一返回结构是为了让前端少写判断。这些设计理由在项目答辩里比“我用了Flask”这种话值钱得多。第二点想清楚数据从哪来再动手写页面。我见过有人先把前端页面画得漂漂亮亮结果发现拿不到数据源最后只能反复造数。正确顺序是先确认数据源、再建表、再写接口、最后做可视化。数据形态决定接口字段接口字段决定图表类型这个链条倒着走会非常痛苦。第三点造数时一定要搞出一点“脏数据”来练手。比如插入一条temp_low比temp_high还高的记录再用查询模块把它查出来看看前端会不会显示异常。真的处理过一次脏数据你对数据质量的理解会比读一百篇教程都深。最后说点实在的如果你打算拿这套天气可视化分析系统作为课程设计或面试展示项目我的建议是不要只停留在“能跑”。可以自己加两三个小功能比如增加城市对比、支持日期范围筛选、把统计结果导出成CSV。功能在精不在多每加一个功能就多一次完整的数据流动你也就更熟悉这套链路里的每一个环节。我个人还有一个小习惯接口调试时用浏览器直接在地址栏输入带参数的URL比如/api/weather/trend?city上海days7比写测试脚本更快因为浏览器会把JSON原样展示出来前端字段对不上时这一招特别管用。做完了这套系统最大的收获应该不是那几个ECharts图表而是你清楚了一条数据从产生到展示要经历哪些环节、每个环节容易在哪里翻车。能把这条链路讲明白比背多少框架API都管用。