十三. mybatis攔截器介紹及使用示例

一. 概述

攔截器: 的作用就是我們可以攔截某些方法的調用逛钻,在目標方法前后加上我們自己邏輯
Mybatis攔截器設計的一個初衷是為了供用戶在某些時候可以實現(xiàn)自己的邏輯而不必去動Mybatis固有的邏輯缴罗。

二. mybatis核心概念

接口 說明
SqlSession 作為MyBatis工作的主要頂層API炎码,表示和數(shù)據(jù)庫交互的會話涕俗,完成必要數(shù)據(jù)庫增刪改查功能
Executor MyBatis執(zhí)行器痰腮,是MyBatis 調度的核心赋访,負責SQL語句的生成和查詢緩存的維護
StatementHandler 封裝了JDBC Statement操作碟狞,負責對JDBC statement 的操作啄枕,如設置參數(shù)、將Statement結果集轉換成List集合
ParameterHandler 負責對用戶傳遞的參數(shù)轉換成JDBC Statement 所需要的參數(shù)
ResultSetHandler 負責將JDBC返回的ResultSet結果集對象轉換成List類型的集合
TypeHandler 負責java數(shù)據(jù)類型和jdbc數(shù)據(jù)類型之間的映射和轉換
MappedStatement MappedStatement維護了一條<select|update|delete|insert>節(jié)點的封裝
SqlSource 負責根據(jù)用戶傳遞的parameterObject族沃,動態(tài)地生成SQL語句频祝,將信息封裝到BoundSql對象中,并返回
BoundSql 表示動態(tài)生成的SQL語句以及相應的參數(shù)信息
Configuration MyBatis所有的配置信息都維持在Configuration對象之中

三. 自定義一個攔截器

步驟:

  1. 實現(xiàn) {@link Interceptor} 接口
  2. 添加攔截注解 {@link Intercepts}
  3. 配置文件中添加攔截器
/**
 * mybatis 自定義攔截器
 *
 * 1 實現(xiàn) {@link Interceptor} 接口
 *      具體作用可以看下面代碼每個方法的注釋
 * 2 添加攔截注解 {@link Intercepts}
 *      mybatis 攔截器默認可攔截的類型只有四種竭业,即四種接口類型 Executor智润、StatementHandler、ParameterHandler 和 ResultSetHandler
 *      對于我們的自定義攔截器必須使用 mybatis 提供的注解來指明我們要攔截的是四類中的哪一個類接口
 *      具體規(guī)則如下:
 *          a:Intercepts 標識我的類是一個攔截器
 *          b:Signature 則是指明我們的攔截器需要攔截哪一個接口的哪一個方法
 *              type    對應四類接口中的某一個未辆,比如是 Executor
 *              method  對應接口中的哪類方法窟绷,比如 Executor 的 update 方法
 *              args    對應接口中的哪一個方法,比如 Executor 中 query 因為重載原因咐柜,方法有多個兼蜈,args 就是指明參數(shù)類型,從而確定是哪一個方法
 * 3 配置文件中添加攔截器
 *      攔截器其實就是一個 plugin拙友,在 mybatis 核心配置文件中我們需要配置我們的 plugin :
 *          <plugin interceptor="liu.york.mybatis.study.plugin.MyInterceptor">
 *              <property name="username" value="LiuYork"/>
 *              <property name="password" value="123456"/>
 *          </plugin>
 *
 * 攔截器順序
 * 1 不同攔截器順序:
 *      Executor -> ParameterHandler -> StatementHandler -> ResultSetHandler
 *
 * 2 對于同一個類型的攔截器的不同對象攔截順序:
 *      在 mybatis 核心配置文件根據(jù)配置的位置为狸,攔截順序是 從上往下
 */
@Intercepts({
        @Signature(method = "update", type = Executor.class, args = {MappedStatement.class, Object.class}),
        @Signature(method = "query", type = StatementHandler.class, args = {Statement.class, ResultHandler.class})
})
public class MyInterceptor implements Interceptor {

