1 前言

其实一开始我是会这个知识点的,只是太久没用过了。大二上之后就没怎么正经写过代码了,所以已经忘得差不多了,刚好趁着写博客把这些东西都捡起来。

2 整体链路

这一篇文章先只写前端到 Router 里面的方法匹配,剩下的部分放到后两篇文章里面再说。

整体链路如下:

按下前端的按钮
↓
监听器监听到 submit 事件
↓
触发 fetch,fetch 发送 HTTP 请求到后端
↓
FastAPI 根据请求方法和路由匹配 Router 里的处理函数

3 前端的按钮

这个网站是一个非常简单的、用来帮助理解的小工程,我之后会把这个压缩包放在可以下载的地方。

先看一下这个网页:

FastAPI 请求全链路 Demo 页面

接下来看到前端对应的网页,也就是:

static/index.html

这里面和这一篇有关的关键代码主要是下面这些。

<button type="submit">创建文章</button>
const form = document.querySelector('#article-form');
const requestPreview = document.querySelector('#request-preview');
const responsePreview = document.querySelector('#response-preview');
const articlesPreview = document.querySelector('#articles');

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  const body = {
    title: document.querySelector('#title').value,
    content: document.querySelector('#content').value,
  };

  requestPreview.textContent =
    `POST /api/articles\n\n${JSON.stringify(body, null, 2)}`;
  responsePreview.textContent = '请求中...';

  const response = await fetch('/api/articles', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });

  const data = await response.json();

  responsePreview.textContent =
    `${response.status} ${response.statusText}\n\n${JSON.stringify(data, null, 2)}`;
});

document.querySelector('#reload').addEventListener('click', async () => {
  const response = await fetch('/api/articles');
  const data = await response.json();

  articlesPreview.textContent = JSON.stringify(data, null, 2);
});

3.1 submit 是什么

把注意力放到这一段代码上:

<button type="submit">创建文章</button>

type="submit" 表示这个按钮是表单的“提交按钮”。

点击它时,会触发所在 <form> 的 submit 事件。

3.2 事件监听器

再看这一段:

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  const body = {
    title: document.querySelector('#title').value,
    content: document.querySelector('#content').value,
  };

第一行的意思是监听 submit 这个事件。

如果表单触发了 submit,就执行后面的这个函数。

3.2.1 event.preventDefault()

这一句话比较关键。

浏览器原本对表单 submit 有自己的默认提交行为,可能导致页面跳转或者刷新。

这里调用:

event.preventDefault();

就是阻止浏览器执行默认行为,然后改由我们自己的 JavaScript 使用 fetch() 来发送请求。

所以这里实际发生的是:

点击“创建文章”
↓
button type="submit"
↓
<form> 触发 submit
↓
JavaScript 监听到 submit
↓
preventDefault() 阻止默认提交
↓
继续执行我们自己写的逻辑

3.3 fetch 是什么

const response = await fetch('/api/articles', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(body),
});

这里的 fetch 就是真正发送 HTTP 请求的部分。

几个参数分别表示:

method: 'POST'
→ HTTP 请求方法

Content-Type: application/json
→ 告诉后端,请求正文使用 JSON

body: JSON.stringify(body)
→ 把 JavaScript 对象转换成 JSON 字符串,
  放进 HTTP Request Body

3.3.1 body 是怎么来的

在发送请求之前,JavaScript 会先从页面输入框里读取内容:

title: document.querySelector('#title').value,
content: document.querySelector('#content').value,

这两个值就是从 DOM 里面读取出来的,也就是页面上标题和正文输入框里的内容。

然后组成一个 JavaScript 对象:

const body = {
  title: document.querySelector('#title').value,
  content: document.querySelector('#content').value,
};

再通过:

JSON.stringify(body)

转换成 JSON 字符串,放进 HTTP Request Body。

所以这一小段链路可以理解成:

页面输入框
↓
document.querySelector(...).value
↓
JavaScript 对象 body
↓
JSON.stringify(body)
↓
HTTP Request Body

3.3.2 fetch 请求的路由

可以看到这里请求的是:

/api/articles

这一部分涉及后端具体方法的匹配,留到第 4 节里面再讲,这里先留个印象。

4 Router

4.1 理解 Router 的作用

接下来看到:

app/routers/articles.py

这个文件就是我们接下来要讲的 Router。

完整代码如下:

"""HTTP routing layer for article endpoints."""

from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session

from .. import schemas, services
from ..database import get_db

router = APIRouter(
    prefix="/api/articles",
    tags=["articles"],
)


@router.post(
    "",
    response_model=schemas.ArticleResponse,
    status_code=status.HTTP_201_CREATED,
)
def create_article(
    payload: schemas.ArticleCreate,
    db: Session = Depends(get_db),
):
    return services.create_article(db=db, payload=payload)


@router.get(
    "",
    response_model=list[schemas.ArticleResponse],
)
def get_articles(
    db: Session = Depends(get_db),
):
    return services.list_articles(db)

用比较简单的话来说,APIRouter 可以把一组相关接口组织在一起。

这里:

