文件上传看起来只是接收一个 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。
文件上传的关键不是把文件写到磁盘,而是守住边界。大小限制、类型校验、文件名生成、路径处理、临时文件清理都要明确。入门阶段把这些细节写顺,后面接对象存储或图片处理服务也会更自然。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。