likes
comments
collection
share

设计模式只看不练可不行,写个上传解耦库练练手

作者站长头像
站长
· 阅读数 45

上周总算是把设计模式之美看完了,一直想写个东西练练手,碰巧最近要重构公司的上传库,所以有了这篇文章。(仅仅是学习练手,有建议可以提,架构大佬轻喷)

设计模式只看不练可不行,写个上传解耦库练练手

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
评论
请登录