Retrofit2 源碼解析

原文鏈接:http://bxbxbai.github.io/2015/12/13/retrofit2-analysis/

開發(fā)Android App肯定會(huì)使用Http請(qǐng)求與服務(wù)器通信,上傳或下載數(shù)據(jù)等。目前開源的Http請(qǐng)求工具也有很多,比如Google開發(fā)的Volley,loopj的Android Async Http,Square開源的OkHttp或者Retrofit等。

我覺得Retrofit 無疑是這幾個(gè)當(dāng)中最好用的一個(gè),設(shè)計(jì)這個(gè)庫(kù)的思路很特別而且巧妙。Retrofit的代碼很少,花點(diǎn)時(shí)間讀它的源碼肯定會(huì)收獲很多

本文的源碼分析基于Retrofit 2,和Retrofit 1.0的Api有較大的不同, 本文主要分為幾部分:0、Retrofit 是什么,1、Retrofit怎么用,2、Retrofit的原理是什么,3、我的心得與看法

0 Retrofit是什么

來自Retrofit官網(wǎng)的介紹:

A type-safe HTTP client for Android and Java

簡(jiǎn)單的說它是一個(gè)基于OkHttp的RESTFUL Api請(qǐng)求工具,從功能上來說和Google的Volley功能上很相似,但是使用上很不相似。

Volley使用上更加原始而且符合使用者的直覺,當(dāng)App要發(fā)送一個(gè)Http請(qǐng)求時(shí),你需要先創(chuàng)建一個(gè)Request對(duì)象,指定這個(gè)Request用的是GET、POST或其他方法,一個(gè)api 地址,一個(gè)處理response的回調(diào),如果是一個(gè)POST請(qǐng)求,那么你還需要給這個(gè)Request對(duì)象設(shè)置一個(gè)body,有時(shí)候你還需要自定義添加Header什么的,然后將這個(gè)Request對(duì)象添加到RequestQueue中,接下去檢查Cache以及發(fā)送Http請(qǐng)求的事情,Volley會(huì)幫你處理。如果一個(gè)App中api不同的api請(qǐng)求很多,這樣代碼就會(huì)很難看。

而Retrofit可以讓你簡(jiǎn)單到調(diào)用一個(gè)Java方法的方式去請(qǐng)求一個(gè)api,這樣App中的代碼就會(huì)很簡(jiǎn)潔方便閱讀

1 Retrofit怎么用

雖然Retrofit官網(wǎng)已經(jīng)說明了,我還是要按照我的思路說一下它的使用方法

比如你要請(qǐng)求這么一個(gè)api,查看知乎專欄的某個(gè)作者信息:

https://zhuanlan.zhihu.com/api/columns/{user}

首先,你需要?jiǎng)?chuàng)建一個(gè)Retrofit對(duì)象,并且指定api的域名:

public static final String API_URL = "https://zhuanlan.zhihu.com";

Create a very simple REST adapter which points the Zhuanlan API.
Retrofit retrofit = new Retrofit.Builder()
    .baseUrl(API_URL)
    .addConverterFactory(GsonConverterFactory.create())
    .build();

其次,你要根據(jù)api新建一個(gè)Java接口,用Java注解來描述這個(gè)api

public interface ZhuanLanApi {
    @GET("/api/columns/{user} ")
    Call<ZhuanLanAuthor> getAuthor(@Path("user") String user)
}

再用這個(gè)retrofit對(duì)象創(chuàng)建一個(gè)ZhuanLanApi對(duì)象:

ZhuanLanApi api = retrofit.create(ZhuanLanApi.class);

Call<ZhuanLanAuthor> call = api.getAuthor("qinchao");

這樣就表示你要請(qǐng)求的api是https://zhuanlan.zhihu.com/api/columns/qinchao

最后你就可以用這個(gè)call對(duì)象獲得數(shù)據(jù)了,enqueue方法是異步發(fā)送http請(qǐng)求的,如果你想用同步的方式發(fā)送可以使用execute()方法,call對(duì)象還提供cancel()isCancel()等方法獲取這個(gè)Http請(qǐng)求的狀態(tài)

