設(shè)計(jì)之道-controller層的設(shè)計(jì)補(bǔ)遺

自從《設(shè)計(jì)之道-controller層的設(shè)計(jì)》去年發(fā)布之后实檀,收獲了許多讀者朋友和同僚的歡迎和喜愛,也收到了不少的意見和建議新翎,首先十分感謝大家的支持署鸡。同時(shí)我也一直在思考如何進(jìn)一步的優(yōu)化這部分代碼,在這里把最近的一些優(yōu)化點(diǎn)總結(jié)一下绊诲。主要針對兩部分進(jìn)行了優(yōu)化送粱,統(tǒng)一返回對象的封裝統(tǒng)一的請求/響應(yīng)日志打印

首先回顧下在上一篇當(dāng)中講到的controller層主要的職責(zé):
1.參數(shù)校驗(yàn)
2.調(diào)用service層接口實(shí)現(xiàn)業(yè)務(wù)邏輯
3.轉(zhuǎn)換業(yè)務(wù)/數(shù)據(jù)對象
4.組裝返回對象
5.異常處理

當(dāng)時(shí)遺漏了一點(diǎn)現(xiàn)在補(bǔ)上:
6.請求日志打印
接下來進(jìn)入正題:

1. 統(tǒng)一返回對象的封裝

這一點(diǎn)其實(shí)在上一篇中已經(jīng)講過掂之,就是第4點(diǎn):組裝返回對象抗俄。只不過當(dāng)時(shí)使用的是在BaseController中封裝返回方法,在業(yè)務(wù)controller中調(diào)用responseOK/responseFail方法世舰。文章發(fā)出去不久后动雹,天草二十六_就建議我可以使用SpringMVC的ResponseBodyAdvice接口來實(shí)現(xiàn)統(tǒng)一的返回對象封裝從而進(jìn)一步優(yōu)化代碼(感謝天草)。他山之石可以攻玉跟压,這里就先來講一下該接口給我們的代碼帶來的變化。

首先看下ResponseBodyAdvice這個(gè)接口:

/**
 * Allows customizing the response after the execution of an {@code @ResponseBody}
 * or a {@code ResponseEntity} controller method but before the body is written
 * with an {@code HttpMessageConverter}.
 *
 * <p>Implementations may be registered directly with
 * {@code RequestMappingHandlerAdapter} and {@code ExceptionHandlerExceptionResolver}
 * or more likely annotated with {@code @ControllerAdvice} in which case they
 * will be auto-detected by both.
 */
public interface ResponseBodyAdvice<T> {

    /**
     * Whether this component supports the given controller method return type
     */
    boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);

    /**
     * Invoked after an HttpMessageConverter is selected and just before
     * its write method is invoked.
     */
    T beforeBodyWrite(T body, MethodParameter returnType, MediaType selectedContentType,
            Class<? extends HttpMessageConverter<?>> selectedConverterType,
            ServerHttpRequest request, ServerHttpResponse response);

}

上面的源碼由于篇幅問題裆馒,刪掉了一些注釋姊氓,感興趣的同學(xué)可以自己查看代碼。通過查看注釋可以發(fā)現(xiàn)喷好,這個(gè)接口可以讓我在controller方法(需有@ResponseBody@ResponseEntity注解)的返回對象被寫到HTTP response body之前做一些事情翔横。這里的兩個(gè)方法都很重要,首先supports決定了哪些controller會被該接口攔截梗搅,其次beforeBodyWrite決定了攔截之后我們的操作禾唁。另外效览,注意到上面注釋中建議了該接口的使用方法,其中有提到可以在實(shí)現(xiàn)該接口的類上使用@ControllerAdvice注解荡短,從而同時(shí)實(shí)現(xiàn)異常捕獲返回對象的封裝丐枉。

弄明白了ResponseBodyAdvice的作用,我們便可摒棄之前的BaseController掘托,讓controller直接返回DTO瘦锹,通過實(shí)現(xiàn)beforeBodyWrite方法來做統(tǒng)一的返回對象封裝。既然BaseController不需要了闪盔,那其中的封裝方法responseOK/responseFail又該何去何從呢弯院?我這邊的做法是參照了《Effective Java》中的建議,用靜態(tài)方法代替構(gòu)造函數(shù)泪掀,改寫了統(tǒng)一返回包裝類HttpResult:

public class HttpResult<T> implements Serializable {

