GO 低内存极速 I/O 编程:无缓冲编码文件,同时计算哈希值一并上传

Golang 自带了内容相当丰富的 io 库,对 io 编程友好。最近碰到个需要发送文件的 REST api,文件内容需 base64 编码为文本,同时还要文件的 md5sum,再附加一系列其他信息,最后用 json 文本发送。最无脑的做法是将整个文件读入缓冲区,依次计算 md5sum,再把文件编码为 base64 文本,最后拼接所需的 json 文本完成发送。当然这样文件一大铁定崩内存💩,显然有更合适的做法……

稍稍好一点的做法是从头到尾读取一次文件计算 md5sum,再 seek 回文件头部再从头到尾读一次完成编码,最后拼接 json 文本并发送。这种做法省掉了一次文件读取的缓冲,但还有 base64 编码的缓冲没有去掉,因为最后拼接 json 的时候仍然需要完整的内容,仍会占用大块内存,文件一大还是会崩。此处的源码以及运行效果如下,其中 echo.go 为模拟的 API 服务器,会原样返回客户端上传的数据,upload.go 为本程序:

点击展开查看/折叠源码 `upload.go`
package main

import (
	"bytes"
	"crypto/md5"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
)

func main() {
	f, err := os.Open("./testFile.bin")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

    // 计算 md5sum
	hasher := md5.New()
	_, err = io.Copy(hasher, f)
	if err != nil {
		log.Fatal(err)
	}
	md5sum := fmt.Sprintf("%x", hasher.Sum(nil))

    // 文件回退到头部
	_, err = f.Seek(0, io.SeekStart)
	if err != nil {
		log.Fatal(err)
	}

    // base64 编码文件,编码后的文本存入 encoded
	encoded := bytes.NewBuffer(nil)
	encoder := base64.NewEncoder(base64.StdEncoding, encoded)
	buf := make([]byte, 1024)
	for {
		n, err := f.Read(buf)
		if err != nil && err != io.EOF {
			log.Fatal(err)
		}
		if n == 0 {
			break
		}
		encoder.Write(buf[:n])
	}
	encoder.Close()

    // 拼接 json
	jsonBytes, err := json.Marshal(map[string]string{
		"md5":  md5sum,
		"file": encoded.String(),
	})
	if err != nil {
		log.Fatal(err)
	}

    // 发送到服务 API
	httpClient := &http.Client{}
	res, err := httpClient.Post(
		"http://127.0.0.1:8081/",
		"application/json",
		bytes.NewBuffer(jsonBytes),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()

    // 读取服务响应
	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		log.Fatal(err)
	}

	log.Printf(
		"client: response Status: %s\n"+
			"body length: %d\n"+
			"body preview: %s",
		res.Status, len(bodyBytes), string(bodyBytes[:100]),
	)
}
点击展开/收缩 `upload.go` 运行效果

下面是本篇要介绍的方法,无需额外的大缓冲区,通过结合使用 go 自带的 io.Pipeio.TeeReaderio.MultiReaderio.Copy 等,用一次文件读取完成了所有操作。其中 io.TeeReader 在读取文件 Reader 的同时,把内容抄写到另一个 md5 hasher 里来计算 md5sum 值;接着再抄写到 base64 编码器中,同时由 io.Pipe 完成写转读(而不是用缓冲区)得到一个读取编码后内容的 reader,最后用 io.MultiReader 把各个 reader 串接出单一 reader chainedhttp.Client.Post 用它一次便能读取到完整内容的 json,无需任何缓冲区直接发送。大体流程示意图和详细代码如下。

点击展开/折叠 `upload2.go`
func upload2() {
	f, err := os.Open("./testFile.bin")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	hasher := md5.New()
	copiedReader := io.TeeReader(f, hasher)
	hashBuffer := bytes.NewBuffer(nil)

	pr, pw := io.Pipe()
	encoder := base64.NewEncoder(base64.StdEncoding, pw)
	encoderDoneChan := make(chan interface{})
	go func() {
		_, err := io.Copy(encoder, copiedReader)
		if err != nil {
			pw.CloseWithError(err)
			log.Fatal(err)
		} else {
			pw.Close()
		}
		encoder.Close()
		hashBuffer.WriteString(fmt.Sprintf("%x", hasher.Sum(nil)))
		close(encoderDoneChan)
	}()

	jsonHead := bytes.NewReader([]byte(`{"fileContent":"`))
	jsonHead2 := bytes.NewReader([]byte(`","md5":"`))
	jsonTail := bytes.NewReader([]byte(`"}`))
	chained := io.MultiReader(jsonHead, pr, jsonHead2, hashBuffer, jsonTail)

	httpClient := &http.Client{}
	res, err := httpClient.Post(
		"http://127.0.0.1:8081/",
		"application/json",
		chained,
	)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()
} 
点击展开/折叠查看示意图