📚 第八章 应用协议
📄 一句话概括

应用层协议就是两个程序之间约定的「说什么话」, 它的成本能精确算出来:真内容几十字节,链路上跑一百多。 按「谁在等谁」分三类:请求响应、一问一答、单向推送。

📝 本节和前面几节的关系

前七章从物理层一路讲到传输层,这一章终于到了「人直接用的那一层」。 这一章会真起服务、真发请求、真跟协议对话,所有数字都从链路上量出来。

一、应用层协议在管什么

下四层解决的是「怎么把字节送到对端」,应用层解决的是「这些字节是什么意思」。 所以应用层协议的全部内容,就是一套「报文格式加交互顺序」的约定。 这套约定必须写下来, 不能靠「两边都聪明」——因为一旦两边对同一段字节的理解不同,结果就是不可调试的错乱。 所以每个成熟协议都有一份规范,而且规范里连「哪个字节在第几位」都要写死。 这带来一个后果:协议一旦铺开就极难改。 HTTP 加了三十年的功能,却始终没敢动请求行那三个字段的顺序—— 因为全世界有几十亿个实现按那个顺序写死了。

二、按「谁在等谁」分三类

第一类是「请求响应」:客户端开口,服务端答。 HTTP、SMTP、POP3、IMAP、FTP 全是这一类。 它们的共同特点是「服务端不能主动说话」—— 所以「服务器推送」这件事在 HTTP 里折腾了二十年才勉强做出来。 第二类是「一问一答的双向流」:两边都可以主动发,但发出去的东西有严格顺序。 TELNET 和 SSH 属于这一类,因为它们要传的是「一个人敲键盘的实时动作」。 第三类是「单向推送」:发送方只管往外发,不管有没有人收。 SNMP 的 Trap、syslog、RTP 都属于这一类。 它们用 UDP,因为「丢一两个包」比「等重传」划算。

三、协议的成本能算出来

下面这个数是真量出来的: 一个 GET 请求 70 字节,加上 TCP、IP、以太网三层头,链路上 128 字节。 而它真正想说的只是「我要 /index.html」这十一个字符。 服务端回来的更夸张:正文 17 字节,响应头 182 字节,头是体的十倍多。 所以「网页为什么慢」这个问题,有一半答案在头里。 这也是「长连接」和「头部压缩」存在的理由: HTTP/1.1 用 keep-alive 省掉重复的握手,HTTP/2 用 HPACK 把头压成索引号,HTTP/3 干脆换掉 TCP。 三代协议改进的方向,全是「减少说明文字的比例」。

四、端口是「约定」,不是「规定」

应用协议靠端口号找到对方:80 是 HTTP、443 是 HTTPS、22 是 SSH、25 是 SMTP。 但这些数字是「约定」,不是「物理规律」——你完全可以把 HTTP 服务跑在 8080 上。 约定之所以重要,是因为「默认值省掉了一次沟通」: 你输 example.com 时浏览器不用问你「用哪个端口」,它直接试 80。 所以端口号本质上是「写在 URL 规范里的默认值」。 0 到 1023 这段要管理员权限才能占,这是 Unix 留下来的规矩: 它保证「普通用户不能冒充一个系统服务」。 所以你自己起的服务只能占 1024 以上。

五、动手 Demo:一次 HTTP 请求,每层到底加了多少字节

起一个本机 HTTP 服务,用裸 socket 发一个 GET,收发字节全量统计。 重点是「真内容」和「链路上跑的」差多少。

import socket, http.server, threading

# 起服务(省略处理器定义)
srv = http.server.HTTPServer(('127.0.0.1', 0), H)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()

req = ('GET /index.html HTTP/1.1\r\n'
       'Host: 127.0.0.1:%d\r\n'
       'Connection: close\r\n\r\n' % port).encode()
s = socket.create_connection(('127.0.0.1', port), timeout=5)
s.sendall(req)
buf = b''
while True:
    chunk = s.recv(65536)
    if not chunk:
        break
    buf += chunk
s.close()

head, _, body = buf.partition(b'\r\n\r\n')
print('请求', len(req), '响应头', len(head), '正文', len(body))

for name, n in (('应用层', len(req)), ('TCP', 20),
                ('IP', 20), ('以太网', 18)):
    print(name, n)

实跑输出:

--------------------------------------------------------------------
一次 HTTP 请求,每层到底加了多少字节
--------------------------------------------------------------------
**「应用协议」听着抽象,但它的成本能精确算出来。**
**真要传的内容只有几个字节,链路上跑的却是一百多。**
**这个差额就是协议的开销。**

**真发出去的请求**(70 字节):

    GET /index.html HTTP/1.1
    Host: 127.0.0.1:64346
    Connection: close
    (空行,表示头结束)

**真收回来的响应**:头 182 字节,体 17 字节。

  这一层加的           字节     累计     占累计
  ----------------------------------------------
  应用层 HTTP 请求     70       70       100.0%
  TCP 首部             20       90       22.2%
  IP 首部              20       110      18.2%
  以太网头加尾         18       128      14.1%

**这个请求真正想说的是「我要 `/index.html`」**,**这句话九个字符就够了。**
**可它一共 70 字节,加上三层的头,链路上是 128 字节。**
**为了说清一句话,付了 11.6 倍的包装。**

**这就是为什么「短连接传小文件」特别不划算**:**握手要三个来回,每个来回都是一个完整的小包。**
**真传的内容还填不满一个包。**
**所以 keep-alive 和复用才那么重要。**

**反过来看响应头**:182 字节的头,17 字节的体。**头是体的 10.7 倍。**
**浏览器每次请求都要吞下这么多「说明书」。**

请求 70 字节,响应头 182 字节、正文 17 字节。 服务端只说了一句「hello /index.html」,却带了一百八十多字节的说明。 加三层头之后,链路上是 128 字节。 这个数只是「一个包」的量,还没算握手。 如果每次都新建 TCP 连接,还要多三个来回。 响应头里最长的两项是 Server 和 Date:一个报软件版本,一个报时间。 它们对传内容毫无用处,但两个都是「排错时最先看的东西」。 这就是协议设计的典型取舍:为可运维性牺牲字节。

30 秒实验

  1. 量一下:把 Connection: close 去掉,第二次请求能复用连接吗?
  2. 算一下:17 字节正文配 182 字节头,比例是多少?
  3. 想一下:为什么 0 到 1023 端口要管理员权限?

常见误区

  1. 以为「应用层协议就是格式定义」。 格式只是其中一半,另一半是「谁先说话、说错了怎么答、 超时怎么办」。
  2. 以为「端口号是协议的一部分」。 它是约定,改端口协议照样跑,只是别人找不到你。
  3. 以为「头越长越不好」。 头是可运维性的载体,砍掉头就等于砍掉排错能力。
自测三问(点开对答案) 1. 应用层和传输层的分工是什么? —— **传输层负责「送到对端」,应用层负责「这些字节是什么意思」。** 2. HTTP 属于哪一类交互? —— **请求响应**:客户端开口,服务端才能答。 3. 为什么要约知名端口? —— **省掉一次沟通**:浏览器直接试 80,不用问用户。

下一节从最老的应用协议看起:TELNET。

相关笔记