自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

手把手教你開發(fā) MyBatis 分頁插件

開發(fā) 前端
今天主要和小伙伴們分享了我們?nèi)绾巫约洪_發(fā)一個(gè) MyBatis 插件,插件功能其實(shí)都是次要的,最主要是希望小伙伴們能夠理解 MyBatis 的工作流程。

在日常開發(fā)中,小伙伴們多多少少都有用過 MyBatis 插件,松哥猜測(cè)大家用的最多的就是 MyBatis 的分頁插件!不知道小伙伴們有沒有想過有一天自己也來開發(fā)一個(gè) MyBatis 插件?

其實(shí)自己動(dòng)手?jǐn)]一個(gè) MyBatis 插件并不難,今天松哥就把手帶大家擼一個(gè) MyBatis 插件!

1.MyBatis 插件接口

即使你沒開發(fā)過 MyBatis 插件,估計(jì)也能猜出來,MyBatis 插件是通過攔截器來起作用的,MyBatis 框架在設(shè)計(jì)的時(shí)候,就已經(jīng)為插件的開發(fā)預(yù)留了相關(guān)接口,如下:

public interface Interceptor {

  Object intercept(Invocation invocation) throws Throwable;

  default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }

  default void setProperties(Properties properties) {
    // NOP
  }

}

這個(gè)接口中就三個(gè)方法,第一個(gè)方法必須實(shí)現(xiàn),后面兩個(gè)方法都是可選的。三個(gè)方法作用分別如下:

  1. intercept:這個(gè)就是具體的攔截方法,我們自定義 MyBatis 插件時(shí),一般都需要重寫該方法,我們插件所完成的工作也都是在該方法中完成的。
  2. plugin:這個(gè)方法的參數(shù) target 就是攔截器要攔截的對(duì)象,一般來說我們不需要重寫該方法。Plugin.wrap 方法會(huì)自動(dòng)判斷攔截器的簽名和被攔截對(duì)象的接口是否匹配,如果匹配,才會(huì)通過動(dòng)態(tài)代理攔截目標(biāo)對(duì)象。
  3. setProperties:這個(gè)方法用來傳遞插件的參數(shù),可以通過參數(shù)來改變插件的行為。我們定義好插件之后,需要對(duì)插件進(jìn)行配置,在配置的時(shí)候,可以給插件設(shè)置相關(guān)屬性,設(shè)置的屬性可以通過該方法獲取到。插件屬性設(shè)置像下面這樣:
<plugins>
    <plugin interceptor="org.javaboy.mybatis03.plugin.CamelInterceptor">
        <property name="xxx" value="xxx"/>
    </plugin>
</plugins>

2.MyBatis 攔截器簽名

攔截器定義好了后,攔截誰?

這個(gè)就需要攔截器簽名來完成了!

攔截器簽名是一個(gè)名為 @Intercepts 的注解,該注解中可以通過 @Signature 配置多個(gè)簽名。@Signature 注解中則包含三個(gè)屬性:

  • type: 攔截器需要攔截的接口,有 4 個(gè)可選項(xiàng),分別是:Executor、ParameterHandler、ResultSetHandler 以及 StatementHandler。
  • method: 攔截器所攔截接口中的方法名,也就是前面四個(gè)接口中的方法名,接口和方法要對(duì)應(yīng)上。
  • args: 攔截器所攔截方法的參數(shù)類型,通過方法名和參數(shù)類型可以鎖定唯一一個(gè)方法。

一個(gè)簡單的簽名可能像下面這樣:

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

3.被攔截的對(duì)象

根據(jù)前面的介紹,被攔截的對(duì)象主要有如下四個(gè):

Executor

public interface Executor {

  ResultHandler NO_RESULT_HANDLER = null;

  int update(MappedStatement ms, Object parameter) throws SQLException;

