Spring MVC統(tǒng)一異常處理及原理分析

文章會(huì)從三個(gè)方面進(jìn)行分析:

  1. 提出統(tǒng)一異常處理機(jī)制的好處,以及該機(jī)制使用姿勢(shì)
  2. 提供案例:不使用該機(jī)制會(huì)產(chǎn)生什么樣的情況
  3. 機(jī)制背后對(duì)應(yīng)的原理分析(重點(diǎn))

機(jī)制好處及使用姿勢(shì)

Spring MVC為我們的WEB應(yīng)用提供了統(tǒng)一異常處理機(jī)制,其好處是:

  1. 業(yè)務(wù)邏輯和異常處理解耦(業(yè)務(wù)代碼不應(yīng)該過多地關(guān)注異常的處理[職責(zé)單一原則])
  2. 消除充斥各處的try catch塊代碼恤磷,使代碼更整潔
  3. 便于統(tǒng)一向前端、客戶端返回友好的錯(cuò)誤提示
使用姿勢(shì)如下
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // Http Status Code 500
    public ResponseDTO handleException(Exception e) {
        // 兜底邏輯河胎,通常用于處理未預(yù)期的異常仿粹,比如不知道哪兒冒出來的空指針異常
        log.error("", e);
        return ResponseDTO.failedResponse().withErrorMessage("服務(wù)器開小差了");
    }

    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)  // Http Status Code 400
    public ResponseDTO handleBizException(BizException e) {
        // 可預(yù)期的業(yè)務(wù)異常,根據(jù)實(shí)際情況晌区,決定是否要打印異常堆棧
        log.warn("業(yè)務(wù)異常:{}", e);
        return ResponseDTO.failedResponse().withErrorMessage(e.getMessage());
    }
}

注:該demo隱含的前提條件如下

  1. 使用Lombok(當(dāng)然恼五,也可以手動(dòng)獲取Logger)
  2. GlobalExceptionHandler需要被@ControllerAdvice(Spring 3.2+)或@RestControllerAdvice(Spring 4.3+)注解灾馒,并且能夠被Spring掃描到

為配合解釋該解決方案,再提供一些基礎(chǔ)信息

  1. 業(yè)務(wù)異常類
  2. 響應(yīng)信息包裝類
// 1
public class BizException extends RuntimeException {
    public BizException(String message) {
        super(message);
    }
}
// 2
@Data
public class ResponseDTO<T> implements Serializable {

    private static final long serialVersionUID = -3436143993984825439L;
    
    private boolean ok = false;

    private T data;

    private String errorMessage = "";

    public static ResponseDTO successResponse() {
        ResponseDTO message = new ResponseDTO();
        message.setOk(true);
        return message;
    }

    public static ResponseDTO failedResponse() {
        ResponseDTO message = new ResponseDTO();
        message.setOk(false);
        return message;
    }

    public ResponseDTO withData(T data) {
        this.data = data;
        return this;
    }

    public ResponseDTO withErrorMessage(String errorMsg) {
        this.errorMessage = errorMsg;
        return this;
    }
}

案例分析

案例分析一:
@GetMapping("/testBizException")
public ResponseDTO testBizException() {
    if (checkFailed) {
        throw new BizException("test BizException");
    }
}

當(dāng)我們請(qǐng)求/testBizException時(shí),該接口在校驗(yàn)失敗后拋出了一個(gè)BizException,用以代表我們的業(yè)務(wù)異常算芯,比如參數(shù)校驗(yàn)失敗(解決方案還有JSR-303的Bean Validation也祠,在此不討論)堪旧,優(yōu)惠券已過期等等業(yè)務(wù)異常信息淳梦。如果沒有統(tǒng)一異常處理爆袍,我們可能會(huì)使用如下方式

try {
    // check
} catch (BizException e) {
    return ResponseDTO.failedResponse().withErrorMessage("test BizException");
}

這種方式弦疮,一是不優(yōu)雅,二是業(yè)務(wù)邏輯跟異常處理耦合在了一起啸罢。

使用統(tǒng)一異常處理之后允懂,直接拋出業(yè)務(wù)異常蕾总,并提供異常上下文(message + errorCode),代碼會(huì)流轉(zhuǎn)到GlobalExceptionHandler#handleBizException置侍,統(tǒng)一打印業(yè)務(wù)日志以及返回錯(cuò)誤碼和業(yè)務(wù)異常信息,且Http Status Code 返回400秕衙。

