Python 与 AI 开发教程

FastAPI 入门

用类型注解声明接口,参数校验、JSON 转换和接口文档都自动完成。写过 Spring Boot 或 Retrofit 都会觉得眼熟。

  • Python 服务端 · 第 3 章
  • 28 分钟
  • 7 组对比
  • 基于 FastAPI 0.162 · Pydantic 2.14 · Spring Boot 4.1

第一个接口

FastAPI 是用来写 HTTP 接口的 Python 框架。你用类型注解写一个函数,它负责解析请求、校验参数、把返回值转成 JSON,并生成接口文档。

先在虚拟环境里安装。standard 附带了开发服务器和测试要用的依赖。

终端
pip install "fastapi[standard]"

声明一个接口

Python
from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

@app.get("/users/{user_id}")
def get_user(user_id: int):
    return {"id": user_id, "name": "Ada"}

client = TestClient(app)
client.get("/users/7").json()  # => {'id': 7, 'name': 'Ada'}
@RestController
class UserController {
    @GetMapping("/users/{id}")
    User getUser(@PathVariable int id) {
        return new User(id, "Ada");
    }
}
对比项PythonJava
路由声明装饰器 @app.get注解 @GetMapping
路径参数参数名和占位符相同就自动绑定用 @PathVariable 绑定
返回值自动转成 JSON由 Jackson 转成 JSON
所在一端相同服务端,收到请求后执行函数
实现相同函数体就是业务逻辑

FastAPI 和 Spring Boot 都站在服务端,装饰器相当于路由注解。Retrofit 方向相反,它描述怎么调用接口,FastAPI 描述怎么响应调用。

TestClient 不启动服务器,直接在进程里调用接口,所以本章的示例存成一个文件就能运行。后面的示例省略了它的导入和创建。

开发时去掉 client 这两行,用命令启动真正的服务:

终端
fastapi dev main.py     # 开发模式,改代码后自动重启
fastapi run main.py     # 生产模式

启动后打开 http://127.0.0.1:8000/docs,能看到自动生成的接口文档,并且可以直接在页面上发请求。

路径参数和查询参数

出现在路径里的是路径参数,其他简单类型的参数是查询参数。类型注解同时负责转换和校验,user_id: int 收到 abc 时会直接返回 422,函数不会执行。

有默认值的参数是可选的。需要更细的约束时,用 Annotated 加上 Query。

路径参数和查询参数

Python
from typing import Annotated

from fastapi import FastAPI, Query

app = FastAPI()

@app.get("/users/{user_id}/posts")
def list_posts(
    user_id: int,
    page: Annotated[int, Query(ge=1)] = 1,
    keyword: str | None = None,
):
    return {"user": user_id, "page": page, "keyword": keyword}

data = client.get("/users/7/posts?page=2").json()
data  # => {'user': 7, 'page': 2, 'keyword': None}
client.get("/users/7/posts?page=0").status_code  # => 422
@GetMapping("/users/{id}/posts")
PostPage listPosts(
    @PathVariable int id,
    @RequestParam(defaultValue = "1") @Min(1) int page,
    @RequestParam(required = false) String keyword
) {
    return service.find(id, page, keyword);
}
对比项PythonJava
路径参数名字出现在路径里@PathVariable
查询参数不在路径里的简单类型@RequestParam
可选参数写默认值,没有值时用 NonedefaultValue 或 required = false
取值范围Query(ge=1),不满足时返回 422@Min(1),不满足时返回 400

校验写在函数签名里,函数体拿到的参数都已经是正确的类型。

请求体和响应模型

JSON 请求体用 Pydantic 的 BaseModel 描述,字段写法和 data class 很像。参数类型是 BaseModel 子类时,FastAPI 会把它当作请求体。

response_model 规定响应的结构,模型里没有的字段不会发出去。创建用户的接口用它过滤掉密码。

请求体和响应

Python
from pydantic import BaseModel, Field

class UserCreate(BaseModel):
    name: str = Field(min_length=1)
    password: str

class UserPublic(BaseModel):
    id: int
    name: str

@app.post("/users", status_code=201, response_model=UserPublic)
def create_user(user: UserCreate):
    return {"id": 1, **user.model_dump()}

body = {"name": "Ada", "password": "secret"}
response = client.post("/users", json=body)
response.status_code  # => 201
response.json()  # => {'id': 1, 'name': 'Ada'}
record UserCreate(@Size(min = 1) String name, String password) {}

record UserPublic(int id, String name) {}

@PostMapping("/users")
@ResponseStatus(HttpStatus.CREATED)
UserPublic createUser(@Valid @RequestBody UserCreate user) {
    return new UserPublic(1, user.name());
}
对比项PythonJava
数据模型继承 BaseModelrecord
请求体参数类型是模型就自动识别@RequestBody
校验时机进入函数之前进入方法之前,要加 @Valid
字段约束Field(min_length=1)@Size(min = 1)
成功状态码默认 200,用 status_code 修改默认 200,用 @ResponseStatus 修改

