Go语言实现HTTP文件下载服务

2022-11-14 ⏳11.8分钟(4.7千字)

本文是Go语言快速入门的实战部分。我会带领大家综合利用前面几节所学的基础知识, 开发一个简单的HTTP文件下载服务器程序。

在设计程序之前,我们需要学习一些HTTP协议的基础知识。

HTTP协议基础

HTTP协议是一种应用层协议,在HTTP/3之前一般都是基于TCP协议实现。因为TCP协议是可靠的流式通信协议,建立连接后,收发双方可以发送任意长度的数据,TCP协议栈还可能对数据做切片处理。所以基于TCP的应用层协议需要约定消息的传输格式,好让收发双方可以从收到的数据流中提取完整的消息。而HTTP协议就是众多约定中的一种。总之,TCP是传输层协议,提供流式通信,HTTP定义消息格式。

HTTP协议中文件下载最为简单,这也是它最初的功能。一个典型的下载请求长这个样子:

GET /index.html HTTP/1.1\r\n
Host: taoshu.in\r\n
User-Agent: httpie/1.0\r\n
\r\n

所有内容都是纯ASCII字符,以\r\n表示一行。第一行叫请求行,包括请求方法、路径和协议版本三个部分。请求行后面的每一行表示一组头信息,头信息又包换名字和取值,用冒号分割。最后用空行\r\n表示头消息传输完毕。

服务端收到GET请求后需要从中解决出文件路径,然后把对应的内容发送给客户端。但在传输数据之前,服务端需要先发送当前的响应状态:

HTTP/1.1 200 OK\r\n
Content-Type: plain/text\r\n
Content-Length: 5\r\n
\r\n
hello

第一行为状态行,包括版本号、状态码和状态信息三个部分。HTTP协议规定了一系列的状态码,2XX表示成功,4XX表示客户端错误,5XX表示服务端错误。状态行后面也跟着头信息,这与请求消息一样。空行之后才是真正要传输的文件内容。

HTTP/1.1默认会复用底层TCP连接,所以客户端需要确定文件的长度,否则客户端会一直等服务器发送数据。确实文件长度有两种方法。第一种比较简单,直接使用Content-Length头来指定。但有时候服务端在传输文件的时候并不能确定数据的总长度,所以HTTP/1.1支持 chunked 传输编码。简单来说就是分段传输,在传输数据之前先传这一段的长度值,再传数据。最后以长度为零表示数据传输结束。

HTTP/1.1 200 OK\r\n
Content-Type: plain/text\r\n
Transfer-Encoding: chunked\r\n
\r\n
2\r\n
he\r\n
3\r\n
llo\r\n
0\r\n
\r\n

分段传输需要使用Transfer-Encoding: chunked头信息指定。所以的数据分段也是以行为单位,结尾要追加\r\n。上面的例子就是把hello分割成hello两段来传输。最后的0\r\n\r\n表示本段数据长度是零,即数据传输结束。

为了节约带宽,HTTP协议支持对数据做压缩。但前提是服务端跟客户端支持同样的压缩算法。客户端在请求文件的时候通过Accept-Encoding头发送当前支持的压缩算法,一般是gzipdeflate,多种算法用逗号分割。服务端提取头信息后选自己支持的一种压缩数据。具体的压缩算法需要通过Content-Encoding这个头来指定。

HTTP/1.1 200 OK\r\n
Content-Type: plain/text\r\n
Content-Length: 5\r\n
Content-Encoding: gzip\r\n
\r\n
[gzip binary data]

这时候Content-Length头里指定的是压缩后的数据总长度。

Content-Type表示数据类型,取值为 MIME 类型,需要根据文件内容判断。常见的类型有plain/text, plain/html, image/png等。

以上本篇用到的HTTP知识。如果想深入学习,可以阅读我的专门文章。下面我来介绍Go语言中网络编程的基本知识。

网络编程基础

一般讲网络编程都是说TCP/IP网络编程。因为IP只有网络地址,数据包从一台机器可以发送到另一台机器。但接收方收到数据后该交给哪个程序来处理呢?这就需要TCP协议来解决了。具体而言,TCP协议在IP协议的基础上定义了端口的概念。通信双方在发送数据之前不但要确定IP地址,还要指明端口号。操作系统收到IP数据后会根据端口找到对应的进程并交由它处理。