    private static final long serialVersionUID = -1L;
    private boolean success;
    private T data;
    private String code;
    private String message;

    private HttpResult(boolean success, T data, String code, String message) {
        this.success = success;
        this.data = data;
        this.code = code;
        this.message = message;
    }

    private HttpResult(boolean success, T data, ResultCode resultCode) {
        this.success = success;
        this.data = data;
        this.code = resultCode.getCode();
        this.message = resultCode.getMessage();
    }

    /**
     * 成功返回
     */
    public static <T> HttpResult<T> ok(T data) {
        return new HttpResult<>(Boolean.TRUE, data, ResultCode.SUCCESS);
    }

    /**
     * 異常返回-指定錯(cuò)誤碼
     */
    public static HttpResult fail(ResultCode resultCode) {
        return new HttpResult<>(Boolean.FALSE, null, resultCode);
    }

    /**
     * 異常返回-非指定異常
     */
    public static HttpResult fail(String code, String message) {
        return new HttpResult<>(Boolean.FALSE, null, code, message);
    }
    
    //getter and setter
}

并將先前的統(tǒng)一異常處理類ExceptionAdvice改名為ResponseAdvice听绳,并實(shí)現(xiàn)ResponseBodyAdvice接口。
其中要注意兩點(diǎn):

  1. @ExceptionHandler要加上@ResponseBody注解异赫,否則會默認(rèn)返回mav椅挣。
  2. 返回結(jié)果被@ExceptionHandler處理后仍然會進(jìn)入到beforeBodyWrite處理,所以為了需要增加判斷邏輯以防重復(fù)包裝返回結(jié)果塔拳。
/**
 * @Author: Sawyer
 * @Description: 統(tǒng)一異常處理及返回對象封裝
 * @Date: Created in 上午11:17 17/8/11
 */
@Slf4j
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {

    @Autowired
    HttpServletRequest httpServletRequest;

    @Override
    public boolean supports(MethodParameter methodParameter, Class aClass) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class selectedConverterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) {
        //返回對象封裝
        if (body instanceof HttpResult) {
            // 被exceptionHandler處理過了鼠证,直接返回
            return body;
        } else {
            return HttpResult.ok(body);
        }
    }


    /**
     * 異常日志記錄
     */
    private void logErrorRequest(Exception e) {
        log.error("報(bào)錯(cuò)API URL:{}", httpServletRequest.getRequestURL().toString());
        log.error("異常:{}", e.getMessage());
    }

    /**
     * 參數(shù)未通過@Valid驗(yàn)證異常,
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    private HttpResult methodArgumentNotValid(MethodArgumentNotValidException exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.INVALID_PARAM);
    }

    /**
     * 參數(shù)格式有誤
     */
    @ExceptionHandler({MethodArgumentTypeMismatchException.class, HttpMessageNotReadableException.class})
    @ResponseBody
    private HttpResult typeMismatch(Exception exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.MISTYPE_PARAM);
    }

    /**
     * 缺少參數(shù)
     */
    @ExceptionHandler(MissingServletRequestParameterException.class)
    @ResponseBody
    private HttpResult missingServletRequestParameter(MissingServletRequestParameterException exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.MISSING_PARAM);
    }

    /**
     * 不支持的請求類型
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    @ResponseBody
    private HttpResult httpRequestMethodNotSupported(HttpRequestMethodNotSupportedException exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.UNSUPPORTED_METHOD);
    }

    /**
     * 業(yè)務(wù)層異常
     */
    @ExceptionHandler(ServiceEx.class)
    @ResponseBody
    private HttpResult serviceExceptionHandler(ServiceEx exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.S_SYS_UNKNOWN.getCode(), exception.getMessage());
    }

    /**
     * 其他異常
     */
    @ExceptionHandler({HttpClientErrorException.class, IOException.class, Exception.class})
    @ResponseBody
    private HttpResult commonExceptionHandler(Exception exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.S_SYS_UNKNOWN);
    }
}

這樣改造后蝙斜,我們的UserController就不再需要繼承BaseController及返回HttpResult對象了:

@RestController
@RequestMapping("/v1/user")
public class UserController {

    @Autowired
    UserService userService;

    @PutMapping("/{id}")
    public UserDTO updateUser(@PathVariable("id") Integer id, @Valid @RequestBody UserDTO userDTO) throws Exception {
        return UserDTO.convert(userService.updateUser(id, userDTO));
    }
}