    /**
     * 這個方法很好理解
     * 作用只有一個:我們不是攔截方法嗎,攔截之后我們要做什么事情呢遗契?
     *      這個方法里面就是我們要做的事情
     *
     * 解釋這個方法前辐棒,我們一定要理解方法參數(shù) {@link Invocation} 是個什么鬼?
     * 1 我們知道牍蜂,mybatis攔截器默認只能攔截四種類型 Executor漾根、StatementHandler、ParameterHandler 和 ResultSetHandler
     * 2 不管是哪種代理鲫竞,代理的目標對象就是我們要攔截對象辐怕,舉例說明:
     *      比如我們要攔截 {@link Executor#update(MappedStatement ms, Object parameter)} 方法,
     *      那么 Invocation 就是這個對象从绘,Invocation 里面有三個參數(shù) target method args
     *          target 就是 Executor
     *          method 就是 update
     *          args   就是 MappedStatement ms, Object parameter
     *
     *   如果還是不能理解寄疏,我再舉一個需求案例:看下面方法代碼里面的需求
     *
     *  該方法在運行時調用
     */
    @Override
    public Object intercept(Invocation invocation) throws Throwable {

        /*
         * 需求:我們需要對所有更新操作前打印查詢語句的 sql 日志
         * 那我就可以讓我們的自定義攔截器 MyInterceptor 攔截 Executor 的 update 方法是牢,在 update 執(zhí)行前打印sql日志
         * 比如我們攔截點是 Executor 的 update 方法 :  int update(MappedStatement ms, Object parameter)
         *
         * 那當我們日志打印成功之后,我們是不是還需要調用這個query方法呢陕截,如何如調用呢驳棱?
         * 所以就出現(xiàn)了 Invocation 對象,它這個時候其實就是一個 Executor艘策,而且 method 對應的就是 query 方法蹈胡,我們
         * 想要調用這個方法,只需要執(zhí)行 invocation.proceed()
         */

        /* 因為我攔截的就是Executor朋蔫,所以我可以強轉為 Executor罚渐,默認情況下,這個Executor 是個 SimpleExecutor */
        Executor executor = (Executor)invocation.getTarget();

        /*
         * Executor 的 update 方法里面有一個參數(shù) MappedStatement驯妄,它是包含了 sql 語句的荷并,所以我獲取這個對象
         * 以下是偽代碼,思路:
         * 1 通過反射從 Executor 對象中獲取 MappedStatement 對象
         * 2 從 MappedStatement 對象中獲取 SqlSource 對象
         * 3 然后從 SqlSource 對象中獲取獲取 BoundSql 對象
         * 4 最后通過 BoundSql#getSql 方法獲取 sql
         */
        MappedStatement mappedStatement = ReflectUtil.getMethodField(executor, MappedStatement.class);
        SqlSource sqlSource = ReflectUtil.getField(mappedStatement, SqlSource.class);
        BoundSql boundSql = sqlSource.getBoundSql(args);
        String sql = boundSql.getSql();
        logger.info(sql);

        /*
         * 現(xiàn)在日志已經(jīng)打印青扔,需要調用目標對象的方法完成 update 操作
         * 我們直接調用 invocation.proceed() 方法
         * 進入源碼其實就是一個常見的反射調用 method.invoke(target, args)
         * target 對應 Executor對象
         * method 對應 Executor的update方法
         * args   對應 Executor的update方法的參數(shù)
         */

        return invocation.proceed();
    }