因为是双工通信,通信双方互为对方的发送方和接收方,所以双方都要绑定端口号。一次TCP会话就包含源地址、源端口、目的地址、目的端口四部分,也叫四元组。一般服务端程序需要指定自己的端口。也叫监听端口。不然客户端不知该怎么连接。而客户端的端口号一般由操作系统自动分配。

网络编程的第一个API就是绑定端口:

import "net"

ln, err := net.Listen("tcp", "0.0.0.0:8080")

该功能由net模块的Listen函数提供。第一个参数表示协议类型,本节只用"tcp"。第二个参数表示绑定的地址和端口。其中0.0.0.0表示绑定到当前设备所有的IP地址。一台设备可能有多个网卡,一个网卡上可能有多个地址,而且还有127.0.0.1这样的特殊地址。如果是对外服务,最简单就是绑定到所有地址,也就是0.0.0.0上。这样就能处理任意地址发过来的数据。冒号后面的数字8080就是要绑定的端口。注意,如果没有管理员权限,程序不能绑定[0-1024]范围内的端口,这些端口也叫知名端口。

客户端连接服务器需要使用net.Dial函数。因为本节只讲服务端,客户端使用现成的 curl,所以不展开介绍。

net.Listen返回net.Listener接口对象。该接口最重要的函数是Accept()。服务端调用该函数后会被挂起,直到有客户端跟服务端完成TCP握手才会被唤醒。

c, err := ln.Accept()

Accept函数会返回net.Conn接口。这个接口稍微有点复杂:

type Conn interface {
  Read(b []byte) (n int, err error)
  Write(b []byte) (n int, err error)
  Close() error
  LocalAddr() Addr
  RemoteAddr() Addr
  SetDeadline(t time.Time) error
  SetReadDeadline(t time.Time) error
  SetWriteDeadline(t time.Time) error
}

程序可以通过ReadWrite函数收发数据。Close函数用来关闭连接。LocalAddrRemoteAddr用来获取TCP连接的四元组信息。最后三个函数用来设置超时时间。

超时控制是网络编程非常重要的主题。如果没有设置合理的超时时间,恶意客户端会批量创建大量的TCP连接,自己也不收发数据,最终把服务器资源吃满,这是一种典型的拒绝服务攻击(DDoS)。

Go语言的超时控制比较特别。它需要程序计算一个绝对时间,也就是当前时间加上超时间隔,再传给操作系统。如果到截止时间还没完成读写,Read或者Write函数调用就会报超时错误。

最简单的网络程序叫 Echo,它会把收到的数据原封不动地再发给客户端:

package main
import "net"
func main() {
  ln, _ := net.Listen("tcp", "0.0.0.0:8080")
  for {
    c, _ := ln.Accept()
    buf := make([]byte, 1024)
    for {
      n, _ := c.Read(buf)
      if n == 0 {
        break
      }
      c.Write(buf[:n])
    }
  }
}

这段程序省略了所有的错误处理逻辑,但实际代码应该严格检查各项错误并做相应处理!

运行后程序会监听8080端口。大家可以执行telnet 127.0.0.1 8080,然后输出任意内容并回车,telnet 就会收到服务返回的相同的内容。

如果你同时运行两个 telnet 进程,就会发现第二个会卡住。产生这个问题是因为上面的 Echo 代码只有一个协程在处理。只要第一个 telnet 不断开连接,内层 for 循环就不会退出。说起退出,如果 telnet 主动断开,Read函数也会返回,但第一返回值的取值是零,表示客户端主动断开。内层循环不结构,外层循环中的Accept函数就没其机会执行。所以第二个 telnet 没法正常建议 TCP 连接。

解决办法也很简单——使用协程。每次Accept函数返回就创建一个新的协程来运行内层循环,当前协程继续调用Accept等待下一个传入连接。代码我就不贴出来了,大家可以当成一次小练习,自己试一试。大家还可以尝试给传入连接设置超时时间,看看到时候会不会断开连接。

另外,Go标准HTTP协议库提供完备的超时控制机制,学有余力的同学可以阅读我的专门文章

到现在网络相关的基础知识已经介绍完了。下面我们开始设计HTTP文件服务器程序。

整体设计

我们希望该程序能支持以下功能:

  1. 通过 GET 请求下载文件
  2. 通过 Gzip 压缩数据
  3. 使用 chunked 传输编码传输大文件
  4. 记录访问日志

