|
|
@@ -1,1606 +1,1945 @@
|
|
|
-[
|
|
|
- {
|
|
|
- "id": "01",
|
|
|
- "group": "起步与 HTTP",
|
|
|
- "title": "启动第一个 FastAPI 服务",
|
|
|
- "summary": "建立 api-lab 项目,让 /health 返回 JSON,并通过交互文档发出第一次请求。",
|
|
|
- "objectives": [
|
|
|
- "为项目创建独立的 Python 环境",
|
|
|
- "解释 main:app、端口与 --reload 的作用",
|
|
|
- "分别在浏览器和 /docs 验证同一个接口"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "服务进程与路由函数",
|
|
|
- "body": "普通脚本运行结束后会退出。API 服务由 Uvicorn 持续监听本机端口,收到 HTTP 请求后调用 FastAPI 中匹配的方法与路径。@app.get('/health') 注册的是 GET /health;函数的返回值会被序列化为 JSON 响应。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "虚拟环境决定从哪里导入依赖",
|
|
|
- "body": "同一台电脑可以有多个 Python。虚拟环境把当前项目的解释器入口和第三方包放在 .venv 中。用 python -m pip 安装,能使 pip 与当前 python 指向同一环境;编辑器也应选择 .venv 中的解释器。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "开发服务器的几个参数",
|
|
|
- "body": "main:app 表示从当前目录的 main.py 导入 app 对象。127.0.0.1 是本机地址,8000 是默认端口。--reload 会在代码变化后重启开发进程,重启也会清空之后课程中的内存数据。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "建立目录和虚拟环境",
|
|
|
- "body": "准备 Python 3.11 或更新版本。以下两组命令按系统选择一组。激活只影响当前终端;以后重新打开终端时,要先切换到 api-lab 再激活。",
|
|
|
- "lang": "bash",
|
|
|
- "code": "# Windows PowerShell\nmkdir api-lab\ncd api-lab\npy -3 -m venv .venv\n.\\.venv\\Scripts\\Activate.ps1\n\n# macOS / Linux\nmkdir api-lab\ncd api-lab\npython3 -m venv .venv\nsource .venv/bin/activate\n\n# 激活后检查解释器路径,结果应包含 api-lab/.venv\npython -c \"import sys; print(sys.executable)\""
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "安装依赖并记录环境",
|
|
|
- "body": "创建 requirements.txt,写入下面三行。然后在激活后的终端运行 python -m pip install -r requirements.txt。本模块使用 Pydantic v2 的 model_dump() 写法。安装成功后运行 python -m pip freeze > requirements-lock.txt,保留当前环境实际使用的版本;另一个环境可从这个锁定文件安装。 如果直接运行下载包,请使用包内已经锁定版本的 requirements.txt。",
|
|
|
- "filename": "requirements.txt",
|
|
|
- "lang": "bash",
|
|
|
- "code": "fastapi>=0.115,<1\nuvicorn[standard]>=0.30,<1\npydantic>=2,<3"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "编写健康检查接口",
|
|
|
- "body": "在 api-lab 根目录创建 main.py。FastAPI 实例只创建一次。路由装饰器紧接在函数定义上方,返回值使用 Python 字典,JSON 编码由框架完成。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "from fastapi import FastAPI\n\napp = FastAPI(title=\"API Lab\", version=\"0.1.0\")\n\n@app.get(\"/health\", tags=[\"system\"])\ndef health():\n return {\"status\": \"ok\"}"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "启动服务并观察终端",
|
|
|
- "body": "在 main.py 所在目录运行以下命令。终端保持运行状态是正常现象;看到 Application startup complete 后再访问浏览器。服务运行期间用另一个终端执行请求,停止服务使用 Ctrl+C。",
|
|
|
- "lang": "bash",
|
|
|
- "code": "python -m uvicorn main:app --reload\n# 默认访问地址:http://127.0.0.1:8000\n# 8000 被占用时,可加 --port 8001"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "通过浏览器和交互文档验收",
|
|
|
- "body": "先打开 http://127.0.0.1:8000/health,确认显示 {\"status\":\"ok\"}。再打开 /docs,展开 GET /health → Try it out → Execute,查看状态码、响应头和响应体。最后把 main.py 中的 ok 改为 ready 并保存,观察终端重载日志和新响应,完成后改回 ok。",
|
|
|
- "lang": "http",
|
|
|
- "code": "GET /health HTTP/1.1\nHost: 127.0.0.1:8000\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"status\":\"ok\"}"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "终端的 sys.executable 路径包含 api-lab/.venv。",
|
|
|
- "GET /health 返回 200 和 {\"status\":\"ok\"};访问未定义的 / 返回 404,这不表示服务未启动。",
|
|
|
- "/docs 中出现 GET /health,点击 Execute 后能看到真实响应。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "ModuleNotFoundError: No module named 'fastapi'",
|
|
|
- "cause": "安装依赖和启动服务使用了不同解释器。",
|
|
|
- "fix": "激活 .venv,检查 sys.executable,再执行 python -m pip install -r requirements.txt。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "Could not import module 'main'",
|
|
|
- "cause": "当前目录没有 main.py,或者文件中存在语法错误。",
|
|
|
- "fix": "切换到 api-lab 根目录,确认文件名为 main.py,阅读终端中最早出现的 Python 错误。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "PowerShell 阻止 Activate.ps1 运行",
|
|
|
- "cause": "当前执行策略限制脚本运行。",
|
|
|
- "fix": "无需修改全局策略,可直接使用 .\\.venv\\Scripts\\python.exe -m pip install -r requirements.txt,以及 .\\.venv\\Scripts\\python.exe -m uvicorn main:app --reload。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "添加 GET /version,返回 {\"service\":\"api-lab\",\"version\":\"0.1.0\"},并在 /docs 中执行一次。",
|
|
|
- "hint": "保留已有 app,在另一个函数上方注册新的 GET 路由。",
|
|
|
- "answer": "在 main.py 末尾添加:\n@app.get('/version')\ndef version():\n return {'service': 'api-lab', 'version': '0.1.0'}\n\n保存后访问 /version,应返回 200;刷新 /docs 后应同时看到 /health 与 /version。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "Python:venv",
|
|
|
- "url": "https://docs.python.org/3/library/venv.html"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:First Steps",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/first-steps/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:手动运行服务器",
|
|
|
- "url": "https://fastapi.tiangolo.com/deployment/manually/"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "02",
|
|
|
- "group": "起步与 HTTP",
|
|
|
- "title": "读懂一次 HTTP 请求与响应",
|
|
|
- "summary": "拆开方法、路径、查询参数、请求头和请求体,亲手发送 GET 与 POST。",
|
|
|
- "demo": "http",
|
|
|
- "objectives": [
|
|
|
- "在请求中指出方法、URL、请求头和请求体的位置",
|
|
|
- "区分 Content-Type、Accept 与 HTTP 状态码",
|
|
|
- "用 /docs 和命令行复现同一条请求"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "请求由几个独立部分组成",
|
|
|
- "body": "以 POST /notes?source=lesson 为例:POST 是方法,/notes 是路径,source=lesson 是查询参数。请求头携带内容类型等元信息,请求体携带笔记数据。下面的 HTTP/1.1 文本用于辨认字段;浏览器也可能使用其他 HTTP 版本,业务含义一致。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "Content-Type 描述实际内容",
|
|
|
- "body": "请求头 Content-Type: application/json 表示请求体按 JSON 编码;Accept: application/json 表示客户端期望的响应格式。服务器仍按接口定义产生响应,并非只改 Accept 就会自动得到另一种格式。JSON 键和字符串使用双引号,布尔值使用 true / false。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "响应状态与业务字段分开读取",
|
|
|
- "body": "响应先给出 HTTP 状态码,再给出响应头和响应体。200 表示本次 HTTP 操作成功,422 表示请求数据未通过当前接口校验。把 {\"code\":404} 放在一个 200 响应里,不会把 HTTP 状态变成 404。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "观察最小 GET 请求",
|
|
|
- "body": "保持第 01 课服务运行。在 /docs 执行 /health,同时查看本页的请求拆解演示。把方法、路径、Host 和响应体分别定位;GET /health 不需要请求体。",
|
|
|
- "lang": "http",
|
|
|
- "code": "GET /health HTTP/1.1\nHost: 127.0.0.1:8000\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"status\":\"ok\"}"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "添加临时的 POST 回显接口",
|
|
|
- "body": "把以下代码追加到 main.py。这个临时接口只读取请求体并返回,不保存笔记,也不宣称创建成功。第 03 课会用带类型的模型替换它。这里先借助 Request 观察 HTTP 数据,异步语法在后续课程集中练习。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "from fastapi import Request\n\n@app.post(\"/notes\")\nasync def inspect_note(request: Request):\n data = await request.json()\n return {\"received\": data}"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "准备 JSON 文件并发送 POST",
|
|
|
- "body": "在 api-lab 中创建 note.json,使用 UTF-8 保存。macOS / Linux 执行 curl -i -X POST http://127.0.0.1:8000/notes -H \"Content-Type: application/json\" --data-binary @note.json;Windows PowerShell 使用 curl.exe,并把 @note.json 写成引号包围的 \"@note.json\"。-i 同时显示响应头。",
|
|
|
- "filename": "note.json",
|
|
|
- "lang": "json",
|
|
|
- "code": "{\n \"title\": \"first note\",\n \"content\": \"learn HTTP\",\n \"pinned\": false\n}"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "对照原始请求与服务器结果",
|
|
|
- "body": "命令行应返回 200 和 received 对象。改动 note.json 中的 title 后再次发送,确认响应随之变化。在浏览器开发者工具 Network 中展开 /docs 发出的请求,也能查看 Method、Request Headers、Payload 和 Response。",
|
|
|
- "lang": "http",
|
|
|
- "code": "POST /notes HTTP/1.1\nHost: 127.0.0.1:8000\nContent-Type: application/json\n\n{\"title\":\"first note\",\"content\":\"learn HTTP\",\"pinned\":false}\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"received\":{\"title\":\"first note\",\"content\":\"learn HTTP\",\"pinned\":false}}"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "能说明查询参数属于 URL,请求体位于请求头之后,两者不是同一块数据。",
|
|
|
- "命令行 POST 返回 200,并在 received 中保留 note.json 的三个字段。",
|
|
|
- "浏览器直接打开 /notes 会发出 GET;当前只注册了 POST,因此应返回 405 Method Not Allowed。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "PowerShell 提示无法识别 -H 参数",
|
|
|
- "cause": "使用了旧版 PowerShell 的 curl 别名。",
|
|
|
- "fix": "明确执行 curl.exe;或者在 /docs 中操作。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "请求体解析失败",
|
|
|
- "cause": "把 Python 字典写法当成 JSON,例如使用单引号或 False。",
|
|
|
- "fix": "使用 note.json 文件,字符串和字段名用双引号,布尔值写 true / false。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "浏览器地址栏打不开 POST 接口",
|
|
|
- "cause": "地址栏导航通常发送 GET,没有发送预期的 POST 和 JSON 请求体。",
|
|
|
- "fix": "使用 curl、/docs 或能够配置 HTTP 方法的客户端。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "把请求体的 title 改成 second note,再发送一次。记录响应的 HTTP 状态码、Content-Type 和 received.title;最后说明 Accept 和 Content-Type 的区别。",
|
|
|
- "hint": "使用 -i 查看响应头,不要只看最后一行 JSON。",
|
|
|
- "answer": "状态码为 200;响应 Content-Type 为 application/json;received.title 为 second note。请求 Content-Type 描述这次发送的数据格式,Accept 描述客户端希望接收的格式。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "RFC 9110:HTTP 消息与内容语义",
|
|
|
- "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-6"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "RFC 8259:JSON 语法",
|
|
|
- "url": "https://www.rfc-editor.org/rfc/rfc8259"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:直接使用 Request",
|
|
|
- "url": "https://fastapi.tiangolo.com/advanced/using-request-directly/"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "03",
|
|
|
- "group": "起步与 HTTP",
|
|
|
- "title": "从路径、查询参数和 JSON 中接收数据",
|
|
|
- "summary": "为笔记接口声明参数类型,让 FastAPI 完成解析、默认值填充和基础校验。",
|
|
|
- "objectives": [
|
|
|
- "将 note_id 放在路径中,将筛选条件放在查询参数中",
|
|
|
- "用 Pydantic 模型接收 JSON 请求体",
|
|
|
- "通过 422 响应定位错误参数"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "路径标识对象,查询参数控制读取方式",
|
|
|
- "body": "/notes/12 中的 12 表示要操作的笔记;/notes?limit=10&pinned=true 中的参数表示读取条件。参数名出现在路由路径中时,FastAPI 将其识别为路径参数。未出现在路径中的简单类型参数通常来自查询字符串。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "类型声明参与运行时解析",
|
|
|
- "body": "HTTP 路径和查询字符串首先都是文本。note_id: int 要求它能解析为整数;limit: int = 10 提供默认值。无法转换的值会在进入路由函数前产生 422 响应,因此函数里不用重复写 int() 和大量类型判断。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "请求模型决定必填与默认值",
|
|
|
- "body": "继承 BaseModel 的 NoteCreate 声明 JSON 对象的结构。title 没有默认值,所以必须发送;content 默认空字符串,pinned 默认 false。str | None 表示允许 null,不自动表示可以省略;若要允许省略,还需写默认值,例如 = None。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "声明创建笔记的输入模型",
|
|
|
- "body": "在 main.py 的路由定义之前添加模型。删除第 02 课的 inspect_note 路由函数及其装饰器,随后用第 04 步的新 POST /notes 替换,避免同一方法和路径出现多个处理函数。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "from pydantic import BaseModel\n\nclass NoteCreate(BaseModel):\n title: str\n content: str = \"\"\n pinned: bool = False"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "添加路径参数",
|
|
|
- "body": "添加 GET /notes/{note_id}。本节先回显解析后的编号,暂不查询存储。依次请求 /notes/12 与 /notes/abc,比较状态码;整数解析失败时,错误位置会包含 path 和 note_id。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "@app.get(\"/notes/{note_id}\")\ndef inspect_note_id(note_id: int):\n return {\"note_id\": note_id}"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "添加查询参数和范围",
|
|
|
- "body": "添加 GET /notes。Annotated 把 Python 类型和 FastAPI 的参数规则写在一起。limit 默认为 10,只允许 1 到 100;pinned 未传时为 None,传 false 时是明确的 False,后续筛选中要区分这两种情况。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "from typing import Annotated\nfrom fastapi import Query\n\n@app.get(\"/notes\")\ndef inspect_filters(\n limit: Annotated[int, Query(ge=1, le=100)] = 10,\n pinned: bool | None = None,\n):\n return {\"limit\": limit, \"pinned\": pinned}"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "用类型模型替换原始请求体",
|
|
|
- "body": "定义新的 POST /notes。在 /docs 中仅发送 {\"title\":\"first note\"},观察缺省字段被补齐。本节仍是参数练习,不写入存储。model_dump() 把 Pydantic 模型转换为 Python 字典。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "@app.post(\"/notes\")\ndef inspect_note_body(note: NoteCreate):\n return note.model_dump()"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "建立有效值和无效值对照",
|
|
|
- "body": "在 /docs 连续完成以下四次调用,记录状态码与响应中的字段位置。缺少 title 的 POST 应在路由运行前失败。下一课会继续收紧 title 的长度与多余字段规则。",
|
|
|
- "lang": "http",
|
|
|
- "code": "GET /notes/12\n# 200: {\"note_id\":12}\n\nGET /notes?limit=5&pinned=false\n# 200: {\"limit\":5,\"pinned\":false}\n\nGET /notes?limit=0\n# 422,错误位置包含 [\"query\",\"limit\"]\n\nPOST /notes\nContent-Type: application/json\n\n{}\n# 422,错误位置包含 [\"body\",\"title\"]"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "GET /notes/12 返回数字 12,/notes/abc 返回 422。",
|
|
|
- "GET /notes 返回 {\"limit\":10,\"pinned\":null};?pinned=false 返回 false。",
|
|
|
- "POST /notes 只传 title 时补齐 content 和 pinned;缺少 title 返回 422。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "把 note_id 放进查询字符串,得到列表而不是单条笔记",
|
|
|
- "cause": "GET /notes?note_id=12 匹配的是 GET /notes。本课列表路由没有声明 note_id,FastAPI 默认忽略这个额外查询参数,因此返回 200 和列表;它不会匹配 /notes/{note_id}。",
|
|
|
- "fix": "读取单条笔记时改为 GET /notes/12。先检查方法和路径,再检查查询参数;只有确实没有匹配路由或资源不存在时才会得到相应的 404。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "写了 title: str | None 后,省略 title 仍然报错",
|
|
|
- "cause": "允许 null 和允许省略是两个条件。",
|
|
|
- "fix": "如果业务允许省略,显式写默认值 = None;本例 title 应保持必填字符串。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "修改 POST /notes 后响应还像上一课",
|
|
|
- "cause": "旧路由没有移除,重复注册了相同方法和路径。",
|
|
|
- "fix": "搜索 @app.post('/notes'),只保留当前一个定义,保存并确认服务完成重载。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "为 GET /notes 添加 offset 查询参数,默认 0,只允许非负整数,并把解析结果加进响应。验证 offset=3 与 offset=-1。",
|
|
|
- "hint": "参考 limit 的 Annotated[int, Query(...)],使用 ge=0。",
|
|
|
- "answer": "在函数参数中加入 offset: Annotated[int, Query(ge=0)] = 0,并在返回字典中加入 'offset': offset。?offset=3 返回 200 且 offset 为 3;?offset=-1 返回 422,loc 包含 query、offset。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "FastAPI:路径参数",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/path-params/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:查询参数",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/query-params/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:请求体",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/body/"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "04",
|
|
|
- "group": "起步与 HTTP",
|
|
|
- "title": "用 Pydantic 定义输入和输出契约",
|
|
|
- "summary": "限制标题长度、拒绝拼错的字段,并通过 response_model 控制客户端能看到的数据。",
|
|
|
- "objectives": [
|
|
|
- "用 Field 声明范围,用 extra='forbid' 发现字段拼写错误",
|
|
|
- "分开定义创建输入与接口输出模型",
|
|
|
- "区分请求校验失败与响应校验失败"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "模型把接口规则放到一起",
|
|
|
- "body": "类型描述数据形状,Field 描述长度或数值约束。本例 title 的长度为 1 到 120。extra='forbid' 会拒绝模型中未声明的字段,能及时发现 pinnd 这样的拼写错误;Pydantic 默认会忽略多余字段,因此需要显式配置。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "输入模型与输出模型承担不同职责",
|
|
|
- "body": "创建时客户端只提交 title、content、pinned;id 由服务端产生。NoteOut 在输入字段之外增加 id。response_model 会校验并筛选普通返回数据,未声明的内部字段不会进入响应;这也会出现在 /docs 的响应结构中。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "校验成功不代表全部业务规则成立",
|
|
|
- "body": "长度为 1 的字符串可能只是一个空格,类型与长度规则不会自动决定标题是否有意义。输入校验失败通常返回 422;如果路由返回的数据不符合输出模型,例如漏掉 id,则属于服务端代码错误,默认返回 500,并在终端记录具体原因。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "收紧 NoteCreate 的输入规则",
|
|
|
- "body": "用下面定义替换原来的 NoteCreate,放在路由之前。保留其余路由。随后在 /docs 中分别发送空标题和拼错的 pinnd 字段,确认都得到 422。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "from pydantic import BaseModel, ConfigDict, Field\n\nclass NoteCreate(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n\n title: str = Field(min_length=1, max_length=120)\n content: str = \"\"\n pinned: bool = False"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "增加响应模型",
|
|
|
- "body": "在 NoteCreate 后添加 NoteOut。输出字段单独声明,避免把输入的 extra='forbid' 规则带入本节的输出过滤实验。id 在输出里必填;客户端创建时不能自行指定 id。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "class NoteOut(BaseModel):\n id: int\n title: str\n content: str\n pinned: bool"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "用 response_model 约束 POST 响应",
|
|
|
- "body": "替换 POST /notes 的装饰器和函数。本节的 id=1 仍是演示值,下一阶段才生成真实编号。返回字典故意增加 internal_source,查看 HTTP 响应时该字段应被过滤。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "@app.post(\"/notes\", response_model=NoteOut)\ndef inspect_note_body(note: NoteCreate):\n return {\n \"id\": 1,\n **note.model_dump(),\n \"internal_source\": \"validation-lesson\",\n }"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "分别制造输入与输出错误",
|
|
|
- "body": "先发送下面的空标题请求,确认 422 的错误位置包含 body、title。再临时删除返回字典中的 id,发送正常请求,观察 500 和终端中的响应校验异常。恢复 id 后再继续;不要把服务端输出错误改成客户端的 422。",
|
|
|
- "lang": "json",
|
|
|
- "code": "{\n \"title\": \"\",\n \"content\": \"title must not be empty\",\n \"pinned\": false\n}"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "检查文档中的契约",
|
|
|
- "body": "刷新 /docs。展开 POST /notes 的 Schema,检查 title 的 minLength、maxLength,以及 NoteOut 的 id。发送 {\"title\":\"schema check\"} 后应返回下面四个字段。保存一份响应作为后续 CRUD 的验收样本。",
|
|
|
- "lang": "json",
|
|
|
- "code": "{\n \"id\": 1,\n \"title\": \"schema check\",\n \"content\": \"\",\n \"pinned\": false\n}"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "空 title 或额外 pinnd 字段均返回 422。",
|
|
|
- "正常创建演示返回 id、title、content、pinned 四个字段,不包含 internal_source。",
|
|
|
- "故意漏掉输出 id 时出现 500;恢复后为 200,当前尚未设置创建成功的 201。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "空格标题通过了 min_length=1",
|
|
|
- "cause": "空格也是字符串字符,长度校验不执行去空格。",
|
|
|
- "fix": "根据业务要求加入 strip_whitespace 或字段校验器,去空格后再检查是否为空。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "response_model 没有过滤某个自定义响应",
|
|
|
- "cause": "直接返回 JSONResponse 等 Response 对象时,框架不会按普通返回值继续执行响应模型转换。",
|
|
|
- "fix": "本节返回字典或模型对象,让 FastAPI 使用 response_model 处理。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "把模型直接拼到字典时提示类型不匹配",
|
|
|
- "cause": "Pydantic 模型对象不等于普通字典。",
|
|
|
- "fix": "需要字典时调用 note.model_dump();不要使用旧教材中的 .dict() 作为本模块标准写法。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "拒绝只有空白的标题,并将 ' first note ' 保存为 'first note'。保持 120 字符上限。",
|
|
|
- "hint": "为 title 添加 mode='before' 的 field_validator,先去除字符串两端空白,再交给 Field 长度校验。",
|
|
|
- "answer": "从 pydantic 导入 field_validator,在 NoteCreate 内添加:\n@field_validator('title', mode='before')\n@classmethod\ndef strip_title(cls, value):\n return value.strip() if isinstance(value, str) else value\n\n只含空格会变为空字符串并返回 422;' first note ' 经解析后为 'first note'。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "Pydantic:Field 与字段约束",
|
|
|
- "url": "https://docs.pydantic.dev/latest/concepts/fields/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "Pydantic:验证器",
|
|
|
- "url": "https://docs.pydantic.dev/latest/concepts/validators/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:响应模型",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/response-model/"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "05",
|
|
|
- "group": "REST 接口",
|
|
|
- "title": "设计笔记资源与 HTTP 行为",
|
|
|
- "summary": "确定集合、单条资源、全量替换和删除的接口约定,再用请求验证方法与状态码。",
|
|
|
- "objectives": [
|
|
|
- "用资源路径表达笔记集合和单条笔记",
|
|
|
- "区分 POST、PUT、PATCH、DELETE 的语义",
|
|
|
- "解释 200、201、204、404、405、422 与幂等性"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "先确定资源,再确定操作",
|
|
|
- "body": "本项目使用 /notes 表示笔记集合,/notes/{note_id} 表示一条笔记。读取用 GET,创建用 POST,完整替换用 PUT,删除用 DELETE。方法和路径共同确定接口,因此 GET /notes 与 POST /notes 可以共用路径。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "PUT 的完整替换需要明确输入规则",
|
|
|
- "body": "本课程的 PUT 要求 title、content、pinned 全部出现,替换指定笔记可编辑字段,id 保持不变。PATCH 通常表达局部更新,本模块暂不实现。若把创建模型直接用于 PUT,省略 content 会触发默认空字符串,容易被误认为保留旧值。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "幂等描述重复请求后的目标状态",
|
|
|
- "body": "重复相同 PUT,目标笔记应保持相同内容;重复 DELETE,目标笔记都处于不存在状态,第一次可以返回 204,第二次可以返回 404。幂等不要求每次响应完全相同。POST 创建通常每次产生新资源,不能假设自动重复发送不会多建一条。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "状态码也是接口契约",
|
|
|
- "body": "读取与更新成功使用 200,创建成功使用 201,删除成功且无响应体使用 204。找不到资源使用 404,路径存在但方法不支持使用 405,输入校验失败使用 422。4xx、5xx 响应不能只靠 JSON 业务字段表达,HTTP 状态也必须正确。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "写下接口清单",
|
|
|
- "body": "以下是第 06 课要实现的目标契约。当前服务还处于参数练习阶段,不应把回显结果视为已保存。先在纸上或请求文件中列出方法、路径、输入和成功状态,后续逐项实现。",
|
|
|
- "filename": "notes-contract.http",
|
|
|
- "lang": "http",
|
|
|
- "code": "GET /notes # 查询集合,200,响应为数组\nPOST /notes # 创建,201,响应包含新 id\nGET /notes/{note_id} # 查询单条,200 或 404\nPUT /notes/{note_id} # 完整替换,200 或 404\nDELETE /notes/{note_id} # 删除,204 或 404"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "用已有服务区分 404 与 405",
|
|
|
- "body": "在 /docs 执行 GET /health,应返回 200。然后命令行发送 POST /health,应返回 405,并检查 Allow 响应头;再 GET /missing-path,应返回 404。Windows 将下面 curl 改为 curl.exe。这两类错误都与 JSON 字段校验无关。",
|
|
|
- "lang": "bash",
|
|
|
- "code": "curl -i http://127.0.0.1:8000/health\ncurl -i -X POST http://127.0.0.1:8000/health\ncurl -i http://127.0.0.1:8000/missing-path"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "为完整替换定义独立输入",
|
|
|
- "body": "在 main.py 的模型区添加 NoteReplace。三个字段均无默认值,确保调用方明确提交完整内容。在 Python 终端或单独脚本中使用下面示例验证;此时只定义模型,尚未注册 PUT 路由。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "class NoteReplace(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n\n title: str = Field(min_length=1, max_length=120)\n content: str\n pinned: bool\n\n# 在独立 Python 终端中测试:\n# from main import NoteReplace\n# NoteReplace(title=\"updated\", content=\"\", pinned=False) # 通过\n# NoteReplace(title=\"updated\") # ValidationError:缺少两项"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "准备替换与删除的验收请求",
|
|
|
- "body": "把完整请求体保存为 replace.json。下一课先创建一条笔记,记录真实 id,再执行两次同样 PUT 和两次 DELETE。不要写死假定 id 永远为 1;服务重载会影响当前内存。204 响应后不要继续解析 JSON。",
|
|
|
- "filename": "replace.json",
|
|
|
- "lang": "json",
|
|
|
- "code": "{\n \"title\": \"updated note\",\n \"content\": \"replace all editable fields\",\n \"pinned\": true\n}"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "GET /health 是 200;POST /health 是 405;不存在的路径是 404。",
|
|
|
- "NoteReplace 缺少 content 或 pinned 时验证失败,空 content 和 false 本身都合法。",
|
|
|
- "能解释 DELETE 第二次返回 404 仍然满足幂等,以及 204 为什么不能带 {\"ok\":true}。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "PUT 请求只传 title,意外把 content 清空",
|
|
|
- "cause": "复用了带默认值的创建模型,未明确全量替换规则。",
|
|
|
- "fix": "使用所有可编辑字段均必填的 NoteReplace;如果需要局部更新,应另行设计 PATCH。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "客户端对成功 DELETE 调用 response.json() 后报错",
|
|
|
- "cause": "204 响应没有响应体。",
|
|
|
- "fix": "先判断状态码;遇到 204 直接按成功处理,不执行 JSON 解码。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "网络超时后重复 POST,出现两条笔记",
|
|
|
- "cause": "客户端没收到响应,并不等于服务器没创建资源。",
|
|
|
- "fix": "本地实验先查询列表核对;生产创建接口需根据业务设计幂等键等重复提交控制,不能直接假定 POST 可安全重试。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "评审三个接口:GET /delete-note?id=1、PUT /notes/1 只接收 pinned、DELETE /notes/1 返回 200 和 {\"deleted\":true}。给出本课程约定下的修改方案,并说明第三种设计是否违反 HTTP。",
|
|
|
- "hint": "分清 HTTP 协议允许的行为与课程选择的统一接口约定。",
|
|
|
- "answer": "第一种改为 DELETE /notes/1,GET 不用于用户请求的数据删除。第二种若只做局部修改应定义 PATCH,或者要求 PUT 传齐 title/content/pinned。第三种返回 200 和结果体在 HTTP 中可以成立;本课程统一选择 204 空体,因此需同时调整状态码和响应内容。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "RFC 9110:请求方法",
|
|
|
- "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-9"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "RFC 9110:204 No Content",
|
|
|
- "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.5"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:更新请求体",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/body-updates/"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "06",
|
|
|
- "group": "REST 接口",
|
|
|
- "title": "完成内存版笔记 CRUD",
|
|
|
- "summary": "把接口契约变成可运行程序,按创建、查询、替换、删除的顺序完成一次完整操作。",
|
|
|
- "objectives": [
|
|
|
- "用字典保存笔记并生成不重复的进程内编号",
|
|
|
- "实现带校验与响应模型的笔记 REST 接口",
|
|
|
- "按真实创建结果串联后续请求并验证失败路径"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "请求之间需要共享状态",
|
|
|
- "body": "局部变量在函数调用结束后不用于保存下一次请求的数据。这里用模块级 notes 字典,键为整数 id,值为笔记字段。它只存在于当前 Python 进程内;重启、重载都会清空,多个 worker 也不会共享这份字典。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "编号与列表位置不是同一个概念",
|
|
|
- "body": "使用单独递增的 next_id 分配编号,删除笔记后不回退。不能用 len(notes)+1 生成 id,否则删掉中间一条后可能复用仍存在的编号。客户端应从创建响应读取 id,再构造单条资源路径。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "演示存储的运行条件",
|
|
|
- "body": "本节路由使用 async def,内部没有 await,字典读写在一次事件循环执行段中完成。以单个 Uvicorn worker 运行本地实验;不要在更新共享状态的中间加入等待操作。异步函数与等待的细节将在后面的并发课程中单独演示。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "重建 main.py 的公共部分",
|
|
|
- "body": "先备份前几课的实验文件,再将 main.py 替换为本步骤代码,并按顺序追加第 2、3、4 步。不要同时保留临时回显路由。全部代码完成后再启动;本节不含 /version 等附加练习接口。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "from typing import Annotated\n\nfrom fastapi import FastAPI, HTTPException, Query, Response\nfrom pydantic import BaseModel, ConfigDict, Field\n\napp = FastAPI(title=\"API Lab\")\n\nclass NoteCreate(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n title: str = Field(min_length=1, max_length=120)\n content: str = \"\"\n pinned: bool = False\n\nclass NoteReplace(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n title: str = Field(min_length=1, max_length=120)\n content: str\n pinned: bool\n\nclass NoteOut(BaseModel):\n id: int\n title: str\n content: str\n pinned: bool\n\nnotes: dict[int, dict] = {}\nnext_id = 1\n\n@app.get(\"/health\", tags=[\"system\"])\nasync def health():\n return {\"status\": \"ok\"}"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "实现列表与创建",
|
|
|
- "body": "追加两个集合路由。pinned is not None 保证 false 也会触发筛选。创建成功后返回 201,并通过 Location 响应头给出新资源路径。next_id 在当前进程递增,保存时写入所有输入字段。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "@app.get(\"/notes\", response_model=list[NoteOut], tags=[\"notes\"])\nasync def list_notes(\n limit: Annotated[int, Query(ge=1, le=100)] = 10,\n pinned: bool | None = None,\n):\n rows = list(notes.values())\n if pinned is not None:\n rows = [row for row in rows if row[\"pinned\"] == pinned]\n return rows[:limit]\n\n@app.post(\"/notes\", response_model=NoteOut, status_code=201, tags=[\"notes\"])\nasync def create_note(note: NoteCreate, response: Response):\n global next_id\n note_id = next_id\n next_id += 1\n row = {\"id\": note_id, **note.model_dump()}\n notes[note_id] = row\n response.headers[\"Location\"] = f\"/notes/{note_id}\"\n return row"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "实现单条读取与全量替换",
|
|
|
- "body": "先定义统一查找函数。不存在时 raise HTTPException 会立即中断本次请求,下一课再统一其错误格式。PUT 创建全新的字段字典并保留路径 id,不把旧 content 或 pinned 合并进去。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "def require_note(note_id: int) -> dict:\n if note_id not in notes:\n raise HTTPException(status_code=404, detail=\"笔记不存在\")\n return notes[note_id]\n\n@app.get(\"/notes/{note_id}\", response_model=NoteOut, tags=[\"notes\"])\nasync def get_note(note_id: int):\n return require_note(note_id)\n\n@app.put(\"/notes/{note_id}\", response_model=NoteOut, tags=[\"notes\"])\nasync def replace_note(note_id: int, note: NoteReplace):\n require_note(note_id)\n row = {\"id\": note_id, **note.model_dump()}\n notes[note_id] = row\n return row"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "实现无响应体的删除",
|
|
|
- "body": "删除前先检查是否存在;返回 Response(status_code=204) 明确表示空响应。全部代码写好后运行 python -m uvicorn main:app --reload,打开 /docs,确认一个健康检查加五个笔记操作,共六个路由方法。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "@app.delete(\"/notes/{note_id}\", status_code=204, tags=[\"notes\"])\nasync def delete_note(note_id: int):\n require_note(note_id)\n del notes[note_id]\n return Response(status_code=204)"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "按顺序完成一轮 CRUD",
|
|
|
- "body": "使用 /docs:先 GET /notes 确認 [];POST 下列对象并记下实际 id;GET /notes/{id};PUT 提交 title='updated'、content=''、pinned=true;再 GET 确认内容已替换;DELETE 后再次 GET,确认 404。操作过程中不要修改代码触发重载。最后重新创建一条,检查编号没有因删除而回退。",
|
|
|
- "lang": "json",
|
|
|
- "code": "{\n \"title\": \"first CRUD\",\n \"content\": \"create, read, replace, delete\",\n \"pinned\": false\n}"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "空列表返回 200 和 [];创建返回 201,响应含整数 id,Location 指向 /notes/{id}。",
|
|
|
- "PUT 传齐字段时返回 200,id 保持不变;仅传 title 时返回 422。",
|
|
|
- "删除返回 204 且响应体为空;再 GET 或 DELETE 相同 id 均返回 404。",
|
|
|
- "创建两条并删除其中一条后,再创建不会覆盖另一条;服务重启后列表重新为空。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "保存代码后刚创建的笔记找不到了",
|
|
|
- "cause": "--reload 重启了服务进程,内存字典被重新初始化。",
|
|
|
- "fix": "完成代码编辑后重新准备数据;验证一轮 CRUD 时不要中途保存服务端文件。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "筛选 pinned=false 返回全部数据",
|
|
|
- "cause": "使用 if pinned 判断是否筛选,False 被当成未提供条件。",
|
|
|
- "fix": "使用 if pinned is not None,再比较字段的布尔值。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "删除一条后,新笔记覆盖了旧笔记",
|
|
|
- "cause": "用 len(notes)+1 计算编号导致重复。",
|
|
|
- "fix": "独立维护递增 next_id,不因删除回退;本地练习保持单进程运行。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "在列表接口加入 offset>=0,先按 pinned 筛选,再返回 rows[offset:offset+limit]。创建三条笔记,第二条和第三条设为 pinned=true,验证 pinned=true&offset=1&limit=1 只返回第三条。",
|
|
|
- "hint": "分页应该作用在筛选后的结果上;把切片放在筛选后,limit 和 offset 均添加参数约束。",
|
|
|
- "answer": "添加 offset: Annotated[int, Query(ge=0)] = 0;保持 pinned 筛选顺序;把 return rows[:limit] 改为 return rows[offset:offset + limit]。按题目创建顺序,返回数组长度为 1,元素为第三条笔记;offset=-1 返回 422。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "FastAPI:响应状态码",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/response-status-code/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:直接返回 Response",
|
|
|
- "url": "https://fastapi.tiangolo.com/advanced/response-directly/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:处理错误",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/handling-errors/"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "07",
|
|
|
- "group": "REST 接口",
|
|
|
- "title": "返回可定位、格式一致的错误",
|
|
|
- "summary": "用 HTTPException 表达已知失败,统一 404、405 与 422 的响应结构,并保留字段位置。",
|
|
|
- "objectives": [
|
|
|
- "在业务失败时抛出 HTTPException",
|
|
|
- "为 HTTPException 和 RequestValidationError 注册处理器",
|
|
|
- "保持 HTTP 状态码,同时返回稳定的错误结构"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "已知失败应在对应位置结束请求",
|
|
|
- "body": "查不到笔记是可以预期的业务结果,用 raise HTTPException(status_code=404, detail='笔记不存在') 结束当前处理。return HTTPException(...) 只是在返回一个对象,不能表达相同控制流程。未知编程错误需要修复,不能一律包装成 200。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "统一外层结构,保留不同错误的细节",
|
|
|
- "body": "本项目约定 {error:{code,message,details}}。HTTP 错误使用 code='http_error';请求校验使用 code='validation_error'。HTTP 状态码仍保留 404、405、422 等差异。details 中只返回定位所需的字段路径、错误类型和说明,不回显整个请求体。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "校验失败可能发生在路由执行之前",
|
|
|
- "body": "参数解析失败会抛出 RequestValidationError,不会进入路由里的 try/except。因此在应用级处理器统一处理。注册 Starlette 的 HTTPException 基类还能覆盖路由不存在和方法不支持等框架错误;处理时保留 Allow 等原有响应头。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "确认已有业务错误入口",
|
|
|
- "body": "保留第 06 课代码,找到 require_note。确认它使用 raise 而非 return。请求一个不存在的 id,此时默认响应通常为 {\"detail\":\"笔记不存在\"},接下来统一其外层结构。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "def require_note(note_id: int) -> dict:\n if note_id not in notes:\n raise HTTPException(status_code=404, detail=\"笔记不存在\")\n return notes[note_id]"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "统一 HTTP 错误处理",
|
|
|
- "body": "在 main.py 中添加导入与处理器。这里演示业务 detail 为字符串;若后续传入字典,应单独约定字段映射,不把任意内部对象直接返回。保留 exc.headers,确保例如 405 的 Allow 头仍然存在。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "from fastapi import Request\nfrom fastapi.responses import JSONResponse\nfrom starlette.exceptions import HTTPException as StarletteHTTPException\n\n@app.exception_handler(StarletteHTTPException)\nasync def handle_http_error(request: Request, exc: StarletteHTTPException):\n return JSONResponse(\n status_code=exc.status_code,\n headers=exc.headers,\n content={\n \"error\": {\n \"code\": \"http_error\",\n \"message\": str(exc.detail),\n \"details\": [],\n }\n },\n )"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "统一 422 并保留字段位置",
|
|
|
- "body": "添加请求校验处理器。不要直接把 exc.errors() 中的所有对象交给 JSON 序列化;自定义校验器的 ctx 可能包含异常对象。这里只提取 JSON 可序列化的 loc、type、msg,也避免在响应中回显原始输入。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "from fastapi.exceptions import RequestValidationError\n\n@app.exception_handler(RequestValidationError)\nasync def handle_validation_error(request: Request, exc: RequestValidationError):\n details = [\n {\n \"loc\": list(error[\"loc\"]),\n \"type\": error[\"type\"],\n \"msg\": error[\"msg\"],\n }\n for error in exc.errors()\n ]\n return JSONResponse(\n status_code=422,\n content={\n \"error\": {\n \"code\": \"validation_error\",\n \"message\": \"请求参数不符合接口要求\",\n \"details\": details,\n }\n },\n )"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "用三类失败验证处理器",
|
|
|
- "body": "先 GET /notes/999999,确认 404 且 error.code='http_error'。再 GET /notes/abc 和 POST /notes 的空对象,确认 422;前者定位 path.note_id,后者定位 body.title。最后 POST /health,检查 405、http_error 以及 Allow 头。具体英文 msg 可能随依赖版本变化,客户端不要解析这段文本作业务判断。",
|
|
|
- "lang": "json",
|
|
|
- "code": "{\n \"error\": {\n \"code\": \"validation_error\",\n \"message\": \"请求参数不符合接口要求\",\n \"details\": [\n {\n \"loc\": [\"body\", \"title\"],\n \"type\": \"missing\",\n \"msg\": \"Field required\"\n }\n ]\n }\n}"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "不存在的笔记返回 404、http_error 和“笔记不存在”。",
|
|
|
- "非法 note_id 与缺失 title 都返回 422、validation_error,details.loc 能指出不同位置。",
|
|
|
- "POST /health 仍返回 405,Allow 响应头被保留。",
|
|
|
- "成功的笔记响应不被包在 error 字段中;204 删除仍然没有响应体。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "错误响应有 message,但 HTTP 状态一直是 200",
|
|
|
- "cause": "只返回了错误字典,没有设置 JSONResponse 的 status_code。",
|
|
|
- "fix": "在异常处理器中显式设置对应状态,HTTP 错误使用 exc.status_code。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "注册 HTTPException 处理器后,未知路径仍是默认格式",
|
|
|
- "cause": "只处理 FastAPI 的异常子类,没有覆盖框架抛出的 Starlette 基类异常。",
|
|
|
- "fix": "导入并为 StarletteHTTPException 注册处理器,路由仍可抛出 FastAPI 的 HTTPException。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "自定义标题校验失败后出现 JSON 序列化异常",
|
|
|
- "cause": "把 exc.errors() 的 ctx 异常对象直接放进 JSONResponse。",
|
|
|
- "fix": "只提取 loc、type、msg 等必要字段,不直接返回任意异常对象或原始请求内容。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "对 GET /notes?limit=0、GET /notes/abc、POST /notes 的 {} 逐一记录 status、error.code 和第一条 details.loc,写出客户端应如何定位表单或请求参数错误。",
|
|
|
- "hint": "三次都是 422,但错误位置的来源不同:query、path、body。",
|
|
|
- "answer": "三次 status 都是 422,code 都是 validation_error。loc 分别为 ['query','limit']、['path','note_id']、['body','title']。客户端先按 status/code 判断错误类别,再用 loc 映射对应输入位置;msg 用于显示,不作为稳定的判断条件。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "FastAPI:覆盖异常处理器",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/handling-errors/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "Starlette:Exceptions",
|
|
|
- "url": "https://github.com/Kludex/starlette/blob/main/docs/exceptions.md"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "Pydantic:错误处理",
|
|
|
- "url": "https://docs.pydantic.dev/latest/errors/errors/"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "08",
|
|
|
- "group": "REST 接口",
|
|
|
- "title": "复用依赖与拆分路由文件",
|
|
|
- "summary": "用 X-API-Key 练习依赖执行顺序,把 /private 放入独立 APIRouter,并通过环境变量配置课堂密钥。",
|
|
|
- "objectives": [
|
|
|
- "用 Depends 在路由执行前完成可复用校验",
|
|
|
- "从请求头读取 X-API-Key,从环境变量读取预期值",
|
|
|
- "用 APIRouter 拆分文件并注册到同一个应用"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "Depends 声明请求处理的前置步骤",
|
|
|
- "body": "FastAPI 收到请求后先解析依赖需要的参数,执行依赖函数,再进入路由。依赖抛出 HTTPException 时,路由不会执行。同一套校验可以挂在多个接口或整个 APIRouter 上,避免每个路由重复读取请求头。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "Header 与查询参数的来源不同",
|
|
|
- "body": "x_api_key: str 这种普通参数默认会作为查询参数。使用 Header(alias='X-API-Key') 才从请求头读取。这里给缺失值默认 None,再由依赖统一返回课堂约定的 401;缺少请求头不会先变成默认必填参数的 422。401 响应携带 WWW-Authenticate: APIKey,说明本实验使用的凭据类型。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "配置值与身份系统分开理解",
|
|
|
- "body": "DEMO_API_KEY 从启动终端的环境变量读取,网页教程中的 classroom-key 仅用于本地实验。这个共享字符串只演示请求校验,不能区分用户,也没有密钥轮换或权限管理。本节只保护 /private,公开的笔记接口用于本地练习。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "APIRouter 负责组织,不会自动启动服务",
|
|
|
- "body": "独立文件创建 router 并注册其路由,主入口再 app.include_router(router)。依赖模块不导入 main,避免循环导入。多个文件最终仍组成同一个 FastAPI 应用,共享第 07 课注册的异常处理器。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "建立依赖函数文件",
|
|
|
- "body": "在 api-lab 根目录新增 dependencies.py,不修改前面的笔记接口。用 secrets.compare_digest 比较字符串。未配置环境变量返回 503,缺失或错误请求头返回 401,这两种失败帮助区分服务配置和调用参数。",
|
|
|
- "filename": "dependencies.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "import os\nimport secrets\nfrom typing import Annotated\n\nfrom fastapi import Header, HTTPException\n\ndef require_demo_key(\n x_api_key: Annotated[str | None, Header(alias=\"X-API-Key\")] = None,\n) -> None:\n expected = os.getenv(\"DEMO_API_KEY\")\n if not expected:\n raise HTTPException(status_code=503, detail=\"服务未配置 DEMO_API_KEY\")\n if x_api_key is None or not secrets.compare_digest(\n x_api_key.encode(\"utf-8\"), expected.encode(\"utf-8\")\n ):\n raise HTTPException(\n status_code=401,\n detail=\"X-API-Key 无效\",\n headers={\"WWW-Authenticate\": \"APIKey\"},\n )"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "创建独立的私有路由",
|
|
|
- "body": "新增 routers 目录,其中创建空的 __init__.py 和下面的 private.py。把依赖挂在 APIRouter 上,当前文件中的接口都会执行相同校验。装饰器使用 @router.get,路径保持 /private。",
|
|
|
- "filename": "routers/private.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "from fastapi import APIRouter, Depends\n\nfrom dependencies import require_demo_key\n\nrouter = APIRouter(\n tags=[\"private\"],\n dependencies=[Depends(require_demo_key)],\n)\n\n@router.get(\"/private\")\ndef read_private():\n return {\"message\": \"API key accepted\"}"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "在主入口注册 router",
|
|
|
- "body": "在 main.py 导入路由模块,并在 app 创建之后调用 include_router。保留原有 /health、笔记路由和两个异常处理器。刷新 /docs,确认出现 private 分组及 X-API-Key 参数;如果没有出现,先检查是否真正注册。",
|
|
|
- "filename": "main.py",
|
|
|
- "lang": "python",
|
|
|
- "code": "from routers import private\n\n# 放在 app = FastAPI(...) 之后\napp.include_router(private.router)\n\n# 目录结构\n# api-lab/\n# main.py\n# dependencies.py\n# routers/\n# __init__.py\n# private.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "设置当前终端环境并重新启动",
|
|
|
- "body": "先 Ctrl+C 停止旧服务,在同一个终端按系统选择一组命令。新启动的进程继承该终端环境;在另一窗口设置变量不会修改正在运行的服务。classroom-key 是公开教学校验值。",
|
|
|
- "lang": "bash",
|
|
|
- "code": "# Windows PowerShell\n$env:DEMO_API_KEY = \"classroom-key\"\npython -m uvicorn main:app --reload\n\n# macOS / Linux\nexport DEMO_API_KEY=\"classroom-key\"\npython -m uvicorn main:app --reload"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "验证缺失、错误与正确密钥",
|
|
|
- "body": "在 /docs 连续执行 GET /private:不填写头部、填写 wrong-key、填写 classroom-key。前两次返回 401,第三次返回 200。第 07 课错误处理器会把失败转换为统一 error 对象。再访问 /notes,确认该本地练习接口未要求密钥。",
|
|
|
- "lang": "bash",
|
|
|
- "code": "# macOS / Linux;Windows PowerShell 使用 curl.exe\ncurl -i http://127.0.0.1:8000/private\ncurl -i http://127.0.0.1:8000/private -H \"X-API-Key: wrong-key\"\ncurl -i http://127.0.0.1:8000/private -H \"X-API-Key: classroom-key\""
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "/docs 显示 /private 和 X-API-Key 请求头输入项。",
|
|
|
- "配置环境变量后,缺失或错误密钥返回 401 和 http_error;正确密钥返回 200 和 {\"message\":\"API key accepted\"}。",
|
|
|
- "未设置 DEMO_API_KEY 的新服务进程返回 503,而不会因为请求头与配置都为空而通过。",
|
|
|
- "拆分文件后 /health、原有笔记 CRUD 与错误结构仍然可用。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "已在客户端写 X-API-Key,却显示密钥无效",
|
|
|
- "cause": "服务读取的头部来源声明不正确,或者客户端把值放到了查询字符串。",
|
|
|
- "fix": "依赖明确使用 Header(alias='X-API-Key');在 Network 或 curl 输出中核对 Request Headers。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "环境变量已设置,接口仍返回 503",
|
|
|
- "cause": "变量设置在另一个终端,或者旧服务进程没有重启。",
|
|
|
- "fix": "在设置变量的同一个终端停止并重启 Uvicorn。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "新增 private.py 后 /private 返回 404",
|
|
|
- "cause": "只创建了 router,没有在主 app 上 include_router,或启动时目录不正确。",
|
|
|
- "fix": "确认 main.py 注册 private.router,并从 api-lab 根目录启动 main:app。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "在 routers/private.py 中新增 GET /private/status,返回 {\"enabled\":true}。复用 router 上的依赖,分别验证无头部与正确头部。再说明为什么这仍不能判断是哪位学生发出的请求。",
|
|
|
- "hint": "只添加 @router.get('/private/status') 的函数,不必复制校验代码。",
|
|
|
- "answer": "添加:\n@router.get('/private/status')\ndef private_status():\n return {'enabled': True}\n\n无 X-API-Key 返回 401;classroom-key 返回 200 和 {'enabled':true}。两个接口自动复用 router 依赖。所有人使用同一公开密钥,服务只能判断是否匹配,无法识别具体用户。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "FastAPI:依赖注入",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/dependencies/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:Header 参数",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/header-params/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:多文件应用",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/bigger-applications/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "Python:compare_digest",
|
|
|
- "url": "https://docs.python.org/3/library/secrets.html#secrets.compare_digest"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "09",
|
|
|
- "group": "异步与调用",
|
|
|
- "title": "使用 async / await 处理 I/O 等待",
|
|
|
- "summary": "用两个并发请求复现阻塞,掌握 async / await 在接口中的实际作用。",
|
|
|
- "objectives": [
|
|
|
- "写出不阻塞事件循环的 /slow 接口",
|
|
|
- "测量串行与并发请求的耗时差异",
|
|
|
- "判断同步 I/O 应改用异步客户端,还是移交线程"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "async def 定义协程函数",
|
|
|
- "body": "调用后得到可等待对象;await 等待结果,并在操作尚未完成时让出执行机会。仅给函数添加 async 不会把内部的阻塞代码变为异步。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "等待与计算分开判断",
|
|
|
- "body": "网络响应、磁盘访问属于 I/O;大量 Python 计算属于 CPU 工作。异步可以交错等待,本课不通过 async 加速计算,也不在接口里引入后台任务队列。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "FastAPI 的执行位置",
|
|
|
- "body": "FastAPI 调用普通 def 路由时使用线程池;async def 路由在事件循环中执行。你在 async def 内直接调用普通辅助函数时,FastAPI 不会自动把辅助函数移到线程。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "增加一个可测量的等待接口",
|
|
|
- "body": "开始前,在已激活的虚拟环境中运行 python -m pip install \"httpx>=0.27,<1\",第 09 节的客户端脚本会导入它。下载包的 requirements.txt 已包含此依赖。\n\n在 api-lab 的应用中加入路由。delay 限制在 0–5 秒,避免误输很大数值;运行单个服务进程进行本课实验。",
|
|
|
- "code": "import asyncio\nfrom fastapi import Query\n\n@app.get(\"/slow\")\nasync def slow(delay: float = Query(default=1, ge=0, le=5)):\n await asyncio.sleep(delay)\n return {\"delay\": delay}",
|
|
|
- "lang": "python",
|
|
|
- "filename": "main.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "同时发出两次请求",
|
|
|
- "body": "新建脚本,保持服务已启动后执行 python concurrent_check.py。先建立一个客户端,再让两次请求同时等待;打印状态码和总耗时。页面演示是浏览器模拟,真实耗时以此脚本为准。",
|
|
|
- "code": "import asyncio\nfrom time import perf_counter\nimport httpx\n\nasync def main():\n async with httpx.AsyncClient(base_url=\"http://127.0.0.1:8000\") as client:\n started = perf_counter()\n results = await asyncio.gather(\n client.get(\"/slow\", params={\"delay\": 1}),\n client.get(\"/slow\", params={\"delay\": 1}),\n )\n print([r.status_code for r in results])\n print(f\"elapsed={perf_counter() - started:.2f}s\")\n\nasyncio.run(main())",
|
|
|
- "lang": "python",
|
|
|
- "filename": "concurrent_check.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "复现阻塞,再恢复",
|
|
|
- "body": "暂时把 await asyncio.sleep(delay) 换成 time.sleep(delay),并 import time。保存后等服务完成重载,再运行相同脚本。单进程时两次等待通常接近 2 秒;恢复 async sleep 后通常接近 1 秒。另开终端访问 /health,观察阻塞时它也要等待。结果会受机器和调度开销影响,不以毫秒误差判断对错。",
|
|
|
- "code": "curl.exe \"http://127.0.0.1:8000/health\"",
|
|
|
- "lang": "bash"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "为无法替换的同步 I/O 选择线程",
|
|
|
- "body": "优先使用库提供的异步方法;若必须在 async def 路由中调用同步 I/O,用 asyncio.to_thread。此处 slow_sync 用 sleep 模拟同步 I/O,实际替换为该库的函数;线程不是本课 CPU 计算加速方案。",
|
|
|
- "code": "import time\n\ndef slow_sync(delay: float):\n time.sleep(delay)\n return {\"delay\": delay}\n\n# 放在 async def 路由内:\n# result = await asyncio.to_thread(slow_sync, delay)\n# return result",
|
|
|
- "lang": "python"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "异步版本两个 delay=1 请求均返回 200,总耗时通常接近 1 秒。",
|
|
|
- "阻塞版本在单进程下耗时明显增加,恢复后 /health 不再等待 slow 的 sleep 结束。",
|
|
|
- "/slow?delay=-1 与 /slow?delay=6 都返回 422。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "写了 async def,其他请求仍卡住",
|
|
|
- "cause": "路由内使用 time.sleep、同步 HTTP 客户端或长时间计算,占住事件循环。",
|
|
|
- "fix": "定位耗时调用;I/O 改用 await 支持的 API,必要时把同步 I/O 放到线程。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "两次 await 请求仍耗时约 2 秒",
|
|
|
- "cause": "先 await 第一次,再创建第二次,代码仍是串行等待。",
|
|
|
- "fix": "用 asyncio.gather 同时调度两个请求,再比较结果。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "出现 coroutine was never awaited",
|
|
|
- "cause": "调用异步函数但没有 await,也没有交给任务调度。",
|
|
|
- "fix": "在 async 函数内 await 结果;脚本最外层用 asyncio.run(main())。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "把脚本改成同时请求 3 个 delay=1 的 /slow,再增加一次 /health,记录总耗时及各状态码。",
|
|
|
- "hint": "把第三个 /slow 和 /health 一并放进 gather;不要依次 await。",
|
|
|
- "answer": "四个请求应全部返回 200。使用非阻塞等待时,总耗时通常仍接近最长的一次等待,即约 1 秒;若接近 3 秒,先检查服务是否恢复了 asyncio.sleep。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "FastAPI:并发与 async / await",
|
|
|
- "url": "https://fastapi.tiangolo.com/async/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "Python:协程、gather 与 to_thread",
|
|
|
- "url": "https://docs.python.org/3/library/asyncio-task.html"
|
|
|
- }
|
|
|
- ],
|
|
|
- "demo": "async"
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "10",
|
|
|
- "group": "异步与调用",
|
|
|
- "title": "使用 HTTPX 调用服务与调试 CORS",
|
|
|
- "summary": "复用 HTTPX 客户端,处理超时与上游错误,并定位浏览器跨域问题。",
|
|
|
- "objectives": [
|
|
|
- "在应用生命周期中创建和关闭 AsyncClient",
|
|
|
- "区分超时、连接失败和非成功状态码",
|
|
|
- "说明为什么 curl 正常而浏览器请求失败"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "一次请求有两个方向",
|
|
|
- "body": "浏览器到 FastAPI 是入站请求;FastAPI 到另一个服务是出站请求。调用失败时,先确定错误发生在哪一段。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "客户端与连接池",
|
|
|
- "body": "AsyncClient 可以复用连接。应用启动时创建,退出时关闭;不要在每次高频请求内重新创建客户端。connect、read、write、pool 是不同阶段的超时,read 通常是等待下一块数据的上限,不是请求总时限。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "CORS 由浏览器检查",
|
|
|
- "body": "Origin 由协议、主机和端口组成。http://localhost:5500 与 http://127.0.0.1:5500 是两个来源;curl 和服务端 HTTPX 不执行浏览器的 CORS 检查。CORS 也不替代 API 认证。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "把客户端交给 lifespan 管理",
|
|
|
- "body": "安装 httpx。把现有 FastAPI() 初始化改为使用 lifespan;若应用已有 lifespan,把下面的客户端管理合并进去,不要再创建第二个 app。上游使用本机已完成的 /slow,让超时实验不依赖公网。",
|
|
|
- "code": "from contextlib import asynccontextmanager\nimport httpx\nfrom fastapi import FastAPI\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n timeout = httpx.Timeout(3.0, connect=1.0)\n async with httpx.AsyncClient(\n base_url=\"http://127.0.0.1:8000\", timeout=timeout\n ) as client:\n app.state.http = client\n yield\n\napp = FastAPI(lifespan=lifespan)",
|
|
|
- "lang": "python",
|
|
|
- "filename": "main.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "把出站失败转换为可判断的响应",
|
|
|
- "body": "增加 /upstream-demo。默认 delay=1 会成功,delay=4 超过 read 超时。这里调用本机接口只是本地实验;正式业务中使用服务方的固定地址,避免让用户任意指定出站 URL。raise_for_status 会把非 2xx 响应转为 HTTPStatusError。",
|
|
|
- "code": "from fastapi import HTTPException, Query, Request\n\n@app.get(\"/upstream-demo\")\nasync def upstream_demo(\n request: Request,\n delay: float = Query(default=1, ge=0, le=5),\n):\n try:\n response = await request.app.state.http.get(\n \"/slow\", params={\"delay\": delay}\n )\n response.raise_for_status()\n return {\"upstream\": response.json()}\n except httpx.TimeoutException as exc:\n raise HTTPException(504, \"上游响应超时\") from exc\n except httpx.HTTPStatusError as exc:\n raise HTTPException(502, \"上游返回非成功状态\") from exc\n except httpx.RequestError as exc:\n raise HTTPException(502, \"无法连接上游服务\") from exc",
|
|
|
- "lang": "python",
|
|
|
- "filename": "main.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "在两个页面来源之间复现 CORS",
|
|
|
- "body": "API 保持运行在 8000 端口。把下面中间件加入现有 app;已有 CORSMiddleware 时修改原配置,不要重复添加。本实验只允许 http://127.0.0.1:5500。如果使用下载的参考项目,先暂时从 allow_origins 中移除 http://localhost:5500,才能复现后面来源不匹配的情况。",
|
|
|
- "code": "from fastapi.middleware.cors import CORSMiddleware\n\napp.add_middleware(\n CORSMiddleware,\n allow_origins=[\"http://127.0.0.1:5500\"],\n allow_credentials=False,\n allow_methods=[\"GET\", \"POST\", \"PUT\", \"DELETE\"],\n allow_headers=[\"Content-Type\", \"X-API-Key\"],\n)",
|
|
|
- "lang": "python",
|
|
|
- "filename": "main.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "建立前端页面并从 5500 访问 API",
|
|
|
- "body": "在 api-lab 中新建 cors-demo.html,粘贴下面内容。另开一个终端,在 api-lab 目录执行 python -m http.server 5500,保留 API 的 8000 终端。打开 http://127.0.0.1:5500/cors-demo.html 并点击按钮,在 Network 中观察 OPTIONS 预检和 POST;JSON 的 Content-Type 会触发本次跨域预检。再通过 http://localhost:5500/cors-demo.html 打开同一文件,对照来源不匹配时的浏览器错误。此时简单的 GET /health 不一定产生 OPTIONS。",
|
|
|
- "code": "<!doctype html>\n<html lang=\"zh-CN\">\n<meta charset=\"utf-8\">\n<title>CORS 实验</title>\n<button id=\"send\">向 API 创建笔记</button>\n<pre id=\"result\"></pre>\n<script>\ndocument.querySelector(\"#send\").onclick = async () => {\n const output = document.querySelector(\"#result\");\n try {\n const response = await fetch(\"http://127.0.0.1:8000/notes\", {\n method: \"POST\",\n headers: {\"Content-Type\": \"application/json\"},\n body: JSON.stringify({title: \"CORS 实验\"}),\n });\n output.textContent = response.status + \"\\n\" + await response.text();\n } catch (error) {\n output.textContent = \"浏览器未能读取响应;检查 Network 和 Console:\" + error.message;\n }\n};\n</script>\n</html>",
|
|
|
- "lang": "html",
|
|
|
- "filename": "cors-demo.html"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "读预检请求和响应头",
|
|
|
- "body": "在浏览器 Network 面板查看 Origin、OPTIONS 和 Access-Control-Allow-Origin。curl.exe 可手动查看预检响应,但它不会替浏览器决定是否允许页面读取响应。把来源改为 localhost:5500 后复现不匹配,再恢复准确来源。 macOS / Linux 把 curl.exe 改为 curl。实验完成后,如需同时使用两个前端域名,再把 localhost:5500 加回白名单。",
|
|
|
- "code": "curl.exe -i -X OPTIONS \"http://127.0.0.1:8000/notes\" -H \"Origin: http://127.0.0.1:5500\" -H \"Access-Control-Request-Method: POST\" -H \"Access-Control-Request-Headers: content-type\"\ncurl.exe -i \"http://127.0.0.1:8000/upstream-demo?delay=4\"",
|
|
|
- "lang": "bash"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "/upstream-demo?delay=1 返回 200,包含上游的 delay 字段。",
|
|
|
- "/upstream-demo?delay=4 返回 504;把上游地址临时改为未监听端口可观察 502,随后恢复。",
|
|
|
- "允许来源的预检响应包含 Access-Control-Allow-Origin: http://127.0.0.1:5500,浏览器可以读取响应。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "上游返回 404,接口却继续执行正常处理",
|
|
|
- "cause": "HTTP 请求收到响应不等于业务成功,代码没有检查状态。",
|
|
|
- "fix": "调用 raise_for_status 并单独处理 HTTPStatusError。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "curl 成功,网页报 CORS 错误",
|
|
|
- "cause": "浏览器页面来源与 allow_origins 不匹配,或预检方法、请求头未被允许。",
|
|
|
- "fix": "逐项核对 Origin、方法和自定义请求头,再看浏览器 Network 中的 OPTIONS 响应。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "用 timeout=3 就以为整个请求最多 3 秒",
|
|
|
- "cause": "把阶段性超时误认为总请求截止时间,流式响应每次及时发送数据时可能持续更久。",
|
|
|
- "fix": "先明确要限制连接、块间等待还是总耗时;本课实验只验证读超时。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "让 /upstream-demo 在等待超过 0.2 秒时返回 504,同时保持直接访问 /slow?delay=1 能正常返回。",
|
|
|
- "hint": "只覆盖 client.get 的 timeout 参数,保留异步 /slow。",
|
|
|
- "answer": "给该次请求设置 timeout=httpx.Timeout(0.2, connect=1.0)。请求 /upstream-demo?delay=1 会在等待上游数据时触发超时;直接请求 /slow?delay=1 仍约 1 秒后返回 200。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "HTTPX:异步客户端",
|
|
|
- "url": "https://www.python-httpx.org/async/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "HTTPX:超时阶段",
|
|
|
- "url": "https://www.python-httpx.org/advanced/timeouts/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "HTTPX:状态码与异常",
|
|
|
- "url": "https://www.python-httpx.org/quickstart/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:lifespan",
|
|
|
- "url": "https://fastapi.tiangolo.com/advanced/events/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:CORS",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/cors/"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "11",
|
|
|
- "group": "流式与实时通信",
|
|
|
- "title": "完整响应、流式响应、SSE 与 WebSocket",
|
|
|
- "summary": "从通信方向与消息格式选择接口形态,用同一份文本比较接收过程。",
|
|
|
- "objectives": [
|
|
|
- "分清 REST 风格、HTTP 响应方式和 WebSocket 协议",
|
|
|
- "根据发送方向与数据频率选择实现",
|
|
|
- "观察首段到达时间与全部完成时间"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "REST 不规定必须一次返回全部数据",
|
|
|
- "body": "REST 主要讨论资源及 HTTP 操作方式。普通 JSON 响应先得到完整结果再返回;HTTP 流式响应逐段发送同一个响应体。流式发送不自动定义每段的业务含义。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "SSE 为文本流定义事件边界",
|
|
|
- "body": "SSE 在 HTTP 响应体中使用 text/event-stream,以空行分隔事件。连接内数据由服务端推向客户端;浏览器可用 EventSource 解析。SSE 是 HTTP 流式响应的一种约定。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "WebSocket 保持双向通信",
|
|
|
- "body": "连接建立后,双方可以独立发送消息,不必每条消息新建 HTTP 请求。业务仍需定义消息类型、结束、错误与连接关闭后的行为。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "先跑一次完整结果接口",
|
|
|
- "body": "用延时代表逐步产生内容,POST /generate 等待全部内容完成后返回一个 JSON 对象。PromptIn 限制长度,本课使用固定文本拼接,便于观察协议而不引入模型服务。",
|
|
|
- "code": "import asyncio\nfrom pydantic import BaseModel, Field\n\nclass PromptIn(BaseModel):\n prompt: str = Field(min_length=1, max_length=200)\n\n@app.post(\"/generate\")\nasync def generate(payload: PromptIn):\n parts = []\n for text in [\"收到:\", payload.prompt, \"。处理完成。\"]:\n await asyncio.sleep(0.4)\n parts.append(text)\n return {\"text\": \"\".join(parts)}",
|
|
|
- "lang": "python",
|
|
|
- "filename": "main.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "记录完整响应的等待",
|
|
|
- "body": "在 /docs 对 POST /generate 输入下面 JSON。结果约在所有步骤完成后出现。区分响应头到达与可用业务内容到达;观察何时收到第一段内容。",
|
|
|
- "code": "{\"prompt\":\"hello\"}",
|
|
|
- "lang": "json"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "对照三条连接过程",
|
|
|
- "body": "在本页协议演示中依次选择完整 HTTP、SSE、WebSocket:完整响应在完成后显示文本;SSE 逐条显示 token 事件;WebSocket 可在连接保持期间发送新消息。此交互是浏览器模拟;第 12–15 课会用 api-lab 进行真实收发验证。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "把场景写成协议选择题",
|
|
|
- "body": "保存笔记通常使用 HTTP 的 POST /notes;一段处理结果要边产生边显示,可使用 HTTP 流式响应,文本事件采用 SSE;前后端要频繁互发消息,可使用 WebSocket。若只需偶尔查询状态,也可以定期 HTTP 查询,不需要先维护长连接。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "能说清 SSE 使用 HTTP,且不是 WebSocket 的别名。",
|
|
|
- "/generate 返回单个 {text:...} 对象;不会在每个步骤结束时返回一个新 HTTP 响应。",
|
|
|
- "能为保存笔记、逐步显示文本、双向实时消息分别选择方案并说明原因。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "为了逐字显示文字,直接把所有接口都改成 WebSocket",
|
|
|
- "cause": "把流式接收和双向消息通信混为一谈。",
|
|
|
- "fix": "先确定客户端是否需要在同一条连接上持续主动发送;单向文本结果通常先考虑 HTTP 流式响应。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "把服务器 yield 的次数当成客户端 read 的次数",
|
|
|
- "cause": "网络、服务器和浏览器可能合并或拆开数据块。",
|
|
|
- "fix": "依赖协议的事件分隔符解析消息,不依赖网络块边界。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "用户提交一次短文本后只接收生成结果,偶尔点击“停止”。是否必须使用 WebSocket?",
|
|
|
- "hint": "思考取消 HTTP 请求是否已经满足停止读取的需要。",
|
|
|
- "answer": "不必须。可以提交 HTTP 请求并读取流式响应,通过取消请求停止读取;服务端同时处理断开并释放资源。如果还需要在同一会话持续双向发送独立消息,再评估 WebSocket。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "FastAPI:StreamingResponse",
|
|
|
- "url": "https://fastapi.tiangolo.com/advanced/custom-response/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "MDN:SSE 事件流",
|
|
|
- "url": "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:WebSocket",
|
|
|
- "url": "https://fastapi.tiangolo.com/advanced/websockets/"
|
|
|
- }
|
|
|
- ],
|
|
|
- "demo": "protocol"
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "12",
|
|
|
- "group": "流式与实时通信",
|
|
|
- "title": "用 StreamingResponse 输出 SSE 事件",
|
|
|
- "summary": "实现有限长度文本流,定义 token / done 事件,并处理客户端断开。",
|
|
|
- "objectives": [
|
|
|
- "用异步生成器逐段发送 text/event-stream",
|
|
|
- "使用 JSON 编码 data 字段并保留空行边界",
|
|
|
- "区分正常完成、连接断开和流内错误"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "yield 交出一段响应体",
|
|
|
- "body": "异步生成器可在产生下一段数据前 await。StreamingResponse 消费生成器并发送响应体;生成器必须有明确的正常结束条件。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "消息格式属于双方约定",
|
|
|
- "body": "本课每个 token 事件的 data 是 {text:...},最后发送 done 事件且 data 为 {}。JSON 编码处理引号和换行,空行负责分隔事件。不能直接把任意用户输入拼接成 SSE 行。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "响应开始后不能改写 HTTP 状态",
|
|
|
- "body": "参数验证应在开始流式响应前完成。发送响应头后若处理中断,应通过约定的流内错误事件或连接结束通知客户端;不能再把已经开始的 200 改成 500。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "定义一处统一的事件编码",
|
|
|
- "body": "把事件名限制为代码中定义的固定值;用 json.dumps 编码数据。返回字符串末尾是两个换行字符,即一个空行。ensure_ascii=False 保留中文可读性。",
|
|
|
- "code": "import json\n\ndef encode_sse(event: str, data: dict) -> str:\n payload = json.dumps(data, ensure_ascii=False)\n return f\"event: {event}\\ndata: {payload}\\n\\n\"",
|
|
|
- "lang": "python",
|
|
|
- "filename": "main.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "产生有限长度 token 流",
|
|
|
- "body": "每隔约 0.4 秒发送一个 token,完成后发 done。使用 finally 观察连接关闭后的清理路径;真实上游迭代器、文件等资源应在这里或上下文管理器内关闭。不要吞掉取消异常。",
|
|
|
- "code": "import asyncio\nfrom fastapi import Request\n\nasync def token_events(request: Request, prompt: str):\n try:\n for text in [\"收到:\", prompt, \"。处理完成。\"]:\n await asyncio.sleep(0.4)\n if await request.is_disconnected():\n return\n yield encode_sse(\"token\", {\"text\": text})\n yield encode_sse(\"done\", {})\n finally:\n print(\"stream closed\")",
|
|
|
- "lang": "python",
|
|
|
- "filename": "main.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "在 /stream 返回 StreamingResponse",
|
|
|
- "body": "GET 查询参数适合 EventSource 演示;不要在 URL 中传密钥或敏感正文。prompt 先经过长度验证,再进入生成器。已有相同路由时替换定义,避免重复注册。",
|
|
|
- "code": "from fastapi import Query\nfrom fastapi.responses import StreamingResponse\n\n@app.get(\"/stream\")\nasync def stream(\n request: Request,\n prompt: str = Query(default=\"hello\", min_length=1, max_length=200),\n):\n return StreamingResponse(\n token_events(request, prompt),\n media_type=\"text/event-stream\",\n headers={\"Cache-Control\": \"no-cache\"},\n )",
|
|
|
- "lang": "python",
|
|
|
- "filename": "main.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "从终端逐段观察,再主动取消",
|
|
|
- "body": "使用 curl.exe 的 -N 禁用它自己的输出缓冲。应先后看到三条 token 与一条 done,每条后有空行。第二次请求在生成中按 Ctrl+C,服务端最终应打印 stream closed;断开通常通过取消路径或断开检查被观察到。",
|
|
|
- "code": "curl.exe -N -i \"http://127.0.0.1:8000/stream?prompt=hello\"",
|
|
|
- "lang": "bash"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "核对线上实际字节格式",
|
|
|
- "body": "以下是事件体格式展示,其中空行是协议的一部分。data 内的 JSON 字符串若有换行,应由 JSON 编码为转义序列,不能破坏事件边界。",
|
|
|
- "code": "event: token\ndata: {\"text\": \"hello\"}\n\nevent: done\ndata: {}\n\n",
|
|
|
- "lang": "http"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "响应 Content-Type 以 text/event-stream 开头;终端能在流结束前看到 token。",
|
|
|
- "正常结束只有一个 done,三个 token 的 text 拼接后与 /generate 的 text 相同。",
|
|
|
- "prompt 为空返回 422;请求中途取消后清理日志出现,服务仍能处理 /health。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "客户端一直等不到事件回调",
|
|
|
- "cause": "发送了 data 行却漏了事件末尾的空行。",
|
|
|
- "fix": "每条事件统一通过 encode_sse 编码,并确认末尾为 \\n\\n。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "输入含换行后事件解析失败",
|
|
|
- "cause": "手工拼接 JSON 或把原始输入直接放进 data 行。",
|
|
|
- "fix": "先 json.dumps,再写入 SSE data 字段。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "断开后仍持续执行,或者没有清理资源",
|
|
|
- "cause": "无限循环未处理断开、没有 await 让取消生效,或遗漏 finally 清理。",
|
|
|
- "fix": "在迭代中检查断开,使用可等待的 I/O,并在 finally 中释放资源;保持本课生成器有限长度。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "在三个 token 后、done 前增加一个 stats 事件,返回 token_count:3;保持已有客户端仍能完成。",
|
|
|
- "hint": "stats 是独立事件类型,不需要把它伪装成 token。",
|
|
|
- "answer": "在 done 前增加 yield encode_sse(\"stats\", {\"token_count\": 3})。原客户端只监听 token 和 done 时会忽略 stats;新增监听器后可以显示统计值。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "FastAPI:自定义与流式响应",
|
|
|
- "url": "https://fastapi.tiangolo.com/advanced/custom-response/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "MDN:SSE 消息格式",
|
|
|
- "url": "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:Request 类与断开检查",
|
|
|
- "url": "https://fastapi.tiangolo.com/reference/request/"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "13",
|
|
|
- "group": "流式与实时通信",
|
|
|
- "title": "在浏览器中读取流,并在中途停止",
|
|
|
- "summary": "分别使用 EventSource 与 fetch,处理 UTF-8 分块、事件边界和取消请求。",
|
|
|
- "objectives": [
|
|
|
- "用 EventSource 监听命名事件并主动关闭",
|
|
|
- "用 ReadableStream 解析可能被拆开的 SSE 数据",
|
|
|
- "通过 AbortController 停止请求并区分取消与错误"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "EventSource 简单,但请求形态有限",
|
|
|
- "body": "原生 EventSource 发起 GET 请求,自动解析 SSE 并具备重连行为;构造参数不能任意设置请求体和自定义请求头。收到本课有限流的 done 后应主动 close,防止结束后重新请求。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "fetch 能发送 POST,再读取响应流",
|
|
|
- "body": "POST 请求不妨碍响应逐段返回。需要 JSON 请求体或自定义头时可用 fetch,但必须自己解析响应体。调用 response.json() 或 response.text() 会等到响应体读完。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "网络块不等于事件",
|
|
|
- "body": "一个中文字符的 UTF-8 字节、一个 JSON 或空行都可能跨越两次 read;一次 read 也可能包含多条事件。先连续解码字节,再缓存文本并按事件边界提取。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "先用 EventSource 接收命名事件",
|
|
|
- "body": "打开本地 http://127.0.0.1:8000/docs,在开发者工具 Console 运行。URL 与页面同源,先排除 CORS 干扰。控制台应逐条输出文本,done 后主动关闭;本课把网络失败也关闭,避免自动重连重复执行。",
|
|
|
- "code": "{\n const source = new EventSource(\"/stream?prompt=hello\");\n source.addEventListener(\"token\", (event) => {\n console.log(JSON.parse(event.data).text);\n });\n source.addEventListener(\"done\", () => {\n console.log(\"完成\");\n source.close();\n });\n source.onerror = () => {\n console.error(\"流连接失败,已停止\");\n source.close();\n };\n window.stopSse = () => source.close();\n}",
|
|
|
- "lang": "javascript",
|
|
|
- "filename": "浏览器 Console"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "把事件解析与网络读取分开",
|
|
|
- "body": "先在 Console 定义解析函数。它为本课 LF / CRLF 事件格式处理跨块与多条 data 行,且要求正常收到 done;不实现完整 EventSource 的 id、retry 与自动重连功能。TextDecoder 的 stream:true 会暂存未完整的 UTF-8 字节,最后再刷新。",
|
|
|
- "code": "async function consumeSse(response, onEvent) {\n if (!response.ok) throw new Error(`HTTP ${response.status}`);\n if (!response.headers.get(\"content-type\")?.includes(\"text/event-stream\")) {\n throw new Error(\"响应不是 SSE\");\n }\n if (!response.body) throw new Error(\"没有响应体\");\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n let buffer = \"\";\n let completed = false;\n try {\n while (!completed) {\n const { value, done } = await reader.read();\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n let boundary;\n while ((boundary = /\\r?\\n\\r?\\n/.exec(buffer)) !== null) {\n const block = buffer.slice(0, boundary.index);\n buffer = buffer.slice(boundary.index + boundary[0].length);\n const lines = block.split(/\\r?\\n/);\n const name = lines.find(line => line.startsWith(\"event:\"))\n ?.slice(6).trim() || \"message\";\n const data = lines.filter(line => line.startsWith(\"data:\"))\n .map(line => line.slice(5).replace(/^ /, \"\")).join(\"\\n\");\n if (data) onEvent(name, JSON.parse(data));\n if (name === \"done\") { completed = true; break; }\n }\n if (done && !completed) throw new Error(\"连接结束,但没有收到 done\");\n }\n await reader.cancel();\n } catch (error) {\n await reader.cancel().catch(() => {});\n throw error;\n } finally {\n reader.releaseLock();\n }\n}",
|
|
|
- "lang": "javascript",
|
|
|
- "filename": "浏览器 Console"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "用 fetch 读取同一个 GET 接口",
|
|
|
- "body": "在同一 Console 定义 startStream,然后执行 startStream()。紧接着执行 stopStream() 可中途取消;AbortError 代表用户主动取消,不应显示为服务故障。新的请求使用新的 AbortController。",
|
|
|
- "code": "async function startStream() {\n const controller = new AbortController();\n window.stopStream = () => controller.abort();\n try {\n const response = await fetch(\"/stream?prompt=hello\", {\n signal: controller.signal,\n });\n await consumeSse(response, (name, data) => {\n if (name === \"token\") console.log(data.text);\n if (name === \"done\") console.log(\"完成\");\n });\n } catch (error) {\n if (error.name === \"AbortError\") console.log(\"已取消\");\n else console.error(error);\n }\n}\nstartStream();",
|
|
|
- "lang": "javascript",
|
|
|
- "filename": "浏览器 Console"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "扩展:需要 POST 请求体时",
|
|
|
- "body": "先在 main.py 额外加入以下 POST /stream 路由,复用第 11 课 PromptIn 与第 12 课 token_events。然后把上一步 fetch 的 URL 改为 /stream,并在配置对象中加入 method:\"POST\"、headers:{\"Content-Type\":\"application/json\"}、body:JSON.stringify({prompt:\"hello\"});保留 signal。POST /generate 仍返回完整 JSON,不可拿它套用 SSE 解析函数。",
|
|
|
- "code": "@app.post(\"/stream\")\nasync def stream_post(request: Request, payload: PromptIn):\n return StreamingResponse(\n token_events(request, payload.prompt),\n media_type=\"text/event-stream\",\n headers={\"Cache-Control\": \"no-cache\"},\n )",
|
|
|
- "lang": "python",
|
|
|
- "filename": "main.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "核对流为什么没有逐段显示",
|
|
|
- "body": "先用 curl.exe -N 直连本地 API。若本地逐段到达但页面一次出现,检查是否调用了 response.text/json,或渲染是否缓存了结果。若只有经过中间服务才一次出现,再检查中间服务的响应缓冲;本课只定位差异,具体代理配置留到部署模块。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "EventSource 收到三次 token 和一次 done 后关闭,不会周期性重新发起 /stream。",
|
|
|
- "fetch 版本能正常显示中文;主动 stopStream 后输出“已取消”。",
|
|
|
- "扩展 POST /stream 返回 SSE;POST /generate 仍返回 application/json。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "偶发 JSON.parse 错误,或中文出现替换字符",
|
|
|
- "cause": "直接对每次 read 的内容解码和解析,忽略了字符、事件可能跨块。",
|
|
|
- "fix": "保持同一 TextDecoder 并设置 stream:true,再缓存到完整空行边界后解析。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "一次成功结束后又出现同样文本",
|
|
|
- "cause": "有限 SSE 结束后 EventSource 自动重连,客户端没有处理 done。",
|
|
|
- "fix": "监听 done 调用 close;真实恢复能力需要单独定义,不默认重复执行。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "点击停止后仍把状态改成失败",
|
|
|
- "cause": "把所有 fetch 异常都当成网络故障。",
|
|
|
- "fix": "单独识别 AbortError,并同步把页面状态改为已取消。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "把一条包含“你好”的 SSE 消息拆成逐个字节的 ReadableStream,验证解析函数仍得到完整中文和 done。",
|
|
|
- "hint": "用 TextEncoder 生成字节,再通过 Response 包装本地 ReadableStream;这只是解析器实验,不是 API 网络测试。",
|
|
|
- "answer": "构造文本 'event: token\\ndata: {\"text\":\"你好\"}\\n\\nevent: done\\ndata: {}\\n\\n';对 TextEncoder().encode(text) 的每个字节调用 controller.enqueue(new Uint8Array([byte])),最后 close。用 new Response(stream,{headers:{\"Content-Type\":\"text/event-stream\"}}) 传入 consumeSse,回调应只收到完整 token 和 done。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "MDN:EventSource",
|
|
|
- "url": "https://developer.mozilla.org/en-US/docs/Web/API/EventSource"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "MDN:读取 ReadableStream",
|
|
|
- "url": "https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "MDN:TextDecoder.decode",
|
|
|
- "url": "https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/decode"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "MDN:AbortController",
|
|
|
- "url": "https://developer.mozilla.org/en-US/docs/Web/API/AbortController"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "14",
|
|
|
- "group": "流式与实时通信",
|
|
|
- "title": "建立第一条 WebSocket 双向连接",
|
|
|
- "summary": "实现 /ws 回显,观察建立、发送、接收和关闭的完整过程。",
|
|
|
- "objectives": [
|
|
|
- "使用 accept、receive_text 和 send_json 完成回显",
|
|
|
- "在浏览器连接打开后发送消息",
|
|
|
- "捕获断开并释放连接相关资源"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "连接先建立,消息再发送",
|
|
|
- "body": "服务端 accept 后进入收发阶段;浏览器 WebSocket 从 CONNECTING 转为 OPEN 后才能发送。send 只表示数据进入发送流程,不证明对方已经处理。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "一条连接,多条消息",
|
|
|
- "body": "本课客户端发送文本,服务端回复 JSON。普通文本对应 type:echo;文本 ping 对应 type:pong。双方必须约定数据格式,不能把 HTTP 路由的返回值当作 WebSocket 消息发送。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "断开是正常生命周期",
|
|
|
- "body": "用户关页、主动关闭和网络中断都可能结束连接。receive_text 在断开时会抛出 WebSocketDisconnect;在 finally 中清理该连接持有的资源。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "在 main.py 插入 /ws 路由",
|
|
|
- "body": "接受连接后循环读取消息。回复必须调用 send_json,而不是 return 一个字典。这个本地练习只接收文本消息,不实现用户身份或消息保存。",
|
|
|
- "code": "from fastapi import WebSocket, WebSocketDisconnect\n\n@app.websocket(\"/ws\")\nasync def websocket_echo(websocket: WebSocket):\n await websocket.accept()\n try:\n while True:\n text = await websocket.receive_text()\n if text == \"ping\":\n await websocket.send_json({\"type\": \"pong\"})\n else:\n await websocket.send_json({\"type\": \"echo\", \"text\": text})\n except WebSocketDisconnect:\n pass\n finally:\n print(\"websocket closed\")",
|
|
|
- "lang": "python",
|
|
|
- "filename": "main.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "从浏览器建立连接",
|
|
|
- "body": "打开本地 http://127.0.0.1:8000/docs,在 Console 运行。代码把连接保留到 window.echoSocket,方便随后发送和关闭。open、message、close 的日志对应三个不同阶段。",
|
|
|
- "code": "{\n const socket = new WebSocket(\"ws://127.0.0.1:8000/ws\");\n window.echoSocket = socket;\n socket.onopen = () => console.log(\"连接已打开\");\n socket.onmessage = event => console.log(\"收到\", JSON.parse(event.data));\n socket.onerror = () => console.error(\"连接发生错误\");\n socket.onclose = event => console.log(\"已关闭\", event.code, event.reason);\n}",
|
|
|
- "lang": "javascript",
|
|
|
- "filename": "浏览器 Console"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "在 OPEN 状态发送两种消息",
|
|
|
- "body": "等“连接已打开”出现后,执行下列代码。在 Network 面板选择 WS 请求并查看消息列表,区分客户端发出的原始文本与服务端返回的 JSON。WebSocket 请求不使用 /docs 的 HTTP Execute 按钮测试。",
|
|
|
- "code": "if (echoSocket.readyState === WebSocket.OPEN) {\n echoSocket.send(\"hello\");\n echoSocket.send(\"ping\");\n}",
|
|
|
- "lang": "javascript",
|
|
|
- "filename": "浏览器 Console"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "主动关闭并确认生命周期结束",
|
|
|
- "body": "调用 close 后,浏览器输出关闭事件,服务端输出清理日志。关闭后不能复用旧 socket 继续发送;需要新建 WebSocket。重新连接并再发送一次 hello,确认服务仍可用。",
|
|
|
- "code": "echoSocket.close(1000, \"lesson finished\");",
|
|
|
- "lang": "javascript",
|
|
|
- "filename": "浏览器 Console"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "浏览器先收到 open,发送 hello 后收到 {type:'echo',text:'hello'}。",
|
|
|
- "发送 ping 后收到 {type:'pong'};同一连接可重复收发。",
|
|
|
- "主动关闭后收到 close,服务端清理日志出现,重新连接仍正常。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "调用 send 报连接尚未建立",
|
|
|
- "cause": "new WebSocket 返回时握手仍在进行。",
|
|
|
- "fix": "在 open 回调中发送,或先检查 readyState === WebSocket.OPEN。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "浏览器无任何回复,路由却 return 了字典",
|
|
|
- "cause": "WebSocket 消息不会像 HTTP 响应一样自动序列化返回。",
|
|
|
- "fix": "在连接内 await websocket.send_json(...)。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "把 WebSocket 地址粘到普通地址栏后得到 404",
|
|
|
- "cause": "使用 HTTP GET 测试了 WebSocket 端点,没有进行 WebSocket 握手。",
|
|
|
- "fix": "通过浏览器 WebSocket API 或支持 WS 的客户端测试,使用 ws:// 地址。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "增加一个文本命令 upper:hello,让服务端回复 {type:'upper',text:'HELLO'},其他消息保持回显。",
|
|
|
- "hint": "把 startswith('upper:') 分支放在默认 echo 分支之前。",
|
|
|
- "answer": "在 echo 的 else 之前加入 elif text.startswith(\"upper:\"): await websocket.send_json({\"type\":\"upper\",\"text\":text.removeprefix(\"upper:\").upper()})。浏览器收到的 JSON type 应为 upper。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "FastAPI:WebSocket 收发与断开",
|
|
|
- "url": "https://fastapi.tiangolo.com/advanced/websockets/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "MDN:编写 WebSocket 客户端",
|
|
|
- "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "15",
|
|
|
- "group": "流式与实时通信",
|
|
|
- "title": "连接断开后:重连、心跳与取消",
|
|
|
- "summary": "给 WebSocket 加上有限重试和定时器清理,验证停止后不会自行恢复。",
|
|
|
- "objectives": [
|
|
|
- "在异常关闭后用指数退避重新连接",
|
|
|
- "通过应用层 ping / pong 检测连接是否仍可用",
|
|
|
- "清理重连和心跳定时器,不自动重发未确认消息"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "重连创建一条新连接",
|
|
|
- "body": "新连接不会自动恢复旧连接内的执行状态,也不保证断开期间消息送达。本课不缓存、不重发业务消息;需要可靠恢复时,必须另行设计消息编号、确认和服务端去重。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "退避限制重试频率",
|
|
|
- "body": "连续失败时逐渐延长等待,并加少量随机抖动,避免同时快速请求。设置最大次数;用户主动停止后取消待执行重连。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "心跳与页面状态",
|
|
|
- "body": "这里的 ping 是普通文本消息,pong 是业务 JSON,属于应用层心跳。浏览器 WebSocket API 不提供主动发送协议级 Ping 帧的方法。计时器会受后台页面节流影响,超时阈值需要按场景调整。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "先定义连接状态与定时器清理",
|
|
|
- "body": "在本地 /docs 的 Console 按步骤运行。先关闭上一课 echoSocket,避免同时观察两条连接。下面状态只属于本次练习;刷新页面可重新开始。",
|
|
|
- "code": "let wsSocket = null;\nlet retryTimer = null;\nlet heartbeatTimer = null;\nlet pongTimer = null;\nlet wsStopped = true;\nlet retryCount = 0;\n\nfunction clearHeartbeat() {\n clearInterval(heartbeatTimer);\n clearTimeout(pongTimer);\n heartbeatTimer = null;\n pongTimer = null;\n}",
|
|
|
- "lang": "javascript",
|
|
|
- "filename": "浏览器 Console"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "加入应用层心跳",
|
|
|
- "body": "每 5 秒发送一次 ping,3 秒内没有 pong 就关闭连接,让同一条关闭处理路径决定是否重连。收到 pong 时取消对应超时。这里只允许一个等待中的心跳。",
|
|
|
- "code": "function startHeartbeat(socket) {\n clearHeartbeat();\n heartbeatTimer = setInterval(() => {\n if (socket.readyState !== WebSocket.OPEN) return;\n socket.send(\"ping\");\n pongTimer = setTimeout(() => {\n socket.close(4000, \"heartbeat timeout\");\n }, 3000);\n }, 5000);\n}",
|
|
|
- "lang": "javascript",
|
|
|
- "filename": "浏览器 Console"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "把重连集中在 close 事件中",
|
|
|
- "body": "首次失败约 0.5 秒后重连,随后约 1、2、4、8 秒,另加少量随机抖动;最多连续重试 5 次。error 只记录,避免 error 和 close 各重试一次。收到服务端有效消息后清零连续失败计数。",
|
|
|
- "code": "function connectWs() {\n if (wsStopped) return;\n const socket = new WebSocket(\"ws://127.0.0.1:8000/ws\");\n wsSocket = socket;\n socket.onopen = () => {\n if (wsStopped || wsSocket !== socket) { socket.close(); return; }\n console.log(\"已连接\");\n startHeartbeat(socket);\n };\n socket.onmessage = event => {\n if (wsSocket !== socket) return;\n const message = JSON.parse(event.data);\n retryCount = 0;\n if (message.type === \"pong\") {\n clearTimeout(pongTimer);\n pongTimer = null;\n } else {\n console.log(\"收到\", message);\n }\n };\n socket.onerror = () => console.log(\"连接错误,等待 close\");\n socket.onclose = () => {\n if (wsSocket !== socket) return;\n clearHeartbeat();\n if (wsStopped) return;\n if (retryCount >= 5) {\n wsStopped = true;\n console.log(\"连续重试已达上限,请手动开始\");\n return;\n }\n const delay = Math.min(8000, 500 * 2 ** retryCount) + Math.random() * 200;\n retryCount += 1;\n console.log(`第 ${retryCount} 次重试,等待 ${Math.round(delay)}ms`);\n retryTimer = setTimeout(connectWs, delay);\n };\n}",
|
|
|
- "lang": "javascript",
|
|
|
- "filename": "浏览器 Console"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "实现开始、发送与停止",
|
|
|
- "body": "开始函数防止重复创建连接;发送函数在未连接时明确拒绝;停止函数先记录主动停止,再清理所有计时器和连接。stopWs 同时可供页面关闭时调用。",
|
|
|
- "code": "function startWs() {\n if (!wsStopped) return;\n wsStopped = false;\n retryCount = 0;\n connectWs();\n}\nfunction sendWs(text) {\n if (wsSocket?.readyState !== WebSocket.OPEN) {\n console.log(\"尚未连接,本条消息未发送\");\n return;\n }\n wsSocket.send(text);\n}\nfunction stopWs() {\n wsStopped = true;\n clearTimeout(retryTimer);\n clearHeartbeat();\n wsSocket?.close(1000, \"user stopped\");\n wsSocket = null;\n console.log(\"已停止\");\n}\nwindow.addEventListener(\"pagehide\", stopWs);\nstartWs();",
|
|
|
- "lang": "javascript",
|
|
|
- "filename": "浏览器 Console"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "执行三组失败实验",
|
|
|
- "body": "正常连接后用 sendWs('hello') 验证回显。接着停止本地服务,观察逐渐延长的重试间隔,重启服务后确认新连接可收发。最后在下一次重试前调用 stopWs(),等待超过 9 秒,确认没有新建 WS 请求。断线期间尝试发送的消息不能在重连后自动出现。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "服务停止时重连间隔逐步增加,连续失败达到 5 次重试后停止。",
|
|
|
- "服务恢复后能建立新连接并再次收到 echo,心跳 ping 对应 pong。",
|
|
|
- "stopWs 后超过 9 秒没有新连接;未连接时调用 sendWs 不会排队重发。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "每次断线都会产生两条新连接",
|
|
|
- "cause": "在 error 和 close 中分别启动重试,或开始按钮允许重复创建。",
|
|
|
- "fix": "只在 close 调度重试;开始前检查当前是否已经运行。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "点停止后几秒钟又自动连上",
|
|
|
- "cause": "只关闭 socket,没有取消已经安排的 setTimeout。",
|
|
|
- "fix": "先设置主动停止标记,再清理重试、心跳和 pong 超时定时器。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "重连后重复执行了一条操作",
|
|
|
- "cause": "客户端自动重发消息,但无法确定服务端在断开前是否已经执行。",
|
|
|
- "fix": "本课不自动重发;需要重试业务操作时另行加入确认、操作编号与幂等处理。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "把连续重试上限改为 3 次,并验证用户手动 startWs() 后可以开始新一轮连接。",
|
|
|
- "hint": "更改重试边界,保留达到上限时 wsStopped=true,以及开始时 retryCount=0。",
|
|
|
- "answer": "把 if (retryCount >= 5) 改为 >= 3。连续失败后控制台打印到达上限;随后调用 startWs() 重置次数并重新连接,若服务已恢复即可继续回显。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "MDN:WebSocket 客户端生命周期",
|
|
|
- "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "MDN:WebSocket.close",
|
|
|
- "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "RFC 6455:异常关闭后的恢复建议",
|
|
|
- "url": "https://www.rfc-editor.org/rfc/rfc6455#section-7.2.3"
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- "id": "16",
|
|
|
- "group": "联调与验收",
|
|
|
- "title": "把能运行的接口变成可重复验收的服务",
|
|
|
- "summary": "用 pytest 检查 CRUD、错误、认证、SSE 和 WebSocket,再完成一次真实浏览器联调。",
|
|
|
- "objectives": [
|
|
|
- "使用 TestClient 在测试中运行 FastAPI 应用",
|
|
|
- "为正常路径和 401 / 404 / 422 写出明确断言",
|
|
|
- "区分接口逻辑验收与真实网络流式验证"
|
|
|
- ],
|
|
|
- "concepts": [
|
|
|
- {
|
|
|
- "title": "断言检查可观察结果",
|
|
|
- "body": "测试应检查状态码、响应字段与操作后结果,而不是只确认请求没报异常。每个测试创建自己的数据,通过返回 id 继续操作,避免假设某条笔记固定为 id=1。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "TestClient 不等同于浏览器",
|
|
|
- "body": "TestClient 基于 HTTPX,可在进程内调用应用,不必先启动 Uvicorn。用 with 管理客户端才会执行 lifespan;它不验证浏览器 CORS,也不能证明真实网络按预期逐段到达。"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "错误与正常结束分别验收",
|
|
|
- "body": "422 验证输入约束;401 验证 /private 的 API Key;404 验证资源不存在。SSE 既检查事件数据,也检查 done;WebSocket 检查来回消息并关闭会话。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "steps": [
|
|
|
- {
|
|
|
- "title": "建立测试客户端",
|
|
|
- "body": "在 api-lab 新建 tests/test_acceptance.py;以下第 1–4 步代码依次放入同一文件。先运行 python -m pip install pytest httpx。monkeypatch 只在测试期间设置演示 Key,测试结束自动恢复;导入入口为根目录 main.py。",
|
|
|
- "code": "import json\nfrom uuid import uuid4\nimport pytest\nfrom fastapi.testclient import TestClient\nfrom main import app\n\n@pytest.fixture\ndef client(monkeypatch):\n monkeypatch.setenv(\"DEMO_API_KEY\", \"classroom-key\")\n with TestClient(app) as test_client:\n yield test_client\n\ndef test_health(client):\n response = client.get(\"/health\")\n assert response.status_code == 200",
|
|
|
- "lang": "python",
|
|
|
- "filename": "tests/test_acceptance.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "验证创建、查询、更新与删除",
|
|
|
- "body": "从 POST 返回值提取 id,再验证默认值、列表、PUT 完整替换和 DELETE 后 404。PUT 必须包含 title、content、pinned 三个可写字段。DELETE 204 响应体应为空,不能再对它调用 json()。",
|
|
|
- "code": "def test_note_lifecycle(client):\n title = f\"acceptance-{uuid4()}\"\n created = client.post(\"/notes\", json={\"title\": title})\n assert created.status_code == 201\n note = created.json()\n note_id = note[\"id\"]\n assert note[\"content\"] == \"\"\n assert note[\"pinned\"] is False\n assert client.get(f\"/notes/{note_id}\").json() == note\n\n listed = client.get(\"/notes\", params={\"q\": title, \"limit\": 100})\n assert listed.status_code == 200\n assert any(item[\"id\"] == note_id for item in listed.json())\n\n replacement = {\"title\": \"更新后\", \"content\": \"测试正文\", \"pinned\": True}\n updated = client.put(f\"/notes/{note_id}\", json=replacement)\n assert updated.status_code == 200\n assert updated.json() == {\"id\": note_id, **replacement}\n assert client.get(f\"/notes/{note_id}\").json() == updated.json()\n\n deleted = client.delete(f\"/notes/{note_id}\")\n assert deleted.status_code == 204\n assert deleted.content == b\"\"\n missing = client.get(f\"/notes/{note_id}\")\n assert missing.status_code == 404\n assert missing.json()[\"error\"][\"code\"] == \"http_error\"",
|
|
|
- "lang": "python",
|
|
|
- "filename": "tests/test_acceptance.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "验证输入错误与受保护接口",
|
|
|
- "body": "空标题应被输入校验拒绝。只有 /private 在本地样例中需要 X-API-Key;不要把笔记的公开练习路由与认证演示混在一起。错误体沿用前面课程的统一结构 error.code / message / details。",
|
|
|
- "code": "def test_validation(client):\n invalid = client.post(\"/notes\", json={\"title\": \"\"})\n assert invalid.status_code == 422\n assert invalid.json()[\"error\"][\"code\"] == \"validation_error\"\n assert client.get(\"/slow\", params={\"delay\": -1}).status_code == 422\n\n\ndef test_api_key(client):\n assert client.get(\"/private\").status_code == 401\n wrong = client.get(\"/private\", headers={\"X-API-Key\": \"wrong-key\"})\n assert wrong.status_code == 401\n assert wrong.json()[\"error\"][\"code\"] == \"http_error\"\n valid = client.get(\"/private\", headers={\"X-API-Key\": \"classroom-key\"})\n assert valid.status_code == 200",
|
|
|
- "lang": "python",
|
|
|
- "filename": "tests/test_acceptance.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "验证 SSE 内容和 WebSocket 来回消息",
|
|
|
- "body": "TestClient 读取有限 SSE 的完整响应后检查协议内容;该测试不用于断言首段到达时间。WebSocket 使用上下文管理器,离开时关闭连接。 如果完成了第 12 课的 stats 扩展,测试允许保留该事件,只对 token/done 的主线顺序和内容断言;done 仍必须是最后一个事件。",
|
|
|
- "code": "def test_stream_matches_complete_result(client):\n complete = client.post(\"/generate\", json={\"prompt\": \"hello\"})\n assert complete.status_code == 200\n response = client.get(\"/stream\", params={\"prompt\": \"hello\"})\n assert response.status_code == 200\n assert response.headers[\"content-type\"].startswith(\"text/event-stream\")\n events = []\n for block in response.text.replace(\"\\r\\n\", \"\\n\").strip().split(\"\\n\\n\"):\n fields = {}\n for line in block.split(\"\\n\"):\n name, value = line.split(\":\", 1)\n fields[name] = value.lstrip(\" \")\n events.append((fields[\"event\"], json.loads(fields[\"data\"])))\n assert [name for name, _ in events if name != \"stats\"] == [\"token\", \"token\", \"token\", \"done\"]\n text = \"\".join(data[\"text\"] for name, data in events if name == \"token\")\n assert text == complete.json()[\"text\"]\n assert events[-1] == (\"done\", {})\n\n\ndef test_websocket(client):\n with client.websocket_connect(\"/ws\") as socket:\n socket.send_text(\"hello\")\n assert socket.receive_json() == {\"type\": \"echo\", \"text\": \"hello\"}\n socket.send_text(\"ping\")\n assert socket.receive_json() == {\"type\": \"pong\"}",
|
|
|
- "lang": "python",
|
|
|
- "filename": "tests/test_acceptance.py"
|
|
|
- },
|
|
|
- {
|
|
|
- "title": "运行自动验收,再测真实网络",
|
|
|
- "body": "在 api-lab 执行 python -m pytest -q tests/test_acceptance.py。通过后另开终端运行 python -m uvicorn main:app --reload,再运行下面脚本。SSE 的 data 行应分批出现,时间通常约为 0.4、0.8、1.2 秒,而非都等到最后。最后在浏览器完成 CORS、主动停止流、WebSocket 断开重连三个场景。",
|
|
|
- "code": "from time import perf_counter\nimport httpx\n\nwith httpx.Client(base_url=\"http://127.0.0.1:8000\", timeout=5.0) as client:\n started = perf_counter()\n with client.stream(\"GET\", \"/stream\", params={\"prompt\": \"hello\"}) as response:\n response.raise_for_status()\n for line in response.iter_lines():\n if line.startswith(\"data:\"):\n print(f\"{perf_counter() - started:.2f}s {line}\", flush=True)",
|
|
|
- "lang": "python",
|
|
|
- "filename": "verify_stream_network.py"
|
|
|
- }
|
|
|
- ],
|
|
|
- "checks": [
|
|
|
- "自动测试覆盖健康检查、完整 CRUD、422 校验、401 认证、删除后 404、SSE 与 WebSocket,运行结果全部通过。",
|
|
|
- "真实 HTTPX / curl 客户端在响应结束前接收到 token,浏览器停止读取后服务端执行清理。",
|
|
|
- "允许来源的浏览器能够调用 API;未允许来源不能读取响应;WebSocket 主动停止后不再重连。"
|
|
|
- ],
|
|
|
- "pitfalls": [
|
|
|
- {
|
|
|
- "symptom": "单个测试能过,整套测试偶尔失败",
|
|
|
- "cause": "多个测试假定相同 id 或依赖前一个测试创建的数据。",
|
|
|
- "fix": "每个测试自行创建记录,用返回 id 操作,并在生命周期测试中删除。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "测试里 app.state.http 不存在",
|
|
|
- "cause": "没有通过 with TestClient(app) 启动 lifespan,或代码未正确注册 lifespan。",
|
|
|
- "fix": "在 fixture 的上下文中创建客户端,并核对 FastAPI(lifespan=lifespan)。"
|
|
|
- },
|
|
|
- {
|
|
|
- "symptom": "SSE 测试通过,网页仍一次显示全部内容",
|
|
|
- "cause": "进程内测试只检查完整内容,没有经过浏览器、真实网络和中间服务。",
|
|
|
- "fix": "用真实 Uvicorn 配合 httpx.stream / curl -N 观察时间,再检查浏览器读取与渲染。"
|
|
|
- }
|
|
|
- ],
|
|
|
- "exercise": {
|
|
|
- "task": "增加一个测试:创建笔记后,PUT 只发送 title 应返回 422,随后 GET 仍返回原来的完整内容。",
|
|
|
- "hint": "输入不合法时不能发生部分更新;先保存创建响应,再比较读取结果。",
|
|
|
- "answer": "创建笔记并保存 note;使用 client.put(f'/notes/{note[\"id\"]}', json={\"title\":\"修改\"}),断言 422;再次 GET 并断言等于 note,最后 DELETE 清理。这个测试同时检查完整替换约定和校验失败不写入的行为。"
|
|
|
- },
|
|
|
- "refs": [
|
|
|
- {
|
|
|
- "label": "FastAPI:TestClient 与 pytest",
|
|
|
- "url": "https://fastapi.tiangolo.com/tutorial/testing/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:测试 lifespan",
|
|
|
- "url": "https://fastapi.tiangolo.com/advanced/testing-events/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "FastAPI:测试 WebSocket",
|
|
|
- "url": "https://fastapi.tiangolo.com/advanced/testing-websockets/"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "pytest:fixtures",
|
|
|
- "url": "https://docs.pytest.org/en/stable/how-to/fixtures.html"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "pytest:临时环境变量",
|
|
|
- "url": "https://docs.pytest.org/en/stable/how-to/monkeypatch.html"
|
|
|
- },
|
|
|
- {
|
|
|
- "label": "HTTPX:流式响应",
|
|
|
- "url": "https://www.python-httpx.org/quickstart/"
|
|
|
- }
|
|
|
- ]
|
|
|
- }
|
|
|
-]
|
|
|
+[
|
|
|
+ {
|
|
|
+ "id": "01",
|
|
|
+ "group": "起步与 HTTP",
|
|
|
+ "title": "启动第一个 FastAPI 服务",
|
|
|
+ "summary": "从空文件夹开始,在浏览器看到 {\"status\":\"ok\"},修改文件后还能看到结果随之变化。",
|
|
|
+ "objectives": [
|
|
|
+ "亲手创建文件夹、安装所需包并写出 main.py",
|
|
|
+ "启动服务后用浏览器和文档页面拿到同一结果",
|
|
|
+ "改一行内容并验证更新,最后能正常停止和重新启动"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "独立环境解决“用哪个 Python”",
|
|
|
+ "body": "回看第 3、4 步,.venv 创建环境,激活选择当前终端使用的环境,sys.executable 给出证据;python -m pip 把包安装到同一个 Python 下。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "路由把地址与函数接起来",
|
|
|
+ "body": "第 9、10 步通过两种入口都拿到 ok:GET /health 先匹配 @app.get,再运行 health,字典被转换成 JSON。状态码 200 与 JSON 内容各自承担不同信息。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "服务进程需要保持运行",
|
|
|
+ "body": "第 8 步的终端持续等待,第 11 步 Ctrl+C 后连接失败。Uvicorn 持续接收请求;main:app 指向代码对象,127.0.0.1:8000 指向本机接收位置。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "自动重载是开发时的便利",
|
|
|
+ "body": "第 11 步保存文件就看到 ready,这是 --reload 的效果。它会重新启动应用,后面用内存保存的商品也会随重载清空,不能把它当成持久保存。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "打开终端,确认 Python 可以使用",
|
|
|
+ "lang": "text",
|
|
|
+ "code": "Windows PowerShell:py -3 --version\nmacOS / Linux 终端:python3 --version",
|
|
|
+ "body": "Windows 打开开始菜单中的 PowerShell;macOS 打开“终端”;Linux 打开终端应用。下方两行是系统对应表,只输入与你的系统对应的冒号后命令,不要复制系统名称,也不要两行都执行。",
|
|
|
+ "expected": "终端显示 Python 3.11.x 或更高版本,然后重新出现输入提示符。如果显示“找不到命令”,先检查 Python 安装。",
|
|
|
+ "explanation": "终端是输入命令并查看程序输出的窗口。--version 只询问 Python 的版本,还没有启动我们的商品服务;Windows 常用 py -3 选择 Python 3,另外两个系统常用 python3。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "创建商品服务的工作文件夹",
|
|
|
+ "lang": "bash",
|
|
|
+ "code": "mkdir api-lab\ncd api-lab",
|
|
|
+ "body": "在编辑器打开准备放练习的父文件夹,并在这里打开终端;也可以先用 cd 切换到该位置。依次执行下面两行。若 api-lab 已存在,先确认它是本课练习目录,再只执行 cd api-lab,不要覆盖已有项目。",
|
|
|
+ "expected": "文件管理器中出现 api-lab;终端当前路径末尾为 api-lab。此时目录里还没有 main.py,这正是预期起点。",
|
|
|
+ "explanation": "mkdir 创建文件夹,cd 把终端的工作位置切换进去。后面不写绝对路径的命令都会以这里为起点,因此文件放错目录会导致启动时找不到 main.py。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "为这个项目创建独立环境",
|
|
|
+ "lang": "bash",
|
|
|
+ "code": "# Windows PowerShell:只执行这两行\npy -3 -m venv .venv\n.\\.venv\\Scripts\\Activate.ps1\n\n# macOS / Linux:只执行这两行\npython3 -m venv .venv\nsource .venv/bin/activate",
|
|
|
+ "body": "在刚才的终端中,只执行下方对应系统的两行。第一行可能需要等待片刻。Windows 如果提示脚本被禁止,不修改全局策略,改用 .\\.venv\\Scripts\\python.exe 代替本课后续命令开头的 python。",
|
|
|
+ "expected": "api-lab 中出现 .venv 文件夹;激活后提示符通常显示 (.venv)。提示符未显示时先做下一步路径检查,不仅凭外观判断。",
|
|
|
+ "explanation": "刚建立的 .venv 是这个项目专用的 Python 环境,也叫虚拟环境。它让之后安装的包放在本项目下。激活只把当前终端的 python 命令指向该环境,关掉终端后需要重新进入目录并激活。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "确认后续命令用的是项目里的 Python",
|
|
|
+ "lang": "bash",
|
|
|
+ "code": "python -c \"import sys; print(sys.executable)\"",
|
|
|
+ "body": "执行下面的检查命令。若第 3 步采用了 Windows 不激活方案,将开头的 python 换成 .\\.venv\\Scripts\\python.exe。编辑器也选择此路径作为 Python 解释器。",
|
|
|
+ "expected": "输出路径包含 api-lab\\.venv 或 api-lab/.venv。如果指向系统安装目录,回到第 3 步激活后重试。",
|
|
|
+ "explanation": "sys.executable 显示这次实际运行的 Python 路径。我们现在确认的是“将由谁安装包、由谁运行代码”,从而避免包装在一个环境、服务却在另一个环境启动。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "写下本项目要安装的三个包",
|
|
|
+ "body": "在编辑器中打开 api-lab,创建 requirements.txt,把下方三行原样保存为文件内容;这三行不是终端命令。确认文件名没有变成 requirements.txt.txt。下载完整示例时可沿用包内已锁定的 requirements.txt。",
|
|
|
+ "filename": "requirements.txt",
|
|
|
+ "lang": "bash",
|
|
|
+ "code": "fastapi>=0.115,<1\nuvicorn[standard]>=0.30,<1\npydantic>=2,<3",
|
|
|
+ "expected": "api-lab/requirements.txt 中有三行包名与版本范围,和 .venv 位于同一层。到这一步没有安装输出是正常的。",
|
|
|
+ "explanation": "FastAPI 帮我们处理网页发来的请求;Uvicorn 负责持续接收请求并把它交给程序;Pydantic 后面负责检查商品字段。>= 和 < 指定可用版本范围,本课程使用 Pydantic 2 的写法。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "安装包并记录实际版本",
|
|
|
+ "lang": "bash",
|
|
|
+ "code": "python -m pip install -r requirements.txt\npython -m pip freeze > requirements-lock.txt",
|
|
|
+ "body": "回到已通过路径检查的终端,逐行运行下面命令。等待第一条没有错误地结束,再运行第二条;如果第一条下载失败,先按报错检查网络,再重新执行安装。",
|
|
|
+ "expected": "安装过程结束且没有 ERROR;已经安装时会看到 Requirement already satisfied。随后出现 requirements-lock.txt,里面列出实际安装的精确版本。",
|
|
|
+ "explanation": "python -m pip 让刚确认过的 Python 运行它的包安装工具;-r 指示从文件读取包清单。freeze 把实际版本写下来,今后可用 python -m pip install -r requirements-lock.txt 重建相同版本的环境。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "写下第一个能返回结果的接口",
|
|
|
+ "body": "在 api-lab 根目录创建 main.py,粘贴下面完整代码并保存。注意文件名和缩进:return 前有四个空格;此时只创建一次 app,不把这段代码重复粘贴两遍。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "from fastapi import FastAPI\n\napp = FastAPI(title=\"API Lab\", version=\"0.1.0\")\n\n@app.get(\"/health\", tags=[\"system\"])\ndef health():\n return {\"status\": \"ok\"}",
|
|
|
+ "expected": "编辑器中 main.py 与 requirements.txt 同级。保存文件本身不会让浏览器出现结果,下一步才真正运行服务。",
|
|
|
+ "explanation": "app = FastAPI(...) 建立应用对象;紧挨 health 函数上方的 @app.get(\"/health\") 把 GET /health 与这个函数关联,这种关联叫路由。请求到来时函数返回 Python 字典,框架再把字典转换成网页能收到的 JSON 文本。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "运行 main.py 中的服务",
|
|
|
+ "body": "在 main.py 所在的 api-lab 终端运行下面命令,保留这个窗口。若 8000 被占用,按 Ctrl+C 后在命令末尾加 --port 8001 重试,并把后续地址中的 8000 都换成 8001。",
|
|
|
+ "lang": "bash",
|
|
|
+ "code": "python -m uvicorn main:app --reload",
|
|
|
+ "expected": "终端出现 Uvicorn running on http://127.0.0.1:8000 和 Application startup complete,随后一直等待。若出现 Could not import module,先检查当前目录和 main.py 的拼写。",
|
|
|
+ "explanation": "main:app 指从 main.py 取出 app;127.0.0.1 指本机,8000 是这次接收请求的端口。终端一直等待的是服务进程,它不会像普通脚本那样执行一次就结束。--reload 让它在保存代码后重新加载,适合本机开发。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "在浏览器里收到第一条结果",
|
|
|
+ "lang": "text",
|
|
|
+ "code": "http://127.0.0.1:8000/health\n\n预期页面内容:{\"status\":\"ok\"}",
|
|
|
+ "body": "在浏览器地址栏输入下方第一行地址并回车。下方“预期页面内容”仅用于对照,不是要输入的命令。保留终端,刷新页面一次,查看终端是否多出访问记录。",
|
|
|
+ "expected": "浏览器显示 {\"status\":\"ok\"}(空格与排版可能不同),终端出现 GET /health 200。直接访问没有 /health 的根地址会得到 404,并不表示服务没启动。",
|
|
|
+ "explanation": "浏览器访问地址发送了一次 GET 请求;/health 选中了我们刚写的函数。200 是服务对此次请求给出的成功状态;字典转换后的 {\"status\":\"ok\"} 是响应内容。404 则表示程序没有为该地址注册处理函数。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "用文档页面发送同一请求",
|
|
|
+ "lang": "text",
|
|
|
+ "code": "http://127.0.0.1:8000/docs\n\nGET /health → Try it out → Execute",
|
|
|
+ "body": "在浏览器打开下面的 /docs 地址,展开 GET /health,依次点击 Try it out 和 Execute。向下找到 Server response,比较 Code 与 Response body;这里不需要填写任何商品字段。",
|
|
|
+ "expected": "Code 为 200,Response body 仍为 {\"status\":\"ok\"}。每点击一次 Execute,终端都会出现新的请求记录。",
|
|
|
+ "explanation": "/docs 是 FastAPI 根据路由生成的交互文档,Execute 会真实发送请求。浏览器地址栏和这个按钮是两种客户端入口,最终调用的是同一个 health 函数。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "修改一处返回值,看到服务更新",
|
|
|
+ "lang": "text",
|
|
|
+ "code": "修改前:return {\"status\": \"ok\"}\n观察时:return {\"status\": \"ready\"}\n完成后:return {\"status\": \"ok\"}",
|
|
|
+ "body": "仅把 main.py 中 return 字典里的 ok 改为 ready 并保存,等终端完成重载后刷新 /health。看到变化后改回 ok 再刷新。最后按 Ctrl+C 停止服务,再执行上一条启动命令,为下一课保留运行状态。",
|
|
|
+ "expected": "页面先显示 ready,恢复后显示 ok。Ctrl+C 后刷新可能连接失败;重新启动后又能收到 ok。",
|
|
|
+ "explanation": "我们刚看到 --reload 在文件保存后重启应用;因此不需要每次手工重启。服务停止后浏览器无法获得响应,说明结果来自正在运行的程序,而不是仅仅因为磁盘上有 main.py。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "终端的 sys.executable 路径包含 api-lab/.venv。",
|
|
|
+ "GET /health 返回 200 和 {\"status\":\"ok\"};访问未定义的 / 返回 404,这不表示服务未启动。",
|
|
|
+ "/docs 中出现 GET /health,点击 Execute 后能看到真实响应。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "ModuleNotFoundError: No module named 'fastapi'",
|
|
|
+ "cause": "安装依赖和启动服务使用了不同解释器。",
|
|
|
+ "fix": "激活 .venv,检查 sys.executable,再执行 python -m pip install -r requirements.txt。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "Could not import module 'main'",
|
|
|
+ "cause": "当前目录没有 main.py,或者文件中存在语法错误。",
|
|
|
+ "fix": "切换到 api-lab 根目录,确认文件名为 main.py,阅读终端中最早出现的 Python 错误。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "PowerShell 阻止 Activate.ps1 运行",
|
|
|
+ "cause": "当前执行策略限制脚本运行。",
|
|
|
+ "fix": "无需修改全局策略,可直接使用 .\\.venv\\Scripts\\python.exe -m pip install -r requirements.txt,以及 .\\.venv\\Scripts\\python.exe -m uvicorn main:app --reload。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "添加 GET /version,返回 {\"service\":\"api-lab\",\"version\":\"0.1.0\"},并在 /docs 中执行一次。",
|
|
|
+ "hint": "保留已有 app,在另一个函数上方注册新的 GET 路由。",
|
|
|
+ "answer": "在 main.py 末尾添加:\n@app.get('/version')\ndef version():\n return {'service': 'api-lab', 'version': '0.1.0'}\n\n保存后访问 /version,应返回 200;刷新 /docs 后应同时看到 /health 与 /version。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "Python:venv",
|
|
|
+ "url": "https://docs.python.org/3/library/venv.html"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:First Steps",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/first-steps/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:手动运行服务器",
|
|
|
+ "url": "https://fastapi.tiangolo.com/deployment/manually/"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "我们要逐步做一个只管理陶瓷杯、帆布袋的小商品目录。这一课先不录入商品,只做一个能回答“服务现在能回应吗”的地址;浏览器能看到 ok,后面的商品操作就有了入口。后续只涉及商品名称、描述、整数价格分、上架状态与简单分类,不加入订单、支付或库存扣减。",
|
|
|
+ "startingPoint": [
|
|
|
+ "会用编辑器创建并保存文本文件,会读简单的 Python 函数和字典;不需要先学过网站开发。",
|
|
|
+ "电脑已安装 Python 3.11 或更新版本,并准备好一个代码编辑器。若第 1 步提示找不到命令,先完成 Python 安装再继续。",
|
|
|
+ "选一个你有写入权限的练习位置,例如“文档”。从零跟做时创建新的 api-lab 文件夹;若使用下载的完整示例,请另建一个练习文件夹,避免把完整项目与本课逐步代码混写。"
|
|
|
+ ],
|
|
|
+ "outcome": "从空文件夹开始,在浏览器看到 {\"status\":\"ok\"},修改文件后还能看到结果随之变化。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "02",
|
|
|
+ "group": "起步与 HTTP",
|
|
|
+ "title": "读懂一次 HTTP 请求与响应",
|
|
|
+ "summary": "发送包含陶瓷杯信息的请求,收到原样返回的数据,并能指出状态码和商品数据分别在哪里。",
|
|
|
+ "demo": "http",
|
|
|
+ "objectives": [
|
|
|
+ "对照一次 /health 访问找到请求地址与返回内容",
|
|
|
+ "把商品信息保存成 JSON 文件并从终端发送",
|
|
|
+ "修改商品名称再次发送,确认服务收到了新值"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "一次往返要分方向阅读",
|
|
|
+ "body": "第 3、4 步修改文件后,received 随之变化,证明请求体先传到服务器,再作为响应的一部分返回。方法、路径和头部告诉服务如何处理,JSON 则承载商品字段。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "格式说明与字段内容分开",
|
|
|
+ "body": "第 3 步同时发送 Content-Type: application/json 和文件内容:头部声明格式,文件提供实际数据。Accept 只是客户端偏好,不会自动改变接口实现。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "200 不等于商品已入库",
|
|
|
+ "body": "本课第 4 步得到 200 但没有 id,函数也没有保存代码。状态码说明本接口操作是否成功,是否真正创建商品还要看接口约定和后续读取结果。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "观察最小 GET 请求",
|
|
|
+ "body": "在浏览器打开 /docs,展开 GET /health 并点击 Try it out → Execute。把下方协议展示与 Server response 对照;该代码块是请求和响应的阅读示意,不要粘贴到终端。",
|
|
|
+ "lang": "http",
|
|
|
+ "code": "GET /health HTTP/1.1\nHost: 127.0.0.1:8000\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"status\":\"ok\"}",
|
|
|
+ "expected": "/docs 的 Code 是 200,Response body 是 {\"status\":\"ok\"};展示中的 GET /health 对应这次访问,空行后的 JSON 对应返回内容。",
|
|
|
+ "explanation": "GET 是请求方法,/health 是路径,Host 指访问的主机和端口。头部与内容之间用空行分开;这个 GET 没有携带请求体,仍然可以收到响应体。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "添加临时的 POST 回显接口",
|
|
|
+ "body": "把下面完整代码追加到 api-lab/main.py 末尾,保存并等待重载。本课只写这一份 POST /products,稍后第 03 课会将它替换。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "from fastapi import Request\n\n@app.post(\"/products\")\nasync def inspect_product(request: Request):\n data = await request.json()\n return {\"received\": data}",
|
|
|
+ "expected": "刷新 /docs 后出现 POST /products;这时 GET /health 仍可返回 ok。若导入失败,检查 Request 导入和函数缩进。",
|
|
|
+ "explanation": "Request 让函数拿到原始请求;await request.json() 读取并解码发送来的 JSON,received 包住这份数据再返回。async / await 先按示例使用,第 09 课会用计时实验解释等待;这个函数没有任何保存动作。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "准备 JSON 文件并发送 POST",
|
|
|
+ "body": "在 api-lab/product.json 保存下方内容,使用 UTF-8。macOS / Linux 在第二个终端执行 curl -i -X POST http://127.0.0.1:8000/products -H \"Content-Type: application/json\" --data-binary @product.json;Windows PowerShell 使用 curl.exe -i -X POST http://127.0.0.1:8000/products -H \"Content-Type: application/json\" --data-binary \"@product.json\"。文件内容不是终端命令。",
|
|
|
+ "filename": "product.json",
|
|
|
+ "lang": "json",
|
|
|
+ "code": "{\n \"name\": \"ceramic mug\",\n \"description\": \"learn HTTP\",\n \"is_active\": false, \"price_cents\": 0\n}",
|
|
|
+ "expected": "终端显示 HTTP 200,并在 received 中看到 ceramic mug、learn HTTP、false 和 0;如果提示打不开文件,先确认第二个终端位于 api-lab。",
|
|
|
+ "explanation": "-X POST 选择发送方式,--data-binary 从文件读取请求体;Content-Type 告诉服务按 JSON 理解这些字节,-i 让你同时看到响应头。JSON 的键与字符串用双引号,false 用小写;价格 0 分表示免费教学样品。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "对照原始请求与服务器结果",
|
|
|
+ "body": "把 product.json 的 name 改为 canvas bag 并保存,再执行刚才的 curl 命令。对照下方协议示意找到请求体和 received;在浏览器 F12 → Network 中也可以查看刚执行的 /health 请求。",
|
|
|
+ "lang": "http",
|
|
|
+ "code": "POST /products HTTP/1.1\nHost: 127.0.0.1:8000\nContent-Type: application/json\n\n{\"name\":\"ceramic mug\",\"description\":\"learn HTTP\",\"is_active\":false, \"price_cents\": 0}\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"received\":{\"name\":\"ceramic mug\",\"description\":\"learn HTTP\",\"is_active\":false, \"price_cents\": 0}}",
|
|
|
+ "expected": "第二次响应中 name 变成 canvas bag。HTTP 状态仍为 200;它只确认回显完成,并没有生成商品 id 或证明商品已保存。",
|
|
|
+ "explanation": "请求体是发给服务的数据,响应体是服务返回的数据,长得相似也不是同一方向。Content-Type 描述实际格式,Accept 表达希望收到的格式;200 等状态属于 HTTP,不能靠在 JSON 里写一个 code 字段替代。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "能说明查询参数属于 URL,请求体位于请求头之后,两者不是同一块数据。",
|
|
|
+ "命令行 POST 返回 200,并在 received 中保留 product.json 的四个字段。",
|
|
|
+ "浏览器直接打开 /products 会发出 GET;当前只注册了 POST,因此应返回 405 Method Not Allowed。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "PowerShell 提示无法识别 -H 参数",
|
|
|
+ "cause": "使用了旧版 PowerShell 的 curl 别名。",
|
|
|
+ "fix": "明确执行 curl.exe;或者在 /docs 中操作。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "请求体解析失败",
|
|
|
+ "cause": "把 Python 字典写法当成 JSON,例如使用单引号或 False。",
|
|
|
+ "fix": "使用 product.json 文件,字符串和字段名用双引号,布尔值写 true / false。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "浏览器地址栏打不开 POST 接口",
|
|
|
+ "cause": "地址栏导航通常发送 GET,没有发送预期的 POST 和 JSON 请求体。",
|
|
|
+ "fix": "使用 curl、/docs 或能够配置 HTTP 方法的客户端。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "把请求体的 name 改成 second product,再发送一次。记录响应的 HTTP 状态码、Content-Type 和 received.name;最后说明 Accept 和 Content-Type 的区别。",
|
|
|
+ "hint": "使用 -i 查看响应头,不要只看最后一行 JSON。",
|
|
|
+ "answer": "状态码为 200;响应 Content-Type 为 application/json;received.name 为 second product。请求 Content-Type 描述这次发送的数据格式,Accept 描述客户端希望接收的格式。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "RFC 9110:HTTP 消息与内容语义",
|
|
|
+ "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-6"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "RFC 8259:JSON 语法",
|
|
|
+ "url": "https://www.rfc-editor.org/rfc/rfc8259"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:直接使用 Request",
|
|
|
+ "url": "https://fastapi.tiangolo.com/advanced/using-request-directly/"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "接下来要把“陶瓷杯”从电脑发给服务。先做一个原样回信的接口,看到名称确实往返一次,再学习保存商品。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 01 课,在 api-lab 中能启动 main.py;浏览器 /health 显示 ok。",
|
|
|
+ "继续使用同一 .venv 和 8000 端口;保留服务终端,另开一个已进入 api-lab 的终端发请求。",
|
|
|
+ "本课会新建 product.json;POST 回显只用于看数据,不会把商品保存到列表。"
|
|
|
+ ],
|
|
|
+ "outcome": "发送包含陶瓷杯信息的请求,收到原样返回的数据,并能指出状态码和商品数据分别在哪里。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "03",
|
|
|
+ "group": "起步与 HTTP",
|
|
|
+ "title": "从路径、查询参数和 JSON 中接收数据",
|
|
|
+ "summary": "让服务接收商品编号、列表条件和商品 JSON,看到默认字段被补齐,并用错误位置找出填错的项。",
|
|
|
+ "objectives": [
|
|
|
+ "分别通过地址、问号后的条件和 JSON 发送输入",
|
|
|
+ "只填写商品名称,观察其余字段的默认值",
|
|
|
+ "输入错误编号、超界数量和缺失名称,读出具体错误位置"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "三种位置,各有明确用途",
|
|
|
+ "body": "本课分别用 /products/12 指定编号,用 ?limit=5 控制读取条件,用 POST 的 JSON 描述商品。路径决定找谁,查询条件决定怎么读,请求体承载要提交的字段。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "声明规则后,先检查再执行",
|
|
|
+ "body": "abc 和 limit=0 都在第 2、3 步被拒绝,函数不需要重复手写转换和范围判断。422 的 loc 标识实际失败位置,是比猜错误原因更直接的线索。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "可省略、可为 null、取默认值是不同约定",
|
|
|
+ "body": "第 4 步省略描述后得到空串,省略价格后得到 0。is_active 查询条件省略时为 None;str | None 允许 null,但能否省略还取决于是否提供默认值。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "声明创建商品的输入模型",
|
|
|
+ "body": "在 api-lab/main.py 中,先删除第 02 课 inspect_product 函数及它上方的 @app.post 装饰器。把下面模型放在 app 创建之后、各路由函数之前;POST 会在第 4 步重新添加。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "from pydantic import BaseModel, Field\n\nclass ProductCreate(BaseModel):\n name: str\n description: str = Field(default=\"\", max_length=16000)\n is_active: bool = False\n price_cents: int = Field(default=0, ge=0, le=2147483647, strict=True)",
|
|
|
+ "expected": "文件保存后可正常重载,/health 可访问;此时 /docs 暂时没有 POST /products 是正常的,因为我们先准备它的输入说明。",
|
|
|
+ "explanation": "ProductCreate 是一个输入模型:它集中说明一个商品 JSON 应有哪些字段。name 后没有默认值,所以必须提供;description、is_active、price_cents 有默认值。价格是整数分,1990 表示 19.90 元,strict=True 使价格不接受小数、字符串或布尔值。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "添加路径参数",
|
|
|
+ "body": "在 main.py 末尾追加下方 GET 路由,保存后在浏览器分别打开 /products/12 和 /products/abc。这里返回解析结果,暂不查找真实商品。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "@app.get(\"/products/{product_id}\")\ndef inspect_product_id(product_id: int):\n return {\"product_id\": product_id}",
|
|
|
+ "expected": "/products/12 返回 {\"product_id\":12};/products/abc 返回 422,detail 中 loc 包含 path 和 product_id。",
|
|
|
+ "explanation": "{product_id} 是路径中预留的位置;函数参数 product_id: int 要求该位置能转为整数。abc 无法满足要求,框架在进入函数前就拒绝请求,这就是类型声明参与实际检查。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "添加查询参数和范围",
|
|
|
+ "body": "继续追加下方 GET /products 路由。依次访问 /products、/products?limit=5&is_active=false、/products?limit=0,记录三个响应。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "from typing import Annotated\nfrom fastapi import Query\n\n@app.get(\"/products\")\ndef inspect_filters(\n limit: Annotated[int, Query(ge=1, le=100)] = 10,\n is_active: bool | None = None,\n):\n return {\"limit\": limit, \"is_active\": is_active}",
|
|
|
+ "expected": "不写条件时返回 limit=10、is_active=null;第二次为 5 和 false;第三次返回 422,错误位置为 query 中的 limit。",
|
|
|
+ "explanation": "问号后的 key=value 是查询参数,多个条件用 & 连接。Annotated 将类型和 Query 范围写在一起;ge=1、le=100 限制上下界。None 表示没有给上架筛选条件,false 表示明确查下架商品,两者不能混用。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "用类型模型替换原始请求体",
|
|
|
+ "body": "在 main.py 中添加下方新的 POST /products,确保没有残留第二个同方法路由。刷新 /docs,在它的请求体框中只填 {\"name\":\"ceramic mug\"} 并 Execute。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "@app.post(\"/products\")\ndef inspect_product_body(product: ProductCreate):\n return product.model_dump()",
|
|
|
+ "expected": "响应中除 name 外还出现 description=\"\"、is_active=false、price_cents=0;仍没有 id,且不会加入任何商品列表。",
|
|
|
+ "explanation": "product: ProductCreate 把刚才的字段规则接到了接口上,输入被解析后才交给函数。model_dump() 再把模型变回普通 Python 字典,因此你看到了已经补齐的默认值。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "建立有效值和无效值对照",
|
|
|
+ "body": "在 /docs 按下方请求清单执行四次,GET 的参数填写在相应框中,POST 的 {} 填到请求体框;清单是操作对照,不是 Shell 脚本。将状态和 loc 记到自己的笔记中。",
|
|
|
+ "lang": "http",
|
|
|
+ "code": "GET /products/12\n# 200: {\"product_id\":12}\n\nGET /products?limit=5&is_active=false\n# 200: {\"limit\":5,\"is_active\":false}\n\nGET /products?limit=0\n# 422,错误位置包含 [\"query\",\"limit\"]\n\nPOST /products\nContent-Type: application/json\n\n{}\n# 422,错误位置包含 [\"body\",\"name\"]",
|
|
|
+ "expected": "前两次返回 200,limit=0 和空商品对象返回 422;错误分别指向 query.limit 与 body.name,和路径填 abc 的 path.product_id 可以区分。",
|
|
|
+ "explanation": "相同的 422 可以来自不同输入位置,loc 帮你知道该改地址、查询条件还是 JSON。我们现在只检查了类型、默认值和数值范围;名称长度与多余字段下一课再逐项收紧。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "GET /products/12 返回数字 12,/products/abc 返回 422。",
|
|
|
+ "GET /products 返回 {\"limit\":10,\"is_active\":null};?is_active=false 返回 false。",
|
|
|
+ "POST /products 只传 name 时补齐 description、is_active 和 price_cents;缺少 name 或价格为负数、小数时返回 422。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "把 product_id 放进查询字符串,得到列表而不是单件商品",
|
|
|
+ "cause": "GET /products?product_id=12 匹配的是 GET /products。本课列表路由没有声明 product_id,FastAPI 默认忽略这个额外查询参数,因此返回 200 和列表;它不会匹配 /products/{product_id}。",
|
|
|
+ "fix": "读取单件商品时改为 GET /products/12。先检查方法和路径,再检查查询参数;只有确实没有匹配路由或资源不存在时才会得到相应的 404。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "写了 name: str | None 后,省略 name 仍然报错",
|
|
|
+ "cause": "允许 null 和允许省略是两个条件。",
|
|
|
+ "fix": "如果业务允许省略,显式写默认值 = None;本例 name 应保持必填字符串。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "修改 POST /products 后响应还像上一课",
|
|
|
+ "cause": "旧路由没有移除,重复注册了相同方法和路径。",
|
|
|
+ "fix": "搜索 @app.post('/products'),只保留当前一个定义,保存并确认服务完成重载。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "为 GET /products 添加 offset 查询参数,默认 0,只允许非负整数,并把解析结果加进响应。验证 offset=3 与 offset=-1。",
|
|
|
+ "hint": "参考 limit 的 Annotated[int, Query(...)],使用 ge=0。",
|
|
|
+ "answer": "在函数参数中加入 offset: Annotated[int, Query(ge=0)] = 0,并在返回字典中加入 'offset': offset。?offset=3 返回 200 且 offset 为 3;?offset=-1 返回 422,loc 包含 query、offset。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "FastAPI:路径参数",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/path-params/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:查询参数",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/query-params/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:请求体",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/body/"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "陶瓷杯的数据已经能往返,但服务还没有区分“商品编号”“最多看几条”“商品名称”。现在让三类输入各自进入合适的位置,并试试填错时会怎样。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 02 课,api-lab/main.py 中有 /health 和临时 POST /products;product.json 可用于回显。",
|
|
|
+ "继续使用已激活的 .venv 和 /docs;本课会删除临时 POST 函数并换成新函数,其余课程尚未要求保存商品。"
|
|
|
+ ],
|
|
|
+ "outcome": "让服务接收商品编号、列表条件和商品 JSON,看到默认字段被补齐,并用错误位置找出填错的项。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "04",
|
|
|
+ "group": "起步与 HTTP",
|
|
|
+ "title": "用 Pydantic 定义输入和输出契约",
|
|
|
+ "summary": "让空名称和拼错字段被明确拒绝,让商品响应只包含五个公开字段,并区分输入错误与服务端输出错误。",
|
|
|
+ "objectives": [
|
|
|
+ "给商品名称加长度规则并拒绝陌生字段",
|
|
|
+ "规定返回给调用方的五个商品字段",
|
|
|
+ "分别制造输入与输出错误,修复后用 /docs 核对约定"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "输入规则来自具体失败样例",
|
|
|
+ "body": "空名称触发 Field,is_actve 触发 extra=\"forbid\",价格保持非负整数分。第 1 步的对照证明这些规则确实执行,而不只是写在文档里。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "输出有单独的公开边界",
|
|
|
+ "body": "第 3 步内部字典有 internal_source,但响应没有;ProductOut 负责规定公开字段及其类型。输入不能自行指定 id,输出却必须有 id。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "422 与 500 指向不同修改者",
|
|
|
+ "body": "第 4 步同样是字段问题:调用方漏或错输入得到 422,服务器漏输出 id 得到 500。修复后再用正常请求验收,不能只看错误实验报错就结束。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "收紧 ProductCreate 的输入规则",
|
|
|
+ "body": "在 api-lab/main.py 中用下面完整定义替换旧 ProductCreate,保留其余路由。保存后在 /docs 分别发送 {\"name\":\"\"} 和 {\"name\":\"cup\",\"is_actve\":true}。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "from pydantic import BaseModel, ConfigDict, Field\n\nclass ProductCreate(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n\n name: str = Field(min_length=1, max_length=120)\n description: str = Field(default=\"\", max_length=16000)\n is_active: bool = False\n price_cents: int = Field(default=0, ge=0, le=2147483647, strict=True)",
|
|
|
+ "expected": "两次都返回 422:第一次指出 name 太短,第二次指出 is_actve 是多余字段;正常名称仍可以通过。",
|
|
|
+ "explanation": "Field 把长度要求写在字段旁;ConfigDict(extra=\"forbid\") 拒绝没有声明的键,使拼写错误不再被静默忽略。价格的严格整数要求只约束 price_cents,不会把其他字段自动变成严格模式。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "增加响应模型",
|
|
|
+ "body": "在 ProductCreate 后、各路由前添加 ProductOut。逐行对照输入模型,先找出多出来的 id;此步骤只定义返回格式,还没有接到接口。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "class ProductOut(BaseModel):\n id: int\n name: str\n description: str = Field(max_length=16000)\n is_active: bool\n price_cents: int = Field(ge=0, le=2147483647, strict=True)",
|
|
|
+ "expected": "文件可重载;POST 暂时仍按上一课返回,因为 response_model 要到下一步才启用。ProductOut 明确列出 id 与四个可写字段。",
|
|
|
+ "explanation": "输入模型描述调用方填写的商品内容,输出模型还包含服务生成的编号。两份模型分别声明,避免把禁止额外输入的设置错误地带到下面的输出过滤实验。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "用 response_model 约束 POST 响应",
|
|
|
+ "body": "用下方装饰器和函数替换原 POST /products,在 /docs 发送 {\"name\":\"ceramic mug\"}。将函数返回字典与实际响应逐字段比较。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "@app.post(\"/products\", response_model=ProductOut)\ndef inspect_product_body(product: ProductCreate):\n return {\n \"id\": 1,\n **product.model_dump(),\n \"internal_source\": \"validation-lesson\",\n }",
|
|
|
+ "expected": "收到 id=1、name、description、is_active、price_cents;代码中的 internal_source 没有出现在 HTTP 响应中。",
|
|
|
+ "explanation": "response_model=ProductOut 对返回字典先检查再筛选,只保留约定字段。id=1 是本课为了观察格式写出的固定值,还不是持久保存后的商品编号。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "分别制造输入与输出错误",
|
|
|
+ "body": "先在 /docs 发送下方空名称对象。随后临时删除 POST 返回字典中的 \"id\": 1,保存并用正常名称发请求,同时看服务终端。观察后立即恢复 id 并再次发正常请求。",
|
|
|
+ "lang": "json",
|
|
|
+ "code": "{\n \"name\": \"\",\n \"description\": \"name must not be empty\",\n \"is_active\": false, \"price_cents\": 0\n}",
|
|
|
+ "expected": "空名称得到 422 且 loc 指向 body.name;缺 id 时正常请求得到 500,终端有响应校验异常;恢复 id 后重新得到 200。",
|
|
|
+ "explanation": "422 表示调用方提供的内容不合约定;本次 500 表示我们自己的返回代码漏字段,需要修服务器。把输出错误一律写成 422 会让调用方反复修改本来正确的商品内容。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "检查文档中的契约",
|
|
|
+ "body": "刷新 /docs,在页面底部 Schemas 和 POST /products 的响应说明中展开 ProductCreate、ProductOut,查看 name 的长度与 id 要求。发送 {\"name\":\"schema check\"},把下方示例作为字段对照保存。",
|
|
|
+ "lang": "json",
|
|
|
+ "code": "{\n \"id\": 1,\n \"name\": \"schema check\",\n \"description\": \"\",\n \"is_active\": false, \"price_cents\": 0\n}",
|
|
|
+ "expected": "响应恰好包含示例中的五个字段;名称规则和输出 id 也在文档中可见。若仍为 500,先确认上一步已恢复 id。",
|
|
|
+ "explanation": "同一份模型同时参与执行时检查和文档生成,所以规则修改会体现在两处。长度检查还不能判断名称是否有实际意义,例如单个空格;更细的业务规则需要另行明确。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "空 name 或额外 is_actve 字段均返回 422。",
|
|
|
+ "正常创建演示返回 id、name、description、is_active、price_cents 五个字段,不包含 internal_source。",
|
|
|
+ "故意漏掉输出 id 时出现 500;恢复后为 200,当前尚未设置创建成功的 201。",
|
|
|
+ "price_cents=1990 原样返回整数;-1、1.5、\"1990\"、true 和超过 2147483647 的值均返回 422。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "空格名称通过了 min_length=1",
|
|
|
+ "cause": "空格也是字符串字符,长度校验不执行去空格。",
|
|
|
+ "fix": "根据业务要求加入 strip_whitespace 或字段校验器,去空格后再检查是否为空。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "response_model 没有过滤某个自定义响应",
|
|
|
+ "cause": "直接返回 JSONResponse 等 Response 对象时,框架不会按普通返回值继续执行响应模型转换。",
|
|
|
+ "fix": "本节返回字典或模型对象,让 FastAPI 使用 response_model 处理。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "把模型直接拼到字典时提示类型不匹配",
|
|
|
+ "cause": "Pydantic 模型对象不等于普通字典。",
|
|
|
+ "fix": "需要字典时调用 product.model_dump();不要使用旧教材中的 .dict() 作为本模块标准写法。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "拒绝只有空白的名称,并将 ' ceramic mug ' 保存为 'ceramic mug'。保持 120 字符上限。",
|
|
|
+ "hint": "为 name 添加 mode='before' 的 field_validator,先去除字符串两端空白,再交给 Field 长度校验。",
|
|
|
+ "answer": "从 pydantic 导入 field_validator,在 ProductCreate 内添加:\n@field_validator('name', mode='before')\n@classmethod\ndef strip_name(cls, value):\n return value.strip() if isinstance(value, str) else value\n\n只含空格会变为空字符串并返回 422;' ceramic mug ' 经解析后为 'ceramic mug'。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "Pydantic:Field 与字段约束",
|
|
|
+ "url": "https://docs.pydantic.dev/latest/concepts/fields/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "Pydantic:验证器",
|
|
|
+ "url": "https://docs.pydantic.dev/latest/concepts/validators/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:响应模型",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/response-model/"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "目前空名称和拼错的商品字段还可能被接受,内部调试字段也可能混进返回结果。我们用几次故意填错的请求,逐步确定“允许送什么、应该收到什么”。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 03 课,main.py 中 ProductCreate 和三种输入练习路由可运行。",
|
|
|
+ "在 /docs 只提交 {\"name\":\"ceramic mug\"},先确认能收到默认描述、上架状态和价格;本课仍使用演示 id,不保存商品。"
|
|
|
+ ],
|
|
|
+ "outcome": "让空名称和拼错字段被明确拒绝,让商品响应只包含五个公开字段,并区分输入错误与服务端输出错误。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "05",
|
|
|
+ "group": "REST 接口",
|
|
|
+ "title": "设计商品资源与 HTTP 行为",
|
|
|
+ "summary": "写出五种商品操作的请求清单,区分不存在的地址和不支持的方法,并准备完整替换所需的四个字段。",
|
|
|
+ "objectives": [
|
|
|
+ "为列表、单件商品、创建、替换、删除各写一个请求",
|
|
|
+ "用实际请求比较 404 与 405",
|
|
|
+ "验证完整替换必须同时填写四个可写字段"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "方法与地址共同表达操作",
|
|
|
+ "body": "第 1 步同一个集合地址对应 GET 和 POST,第 2 步同一路径换方法得到 405。只看 URL 无法确定完整接口。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "完整替换用明确必填字段",
|
|
|
+ "body": "第 3 步少填三项时模型拒绝请求;这与创建模型补默认值的行为不同。本课程 PUT 替换四个可写字段,PATCH 式局部修改不在本课范围内。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "重复操作要看最终状态",
|
|
|
+ "body": "第 4 步设计的两次 PUT 应留下同一内容,两次 DELETE 都留下“不存在”。成功读取与更新用 200,创建用 201,空体删除用 204,错误仍用相应的 4xx 状态。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "写下接口清单",
|
|
|
+ "body": "在 api-lab 新建 products-contract.http,记录下方目标清单。先为每行口头补出“操作哪个商品、提供哪些字段、如何确认结果”;这份清单供第 06 课逐项实现,现在不要期待未实现的 PUT 和 DELETE 成功。",
|
|
|
+ "filename": "products-contract.http",
|
|
|
+ "lang": "http",
|
|
|
+ "code": "GET /products # 查询集合,200,响应为数组\nPOST /products # 创建,201,响应包含新 id\nGET /products/{product_id} # 查询单条,200 或 404\nPUT /products/{product_id} # 完整替换,200 或 404\nDELETE /products/{product_id} # 删除,204 或 404",
|
|
|
+ "expected": "清单有五行,集合地址与带 product_id 的单件地址清楚分开;POST 目标是 201,DELETE 目标是 204,和当前回显阶段有所不同。",
|
|
|
+ "explanation": "方法与路径一起确定操作:同一个 /products 可以分别用于 GET 列表和 POST 创建。把输入与成功状态一并写下,形成双方都能验收的接口约定,也常称为接口契约。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "用已有服务区分 404 与 405",
|
|
|
+ "body": "在第二个终端执行下方三条 curl 命令;Windows PowerShell 把 curl 写成 curl.exe。先记状态码,再在第二次响应里找 Allow。",
|
|
|
+ "lang": "bash",
|
|
|
+ "code": "curl -i http://127.0.0.1:8000/health\ncurl -i -X POST http://127.0.0.1:8000/health\ncurl -i http://127.0.0.1:8000/missing-path",
|
|
|
+ "expected": "三次依次得到 200、405、404;405 响应有 Allow 头,说明 /health 支持哪些方法。",
|
|
|
+ "explanation": "404 表示地址没找到,405 表示地址存在但请求方式不支持。两次失败都发生在商品字段检查之外;JSON 里返回一个数字字段不能代替真正的 HTTP 状态。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "为完整替换定义独立输入",
|
|
|
+ "body": "在 main.py 模型区追加 ProductReplace 并保存。另开终端进入 api-lab、激活 .venv,执行 python 进入 Python 提示符;依次输入代码注释中的 from main import ProductReplace 和两次 ProductReplace(...) 调用,不复制开头的 #。最后 exit() 退出。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "class ProductReplace(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n\n name: str = Field(min_length=1, max_length=120)\n description: str = Field(max_length=16000)\n is_active: bool\n price_cents: int = Field(ge=0, le=2147483647, strict=True)\n\n# 在独立 Python 终端中测试:\n# from main import ProductReplace\n# ProductReplace(name=\"updated\", description=\"\", is_active=False, price_cents=0) # 通过\n# ProductReplace(name=\"updated\") # ValidationError:缺少三项",
|
|
|
+ "expected": "填写四个字段时得到一个模型对象;只填 name 时出现 ValidationError,指出缺少 description、is_active、price_cents。此时尚无 PUT 路由。",
|
|
|
+ "explanation": "创建时可以使用默认值,而完整替换必须说明所有可写字段的新值。ProductReplace 不给这四项默认值,避免省略描述时被误认为保留旧描述;id 仍来自路径并保持不变。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "准备替换与删除的验收请求",
|
|
|
+ "body": "把下面完整 JSON 保存为 api-lab/replace.json。另写验收顺序:创建并记录返回 id → 同样 PUT 两次 → 同样 DELETE 两次。实际执行留到下一课,届时将路径编号换成真实返回值。",
|
|
|
+ "filename": "replace.json",
|
|
|
+ "lang": "json",
|
|
|
+ "code": "{\n \"name\": \"updated ceramic mug\",\n \"description\": \"replace all editable fields\",\n \"is_active\": true, \"price_cents\": 0\n}",
|
|
|
+ "expected": "replace.json 包含 name、description、is_active、price_cents 四项;验收笔记说明重复 PUT 内容应一致,第二次 DELETE 的目标仍应不存在。",
|
|
|
+ "explanation": "这就是幂等的关注点:重复操作后的目标状态相同,不要求响应完全一样。删除可以先返回 204 后返回 404;204 没有响应体,不能继续按 JSON 解析。重复 POST 则可能多建商品。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "GET /health 是 200;POST /health 是 405;不存在的路径是 404。",
|
|
|
+ "ProductReplace 的 name、description、is_active、price_cents 四个可写字段都必须提交;空 description、false 和 0 分本身合法。",
|
|
|
+ "能解释 DELETE 第二次返回 404 仍然满足幂等,以及 204 为什么不能带 {\"ok\":true}。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "PUT 请求只传 name,意外把 description 清空",
|
|
|
+ "cause": "复用了带默认值的创建模型,未明确全量替换规则。",
|
|
|
+ "fix": "使用所有可编辑字段均必填的 ProductReplace;如果需要局部更新,应另行设计 PATCH。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "客户端对成功 DELETE 调用 response.json() 后报错",
|
|
|
+ "cause": "204 响应没有响应体。",
|
|
|
+ "fix": "先判断状态码;遇到 204 直接按成功处理,不执行 JSON 解码。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "网络超时后重复 POST,出现两件商品",
|
|
|
+ "cause": "客户端没收到响应,并不等于服务器没创建资源。",
|
|
|
+ "fix": "本地实验先查询列表核对;生产创建接口需根据业务设计幂等键等重复提交控制,不能直接假定 POST 可安全重试。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "评审三个接口:GET /delete-product?id=1、PUT /products/1 只接收 is_active、DELETE /products/1 返回 200 和 {\"deleted\":true}。给出本课程约定下的修改方案,并说明第三种设计是否违反 HTTP。",
|
|
|
+ "hint": "分清 HTTP 协议允许的行为与课程选择的统一接口约定。",
|
|
|
+ "answer": "第一种改为 DELETE /products/1,GET 不用于用户请求的数据删除。第二种若只做局部修改应定义 PATCH,或者要求 PUT 传齐 name/description/is_active。第三种返回 200 和结果体在 HTTP 中可以成立;本课程统一选择 204 空体,因此需同时调整状态码和响应内容。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "RFC 9110:请求方法",
|
|
|
+ "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-9"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "RFC 9110:204 No Content",
|
|
|
+ "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.5"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:更新请求体",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/body-updates/"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "商品信息已经能检查了,但“创建一件商品”和“修改已有商品”还没有明确区别。先把查看、创建、替换、删除的操作约定写清,再进入真正保存数据的实现。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 04 课,main.py 中有 ProductCreate、ProductOut,正常 POST 仍回传演示 id=1。",
|
|
|
+ "api-lab 的服务正在运行,GET /health 为 200;本课准备请求文件,不把回显当成已经保存的商品。"
|
|
|
+ ],
|
|
|
+ "outcome": "写出五种商品操作的请求清单,区分不存在的地址和不支持的方法,并准备完整替换所需的四个字段。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "06",
|
|
|
+ "group": "REST 接口",
|
|
|
+ "title": "完成内存版商品 CRUD",
|
|
|
+ "summary": "在同一次服务运行中完成商品创建、查询、完整替换和删除,并从实际响应追踪同一个商品编号。",
|
|
|
+ "objectives": [
|
|
|
+ "用同一份共享字典保存请求之间的商品数据",
|
|
|
+ "实现五项商品操作及对应状态码",
|
|
|
+ "按返回 id 验证修改和删除结果,并观察重启后数据的边界"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "共享状态让操作串成完整过程",
|
|
|
+ "body": "第 5 步创建后能再次查询、更新后能读取新值,是 products 字典跨请求存在的结果。它只属于当前进程,重载和多进程都不等于共享持久存储。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "编号独立于当前商品数量",
|
|
|
+ "body": "第 5 步删除后再次创建得到更大的编号。next_id 递增且不回退,客户端始终使用创建响应中的 id;不能用 len(products)+1 替代。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "结果要用下一次请求核实",
|
|
|
+ "body": "本课创建 201 后用 GET 确认,替换后再读四个字段,删除 204 后再读得到 404。这些后续观察比仅看到一次“成功”更能证明接口行为。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "当前内存实现有明确运行条件",
|
|
|
+ "body": "第 4 步使用单个 Uvicorn 进程,异步路由内部不等待,字典修改在一次执行段中完成。不要在共享状态更新中途随意加入 await;第 09 课会观察等待对执行的影响。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "重建 main.py 的公共部分",
|
|
|
+ "body": "用下面完整代码替换 api-lab/main.py,再按后面三个代码步骤逐段追加。不要保留此前回显函数;在第 4 步之前先不启动服务。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "from typing import Annotated\n\nfrom fastapi import FastAPI, HTTPException, Query, Response\nfrom pydantic import BaseModel, ConfigDict, Field\n\napp = FastAPI(title=\"API Lab\")\n\nclass ProductCreate(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n name: str = Field(min_length=1, max_length=120)\n description: str = Field(default=\"\", max_length=16000)\n is_active: bool = False\n price_cents: int = Field(default=0, ge=0, le=2147483647, strict=True)\n\nclass ProductReplace(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n name: str = Field(min_length=1, max_length=120)\n description: str = Field(max_length=16000)\n is_active: bool\n price_cents: int = Field(ge=0, le=2147483647, strict=True)\n\nclass ProductOut(BaseModel):\n id: int\n name: str\n description: str = Field(max_length=16000)\n is_active: bool\n price_cents: int = Field(ge=0, le=2147483647, strict=True)\n\nproducts: dict[int, dict] = {}\nnext_id = 1\n\n@app.get(\"/health\", tags=[\"system\"])\nasync def health():\n return {\"status\": \"ok\"}",
|
|
|
+ "expected": "main.py 中有三个模型、空的 products 字典、next_id=1 和 /health。此时尚未写完五个商品操作,文件内容与下方起始代码一致即可。",
|
|
|
+ "explanation": "products 放在函数外,使下一次请求还能读到上一次写入;键是编号,值是商品字段。next_id 独立计数,避免用商品数量分配编号时因删除而与现存编号冲突。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "实现列表与创建",
|
|
|
+ "body": "把下面两个集合路由追加到 main.py。对照前一课清单检查路径与状态码;等第 4 步全部完成后再发请求。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "@app.get(\"/products\", response_model=list[ProductOut], tags=[\"products\"])\nasync def list_products(\n limit: Annotated[int, Query(ge=1, le=100)] = 10,\n is_active: bool | None = None,\n):\n rows = list(products.values())\n if is_active is not None:\n rows = [row for row in rows if row[\"is_active\"] == is_active]\n return rows[:limit]\n\n@app.post(\"/products\", response_model=ProductOut, status_code=201, tags=[\"products\"])\nasync def create_product(product: ProductCreate, response: Response):\n global next_id\n product_id = next_id\n next_id += 1\n row = {\"id\": product_id, **product.model_dump()}\n products[product_id] = row\n response.headers[\"Location\"] = f\"/products/{product_id}\"\n return row",
|
|
|
+ "expected": "文件里有 GET /products 与 POST /products,POST 标明 201,保存新商品时既写字典又增加 next_id;不存在第二份同方法的回显路由。",
|
|
|
+ "explanation": "创建从输入模型取出所有字段,加入新 id 后存入共享字典,Location 头告诉客户端新商品的路径。列表筛选用 is_active is not None,保证 false 也会筛选,不能把 false 当成没传条件。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "实现单条读取与全量替换",
|
|
|
+ "body": "继续在 main.py 追加查找函数、GET 单件和 PUT 路由。留意 PUT 使用 ProductReplace,保留四个必填字段;不要把旧商品字段合并进新字典。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "def require_product(product_id: int) -> dict:\n if product_id not in products:\n raise HTTPException(status_code=404, detail=\"商品不存在\")\n return products[product_id]\n\n@app.get(\"/products/{product_id}\", response_model=ProductOut, tags=[\"products\"])\nasync def get_product(product_id: int):\n return require_product(product_id)\n\n@app.put(\"/products/{product_id}\", response_model=ProductOut, tags=[\"products\"])\nasync def replace_product(product_id: int, product: ProductReplace):\n require_product(product_id)\n row = {\"id\": product_id, **product.model_dump()}\n products[product_id] = row\n return row",
|
|
|
+ "expected": "单件读取和替换都先调用 require_product;PUT 以路径 id 和完整新字段重新写字典。尚未出现的 DELETE 下一步添加。",
|
|
|
+ "explanation": "require_product 将“找不到就结束请求”集中到一处;raise HTTPException(404,...) 会中断后续代码。PUT 先检查存在,再整体替换可编辑字段,id 因取自路径而保持不变。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "实现无响应体的删除",
|
|
|
+ "body": "追加下方 DELETE 路由后保存。在 api-lab 终端运行 python -m uvicorn main:app --reload,等待 startup complete,再打开 /docs。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "@app.delete(\"/products/{product_id}\", status_code=204, tags=[\"products\"])\nasync def delete_product(product_id: int):\n require_product(product_id)\n del products[product_id]\n return Response(status_code=204)",
|
|
|
+ "expected": "文档出现 /health 加五个商品操作,共六个路由方法。启动若报模型未定义,检查代码是否按前面顺序拼入同一文件。",
|
|
|
+ "explanation": "删除先查存在再从字典移除;Response(status_code=204) 明确创建没有内容的响应,而不是返回一个会被转成 JSON 的值。本课 async 函数中没有 await,单进程字典修改会在一次执行段内完成。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "按顺序完成一轮 CRUD",
|
|
|
+ "body": "在 /docs 依次执行:GET /products → 用下面 JSON 做 POST → 记下返回 id → GET /products/{id} → PUT 提交 {\"name\":\"updated\",\"description\":\"\",\"is_active\":true,\"price_cents\":2090} → 再 GET → DELETE → 再 GET。期间不要改代码。最后再创建一件,比较新编号。",
|
|
|
+ "lang": "json",
|
|
|
+ "code": "{\n \"name\": \"陶瓷杯\",\n \"description\": \"350ml,白色\",\n \"is_active\": false,\n \"price_cents\": 1990\n}",
|
|
|
+ "expected": "初始列表为 [];创建为 201 且含真实 id;修改后同 id 的价格为 2090、上架为 true、描述为空串;删除为 204 且无内容,再查为 404;新建编号不会因刚才删除而回退。",
|
|
|
+ "explanation": "这次能在后续 GET 读到修改,才证明同一进程内的保存生效。客户端从创建响应读取 id,不假设永远为 1。价格用分,2090 表示 20.90 元;内存字典仍会因重启清空,持久保存将由数据库模块解决。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "空列表返回 200 和 [];创建返回 201,响应含整数 id,Location 指向 /products/{id}。",
|
|
|
+ "PUT 传齐字段时返回 200,id 保持不变;仅传 name 时返回 422。",
|
|
|
+ "删除返回 204 且响应体为空;再 GET 或 DELETE 相同 id 均返回 404。",
|
|
|
+ "创建两条并删除其中一条后,再创建不会覆盖另一条;服务重启后列表重新为空。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "保存代码后刚创建的商品找不到了",
|
|
|
+ "cause": "--reload 重启了服务进程,内存字典被重新初始化。",
|
|
|
+ "fix": "完成代码编辑后重新准备数据;验证一轮 CRUD 时不要中途保存服务端文件。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "筛选 is_active=false 返回全部数据",
|
|
|
+ "cause": "使用 if is_active 判断是否筛选,False 被当成未提供条件。",
|
|
|
+ "fix": "使用 if is_active is not None,再比较字段的布尔值。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "删除一条后,新商品覆盖了旧商品",
|
|
|
+ "cause": "用 len(products)+1 计算编号导致重复。",
|
|
|
+ "fix": "独立维护递增 next_id,不因删除回退;本地练习保持单进程运行。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "在列表接口加入 offset>=0,先按 is_active 筛选,再返回 rows[offset:offset+limit]。创建三条商品,第二条和第三条设为 is_active=true,验证 is_active=true&offset=1&limit=1 只返回第三条。",
|
|
|
+ "hint": "分页应该作用在筛选后的结果上;把切片放在筛选后,limit 和 offset 均添加参数约束。",
|
|
|
+ "answer": "添加 offset: Annotated[int, Query(ge=0)] = 0;保持 is_active 筛选顺序;把 return rows[:limit] 改为 return rows[offset:offset + limit]。按题目创建顺序,返回数组长度为 1,元素为第三条商品;offset=-1 返回 422。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "FastAPI:响应状态码",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/response-status-code/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:直接返回 Response",
|
|
|
+ "url": "https://fastapi.tiangolo.com/advanced/response-directly/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:处理错误",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/handling-errors/"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "之前收到的商品只是回显,换一次请求就无处查找。现在真正做一个小目录:添加陶瓷杯、查出来、改价格和上架状态,最后删除它。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 05 课,理解五项操作清单,手边有 replace.json;仍使用 api-lab/.venv。",
|
|
|
+ "先停止 Uvicorn,将 main.py 另存为 main_before_crud.py 留作对照。接下来按第 1–4 步重建同一个 main.py,完成后再启动。",
|
|
|
+ "本课只用一个 Uvicorn 服务进程,数据放在内存。请求验收期间不保存代码、不重启;重载会清空商品。"
|
|
|
+ ],
|
|
|
+ "outcome": "在同一次服务运行中完成商品创建、查询、完整替换和删除,并从实际响应追踪同一个商品编号。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "07",
|
|
|
+ "group": "REST 接口",
|
|
|
+ "title": "返回可定位、格式一致的错误",
|
|
|
+ "summary": "让找不到商品、输入不合法和方法不支持都返回一致的 error 外层,并保留状态码、字段位置与必要响应头。",
|
|
|
+ "objectives": [
|
|
|
+ "从已存在的 404 开始统一错误格式",
|
|
|
+ "让 422 同时告诉调用方具体哪一项输入有问题",
|
|
|
+ "用不存在、填错和方法错误三组请求验证变化"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "判断失败与输出格式可以分工",
|
|
|
+ "body": "第 1 步 require_product 负责发现不存在,第 2 步处理器负责生成统一响应。raise 结束当前请求,不需要每个路由重新拼一份错误 JSON。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "统一结构仍保留差异",
|
|
|
+ "body": "验收里 404、405、422 没有变成同一个状态。error 给出固定外层,code 区分错误类别,details.loc 指出路径或字段,Allow 继续说明允许的方法。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "输入失败在路由之前发生",
|
|
|
+ "body": "第 3 步 abc 与缺 name 都由 RequestValidationError 处理;在商品函数内部加 try/except 接不到这类解析失败。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "确认已有业务错误入口",
|
|
|
+ "body": "在 main.py 找到下方 require_product,与现有函数对照即可,不要重复添加。先在 /docs 读取一个确认不存在的编号,保存默认错误响应作比较。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "def require_product(product_id: int) -> dict:\n if product_id not in products:\n raise HTTPException(status_code=404, detail=\"商品不存在\")\n return products[product_id]",
|
|
|
+ "expected": "修改前响应状态是 404,内容通常为 {\"detail\":\"商品不存在\"},函数中使用的是 raise。",
|
|
|
+ "explanation": "raise 会立即结束当前处理,把异常交给框架;return 一个异常对象只是返回数据,无法表达同样的中断。我们保留判断位置,只改变出口格式。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "统一 HTTP 错误处理",
|
|
|
+ "body": "把下面导入与异常处理函数加入 main.py,放在 app 创建之后。保存后重发刚才的不存在商品请求。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "from fastapi import Request\nfrom fastapi.responses import JSONResponse\nfrom starlette.exceptions import HTTPException as StarletteHTTPException\n\n@app.exception_handler(StarletteHTTPException)\nasync def handle_http_error(request: Request, exc: StarletteHTTPException):\n return JSONResponse(\n status_code=exc.status_code,\n headers=exc.headers,\n content={\n \"error\": {\n \"code\": \"http_error\",\n \"message\": str(exc.detail),\n \"details\": [],\n }\n },\n )",
|
|
|
+ "expected": "仍是 404,但响应变为 error 对象,其中 code=http_error、message=商品不存在、details=[]。",
|
|
|
+ "explanation": "exception_handler 注册应用层的错误出口;使用 StarletteHTTPException 基类还能接住框架产生的 404、405。status_code 和 headers 从原异常保留,格式统一不应破坏 HTTP 含义。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "统一 422 并保留字段位置",
|
|
|
+ "body": "继续添加下方请求校验处理器。保存后在 /docs 对 POST /products 发送 {},再 GET /products/abc。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "from fastapi.exceptions import RequestValidationError\n\n@app.exception_handler(RequestValidationError)\nasync def handle_validation_error(request: Request, exc: RequestValidationError):\n details = [\n {\n \"loc\": list(error[\"loc\"]),\n \"type\": error[\"type\"],\n \"msg\": error[\"msg\"],\n }\n for error in exc.errors()\n ]\n return JSONResponse(\n status_code=422,\n content={\n \"error\": {\n \"code\": \"validation_error\",\n \"message\": \"请求参数不符合接口要求\",\n \"details\": details,\n }\n },\n )",
|
|
|
+ "expected": "两次仍是 422,error.code 为 validation_error;details 中分别能看到 body.name 与 path.product_id 的位置。",
|
|
|
+ "explanation": "输入解析失败在路由运行前发生,所以要用 RequestValidationError 的应用处理器。这里只提取 loc、type、msg,既提供定位信息,又避免把原始输入或不能编码的异常上下文直接回显。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "用三类失败验证处理器",
|
|
|
+ "body": "在 /docs 重测不存在商品、abc 编号和空 POST;另在终端发送 curl -i -X POST http://127.0.0.1:8000/health(Windows 用 curl.exe)。下方 JSON 是空 POST 的响应示例,只用于对照。",
|
|
|
+ "lang": "json",
|
|
|
+ "code": "{\n \"error\": {\n \"code\": \"validation_error\",\n \"message\": \"请求参数不符合接口要求\",\n \"details\": [\n {\n \"loc\": [\"body\", \"name\"],\n \"type\": \"missing\",\n \"msg\": \"Field required\"\n }\n ]\n }\n}",
|
|
|
+ "expected": "404 与 405 的 code 都为 http_error,422 为 validation_error;所有响应都有 error,405 的 Allow 头仍存在。英文 msg 可能与示例略有不同。",
|
|
|
+ "explanation": "外层一致方便界面统一显示,状态码和 details 又保留了不同失败原因。程序适合按稳定的 code 与 loc 判断,不能依赖可能随库版本变化的英文说明;未知服务器错误仍应修代码,不能包装成 200。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "不存在的商品返回 404、http_error 和“商品不存在”。",
|
|
|
+ "非法 product_id 与缺失 name 都返回 422、validation_error,details.loc 能指出不同位置。",
|
|
|
+ "POST /health 仍返回 405,Allow 响应头被保留。",
|
|
|
+ "成功的商品响应不被包在 error 字段中;204 删除仍然没有响应体。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "错误响应有 message,但 HTTP 状态一直是 200",
|
|
|
+ "cause": "只返回了错误字典,没有设置 JSONResponse 的 status_code。",
|
|
|
+ "fix": "在异常处理器中显式设置对应状态,HTTP 错误使用 exc.status_code。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "注册 HTTPException 处理器后,未知路径仍是默认格式",
|
|
|
+ "cause": "只处理 FastAPI 的异常子类,没有覆盖框架抛出的 Starlette 基类异常。",
|
|
|
+ "fix": "导入并为 StarletteHTTPException 注册处理器,路由仍可抛出 FastAPI 的 HTTPException。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "自定义名称校验失败后出现 JSON 序列化异常",
|
|
|
+ "cause": "把 exc.errors() 的 ctx 异常对象直接放进 JSONResponse。",
|
|
|
+ "fix": "只提取 loc、type、msg 等必要字段,不直接返回任意异常对象或原始请求内容。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "对 GET /products?limit=0、GET /products/abc、POST /products 的 {} 逐一记录 status、error.code 和第一条 details.loc,写出客户端应如何定位表单或请求参数错误。",
|
|
|
+ "hint": "三次都是 422,但错误位置的来源不同:query、path、body。",
|
|
|
+ "answer": "三次 status 都是 422,code 都是 validation_error。loc 分别为 ['query','limit']、['path','product_id']、['body','name']。客户端先按 status/code 判断错误类别,再用 loc 映射对应输入位置;msg 用于显示,不作为稳定的判断条件。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "FastAPI:覆盖异常处理器",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/handling-errors/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "Starlette:Exceptions",
|
|
|
+ "url": "https://github.com/Kludex/starlette/blob/main/docs/exceptions.md"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "Pydantic:错误处理",
|
|
|
+ "url": "https://docs.pydantic.dev/latest/errors/errors/"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "查询不存在的杯子和提交空名称都会失败,但默认返回的形状不一样。我们让调用方始终能在相同位置找到错误,同时保留该改哪个字段的线索。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 06 课的完整 main.py,创建、删除商品与删除后 404 均已验证。",
|
|
|
+ "继续 api-lab 和 /docs;先访问 /products/999999,确认这个编号确实不存在,若已存在则换一个未使用的正整数。"
|
|
|
+ ],
|
|
|
+ "outcome": "让找不到商品、输入不合法和方法不支持都返回一致的 error 外层,并保留状态码、字段位置与必要响应头。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "08",
|
|
|
+ "group": "REST 接口",
|
|
|
+ "title": "复用依赖与拆分路由文件",
|
|
|
+ "summary": "在独立文件实现 /private,并看到缺少、错误、正确校验值分别被拒绝或放行。",
|
|
|
+ "objectives": [
|
|
|
+ "把共同的请求检查写成一个函数",
|
|
|
+ "将独立路由文件接到现有 app",
|
|
|
+ "设置服务读取的配置值并用三次请求验收"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "依赖是实际执行的前置动作",
|
|
|
+ "body": "第 5 步三次请求经过同一个 require_demo_key;前两次抛异常,路由不执行,第三次才返回成功。Depends 让这份规则可以复用到多个接口。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "请求头与服务配置来自两边",
|
|
|
+ "body": "第 4 步设置的 DEMO_API_KEY 属于服务进程,第 5 步填写的 X-API-Key 属于来访请求。把值放在查询参数里不会满足 Header 声明。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "文件组织还需要注册",
|
|
|
+ "body": "第 2 步建立 private.py 后接口尚不可用,第 3 步 include_router 后才出现在 /docs。多个文件最终仍由同一个 app 提供服务和错误处理。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "课堂校验不等于用户身份系统",
|
|
|
+ "body": "这次只验证一个公开课堂字符串,没有用户、权限分组和密钥轮换。它帮助理解配置、前置检查和请求头,业务认证需要另定方案。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "建立依赖函数文件",
|
|
|
+ "body": "在 api-lab 根目录新建 dependencies.py,粘贴下方完整代码。先保留 main.py 的商品操作和错误处理器,这一步还不访问 /private。",
|
|
|
+ "filename": "dependencies.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "import os\nimport secrets\nfrom typing import Annotated\n\nfrom fastapi import Header, HTTPException\n\ndef require_demo_key(\n x_api_key: Annotated[str | None, Header(alias=\"X-API-Key\")] = None,\n) -> None:\n expected = os.getenv(\"DEMO_API_KEY\")\n if not expected:\n raise HTTPException(status_code=503, detail=\"服务未配置 DEMO_API_KEY\")\n if x_api_key is None or not secrets.compare_digest(\n x_api_key.encode(\"utf-8\"), expected.encode(\"utf-8\")\n ):\n raise HTTPException(\n status_code=401,\n detail=\"X-API-Key 无效\",\n headers={\"WWW-Authenticate\": \"APIKey\"},\n )",
|
|
|
+ "expected": "dependencies.py 保存后没有顶层执行输出;文件中包含 require_demo_key,main.py 仍可运行。下一步会让新接口真正调用它。",
|
|
|
+ "explanation": "函数先读取服务端环境变量,再读取请求头并比较。未配置时返回 503,表示服务还没准备好;配置了但来访者没带正确值时返回 401。compare_digest 用于比较校验值,Header(alias=...) 指明数据来自请求头。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "创建独立的私有路由",
|
|
|
+ "body": "新建 api-lab/routers 目录,在其中创建空的 __init__.py 和下方 private.py。注意 import 从 dependencies 读函数,文件内使用 @router.get。",
|
|
|
+ "filename": "routers/private.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "from fastapi import APIRouter, Depends\n\nfrom dependencies import require_demo_key\n\nrouter = APIRouter(\n tags=[\"private\"],\n dependencies=[Depends(require_demo_key)],\n)\n\n@router.get(\"/private\")\ndef read_private():\n return {\"message\": \"API key accepted\"}",
|
|
|
+ "expected": "routers 目录有两个文件;此时只保存文件还不会让 /docs 出现 /private,这是下一步注册的工作。",
|
|
|
+ "explanation": "APIRouter 先在独立模块收集路由;Depends(require_demo_key) 声明每次访问前先执行检查。检查抛异常时不会进入 read_private,因此多个入口可共用同一前置步骤。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "在主入口注册 router",
|
|
|
+ "body": "在 main.py 顶部导入 private,在已有 app = FastAPI(...) 之后加入 include_router;不要新建第二个 app。保存、等待重载并刷新 /docs,展开 private 分组。",
|
|
|
+ "filename": "main.py",
|
|
|
+ "lang": "python",
|
|
|
+ "code": "from routers import private\n\n# 放在 app = FastAPI(...) 之后\napp.include_router(private.router)\n\n# 目录结构\n# api-lab/\n# main.py\n# dependencies.py\n# routers/\n# __init__.py\n# private.py",
|
|
|
+ "expected": "/docs 中出现 GET /private 和可填写的 X-API-Key;商品接口及统一错误处理仍在。若没有新路由,核对 include_router 是否执行。",
|
|
|
+ "explanation": "导入 Python 模块不等于注册接口,include_router 才把收集的路由加入同一个应用。独立文件不反向 import main,可以避免循环导入;应用级错误处理器也会作用于新路由。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "设置当前终端环境并重新启动",
|
|
|
+ "body": "在运行服务的终端按 Ctrl+C。只执行下方与你系统对应的一组命令,在同一个窗口设置变量再启动;不要在另一终端设置后直接刷新旧服务。",
|
|
|
+ "lang": "bash",
|
|
|
+ "code": "# Windows PowerShell\n$env:DEMO_API_KEY = \"classroom-key\"\npython -m uvicorn main:app --reload\n\n# macOS / Linux\nexport DEMO_API_KEY=\"classroom-key\"\npython -m uvicorn main:app --reload",
|
|
|
+ "expected": "服务重新启动后不再因未配置 DEMO_API_KEY 返回 503。先访问不带头的 /private,应得到 401。",
|
|
|
+ "explanation": "新进程会继承启动它的终端环境,这解释了为什么必须在设置值后重新启动。classroom-key 是服务端期望的课堂值,请求还需要从 X-API-Key 头带来匹配值。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "验证缺失、错误与正确密钥",
|
|
|
+ "body": "在 /docs 依次执行 GET /private:不填 X-API-Key、填 wrong-key、填 classroom-key。也可用下面三条 curl 对照,Windows 用 curl.exe。最后访问 GET /products。",
|
|
|
+ "lang": "bash",
|
|
|
+ "code": "# macOS / Linux;Windows PowerShell 使用 curl.exe\ncurl -i http://127.0.0.1:8000/private\ncurl -i http://127.0.0.1:8000/private -H \"X-API-Key: wrong-key\"\ncurl -i http://127.0.0.1:8000/private -H \"X-API-Key: classroom-key\"",
|
|
|
+ "expected": "前两次为 401,第三次为 200 和 API key accepted;错误使用第 07 课的 error 格式,401 保留 WWW-Authenticate;商品列表仍可按原方式访问。",
|
|
|
+ "explanation": "同一检查函数在每次私有请求前执行,只有通过才进入路由。Header 的默认值 None 让“未提供”进入我们自定义的 401 判断,而不是先触发必填字段 422;本实验仅保护 /private。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "/docs 显示 /private 和 X-API-Key 请求头输入项。",
|
|
|
+ "配置环境变量后,缺失或错误密钥返回 401 和 http_error;正确密钥返回 200 和 {\"message\":\"API key accepted\"}。",
|
|
|
+ "未设置 DEMO_API_KEY 的新服务进程返回 503,而不会因为请求头与配置都为空而通过。",
|
|
|
+ "拆分文件后 /health、原有商品 CRUD 与错误结构仍然可用。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "已在客户端写 X-API-Key,却显示密钥无效",
|
|
|
+ "cause": "服务读取的头部来源声明不正确,或者客户端把值放到了查询字符串。",
|
|
|
+ "fix": "依赖明确使用 Header(alias='X-API-Key');在 Network 或 curl 输出中核对 Request Headers。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "环境变量已设置,接口仍返回 503",
|
|
|
+ "cause": "变量设置在另一个终端,或者旧服务进程没有重启。",
|
|
|
+ "fix": "在设置变量的同一个终端停止并重启 Uvicorn。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "新增 private.py 后 /private 返回 404",
|
|
|
+ "cause": "只创建了 router,没有在主 app 上 include_router,或启动时目录不正确。",
|
|
|
+ "fix": "确认 main.py 注册 private.router,并从 api-lab 根目录启动 main:app。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "在 routers/private.py 中新增 GET /private/status,返回 {\"enabled\":true}。复用 router 上的依赖,分别验证无头部与正确头部。再说明为什么这仍不能判断是哪位学生发出的请求。",
|
|
|
+ "hint": "只添加 @router.get('/private/status') 的函数,不必复制校验代码。",
|
|
|
+ "answer": "添加:\n@router.get('/private/status')\ndef private_status():\n return {'enabled': True}\n\n无 X-API-Key 返回 401;classroom-key 返回 200 和 {'enabled':true}。两个接口自动复用 router 依赖。所有人使用同一公开密钥,服务只能判断是否匹配,无法识别具体用户。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "FastAPI:依赖注入",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/dependencies/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:Header 参数",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/header-params/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:多文件应用",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/bigger-applications/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "Python:compare_digest",
|
|
|
+ "url": "https://docs.python.org/3/library/secrets.html#secrets.compare_digest"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "商品目录已经能操作了,现在另加一个课堂用的“内部入口”:只有带正确 X-API-Key 才能访问。顺便把它放进独立文件,看多个文件如何组成同一个服务。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 07 课,api-lab/main.py 的错误响应有统一 error 对象。",
|
|
|
+ "仍使用同一 .venv;本课新增 dependencies.py 和 routers/private.py,保留商品接口。",
|
|
|
+ "公开课堂值 classroom-key 仅用于本地练习,不把它当作真实用户认证系统。"
|
|
|
+ ],
|
|
|
+ "outcome": "在独立文件实现 /private,并看到缺少、错误、正确校验值分别被拒绝或放行。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "09",
|
|
|
+ "group": "异步与调用",
|
|
|
+ "title": "使用 async / await 处理 I/O 等待",
|
|
|
+ "summary": "同时发出两次各等待 1 秒的请求,比较两种写法的总耗时,并能把阻塞写法改回可交错等待的实现。",
|
|
|
+ "objectives": [
|
|
|
+ "建立有固定等待时间的本地接口",
|
|
|
+ "用实际请求比较约 1 秒与约 2 秒的差异",
|
|
|
+ "将无法替换的同步等待放到线程中,并恢复可继续学习的代码"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "先看等待是否重叠,再理解 await",
|
|
|
+ "body": "第 2 步两个各等 1 秒的请求总计约 1 秒;第 3 步换成 time.sleep 后约 2 秒。await 的价值是把等待时间让给其他任务,async 本身不会自动改造阻塞函数。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "I/O 等待与计算分别处理",
|
|
|
+ "body": "本课 sleep 只是模拟网络或磁盘等 I/O 等待;计算密集任务即使写进 async 也不会凭空加快。第 4 步线程方案适用于无法替换的同步等待。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "执行位置决定会拖住谁",
|
|
|
+ "body": "async 路由由事件循环推进,普通 def 路由由 FastAPI 交到线程池。第 3 步是在 async 内直接调用阻塞函数,所以连 /health 都受影响;第 4 步显式 to_thread 才改变它的位置。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "增加一个可测量的等待接口",
|
|
|
+ "body": "在已激活的 api-lab 终端执行 python -m pip install \"httpx>=0.27,<1\"。把下方路由追加到 main.py 并保存;访问 /slow?delay=1,再试 /slow?delay=-1。服务只启动一个进程。",
|
|
|
+ "code": "import asyncio\nfrom fastapi import Query\n\n@app.get(\"/slow\")\nasync def slow(delay: float = Query(default=1, ge=0, le=5)):\n await asyncio.sleep(delay)\n return {\"delay\": delay}",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "main.py",
|
|
|
+ "expected": "第一次约等 1 秒后返回 {\"delay\":1},第二次立刻得到 422;/health 仍可访问。若安装提示找不到 Python 包,先核对 .venv 路径。",
|
|
|
+ "explanation": "asyncio.sleep 模拟等待外部结果,await 在等待时让其他任务有机会执行。Query 把 delay 限制为 0–5 秒,避免误输过大的等待时间;HTTPX 将在下一步充当同时发请求的客户端。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "同时发出两次请求",
|
|
|
+ "body": "在 api-lab/concurrent_check.py 保存下面代码。保持服务运行,在第二个终端执行 python concurrent_check.py,记下状态列表和 elapsed。页面交互是模拟,计时以这个脚本为准。",
|
|
|
+ "code": "import asyncio\nfrom time import perf_counter\nimport httpx\n\nasync def main():\n async with httpx.AsyncClient(base_url=\"http://127.0.0.1:8000\") as client:\n started = perf_counter()\n results = await asyncio.gather(\n client.get(\"/slow\", params={\"delay\": 1}),\n client.get(\"/slow\", params={\"delay\": 1}),\n )\n print([r.status_code for r in results])\n print(f\"elapsed={perf_counter() - started:.2f}s\")\n\nasyncio.run(main())",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "concurrent_check.py",
|
|
|
+ "expected": "输出 [200, 200],总耗时通常接近 1 秒加通信开销,而不是两次等待简单相加的 2 秒。连接失败时先检查服务是否在 8000 运行。",
|
|
|
+ "explanation": "asyncio.gather 让两个请求同时进行,AsyncClient 负责实际收发。服务在每次 await sleep 时可以处理另一条请求,因此两段等待重叠;async 并没有把单次 1 秒缩短。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "复现阻塞,再恢复",
|
|
|
+ "body": "在 main.py 临时加入 import time,将 /slow 中 await asyncio.sleep(delay) 换成 time.sleep(delay)。等待重载后重跑计时脚本,并在两次慢请求进行时从第三个终端访问 /health。记录后立刻恢复 await asyncio.sleep(delay)。macOS / Linux 把下面 curl.exe 改为 curl。",
|
|
|
+ "code": "curl.exe \"http://127.0.0.1:8000/health\"",
|
|
|
+ "lang": "bash",
|
|
|
+ "expected": "阻塞写法下两次请求通常约 2 秒,等待期间 /health 也可能迟迟不返回;恢复后计时通常又接近 1 秒。以明显差异判断,不要求毫秒完全一致。",
|
|
|
+ "explanation": "time.sleep 直接占住 async 路由所在的执行线程,函数名字前写 async 也不能自动把它变成可让出的等待。其他请求被拖住,正是事件循环无法继续处理它们的表现。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "为无法替换的同步 I/O 选择线程",
|
|
|
+ "body": "把下面 slow_sync 函数加到 main.py。在 /slow 函数内,把原来的等待和 return 两行临时改成 result = await asyncio.to_thread(slow_sync, delay) 与 return result(去掉示例前的 #)。重跑 concurrent_check.py;验收后把 /slow 恢复为第 1 步的 asyncio.sleep 版本供后课使用。",
|
|
|
+ "code": "import time\n\ndef slow_sync(delay: float):\n time.sleep(delay)\n return {\"delay\": delay}\n\n# 放在 async def 路由内:\n# result = await asyncio.to_thread(slow_sync, delay)\n# return result",
|
|
|
+ "lang": "python",
|
|
|
+ "expected": "使用 to_thread 后两次请求通常又能在约 1 秒完成,/health 不必等待两段串行 sleep。最后 /slow 保持异步 sleep 原版,下一课的超时实验有确定起点。",
|
|
|
+ "explanation": "to_thread 把同步辅助函数交到另一个线程执行,让当前异步路由可以等待而不占住事件循环。FastAPI 会在线程池运行普通 def 路由,却不会自动搬走 async def 里直接调用的普通函数;线程也不是本课大量 CPU 计算的加速方案。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "异步版本两个 delay=1 请求均返回 200,总耗时通常接近 1 秒。",
|
|
|
+ "阻塞版本在单进程下耗时明显增加,恢复后 /health 不再等待 slow 的 sleep 结束。",
|
|
|
+ "/slow?delay=-1 与 /slow?delay=6 都返回 422。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "写了 async def,其他请求仍卡住",
|
|
|
+ "cause": "路由内使用 time.sleep、同步 HTTP 客户端或长时间计算,占住事件循环。",
|
|
|
+ "fix": "定位耗时调用;I/O 改用 await 支持的 API,必要时把同步 I/O 放到线程。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "两次 await 请求仍耗时约 2 秒",
|
|
|
+ "cause": "先 await 第一次,再创建第二次,代码仍是串行等待。",
|
|
|
+ "fix": "用 asyncio.gather 同时调度两个请求,再比较结果。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "出现 coroutine was never awaited",
|
|
|
+ "cause": "调用异步函数但没有 await,也没有交给任务调度。",
|
|
|
+ "fix": "在 async 函数内 await 结果;脚本最外层用 asyncio.run(main())。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "把脚本改成同时请求 3 个 delay=1 的 /slow,再增加一次 /health,记录总耗时及各状态码。",
|
|
|
+ "hint": "把第三个 /slow 和 /health 一并放进 gather;不要依次 await。",
|
|
|
+ "answer": "四个请求应全部返回 200。使用非阻塞等待时,总耗时通常仍接近最长的一次等待,即约 1 秒;若接近 3 秒,先检查服务是否恢复了 asyncio.sleep。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "FastAPI:并发与 async / await",
|
|
|
+ "url": "https://fastapi.tiangolo.com/async/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "Python:协程、gather 与 to_thread",
|
|
|
+ "url": "https://docs.python.org/3/library/asyncio-task.html"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "demo": "async",
|
|
|
+ "intro": {
|
|
|
+ "scenario": "假设商品目录需要等另一个服务返回结果。一件商品要等 1 秒,同时查两件一定要等 2 秒吗?先用可控的等待实验观察,再解释 async 和 await。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 08 课,api-lab/main.py 能启动且 /health 返回 200;继续同一 .venv。",
|
|
|
+ "保留一个 Uvicorn 进程,另开已激活环境的终端执行客户端脚本。本课新增 concurrent_check.py;安装命令在第 1 步给出。"
|
|
|
+ ],
|
|
|
+ "outcome": "同时发出两次各等待 1 秒的请求,比较两种写法的总耗时,并能把阻塞写法改回可交错等待的实现。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "10",
|
|
|
+ "group": "异步与调用",
|
|
|
+ "title": "使用 HTTPX 调用服务与调试 CORS",
|
|
|
+ "summary": "看到 API 等待过久时返回 504,并让本机商品页面在正确来源下创建商品、在不匹配来源下显示浏览器错误。",
|
|
|
+ "objectives": [
|
|
|
+ "让应用创建和关闭同一个 HTTP 客户端",
|
|
|
+ "对照正常调用和超时调用的响应",
|
|
|
+ "用两个网页地址复现跨来源访问差异并读出相关请求头"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "一段是入站,一段是出站",
|
|
|
+ "body": "第 2 步 /upstream-demo 收到浏览器请求后再请求 /slow。观察 504 时先定位第二段等待,别把它与第 4 步浏览器来源错误混在一起。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "客户端随应用复用和关闭",
|
|
|
+ "body": "第 1 步在 lifespan 中创建 AsyncClient,后续请求复用连接,退出时关闭。不同超时参数限制不同阶段,read 通常是等待下一块数据的上限。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "浏览器以完整来源比较授权",
|
|
|
+ "body": "第 4 步同一网页换 localhost 后失败。Origin 包括协议、主机和端口;第 5 步的响应头提供判断依据,curl 返回了数据也不等于浏览器授权通过。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "把客户端交给 lifespan 管理",
|
|
|
+ "body": "在 main.py 中把下方 lifespan 定义放到 app 创建之前,将原来的 FastAPI(...) 初始化改为 FastAPI(lifespan=lifespan)。已有 lifespan 时合并客户端管理;保留全部路由并只创建一个 app。若未装 HTTPX,先在 .venv 执行 python -m pip install \"httpx>=0.27,<1\"。",
|
|
|
+ "code": "from contextlib import asynccontextmanager\nimport httpx\nfrom fastapi import FastAPI\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n timeout = httpx.Timeout(3.0, connect=1.0)\n async with httpx.AsyncClient(\n base_url=\"http://127.0.0.1:8000\", timeout=timeout\n ) as client:\n app.state.http = client\n yield\n\napp = FastAPI(lifespan=lifespan)",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "main.py",
|
|
|
+ "expected": "重启后 /health 与 /slow 仍可用,没有第二个 app 把已注册路由覆盖;服务启动和停止都无客户端创建或关闭异常。",
|
|
|
+ "explanation": "lifespan 是应用启动到关闭的管理入口:yield 前创建客户端,yield 后退出上下文并关闭。app.state.http 保存这份可复用客户端。base_url 固定本机,connect 限制建立连接等待,其余阶段默认 3 秒,并非整个请求总时限。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "把出站失败转换为可判断的响应",
|
|
|
+ "body": "追加下方 /upstream-demo 路由。分别访问 /upstream-demo?delay=1 和 /upstream-demo?delay=4,记录等待时长及状态;保留 /slow 的异步 sleep,避免被上一课阻塞实验影响。",
|
|
|
+ "code": "from fastapi import HTTPException, Query, Request\n\n@app.get(\"/upstream-demo\")\nasync def upstream_demo(\n request: Request,\n delay: float = Query(default=1, ge=0, le=5),\n):\n try:\n response = await request.app.state.http.get(\n \"/slow\", params={\"delay\": delay}\n )\n response.raise_for_status()\n return {\"upstream\": response.json()}\n except httpx.TimeoutException as exc:\n raise HTTPException(504, \"上游响应超时\") from exc\n except httpx.HTTPStatusError as exc:\n raise HTTPException(502, \"上游返回非成功状态\") from exc\n except httpx.RequestError as exc:\n raise HTTPException(502, \"无法连接上游服务\") from exc",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "main.py",
|
|
|
+ "expected": "delay=1 返回 200 和 upstream.delay=1;delay=4 通常约 3 秒后返回 504,消息为上游响应超时。",
|
|
|
+ "explanation": "浏览器到本应用是一段请求,应用再到 /slow 是第二段;这里第二段模拟“上游服务”。HTTPX 的超时异常被转成 504,非成功状态与连接失败转成 502。raise_for_status 负责把非 2xx 变成可捕获错误;出站地址固定,调用方不能任意指定。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "在两个页面来源之间复现 CORS",
|
|
|
+ "body": "在 app 创建之后加入下方 CORSMiddleware;已有配置时修改它,不重复添加。暂时只保留 http://127.0.0.1:5500 这个允许来源;下载版若还允许 localhost:5500,先将后者移出。",
|
|
|
+ "code": "from fastapi.middleware.cors import CORSMiddleware\n\napp.add_middleware(\n CORSMiddleware,\n allow_origins=[\"http://127.0.0.1:5500\"],\n allow_credentials=False,\n allow_methods=[\"GET\", \"POST\", \"PUT\", \"DELETE\"],\n allow_headers=[\"Content-Type\", \"X-API-Key\"],\n)",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "main.py",
|
|
|
+ "expected": "服务能正常重载;配置中明确只有一个允许来源。此时尚未打开前端页面,不会仅因加了配置就看到浏览器提示。",
|
|
|
+ "explanation": "这个配置将告诉浏览器哪些页面可以读取 API 响应。来源由协议、主机、端口共同组成,所以网页的 5500 与 API 的 8000 不同;localhost 和 127.0.0.1 也会被视为不同来源。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "建立前端页面并从 5500 访问 API",
|
|
|
+ "body": "在 api-lab/cors-demo.html 保存下方完整 HTML。另开终端进入 api-lab、激活 .venv,运行 python -m http.server 5500。打开 http://127.0.0.1:5500/cors-demo.html,按 F12 打开 Network,再点击按钮;然后通过 http://localhost:5500/cors-demo.html 打开同一文件并再次点击。",
|
|
|
+ "code": "<!doctype html>\n<html lang=\"zh-CN\">\n<meta charset=\"utf-8\">\n<title>CORS 实验</title>\n<button id=\"send\">向 API 创建商品</button>\n<pre id=\"result\"></pre>\n<script>\ndocument.querySelector(\"#send\").onclick = async () => {\n const output = document.querySelector(\"#result\");\n try {\n const response = await fetch(\"http://127.0.0.1:8000/products\", {\n method: \"POST\",\n headers: {\"Content-Type\": \"application/json\"},\n body: JSON.stringify({name: \"陶瓷杯\", price_cents: 1990}),\n });\n output.textContent = response.status + \"\\n\" + await response.text();\n } catch (error) {\n output.textContent = \"浏览器未能读取响应;检查 Network 和 Console:\" + error.message;\n }\n};\n</script>\n</html>",
|
|
|
+ "lang": "html",
|
|
|
+ "filename": "cors-demo.html",
|
|
|
+ "expected": "第一个页面显示 201 和新商品字段,Network 中可见 OPTIONS 与 POST;第二个页面显示无法读取响应的提示,Console 或 Network 有来源相关错误。",
|
|
|
+ "explanation": "这个 JSON POST 会先触发 OPTIONS 预检,浏览器确认服务允许该来源和请求头后再发送业务请求。两个网页文件相同却结果不同,说明这里检查的是页面来源;不是陶瓷杯数据格式发生了变化。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "读预检请求和响应头",
|
|
|
+ "body": "在 Network 中展开 OPTIONS,比较 Origin 与 Access-Control-Allow-Origin。再用第二个终端执行下方 curl 预检请求,随后执行超时请求;macOS / Linux 用 curl。把 curl 的 Origin 改为 http://localhost:5500 对照后恢复。若以后需要两种网页地址,再把它们都加入允许列表。",
|
|
|
+ "code": "curl.exe -i -X OPTIONS \"http://127.0.0.1:8000/products\" -H \"Origin: http://127.0.0.1:5500\" -H \"Access-Control-Request-Method: POST\" -H \"Access-Control-Request-Headers: content-type\"\ncurl.exe -i \"http://127.0.0.1:8000/upstream-demo?delay=4\"",
|
|
|
+ "lang": "bash",
|
|
|
+ "expected": "允许来源的预检响应包含匹配的 Access-Control-Allow-Origin;不匹配来源不会得到同样的授权头。超时请求仍为 504,它与网页来源错误有不同触发路径。",
|
|
|
+ "explanation": "CORS 是浏览器决定页面能否读响应的机制;curl 和服务端 HTTPX 不执行这种页面限制。因此命令行能拿到响应并不能证明网页可以读取,CORS 也不能代替第 08 课那类身份检查。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "/upstream-demo?delay=1 返回 200,包含上游的 delay 字段。",
|
|
|
+ "/upstream-demo?delay=4 返回 504;把上游地址临时改为未监听端口可观察 502,随后恢复。",
|
|
|
+ "允许来源的预检响应包含 Access-Control-Allow-Origin: http://127.0.0.1:5500,浏览器可以读取响应。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "上游返回 404,接口却继续执行正常处理",
|
|
|
+ "cause": "HTTP 请求收到响应不等于业务成功,代码没有检查状态。",
|
|
|
+ "fix": "调用 raise_for_status 并单独处理 HTTPStatusError。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "curl 成功,网页报 CORS 错误",
|
|
|
+ "cause": "浏览器页面来源与 allow_origins 不匹配,或预检方法、请求头未被允许。",
|
|
|
+ "fix": "逐项核对 Origin、方法和自定义请求头,再看浏览器 Network 中的 OPTIONS 响应。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "用 timeout=3 就以为整个请求最多 3 秒",
|
|
|
+ "cause": "把阶段性超时误认为总请求截止时间,流式响应每次及时发送数据时可能持续更久。",
|
|
|
+ "fix": "先明确要限制连接、块间等待还是总耗时;本课实验只验证读超时。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "让 /upstream-demo 在等待超过 0.2 秒时返回 504,同时保持直接访问 /slow?delay=1 能正常返回。",
|
|
|
+ "hint": "只覆盖 client.get 的 timeout 参数,保留异步 /slow。",
|
|
|
+ "answer": "给该次请求设置 timeout=httpx.Timeout(0.2, connect=1.0)。请求 /upstream-demo?delay=1 会在等待上游数据时触发超时;直接请求 /slow?delay=1 仍约 1 秒后返回 200。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "HTTPX:异步客户端",
|
|
|
+ "url": "https://www.python-httpx.org/async/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "HTTPX:超时阶段",
|
|
|
+ "url": "https://www.python-httpx.org/advanced/timeouts/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "HTTPX:状态码与异常",
|
|
|
+ "url": "https://www.python-httpx.org/quickstart/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:lifespan",
|
|
|
+ "url": "https://fastapi.tiangolo.com/advanced/events/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:CORS",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/cors/"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "商品页面向 API 发请求后,API 有时还要等待另一项服务。我们先让 API 请求本机的等待接口,再从一个简单商品网页调用 API,分别观察服务超时和浏览器不让页面读响应的现象。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 09 课并恢复 /slow 的 await asyncio.sleep 写法;HTTPX 已装进 api-lab/.venv。",
|
|
|
+ "已有第 06 课 POST /products 和第 07 课错误处理。先访问 /slow?delay=1 确认约 1 秒返回。",
|
|
|
+ "本课保留 API 在 8000,新增 cors-demo.html 并在第二终端启动 5500;实验服务均在本机。"
|
|
|
+ ],
|
|
|
+ "outcome": "看到 API 等待过久时返回 504,并让本机商品页面在正确来源下创建商品、在不匹配来源下显示浏览器错误。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "11",
|
|
|
+ "group": "流式与实时通信",
|
|
|
+ "title": "完整响应、流式响应、SSE 与 WebSocket",
|
|
|
+ "summary": "亲眼比较等完再显示、逐段显示和连接内继续发送消息,并为三个简单商品场景选择合适的通信方式。",
|
|
|
+ "objectives": [
|
|
|
+ "运行一次等待完成后返回的清单进度接口",
|
|
|
+ "在交互演示中比较第一段结果何时出现",
|
|
|
+ "用商品保存、清单进度与咨询消息说明选择理由"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "完整响应先准备好结果",
|
|
|
+ "body": "第 2 步所有进度文字一起出现,因为函数在三段处理后才 return。第 12 课会把相同内容改成逐段发送,方便公平比较。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "SSE 是带事件边界的 HTTP 文本流",
|
|
|
+ "body": "第 3 步的逐条进度可以用 SSE 表达:响应类型 text/event-stream,空行分隔事件,方向是服务端向客户端推送。它仍然建立在 HTTP 响应上。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "WebSocket 适合连接内双向消息",
|
|
|
+ "body": "本页模拟里可以在连接保持时继续发咨询,双方不必每条消息都新建 HTTP 请求。消息类型、完成、错误与断线行为仍需要自己定义。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "先跑一次完整结果接口",
|
|
|
+ "body": "把下方 PromptIn 模型和 /generate 路由追加到 api-lab/main.py;模型放在使用它的函数之前。保存后刷新 /docs,找到 POST /generate。",
|
|
|
+ "code": "import asyncio\nfrom pydantic import BaseModel, Field\n\nclass PromptIn(BaseModel):\n prompt: str = Field(min_length=1, max_length=200)\n\n@app.post(\"/generate\")\nasync def generate(payload: PromptIn):\n parts = []\n for text in [\"准备导出商品清单:\", payload.prompt, \"。导出进度演示完成。\"]:\n await asyncio.sleep(0.4)\n parts.append(text)\n return {\"text\": \"\".join(parts)}",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "main.py",
|
|
|
+ "expected": "文档中出现 /generate,输入需要 prompt;此时没有生成下载文件或后台作业,这个接口只准备返回文字。",
|
|
|
+ "explanation": "PromptIn 限定一个非空清单名,循环模拟三次各 0.4 秒的处理。parts 先收集所有片段,最后 join 成一条完整文字再 return,所以响应内容要等循环结束才可用。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "记录完整响应的等待",
|
|
|
+ "body": "在 /docs 的 POST /generate 请求体框中输入下方 JSON,点击 Execute;从点击开始观察,记录业务文字何时出现,再重复一次。",
|
|
|
+ "code": "{\"prompt\":\"catalog\"}",
|
|
|
+ "lang": "json",
|
|
|
+ "expected": "大约 1.2 秒加通信开销后,一次看到完整的“准备导出商品清单:catalog。导出进度演示完成。”,中间没有逐条进度文字。",
|
|
|
+ "explanation": "这次等待来自三次 sleep,return 在循环之后执行。HTTP 响应头何时到达与可用业务文字何时到达不是同一件事;我们关心的是用户何时开始看到内容。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "对照三条连接过程",
|
|
|
+ "body": "操作本页协议演示,依次选择完整 HTTP、SSE、WebSocket。每次先开始再观察第一段文字的出现时机;在 WebSocket 模式尝试保持连接并继续发一条商品咨询。此处不需要把演示动作复制到终端。",
|
|
|
+ "expected": "完整响应等完成后显示文字,SSE 模式逐条显示片段,WebSocket 模式允许在同一连接中再发消息。这些是浏览器模拟的现象,真实实现从下一课开始。",
|
|
|
+ "explanation": "流式 HTTP 把同一个响应体分段送达;SSE 在文本流里规定了“每条事件在哪里结束”。WebSocket 则让双方在连接建立后独立发送消息,这解释了它适合来回咨询。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "把场景写成协议选择题",
|
|
|
+ "body": "在学习笔记写下三个具体选择:保存陶瓷杯字段、等待商品清单进度、在连接中反复咨询商品。为每项补一句“用户需要什么时候看到什么”,再与预期对照。",
|
|
|
+ "expected": "可以得到:保存商品用一次 POST/响应;连续展示进度可用 SSE;双方持续发消息可用 WebSocket。只偶尔查进度时,定期 GET 也可满足需求。",
|
|
|
+ "explanation": "通信方式由交互需要决定:是否需要早到片段、谁能主动发送、连接是否持续。REST 讨论资源和 HTTP 操作,不禁止流式返回;用 WebSocket 也不会自动得到消息保存或真实客服逻辑。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "能说清 SSE 使用 HTTP,且不是 WebSocket 的别名。",
|
|
|
+ "/generate 返回单个 {text:...} 对象;不会在每个步骤结束时返回一个新 HTTP 响应。",
|
|
|
+ "能为保存商品、逐步显示文本、双向实时消息分别选择方案并说明原因。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "为了逐字显示文字,直接把所有接口都改成 WebSocket",
|
|
|
+ "cause": "把流式接收和双向消息通信混为一谈。",
|
|
|
+ "fix": "先确定客户端是否需要在同一条连接上持续主动发送;单向文本结果通常先考虑 HTTP 流式响应。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "把服务器 yield 的次数当成客户端 read 的次数",
|
|
|
+ "cause": "网络、服务器和浏览器可能合并或拆开数据块。",
|
|
|
+ "fix": "依赖协议的事件分隔符解析消息,不依赖网络块边界。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "用户提交一次短文本后只接收生成结果,偶尔点击“停止”。是否必须使用 WebSocket?",
|
|
|
+ "hint": "思考取消 HTTP 请求是否已经满足停止读取的需要。",
|
|
|
+ "answer": "不必须。可以提交 HTTP 请求并读取流式响应,通过取消请求停止读取;服务端同时处理断开并释放资源。如果还需要在同一会话持续双向发送独立消息,再评估 WebSocket。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "FastAPI:StreamingResponse",
|
|
|
+ "url": "https://fastapi.tiangolo.com/advanced/custom-response/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "MDN:SSE 事件流",
|
|
|
+ "url": "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:WebSocket",
|
|
|
+ "url": "https://fastapi.tiangolo.com/advanced/websockets/"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "demo": "protocol",
|
|
|
+ "intro": {
|
|
|
+ "scenario": "商品清单导出需要一点时间:页面可以等全部完成,也可以边等边显示进度。再换成商品咨询,用户与服务还需要不断往返消息。先用一个小实验比较这几种体验。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 10 课,main.py 可正常启动,已会在 /docs 发送 JSON;保留 asyncio 和 Pydantic 依赖。",
|
|
|
+ "本课先新增 /generate,仍在 api-lab 中操作。进度只模拟三个片段,不生成真实导出文件。",
|
|
|
+ "本页协议交互用于观察;后面第 12–15 课会逐个实现真实连接,不必先掌握所有协议名。"
|
|
|
+ ],
|
|
|
+ "outcome": "亲眼比较等完再显示、逐段显示和连接内继续发送消息,并为三个简单商品场景选择合适的通信方式。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "12",
|
|
|
+ "group": "流式与实时通信",
|
|
|
+ "title": "用 StreamingResponse 输出 SSE 事件",
|
|
|
+ "summary": "在终端依次收到三条进度和一条完成事件,并在主动断开后看到服务端清理日志。",
|
|
|
+ "objectives": [
|
|
|
+ "把一条进度编码成明确的事件格式",
|
|
|
+ "让服务每次产生一个片段并最终发送 done",
|
|
|
+ "用真实网络观察逐段到达和中途取消"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "yield 让结果逐段交出去",
|
|
|
+ "body": "第 4 步文字每隔约 0.4 秒出现,来自生成器每轮 yield;StreamingResponse 负责发送。与上一课最后 return 相比,业务内容更早可见。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "双方依赖同一事件约定",
|
|
|
+ "body": "第 5 步核对了 token 的 {text:...}、done 的 {} 和空行边界。JSON 编码保护数据内容,固定事件名保护协议结构,不能把任意输入直接拼成协议行。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "开始前校验,结束时清理",
|
|
|
+ "body": "第 3 步先校验 prompt,第 4 步取消后 finally 打印清理。响应头发送后不能改写 HTTP 状态,后续异常要约定流内错误或明确的连接结束行为。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "定义一处统一的事件编码",
|
|
|
+ "body": "在 main.py 的辅助函数区添加 encode_sse。为方便先看到格式,可在第二终端执行 python 进入提示符,输入 from main import encode_sse,再输入 print(encode_sse(\"token\", {\"text\":\"陶瓷杯\"})),之后 exit()。",
|
|
|
+ "code": "import json\n\ndef encode_sse(event: str, data: dict) -> str:\n payload = json.dumps(data, ensure_ascii=False)\n return f\"event: {event}\\ndata: {payload}\\n\\n\"",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "main.py",
|
|
|
+ "expected": "打印中有 event: token、data: {\"text\": \"陶瓷杯\"},数据后还留出空行。JSON 使用双引号,中文可直接读。",
|
|
|
+ "explanation": "SSE 用 event 指定事件名、data 保存内容,两个换行形成事件结束的空行。json.dumps 负责转义内容里的引号和换行,避免用户文本被误解成另一行协议;event 名由代码固定。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "产生有限长度 token 流",
|
|
|
+ "body": "继续在 main.py 添加 token_events。保留循环中的等待、断开检查、三个 token 和最后的 done;它暂时还没有接到 HTTP 地址。",
|
|
|
+ "code": "import asyncio\nfrom fastapi import Request\n\nasync def token_events(request: Request, prompt: str):\n try:\n for text in [\"准备导出商品清单:\", prompt, \"。导出进度演示完成。\"]:\n await asyncio.sleep(0.4)\n if await request.is_disconnected():\n return\n yield encode_sse(\"token\", {\"text\": text})\n yield encode_sse(\"done\", {})\n finally:\n print(\"stream closed\")",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "main.py",
|
|
|
+ "expected": "文件可重载;函数明确会产生三个文字事件和一个 done,并有 finally 中的 stream closed。仅定义函数不会立即输出事件。",
|
|
|
+ "explanation": "yield 每次交出一段,而不是像上一课那样积累后一次 return。生成器可以在两段之间 await;request.is_disconnected 检查客户端是否离开,finally 则让正常完成、断开或取消都经过清理路径。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "在 /stream 返回 StreamingResponse",
|
|
|
+ "body": "在已有 app 上添加下方 GET /stream,已有同名路由则替换,避免重复注册。保存后在 /docs 查看 prompt 的默认值与长度要求,先保留默认 catalog。",
|
|
|
+ "code": "from fastapi import Query\nfrom fastapi.responses import StreamingResponse\n\n@app.get(\"/stream\")\nasync def stream(\n request: Request,\n prompt: str = Query(default=\"catalog\", min_length=1, max_length=200),\n):\n return StreamingResponse(\n token_events(request, prompt),\n media_type=\"text/event-stream\",\n headers={\"Cache-Control\": \"no-cache\"},\n )",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "main.py",
|
|
|
+ "expected": "/docs 中出现 GET /stream;空 prompt 会在请求开始前被拒绝,正常请求使用 text/event-stream 作为响应类型。下一步从终端观察逐段结果。",
|
|
|
+ "explanation": "StreamingResponse 持续读取上一步生成器并把片段发送出去。prompt 先完成校验,再开始流;响应头一旦发送,就不能事后把已经开始的 200 改成 500,因此错误边界要先想清。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "从终端逐段观察,再主动取消",
|
|
|
+ "body": "在第二个终端执行下方命令;macOS / Linux 将 curl.exe 改为 curl。完整看一次后再请求一次,在第一个片段出现时按 Ctrl+C,同时看 Uvicorn 终端。",
|
|
|
+ "code": "curl.exe -N -i \"http://127.0.0.1:8000/stream?prompt=catalog\"",
|
|
|
+ "lang": "bash",
|
|
|
+ "expected": "完整请求每隔约 0.4 秒出现一条 token,最后出现 done;正常完成和中途取消后服务端最终都有 stream closed。第二次可能来不及收到全部片段。",
|
|
|
+ "explanation": "-N 关闭 curl 自己的输出缓冲,让已到达的数据尽快显示。网络断开可能由检查或取消路径被观察到;finally 负责释放本连接相关资源,真实文件或上游迭代器也应在这类路径显式关闭。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "核对线上实际字节格式",
|
|
|
+ "body": "把刚才终端输出与下方事件体展示逐行对照,尤其检查每个 data 后的空行。此代码块是网络格式示意,不是需要运行的命令。",
|
|
|
+ "code": "event: token\ndata: {\"text\": \"准备导出商品清单:\"}\n\nevent: token\ndata: {\"text\": \"catalog\"}\n\nevent: token\ndata: {\"text\": \"。导出进度演示完成。\"}\n\nevent: done\ndata: {}\n\n",
|
|
|
+ "lang": "http",
|
|
|
+ "expected": "看到三条 event: token,最后为 event: done 且 data 是 {};每条事件之间都有空行,不会将相邻 JSON 黏在一起。",
|
|
|
+ "explanation": "token 只是本课程为文字片段选择的事件名,不是模型分词。JSON 字符串内部的换行会变成转义序列,协议边界仍由外层空行决定;正常完成靠 done 明确表达,不能仅凭连接消失判断。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "响应 Content-Type 以 text/event-stream 开头;终端能在流结束前看到 token。",
|
|
|
+ "正常结束只有一个 done,三个 token 的 text 拼接后与 /generate 的 text 相同。",
|
|
|
+ "prompt 为空返回 422;请求中途取消后清理日志出现,服务仍能处理 /health。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "客户端一直等不到事件回调",
|
|
|
+ "cause": "发送了 data 行却漏了事件末尾的空行。",
|
|
|
+ "fix": "每条事件统一通过 encode_sse 编码,并确认末尾为 \\n\\n。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "输入含换行后事件解析失败",
|
|
|
+ "cause": "手工拼接 JSON 或把原始输入直接放进 data 行。",
|
|
|
+ "fix": "先 json.dumps,再写入 SSE data 字段。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "断开后仍持续执行,或者没有清理资源",
|
|
|
+ "cause": "无限循环未处理断开、没有 await 让取消生效,或遗漏 finally 清理。",
|
|
|
+ "fix": "在迭代中检查断开,使用可等待的 I/O,并在 finally 中释放资源;保持本课生成器有限长度。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "在三个 token 后、done 前增加一个 stats 事件,返回 token_count:3;保持已有客户端仍能完成。",
|
|
|
+ "hint": "stats 是独立事件类型,不需要把它伪装成 token。",
|
|
|
+ "answer": "在 done 前增加 yield encode_sse(\"stats\", {\"token_count\": 3})。原客户端只监听 token 和 done 时会忽略 stats;新增监听器后可以显示统计值。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "FastAPI:自定义与流式响应",
|
|
|
+ "url": "https://fastapi.tiangolo.com/advanced/custom-response/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "MDN:SSE 消息格式",
|
|
|
+ "url": "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:Request 类与断开检查",
|
|
|
+ "url": "https://fastapi.tiangolo.com/reference/request/"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "上一课清单进度要等全部结束才出现。现在保留同样三个片段,让终端边等边看到它们,再在中途停止一次,检查服务是否清理连接。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 11 课,main.py 中 POST /generate 能返回完整清单文字,服务在 8000。",
|
|
|
+ "继续 api-lab/.venv;另开终端准备使用 curl(Windows 为 curl.exe)。本课新增 GET /stream,不替换 /generate。"
|
|
|
+ ],
|
|
|
+ "outcome": "在终端依次收到三条进度和一条完成事件,并在主动断开后看到服务端清理日志。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "13",
|
|
|
+ "group": "流式与实时通信",
|
|
|
+ "title": "在浏览器中读取流,并在中途停止",
|
|
|
+ "summary": "在浏览器逐条读出清单进度,能中途取消,也能把清单名放进 POST 请求体后继续接收同样的流。",
|
|
|
+ "objectives": [
|
|
|
+ "用 EventSource 收到 token 并在 done 后关闭",
|
|
|
+ "用 fetch 分块读取并按事件边界还原内容",
|
|
|
+ "验证主动停止和 POST 输入两种变化"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "现成接收器适合简单 GET",
|
|
|
+ "body": "第 1 步 EventSource 自动处理 SSE,但我们仍监听命名事件并在 done 后 close。它不能任意配置请求体或自定义头,也不能把自动重连当成可靠业务恢复。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "网络块与业务事件不是一回事",
|
|
|
+ "body": "第 2 步连续解码字节、缓存文字、按空行拆事件;一次 read 可包含多个事件,也可能只含半个字符。这个解析器覆盖本课 LF/CRLF 格式,不实现完整 EventSource 的 id、retry 等行为。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "主动取消与结果不完整分开判断",
|
|
|
+ "body": "第 3 步 AbortError 明确表示用户停止;没有 done 就意外断开则报告错误。第 4 步 POST 只改变输入方式,仍可读取相同的流式响应。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "先用 EventSource 接收命名事件",
|
|
|
+ "body": "在本地 /docs 的 Console 执行下面完整代码,观察一次完成过程。再次执行后立刻输入 stopSse(),比较中途关闭的输出;若 Console 禁止直接粘贴,按浏览器提示检查代码后使用其允许的输入方式。",
|
|
|
+ "code": "{\n const source = new EventSource(\"/stream?prompt=catalog\");\n source.addEventListener(\"token\", (event) => {\n console.log(JSON.parse(event.data).text);\n });\n source.addEventListener(\"done\", () => {\n console.log(\"完成\");\n source.close();\n });\n source.onerror = () => {\n console.error(\"流连接失败,已停止\");\n source.close();\n };\n window.stopSse = () => source.close();\n}",
|
|
|
+ "lang": "javascript",
|
|
|
+ "filename": "浏览器 Console",
|
|
|
+ "expected": "正常时逐条打印三段文字和“完成”;主动 stopSse() 时后续片段停止。Network 中不会因 done 后自动反复新建同一请求。",
|
|
|
+ "explanation": "EventSource 已经会按 SSE 格式拆事件,但 token 是命名事件,需要 addEventListener(\"token\",...)。它默认具备重连行为,所以有限进度在 done 或本课错误处理时主动 close,避免重新执行。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "把事件解析与网络读取分开",
|
|
|
+ "body": "在同一 Console 定义下方 consumeSse 函数,完整复制到最后一个大括号。这一步只定义函数,先不再发请求;沿代码找到 reader.read、decoder、buffer 和 done 四个位置。",
|
|
|
+ "code": "async function consumeSse(response, onEvent) {\n if (!response.ok) throw new Error(`HTTP ${response.status}`);\n if (!response.headers.get(\"content-type\")?.includes(\"text/event-stream\")) {\n throw new Error(\"响应不是 SSE\");\n }\n if (!response.body) throw new Error(\"没有响应体\");\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n let buffer = \"\";\n let completed = false;\n try {\n while (!completed) {\n const { value, done } = await reader.read();\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n let boundary;\n while ((boundary = /\\r?\\n\\r?\\n/.exec(buffer)) !== null) {\n const block = buffer.slice(0, boundary.index);\n buffer = buffer.slice(boundary.index + boundary[0].length);\n const lines = block.split(/\\r?\\n/);\n const name = lines.find(line => line.startsWith(\"event:\"))\n ?.slice(6).trim() || \"message\";\n const data = lines.filter(line => line.startsWith(\"data:\"))\n .map(line => line.slice(5).replace(/^ /, \"\")).join(\"\\n\");\n if (data) onEvent(name, JSON.parse(data));\n if (name === \"done\") { completed = true; break; }\n }\n if (done && !completed) throw new Error(\"连接结束,但没有收到 done\");\n }\n await reader.cancel();\n } catch (error) {\n await reader.cancel().catch(() => {});\n throw error;\n } finally {\n reader.releaseLock();\n }\n}",
|
|
|
+ "lang": "javascript",
|
|
|
+ "filename": "浏览器 Console",
|
|
|
+ "expected": "控制台可完成定义,可能显示 undefined,且没有新的 /stream 请求,这是预期的准备步骤。",
|
|
|
+ "explanation": "reader.read 得到的是网络字节块,不保证对应一个中文字或一条事件。TextDecoder(stream:true) 保存不完整 UTF-8 字节,buffer 保存跨块文本,遇到空行才提取事件;没有收到 done 就断开会报告不完整结果。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "用 fetch 读取同一个 GET 接口",
|
|
|
+ "body": "继续在 Console 定义并执行下方 startStream。完整完成一次后再次调用 startStream(),马上调用 stopStream();每次开始都使用函数内部新建的控制器。",
|
|
|
+ "code": "async function startStream() {\n const controller = new AbortController();\n window.stopStream = () => controller.abort();\n try {\n const response = await fetch(\"/stream?prompt=catalog\", {\n signal: controller.signal,\n });\n await consumeSse(response, (name, data) => {\n if (name === \"token\") console.log(data.text);\n if (name === \"done\") console.log(\"完成\");\n });\n } catch (error) {\n if (error.name === \"AbortError\") console.log(\"已取消\");\n else console.error(error);\n }\n}\nstartStream();",
|
|
|
+ "lang": "javascript",
|
|
|
+ "filename": "浏览器 Console",
|
|
|
+ "expected": "完整过程打印三个片段和“完成”;取消时打印“已取消”,不会显示成一般服务故障。服务端也会经过上课的 stream closed 清理路径。",
|
|
|
+ "explanation": "fetch 先拿到响应,再由 consumeSse 逐块读取。AbortController 的 signal 把停止动作传入本次请求,abort 触发的 AbortError 代表用户取消;读完后取消读取并释放 reader 的锁,避免悬挂资源。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "扩展:需要 POST 请求体时",
|
|
|
+ "body": "把下方 POST /stream 加入 main.py,保留已有 GET /stream。服务重载后在 Console 重新定义 startStream:将 fetch 地址改为 /stream,并增加 method:\"POST\"、headers:{\"Content-Type\":\"application/json\"}、body:JSON.stringify({prompt:\"catalog\"}),保留 signal。执行 startStream()。",
|
|
|
+ "code": "@app.post(\"/stream\")\nasync def stream_post(request: Request, payload: PromptIn):\n return StreamingResponse(\n token_events(request, payload.prompt),\n media_type=\"text/event-stream\",\n headers={\"Cache-Control\": \"no-cache\"},\n )",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "main.py",
|
|
|
+ "expected": "Network 中此请求方法是 POST、Payload 有 prompt,控制台仍逐条收到相同的 token 和 done;不是一次性完整 JSON。",
|
|
|
+ "explanation": "请求如何发送与响应如何流式返回是两件事。原生 EventSource 不能任意附加 POST 请求体,而 fetch 可以;/generate 返回完整 JSON,不能误交给要求 text/event-stream 的 consumeSse。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "核对流为什么没有逐段显示",
|
|
|
+ "body": "再次用 curl -N(Windows curl.exe -N)请求 http://127.0.0.1:8000/stream?prompt=catalog,再运行浏览器 startStream()。比较第一段到达时机;若浏览器只最后一次显示,检查是否把消费代码换成了 response.text() 或 response.json()。",
|
|
|
+ "expected": "直连终端与浏览器都应逐段出现内容;主动停止后没有继续输出。若两边行为不同,记录是终端、读取代码还是经过中间服务时才出现差异。",
|
|
|
+ "explanation": "text() 和 json() 会等完整响应读完,失去边读边显示的效果。若本机两边都正常而通过中间服务才被积攒,才进一步检查代理缓冲;本课的分层对比先确定问题发生的位置。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "EventSource 收到三次 token 和一次 done 后关闭,不会周期性重新发起 /stream。",
|
|
|
+ "fetch 版本能正常显示中文;主动 stopStream 后输出“已取消”。",
|
|
|
+ "扩展 POST /stream 返回 SSE;POST /generate 仍返回 application/json。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "偶发 JSON.parse 错误,或中文出现替换字符",
|
|
|
+ "cause": "直接对每次 read 的内容解码和解析,忽略了字符、事件可能跨块。",
|
|
|
+ "fix": "保持同一 TextDecoder 并设置 stream:true,再缓存到完整空行边界后解析。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "一次成功结束后又出现同样文本",
|
|
|
+ "cause": "有限 SSE 结束后 EventSource 自动重连,客户端没有处理 done。",
|
|
|
+ "fix": "监听 done 调用 close;真实恢复能力需要单独定义,不默认重复执行。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "点击停止后仍把状态改成失败",
|
|
|
+ "cause": "把所有 fetch 异常都当成网络故障。",
|
|
|
+ "fix": "单独识别 AbortError,并同步把页面状态改为已取消。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "把一条包含“你好”的 SSE 消息拆成逐个字节的 ReadableStream,验证解析函数仍得到完整中文和 done。",
|
|
|
+ "hint": "用 TextEncoder 生成字节,再通过 Response 包装本地 ReadableStream;这只是解析器实验,不是 API 网络测试。",
|
|
|
+ "answer": "构造文本 'event: token\\ndata: {\"text\":\"你好\"}\\n\\nevent: done\\ndata: {}\\n\\n';对 TextEncoder().encode(text) 的每个字节调用 controller.enqueue(new Uint8Array([byte])),最后 close。用 new Response(stream,{headers:{\"Content-Type\":\"text/event-stream\"}}) 传入 consumeSse,回调应只收到完整 token 和 done。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "MDN:EventSource",
|
|
|
+ "url": "https://developer.mozilla.org/en-US/docs/Web/API/EventSource"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "MDN:读取 ReadableStream",
|
|
|
+ "url": "https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "MDN:TextDecoder.decode",
|
|
|
+ "url": "https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/decode"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "MDN:AbortController",
|
|
|
+ "url": "https://developer.mozilla.org/en-US/docs/Web/API/AbortController"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "终端已经能看到逐段进度,接下来把它显示到浏览器,并给用户一个“停止等待”的动作。先用浏览器现成接收器,再拆开看看手动读取为何不能每个网络块都当作完整事件。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 12 课,curl -N 直连 GET /stream 能收到三个 token 和 done。",
|
|
|
+ "保留第 11 课 PromptIn 与第 12 课 token_events、StreamingResponse 导入;本课仍编辑 api-lab/main.py。",
|
|
|
+ "在浏览器打开 http://127.0.0.1:8000/docs,按 F12 进入 Console;以下 JavaScript 均在这个页面执行,先避免跨来源干扰。"
|
|
|
+ ],
|
|
|
+ "outcome": "在浏览器逐条读出清单进度,能中途取消,也能把清单名放进 POST 请求体后继续接收同样的流。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "14",
|
|
|
+ "group": "流式与实时通信",
|
|
|
+ "title": "建立第一条 WebSocket 双向连接",
|
|
|
+ "summary": "在浏览器建立一条连接,连续发送咨询与 ping,收到 echo 与 pong,然后关闭并重新连接。",
|
|
|
+ "objectives": [
|
|
|
+ "在服务端接受连接并循环收发消息",
|
|
|
+ "等待浏览器连接打开后再发送两种文本",
|
|
|
+ "主动关闭并核对浏览器与服务端的结束日志"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "先建立连接,再发送消息",
|
|
|
+ "body": "第 2 步等 open,第 3 步再 send;WebSocket 不使用 /docs 的 HTTP Execute。连接建立后可以连续收发多条消息。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "消息格式由双方约定",
|
|
|
+ "body": "本课发咨询文本收到 echo,发 ping 收到 pong。send_json 真正发送消息,return 字典不能代替 WebSocket 的发送动作。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "断开是要处理的正常出口",
|
|
|
+ "body": "第 4 步两端都看到关闭日志。WebSocketDisconnect 与 finally 让服务清理该连接资源,新建连接则重新开始一个生命周期。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "在 main.py 插入 /ws 路由",
|
|
|
+ "body": "把下方导入和 /ws 路由追加到 api-lab/main.py,保存并等待重载。保留 receive_text、send_json 与 finally 日志;不要把 send_json 改为 HTTP 路由式的 return 字典。",
|
|
|
+ "code": "from fastapi import WebSocket, WebSocketDisconnect\n\n@app.websocket(\"/ws\")\nasync def websocket_echo(websocket: WebSocket):\n await websocket.accept()\n try:\n while True:\n text = await websocket.receive_text()\n if text == \"ping\":\n await websocket.send_json({\"type\": \"pong\"})\n else:\n await websocket.send_json({\"type\": \"echo\", \"text\": text})\n except WebSocketDisconnect:\n pass\n finally:\n print(\"websocket closed\")",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "main.py",
|
|
|
+ "expected": "服务可正常启动;/ws 不会像普通 HTTP 操作那样出现 /docs 的 Execute 按钮,需从下一步浏览器代码连接。",
|
|
|
+ "explanation": "websocket.accept 接受连接,while True 等下一条文本,send_json 沿同一连接回发消息。收到 ping 时回 pong,其余文字回 echo;断开会抛出 WebSocketDisconnect,再进入 finally 清理。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "从浏览器建立连接",
|
|
|
+ "body": "在 http://127.0.0.1:8000/docs 的 Console 执行下面代码。打开 Network 的 WS 筛选,先等待“连接已打开”再继续下一步。",
|
|
|
+ "code": "{\n const socket = new WebSocket(\"ws://127.0.0.1:8000/ws\");\n window.echoSocket = socket;\n socket.onopen = () => console.log(\"连接已打开\");\n socket.onmessage = event => console.log(\"收到\", JSON.parse(event.data));\n socket.onerror = () => console.error(\"连接发生错误\");\n socket.onclose = event => console.log(\"已关闭\", event.code, event.reason);\n}",
|
|
|
+ "lang": "javascript",
|
|
|
+ "filename": "浏览器 Console",
|
|
|
+ "expected": "控制台出现“连接已打开”,Network 中有 /ws 连接;不会立即出现 echo,因为我们还没发业务消息。",
|
|
|
+ "explanation": "ws:// 表示 WebSocket 地址,浏览器从 CONNECTING 走到 OPEN 后才可发数据。open、message、close 分别表示连接可用、收到消息、连接结束,不是同一个阶段的不同文字。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "在 OPEN 状态发送两种消息",
|
|
|
+ "body": "在同一 Console 执行下面代码。查看控制台和 Network 中 /ws 的消息列表,分清发出方向与收到方向。",
|
|
|
+ "code": "if (echoSocket.readyState === WebSocket.OPEN) {\n echoSocket.send(\"这款陶瓷杯有多大?\");\n echoSocket.send(\"ping\");\n}",
|
|
|
+ "lang": "javascript",
|
|
|
+ "filename": "浏览器 Console",
|
|
|
+ "expected": "连续收到 {type:\"echo\",text:\"这款陶瓷杯有多大?\"} 与 {type:\"pong\"};两次往返使用同一条 /ws 连接。",
|
|
|
+ "explanation": "客户端发的是文本,服务端回的是 JSON,所以 onmessage 需要 JSON.parse。readyState 检查确保连接可用;send 只将内容送入发送流程,实际收到 echo 才是对方已处理的证据。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "主动关闭并确认生命周期结束",
|
|
|
+ "body": "执行下方 close,再观察浏览器与服务终端。随后重新执行第 2 步建立新连接,等 open 后再次执行第 3 步;验收完关闭新连接。",
|
|
|
+ "code": "echoSocket.close(1000, \"lesson finished\");",
|
|
|
+ "lang": "javascript",
|
|
|
+ "filename": "浏览器 Console",
|
|
|
+ "expected": "浏览器打印已关闭及 1000,服务端打印 websocket closed;新建连接后可再次收发。旧的 echoSocket 一旦关闭便不能拿来继续发送。",
|
|
|
+ "explanation": "1000 表示正常关闭。关页、断网或主动关闭都属于连接生命周期,服务要处理这些常见出口。重连得到新连接,并不自动恢复旧连接的聊天内容或执行状态。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "浏览器先收到 open,发送商品咨询后收到 {type:'echo',text:'这款陶瓷杯有多大?'}。",
|
|
|
+ "发送 ping 后收到 {type:'pong'};同一连接可重复收发。",
|
|
|
+ "主动关闭后收到 close,服务端清理日志出现,重新连接仍正常。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "调用 send 报连接尚未建立",
|
|
|
+ "cause": "new WebSocket 返回时握手仍在进行。",
|
|
|
+ "fix": "在 open 回调中发送,或先检查 readyState === WebSocket.OPEN。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "浏览器无任何回复,路由却 return 了字典",
|
|
|
+ "cause": "WebSocket 消息不会像 HTTP 响应一样自动序列化返回。",
|
|
|
+ "fix": "在连接内 await websocket.send_json(...)。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "把 WebSocket 地址粘到普通地址栏后得到 404",
|
|
|
+ "cause": "使用 HTTP GET 测试了 WebSocket 端点,没有进行 WebSocket 握手。",
|
|
|
+ "fix": "通过浏览器 WebSocket API 或支持 WS 的客户端测试,使用 ws:// 地址。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "增加一个文本命令 upper:hello,让服务端回复 {type:'upper',text:'HELLO'},其他消息保持回显。",
|
|
|
+ "hint": "把 startswith('upper:') 分支放在默认 echo 分支之前。",
|
|
|
+ "answer": "在 echo 的 else 之前加入 elif text.startswith(\"upper:\"): await websocket.send_json({\"type\":\"upper\",\"text\":text.removeprefix(\"upper:\").upper()})。浏览器收到的 JSON type 应为 upper。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "FastAPI:WebSocket 收发与断开",
|
|
|
+ "url": "https://fastapi.tiangolo.com/advanced/websockets/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "MDN:编写 WebSocket 客户端",
|
|
|
+ "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "清单进度只需服务往浏览器推送;商品咨询则希望用户随时继续提问。现在保持一条连接,发送两种消息并观察回信,再主动关闭它。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 13 课,api-lab/main.py 服务在 8000,Uvicorn standard 依赖已安装。",
|
|
|
+ "在本地 /docs 打开浏览器 Console 与 Network,先让已有 SSE 完成或停止,便于观察新连接。",
|
|
|
+ "本课新增 /ws,只回显咨询文本,不做客服逻辑、身份认证或聊天保存。"
|
|
|
+ ],
|
|
|
+ "outcome": "在浏览器建立一条连接,连续发送咨询与 ping,收到 echo 与 pong,然后关闭并重新连接。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "15",
|
|
|
+ "group": "流式与实时通信",
|
|
|
+ "title": "连接断开后:重连、心跳与取消",
|
|
|
+ "summary": "让断线后重试间隔逐渐变长,服务恢复后能重新收发,并在手动停止后取消所有后续连接尝试。",
|
|
|
+ "objectives": [
|
|
|
+ "用 ping/pong 检查连接是否仍能往返",
|
|
|
+ "在断开出口安排有上限的重试",
|
|
|
+ "执行正常、断线恢复、主动停止三组实验"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "重试必须有等待、上限和停止出口",
|
|
|
+ "body": "第 5 步观察了递增间隔、5 次上限和 stopWs 后无新请求。重试集中在 close 路径,避免 error 与 close 重复创建连接。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "心跳证明近期消息仍能往返",
|
|
|
+ "body": "第 4 步 Network 中的 ping/pong 是应用层普通消息;浏览器 API 不提供主动发送协议级 Ping 帧的方法。后台页面可能节流计时器,阈值要按使用场景调整。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "恢复连接不代表恢复业务",
|
|
|
+ "body": "第 5 步断线时未发送的文本不会自动补发。重连产生新 socket;可靠恢复需要单独的消息确认、编号和去重设计。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "先定义连接状态与定时器清理",
|
|
|
+ "body": "先执行 echoSocket.close() 关闭上一课连接,或刷新本地 /docs 页面。从 Console 执行下方状态变量与清理函数;本课各代码块按顺序执行,暂时不会建立新连接。",
|
|
|
+ "code": "let wsSocket = null;\nlet retryTimer = null;\nlet heartbeatTimer = null;\nlet pongTimer = null;\nlet wsStopped = true;\nlet retryCount = 0;\n\nfunction clearHeartbeat() {\n clearInterval(heartbeatTimer);\n clearTimeout(pongTimer);\n heartbeatTimer = null;\n pongTimer = null;\n}",
|
|
|
+ "lang": "javascript",
|
|
|
+ "filename": "浏览器 Console",
|
|
|
+ "expected": "控制台没有新 WS 请求;变量初始为停止状态,重复整块声明若报已定义,应刷新页面后重来。",
|
|
|
+ "explanation": "这些变量分别记录当前连接、计划中的重试、定时心跳与等待 pong 的计时器。集中保存它们,停止时才能逐一取消,而不是留下页面已经不需要的任务。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "加入应用层心跳",
|
|
|
+ "body": "继续在同一 Console 定义 startHeartbeat。先只定义,不手工调用;下一步会在连接打开时启动它,在收到 pong 时取消等待计时。",
|
|
|
+ "code": "function startHeartbeat(socket) {\n clearHeartbeat();\n heartbeatTimer = setInterval(() => {\n if (socket.readyState !== WebSocket.OPEN) return;\n socket.send(\"ping\");\n pongTimer = setTimeout(() => {\n socket.close(4000, \"heartbeat timeout\");\n }, 3000);\n }, 5000);\n}",
|
|
|
+ "lang": "javascript",
|
|
|
+ "filename": "浏览器 Console",
|
|
|
+ "expected": "函数定义完成但 Network 里仍没有周期 ping;心跳要等实际连接后才开始。",
|
|
|
+ "explanation": "每 5 秒发一次普通文本 ping,3 秒没有回复就关闭该连接。这里的间隔比等待回复时间长,同一时刻只等一个 pong;关闭动作统一交给后面的 close 处理器决定是否重试。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "把重连集中在 close 事件中",
|
|
|
+ "body": "定义下方 connectWs,逐处找到 onopen、onmessage、onerror、onclose。保持重试安排只在 onclose 中,先不额外手工调用 connectWs。",
|
|
|
+ "code": "function connectWs() {\n if (wsStopped) return;\n const socket = new WebSocket(\"ws://127.0.0.1:8000/ws\");\n wsSocket = socket;\n socket.onopen = () => {\n if (wsStopped || wsSocket !== socket) { socket.close(); return; }\n console.log(\"已连接\");\n startHeartbeat(socket);\n };\n socket.onmessage = event => {\n if (wsSocket !== socket) return;\n const message = JSON.parse(event.data);\n retryCount = 0;\n if (message.type === \"pong\") {\n clearTimeout(pongTimer);\n pongTimer = null;\n } else {\n console.log(\"收到\", message);\n }\n };\n socket.onerror = () => console.log(\"连接错误,等待 close\");\n socket.onclose = () => {\n if (wsSocket !== socket) return;\n clearHeartbeat();\n if (wsStopped) return;\n if (retryCount >= 5) {\n wsStopped = true;\n console.log(\"连续重试已达上限,请手动开始\");\n return;\n }\n const delay = Math.min(8000, 500 * 2 ** retryCount) + Math.random() * 200;\n retryCount += 1;\n console.log(`第 ${retryCount} 次重试,等待 ${Math.round(delay)}ms`);\n retryTimer = setTimeout(connectWs, delay);\n };\n}",
|
|
|
+ "lang": "javascript",
|
|
|
+ "filename": "浏览器 Console",
|
|
|
+ "expected": "函数中可见最多 5 次连续失败重试,等待依次约 0.5、1、2、4、8 秒并有小幅随机变化;定义本身不会连上服务。",
|
|
|
+ "explanation": "逐渐延长等待叫退避,随机量叫抖动,用来避免失败时不断密集重连。error 只记录,close 只安排一次重试,防止两个事件各创建一条连接;有效消息到来后才清零失败计数。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "实现开始、发送与停止",
|
|
|
+ "body": "执行下方开始、发送和停止函数以及最后的 startWs()。等“已连接”后调用 sendWs(\"陶瓷杯有多大?\"),保持窗口可见至少 6 秒,查看 WS 消息中的 ping/pong。",
|
|
|
+ "code": "function startWs() {\n if (!wsStopped) return;\n wsStopped = false;\n retryCount = 0;\n connectWs();\n}\nfunction sendWs(text) {\n if (wsSocket?.readyState !== WebSocket.OPEN) {\n console.log(\"尚未连接,本条消息未发送\");\n return;\n }\n wsSocket.send(text);\n}\nfunction stopWs() {\n wsStopped = true;\n clearTimeout(retryTimer);\n clearHeartbeat();\n wsSocket?.close(1000, \"user stopped\");\n wsSocket = null;\n console.log(\"已停止\");\n}\nwindow.addEventListener(\"pagehide\", stopWs);\nstartWs();",
|
|
|
+ "lang": "javascript",
|
|
|
+ "filename": "浏览器 Console",
|
|
|
+ "expected": "收到咨询 echo;约 5 秒后看到 ping 和 pong,连接仍保持。再次调用 startWs() 不会额外建立第二条连接。",
|
|
|
+ "explanation": "开始函数防止重复连接;发送前检查 OPEN,未连上时明确报告未发送。停止先设置 wsStopped,再取消全部计时器并关闭连接,因此随后触发 close 时也不会安排重试。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "执行三组失败实验",
|
|
|
+ "body": "先确认 sendWs(\"hello\") 能回显。停止 Uvicorn,观察两次重试间隔后尽快重启,待重连成功再发消息;若已达 5 次上限,手动 startWs()。再次停止服务,在下一次重试前执行 stopWs(),等待超过 9 秒并检查 WS 请求列表,最后重启服务供后课使用。",
|
|
|
+ "expected": "断线时显示逐渐延长的重试,恢复后新连接可收发;主动停止后超过 9 秒没有新增 WS。断线期间 sendWs 提示未发送,该文本不会在重连后突然出现。",
|
|
|
+ "explanation": "重连只恢复一条新通道,不恢复旧执行状态,也不自动补送业务消息。本课选择明确拒绝未连接时发送;若将来要求可靠补发,还需要消息编号、确认与服务端去重,不能只加一个重试计时器。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "服务停止时重连间隔逐步增加,连续失败达到 5 次重试后停止。",
|
|
|
+ "服务恢复后能建立新连接并再次收到 echo,心跳 ping 对应 pong。",
|
|
|
+ "stopWs 后超过 9 秒没有新连接;未连接时调用 sendWs 不会排队重发。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "每次断线都会产生两条新连接",
|
|
|
+ "cause": "在 error 和 close 中分别启动重试,或开始按钮允许重复创建。",
|
|
|
+ "fix": "只在 close 调度重试;开始前检查当前是否已经运行。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "点停止后几秒钟又自动连上",
|
|
|
+ "cause": "只关闭 socket,没有取消已经安排的 setTimeout。",
|
|
|
+ "fix": "先设置主动停止标记,再清理重试、心跳和 pong 超时定时器。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "重连后重复执行了一条操作",
|
|
|
+ "cause": "客户端自动重发消息,但无法确定服务端在断开前是否已经执行。",
|
|
|
+ "fix": "本课不自动重发;需要重试业务操作时另行加入确认、操作编号与幂等处理。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "把连续重试上限改为 3 次,并验证用户手动 startWs() 后可以开始新一轮连接。",
|
|
|
+ "hint": "更改重试边界,保留达到上限时 wsStopped=true,以及开始时 retryCount=0。",
|
|
|
+ "answer": "把 if (retryCount >= 5) 改为 >= 3。连续失败后控制台打印到达上限;随后调用 startWs() 重置次数并重新连接,若服务已恢复即可继续回显。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "MDN:WebSocket 客户端生命周期",
|
|
|
+ "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "MDN:WebSocket.close",
|
|
|
+ "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "RFC 6455:异常关闭后的恢复建议",
|
|
|
+ "url": "https://www.rfc-editor.org/rfc/rfc6455#section-7.2.3"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "咨询连接遇到网络中断后,页面应尝试恢复,但用户点“停止”后就不应继续连接。我们沿用上一课的回显服务,只补齐浏览器端的检测、等待重试与取消。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 14 课,main.py 的 /ws 能对 ping 返回 pong,对其他文本回 echo;本课不修改服务端路由。",
|
|
|
+ "在本地 /docs 的 Console 执行脚本,先关闭上一课 echoSocket;若要重做整课,刷新页面后从第 1 步开始,避免重复声明 let。",
|
|
|
+ "保留能停止和重启 Uvicorn 的终端;这些中断只针对本机练习服务。"
|
|
|
+ ],
|
|
|
+ "outcome": "让断线后重试间隔逐渐变长,服务恢复后能重新收发,并在手动停止后取消所有后续连接尝试。"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "16",
|
|
|
+ "group": "联调与验收",
|
|
|
+ "title": "把能运行的接口变成可重复验收的服务",
|
|
|
+ "summary": "用一条 pytest 命令自动核对商品生命周期、错误、校验值、SSE 与 WebSocket,再用真实网络确认逐段到达和浏览器行为。",
|
|
|
+ "objectives": [
|
|
|
+ "把手工验收的状态码与字段写成断言",
|
|
|
+ "通过返回的真实商品 id 串起创建到删除",
|
|
|
+ "分清进程内测试覆盖范围与仍需浏览器验证的行为"
|
|
|
+ ],
|
|
|
+ "concepts": [
|
|
|
+ {
|
|
|
+ "title": "把观察写成具体断言",
|
|
|
+ "body": "第 2 步不是只检查创建没报错,而是跟踪 id、再次读取更新结果、确认 204 空体及删除后 404。这样的检查才能保护接口行为。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "TestClient 不是浏览器或真实网络",
|
|
|
+ "body": "第 1 步无需 Uvicorn 也能测试,with 仍会运行 lifespan;第 4 步验证事件内容,不能据此保证首段延迟或浏览器 CORS。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "不同结束方式分别验证",
|
|
|
+ "body": "本课自动检查 422、401、404、SSE done 与 WS 回信;第 5 步另行确认取消和断线停止。正常完成、主动取消和错误不能混为同一结果。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "steps": [
|
|
|
+ {
|
|
|
+ "title": "建立测试客户端",
|
|
|
+ "body": "在 api-lab 新建 tests 目录和 tests/test_acceptance.py。先在已激活终端运行 python -m pip install pytest httpx,再把下方代码写入文件;第 2–4 步继续追加到同一个文件。先执行 python -m pytest -q tests/test_acceptance.py 检查第一条测试。",
|
|
|
+ "code": "import json\nfrom uuid import uuid4\nimport pytest\nfrom fastapi.testclient import TestClient\nfrom main import app\n\n@pytest.fixture\ndef client(monkeypatch):\n monkeypatch.setenv(\"DEMO_API_KEY\", \"classroom-key\")\n with TestClient(app) as test_client:\n yield test_client\n\ndef test_health(client):\n response = client.get(\"/health\")\n assert response.status_code == 200",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "tests/test_acceptance.py",
|
|
|
+ "expected": "目前看到 1 passed;若导入 main 失败,确认命令从 api-lab 根目录运行。测试不需要先在 8000 启动 Uvicorn。",
|
|
|
+ "explanation": "TestClient 在进程内调用 app,assert 检查真实响应状态;pytest.fixture 为每个测试提供客户端。with 会运行 lifespan,monkeypatch 只在测试期间设置 classroom-key,结束后恢复。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "验证创建、查询、更新与删除",
|
|
|
+ "body": "把下面生命周期测试追加到同一个文件,再执行相同 pytest 命令。保留从 POST 响应读取 id 的过程;PUT 明确提供 name、description、is_active、price_cents 四个可写字段。",
|
|
|
+ "code": "def test_product_lifecycle(client):\n name = f\"acceptance-{uuid4()}\"\n created = client.post(\"/products\", json={\"name\": name})\n assert created.status_code == 201\n product = created.json()\n product_id = product[\"id\"]\n assert product[\"description\"] == \"\"\n assert product[\"is_active\"] is False\n assert product[\"price_cents\"] == 0\n assert client.get(f\"/products/{product_id}\").json() == product\n\n listed = client.get(\"/products\", params={\"limit\": 100})\n assert listed.status_code == 200\n assert any(item[\"id\"] == product_id for item in listed.json())\n\n replacement = {\"name\": \"更新后\", \"description\": \"测试描述\", \"is_active\": True, \"price_cents\": 0}\n updated = client.put(f\"/products/{product_id}\", json=replacement)\n assert updated.status_code == 200\n assert updated.json() == {\"id\": product_id, **replacement}\n assert client.get(f\"/products/{product_id}\").json() == updated.json()\n\n deleted = client.delete(f\"/products/{product_id}\")\n assert deleted.status_code == 204\n assert deleted.content == b\"\"\n missing = client.get(f\"/products/{product_id}\")\n assert missing.status_code == 404\n assert missing.json()[\"error\"][\"code\"] == \"http_error\"",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "tests/test_acceptance.py",
|
|
|
+ "expected": "累计 2 passed;断言同时检查 201、默认字段、列表里出现编号、PUT 后再次读取一致、DELETE 空体和删除后的 404。",
|
|
|
+ "explanation": "每个测试用唯一名称创建自己的商品,避免依赖 id=1 或已有样本。断言检查后续可观察结果,而不只看请求未抛异常。DELETE 的 204 响应没有 JSON,所以检查 deleted.content 为空字节。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "验证输入错误与受保护接口",
|
|
|
+ "body": "继续追加输入校验与 API Key 测试,再运行同一命令。遇到失败时读失败行及 expected/actual,先与第 07、08 课接口约定对照。",
|
|
|
+ "code": "def test_validation(client):\n invalid = client.post(\"/products\", json={\"name\": \"\"})\n assert invalid.status_code == 422\n assert invalid.json()[\"error\"][\"code\"] == \"validation_error\"\n assert client.get(\"/slow\", params={\"delay\": -1}).status_code == 422\n\n\ndef test_api_key(client):\n assert client.get(\"/private\").status_code == 401\n wrong = client.get(\"/private\", headers={\"X-API-Key\": \"wrong-key\"})\n assert wrong.status_code == 401\n assert wrong.json()[\"error\"][\"code\"] == \"http_error\"\n valid = client.get(\"/private\", headers={\"X-API-Key\": \"classroom-key\"})\n assert valid.status_code == 200",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "tests/test_acceptance.py",
|
|
|
+ "expected": "累计 4 passed:空名称和负 delay 为 422,缺失/错误 Key 为 401,正确 Key 为 200;error.code 保持统一约定。",
|
|
|
+ "explanation": "成功路径通过还不够,错误路径也是服务行为的一部分。这里只有 /private 需要课堂 Key,商品接口仍按本地练习设计开放;测试把这两个边界分别检查。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "验证 SSE 内容和 WebSocket 来回消息",
|
|
|
+ "body": "把 SSE 与 WebSocket 测试追加到文件末尾,再次运行 pytest。若做过 stats 练习可保留它,但 done 必须最后出现;不要修改断言去容忍缺少完成事件。",
|
|
|
+ "code": "def test_stream_matches_complete_result(client):\n complete = client.post(\"/generate\", json={\"prompt\": \"catalog\"})\n assert complete.status_code == 200\n response = client.get(\"/stream\", params={\"prompt\": \"catalog\"})\n assert response.status_code == 200\n assert response.headers[\"content-type\"].startswith(\"text/event-stream\")\n events = []\n for block in response.text.replace(\"\\r\\n\", \"\\n\").strip().split(\"\\n\\n\"):\n fields = {}\n for line in block.split(\"\\n\"):\n name, value = line.split(\":\", 1)\n fields[name] = value.lstrip(\" \")\n events.append((fields[\"event\"], json.loads(fields[\"data\"])))\n assert [name for name, _ in events if name != \"stats\"] == [\"token\", \"token\", \"token\", \"done\"]\n text = \"\".join(data[\"text\"] for name, data in events if name == \"token\")\n assert text == complete.json()[\"text\"]\n assert events[-1] == (\"done\", {})\n\n\ndef test_websocket(client):\n with client.websocket_connect(\"/ws\") as socket:\n socket.send_text(\"hello\")\n assert socket.receive_json() == {\"type\": \"echo\", \"text\": \"hello\"}\n socket.send_text(\"ping\")\n assert socket.receive_json() == {\"type\": \"pong\"}",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "tests/test_acceptance.py",
|
|
|
+ "expected": "累计 6 passed;SSE 拼接的文本与 /generate 完整结果一致,主线是三个 token 后 done;WS 的 hello/echo 与 ping/pong 都匹配。",
|
|
|
+ "explanation": "这次 TestClient 收到有限 SSE 的完整内容后检查格式,不证明真实网络按 0.4 秒逐段到达。WebSocket 上下文结束时关闭连接;流要检查 done,不能只检查有几段文字。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "运行自动验收,再测真实网络",
|
|
|
+ "body": "最终运行 python -m pytest -q tests/test_acceptance.py。通过后启动一个 Uvicorn 进程,将下方 Python 保存为 api-lab/check_stream_timing.py,在另一个终端执行 python check_stream_timing.py。再按第 10、13、15 课分别复验 CORS、取消流与断线后主动停止。",
|
|
|
+ "code": "from time import perf_counter\nimport httpx\n\nwith httpx.Client(base_url=\"http://127.0.0.1:8000\", timeout=5.0) as client:\n started = perf_counter()\n with client.stream(\"GET\", \"/stream\", params={\"prompt\": \"catalog\"}) as response:\n response.raise_for_status()\n for line in response.iter_lines():\n if line.startswith(\"data:\"):\n print(f\"{perf_counter() - started:.2f}s {line}\", flush=True)",
|
|
|
+ "lang": "python",
|
|
|
+ "filename": "check_stream_timing.py",
|
|
|
+ "expected": "pytest 为 6 passed;真实计时脚本的 token data 通常约在 0.4、0.8、1.2 秒出现,done 紧随末段。浏览器三个场景也与各课预期一致;网络时间允许有合理开销。",
|
|
|
+ "explanation": "进程内测试适合快速检查状态和数据,真实 HTTPX 计时检查逐段到达,浏览器检查 CORS 与页面生命周期。三者覆盖不同层面,不能用一份通过结果代替所有体验验收。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "checks": [
|
|
|
+ "自动测试覆盖健康检查、完整 CRUD、422 校验、401 认证、删除后 404、SSE 与 WebSocket,运行结果全部通过。",
|
|
|
+ "真实 HTTPX / curl 客户端在响应结束前接收到 token,浏览器停止读取后服务端执行清理。",
|
|
|
+ "允许来源的浏览器能够调用 API;未允许来源不能读取响应;WebSocket 主动停止后不再重连。"
|
|
|
+ ],
|
|
|
+ "pitfalls": [
|
|
|
+ {
|
|
|
+ "symptom": "单个测试能过,整套测试偶尔失败",
|
|
|
+ "cause": "多个测试假定相同 id 或依赖前一个测试创建的数据。",
|
|
|
+ "fix": "每个测试自行创建记录,用返回 id 操作,并在生命周期测试中删除。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "测试里 app.state.http 不存在",
|
|
|
+ "cause": "没有通过 with TestClient(app) 启动 lifespan,或代码未正确注册 lifespan。",
|
|
|
+ "fix": "在 fixture 的上下文中创建客户端,并核对 FastAPI(lifespan=lifespan)。"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "symptom": "SSE 测试通过,网页仍一次显示全部内容",
|
|
|
+ "cause": "进程内测试只检查完整内容,没有经过浏览器、真实网络和中间服务。",
|
|
|
+ "fix": "用真实 Uvicorn 配合 httpx.stream / curl -N 观察时间,再检查浏览器读取与渲染。"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "exercise": {
|
|
|
+ "task": "增加一个测试:创建商品后,PUT 只发送 name 应返回 422,随后 GET 仍返回原来的完整内容。",
|
|
|
+ "hint": "输入不合法时不能发生部分更新;先保存创建响应,再比较读取结果。",
|
|
|
+ "answer": "创建商品并保存 product;使用 client.put(f'/products/{product[\"id\"]}', json={\"name\":\"修改\"}),断言 422;再次 GET 并断言等于 product,最后 DELETE 清理。这个测试同时检查完整替换约定和校验失败不写入的行为。"
|
|
|
+ },
|
|
|
+ "refs": [
|
|
|
+ {
|
|
|
+ "label": "FastAPI:TestClient 与 pytest",
|
|
|
+ "url": "https://fastapi.tiangolo.com/tutorial/testing/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:测试 lifespan",
|
|
|
+ "url": "https://fastapi.tiangolo.com/advanced/testing-events/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "FastAPI:测试 WebSocket",
|
|
|
+ "url": "https://fastapi.tiangolo.com/advanced/testing-websockets/"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "pytest:fixtures",
|
|
|
+ "url": "https://docs.pytest.org/en/stable/how-to/fixtures.html"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "pytest:临时环境变量",
|
|
|
+ "url": "https://docs.pytest.org/en/stable/how-to/monkeypatch.html"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "label": "HTTPX:流式响应",
|
|
|
+ "url": "https://www.python-httpx.org/quickstart/"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "intro": {
|
|
|
+ "scenario": "商品接口、清单进度和咨询连接都已经能手工演示。现在把重要观察写成每次都可执行的检查,防止以后改一处代码却悄悄破坏另一项功能。",
|
|
|
+ "startingPoint": [
|
|
|
+ "已完成 API 第 01–15 课,api-lab 中保留完整商品 CRUD、统一错误、/private、/slow、/generate、GET /stream 和 /ws。",
|
|
|
+ "确认 /slow 已恢复异步 sleep,/stream 有三个 token 与最后 done,手工停止实验已结束。",
|
|
|
+ "本课新增 tests/test_acceptance.py 和一个真实网络计时脚本;先用原 .venv 安装 pytest、httpx,再运行。"
|
|
|
+ ],
|
|
|
+ "outcome": "用一条 pytest 命令自动核对商品生命周期、错误、校验值、SSE 与 WebSocket,再用真实网络确认逐段到达和浏览器行为。"
|
|
|
+ }
|
|
|
+ }
|
|
|
+]
|