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.Pipe
、io.TeeReader
、io.MultiReader
、io.Copy
等,用一次文件读取完成了所有操作。其中 io.TeeReader
在读取文件 Reader 的同时,把内容抄写到另一个 md5 hasher 里来计算 md5sum 值;接着再抄写到 base64 编码器中,同时由 io.Pipe
完成写转读(而不是用缓冲区)得到一个读取编码后内容的 reader,最后用 io.MultiReader
把各个 reader 串接出单一 reader chained
,http.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()
}