【文件系统】uploader实战详解实现分片上传、秒传、续传等(1)
文章目录
- 1.前端
- 1.1 uploader组件
- 1.2 测试分片上传与秒传
- 2.后端
- 2.1 test接口与正式接口
- 2.2 Service层
- 2.3 工具类介绍
- 2.4 演示
- 3.个人封装的uploader.vue
- 4.后端的MutipartFileUtil
- 5.总结与预告
了解概念:
分片:文件分多次传输,每次传输一小部分,最后再合并所有文件
秒传:先发送一个简易请求让后端判断是否存在,如果存在则直接不发送,如果不存在再进行第二次请求,第二次请求包含file流。避免重复的时候大文件在传输中浪费带宽。
续传:在分片上传有序的基础上,我们只会进行一次的检验,后端找到最新的一个分片(编号最大),返回分片编号即可,前端接收分片编号并把上传器的索引设置为分片编号+1后继续重传即可。
此文章先简单实现分片上传与普通秒传,http并发数设置为1保证按序传送分片,至于分片校验,完整文件校验留在下一篇文章。
找到的比较优秀的demo博客:
https://www.cnblogs.com/xiahj/p/vue-simple-uploader.html#file%E7%9A%84%E5%8A%A8%E6%80%81params%E7%BB%91%E5%AE%9A
1.前端
组件需要的参数和事件的介绍,去看我整理的vue-simple-uploader文档,是github找的一个轮子。
【uploader】表格化自整理vue-simple-uploader的文档(超详细)_玖等了的博客-CSDN博客
先来理清流程:
1.点击上传器,选择文件后,我们使用自动上传,即选择好就上传
2.判断+发送。
上传器把选择到的文件先进行test,也就是尝试发送,其不包含file文件流,只包含一些普通的值,会涉及组件参数里的testMethod,testChunks和checkChunkUploadedByResponse,可以稍微注意一下。也就是一个文件会发送两次请求,第一次是不包含文件的发送让后端判断文件或者分片是否存在,如果后端返回true表示文件已经存在,则不会发送文件,否则会发送第二次请求中包含文件流。
我用一个会发生错误的请求演示:
可以看到,我们只有3个分片,但是发送了6个请求,并且我们可以看到这个红色的请求是不包含file字段的
而这个发送成功的请求是包含file字段的,说明了上传器默认是先发送一个简易的请求用来判断文件是否已经存在,从而实现上传重复文件的秒传,避免重复的时候文件在网络中占用带宽。
3.分片上传的按序上传
我们前端设置了并发数为1,并且不会存在中间的分片丢失,因为上传失败的分片会重传,如果重传失败会停止,不会说跳过。
而如果设置高并发数,会出现后面的分片先到达的情况。
高并发数我们下一个文章再尝试
4.不应该对每个分片都检验
在第3点,有序的基础上,我们的检验应该是enableOnceCheck,也就是只会进行一次的检验,后端找到最新的一个分片(编号最大),返回分片编号即可,前端接收分片编号并把上传器的索引设置为分片编号+1后继续重传即可。
1.1 uploader组件
- 把uploader当做一个上传器整体,uploader-btn就是我们看见的按钮,点击按钮就可以触发上传器。
<uploader:options="this.options"@file-added="this.fileAdded"style="margin-right: 30px;float: left;"><uploader-unsupport></uploader-unsupport><uploader-btn class="uploader-btn">点击上传</uploader-btn>
</uploader>
- 其中我们绑定了options,也就是uploader的配置参数,有什么参数具体看文档。
data() {return {options: {target: "/file/uploadFile", //目标位置,后端的位置//query是额外的参数,属于query式参数,后端要用@RequestParam接收query: { curUrl: this.$store.state.file.curUrl }, //testMethod和uploadMethod的请求方法设置为不同,方便后端接口编写testMethod: "GET", //这里表示的是秒传的优先判断的请求方式uploadMethod: "POST", //这里表示的是发送文件流的请求方式//单个分片的大小,这里表示5MbchunkSize: 5 * 1024 * 1024,forceChunkSize: true, //强制每个分片都小于chunkSize,否则可能出现单个分片过大的情况testChunks: true,//是否开启服务器分片校验,开启就是可以实现秒传//一个回调函数,也就是第一次不带文件的请求的一个回调,一般后端对应接口需要返回skipUpload【true or false】//js中一个函数是可以作为值的checkChunkUploadedByResponse: function (chunk, res) {// 服务器分片校验函数,秒传及断点续传基础//需后台给出对应的查询分片的接口进行分片文件验证//这里是可以自定义的,但最好用官方的参数,如果后端判断文件存在,return一个skipUpload = true就行let objMessage = JSON.parse(res);if (objMessage.skipUpload) {return true;}//如果当前分片比已经上传的最后一个分片要大,则允许上传//如果小于等于,因为已经有序存在于服务器,为false不需要重传return chunk.offset+1 > res.position;},simultaneousUploads: 1, //最大并发数,可以保证分片到达后端是有序的},}
},
1.2 测试分片上传与秒传
- 在搞懂了上面的配置后,只要把后端写好,就可以实现分片上传和秒传,如图:
先test,如果是false说明没有完整文件,检查分片位置,从position+1开始
明确了要开始的分片数
- 然后我们再上传相同的文件,如图:
对于已经存在的文件,发送一个尝试请求,后端返回skipUpload = true的话,会子哦东跳过,不再发送文件上传请求
但是要注意,秒传只能针对完整文件,对于分片是不作用的。断点续传才是针对分片的,要注意定义。
2.后端
2.1 test接口与正式接口
区别:test我们设置为get接口,并且是非multipart文件接口;而正式接口是用来接收文件,对应上面前端设置的两个请求。
2.1.1 test接口
- 拼接url,判断完整文件是否已经存在
- 如果存在,返回一个skipUpload = true的json串给前端,跳过传输,实现秒传
- 如果不存在,返回一个skipUpload = false 与最新分片位置 position = xx 的json串给前端,开始传输
/**
* @Author Nineee
* @Date 2022/9/22 13:07
* @Description : 用于test快传 秒传 和 续传 的接口
* @param chunkNumber: 分片编号
* @param totalChunks: 总分片数
* @param totalSize: 总大小
* @param filename: 文件名
* @param curUrl: 当前位置
* @return Map
*/
@GetMapping("/uploadFile")
@ResponseBody
public Map uploadFile( @RequestParam("chunkNumber") String chunkNumber,@RequestParam("totalChunks") String totalChunks,@RequestParam("totalSize") String totalSize,@RequestParam("filename") String filename,@RequestParam("curUrl") String curUrl,HttpServletRequest request, HttpServletResponse response) {User user = (User)request.getAttribute("user");int uid = user.getUid();Map map = new HashMap();boolean isTotalFileExist = Files.exists(Paths.get(store + uid + curUrl + "\\" + filename));if(isTotalFileExist) {//存在文件,秒传文件map.put("skipUpload", true);}else {//未存在完整文件map.put("skipUpload", false);//应该检查目前到第几个分片,默认分片是有序的String[] strs = filename.split("\\.");String localUrl = store + uid + curUrl + "\\" + "tmp_"+strs[1]+"_"+strs[0]+"\\";long count = fileService.findNewShard(localUrl);map.put("position", count);}return map;
}
2.1.2 正式接口
- 拼接一个临时文件夹用来存放分片
- 如果chunkNumber还没等于totalChunks,说明还没传送完(因为我们并发为1,一定按序传送),则传入false给服务,直接IO把分片写入到临时文件夹中
- 如果chunkNumber等于totalChunks说明这是最后一个分片,在把最后一个分片也传入到临时文件后,传入true进行合并服务
/**
* @Author Nineee
* @Date 2022/8/16 23:47
* @Description : 上传文件
* @param file: 需要上传的文件
* @param request: 请求体获取当前对象
* @return void
*/
@PostMapping("/uploadFile")
@ResponseBody
public void uploadFile( @RequestParam("file") MultipartFile file,@RequestParam("chunkNumber") Integer chunkNumber,@RequestParam("totalChunks") Integer totalChunks,@RequestParam("totalSize") String totalSize,@RequestParam("filename") String filename,@RequestParam("curUrl") String curUrl,HttpServletRequest request) {User user = (User)request.getAttribute("user");int uid = user.getUid();//对于localUrl,如果不是末尾分片,我们应该加上一个tmp文件夹避免文件混乱。//只有发起合并请求的时候再合并到源路径后删除tmp文件夹。//注意,.需要转义String[] strs = filename.split("\\.");String localUrl = store + uid + curUrl + "\\" + "tmp_"+strs[1]+"_"+strs[0];//不是最后一个分片,不需要合并fileService.uploadFile(file, localUrl, ""+chunkNumber, false);if(chunkNumber == totalChunks) {//否则发起合并服务,merge合并fileService.uploadFile(file, localUrl, filename, true);}}
2.2 Service层
- merge参数表示是否要合并
- 如果不用合并,直接使用工具类的IO写入到临时文件夹curUrl
- 如果要合并,则使用工具类进行合并
- 合并完后要递归删除临时文件夹
@Override
/**
* @Author Nineee
* @Date 2022/8/16 23:07
* @Description : 上传文件
* @param file: multipart二进制文件流,也就是目标文件
* @param curUrl: 上传的目标地址
* @return Integer 1表示成功,0表示失败
*/
public Integer uploadFile(MultipartFile file, String localUrl, String filename, boolean merge) {if(!merge) {MultipartFileUtil.addFile(file, localUrl, filename);}else {MultipartFileUtil.mergeFileByRandomAccessFile(localUrl, filename);//合并后删除tmp文件夹try {MultipartFileUtil.deleteDirByNio(localUrl);} catch (Exception e) {e.printStackTrace();}}return 1;
}
/*** @Author Nineee* @Date 2022/9/22 16:41* @Description : 找到最新的分片编号* @param localUrl: 临时文件夹位置* @return Long*/
@Override
public Long findNewShard(String localUrl) {File tempDir = new File(localUrl);//如果连临时文件夹都没有,说明一定还没开始上传,不需要续传,直接0if (!tempDir.exists()) {tempDir.mkdirs();return 0L;}//应该检查目前到第几个分片,默认分片是有序的long count = 0;try {count = Files.list(Paths.get(localUrl)).count();} catch (IOException e) {e.printStackTrace();}return count;
}
2.3 工具类介绍
2.3.1 addFile直接IO写入分片
- 因为设置的一个分片为5M,比较小,所以使用传统BIO也不会很影响性能,比较简单,后续可以改为NIO
/*** @Author Nineee* @Date 2022/8/16 23:32* @Description : 把multipartFile流文件写入到本地url中* @param file: 文件流* @param url: 本地路径* @return void*/
public static void addFile(MultipartFile file, String url, String filename){OutputStream outputStream = null;InputStream inputStream = null;try {inputStream = file.getInputStream();//log.info("fileName="+fileName);} catch (IOException e) {e.printStackTrace();}try {// 2、保存到临时文件// 1K的数据缓冲byte[] bs = new byte[1024];// 读取到的数据长度int len;// 输出的文件流保存到本地文件File tempFile = new File(url);if (!tempFile.exists()) {tempFile.mkdirs();}//跨平台写法,windows和linux都适用outputStream = new FileOutputStream(tempFile.getPath()+"\\"+filename);// 开始读取和写入oswhile ((len = inputStream.read(bs)) != -1) {outputStream.write(bs, 0, len);}} catch (IOException e) {e.printStackTrace();} catch (Exception e) {e.printStackTrace();} finally {// 完毕,关闭所有链接try {outputStream.close();inputStream.close();} catch (IOException e) {e.printStackTrace();}}
}
2.3.2 mergeFileByRandomAccessFile合并文件
- RandomAccessFile是NIO的一个随机访问IO的工具,速度要比流式IO的FileChannel快一点点
- 随机访问即维护两个指针,一个头指针,一个limit指针,把一个File数组传入循环写入到单个输出url即可
/*** @Author Nineee* @Date 2022/9/22 13:53* @Description : 通过随机访问IO即RandomAccessFile合并某文件夹里面的所有文件为一个文件* @param fromUrl: 文件夹里装有所有的分片* @param filename: 新的文件名* @return void*/public static void mergeFileByRandomAccessFile(String fromUrl, String filename) {String toUrl = fromUrl.substring(0, fromUrl.lastIndexOf("\\")+1)+"\\"+filename;RandomAccessFile in = null;RandomAccessFile out = null;System.out.println(fromUrl);File[] files = new File(fromUrl).listFiles();//必须保证有序,名字根据编号排。避免默认的字典序,即1后面是10编号而不是2Arrays.sort(files, (o1,o2) -> Integer.parseInt(o1.getName())-Integer.parseInt(o2.getName()));try {out = new RandomAccessFile(toUrl, "rw");for (File file : files) {in = new RandomAccessFile(file, "r");int len = 0;byte[] bt = new byte[BUF_SIZE];while (-1 != (len = in.read(bt))) {out.write(bt, 0, len);}in.close();}} catch (Exception e) {e.printStackTrace();} finally {try {if (in != null) {in.close();}if (out != null) {out.close();}} catch (IOException e) {e.printStackTrace();}}}
2.3.3 deleteDirByNio删除文件夹
- 如果直接使用Files的delete,可能会出现文件不存在或者文件夹不为空的异常,因此需要递归删除
- Files类中的walkFileTree方法可以获取到文件树,方便递归访问,文件树会遵循先文件后文件夹的访问顺序
/*** @Author Nineee* @Date 2022/9/22 14:29* @Description : 递归删除一个含有文件或者子文件夹的文件夹* @param url: 文件夹地址* @return void*/
public static void deleteDirByNio(String url) throws Exception {Path path = Paths.get(url);Files.walkFileTree(path,new SimpleFileVisitor<Path>() {// 先去遍历删除文件@Overridepublic FileVisitResult visitFile(Path file,BasicFileAttributes attrs) throws IOException {Files.delete(file);return FileVisitResult.CONTINUE;}// 再去遍历删除目录@Overridepublic FileVisitResult postVisitDirectory(Path dir,IOException exc) throws IOException {Files.delete(dir);return FileVisitResult.CONTINUE;}});
}
2.4 演示
2.4.1 前端选择文件夹
2.4.2 临时文件夹
2.4.3 文件分片按序存储
2.4.4 合并与自动删除
3.个人封装的uploader.vue
已实现:分片,秒传
<template><div><uploader:options="this.options"@file-added="this.fileAdded"style="margin-right: 30px;float: left;"><uploader-unsupport></uploader-unsupport><uploader-btn class="uploader-btn">点击上传</uploader-btn></uploader></div>
</template><script>export default {data() {return {options: {target: "/file/uploadFile",query: { curUrl: this.$store.state.file.curUrl },testMethod: "GET", //这里表示的是秒传的优先判断的请求方式uploadMethod: "POST", //这里表示的是发送文件流的请求方式method: "multipart",fileParameterName: "file",chunkSize: 5 * 1024 * 1024,forceChunkSize: true, //强制每个分片都小于chunkSizesimultaneousUploads: 1, //最大并发数testChunks: true,//是否开启服务器分片校验checkChunkUploadedByResponse: function (chunk, res) {// 服务器分片校验函数,秒传及断点续传基础//需后台给出对应的查询分片的接口进行分片文件验证let objMessage = JSON.parse(res);//skipUpload、uploaded 需自己跟后台商量好参数名称if (objMessage.skipUpload) {return true;}return (objMessage.uploaded || []).indexOf(chunk.offset + 1) >= 0},maxChunkRetries: 2, //最大自动失败重试上传次数},}},methods:{}}
</script><style scoped></style>
4.后端的MutipartFileUtil
package com.nw.nwcloud.util;import org.springframework.web.multipart.MultipartFile;import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;public class MultipartFileUtil {public static final int BUF_SIZE = 1024 * 1024;/*** @Author Nineee* @Date 2022/8/16 23:32* @Description : 把multipartFile流文件写入到本地url中* @param file: 文件流* @param url: 本地路径* @return void*/public static void addFile(MultipartFile file, String url, String filename){OutputStream outputStream = null;InputStream inputStream = null;try {inputStream = file.getInputStream();//log.info("fileName="+fileName);} catch (IOException e) {e.printStackTrace();}try {// 2、保存到临时文件// 1K的数据缓冲byte[] bs = new byte[1024];// 读取到的数据长度int len;// 输出的文件流保存到本地文件File tempFile = new File(url);if (!tempFile.exists()) {tempFile.mkdirs();}//跨平台写法,windows和linux都适用outputStream = new FileOutputStream(tempFile.getPath()+"\\"+filename);// 开始读取和写入oswhile ((len = inputStream.read(bs)) != -1) {outputStream.write(bs, 0, len);}} catch (IOException e) {e.printStackTrace();} catch (Exception e) {e.printStackTrace();} finally {// 完毕,关闭所有链接try {outputStream.close();inputStream.close();} catch (IOException e) {e.printStackTrace();}}}/*** @Author Nineee* @Date 2022/9/22 13:53* @Description : 通过随机访问IO即RandomAccessFile合并某文件夹里面的所有文件为一个文件* @param fromUrl: 文件夹里装有所有的分片* @param filename: 新的文件名* @return void*/public static void mergeFileByRandomAccessFile(String fromUrl, String filename) {String toUrl = fromUrl.substring(0, fromUrl.lastIndexOf("\\")+1)+"\\"+filename;RandomAccessFile in = null;RandomAccessFile out = null;System.out.println(fromUrl);File[] files = new File(fromUrl).listFiles();//必须保证有序,名字根据编号排。避免默认的字典序,即1后面是10编号而不是2Arrays.sort(files, (o1,o2) -> Integer.parseInt(o1.getName())-Integer.parseInt(o2.getName()));for(File f : files) {System.out.println(f.getPath());}try {out = new RandomAccessFile(toUrl, "rw");for (File file : files) {in = new RandomAccessFile(file, "r");int len = 0;byte[] bt = new byte[BUF_SIZE];while (-1 != (len = in.read(bt))) {out.write(bt, 0, len);}in.close();}} catch (Exception e) {e.printStackTrace();} finally {try {if (in != null) {in.close();}if (out != null) {out.close();}} catch (IOException e) {e.printStackTrace();}}}/*** @Author Nineee* @Date 2022/9/22 13:56* @Description : 通过流式NIO即FileChannel合并某文件夹里面的所有文件为一个文件* @param fromUrl: 文件夹里装有所有的分片* @param filename: 新的文件名* @return void*/public static void mergeFileByFileChannel(String fromUrl, String filename) {String toUrl = fromUrl.substring(0, fromUrl.lastIndexOf("\\")+1)+"\\"+filename;FileChannel outChannel = null;FileChannel inChannel = null;File[] files = new File(fromUrl).listFiles();try {outChannel = new FileOutputStream(toUrl).getChannel();for (File file : files) {inChannel = new FileInputStream(file).getChannel();ByteBuffer bb = ByteBuffer.allocate(BUF_SIZE);while (inChannel.read(bb) != -1) {bb.flip();outChannel.write(bb);bb.clear();}inChannel.close();}} catch (IOException e) {e.printStackTrace();} finally {try {if (outChannel != null) {outChannel.close();}if (inChannel != null) {inChannel.close();}} catch (IOException e) {e.printStackTrace();}}}/*** @Author Nineee* @Date 2022/9/22 14:29* @Description : 递归删除一个含有文件或者子文件夹的文件夹* @param url: 文件夹地址* @return void*/public static void deleteDirByNio(String url) throws Exception {Path path = Paths.get(url);Files.walkFileTree(path,new SimpleFileVisitor<Path>() {// 先去遍历删除文件@Overridepublic FileVisitResult visitFile(Path file,BasicFileAttributes attrs) throws IOException {Files.delete(file);return FileVisitResult.CONTINUE;}// 再去遍历删除目录@Overridepublic FileVisitResult postVisitDirectory(Path dir,IOException exc) throws IOException {Files.delete(dir);return FileVisitResult.CONTINUE;}});}
}
5.总结与预告
已实现:
- 分片上传
- 建立临时文件夹存放分片
- 基于并发数为1实现分片按序,遇到最后一个分片即合并标记
- 通过RandomAccessFile实现了分片文件按序合并,因为分片文件是按编号命名,编号有序
- 合并后实现递归删除分片文件夹
- 前端通过test请求与后端对应接口实现了秒传完整文件
- 实现了单次test请求获取当前文件分片位置,实现续传
未实现:
前端并发上传
分片校验
完整文件校验
【文件系统】uploader实战详解实现分片上传、秒传、续传等(1)相关推荐
- 《oracle大型数据库系统在AIX/unix上的实战详解》讨论31: oracle、sybase 数据库的不同访问...
<Oracle大型数据库系统在AIX/UNIX上的实战详解> 讨论31: oracle.sybase 数据库的不同访问方式 文平. 用户来信要求更细节比较一下Oracle和sybas ...
- Oracle大型数据库系统在AIX/UNIX上的实战详解
前言 风,紧, 夜,深沉, 剑,已出鞘, 影,飘然前行! 本书的立意和内容 在服务器领域,IBM p系列服务器与AIX操作系统毫无疑问是UNIX服务器领域中的佼佼者,它代表着UNIX深刻的技术内涵和广 ...
- 【华为云计算产品系列】云上迁移工具RainBow实战详解
[华为云计算产品系列]云上迁移工具RainBow实战详解 1. 迁移方案 2.迁移流程 3. 迁移实验 3.1. Windows系统迁移 3.2. Linux系统迁移 3.3. 存储层迁移 1. 迁移 ...
- 《Java和Android开发实战详解》——1.2节Java基础知识
本节书摘来自异步社区<Java和Android开发实战详解>一书中的第1章,第1.2节Java基础知识,作者 陈会安,更多章节内容可以访问云栖社区"异步社区"公众号查看 ...
- 《数据修复技术与典型实例实战详解》——1.4 分区表的修复
本节书摘来自异步社区<数据修复技术与典型实例实战详解>一书中的第1章,第1.4节,作者:叶润华著,更多章节内容可以访问云栖社区"异步社区"公众号查看 1.4 分区表的修 ...
- libraries 和android runtime之间的关系,《Android Studio应用开发实战详解》——第1章,第1.3节Android系统架构...
本节书摘来自异步社区<Android Studio应用开发实战详解>一书中的第1章,第1.3节Android系统架构,作者 王翠萍,更多章节内容可以访问云栖社区"异步社区&quo ...
- 《Android Studio应用开发实战详解》——第1章,第1.4节Android和Linux的关系
本节书摘来自异步社区<Android Studio应用开发实战详解>一书中的第1章,第1.4节Android和Linux的关系,作者 王翠萍,更多章节内容可以访问云栖社区"异步社 ...
- 《Unity 4 3D开发实战详解》一6.7 物理引擎综合案例
本节书摘来异步社区<Unity 4 3D开发实战详解>一书中的第6章,第6.7节,作者: 吴亚峰 , 杜化美 , 张月霞 , 索依娜 责编: 张涛,更多章节内容可以访问云栖社区" ...
- 《Java和Android开发实战详解》——2.5节良好的Java程序代码编写风格
本节书摘来自异步社区<Java和Android开发实战详解>一书中的第2章,第2.5节良好的Java程序代码编写风格,作者 陈会安,更多章节内容可以访问云栖社区"异步社区&quo ...
最新文章
- linux epoll模型
- jzoj6297-世界第一的猛汉王【切比雪夫距离,扫描线】
- 【渝粤题库】陕西师范大学202041 国际经济学 作业(专升本)
- Java学习之IDEA2020安装
- 思科服务器备份文件失败,思科路由器tftp备份、还原 IOS升级的方法
- 18-elasticsearch集群健康为黄色
- 《C++ Primer Plus》读书笔记之七—内存模型和名称空间
- 计算机网络---ICMP、IGMP协议
- 【ARM-Linux开发】linux下Eclipse进行C编程时动态链接库的生成和使用
- 高通WIFI模块QCA9377 调试
- 学习总结:Handler机制
- TF标准模型TensorFlow Mobile for Android
- SAP CO88 生产订单实际成本计算
- 碗中有米,心中有他,他解决的不只是吃饭问题......
- laravel学习笔记------使用 Entrust 扩展包在 Laravel 5 中实现 RBAC 权限管理
- 测试用例设计——微信发朋友圈(详细)
- 独家可用发卡小程序源码下载卡密系统
- phpunit学习第一章
- Java项目:jsp+servlet图书管理系统
- Python全栈(五)Web安全攻防之2.信息收集和sqlmap介绍
热门文章
- Qt QTableWidget表格控件的用法(非常详细)
- openvino CvCapture_MSMF::initStream Failed to set mediaType (unsupported media type)
- arc242||C - 1111gal password(希望下次能带脑子写题...)
- 博客文章内容导航(实时更新)
- Docker的镜像制作与整套项目一键打包部署
- EFR32--如何在EFR32程序中修改UUID
- 经典进程同步问题(十)
- SAP ABAP——SAP简介(四)【SAP GUI】
- python量化交易书籍推荐知乎_GitHub - XingkaiLiang/vnpy: 基于python的开源量化交易平台开发框架...
- stm32f103利用HC06进行蓝牙通信,在7针的OLED屏幕上显示,带数据更新功能(带超详细讲解)