模块 5.3:看懂 HTTP,手搓 API

模块 5.3:看懂 HTTP,手搓 API

Table of Contents

这一节先不写代码——把 HTTP 请求和响应的规范看明白:一次网络对话,双方到底各说了什么、按什么格式说。然后再用 Python 把这套规范亲手实现出来(零依赖,纯标准库),用 curl、浏览器逐一验证。规范在前,代码在后——代码只是规范的一种实现。


API 和 HTTP

上一节我们安装了 Python,现在大家也知道什么是 API 了,那么这一节,我们就用 Python 手搓一个 API 出来。

先回忆一下,我们之前调用 API 的经历——5.1,我们用 curl 调了两个真实的 API;5.2,我们写了 api_demo.py用 Python(requests)调了一次 ipify 的 API

注意一个共同点:到目前为止,我们一直站在调用方这一边——发请求、收 JSON 的那一边。

而我们的目标,是给自己的网站写一个 API。这意味着要换到另一边去:做那个“被调用方”,也就是“一直守着、接到请求、回一段 JSON”的程序。

在动手之前,我们需要先搞清楚一件事:这一来一回的网络对话里,双方传的到底是什么?调用方发来的“请求”长什么样?我们回的“响应”又该长什么样?这两样东西是有明确规范的——这套规范就是 HTTP。5.1 我们提到过:API 没有另起炉灶,直接用了浏览器上网用的那套规矩。

所以说要手搓 API,就绕不开 HTTP,因为如果要接住请求、按规矩回话,就得把这套规矩本体看清楚。

这一节的主要知识点,其实是 HTTP。

动身之前,先把 HTTP 和 API 这两个词的关系摆正,后面的知识点才好记账:

  • API 是一个程序对外提供能力的入口(5.1 的定义)——而这个入口上的对话协议用的是 HTTP
  • HTTP 是一套通信规范,管的是“两个程序之间怎么对话”——对话是什么格式、有哪几种问法、怎么表示成功和失败。它不是为 API 发明的:浏览器加载的每一个网页、每一张图片,走的都是 HTTP(包括我们此前用 Nginx 返回 html 页面)。

也就是说,我们其实已经用过很多次 HTTP 了,只不过,我们从来没有深入进去看一看 HTTP 的全貌

  • 用浏览器发起网页请求的时候,浏览器的页面上显示的只是正文,其余信息隐藏起来了,在 Chrome 浏览器上需要用 F12 才可以看到;
  • 在终端用 curl 向 API 发起请求的时候,终端默认只把响应的“正文”打印出来,其余信息全被它省略了而已;
  • 5.2 用 Python 的 requests 发起请求时也一样,resp.json() 拿到的只是正文,并非完整的 HTTP 信息。

下面就看一看 HTTP 的请求、响应信息的全貌。


HTTP 的一去一回:请求和响应

HTTP 规定的一次对话,就是一去一回、两段有固定格式的文本:调用方发过去的那段叫请求(request),服务方回过来的那段叫响应(response)。两段文本的结构几乎对称:

请求(去)                 响应(回)
├─ 请求行                 ├─ 状态行
├─ 请求头(若干行)         ├─ 响应头(若干行)
├─ (一个空行)            ├─ (一个空行)
└─ 请求体                 └─ 响应体

先看请求这一侧的四个部分。

① 请求行——整个请求的第一行,永远只有一行,说清三件事:方法、路径、协议版本

  • 方法:这次是来干什么的。GET 是“取数据”,POST 是“提交内容”——5.1 认过脸的那两位(HTTP 还定义了别的方法,稍后给一张脸谱表);
  • 路径:要访问对方的哪个资源;
  • 协议版本:先不用管,这一节稍后会交代。

② 请求头——若干行“附加说明”,一行一条,格式统一是 名字: 值——“我要找哪台服务器”“我是谁”“我提交的内容是什么格式”……都写在这里。

③ 一个空行——分界线,意思是“头说完了,下面是体”。

④ 请求体——真正要提交的内容。不是必须有——取数据的请求,一般就没有体。