入参和出参各用一个模型,敏感字段就不会被顺手返回给客户端。

model_dump() 把模型转成字典,相当于序列化的中间结果。

错误处理

要返回错误时,抛出 HTTPException。FastAPI 会停止执行,并把 detail 放进响应。

返回 404

Python
from fastapi import FastAPI, HTTPException

app = FastAPI()
USERS = {1: "Ada"}

@app.get("/users/{user_id}")
def get_user(user_id: int):
    if user_id not in USERS:
        raise HTTPException(status_code=404, detail="用户不存在")
    return {"id": user_id, "name": USERS[user_id]}

response = client.get("/users/9")
response.status_code  # => 404
response.json()  # => {'detail': '用户不存在'}
@GetMapping("/users/{id}")
User getUser(@PathVariable int id) {
    var name = USERS.get(id);
    if (name == null) {
        throw new ResponseStatusException(
            HttpStatus.NOT_FOUND, "用户不存在");
    }
    return new User(id, name);
}
对比项PythonJava
错误信息响应体里的 detail异常的 reason
所在一端相同服务端抛出,决定返回什么
成功时相同正常返回

HTTPException 是给客户端看的。预期之外的异常会变成 500,具体内容不会发给客户端。

参数校验失败时 FastAPI 自动返回 422,detail 是一个列表,指明哪个字段出了什么问题。

Python
error = client.get("/users/abc").json()["detail"][0]
print(error["loc"], error["type"])
输出
['path', 'user_id'] int_parsing

依赖注入

多个接口要做同一件事时,比如检查身份、打开数据库,把它写成一个函数,再用 Depends 声明。FastAPI 处理请求时先运行这个函数,把返回值传给接口。

依赖函数的参数和接口一样,可以读取请求头、查询参数,也可以再依赖别的函数。

依赖注入

Python
from typing import Annotated

from fastapi import Depends

def current_user(x_token: Annotated[str, Header()]) -> str:
    if x_token != "secret":
        raise HTTPException(status_code=401, detail="令牌无效")
    return "ada"

CurrentUser = Annotated[str, Depends(current_user)]

@app.get("/me")
def read_me(user: CurrentUser):
    return {"user": user}

good = {"X-Token": "secret"}
client.get("/me", headers=good).json()  # => {'user': 'ada'}
bad = {"X-Token": "wrong"}
client.get("/me", headers=bad).status_code  # => 401
@Component
class TokenStore {
    String userOf(String token) {
        if (!"secret".equals(token)) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }
        return "ada";
    }
}

@RestController
class MeController {
    private final TokenStore tokens;

    MeController(TokenStore tokens) {
        this.tokens = tokens;
    }

    @GetMapping("/me")
    Map<String, String> readMe(@RequestHeader("X-Token") String token) {
        return Map.of("user", tokens.userOf(token));
    }
}
对比项PythonJava
声明依赖参数类型里写 Depends构造函数参数
提供依赖普通函数,不用注册@Component 或 @Bean
解析时机每次请求时解析启动时创建好
默认作用域一次请求内同一个依赖只运行一次单例
依赖的来源可以直接读取当前请求来自容器

Depends 比 Spring 和 Hilt 都轻,本质是「处理请求前先调用这个函数」。Annotated 起个别名后,多个接口写起来很短。

依赖函数里用 yield 代替 return,yield 之后的代码会在响应发送完之后运行,用来关闭连接之类的资源。后面讲数据库时会用到这个写法。

Python
def get_connection():
    print("打开连接")
    try:
        yield "connection"
    finally:
        print("关闭连接")

@app.get("/ping")
def ping(connection: Annotated[str, Depends(get_connection)]):
    print("处理请求")
    return {"ok": True}
输出
打开连接
处理请求
关闭连接

async def 还是 def

FastAPI 同时支持两种写法,它们的运行位置不同:

  • async def 直接运行在事件循环上。事件循环是单线程的,所以函数里只能 await,不能阻塞。
  • def 被放到线程池里运行,阻塞也不会影响其他请求。

事件循环可以类比成 Android 的主线程。在主线程上做耗时操作会卡住界面,在 async def 里调用阻塞函数会卡住所有请求。Spring MVC 每个请求占一个线程,更接近这里的 def,WebFlux 才接近 async def。

不要阻塞事件循环

Python
import asyncio
import time

from fastapi import FastAPI

app = FastAPI()

@app.get("/good")
async def good():
    await asyncio.sleep(0.01)  # 挂起,事件循环去处理别的请求
    return {"ok": True}

@app.get("/bad")
async def bad():
    time.sleep(0.01)  # 阻塞,所有请求都要等它
    return {"ok": True}

@app.get("/fine")
def fine():
    time.sleep(0.01)  # 在线程池里运行,不影响别的请求
    return {"ok": True}
