关于网络框架设计封装的扯淡
本blog的代码库:
[HttpUtil2] github.com/hss01248/HttpUtil2
1. 前后端交互协议设计
常规是data-code-msg三字段设计
也有data-code-msg-isSuccess. 其中isSuccess和code其实互为冗余.
但看了Facebook,google等大公司的接口交互协议,发现其实最全的是:
data-code-msg-errorData.
请求正确时:
{
“data”: {
“uid”: “898997899788997”
},
“code”: “0”,
“msg”: “success!”,
“success”: true,
“errorData”: null
}
请求错误时
错误原因千奇百怪,应使用map来解析errorData,避免解析异常.或直接使用optJSONObject(“errorData”)
{
“data”: null,
“code”: “user.login.401”,
“msg”: “unlogin”,
“success”: false,
“errorData”: {
“reason”:“kickout”,
“time”:1689799989
}
}
为了debug方便,在开发/测试环境,后台500时,应将异常栈信息直接塞在msg里返回给前端.
2. 应该包含哪些功能
底层
从urlconnection到httpclient到okhttp
封装层
从volley/asyncHttpClient到retrofit
如今基本上是okhttp一统底层,上层retrofit+rxjava.
即使用retrofit,仍然有很多重复代码要写,需要更进一层的封装,方便日常crtl+c ,ctrl+v.
即使是crtl+c,也希望代码能少一点是一点.
那么一个封装完善的网络框架,还需要哪些功能?
先看看几个star比较多的封装库:
结合日常开发经验,总结一下,其实有如下可塞入框架中:
其实,再想想,一个完善的客户端网络库,应该像postman一样基于配置,傻瓜易用.
封装网络框架,无非是吧这些个gui变成api而已.
3. 几个设计上的思想
开箱即用
跟spring boot一样,约定大于配置. 里面的配置项大多都有默认值.
初始化即使只是调用最简单的init方法,也能够使用框架大部分功能.
全量信息可访问
回调里要能拿到本次请求和响应的全量信息.
比如okhttp在他的callback里就能拿到整个call对象,以及整个response信息.
很多框架callback里只有解析后的data. 需要用到其他信息时就懵逼了.
全方位适应页面生命周期
管你传view,fragment,activity,lifecycleowner,viewmodel,通通自动处理.
你说view怎么拿到页面生命周期? context里层层剥开,总能拿到activity.
生命周期结束后自动取消请求.
取消请求有两种做法:
(在等待队列里没有区别,都是移出队列–>只是… okhttp-rxjava的线程模型下,基本都是立刻发出,没有等待)
-
直接socket.close()关掉连接
-
不干预底层,只是在回调里通过boolean值切断回调
retrofit和rxjava的takeutil,都是用的第一种.简单粗暴易实现,只是后端接口监控里多了一些0或者499的错误.
不用kotlin协程
kotlin协程很牛逼?抱歉,只是假协程,底层还是线程池切换.只是用同步方式写异步代码而已(跟js的async,await差不多).
当然这并非kotlin不行,而是jvm本身并未支持协程.
要真能实现像go一样的真协程,或者跳出jvm,自己调用epoll实现多路复用,那就牛逼了,我肯定抢着用kotlin来改写这个框架.
下面开始讲讲每个关键点的实现和使用
4. api使用:
直接看readme
HttpUtil.requestAsJsonArray(“article/getArticleCommentList/v1.json”,PostStandardJsonArray.class)
.addParam(“pageSize”,“30”)
.addParam(“articleId”,“1738”)
.addParam(“pageIndex”,“1”)
.post()
.setCacheMode(CacheMode.FIRST_CACHE_THEN_REQUEST)
// .setCacheMode(CacheStrategy.REQUEST_FAILED_READ_CACHE)
.callback(new MyNetCallback<ResponseBean<List>>(true,null) {
@Override
public void onSuccess(ResponseBean<List> response) {
MyLog.json(MyJson.toJsonStr(response.data));
}
@Override
public void onError(String msgCanShow) {
MyLog.e(msgCanShow);
}
});
String url2 = “https://kiwivm.64clouds.com/dist/openvpn-install-2.4.5-I601.exe”;
HttpUtil.download(url2)
.setFileDownlodConfig(
FileDownlodConfig.newBuilder()
.verifyBySha1(“76DAB206AE43FB81A15E9E54CAC87EA94BB5B384”)
.isOpenAfterSuccess(true)
.build())
.callback(new MyNetCallback<ResponseBean>() {
@Override
public void onSuccess(ResponseBean response) {
MyLog.i(“path:”+response.data.filePath);
}
@Override
public void onError(String msgCanShow) {
MyLog.e(msgCanShow);
}
});
5.关键点
5.1 同步异步的支持
其实okhttp本身就有同步和异步的写法.
同步直接return,用try-catch包裹.
异步就使用callback.
但我们这里内部使用retrofit,基于rxjava.全部变成了回调的形式.
那么,就不追求同步的写法,直接以异步的形式写同步执行.
rxjava怎么同步执行?
不进行线程切换,就同步执行了. so easy
HttpUtil.requestString(“article/getArticleCommentList/v1.json”)
.post()
.setSync(true)//同步执行
.addParam(“pageSize”,“30”)
.addParam(“articleId”,“1738”)
.addParam(“pageIndex”,“1”)
.callback(new MyNetCallback<ResponseBean>(true,null) {
@Override
public void onSuccess(ResponseBean response) {
MyLog.i(response.data);
}
@Override
public void onError(String msgCanShow) {
MyLog.e(msgCanShow);
}
});
5.2 自动处理生命周期
原始时代:
本库使用的方式.
用静态map存储activity/fragment对象和请求, activity/fragment destory时,从map中取出请求,判断状态,进行cancel.
/**
-
取消请求,常在activity ondestory处调用.直接传入activity即可,不会保存引用,直接识别其名字作为tag
-
@param obj
*/
public static void cancelByTag(Object obj) {
if (obj == null) {
return;
}
List<retrofit2.Call> calls = callMap.remove(obj);//从gc root引用中删除
if (calls != null && calls.size() > 0) {
for (retrofit2.Call call : calls) {
try {
if (call.isCanceled()) {
return;
}
call.cancel();
} catch (Exception e) {
ExceptionReporterHelper.reportException(e);
}
}
}
}
RxLifecycle + rxjava
onDestory时构建transformer,传给rxjava的takeUtil操作符.
本库未实现
livedata
observable转livedata,直接跟lifecyclerOwner挂钩.
本库已实现.
5.3 通用UI支持
loadingDialog
内置,默认不显示.可配置开关,UI样式
错误msg的toast
比较方便的做法是在onError里统一处理,默认关闭,可以通过链式api开启.
测试环境应toast: code+"\n"+msg. 且测试环境的msg应尽量带栈信息.
错误码转文案
一般,应在框架内统一处理.
分三个类型:
底层框架抛出的exception,应转为友好文案
http请求本身的错误码,比如400,500之类的,应提供统一文案
业务data-code-msg内,如果msg部分后台不做国际化,那么客户端应配置对应的翻译文案.
框架应自动处理前两个,并提供第三种业务错误文案的配置接口:
ExceptionFriendlyMsg.init(context, new IFriendlyMsg() {
Map<String,Integer> errorMsgs = new HashMap<>();
{
errorMsgs.put(“user.login.89899”,R.string.httputl_unlogin_error);
}
@Override
public String toMsg(String code) {
Integer res = errorMsgs.get(code);
if(res != null && res != 0){
return context.getResources().getString(res);
}
return “”;
}
});
内部已配置文案:(中文+英文)
5.4 响应体格式校验
bean validator这件事情在服务端接收客户端/浏览器请求时比较常用.已经发展成为了一项java规范.
其实这个需求在客户端并不强烈.服务端的返回大多数情况还是比较稳定的,出现丢字段,字段错误等情况比较少.
不过,为了小装一个X,我还是把这个功能实现了–>
其实也不是实现,只是把服务端常用的功能迁移到移动端,并进行了适配. 做了一点微小的工作.
请看:
要移植到Android,需要考虑java8兼容性问题,性能(方法耗时),以及对apk大小的影响,默认使用的是Apache BVal 1.1.2.
String errorMsg = BeanValidator.validate(bean);
//返回的errorMsg为空就说明校验通过
if(!TextUtils.isEmpty(errorMsg)){
//Toast.makeText(this,errorMsg,Toast.LENGTH_LONG).show();
Observable.error(xxx)//把errorMsg和指定errorCode往外抛
}else {
//拿到合格的bean
}
这个操作,放到bean刚被解析出来的时候做就行.
5.5 缓存控制:丰富的缓存模式
超越http协议本身的缓存控制模式
http协议本身缓存控制有哪些局限:
-
只能缓存get请求
-
老复杂的请求头
自己写的客户端,能受这点气?必须得改,大改!
-
要能缓存任何请求
-
要能一键支持常用业务模式
.setCacheMode(CacheMode.FIRST_CACHE_THEN_REQUEST)
//缓存策略,分类参考:https://github.com/jeasonlzy/okhttp-OkGo
//不使用缓存,该模式下,cacheKey,cacheMaxAge 参数均无效
public static final int NO_CACHE = 1;
//完全按照HTTP协议的默认缓存规则,例如有304响应头时缓存。
public static final int DEFAULT = 2;
//先请求网络,如果请求网络失败,则读取缓存,如果读取缓存失败,本次请求失败。成功或失败的回调只有一次
public static final int REQUEST_FAILED_READ_CACHE = 3;
//优先使用缓存,如果缓存不存在才请求网络,成功或失败的回调只有一次
public static final int IF_NONE_CACHE_REQUEST = 4;
//先使用缓存,不管是否存在,仍然请求网络,可能导致两次成功的回调或一次失败的回调.
//成功回调里,有标识识别本次是缓存还是网络返回.
public static final int FIRST_CACHE_THEN_REQUEST = 5;
//只读取缓存,不请求网络
public static final int ONLY_CACHE = 6;
5.6 cookie
okhttp底层默认没有存cookie,但提供了接口,我们基于他的接口cookiejar实现.
一般有:
-
不存储cookie
-
只在内存存储cookie
-
cookie序列化到shareprefences/文件:
第三种跟浏览器行为比较像了.只不过没有浏览器恶心的各种跨域,安全限制,随便玩.
你说httpOnly?sameSite?不存在的,在我这就是几个key-value,想怎么搞就怎么搞.
不过作为一个框架,还是遵循一下最基本的,响应一下host和path还是要的.其他的,提供接口给别人自定义吧.松或者严都随意.
public static final int COOKIE_NONE = 1;
public static final int COOKIE_MEMORY = 2;
public static final int COOKIE_DISK = 3;
private int cookieMode = COOKIE_DISK;//默认是做持久化操作
/**
- 设置cookie管理策略
*/
public GlobalConfig setCookieMode(int cookieMode) {
this.cookieMode = cookieMode;
return this;
}
5.7 公共请求头,请求参数/请求体参数
可初始化时用map存储,每次请求时加入:
如果值初始化后就不变,那推荐使用这种方式.
如果会变化,就不能用这种.或者变化后更新缓存的map.
也可以利用okhttp的拦截器,在拦截器里加入.
对于请求头,get请求,很简单就实现了
但对于post json或者multiPart,就需要将json再变成map,然后加入,将multiPart还原,再加入.
可参考:
如果涉及到请求体签名,那么务必将此拦截器加到签名拦截器之前.
5.8 请求超时
okhttp不是有超时设置么?
之前只有connecTimeout,read,write三个超时时间,现在看,已新增callTimeout,涵盖了okhttp层面的整个请求过程.
对于当初没有calltimeout的时代,单纯设置下面三个是不够的,因为dns解析过程并不能被这三者覆盖.
可以使用rxjava的timeout来控制整个流程的耗时.
如今依然优先使用rxjava来控制.因为okhttp的calltimeout无法覆盖自定义缓存读写的超时.
这种一般提供全局配置和单个请求配置
5.9 请求重试
okhttp本身有个重试api:
builder.retryOnConnectionFailure(boolean)
但只是tcp连接失败的重试.且只能重试一次
要不论什么错误都重试,且可指定重试次数,还是得靠rxjava的api. 这就不说了,直接用就行.
5.10 异常捕获和上报
别管okhttp/retrofit崩不崩,你作为一个封装框架,肯定不能崩.
任何情况都不能崩,得做到100% crash free.
有几个关键的地方:
拦截器内
作为应用拦截器第一个,对chain.proceed(request)加上try-catch,降级为ioException,可以被okhttp的error回调处理.
@Override
public Response intercept(Chain chain) throws IOException {
try {
Response response = chain.proceed(chain.request());
} catch (Throwable e) {
if (e instanceof IOException) {
throw e;
} else {
//降级,让okhttp框架能处理错误,而不是crash
throw new IOException(e);
}
}
}
rxjava全局异常捕获:
这个一般在主工程做.框架内不参与.
RxJavaPlugins.setErrorHandler(new Consumer() {
@Override
public void accept(Throwable e) throws Exception {
report(e);
}
});
自己框架层的回调里
回调的onSuccess和onError是使用者实现的,如果也出现了崩溃怎么办?也给你兜住!
onSuccess抛异常,降级给onError
onError还抛异常,模仿rxjava,降级给全局错误处理
if(bean.success){
try {
onSuccess(callback,t);
}catch (Throwable throwable){
onError(callback,throwable);
}
}else {
onError(callback,bean.errorInfo);
}
private static void onError(MyNetCallback callback, Throwable e) {
try {
Tool.logd("–>http is onError: "+callback.getUrl() );
Tool.dismissLoadingDialog(callback.dialogConfig, callback.tagForCancel);
ErrorCallbackDispatcher.dispatchException(callback, e);
}catch (Throwable e2){
if(GlobalConfig.get().getErrorHandler() != null){
try {
GlobalConfig.get().getErrorHandler().accept(e2);
} catch (Exception exception) {
exception.printStackTrace();
}
}else {
if(!GlobalConfig.get().isDebug()){
e2.printStackTrace();
}
}
//测试环境,都崩溃,提醒一下
if(GlobalConfig.get().isDebug()){
throw e2;
}
}
}
5.11 debug功能
网络嘛,debug主要形式还是抓包
提供丰富多彩的看包的形式:
-
logcat
改造okhttpLoggingInterceptor,请求体响应体直接一行打印,方便拷贝. 大于4000个字符切割分行.
-
手机内抓包
改造的chuck,基于okhttp拦截器,通知栏显示抓包内容.提供过滤过于频繁的刷屏请求,比如各种行为日志上报之类的.
-
pc代理抓包
通常用fiddler或者chales.
需要配置: 7.0以上debugable环境忽略证书
或者直接网络框架在debug环境忽略证书
-
stetho-> flipper
基于okhttp拦截器,抓包内容发送到pc上的客户端显示. 显示界面更高端大气上档次.
改写flipper内置的拦截器,有额外加密的,解密后发明文过去显示.
一行脚本集成: flipperUtil
5.12 线上监测
上报不麻烦,关键是统计分析怎么搞?有哪些现成的,自己搭又要怎么搭.
在上面的拦截器里添加上报即可. 关键是上报到哪里
构建exception,上报到sentry.
或者自己搭一条flume+elk的分析系统.
或者猥琐一点,构建event上报到事件统计平台,蹭他们的流量.
哪些参数
-
错误信息:
在上面拦截器/统一的错误回调里拿到并上报即可. 一般上报到统计平台看错误趋势,根据趋势看某时段前后台服务是否有异常. 这通常只是后台本身请求监控的补充.
前几年利用谷歌分析的事件实时分析功能,将错误信息变成event上报,能实时看1min内,30min内的网络错误趋势,自带排序,爽得一逼,可惜后面谷歌分析移动端下线了,firebase上这个功能被运营占用了.
-
请求分时信息:
比如dns耗时,tcp耗时,tls,http请求响应,这些都可以通过okhttp的eventListener接口来获取.
5.13 安全
手段基本是:
-
https上玩的一些操作
-
自定义加密
-
请求头,请求体签名-防篡改
https
基本上就是这几个问题
什么是中间人攻击
如何防范中间人攻击
什么是单向证书校验,框架层如何实现
什么是双向证书校验,框架层如何实现
如何对抗证书校验? root手机+frida+okhttplogging的dex 参考: frida使用
自定义加密
拦截器里拿到请求体字节数组,加密,再构建新的requestBody,继续走即可.
final Buffer buffer = new Buffer();
requestBody.writeTo(buffer);
final long size = buffer.size();
final byte[] bytes = new byte[(int) size];
buffer.readFully(bytes);
final byte[] bytesEncrypted = encrypt(bytes);
//加密成功/失败,最好在请求头加一个标识
return new RequestBody() {
@Override
public MediaType contentType() {
return MediaType.parse(type);
}
@Override
public long contentLength() {
return bytesEncrypted.length;
}
@Override
public void writeTo(BufferedSink sink) throws IOException {
sink.write(bytesEncrypted);
}
};
请求头请求体签名
无非是加盐来生成sha1,sha256什么的,没什么好讲的.
5.14 gzip
okhttp已内置对响应体的gzip处理,这个不用再说.
如果请求体是比较大的字符串,那么用gzip压缩,流量收益方面还是可以的.
需要前后端支持.
我们在拦截器里进行gzip压缩.
gzip前无法指定gzip后的大小,可以再包裹一层,以设定请求体的contentLength
private RequestBody gzip(final RequestBody body, String type) {
return new RequestBody() {
@Override
public MediaType contentType() {
return body.contentType();
}
@Override
public long contentLength() {
return -1; // We don’t know the compressed length in advance!
}
@Override
public void writeTo(BufferedSink sink) throws IOException {
BufferedSink gzipSink = Okio.buffer(new GzipSink(sink));
body.writeTo(gzipSink);
gzipSink.close();
}
};
}
后端nginx上用lua脚本进行解压缩后再转发即可.
5.15 断点上传/下载
利用的是http头的range和content-range, 加上java 的randomAccessFile api.
主要还是工程问题比较难处理.写得好的框架不多.我这个没有做这个断点续传功能.
5.16 下载后处理
抄了些迅雷等下载软件的功能,用api的形式提供出来
比如:
-
下载后校验md5/sha1
-
下载后自动打开: 需要处理Android7的File uri permission
-
下载后通知mediastore扫描
-
是否隐藏文件: 下载一些隐私文件时用,你懂的.利用.nomedia空文件隐藏,防君子不防小人.
-
通知栏显示下载进度/对话框显示下载进度
5.17 回调形式
-
callback
-
livedata
-
返回observable
5.18 接口聚合
场景1 多图异步上传
public static io.reactivex.Observable<ResponseBean> uploadImgs(String type, final List filePaths){
final List infos = new ArrayList<>();
io.reactivex.Observable<ResponseBean> observable =
HttpUtil.requestAsJsonArray(getUploadTokenPath,S3Info.class)
.get()
.addParam(“type”, type)
.addParam(“contentType”, IMAGE_JPEG)
.addParam(“cnt”,filePaths.size())
.asObservable()
.flatMap(new Function<ResponseBean<List>, ObservableSource<ResponseBean>>() {
@Override
public ObservableSource<ResponseBean> apply(ResponseBean<List> bean) throws Exception {
infos.addAll(bean.bean);
List<io.reactivex.ObservableSource<ResponseBean>> observables = new ArrayList<>();
for(int i = 0; i< bean.bean.size(); i++){
S3Info info = bean.bean.get(i);
String filePath = filePaths.get(i);
io.reactivex.Observable<ResponseBean> observable =
HttpUtil.request(info.getUrl(),S3Info.class)
.uploadBinary(filePath)
.put()
.setExtraFromOut(info)
.responseAsString()
.treatEmptyDataAsSuccess()
.asObservable();
observables.add(observable);
}
return io.reactivex.Observable.merge(observables);
}
});
return observable;
}
场景2:多接口异步请求,统一回调一次
后台微服务拆得太细,又不愿做聚合,只能客户端自己做.
在客户端,基于Rxjava实现通用的聚合接口请求.
每个接口可配置能否接受失败