做后端开发这几年,我最大的感受是:现在的系统几乎不存在完全封闭的。哪怕是个人小项目,只要想接个支付、发个短信、调个大模型能力,就得面对别人的 API;反过来,只要你的服务想被别的地方调用,你就得设计自己的 API。API 就是软件之间的普通话,而 RESTful API 是目前说得最通用的一套语法规则。
这篇文章我想把 RESTful 这件事从头到尾讲透,再带你在 Python 里完整做一遍。先讲清楚 REST 到底在解决什么问题、哪些设计约束必须坚持、哪些可以灵活取舍;然后对比 Flask、Django REST Framework、FastAPI 三个主流方案;最后从零写一个带认证、带数据库、带统一异常处理的真实 API,并把实际开发中踩过的一些坑直接列出来。适合刚入门想搞懂接口设计的人,也适合已经写了不少接口、但总觉得哪里不对劲的后端开发者。
1. 先理解 RESTful:资源、约束与 HTTP 语义
1.1 为什么说 REST 先解决的是"资源描述"问题
我第一次接触 REST 的时候,看了不少博客,得到的信息是"RESTful 就是 URL 里用名词、方法用 GET POST",后来发现这就把最重要的事讲小了。REST 的核心不是 URL 长什么样,而是它把整个 Web 看成一张由"资源"组成的网。资源可以是一个人、一篇文章、一笔订单,任何可以被命名、被操作的对象。REST 的要求是:用 URL 唯一定位资源,用 HTTP 方法表达对这个资源的操作,用 HTTP 状态码表达操作结果。
这里面有一个很关键的思维转变:从"函数调用"到"资源操作"。我们早期写接口时常见的风格是 /api/getUserInfo?userId=1024&type=detail,这个接口的问题不是"丑",而是一旦调用方多起来,你要加一个"获取用户简要信息"的需求,就得再写一个 getUserBriefInfo,或者给原来接口加一个 type 参数。接口越来越多、风格越来越乱,前端每次对接都像在学一门新语言。如果一开始就把"用户"当成资源,表达成 GET /users/1024,需要简要信息就通过查询参数或字段投影去表达,接口的自解释性会好很多。
这种资源导向的思维,放到现在的大模型 API 上也完全成立。你调用 DeepSeek、智谱这类大模型服务,本质是"创建/补全一段对话",所以你会看到类似 POST /v1/chat/completions 的路径,再用 body 描述要传给模型的消息和参数。底层原理还是这一套。把资源思维理解透了,遇到再陌生的 API,你也能很快判断出它是 RESTful 风格还是老式 RPC 风格,从而决定怎么去调试它。
1.2 六大约束不是圣旨:工程里的取舍优先级
Fielding 在博士论文里给出了 REST 的六个约束,网上经常照着列一遍,这里我想聊的是:哪些必须坚持,哪些可以灵活,以及为什么。
第一,客户端-服务器。界面与数据分离,一套后端接口服务网页、App、小程序多个客户端,这点没有争议,必须坚持。第二,无状态。服务器不能依赖会话里保存的客户端状态,每个请求都要自包含。说实话,纯理论上的"无状态"在实际业务里很难做到,比如登录状态,服务器确实不知道你是谁,但它可以通过签名校验客户端带来的 token。真正的意思是:不要用服务端 session 内存保存状态,否则无法水平扩展。第三,可缓存。响应头里加 Cache-Control,让浏览器、CDN、网关帮你缓存 GET 请求,能省下很大一部分服务器压力。但缓存也会带来脏数据,所以写操作默认不可缓存。第四,统一接口。这是 REST 和普通 HTTP 接口最大的区别:大家约定名词 URL 标识资源、标准方法操作资源、标准状态码表示结果,所有客户端都不需要猜测。第五,分层系统。客户端不需要知道它连接的是应用服务器、网关还是负载均衡器,这意味着你可以在中间加一大堆东西,只要接口语义不变。第六,按需代码。允许服务器"下发代码"给客户端,现实中几乎没人用,可以直接忽略。
我在实际团队里定的规矩是:前五条尽量做到,第六条不讨论。而在前五条里,统一接口和无状态是接口设计变好变坏的分水岭,缓存和分层更多是架构层面的工作而非接口代码层面的事。为了直观,拿 RPC 风格和 RESTful 风格做个对比:
| 维度 | RPC 风格 | RESTful 风格 |
|---|---|---|
| 思考方式 | 动作 / 函数 | 资源 / 状态 |
| 典型 URL | /getUserById?id=1 | GET /users/1 |
| 操作语义 | 自定义函数名 | HTTP 方法 |
| 状态传递 | 通常由服务端保存 | 客户端携带,服务端无状态 |
| 缓存友好 | 差 | 好 |
| 客户端接入成本 | 每个函数都要单独学习 | 规则统一,可预测 |
1.3 URL、方法、状态码:一份可以直接照抄的设计规范
具体到落地,一个 RESTful 接口可以拆成四件事:URL 怎么写、方法用什么、状态码给什么、版本怎么管。
URL 方面,规则比较简单:资源用名词复数,比如 /users、/articles;层级关系用斜杠表达,比如 /users/1/articles 表示某个用户的文章列表;不出现动词,getOrders、deleteUser 这种是 RPC 思维;过滤、排序、分页放在查询参数,比如 ?status=paid&cursor=xxx。
方法方面,一张表就能说清楚:
| 方法 | 语义 | 典型状态码 | 幂等性 |
|---|---|---|---|
| GET | 获取资源 | 200 | 幂等 |
| POST | 创建资源或触发动作 | 201 | 非幂等 |
| PUT | 整体替换 | 200 | 幂等 |
| PATCH | 部分更新 | 200 | 幂等 |
| DELETE | 删除资源 | 204 | 幂等 |
状态码的选择比列表更重要,因为状态码是客户端判断"接下来怎么走"的第一依据。常见场景我直接给结论:创建成功用 201,删除成功用 204,参数错误用 400 或 422,未登录用 401,没权限用 403,资源不存在用 404,冲突用 409,服务器内部错误用 500。不要所有失败都返回 200 然后在 body 里塞一个 code,这会让网关监控、缓存、重试机制全部失效。
版本管理是一个容易被忽略但早晚要面对的问题。我推荐在路径里放 /api/v1/,理由有两个:一眼就能看出调的是哪版接口,与框架无关,任何客户端都认识。Header 版本号虽然更"干净",但对调试不友好。还有一个实操建议:接口上线前,花半天时间把错误响应体的结构定了。比如统一用 {"detail": "错误原因"},不要一会儿返回字符串,一会儿返回数组,客户端封装 HTTP 层时会被坑死。
2. Python API 框架选型与关键设计
2.1 选型对比:Flask、DRF 与 FastAPI 到底差在哪
Python 写 REST API,绕不开三个框架:Flask、Django REST Framework(DRF)、FastAPI。我见过不少团队为这个吵架,这里直接给出我在不同场景下的判断,拿走就能用。
Flask 是最轻的,学习曲线最平缓。它默认只有路由和请求响应,ORM、校验、文档全都要自己搭。适合团队小、接口少、希望完全掌控的情况。但也是因为过度自由,项目大了以后容易各写各的,校验和响应格式很难统一,最后五六个接口五种写法,维护时只能靠人工约束。
DRF 是 Django 生态里的 API 框架,功能最全:认证、权限、限流、序列化、可浏览文档全都内置。如果你的项目本来就在 Django 里,或者业务复杂到需要一个完整后台管理系统,选它非常稳。代价是框架理念重,很多东西是"约定优于配置",新人上手成本高。
FastAPI 是后起之秀,基于 Starlette 和 Pydantic,性能和开发体验都不错。我推荐新项目优先考虑它,最重要的原因是"类型驱动":你在一个 Pydantic 模型上同时定义了请求数据结构、校验规则、响应结构和文档 schema,四个角色合一,写起来非常顺。
| 框架 | 性能 | 学习曲线 | 内置能力 | 适合场景 |
|---|---|---|---|---|
| Flask | 中 | 平缓 | 少,需自拼生态 | 小型服务、已有 Flask 团队 |
| Django REST Framework | 中 | 陡峭 | 全,自带 admin/ORM | 大型业务系统、管理后台 |
| FastAPI | 高 | 平缓 | 文档、校验、依赖注入 | 新项目、高性能 API、微服务 |
选型还要考虑团队存量:老项目是 Flask 就用 Flask,老项目是 Django 就继续 DRF,没有历史包袱的新项目再考虑 FastAPI。技术栈统一比"框架更牛"重要得多。
2.2 FastAPI 的类型驱动和依赖注入,为什么值得认真理解
我第一次用 FastAPI 最大的震撼是:函数签名居然能直接决定 HTTP 接口的行为。你的参数声明成 int,框架就会自动做类型转换校验;声明成 Pydantic 模型,框架就会自动从请求体里解析 JSON;声明成 UploadFile,就能自动处理文件上传。这背后是 FastAPI 把"类型系统"变成了"契约系统",你声明的不再只是变量类型,而是接口协议。
但我觉得真正需要认真理解的是依赖注入(Depends)。FastAPI 允许你声明一个函数作为依赖,框架会在进入业务逻辑前先执行它,把返回值传给接口。最经典的用法是 get_current_user:
from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from sqlalchemy.orm import Session oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") def get_current_user( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db), ): # 内部做 JWT 校验、查用户表,失败就抛 401 return user任何需要登录的接口,只要在函数参数里写 user = Depends(get_current_user),它就自动拿到当前用户对象。认证代码不用在每个接口里复制粘贴。依赖还可以嵌套依赖,可以带参数,可以覆盖,这比装饰器灵活得多,也方便测试时替换假数据。
很多初学者觉得依赖注入是"高级概念",其实可以这样理解:接口函数不是从零开始的,它声明了"我需要什么前置条件",框架帮它把这些前置条件准备好。这不只是减少重复代码,更重要的是让安全逻辑和业务逻辑分离。后端的统一约束力越强,团队的代码就越不容易变成一盘散沙。
2.3 第一个 FastAPI 接口:从代码看请求处理链路
先感性认识一下 FastAPI 的写法。假设要做一个简单的"用户注册"接口,模型是 Pydantic 2 的写法,代码放在 main.py:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI(title="用户服务 API") class UserCreate(BaseModel): name: str email: str age: int = 18 class UserOut(BaseModel): id: int name: str email: str @app.post("/api/v1/users", response_model=UserOut, status_code=201) def create_user(user: UserCreate): # 这里先不接数据库,只做演示 return UserOut(id=1, name=user.name, email=user.email) @app.get("/api/v1/users/{user_id}", response_model=UserOut) def get_user(user_id: int): return UserOut(id=user_id, name="demo", email="demo@example.com")启动服务:
uvicorn main:app --reload --host 0.0.0.0 --port 8000然后打开 http://127.0.0.1:8000/docs ,你看到的是一个可以实际点击发送请求的 Swagger 文档页面。直接在页面上把请求发一遍,就能感受到"模型即文档、即校验"的开发方式有多省事。
当你向 POST /api/v1/users 发请求时,FastAPI 的处理链路大致是:先路由匹配到 create_user 函数;然后根据函数签名解析请求,UserCreate 模型从请求体取 JSON 并做 Pydantic 校验,校验不过直接返回 422;接着执行函数体;最后按 response_model 做一次类型转换和字段过滤,再序列化成 JSON 返回。
response_model 这个参数很多人会忽略,实际上它是一个安全边界。假设你的 User 表里有 hashed_password 字段,只要在 UserOut 里不写它,响应就一定不会带上它。哪怕函数直接返回了完整 ORM 对象,框架也会按 response_model 过滤。这在接口设计里是防止敏感信息泄露的重要防线。
3. 实战:从零构建一套可上线的 RESTful API
3.1 项目结构设计:路由、模型、核心逻辑不混在一起
看过 demo,来看完整工程。目标不是写玩具,而是尽可能接近生产环境的最小骨架:有认证、有数据库、有统一错误处理,还能自动化测试。以常见的"用户 + 文章"场景为例,项目结构如下:
api-project/ ├── app/ │ ├── __init__.py │ ├── main.py # 应用入口,注册路由和异常处理 │ ├── config.py # 配置项:密钥、数据库地址、过期时间 │ ├── models.py # SQLAlchemy ORM 模型 │ ├── schemas.py # Pydantic 请求/响应模型 │ ├── deps.py # 公共依赖:数据库 Session、当前用户 │ ├── routers/ │ │ ├── __init__.py │ │ ├── auth.py # 登录、刷新 token │ │ ├── users.py # 用户资源 │ │ └── articles.py # 文章资源 │ ├── core/ │ │ ├── security.py # 密码哈希、JWT 加解密 │ │ └── exceptions.py # 全局异常处理 │ └── crud/ │ ├── __init__.py │ └── users.py ├── alembic/ # 数据库迁移脚本 ├── tests/ └── requirements.txt我特别想说一说为什么要拆这么细。刚开始写接口时,大家都习惯"一个 main.py 全搞定",30 行代码确实不需要拆。但当接口数量超过 20 个、文件超过 1000 行,路由函数里堆着 SQL、日志、业务判断,谁改谁崩溃。拆分的核心原则是:路由只负责 HTTP 语义(URL、参数、状态码),业务逻辑放在 crud 或 service 层,数据访问独立成模块,这样接口文档、单元测试、代码审查都会轻松很多。
3.2 用户认证:JWT 的完整落地与 401/403 的正确区分
认证是现代 API 绕不过去的一环。我用 JWT,因为它本身是无状态的,适合水平扩展。JWT 由三段组成:Header 说明签名算法,Payload 携带声明,Signature 用服务端密钥签名。需要注意的是,Payload 只是 base64 编码,客户端能轻松解开,所以里面绝对不能放密码、手机号这类敏感信息,只放用户 ID(sub)和过期时间(exp)就够了。
在 security.py 里实现密码哈希和 token 创建:
from datetime import datetime, timedelta, timezone from jose import jwt, JWTError from passlib.context import CryptContext SECRET_KEY = "your-secret-key-change-me" ALGORITHM = "HS256" pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def hash_password(password: str) -> str: return pwd_context.hash(password) def verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed) def create_access_token(user_id: int, expires_minutes: int = 1440) -> str: expire = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes) payload = {"sub": str(user_id), "exp": expire} return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) def decode_access_token(token: str) -> int: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return int(payload["sub"])登录端点用 OAuth2PasswordRequestForm 接收表单格式的账号密码,验证通过后返回 token:
from fastapi import APIRouter, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session router = APIRouter() @router.post("/api/v1/auth/login") def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): user = get_user_by_username(db, form.username) if not user or not verify_password(form.password, user.hashed_password): raise HTTPException(status_code=401, detail="用户名或密码错误") token = create_access_token(user.id) return {"access_token": token, "token_type": "bearer"}接着做 get_current_user 依赖,这一步是把 JWT 校验变成"可复用中间件"的关键。任何接口只要写上 user = Depends(get_current_user),就自动完成身份识别。这里还要把 401 和 403 的区分单独拿出来说,因为很多项目里有人混用。401 是"你是谁我不知道",也就是未认证或认证无效,客户端应该去重新登录;403 是"我知道你是谁,但你不允许做这件事",客户端不应该重试。这两种状态码差一个字母,前端处理逻辑完全不同,别混。
3.3 数据库接入:SQLAlchemy 模型定义与常见查询问题
数据库我用 SQLAlchemy 2.0 的 DeclarativeBase 风格,起步阶段用 SQLite,上线换 PostgreSQL 只需要改连接串。两个核心模型:User 和 Article。
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text from sqlalchemy.orm import DeclarativeBase, relationship from datetime import datetime, timezone class Base(DeclarativeBase): pass class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) username = Column(String(64), unique=True, index=True) hashed_password = Column(String(128)) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) articles = relationship("Article", back_populates="author") class Article(Base): __tablename__ = "articles" id = Column(Integer, primary_key=True) title = Column(String(128), nullable=False) content = Column(Text, nullable=False) author_id = Column(Integer, ForeignKey("users.id")) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) author = relationship("User", back_populates="articles")有几个细节值得注意:时间字段统一用 UTC 存储,避免多时区项目里各写各的;username 加 unique 和 index,登录查询能走索引;relationship 默认是懒加载,如果你在列表接口里遍历用户再取文章列表,就会产生 N+1 查询问题,需要主动用 selectinload 预加载。
CRUD 写法也要注意一个坑,新建对象保存后要 refresh 才能拿到数据库生成的主键:
def create_article(db: Session, author_id: int, title: str, content: str) -> Article: article = Article(title=title, content=content, author_id=author_id) db.add(article) db.commit() db.refresh(article) return article为什么必须 refresh?因为 SQLAlchemy 的默认行为下,新建对象的 id 在 commit 之后、autoincrement 生成前可能取不到,像 SQLite 这种数据库通常 commit 后主键就带上了,但为了兼容 PostgreSQL sequence 等情况,显式 refresh 更稳妥。另外,如果业务处理中途抛出异常,记得调用 db.rollback(),否则会话状态会污染下一个请求。
3.4 全局异常与统一错误结构:该不该套 code/data/message
统一错误处理的目标是:业务代码只用抛异常,HTTP 层自动转换成标准响应。FastAPI 里可以注册全局异常处理器:
from fastapi import FastAPI, Request from fastapi.responses import JSONResponse class BizError(Exception): def __init__(self, code: int, message: str): self.code = code self.message = message def register_exception_handlers(app: FastAPI): @app.exception_handler(BizError) async def biz_error_handler(request: Request, exc: BizError): return JSONResponse( status_code=exc.code, content={"detail": exc.message}, ) @app.exception_handler(Exception) async def unhandled_error_handler(request: Request, exc: Exception): # 生产环境这里打完整日志,不要直接把内部异常暴露给客户端 return JSONResponse( status_code=500, content={"detail": "服务器内部错误"}, )这里想重点讲一个很多团队都会纠结的事:响应要不要统一包成 {code, message, data}。我的观点是:不一定。如果你做的是内部系统,前端确实需要判断多种业务状态,包一层也无妨。但只要你的 API 要面向外部,或者有缓存、监控、SDK 客户端,我建议使用 HTTP 状态码本身作为错误语义,错误信息保持简单结构。这样中间件、网关、基础设施都能正确识别错误。
FastAPI 默认的 HTTPException(detail=...) 其实已经是一种不错的统一结构。折中的方案是:HTTP 状态码保持准确,错误 body 里只放一个可读的 detail,再扩展一个 code 字段表示业务细分错误码。最不能接受的就是"一切皆 200,业务错误靠 body 里的 code 判断",这会毁掉整个 HTTP 生态的语义,也让限流、重试、监控全部失效。
3.5 接口测试与调试方法:docs、curl、httpx 三种方式
接口写完了,怎么验证?我一般按三种场景用不同工具。
第一,开发期用 FastAPI 自带的 /docs。它是动态从代码里的类型标注生成的,参数、响应结构、示例全都自动列出,直接在页面里发请求调试就行,但只适合开发环境和接口自测。
第二,写文档或需要证明接口可运行时,用 curl 命令,直观且能在任何环境复现:
# 登录获取 token curl -X POST http://127.0.0.1:8000/api/v1/auth/login \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=demo&password=secret123" # 带 token 访问受保护资源 curl -X GET http://127.0.0.1:8000/api/v1/users/me \ -H "Authorization: Bearer <token>"第三,自动化回归测试用 httpx 的 ASGITransport,不需要真正起服务也能调用 ASGI 应用,非常适合 CI:
from fastapi.testclient import TestClient from app.main import app def test_list_articles_requires_auth(): client = TestClient(app) resp = client.get("/api/v1/articles") assert resp.status_code == 401 def test_create_article_success(): client = TestClient(app) token = client.post("/api/v1/auth/login", data={"username": "demo", "password": "secret123"}).json()["access_token"] headers = {"Authorization": f"Bearer {token}"} resp = client.post("/api/v1/articles", json={"title": "t", "content": "c"}, headers=headers) assert resp.status_code == 201测试至少要覆盖四条路径:正常请求、参数校验失败、未认证访问受保护资源、越权访问。把这些路径写成用例,后续重构才有底气。
4. 常见问题与排查技巧实录
4.1 认证相关:API Key 缺失、网关丢 Header、token 过期
开发中遇到的认证问题远超想象,这里列几个典型的。
第一类,报错信息里直接说"no api key for provider route"这类。十有八九是环境变量没配好。很多人把配置写在本地 .env 里,部署时 .env 没被加载,或者环境变量名拼错,报错信息其实说得很清楚,但大家习惯先搜索而不是先看配置。排查时先打印一遍配置值,再确认 dotenv 是否真的执行,这一步能省下大量时间。
第二类,Authorization 头被网关弄丢。本地跑一切正常,一部署就 401,先怀疑反向代理。有些网关清洗 Header 时默认会去掉 Authorization,或者覆盖了自定义的 proxy_set_header。遇到这种情况,你可以在服务端临时打个日志看看进来的 Header 里到底有没有 Authorization,很快就能定位。
第三类,token 过期。签名验证通过但 exp 时间已过,JWT 库会抛异常。要给前端明确的 401 而不是 500,并且把"需要重新登录"的提示写进错误信息。有些人把过期时间设成一个月,安全上很危险,我建议内部系统 24 小时、面向用户类系统 2 小时左右,再配合 refresh token 续期。
补充一个第三方服务的排查经验:很多人遇到短信接口发不出去,第一反应是改代码重试,其实正确做法是先看错误码。签名不匹配、模板 ID 不对、手机号缺国别码、触发流控、账户欠费,错误码都不一样。对照文档查错误码,大概率能在几分钟内定位问题,而不是瞎调参数。
4.2 Docker API 权限:socket 权限不够的排查
"permission denied while trying to connect to the docker api at unix:///var/run/docker.sock" 这个报错太经典了。原因很直接:当前用户没有权限访问 Docker socket。默认情况下 docker.sock 只允许 root 和 docker 组的用户访问,普通用户直接调 Docker API 就会遇到这个错。
排查分三步:先执行 ls -l /var/run/docker.sock 看 socket 的属主和权限;再确认当前用户在哪个组;最后按需处理。解决方案有两种:一是把用户加入 docker 组,执行 sudo usermod -aG docker $USER 后重新登录;二是用 rootless Docker,让用户不需要 root 权限也能运行容器。
这里有一条安全提示:把用户加入 docker 组,相当于把这个用户提升到了接近 root 的权限,因为他可以通过 docker 挂载宿主机目录。生产环境要不要这么做,得在便利性和安全性之间权衡,不要无脑照抄。
4.3 高并发下的调用量与限流
调用量是 API 运维里最容易忽视的指标。我见过太多服务,上线时没人知道每个接口被谁调了多少次,结果某天第三方账单爆了,才发现有个脚本在死循环调你的付费接口。我习惯用三件套来保证:可观测、限流、降级。
可观测是最基础的,每个接口的调用量、延迟、错误率要有指标。Prometheus 是标准选择,如果没有 Grafana 这一套,至少也要在日志里打印关键指标。
限流方面,FastAPI 生态里可以直接用 slowapi,它基于 request 的 IP 来做简单的每客户端计数:
from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from fastapi import FastAPI, Request limiter = Limiter(key_func=get_remote_address) app = FastAPI() app.state.limiter = limiter app.add_exception_handler(429, _rate_limit_exceeded_handler) @app.get("/api/v1/articles") @limiter.limit("100/minute") def list_articles(request: Request): return {"data": []}要注意的是,限流策略最好放在网关或统一依赖层,而不是散落在每个接口逻辑里。限流是横切关注点,集中定义才可维护,这也是我在前面提到的分层系统约束在工程里的具体体现。
4.4 第三方接口超时与重试:别让默认值坑了你
调用第三方 API 时,requests 库默认不设置超时是最常见的坑。你不设 timeout,对方服务挂掉的时候你的线程会一直挂着,连接池被占满,整个服务跟着雪崩。正确的做法是显式设置合理的超时,httpx 可以分连接、读、写、连接池分别控制:
import httpx with httpx.Client(timeout=httpx.Timeout(connect=2.0, read=10.0, write=5.0, pool=5.0)) as client: resp = client.get("https://api.example.com/v1/users/1")重试策略也只对幂等请求开放。GET、PUT、DELETE 可以安全重试;POST 如果是创建资源,重试要格外小心,可能造成重复创建。如果真的需要重试非幂等请求,可以让客户端传一个幂等键(Idempotency-Key),服务端根据这个 Key 去重。我见过因为重试导致用户收到两条相同短信的事故,老生常谈,但值得铭记。
5. 工程心得与后续扩展
5.1 设计 API 前,先画一张"资源地图"
可能有人觉得,上面这些够多了,但在实际项目中我还想再强调一件事:设计 API 前先画一张资源地图,不要上来就写路由。先回答几个问题:有哪些核心资源?资源的 owner 是谁?资源和资源之间是什么关系?比如"用户 vs 文章",文章属于用户,那么 /users/1/articles 和 /articles?author_id=1 两种表达都可以,但前者更 RESTful,而且在"获取某用户的所有文章"这个业务场景下,路由语义非常清晰。
资源地图理清之后,后面加接口基本都是顺着 URL 模板走,不会出现风格混乱。团队里可以把这个地图画在白板上,也可以放进接口文档的 introduction 里,它会成为新同学快速理解系统的最佳入口。
5.2 有了这套骨架,你还能往哪些方向扩展
工程里实际要处理的问题远不止这些,但骨架已经具备,接下来你可以按需扩展。分页可以做游标分页或 offset 分页,对大数据量用户来说游标更稳定;写操作可以加幂等键,防止客户端重试造成重复数据;资源变化可以通过 Webhook 通知调用方;微服务场景可以在网关层做统一鉴权、限流、聚合;最后还可以根据 OpenAPI 文档用 openapi-generator 自动生成多语言客户端 SDK,省掉手动写 HTTP 调用代码的体力活。
我个人实际做项目时体会最深的一点是:RESTful 的核心并不是那几条规范本身,而是"资源思维"。当你习惯了把系统抽象成资源,并用标准 HTTP 语义去描述操作,设计 API 会变得简单很多。如果再结合 FastAPI 这类工具把类型和文档拉通,开发速度会非常可观。希望这篇能给你一个可以直接落地的起点,剩下的坑,踩过之后都会有收获。