案例分析二:
@GetMapping("/testUnExpectedException")
public ResponseDTO testUnExpectedException() {
    int i = 1 / 0;
}

當(dāng)我們請(qǐng)求/testUnExpectedException時(shí),該接口會(huì)拋出java.lang.ArithmeticException: / by zero,用以代表未預(yù)期的異常汉规,比如該案例中的0除異常,僅管此處能一眼辯識(shí)出來,但更多的時(shí)候射亏,0由變量表示及舍,很容易被忽視锯玛,又或者是其它未預(yù)期的空指針異常等。當(dāng)不知道哪里有可能會(huì)出異常,又為了前端友好提示病曾,其中一個(gè)做法就是try catch大包大攬,將整個(gè)方法都try catch住逼蒙,于是代碼產(chǎn)生了腐朽的味道陕截。

try {
    // do business
} catch (Exception e) {
    log.error("xxx", e);
    return ResponseDTO.failedResponse().withErrorMessage("服務(wù)器開小差");
}

使用統(tǒng)一異常處理之后艘策,業(yè)務(wù)代碼里不再充斥(濫用)try catch塊却汉,只需要關(guān)心業(yè)務(wù)邏輯,當(dāng)出現(xiàn)不可預(yù)期的異常時(shí),代碼會(huì)流轉(zhuǎn)到GlobalExceptionHandler#handleException谈息,統(tǒng)一打印異常堆棧,以及返回錯(cuò)誤碼和統(tǒng)一異常信息,且Http Status Code 返回500余素。

以上便是Spring MVC為我們提供的統(tǒng)一異常處理機(jī)制窑眯,我們可以好好加以利用炊林。實(shí)際上渣聚,該機(jī)制在很多公司都在使用,可以從一些開源代碼管中窺豹,其中著名的代表就有Apollo,參考com.ctrip.framework.apollo.common.controller.GlobalDefaultExceptionHandler

原理分析

了解存在的問題,以及對(duì)應(yīng)的解決方案之后闰蛔,接下來分析統(tǒng)一異常處理的工作原理

前提假設(shè):

  1. 原理分析基于Spring Boot 1.5.19.RELEASE,對(duì)應(yīng)的springframework版本為4.3.22.RELEASE
  2. 理解Spring Boot自動(dòng)裝配原理

先來分析Spring Boot是如何使GlobalExceptionHandler生效的,步驟如下:

  1. 啟動(dòng)類(XXXApplication)@SpringBootApplication注解余佃,而@SpringBootApplication又被@EnableAutoConfiguration所注解爆土,@EnableAutoConfiguration導(dǎo)入EnableAutoConfigurationImportSelector
  2. EnableAutoConfigurationImportSelector實(shí)現(xiàn)了ImportSelector接口背犯,其核心方法是selectImports漠魏,在該方法中有一行代碼是List<String> configurations = getCandidateConfigurations(annotationMetadata,attributes);其含義是通過Spring 的SPI機(jī)制,從classpath 所有jar包的META-INF/spring.factories文件中瞧毙,找到EnableAutoConfiguration對(duì)應(yīng)的"一堆"類[自動(dòng)裝配原理]鲜结。這些類作為selectImports的返回值灵汪,后期會(huì)被Spring加載并實(shí)例化,并置入IOC容器中差牛,其中有一項(xiàng)為WebMvcAutoConfiguration
  3. WebMvcAutoConfiguration類存在內(nèi)部類WebMvcAutoConfigurationAdapter驶冒,內(nèi)部類將導(dǎo)入EnableWebMvcConfiguration(WebMvcConfigurationSupport的子類)
  4. WebMvcConfigurationSupport有個(gè)factory methodhandlerExceptionResolver()骗污,該方法向Spring容器中注冊(cè)了一個(gè)HandlerExceptionResolverComposite(實(shí)現(xiàn)HandlerExceptionResolver接口),并且默認(rèn)情況下沈条,給該Composite類添加了三個(gè)HandlerExceptionResolver需忿,其中有一個(gè)類為ExceptionHandlerExceptionResolver
  5. ExceptionHandlerExceptionResolverInitializingBean的回調(diào)方法afterPropertiesSet中,調(diào)用initExceptionHandlerAdviceCache()方法進(jìn)行異常處理器通知緩存的初始化:查找IOC容器中拍鲤,所有被@ControllerAdvice注解的Bean贴谎,如果Bean中存在異常映射,則該Bean會(huì)作為key季稳,對(duì)應(yīng)的ExceptionHandlerMethodResolver作為value被緩存起來
  6. ExceptionHandlerMethodResolver是真正干活的類擅这,用于解析被@ExceptionHandler注解的方法,保存異常類及對(duì)應(yīng)的異處常理方法<exceptionType, method>景鼠。對(duì)應(yīng)到上述案例一仲翎,保存的是BizExceptionhandleBizException()方法的映射關(guān)系,表明:當(dāng)業(yè)務(wù)代碼拋出BizException時(shí)铛漓,會(huì)由handleBizException()進(jìn)行處理
