web服务端接收多用户并发上传同一文件,保证文件副本只存在一份(附go语言实现)

背景

对于一个文件服务器来说,对于同一文件,应该只保存一份在服务器上。基于这个原则,引发出本篇内容。

本篇仅阐述文件服务器在同一时间接收同一文件的并发问题,这种对于小体量的服务来说并不常见,但是最好还是要留意一下这种极端情况。

实现原理

常见的流程:数据库记录文件的基本属性:文件名、大小、哈希值、文件路径等,以哈希值作为唯一标志。当用户新上传文件时,先查询数据库,若已存在哈希值(客户端计算并传给服务端,客户端最常见的 spark-md5)相同的记录,则不保存文件,直接标记为上传成功,使用已存在的文件副本,即通常所说的秒传实现。

上述流程缺失的就是当数据库中不存在的文件,同一时间上传了多个相同文件时,如果不做处理,服务器上是会存在多个该文件副本。所以当一个用户上传文件时,可以将文件标记为锁定状态,其他用户若上传同一文件,需查看文件的锁定状态,待锁定解除后才能进行操作。

请添加图片描述

代码实现

文字表现力有点差,还是上代码吧!本例中的服务端使用的是 gogin 框架,仅简单模拟了同一文件并发上传的情况。

文件目录结构

	- go.mod- go.sum- hash_cache.go- main.go- spark-md5-min.js- upload.html

js 客户端

upload.html 选择文件后,可重复点击上传按钮测试并发,或者自己改下脚本。

在这里插入图片描述