再看响应这一侧。它的四个部分——状态行、响应头、一个空行、响应体——和请求几乎对称,唯一的结构差别在第一行:请求的第一行叫请求行,响应的第一行叫状态行

① 状态行——一行说清三件事:协议版本、状态码、简短说明。最重要的是中间那个状态码:一个数字,表态“这次处理得怎么样”,后面跟着一句人类友好的简短说明(OKNot Found)。

  • 200:成功;
  • 404:没找到——这个数字我们在模块 4 见得够多了,那时是 Nginx 替我们回的;
  • 记个家族规律就行:2xx 成功;4xx 请求方的问题;5xx 服务方的问题

② 响应头——同样是一行一条的“附加说明”。最重要的一条是 Content-Type我给你的这段内容,是什么格式——application/json 就是在说“按 JSON 来解析它”。它有多大威力,一会儿动手实验。

③ 空行 + ④ 响应体——和请求侧一样:空行分界,体是正文。我们平时在终端、在浏览器页面上“看到”的,基本都只是响应体——5.1 那行 {"ip": …} 是一段响应体,DeepSeek 回的那段 choices JSON 也是;它们上面顶着的状态行和一排头,当时全被工具省略了。

把一去一回摆在一起,规律就出来了:

请求 = 请求行 + 头 + 空行 + 体;响应 = 状态行 + 头 + 空行 + 体。 格式对称,全是纯文本。我们和任何服务器之间的每一次对话,本质就是这样两段文本的往返。


用 curl -v 验证

到这里,上面说的一切都还只是“纸面上的说法”。空口无凭——curl 有一个 -v 参数(verbose,“把过程全说出来”),能把一次调用的请求原文、响应原文全部亮出来。

把 5.1 查 IP 的那条命令加上 -v,再跑一次:

curl -v 'https://api.ipify.org?format=json'

这回输出多了一大截。我们先学会认行首的三种记号

  • * 开头:curl 的过程旁白——建立连接、加密握手之类,全部跳过;
  • > 开头:发出去的请求原文
  • < 开头:收到的响应原文

* 的行掠过去,剩下的就是一次完整的“一去一回”(具体的值每个人会不同):

> GET /?format=json 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"}              ← 响应体——5.1 我们看到的,只有这一行

看到了吧,真的是——> 那段:请求行、头、空行;< 那段:状态行、头、空行、体。理论里的每个部件,都能逐行指认出来。 几个需要了解的细节:

  • 请求行里,方法是 GET(取数据);路径是 URL 去掉协议和域名之后剩下的那段——? 后面的 format=json查询参数,跟在路径后面(还记得 5.1 为什么要给网址加引号吗?防的就是这个 ? 被终端误解);
  • 三行请求头Host——要找哪台服务器;User-Agent(常简称 UA)——我是谁,用什么工具、什么浏览器发的这个请求;Accept——我能接受什么格式的回应。注意:这条命令里我们一个头都没写,它们全是 curl 自动带上的

两个小注:这里看到的版本多半是 HTTP/2,它和老一些的 HTTP/1.1 在显示上有两处小差别——头的名字统一小写、状态行的状态码后面不带 OK 那句简短说明——其余一模一样;另外,如果电脑开着网络代理< 里可能先冒出一行 HTTP/1.1 200 Connection established——那是代理隧道的痕迹,跳过它。

再验证一个带请求体的。给 5.1 调用 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 填的是请求体。 5.1 那条看起来吓人的长命令,不过是在拼一段规范文本——现在我们亲眼看到它拼出来的样子了。

关于方法,两个可能冒出来的疑问

疑问一:我从头到尾没说过“用 GET”或“用 POST”,是谁决定的?

是 curl 替我们决定的,规则很简单:默认发 GET;一旦用了 -d(有请求体要提交),自动改发 POST。 查 IP 那条没有 -d,所以是 GET;DeepSeek 那条带着 -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;在网页上提交一张表单,发的往往就是 POST——API 只是沿用了这套问法。

