2004年,我看了第一个来自互联网的视频。那是一段通过iTunes下载的音乐视频。重点是“下载”。从那时起,我们大多数人看互联网视频的方式已经发生了很大变化。从只下载,到只看流媒体。
不论是通过 YouTube 还是 Netflix 平台,最终总是在某个地方有一台服务器在为你传输你正在观看的视频。最初,我对视频流媒体的理解是:
- 这是一件很难实施的事。
- 它需要一些专门的协议,我太懒去学。
原来搭建一个流媒体服务器其实很简单。不过,我也不想小看那些大型视频流媒体公司所面临的挑战。
Thomas William — https://unsplash.com/photos/person-holding-video-camera-4qGbMEZb56c
在这篇文章里,我们将用 Go 搭建一个符合 RFC 7233 的 HTTP 视频流媒体服务器。
架构:在编写任何 Go 代码之前,我想先解释视频流在现代浏览器中的工作方式。这是默认的开箱即用行为,在本文中不会涉及任何 HTML 代码。
在我看来,流式传输允许客户端请求资源的特定部分,而不是下载整个文件。客户端会发送一个带有Range
头部的HTTP
请求来获取文件的一部分。该头部指定了服务器应返回文件的哪个部分。服务器还会用一个名为Content-Range
的响应头部告知客户端它请求了文件的哪些部分。
这里有一幅图来更清楚地说明上面提到的想法:
Go 语言的代码示例要播放一个MP4视频文件,我需要什么呢?
- 可以退还视频片段流的视频店。
- 一个端点用于解析客户端请求并返回所需的文件片段。
我将从视频流播放器界面开始。这个界面将有一个功能,用于查找文件中的某些片段。下面是对 VideoStreamer
接口的定义:
type VideoStreamer interface {
// Seek 方法的 start 和 end 参数都是以字节为单位的。1024 表示 1KB。
Seek(key string, start, end int) ([]byte,error)
}
接下来,我将添加一个新的 struct 类型,名为 MockVideoStreamer
。此类型将有一个名为 Store
的字段,用于存储视频数据。这里是该类型的代码示例:
// MockVideoStreamer 是一个简单的视频流模拟实现,视频流接口
type MockVideoStreamer struct {
Store map[string][]byte
}
声明了 struct 类型之后,我将通过更新 MockVideoStreamer
类型来实现 VideoStreamer
接口的功能。Seek
方法将检查提供的视频键是否存在于 Store
字段中,如果存在,则返回请求的字节,如果不存在,则返回错误。下面是该实现的代码:
// Seek 根据键和指定的字节范围从存储中检索视频片段
func (m *MockVideoStreamer) Seek(key string, start, end int) ([]byte, error) {
// 使用键从存储中检索视频数据
videoData, exists := m.Store[key]
if !exists {
return nil, fmt.Errorf("找不到视频")
}
// 确保范围在视频数据的边界内
if start < 0 || start >= len(videoData) {
return nil, fmt.Errorf("起始字节 %d 超出视频数据范围", start)
}
if end < start || end >= len(videoData) {
end = len(videoData) - 1 // 超出边界时,将结束位置调整为最后一个字节
}
// 返回视频数据的片段
return videoData[start : end+1], nil
}
下一步是添加一个HTTP处理器,以便将所有这些优秀的代码公开到网络。
HTTP 处理程序在这篇文章中,我将使用来自标准库中的http
包里的HandlerFunc
类型来进行流处理。这是一个普通的设置。HandlerFunc
将通过一个工厂函数生成。该工厂函数将有三个参数。
streamer
: 这将是VideoStreamer
类型的对象,用于获取处理器请求的文件部分。videoKey
: 视频的唯一标识videoKey
。它将作为Seek
方法的key
参数传递。totalSize
: 视频的总大小。
我会把 HandlerFunc
功能分成三个部分:
- 阅读并准备从客户端请求接收的数据。
- 加载客户端请求的字节数据。
- 用适当的头部信息将请求的字节回传给客户端。
在这第一部分,我的处理程序将读取Range
头部的值。读取后,处理程序将提取请求字节范围的开始和结束索引。这将通过分割Range
头部值并将开始和结束索引的字符串转换为int
类型来完成。以下是一部分的代码:
func VideoStreamHandler(streamer VideoStreamer, videoKey string, totalSize int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rangeHeader := r.Header.Get("Range")
var start, end int
if rangeHeader == "" {
// 默认前1MB
start = 0
end = 1024*1024 - 1
} else {
// 解析Range头:"bytes=start-end"
rangeParts := strings.TrimPrefix(rangeHeader, "bytes=")
rangeValues := strings.Split(rangeParts, "-")
var err error
// 获取起始字节值
start, err = strconv.Atoi(rangeValues[0])
if err != nil {
http.Error(w, "无效的字节范围", http.StatusBadRequest)
return
}
// 获取结束字节或设置默认值
if len(rangeValues) > 1 && rangeValues[1] != "" {
end, err = strconv.Atoi(rangeValues[1])
if err != nil {
http.Error(w, "无效的字节范围", http.StatusBadRequest)
return
}
} else {
end = start + 1024*1024 - 1 // 默认1MB部分
}
}
// 确保结束字节不超过总视频大小
if end >= totalSize {
end = totalSize - 1
}
...
}
}
对于第二部分,我会将 videoKey
、start
和 end
变量传递给 VideoStreamer
的 Seek
方法中以获取所需的字节流。这里是执行此操作的代码:
func VideoStreamHandler(streamer VideoStreamer, videoKey string, totalSize int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
...
// 获取视频
videoData, err := streamer.Seek(videoKey, start, end)
if err != nil {
http.Error(w, fmt.Sprintf("获取视频出错: %v", err), http.StatusInternalServerError)
return
}
...
}
}
一旦请求的字节被加载到内存中,我就可以进入第三部分并返回响应给客户端。首先返回一套对于流媒体工作必不可少的头部信息,不包含额外的HTML……具体如下是头部信息列表:
Content-Range
: 指示响应中所服务文件的字节范围和总大小。Accept-Ranges
: 指示服务器是否支持范围请求。Content-Length
: 指定响应正文的长度(以字节为单位)。Content-Type
: 指定所服务资源的媒体类型,确保客户端(如浏览器或媒体播放器)能正确解读内容。
接下来,我会将响应状态码设置为部分内容状态码(206),并将VideoStreamer
接口返回的字节写入响应体。
func VideoStreamHandler(streamer VideoStreamer, videoKey string, totalSize int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
...
// 设置响应头并发送视频片段
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(videoData)))
w.Header().Set("Content-Type", "video/mp4")
w.WriteHeader(http.StatusPartialContent)
_, err := w.Write(videoData)
if err != nil {
http.Error(w, "视频流处理出错", http.StatusInternalServerError)
}
}
}
您可以在下面的“源码”部分找到此处理程序的完整源码。
把它们放在一起为了测试之前定义的那段代码,我将添加一个主函数来做以下事情:
- 从一个素材网站下载一个 MP4 文件。并将下载的 MP4 文件存储到
MockVideoStreamer
实例中。 - 将由
VideoStreamHandler
返回的HandlerFunc
绑定到/video
路由。 - 启动一个在端口
8080
监听的 web 服务器。
以下是用来完成这些任务的代码:
# 原始代码段,保持不变
const url = "https://download.samplelib.com/mp4/sample-5s.mp4"
func 主函数() {
// DownloadBytes 是一个工具函数。
// 详见下方的完整源代码
data, err := DownloadBytes(url)
if err != nil {
fmt.Println("下载错误:", err)
return
}
日志打印("数据长度:", len(data))
streamer := &MockVideoStreamer{
Store: map[string][]byte{"video-key": data},
}
http.HandleFunc("/video", VideoStreamHandler(streamer, "video-key", len(data)))
http.ListenAndServe(":8080", nil)
}
然后……这就来了:
网络已降至3G速度
总结这篇帖子展示了如何利用客户端范围请求来提供流式传输体验。它还展示了 Go 语言开箱即用的功能。该帖子中的代码也存在一些局限性。
- 它会把文件数据加载到内存里。
- 正确的方法是用
io.Reader
来流式传输数据。 - 不支持完整内容请求,仅支持部分内容。
在高流量的环境中,流媒体传输可能需要不同的配置。我并不建议在这种环境下使用上述代码,尤其是考虑到这些限制。
谢谢您的阅读。
参考资料RFC 7233 — 这是一个关于范围请求的RFC文档 — https://datatracker.ietf.org/doc/html/rfc7233
完整代码可以在这里找到:https://gist.github.com/cheikhsimsol/12183f73b250bc09952ae52b5f36f53e