關於網路框架設計封裝的扯淡

qianby發表於2021-09-09

關於網路框架設計封裝的扯淡

本blog的程式碼庫:

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. 應該包含哪些功能

image-20210812110723691

底層

從urlconnection到httpclient到okhttp

封裝層

從volley/asyncHttpClient到retrofit

如今基本上是okhttp一統底層,上層retrofit+rxjava.

即使用retrofit,仍然有很多重複程式碼要寫,需要更進一層的封裝,方便日常crtl+c ,ctrl+v.

即使是crtl+c,也希望程式碼能少一點是一點.

那麼一個封裝完善的網路框架,還需要哪些功能?

先看看幾個star比較多的封裝庫:

OkGo:

image-20210811192424993

大強子的NET

image-20210811192844224

rxHttp

image-20210811193245285

結合日常開發經驗,總結一下,其實有如下可塞入框架中:

image-20210811193519187

image-20210811193542328

其實,再想想,一個完善的客戶端網路庫,應該像postman一樣基於配置,傻瓜易用.

image-20210812111445596

封裝網路框架,無非是吧這些個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<PostStandardJsonArray>>>(true,null) {
                            @Override
                            public void onSuccess(ResponseBean<List<PostStandardJsonArray>> 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<FileDownlodConfig>>() {
                                    @Override
                                    public void onSuccess(ResponseBean<FileDownlodConfig> 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<String>>(true,null) {
          @Override
          public void onSuccess(ResponseBean<String> 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掛鉤.

本庫已實現.

image-20210812114553995

5.3 通用UI支援

loadingDialog

內建,預設不顯示.可配置開關,UI樣式

image-20210812120031799

錯誤msg的toast

比較方便的做法是在onError裡統一處理,預設關閉,可以通過鏈式api開啟.

測試環境應toast: code+"\n"+msg. 且測試環境的msg應儘量帶棧資訊.

錯誤碼轉文案

一般,應在框架內統一處理.

分三個型別:

底層框架丟擲的exception,應轉為友好文案

http請求本身的錯誤碼,比如400,500之類的,應提供統一文案

業務data-code-msg內,如果msg部分後臺不做國際化,那麼客戶端應配置對應的翻譯文案.

框架應自動處理前兩個,並提供第三種業務錯誤文案的配置介面:

image-20210812142136483

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 "";
            }
        });

內部已配置文案:(中文+英文)

image-20210812142429177

5.4 響應體格式校驗

bean validator這件事情在服務端接收客戶端/瀏覽器請求時比較常用.已經發展成為了一項java規範.

其實這個需求在客戶端並不強烈.服務端的返回大多數情況還是比較穩定的,出現丟欄位,欄位錯誤等情況比較少.

不過,為了小裝一個X,我還是把這個功能實現了-->

其實也不是實現,只是把服務端常用的功能遷移到移動端,並進行了適配. 做了一點微小的工作.

請看:

AndroidBeanValidator

要移植到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;

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還原,再加入.

可參考:

AddCommonHeaderAndParamInterceptor

如果涉及到請求體簽名,那麼務必將此攔截器加到簽名攔截器之前.

5.8 請求超時

okhttp不是有超時設定麼?

之前只有connecTimeout,read,write三個超時時間,現在看,已新增callTimeout,涵蓋了okhttp層面的整個請求過程.

image-20210812151248714

對於當初沒有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<Throwable>() {
            @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 <T> void onError(MyNetCallback<T> 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環境忽略證照

    <network-security-config>
        <debug-overrides>
            <trust-anchors>
                <certificates src="system"/>
                <certificates src="user"/>
            </trust-anchors>
        </debug-overrides>
        <base-config cleartextTrafficPermitted="true">
            <trust-anchors>
                <certificates src="system" />
            </trust-anchors>
        </base-config>
    </network-security-config>
    

    或者直接網路框架在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.

主要還是工程問題比較難處理.寫得好的框架不多.我這個沒有做這個斷點續傳功能.

image-20210812171218765

5.16 下載後處理

抄了些迅雷等下載軟體的功能,用api的形式提供出來

比如:

  • 下載後校驗md5/sha1
  • 下載後自動開啟: 需要處理Android7的File uri permission
  • 下載後通知mediastore掃描
  • 是否隱藏檔案: 下載一些隱私檔案時用,你懂的.利用.nomedia空檔案隱藏,防君子不防小人.
  • 通知欄顯示下載進度/對話方塊顯示下載進度

image-20210812171604990

5.17 回撥形式

  • callback
  • livedata
  • 返回observable

5.18 介面聚合

場景1 多圖非同步上傳

public static io.reactivex.Observable<ResponseBean<S3Info>> uploadImgs(String type, final List<String> filePaths){
        final List<S3Info> infos = new ArrayList<>();
        io.reactivex.Observable<ResponseBean<S3Info>> observable =
                HttpUtil.requestAsJsonArray(getUploadTokenPath,S3Info.class)
                .get()
                .addParam("type", type)
                .addParam("contentType", IMAGE_JPEG)
                .addParam("cnt",filePaths.size())
                .asObservable()
                .flatMap(new Function<ResponseBean<List<S3Info>>, ObservableSource<ResponseBean<S3Info>>>() {
                    @Override
                    public ObservableSource<ResponseBean<S3Info>> apply(ResponseBean<List<S3Info>> bean) throws Exception {

                        infos.addAll(bean.bean);
                        List<io.reactivex.ObservableSource<ResponseBean<S3Info>>> 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<S3Info>> 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實現通用的聚合介面請求.

每個介面可配置能否接受失敗

程式碼

HttpUtil2

flipperUtil

AndroidBeanValidator

AddCommonHeaderAndParamInterceptor

frida使用

相關文章