设计模式只看不练可不行,写个上传解耦库练练手
上周总算是把设计模式之美看完了,一直想写个东西练练手,碰巧最近要重构公司的上传库,所以有了这篇文章。(仅仅是学习练手,有建议可以提,架构大佬轻喷)
0x1、杂谈
① 为什么要重构上传库?
我司APP在上传图片/视频前,需要进行一系列处理,最后才上传,如:
- 图片:判断路径是否存在 → 通过Exif信息判断是否需要旋转 → 判断是否需要压缩 → 获取MD5 → 如果开启秒传查询是否有秒传记录,有直接返回 → 没有才上传 → 上传完成对应状态更新;
- 视频:判断路径是否存在 → 判断是否需要压缩 → 要压缩的话压缩 → 获取MD5 → 获取视频第一帧 → 判断是否需要压缩 → 要压缩的话再获取一次视频MD5 → 同样是秒传验证 → 上传完视频传第一帧图片
有些业务场景的处理更复杂,Talk is cheap,show you the code,出现这样的代码很正常(局部):
得益于rx链式调用,上述代码是已经简化后的一版了,可以想象没rx前的就更混乱了,对写的人来说负担,对看的人来说也是,重构势在必行...
② 不重构的话有其他低成本一点的方案没?
答:有,在上面代码的基础上优化,把flatMap抽取为单独的功能函数,按流程调用,当然,也不算太优雅。最优雅的应该是上Kotlin协程,写几个挂起函数,同步方式写异步代码。当然,问题也有:一丢丢的学习成本 和 不能在Java里用。
0x2、需求拆解
原始需求
写个图片上传库,给你一个本地图片路径、一个上传接口,完成图片上传。
小白视角
简单,库都不用谢,直接写个UploadPicUtils工具类,定义一个上传方法就好了
光速敲完代码:
object UploadPicUtils {
fun uploadPic(picPath: String, serverUrl: String) {
val pic = File(picPath)
if(pic.exists()) {
// 执行网络上传操作(如调用OkHttp直接传)
// 利用rx或EventBus通知上传结果,给出成功、失败反馈
}
}
}
// 上传图片处调用:
UploadPicUtil.uploadPic("本地图片路径", "上传接口")
看着挺简单的,但 唯一不变的是变化,需求往往是反复无常的~
- 因为公司不舍得买图片加水印服务,所以客户端传图片前本地要加下水印;
- BUG:有用户用自己手机拍照,上传后的图片却旋转了,上传前要检查下,歪了的要摆正;
- BUG:有用户反馈上传图片太慢,一排查图片太大,服务器顶不住,上传前要做下图片压缩;
- 某些图片尺寸有规定(X*Y),尺寸不对的不能上传;
- 秒传功能,md5一样的文件传过就不要传了,直接返回地址;
- 支持同时上传多张图片;
- 现在不止传图片了,还有传视频、音频、文件、文件夹等场景...
然后代码就变成上面这样的结果,写的人看了沉默,接盘的人看了流泪。
所以,在拿到原始需求时,不要上来就肝代码,而是 对需求进行拆解、分析、假设和思考。
- 真的只上传图片吗?后面会不会要传其他东西,如音视频?
- 要做图片有效性校验吗?如:存不存在,大小是否为0,文件格式为图片类型等;
- 库需要对上传图片做什么特别的处理吗?如打水印、翻转、压缩、切割、尺寸校验等;
- 是否要支持多张图片同时上传,最多多少张同时传;
- 是否要支持秒传;
- 不同手机系统版本或设备文件获取API兼容;
- 上传接口地址是不是一直变化的,是否需要鉴权,是否有特定上传配置;
- 上传任务在后台异步进行,还是前台同步堵塞,上传中途能否取消;
- 上传任务中断了(杀掉APP)是否需要保留进度,下次打开APP重新传;
- 上传失败是否需要重试,最多重试次数多少;
- 上传任务是否还有优先级等;
当然,不要想着一下子就给出完美设计方案,完整的功能实现,受限于设计者的架构经验和有限的排期,先给出一个粗糙的、基础可用的方案,有一个迭代的基础,再慢慢进行优化,最小化可行产品。
0x3、架构设计
从宏观角度看,文件上传的经历的过程跟车间流水线组装很像,以 袋装薯片 的生产流程为例:
土豆进厂 → 清洗削皮 → 切片烘干 → 350度高温油炸 → 加盐 → 按克分装充入氮气 → 袋装薯片
从土豆经历各种转换,到最后的袋装薯片,类比到我们的单个上传任务:
再抽象简化为三个部分:
任务构建和任务完成 这种流水线处理任务的方式,就很适合上 责任链模式
了。
传统责任链实现,单向往后传递,一层层拦截,直到有人处理为止。
这里参考下 OkHttp拦截器的实现,双向责任链,大概原理:
- Interceptor实现类调用 intercept(Chain) 往下传递Chain实例(包含此拦截器处理后的requests);
- 最后一个拦截器调用 chain.proceed() 返回Response实例,递归往上传递;
这里可以先前后都是Task,后续再来拆,所以单个任务的组合变成了:
请求前的拦截器若干 → 执行上传请求 → 请求后的拦截器若干
执行上传请求,就交给用户自定义了,提供请求构造及发送请求的方法。成功与否,通过回调告知即可。
这是单个任务上传的情况,多个任务还需要:任务队列、轮询器和线程池。
当发起一个上传任务时,把任务加到队列中,轮询器不断从队列里拿任务(直到没任务),从线程池中拿个线程执行任务。
大概的原理很清晰,接着就是具体的代码实现了,代码注释写得很详尽了,就不再一一解释了~
0x4、库的使用
目前还是 写来玩玩状态(一堆坑,菜鸡边踩边改中~),感兴趣可以star下, 仓库地址:github.com/coder-pig/C…
添加依赖:
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
dependencies {
implementation 'com.github.coder-pig:CpLightUpload:v0.0.3'
}
① 自定义Task
不同场景可能有不同的需求,按需自定义属性:
class CpImageTask : ImageTask() {
var needRotate: Boolean? = null
var needCompress: Boolean? = null
var compressPercent: Int? = 80
}
class CpVideoTask : VideoTask() {
var limitSize: Int? = -1 // 视频限制大小
var compressVideoPath: String? = null // 压缩视频路径
var compressVideoMD5: String? = null // 压缩视频MD5
var firstFramePath: String? = null // 视频第一帧路径
var firstFrameMD5: String? = null // 视频第一帧MD5
}
② 自定义上传配置
就是一个上传的默认配置,当上传Task对应项没有配置时,填充默认配置:
class ImageUploadConfig : LightUploadConfig() {
var needRotate: Boolean = true // 是否需要旋转纠正
var needCompress: Boolean = true // 是否需要压缩
var compressPercent: Int = 80 // 压缩比例,默认80
}
class VideoUploadConfig : LightUploadConfig() {
// 按需自定义
}
③ 自定义前拦截器
继承 Interceptor
接口,实现intercept() 方法:
class PictureRotateInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Task {
val task = chain.task()
val config = task.config as? ImageUploadConfig
if (task is CpImageTask) {
if(task.needRotate == null) task.needRotate = config?.needRotate
"============ 判断是否需要图片翻转 ============".logV()
val degree = FileUtils.readPictureDegree(task.filePath!!)
if (degree != 0) {
"图片旋转修正".logV()
FileUtils.rotateToDegrees(task.filePath!!, degree.toFloat())
"图片旋转处理完毕".logV()
} else {
"不需要旋转修正.".logV()
}
}
// 往下传递
return chain.proceed(task)
}
}
class VideoFrameInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Task {
val task = chain.task()
if (task is CpVideoTask) {
"生成视频缩略图...".logV()
// 获取第一帧文件名
val tag = task.compressVideoPath!!.substring(task.compressVideoPath!!.lastIndexOf("/"))
val frameFile = File(getExternalVideoPath() + tag + ".jpg")
task.firstFramePath = frameFile.absolutePath
val mmr = MediaMetadataRetriever()
mmr.setDataSource(task.compressVideoPath!!)
val frameBitmap = mmr.frameAtTime
FileUtils.compressImage(frameBitmap, frameFile, 80)
task.firstFrameMD5 = FileUtils.getFileMD5ToString(frameFile)
LightUpload.upload(task = CpImageTask().apply {
filePath = task.firstFramePath
md5 = task.firstFrameMD5
})
frameBitmap?.recycle()
}
return chain.proceed(task)
}
}
④ 自定义请求
实现 Upload
抽象类,重写initRequest()和sendRequest()方法,对不同请求结果进行回调:
class HucUpload : Upload() {
override fun sendRequest() {
"开始文件上传...".logV()
var ins: InputStream? = null
try {
mTask.reqData?.let { req ->
val conn = (URL(req.uploadUrl).openConnection() as HttpURLConnection).apply {
readTimeout = req.timeout!!
connectTimeout = req.timeout!!
doInput = true
doOutput = true
useCaches = false
requestMethod = req.requestMethod
// 请求头设置
val boundary = UUID.randomUUID()
req.headers["Content-Type"] = "multipart/form-data;boundary=${boundary}"
for ((k, v) in req.headers) setRequestProperty(k, v)
val dos = DataOutputStream(outputStream)
val sb = StringBuilder().append("--").append(boundary).append("\r\n")
.append("Content-Disposition: form-data; name=\"file\"; filename=\"")
.append(mTask.md5).append(mTask.fileName).append("\"")
.append("\r\n")
.append("Content-Type: application/octet-stream; charset=utf-8")
.append("\r\n").append("\r\n")
dos.write(sb.toString().toByteArray())
ins = FileInputStream(File(mTask.filePath!!))
val bytes = ByteArray(1024)
var len: Int
while (ins!!.read(bytes).also { len = it } != -1) {
dos.write(bytes, 0, len)
}
ins!!.close()
dos.write("\r\n".toByteArray())
val endData: ByteArray = "--$boundary--\r\n".toByteArray()
dos.write(endData)
dos.flush()
}
// 获取响应
val input = BufferedReader(InputStreamReader(conn.inputStream, "UTF-8"))
val sb1 = StringBuilder()
var ss: Int
while (input.read().also { ss = it } != -1) {
sb1.append(ss.toChar())
}
val result = sb1.toString()
"文件上传结束...".logV()
mTask.response = Response(conn.responseCode, result)
mTask.status = TaskStatus.DONE
mCallback?.onSuccess(mTask)
}
} catch (e: IOException) {
e.message?.logE()
mTask.status = TaskStatus.FAILURE
mTask.throwable = e
mCallback?.onFailure(mTask)
LightUpload.postTask(mTask)
} finally {
if (ins != null) {
try {
ins!!.close()
} catch (e: IOException) {
e.message?.logE()
}
}
}
}
}
⑤ 自定义后拦截器
对响应数据进行处理,如字符串解析显示
class SimpleParsingInterceptor: Interceptor {
override fun intercept(chain: Interceptor.Chain): Task {
val task = chain.task()
if(task is ImageTask) {
task.response?.let {
var tempContent = it.content
if(tempContent.startsWith("{")) {
val index: Int = tempContent.indexOf("{")
tempContent = tempContent.substring(index)
}
try {
val jsonObject = JSONObject(tempContent)
if (jsonObject.getInt("code") == 200) {
//解析服务端回传内容
val mapJson: JSONObject = jsonObject.getJSONObject("data")
var key = ""
var image = ""
val ite = mapJson.keys()
while (ite.hasNext()) {
key = ite.next()
image = mapJson[key] as String
}
task.fileUrl = image
task.fileUrl?.logV()
} else {
jsonObject.toString().logV()
}
} catch (e: Exception) {
e.message?.logD()
}
}
}
return chain.proceed(task)
}
}
⑥ 初始化
可以不在App类中初始化,只要保证在upload前init()了就行~
LightUpload.init(LightUploadBuilder()
// 传入默认配置,可变参数,支持多种类型Task的定制
.config(LightUploadTask.IMAGE to ImageUploadConfig().apply {
reqData = ReqData(
uploadUrl = "http://127.0.0.1:5000/upload",
requestMethod = "POST",
headers = hashMapOf(
"Charset" to "utf-8",
"connection" to "keep-alive"
)
)
}, LightUploadTask.VIDEO to VideoUploadConfig()
.apply {
reqData = ReqData(
uploadUrl = "http://127.0.0.1:5000/upload",
requestMethod = "POST",
headers = hashMapOf(
"Charset" to "utf-8",
"connection" to "keep-alive"
)
)
})
// 设置上传请求,同样也是可变参数,支持多种类型定制
.upload(LightUploadTask.IMAGE to HucUpload())
// 添加前拦截器
.addBeforeInterceptor(PictureRotateInterceptor())
.addBeforeInterceptor(PictureCompressInterceptor())
.addBeforeInterceptor(VideoCompressInterceptor())
.addBeforeInterceptor(VideoFrameInterceptor())
// 添加后拦截器
.addDoneInterceptors(SimpleParsingInterceptor())
)
⑦ 调用上传
LightUpload.upload(task = CpImageTask().apply {
filePath = path
needCompress = true
compressPercent = (1..100).random()
callback = object : Upload.CallBack {
override fun onSuccess(task: Task) {
// 成功回调
text = " ${task.response!!.content}\n"
}
override fun onFailure(task: Task) {
// 失败回调
task.throwable?.message?.let { it1 -> shortToast(it1) }
}
}
})
0x5、Demo测试
命令行cd到项目的upload-server项目,第一次运行前执行下述命令安装python脚本相关依赖:
pip install -r pip install requirements.txt
安装完后,键入下述命令运行脚本:
python app.py
然后手机和电脑在同一局域网,配置下代理,输入ipconfig查看本机IP
配置下手机,开下charles抓包:
运行效果如下:
Logcat也可以看到输出信息:
Nice~
转载自:https://juejin.cn/post/6999914854618234888