  <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey cacheKey, BoundSql boundSql) throws SQLException;

  <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException;

  <E> Cursor<E> queryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds) throws SQLException;

  List<BatchResult> flushStatements() throws SQLException;

  void commit(boolean required) throws SQLException;

  void rollback(boolean required) throws SQLException;

  CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql);

  boolean isCached(MappedStatement ms, CacheKey key);

  void clearLocalCache();

  void deferLoad(MappedStatement ms, MetaObject resultObject, String property, CacheKey key, Class<?> targetType);

  Transaction getTransaction();

  void close(boolean forceRollback);

  boolean isClosed();

  void setExecutorWrapper(Executor executor);

}

各方法含義分別如下:

  • update:該方法會(huì)在所有的 INSERT、 UPDATE、 DELETE 執(zhí)行時(shí)被調(diào)用,如果想要攔截這些操作,可以通過該方法實(shí)現(xiàn)。
  • query:該方法會(huì)在 SELECT 查詢方法執(zhí)行時(shí)被調(diào)用,方法參數(shù)攜帶了很多有用的信息,如果需要獲取,可以通過該方法實(shí)現(xiàn)。
  • queryCursor:當(dāng) SELECT 的返回類型是 Cursor 時(shí),該方法會(huì)被調(diào)用。
  • flushStatements:當(dāng) SqlSession 方法調(diào)用 flushStatements 方法或執(zhí)行的接口方法中帶有 @Flush 注解時(shí)該方法會(huì)被觸發(fā)。
  • commit:當(dāng) SqlSession 方法調(diào)用 commit 方法時(shí)該方法會(huì)被觸發(fā)。
  • rollback:當(dāng) SqlSession 方法調(diào)用 rollback 方法時(shí)該方法會(huì)被觸發(fā)。
  • getTransaction:當(dāng) SqlSession 方法獲取數(shù)據(jù)庫連接時(shí)該方法會(huì)被觸發(fā)。
  • close:該方法在懶加載獲取新的 Executor 后會(huì)被觸發(fā)。
  • isClosed:該方法在懶加載執(zhí)行查詢前會(huì)被觸發(fā)。

ParameterHandler

public interface ParameterHandler {

  Object getParameterObject();

  void setParameters(PreparedStatement ps) throws SQLException;

}

各方法含義分別如下:

  • getParameterObject:在執(zhí)行存儲(chǔ)過程處理出參的時(shí)候該方法會(huì)被觸發(fā)。
  • setParameters:設(shè)置 SQL 參數(shù)時(shí)該方法會(huì)被觸發(fā)。

ResultSetHandler

public interface ResultSetHandler {

  <E> List<E> handleResultSets(Statement stmt) throws SQLException;

  <E> Cursor<E> handleCursorResultSets(Statement stmt) throws SQLException;

  void handleOutputParameters(CallableStatement cs) throws SQLException;

}

各方法含義分別如下:

  • handleResultSets:該方法會(huì)在所有的查詢方法中被觸發(fā)(除去返回值類型為 Cursor的查詢方法),一般來說,如果我們想對(duì)查詢結(jié)果進(jìn)行二次處理,可以通過攔截該方法實(shí)現(xiàn)。
  • handleCursorResultSets:當(dāng)查詢方法的返回值類型為 Cursor時(shí),該方法會(huì)被觸發(fā)。
  • handleOutputParameters:使用存儲(chǔ)過程處理出參的時(shí)候該方法會(huì)被調(diào)用。

StatementHandler

public interface StatementHandler {

  Statement prepare(Connection connection, Integer transactionTimeout)
      throws SQLException;

  void parameterize(Statement statement)
      throws SQLException;

  void batch(Statement statement)
      throws SQLException;

  int update(Statement statement)
      throws SQLException;

  <E> List<E> query(Statement statement, ResultHandler resultHandler)
      throws SQLException;

  <E> Cursor<E> queryCursor(Statement statement)
      throws SQLException;

  BoundSql getBoundSql();

  ParameterHandler getParameterHandler();

}