<!DOCTYPE html>
<html>
<head><title>文件上传</title><script src="spark-md5.min.js"></script>
</head>
<body>
<h1>文件上传</h1>
<input id="file" type="file" name="file"/>
<button onclick="upload();">上传</button><script>var file_md5 = {};function upload() {if (!file_md5.md5) {alert("请先选择文件");return}var form = new FormData();form.append("md5", file_md5.md5);form.append("file", file_md5.file);var xhr = new XMLHttpRequest();var action = "/upload"; // 上传服务的接口地址xhr.open("POST", action);xhr.send(form); // 发送表单数据xhr.onreadystatechange = function () {if (xhr.readyState == 4 && xhr.status == 200) {var resultObj = JSON.parse(xhr.responseText);// 处理返回的数据......console.log(resultObj)}}}document.getElementById('file').addEventListener('change', function (event) {var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,file = this.files[0],chunkSize = 2097152,                             // Read in chunks of 2MBchunks = Math.ceil(file.size / chunkSize),currentChunk = 0,spark = new SparkMD5.ArrayBuffer(),fileReader = new FileReader();fileReader.onload = function (e) {console.log('read chunk nr', currentChunk + 1, 'of', chunks);spark.append(e.target.result);                   // Append array buffercurrentChunk++;if (currentChunk < chunks) {loadNext();} else {console.log('finished loading');var md5=spark.end()console.info('computed hash', md5);  // Compute hashfile_md5 = {file:file,md5:md5}}};fileReader.onerror = function () {console.warn('oops, something went wrong.');};function loadNext() {var start = currentChunk * chunkSize,end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));}loadNext();});
</script>
</body>
</html>

请添加图片描述

go gin 服务端

main.go4780 为端口开放了一个 http 服务,可访问 http://127.0.0.1:4780/client/upload.html 来访问 html 页面。

为了模拟并发场景,服务端 /upload 接口中故意让其睡眠了 30s

package mainimport ("crypto/md5""embed""encoding/hex""errors""fmt""github.com/gin-gonic/gin""io""net/http""os""path/filepath""runtime""time"
)//go:embed upload.html spark-md5.min.js
var client embed.FSvar hashCache = NewHashCache()func main() {engine := gin.New()engine.StaticFS("/client", http.FS(client))engine.POST("/upload", doUpload)engine.Run(":4780")
}func doUpload(c *gin.Context) {printMem("start")clientMd5 := c.PostForm("md5")// 查询是否有其他正在上传,若有,则等待其上传完毕,根据返回值来做判断if hashCache.Has(clientMd5) {info, er := hashCache.Wait(clientMd5)if er != nil {c.String(http.StatusInternalServerError, er.Error())return}if info.Err == nil {c.String(http.StatusOK, "上传成功: "+info.SavedPath)return}// 若是出错了,则继续接收}hashCache.Set(clientMd5)// 模拟并发,这里睡一下time.Sleep(time.Second * 30)savedPath, err := doSaveFile(c, clientMd5)if err != nil {hashCache.SetDone(clientMd5, "", err)c.String(http.StatusInternalServerError, err.Error())return}hashCache.SetDone(clientMd5, savedPath, nil)c.String(http.StatusOK, "上传成功: "+savedPath)
} func doSaveFile(c *gin.Context, clientMd5 string) (savedPath string, err error) {fh, err := c.FormFile("file")if err != nil {return}fn := fmt.Sprintf("%s_%d", fh.Filename, time.Now().UnixMilli())savedPath = filepath.Join("uploaded", fn)err = c.SaveUploadedFile(fh, savedPath)if err != nil {return}md5Str, err := getFileMd5(savedPath)if err != nil {return}if clientMd5 != md5Str {os.Remove(savedPath)err = errors.New("哈希不匹配")return}return
}func getFileMd5(p string) (md5Str string, err error) {f, err := os.Open(p)if err != nil {return}defer f.Close()h := md5.New()_, err = io.Copy(h, f)if err != nil {return}md5Str = hex.EncodeToString(h.Sum(nil))return
}func printMem(prefix string) {var m runtime.MemStatsruntime.ReadMemStats(&m)fmt.Printf("%s: %d Kb\n", prefix, m.Alloc/1024)
}

hash_cache.go 维护了一个 map 用于判断是否有相同的文件上传。

package mainimport ("errors""sync"
)type HashCache struct {mutex sync.RWMutexm     map[string]*HashCacheInfo
}func NewHashCache() *HashCache {return &HashCache{m: make(map[string]*HashCacheInfo),}
}type HashCacheInfo struct {Done      chan struct{}SavedPath stringErr       error
}func (this *HashCache) Set(md5Hash string) {this.mutex.Lock()defer this.mutex.Unlock()this.m[md5Hash] = &HashCacheInfo{Done: make(chan struct{}),}
}func (this *HashCache) SetDone(md5Hash, savedPath string, err error) error {this.mutex.Lock()defer this.mutex.Unlock()data, ok := this.m[md5Hash]if !ok {return errors.New("no hash: " + md5Hash)}data.SavedPath = savedPathdata.Err = errclose(data.Done)delete(this.m, md5Hash)//这里的 data 不能直接释放,wait 那里需要用,垃圾收集器自己去回收吧return nil
}func (this *HashCache) Has(md5Hash string) bool {this.mutex.RLock()defer this.mutex.RUnlock()_, has := this.m[md5Hash]return has
}func (this *HashCache) Wait(md5Hash string) (info HashCacheInfo, err error) {this.mutex.RLock()data, ok := this.m[md5Hash]if !ok {this.mutex.RUnlock()err = errors.New("no hash: " + md5Hash)return}this.mutex.RUnlock()<-data.Doneinfo = *datareturn
} 维护了一个 map 用于

服务端日志输出,可见每多提交一次请求,内存占用就会增加。
请添加图片描述

在此服务中,若是同一时间上传了大量相同的文件,会导致内存占用飙升(c.PostForm 解析 formdata 数据时,会将数据读入内存)。如果要解决该问题,需要自己去做数据的读取,如下:

doUpload1 方法的更改

package mainimport ("crypto/md5""embed""encoding/hex""errors""fmt""github.com/gin-gonic/gin""io""mime/multipart""net/http""os""path/filepath""runtime""time"
)//go:embed upload.html spark-md5.min.js
var client embed.FSvar hashCache = NewHashCache()func main() {engine := gin.New()engine.StaticFS("/client", http.FS(client))engine.POST("/upload", doUpload1)engine.Run(":4780")
}func doUpload(c *gin.Context) {printMem("start")clientMd5 := c.PostForm("md5")// 查询是否有其他正在上传,若有,则等待其上传完毕,根据返回值来做判断if hashCache.Has(clientMd5) {info, er := hashCache.Wait(clientMd5)if er != nil {c.String(http.StatusInternalServerError, er.Error())return}if info.Err == nil {c.String(http.StatusOK, "上传成功: "+info.SavedPath)return}// 若是出错了,则继续接收}hashCache.Set(clientMd5)// 模拟并发,这里睡一下//time.Sleep(time.Second * 30)savedPath, err := doSaveFile(c, clientMd5)if err != nil {hashCache.SetDone(clientMd5, "", err)c.String(http.StatusInternalServerError, err.Error())return}hashCache.SetDone(clientMd5, savedPath, nil)c.String(http.StatusOK, "上传成功: "+savedPath)
}func doSaveFile(c *gin.Context, clientMd5 string) (savedPath string, err error) {fh, err := c.FormFile("file")if err != nil {return}fn := fmt.Sprintf("%s_%d", fh.Filename, time.Now().UnixMilli())savedPath = filepath.Join("uploaded", fn)err = c.SaveUploadedFile(fh, savedPath)if err != nil {return}md5Str, err := getFileMd5(savedPath)if err != nil {return}if clientMd5 != md5Str {os.Remove(savedPath)err = errors.New("哈希不匹配")return}return
}func getFileMd5(p string) (md5Str string, err error) {f, err := os.Open(p)if err != nil {return}defer f.Close()h := md5.New()_, err = io.Copy(h, f)if err != nil {return}md5Str = hex.EncodeToString(h.Sum(nil))return
}func printMem(prefix string) {var m runtime.MemStatsruntime.ReadMemStats(&m)fmt.Printf("%s: %d Kb\n", prefix, m.Alloc/1024)
}func doUpload1(c *gin.Context) {printMem("start")reader, err := c.Request.MultipartReader()if err != nil {c.String(http.StatusBadRequest, err.Error())return}clientMd5, err := readMd5(reader) // 读 md5if err != nil {c.String(http.StatusBadRequest, err.Error())return}// 查询是否有其他正在上传,若有,则等待其上传完毕,根据返回值来做判断if hashCache.Has(clientMd5) {info, er := hashCache.Wait(clientMd5)if er != nil {c.String(http.StatusInternalServerError, er.Error())return}if info.Err == nil {er = closeReaderParts(reader)if er != nil {c.String(http.StatusInternalServerError, er.Error())} else {c.String(http.StatusOK, "上传成功: "+info.SavedPath)}return}}hashCache.Set(clientMd5)// 模拟并发,这里睡一下time.Sleep(time.Second * 30)savedPath, err := saveFilePart(reader, clientMd5)hashCache.SetDone(clientMd5, savedPath, err)if err != nil {c.String(http.StatusInternalServerError, err.Error())return}c.String(http.StatusOK, "上传成功: "+savedPath)
}func readMd5(reader *multipart.Reader) (md5Hash string, err error) {part, err := reader.NextPart() // 读 md5if err != nil {return}name := part.FormName()if name != "md5" {err = errors.New("first key is not match")return}buf, err := io.ReadAll(part)if err != nil {return}md5Hash = string(buf)return
}func closeReaderParts(reader *multipart.Reader) (err error) {for {p, er := reader.NextPart()if er == io.EOF {break}if er != nil {err = erreturn}p.Close()}return
}func saveFilePart(reader *multipart.Reader, clientMd5 string) (fp string, err error) {part, err := reader.NextPart() // 读 fileif err != nil {return}name := part.FormName()if name != "file" {err = errors.New("key not match")return}fn := fmt.Sprintf("%s_%d", part.FileName(), time.Now().UnixMilli())fp = filepath.Join("uploaded", fn)f, err := os.Create(fp)if err != nil {return}defer f.Close()_, err = io.Copy(f, part)if err != nil {return}md5Str, err := getFileMd5(fp)if err != nil {return}if clientMd5 != md5Str {os.Remove(fp)err = errors.New("哈希不匹配")return}returnreturn
}

服务端日志输出,可见内存已不像之前消耗的多了。
请添加图片描述

这里需要注意:客户端在对 formdata 中添加数据时,需要将 md5 放在第一位,不然逻辑会出错。还有一点就是服务端若是不将请求的 body 数据读完(closeReaderParts 就是做这个的),直接将 api 返回,也会导致 js 客户端请求出错(目前只是在上传文件比较大时碰到过,但是我用 go 的客户端测试是不会有问题的,应该是浏览器实现原因,有知晓的小伙伴可以评论留言)。

其他的实现方式,也可以将 md5 放在请求 url 中(http://127.0.0.1:4780/client/upload?md5=xxx),然后做匹配(这里也像上述一样,如果请求的 body 不读完,客户端会报错)。

总结

本篇只是给个思路,抛砖引玉,介绍了如何实现客户端和服务器端的并发上传控制。通过示例代码,能够确保在并发上传时服务器中只存在一份文件副本。

在实际的生产环境中,可能需要进一步优化和增强这些代码,以满足性能、安全性和可靠性方面的需求。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/14381.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

文本分析-使用Python做词频统计分析

&#x1f935;‍♂️ 个人主页&#xff1a;艾派森的个人主页 ✍&#x1f3fb;作者简介&#xff1a;Python学习者 &#x1f40b; 希望大家多多支持&#xff0c;我们一起进步&#xff01;&#x1f604; 如果文章对你有帮助的话&#xff0c; 欢迎评论 &#x1f4ac;点赞&#x1f4…

【滑动窗口】209. 长度最小的子数组

209. 长度最小的子数组 解题思路 滑动窗口设置前后指针滑动窗口内的元素之和总是大于或者等于s滑动窗口的起始位置: 如果窗口的值大于等于s 窗口向前移动窗口结束位置:for循环的j class Solution {public int minSubArrayLen(int target, int[] nums) {int left 0;// 滑动窗口…

学习系统编程No.28【多线程概念实战】

引言&#xff1a; 北京时间&#xff1a;2023/6/29/15:33&#xff0c;刚刚更新完博客&#xff0c;目前没什么状态&#xff0c;不好趁热打铁&#xff0c;需要去睡一会会&#xff0c;昨天睡的有点迟&#xff0c;然后忘记把7点到8点30之间的4个闹钟关掉了&#xff0c;恶心了我自己…

使用 Rust 实现连接远程 Linux 服务器、发送文件、执行命令

使用 Rust 实现连接远程 Linux 服务器、发送文件、执行命令 文章目录 使用 Rust 实现连接远程 Linux 服务器、发送文件、执行命令一、Rust 概述使用场景优点缺点 二、功能实现1、代码2、运行日志3、服务器文件 一、Rust 概述 Rust 已经听了无数遍&#xff0c;我很清楚它很强&am…

LVS-DR集群

目录 一、构建LVS-DR集群的步骤 实验环境准备&#xff1a; 1、配置负载调度器&#xff08;192.168.40.200&#xff09; 1.1 配置虚拟 IP 地址&#xff08;VIP&#xff1a;192.168.40.190&#xff09; 1.2 调整 proc 响应参数 1.3 配置负载分配策略 2. 部署共享存储&#xf…

Elasticsearch 基本使用(五)查询条件匹配方式(query query_string)

查询条件匹配方式 概述querytermtermsrangematch_allmatchmatch 匹配精度问题 match_phrasematch_pharse_prefixmatch_bool_prefixmulti_match query_string简单查询一个字段在多个字段上应用同一个条件 &#xff08;类似multi_match&#xff09;在所有字段上应用同一个条件 &a…

html掉落本地图片效果

实现一个加载本地图片并掉落的html页面。 说明 将DuanWu.html与zongzi_1.png, zongzi_2.png, zongzi_3.png, yadan.png4张图片放在同一个目录下&#xff0c;然后双击打开DuanWu.html即可。 使用Chrome或Microsoft Edge浏览器打开 若使用IE浏览器打开&#xff0c;下方会出现In…

什么是敏捷测试?

目录 前言&#xff1a; 敏捷测试的定义 敏捷测试的特点 为什么要敏捷测试 缩短价值交付周期 强调质量属于大家 化繁为简节省成本 敏捷测试VS. 传统测试 传统测试如何迁移到敏捷测试 1. 组织文化的转变 2. 组织架构的调整 3. 人员培训与指导 4. 轻流程 敏捷测试成…

一文了解潜力黑马Infiblue:借力Web3,释放元宇宙价值

2013 年&#xff0c;JDN Dionisio 曾发表了一篇名为《3D Virtual Worlds and the Metaverse: Current Status and Future Possibilities》的论文&#xff0c;深入探讨与归纳了虚拟世界的几个发展阶段&#xff0c;可以简单的归纳为&#xff1a; 第一阶段&#xff1a;基于计算机…

Blender基础入门(2):Blender简单渲染

文章目录 我个人的Blender专栏前言渲染基本常识科普Blender渲染设置Blender窗口分栏分屏渲染 渲染设置GPU渲染引擎推荐最大采样 切换摄像机渲染图片渲染采样512和4096差异512采样4096采样 渲染建议 我个人的Blender专栏 Blender简单教学 前言 渲染是从白模到成品的过程&…

CEC2018动态多目标优化算法:基于自适应启动策略的混合交叉动态约束多目标优化算法(MC-DCMOEA)求解CEC2018

一、动态多目标优化问题 1.1问题定义 1.2 动态支配关系定义 二、 基于自适应启动策略的混合交叉动态多目标优化算法 基于自适应启动策略的混合交叉动态多目标优化算法&#xff08;Mixture Crossover Dynamic Constrained Multi-objective Evolutionary Algorithm Based on Se…

药物 3D 打印新突破:圣地亚哥大学用机器学习筛选喷墨打印生物墨水,准确率高达 97.22%

内容一览&#xff1a;药物喷墨打印是一种高度灵活和智能化的制药方式。据相关报告统计&#xff0c;该领域市场规模将在不久的未来呈现指数级增长。过往&#xff0c;筛选合适生物墨水的方法费时且费力&#xff0c;因此也成为药物喷墨打印领域面临的主要挑战之一。为解决这一问题…