1. 选型问题的提出
使用python FastAPI开发应用界面,最初的技术方案是前后端分离的双服务结构,这个微电网仿真工具最初采用标准的前后端分离结构,后端是 FastAPI,前端是纯静态 HTML/JS,两者各自独立启动。
启动后端,在 backend 目录执行:
uvicorn main:app --reload --host 127.0.0.1 --port 8000
先启动后端,再启动前端静态服务器。在 frontend 目录执行:
python -m http.server 5500
浏览器访问:http://127.0.0.1:5500/index.html
这个微电网仿真工具最初是标准的前后端分离结构:后端 uvicorn main:app 跑在 8000 端口,前端 python -m http.server 5500 跑在 5500 端口。两套启动命令、两个进程、一份 CORS 中间件配置,构成了日常开发中最容易出错的环节------前端页面能打开,但接口报 blocked by CORS policy;后端 --reload 重启时,前端还在发请求。
日常开发中暴露的问题
(1)两套启动命令、两个进程、一个隐含的启动顺序
先后端、再前端,顺序错了前端页面能打开但接口全挂。每次重启开发环境,都要记得开两个终端、激活虚拟环境、按顺序执行。
(2)CORS 配置是一份必须维护的"隐性契约"
后端 allow_origins 里写的地址必须与前端实际访问的地址逐字符一致(协议、主机、端口都算)。开发时用 127.0.0.1 还是 localhost、端口是否被改动,都会导致 blocked by CORS policy。这类报错在浏览器控制台里信息有限,排查时容易先怀疑接口代码,再怀疑网络,最后才回到 CORS 配置。
(3)--reload 重启期间前端仍在发请求
后端保存代码自动重启的几秒内,前端页面上的操作会失败;两个服务的日志分散在两个终端里,定位问题要在两边来回看。
(4)http.server 自身的能力边界
它只是静态文件分发器,不支持 POST、无访问控制、默认会列目录。虽然在这个架构里它只负责托管前端、不碰业务逻辑,但"多一个进程"本身没有带来任何业务价值。
于是引出一个问题:能不能只用一个服务,既提供 API,又托管前端页面? 沿着这条线,我们梳理出五个候选方案,并最终落在方案 1 上。这篇博客记录完整的选型过程。
2. 五个候选方案一览
| 编号 | 方案 | 进程数 | 端口数 | 跨域 | 动态内容生成位置 |
|---|---|---|---|---|---|
| 1 | Uvicorn + FastAPI StaticFiles |
1 | 1 | 无 | 浏览器(前端 JS) |
| 2 | Uvicorn + FastAPI + http.server |
2 | 2 | 有 | 浏览器(前端 JS) |
| 3 | Uvicorn + FastAPI(Jinja2 模板) | 1 | 1 | 无 | 服务端(Jinja2 渲染) |
| 4 | Uvicorn + FastAPI + Nginx | 2 | 1(Nginx 对外) | 无(同源代理) | 浏览器(前端 JS) |
| 5 | 其他(Granian / Waitress / Hypercorn 等) | 视情况 | 视情况 | 视情况 | 视情况 |
方案 4 是方案 1 的"生产加固版",方案 5 属于应用服务器层面的替换,二者都不改变"前端页面如何生成"这个核心问题。真正需要对比的是 1、2、3。
3. 重点对比:方案 1 / 2 / 3
3.1 架构层面的差异
方案 2(现状) :前端静态文件和 API 是两个独立的 HTTP 服务。浏览器访问 127.0.0.1:5500 拿到 HTML/JS,页面里的 fetch 再打到 127.0.0.1:8000。端口不同即跨域,后端必须显式配置 CORSMiddleware,否则响应被浏览器拦下。
方案 1 :只有一个 Uvicorn 进程。FastAPI 先注册所有 /api/* 路由,最后用 app.mount("/", StaticFiles(...)) 把前端目录挂到根路径。浏览器访问 127.0.0.1:8000 时,页面和接口同源,CORS 中间件可以直接删掉。
方案 3:同样只有一个 Uvicorn 进程,但页面的 HTML 不是磁盘上的静态文件,而是由 Jinja2 模板在请求到达时渲染生成。数据和模板在服务端拼装成完整 HTML 再返回浏览器。
3.2 动态内容到底在哪里生成(核心差异)
这是三个方案最本质的分歧,也是选型时最该想清楚的一点。
| 方案 1 | 方案 2 | 方案 3 | |
|---|---|---|---|
| HTML 骨架来源 | 磁盘静态文件 | 磁盘静态文件 | Jinja2 模板渲染 |
| 页面动态内容由谁生成 | 浏览器 JS (fetch + DOM 操作) |
浏览器 JS | 服务端 Jinja2 |
| 数据到达页面的方式 | 页面加载后再发异步请求取 JSON | 同方案 1 | 数据随 HTML 一次性注入 |
| 刷新页面时 | 重新请求 JSON | 重新请求 JSON | 服务端重新渲染整页 |
用一句话概括:方案 1 和 2 返回的是"空壳 HTML + JS",动态内容由浏览器执行 JS 后填充;方案 3 返回的是"已经填好数据的 HTML",浏览器只负责显示。
3.3 同一功能的三种写法
以"显示项目列表"为例,三个方案的代码形态差异一目了然。
方案 1 / 方案 2 的前端(两者 JS 部分相同,差异只在请求地址与 CORS)
html
<!-- index.html:空壳骨架 -->
<ul id="project-list"></ul>
<script>
async function loadProjects() {
const res = await fetch("http://127.0.0.1:8000/api/projects");
const projects = await res.json();
document.getElementById("project-list").innerHTML =
projects.map(p => `<li>${p.name} - ${p.status}</li>`).join("");
}
loadProjects();
</script>
方案 1 的额外改动只有一处:fetch("/api/projects") 用相对路径,同源;后端删掉 CORSMiddleware。
方案 3 的前端(模板 + 渲染路由)
html
<!-- templates/projects.html:带占位符的模板 -->
<ul>
{% for p in projects %}
<li>{{ p.name }} - {{ p.status }}</li>
{% endfor %}
</ul>
python
# main.py
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
@app.get("/projects", response_class=HTMLResponse)
async def projects_page(request: Request):
projects = query_projects_from_db() # 服务端直接查数据
return templates.TemplateResponse(
request=request, name="projects.html", context={"projects": projects}
)
对比很直观:方案 1/2 的动态逻辑在浏览器里,方案 3 的动态逻辑在 TemplateResponse 这一行。 方案 2 与方案 1 的代码几乎一样,只是多了一层跨域成本。
3.4 多维度对比
| 维度 | 方案 1 | 方案 2 | 方案 3 |
|---|---|---|---|
| 进程/端口 | 1 / 1 | 2 / 2 | 1 / 1 |
| CORS 配置 | 不需要 | 必须 | 不需要 |
| 启动命令 | 一条 uvicorn |
两条(先后顺序有讲究) | 一条 uvicorn |
| 前后端耦合度 | 低(API 契约解耦) | 低 | 高(页面逻辑混入后端) |
| 页面交互复杂度上限 | 高(可引入任意前端框架) | 高 | 低(适合表单、列表、详情页) |
| 首屏数据到达 | 需二次请求 | 需二次请求 | 一次请求直达 |
| SEO / 无 JS 环境 | 差 | 差 | 好 |
| 调试方式 | 浏览器 Network + 后端日志 | 同左,且要跨两个服务排查 | 主要看后端日志 |
| 适合的场景 | 内部工具、SPA、交互复杂 | 不推荐(除非有历史包袱) | 内容展示、管理后台、SEO 站 |
3.5 方案 2 为什么被直接排除
方案 2 与方案 1 的前端代码几乎等价,却额外承担了:多一个进程、多一个端口、一份 CORS 配置、一套"先启动谁"的顺序约定。它唯一的历史价值是"不需要改后端就能把前端跑起来",但一旦确认要调整架构,方案 2 没有任何保留理由。
4. 为什么最终选方案 1
结合微电网仿真工具的实际约束------少量用户、内部使用、交互较复杂(多标签页数据录入、方案版本管理、枪桩映射)------方案 1 的优势是全方位的:
- 部署回归一条命令 。
uvicorn main:app --host 127.0.0.1 --port 8000同时提供页面与 API,不用再维护前端静态服务器的启动脚本和端口约定。 - 跨域问题从架构层面消失 。
CORSMiddleware可以整段删除,前后端联调时少了一整类"明明接口通、页面就是不显示"的排查路径。 - 保留了前端交互的灵活性。工具里的多标签页表单、方案"打开/另存为/基于新建"等操作,用原生 JS 或未来引入轻量框架都很自然;这一点方案 3 会明显吃力。
- 迁移成本最低 。现有前端代码只需把
fetch的绝对地址改成相对路径(或统一由api()封装拼接),后端加一行mount即可,业务代码零改动。 - 未来升级路径清晰。若日后需要域名、HTTPS 或静态资源缓存,直接在前面加一层 Nginx(方案 4)即可,FastAPI 侧代码不变。
方案 3 并非不好,它适合的是"页面以展示为主、交互简单、希望省掉前端工程化"的场景。但微电网仿真工具的交互密度决定了动态内容放在浏览器端更合适,这正是方案 1 的定位。
5. 方案 1 的落地要点
挂载顺序 :所有 API 路由先注册,StaticFiles 最后挂载到 /,避免静态挂载把 API 路径"吞掉"。
python
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
app = FastAPI()
# 1. 先注册全部 API 路由(或 include_router)
# ...
# 2. 最后挂载前端目录
app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend")
html=True :访问目录时自动返回 index.html,对多页面前端足够用。若前端使用 History 路由(如 /projects/123 这种无扩展名路径),需要额外写一个通配路由回退到 index.html。
开发体验 :--reload 只监控 Python 文件,前端 HTML/JS 改动不会被自动重启,但浏览器直接刷新即可看到最新静态文件,反而比方案 2 少了一次"两个服务谁没起来"的怀疑。
路径一致性 :前端所有 fetch 改为相对路径(/api/...),这一步是消除跨域的实质动作;绝对地址写成 127.0.0.1:8000 会让同源优势失效。
6. 什么时候该换别的方案
- 需要 HTTPS、域名、多用户公网访问、静态资源压缩缓存 → 在方案 1 前面加 Nginx(方案 4),FastAPI 代码不动。
- 页面以内容展示为主、希望免去 JS 状态管理、对 SEO 有要求 → 选方案 3,接受前后端逻辑耦合的代价。
- 应用服务器层面想换实现 → 注意 FastAPI 是 ASGI,
Waitress(WSGI)不能直接跑;Granian等可替换 Uvicorn,但在少量用户场景下收益有限。
7. 结论
| 方案 | 一句话评价 |
|---|---|
| 1. FastAPI StaticFiles | 单进程单端口、零跨域、交互灵活,是当前场景的最优解 |
2. + http.server |
方案 1 的前身,多一个进程和一份跨域配置,无保留价值 |
| 3. Jinja2 模板 | 动态内容服务端渲染,适合展示型页面,不适合高交互工具 |
| 4. + Nginx | 方案 1 的生产加固版,需求出现时再引入 |
| 5. 其他服务器 | 当前技术栈下无必要 |
动态内容的实现方式是这次选型的核心:
- 方案 1, 把动态生成交给浏览器 JS,优势是交互自由度和前后端解耦;
- 方案 3, 把动态生成放在服务端 Jinja2,优势是首屏直达和简单页面下的低心智负担。
微电网仿真工具的交互复杂度,让选型明确偏向方案 1。最终架构选型方案为:一个 Uvicorn 进程、一个端口、一套 FastAPI 代码,同时承载 API 与前端静态资源。 部署命令从两条变成一条,CORS 中间件从代码里删除,而业务逻辑一行未改。