在实际的业务场景中,很多时候都需要对App进行更新,最近就遇到这样的一个需求,在App端有个检查更新的功能,如果发现当前App的版本低于服务器指定目录的App版本,那么App端就发起相应的请求从服务器下载版本较高的apk安装包,同样的服务器怎么管理不同版本的apk安装包,从而方便App端获取指定版本的App呢?这就是这篇文章要分享的内容了。
我将后端需要做的工作拆分成如下流程:
1、每上传一次文件(可以是不同类型的文件、视频、图片等),都将文件基本信息(文件类型(唯一)、版本号等)保存到数据库记录
2、上传文件时从数据库判断该类型的文件是否存在,如果不存在,就插入到数据库,并将文件保存到服务器指定磁盘
3、如果数据库已经存在文件,对应的version-code版本号加一,将上传的文件储存到指定目录,已存在的文件重新命名带上版本号成为历史版本。
前期准备
文件信息类Sys_file
public class Sys_file : Sys_BaseEntity
{
/// <summary>
/// 文件类型
/// </summary>
public string Type { get; set; }
/// <summary>
/// 每次迭代一个版本就加1
/// </summary>
public int VersionCode { get; set; }
/// <summary>
/// 文件版本 从1.0.0开始,每次更新可以改为1.0.1、1.1.0、2.0.0等等
/// </summary>
public string VersionName { get; set; }
/// <summary>
/// 文件名
/// </summary>
public string Name { get; set; }
/// <summary>
/// 文件扩展名
/// </summary>
public string Extension { get; set; }
/// <summary>
/// 文件存放路径
/// </summary>
public string StorePath { get; set; }
/// <summary>
/// 文件大小
/// </summary>
public string Size { get; set; }
/// <summary>
/// 版本描述
/// </summary>
public string Direction { get; set; }
}
考虑到实际项目中,可能涉及到多种不同类型的App程序,比如用于工业公司内部为了实现仓库和车间之间物料的自动化配送的AGV物料配送程序,或者是企业设备管理、订单管理、用户管理等各种类似后台管理的程序,这就属于两种不同类型的App,针对这种情况,我在系统的配置文件中为不同类型的程序预先指定不同的文件存放路径,如下
这样根据前端上传程序时附带的不同的文件类型,就能将文件存放在不同的目录,便于维护
后端逻辑:
当文件太大时,服务端会报415异常,这时候就需要考虑分片上传,分片上传就是在前端将文件切分成几个片段,然后分片发出请求,服务端一个一个切片处理保存,当执行完最后一个切片请求时,前端通知服务器将前面的分片文件合并。
代码如下:
[HttpPost, Route("/FileVersion/UploadApkLastestPackage")]
public IActionResult UploadApkLastestPackage()
{
try
{
_nlogger.Info("程序版本管理界面(UploadApkLastestPackage),开始。");
var form = Request.Form;
if (form is null || form.Files.Count == 0)
{
throw ExceptionResult.Error(_Constants.FAIL, "参数错误:参数为空。");
}
//上传的文件目录
//文件路径
var rootPath = $"{JsonConfig.Params.Server.RootPath}";
var apkPath = $"{JsonConfig.Params.Android.ApkPackage}";
var dicPath = Path.GetFullPath($"{rootPath}{apkPath}");
var typeObj = form.FirstOrDefault(t => t.Key.Equals("type"));
if (!int.TryParse(typeObj.Value, out int type))
{
throw ExceptionResult.Error(_Constants.FAIL, "参数错误:上传文件的参数中没有type的键。");
}
if (type == 0)
{
if (!Directory.Exists(dicPath))
{
Directory.CreateDirectory(dicPath);
}
//小文件上传
//处理Apk安装包
var lastestApk = form.Files.GetFile("upload-file");
var baseInfo = form.First(t => t.Key.Equals("baseInfo")).Value;
var fileInfo = new
{
type = "",
versionName = "",
direction = ""
};
fileInfo = JsonConvert.DeserializeAnonymousType(baseInfo, fileInfo);
//创建新版本文件信息保存进数据库
var newFileInfo = new Sys_file();
newFileInfo.Type = fileInfo.type;
newFileInfo.VersionName = fileInfo.versionName;
newFileInfo.Direction = fileInfo.direction;
//通过type查现有版本
var fileDbInfo = _uploadFileRepository.Get(t => t.Type.Equals(fileInfo.type) && t.Status);
if (fileDbInfo is null)
{
newFileInfo.VersionCode = 0;
}
else
{
newFileInfo.VersionCode = fileDbInfo.VersionCode + 1;
fileDbInfo.Delete("ed7089c986974b869c999327331ae162");
}
newFileInfo.Name = lastestApk.FileName.Split('.')[0];
newFileInfo.Extension = lastestApk.FileName.Split('.')[1];
newFileInfo.StorePath = dicPath;
newFileInfo.Size = $"{lastestApk.Length}";
newFileInfo.Create("ed7089c986974b869c999327331ae162");
using (TransactionScope scope = new TransactionScope())
{
_uploadFileRepository.Add(newFileInfo);
_uploadFileRepository.Update(fileDbInfo);
scope.Complete();
var path = Path.Combine(dicPath, lastestApk.FileName);
using (var stream = new FileStream(path, FileMode.Create))
{
lastestApk.CopyTo(stream);
}
}
}
else if (type == 1)
{
if (!form.Keys.Contains("index") || !form.Keys.Contains("chunkCount") || !form.Keys.Contains("baseInfo"))
{
throw ExceptionResult.Error(_Constants.FAIL, "参数错误:上传文件的参数键值对不完整。");
}
//大文件切片上传
var chunkApk = form.Files.GetFile("upload-file");
var chunkIndex = form.First(t => t.Key.Equals("index")).Value;
var chunkCount = form.First(t => t.Key.Equals("chunkCount")).Value;
dicPath = dicPath + "/temp";
if (!Directory.Exists(dicPath))
{
Directory.CreateDirectory(dicPath);
}
var path = Path.Combine(dicPath, chunkApk.FileName.Split('.')[0] + chunkIndex + ".apk");
using (var stream = new FileStream(path, FileMode.Create))
{
chunkApk.CopyTo(stream);
}
}
_nlogger.Info("程序版本界面(UploadApkLastestPackage),成功。");
return Json(AjaxResult.Success(_Constants.SUCCESS, $"上传成功", data: null));
}
catch (ExceptionResult e)
{
_nlogger.Info($"程序版本界面(UploadApkLastestPackage),失败:{e.Msg}");
var result = AjaxResult.Error(e.ErrorCode, $"程序版本界面,加载失败:{e.Msg}");
return Json(result);
}
catch (Exception e)
{
_nlogger.Info($"程序版本界面(UploadApkLastestPackage),异常:{e.Message}");
var result = AjaxResult.Error(_Constants.FAIL, $"程序版本界面,加载异常:{e.Message}");
return Json(result);
}
}
首先需要获取文件存储路径,然后进行判断,如果不存在则创建
通过前端上传的type类型,如果type = 0 表示文件比较小,可以直接一次请求就完成文件上传,type = 1的情况就需要处理多次切片文件,然后再最后合并的时候记录到数据库
这里我在文件存储路径创建一个临时文件temp,同于存放切片文件,在最后合并的时候取temp里面的切片合并,然后保存到temp的父目录,同时将temp里的切片清空。
前端逻辑:
前端使用的是饿了么框架
下面是前端的代码javascript代码
<div id="ordertasklist-container">
<div style="width:30%">
<div style="color:orange">
<h2>版本更新发布</h2>
</div>
<el-form ref="uploadForm" label-width="100px">
<el-form-item label="文件类型:" >
<el-select v-model="uploadFileInfo.type" placeholder="选择文件类型(必填)" size="small"
v-on:change="selectUploadType">
<el-option v-for="item in uploadTypes"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="版本:" label-width="100px">
<el-input v-model="uploadFileInfo.versionName"
placeholder=" 示例:1.0.0"
size="small"></el-input>
</el-form-item>
<el-form-item label="版本描述:" label-width="100px">
<el-input v-model="uploadFileInfo.direction"
type="textarea"
placeholder="请输入版本描述">
</el-input>
</el-form-item>
<el-form-item label="选择文件:" label-width="100px">
<el-upload ref="public"
:accept="upload.accapt"
:action="upload.action"
:limit="upload.limit"
:multiple="upload.multiple"
:file-list="upload.uploadFileList"
:headers="upload.uploadHeaders"
:disabled="upload.isUploading"
:before-upload="beforeUpload"
:http-request="uploadFileRequest"
:on-change="handleUploadFile"
:auto-upload="false"
style="width:auto">
<el-button slot="trigger" size="small" type="primary">选择上传的文件</el-button>
<div class="el-upload__tip text-center" slot="tip">
<span>允许导入如下格式文件:{{upload.accapt}}</span>
</div>
</el-upload>
<!-- 进度显示 -->
<el-progress type="circle"
status="success"
:percentage="upload.progress"
:format="processFormat"
:indeterminate="true" />
</el-form-item>
<el-form-item>
<el-button type="primary"
size="small"
:disabled="upload.isUploading"
v-on:click="publicFile">{{upload.publicText}}</el-button>
<el-button type="primary"
size="small"
:disabled="upload.isUploading"
v-on:click="publicReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
<script>
var app = new Vue({
el: '#ordertasklist-container',
data: {
upload: {
ipAddr: "",
accapt: ".xlsx, .xls,.apk",
action: "#",
limit: 1,
// 弹出层标题
title: "",
multiple: false,
// 是否禁用上传
isUploading: false,
// 是否更新已经存在的用户数据
updateSupport: false,
// 设置上传的请求头部
uploadHeaders: {
"Content-Type": ""
},
uploadFileList: [],
// 上传的地址
requestUrl: "",
// 合并的请求地址
mergeUrl: "",
publicText: "发布",
progress: 0
},
uploadTypes: [
{
value: "0",
label: "AndroidApk安装包版本发布"
}
],
uploadFileInfo: {
type: "",
versionName: "",
direction: ""
},
processFormat(percentage) {
return `${percentage}%`;
}
},
methods:{
//选择上传的文件类型
selectUploadType(value){
this.uploadFileInfo.type = value;
switch(value){
case "0":
this.upload.requestUrl = "/FileVersion/UploadApkLastestPackage";
this.upload.mergeUrl = "/FileVersion/CombineFile";
break;
case "1":
this.upload.requestUrl = "/";
break;
}
},
//发布
publicFile(){
if(this.uploadFileInfo.type === ""){
this.messageTip("warning","请选择上传文件类型");
return;
}
if (this.uploadFileInfo.versionName === "") {
this.messageTip("warning","请输入版本号");
return;
}
this.$refs.public.submit();
},
//限制文件上传的个数只有一个,获取上传列表的最后一个
handleUploadFile() {
if (this.upload.uploadFileList.length == 0) {
this.messageTip("warning", "请选择上传文件");
return;
}
if (this.upload.uploadFileList.length > 0) {
this.upload.uploadFileList = [this.upload.uploadFileList[this.upload.uploadFileList.length - 1]];//这一步,是展示最后一次选择的文件
}
},
//导入前
beforeUpload(file) {
const fileType = file.name.substring(file.name.lastIndexOf('.'));
if (fileType.toLowerCase() != '.apk' && fileType.toLowerCase() != '.xlsx' && fileType.toLowerCase() != '.xls') {
this.messageTip('error', '文件必须以' + this.upload.accapt + '为后缀');
this.upload.uploadFileList = []
return false
}
},
//发布AGV调度的APK安装包
uploadFileRequest: async function (data) {
//file就是当前添加的文件
let fileObj = data.file;
try {
//如果文件小于5MB,直接上传
if (fileObj.size < 5 * 1024 * 1024) {
let formData = new FormData();
for (let key in data) {
formData.append(key, data[key]);
}
formData.append("type",0);
formData.append("upload-file", fileObj);
formData.append("baseInfo", JSON.stringify(this.uploadFileInfo));
const result = await this.uploadSingle(this.upload.requestUrl, formData);
var code = result.errorCode;
var data = result.data;
if (result.isSuccess) {
//上传成功之后清除历史记录
//解决上传一次后再次点击无法上传的问题
this.$refs.public.clearFiles();
this.messageTip("success", "发布成功");
} else {
switch (code) {
case '1':
this.messageTip('error', '系统错误:请联系管理员。');
break;
default:
this.messageTip('error', result.msg);
break;
}
}
} else {
//如果文件大于等于5MB,分片上传
const res = await this.uploadByPieces(this.upload.requestUrl, this.upload.mergeUrl, data);
var results = res.results;
var count = res.count;
if (results.length == count){
var errorRes = results.filter(e => e.isSuccess === false);
if(errorRes.length == 0){
this.messageTip("success", "发布成功");
}else{
this.messageTip("error", "发布失败");
}
}else{
this.messageTip("error", "发布失败");
}
this.$refs.public.clearFiles();
}
} catch (e) {
}
},
//正常上传
uploadSingle: async function (url, form) {
return new Promise((resolve, reject) => {
axios.post(url, form)
.then(resp => {
return resolve(resp.data)
})
.catch(err => {
return reject(err)
})
})
},
//分片上传
uploadByPieces: async function (uploadUrl,mergeUrl, { file }) {
const name = file.name;
// 文件大小
const size = file.size
// 分片大小
const chunkSize = 5 * 1024 * 1024; //5MB一片
// 分片总数
const chunkCount = Math.ceil(file.size / chunkSize);
//获取当前chunk数据
const getChunkInfo = (file, index) => {
let start = index * chunkSize;
let end = Math.min(file.size, start + chunkSize);
let chunk = file.slice(start, end);
return { start, end, chunk };
};
//分片上传接口
const uploadChunk = (form) => {
return new Promise((resolve, reject) => {
axios.post(uploadUrl, form)
.then(resp => {
return resolve(resp.data)
})
.catch(err => {
return reject(err)
})
})
}
//通知上传的切片文件合并
const notifyMerge = (form) => {
return new Promise((resolve, reject) => {
axios.post(mergeUrl, form)
.then(resp => {
return resolve(resp.data)
})
.catch(err => {
return reject(err)
})
})
};
//针对单个文件进行chunk上传
const readChunk = (index) => {
if(index === -1){
//合并
var info = {
size: size,
fileName: name,
type: this.uploadFileInfo.type,
versionName: this.uploadFileInfo.versionName,
direction: this.uploadFileInfo.direction
};
return notifyMerge(info);
}else{
const { chunk } = getChunkInfo(file, index);
let fetchForm = new FormData();
fetchForm.append("type", 1);
fetchForm.append("upload-file", chunk);
fetchForm.append("index", index);
fetchForm.append("chunkCount", chunkCount);
fetchForm.append("baseInfo", JSON.stringify(this.uploadFileInfo));
return uploadChunk(fetchForm)
}
};
try{
var list = []
for (let index = 0; index < chunkCount; ++index) {
list.push(index)
}
//在最后面再加上合并的异步请求
list.push(-1);
const res = await this.asyncPool(1, list, readChunk);
return {
count: chunkCount + 1,//加上最后的合并请求
results: res
};
}catch(e){
}
//针对每个文件进行chunk处理
//const promiseList = []
//try {
// for (let index = 0; index < chunkCount; ++index) {
// promiseList.push(readChunk(index))
// }
// 发起并行操作,然后等多个操作全部结束后进行下一步操作
// const res = await Promise.all(promiseList)
// return res
//} catch (e) {
// return e
//}
},
asyncPool: async function (poolLimit, array, iteratorFn) {
const ret = []; // 用于存放所有的promise实例,存储所有的异步任务
const executing = []; // 用于存放目前正在执行的promise,存储正在执行的异步任务
for(const item of array) {
// --------重点开始---------------
// 调用iteratorFn函数创建异步任务
// 防止回调函数返回的不是promise,使用Promise.resolve进行包裹
const p = Promise.resolve(iteratorFn(item));
// 保存新的异步任务
ret.push(p);
// 当poolLimit值小于或等于总任务个数时,进行并发控制
if (poolLimit <= array.length) {
// then回调中,当这个promise状态变为fulfilled后,将其从正在执行的promise列表executing中删除
// 当任务完成后,从正在执行的任务数组中移除已完成的任务
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
// 保存正在执行的异步任务
executing.push(e);
if (executing.length >= poolLimit) {
// 一旦正在执行的promise列表数量等于限制数,就使用Promise.race等待某一个promise状态发生变更,
// 状态变更后,就会执行上面then的回调,将该promise从executing中删除,
// 然后再进入到下一次for循环,生成新的promise进行补充
// 等待较快的任务执行完成
await Promise.race(executing);
}
}
// --------重点结束---------------
// 进度条
var num = (ret.length / array.length) * 100;
this.upload.progress = Math.round(num);
}
return Promise.all(ret);
},
publicReset(){
this.reset();
},
reset(){
this.uploadFileInfo = {
type: "",
versionName: "",
direction: ""
};
this.upload = {
ipAddr: "",
accapt: ".xlsx, .xls,.apk",
action: "#",
limit: 1,
// 弹出层标题
title: "",
multiple: false,
// 是否禁用上传
isUploading: false,
// 是否更新已经存在的用户数据
updateSupport: false,
// 设置上传的请求头部
uploadHeaders: {
"Content-Type": ""
},
uploadFileList: [],
// 上传的地址
requestUrl: "",
publicText: "发布",
progress: 0
};
},
//弹窗提示语
messageTip(type, msg) {
this.$message({
message: msg,
type: type
});
},
}
})
</script>
上传前,先对文件大小判断,我这里是设置如果文件大于5M就分片上传,小于的就正常上传就行了
这里使用的是Javascript的Promise来进行异步操作,正常上传情况下,先实例化一个Promise实例,实例里传入axios异步请求,当请求返回结果时对这个实例调用resolve进行下一步操作返回请求结果。
分片上传核心在于利用file.slice对文件进行切片
在asyncPool中对并发的请求进行限制
asyncPool: async function (poolLimit, array, iteratorFn) {
const ret = []; // 用于存放所有的promise实例,存储所有的异步任务
const executing = []; // 用于存放目前正在执行的promise,存储正在执行的异步任务
for(const item of array) {
// --------重点开始---------------
// 调用iteratorFn函数创建异步任务
// 防止回调函数返回的不是promise,使用Promise.resolve进行包裹
const p = Promise.resolve(iteratorFn(item));
// 保存新的异步任务
ret.push(p);
// 当poolLimit值小于或等于总任务个数时,进行并发控制
if (poolLimit <= array.length) {
// then回调中,当这个promise状态变为fulfilled后,将其从正在执行的promise列表executing中删除
// 当任务完成后,从正在执行的任务数组中移除已完成的任务
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
// 保存正在执行的异步任务
executing.push(e);
if (executing.length >= poolLimit) {
// 一旦正在执行的promise列表数量等于限制数,就使用Promise.race等待某一个promise状态发生变更,
// 状态变更后,就会执行上面then的回调,将该promise从executing中删除,
// 然后再进入到下一次for循环,生成新的promise进行补充
// 等待较快的任务执行完成
await Promise.race(executing);
}
}
// --------重点结束---------------
// 进度条
var num = (ret.length / array.length) * 100;
this.upload.progress = Math.round(num);
}
return Promise.all(ret);
},