這樣名惩,我們就完成了統(tǒng)一返回對象封裝的優(yōu)化。有的同學(xué)要問了孕荠,你這兒明明沒有指定@ResponseBody娩鹉,為啥也能被攔截呢?這里暴露了很多同學(xué)寫代碼的一個(gè)問題:無意識地寫一些多余的代碼稚伍。仔細(xì)看@RestController的源碼弯予,其實(shí)其中已經(jīng)包含了@ResponseBody注解了:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {

    String value() default "";

}

2. 統(tǒng)一的請求/響應(yīng)日志打印

對于開發(fā)過web應(yīng)用的同學(xué)來說,日志的重要性相信是不言而喻的个曙。特別是排查問題的時(shí)候锈嫩,如果如果少打了請求/響應(yīng)日志,那查問題的難度簡直一下子就上升了幾個(gè)等級垦搬。那我們該如何做才能最簡便呢呼寸?其實(shí)看了前一部分,你一定能想到可以在實(shí)現(xiàn)beforeBodyWrite接口的同時(shí)做日志打印猴贰,畢竟參數(shù)里都有ServerHttpRequestServerHttpResponse了嘛对雪。但這有一個(gè)問題,這個(gè)方法是在responseBody被寫入之前執(zhí)行的米绕,但如果controller本身就已經(jīng)報(bào)錯(cuò)了瑟捣,這個(gè)方法是不會被執(zhí)行的馋艺,這個(gè)時(shí)候日志也就不會被打印了。

其實(shí)對于ResponseBodyAdvice來說迈套,還有一個(gè)對應(yīng)的RequestBodyAdvice(這里就不展開了捐祠,感興趣的同學(xué)可以自行研究),似乎可以在beforeBodyRead中打印請求日志桑李,在beforeBodyWrite中打印正常返回日志踱蛀,在@ExceptionHandler中打印異常返回日志。這個(gè)方案的確可行芙扎,但會有一個(gè)問題星岗,這里賣個(gè)關(guān)子暫且不表填大,先來看我所采用的方法:單獨(dú)創(chuàng)建一個(gè)切面來做統(tǒng)一的日志打咏渫荨:

/**
 * @Author: Sawyer
 * @Description: 請求日志切面
 * @Date: Created in 3:07 PM 2019/8/15
 */
@Slf4j
@Aspect
@Component
public class RequestLogAspect {

    @Autowired
    HttpServletRequest request;

    @Around("execution(* com.sawyer.api.controller..*.*(..))")
    public Object around(final ProceedingJoinPoint joinPoint) throws Throwable {

        log.info("請求url:{}", request.getRequestURL().toString());

        ObjectMapper mapper = new ObjectMapper();
        log.info("請求參數(shù):{}", mapper.writeValueAsString(joinPoint.getArgs()));

        Object result = joinPoint.proceed();
        log.info("請求返回:{}", mapper.writeValueAsString(result));

        return result;
    }
}

這里稍微講一下AOP表達(dá)式"execution(* com.sawyer.api.controller..*.*(..))"的含義:

  • execution()表示是最常用的切點(diǎn)函數(shù),表示切面作用于方法執(zhí)行時(shí)允华;
  • 第一個(gè)*表示不限制返回類型圈浇;
  • controller后面的..表示要攔截的包路徑包含controller目錄及其所有子目錄;
  • 第二個(gè)*表示不限類名靴寂;
  • 第三個(gè)*表示不限方法名磷蜀;
    -(..)表示不限參數(shù);
  • @Around表示該切面的類型是包圍類型百炬;
    故總體的含義為:在com.sawyer.api.controller包下所有的類的所有方法的執(zhí)行前后進(jìn)行攔截褐隆。

通過定義這樣一個(gè)切面,我們就可以在controller的方法被調(diào)用前打印請求日志剖踊,被調(diào)用后打印響應(yīng)日志庶弃。當(dāng)然,在拋出異常的情況德澈,日志還是打印在@ExceptionHandler里的歇攻。這個(gè)做法和之前的方法相比,有什么特別的好處嗎梆造?這里就要講到剛剛賣的關(guān)子缴守。