private void initExceptionHandlerAdviceCache() {
    ...
    
    List<ControllerAdviceBean> adviceBeans = ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());
    AnnotationAwareOrderComparator.sort(adviceBeans);

    for (ControllerAdviceBean adviceBean : adviceBeans) {
        ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(adviceBean.getBeanType());
        if (resolver.hasExceptionMappings()) {
            this.exceptionHandlerAdviceCache.put(adviceBean, resolver);
            ...
        }
        
        ...
    }
}

應(yīng)用啟動(dòng)完畢之后溯香,GlobalExceptionHandler已經(jīng)生效,即exceptionHandlerAdviceCache已經(jīng)緩存了異常處理器及其對(duì)應(yīng)的ExceptionHandlerMethodResolver浓恶,一旦發(fā)生了異常玫坛,會(huì)從exceptionHandlerAdviceCache里依次判斷哪個(gè)異常處理器可以用,并找到對(duì)應(yīng)的異常處理方法進(jìn)行異常的處理包晰。

接著分析異常處理的具體流程湿镀,當(dāng)一個(gè)Controller方法中拋出異常后炕吸,步驟如下:

  1. org.springframework.web.servlet.DispatcherServlet#doDispatch會(huì)catch住異常,并調(diào)用processDispatchResult();方法進(jìn)行異常的處理
// DispatcherServlet#processHandlerException
            
if (exception != null) {
    if (exception instanceof ModelAndViewDefiningException) {
        logger.debug("ModelAndViewDefiningException encountered", exception);
        mv = ((ModelAndViewDefiningException) exception).getModelAndView();
    }
    else {
        Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
        mv = processHandlerException(request, response, handler, exception);
        errorView = (mv != null);
    }
}
// DispatcherServlet#processHandlerException

for (HandlerExceptionResolver handlerExceptionResolver : this.handlerExceptionResolvers) {
    exMv = handlerExceptionResolver.resolveException(request, response, handler, ex);
    if (exMv != null) {
        break;
    }
}

這里的this.handlerExceptionResolvers是在Spring Boot啟動(dòng)的過程中初始化的勉痴,其中就包含上述啟動(dòng)步驟4中的HandlerExceptionResolverComposite赫模。因此,這里會(huì)調(diào)用HandlerExceptionResolverCompositeresolveException方法進(jìn)行異常的處理

  1. XXXComposite在Spring中是個(gè)組合類蒸矛,一般內(nèi)部會(huì)維護(hù)一個(gè)由Composite父接口實(shí)例構(gòu)成的列表瀑罗,如HandlerExceptionResolverComposite實(shí)現(xiàn)了HandlerExceptionResolver接口,其內(nèi)部維護(hù)了一個(gè)HandlerExceptionResolver集合雏掠。HandlerExceptionResolverCompositeresolveException方法同樣是迭代其內(nèi)部維護(hù)的集合斩祭,并依次調(diào)用其resolveException方法進(jìn)行解析,
    其內(nèi)部集合中有一個(gè)ExceptionHandlerExceptionResolver實(shí)例磁玉,且首先會(huì)進(jìn)入該實(shí)例進(jìn)行處理
// HandlerExceptionResolverComposite#resolveException

public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler,Exception ex) {
    if (this.resolvers != null) {
        for (HandlerExceptionResolver handlerExceptionResolver : this.resolvers) {
            ModelAndView mav = handlerExceptionResolver.resolveException(request, response, handler, ex);
            if (mav != null) {
                return mav;
            }
        }
    }
    return null;
}
  1. 根據(jù)拋出的異常類型停忿,拿到異常處理器及對(duì)應(yīng)的異常處理方法,并轉(zhuǎn)化成ServletInvocableHandlerMethod蚊伞,并執(zhí)行invokeAndHandle方法席赂,也即是說,最終會(huì)轉(zhuǎn)換成執(zhí)行異常處理器的異常處理方法时迫。(org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod#invokeAndHandle 是Spring MVC處理Http請(qǐng)求中的重要方法颅停,篇幅原因不在此介紹其原理)
// ExceptionHandlerExceptionResolver#doResolveHandlerMethodException

ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception);