常见的方法、常见的头

到这里,我们认识了两个方法(GET、POST)和七八个头。HTTP 定义的不止这些。下面几张表把常见的列出来,不需要记住,但可以大致看一下,建立直觉:以后在真实报文里撞见,能大致猜到它在说什么。

方法就是“请求的意图”,所以这张表其实就是几种常见的意图:

方法意图(一句话直觉)
GET把某样东西给我
POST我提交一段内容,请你处理
PUT用我给的内容,把某样东西整个换掉
PATCH把某样东西改一部分
DELETE把某样东西删掉
HEAD跟 GET 一样,但只要头、不要体——探路用
OPTIONS询问:我能对这个资源做什么?

常见的请求头——调用方的自我交代:

请求头在说什么
Host我要找哪台服务器
User-Agent我是谁(什么工具、什么浏览器)
Accept我能接受什么格式的回应
Accept-Language我偏好什么语言
Content-Type我提交的请求体是什么格式
Content-Length我提交的请求体有多长
Authorization我的身份凭证(5.1 的 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.1HTTP/2,是协议的版本号,其实还有个更新的 HTTP/3。它们有一些小小的差异,但是差别不大,方法、路径、状态码、头、体这套语义都一样,我们一般也不需要管,知道这件事就行。


手搓 API 要照顾到什么?

做一个 API,就是做那个“接话、回话”的程序。但规范里的部件这么多,哪些是必须处理的,缺了就不 work 的那种呢?

首先是请求这一侧

  • 请求行里的方法路径,每个请求都必带,我们作为 API 的提供者,也必须要读取请求行,因为请求行里面包含了方法和路径。如果我们不看请求的方法,就分不清对方是来取数据还是来提交内容;如果不看路径,就分不清对方要访问哪个资源,因为不同的路径,得给不同的回应。
  • 请求头那一排,一般的请求都会带,我们作为 API 的提供者,这些信息可读可不读,建议按需读取,需要用到哪条再读哪条。

关于响应这一侧

  • 状态行——我们必须返回。不回状态码,这就不是一段 HTTP 响应,调用方会直接报错;
  • Content-Type 响应头——严格说规范允许省略,但省了,调用方就只能猜我们回的是什么格式——实践里按必写对待
  • 那个空行——必须。它是“头”和“体”之间唯一的分界线;漏了它,调用方会把正文误当成头来解析,全盘皆乱;
  • 响应体——真正要给对方的内容。规范上可以没有体,但做 API 回数据,它就是主角(我们的 JSON 就放这儿)。

从这个角度来看,如果要手搓一个 API,要处理的事儿还不少,因为 HTTP 规范的每一样我们都得亲手写;不过,好在 Python 标准库里有一个专门处理 HTTP 的模块——http.server,接下来我们就用它,把这份清单逐项落实。


用 Python 实现这套规范

我们要实现的接口,就是以后前端真的会来调用的那一个:GET /api/profile,返回主页要显示的内容(现在这些内容是写死在前端 site.js 里的)。

这一节我们先site.jshome 的前两个字段意思一下就行——今天的重点是 HTTP,不是数据本身。等 5.5 前端真的来调这个接口时,我们再把返回的结构和 home 完整对齐。

回到 5.2 建好的 ~/zero-to-tech/backend/,照例先激活环境(虽然 http.server 是标准库成员,其实不激活也能跑,但“进项目先激活”这个习惯值得从现在养成):

source .venv/bin/activate

然后新建 main.py

from 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_ascii=False)  # ensure_ascii=False:让中文原样输出
            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)响应的状态行——回一个 200
self.send_header(...)响应头——一行一条,我们写了 Content-Type 这一条
self.end_headers()那个空行——“头写完了”,头和体的分界线
self.wfile.write(...)响应体——我们的 JSON(encode 是因为网络上传输的是字节,文本要先编码)
else 分支的 404状态码 404——没找到。模块 4 时是 Nginx 替我们回,现在轮到我们自己回