// 請(qǐng)求數(shù)據(jù),并且處理response
call.enqueue(new Callback<ZhuanLanAuthor>() {
    @Override
    public void onResponse(Response<ZhuanLanAuthor> author) {
        System.out.println("name: " + author.getName());
    }
    @Override
    public void onFailure(Throwable t) {
    }
});

看到?jīng)],Retrofit只要創(chuàng)建一個(gè)接口來描述Http請(qǐng)求,然后可以讓我們可以像調(diào)用Java方法一樣請(qǐng)求一個(gè)Api,是不是覺得很神奇,很不可思議!!

2 Retrofit的原理

從上面Retrofit的使用來看,Retrofit就是充當(dāng)了一個(gè)適配器(Adapter)的角色:將一個(gè)Java接口翻譯成一個(gè)Http請(qǐng)求,然后用OkHttp去發(fā)送這個(gè)請(qǐng)求**

Volley描述一個(gè)HTTP請(qǐng)求是需要?jiǎng)?chuàng)建一個(gè)Request對(duì)象,而執(zhí)行這個(gè)請(qǐng)求呢,就是把這個(gè)請(qǐng)求對(duì)象放到一個(gè)隊(duì)列中,在網(wǎng)絡(luò)線程中用HttpUrlConnection去請(qǐng)求

問題來了:

Retrofit是怎么做的呢?

答案很簡(jiǎn)單,就是:Java的動(dòng)態(tài)代理

動(dòng)態(tài)代理

我剛開始看Retrofit的代碼,我對(duì)下面這句代碼感到很困惑:

ZhuanLanApi api = retrofit.create(ZhuanLanApi.class);

我給Retrofit對(duì)象傳了一個(gè)ZhuanLanApi接口的Class對(duì)象,怎么又返回一個(gè)ZhuanLanApi對(duì)象呢?進(jìn)入create方法一看,沒幾行代碼,但是我覺得這幾行代碼就是Retrofit的精妙的地方

/** Create an implementation of the API defined by the {@code service} interface. */
public <T> T create(final Class<T> service) {
  Utils.validateServiceInterface(service);
  if (validateEagerly) {
     eagerlyValidateMethods(service);
  }
  return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class<?>[] { service },
    new InvocationHandler() {
      private final Platform platform = Platform.get();

      @Override public Object invoke(Object proxy, Method method, Object... args)
          throws Throwable {
        // If the method is a method from Object then defer to normal invocation.
        if (method.getDeclaringClass() == Object.class) {
          return method.invoke(this, args);
        }
        if (platform.isDefaultMethod(method)) {
          return platform.invokeDefaultMethod(method, service, proxy, args);
        }
        ServiceMethod serviceMethod = loadServiceMethod(method);
        OkHttpCall okHttpCall = new OkHttpCall<>(serviceMethod, args);
        return serviceMethod.callAdapter.adapt(okHttpCall);
      }
    });

}

create方法就是返回了一個(gè)Proxy.newProxyInstance動(dòng)態(tài)代理對(duì)象。那么問題來了...

動(dòng)態(tài)代理是個(gè)什么東西?

看Retrofit代碼之前我知道Java動(dòng)態(tài)代理是一個(gè)很重要的東西,比如在Spring框架里大量的用到,但是它有什么用呢?

Java動(dòng)態(tài)代理就是給了程序員一種可能:當(dāng)你要調(diào)用某個(gè)Class的方法前或后,插入你想要執(zhí)行的代碼

比如你要執(zhí)行某個(gè)操作前,你必須要判斷這個(gè)用戶是否登錄,或者你在付款前,你需要判斷這個(gè)人的賬戶中存在這么多錢。這么簡(jiǎn)單的一句話,我相信可以把一個(gè)不懂技術(shù)的人也講明白Java動(dòng)態(tài)代理是什么東西了。

為什么要使用動(dòng)態(tài)代理

你看上面代碼,獲取數(shù)據(jù)的代碼就是這句:

Call<ZhuanLanAuthor> call = api.getAuthor("qinchao");

