基於retrofit的網路框架的終極封裝(二)-與retrofit的對接與解耦,以及遇到的坑

水牛發表於2016-12-04

在上一篇基於retrofit的網路框架的終極封裝(一)中介紹了頂層api的設計.這裡再沿著程式碼走向往裡說.
由於這裡講的是retrofit的封裝性使用,所以一些retrofit基礎性的使用和配置這裡就不講了.

引數怎麼傳遞到retrofit層的?

所有網路請求相關的引數和配置全部通過第一層的api和鏈式呼叫封裝到了ConfigInfo中,最後在start()方法中呼叫retrofit層,開始網路請求.

/**
 * 在這裡組裝請求,然後發出去
 * @param <E>
 * @return
 */
@Override
public <E> ConfigInfo<E> start(ConfigInfo<E> configInfo) {

    String url = Tool.appendUrl(configInfo.url, isAppendUrl());//組拼baseUrl和urltail
    configInfo.url = url;
    configInfo.listener.url = url;

    //todo 這裡token還可能在請求頭中,應加上此類情況的自定義.
    if (configInfo.isAppendToken){
        Tool.addToken(configInfo.params);
    }

    if (configInfo.loadingDialog != null && !configInfo.loadingDialog.isShowing()){
        try {//預防badtoken最簡便和直接的方法
            configInfo.loadingDialog.show();
        }catch (Exception e){
        }
    }

    if (getCache(configInfo)){//非同步,去拿快取--只針對String型別的請求
        return configInfo;
    }
    T request = generateNewRequest(configInfo);//根據型別生成/執行不同的請求物件

    /*
    這三個方式是給volley預留的
    setInfoToRequest(configInfo,request);
    cacheControl(configInfo,request);
    addToQunue(request);*/

    return configInfo;
}複製程式碼

分類生成/執行各類請求:

 private <E> T generateNewRequest(ConfigInfo<E> configInfo) {
    int requestType = configInfo.type;
    switch (requestType){
        case ConfigInfo.TYPE_STRING:
        case ConfigInfo.TYPE_JSON:
        case ConfigInfo.TYPE_JSON_FORMATTED:
            return  newCommonStringRequest(configInfo);
        case ConfigInfo.TYPE_DOWNLOAD:
            return newDownloadRequest(configInfo);
        case ConfigInfo.TYPE_UPLOAD_WITH_PROGRESS:
            return newUploadRequest(configInfo);
        default:return null;
    }
}複製程式碼

所以,對retrofit的使用,只要實現以下三個方法就行了:
如果切換到volley或者其他網路框架,也是實現這三個方法就好了.

newCommonStringRequest(configInfo),
newDownloadRequest(configInfo);
newUploadRequest(configInfo)複製程式碼

String類請求在retrofit中的封裝:

 @Override
protected <E> Call newCommonStringRequest(final ConfigInfo<E> configInfo) {
    Call<ResponseBody> call;
    if (configInfo.method == HttpMethod.GET){
        call = service.executGet(configInfo.url,configInfo.params);
    }else if (configInfo.method == HttpMethod.POST){
        if(configInfo.paramsAsJson){//引數在請求體以json的形式發出
            String jsonStr = MyJson.toJsonStr(configInfo.params);
            Log.e("dd","jsonstr request:"+jsonStr);
            RequestBody body = RequestBody.create(MediaType.parse("application/json;charset=UTF-8"), jsonStr);
            call = service.executeJsonPost(configInfo.url,body);
        }else {
            call = service.executePost(configInfo.url,configInfo.params);
        }
    }else {
        configInfo.listener.onError("不是get或post方法");//暫時不考慮其他方法
        call = null;
        return call;
    }
    configInfo.tagForCancle = call;

    call.enqueue(new Callback<ResponseBody>() {
        @Override
        public void onResponse(Call<ResponseBody> call, final Response<ResponseBody> response) {
            if (!response.isSuccessful()){
                        configInfo.listener.onCodeError("http錯誤碼為:"+response.code(),response.message(),response.code());
                Tool.dismiss(configInfo.loadingDialog);
                return;
            }
            String string = "";
            try {
                string =  response.body().string();
                Tool.parseStringByType(string,configInfo);
                Tool.dismiss(configInfo.loadingDialog);

            } catch (final IOException e) {
                e.printStackTrace();
                        configInfo.listener.onError(e.toString());
                Tool.dismiss(configInfo.loadingDialog);
            }
        }
        @Override
        public void onFailure(Call<ResponseBody> call, final Throwable t) {

                    configInfo.listener.onError(t.toString());
            Tool.dismiss(configInfo.loadingDialog);
        }
    });
    return call;
}複製程式碼

