前面很多教程会讲服务端如何接收文件上传,但客户端怎么上传也很常见:你的 Go 程序要把图片传给素材服务,把 CSV 传给导入接口,或者把日志包传给诊断系统。这类请求通常使用 multipart/form-data。标准库可以完成,但写法比普通 JSON 请求稍微长一点。
本文用“上传头像”的客户端示例,讲如何构造 multipart 请求、设置 Content-Type、传普通字段,并用 httptest.Server 测试。
最小上传函数
type UploadClient struct {
BaseURL string
Client *http.Client
}
func (c *UploadClient) UploadAvatar(ctx context.Context, userID string, filename string, r io.Reader) error {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("user_id", userID); err != nil {
return err
}
part, err := writer.CreateFormFile("avatar", filepath.Base(filename))
if err != nil {
return err
}
if _, err := io.Copy(part, r); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/avatar", &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
client := c.Client
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return fmt.Errorf("upload status: %d", resp.StatusCode)
}
return nil
}
multipart.NewWriter 会生成 boundary。writer.FormDataContentType() 会返回带 boundary 的 Content-Type,比如 multipart/form-data; boundary=...。不要自己手写 multipart/form-data,否则服务端不知道如何分割字段。
文件名要处理
示例里用了 filepath.Base(filename),避免把本地完整路径传给服务端。比如 /Users/me/avatar.png 只会发送 avatar.png。文件名只是展示信息,服务端仍然不能完全信任它。
如果客户端上传的是内存内容,也可以自己传一个逻辑文件名:
err := client.UploadAvatar(ctx, "u-1", "avatar.png", bytes.NewReader(data))
函数接收 io.Reader 而不是文件路径,会更灵活。调用方可以从磁盘、内存、网络流提供内容。
大文件不要全部放内存
上面的示例用 bytes.Buffer,适合小文件。大文件会占用大量内存。更高级的方式可以用 io.Pipe 边写 multipart 边发送,但代码复杂一些。入门阶段可以先明确限制:头像、截图、小 CSV 用 buffer;大视频、大压缩包再考虑流式上传。
如果你确实要流式:
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
go func() {
defer pw.Close()
defer writer.Close()
part, err := writer.CreateFormFile("file", "big.bin")
if err != nil {
pw.CloseWithError(err)
return
}
_, err = io.Copy(part, source)
if err != nil {
pw.CloseWithError(err)
}
}()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, pr)
req.Header.Set("Content-Type", writer.FormDataContentType())
流式版本要认真处理错误和关闭顺序。小文件没有必要一开始就写这么复杂。
用 httptest 测试
func TestUploadAvatar(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s", r.Method)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatal(err)
}
if got := r.FormValue("user_id"); got != "u-1" {
t.Fatalf("user_id = %q", got)
}
file, header, err := r.FormFile("avatar")
if err != nil {
t.Fatal(err)
}
defer file.Close()
if header.Filename != "avatar.png" {
t.Fatalf("filename = %q", header.Filename)
}
data, _ := io.ReadAll(file)
if string(data) != "image-data" {
t.Fatalf("file = %q", data)
}
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
client := &UploadClient{BaseURL: server.URL, Client: server.Client()}
err := client.UploadAvatar(context.Background(), "u-1", "avatar.png", strings.NewReader("image-data"))
if err != nil {
t.Fatal(err)
}
}
这个测试验证了方法、字段、文件名和文件内容。上传客户端不需要依赖真实服务器就能测得很充分。
超时和日志
上传接口一旦网络卡住,默认 client 可能等待很久。即使是内部服务,也建议调用方传入带超时的 context:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := client.UploadAvatar(ctx, "u-1", "avatar.png", file)
上传失败时,错误日志里记录文件大小、用户 ID 和请求 ID 即可,不要把文件内容或敏感本地路径写进日志。客户端上传往往发生在边界位置,日志要能排查问题,也要避免泄露信息。
小结
Go 构造 multipart 上传请求的关键是:用 multipart.NewWriter,通过 WriteField 写普通字段,通过 CreateFormFile 写文件字段,最后调用 writer.Close() 并设置 writer.FormDataContentType()。
小文件可以先用 bytes.Buffer,代码简单可靠。大文件再考虑 io.Pipe 流式上传。无论哪种方式,都要让 base URL 和 HTTP client 可注入,这样测试才能用 httptest.Server 完成。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。