各方法含義分別如下:

  • prepare:該方法在數(shù)據(jù)庫執(zhí)行前被觸發(fā)。
  • parameterize:該方法在 prepare 方法之后執(zhí)行,用來處理參數(shù)信息。
  • batch:如果 MyBatis 的全劇配置中配置了 defaultExecutorType=”BATCH”,執(zhí)行數(shù)據(jù)操作時(shí)該方法會(huì)被調(diào)用。
  • update:更新操作時(shí)該方法會(huì)被觸發(fā)。
  • query:該方法在 SELECT 方法執(zhí)行時(shí)會(huì)被觸發(fā)。
  • queryCursor:該方法在 SELECT 方法執(zhí)行時(shí),并且返回值為 Cursor 時(shí)會(huì)被觸發(fā)。

在開發(fā)一個(gè)具體的插件時(shí),我們應(yīng)當(dāng)根據(jù)自己的需求來決定到底攔截哪個(gè)方法。

4.開發(fā)分頁插件

4.1 內(nèi)存分頁

MyBatis 中提供了一個(gè)不太好用的內(nèi)存分頁功能,就是一次性把所有數(shù)據(jù)都查詢出來,然后在內(nèi)存中進(jìn)行分頁處理,這種分頁方式效率很低,基本上沒啥用,但是如果我們想要自定義分頁插件,就需要對(duì)這種分頁方式有一個(gè)簡單了解。

內(nèi)存分頁的使用方式如下,首先在 Mapper 中添加 RowBounds 參數(shù),如下:

public interface UserMapper {
    List<User> getAllUsersByPage(RowBounds rowBounds);
}

然后在 XML 文件中定義相關(guān) SQL:

<select id="getAllUsersByPage" resultType="org.javaboy.mybatis03.model.User">
    select * from user
</select>

可以看到,在 SQL 定義時(shí),壓根不用管分頁的事情,MyBatis 會(huì)查詢到所有的數(shù)據(jù),然后在內(nèi)存中進(jìn)行分頁處理。

Mapper 中方法的調(diào)用方式如下:

@Test
public void test3() {
    UserMapper userMapper = sqlSessionFactory.openSession().getMapper(UserMapper.class);
    RowBounds rowBounds = new RowBounds(1,2);
    List<User> list = userMapper.getAllUsersByPage(rowBounds);
    for (User user : list) {
        System.out.println("user = " + user);
    }
}

構(gòu)建 RowBounds 時(shí)傳入兩個(gè)參數(shù),分別是 offset 和 limit,對(duì)應(yīng)分頁 SQL 中的兩個(gè)參數(shù)。也可以通過 RowBounds.DEFAULT 的方式構(gòu)建一個(gè) RowBounds 實(shí)例,這種方式構(gòu)建出來的 RowBounds 實(shí)例,offset 為 0,limit 則為 Integer.MAX_VALUE,也就相當(dāng)于不分頁。

這就是 MyBatis 中提供的一個(gè)很不實(shí)用的內(nèi)存分頁功能。

了解了 MyBatis 自帶的內(nèi)存分頁之后,接下來我們就可以來看看如何自定義分頁插件了。

4.2 自定義分頁插件

首先要聲明一下,這里松哥帶大家自定義 MyBatis 分頁插件,主要是想通過這個(gè)東西讓小伙伴們了解自定義 MyBatis 插件的一些條條框框,了解整個(gè)自定義插件的流程,分頁插件并不是我們的目的,自定義分頁插件只是為了讓大家的學(xué)習(xí)過程變得有趣一些而已。

接下來我們就來開啟自定義分頁插件之旅。

首先我們需要自定義一個(gè) RowBounds,因?yàn)?MyBatis 原生的 RowBounds 是內(nèi)存分頁,并且沒有辦法獲取到總記錄數(shù)(一般分頁查詢的時(shí)候我們還需要獲取到總記錄數(shù)),所以我們自定義 PageRowBounds,對(duì)原生的 RowBounds 功能進(jìn)行增強(qiáng),如下:

public class PageRowBounds extends RowBounds {
    private Long total;

    public PageRowBounds(int offset, int limit) {
        super(offset, limit);
    }

    public PageRowBounds() {
    }

    public Long getTotal() {
        return total;
    }