service中通用方法的封裝

既然要封裝,肯定就不能用retrofit的常規用法:ApiService介面裡每個介面文件上的介面都寫一個方法,而是應該用QueryMap/FieldMap註解,接受一個以Map形式封裝好的鍵值對.這個與我們上一層的封裝思路和形式都是一樣的.

@GET()
Call<ResponseBody> executGet(@Url String url, @QueryMap Map<String, String> maps);

/**
 * 注意:
 * 1.如果方法的泛型指定的類不是ResonseBody,retrofit會將返回的string成用json轉換器自動轉換該類的一個物件,轉換不成功就報錯.
 *  如果不需要gson轉換,那麼就指定泛型為ResponseBody,
 *  只能是ResponseBody,子類都不行,同理,下載上傳時,也必須指定泛型為ResponseBody
 * 2. map不能為null,否則該請求不會執行,但可以size為空.
 * 3.使用@url,而不是@Path註解,後者放到方法體上,會強制先urlencode,然後與baseurl拼接,請求無法成功.
 * @param url
 * @param map
 * @return
 */
@FormUrlEncoded
@POST()
Call<ResponseBody> executePost(@Url String url, @FieldMap Map<String, String> map);


/**
 * 直接post體為一個json格式時,使用這個方法.注意:@Body 不能與@FormUrlEncoded共存
 * @param url
 * @param body
 * @return
 */
@POST()
Call<ResponseBody> executeJsonPost(@Url String url, @Body RequestBody body);複製程式碼

post引數體以json的形式發出時需要注意:

retrofit其實有請求時傳入一個javabean的註解方式,確實可以在框架內部轉換成json.但是不適合封裝.
其實很簡單,搞清楚以json形式發出引數的本質: 請求體中的json本質上還是一個字串.那麼可以將Map攜帶過來的引數轉成json字串,然後用RequestBody包裝一層就好了:

 String jsonStr = MyJson.toJsonStr(configInfo.params);
 RequestBody body = RequestBody.create(MediaType.parse("application/json;charset=UTF-8"), jsonStr);
 call = service.executeJsonPost(configInfo.url,body);複製程式碼

不採用retrofit的json轉換功能:

Call的泛型不能採用二次泛型的形式--retrofit框架不接受:

@GET()
<T>  Call<BaseNetBean<T>> getStandradJson(@Url String url, @QueryMap Map<String, String> maps);


//注:BaseNetBean就是三個標準欄位的json:
public class BaseNetBean<T>{
     public int code;
    public String msg;
    public T data;
}複製程式碼

這樣寫會丟擲異常:
報的錯誤

Method return type must not include a type variable or wildcard: retrofit2.Call<T>複製程式碼

JakeWharton的回覆:
You cannot. Type information needs to be fully known at runtime in order for deserialization to work.

因為上面的原因,我們只能通過retrofit發請求,返回一個String,自己去解析.但這也有坑:

1.不能寫成下面的形式:

@GET()
Call<String> executGet(@Url String url, @QueryMap Map<String, String> maps);複製程式碼

你以為指定泛型為String它就返回String,不,你還太年輕了.
這裡的泛型,意思是,使用retrofit內部的json轉換器,將response裡的資料轉換成一個實體類xxx,比如UserBean之類的,而String類明顯不是一個有效的實體bean類,自然轉換失敗.
所以,要讓retrofit不適用內建的json轉換功能,你應該直接指定型別為ResponseBody:

@GET()
Call<ResponseBody> executGet(@Url String url, @QueryMap Map<String, String> maps);複製程式碼

2.既然不採用retrofit內部的json轉換功能,那就要在回撥那裡自己拿到字串,用自己的json解析了.那麼坑又來了:
泛型擦除:
回撥介面上指定泛型,在回撥方法裡直接拿到泛型,這是在java裡很常見的一個泛型介面設計:

public abstract class MyNetListener<T>{
    public abstract void onSuccess(T response,String resonseStr);
    ....
}

