Go 文件上传入门:限制大小、校验类型和安全保存

用头像上传接口讲 Go HTTP 文件上传的基本流程,包括 MaxBytesReader、ParseMultipartForm、文件名处理、类型校验和安全保存。

文件上传看起来只是接收一个 multipart 表单,但真正写到服务里,会遇到不少边界:用户上传超大文件怎么办,文件名里带路径怎么办,扩展名能不能信,保存失败如何处理,旧文件要不要删除。初学者最容易写出能跑但不安全的代码。

本文用“头像上传”做例子。头像文件比较小,适合讲基础流程:限制请求大小,解析 multipart,检查文件类型,生成服务端文件名,保存到目录,最后返回访问路径。

HTML 表单长什么样

浏览器上传文件通常是 multipart 表单:

<form method="post" action="/avatar" enctype="multipart/form-data">
  <input type="file" name="avatar">
  <button type="submit">上传</button>
</form>

服务端 handler:

func uploadAvatar(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	// 处理上传
}

如果你只写 API,也可以由前端用 FormData 提交。服务端处理方式一样。

先限制整体大小

不要先解析再判断大小。应该在读取 body 前限制:

const maxUploadSize = 2 << 20 // 2 MB

func uploadAvatar(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
	defer r.Body.Close()

	if err := r.ParseMultipartForm(maxUploadSize); err != nil {
		http.Error(w, "file too large or invalid form", http.StatusBadRequest)
		return
	}
	// ...
}

MaxBytesReader 限制整个请求体,ParseMultipartForm 解析表单。头像上传通常不需要接受很大的 body。限制越明确,服务越不容易被意外请求拖垮。

读取上传字段

获取文件:

file, header, err := r.FormFile("avatar")
if err != nil {
	http.Error(w, "avatar is required", http.StatusBadRequest)
	return
}
defer file.Close()

log.Printf("upload file name=%s size=%d", header.Filename, header.Size)

header.Filename 是客户端提供的名字,不能直接相信。它可能为空,可能带奇怪字符,也可能试图伪装路径。不要把它直接拼到保存路径里。

检查文件类型

只看扩展名不可靠。可以读取文件头,用 http.DetectContentType 判断:

buf := make([]byte, 512)
n, err := file.Read(buf)
	if err != nil && err != io.EOF {
	http.Error(w, "read file", http.StatusBadRequest)
	return
}
contentType := http.DetectContentType(buf[:n])
if contentType != "image/jpeg" && contentType != "image/png" {
	http.Error(w, "only jpeg and png are allowed", http.StatusBadRequest)
	return
}

if _, err := file.Seek(0, io.SeekStart); err != nil {
	http.Error(w, "reset file", http.StatusInternalServerError)
	return
}

这里有个细节:读完前 512 字节后,文件读取位置已经往后移动。保存前要 Seek 回开头。multipart.File 通常支持 Seek,但如果你换成其他流式来源,就要用 io.MultiReader 把读过的头部拼回去。

生成服务端文件名

不要使用用户文件名作为最终文件名。可以用随机 ID:

func randomName(ext string) (string, error) {
	var b [16]byte
	if _, err := rand.Read(b[:]); err != nil {
		return "", err
	}
	return hex.EncodeToString(b[:]) + ext, nil
}

根据检测到的类型决定扩展名:

ext := ".jpg"
if contentType == "image/png" {
	ext = ".png"
}
name, err := randomName(ext)
if err != nil {
	http.Error(w, "generate file name", http.StatusInternalServerError)
	return
}

这样用户上传 ../../../etc/passwd 也不会影响保存路径。文件名是服务端生成的,客户端名字最多用于日志或展示,而且展示前也要转义。

安全保存文件

保存目录应该由配置决定,并提前创建:

func saveUpload(dir, name string, src multipart.File) error {
	if err := os.MkdirAll(dir, 0755); err != nil {
		return err
	}

	path := filepath.Join(dir, name)
	dst, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
	if err != nil {
		return err
	}
	defer dst.Close()

	_, err = io.Copy(dst, src)
	return err
}

O_EXCL 表示如果文件已存在就失败,避免意外覆盖。虽然随机文件名冲突概率很低,但这个保护很便宜。路径拼接用 filepath.Join,不要手写 /

如果上传目录会被 Web 服务器直接访问,还要确保只保存允许的类型,不要让用户上传可执行脚本。更稳的做法是上传目录只存文件,由应用鉴权后再读取并返回。

返回结果

保存成功后可以返回 JSON:

type UploadResponse struct {
	URL string `json:"url"`
}

writeJSON(w, http.StatusCreated, UploadResponse{
	URL: "/uploads/" + name,
})

如果你使用对象存储,返回的可能是对象 key,而不是本地 URL。无论哪种方式,都不要把服务器真实路径返回给用户,比如 /var/app/uploads/xxx.png。响应里应该是业务层可理解的资源地址。

清理临时文件

ParseMultipartForm 在文件较大时可能使用临时文件。请求结束后可以清理:

defer func() {
	if r.MultipartForm != nil {
		_ = r.MultipartForm.RemoveAll()
	}
}()

这不是每个小示例都写,但实际服务里建议加上。上传接口通常容易被频繁调用,临时文件清理不能靠运气。

小结

Go 处理文件上传的基本流程是:先用 MaxBytesReader 限制请求体,再解析 multipart,读取文件字段,检查内容类型,生成服务端文件名,安全保存,最后返回业务 URL。

文件上传的关键不是把文件写到磁盘,而是守住边界。大小限制、类型校验、文件名生成、路径处理、临时文件清理都要明确。入门阶段把这些细节写顺,后面接对象存储或图片处理服务也会更自然。

继续阅读

探索更多技术文章

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

全部文章 返回首页