我最早意识到API设计值得认真对待,不是因为读了多少规范,而是吃了一次亏。那会儿给内部项目写了个数据导出接口,方法名取的是get_data,参数一堆布尔值往里面塞,前端同学每次调用前都要来问我:“这个参数传True是什么意思?那个字段返回什么格式?”我被问烦了,才回头去看那些优秀开源库的接口,发现人家根本没有这个烦恼——拿到文档就能写代码,参数、返回、错误都清清楚楚。
这章我打算把自己在Python里做API设计的实操经验完整梳理一遍,覆盖从“怎么命名URL”到“错误消息怎么统一”、再到“怎么用FastAPI把接口文档自动生成出来”这一整套细节。适合的对象很明确:会写Python函数,但还没系统设计过接口的人;或者是已经写了几个API,但总觉得调用方用起来别扭的人。看完这章,你至少能设计出一个让前端、让同事、让未来的自己都觉得很舒服的Python API。
1. 内容整体设计与思路拆解
1.1 好的API到底“好”在哪里
先问一个问题:你写一个函数add_user(name, age),和写一个接口POST /api/v1/users,本质区别是什么?
函数是一对一沟通——只有你和调用者,错了可以马上改口。API是一对多沟通——可能有三个前端、两个服务端程序在调你的接口,任何一处设计失误,都要乘以五倍的成本去修复。所以API设计的第一原则不是“我自己用得爽”,而是“调用方少踩坑”。
我总结好的API就三个字:好用、好懂、好扩展。
好用,指的是调用方不需要猜——参数叫user_name还是username,文档里写清楚,代码里统一;好懂,指的是看一眼URL和请求方法,就知道这个接口在干什么——POST /orders就是创建订单,不会让人产生歧义;好扩展,指的是你加了新功能,旧的调用方代码不需要改——比如后来加了权限控制,老的接口路径和参数不能随意变动。
这三件事,说到底是靠“事先约定”来保证的。把接口当成一份正式的契约去设计,而不是随手写的工具函数,这就算入行了。
1.2 工具选型:FastAPI还是Flask,别拍脑袋
Python里做API,绕不开三个框架:Flask、Django REST Framework(DRF)、FastAPI。很多新手纠结选哪个,我直接给结论:新项目无脑FastAPI,原因有三个。
第一,FastAPI原生支持类型校验。你定义一个Pydantic模型,写清楚name: str、age: int = Field(ge=0),参数不对的时候框架自动返回422错误,省掉了一堆手写校验代码。第二,FastAPI自动生成Swagger文档,浏览器打开/docs就能看到可交互的接口页面,调试和沟通成本低到离谱。第三,它是基于ASGI的,异步支持是天然优势,将来做并发高的场景不用重构。
Flask的优势是轻量、生态老、教程多,适合做极小的内部工具,但参数校验、文档生成这些都要自己动手,越往后越累。DRF适合你已经在用Django做项目的情况,它和Django的ORM结合很深,但学习曲线也陡。
不夸张地说,FastAPI是Python API设计的最佳实践样板。它把参数校验、序列化、文档生成这些重复工作全部标准化了,让你把精力集中在“接口的语义设计”上,而不是纠结“这个参数怎么从request里取出来”。后面我的实操演示也用FastAPI。
1.3 写接口前,先画一张“资源地图”
我见过太多人打开编辑器就写路由,写完一个加一个,最后API是一盘散沙。正确的方式是先梳理资源。
所谓资源,就是你系统里的核心实体——用户、订单、文章、评论。对着业务需求,把这些实体列出来,然后用“名词”定义资源,用“HTTP方法”定义操作,这个接口设计就完成了一半。
举个例子,一个简单的博客系统,资源地图是这个样子:
- 文章(articles)
- 评论(comments)
- 用户(users)
每个资源对应一组操作:
- 创建:
POST /api/v1/articles - 列表:
GET /api/v1/articles - 详情:
GET /api/v1/articles/{id} - 更新:
PATCH /api/v1/articles/{id} - 删除:
DELETE /api/v1/articles/{id}
把这张图画出来之后,还要考虑资源之间的关系。文章和评论是父子关系,那评论的URL就建议挂在文章下面:POST /api/v1/articles/{id}/comments,这样看到URL就明白“这是某个文章下的评论”,语义清清楚楚。
这一步花十五分钟,后面写代码省五小时。
2. 资源命名与HTTP方法:RESTful的细节与门道
2.1 URL别乱起,命名规则要统一
URL是API的脸面。我总结了四条命名铁律,都是实际踩坑踩出来的。
第一,用名词复数,不用动词。/api/v1/users表示“用户资源集合”,/api/v1/users/42表示“某个用户”,都是名词。而/api/v1/getUserInfo这种用动词的,等于把接口写死成了“获取”这一个动作——将来你要同一个用户信息同时支持更新,这个URL就派不上用场了。动词应该交给HTTP方法去表达,而不是出现在URL里。
第二,层级不要超过两层。/api/v1/countries/{country_id}/cities/{city_id}/districts这种三层嵌套,读起来费劲,写起来也费劲,而且一旦业务调整,URL就失效。遇到深层关系,建议拆成独立资源:GET /api/v1/cities/{city_id}就够了,country和city的关联放到查询参数里处理。
第三,统一用下划线还是连字符?我推荐连字符(-),因为它不容易被误读,而且在一些浏览器和中间件里,下划线出现在URL中偶尔会有兼容性问题。但这不是死规定,关键在于“整个项目统一”。最怕的是有的接口用下划线、有的用连字符,调用方得靠猜。
第四,不要在URL里出现大写字母。URL是区分大小写的,/Users/1和/users/1会被当成两个不同的资源,而调用方大概率记不清你哪个字母大写了。一律小写,问题直接消失。
2.2 HTTP方法与状态码的搭配
HTTP方法一共就那么几个,但很多人用得乱七八糟。最常见的错误是“只凭POST和GET打天下”——更新用POST,删除用POST,查询用POST。这能跑,但调用方从URL和方法里根本看不出你的意图。
正确的用法是:
| 方法 | 语义 | 典型场景 | 成功状态码 |
|---|---|---|---|
| GET | 查询,不改变状态 | 获取列表、详情 | 200 |
| POST | 创建新资源 | 创建订单、注册用户 | 201(Created) |
| PUT | 整体替换 | 更新全部字段 | 200 或 204 |
| PATCH | 局部更新 | 只改一个字段 | 200 或 204 |
| DELETE | 删除 | 删除一个资源 | 204(No Content) |
这里有个细节我要特别强调:GET请求不允许产生副作用。一个人不小心刷新了一下列表页,结果数据被改了或者被删了,这种事故我见过不止一次。副作用操作请用POST、PUT或DELETE。
状态码也是重灾区。很多人不管成功失败一律返回200,然后在body里写一个success: false,这样做的坏处是:调用方必须先把body解析出来才能判断业务成没成功,HTTP层的语义完全废掉了。
正确的做法是:
- 成功:200(带body)、201(创建成功)、204(删除成功,无body)
- 参数错误:400(Bad Request),协议对但字段不对
- 未认证:401(Unauthorized),没登录或token失效
- 无权限:403(Forbidden),登录了但没权限
- 资源不存在:404(Not Found)
- 服务器内部错误:500(Internal Server Error)
这六类状态码用好,调用方就算不看文档,也能猜个八九不离十。
2.3 版本管理:API一定会变,先想好后路
没有永远不变的API。业务调整、字段增删、数据结构变化,迟早都会发生。所以版本管理不是可选项,是必选项。
主流方案有三种:
- URL路径版本:
/api/v1/users、/api/v2/users,最直观,我推荐新手用这个 - 请求头版本:
Accept: application/vnd.example.v2+json,更干净,但对调用方不友好,需要额外说明 - 查询参数版本:
/api/users?version=2,实现最简单,但容易被忽略,没法强制升级
我个人的实践是:内部项目用URL路径版本,简单直接,一眼就能看出来调的是哪个版本的接口;对外公开的API,如果没有历史包袱,可以一开始就用请求头版本,实现更优雅。但真的,大部分项目用不上这么讲究的方案,/api/v1/够用一辈子。
需要注意的坑是:版本升级意味着行为变化,而不是单纯“加了几个字段”。加了字段要保证向后兼容——老客户端不传新字段也能正常工作;删字段或改字段类型,才算破坏性变更,必须升大版本。
3. 请求与响应的核心细节:调用方痛不痛,全看这里
3.1 参数放哪里:路径、查询字符串还是请求体
设计接口时,每个参数都要想清楚放哪里。我给出一个简单的判断规则:
- 路径参数,放“唯一标识一个资源”的变量:
/api/v1/users/{user_id},这里user_id是资源的主键,换成任何别的参数都不合适。 - 查询字符串参数,放“筛选、排序、分页”等非必填条件:
GET /api/v1/articles?tag=python&page=2&page_size=20。 - 请求体,放“创建或更新时的业务数据”:
POST /api/v1/articles的body里放标题、正文、分类等。
有一条铁律:GET请求不要用请求体传参。虽然HTTP协议没有明文禁止,但很多网关、代理服务器会直接丢弃GET的body,而且调用方用浏览器地址栏就能直接测试的便利性就没了。
关于参数命名,我建议接口对外统一用camelCase还是snake_case,选一个然后打死不变。Python后端习惯用snake_case,但很多前端同学更适应camelCase。怎么选?
我的方案是:如果团队以Python为主,对外保持snake_case;如果团队有大量前端或者对外公开API,用camelCase可能更舒适。没有标准答案,但要在文档里写清楚,并且保持统一。说实话,这个事纠结太久没意义,定了就别改。
3.2 统一响应结构:别让调用方瞎猜
我见过最折磨人的API是:成功时返回一个列表,失败时返回一个字符串,未授权时返回一段HTML。调用方每次都要写一堆防御性代码,气得想骂人。
统一的响应结构这回事,原则上就是让调用方拿到任何响应,都能用同一套代码去解析。我自己用的结构是这样的:
成功时:
{ "code": 0, "message": "ok", "data": { "id": 1, "name": "张三" } }失败时:
{ "code": 40001, "message": "用户名不能为空", "data": null }这里有一个设计细节:业务错误码和HTTP状态码分离。HTTP状态码只管“这个请求通没通”,业务码管“具体发生了什么”——比如参数校验失败统一返回400,但具体是哪个字段错了、为什么错了,靠业务码和message告诉调用方。
也不要过度封装。如果只是纯粹的CRUD接口,可以直接返回资源本身(比如POST创建用户,直接返回用户JSON),不需要在最外层再包一层code。统一结构的目的是降低调用方的理解成本,而不是为了“看起来规范”就千篇一律。我的经验是:业务型接口包一层,资源型接口直接返回资源,两种风格都行,但不要混用。
3.3 分页、过滤、排序:列表接口的“三件套”
列表接口如果不做分页,数据量一旦上来,响应体大到让浏览器卡死,数据库也被拖垮。分页方案我推荐两种:
- 偏移量分页:
page和page_size,实现简单,适合内部系统;缺点是在大数据集下,翻页越深性能越差。 - 游标分页:
cursor参数,性能稳定,适合数据持续增加的场景(比如消息列表);缺点是客户端理解成本略高。
个人建议:中小型项目起步用偏移量分页就够了,真的遇到性能问题再切游标。
过滤和排序,统一用查询参数。比如GET /api/v1/articles?tag=python&author_id=7&sort=-created_at,这里的sort=-created_at表示按创建时间倒序,加负号表示倒序,这个约定简单可行。
分页响应的结构建议:
{ "items": [], "total": 100, "page": 1, "page_size": 20, "has_more": true }这里has_more对客户端做“加载更多”非常有用,不用自己去算page * page_size >= total。
3.4 写操作要重视幂等性和校验
幂等性这个词听着高级,其实意思很简单:同一个请求执行一次和执行一百次,结果是相同的。GET天然幂等,因为你没有修改数据;DELETE也是幂等的——删一个不存在的资源,客户端该得到的响应也应该收敛到“不再存在”这个状态,只是第一次返回204,后面可以统一返回404,这各团队有各团队的约定。
真正的坑在POST和PUT。POST创建资源,天然不幂等——点两下就是两条数据。如果要防止重复下单,就需要客户端传一个幂等键(Idempotency-Key请求头),服务端把它缓存下来,收到重复请求直接返回第一次的结果。
PUT和PATCH是幂等的吗?按语义来说是。但要注意:如果你的更新逻辑里有“更新时间戳”这种自动字段,那重复调用PATCH的结果就不完全一样了。这一点和前端约定好就行。
参数校验这一块,FastAPI给了极大的便利。Pydantic模型里写清楚字段类型、是否必填、取值范围、正则规则,框架自动处理校验并返回422错误。我强烈建议从一开始就把校验规则写完整——命名title: str = Field(..., min_length=1, max_length=100),比你手动写一堆if判断要可靠得多。
4. 实操:用FastAPI从零搭一个高质量API
4.1 环境准备与项目结构
实操部分我们用FastAPI写一个“待办事项”API,麻雀虽小五脏俱全,覆盖资源设计、参数校验、统一响应、异常处理、自动文档这些完整环节。
先安装依赖:
pip install fastapi uvicorn pydantic然后按下面的结构创建项目:
todo_api/ ├── main.py # 应用入口与路由 ├── models.py # Pydantic数据模型 ├── database.py # 模拟数据库(线上换真库即可) └── requirements.txt4.2 定义数据模型
打开models.py,定义两个模型:一个用于创建待办事项请求,一个用于返回响应。
from pydantic import BaseModel, Field from typing import Optional from datetime import datetime class TodoCreate(BaseModel): title: str = Field(..., min_length=1, max_length=200, description="待办事项标题") description: Optional[str] = Field(None, max_length=1000, description="备注") priority: int = Field(1, ge=1, le=5, description="优先级,1最低,5最高") class Todo(TodoCreate): id: int done: bool = False created_at: datetime这个设计有两点很关键:第一,TodoCreate把创建请求的字段约束写死了——字数、范围、必填与否,一口气全定义清楚;第二,Todo继承TodoCreate,保证响应体一定包含所有请求字段,再额外加上服务端生成的id、done、created_at,类型上就杜绝了“忘记返回字段”的问题。
4.3 主体实现
database.py里用一个字典模拟数据库,重点看main.py的实现:
from fastapi import FastAPI, HTTPException, Query, Path, status from models import Todo, TodoCreate from database import todos_db, next_id app = FastAPI(title="Todo API", version="v1") @app.post("/api/v1/todos", response_model=Todo, status_code=status.HTTP_201_CREATED) def create_todo(payload: TodoCreate): todo = Todo( id=next_id(), created_at=datetime.utcnow(), **payload.dict() ) todos_db[todo.id] = todo return todo @app.get("/api/v1/todos", response_model=list[Todo]) def list_todos( done: Optional[bool] = Query(None, description="按完成状态筛选"), priority: Optional[int] = Query(None, ge=1, le=5), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ): items = list(todos_db.values()) if done is not None: items = [t for t in items if t.done == done] if priority is not None: items = [t for t in items if t.priority == priority] start = (page - 1) * page_size return items[start : start + page_size] @app.get("/api/v1/todos/{todo_id}", response_model=Todo) def get_todo(todo_id: int = Path(..., ge=1)): if todo_id not in todos_db: raise HTTPException(status_code=404, detail="Todo not found") return todos_db[todo_id] @app.patch("/api/v1/todos/{todo_id}", response_model=Todo) def update_todo(payload: TodoCreate, todo_id: int = Path(..., ge=1)): if todo_id not in todos_db: raise HTTPException(status_code=404, detail="Todo not found") todo = Todo(id=todo_id, **payload.dict(), created_at=todos_db[todo_id].created_at) todos_db[todo_id] = todo return todo @app.delete("/api/v1/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_todo(todo_id: int = Path(..., ge=1)): if todo_id not in todos_db: raise HTTPException(status_code=404, detail="Todo not found") del todos_db[todo_id]几个设计细节可以重点看看。第一,所有路由都在/api/v1下,版本号一目了然。第二,create_todo返回201而不是200,“创建成功”这个语义在协议层就表达清楚了。第三,delete_todo返回204,没有body,调用方不需要解析任何内容。第四,路径参数todo_id约束了ge=1,传0或负数直接422,不用进业务逻辑。
启动服务:
uvicorn main:app --reload --port 8000打开http://127.0.0.1:8000/docs,Swagger UI已经把所有接口的请求参数、响应模型、状态码都列好了,可以直接在浏览器里点“Try it out”调试。这个页面本身就是团队协作时的接口文档,前后端开发直接看这里,省掉了一大批沟通成本。
4.4 补充统一错误处理
上面只是最简单的用法,但“高质量”还差一步统一错误处理。如果任由各种异常抛出,调用方拿到的错误千奇百怪。加一个全局异常处理器,把错误标准化:
from fastapi import Request from fastapi.responses import JSONResponse class BizException(Exception): def __init__(self, code: int, message: str): self.code = code self.message = message @app.exception_handler(BizException) async def biz_exception_handler(request: Request, exc: BizException): return JSONResponse( status_code=400, content={"code": exc.code, "message": exc.message, "data": None}, )这样业务代码里想表达“用户名已存在”这类错误,直接raise BizException(1001, "用户名已存在"),响应就统一了。调用方只需要判断code是不是0,不是0就弹出message,防御代码量直接降到最低。
5. 文档与测试:API能活多久,看这两件事
5.1 用好自动文档,但别把文档当摆设
FastAPI的/docs是自动生成的Swagger UI,不用花时间额外写文档,但这个“自动”有个前提——你的代码注释和模型描述得写清楚。
每个路由函数要有docstring,每个字段要有description。比如:
@app.get("/api/v1/todos", response_model=list[Todo]) def list_todos( done: Optional[bool] = Query(None, description="按完成状态筛选"), ... ): """获取待办事项列表,支持按状态、优先级筛选,以及分页。"""这些内容会自动显示在Swagger页面上,前端同学一眼就能看懂每个字段是什么意思。人脑记忆是有限的,就算你现在记得每个参数的作用,三个月后的自己也未必记得。
5.2 用pytest和TestClient给API上“保险”
接口写完了,不测试就上线,等于裸奔。FastAPI集成测试很方便,用TestClient就行。说句实话,我无论项目多小都会把核心接口的测试写上,因为改代码的时候,跑一遍测试的安心感是任何代码审查都给不了的。
from fastapi.testclient import TestClient from main import app client = TestClient(app) def test_create_todo(): resp = client.post("/api/v1/todos", json={"title": "写博客"}) assert resp.status_code == 201 data = resp.json() assert data["title"] == "写博客" assert data["id"] > 0 assert data["done"] is False def test_create_todo_invalid_priority(): resp = client.post("/api/v1/todos", json={"title": "x", "priority": 99}) assert resp.status_code == 422 def test_get_todo_not_found(): resp = client.get("/api/v1/todos/99999") assert resp.status_code == 404 def test_delete_todo(): resp = client.delete("/api/v1/todos/1") assert resp.status_code == 204三个测试分别覆盖了正常流程、参数校验、资源不存在这三种情况,基本够用了。后面再写具体业务时,照着这个模式补测试就行。
5.3 变更向后兼容的三个实操建议
API上线后,任何改动都要克制。我给自己定的规矩很简单:
- 新增字段:后端加一个带默认值的可选字段,老客户端不受影响。
- 修改字段语义:比如把
status从字符串改成数字,相当于删了老字段,必须升版本。 - 删除字段:先废弃(deprecated)一个版本周期,再真正删除。
另外,线上接口的日志一定要打全——谁调的、传了什么参数、返回了什么、耗时多少。排查问题的时候,没有日志的API就像黑盒,出了bug你连“复现”都做不到,只能干瞪眼。
6. 常见问题与排查技巧实录
6.1 前后端联调时,CORS老报错怎么办
本地开发最常见的就是前端浏览器报CORS错误。如果你的前端跑在http://localhost:5173,后端跑在http://localhost:8000,浏览器默认是禁止跨域读取资源的。FastAPI里加中间件:
from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], allow_methods=["*"], allow_credentials=True, allow_headers=["*"], )生产环境一定不要用allow_origins=["*"],把前端真实域名写进去。CORS配置不当,等于给攻击者开了一扇门。
6.2 422和400傻傻分不清
FastAPI的依赖注入会自动做参数校验,校验失败默认返回422 Unprocessable Entity。很多新手看到422就懵了:“这不是参数错误吗?为什么不是400?”
其实422在FastAPI里的含义是“请求语法没问题,但语义校验不通过”。它和400并不冲突:
- 400:请求本身有问题(比如JSON解析失败、内容类型不对)
- 422:请求能解析,但字段的值不符合约束(比如年龄传了-1)
调用方只需要知道:422就去看body里的detail,里面会精确告诉你哪个字段、什么错误。只是因为很多前端只认400,联调时被问“为什么不是400”的次数太多了,我后来直接在文档里写明“校验错误统一422”,省得来回解释。
6.3 分页参数传超大值,直接把数据库打爆
没有对page_size做上限的接口,就是一颗定时炸弹。有一个人写脚本扫数据,传了个page_size=100000,直接把接口拖垮了。这是我在生产环境踩过的真实事故。
解决方案很简单,用Query(le=100)限制最大分页大小,或者干脆在拿到参数后对超大值做截断:
page_size: int = Query(20, ge=1, le=100)优先用FastAPI的le校验,参数一进来就被拦截了,根本不进业务逻辑。
6.4 字段命名在前后端之间反复横跳
这个坑太普遍了:后端返回create_time,前端说我们统一用createdAt,后端改了,结果别的接口还在用create_time。一个项目里同时存在两种命名风格,调用方必须查文档才能知道某个接口到底返回什么字段,这就是典型的“风格没定死”引发的连锁混乱。
我的建议是:接口文档里明确写“本项目字段命名一律使用snake_case”或者“camelCase”,所有接口示例、错误消息、文档描述都遵循这个约定。如果非要转换,用Pydantic的Field(alias=...)统一在出口转换,不要在业务代码里到处dict手动改字段名。
最后再分享一个小经验
API设计这件事,一旦你开始在意“调用方用起来痛不痛”,你就已经在进步了。我个人的一个小习惯是:每个接口写完,假装自己是一个完全不了解系统的人,照着文档从头调一遍——参数全传对会怎样,漏传会怎样,传错类型会怎样,看看返回的消息是不是足够清楚。如果我自己都看不懂,那这个接口就不合格,果断改。这样反复磨了一段时间之后,你的接口设计会形成肌肉记忆,写出来的东西越来越“稳”,踩着坑走过来的经验,比背多少规范都管用。