ctx.FormFile返回的是*multipart.FileHeader,仅含元信息不含内容,故直接算MD5为空或错误;必须调用header.Open()获取io.ReadCloser流,再用io.TeeReader边读边算MD5并保存,避免重复读和OOM。

为什么 ctx.FormFile 读取后 MD5 总是空或错误
因为 ctx.FormFile 返回的是一个 *multipart.FileHeader,它只包含文件元信息(如名称、大小、头),不包含实际文件内容流。直接对 FileHeader 计算 MD5 毫无意义,结果往往是空或固定值。
- 必须调用
header.Open()获取io.ReadCloser,才能读取真实字节 -
Open()返回的 reader 只能读一次,后续再读会返回 EOF —— 这是常见踩坑点 - 如果同时要保存文件又计算 MD5,别用两次
io.Copy,应先完整读入内存或用io.TeeReader边读边算
用 io.TeeReader 一边保存一边算 MD5
这是最实用的方案:避免重复读、不爆内存、逻辑清晰。核心是把文件流“分叉”——一份进哈希器,一份进目标文件。
file, err := ctx.FormFile("file")
if err != nil {
ctx.AbortWithStatusJSON(400, gin.H{"error": "no file"})
return
}
src, err := file.Open()
if err != nil {
ctx.AbortWithStatusJSON(500, gin.H{"error": "open failed"})
return
}
defer src.Close()
dst, err := os.Create("/tmp/uploaded_" + file.Filename)
if err != nil {
ctx.AbortWithStatusJSON(500, gin.H{"error": "create dst failed"})
return
}
defer dst.Close()
hash := md5.New()
tee := io.TeeReader(src, hash)
if _, err = io.Copy(dst, tee); err != nil {
ctx.AbortWithStatusJSON(500, gin.H{"error": "copy failed"})
return
}
md5Sum := fmt.Sprintf("%x", hash.Sum(nil))
ctx.JSON(200, gin.H{"md5": md5Sum})
只算 MD5 不保存时,别用 io.ReadAll 加载大文件
用户上传几百 MB 文件时,io.ReadAll 会把全部内容加载进内存,极易 OOM。应始终用流式处理。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 小文件(data, _ := io.ReadAll(src) →
md5.Sum(data) - 大文件必须用
hash.Hash的io.Copy或hash.Write分块写入 - 注意:
src是io.ReadCloser,务必在计算完成后Close(),否则可能泄漏文件句柄
Gin 中 multipart 表单字段名和文件名的区别
前端 <input type="file" name="avatar">,服务端必须用 ctx.FormFile("avatar"),不是 "file" 或其他名字 —— 名字不匹配会导致 FormFile 返回 nil 和 http.ErrMissingFile 错误。
立即学习“go语言免费学习笔记(深入)”;
- 文件原始名来自
file.Filename,但不可信,需清洗(如去掉路径、校验扩展名) - MD5 唯一性只针对文件内容,与文件名无关;同内容不同名,MD5 相同
- 若需支持多文件,用
ctx.MultipartForm()获取*multipart.Form,再遍历form.File["files"]
MD5 计算本身很简单,难的是和 Gin 的文件生命周期、IO 流管理咬合好——漏关 reader、重复读、字段名错配,这三类问题占了 90% 的调试时间。

