    /**
     * 這個方法也很好理解
     * 作用就只有一個:那就是Mybatis在創(chuàng)建攔截器代理時候會判斷一次源织,當前這個類 MyInterceptor 到底需不需要生成一個代理進行攔截,
     * 如果需要攔截微猖,就生成一個代理對象谈息,這個代理就是一個 {@link Plugin},它實現(xiàn)了jdk的動態(tài)代理接口 {@link InvocationHandler}凛剥,
     * 如果不需要代理侠仇,則直接返回目標對象本身
     *
     * Mybatis為什么會判斷一次是否需要代理呢?
     * 默認情況下犁珠,Mybatis只能攔截四種類型的接口:Executor逻炊、StatementHandler、ParameterHandler 和 ResultSetHandler
     * 通過 {@link Intercepts} 和 {@link Signature} 兩個注解共同完成
     * 試想一下犁享,如果我們開發(fā)人員在自定義攔截器上沒有指明類型余素,或者隨便寫一個攔截點,比如Object炊昆,那Mybatis瘋了桨吊,難道所有對象都去攔截
     * 所以Mybatis會做一次判斷,攔截點看看是不是這四個接口里面的方法凤巨,不是則不攔截屏积,直接返回目標對象,如果是則需要生成一個代理
     *
     *  該方法在 mybatis 加載核心配置文件時被調用
     */
    @Override
    public Object plugin(Object target) {
        /*
         * 看了這個方法注釋磅甩,就應該理解,這里的邏輯只有一個姥卢,就是讓mybatis判斷卷要,要不要進行攔截渣聚,然后做出決定是否生成一個代理
         *
         * 下面代碼什么鬼,就這一句就搞定了僧叉?
         * Mybatis判斷依據(jù)是利用反射奕枝,獲取這個攔截器 MyInterceptor 的注解 Intercepts和Signature,然后解析里面的值瓶堕,
         * 1 先是判斷要攔截的對象是四個類型中 Executor隘道、StatementHandler、ParameterHandler郎笆、 ResultSetHandler 的哪一個
         * 2 然后根據(jù)方法名稱和參數(shù)(因為有重載)判斷對哪一個方法進行攔截  Note:mybatis可以攔截這四個接口里面的任一一個方法
         * 3 做出決定谭梗,是返回一個對象呢還是返回目標對象本身(目標對象本身就是四個接口的實現(xiàn)類,我們攔截的就是這四個類型)
         *
         * 好了宛蚓,理解邏輯我們寫代碼吧~~~  What !!! 要使用反射激捏,然后解析注解,然后根據(jù)參數(shù)類型凄吏,最后還要生成一個代理對象
         * 我一個小白我怎么會這么高大上的代碼嘛远舅,怎么辦?
         *
         * 那就是使用下面這句代碼吧  哈哈
         * mybatis 早就考慮了這里的復雜度痕钢,所以提供這個靜態(tài)方法來實現(xiàn)上面的邏輯
         */
        return Plugin.wrap(target, this);
    }

    /**
     * 這個方法最好理解图柏,如果我們攔截器需要用到一些變量參數(shù),而且這個參數(shù)是支持可配置的任连,
     *  類似Spring中的@Value("${}")從application.properties文件獲取
     * 這個時候我們就可以使用這個方法
     *
     * 如何使用蚤吹?
     * 只需要在 mybatis 配置文件中加入類似如下配置,然后 {@link Interceptor#setProperties(Properties)} 就可以獲取參數(shù)
     *      <plugin interceptor="liu.york.mybatis.study.plugin.MyInterceptor">
     *           <property name="username" value="LiuYork"/>
     *           <property name="password" value="123456"/>
     *      </plugin>
     *      方法中獲取參數(shù):properties.getProperty("username");
     *
     * 問題:為什么要存在這個方法呢课梳,比如直接使用 @Value("${}") 獲取不就得了距辆?
     * 原因是 mybatis 框架本身就是一個可以獨立使用的框架,沒有像 Spring 這種做了很多依賴注入的功能
     *
     *  該方法在 mybatis 加載核心配置文件時被調用 
     */
    @Override
    public void setProperties(Properties properties) {
        String username = properties.getProperty("username");
        String password = properties.getProperty("password");
        // TODO: 2019/2/28  業(yè)務邏輯處理...
    }
}

Plugin類

package org.apache.ibatis.plugin;

/**
 * Plugin 類其實就是一個代理類暮刃,因為它實現(xiàn)了jdk動態(tài)代理接口 InvocationHandler
 * 我們核心只需要關注兩個方法
 * wrap:
 *      如果看懂了代碼案例1的例子跨算,那么這個方法很理解,這個方法就是 mybatis 提供給開發(fā)人員使用的一個工具類方法椭懊,
 *      目的就是幫助開發(fā)人員省略掉 反射解析注解 Intercepts 和 Signature诸蚕,有興趣的可以去看看源碼 Plugin#getSignatureMap 方法
 *
 * invoke:
 *      這個方法就是根據(jù) wrap 方法的解析結果,判斷當前攔截器是否需要進行攔截氧猬,
 *      如果需要攔截:將 目標對象+目標方法+目標參數(shù) 封裝成一個 Invocation 對象背犯,給我們自定義的攔截器 MyInterceptor 的 intercept 方法
 *                   這個時候就剛好對應上了上面案例1中對 intercept 方法的解釋了,它就是我們要處理自己邏輯的方法盅抚,
 *                   處理好了之后是否需要調用目標對象的方法漠魏,比如上面說的 打印了sql語句,是否還要查詢數(shù)據(jù)庫呢妄均?答案是肯定的
 *      如果不需要攔截:則直接調用目標對象的方法
 *                   比如直接調用 Executor 的 update 方法進行更新數(shù)據(jù)庫
 *
 */