    public void setTotal(Long total) {
        this.total = total;
    }
}

可以看到,我們自定義的 PageRowBounds 中增加了 total 字段,用來保存查詢的總記錄數(shù)。

接下來我們自定義攔截器 PageInterceptor,如下:

@Intercepts(@Signature(
        type = Executor.class,
        method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
))
public class PageInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameterObject = args[1];
        RowBounds rowBounds = (RowBounds) args[2];
        if (rowBounds != RowBounds.DEFAULT) {
            Executor executor = (Executor) invocation.getTarget();
            BoundSql boundSql = ms.getBoundSql(parameterObject);
            Field additionalParametersField = BoundSql.class.getDeclaredField("additionalParameters");
            additionalParametersField.setAccessible(true);
            Map<String, Object> additionalParameters = (Map<String, Object>) additionalParametersField.get(boundSql);
            if (rowBounds instanceof PageRowBounds) {
                MappedStatement countMs = newMappedStatement(ms, Long.class);
                CacheKey countKey = executor.createCacheKey(countMs, parameterObject, RowBounds.DEFAULT, boundSql);
                String countSql = "select count(*) from (" + boundSql.getSql() + ") temp";
                BoundSql countBoundSql = new BoundSql(ms.getConfiguration(), countSql, boundSql.getParameterMappings(), parameterObject);
                Set<String> keySet = additionalParameters.keySet();
                for (String key : keySet) {
                    countBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
                }
                List<Object> countQueryResult = executor.query(countMs, parameterObject, RowBounds.DEFAULT, (ResultHandler) args[3], countKey, countBoundSql);
                Long count = (Long) countQueryResult.get(0);
                ((PageRowBounds) rowBounds).setTotal(count);
            }
            CacheKey pageKey = executor.createCacheKey(ms, parameterObject, rowBounds, boundSql);
            pageKey.update("RowBounds");
            String pageSql = boundSql.getSql() + " limit " + rowBounds.getOffset() + "," + rowBounds.getLimit();
            BoundSql pageBoundSql = new BoundSql(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameterObject);
            Set<String> keySet = additionalParameters.keySet();
            for (String key : keySet) {
                pageBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
            }
            List list = executor.query(ms, parameterObject, RowBounds.DEFAULT, (ResultHandler) args[3], pageKey, pageBoundSql);
            return list;
        }
        //不需要分頁,直接返回結(jié)果
        return invocation.proceed();
    }

    private MappedStatement newMappedStatement(MappedStatement ms, Class<Long> longClass) {
        MappedStatement.Builder builder = new MappedStatement.Builder(
                ms.getConfiguration(), ms.getId() + "_count", ms.getSqlSource(), ms.getSqlCommandType()
        );
        ResultMap resultMap = new ResultMap.Builder(ms.getConfiguration(), ms.getId(), longClass, new ArrayList<>(0)).build();
        builder.resource(ms.getResource())
                .fetchSize(ms.getFetchSize())
                .statementType(ms.getStatementType())
                .timeout(ms.getTimeout())
                .parameterMap(ms.getParameterMap())
                .resultSetType(ms.getResultSetType())
                .cache(ms.getCache())
                .flushCacheRequired(ms.isFlushCacheRequired())
                .useCache(ms.isUseCache())
                .resultMaps(Arrays.asList(resultMap));
        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());
        }
        return builder.build();
    }
}