真正在生產(chǎn)中,我們往往會遇到一個(gè)問題镇辉,就是有些接口的日志我們并不想打印出來屡穗。特別是一些批量查詢接口的響應(yīng)結(jié)果,一打就一堆忽肛,如果調(diào)用頻繁村砂,就可能會造成大量空間的浪費(fèi),也不方便日志的排查麻裁。那我們就需要針對不同的類箍镜,甚至方法進(jìn)行區(qū)別對待源祈。對于不同類,自定義切面和@ControllerAdvice都可以解決色迂,對于AOP來說可以在表達(dá)式里使用'||'或者'or'來指定多個(gè)連接點(diǎn)香缺,而@ControllerAdvice則可以用basePackages數(shù)組來指定多個(gè)類。但是如果同一個(gè)類中不同的方法有不同的日志需求歇僧,那@ControllerAdvice就愛莫能助了图张。不過,我們真的需要在切點(diǎn)表達(dá)式中維護(hù)那么復(fù)雜的又無聊的關(guān)系嗎诈悍?有更好的做法嗎祸轮?當(dāng)然有。

這里我的做法創(chuàng)建了一個(gè)自定義注解@LessLog用來指定是否要打日志侥钳、打什么日志适袜。然后通過切面中的joinPoint及java反射機(jī)制來獲取到方法上的注解,從而影響日志的行為舷夺,直接看代碼:

首先是忽略的日志內(nèi)容苦酱,主要有url日志、請求日志给猾、響應(yīng)日志疫萤、全部忽略和全部不忽略這5種:

/**
 * @Author: Sawyer
 * @Description: 忽略的日志類型
 * @Date: Created in 3:56 PM 2019/8/14
 */

public enum LogType {

    /**
     * 請求url
     */
    URL,

    /**
     * 請求
     */
    REQUEST,

    /**
     * 返回
     */
    RESPONSE,

    /**
     * 全部
     */
    ALL,

    /**
     * 無
     */
    NONE
}

然后是注解@LessLog本身,這里制定了一個(gè)type參數(shù)敢伸,用來指定忽略的日志內(nèi)容扯饶,默認(rèn)是全部不忽略:

/**
 * @Author: Sawyer
 * @Description: 忽略日志的注解
 * @Date: Created in 2:40 PM 2019/8/14
 */

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LessLog {
    /**
     * 默認(rèn)不忽略日志
     *
     * @return
     */
    LogType type() default LogType.NONE;
}

最后是改寫我們的RequestLogAspect,利用反射機(jī)制獲取到controller方法上的LessLog注解實(shí)例池颈,并根據(jù)其type參數(shù)決定具體忽略的日志內(nèi)容:

@Slf4j
@Aspect
@Component
public class RequestLogAspect {

    @Autowired
    HttpServletRequest request;

    @Around("execution(* com.yst.nfsq.vem.api.controller..*.*(..))")
    public Object around(final ProceedingJoinPoint joinPoint) throws Throwable {

        boolean urlLogRequired = Boolean.TRUE;
        boolean requestLogRequired = Boolean.TRUE;
        boolean responseLogRequired = Boolean.TRUE;

        Class<?> clazz = joinPoint.getTarget().getClass();
        String methodName = joinPoint.getSignature().getName();
        Class<?>[] args = ((MethodSignature) joinPoint.getSignature()).getParameterTypes();
        Method method = clazz.getMethod(methodName, args);

        if (method.isAnnotationPresent(LessLog.class)) {
            //減少日志的注解
            LessLog lessLog = method.getAnnotation(LessLog.class);
            LogType logType = lessLog.type();
            switch (logType) {
                case URL:
                    urlLogRequired = Boolean.FALSE;
                    break;
                case REQUEST:
                    requestLogRequired = Boolean.FALSE;
                    break;
                case RESPONSE:
                    responseLogRequired = Boolean.FALSE;
                    break;
                case ALL:
                    urlLogRequired = Boolean.FALSE;
                    requestLogRequired = Boolean.FALSE;
                    responseLogRequired = Boolean.FALSE;
                    break;
                default:
            }
        }
        //url日志
        if (urlLogRequired) {
            log.info("請求url:{}", request.getRequestURL().toString());
        }

        ObjectMapper mapper = new ObjectMapper();
        //請求日志
        if (requestLogRequired) {
            log.info("請求參數(shù):{}", mapper.writeValueAsString(joinPoint.getArgs()));
        }
        Object result = joinPoint.proceed();
        //響應(yīng)日志
        if (responseLogRequired) {
            log.info("請求返回:{}", mapper.writeValueAsString(result));
        }

        return result;
    }
}

