今年上半年我接到一个挺典型的练手项目——城市地铁查询系统。客户(其实就是个即将毕业的朋友)指定要用 Python 做后端,前端要是 Vue,开发工具用 Pycharm,后端框架在 Django 和 Flask 之间二选一。聊完之后我意识到,这不只是一个“做个网站”的事,而是要把数据建模、接口设计、前端交互、算法选型全走一遍的综合性项目。正好借这个机会,把从环境搭建到前后端联调的完整过程梳理出来。无论你是准备做毕业设计,还是想搞一个能有实战体感的 Web 入门项目,这篇文章都适用。我会把 Django 和 Flask 两种实现路径都讲清楚,再说说我在实际开发中踩过的坑。
城市地铁查询系统的核心,说白了就是给用户一个界面,输入起点和终点,系统返回最佳换乘方案;或者输入一条线路,展示所有站点。它既不复杂,又能把 Web 开发的常见知识点都串起来。数据上我建议先用城市公开的静态地铁线路数据,后面想加分可以再加爬虫去实时抓取。下面直接进入正题。
1. 项目整体设计与技术选型
1.1 核心功能需求拆解
在动手写代码之前,先把需求拆明白。我当时列出来的核心功能有四块:
- 城市与线路列表展示:用户进入系统能看到当前有哪些地铁线路,每条线路经过哪些站点。
- 站点信息查询:点击某个站点,能显示它属于哪些线路、当前是否换乘站、相邻站是什么。
- 起点到终点的路径规划:输入两个站,返回最少经过多少站、换乘几次、具体要坐哪条线到哪一站换乘。
- 线路详情与时刻信息(可选):比如首末班车时间,这两项如果拿不到真实数据,可以用模拟数据,不影响主流程。
这四块功能本身并不高深,但每一块都对应着前后端的具体实现:线路列表需要后端提供数据接口,路径规划考验的是图算法功底,站点详情要求前端处理好路由参数。把需求拆到这一步,后面写代码才会有目标感,而不是拿到需求就急着敲键盘。
1.2 后端框架到底选 Django 还是 Flask
“Django 还是 Flask”这个问题几乎每个新手都会卡住。我的结论是:想快速敲定项目结构、自带后台管理,选 Django;想要轻量、自己掌控每一步,选 Flask。但如果你只是做课程设计或毕业设计,我更推荐 Django,因为它在同一点上已经把 ORM、Admin 后台、表单校验都给你配好了,减少了很多重复造轮子的时间。
我实际做的项目里是用 Django + Django REST Framework(DRF)搭的 API。DRF 可以非常方便地把模型直接序列化成 JSON,而且自带了可视化 API 调试页面,联调时省了很大力气。再说 Flask,它更适合做小型 API 服务,只要写一个app.py就能跑起来。我也在附录里给了基于 Flask + Flask-CORS 的版本,方便只想看轻量实现的朋友。
下面用一个表格把两者对比一下,方便你根据自己的场景去选。
| 对比维度 | Django + DRF | Flask + Flask-RESTX/Flask-CORS |
|---|---|---|
| 项目初始化 | 自带完整目录结构,有 settings、urls、admin | 只有一个入口文件,所有路由自己写 |
| ORM 模型 | Models 是标配,迁移命令自动生成 | 默认没有,需要自己装 SQLAlchemy |
| 后台管理 | 自带 /admin 后台,免费送 | 需要自己接 flask-admin |
| 适合场景 | 多人协作、功能复杂的项目 | 单文件原型、微服务、小 API |
| 学习成本 | 上手门槛稍高,但体系化 | 入门简单,后期结构要自己约束 |
我当时是先用 Django 把主流程全部跑通,再用 Flask 写了一个极简版接口做对比,这也是为什么这篇博客里两套代码都会有。
1.3 前端为什么选择 Vue
地铁查询系统的页面交互说复杂也复杂,说简单也简单。复杂在于换乘方案的渲染,需要动态展示“坐几号线—在哪个站换乘—再到哪个站下车”;简单在于页面数量不多,基本就是首页、线路列表、线路详情、站点详情、路径规划这几个视图。
Vue 解决这个问题的思路很直接:用组件拆页面,用路由切换视图,用状态管理保存用户当前搜索的起点和终点。特别是 Vue 的单文件组件(SFC),模板、脚本、样式写在一个文件里,对写惯了 HTML + JavaScript 的人来说非常友好。相比 React 要理解 JSX 语法,Vue 的模板更贴近传统前端写法,上手曲线平缓得多。
我在这个项目里用的是 Vue 2 + Vue Router 3 + Axios。如果你本来就用 Vue 3,也没关系,核心逻辑是一样的。关键是“前后端分离”的思维:前端跑在 8080 端口,后端跑在 8000 端口,两边通过 HTTP 接口互相通信。这跟你之前可能做过的“用 Django 直接渲染模板”是两个思路,下面我会全程沿着这种分工来写。
2. 开发环境准备与初始搭建
2.1 Python 与 Pycharm 环境配置
这一步没什么捷径,但有很多小坑。我建议先装 Python 3.9 或 3.10,别一上来追最新的 3.13,因为很多第三方库对最新版本的支持会有滞后。装完之后一定要在命令行里验证一下:
python --version pip --versionPycharm 我用的是 Community 版,也就是社区版,开源免费,足够做 Django 和 Flask 开发。专业版确实有一些 Web 开发增强功能,比如数据库工具、运行配置模板,但社区版加上这些插件完全能覆盖本项目需求。打开 Pycharm 后新建项目时,一定要选择虚拟环境(Virtualenv 或 Conda),这一步很多人会忽略,结果全局环境下依赖混乱,项目换台机器就跑不起来了。
我习惯在 Pycharm 的 Terminal 里执行下面几条命令:
python -m venv venv venv\Scripts\activate # Windows source venv/bin/activate # macOS/Linux激活虚拟环境后,会看到命令行前面出现(venv)前缀。之后安装的所有依赖都只会进入这个项目目录里,干净又安全。这一步做完,基本的 Python 环境才算是真正可用。
2.2 Vue 安装及环境配置
Vue 的安装环境同样容易被卡住。核心是 Node.js,不是 Python。先去 Node 官网下载 LTS 版本,安装完检查:
node -v npm -v然后全局安装 Vue CLI。我用的是@vue/cli4.x/5.x 版本,推荐 5.x:
npm install -g @vue/cli vue --version如果你发现 npm 下载特别慢,大概率是网络原因。这时候不要死磕官网,直接换国内镜像源(比如淘宝源)会快很多。命令如下,实测有效:
npm config set registry https://registry.npmmirror.com npm install -g @vue/cli创建 Vue 项目的命令是:
vue create metro-frontend创建过程中 CLI 会问你选择 Vue 2 还是 Vue 3、是否安装 Vue Router、是否安装状态管理工具。我这里选 Vue 2 + Router,等生成完再单独装 Axios。到这步,前端环境就算通了。之后你可以在vue.config.js里设置开发服务器代理,我后面会讲到。
2.3 创建 Django 后端骨架
用 Pycharm 打开后端项目目录后,在虚拟环境激活的前提下安装依赖:
pip install django djangorestframework django-cors-headers然后创建 Django 项目和一个专门的 app。注意区分项目与 app:项目是整体的配置容器,app 才是业务代码所在的地方。
django-admin startproject metro_backend cd metro_backend python manage.py startapp line这里metro_backend是项目名称,line是我给地铁业务取的 app 名。创建完,你会看到settings.py、urls.py、views.py等文件。这时候记得去settings.py里把rest_framework和corsheaders加到INSTALLED_APPS,再把corsheaders.middleware.CorsMiddleware加进中间件列表,否则前后端联调时会遇到跨域问题。
如果走 Flask 路线,流程就更轻了:
pip install flask flask-cors新建app.py,直接定义接口路由就行。Django 和 Flask 的骨架差异确实不小,但它们的核心思想是一致的:路由分发表、请求处理函数、JSON 返回格式。下面我会把两套代码都写清楚。
3. 核心细节解析与数据建模
3.1 地铁线路与站点数据结构设计
做地铁查询,第一步不是写接口,而是设计数据库表。没有数据结构,后面所有算法都无从谈起。我在项目里用了三张核心表:Line(线路)、Station(站点)、LineStation(线路与站点的关联表)。之所以不把站点直接塞进线路表,是因为一个换乘站会属于多条线路,如果直接存一个字段根本没法高效查询。
Django 里的模型可以这样写:
from django.db import models class Line(models.Model): name = models.CharField(max_length=50, verbose_name="线路名称") color = models.CharField(max_length=20, verbose_name="线路颜色") class Station(models.Model): name = models.CharField(max_length=50, verbose_name="站点名称") class LineStation(models.Model): line = models.ForeignKey(Line, on_delete=models.CASCADE, related_name="line_stations") station = models.ForeignKey(Station, on_delete=models.CASCADE, related_name="station_lines") order = models.IntegerField(verbose_name="在该线路上的序号") is_transfer = models.BooleanField(default=False, verbose_name="是否换乘站") class Meta: ordering = ["line_id", "order"]order字段非常重要,它决定了某条线上站点的先后顺序。比如 1 号线的order=1是“苹果园”,order=2是“古城”,这样我们才能判断两个站点在地铁线上谁前谁后,才能给路径规划算法提供基础数据。
如果你想用 Flask,那建表逻辑也是一样,只是换成 Flask-SQLAlchemy 的语法。核心数据结构搞明白了,换框架只是一个语法翻译的过程。
3.2 换乘方案查询的算法实现
路径规划是项目的灵魂。地铁换乘本质上是一个图的最短路径问题:把每个站看成图上的一个点,相邻两站之间有一条长度为主的边。换乘时要额外加上“换乘惩罚系数”,这样算法才不会一味地追求少站数而让你折返跑换乘线路。
我这里采用的是 Dijkstra 算法的变体。每条边的权重设为“时间成本”,普通乘坐一段计 2 分钟,换乘一次额外加 5 分钟惩罚。什么时候加惩罚?当路径从一条线跳转到另一条线的瞬间。算法核心伪代码如下:
初始化优先队列,起点站入队,dist[w] = 0 记录每个站点当前所在线路 line_for_station[w] 当队列不为空时弹出当前站 u 遍历 u 的所有邻站 v: 若从 u 到 v 不需要换线(line_for_station[u] == line_for_station[v]), 新权重 = dist[u] + ride_time 若需要换线, 新权重 = dist[u] + ride_time + transfer_penalty 如果 v 的新权重比 dist[v] 小,则更新并记录前驱节点 最后从终点通过前驱节点回溯得到完整路径由于地铁图的节点数基本都在几百个以内,Dijkstra 跑一次是毫秒级的,完全不用优化成 A*。如果你只是想实现“最少换乘次数”,也可以用 BFS 分层搜索:先找出距离起点 0 次换乘可达的所有站点,再找 1 次、2 次……直到包含终点。我在接口里同时返回了“最少站数方案”和“最少换乘方案”两个结果,让用户自己选。
3.3 后端接口设计与返回格式
接口设计遵循“约定优于配置”,我用了 REST 风格:
GET /api/lines/:返回所有线路列表GET /api/lines/{line_id}/:返回某条线路及其站点顺序GET /api/stations/{station_id}/:返回站点详情及所属线路GET /api/route/?from=站名&to=站名:返回换乘方案
Django 的 DRF 写起来非常顺手。比如线路列表视图可以这样写:
from rest_framework import generics from .models import Line, LineStation from .serializers import LineSerializer class LineList(generics.ListAPIView): queryset = Line.objects.all() serializer_class = LineSerializer序列化器再定义一下对应字段:
from rest_framework import serializers class LineSerializer(serializers.ModelSerializer): class Meta: model = Line fields = ["id", "name", "color"]接口返回的 JSON 结构我尽量保持下面这样,前端解析起来会比较轻松:
{ "code": 0, "data": { "lines": [ {"id": 1, "name": "1号线", "color": "#b50c16"} ] }, "message": "success" }统一返回code、data、message三层结构,是前后端对接时减少沟通成本的好习惯。后面就算接口报错,前端也只需判断code是否等于 0,不需要一层层找 Bug。
4. 前端页面实现与接口联调
4.1 Vue 项目结构与路由配置
Vue 项目的src目录我一般按功能拆成views、components、router、api四块。views放页面,components放可复用组件,router放路由表,api统一封装接口请求。
路由表我这样配:
import Vue from 'vue' import Router from 'vue-router' import LineList from '../views/LineList.vue' import StationDetail from '../views/StationDetail.vue' import RoutePlan from '../views/RoutePlan.vue' Vue.use(Router) const routes = [ { path: '/', redirect: '/lines' }, { path: '/lines', component: LineList }, { path: '/station/:id', component: StationDetail, props: true }, { path: '/route', component: RoutePlan } ]这里station/:id是动态路由,传入的参数id会在StationDetail组件里通过this.$route.params.id或者 props 获取。Vue Router 的动态路由参数在站点点详情场景里特别好用,不用为每个站点创建单独的页面。
4.2 页面组件与数据请求封装
我在api/request.js里统一封装了 Axios 实例:
import axios from 'axios' const request = axios.create({ baseURL: process.env.VUE_APP_BASE_API || 'http://127.0.0.1:8000/api', timeout: 5000 }) export function getLines() { return request.get('/lines/') } export function getRoute(from, to) { return request.get('/route/', { params: { from, to } }) }组件里调用getLines()后,把返回的数据渲染到页面中。比如线路列表:
<template> <div class="line-card" v-for="line in lines" :key="line.id" @click="$router.push('/station/' + line.id)"> <span class="line-color" :style="{ backgroundColor: line.color }"></span> <span class="line-name">{{ line.name }}</span> </div> </template> <script> export default { data() { return { lines: [] } }, async created() { const res = await getLines() this.lines = res.data.data.lines } } </script>这里有个小技巧:接口返回的 JSON 是两层data,第一次是 Axios 包裹的响应体,第二次是我在接口层定义的业务数据字段。很多新手会在这里搞混,一定要看清楚res.data.data.lines的三层结构。
4.3 前后端联调与跨域问题解决
前端跑在 8080,后端跑在 8000,浏览器会认为这是两个不同源,请求时就会触发跨域问题。解决方案有两种:一种是在后端开 CORS,另一种是前端开发时配代理。我两种都用了。
Django 端安装django-cors-headers之后,在settings.py里设置:
CORS_ALLOWED_ORIGINS = [ "http://localhost:8080", ]前端 Vue 开发环境里更推荐配 proxy,这样前端请求时是发到自己的 8080,再由 Node 的开发服务器中转给后端 8000。在vue.config.js里加:
module.exports = { devServer: { proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } } } }配好这个,前端请求/api/lines/,实际上会转发到127.0.0.1:8000/api/lines/,跨域问题就消失了。注意修改代理配置后一定要重启npm run serve,否则不生效。
5. 实操过程与核心环节实现
5.1 Django 后端完整启动步骤
下面我会按实际操作顺序,把 Django 后端从启动到提供接口的完整流程走一遍。
首先激活虚拟环境,在metro_backend目录下执行数据迁移:
python manage.py makemigrations line python manage.py migrate写完了模型,就通过 Django 的管理后台往数据库里塞初始数据。创建超级用户:
python manage.py createsuperuser然后启动开发服务器:
python manage.py runserver 8000打开http://127.0.0.1:8000/admin,登录后手动录入线路、站点、站点顺序。数据量如果只有几条,手工录没问题;如果录完一个城市的地铁图,建议写一个data_import.py脚本,批量导入。下面是我常用的数据导入脚本片段:
from line.models import Line, Station, LineStation def import_line_data(line_name, stations, color="#000000"): line, created = Line.objects.get_or_create(name=line_name, defaults={"color": color}) for order, station_name in enumerate(stations, start=1): station, _ = Station.objects.get_or_create(name=station_name) LineStation.objects.update_or_create( line=line, station=station, defaults={"order": order} )这个脚本只管插入数据,还自动给站点排了序号,是用在真实项目中最稳妥的一步。
5.2 路径查询接口的完整实现
路径查询接口我写在一个单独的任务文件里,叫metro_service.py,这样能在views.py里保持简洁。核心逻辑返回两个方案:
from heapq import heappop, heappush def calc_route(from_station, to_station): # 省略建图过程,把相邻站点关系整理成 graph # 多轮 Dijkstra 同时记录换乘 ... return { "min_stops": path_a, "min_transfers": path_b }views.py中对外暴露接口:
from rest_framework.decorators import api_view from rest_framework.response import Response from .metro_service import calc_route @api_view(["GET"]) def get_route(request): from_name = request.GET.get("from") to_name = request.GET.get("to") result = calc_route(from_name, to_name) return Response({"code": 0, "data": result, "message": "success"})这里需要注意一个细节:站点名称在请求时可能存在同义词,比如“北大东门”和“北京大学东门”。最稳妥的方案是不靠前端传站名,而是传站点的 ID。站名由前端通过下拉框选择,传给后端的是 ID,这样能彻底避免歧义。
5.3 Flask 轻量版后端如何实现
如果你想换成 Flask,或者想借这个题目熟悉 Flask,那代码量会小不少。同样一段路径查询接口,Flask 实现如下:
from flask import Flask, request, jsonify from flask_cors import CORS app = Flask(__name__) CORS(app) @app.route("/api/lines", methods=["GET"]) def lines(): # 从数据库查询线路数据,这里用 SQLAlchemy 或直接读 JSON return jsonify({"code": 0, "data": lines_data, "message": "success"}) @app.route("/api/route", methods=["GET"]) def route(): from_id = request.args.get("from") to_id = request.args.get("to") result = calc_route_by_id(from_id, to_id) return jsonify({"code": 0, "data": result, "message": "success"}) if __name__ == "__main__": app.run(host="127.0.0.1", port=8000, debug=True)Flask 的“怎么把接口绑定到网页元素”这个问题,其实不用纠结“绑定”这个词。网页元素是通过 JavaScript 的fetch或 Axios 调用后端接口,后端接口把 JSON 返回到前端,前端再更新 DOM。你只需要保证 Flask 定义的接口路径和前端请求的 URL 完全一致即可。
5.4 Vue 前端启动与效果记录
前端写完路由、组件、接口请求之后,在项目根目录启动:
npm run serve打开浏览器访问http://localhost:8080,正常情况下首页会显示线路列表。我抽查了一次路径规划,输入“苹果园”到“国贸”,接口返回的换乘方案是“1号线坐到军事博物馆,换乘 9 号线到国贸”,这个结果基于我用北京地铁真实数据做的测试,基本正确。如果你用别的城市数据,结果会有所不同,但算法逻辑是一致的。
联调时我习惯把浏览器开发者工具打开,重点看 Network 面板。如果某个接口挂了,能看到请求状态是 404 还是 500,再点进请求详情看返回值,问题定位会快很多。这一步很多人不愿意做,但恰恰是排查问题最有效的手段。
6. 常见问题与排查技巧实录
6.1 Django 静态文件不显示的问题
很多人在 Vue 里写的<img src="/static/xxx.png">在 Django 静态文件里加载不出来。原因很简单:前端开发服务器是 8080 端口,它的/static路径和后端 8000 端口没有任何关系。有两种处理方式:
- 如果图片放在 Vue 项目的
src/assets下,直接import img from '../assets/xxx.png'交给 Webpack 处理。 - 如果图片放在 Django 的
static目录,那请求的 URL 必须是http://127.0.0.1:8000/static/xxx.png,不能让前端直接访问后端的静态资源,通常也不该这么干。
我最后是把图片都放在前端静态目录里,后端只管数据。前后端分离项目里,“静态文件归前端,数据接口归后端”是一条铁律。
6.2 跨域请求失败与安装慢
项目里最常遇到的报错是“Access to XMLHttpRequest ... has been blocked by CORS policy”。遇到这个,先别急着改代码,检查三件事:后端有没有开 CORS 中间件;CORS_ALLOWED_ORIGINS是不是包含了当前前端地址;如果是开发环境,是不是没重启后端服务。只要后端改了配置,就必须重启,Django 的 runserver 不会自动加载新安装的中间件命令吗?其实会,但中间件改了之后我遇到过不清缓存不生效的情况,干脆重启最省心。
前端npm install慢的问题,解决办法只有一个:把 npm 镜像切到国内源。命令前面已经给了。另外注意vue create的时候如果问你”Use this mirror for downloads”,就可以直接选淘宝镜像,省得后面再切。
6.3 数据库数据乱码与字段命名问题
站点名称里有中文,如果数据库表创建时没指定 UTF-8 字符集,查询接口可能返回乱码。Django 默认会在创建表时使用数据库的默认字符集,MySQL 有时候默认是 latin1。解决方法是建库时显式指定字符集:
CREATE DATABASE metro_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;另外字段命名上,Django 里如果模型包含order字段,表示排序没问题,但如果你用的数据库是 MySQL 5.7 之前的版本,order是保留字,可能报错。我在迁移时确实遇到过一次,后来改成order_num才顺利通过。这个坑虽然不常见,但碰到了会卡很久,提前写出来提醒你。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 前端请求接口报 404 | 后端路由路径和前端请求不一致 | 先在后端浏览器直接访问接口地址,确认接口存在 |
| 接口返回 500 | 后端代码异常或数据缺失 | 看后端控制台报错日志,重点看最后几行 Traceback |
| 页面渲染空白 | Vue 路由匹配失败或接口未返回 | 打开 Vue Devtools 看组件路由状态 |
| 路径规划结果绕路 | 图的邻接关系没建完整 | 检查LineStation的order是否按顺序录入 |
| 换乘方案不显示“换乘”提示 | 前端渲染逻辑判断有误 | 确认接口返回的数据里有没有transfer_station字段 |
这些小问题绝大部分都是细节问题,只要日志看得仔细,每条都能在一小时内定位出来。
结尾:一些经验
把整个项目跑完之后,我最深的体会是:这类项目最花时间的不是写代码,而是数据录入和联调。你辛辛苦苦写完算法,结果因为有几条线路的站点顺序录错了,路径规划出来就是绕远路。第二天再审核数据比写代码还痛苦。所以做数据录入脚本时,一定要能自动校验“线路首站到尾站的连续性”,比如相邻站点的order差值不能大于 1,否则直接报错。
另外一个小技巧是,开发时不要一次性把所有功能都做完再做联调。我就是先做完“线路列表”,前端npm run serve能展示第一屏后,再开始做“站点详情”,每完成一个接口就立刻验证一个页面。这种迭代方式虽然看起来慢,但能让你随时知道自己写的代码有没有问题,不会到最后攒了一大堆 Bug 再集中排查。
这个项目后续做扩展的话,我建议加两样东西:一是用户反馈功能,让用户能把错误的站点信息上报;二是加入夜间运营时段提醒,帮用户判断能不能赶上末班车。这些都是真实地铁查询产品的核心体验点,做出来后整站的专业感会明显提升。如果你也正在做类似项目,欢迎按这个流程走一遍,遇到问题再对照第六部分的排查表,基本能省掉很多瞎折腾的时间。