[ { "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": "\n\n\n