<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          Mybatis自定義攔截器與插件開發(fā)

          共 14700字,需瀏覽 30分鐘

           ·

          2021-12-09 13:51

          點擊關注公眾號,Java干貨及時送達??

          在Spring中我們經(jīng)常會使用到攔截器,在登錄驗證、日志記錄、性能監(jiān)控等場景中,通過使用攔截器允許我們在不改動業(yè)務代碼的情況下,執(zhí)行攔截器的方法來增強現(xiàn)有的邏輯。在mybatis中,同樣也有這樣的業(yè)務場景,有時候需要我們在不侵入原有業(yè)務代碼的情況下攔截sql,執(zhí)行特定的某些邏輯。那么這個過程應該怎么實現(xiàn)呢,同樣,在mybatis中也為開發(fā)者預留了攔截器接口,通過實現(xiàn)自定義攔截器這一功能,可以實現(xiàn)我們自己的插件,允許用戶在不改動mybatis的原有邏輯的條件下,實現(xiàn)自己的邏輯擴展。

          本文將按下面的結構進行mybatis攔截器學習:

          本文結構

          1、攔截器核心對象

          2、工作流程

          3、攔截器能實現(xiàn)什么

          4、插件定義與注冊

          5、攔截器使用示例

          6、總結

          攔截器核心對象

          在實現(xiàn)攔截器之前,我們首先看一下攔截器的攔截目標對象是什么,以及攔截器的工作流程是怎樣的。mybatis攔截器可以對下面4種對象進行攔截:

          1、Executor:mybatis的內(nèi)部執(zhí)行器,作為調(diào)度核心負責調(diào)用StatementHandler操作數(shù)據(jù)庫,并把結果集通過ResultSetHandler進行自動映射

          2、StatementHandler: 封裝了JDBC Statement操作,是sql語法的構建器,負責和數(shù)據(jù)庫進行交互執(zhí)行sql語句

          3、ParameterHandler:作為處理sql參數(shù)設置的對象,主要實現(xiàn)讀取參數(shù)和對PreparedStatement的參數(shù)進行賦值

          4、ResultSetHandler:處理Statement執(zhí)行完成后返回結果集的接口對象,mybatis通過它把ResultSet集合映射成實體對象

          工作流程

          在mybatis中提供了一個Interceptor接口,通過實現(xiàn)該接口就能夠自定義攔截器,接口中定義了3個方法:

          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
          ??}
          }
          • intercept:在攔截目標對象的方法時,實際執(zhí)行的增強邏輯,我們一般在該方法中實現(xiàn)自定義邏輯

          • plugin:用于返回原生目標對象或它的代理對象,當返回的是代理對象的時候,會調(diào)用intercept方法

          • setProperties:可以用于讀取配置文件中通過property標簽配置的一些屬性,設置一些屬性變量

          看一下plugin方法中的wrap方法源碼:

          public?static?Object?wrap(Object?target,?Interceptor?interceptor)?{
          ??Map,?Set>?signatureMap?=?getSignatureMap(interceptor);
          ??Class?type?=?target.getClass();
          ??Class[]?interfaces?=?getAllInterfaces(type,?signatureMap);
          ??if?(interfaces.length?>?0)?{
          ????return?Proxy.newProxyInstance(
          ????????type.getClassLoader(),
          ????????interfaces,
          ????????new?Plugin(target,?interceptor,?signatureMap));
          ??}
          ??return?target;
          }

          可以看到,在wrap方法中,通過使用jdk動態(tài)代理的方式,生成了目標對象的代理對象,在執(zhí)行實際方法前,先執(zhí)行代理對象中的邏輯,來實現(xiàn)的邏輯增強。以攔截Executorquery方法為例,在實際執(zhí)行前會執(zhí)行攔截器中的intercept方法:

          在mybatis中,不同類型的攔截器按照下面的順序執(zhí)行:

          Executor -> StatementHandler -> ParameterHandler -> ResultSetHandler

          以執(zhí)行query 方法為例對流程進行梳理,整體流程如下:

          1、Executor執(zhí)行query()方法,創(chuàng)建一個StatementHandler對象

          2、StatementHandler 調(diào)用ParameterHandler對象的setParameters()方法

          3、StatementHandler 調(diào)用 Statement對象的execute()方法

          4、StatementHandler 調(diào)用ResultSetHandler對象的handleResultSets()方法,返回最終結果

          攔截器能實現(xiàn)什么

          在對mybatis攔截器有了初步的認識后,來看一下攔截器被普遍應用在哪些方面:

          • sql 語句執(zhí)行監(jiān)控

            可以攔截執(zhí)行的sql方法,可以打印執(zhí)行的sql語句、參數(shù)等信息,并且還能夠記錄執(zhí)行的總耗時,可供后期的sql分析時使用

          • sql 分頁查詢

            mybatis中使用的RowBounds使用的內(nèi)存分頁,在分頁前會查詢所有符合條件的數(shù)據(jù),在數(shù)據(jù)量大的情況下性能較差。通過攔截器,可以做到在查詢前修改sql語句,提前加上需要的分頁參數(shù)

          • 公共字段的賦值

            在數(shù)據(jù)庫中通常會有createTimeupdateTime等公共字段,這類字段可以通過攔截統(tǒng)一對參數(shù)進行的賦值,從而省去手工通過set方法賦值的繁瑣過程

          • 數(shù)據(jù)權限過濾

            在很多系統(tǒng)中,不同的用戶可能擁有不同的數(shù)據(jù)訪問權限,例如在多租戶的系統(tǒng)中,要做到租戶間的數(shù)據(jù)隔離,每個租戶只能訪問到自己的數(shù)據(jù),通過攔截器改寫sql語句及參數(shù),能夠實現(xiàn)對數(shù)據(jù)的自動過濾

          除此之外,攔截器通過對上述的4個階段的介入,結合我們的實際業(yè)務場景,還能夠實現(xiàn)很多其他功能。

          插件定義與注冊

          在我們自定義的攔截器類實現(xiàn)了Interceptor接口后,還需要在類上添加@Intercepts 注解,標識該類是一個攔截器類。注解中的內(nèi)容是一個@Signature對象的數(shù)組,指明自定義攔截器要攔截哪一個類型的哪一個具體方法。其中type指明攔截對象的類型,method是攔截的方法,argsmethod執(zhí)行的參數(shù)。通過這里可以了解到 mybatis 攔截器的作用目標是在方法級別上進行攔截,例如要攔截Executorquery方法,就在類上添加:

          @Intercepts({
          ????????@Signature(type?=?Executor.class,method?=?"query",?args?=?{?MappedStatement.class,?Object.class,
          ????????????????RowBounds.class,?ResultHandler.class?})
          })

          如果要攔截多個方法,可以繼續(xù)以數(shù)組的形式往后追加。這里通過添加參數(shù)可以確定唯一的攔截方法,例如在Executor中存在兩個query方法,通過上面的參數(shù)可以確定要攔截的是下面的第2個方法:

          ?List?query(MappedStatement?ms,?Object?parameter,?RowBounds?rowBounds,?ResultHandler?resultHandler,?CacheKey?cacheKey,?BoundSql?boundSql);
          ?List?query(MappedStatement?ms,?Object?parameter,?RowBounds?rowBounds,?ResultHandler?resultHandler);

          當編寫完成我們自己的插件后,需要向mybatis中注冊插件,有兩種方式可以使用,第一種直接在SqlSessionFactory中配置:

          @Bean
          public?SqlSessionFactory?sqlSessionFactory(DataSource?dataSource)?throws?Exception?{
          ????SqlSessionFactoryBean?sqlSessionFactoryBean?=?new?SqlSessionFactoryBean();
          ????sqlSessionFactoryBean.setDataSource(dataSource);
          ????sqlSessionFactoryBean.setPlugins(new?Interceptor[]{new?ExecutorPlugin()});
          ????return?sqlSessionFactoryBean.getObject();
          }

          第2種是在mybatis-config.xml中對自定義插件進行注冊:

          <configuration>
          ????<plugins>
          ????????<plugin?interceptor="com.cn.plugin.interceptor.MyPlugin">
          ?????????<property?name="text"?value="hello"/>
          ????????plugin>
          ????????<plugin?interceptor="com.cn.plugin.interceptor.MyPlugin2">plugin>
          ????????<plugin?interceptor="com.cn.plugin.interceptor.MyPlugin3">plugin>
          ????plugins>
          configuration>

          在前面我們了解了不同類型攔截器執(zhí)行的固定順序,那么對于同樣類型的多個自定義攔截器,它們的執(zhí)行順序是怎樣的呢?分別在plugin方法和intercept中添加輸出語句,運行結果如下:

          從結果可以看到,攔截順序是按照注冊順序執(zhí)行的,但代理邏輯的執(zhí)行順序正好相反,最后注冊的會被最先執(zhí)行。這是因為在mybatis中有一個類InterceptorChain,在它的pluginAll()方法中,會對原生對象target進行代理,如果有多個攔截器的話,會對代理類再次進行代理,最終實現(xiàn)一層層的增強target對象,因此靠后被注冊的攔截器的增強邏輯會被優(yōu)先執(zhí)行。從下面的圖中可以直觀的看出代理的嵌套關系:

          xml中注冊完成后,在application.yml中啟用配置文件,這樣插件就可以正常運行了:

          mybatis:
          ??config-location:?classpath:mybatis-config.xml

          在了解了插件的基礎概念與運行流程之后,通過代碼看一下應用不同的攔截器能夠實現(xiàn)什么功能。

          攔截器使用示例

          Executor

          通過攔截Executorqueryupdate方法實現(xiàn)對sql的監(jiān)控,在攔截方法中,打印sql語句、執(zhí)行參數(shù)、實際執(zhí)行時間:

          @Intercepts({
          ????????@Signature(type?=?Executor.class,method?=?"update",?args?=?{MappedStatement.class,?Object.class}),
          ????????@Signature(type?
          =?Executor.class,method?=?"query",?args?=?{?MappedStatement.class,?Object.class,
          ????????????????RowBounds.class,?ResultHandler.class?})})
          public?class?ExecutorPlugin?implements?Interceptor?
          {
          ????@Override
          ????public?Object?intercept(Invocation?invocation)?throws?Throwable?{
          ????????System.out.println("Executor?Plugin?攔截?:"+invocation.getMethod());
          ????????Object[]?queryArgs?=?invocation.getArgs();
          ????????MappedStatement?mappedStatement?=?(MappedStatement)?queryArgs[0];
          ????????//獲取?ParamMap
          ????????MapperMethod.ParamMap?paramMap?=?(MapperMethod.ParamMap)?queryArgs[1];
          ????????//?獲取SQL
          ????????BoundSql?boundSql?=?mappedStatement.getBoundSql(paramMap);
          ????????String?sql?=?boundSql.getSql();
          ????????log.info("==>?ORIGIN?SQL:?"+sql);
          ????????long?startTime?=?System.currentTimeMillis();
          ????????Configuration?configuration?=?mappedStatement.getConfiguration();
          ????????String?sqlId?=?mappedStatement.getId();

          ????????Object?proceed?=?invocation.proceed();
          ????????long?endTime=System.currentTimeMillis();
          ????????long?time?=?endTime?-?startTime;
          ????????printSqlLog(configuration,boundSql,sqlId,time);
          ????????return?proceed;
          ????}

          ????public?static?void?printSqlLog(Configuration?configuration,?BoundSql?boundSql,?String?sqlId,?long?time){
          ????????Object?parameterObject?=?boundSql.getParameterObject();
          ????????List?parameterMappings?=?boundSql.getParameterMappings();
          ????????String?sql=?boundSql.getSql().replaceAll("[\\s]+",?"?");
          ????????StringBuffer?sb=new?StringBuffer("==>?PARAM:");
          ????????if?(parameterMappings.size()>0?&&?parameterObject!=null){
          ????????????TypeHandlerRegistry?typeHandlerRegistry?=?configuration.getTypeHandlerRegistry();
          ????????????if?(typeHandlerRegistry.hasTypeHandler(parameterObject.getClass()))?{
          ????????????????sql?=?sql.replaceFirst("\\?",?parameterObject.toString());
          ????????????}?else?{
          ????????????????MetaObject?metaObject?=?configuration.newMetaObject(parameterObject);
          ????????????????for?(ParameterMapping?parameterMapping?:?parameterMappings)?{
          ????????????????????String?propertyName?=?parameterMapping.getProperty();
          ????????????????????if?(metaObject.hasGetter(propertyName))?{
          ????????????????????????Object?obj?=?metaObject.getValue(propertyName);
          ????????????????????????String?parameterValue?=?obj.toString();
          ????????????????????????sql?=?sql.replaceFirst("\\?",?parameterValue);
          ????????????????????????sb.append(parameterValue).append("(").append(obj.getClass().getSimpleName()).append("),");
          ????????????????????}?else?if?(boundSql.hasAdditionalParameter(propertyName))?{
          ????????????????????????Object?obj?=?boundSql.getAdditionalParameter(propertyName);
          ????????????????????????String?parameterValue?=?obj.toString();
          ????????????????????????sql?=?sql.replaceFirst("\\?",?parameterValue);
          ????????????????????????sb.append(parameterValue).append("(").append(obj.getClass().getSimpleName()).append("),");
          ????????????????????}
          ????????????????}
          ????????????}
          ????????????sb.deleteCharAt(sb.length()-1);
          ????????}
          ????????log.info("==>?SQL:"+sql);
          ????????log.info(sb.toString());
          ????????log.info("==>?SQL?TIME:"+time+"?ms");
          ????}
          }

          執(zhí)行代碼,日志輸出如下:

          在上面的代碼中,通過Executor攔截器獲取到了BoundSql對象,進一步獲取到sql的執(zhí)行參數(shù),從而實現(xiàn)了對sql執(zhí)行的監(jiān)控與統(tǒng)計。

          StatementHandler

          下面的例子中,通過改變StatementHandler對象的屬性,動態(tài)修改sql語句的分頁:

          @Intercepts({
          ????????@Signature(type?=?StatementHandler.class,?method?=?"prepare",?args?=?{Connection.class,?Integer.class})})
          public?class?StatementPlugin?implements?Interceptor?
          {
          ????@Override
          ????public?Object?intercept(Invocation?invocation)?throws?Throwable?{????????
          ????????StatementHandler?statementHandler?=?(StatementHandler)?invocation.getTarget();
          ????????MetaObject?metaObject?=?SystemMetaObject.forObject(statementHandler);????????????
          ????????metaObject.setValue("delegate.rowBounds.offset",?0);
          ????????metaObject.setValue("delegate.rowBounds.limit",?2);
          ????????return?invocation.proceed();
          ????}
          }

          MetaObject是mybatis提供的一個用于方便、優(yōu)雅訪問對象屬性的對象,通過將實例對象作為參數(shù)傳遞給它,就可以通過屬性名稱獲取對應的屬性值。雖然說我們也可以通過反射拿到屬性的值,但是反射過程中需要對各種異常做出處理,會使代碼中堆滿難看的try/catch,通過MetaObject可以在很大程度上簡化我們的代碼,并且它支持對BeanCollectionMap三種類型對象的操作。

          對比執(zhí)行前后:

          可以看到這里通過改變了分頁對象RowBounds的屬性,動態(tài)的修改了分頁參數(shù)。

          ResultSetHandler

          ResultSetHandler 會負責映射sql語句查詢得到的結果集,如果在生產(chǎn)環(huán)境中存在一些保密數(shù)據(jù),不想在外部系統(tǒng)中展示,那么可能就需要在查詢到結果后做一下數(shù)據(jù)的脫敏處理,這時候就可以使用ResultSetHandler對結果集進行改寫。

          @Intercepts({
          ????????@Signature(type=?ResultSetHandler.class,method?=?"handleResultSets",args?=?{Statement.class})})
          public?class?ResultSetPlugin?implements?Interceptor?
          {
          ????@Override
          ????public?Object?intercept(Invocation?invocation)?throws?Throwable?{
          ????????System.out.println("Result?Plugin?攔截?:"+invocation.getMethod());
          ????????Object?result?=?invocation.proceed();
          ????????if?(result?instanceof?Collection)?{
          ????????????Collection?objList=?(Collection)?result;
          ????????????List?resultList=new?ArrayList<>();
          ????????????for?(Object?obj?:?objList)?{
          ????????????????resultList.add(desensitize(obj));
          ????????????}
          ????????????return?resultList;
          ????????}else?{
          ????????????return?desensitize(result);
          ????????}
          ????}
          ?//脫敏方法,將加密字段變?yōu)樾翘?/span>
          ????private?Object?desensitize(Object?object)?throws?InvocationTargetException,?IllegalAccessException?{
          ????????Field[]?fields?=?object.getClass().getDeclaredFields();
          ????????for?(Field?field?:?fields)?{
          ????????????Confidential?confidential?=?field.getAnnotation(Confidential.class);
          ????????????if?(confidential==null){
          ????????????????continue;
          ????????????}
          ????????????PropertyDescriptor?ps?=?BeanUtils.getPropertyDescriptor(object.getClass(),?field.getName());
          ????????????if?(ps.getReadMethod()?==?null?||?ps.getWriteMethod()?==?null)?{
          ????????????????continue;
          ????????????}
          ????????????Object?value?=?ps.getReadMethod().invoke(object);
          ????????????if?(value?!=?null)?{
          ????????????????ps.getWriteMethod().invoke(object,?"***");
          ????????????}
          ????????}
          ????????return?object;
          ????}
          }

          運行上面的代碼,查看執(zhí)行結果:

          {"id":1358041517788299266,"orderNumber":"***","money":122.0,"status":3,"tenantId":2}

          在上面的例子中,在執(zhí)行完sql語句得到結果對象后,通過反射掃描結果對象中的屬性,如果實體的屬性上帶有自定義的@Confidential注解,那么在脫敏方法中將它轉化為星號再返回結果,從而實現(xiàn)了數(shù)據(jù)的脫敏處理。

          ParameterHandler

          mybatis可以攔截ParameterHandler注入?yún)?shù),下面的例子中我們將結合前面介紹的其他種類的對象,通過組合攔截器的方式,實現(xiàn)一個簡單的多租戶攔截器插件,實現(xiàn)多租戶下的查詢邏輯。

          @Intercepts({
          ????????@Signature(type?=?Executor.class,method?=?"query",?args?=?{?MappedStatement.class,?Object.class,RowBounds.class,?ResultHandler.class?}),
          ????????@Signature(type?
          =?StatementHandler.class,?method?=?"prepare",?args?=?{Connection.class,?Integer.class}),
          ????????@Signature(type?
          =?ParameterHandler.class,?method?=?"setParameters",?args?=?PreparedStatement.class),
          })
          public?class?TenantPlugin?implements?Interceptor?
          {
          ????private?static?final?String?TENANT_ID?=?"tenantId";

          ????@Override
          ????public?Object?intercept(Invocation?invocation)?throws?Throwable?{
          ????????Object?target?=?invocation.getTarget();
          ????????String?methodName?=?invocation.getMethod().getName();
          ????????if?(target?instanceof?Executor?&&??methodName.equals("query")?&&?invocation.getArgs().length==4)?{
          ????????????return?doQuery(invocation);
          ????????}
          ????????if?(target?instanceof?StatementHandler){
          ????????????return?changeBoundSql(invocation);
          ????????}
          ????????if?(target?instanceof?ParameterHandler){
          ????????????return?doSetParameter(invocation);
          ????????}
          ????????return?null;
          ????}

          ????private?Object?doQuery(Invocation?invocation)?throws?Exception{
          ????????Executor?executor?=?(Executor)?invocation.getTarget();
          ????????MappedStatement?ms=?(MappedStatement)?invocation.getArgs()[0];
          ????????Object?paramObj?=?invocation.getArgs()[1];
          ????????RowBounds?rowBounds?=?(RowBounds)?invocation.getArgs()[2];

          ????????if?(paramObj?instanceof?Map){
          ????????????MapperMethod.ParamMap?paramMap=?(MapperMethod.ParamMap)?paramObj;
          ????????????if?(!paramMap.containsKey(TENANT_ID)){
          ????????????????Long?tenantId=1L;
          ????????????????paramMap.put("param"+(paramMap.size()/2+1),tenantId);
          ????????????????paramMap.put(TENANT_ID,tenantId);
          ????????????????paramObj=paramMap;
          ????????????}
          ????????}
          ????????//直接執(zhí)行query,不用proceed()方法
          ????????return?executor.query(ms,?paramObj,rowBounds,null);
          ????}

          ????private?Object?changeBoundSql(Invocation?invocation)?throws?Exception?{
          ????????StatementHandler?statementHandler?=?(StatementHandler)?invocation.getTarget();
          ????????MetaObject?metaObject?=?SystemMetaObject.forObject(statementHandler);
          ????????PreparedStatementHandler?preparedStatementHandler?=?(PreparedStatementHandler)?metaObject.getValue("delegate");
          ????????String?originalSql?=?(String)?metaObject.getValue("delegate.boundSql.sql");
          ????????metaObject.setValue("delegate.boundSql.sql",originalSql+?"?and?tenant_id=?");
          ????????return?invocation.proceed();
          ????}

          ????private?Object?doSetParameter(Invocation?invocation)?throws?Exception?{
          ????????ParameterHandler?parameterHandler?=?(ParameterHandler)?invocation.getTarget();
          ????????PreparedStatement?ps?=?(PreparedStatement)?invocation.getArgs()[0];
          ????????MetaObject?metaObject?=?SystemMetaObject.forObject(parameterHandler);
          ????????BoundSql?boundSql=?(BoundSql)?metaObject.getValue("boundSql");

          ????????List?parameterMappings?=?boundSql.getParameterMappings();
          ????????boolean?hasTenantId=false;
          ????????for?(ParameterMapping?parameterMapping?:?parameterMappings)?{
          ????????????if?(parameterMapping.getProperty().equals(TENANT_ID))?{
          ????????????????hasTenantId=true;
          ????????????}
          ????????}
          ????????//添加參數(shù)
          ????????if?(!hasTenantId){
          ????????????Configuration?conf=?(Configuration)?metaObject.getValue("configuration");
          ????????????ParameterMapping?parameterMapping=?new?ParameterMapping.Builder(conf,TENANT_ID,Long.class).build();
          ????????????parameterMappings.add(parameterMapping);
          ????????}
          ????????parameterHandler.setParameters(ps);
          ????????return?null;
          ????}
          }

          在上面的過程中,攔截了sql執(zhí)行的三個階段,來實現(xiàn)多租戶的邏輯,邏輯分工如下:

          • 攔截Executorquery方法,在查詢的參數(shù)Map中添加租戶的屬性值,這里只是簡單的對Map的情況作了判斷,沒有對Bean的情況進行設置
          • 攔截StatementHandlerprepare方法,改寫sql語句對象BoundSql,在sql語句中拼接租戶字段的查詢條件
          • 攔截ParameterHandlersetParameters方法,動態(tài)設置參數(shù),將租戶id添加到要設置到參數(shù)列表中

          最終通過攔截不同執(zhí)行階段的組合,實現(xiàn)了基于租戶的條件攔截。

          總結

          總的來說,mybatis攔截器通過對ExecutorStatementHandlerParameterHandlerResultSetHandler 這4種接口中的方法進行攔截,并生成代理對象,在執(zhí)行方法前先執(zhí)行代理對象的邏輯,來實現(xiàn)我們自定義的邏輯增強。從上面的例子中,可以看到通過靈活使用mybatis攔截器開發(fā)插件能夠幫助我們解決很多問題,但是同樣它也是一把雙刃劍,在實際工作中也不要濫用插件、定義過多的攔截器,因為通過學習我們知道m(xù)ybatis插件在執(zhí)行中使用到了代理模式和責任鏈模式,在執(zhí)行sql語句前會經(jīng)過層層代理,如果代理次數(shù)過多將會消耗額外的性能,并增加響應時間。

          1.?ELK不香了!我用Graylog

          2.?阿里面試這樣問:Nacos配置中心交互模型是 push 還是 pull ?(原理+源碼分析)

          3.?什么是 APM 系統(tǒng)?如何設計與實現(xiàn)?

          4.?帶著8個問題5分鐘教你學會 Arthas 診斷工具

          最近面試BAT,整理一份面試資料Java面試BATJ通關手冊,覆蓋了Java核心技術、JVM、Java并發(fā)、SSM、微服務、數(shù)據(jù)庫、數(shù)據(jù)結構等等。

          獲取方式:點“在看”,關注公眾號并回復?Java?領取,更多內(nèi)容陸續(xù)奉上。

          文章有幫助的話,在看,轉發(fā)吧。

          謝謝支持喲 (*^__^*)

          瀏覽 54
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                    <th id="afajh"><progress id="afajh"></progress></th>
                    亚洲色逼 | 中文字幕免费无码视频 | 午夜婷婷福利 | 国产在线欧美日韩字幕 | 搞基操逼摸奶黄色视频网站 |