...

exceptionHandlerMethod.invokeAndHandle(webRequest, mavContainer, exception, handlerMethod);

...
// ExceptionHandlerExceptionResolver#getExceptionHandlerMethod

// 這段邏輯是@ExceptionHandler寫在Controller類里的處理方式,這種方式不通用也不常用掠拳,不做介紹
...

for (Map.Entry<ControllerAdviceBean, ExceptionHandlerMethodResolver> entry : this.exceptionHandlerAdviceCache.entrySet()) {
    ControllerAdviceBean advice = entry.getKey();
    // @ControllerAdvice注解可以指定僅攔截某些類癞揉,這里判斷handlerType是否在其作用域內(nèi)
    if (advice.isApplicableToBeanType(handlerType)) {
        ExceptionHandlerMethodResolver resolver = entry.getValue();
        Method method = resolver.resolveMethod(exception);
        if (method != null) {
            return new ServletInvocableHandlerMethod(advice.resolveBean(), method);
        }
    }
}

根據(jù)異常,找到異常處理方法

// ExceptionHandlerMethodResolver#resolveMethodByExceptionType

public Method resolveMethod(Exception exception) {
    Method method = resolveMethodByExceptionType(exception.getClass());
    if (method == null) {
        Throwable cause = exception.getCause();
        if (cause != null) {
            method = resolveMethodByExceptionType(cause.getClass());
        }
    }
    return method;
}
// ExceptionHandlerMethodResolver#resolveMethodByExceptionType

public Method resolveMethodByExceptionType(Class<? extends Throwable> exceptionType) {
    Method method = this.exceptionLookupCache.get(exceptionType);
    if (method == null) {
        // 核心方法
        method = getMappedMethod(exceptionType);
        this.exceptionLookupCache.put(exceptionType, (method != null ? method : NO_METHOD_FOUND));
    }
    return (method != NO_METHOD_FOUND ? method : null);
}
private Method getMappedMethod(Class<? extends Throwable> exceptionType) {
    List<Class<? extends Throwable>> matches = new ArrayList<Class<? extends Throwable>>();
    for (Class<? extends Throwable> mappedException : this.mappedMethods.keySet()) {
        if (mappedException.isAssignableFrom(exceptionType)) {
            matches.add(mappedException);
        }
    }
    // 如果找到多個(gè)匹配的異常溺欧,就排序之后取第一個(gè)(最優(yōu)的)
    if (!matches.isEmpty()) {
        Collections.sort(matches, new ExceptionDepthComparator(exceptionType));
        return this.mappedMethods.get(matches.get(0));
    }
    else {
        return null;
    }
}

案例中喊熟,我們的mappedException有兩個(gè):BizExceptionException,都滿足mappedException.isAssignableFrom(exceptionType)條件姐刁,均會(huì)被加入matches中芥牌,經(jīng)過排序之后,"最匹配"的BizException會(huì)排在matchs集合的第一個(gè)位置聂使,所以會(huì)選擇它所對(duì)應(yīng)的異常處理方法返回壁拉。因此,"最匹配"的關(guān)鍵點(diǎn)就在于比較器ExceptionDepthComparator柏靶,根據(jù)類名弃理,可以推測(cè)其出比較的依據(jù)是目標(biāo)異常類與待排序異常類的"深度"。

舉個(gè)例子屎蜓,假設(shè)目標(biāo)異常類為BizException痘昌,而待排序的集合中分別有BizExceptionRuntimeExceptionException控汉,那么他們之間的深度分別為0笔诵,1,2姑子,因此,排序之后测僵,集合中的BizException與目標(biāo)異常類BizException最為匹配街佑,排在了集合中首位,RuntimeException次匹配捍靠,排在了集合的第二位沐旨,Exception最不匹配,排在集合的第三位榨婆。

public int compare(Class<? extends Throwable> o1, Class<? extends Throwable> o2) {
    int depth1 = getDepth(o1, this.targetException, 0);
    int depth2 = getDepth(o2, this.targetException, 0);
    return (depth1 - depth2);
}