并发模型上我们使用最简单的多协程模式。由main函数所在协程(主协程)负责监听端口并且循环调用Accept接受客户端传入连接。然后为每一个链接启动独立的工作协程处理HTTP请求。这种方式虽然经典,却也太简单了点。为了进一步展示并发编程,我给日志系统强行加戏,通过专门的协程来收集并输出日志。

每个工作协程的执行过程如下:

各工作协程完全独立,互不影响。

下面我们开始详细介绍各部分的关键设计。

组件设计

协议解析

服务端软件最复杂的就是协议解析。HTTP协议是一种扩展性很强的协议,使用上非常灵活,但代价是解析起来非常麻烦。

所谓「粘包」问题

除此之外,协议解析还要处理所谓的「粘包」问题。我们前面讲TCP在逻辑上是流式传输协议。但在实现上数据又是分段传输的。这种分段行简单来说中会导致接收端收到的数据跟发端不一致。比如客户端一次性发了abcde个字节,接收端可能会先收到abc,然后又收到de。这当然是比较夸张的说法。实际上只有在一次发送比较多的数据时才会产生这样的问题。

还有一种「粘包」的情况是客户端发来的两条数据被服务端一次性收到,或者收到了第一条和第二条的一部分。假设客户端依次发送两条头信息User-Agent: curl/1.0\r\nAccept-Encoding: gzip\r\n。但因为TCP底层可能分段,服务端收到的可能User-Agent: curl/1.0\r\nAccept-Encoding:。注意,后面的Accept-Encoding部分是不完整的。

不论是哪种情况,服务端都必须兼容。处理方法也非常简单,而且也很经典,使用缓冲区:

n := 0
buf := make([]byte, 1024)
for {
  n,_ = c.Read(buf[n:])
  r, ok := parse(buf[:n])
  if ok { /*..*/ }
  copy(buf, buf[r:n])
  n = n - r
}

每次parse结束后需要把buf中尚未处理的数据的偏移量返回,程序需要把剩下的数据挪到缓冲区的开头,然后再跳过这部分数据继续从客户端接收后续数据。也就是说我们不能简单假设服务端一次收到的就是一个完整的数据包。

解析HTTP请求

HTTP请求以行为单位,处理方法也有很多种。最简单的就是设置一个比较大的缓冲区,争取一次性收下所有请求数据。如果解析失败就认为客户端有问题。这种方法简单粗暴,但实际中很少用到。

经典的做法设置合适的缓冲区,比如1k字节。这样就要求每一行数据不能超过1k,不然无法解析。然后一次遍历收到的数据,并使用状态机来记录当前要解析内容的起始位置。

说起来有点复杂,还是举个例子。

GET /index.html HTTP/1.1\r\n

最开始我们要提取请求方法GET。我们可以把开始位置p设置为0,然后扫描每一个字节,直到第一个空格为止。假设当前位置用i表示,那么buf[p:i]就是请求方法GET

紧接着,我们要跳过空格。可以为这种行为设置单独的状态。在当前状态下,当扫描到第一个非空字符的时候需要记录当前位置p,然后切换到解析路径的状态。等再扫描到空格的时候buf[p:i]对应的就是请求的路径。依此类推,不断切换和扫描,最终完成解析。

代码框架就是外层循环套着内导一个很大的switch分支判断语句。因为HTTP协议比较复杂,状态机需要设置很多状态。我自己实现了一个简化版本,很多规则都没有判断,但可以正常提取本节需要的所有信息。因为是简单版本,所以只设定了16个状态。对初学者来说已经是比较复杂了。

func (req *Request) Feed(buf []byte) (ParseStatus, int) {
  var p, i int
  status := ParseBegin
  if req.status != ParseBegin {
    status = req.status
  }
  var headerName, headerValue string
  for i = 0; i < len(buf); i++ {
    switch status {
    case ParseBegin:
    //...
    case ParseMethod:
    //...
    default:
      status = ParseError
      break
    }
  }
  req.status = status
  return status, i
}

虽然HTTP协议要求使用\r\n换行,但几乎所有实现都支持使用\n换行。兼容此功能也让状态机变的比较复杂。大家阅读代码的时候一定要注意。

传输编码

传输编码主要用于解决内存占用问题。如果什么都不管,我们完全可以把要发送的文件先读到内存,然后再发给客户端。但如果文件很大会占用很多内存。通过 chunked 传输编码就可以使用固定长度的缓冲区来分段发送。核心逻辑如下:

buf := make([]byte, 1024)
for {
  n, err := f.Read(buf)
  chunk := buf[:n]
  if n == 0 {
          break
  }
  hexSize := strconv.FormatInt(int64(n), 16)
  w.Write([]byte("\r\n" + hexSize + "\r\n"))
  a.Write(chunk)
}
w.Write([]byte("\r\n0\r\n\r\n"))

这里有一个小技巧。因为每一段数据后面都要追加\r\n。如果单独写w.Write([]byte("\r\n"))有可能触发额外的数据发送,但把它跟前面的数据连到一起会产生内存复制。所以我把当前数据结尾的\r\n跟下一行数据的长度一起发送,于是就有了w.Write([]byte("\r\n"+hexSize+"\r\n"))的写法。

内容压缩

内容压缩需要考虑两个问题。第一,有很多文件本身就是压缩格式,比如 jpeg。压缩它们纯属白费力气。一般来说纯文本的压缩效果比较好。每二,要考虑文件的体积。压缩文件会产生额外的数据信息,如果文件本身比较短,再压缩有可能会增大体积。

还有一个问题,无损压缩(比如霍夫曼算法)需要事先读取文件的所有内容然后生成压缩词典,这样就需要一次性将文件加载到内存。压缩后体验已经确定,所以也就不需要 chunked 传输编码了。

Go语言标准库支持 gzip 压缩,使用方法也非常简单:

data, err := io.ReadAll(f)
ctype := http.DetectContentType(data)
gzip := false
if strings.HasPrefix(ctype, "text/") && len(data) > zipSize {
  var buf bytes.Buffer
  zw, _ := gzip.NewWriterLevel(&buf, gzip.DefaultCompression)
  zw.Write(data)
  zw.Close()
  data = buf.Bytes()
  gziped = true
}

使用zw写入数据后需要调用Close函数,不然 gzip 不会把压缩数据写入buf缓冲区。

最后说一下日志模块。

日志模块

我在前面说过,为了演示并发编程,我把日志功能放到了一个单独的协程。其实在现实项目中,这也是常规操作。因为系统同时要处理的请求有很多,每个请求结束就立即写日志会影响系统性能。所以一般都会搞个日志缓冲区,攒够一拨后再输出。

但如果某个时段请求又很少,攒不够一拨怎么办呢?这就需要再结合定时器周期性刷新。日志组件这种行为特殊非常适合用来展示协程的用法,所以我就给它加戏了。

日志核心定义如下:

type Log struct {
  EntryNum int
  Writer   io.Writer
  Interval time.Duration

  i  int
  ch chan entry
  t  *time.Ticker

  entries []entry
}

大写字母开头给调用方修改设置用。EntryNum表示缓冲队列长度,Writer表示真正要输出日志的对象,Interval表示定时刷新的间隔。

日志处理的核心逻辑如下:

func (l *Log) Loop() {
  for {
    select {
      case e := <-l.ch:
        l.entries[l.i] = e
        l.i++
        if l.i == l.EntryNum {
          l.flush()
        }
      case _ = <-l.t.C:
        l.flush()
    }
  }
}

我们需要在单独的协程运行Loop函数。它在循环中通过select同时监听l.chl.t.C两个通道。任何通道有消息都能及时处理。如果有日志,则将其保存到l.entries队列,如果日志数量达到l.EntryNum就刷新一拨。如果日志数据不够但是时间到了也强行刷一次。这样就实现了上面说的效果。

以上是简单介绍了各组件的设计思路。具体细节还得仔细阅读源码。

代码结构

完整代码托管在GitHub,目录结构如下:

├── LICENSE
├── README.md
├── cmd
│   └── sfile
│       └── main.go  # 程序入口
├── go.mod
├── go.sum
├── http             # HTTP 协议相关
│   ├── file.go      # chunked 和 gzip 相关
│   ├── file_test.go
│   ├── http.go      # HTTP 请求解析
│   └── http_test.go
├── log
│   ├── log.go       # 日志模块
│   └── log_test.go
└── server
    └── server.go    # 服务器主流程

所以有核心逻辑都添加了单元测试,代码保存在对应的*_test.go文件。大家也要仔细阅读。

总结

以上就是本文的全部内容。希望大家能够通过实践来加深对Go语言的理解。如果大家吃透了 sfile 全部的代码,那么可以考虑学习Go语言的标准库net/http,然后尝试用标准库重新实现 sfile 的功能。我之前搞过一个文本转图片的小工具,也适合初学者练手。