上面api對(duì)象其實(shí)是一個(gè)動(dòng)態(tài)代理對(duì)象,并不是一個(gè)真正的ZhuanLanApi接口的implements產(chǎn)生的對(duì)象,當(dāng)api對(duì)象調(diào)用getAuthor方法時(shí)會(huì)被動(dòng)態(tài)代理攔截,然后調(diào)用Proxy.newProxyInstance方法中的InvocationHandler對(duì)象,它的invoke方法會(huì)傳入3個(gè)參數(shù):

  • Object proxy: 代理對(duì)象,不關(guān)心這個(gè)
  • Method method:調(diào)用的方法,就是getAuthor方法
  • Object... args:方法的參數(shù),就是"qinchao"

而Retrofit關(guān)心的就是method和它的參數(shù)args,接下去Retrofit就會(huì)用Java反射獲取到getAuthor方法的注解信息,配合args參數(shù),創(chuàng)建一個(gè)ServiceMethod對(duì)象

ServiceMethod就像是一個(gè)中央處理器,傳入Retrofit對(duì)象和Method對(duì)象,調(diào)用各個(gè)接口和解析器,最終生成一個(gè)Request,包含api 的域名、path、http請(qǐng)求方法、請(qǐng)求頭、是否有body、是否是multipart等等。最后返回一個(gè)Call對(duì)象,Retrofit2中Call接口的默認(rèn)實(shí)現(xiàn)是OkHttpCall,它默認(rèn)使用OkHttp3作為底層http請(qǐng)求client

使用Java動(dòng)態(tài)代理的目的就要攔截被調(diào)用的Java方法,然后解析這個(gè)Java方法的注解,最后生成Request由OkHttp發(fā)送

3 Retrofit的源碼分析

想要弄清楚Retrofit的細(xì)節(jié),先來看一下Retrofit源碼的組成:

  1. 一個(gè)retrofit2.http包,里面全部是定義HTTP請(qǐng)求的Java注解,比如GETPOSTPUTDELETEHeadersPathQuery等等
  2. 余下的retrofit2包中幾個(gè)類和接口就是全部retrofit的代碼了,代碼真的很少,很簡(jiǎn)單,因?yàn)閞etrofit把網(wǎng)絡(luò)請(qǐng)求這部分功能全部交給了OkHttp了

Retrofit接口

Retrofit的設(shè)計(jì)非常插件化而且輕量級(jí),真的是非常高內(nèi)聚而且低耦合,這個(gè)和它的接口設(shè)計(jì)有關(guān)。Retrofit中定義了4個(gè)接口:

Callback<T>

這個(gè)接口就是retrofit請(qǐng)求數(shù)據(jù)返回的接口,只有兩個(gè)方法

  • void onResponse(Response<T> response);
  • void onFailure(Throwable t);

Converter<F, T>

這個(gè)接口主要的作用就是將HTTP返回的數(shù)據(jù)解析成Java對(duì)象,主要有Xml、Gson、protobuf等等,你可以在創(chuàng)建Retrofit對(duì)象時(shí)添加你需要使用的Converter實(shí)現(xiàn)(看上面創(chuàng)建Retrofit對(duì)象的代碼)

Call<T>

這個(gè)接口主要的作用就是發(fā)送一個(gè)HTTP請(qǐng)求,Retrofit默認(rèn)的實(shí)現(xiàn)是OkHttpCall<T>,你可以根據(jù)實(shí)際情況實(shí)現(xiàn)你自己的Call類,這個(gè)設(shè)計(jì)和Volley的HttpStack接口設(shè)計(jì)的思想非常相似,子類可以實(shí)現(xiàn)基于HttpClientHttpUrlConnetction的HTTP請(qǐng)求工具,這種設(shè)計(jì)非常的插件化,而且靈活

CallAdapter<T>

上面說到過,CallAdapter中屬性只有responseType一個(gè),還有一個(gè)<R> T adapt(Call<R> call)方法,這個(gè)接口的實(shí)現(xiàn)類也只有一個(gè),DefaultCallAdapter。這個(gè)方法的主要作用就是將Call對(duì)象轉(zhuǎn)換成另一個(gè)對(duì)象,可能是為了支持RxJava才設(shè)計(jì)這個(gè)類的吧

Retrofit的運(yùn)行過程