class Plugin implements InvocationHandler {

    public static Object wrap(Object target, Interceptor interceptor) {
        // 省略
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 省略
    }
}

四. 使用示例一

4.1 業(yè)務背景

全局攔截mybatis執(zhí)行的sql, 修改sql內容后再執(zhí)行

4.1 sql獲取與替換工具類

import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.reflection.DefaultReflectorFactory;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.factory.DefaultObjectFactory;
import org.apache.ibatis.reflection.wrapper.DefaultObjectWrapperFactory;

import java.sql.SQLException;

public class ExecutorPluginUtils {
    /**
     * 獲取sql語句
     * @param invocation
     * @return
     */
    public static String getSqlByInvocation(Invocation invocation) {
        final Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameterObject = args[1];
        BoundSql boundSql = ms.getBoundSql(parameterObject);
        return boundSql.getSql();
    }

    /**
     * 包裝sql后柱锹,重置到invocation中
     * @param invocation
     * @param sql
     * @throws SQLException
     */
    public static void resetSql2Invocation(Invocation invocation, String sql) throws SQLException {
        final Object[] args = invocation.getArgs();
        MappedStatement statement = (MappedStatement) args[0];
        Object parameterObject = args[1];
        BoundSql boundSql = statement.getBoundSql(parameterObject);
        MappedStatement newStatement = newMappedStatement(statement, new BoundSqlSqlSource(boundSql));
        MetaObject msObject =  MetaObject.forObject(newStatement, new DefaultObjectFactory(),
                new DefaultObjectWrapperFactory(),new DefaultReflectorFactory());
        msObject.setValue("sqlSource.boundSql.sql", sql);
        args[0] = newStatement;
    }

    private static MappedStatement newMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
        MappedStatement.Builder builder =
                new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
        builder.resource(ms.getResource());
        builder.fetchSize(ms.getFetchSize());
        builder.statementType(ms.getStatementType());
        builder.keyGenerator(ms.getKeyGenerator());
        if (ms.getKeyProperties() != null && ms.getKeyProperties().length != 0) {
            StringBuilder keyProperties = new StringBuilder();
            for (String keyProperty : ms.getKeyProperties()) {
                keyProperties.append(keyProperty).append(",");
            }
            keyProperties.delete(keyProperties.length() - 1, keyProperties.length());
            builder.keyProperty(keyProperties.toString());
        }
        builder.timeout(ms.getTimeout());
        builder.parameterMap(ms.getParameterMap());
        builder.resultMaps(ms.getResultMaps());
        builder.resultSetType(ms.getResultSetType());
        builder.cache(ms.getCache());
        builder.flushCacheRequired(ms.isFlushCacheRequired());
        builder.useCache(ms.isUseCache());

        return builder.build();
    }

    private static String getOperateType(Invocation invocation) {
        final Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        SqlCommandType commondType = ms.getSqlCommandType();
        if (commondType.compareTo(SqlCommandType.SELECT) == 0) {
            return "select";
        }
        if (commondType.compareTo(SqlCommandType.INSERT) == 0) {
            return "insert";
        }
        if (commondType.compareTo(SqlCommandType.UPDATE) == 0) {
            return "update";
        }
        if (commondType.compareTo(SqlCommandType.DELETE) == 0) {
            return "delete";
        }
        return null;
    }
    //    定義一個內部輔助類哪自,作用是包裝sq
   static class BoundSqlSqlSource implements SqlSource {
        private BoundSql boundSql;
        public BoundSqlSqlSource(BoundSql boundSql) {
            this.boundSql = boundSql;
        }
        @Override
        public BoundSql getBoundSql(Object parameterObject) {
            return boundSql;
        }
    }

}

4.3 編寫攔截器

@Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})})
@Slf4j
public class MyInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        String processSql = ExecutorPluginUtils.getSqlByInvocation(invocation);
        log.debug("schema替換前:{}", processSql);
        // 執(zhí)行自定義修改sql操作
        log.debug("schema替換后:{}", processSql);
        // 替換sql
        ExecutorPluginUtils.resetSql2Invocation(invocation, processSql);
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {

    }

}

4.4 配置文件添加攔截器

@Configuration
@ConditionalOnClass(SqlSessionFactory.class)
public class MybatisInterceptorConfig {