這是我們今天定義的核心代碼,涉及到的知識(shí)點(diǎn)松哥來給大家一個(gè)一個(gè)剖析。

  1. 首先通過 @Intercepts 注解配置攔截器簽名,從 @Signature 的定義中我們可以看到,攔截的是 Executor#query 方法,該方法有一個(gè)重載方法,通過 args 指定了方法參數(shù),進(jìn)而鎖定了重載方法(實(shí)際上該方法的另一個(gè)重載方法我們沒法攔截,那個(gè)是 MyBatis 內(nèi)部調(diào)用的,這里不做討論)。
  2. 將查詢操作攔截下來之后,接下來我們的操作主要在 PageInterceptor#intercept 方法中完成,該方法的參數(shù)重包含了攔截對(duì)象的諸多信息。
  3. 通過 invocation.getArgs() 獲取攔截方法的參數(shù),獲取到的是一個(gè)數(shù)組,正常來說這個(gè)數(shù)組的長度為 4。數(shù)組第一項(xiàng)是一個(gè) MappedStatement,我們?cè)?Mapper.xml 中定義的各種操作節(jié)點(diǎn)和 SQL,都被封裝成一個(gè)個(gè)的 MappedStatement 對(duì)象了;數(shù)組第二項(xiàng)就是所攔截方法的具體參數(shù),也就是你在 Mapper 接口中定義的方法參數(shù);數(shù)組的第三項(xiàng)是一個(gè) RowBounds 對(duì)象,我們?cè)?Mapper 接口中定義方法時(shí)不一定使用了 RowBounds 對(duì)象,如果我們沒有定義 RowBounds 對(duì)象,系統(tǒng)會(huì)給我們提供一個(gè)默認(rèn)的 RowBounds.DEFAULT;數(shù)組第四項(xiàng)則是一個(gè)處理返回值的 ResultHandler。
  4. 接下來判斷上一步提取到的 rowBounds 對(duì)象是否不為 RowBounds.DEFAULT,如果為 RowBounds.DEFAULT,說明用戶不想分頁;如果不為 RowBounds.DEFAULT,則說明用戶想要分頁,如果用戶不想分頁,則直接執(zhí)行最后的 return invocation.proceed();,讓方法繼續(xù)往下走就行了。
  5. 如果需要進(jìn)行分頁,則先從 invocation 對(duì)象中取出執(zhí)行器 Executor、BoundSql 以及通過反射拿出來 BoundSql 中保存的額外參數(shù)(如果我們使用了動(dòng)態(tài) SQL,可能會(huì)存在該參數(shù))。BoundSql 中封裝了我們執(zhí)行的 Sql 以及相關(guān)的參數(shù)。
  6. 接下來判斷 rowBounds 是否是 PageRowBounds 的實(shí)例,如果是,說明除了分頁查詢,還想要查詢總記錄數(shù),如果不是,則說明 rowBounds 可能是 RowBounds 實(shí)例,此時(shí)只要分頁即可,不用查詢總記錄數(shù)。
  7. 如果需要查詢總記錄數(shù),則首先調(diào)用 newMappedStatement 方法構(gòu)造出一個(gè)新的 MappedStatement 對(duì)象出來,這個(gè)新的 MappedStatement 對(duì)象的返回值是 Long 類型的。然后分別創(chuàng)建查詢的 CacheKey、拼接查詢的 countSql,再根據(jù) countSql 構(gòu)建出 countBoundSql,再將額外參數(shù)添加進(jìn) countBoundSql 中。最后通過 executor.query 方法完成查詢操作,并將查詢結(jié)果賦值給 PageRowBounds 中的 total 屬性。
  8. 接下來進(jìn)行分頁查詢,有了第七步的介紹之后,分頁查詢就很簡單了,這里就不細(xì)說了,唯一需要強(qiáng)調(diào)的是,當(dāng)我們啟動(dòng)了這個(gè)分頁插件之后,MyBatis 原生的 RowBounds 內(nèi)存分頁會(huì)變成物理分頁,原因就在這里我們修改了查詢 SQL。
  9. 最后將查詢結(jié)果返回。

在前面的代碼中,我們一共在兩個(gè)地方重新組織了 SQL,一個(gè)是查詢總記錄數(shù)的時(shí)候,另一個(gè)則是分頁的時(shí)候,都是通過 boundSql.getSql() 獲取到 Mapper.xml 中的 SQL 然后進(jìn)行改裝,有的小伙伴在 Mapper.xml 中寫 SQL 的時(shí)候不注意,結(jié)尾可能加上了 ;,這會(huì)導(dǎo)致分頁插件重新組裝的 SQL 運(yùn)行出錯(cuò),這點(diǎn)需要注意。松哥在 GitHub 上看到的其他 MyBatis 分頁插件也是一樣的,Mapper.xml 中 SQL 結(jié)尾不能有 ;。