上面講到ZhuanLanApi api = retrofit.create(ZhuanLanApi.class);代碼返回了一個(gè)動(dòng)態(tài)代理對(duì)象,而執(zhí)行Call<ZhuanLanAuthor> call = api.getAuthor("qinchao");代碼時(shí)返回了一個(gè)OkHttpCall對(duì)象,拿到這個(gè)Call對(duì)象才能執(zhí)行HTTP請(qǐng)求

上面api對(duì)象其實(shí)是一個(gè)動(dòng)態(tài)代理對(duì)象,并不是一個(gè)真正的ZhuanLanApi接口的implements產(chǎn)生的對(duì)象,當(dāng)api對(duì)象調(diào)用getAuthor方法時(shí)會(huì)被動(dòng)態(tài)代理攔截,然后調(diào)用Proxy.newProxyInstance方法中的InvocationHandler對(duì)象, 創(chuàng)建一個(gè)ServiceMethod對(duì)象

ServiceMethod serviceMethod = loadServiceMethod(method);
OkHttpCall okHttpCall = new OkHttpCall<>(serviceMethod, args);
return serviceMethod.callAdapter.adapt(okHttpCall);

創(chuàng)建ServiceMethod

剛才說到,ServiceMethod就像是一個(gè)中央處理器,具體來看一下創(chuàng)建這個(gè)ServiceMethod的過程是怎么樣的

第一步,獲取到上面說到的3個(gè)接口對(duì)象:

callAdapter = createCallAdapter();
responseType = callAdapter.responseType();
responseConverter = createResponseConverter();

第二步,解析Method的注解,主要就是獲取Http請(qǐng)求的方法,比如是GET還是POST還是其他形式,如果沒有,程序就會(huì)報(bào)錯(cuò),還會(huì)做一系列的檢查,比如如果在方法上注解了@Multipart,但是Http請(qǐng)求方法是GET,同樣也會(huì)報(bào)錯(cuò)。因此,在注解Java方法是需要嚴(yán)謹(jǐn)

for (Annotation annotation : methodAnnotations) {
    parseMethodAnnotation(annotation);
}

if (httpMethod == null) {
   throw methodError("HTTP method annotation is required (e.g., @GET, @POST, etc.).");
}

第三步,比如上面api中帶有一個(gè)參數(shù){user},這是一個(gè)占位符,而真實(shí)的參數(shù)值在Java方法中傳入,那么Retrofit會(huì)使用一個(gè)ParameterHandler來進(jìn)行替換:

int parameterCount = parameterAnnotationsArray.length;
parameterHandlers = new ParameterHandler<?>[parameterCount];

最后,ServiceMethod會(huì)做其他的檢查,比如用了@FormUrlEncoded注解,那么方法參數(shù)中必須至少有一個(gè)@Field@FieldMap

執(zhí)行Http請(qǐng)求

之前講到,OkHttpCall是實(shí)現(xiàn)了Call接口的,并且是真正調(diào)用OkHttp3發(fā)送Http請(qǐng)求的類。OkHttp3發(fā)送一個(gè)Http請(qǐng)求需要一個(gè)Request對(duì)象,而這個(gè)Request對(duì)象就是從ServiceMethodtoRequest返回的

總的來說,OkHttpCall就是調(diào)用ServiceMethod獲得一個(gè)可以執(zhí)行的Request對(duì)象,然后等到Http請(qǐng)求返回后,再將response body傳入ServiceMethod中,ServiceMethod就可以調(diào)用Converter接口將response body轉(zhuǎn)成一個(gè)Java對(duì)象

結(jié)合上面說的就可以看出,ServiceMethod中幾乎保存了一個(gè)api請(qǐng)求所有需要的數(shù)據(jù),OkHttpCall需要從ServiceMethod中獲得一個(gè)Request對(duì)象,然后得到response后,還需要傳入ServiceMethodConverter轉(zhuǎn)換成Java對(duì)象

你可能會(huì)覺得我只要發(fā)送一個(gè)HTTP請(qǐng)求,你要做這么多事情不會(huì)很“慢”嗎?不會(huì)很浪費(fèi)性能嗎?

