Go HTTP 客户端上传文件入门:multipart 请求怎么构造

用头像上传客户端示例讲 Go 如何构造 multipart/form-data 请求,包含文件字段、普通字段、Content-Type 和测试。

前面很多教程会讲服务端如何接收文件上传,但客户端怎么上传也很常见:你的 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 完成。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页