上一篇我们亲手调通了两个真实的 API——用 curl 问 ipify 拿到自己的公网 IP照着官方文档让 DeepSeek 回了一句话——也弄清了 API 的本质一个程序对外提供能力的固定入口按规范发请求、收 JSON与内部用什么语言实现无关Python 环境也随之备齐venv 给项目隔出专属环境pip 装好 requests 并用它在代码里调了一次 API依赖记进了 requirements.txt但到目前为止我们一直站在调用方一侧这一篇要换到被调用方——先看懂 HTTP 这套对话规范再用纯标准库手搓出第一个属于自己的 API看懂 HTTP手搓第一个 API这一部分先不写代码——把 HTTP 请求和响应的规范看明白一次网络对话双方到底各说了什么、按什么格式说然后再用 Python 把这套规范亲手实现出来零依赖纯标准库用 curl、浏览器逐一验证规范在前代码在后——代码只是规范的一种实现API 和 HTTPPython 已装好API 是什么也知道了这一部分就用 Python 手搓一个 API 出来先回忆调用 API 的经历用 curl 调过两个真实的 API写过 api_demo.py用 Python (requests) 调过一次 ipify注意一个共同点到目前为止我们一直站在调用方这一边——发请求、收 JSON 的那一边而我们的目标是给自己的网站写一个 API这意味着要换到另一边去做那个 被调用方——一直守着、接到请求、回一段 JSON 的程序动手之前要先搞清楚这一来一回的网络对话里双方传的到底是什么调用方发来的 请求 长什么样我们回的 响应 又该长什么样这两样东西有明确的规范——这套规范就是 HTTP之前提过API 没有另起炉灶直接用了浏览器上网用的那套规矩所以要手搓 API就绕不开 HTTP——要接住请求、按规矩回话就得把这套规矩本体看清楚这一部分的主要知识点其实是 HTTP动身之前先把 HTTP 和 API 这两个词的关系摆正API 是一个程序对外提供能力的入口——而这个入口上的对话协议用的是 HTTPHTTP 是一套通信规范管的是 两个程序之间怎么对话对话是什么格式、有哪几种问法、怎么表示成功和失败它不是为 API 发明的浏览器加载的每一个网页、每一张图片走的都是 HTTP包括此前用 Nginx 返回 html 页面也就是说我们其实已经用过很多次 HTTP 了只是从来没深入看一眼全貌用浏览器发起网页请求时页面上显示的只是正文其余信息隐藏着Chrome 上要用 F12 才能看到在终端用 curl 向 API 发请求时终端默认只打印响应的 正文其余全被它省略了用 Python 的 requests 发请求也一样resp.json() 拿到的只是正文并非完整的 HTTP 信息下面就看看 HTTP 请求、响应信息的全貌HTTP 的一去一回请求和响应HTTP 规定的一次对话就是一去一回、两段有固定格式的文本调用方发过去的那段叫请求 (request)服务方回过来的那段叫响应 (response)两段文本的结构几乎对称请求去 响应回 ├─ 请求行 ├─ 状态行 ├─ 请求头若干行 ├─ 响应头若干行 ├─ 一个空行 ├─ 一个空行 └─ 请求体 └─ 响应体先看请求这一侧的四个部分请求行——整个请求的第一行永远只有一行说清三件事方法、路径、协议版本方法这次是来干什么的GET 是 取数据POST 是 提交内容——认过脸的那两位稍后还有一张脸谱表路径要访问对方的哪个资源协议版本先不用管稍后交代请求头——若干行 附加说明一行一条格式统一是名字: 值我要找哪台服务器 我是谁 我提交的内容是什么格式……都写在这里一个空行——分界线意思是 头说完了下面是体请求体——真正要提交的内容不是必须有取数据的请求一般就没有体再看响应这一侧它的四部分——状态行、响应头、空行、响应体——和请求几乎对称唯一的结构差别在第一行请求的第一行叫请求行响应的第一行叫状态行状态行——一行说清三件事协议版本、状态码、简短说明最重要的是中间那个状态码一个数字表态 这次处理得怎么样后面跟着一句人类友好的简短说明OK、Not Found200 是成功404 是没找到——这个数字之前见得够多了那时是 Nginx 替我们回的记个家族规律就行2xx 成功4xx 请求方的问题5xx 服务方的问题响应头——同样一行一条的 附加说明最重要的一条是 Content-Type我给你的这段内容是什么格式——application/json 就是在说 按 JSON 来解析它它有多大威力一会儿动手实验空行 响应体——和请求侧一样空行分界体是正文。我们平时在终端、在浏览器页面上 看到 的基本都只是响应体——那行{ip: …}是响应体DeepSeek 回的那段 choices JSON 也是它们上面顶着的状态行和一排头当时全被工具省略了把一去一回摆在一起规律就出来了请求 请求行 头 空行 体响应 状态行 头 空行 体格式对称全是纯文本——我们和任何服务器之间的每一次对话本质就是这样两段文本的往返用 curl -v 验证到这里上面说的一切都还只是 纸面上的说法空口无凭——curl 有一个 -v 参数verbose把过程全说出来能把一次调用的请求原文、响应原文全部亮出来把查 IP 那条命令加上 -v再跑一次curl -v https://api.ipify.org?formatjson这回输出多了一大截先学会认行首的三种记号*开头curl 的过程旁白——建立连接、加密握手之类全部跳过开头发出去的请求原文开头收到的响应原文把 * 的行掠过去剩下的就是一次完整的 一去一回具体的值每个人会不同 GET /?formatjson HTTP/2 ← 请求行方法 路径 协议版本 Host: api.ipify.org ← 请求头从这行开始 User-Agent: curl/8.7.1 Accept: */* ← 空行请求头完注意GET 没有请求体 HTTP/2 200 ← 状态行协议版本 状态码 date: Mon, 13 Jul 2026 05:38:00 GMT content-type: application/json ← 响应头内容是 JSON 格式 content-length: 22 server: cloudflare 还有几条略 ← 空行响应头完 {ip:114.86.123.45} ← 响应体——之前看到的只有这一行看到了吧 那段是请求行、头、空行 那段是状态行、头、空行、体——理论里的每个部件都能逐行指认出来几个需要了解的细节请求行里方法是 GET取数据路径是 URL 去掉协议和域名之后剩下的那段——?后面的 formatjson 叫查询参数跟在路径后面还记得为什么给网址加引号吗防的就是这个?被终端误解三行请求头Host——要找哪台服务器User-Agent常简称 UA——我是谁用什么工具、什么浏览器发的这个请求Accept——我能接受什么格式的回应注意这条命令里我们一个头都没写它们全是 curl 自动带上的两个小注这里看到的版本多半是 HTTP/2它和老一些的 HTTP/1.1 在显示上有两处小差别——头的名字统一小写、状态行状态码后面不带 OK 那句简短说明其余一模一样另外如果电脑开着网络代理 里可能先冒出一行 HTTP/1.1 200 Connection established——那是代理隧道的痕迹跳过它再验证一个带请求体的给调用 DeepSeek 的那条长命令也加上 -v 跑一次key 用自己的如果 key 已经删了对照下面的输出看就行这次 的部分是 POST /chat/completions HTTP/2 ← 请求行方法换成了 POST Host: api.deepseek.com ← URL 拆出来的 User-Agent: curl/8.7.1 Accept: */* Content-Type: application/json ← 我们用 -H 写的那行原样成为一行请求头 Authorization: Bearer sk-**** ← 我们用 -H 写的另一行身份钥匙 Content-Length: 333 ← curl 自动算好请求体有多长 ← 空行头到此为止咦说好的请求体呢-v 不回显请求体但紧跟着有一句旁白说明了请求体已经发送* upload completely sent off: 333 bytes说的就是它——我们用 -d 写的那段 JSON此刻已经作为请求体发了出去长度正好是 Content-Length 说的那个数一一对应URL 拆成 Host 路径-H 添的是请求头-d 填的是请求体那条看起来吓人的长命令不过是在拼一段规范文本——现在亲眼看到它拼出来的样子了关于方法两个可能冒出来的疑问疑问一我从头到尾没说过 用 GET 或 用 POST是谁决定的是 curl 替我们决定的规则很简单默认发 GET一旦用了 -d有请求体要提交自动改发 POST查 IP 那条没有 -d所以是 GETDeepSeek 那条带着 -d所以是 POST——证据刚刚都在 -v 里看过了两个请求行的第一个词疑问二调 DeepSeek 我也 取回了数据 啊——凭什么它算 POST而不算 GET或者 POST GET因为 GET / POST 描述的不是 数据往哪边流看刚才的报文就明白了无论方法是什么一次调用永远是完整的一来一回——GET 也发出去了一段请求文本POST 也收回来了一段响应文本响应从来都有它不需要、也不由方法来 申请方法描述的只有一件事这次请求的意图GET把某样东西给我——通常不带请求体POST我提交一段内容请你处理——内容就放在请求体里所以调 DeepSeek 是一次 POST我们的意图是提交一段对话让它处理它回来的那段 choices JSON是这次 POST 的响应而不是另一次 GET顺便把账记到正确的科目上GET、POST 是 HTTP 的方法不是 API 的发明——浏览器打开一个网页发的就是 GET在网页上提交一张表单发的往往就是 POSTAPI 只是沿用了这套问法常见的方法、常见的头到这里我们认识了两个方法和七八个头HTTP 定义的不止这些下面几张表把常见的列出来——不需要记住大致看一下建立直觉就行以后在真实报文里撞见能大致猜到它在说什么方法就是 请求的意图这张表其实就是几种常见的意图方法意图一句话直觉GET把某样东西给我POST我提交一段内容请你处理PUT用我给的内容把某样东西整个换掉PATCH把某样东西改一部分DELETE把某样东西删掉HEAD跟 GET 一样但只要头、不要体——探路用OPTIONS询问我能对这个资源做什么常见的请求头——调用方的自我交代请求头在说什么Host我要找哪台服务器User-Agent我是谁什么工具、什么浏览器Accept我能接受什么格式的回应Accept-Language我偏好什么语言Content-Type我提交的请求体是什么格式Content-Length我提交的请求体有多长Authorization我的身份凭证那个 Bearer sk-… 就放这儿Cookie我随身带的 小纸条常见的响应头——服务方对内容的交代响应头在说什么Content-Type我回的体是什么格式本部分的主角Content-Length我回的体有多长Server我是什么服务器软件Date我是什么时间处理的Cache-Control这份内容可以缓存、能存多久Set-Cookie给调用方发一张 小纸条下次来记得带上Location内容搬家了去这个新地址找配合 3xx 跳转用Access-Control-Allow-Origin允许哪些来源的网页调用我这些还不是 HTTP 完整的清单但日常开发了解这些就够了以后遇到陌生的头先查再用认识 HTTP/1.1 和 HTTP/2报文里反复出现的 HTTP/1.1、HTTP/2 是协议的版本号其实还有个更新的 HTTP/3它们有一些小差异但差别不大方法、路径、状态码、头、体这套语义都一样——一般不需要管知道这件事就行手搓 API 要照顾到什么做一个 API就是做那个 接话、回话 的程序但规范里的部件这么多哪些是必须处理的、缺了就不 work 的呢请求这一侧请求行里的方法和路径每个请求都必带我们作为 API 的提供者也必须读取请求行——不看方法就分不清对方是来取数据还是来提交内容不看路径就分不清对方要访问哪个资源因为不同的路径得给不同的回应请求头那一排一般的请求都会带这些信息可读可不读建议按需读取——需要用到哪条再读哪条响应这一侧状态行——必须返回不回状态码这就不是一段 HTTP 响应调用方会直接报错Content-Type 响应头——严格说规范允许省略但省了调用方就只能猜我们回的是什么格式——实践里按必写对待那个空行——必须它是 头 和 体 之间唯一的分界线漏了它调用方会把正文误当成头来解析全盘皆乱响应体——真正要给对方的内容规范上可以没有体但做 API 回数据它就是主角我们的 JSON 就放这儿从这个角度看手搓一个 API 要处理的事还不少——HTTP 规范的每一样都得亲手写好在 Python 标准库里有一个专门处理 HTTP 的模块http.server接下来就用它把这份清单逐项落实用 Python 实现这套规范我们要实现的接口就是以后前端真的会来调用的那一个GET /api/profile返回主页要显示的内容现在这些内容写死在前端 site.js 里先挑 site.js 里 home 的前两个字段意思一下就行——现在的重点是 HTTP不是数据本身等前端真的来调这个接口时再把返回的结构和 home 完整对齐回到建好的 ~/zero-to-tech/backend/照例先激活环境虽然 http.server 是标准库成员、不激活也能跑但 进项目先激活 这个习惯值得从现在养成source .venv/bin/activate然后新建 main.pyfrom http.server import BaseHTTPRequestHandler, HTTPServer import json profile { heroTitle: 关于我, heroSubtitle: 项目创意灵感心得我的作品, } class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path /api/profile: self.send_response(200) self.send_header(Content-Type, application/json) self.end_headers() body json.dumps(profile, ensure_asciiFalse) # ensure_asciiFalse让中文原样输出 self.wfile.write(body.encode(utf-8)) else: self.send_response(404) self.end_headers() print(后端已启动http://localhost:8000/api/profile) HTTPServer((, 8000), Handler).serve_forever()对着刚才的规范逐行看这段代码分别实现了规范的哪一部分代码对应规范里的def do_GET(self):请求行里的方法——方法是 GET 的请求归这个函数管self.path请求行里的路径——拿它判断对方要访问哪个资源self.send_response(200)响应的状态行——回一个 200self.send_header(...)响应头——一行一条我们写了 Content-Type 这一条self.end_headers()那个空行——头写完了头和体的分界线self.wfile.write(...)响应体——我们的 JSONencode 是因为网络上传输的是字节文本要先编码else 分支的 404状态码 404——没找到以前是 Nginx 替我们回现在轮到我们自己回规范里的每一个部件都能在代码里找到——这段代码没干别的就是老老实实按 HTTP 规范 接话、回话再对照那份 缺了就不 work 的清单把必须项在代码里点一遍名self.send_response(200)——那条必须的状态行不写它回出去的就不是一段 HTTP 响应self.end_headers()——那个必须的空行名字里带着 headers干的活其实是 头到此为止——漏了它调用方会把正文误当成头self.send_header(Content-Type, ...)——那条按必写对待的头告诉调用方 这是 JSON这三行不是可有可无的样板前两行属于 硬要求删掉其中任何一行curl 那头直接报错因为收到的根本不是一段合法的 HTTP 响应Content-Type 那行删掉倒是还能跑通但调用方就只能猜我们回的是什么格式了——规范必须 和 实践必写 的区别落到了代码上404 分支里同样是先 send_response 再 end_headers硬要求一样不能省只是没有 体最后一行代码里还有个数字值得交代HTTPServer((, 8000), Handler).serve_forever()这是指定 http 服务在 8000 端口上启动回想讲过的IP 找到机器端口找到机器上的某个程序——一台电脑可以同时跑很多网络程序各守各的端口80HTTP和 443HTTPS是网页的默认端口地址栏里不写端口号时走的就是它们一般留给正式服务开发时挑一个没被占用的端口就行8000 是 Python 圈的习惯值python3 -m http.server 默认就用它就像前端圈 Vite 爱用 5173、Next 爱用 3000——都只是习惯不是规定改成 9000 也照样跑只是调用时的地址要跟着变跑起来python3 main.py终端打印 后端已启动然后——光标停住不动了别慌这不是卡死还记得吗后端是一个 一直运行、守着等请求 的程序现在它真的出现在终端里了serve_forever() 就是字面意思——守在 8000 端口永远等着想停掉它按 Ctrl C换一个新的终端窗口旧的那个正跑着服务呢调用它curl http://localhost:8000/api/profile回来一段 JSON再用浏览器打开http://localhost:8000/api/profile——同样的 JSON成了我们写出了自己的第一个 API之前对 ipify、对 DeepSeek 做的事现在别人也可以对我们做了这时候回头看一眼跑着服务的那个终端多了几行东西127.0.0.1 - - [20/Sep/2026 15:42:10] GET /api/profile HTTP/1.1 200 - 127.0.0.1 - - [20/Sep/2026 15:42:31] GET /api/profile HTTP/1.1 200 - 127.0.0.1 - - [20/Sep/2026 15:42:31] GET /favicon.ico HTTP/1.1 404 -每来一个请求记一行这就是服务的访问日志注意那行 favicon.ico我们并没有请求它是浏览器自动多发了一个请求想要标签页小图标我们没有它吃了个 404——凭着请求里带的信息服务端可以把每个来访者的动作看得清清楚楚再来一次 curl -v这回是自己服务器的报文现在做一次漂亮的闭环前面用 -v 验证过和 ipify、DeepSeek 之间的对话现在对自己刚写出来的服务器来一次curl -v http://localhost:8000/api/profile还是那三种记号—— 请求原文、 响应原文、* 旁白 GET /api/profile HTTP/1.1 ← 请求行 Host: localhost:8000 ← 请求头 User-Agent: curl/8.7.1 Accept: */* ← 空行头结束 HTTP/1.0 200 OK ← 状态行 Content-Type: application/json ← 我们写的那行头躺在这 {heroTitle: 关于我, ...} ← 响应体和前面 ipify 的那两段逐行对得上——只不过这一回 那一段的每一行都是我们自己的代码生成的规范 → 代码 → 真实报文三点连成一线小字注两条响应第一行是 HTTP/1.0 200 OK——我们这个极简服务器用的老版本协议状态码后面带着那句简短说明就是前面说过的样子响应头里还多了 Server、Date 两条我们没写的——是 http.server 自动替我们加的服务器顺带做了自我介绍浏览器视角F12 里的同一份报文Chrome 打开http://localhost:8000/api/profile按 F12 → Network 面板 → 刷新 → 点开那条请求GeneralRequest URL、Request Method: GET、Status Code: 200Response Headers我们 send_header 写的那两行原样躺着Request Headers浏览器发出的请求头——一会儿的实验里细看F12 的 Network 之前用过那时看加载顺序今天点进单个请求的内部——curl -v 看到的和这里看到的是同一套东西的两个视角动手改两处做两个实验前面说过Content-Type 不是 规范必须 但是 实践必写——那就做个小实验看看为什么另外还说过服务端可以把来访者看得清清楚楚也通过实验看一下打开 main.py一次改两处改动一临时加一个 /hello 路径写到 else 那一行的上面elif self.path /hello: self.send_response(200) self.send_header(Content-Type, text/html; charsetutf-8) # ← 一会儿改成 text/plain 再试 self.end_headers() self.wfile.write(h1你好HTTP/h1.encode(utf-8))后面那截 charsetutf-8 是给浏览器多交代一句内容按 UTF-8 解码——响应体里有中文不交代这一句浏览器可能猜错编码页面上就是乱码又一个 头是关于内容的说明 的例子改动二在 do_GET 的开头加两行打印给实验二用def do_GET(self): print(self.headers) # 收到的请求头 print(self.client_address) # 请求是从哪个地址来的 ...改完重启服务Ctrl C 再 python3 main.py两个实验连着做实验一响应头的威力规范里说 Content-Type 是 告诉对方内容是什么格式空口无凭——浏览器打开http://localhost:8000/hello一个大标题浏览器把内容当网页渲染了现在把代码里的 text/html 改成 text/plain重启刷新——变成了原样的一行字h1标签直接露了出来内容一个字没变头一变对方的处理方式就变了响应头不是内容本身而是 关于内容的说明——application/json 同理它让调用方知道该按 JSON 解析响应体所以也可以说在浏览器眼里网页 和 API 数据 并没有本质区别——都是一段 HTTP 响应差别只在 Content-Typetext/html 就当网页渲染application/json 就当数据处理所谓 做 API从 HTTP 的角度看不过是选择返回 JSON 而不是返回 HTML实验二服务端能拿到什么响应这一侧摸透了回头看请求这一侧——刚才加的那两行 print 派上用场先用 curl 调用一次 /api/profile看服务端终端打印了什么Host: localhost:8000 User-Agent: curl/8.7.1 Accept: */*再用浏览器访问一次Host: localhost:8000 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ... Accept: text/html,application/xhtmlxml,... Accept-Language: zh-CN,zh;q0.9 Accept-Encoding: gzip, deflate ...一大串但请注意两件事这些头我们一行都没写过——是 curl、浏览器自动带上的User-Agent 就是 我是谁 的自我介绍curl 老老实实报名字和版本浏览器报出一长串型号服务端一眼就能分辨请求是浏览器来的还是程序来的这正是规范图里 请求头 那一段——在 F12 的 Request Headers 里看到的就是它们出发前的样子现在我们站在服务端看到了它们被接收另外之前写过 api_demo.py当时用它请求 ipify 的 API也可以试一下用它来请求我们自己的http://localhost:8000/api/profile观察服务端拿到了什么样的 User-Agent改动二还有第二行 print(self.client_address)打出来的是类似(127.0.0.1, 54321)的一对值——发起这次请求的 IP 和端口本机访问本机所以是 127.0.0.1要是别的机器来调用这里就是对方的 IP也就是说服务端天然看得见每个请求从哪个地址来——ipify 能报出我们的公网 IP根源就在这一句边界感服务端天然能看到 UA、IP、语言偏好这些元信息——这是很多功能的地基访问统计、防刷、按语言返回内容也是一个提醒我们在网络上发出的每个请求都比自己以为的更 透明——这也是为什么不要在不信任的网站上乱发请求两个实验做完把 /hello 那段删掉保持 main.py 干净那两行 print 留着也无妨——这份手搓版接下来就会存档成纪念版数数我们干了多少杂活最后清点一下劳动为了按规范 把一段 JSON 发出去我们亲手做了路由if / elif 自己判断路径还得记着写 else 兜底 404状态行每个分支自己 send_response响应头一行一行自己 send_header——一个接口两行十个接口二十行空行连 头结束 都要自己 end_headers响应体自己 dumps、自己 encode而这才一个接口还只是 GET——要是 POST还得自己从请求体里读字节、解析 JSON、校验字段全不全……一个接口尚且如此真实项目几十个接口这么写下去是要出人命的好消息是这些杂活不属于任何一个具体项目——它们属于 HTTP 规范谁写后端都得来一遍。无论用什么语言写后端——Python、JavaScript、Go——写的都是对同一套规范的实现所以 API 的概念才和语言无关一模一样的事就有人打包做好了给大家复用打包好的那个东西叫框架接下来就让 FastAPI 登场我们会看到这一节的全部杂活在框架里缩成几行——而正因为亲手按规范搓过一遍我们会确切地知道它替我们干了什么框架会把状态码和头都藏起来但它们一直都在——我们已经摸过一遍以后想看curl -v 和 F12 里随时都在