我覺得,首先現(xiàn)在手機(jī)處理器主頻非常高了,解析這個(gè)接口可能就花1ms可能更少的時(shí)間(我沒有測(cè)試過),面對(duì)一個(gè)HTTP本來就需要幾百ms,甚至幾千ms來說不值得一提;而且Retrofit會(huì)對(duì)解析過的請(qǐng)求進(jìn)行緩存,就在Map<Method, ServiceMethod> serviceMethodCache = new LinkedHashMap<>();這個(gè)對(duì)象中

如何在Retrofit中使用RxJava

由于Retrofit設(shè)計(jì)的擴(kuò)展性非常強(qiáng),你只需要添加一個(gè)CallAdapter就可以了

Retrofit retrofit = new Retrofit.Builder()
  .baseUrl("https://api.github.com")
  .addConverterFactory(ProtoConverterFactory.create())
  .addConverterFactory(GsonConverterFactory.create())
  .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
  .build();

上面代碼創(chuàng)建了一個(gè)Retrofit對(duì)象,支持Proto和Gson兩種數(shù)據(jù)格式,并且還支持RxJava

4 最后

Retrofit非常巧妙的用注解來描述一個(gè)HTTP請(qǐng)求,將一個(gè)HTTP請(qǐng)求抽象成一個(gè)Java接口,然后用了Java動(dòng)態(tài)代理的方式,動(dòng)態(tài)的將這個(gè)接口的注解“翻譯”成一個(gè)HTTP請(qǐng)求,最后再執(zhí)行這個(gè)HTTP請(qǐng)求

Retrofit的功能非常多的依賴Java反射,代碼中其實(shí)還有很多細(xì)節(jié),比如異常的捕獲、拋出和處理,大量的Factory設(shè)計(jì)模式(為什么要這么多使用Factory模式?)

Retrofit中接口設(shè)計(jì)的恰到好處,在你創(chuàng)建Retrofit對(duì)象時(shí),讓你有更多更靈活的方式去處理你的需求,比如使用不同的Converter、使用不同的CallAdapter,這也就提供了你使用RxJava來調(diào)用Retrofit的可能

我也慢慢看了Picasso和Retrofit的代碼了,收獲還是很多的,也更加深入的理解面向接口的編程方法,這個(gè)寫代碼就是好的代碼就是依賴接口而不是實(shí)現(xiàn)最好的例子

好感謝開源的世界,讓我能讀到大牛的代碼。我一直覺得一個(gè)人如果沒有讀過好的代碼是不太可能寫出好代碼的。什么是好的代碼?像Picasso和Retrofit這樣的就是好的代碼,擴(kuò)展性強(qiáng)、低耦合、插件化

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,488評(píng)論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,034評(píng)論 3 414
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,327評(píng)論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,554評(píng)論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,337評(píng)論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,883評(píng)論 1 321
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,975評(píng)論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,114評(píng)論 0 286
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,625評(píng)論 1 332
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,555評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,737評(píng)論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,244評(píng)論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 43,973評(píng)論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,615評(píng)論 1 280
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,343評(píng)論 3 390
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,699評(píng)論 2 370

推薦閱讀更多精彩內(nèi)容

  • 本文的源碼分析基于Retrofit 2,和Retrofit 1.0的Api有較大的不同, 本文主要分為幾部分:1、...
    小帝Ele閱讀 286評(píng)論 0 1
  • 主目錄見:Android高級(jí)進(jìn)階知識(shí)(這是總目錄索引)?我們知道Retrofit2是基于OkHttp的一個(gè)Rest...
    ZJ_Rocky閱讀 1,553評(píng)論 0 6
  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,589評(píng)論 25 707
  • 在開發(fā)Android APP時(shí),肯定會(huì)使用到Http請(qǐng)求與服務(wù)器通信,上傳或下載數(shù)據(jù)等功能。目前開源的Http請(qǐng)求...
    wangling90閱讀 485評(píng)論 0 0
  • 文/秦溯之 大地蒼茫涵正氣,虬枝傲雪向天開。 寧為獨(dú)秀迎寒冽,不與群芳競(jìng)玉腮。 和靖梅詩(shī)已吟盡,李君妙筆又重來。 ...
    秦溯之閱讀 817評(píng)論 3 8