private int getDepth(Class<?> declaredException, Class<?> exceptionToMatch, int depth) {
    if (exceptionToMatch.equals(declaredException)) {
        // Found it!
        return depth;
    }
    // If we've gone as far as we can go and haven't found it...
    if (exceptionToMatch == Throwable.class) {
        return Integer.MAX_VALUE;
    }
    return getDepth(declaredException, exceptionToMatch.getSuperclass(), depth + 1);
}

總結(jié):

  1. Spring Boot應(yīng)用啟動(dòng)時(shí)磁携,會(huì)掃描被@ControllerAdvice注解的Bean,找到其內(nèi)部被@ExceptionHandler注解的方法良风,解析其所能處理的異常類谊迄,并緩存到exceptionHandlerAdviceCache

  2. 當(dāng)HTTP請(qǐng)求在Controller中發(fā)生異常,會(huì)被DispatcherServlet捕獲烟央,并調(diào)用ExceptionHandlerExceptionResolver#resolveException進(jìn)行異常的解析统诺,解析的過程依賴exceptionHandlerAdviceCache進(jìn)行真正的異常處理方法的查找,找到之后封裝成ServletInvocableHandlerMethod疑俭,然后被Spring進(jìn)行調(diào)用粮呢,也即是會(huì)回調(diào)到我們的異常處理器的異常處理方法之中,即處理了異常钞艇。

注:

本文限于篇幅原因啄寡,不會(huì)面面俱到,只重點(diǎn)分析統(tǒng)一異常處理器的生效過程哩照,以及作用過程挺物,摘出其中重點(diǎn)的代碼進(jìn)行分析而忽略了其中的一些分支情況,讀者們可自行跟蹤代碼看看其中的細(xì)節(jié)處理葡秒。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末姻乓,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子眯牧,更是在濱河造成了極大的恐慌蹋岩,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,482評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件学少,死亡現(xiàn)場(chǎng)離奇詭異剪个,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)版确,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,377評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門扣囊,熙熙樓的掌柜王于貴愁眉苦臉地迎上來乎折,“玉大人,你說我怎么就攤上這事侵歇÷畛危” “怎么了?”我有些...
    開封第一講書人閱讀 152,762評(píng)論 0 342
  • 文/不壞的土叔 我叫張陵惕虑,是天一觀的道長(zhǎng)坟冲。 經(jīng)常有香客問我,道長(zhǎng)溃蔫,這世上最難降的妖魔是什么健提? 我笑而不...
    開封第一講書人閱讀 55,273評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮伟叛,結(jié)果婚禮上私痹,老公的妹妹穿的比我還像新娘。我一直安慰自己统刮,他們只是感情好紊遵,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,289評(píng)論 5 373
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著网沾,像睡著了一般癞蚕。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上辉哥,一...
    開封第一講書人閱讀 49,046評(píng)論 1 285
  • 那天桦山,我揣著相機(jī)與錄音,去河邊找鬼醋旦。 笑死恒水,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的饲齐。 我是一名探鬼主播钉凌,決...
    沈念sama閱讀 38,351評(píng)論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼捂人!你這毒婦竟也來了御雕?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,988評(píng)論 0 259
  • 序言:老撾萬榮一對(duì)情侶失蹤滥搭,失蹤者是張志新(化名)和其女友劉穎酸纲,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體瑟匆,經(jīng)...
    沈念sama閱讀 43,476評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡闽坡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,948評(píng)論 2 324
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片疾嗅。...
    茶點(diǎn)故事閱讀 38,064評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡外厂,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出代承,到底是詐尸還是另有隱情汁蝶,我是刑警寧澤,帶...
    沈念sama閱讀 33,712評(píng)論 4 323
  • 正文 年R本政府宣布论悴,位于F島的核電站穿仪,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏意荤。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,261評(píng)論 3 307
  • 文/蒙蒙 一只锻、第九天 我趴在偏房一處隱蔽的房頂上張望玖像。 院中可真熱鬧,春花似錦齐饮、人聲如沸捐寥。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,264評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽握恳。三九已至,卻和暖如春捺僻,著一層夾襖步出監(jiān)牢的瞬間乡洼,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,486評(píng)論 1 262
  • 我被黑心中介騙來泰國打工匕坯, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留束昵,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,511評(píng)論 2 354
  • 正文 我出身青樓葛峻,卻偏偏與公主長(zhǎng)得像锹雏,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子术奖,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,802評(píng)論 2 345

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