如此之后,我們的分頁插件就算是定義成功了。

5.測(cè)試

接下來我們對(duì)我們的分頁插件進(jìn)行一個(gè)簡單測(cè)試。

首先我們需要在全局配置中配置分頁插件,配置方式如下:

<plugins>
    <plugin interceptor="org.javaboy.mybatis03.plugin.PageInterceptor"></plugin>
</plugins>

接下來我們?cè)?Mapper 中定義查詢接口:

public interface UserMapper {
    List<User> getAllUsersByPage(RowBounds rowBounds);
}

接下來定義 UserMapper.xml,如下:

<select id="getAllUsersByPage" resultType="org.javaboy.mybatis03.model.User">
    select * from user
</select>

最后我們進(jìn)行測(cè)試:

@Test
public void test3() {
    UserMapper userMapper = sqlSessionFactory.openSession().getMapper(UserMapper.class);
    List<User> list = userMapper.getAllUsersByPage(new RowBounds(1,2));
    for (User user : list) {
        System.out.println("user = " + user);
    }
}

這里在查詢時(shí),我們使用了 RowBounds 對(duì)象,就只會(huì)進(jìn)行分頁,而不會(huì)統(tǒng)計(jì)總記錄數(shù)。需要注意的時(shí),此時(shí)的分頁已經(jīng)不是內(nèi)存分頁,而是物理分頁了,這點(diǎn)我們從打印出來的 SQL 中也能看到,如下:

可以看到,查詢的時(shí)候就已經(jīng)進(jìn)行了分頁了。

當(dāng)然,我們也可以使用 PageRowBounds 進(jìn)行測(cè)試,如下:

@Test
public void test4() {
    UserMapper userMapper = sqlSessionFactory.openSession().getMapper(UserMapper.class);
    PageRowBounds pageRowBounds = new PageRowBounds(1, 2);
    List<User> list = userMapper.getAllUsersByPage(pageRowBounds);
    for (User user : list) {
        System.out.println("user = " + user);
    }
    System.out.println("pageRowBounds.getTotal() = " + pageRowBounds.getTotal());
}

此時(shí)通過 pageRowBounds.getTotal() 方法我們就可以獲取到總記錄數(shù)。

6.小結(jié)

好啦,今天主要和小伙伴們分享了我們?nèi)绾巫约洪_發(fā)一個(gè) MyBatis 插件,插件功能其實(shí)都是次要的,最主要是希望小伙伴們能夠理解 MyBatis 的工作流程。


責(zé)任編輯:武曉燕 來源: 江南一點(diǎn)雨
相關(guān)推薦

2024-03-05 18:27:43

2021-02-26 11:54:38

MyBatis 插件接口

2024-03-18 18:07:38

VSCode插件文件

2011-04-28 15:09:15

jQueryjqPlot

2011-05-27 08:41:26

JavascriptFirefox

2021-07-14 09:00:00

JavaFX開發(fā)應(yīng)用

2011-03-28 16:14:38

jQuery

2011-01-10 14:41:26

2011-05-03 15:59:00

黑盒打印機(jī)

2022-01-08 20:04:20

攔截系統(tǒng)調(diào)用

2022-03-14 14:47:21

HarmonyOS操作系統(tǒng)鴻蒙

2023-04-26 12:46:43

DockerSpringKubernetes

2022-12-07 08:42:35

2022-07-27 08:16:22

搜索引擎Lucene

2011-02-22 13:46:27

微軟SQL.NET

2021-12-28 08:38:26

Linux 中斷喚醒系統(tǒng)Linux 系統(tǒng)

2009-06-02 15:38:36

eclipse streclipse開發(fā)steclipse str

2021-11-24 16:02:57

鴻蒙HarmonyOS應(yīng)用

2017-07-07 11:01:04

Spark性能調(diào)優(yōu)

2020-08-12 07:41:39

SQL 優(yōu)化語句
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)