从 JS/TS 到 Python:前端开发者的无缝迁移指南(二) 服务篇
一、环境与工具链:从 nvm/npm 到 pyenv/poetry
Python 的包管理和环境隔离体系,是 Node 开发者迁移时最容易”撞墙”的地方——不是因为难,而是因为默认行为和 Node 生态完全不同。Node 的 node_modules 天然提供项目级隔离,而 Python 的 pip install 是全局安装。这一节的目标是让你在 10 分钟内搞清楚这个体系,避开最痛的坑。
1.1 版本管理:nvm → pyenv
Node 开发者对 nvm/fnm 的使用已经刻在肌肉记忆里。Python 界有对应物:pyenv。
| 特性/概念 | JS/TS | Python | 核心差异与避坑说明 |
|---|---|---|---|
| 版本管理器 | nvm / fnm / volta |
pyenv (Windows 用 pyenv-win) |
pyenv 需要编译安装 CPython,首次 pyenv install 3.12 会从源码编译,耗时较长 |
| 版本声明文件 | .nvmrc |
.python-version |
语法相同,都是纯文本写版本号 |
| 安装指定版本 | nvm install 20 |
pyenv install 3.12 |
pyenv 安装后还需 pip install 各种工具 |
| 查看已安装版本 | nvm ls |
pyenv versions |
|
| 全局默认版本 | nvm alias default 20 |
pyenv global 3.12 |
|
| 终端自动切换 | .nvmrc + nvm 脚本 |
.python-version + pyenv 自动读取 |
无需额外配置,进入目录自动切换 |
⚠️ 避坑警告:macOS/Linux 上不要用系统自带的 Python(
/usr/bin/python3)。那个版本通常偏旧,且是系统工具链的依赖,乱动可能搞崩系统。务必通过 pyenv 安装独立版本。
// Node: 项目级版本锁定
// $ echo "20" > .nvmrc
// $ nvm use
# Python: 项目级版本锁定
# $ echo "3.12" > .python-version
# $ pyenv local 3.12 # 或直接 cd 进入目录自动切换
1.2 虚拟环境:node_modules 的天然隔离 vs 必须手动创建
这是 Node 开发者最容易踩的坑。在 Node 里,npm install 默认安装在当前项目的 node_modules 下,项目之间天然隔离。Python 的 pip install 默认装到全局。
| 特性/概念 | JS/TS | Python |
|---|---|---|
| 项目级依赖隔离 | node_modules(自动,无需任何操作) |
venv(虚拟环境,必须手动创建和激活) |
| 创建隔离环境 | 不需要,npm init 就是项目边界 |
python -m venv .venv |
| 激活环境 | 不需要 | source .venv/bin/activate(macOS/Linux)或 .venv\Scripts\activate(Windows) |
| 环境标识 | package.json 的存在即为项目根 |
.venv/ 目录 |
| 退出环境 | 离开目录即退出 | deactivate |
| 依赖全局安装 | npm install -g xxx |
pip install xxx(这也是全局安装,别搞混) |
// Node:进入项目目录后直接装依赖,天然隔离
// $ cd my-project && npm install
// 依赖全在 node_modules 里,互不影响
# Python:必须先创建并激活虚拟环境
# $ python -m venv .venv
# $ source .venv/bin/activate # Linux/macOS
# $ .venv\Scripts\activate # Windows
# (.venv) $ pip install fastapi # 注意终端提示符变了
⚠️ 头号避坑:你忘记激活 venv 时,
pip install不会报错——它只是默默装到了全局。然后你会困惑”为什么项目中 import 不到?”。解决方案:在setting.json中设置"python.terminal.activateEnvironment": true,让 VS Code 自动激活 venv。也可以在项目根目录加一个.envrc配合direnv自动激活。
1.3 包管理器演进:npm/yarn/pnpm → pip/poetry/uv
这可能是 Node 开发者在 Python 生态里最怀念的一个功能——统一的项目配置文件。
| 特性/概念 | JS/TS | Python |
|---|---|---|
| 项目配置文件 | package.json |
pyproject.toml(现代标准)/ setup.py(遗留) |
| 默认包管理器 | npm |
pip(只是安装器,不管理项目元数据) |
| Lock 文件 | package-lock.json / yarn.lock / pnpm-lock.yaml |
poetry.lock / uv.lock(pip 没有内置 lock) |
| 现代包管理器 | pnpm(快、磁盘省) |
uv(Rust 实现,速度对标 pnpm) |
| 依赖分组 | dependencies / devDependencies |
[tool.poetry.dependencies] / [tool.poetry.group.dev.dependencies] |
| Scripts | npm run dev |
无官方等价物,通常用 make / task / just |
| Workspace | pnpm-workspace.yaml |
pyproject.toml 的 [tool.uv.sources] |
// Node:npm/yarn/pnpm 自带 scripts 能力
// "scripts": { "dev": "nodemon src/index.ts" }
// $ npm run dev
# Python:没有内置的 npm scripts 等价物
# 多数项目直接用 Makefile 或 task runner
# Makefile 示例:
# dev:
# uvicorn app.main:app --reload
# $ make dev
推荐方案:新项目直接用 uv。它是 Rust 写的,速度碾压 pip,且整合了包管理和虚拟环境管理。
uv init→uv add fastapi→uv run uvicorn app.main:app一条龙,工作流上最贴近pnpm的体验。
1.4 安装依赖对照速查
| 操作 | JS/TS | Python (pip) | Python (uv,推荐) |
|---|---|---|---|
| 安装项目所有依赖 | npm install |
pip install -r requirements.txt |
uv sync |
| 安装单个包 | npm install fastify |
pip install fastapi |
uv add fastapi |
| 安装开发依赖 | npm install -D typescript |
pip install -r requirements-dev.txt |
uv add --dev pytest |
| 卸载包 | npm uninstall axios |
pip uninstall requests |
uv remove requests |
| 查看已安装 | npm ls |
pip list |
uv pip list |
| 升级包 | npm update |
pip install --upgrade xxx |
uv sync --upgrade |
| 导出依赖 | 不必要(package.json 即规范) | pip freeze > requirements.txt |
uv export |
// Node 的安装流程
// $ npm init
// $ npm install express
// $ npm install -D @types/express
# Python + uv 的等效流程
# $ uv init my-project
# $ uv add fastapi
# $ uv add --dev pytest
# $ uv run python -m app.main # 运行项目
1.5 环境变量与配置管理
| 特性/概念 | JS/TS | Python |
|---|---|---|
| 读取环境变量 | process.env.PORT |
os.environ["PORT"] |
| .env 文件加载 | dotenv 包 |
python-dotenv 包 |
| 带验证的配置 | zod + process.env |
pydantic-settings |
| 安全访问(不存在时报错) | process.env.PORT ?? 3000 |
os.environ.get("PORT", 3000) |
// TypeScript: zod 验证环境变量
import { z } from "zod";
const env = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string(),
}).parse(process.env);
# Python: pydantic-settings 验证环境变量
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
port: int = 3000
database_url: str
model_config = {"env_prefix": "", "env_file": ".env"}
settings = Settings() # 自动读取 .env 并验证类型
1.6 这一节你该记住的
- pyenv 管 Python 版本,venv 管依赖隔离——任何项目第一步
python -m venv .venv && source .venv/bin/activate - pip 是旧世界的工具,uv 是新世界的工具——
uv≈pnpm之于 npm - Python 没有 npm scripts——用 Makefile 或 just 代替
pyproject.toml=package.json——但不完全等价,它只声明元数据和依赖,不负责脚本和运行
二、Web 框架全景:Express/Koa/Fastify 用户该选什么
Python Web 框架的格局和 Node 没有本质区别——也是”微框架 → 全栈框架”的梯度分布。关键是找到你熟悉框架的对应物,避免用 Express 的惯性去用 Django。
2.1 三大框架速览
| 框架 | 定位 | Node 对照 | 特点 |
|---|---|---|---|
| Flask | 微框架,最小核心 + 插件生态 | Express | 自由度高,全家桶靠第三方库拼装 |
| FastAPI | 现代异步框架,类型驱动 | Fastify / Koa + TypeScript | 原生 async、自动生成 OpenAPI、Pydantic 集成 |
| Django | 全栈电池框架 | Nest.js / Adonis.js | ORM、Admin 后台、认证、模板引擎全内置 |
2.2 Flask ≈ Express:极简哲学,插件拼装
Flask 和 Express 的设计思想几乎完全一致——核心极简,功能通过扩展补全:
| 功能 | Express | Flask |
|---|---|---|
| 路由 | app.get('/users', handler) |
@app.route("/users") |
| 请求体解析 | express.json() 中间件 |
request.get_json() |
| 参数校验 | Joi / zod(自己集成) | marshmallow / pydantic(自己集成) |
| CORS | cors() 中间件 |
flask-cors 扩展 |
| 数据库 | 自己选 ORM | flask-sqlalchemy 扩展 |
| 模板引擎 | EJS / Pug | Jinja2(内置,和 Nunjucks 同源) |
// Express: 最小化 API
const express = require("express");
const app = express();
app.get("/hello", (req, res) => {
res.json({ message: `Hello, ${req.query.name}` });
});
app.listen(3000);
# Flask: 最小化 API——结构和思路完全一致
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/hello")
def hello():
name = request.args.get("name")
return jsonify({"message": f"Hello, {name}"})
app.run(port=3000)
⚠️ 避坑警告:Flask 对 async/await 的支持很有限。
@app.route装饰的函数默认是同步的,虽然 Flask 2.0+ 可以在异步视图上写async def,但 Flask 本身是同步框架——异步视图在单独的线程中运行,性能提升有限。如果你的项目重度依赖 async(数据库查询、HTTP 调用),请直接选 FastAPI,别用 Flask。
2.3 FastAPI ≈ Fastify + TypeScript:现代化、高性能
FastAPI 是对 Node 开发者最友好的 Python 框架。如果你习惯写 TypeScript + Fastify + zod/json-schema,你会感觉 FastAPI 就像一个 “Python 版的 Fastify”:
| 特性 | Fastify | FastAPI |
|---|---|---|
| 核心异步 | ✅ 原生 Promise/async | ✅ 原生 async/await |
| 请求验证 | JSON Schema / zod | Pydantic(自动生成 JSON Schema) |
| 自动文档 | Swagger(需配置 fastify-swagger) | Swagger + ReDoc 零配置自动生成 |
| 序列化 | 手动 or ajv | Pydantic 自动序列化 |
| 依赖注入 | 无内置 | Depends() 依赖注入系统 |
| 性能基准 | 高(Node 顶级) | 高(Python 顶级,可与 Node 框架对打) |
// Fastify + TypeScript: 类型驱动的路由
import fastify from "fastify";
const app = fastify();
app.get<{
Querystring: { name: string };
}>("/hello", async (req) => {
return { message: `Hello, ${req.query.name}` };
});
await app.listen({ port: 3000 });
# FastAPI: 类型驱动的路由——几乎一模一样的思路
from fastapi import FastAPI
app = FastAPI()
@app.get("/hello")
async def hello(name: str): # 查询参数类型声明即验证
return {"message": f"Hello, {name}"}
# uvicorn 启动: $ uvicorn main:app --reload
2.4 Django ≈ Nest.js:全栈”电池框架”
如果你来自 Nest.js 或 Angular 背景,Django 的哲学你会觉得眼熟——模块化、依赖注入、装饰器、ORM 全内置。但 Django 的笨重体量和学习曲线对于一个只需要写 API 的 Node 开发者来说通常是过度的:
| 特性 | Nest.js | Django |
|---|---|---|
| 架构 | Module → Controller → Service | App → View → Model |
| ORM | TypeORM / Prisma | Django ORM(内置) |
| 管理后台 | 无内置 | Django Admin(零代码生成 CRUD 界面) |
| 模板引擎 | Handlebars / 可选 | Django Templates(内置) |
| 认证 | Passport.js / @nestjs/passport | django.contrib.auth(内置) |
| 迁移 | TypeORM Migrations / Drizzle Kit | Django Migrations(内置) |
| 脚手架 | nest g resource |
python manage.py startapp |
Django 的唯一优势在于开发全栈单体应用极快——Admin 后台、ORM、表单验证、认证、模板全内置。但如果你只是写 API,Django 的大量内置功能都是噪音。写 API 首选 FastAPI,写全栈后台用 Django。
2.5 选型速查
| 你的背景 | 推荐框架 | 理由 |
|---|---|---|
| Express + 少量 TypeScript | Flask | 学习曲线最低,概念最接近 |
| Fastify + TypeScript + zod | FastAPI(强烈推荐) | 开发体验最接近,类型驱动,自动文档 |
| Nest.js 用户写全栈应用 | Django | 全内置方案,无需拼装第三方库 |
| 只想写高性能 API,不关心全栈 | FastAPI | 异步原生、性能最优、文档自动生成 |
| 写边缘小脚本或仅暴露几个端点 | Flask | 够用,无过度设计 |
2.6 这一节你该记住的
- 写 API 就 FastAPI——类型驱动、自动文档、性能好,对 TypeScript 用户最友好
- Flask = Python 界的 Express——概念一样,但别指望它写高性能异步服务
- Django 的 Admin 是魔法——但这个魔法对纯 API 项目来说是负担
三、FastAPI 实战:从零搭建一个 API 服务
这一节我们不谈理论,用 Fastify + TypeScript 用户的视角,一步步写出一个完整的 CRUD API 服务。目标:看完你就能上手写。
3.1 项目初始化
# Python 项目初始化(uv 推荐)
$ uv init fastapi-blog
$ cd fastapi-blog
$ uv add fastapi uvicorn
$ uv add --dev pytest httpx
# 等效于 Node:
# $ mkdir fastapi-blog && cd fastapi-blog && npm init -y
# $ npm install fastapi # 注意:Python 版叫 fastapi,是 pip 包
# $ npm install -D jest supertest
推荐目录结构:
# Node
src/
routes/
posts.ts
models/
post.ts
middleware/
auth.ts
index.ts
# Python
app/
routers/
posts.py
models/
post.py
dependencies/
auth.py
main.py
3.2 路由与请求处理
这是 Node 开发者最直接的切入点——写路由。
| 操作 | Fastify | FastAPI |
|---|---|---|
| GET 路由 | app.get("/path", handler) |
@app.get("/path") |
| 路径参数 | "/users/:id" |
"/users/{id}" |
| 查询参数 | req.query.page |
函数参数 page: int = 1 |
| 请求体 | req.body |
函数参数 body: SomeModel |
| 请求头 | req.headers.authorization |
Header(None) |
| Cookie | req.cookies |
Cookie(None) |
| 响应状态码 | res.status(201).send(...) |
return ..., status_code=201 或 @app.post(status_code=201) |
// Fastify: 多参数来源的路由
app.get<{
Params: { id: string };
Querystring: { include_comments: boolean };
Headers: { authorization: string };
}>("/posts/:id", async (req) => {
const { id } = req.params;
const { include_comments } = req.query;
const token = req.headers.authorization;
// ...
});
# FastAPI: 参数来源由声明位置自动推断——路径参数在装饰器路径中,其他在函数签名中
from fastapi import FastAPI, Header
app = FastAPI()
@app.get("/posts/{id}")
async def get_post(
id: int, # 路径参数(装饰器中同名)
include_comments: bool = False, # 查询参数
authorization: str = Header(None), # 请求头
):
return {"id": id, "include_comments": include_comments, "auth": authorization}
核心理解:FastAPI 通过函数签名中的参数名和类型注解自动推断参数来源。路径中
{id}的同名参数自动绑定为路径参数,未被路径或Header()/Cookie()包裹的参数默认是查询参数。
3.3 请求体验证:zod → Pydantic
在 Node 里你用 zod/json-schema 校验请求体,FastAPI 把这一步变成了 Pydantic 类型声明——声明即校验,手动挡变自动挡。
// Fastify + zod: 定义 schema → 手动校验
import { z } from "zod";
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string(),
published: z.boolean().default(false),
});
app.post("/posts", async (req) => {
const body = createPostSchema.parse(req.body);
// body 类型自动推导为 { title: string; content: string; published: boolean }
return { id: 1, ...body };
});
# FastAPI + Pydantic: 声明模型 → 自动校验 + 自动文档
from pydantic import BaseModel, Field
class PostCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str
published: bool = False
@app.post("/posts")
async def create_post(post: PostCreate): # 声明类型,框架自动校验
return {"id": 1, **post.model_dump()} # model_dump() ≈ JSON.stringify()
这才是 FastAPI 真正的杀手特性:你只需声明 Pydantic 模型,FastAPI 自动完成——① 请求体验证(非法数据返回 422 + 详细错误)② 生成 OpenAPI Schema ③ Swagger 文档交互界面(
/docs)④ IDE 自动补全。
3.4 完整 CRUD 示例:Posts API
下面是一个完整对照,左边 Fastify + TypeScript,右边 FastAPI:
// ═══════════ Fastify + TypeScript ═══════════
import fastify from "fastify";
import { z } from "zod";
const app = fastify();
// --- Schema ---
const PostSchema = z.object({
id: z.number(),
title: z.string(),
content: z.string(),
published: z.boolean(),
});
const CreatePostSchema = PostSchema.omit({ id: true });
type Post = z.infer<typeof PostSchema>;
// --- 内存数据库 ---
const posts: Post[] = [];
let nextId = 1;
// --- Routes ---
app.get("/posts", async () => posts);
app.get("/posts/:id", async (req) => {
const post = posts.find(p => p.id === Number(req.params.id));
if (!post) return req.server.notFound().status(404);
return post;
});
app.post("/posts", async (req) => {
const body = CreatePostSchema.parse(req.body);
const post: Post = { id: nextId++, ...body };
posts.push(post);
return post;
});
app.put("/posts/:id", async (req) => {
const idx = posts.findIndex(p => p.id === Number(req.params.id));
if (idx === -1) return req.server.notFound().status(404);
const body = CreatePostSchema.parse(req.body);
posts[idx] = { ...posts[idx], ...body };
return posts[idx];
});
app.delete("/posts/:id", async (req) => {
const idx = posts.findIndex(p => p.id === Number(req.params.id));
if (idx === -1) return req.server.notFound().status(404);
posts.splice(idx, 1);
return { message: "deleted" };
});
app.listen({ port: 3000 });
# ═══════════ FastAPI ═══════════
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
# --- Schema ---
class PostBase(BaseModel):
title: str
content: str
published: bool = False
class PostCreate(PostBase):
pass # 继承父模型的所有字段
class Post(PostBase):
id: int
# --- 内存数据库 ---
posts: list[Post] = []
next_id = 1
# --- Routes ---
@app.get("/posts")
async def list_posts() -> list[Post]:
return posts
@app.get("/posts/{post_id}")
async def get_post(post_id: int) -> Post:
post = next((p for p in posts if p.id == post_id), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return post
@app.post("/posts", status_code=201)
async def create_post(data: PostCreate) -> Post:
global next_id
post = Post(id=next_id, **data.model_dump())
next_id += 1
posts.append(post)
return post
@app.put("/posts/{post_id}")
async def update_post(post_id: int, data: PostCreate) -> Post:
for i, p in enumerate(posts):
if p.id == post_id:
posts[i] = Post(id=post_id, **data.model_dump())
return posts[i]
raise HTTPException(status_code=404, detail="Post not found")
@app.delete("/posts/{post_id}")
async def delete_post(post_id: int):
for i, p in enumerate(posts):
if p.id == post_id:
posts.pop(i)
return {"message": "deleted"}
raise HTTPException(status_code=404, detail="Post not found")
3.5 启动与热重载
| 操作 | Node | Python |
|---|---|---|
| 启动服务 | node src/index.ts |
uvicorn app.main:app |
| 热重载 | nodemon / tsx --watch |
uvicorn app.main:app --reload |
| 生产启动 | node dist/index.js |
uvicorn app.main:app --workers 4 |
# 开发模式:热重载 + 访问 /docs 交互测试
$ uvicorn app.main:app --reload
# 打开 http://localhost:8000/docs → 自动生成的 Swagger UI
3.6 这一节你该记住的
- FastAPI 的装饰器语法跟 Express/Fastify 几乎是 1:1 的——
@app.get()、@app.post(),差异只在路径参数语法({id}vs:id) - 参数来源由类型注解自动推断——路径参数、查询参数、请求头、Cookie 全由声明位置决定,不用手动从
req.xxx里取 - Pydantic 模型 = zod schema + TypeScript 类型——声明一次,校验 + 文档 + 类型推导全有了
- 404 处理用
HTTPException抛出,不是res.status(404).send()——Python 的异常机制替代了 Express 的 response 链式调用
四、异步编程深度对比:事件循环 vs asyncio
这是 Node 开发者在 Python 世界里最容易被反直觉击中的一章。Node 的异步是”渗透式”的——你甚至不需要知道它在异步运行,一切默认就是非阻塞的。Python 则截然相反:同步是默认,异步是显式选择。理解这一层的差异,才能避免写出”看起来对、跑起来卡”的代码。
4.1 核心哲学差异:Implicit vs Explicit
| 维度 | Node.js | Python |
|---|---|---|
| 异步模型 | 隐式——一切 I/O 默认非阻塞 | 显式——同步和异步有严格边界 |
| 事件循环 | libuv,启动即运行,不可见 | asyncio event loop,需显式启动 |
| 函数染色 | 不存在——所有函数可被 await | 存在——async def 和 def 之间不可混用 |
| await 范围 | 任何地方(ESM 顶层也可) | 只能在 async def 函数内 |
| 阻塞后果 | 几乎不可能(除非 JSON.parse 大文件) | 极易发生——一个忘记 await 就阻塞整个线程 |
⚠️ 核心避坑:Python 有”红蓝函数”问题。红色函数(
async def)只能被红色函数await,蓝色函数(def)不能调用红色函数。你没法像 Node 那样在任意地方await。如果你在同步函数中调用了一个异步库的方法,你会发现根本没法拿到结果——会得到 coroutine 对象而不是返回值。
// Node: 异步是渗透式的,没有边界感
async function fetchUser(id) {
return await db.query("SELECT * FROM users WHERE id = ?", [id]);
}
// 可以在任何地方 await,包括 ESM 顶层
const user = await fetchUser(1);
console.log(user); // ✅ 直接工作
# Python: 异步有严格的边界
import asyncio
async def fetch_user(id: int):
... # 异步数据库查询
return {"id": id, "name": "Alice"}
# ❌ 顶层直接 await 不行(Python 3.11+ Jupyter 等交互环境除外)
# user = await fetch_user(1) # SyntaxError!
# ✅ 必须通过 asyncio.run() 启动事件循环
user = asyncio.run(fetch_user(1))
print(user)
4.2 事件循环对比
| 概念 | Node.js | Python |
|---|---|---|
| 事件循环实现 | libuv(C 实现,多平台) | asyncio(纯 Python + 少量 C) |
| 启动方式 | 自动——任何 async 操作触发 | 手动——asyncio.run() |
| Task 创建 | Promise 即微任务 |
asyncio.create_task() 创建 Task |
| 并发等待 | Promise.all([...]) |
asyncio.gather(*tasks) |
| 竞速 | Promise.race([...]) |
asyncio.wait(tasks, return_when=FIRST_COMPLETED) |
| 全都完成 or 失败 | Promise.allSettled([...]) |
asyncio.gather(return_exceptions=True) 或 asyncio.TaskGroup |
// Node: 并发模式
const [user, posts] = await Promise.all([
fetchUser(1),
fetchPosts(1),
]);
// 竞速:取第一个完成的
const first = await Promise.race([
fetchFromSource("a"),
fetchFromSource("b"),
]);
# Python: 并发模式
user, posts = await asyncio.gather(
fetch_user(1),
fetch_posts(1),
)
# 竞速:取第一个完成的
done, pending = await asyncio.wait(
{asyncio.create_task(fetch_from_source("a")),
asyncio.create_task(fetch_from_source("b"))},
return_when=asyncio.FIRST_COMPLETED,
)
first = done.pop().result()
for task in pending: # 记得取消未完成的任务
task.cancel()
4.3 并发控制:Semaphore ≈ p-limit
Node 里你用 p-limit 控制并发数,Python 用 asyncio.Semaphore:
// Node: p-limit 控制并发
import pLimit from "p-limit";
const limit = pLimit(5);
const results = await Promise.all(
urls.map(url => limit(() => fetch(url)))
);
# Python: asyncio.Semaphore 控制并发
import asyncio
async def fetch_with_limit(url: str, sem: asyncio.Semaphore):
async with sem:
return await fetch_url(url)
sem = asyncio.Semaphore(5)
results = await asyncio.gather(
*(fetch_with_limit(url, sem) for url in urls)
)
4.4 同步阻塞是异步的敌人
这是 Node 开发者迁移到 Python 后最容易犯的错——把同步阻塞函数放在 async 函数里。
# ❌ 致命错误:在 async 函数里调用同步阻塞 I/O
import time
async def fetch_all(urls):
for url in urls:
data = fetch_url_sync(url) # 同步 I/O!阻塞整个事件循环
time.sleep(1) # 阻塞 sleep!
# process data...
在 Node 里没有这个问题,因为 fs.readFileSync 阻塞的是整个进程——你也知道别这么做。但 Python 里你写的函数默认就是同步的,很多库(如 requests)也是同步的。如果不加注意塞进 async 函数里,整个事件循环会被卡住。
# ✅ 正确:同步阻塞代码丢进线程池
import asyncio
import requests # 同步 HTTP 库
async def fetch_all(urls):
loop = asyncio.get_running_loop()
async def fetch_one(url):
# 把同步的 requests.get 丢进线程池执行
resp = await loop.run_in_executor(
None, lambda: requests.get(url)
)
return resp.json()
return await asyncio.gather(*(fetch_one(u) for u in urls))
| 场景 | Node | Python |
|---|---|---|
| 异步 HTTP 客户端 | fetch() / undici |
httpx / aiohttp |
| 同步 HTTP 客户端 | sync-fetch(不推荐) |
requests(跑在线程池) |
| 异步文件读取 | fs.promises.readFile() |
aiofiles.open() |
| CPU 密集任务 | Worker Threads | loop.run_in_executor() / ProcessPoolExecutor |
| sleep | 无同步 sleep(阻塞) | time.sleep() 同步阻塞 vs asyncio.sleep() 异步非阻塞 |
⚠️ 避坑警告:
time.sleep()和asyncio.sleep()是两个完全不同的东西。前者阻塞当前线程(包括事件循环),后者让出控制权给其他协程。永远不要在 async 函数里用time.sleep()。
4.5 asyncio.run() 只能调一次
# ❌ 错误:多次调用 asyncio.run()
async def main():
asyncio.run(fetch_user(1)) # RuntimeError: asyncio.run() cannot be called
# from a running event loop
asyncio.run() 等价于创建一个全新的事件循环并运行它。如果已有事件循环在跑(比如你在 uvicorn 里),就不能再调。框架环境(FastAPI、pytest-asyncio)里永远直接用 await。
4.6 async/await 语法小结
// Node
async function fn() { ... } // 声明
await promise; // 等待
async () => { ... } // 箭头函数
for await (const x of iterable) // 异步迭代
# Python
async def fn(): ... # 声明
await coroutine # 等待
# 没有箭头函数,用普通函数或 lambda(lambda 不能是 async)
async for x in async_iterable: # 异步迭代
...
async with ctx_mgr as val: # 异步上下文管理器
...
Python 多了一个 async with(异步上下文管理器),比如数据库会话、信号量等场景经常用到。
4.7 这一节你该记住的
- Node 异步是隐式的,Python 异步是显式的——
async def和def之间有严格边界,不能混用 asyncio.run()是入口,一次启动一次退出——框架内直接用awaitasyncio.gather()=Promise.all(),asyncio.Semaphore=p-limit- 同步阻塞函数是异步的毒药——
requests.get()塞进 async 函数会卡死整个事件循环,用httpx或run_in_executor() time.sleep()≠asyncio.sleep()——在 async 函数里用前者,所有并发全被阻塞
五、Pydantic 与类型系统:Python 版 TypeScript
如果你从 TypeScript 转过来,你可能会下意识认为 Python 的类型注解跟 TS 是一回事。不是。 TypeScript 的类型只在编译时存在,运行时完全消失;Python 的类型注解默认也只是”注释”,不做任何检查。Pydantic 填补了这个空白——它把 Python 的类型注解变成了运行时的数据验证引擎。
5.1 类型基础对照
| 类型 | TypeScript | Python |
|---|---|---|
| 字符串 | string |
str |
| 数字 | number |
int / float |
| 布尔 | boolean |
bool |
| 数组 | string[] / Array<string> |
list[str](3.9+)/ List[str] |
| 元组 | [string, number] |
tuple[str, int] |
| 字典/对象 | Record<string, number> / { [k: string]: number } |
dict[str, int] |
| 可选 | string | null / string | undefined |
str | None(3.10+)/ Optional[str] |
| 联合类型 | string | number |
str | int(3.10+)/ Union[str, int] |
| 任意类型 | any / unknown |
Any |
| 字面量 | "admin" | "user" |
Literal["admin", "user"] |
| 函数类型 | (x: number) => string |
Callable[[int], str] |
| 泛型 | Array<T> |
list[T] |
// TypeScript: 定义一个用户类型
interface User {
id: number;
name: string;
email: string;
role: "admin" | "user";
tags: string[];
metadata: Record<string, unknown>;
}
# Python: 定义同样的类型
from typing import Literal
from pydantic import BaseModel
from uuid import UUID
class User(BaseModel):
id: int
name: str
email: str
role: Literal["admin", "user"]
tags: list[str]
metadata: dict[str, object] # object ≈ unknown
5.2 核心差异:编译时 vs 运行时
这是理解 TypeScript 和 Pydantic 根本不同之处的关键:
| 维度 | TypeScript | Python + Pydantic |
|---|---|---|
| 类型存在时机 | 仅编译时,运行时完全擦除 | 运行时存在,Pydantic 读取并验证 |
| 不匹配后果 | 编译报错(阻止构建) | 运行时抛出 ValidationError(422 响应) |
| 数据来源 | 开发者写的代码 | 外部输入(HTTP 请求、环境变量、配置文件) |
| 类型守卫 | typeof、instanceof、类型谓词 |
Pydantic 自动验证 |
| 默认行为 | 不验证,信任开发者 | 不信任输入,必须验证 |
一句话总结:TypeScript 保护你写出类型安全的代码;Pydantic 保护你的程序不被外部数据搞崩。前端的类型系统防的是你自己,后端的类型系统防的是外部世界。
// TypeScript: 编译时安全,但运行时不管
const body: unknown = JSON.parse(requestBody);
// 如果 body 结构不对,下面这行在运行时可能炸
const user = body as User; // as 断言在运行时什么都不做
user.email.toLowerCase(); // 💥 如果 email 字段不存在
# Pydantic: 运行时安全,不合规就抛异常
from pydantic import BaseModel, ValidationError
class User(BaseModel):
id: int
email: str
try:
user = User(**{"id": "not-a-number", "email": None})
except ValidationError as e:
print(e.errors())
# [
# {'loc': ('id',), 'msg': 'Input should be a valid integer', ...},
# {'loc': ('email',), 'msg': 'Input should be a valid string', ...},
# ]
5.3 TypeScript 类型体操 → Pydantic 等价写法
你在 TypeScript 里常用的类型操作,Pydantic 都有对应物:
| TypeScript | Pydantic |
|---|---|
Partial<User> |
把所有字段标 Optional,或新模型全设默认值 |
Omit<User, "id"> |
定义 UserCreate 继承 UserBase(不含 id) |
Pick<User, "name" | "email"> |
继承后只保留需要的字段 |
z.union([A, B]) |
Union[A, B] / A | B |
z.discriminatedUnion("type", [...]) |
Literal + Union |
Record<string, number> |
dict[str, int] |
Readonly<T> |
Pydantic 默认不可变(使用 model_copy 修改) |
enum |
Enum / IntEnum |
// TypeScript: 常见的 CRUD 模型模式
interface PostBase {
title: string;
content: string;
published: boolean;
}
interface PostCreate extends PostBase {} // 创建时无 id
interface Post extends PostBase { id: number; } // 查询时带 id
interface PostUpdate extends Partial<PostBase> {} // 更新时全可选
# Python/Pydantic: 同样的 CRUD 模型模式
from pydantic import BaseModel
from typing import Optional
class PostBase(BaseModel):
title: str
content: str
published: bool = False
class PostCreate(PostBase):
pass # 创建时无 id
class Post(PostBase):
id: int # 查询时带 id
class PostUpdate(BaseModel):
title: Optional[str] = None # 更新时全可选
content: Optional[str] = None
published: Optional[bool] = None
5.4 Pydantic v2 核心特性
字段校验器(@field_validator)——相当于 zod 的 .refine() / .transform():
// TypeScript + zod: 字段级校验和转换
const schema = z.object({
email: z.string().email().toLowerCase(),
age: z.number().min(0).max(150),
});
# Pydantic: 字段级校验和转换
from pydantic import BaseModel, field_validator
class UserCreate(BaseModel):
email: str
age: int
@field_validator("email")
@classmethod
def normalize_email(cls, v: str) -> str:
return v.strip().lower()
@field_validator("age")
@classmethod
def check_age(cls, v: int) -> int:
if v < 0 or v > 150:
raise ValueError("Age must be between 0 and 150")
return v
模型级校验器(@model_validator)——跨字段校验:
// TypeScript + zod: 跨字段校验
const schema = z.object({
password: z.string(),
confirm_password: z.string(),
}).refine(data => data.password === data.confirm_password, {
message: "Passwords don't match",
path: ["confirm_password"],
});
# Pydantic: 跨字段校验
from pydantic import BaseModel, model_validator
class RegisterForm(BaseModel):
password: str
confirm_password: str
@model_validator(mode="after")
def check_passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("Passwords don't match")
return self
JSON / camelCase 自动转换:
| TypeScript 惯例 | Python 惯例 | Pydantic 配置 |
|---|---|---|
firstName (camelCase) |
first_name (snake_case) |
model_config = {"alias_generator": to_camel} |
| JSON 字段名 | Python 变量名 | alias + populate_by_name=True |
// TypeScript: JSON 字段就是 camelCase
// API 返回 { "firstName": "Alice" }
// 直接用 user.firstName 访问
# Pydantic: JSON camelCase → Python snake_case 自动映射
from pydantic import BaseModel
from pydantic.alias_generators import to_camel
class User(BaseModel):
first_name: str
last_name: str
model_config = {
"alias_generator": to_camel, # 导出/导入自动转 camelCase
"populate_by_name": True, # 两种名字都接受
}
# 输入:{"firstName": "Alice", "lastName": "Smith"}
# 访问:user.first_name # "Alice"
# 导出:user.model_dump(by_alias=True) # {"firstName": "Alice", ...}
5.5 依赖注入:Depends() —— JS 世界没有的范式
FastAPI 的 Depends() 是 Node 生态里没有直接对应物的特性。它比 Express 中间件更灵活——可以注入到特定路由而不是全局应用。
| 概念 | Express / Fastify | FastAPI |
|---|---|---|
| 认证守卫 | 中间件 / preHandler |
Depends(get_current_user) |
| 数据库连接 | 中间件挂载到 req |
Depends(get_db) |
| 权限校验 | 中间件或手动判断 | Depends(require_role("admin")) |
| 请求级缓存 | 手动实现 | Depends() 在同一请求中复用结果 |
// Express: 认证中间件 → 挂载到 req,路由里手动取
app.use((req, res, next) => {
const token = req.headers.authorization;
req.user = decodeToken(token); // 挂载到 req 对象
next();
});
app.get("/me", (req, res) => {
res.json(req.user); // 手动从 req 取
});
# FastAPI: Depends() → 声明式注入,路由签名直接声明依赖
from fastapi import FastAPI, Depends, Header
from typing import Annotated
app = FastAPI()
async def get_current_user(authorization: str = Header(...)):
# 从 Header 取 token → 验证 → 返回 user
return decode_token(authorization)
@app.get("/me")
async def me(user = Depends(get_current_user)):
return user # user 直接可用,类型自动推导
# 进阶:链式依赖 + 可复用
async def get_db():
db = Database()
try:
yield db
finally:
await db.close()
async def get_current_user(
token: str = Depends(get_token),
db = Depends(get_db),
):
return await db.users.find_by_token(token)
# 路由里只需声明最终需要的依赖
@app.get("/me")
async def me(user = Depends(get_current_user)):
return user
理解 Depends():它跟 Express 中间件的区别在于——中间件是管道(请求流过),Depends 是注入(路由声明需要什么)。前者隐式,后者显式,显式依赖让测试和复用更简单。
5.6 这一节你该记住的
- TypeScript 类型止于编译时,Pydantic 类型活在运行时——前者防你写错,后者防外部数据搞崩程序
- Pydantic 模型定义一次,校验 + 序列化 + 文档全搞定——跟 zod + TS 的体验一致,但多了运行时保障
alias_generator解决 camelCase ↔ snake_case 问题——一行配置搞定前后端命名风格差异Depends()是 Express 中间件的升级版——声明式、可组合、可复用、按需注入
六、数据库与 ORM:从 Prisma/Drizzle 到 SQLAlchemy
Python 的 ORM 生态跟 Node 一样是分层级的。SQLAlchemy 是 Python 生态中无可争议的王者,但同时提供了两层 API——高级 ORM 和低级 Core——让你可以像用 Prisma 一样全 ORM,也可以像用 Drizzle/Knex 一样更接近 SQL。
6.1 ORM 生态分层对照
| 层级 | Node.js | Python |
|---|---|---|
| 全功能 ORM | Prisma | SQLAlchemy ORM |
| 薄层查询构建器 | Drizzle / Kysely | SQLAlchemy Core |
| 原始 SQL 执行 | pg / mysql2 |
psycopg2 / aiomysql / SQLAlchemy Core text() |
| 迁移工具 | Prisma Migrate / Drizzle Kit | Alembic(SQLAlchemy 官方) |
| 异步支持 | 原生(Prisma 自带) | SQLAlchemy 2.0+ create_async_engine |
选型建议:如果你习惯 Prisma 的体验,用 SQLAlchemy ORM;如果你更偏向 Drizzle/Knex 的手写查询风格,用 SQLAlchemy Core。两者可以混用——同一个项目里 ORM 和 Core 共享同一个 engine 和连接池。
6.2 模型定义:Prisma Schema → SQLAlchemy ORM
// Prisma: 声明式 Schema
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
authorId Int
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}
# SQLAlchemy ORM: 声明式模型(2.0 风格)
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from datetime import datetime
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
posts: Mapped[list["Post"]] = relationship(back_populates="author")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str | None] = mapped_column(nullable=True)
published: Mapped[bool] = mapped_column(Boolean, default=False)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
author: Mapped["User"] = relationship(back_populates="posts")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
6.3 数据库连接与 Session 管理
这是 Prisma 和 SQLAlchemy 差异最大的地方。Prisma 为你管理连接池和事务生命周期——你引入 PrismaClient 就开始工作。SQLAlchemy 需要显式管理 Engine + Session。
| 概念 | Prisma | SQLAlchemy |
|---|---|---|
| 连接池 | PrismaClient 自动管理 |
create_engine() + pool_size 参数 |
| 工作单元 | 天然事务(每次查询即事务) | Session() 显式管理 |
| 请求级会话 | 不需要特别处理 | Depends(get_db) + yield pattern |
| 异步引擎 | PrismaClient 默认异步 |
create_async_engine() + AsyncSession |
// Prisma: 引入即用,连接池自动管理
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const users = await prisma.user.findMany({
include: { posts: true },
});
# SQLAlchemy 2.0 async: 显式 Engine + AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
engine = create_async_engine(
"postgresql+asyncpg://user:pass@localhost/mydb",
pool_size=10, # 连接池大小
echo=False, # 设为 True 打印所有 SQL(调试用)
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db():
async with AsyncSessionLocal() as session:
yield session
# FastAPI 集成:Depends + yield 保证请求结束后关闭 session
from fastapi import FastAPI, Depends
app = FastAPI()
@app.get("/users")
async def list_users(db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(User).options(selectinload(User.posts))
)
return result.scalars().all()
⚠️ 避坑警告:
session.expire_on_commit默认是True,这意味着commit()后所有已加载的实例属性会被清空,下次访问会触发 lazy load。在异步环境或 FastAPI 的 Pydantic 序列化场景下会意外触发额外 SQL,甚至因 session 已关闭而报错。创建async_sessionmaker时务必设expire_on_commit=False。
6.4 查询语法对照
| 操作 | Prisma | SQLAlchemy |
|---|---|---|
| 查全部 | findMany() |
select(Model) |
| 查一条 | findUnique({ where: { id } }) |
session.get(Model, id) |
| 条件过滤 | where: { published: true } |
.where(Model.published == True) |
| 关联加载 | include: { posts: true } |
.options(selectinload(User.posts)) |
| 分页 | skip + take |
.offset().limit() |
| 排序 | orderBy: { createdAt: "desc" } |
.order_by(Model.created_at.desc()) |
| 创建 | create({ data: {...} }) |
session.add(instance) + commit() |
| 更新 | update({ where, data }) |
修改属性 + commit() |
| 删除 | delete({ where }) |
session.delete(instance) + commit() |
| 聚合 | _count() / groupBy |
func.count() / .group_by() |
| 原生 SQL | $queryRaw |
session.execute(text(sql)) |
// Prisma: 复杂查询
const posts = await prisma.post.findMany({
where: {
published: true,
author: { email: { contains: "@gmail.com" } },
},
include: { author: true },
orderBy: { createdAt: "desc" },
skip: 0,
take: 10,
});
# SQLAlchemy: 等价复杂查询
from sqlalchemy import select
from sqlalchemy.orm import selectinload
stmt = (
select(Post)
.options(selectinload(Post.author)) # 等同于 Prisma 的 include
.where(
Post.published == True,
User.email.contains("@gmail.com"),
)
.join(Post.author) # where 用到了关联表,需要显式 join
.order_by(Post.created_at.desc())
.offset(0)
.limit(10)
)
result = await db.execute(stmt)
posts = result.scalars().all()
关键差异:Prisma 的
include可以根据where条件自动推断需要的 join;SQLAlchemy 需要显式写.join()。但这也给了你更精确的控制——Prisma 的自动 join 有时候会生成你不想看到的 SQL。
6.5 关联加载策略:Prisma include → SQLAlchemy loader
Prisma 的 include 总是发一条带 JOIN 的查询(或 Prisma 优化后的批量查询)。SQLAlchemy 给了你更多选择:
| 策略 | 说明 | SQL | 适用场景 |
|---|---|---|---|
selectinload() |
第二条 SELECT IN 查询 | SELECT ... WHERE id IN (1,2,3) |
一对多/多对多,默认首选 |
joinedload() |
LEFT JOIN 一次查出 | LEFT JOIN ... ON ... |
一对一/多对一 |
lazyload |
懒加载(默认) | 访问属性时再查一次 | 避免使用——N+1 之源 |
# ✅ 推荐:显式声明加载策略,避免 N+1
stmt = select(User).options(
selectinload(User.posts), # 一对多:第二条 SELECT IN
joinedload(User.profile), # 一对一:JOIN
)
6.6 数据库迁移:Prisma Migrate → Alembic
| 操作 | Prisma | Alembic |
|---|---|---|
| 初始化 | npx prisma init |
alembic init alembic |
| 生成迁移 | npx prisma migrate dev --name xxx |
alembic revision --autogenerate -m "xxx" |
| 执行迁移 | npx prisma migrate deploy |
alembic upgrade head |
| 回滚 | npx prisma migrate reset |
alembic downgrade -1 |
| 查看历史 | npx prisma migrate status |
alembic history |
| 生成 SQL | Prisma Migrate 自动 | alembic upgrade head --sql |
# Node
$ npx prisma migrate dev --name add_user_table
# → prisma/migrations/20250101000000_add_user_table/migration.sql
# Python
$ alembic revision --autogenerate -m "add_user_table"
# → alembic/versions/abc123_add_user_table.py
$ alembic upgrade head
⚠️ 避坑警告:Alembic 的
--autogenerate不是万能的。表重命名、列类型变更(如String(100)→String(200))不会自动检测到。生成后务必人工检查生成的迁移文件。
6.7 这一节你该记住的
- Prisma → SQLAlchemy ORM,Drizzle → SQLAlchemy Core——分层结构一致,可以混用
- Session 管理是核心差异——Prisma 自动,SQLAlchemy 显式。用
Depends(get_db)+async_sessionmaker模式封装 expire_on_commit=False是必须配置——否则 Pydantic 序列化时各种幽灵错误selectinload()= Prisma 的include——默认用它做关联加载,避免 N+1- Alembic autogenerate 不是银弹——生成后人工检查,表重命名不会自动识别
七、中间件与请求生命周期
Express/Koa 的洋葱模型中间件是 Node 开发者最熟悉的概念之一。FastAPI 的中间件体系基于 Starlette,模型略有不同——理解这个差异,才能在两种生态间自由切换。
7.1 中间件模型对比
| 维度 | Express / Koa | FastAPI / Starlette |
|---|---|---|
| 执行模型 | 洋葱模型(next() 前后执行) |
洋葱模型(call_next() 前后执行) |
| 注册方式 | app.use(fn) |
app.add_middleware(Cls) |
| 中间件形式 | 函数 | 类(BaseHTTPMiddleware)或纯 ASGI 中间件 |
| 修改请求 | req.body = ... / 挂载属性 |
修改 request.scope / 注入到 request.state |
| 修改响应 | res.json(...) |
修改 response 对象并返回 |
| 路由级中间件 | router.use(fn) |
APIRouter(dependencies=[Depends()]) |
// Express: 洋葱模型——中间件是函数
app.use(async (req, res, next) => {
const start = Date.now();
await next(); // 执行后续中间件和路由
const ms = Date.now() - start;
res.set("X-Response-Time", `${ms}ms`);
});
# FastAPI: 洋葱模型——中间件是类或函数
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
import time
app = FastAPI()
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start = time.time()
response = await call_next(request) # 执行后续中间件和路由
elapsed = time.time() - start
response.headers["X-Response-Time"] = str(elapsed)
return response
app.add_middleware(TimingMiddleware)
7.2 常用中间件对照
| 功能 | Express / Fastify | FastAPI |
|---|---|---|
| CORS | cors() |
CORSMiddleware |
| 安全头 | helmet() |
无内置等价物,需手动配置或使用 Secure 等第三方 |
| 请求日志 | morgan |
logging 模块 + 自定义中间件 |
| 限流 | express-rate-limit |
slowapi |
| Body 解析 | express.json() / express.urlencoded() |
内置(与框架一体) |
| 静态文件 | express.static("public") |
StaticFiles |
| 压缩 | compression() |
GZipMiddleware |
| 可信代理 | trust proxy 设置 |
TrustedHostMiddleware |
// Express: 常用中间件组合
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");
const app = express();
app.use(helmet());
app.use(cors({ origin: "https://example.com" }));
app.use(morgan("combined"));
app.use(express.json());
# FastAPI: 常用中间件组合
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://example.com"],
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(GZipMiddleware)
# 请求日志 → 自定义中间件(见下方 7.3)
⚠️ 避坑警告:
add_middleware的顺序就是执行顺序——最后注册的最先执行(Starlette 的实现细节)。如果你先加 CORS 再加 日志,日志会比 CORS 先执行。这和 Express 的app.use()顺序语义恰好相反。
7.3 自定义中间件示例
// Express: 简单中间件——请求日志 + 用户注入
app.use(async (req, res, next) => {
console.log(`${req.method} ${req.url}`);
const token = req.headers.authorization;
if (token) {
req.user = await decodeToken(token);
}
next();
});
# FastAPI: 等价的中间件 + Depends 模式
# 方案一:纯中间件(对全局生效)
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
print(f"{request.method} {request.url}")
return await call_next(request)
app.add_middleware(LoggingMiddleware)
# 方案二:Depends(推荐——按路由注入,更精确)
from fastapi import Depends
async def get_current_user(authorization: str = Header(None)):
if not authorization:
return None
return await decode_token(authorization)
@app.get("/me")
async def me(user = Depends(get_current_user)):
return user
选型建议:全局逻辑用中间件(日志、CORS、压缩),路由级逻辑用 Depends()(认证、权限、数据库连接)。后者更灵活,且不会在不需要的路由上浪费数据库连接。
7.4 请求生命周期总览
一条请求在两种框架中的完整路径:
Express 生命周期:
Request → Middleware 1(next前) → ... → Middleware N(next前) → Route Handler
→ Middleware N(next后) → ... → Middleware 1(next后) → Response
FastAPI 生命周期:
Request → Middleware(call_next前)
→ Exception Handlers(未被捕获的异常到此兜底)
→ Dependencies(按路由声明的 Depends 链)
→ Route Handler
→ Dependencies 清理(yield 后的 finally 块)
→ Response 序列化(Pydantic → JSON)
→ Middleware(call_next后)
→ Response
| 钩子 | Express | FastAPI |
|---|---|---|
| 请求进入时 | 中间件 next() 之前 |
中间件 call_next() 之前 |
| 路由处理前 | 中间件链最后 | Depends 依赖链 |
| 路由处理 | Route handler | Route handler |
| 响应发出前 | 中间件 next() 之后 |
中间件 call_next() 之后 |
| 请求结束时 | 无显式清理 | Depends yield 的 finally 块 |
| 异常兜底 | app.use((err, req, res, next) => {}) |
@app.exception_handler() |
7.5 这一节你该记住的
- FastAPI 中间件也是洋葱模型——只是注册方式从
app.use(fn)变为app.add_middleware(Cls) - 注册顺序和执行顺序相反——最后
add_middleware的最先执行 - 全局用中间件,路由级用 Depends——把认证、数据库连接从中间件里解放出来
- Python 没有 helmet——安全头需要手动配置,或用第三方 Secure 库
八、错误处理与日志体系
try/catch 在两种语言中都在被大量使用,但异常体系的哲学和处理模式有显著差异。Python 的异常系统比 Node 更加”融入血液”——很多你习惯用 return value 表达的东西,Python 社区直接用异常。
8.1 try/catch vs try/except
语法层面几乎一致,但几个细微差异值得注意:
| 特性 | JavaScript | Python |
|---|---|---|
| 捕获异常 | try { ... } catch (err) { ... } |
try: ... except Exception as err: ... |
| 最终块 | finally { ... } |
finally: ... |
| 无异常时执行 | 无直接等价物 | else: 块(仅当 try 块无异常时执行) |
| 多异常类型 | catch (err) { if (err instanceof TypeError) ... } |
except TypeError: ...(原生支持多个 except 分支) |
| 重新抛出 | throw err |
raise(在 except 块中不带参数即重新抛出当前异常) |
| 自定义异常 | class MyError extends Error {} |
class MyError(Exception): pass |
// JS: 多类型异常——手动判断
try {
riskyOperation();
} catch (err) {
if (err instanceof ValidationError) {
console.log("Validation:", err.message);
} else if (err instanceof TimeoutError) {
console.log("Timeout:", err.message);
} else {
throw err; // 不是预期异常,继续抛
}
}
# Python: 多类型异常——原生多分支
try:
risky_operation()
except ValidationError as e:
print(f"Validation: {e}")
except TimeoutError as e:
print(f"Timeout: {e}")
else:
print("No error occurred") # JS 没有的 else 块
finally:
cleanup()
⚠️ 避坑警告:
except的顺序很重要!Python 从上到下匹配第一个兼容的 exception 类型。如果except Exception写在except ValueError前面,后面的分支永远不会执行——因为ValueError是Exception的子类。
8.2 框架级异常处理
| 场景 | Express | FastAPI |
|---|---|---|
| 抛 HTTP 异常 | throw createHttpError(404, "Not found") |
raise HTTPException(status_code=404, detail="Not found") |
| 全局异常处理器 | app.use((err, req, res, next) => {...}) |
@app.exception_handler(SomeError) |
| 未处理异常 | Express 默认返回 HTML | FastAPI 返回 JSON {"detail": "Internal Server Error"} |
| 请求体验证失败 | 手动处理 | Pydantic 自动返回 ValidationError → 422 JSON |
// Express: 全局异常处理
app.use((err, req, res, next) => {
console.error(err.stack);
const status = err.statusCode || 500;
res.status(status).json({
error: { message: err.message, status },
});
});
# FastAPI: 全局异常处理——装饰器风格
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"error": {"message": exc.detail, "status": exc.status_code}},
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
# 在生产环境中你可能会记录到 Sentry/Datadog
print(f"Unhandled error: {exc}")
return JSONResponse(
status_code=500,
content={"error": {"message": "Internal Server Error", "status": 500}},
)
8.3 日志体系:winston/pino → logging/loguru
这是 Node 和 Python 差异最大的基础设施之一。Node 的日志全是第三方(winston、pino、bunyan);Python 的 logging 是标准库内置的四级日志系统,理念接近 Java 的 log4j。
| 概念 | JS/TS (winston/pino) | Python (logging) | Python (loguru,推荐) |
|---|---|---|---|
| 获取 Logger | winston.createLogger() |
logging.getLogger(__name__) |
from loguru import logger(全局单例) |
| 日志级别 | error/warn/info/debug |
ERROR/WARNING/INFO/DEBUG |
相同 + 自定义级别 |
| 格式化 | winston.format.combine(...) |
logging.Formatter(fmt, datefmt) |
logger.add(sink, format=...) |
| 结构化日志 | pino() 默认 JSON |
python-json-logger |
logger.add(sink, serialize=True) |
| 请求日志 | morgan |
自定义中间件 | 自定义中间件 |
| 文件轮转 | winston-daily-rotate-file |
logging.handlers.RotatingFileHandler |
logger.add("file.log", rotation="1 day") |
// Node + pino: 结构化日志
import pino from "pino";
const logger = pino({
level: "info",
transport: { target: "pino-pretty" },
});
logger.info({ userId: 1, action: "login" }, "User logged in");
# Python + loguru: 结构化日志——一行初始化
from loguru import logger
logger.add("app.log", rotation="1 day", retention="7 days", serialize=True)
logger.add("app_error.log", level="ERROR") # 错误单独记一个文件
logger.info("User logged in", user_id=1, action="login")
# loguru 的上下文参数自动序列化为 JSON
选型建议:如果你习惯 pino 的体验,直接用 Loguru。它比标准库
logging简洁太多——无需配置 dict、无需 Handler 和 Formatter 的黑话,一行logger.add()搞定文件轮转和格式化。
8.4 请求日志中间件
// Express + morgan: 请求日志
app.use(morgan(':method :url :status :response-time ms'));
# FastAPI: 自定义请求日志
import time
from starlette.middleware.base import BaseHTTPMiddleware
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start = time.time()
response = await call_next(request)
elapsed = (time.time() - start) * 1000
logger.info(
f"{request.method} {request.url.path}",
status=response.status_code,
duration_ms=round(elapsed, 2),
)
return response
app.add_middleware(RequestLoggingMiddleware)
8.5 这一节你该记住的
try/catch→try/except——语法几乎一样,但 Python 多了else:和多重except分支except顺序是关键的——先捕获子类再捕获父类,否则后面的分支永远不会执行- 异常是 Python 的”一等公民”——很多你在 JS 里用 return value 检查的场景,Python 直接用异常
- 日志用 loguru,别折腾标准库 logging——一行的
logger.add()代替几十行 logging dictConfig
九、测试:从 Jest/Vitest 到 pytest
如果你习惯了 Jest 或 Vitest 的体验,pytest 会让你有回家的感觉——它是 Python 生态中最受爱戴的测试框架,设计哲学跟 Jest 意外地接近。但有一个特性是 Jest 不具备的:fixture 依赖注入体系,这可能是 pytest 最强大的武器。
9.1 基础对比
| 特性 | Jest / Vitest | pytest |
|---|---|---|
| 测试文件命名 | *.test.ts / *.spec.ts |
test_*.py / *_test.py |
| 测试函数命名 | test("description", fn) |
def test_description(): |
| 测试分组 | describe("group", fn) |
class TestGroup: |
| 断言 | expect(x).toBe(y) |
assert x == y |
| 异步测试 | async () => { await ... } |
async def ... + pytest-asyncio |
| setup/teardown | beforeEach / afterEach |
fixture(依赖注入,更强大) |
| Mock | jest.fn() / jest.mock() |
unittest.mock.Mock / .patch() |
| 参数化测试 | it.each([...]) |
@pytest.mark.parametrize |
| 覆盖率 | --coverage (内置) |
pytest-cov 插件 |
| 运行单个文件 | npx jest path/to/test.ts |
pytest path/to/test.py |
| watch 模式 | --watch |
ptw / pytest-watch 插件 |
// Jest: 一个完整的测试文件
import { describe, it, expect, beforeEach } from "vitest";
describe("Calculator", () => {
let calc;
beforeEach(() => {
calc = new Calculator();
});
it("should add two numbers", () => {
expect(calc.add(1, 2)).toBe(3);
});
it.each([
[1, 2, 3],
[0, 0, 0],
[-1, 1, 0],
])("add(%i, %i) = %i", (a, b, expected) => {
expect(calc.add(a, b)).toBe(expected);
});
});
# pytest: 等价的测试文件
import pytest
class TestCalculator:
@pytest.fixture
def calc(self):
return Calculator() # 注入到测试方法
def test_add(self, calc):
assert calc.add(1, 2) == 3
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_add_parametrized(self, calc, a, b, expected):
assert calc.add(a, b) == expected
9.2 Fixture:pytest 的杀手级特性
beforeEach 和 afterEach 是你知道的模式。pytest 的 fixture 让这个概念进化了:
| 特性 | Jest | pytest fixture |
|---|---|---|
| 注入方式 | 在 beforeEach 中赋值给外部变量 |
直接作为测试函数参数接收 |
| 作用域 | function(每次) | function / class / module / session |
| 复用方式 | 共享 beforeEach 代码 |
fixture 自动发现 + 依赖注入 |
| 清理 | afterEach / afterAll |
yield 之后的代码(和 Depends 模式一致) |
| 依赖链 | 手动控制 beforeEach 顺序 |
fixture 可以依赖其他 fixture |
// Jest: beforeEach 手动管理状态
let db, user;
beforeEach(async () => {
db = await createTestDB(); // 创建数据库
await db.migrate();
user = await db.createUser({ // 创建用户
email: "test@example.com",
});
});
afterEach(async () => {
await db.close(); // 清理
});
it("fetches user", async () => {
const result = await getUser(db, user.id);
expect(result.email).toBe("test@example.com");
});
# pytest: fixture 链式依赖 + yield 清理
import pytest
@pytest.fixture
async def db():
db = await create_test_db()
await db.migrate()
yield db # 测试用这个值
await db.close() # 测试结束后自动清理
@pytest.fixture
async def user(db): # user fixture 依赖 db fixture
return await db.create_user(email="test@example.com")
async def test_get_user(db, user): # 需要啥声明啥
result = await get_user(db, user.id)
assert result.email == "test@example.com"
理解 fixture:如果你只看到一个 declare-test-use 的模式,你只看到了表层。真正强大的是 fixture 的可组合性——
user依赖db,auth_user依赖user。每个测试函数只声明它需要的最终结果,pytest 自动构建完整的依赖图。
9.3 Mock 对比
| 操作 | Jest | Python unittest.mock |
|---|---|---|
| 创建 mock 函数 | const fn = jest.fn() |
fn = Mock() |
| 返回值 | fn.mockReturnValue(42) |
fn.return_value = 42 |
| 副作用 | fn.mockImplementation(x => x*2) |
fn.side_effect = lambda x: x * 2 |
| 验证调用 | expect(fn).toHaveBeenCalled() |
fn.assert_called() |
| 验证参数 | expect(fn).toHaveBeenCalledWith(1, 2) |
fn.assert_called_with(1, 2) |
| 模块 Mock | vi.mock("./module") 或 jest.mock() |
patch("path.to.module.Class") |
| 重置 | fn.mockClear() |
fn.reset_mock() |
// Jest: Mock HTTP 请求
import { vi } from "vitest";
vi.mock("./api", () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
# pytest: Mock HTTP 请求
from unittest.mock import patch, AsyncMock
@patch("app.services.api.fetch_user")
async def test_my_service(mock_fetch):
mock_fetch.return_value = {"id": 1, "name": "Alice"}
result = await my_service.get_user(1)
mock_fetch.assert_called_once_with(1)
assert result.name == "Alice"
⚠️ 避坑警告:
patch()的路径参数是基于模块导入路径,不是文件路径。如果你在app/services/user.py中from .api import fetch_user,patch 的路径应该是"app.services.user.fetch_user",不是"app.services.api.fetch_user"。这个陷阱每个新手都会掉进去。
9.4 HTTP 测试:supertest → TestClient
// Node + supertest: 集成测试
import request from "supertest";
const res = await request(app)
.post("/posts")
.send({ title: "Hello", content: "..." })
.expect(201);
expect(res.body.title).toBe("Hello");
# FastAPI + TestClient: 集成测试
from fastapi.testclient import TestClient
client = TestClient(app)
def test_create_post():
response = client.post(
"/posts",
json={"title": "Hello", "content": "..."},
)
assert response.status_code == 201
assert response.json()["title"] == "Hello"
对于异步测试,用 httpx.AsyncClient 走 ASGI transport:
import pytest
from httpx import AsyncClient, ASGITransport
@pytest.mark.asyncio
async def test_create_post_async():
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
response = await client.post(
"/posts",
json={"title": "Hello", "content": "..."},
)
assert response.status_code == 201
9.5 这一节你该记住的
- pytest fixture 是
beforeEach的进化版——依赖注入、可组合、自动清理,比手动管理状态强一个量级 assert就是断言,不需要expect().toBe()那一套patch()的目标路径是导入路径——错误的目标路径是 pytest 最大的坑- TestClient 对标 supertest——同步测试用
TestClient,异步测试用httpx.AsyncClient+ ASGI transport - 别忘记
pytest-asyncio——没有它,async def测试函数不会被执行
十、部署与生产化
最后一节聚焦于把开发完的服务推到生产环境。Node 的部署模式你已经了如指掌——pm2 start 或容器化。Python 的部署多了一层”协议”的概念,这是 Node 开发者最容易困惑的地方。
10.1 WSGI / ASGI:Python 独有的协议层
Node 没有 WSGI/ASGI 这种概念——HTTP server 和 Application 的接口是隐式的。Python 有一个标准化的服务器-应用协议。这不是你需要手写的东西,但理解它能避免选错部署方案。
| 协议 | 适用框架 | 类比理解 |
|---|---|---|
| WSGI(同步) | Flask, Django(同步模式) | 类似 CGI 但保持进程常驻——只处理同步请求 |
| ASGI(异步) | FastAPI, Starlette, Django(异步模式) | 类似 Node 的 HTTP 服务器——支持 WebSocket、长轮询、异步 I/O |
一句话:FastAPI 必须用 ASGI 服务器启动(uvicorn),Flask 用 WSGI 服务器(gunicorn)。别搞混——用 gunicorn 跑 FastAPI 需要 uvicorn worker。
10.2 应用服务器对照
| 功能 | Node.js | Python |
|---|---|---|
| 开发服务器 | node index.js |
uvicorn app.main:app --reload |
| 生产服务器 | node dist/index.js |
uvicorn app.main:app --workers 4 |
| 进程管理 | pm2 |
gunicorn / supervisor / systemd |
| Worker 模式 | pm2 start -i 4 |
gunicorn -w 4 |
# Node: 生产启动
$ pm2 start dist/index.js -i 4 --name my-app
# Python: 生产启动
$ uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
# 或者用 gunicorn 管理 uvicorn workers(更稳定):
$ gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
⚠️ 避坑警告:
python app.py里面加app.run()只能在开发环境用!它启动的是单线程、非生产级别的服务器。生产环境必须用 uvicorn/gunicorn,否则并发、稳定性、优雅关闭全都没保障。
10.3 Docker 化
# Node Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
# Python Dockerfile (FastAPI)
FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /app/.venv ./.venv
COPY app/ ./app/
ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# .dockerignore 注意
__pycache__
*.pyc
.venv
.idea
.git
.env
*.egg-info
# docker-compose.yml(Node 和 Python 的结构完全一致)
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/mydb
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
10.4 环境变量管理
| 操作 | Node | Python |
|---|---|---|
| 读取变量 | process.env.PORT |
os.environ["PORT"] |
| 带默认值 | process.env.PORT || 3000 |
os.environ.get("PORT", "3000") |
| .env 加载 | dotenv |
python-dotenv |
| 类型安全配置 | zod + process.env |
pydantic-settings |
// Node: 生产级配置管理
import { z } from "zod";
const config = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
}).parse(process.env);
# Python: 生产级配置管理
from pydantic_settings import BaseSettings
class Config(BaseSettings):
port: int = 3000
database_url: str
log_level: str = "info"
model_config = {"env_file": ".env", "env_prefix": ""}
config = Config() # 自动读取 .env 并验证
10.5 健康检查与优雅关闭
// Node: 优雅关闭
process.on("SIGTERM", async () => {
console.log("SIGTERM received, shutting down...");
await server.close();
await db.disconnect();
process.exit(0);
});
# FastAPI: 优雅关闭
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动时
await database.connect()
yield
# 关闭时——自动响应 SIGTERM/SIGINT
await database.disconnect()
app = FastAPI(lifespan=lifespan)
@app.get("/health")
async def health():
return {"status": "ok"}
uvicorn 本身处理 SIGTERM/SIGINT 信号,并使用
lifespan上下文优雅关闭。不需要像 Node 那样手动绑定信号处理器。
10.6 性能与生产 checklist
| 检查项 | Node | Python |
|---|---|---|
| 生产服务器 | pm2 + node / 容器化 | uvicorn + gunicorn / 容器化 |
| Worker 数 | CPU 核数 × 1 | CPU 核数 × 1(uvicorn --workers) |
| 健康检查 | /health 端点 |
/health 端点 |
| 优雅关闭 | 信号处理 + server.close() |
lifespan 上下文 |
| 日志 | stdout → docker / datadog | stdout → docker / datadog |
| 反向代理 | nginx(静态文件 + SSL 终止) | nginx(静态文件 + SSL 终止) |
| 环境变量 | .env + 运行时注入 |
.env + 运行时注入 |
10.7 这一节你该记住的
- FastAPI 必须用 ASGI 服务器——
uvicorn是标配,gunicorn是可选的进程管理器外壳 python app.py只用于开发——生产环境必须用 uvicorn 多 worker 模式- Dockerfile 结构跟 Node 几乎一致——多阶段构建 + uv 安装依赖,注意
__pycache__相关文件 - 配置管理用 pydantic-settings——和 zod + process.env 同模式,但运行时验证
- lifespan 代替手动信号处理——uvicorn 自动处理 SIGTERM/SIGINT,框架的 lifespan 执行优雅关闭