🎬 第八章 FFmpeg命令行实现流媒体功能及直播应用
📄 一句话概括

一条 HTTP 请求只有 73 字节,比 RTSP 还小。 它天生「一问一答、答完就完」,所以不需要会话管理。

📝 本节和前面几节的关系

8.1、8.2 那两套是「专门的直播协议」,从 8.3 起换成最常见的 HTTP。 它本来不是为直播设计的,结果现在直播大半走它——原因在这个协议的性格里。

一、一条请求有多小

最简的一条 HTTP 请求,三行就够:

部分 字节


请求行 22 Host 头 22 User-Agent 头 27 整条请求 73

比 RTSP 那 93 字节还少 20 字节。 少在哪:URI 短(没有 rtsp://主机:端口),也没有 CSeq 这种会话头。

HTTP 1.1 里 Host 是必需的,因为一台机器上可能放好几个网站。 得靠 Host 区分你要找的是哪一个,这也是「虚拟主机」能存在的原因:一个 IP,几百个域名。

User-Agent: Lavf62.12.102 跟 RTSP 一样,libav 系的工具都会带。 服务器日志里看到 Lavf,就知道来的是 ffmpeg 或 ffplay。

二、三个状态码就够用

拿同一份文件试四种请求,返回三种状态码:

请求 状态 长度


GET /s7.mp4 200 OK 48741 HEAD /s7.mp4 200 OK 48741 GET /nope.mp4 404 Not Found 0 GET 加 Range 0-99 206 Partial 100

HEAD 的返回码与长度跟 GET 一模一样,但一个体的字节都不发。 这就是「先问大小再决定下不下」的用法——播放器常用它探文件。

404 很简单:文件不存在。 但直播里更常见的是 403 与 401:前者是「有权限但不许」,后者是「没登录」。

206 是本节的主角:它表示「你要的那一段,我给你了」。 这个状态码决定了「能不能拖进度条」。

三、Range 是 HTTP 的杀手锏

HTTP 能拖进度条,全靠一个请求头:

Range: bytes=0-99

它的意思是「我只要第 0 到第 99 字节」。服务器要回 206,并在头里带上 Content-Range: bytes 0-99/48741,告诉客户端「这段是哪一段」。

服务器行为 客户端看到什么


回 206 加 Content-Range 能跳读,可以拖进度条 忽略 Range 回 200 整份发过来,不支持拖 回 416 越界了,这段没有

很多静态服务器其实支持 Range,但不发 Accept-Ranges: bytes。 客户端看不到这个头,就不敢发 Range——所以「支持」和「敢用」是两件事。

8.3.2 会实测一件事:libav 一上来就发 Range: bytes=0-, 不是裸 GET。它默认就想要一个能随机访问的连接。

四、为什么直播也用它

HTTP 本来完全不适合直播:它是「要一个文件」的协议。 但现在的直播大半走 HTTP,理由只有两个字:能穿。

项 HTTP RTSP/RTP


端口 80/443 554/5004 等 过防火墙 基本都放行 经常被拦 能用 CDN 能,就是静态文件 很难 延迟 偏大(要缓冲) 偏小

第三行是关键:CDN 天生只会缓存 HTTP 文件。 HLS 之所以流行,就是因为它把直播切成了「一堆静态文件」,CDN 直接能用。

代价是延迟变大:切片要攒够一段才能发,所以 HLS 的延迟通常五秒起。 要更低延迟就得用 HTTP-FLV 那类「永不结束的响应」,牺牲的是 CDN 友好度。

五、动手 Demo:手写四条请求看状态码

下面这段代码量一条请求的分段字节,再手写四条请求打到本地服务器上, 分别看 200、404、206 三种返回。

import http.server, os, socket, threading

class H(http.server.BaseHTTPRequestHandler):
    u"""最小 HTTP 服务:支持 HEAD 与 Range。"""

    def log_message(self, *a):
        pass

    def do_HEAD(self):
        self._send(False)

    def do_GET(self):
        self._send(True)

    def _send(self, body):
        name = self.path.strip("/").split("/")[-1]
        p = os.path.join(os.getcwd(), name)
        if not os.path.exists(p):
            self.send_response(404)
            self.send_header("Content-Length", "0")
            self.end_headers()
            return
        n = os.path.getsize(p)
        rng, code, start, cnt = self.headers.get("Range"), 200, 0, n
        if rng:
            a, _b, b = rng.split("=")[1].partition("-")
            start = int(a or 0)
            cnt = (min(int(b or n - 1), n - 1) - start + 1)
            code = 206
        self.send_response(code)
        self.send_header("Content-Length", str(cnt))
        if code == 206:
            self.send_header("Content-Range",
                             "bytes %d-%d/%d" % (start, start + cnt - 1, n))
        self.end_headers()
        if body:
            with open(p, "rb") as f:
                f.seek(start)
                self.wfile.write(f.read(cnt))

srv = http.server.ThreadingHTTPServer(("127.0.0.1", 8000), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()

req = (u"GET /s7.mp4 HTTP/1.1\r\n"
       u"Host: 127.0.0.1:8000\r\n"
       u"User-Agent: Lavf62.12.102\r\n"
       u"\r\n")
print(u"请求行 %d 字节" % (len(u"GET /s7.mp4 HTTP/1.1") + 2))
print(u"整条   %d 字节" % len(req))

CASES = (u"GET /s7.mp4 HTTP/1.1\r\nHost: x\r\n\r\n",
         u"HEAD /s7.mp4 HTTP/1.1\r\nHost: x\r\n\r\n",
         u"GET /nope.mp4 HTTP/1.1\r\nHost: x\r\n\r\n",
         u"GET /s7.mp4 HTTP/1.1\r\nHost: x\r\nRange: bytes=0-99\r\n\r\n")

for line in CASES:
    s = socket.create_connection(("127.0.0.1", 8000), 3.0)
    s.sendall(line.encode())
    buf = b""
    while True:
        d = s.recv(65535)
        if not d:
            break
        buf += d
        if len(buf) > 4000:
            break
    s.close()
    head = buf.split(b"\r\n\r\n")[0].decode("utf-8", "replace")
    rows = head.split("\r\n")
    clen = [x.split(":")[1].strip() for x in rows
            if x.lower().startswith("content-length")]
    print(u"%-28s %-22s %s" % (line.split(u"\r\n")[0],
                                rows[0].split(" ", 1)[1],
                                clen[0] if clen else "—"))

srv.shutdown()
srv.server_close()

实跑输出:

--------------------------------------------------------------------
8.3.1 HTTP 简介
--------------------------------------------------------------------

**① 一条 HTTP 请求有多小**

  部分              字节
  --------------------------
  请求行            22
  Host 头           22
  User-Agent 头     27
  整条请求          73

**比 RTSP 还小:HTTP 只要一个 Host 头就能开工,**
**因为它天生就是「一问一答、答完就完」,不需要会话管理。**

**② 手写三条请求,看三种状态码**

  请求                       状态行                  长度
  ------------------------------------------------------------
  GET /s7.mp4 HTTP/1.1       200 OK                  48741
  HEAD /s7.mp4 HTTP/1.1      200 OK                  48741
  GET /nope.mp4 HTTP/1.1     404 Not Found           0
  GET /s7.mp4 HTTP/1.1       206 Partial Content     100

**`HEAD` 的返回码跟 `GET` 一样、长度也报出来了,但一个体字节都不发。**
**这就是「先问大小再决定要不要下」的用法。**

**③ Range 请求要服务器配合**

**最后一条请求带了 `Range: bytes=0-99`,服务器回了 206 与 `Content-Range`。**
**换成不会 Range 的服务器,它会忽略这个头、直接回 200 加整个文件。**
**客户端要能分辨:回了 200 就说明「你要的那段我不管,整份给你」。**

**④ 直播为什么不用普通 HTTP 文件服务**

**普通 HTTP 是「我要一个文件」,服务端算好长度、发完就断开。**
**直播是「我要一条永不结束的响应」——长度算不出来,只能边生成边发。**
**这就是 8.3.4 要试的 HTTP-FLV/HTTP-TS 那套。**

第一条打印 22 字节、整条 73 字节,跟节里的表对得上。 七十三字节就能开始下一部电影,这就是 HTTP 的分量。

四条请求的返回分别是 200、200、404、206。 注意第二条(HEAD)也是 200——它跟 GET 的返回码一样,只是不发体。

第四条带了 Range,服务器回 206,长度只有 100 字节。 这就是「只要一段」的效果,进度条能拖,全靠它。

30 秒实验

  1. 把 Range 改成 100-199:看 Content-Range 与长度是不是跟着变。
  2. 发一条 Range 超过文件长度:看服务器回 416 还是回 200。
  3. 去掉 Host 头:HTTP 1.1 服务器会回 400,因为不知道你要哪个站。

常见误区

  1. 以为 HEAD 不返回长度。它照样报 Content-Length,只是不发体。
  2. 以为支持 Range 就一定能拖。还要发 Accept-Ranges,客户端才敢用。
  3. 以为 HTTP 天生适合直播。它是靠「能穿防火墙、能上 CDN」赢的。
自测三问(点开对答案) 1. 一条最简 HTTP 请求多大? —— 73 字节:请求行 22、Host 22、User-Agent 27,还有一个空行。 2. 206 是什么意思? —— 部分内容。服务器按 Range 只回了一段。 3. 直播为什么愿意用 HTTP? —— 端口能穿防火墙,而且能走 CDN——代价是延迟偏大。

相关笔记