    @Bean
    public String myInterceptor() {
        SqlSessionFactory sqlSessionFactory = null;
        try {
            sqlSessionFactory = SpringContextHolder.getApplicationContext().getBean(SqlSessionFactory.class);
        } catch (Exception e) {
            return null;
        }
        MyInterceptor myInterceptor = new MyInterceptor ();
        sqlSessionFactory.getConfiguration().addInterceptor(myInterceptor);
        return "interceptor";
    }
    
}

五. 使用示例二

5.1 業(yè)務背景

對mybatis執(zhí)行返回結果進行處理

@Intercepts({@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})})
@Slf4j
public class UserInfoConvertInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 執(zhí)行sql返回結果
        List<Object> result = (List<Object>) invocation.proceed();
        try {
            for (Object basePage : result) {
                // 獲取創(chuàng)建時間
                Object obj = getCreatedDate("createdDate", basePage);
                // 替換創(chuàng)建時間
                setCreatedDate("createdDate", basePage, LocalDate.now());
            }
        } catch (Throwable throwable) {
            log.error("創(chuàng)建日期轉換異常,請檢查", throwable);
        } finally {
            return result;
        }
    }

    public Object getCreatedDate(String fieldName, Object basePage) throws Throwable {
        Object obj = null;
        Field field = basePage.getClass().getDeclaredField(fieldName);
        if (field != null) {
            field.setAccessible(true);
            obj = field.get(basePage);
        }
        return obj;
    }

    public void setCreatedDate(String fieldName, Object basePage, Object date) throws Throwable {
        Field userNameField = basePage.getClass().getDeclaredField(fieldName);
        if (userNameField != null) {
            userNameField.setAccessible(true);
            userNameField.set(basePage,date);
        }
    }
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末禁熏,一起剝皮案震驚了整個濱河市壤巷,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌瞧毙,老刑警劉巖胧华,帶你破解...
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異宙彪,居然都是意外死亡矩动,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,277評論 3 385
  • 文/潘曉璐 我一進店門您访,熙熙樓的掌柜王于貴愁眉苦臉地迎上來铅忿,“玉大人,你說我怎么就攤上這事灵汪√囱担” “怎么了?”我有些...
    開封第一講書人閱讀 157,221評論 0 348
  • 文/不壞的土叔 我叫張陵享言,是天一觀的道長峻凫。 經(jīng)常有香客問我,道長览露,這世上最難降的妖魔是什么荧琼? 我笑而不...
    開封第一講書人閱讀 56,474評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮差牛,結果婚禮上命锄,老公的妹妹穿的比我還像新娘。我一直安慰自己偏化,他們只是感情好脐恩,可當我...
    茶點故事閱讀 65,570評論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著侦讨,像睡著了一般驶冒。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上韵卤,一...
    開封第一講書人閱讀 49,816評論 1 290
  • 那天骗污,我揣著相機與錄音,去河邊找鬼沈条。 笑死需忿,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播贴谎,決...
    沈念sama閱讀 38,957評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼汞扎,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了擅这?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 37,718評論 0 266
  • 序言:老撾萬榮一對情侶失蹤景鼠,失蹤者是張志新(化名)和其女友劉穎仲翎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體铛漓,經(jīng)...
    沈念sama閱讀 44,176評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡溯香,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,511評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了浓恶。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片玫坛。...
    茶點故事閱讀 38,646評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖包晰,靈堂內的尸體忽然破棺而出湿镀,到底是詐尸還是另有隱情,我是刑警寧澤伐憾,帶...
    沈念sama閱讀 34,322評論 4 330
  • 正文 年R本政府宣布勉痴,位于F島的核電站,受9級特大地震影響树肃,放射性物質發(fā)生泄漏蒸矛。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,934評論 3 313
  • 文/蒙蒙 一胸嘴、第九天 我趴在偏房一處隱蔽的房頂上張望雏掠。 院中可真熱鬧,春花似錦劣像、人聲如沸乡话。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,755評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽蚊伞。三九已至,卻和暖如春吮铭,著一層夾襖步出監(jiān)牢的瞬間时迫,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,987評論 1 266
  • 我被黑心中介騙來泰國打工谓晌, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留掠拳,地道東北人。 一個月前我還...
    沈念sama閱讀 46,358評論 2 360
  • 正文 我出身青樓纸肉,卻偏偏與公主長得像溺欧,于是被迫代替她去往敵國和親喊熟。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 43,514評論 2 348