//使用:
 call.enqueue(new Callback<ResponseBody>() {
        @Override
        public void onResponse(Call<ResponseBody> call, final Response<ResponseBody> response) {
          String  string =  response.body().string();
            Gson gson = new Gson();
            Type objectType = new TypeToken<T>() {}.getType();
            final T bean = gson.fromJson(string,objectType);
            configInfo.listener.onSuccess(bean,string);
            ...
        }
        ...
    }複製程式碼

但是,丟擲異常:

java.lang.ClassCastException: com.google.gson.internal.LinkedTreeMap cannot be cast to xxx複製程式碼

這是因為在執行過程中,通過泛型傳入的型別T丟失了,所以無法轉換,這叫做泛型擦除:.
要解析的話,還是老老實實傳入javabean的class吧.所以在最頂層的API裡,有一個必須傳的Class clazz:

postStandardJson(String url, Map map, Class clazz, MyNetListener listener)複製程式碼

綜上,我們需要傳入class物件,完全自己去解析json.解析已封裝成方法.也是根據三個不同的小型別(字串,一般json,標準json)

這裡處理快取時,如果要快取內容,當然是快取成功的內容,失敗的就不必快取了.

Tool.parseStringByType(string,configInfo);

 public static  void parseStringByType(final String string, final ConfigInfo configInfo) {
    switch (configInfo.type){
        case ConfigInfo.TYPE_STRING:
            //快取
            cacheResponse(string, configInfo);
            //處理結果
             configInfo.listener.onSuccess(string, string);
            break;
        case ConfigInfo.TYPE_JSON:
             parseCommonJson(string,configInfo);
            break;
        case ConfigInfo.TYPE_JSON_FORMATTED:
            parseStandJsonStr(string, configInfo);
            break;
    }
}複製程式碼

json解析框架選擇,gson,fastjson隨意,不過最好也是自己再包一層api:

public static <T> T  parseObject(String str,Class<T> clazz){
   // return new Gson().fromJson(str,clazz);
     return JSON.parseObject(str,clazz);
}複製程式碼

注意區分返回的是jsonObject還是jsonArray,有不同的解析方式和回撥.

 private static <E> void parseCommonJson( String string, ConfigInfo<E> configInfo) {
    if (isJsonEmpty(string)){
        configInfo.listener.onEmpty();
    }else {
        try{
            if (string.startsWith("{")){
                E bean =  MyJson.parseObject(string,configInfo.clazz);
                configInfo.listener.onSuccessObj(bean ,string,string,0,"");
                cacheResponse(string, configInfo);
            }else if (string.startsWith("[")){
                List<E> beans =  MyJson.parseArray(string,configInfo.clazz);
                configInfo.listener.onSuccessArr(beans,string,string,0,"");
                cacheResponse(string, configInfo);
            }else {
                configInfo.listener.onError("不是標準json格式");
            }
        }catch (Exception e){
            e.printStackTrace();
            configInfo.listener.onError(e.toString());
        }
    }
}複製程式碼

標準json的解析:

三個欄位對應的資料直接用jsonObject.optString來取:

        JSONObject object = null;
        try {
            object = new JSONObject(string);
        } catch (JSONException e) {
            e.printStackTrace();
            configInfo.listener.onError("json 格式異常");
            return;
        }
        String key_data = TextUtils.isEmpty(configInfo.key_data) ? NetDefaultConfig.KEY_DATA : configInfo.key_data;
        String key_code = TextUtils.isEmpty(configInfo.key_code) ? NetDefaultConfig.KEY_CODE : configInfo.key_code;
        String key_msg = TextUtils.isEmpty(configInfo.key_msg) ? NetDefaultConfig.KEY_MSG : configInfo.key_msg;

        final String dataStr = object.optString(key_data);
        final int code = object.optInt(key_code);
        final String msg = object.optString(key_msg);複製程式碼

注意,optString後字串為空的判斷:一個欄位為null時,optString的結果是字串"null"而不是null

public static boolean isJsonEmpty(String data){
        if (TextUtils.isEmpty(data) || "[]".equals(data)
                || "{}".equals(data) || "null".equals(data)) {
            return true;
        }
        return false;
    }複製程式碼

然後就是相關的code情況的處理和回撥:
狀態碼為未登入時,執行自動登入的邏輯,自動登入成功後再重發請求.登入不成功才執行unlogin()回撥.
注意data欄位可能是一個普通的String,而不是json.

private static <E> void parseStandardJsonObj(final String response, final String data, final int code,
                                             final String msg, final ConfigInfo<E> configInfo){

    int codeSuccess = configInfo.isCustomCodeSet ? configInfo.code_success : BaseNetBean.CODE_SUCCESS;
    int codeUnFound = configInfo.isCustomCodeSet ? configInfo.code_unFound : BaseNetBean.CODE_UN_FOUND;
    int codeUnlogin = configInfo.isCustomCodeSet ? configInfo.code_unlogin : BaseNetBean.CODE_UNLOGIN;

    if (code == codeSuccess){
        if (isJsonEmpty(data)){
            if(configInfo.isResponseJsonArray()){
                configInfo.listener.onEmpty();
            }else {
                configInfo.listener.onError("資料為空");
            }
        }else {
            try{
                if (data.startsWith("{")){
                    final E bean =  MyJson.parseObject(data,configInfo.clazz);
                     configInfo.listener.onSuccessObj(bean ,response,data,code,msg);
                    cacheResponse(response, configInfo);
                }else if (data.startsWith("[")){
                    final List<E> beans =  MyJson.parseArray(data,configInfo.clazz);
                     configInfo.listener.onSuccessArr(beans,response,data,code,msg);
                    cacheResponse(response, configInfo);
                }else {//如果data的值是一個字串,而不是標準json,那麼直接返回
                    if (String.class.equals(configInfo.clazz) ){//此時,E也應該是String型別.如果有誤,會丟擲到下面catch裡
                       configInfo.listener.onSuccess((E) data,data);
                    }else {
                        configInfo.listener.onError("不是標準的json資料");
                    }
                }
            }catch (final Exception e){
                e.printStackTrace();
                configInfo.listener.onError(e.toString());
                return;
            }
        }
    }else if (code == codeUnFound){
       configInfo.listener.onUnFound();
    }else if (code == codeUnlogin){
    //自動登入
        configInfo.client.autoLogin(new MyNetListener() {
            @Override
            public void onSuccess(Object response, String resonseStr) {
                configInfo.client.resend(configInfo);
            }
            @Override
            public void onError(String error) {
                super.onError(error);
                 configInfo.listener.onUnlogin();
            }
        });
    }else {
       configInfo.listener.onCodeError(msg,"",code);
    }
}複製程式碼

檔案下載

先不考慮多執行緒下載和斷點續傳的問題,就單單檔案下載而言,用retrofit寫還是挺簡單的

1.讀寫的超時時間的設定:

不能像上面字元流型別的請求一樣設定多少s,而應該設為0,也就是不限時:

 OkHttpClient client=httpBuilder.readTimeout(0, TimeUnit.SECONDS)
            .connectTimeout(30, TimeUnit.SECONDS).writeTimeout(0, TimeUnit.SECONDS) //設定超時複製程式碼

2.介面需要宣告為流式下載:

@Streaming //流式下載,不加這個註解的話,會整個檔案位元組陣列全部載入進記憶體,可能導致oom
@GET
Call<ResponseBody> download(@Url String fileUrl);複製程式碼

3.宣告瞭流式下載後,就能從回撥而來的ResponseBody中拿到輸入流(body.byteStream()),然後開子執行緒寫到本地檔案中去.

這裡用的是一個非同步任務框架,其實用Rxjava更好.

 SimpleTask<Boolean> simple = new SimpleTask<Boolean>() {
                @Override
                protected Boolean doInBackground() {
                    return writeResponseBodyToDisk(response.body(),configInfo.filePath);
                }
                @Override
                protected void onPostExecute(Boolean result) {
                    Tool.dismiss(configInfo.loadingDialog);
                    if (result){
                        configInfo.listener.onSuccess(configInfo.filePath,configInfo.filePath);
                    }else {
                        configInfo.listener.onError("檔案下載失敗");
                    }
                }
            };
            simple.execute();複製程式碼

進度回撥的兩種實現方式

最簡單的,網路流寫入到本地檔案時,獲得進度(writeResponseBodyToDisk方法裡)

byte[] fileReader = new byte[4096];
long fileSize = body.contentLength();
long fileSizeDownloaded = 0;
inputStream = body.byteStream();
 outputStream = new FileOutputStream(futureStudioIconFile);
 while (true) {
     int read = inputStream.read(fileReader);
     if (read == -1) {
       break;
      }
    outputStream.write(fileReader, 0, read);
    fileSizeDownloaded += read;
    Log.d("io", "file download: " + fileSizeDownloaded + " of " + fileSize);//  這裡也可以實現進度監聽
 }複製程式碼

利用okhttp的攔截器

1.新增下載時更新進度的攔截器

okHttpClient .addInterceptor(new ProgressInterceptor())複製程式碼

2.ProgressInterceptor:實現Interceptor介面的intercept方法,攔截網路響應

    @Override
    public Response intercept(Interceptor.Chain chain) throws IOException{
        Response originalResponse = chain.proceed(chain.request());
        return originalResponse.newBuilder().body(new ProgressResponseBody(originalResponse.body(),chain.request().url().toString())).build();
    }複製程式碼

3 ProgressResponseBody: 繼承 ResponseBody ,在內部網路流傳輸過程中讀取進度:

public class ProgressResponseBody extends ResponseBody {
    private final ResponseBody responseBody;
    private BufferedSource bufferedSource;
    private String url;

    public ProgressResponseBody(ResponseBody responseBody,String url) {
        this.responseBody = responseBody;
        this.url = url;

    }

    @Override
    public MediaType contentType() {
        return responseBody.contentType();
    }


    @Override
    public long contentLength() {
        return responseBody.contentLength();
    }

    @Override
    public BufferedSource source() {
        if (bufferedSource == null) {
            bufferedSource = Okio.buffer(source(responseBody.source()));
        }
        return bufferedSource;
    }
    long timePre = 0;
    long timeNow;

    private Source source(final Source source) {
        return new ForwardingSource(source) {
            long totalBytesRead = 0L;
            @Override
            public long read(Buffer sink, long byteCount) throws IOException {
                long bytesRead = super.read(sink, byteCount);
                totalBytesRead += bytesRead != -1 ? bytesRead : 0;
                timeNow = System.currentTimeMillis();
                if (timeNow - timePre > NetDefaultConfig.PROGRESS_INTERMEDIATE || totalBytesRead == responseBody.contentLength()){//至少300ms才更新一次狀態
                    timePre = timeNow;
                    EventBus.getDefault().post(new ProgressEvent(totalBytesRead,responseBody.contentLength(),
                            totalBytesRead == responseBody.contentLength(),url));
                }
                return bytesRead;
            }
        };
    }
}複製程式碼

進度資料以event的形式傳出(採用Eventbus),在listener中接收

一般進度資料用於更新UI,所以最好設定資料傳出的時間間隔,不要太頻繁:

事件的發出:

timeNow = System.currentTimeMillis();
if (timeNow - timePre > NetDefaultConfig.PROGRESS_INTERMEDIATE || totalBytesRead == responseBody.contentLength()){//至少300ms才更新一次狀態
    timePre = timeNow;
     EventBus.getDefault().post(new ProgressEvent(totalBytesRead,responseBody.contentLength(), totalBytesRead == responseBody.contentLength(),url));
    }複製程式碼

事件的接收(MyNetListener物件中):

注意: MyNetListener與url繫結,以防止不同下載間的進度錯亂.

@Subscribe(threadMode = ThreadMode.MAIN)
public void  onMessage(ProgressEvent event){
    if (event.url.equals(url)){
        onProgressChange(event.totalLength,event.totalBytesRead);
        if (event.done){
            unRegistEventBus();
            onFinish();
        }
    }
}複製程式碼

檔案上傳

檔案上傳相對於普通post請求有區別,你非常需要了解http檔案上傳的協議:

基於retrofit的網路框架的終極封裝(二)-與retrofit的對接與解耦,以及遇到的坑

1.提交一個表單,如果包含檔案上傳,那麼必須指定型別為multipart/form-data.這個在retrofit中通過@Multipart註解指定即可.

2.表單中還有其他鍵值對也要一同傳遞,在retrofit中通過@QueryMap以map形式傳入,這個與普通post請求一樣

3.伺服器接收檔案的欄位名,以及上傳的檔案路徑,通過@PartMap以map形式傳入.這裡的欄位名對應請求體中Content-Disposition中的name欄位的值.大多數伺服器預設是file.(因為SSH框架預設的是file?)

4.請求體的content-type用於標識檔案的具體MIME型別.在retrofit中,是在構建請求體RequestBody時指定的.需要我們指定.
那麼如何獲得一個檔案的MIMe型別呢?讀檔案的字尾名的話,不靠譜.最佳方式是讀檔案頭,從檔案頭中拿到MIME型別.不用擔心,Android有相關的api的

綜上,相關的封裝如下:

同下載一樣,配置httpclient時,讀和寫的超時時間都要置為0

OkHttpClient client=httpBuilder.readTimeout(0, TimeUnit.SECONDS)
                                .connectTimeout(0, TimeUnit.SECONDS).writeTimeout(0, TimeUnit.SECONDS) //設定超時複製程式碼

ApiService中通用介面的定義

//坑: 額外的字串引數key-value的傳遞也要用@Part或者@PartMap註解,而不能用@QueryMap或者@FieldMap註解,因為字串引數也是一個part.
@POST()
@Multipart
Call<ResponseBody> uploadWithProgress(@Url String url,@PartMap Map<String, RequestBody> options,@PartMap Map<String, RequestBody> fileParameters) ;複製程式碼

key-filepath到key-RequestBody的轉換:

這裡的回撥就不用開後臺執行緒了,因為流是在請求體中,而retrofit已經幫我們搞定了請求過程的後臺執行.

protected  Call newUploadRequest(final ConfigInfo configInfo) {
    if (serviceUpload == null){
        initUpload();
    }
    configInfo.listener.registEventBus();
    Map<String, RequestBody> requestBodyMap = new HashMap<>();
    if (configInfo.files != null && configInfo.files.size() >0){
        Map<String,String> files = configInfo.files;
        int count = files.size();
        if (count>0){
            Set<Map.Entry<String,String>> set = files.entrySet();
            for (Map.Entry<String,String> entry : set){
                String key = entry.getKey();
                String value = entry.getValue();
                File file = new File(value);
                String type = Tool.getMimeType(file);//拿到檔案的實際型別
                Log.e("type","mimetype:"+type);
                UploadFileRequestBody fileRequestBody = new UploadFileRequestBody(file, type,configInfo.url);
                requestBodyMap.put(key+"\"; filename=\"" + file.getName(), fileRequestBody);
            }
        }
    }

     Map<String, RequestBody> paramsMap = new HashMap<>();
    if (configInfo.params != null && configInfo.params.size() >0){
        Map<String,String> params = configInfo.params;
        int count = params.size();
        if (count>0){
            Set<Map.Entry<String,String>> set = params.entrySet();
            for (Map.Entry<String,String> entry : set){
                String key = entry.getKey();
                String value = entry.getValue();
                String type = "text/plain";
                RequestBody fileRequestBody = RequestBody.create(MediaType.parse(type),value);
                paramsMap.put(key, fileRequestBody);
            }
        }
    }

    Call<ResponseBody> call = serviceUpload.uploadWithProgress(configInfo.url,paramsMap,requestBodyMap);複製程式碼

抓包可以看到其傳輸的形式如下:

基於retrofit的網路框架的終極封裝(二)-與retrofit的對接與解耦,以及遇到的坑

注意,RequestBody中的content-type不是multipart/form-data,而是檔案的實際型別.multipart/form-data是請求頭中的檔案上傳的統一type.

    public class UploadFileRequestBody extends RequestBody {
        private RequestBody mRequestBody;
        private BufferedSink bufferedSink;
        private String url;
        public UploadFileRequestBody(File file,String mimeType,String url) {
           // this.mRequestBody = RequestBody.create(MediaType.parse("multipart/form-data"), file);
            this.mRequestBody = RequestBody.create(MediaType.parse(mimeType), file);
            this.url = url;
        }
         @Override
        public MediaType contentType() {
            return mRequestBody.contentType();
        }複製程式碼

進度的回撥

封裝在UploadFileRequestBody中,無需通過okhttp的攔截器實現,因為可以在構建RequestBody的時候就包裝好(看上面程式碼),就沒必要用攔截器了.

最後的話

到這裡,主要的請求執行和回撥就算講完了,但還有一些,比如快取控制,登入狀態的維護,以及cookie管理,請求的取消,gzip壓縮,本地時間校準等等必需的輔助功能的實現和維護,這些將在下一篇文章進行解析.

程式碼

github.com/hss01248/Ne…

相關文章