规范里的每一个部件,都能在代码里找到——这段代码没干别的,就是老老实实按 HTTP 规范“接话、回话”。

再对照刚才那份“缺了就不 work”的清单,把必须项在代码里点一遍名:

  • self.send_response(200)——那条必须的状态行。不写它,回出去的就不是一段 HTTP 响应;
  • self.end_headers()——那个必须的空行。名字里带着 headers,干的活其实是“头到此为止”——漏了它,调用方会把正文误当成头;
  • self.send_header("Content-Type", ...)——那条按必写对待的头,告诉调用方“这是 JSON”。

这三行不是可有可无的样板。 前两行属于“硬要求”——删掉其中任何一行,curl 那头直接报错,因为收到的根本不是一段合法的 HTTP 响应;Content-Type 那行删掉倒是还能跑通,但调用方就只能猜我们回的是什么格式了——这正是“规范必须”和“实践必写”的区别,落到了代码上。(404 分支里同样是先 send_responseend_headers,硬要求一样不能省,只是没有“体”。)

最后一行代码里还有个数字值得交代:

HTTPServer(("", 8000), Handler).serve_forever()

这是指定了 http 服务在 8000 端口上启动。回想模块 2 讲过的:IP 找到机器,端口找到机器上的某个程序——一台电脑可以同时跑很多网络程序,各守各的端口。80(HTTP)和 443(HTTPS)是网页的默认端口(地址栏里不写端口号时走的就是它们),一般留给正式服务;开发的时候,挑一个没被占用的端口就行。8000 是 Python 圈的习惯值(python3 -m http.server 默认就用它),就像前端圈 Vite 爱用 5173、Next 爱用 3000,都只是习惯,不是规定:改成 9000 也照样跑,只是调用时的地址要跟着变。


跑起来

python3 main.py

终端打印“后端已启动”,然后——光标停住不动了。别慌,这不是卡死:

还记得 5.1 说的吗,后端是一个“一直运行、守着等请求”的程序。现在它真的出现在我们的终端里了:serve_forever() 就是字面意思——守在 8000 端口,永远等着。想停掉它,按 Ctrl + C

换一个新的终端窗口(旧的那个正跑着服务呢),调用它:

curl http://localhost:8000/api/profile

回来一段 JSON。再用浏览器打开 http://localhost:8000/api/profile——同样的 JSON。

成了。我们写出了自己的第一个 API。 5.1 我们对 ipify、对 DeepSeek 做的事,现在别人也可以对我们做了。

这时候回头看一眼跑着服务的那个终端,多了几行东西:

127.0.0.1 - - [03/Jul/2026 15:42:10] "GET /api/profile HTTP/1.1" 200 -
127.0.0.1 - - [03/Jul/2026 15:42:31] "GET /api/profile HTTP/1.1" 200 -
127.0.0.1 - - [03/Jul/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——我们这个极简服务器用的老版本协议,状态码后面带着那句简短说明,就是前面说过的样子;响应头里还多了 ServerDate 两条我们没写的——是 http.server 自动替我们加的,服务器顺带做了自我介绍。

浏览器视角:F12 里的同一份报文

Chrome 打开 http://localhost:8000/api/profile,按 F12Network 面板 → 刷新 → 点开那条请求:

  • General:Request URL、Request Method: GET、Status Code: 200
  • Response Headers:我们 send_header 写的那两行,原样躺着;
  • Request Headers:浏览器发出的请求头——一会儿的实验里细看。

F12 的 Network 我们 4.5 用过,那时看加载顺序;今天点进单个请求的内部——curl -v 看到的和这里看到的,是同一套东西的两个视角。


动手改两处,做两个实验

我们前面说,响应头里面的 Content-Type 不是“规范必须”,但是它是“实践必写”,那我们就做个小实验,看一看为什么说它是实践必写。

另外,我们前面还说,服务端可以把来访者看得清清楚楚,那我们也通过这个实验看一下。

打开 main.py一次改两处

改动一:临时加一个 /hello 路径,把它写到 else 那一行的上边:

        elif self.path == "/hello":
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")   # ← 一会儿改成 text/plain 再试
            self.end_headers()
            self.wfile.write("<h1>你好,HTTP</h1>".encode("utf-8"))

后面那截 charset=utf-8 是给浏览器多交代一句:内容按 UTF-8 解码。我们的响应体里有中文,不交代这一句,浏览器可能猜错编码,页面上就是乱码——又一个「头是关于内容的说明」的例子。

改动二:在 do_GET 的开头加两行打印(给实验二用):

    def do_GET(self):
        print(self.headers)          # 收到的请求头
        print(self.client_address)   # 请求是从哪个地址来的
        ...

改完,重启服务(Ctrl + Cpython3 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/xhtml+xml,...
Accept-Language: zh-CN,zh;q=0.9
Accept-Encoding: gzip, deflate
...

可以看到一大串,但请注意两件事:

  1. 这些头,我们一行都没写过——是 curl、浏览器自动带上的。User-Agent 就是“我是谁”的自我介绍:curl 老老实实报名字和版本,浏览器报出一长串型号。服务端一眼就能分辨请求是浏览器来的还是程序来的;
  2. 这正是规范图里“请求头”那一段——在 F12 的 Request Headers 看到的,就是它们出发前的样子;现在我们站在服务端,看到了它们在服务端被接收。

另外,在 5.2 的时候,我们也写过一个 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。也就是说,服务端天然看得见每个请求是从哪个地址来的——5.1 里 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——写的都是对同一套规范的实现,所以 5.1 才说,API 的概念和语言无关。

一模一样的事,就有人打包做好了给大家复用。打包好的那个东西,叫框架

下一节,FastAPI 登场。我们会看到这一节的全部杂活在框架里缩成几行——而正因为亲手按规范搓过一遍,我们会确切地知道它替我们干了什么。(框架会把状态码和头都藏起来,但它们一直都在——我们已经摸过一遍,以后想看,curl -v 和 F12 里随时都在。)


这一节你应该带走什么

  • 这一节的知识点,几乎全记在 HTTP 名下:报文格式、方法、状态码、头、版本——它们是整个 Web 的对话规范,浏览器打开网页用的也是这套;API 只是用 HTTP 对外提供能力的一种用法(回 JSON 而不是回 HTML)。
  • HTTP 的一来一回是两段有格式的纯文本:请求 = 请求行+头+空行+体;响应 = 状态行+头+空行+体——格式对称。
  • 方法描述的是请求的意图,不是数据方向:GET=“给我”,POST=“我提交内容请你处理”;响应永远都有,和方法无关。curl 默认发 GET,带上 -d 自动改发 POST。
  • HTTP/1.1、HTTP/2、HTTP/3 是协议的版本:彼此有些小差异,但方法、路径、状态码、头、体这套语义都一样——一般不用管。
  • 状态码家族:2xx 成功,4xx 请求方的问题,5xx 服务方的问题;404 现在轮到我们自己回了。
  • 回一段响应,哪些不能省:状态行、空行是硬要求(缺了调用方直接报错),Content-Type 按必写对待(省了对方只能猜)——这是 HTTP 的要求,谁写后端都绕不开,框架也只是替我们写。
  • Content-Type 告诉对方内容是什么格式——内容不变、头一变,对方的处理方式就变。
  • 服务端能看到的,不止我们显式发的内容:User-Agent、来源 IP、语言偏好……都是自动带上的元信息——ipify 的谜底就在这。
  • 我们写出了自己的第一个 API——零依赖,纯标准库;后端就是一个“一直跑着、守着等请求”的程序(Ctrl + C 停止);curl -v> 请求、< 响应、* 旁白)和 F12 随时能看到报文原文。
  • 任何语言写后端,都是在实现同一套 HTTP 规范——杂活人人一样,所以有了框架。下一节 FastAPI。

← 上一节:模块 5.2 Python 的安装和环境设置 | 下一节:模块 5.4 从手搓到框架,FastAPI 登场 →