這樣尾序,我們就可以在具體的發(fā)放上使用@LessLog注解來控制日志打印的內(nèi)容了,比如下面的方法就不會打印響應(yīng)日志:

@RestController
@RequestMapping("/v1/user")
public class UserController {

    @Autowired
    UserService userService;

    //不打印響應(yīng)日志
    @LessLog(type = LogType.RESPONSE)
    @PutMapping("/{id}")
    public UserDTO updateUser(@PathVariable("id") Integer id, @Valid @RequestBody UserDTO userDTO) throws Exception {
        return UserDTO.convert(userService.updateUser(id, userDTO));
    }
}

寫到這里饶辙,結(jié)合上一篇蹲诀,我們已經(jīng)完成了controller層的所有任務(wù),再來回顧一下:
1.參數(shù)校驗(yàn)
2.調(diào)用service層接口實(shí)現(xiàn)業(yè)務(wù)邏輯
3.轉(zhuǎn)換業(yè)務(wù)/數(shù)據(jù)對象
4.組裝返回對象
5.異常處理
6.請求日志打印

這篇文章中用到的技術(shù)包括AOP弃揽、反射脯爪、注解等其實(shí)大家都耳熟能詳,但很多時(shí)候都只是只知其然而不知其所以然矿微。我還是鼓勵大家在寫代碼時(shí)多加思考痕慢,創(chuàng)造機(jī)會使用這些技術(shù),而非一味地照搬安全的老代碼涌矢,從而喪失了使自己技術(shù)精進(jìn)和代碼更優(yōu)雅的機(jī)會掖举。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市娜庇,隨后出現(xiàn)的幾起案子塔次,更是在濱河造成了極大的恐慌方篮,老刑警劉巖,帶你破解...
    沈念sama閱讀 207,248評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件励负,死亡現(xiàn)場離奇詭異藕溅,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)继榆,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,681評論 2 381
  • 文/潘曉璐 我一進(jìn)店門巾表,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人略吨,你說我怎么就攤上這事集币。” “怎么了翠忠?”我有些...
    開封第一講書人閱讀 153,443評論 0 344
  • 文/不壞的土叔 我叫張陵鞠苟,是天一觀的道長。 經(jīng)常有香客問我负间,道長偶妖,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,475評論 1 279
  • 正文 為了忘掉前任政溃,我火速辦了婚禮,結(jié)果婚禮上态秧,老公的妹妹穿的比我還像新娘董虱。我一直安慰自己,他們只是感情好申鱼,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,458評論 5 374
  • 文/花漫 我一把揭開白布愤诱。 她就那樣靜靜地躺著,像睡著了一般捐友。 火紅的嫁衣襯著肌膚如雪淫半。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,185評論 1 284
  • 那天匣砖,我揣著相機(jī)與錄音科吭,去河邊找鬼。 笑死猴鲫,一個(gè)胖子當(dāng)著我的面吹牛对人,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播拂共,決...
    沈念sama閱讀 38,451評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼牺弄,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了宜狐?” 一聲冷哼從身側(cè)響起势告,我...
    開封第一講書人閱讀 37,112評論 0 261
  • 序言:老撾萬榮一對情侶失蹤蛇捌,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后咱台,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體豁陆,經(jīng)...
    沈念sama閱讀 43,609評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,083評論 2 325
  • 正文 我和宋清朗相戀三年吵护,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了盒音。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,163評論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡馅而,死狀恐怖祥诽,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情瓮恭,我是刑警寧澤雄坪,帶...
    沈念sama閱讀 33,803評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站屯蹦,受9級特大地震影響维哈,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜登澜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,357評論 3 307
  • 文/蒙蒙 一阔挠、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧脑蠕,春花似錦购撼、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,357評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至晃跺,卻和暖如春揩局,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背掀虎。 一陣腳步聲響...
    開封第一講書人閱讀 31,590評論 1 261
  • 我被黑心中介騙來泰國打工凌盯, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人涩盾。 一個(gè)月前我還...
    沈念sama閱讀 45,636評論 2 355
  • 正文 我出身青樓十气,卻偏偏與公主長得像,于是被迫代替她去往敵國和親春霍。 傳聞我的和親對象是個(gè)殘疾皇子砸西,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,925評論 2 344

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