// WebFlux 运行在事件循环上
@GetMapping("/good")
Mono<String> good() {
    return Mono.delay(Duration.ofMillis(10))  // 不占线程
        .thenReturn("ok");
}

@GetMapping("/bad")
Mono<String> bad() throws InterruptedException {
    Thread.sleep(10);  // 阻塞,所有请求都要等它
    return Mono.just("ok");
}

@GetMapping("/fine")
Mono<String> fine() {
    return Mono.fromCallable(() -> {
        Thread.sleep(10);  // 放到线程池,不影响别的请求
        return "ok";
    }).subscribeOn(Schedulers.boundedElastic());
}
对比项PythonJava
可挂起的函数async def返回 Mono 的方法
挂起点await操作符链
单线程调度器事件循环Netty 事件循环
阻塞代码放哪写成 def,由框架放进线程池subscribeOn(boundedElastic())

所有调用都支持 await 时写 async def,用到阻塞的库时写 def,拿不准也写 def。最危险的写法是在 async def 里调用阻塞函数。

调用外部 HTTP 接口时,requests 是阻塞的,在 async def 里要换成 httpx.AsyncClient。事件循环的基础知识见 Python 语言部分的「异步编程和异步生成器」。

拆分路由

接口多了以后,用 APIRouter 按业务拆到不同文件,作用类似把 Retrofit 接口拆成多个 interface。

Python
from fastapi import APIRouter

users = APIRouter(prefix="/users", tags=["users"])

@users.get("/{user_id}")
def get_user(user_id: int):
    return {"id": user_id}

app.include_router(users, prefix="/api")

client.get("/api/users/7").json()  # => {'id': 7}

启动和关闭

有些资源只该创建一次,比如 HTTP 客户端或数据库连接池。把它们写进 lifespan,yield 之前的代码在服务启动时运行,之后的代码在关闭时运行,作用类似 Application.onCreate。

Python
from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.greeting = "你好"  # 启动时运行
    yield
    print("服务关闭")  # 关闭时运行

app = FastAPI(lifespan=lifespan)

@app.get("/hello")
def hello(request: Request):
    return {"message": request.app.state.greeting}

with TestClient(app) as client:
    print(client.get("/hello").json())
输出
{'message': '你好'}
服务关闭

TestClient 要写在 with 里,lifespan 才会运行。

测试

TestClient 配合 pytest 就是接口测试。pytest 要单独安装,测试函数以 test_ 开头,用普通的 assert 判断。

要替换依赖时,用 dependency_overrides 指定一个假的函数,接口代码不用改动。

替换依赖来测试

Python
def current_user() -> str:
    raise RuntimeError("测试里不该调用真实的认证")

@app.get("/me")
def read_me(user: Annotated[str, Depends(current_user)]):
    return {"user": user}

def test_read_me():
    app.dependency_overrides[current_user] = lambda: "tester"
    client = TestClient(app)
    assert client.get("/me").json() == {"user": "tester"}
    app.dependency_overrides.clear()
@WebMvcTest(MeController.class)
class MeControllerTest {
    @Autowired MockMvc mvc;
    @MockitoBean TokenStore tokens;

    @Test
    void readsUser() throws Exception {
        when(tokens.userOf("t")).thenReturn("tester");
        mvc.perform(get("/me").header("X-Token", "t"))
            .andExpect(jsonPath("$.user").value("tester"));
    }
}
对比项PythonJava
测试框架pytestJUnit
断言assert 语句assertEquals 等函数
发现测试函数名以 test_ 开头@Test 注解
替换依赖app.dependency_overrides@MockitoBean

记得在测试结束时清空 dependency_overrides,否则替换会影响后面的测试。

FastAPI、Spring Boot 和 Android 写法对照
PythonJava说明
@app.get("/users/{id}")@GetMappingRetrofit 在客户端,另外两个在服务端
Query(ge=1)@Min(1)Retrofit 不校验
BaseModelrecordPydantic 在运行时校验
HTTPExceptionResponseStatusExceptionRetrofit 的异常由客户端捕获
Depends构造函数注入Depends 每次请求时解析
async defWebFlux 的 Mono都不能在里面阻塞线程
lifespan@PostConstruct、@PreDestroylifespan 把启动和关闭写在一起
TestClientMockMvcMockWebServer 模拟的是服务端

本章要点

  • FastAPI 用函数签名声明接口:路径参数、查询参数和请求体都靠类型注解区分
  • 入参和出参分别用 Pydantic 模型,response_model 负责过滤字段
  • HTTPException 返回预期的错误,校验失败自动返回 422
  • Depends 在处理请求前运行函数,yield 之后的代码负责清理
  • async def 里不能阻塞,用到阻塞的库时写 def
  • 只创建一次的资源放进 lifespan,测试时用 TestClient 和 dependency_overrides

参考资料:第一步请求体依赖项并发和 async生命周期事件测试