router = APIRouter(
    prefix="/api/articles",
    tags=["articles"],
)

通过:

prefix="/api/articles"

给这一组文章相关接口统一加上:

/api/articles

这个路径前缀。

顺便说一下:

tags=["articles"]

主要是用来在 Swagger 这类接口文档页面里做分类显示,在这里不参与请求匹配。

4.1.1 Router 想要使用,需要先在 main 里面注册

app.include_router(articles_router)

这个语句就是把 articles_router 注册到主 FastAPI 应用。

如果没有注册,这组 Router 就不会真正成为主应用的一部分,对应的请求也就匹配不到这里。

4.2 Router 里面的两个接口

这个 Router 里面有两个接口,一个是 POST,一个是 GET。

为了方便看清楚结构,分开来看。

4.2.1 POST 接口

@router.post(
    "",
    response_model=schemas.ArticleResponse,
    status_code=status.HTTP_201_CREATED,
)
def create_article(
    payload: schemas.ArticleCreate,
    db: Session = Depends(get_db),
):
    return services.create_article(db=db, payload=payload)

最上面的:

@router.post("")

说明下面这个函数处理的是一个 POST 请求。

其中:

""

表示这个接口在公共前缀 /api/articles 的基础上,没有再增加额外子路径。

所以完整路径还是:

/api/articles

也就是说:

POST /api/articles
→ create_article()

如果以后写成:

@router.post("/draft")

那么完整路径就是:

/api/articles/draft

也就是:

POST /api/articles/draft

再看下面两项:

response_model=schemas.ArticleResponse
status_code=status.HTTP_201_CREATED

它们分别表示:

response_model
→ 规定响应数据结构

status_code
→ 规定成功响应的 HTTP 状态码

例如:

ArticleResponse
→ 返回的数据应该符合这个结构

201 Created
→ 告诉客户端资源创建成功

接下来是:

def create_article(
    payload: schemas.ArticleCreate,
    db: Session = Depends(get_db),
):
    return services.create_article(db=db, payload=payload)

这一段定义了具体的接口处理函数。

Router 本身更多负责 HTTP 层的组织和转发:

  • 通过 Schema 的类型标注描述请求和响应数据;
  • 通过 Depends(get_db) 获取数据库 Session;
  • 再把真正的业务处理交给 Service。

至于 ArticleCreate、Depends(get_db) 和 Service 里面到底发生了什么,就留到下一篇继续看。

4.2.2 GET 接口

@router.get(
    "",
    response_model=list[schemas.ArticleResponse],
)
def get_articles(
    db: Session = Depends(get_db),
):
    return services.list_articles(db)

这里的路径也是:

/api/articles

但是 HTTP 方法变成了:

GET

所以它和前面的 POST 接口虽然 URL 一样,却可以对应不同的处理函数。

4.3 FastAPI 如何把请求匹配到具体处理函数

FastAPI 主要根据两个信息判断请求应该进入哪个处理函数:

HTTP Method
+
完整 URL 路径

例如这个 Demo 里:

GET  /api/articles
→ get_articles()

POST /api/articles
→ create_article()

虽然它们的路径都是:

/api/articles

但是 HTTP 方法不同:

GET
POST

所以 FastAPI 可以判断应该执行哪个处理函数。

至于 FastAPI 内部具体是怎么完成路由匹配的,这一篇就先不往下钻了。

4.4 用浏览器直接访问 /api/articles

直接在浏览器地址栏访问:

http://127.0.0.1:8000/api/articles

可以看到之前创建过的文章数据:

浏览器直接访问 GET /api/articles

回头再看 GET 接口:

@router.get(
    "",
    response_model=list[schemas.ArticleResponse],
)
def get_articles(
    db: Session = Depends(get_db),
):
    return services.list_articles(db)

这其实就是触发了这个 GET 接口的结果。

之所以可以直接在浏览器地址栏里访问,是因为浏览器本身也是一个 HTTP 客户端。

直接输入一个网址访问时,浏览器会发出 GET 请求,所以:

GET /api/articles

就会匹配到:

get_articles()

因此可以直接看到这个接口返回的数据。

这也说明:

浏览器地址栏比较适合直接测试 GET 接口。

如果想发送 POST、DELETE,或者需要携带自定义 Request Body,就需要使用 fetch、Swagger、Postman 等方式构造请求。

5 总结

到这里,一次请求的前半程已经比较清楚了:

用户点击“创建文章”
↓
<form> 触发 submit
↓
JavaScript 捕获事件
↓
收集 title / content
↓
组成 JavaScript 对象 body
↓
JSON.stringify(body)
↓
fetch 构造 HTTP POST 请求
↓
POST /api/articles
↓
FastAPI 根据 Method + Path
↓
匹配 create_article()

到这里,请求已经成功从浏览器进入了 FastAPI 对应的 Router 函数。

但是 Router 里面还有两个看起来有点“凭空出现”的东西:

payload: ArticleCreate

为什么能直接拿到前端发来的 JSON?

还有:

db: Session = Depends(get_db)

这个 db 又是从哪里来的?

这些就是下一篇继续要看的内容。