<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的動態(tài)代理實現(xiàn)機制

          共 15541字,需瀏覽 32分鐘

           ·

          2021-01-29 13:43


          點擊上方?藍字?關注我們!



          Java,Python,C/C++,Linux,PHP,Go,C#,QT,大數據,算法,軟件教程,前端,簡歷,畢業(yè)設計等分類,資源在不斷更新中... 點擊領取

          每天 11 點更新文章,餓了點外賣,點擊 ??《無門檻外賣優(yōu)惠券,每天免費領!》

          前言

          一直以來都在使用MyBatis做持久化框架,也知道當我們定義XXXMapper接口類并利用它來做CRUD操作時,Mybatis是利用了動態(tài)代理的技術幫我們生成代理類。那么動態(tài)代理內部的實現(xiàn)細節(jié)到底是怎么的呀?XXXMapper.java類和XXXMapper.xml到底是如何關聯(lián)起來的呀?本篇文章就來詳細剖析下MyBatis的動態(tài)代理的具體實現(xiàn)機制。

          MyBatis的核心組件及應用

          在詳細探究MyBatis中動態(tài)代理機制之前,先來補充一下基礎知識,認識一下MyBatis的核心組件。

          • SqlSessionFactoryBuilder(構造器):它可以從XML、注解或者手動配置Java代碼來創(chuàng)建SqlSessionFactory。
          • SqlSessionFactory: 用于創(chuàng)建SqlSession (會話) 的工廠
          • SqlSession: SqlSession是Mybatis最核心的類,可以用于執(zhí)行語句、提交或回滾事務以及獲取映射器Mapper的接口
          • SQL Mapper:它是由一個Java接口和XML文件(或注解)構成的,需要給出對應的SQL和映射規(guī)則,它負責發(fā)送SQL去執(zhí)行,并返回結果

          注意:現(xiàn)在我們使用Mybatis,一般都是和Spring框架整合在一起使用,這種情況下,SqlSession將被Spring框架所創(chuàng)建,所以往往不需要我們使用SqlSessionFactoryBuilder或者SqlSessionFactory去創(chuàng)建SqlSession

          下面展示一下如何使用MyBatis的這些組件,或者如何快速使用MyBatis:

          1. 數據庫表
          ???CREATE?TABLE??user(
          ?????id?int,
          ?????name?VARCHAR(255)?not?NULL?,
          ?????age?int?,
          ?????PRIMARY?KEY?(id)
          ???)ENGINE?=INNODB?DEFAULT?CHARSET=utf8;
          1. 聲明一個User類
          ???@Data
          ???public?class?User?{
          ???????private?int?id;
          ???????private?int?age;
          ???????private?String?name;
          ???
          ???????@Override
          ???????public?String?toString()?{
          ???????????return?"User{"?+
          ???????????????????"id="?+?id?+
          ???????????????????",?age="?+?age?+
          ???????????????????",?name='"?+?name?+?'\''?+
          ???????????????????'
          }';
          ???????}
          ???}
          1. 定義一個全局配置文件mybatis-config.xml (關于配置文件中具體屬性標簽解釋參閱官方文檔)
          ???"1.0"?encoding="UTF-8"??>
          ???"-//mybatis.org//DTD?Config?3.0//EN"
          ???????????"http://mybatis.org/dtd/mybatis-3-config.dtd">
          ???
          ???
          ???????
          ???????"development">
          ???????????"development">
          ???????????????
          ???????????????type="MANAGED"?/>
          ???????????????
          ???????????????type="POOLED">
          ???????????????????"driver"?value="com.mysql.jdbc.Driver"?/>
          ???????????????????"url"?value="jdbc:mysql://localhost:3306/test"/>
          ???????????????????"username"?value="root"?/>
          ???????????????????"password"?value="root"?/>
          ???????????????
          ???????????
          ???????
          ???????
          ???????
          ???????????"mapper/UserMapper.xml"/>
          ???????

          ???

          1. UserMapper接口
          ???public?interface?UserMapper?{
          ???????User?selectById(int?id);
          ???}
          1. UserMapper文件
          ???"-//mybatis.org//DTD?Mapper?3.0//EN"
          ???????????"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
          ???
          ???"com.pjmike.mybatis.UserMapper">
          ???????"selectById"?parameterType="int"
          ???????????????resultType="com.pjmike.mybatis.User">
          ????????????????SELECT?id,name,age?FROM?user?where?id=?#{id}
          ??????????
          ???
          1. 測試類
          ???public?class?MybatisTest?{
          ???????private?static?SqlSessionFactory?sqlSessionFactory;
          ???????static?{
          ???????????try?{
          ???????????????sqlSessionFactory?=?new?SqlSessionFactoryBuilder()
          ???????????????????????.build(Resources.getResourceAsStream("mybatis-config.xml"));
          ???????????}?catch?(IOException?e)?{
          ???????????????e.printStackTrace();
          ???????????}
          ???????}
          ???????public?static?void?main(String[]?args)?{
          ???????????try?(SqlSession?sqlSession?=?sqlSessionFactory.openSession())?{
          ???????????????UserMapper?userMapper?=?sqlSession.getMapper(UserMapper.class);
          ???????????????User?user?=?userMapper.selectById(1);
          ???????????????System.out.println("User?:?"?+?user);
          ???????????}
          ???????}
          ???}
          ???//?結果:
          ???User?:?User{id=1,?age=21,?name='pjmike'}

          上面的例子簡單的展示了如何使用MyBatis,與此同時,我也將用這個例子來進一步探究MyBatis動態(tài)原理的實現(xiàn)。

          MyBatis動態(tài)代理的實現(xiàn)

          public?static?void?main(String[]?args)?{
          ????try?(SqlSession?sqlSession?=?sqlSessionFactory.openSession())?{
          ????????UserMapper?userMapper?=?sqlSession.getMapper(UserMapper.class);//?<1>
          ????????User?user?=?userMapper.selectById(1);
          ????????System.out.println("User?:?"?+?user);
          ????}
          }

          在前面的例子中,我們使用sqlSession.getMapper()方法獲取UserMapper對象,實際上這里我們是獲取了UserMapper接口的代理類,然后再由代理類執(zhí)行方法。那么這個代理類是如何生成的呢?在探究動態(tài)代理類如何生成之前,我們先來看下SqlSessionFactory工廠的創(chuàng)建過程做了哪些準備工作,比如說mybatis-config配置文件是如何讀取的,映射器文件是如何讀取的?

          mybatis全局配置文件解析

          private?static?SqlSessionFactory?sqlSessionFactory;
          static?{
          ????try?{
          ????????sqlSessionFactory?=?new?SqlSessionFactoryBuilder()
          ????????????????.build(Resources.getResourceAsStream("mybatis-config.xml"));
          ????}?catch?(IOException?e)?{
          ????????e.printStackTrace();
          ????}
          }

          我們使用new SqlSessionFactoryBuilder().build()的方式創(chuàng)建SqlSessionFactory工廠,走進build方法

          public?SqlSessionFactory?build(InputStream?inputStream,?Properties?properties)?{
          ???return?build(inputStream,?null,?properties);
          ?}

          ?public?SqlSessionFactory?build(InputStream?inputStream,?String?environment,?Properties?properties)?{
          ???try?{
          ?????XMLConfigBuilder?parser?=?new?XMLConfigBuilder(inputStream,?environment,?properties);
          ?????return?build(parser.parse());
          ???}?catch?(Exception?e)?{
          ?????throw?ExceptionFactory.wrapException("Error?building?SqlSession.",?e);
          ???}?finally?{
          ?????ErrorContext.instance().reset();
          ?????try?{
          ???????inputStream.close();
          ?????}?catch?(IOException?e)?{
          ???????//?Intentionally?ignore.?Prefer?previous?error.
          ?????}
          ???}
          ?}

          對于mybatis的全局配置文件的解析,相關解析代碼位于XMLConfigBuilder的parse()方法中:

          public?Configuration?parse()?{
          ???if?(parsed)?{
          ?????throw?new?BuilderException("Each?XMLConfigBuilder?can?only?be?used?once.");
          ???}
          ???parsed?=?true;
          ???//解析全局配置文件
          ???parseConfiguration(parser.evalNode("/configuration"));
          ???return?configuration;
          ?}

          ?private?void?parseConfiguration(XNode?root)?{
          ???try?{
          ?????//issue?#117?read?properties?first
          ?????propertiesElement(root.evalNode("properties"));
          ?????Properties?settings?=?settingsAsProperties(root.evalNode("settings"));
          ?????loadCustomVfs(settings);
          ?????loadCustomLogImpl(settings);
          ?????typeAliasesElement(root.evalNode("typeAliases"));
          ?????pluginElement(root.evalNode("plugins"));
          ?????objectFactoryElement(root.evalNode("objectFactory"));
          ?????objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
          ?????reflectorFactoryElement(root.evalNode("reflectorFactory"));
          ?????settingsElement(settings);
          ?????//?read?it?after?objectFactory?and?objectWrapperFactory?issue?#631
          ?????environmentsElement(root.evalNode("environments"));
          ?????databaseIdProviderElement(root.evalNode("databaseIdProvider"));
          ?????typeHandlerElement(root.evalNode("typeHandlers"));
          ?????//解析mapper映射器文件
          ?????mapperElement(root.evalNode("mappers"));
          ???}?catch?(Exception?e)?{
          ?????throw?new?BuilderException("Error?parsing?SQL?Mapper?Configuration.?Cause:?"?+?e,?e);
          ???}
          ?}

          從parseConfiguration方法的源代碼中很容易就可以看出它對mybatis全局配置文件中各個元素屬性的解析。當然最終解析后返回一個Configuration對象,Configuration是一個很重要的類,它包含了Mybatis的所有配置信息,它是通過XMLConfigBuilder取錢構建的,Mybatis通過XMLConfigBuilder讀取mybatis-config.xml中配置的信息,然后將這些信息保存到Configuration中

          映射器Mapper文件的解析

          //解析mapper映射器文件
          mapperElement(root.evalNode("mappers"));

          該方法是對全局配置文件中mappers屬性的解析,走進去:

          [](https://pjmike-1253796536.cos.ap-beijing.myqcloud.com/mybatis/mapper xml解析.png)mapper xml

          mapperParser.parse()方法就是XMLMapperBuilder對Mapper映射器文件進行解析,可與XMLConfigBuilder進行類比

          public?void?parse()?{
          ??if?(!configuration.isResourceLoaded(resource))?{
          ????configurationElement(parser.evalNode("/mapper"));?//解析映射文件的根節(jié)點mapper元素
          ????configuration.addLoadedResource(resource);??
          ????bindMapperForNamespace();?//重點方法,這個方法內部會根據namespace屬性值,生成動態(tài)代理類
          ??}
          ??parsePendingResultMaps();
          ??parsePendingCacheRefs();
          ??parsePendingStatements();
          }
          • configurationElement(XNode context)方法

          該方法主要用于將mapper文件中的元素信息,比如insertselect這等信息解析到MappedStatement對象,并保存到Configuration類中的mappedStatements屬性中,以便于后續(xù)動態(tài)代理類執(zhí)行CRUD操作時能夠獲取真正的Sql語句信息

          configurationElement

          buildStatementFromContext方法就用于解析insert、select這類元素信息,并將其封裝成MappedStatement對象,具體的實現(xiàn)細節(jié)這里就不細說了。

          • bindMapperForNamespace()方法

          該方法是核心方法,它會根據mapper文件中的namespace屬性值,為接口生成動態(tài)代理類,這就來到了我們的主題內容——動態(tài)代理類是如何生成的。

          動態(tài)代理類的生成

          bindMapperForNamespace方法源碼如下所示:

          private?void?bindMapperForNamespace()?{
          ???//獲取mapper元素的namespace屬性值
          ???String?namespace?=?builderAssistant.getCurrentNamespace();
          ???if?(namespace?!=?null)?{
          ?????Class?boundType?=?null;
          ?????try?{
          ???????//?獲取namespace屬性值對應的Class對象
          ???????boundType?=?Resources.classForName(namespace);
          ?????}?catch?(ClassNotFoundException?e)?{
          ???????//如果沒有這個類,則直接忽略,這是因為namespace屬性值只需要唯一即可,并不一定對應一個XXXMapper接口
          ???????//沒有XXXMapper接口的時候,我們可以直接使用SqlSession來進行增刪改查
          ?????}
          ?????if?(boundType?!=?null)?{
          ???????if?(!configuration.hasMapper(boundType))?{
          ?????????//?Spring?may?not?know?the?real?resource?name?so?we?set?a?flag
          ?????????//?to?prevent?loading?again?this?resource?from?the?mapper?interface
          ?????????//?look?at?MapperAnnotationBuilder#loadXmlResource
          ?????????configuration.addLoadedResource("namespace:"?+?namespace);
          ?????????//如果namespace屬性值有對應的Java類,調用Configuration的addMapper方法,將其添加到MapperRegistry中
          ?????????configuration.addMapper(boundType);
          ???????}
          ?????}
          ???}
          ?}

          這里提到了Configuration的addMapper方法,實際上Configuration類里面通過MapperRegistry對象維護了所有要生成動態(tài)代理類的XxxMapper接口信息,可見Configuration類確實是相當重要一類

          public?class?Configuration?{
          ????...
          ????protected?MapperRegistry?mapperRegistry?=?new?MapperRegistry(this);
          ????...
          ????public??void?addMapper(Class?type)?{
          ??????mapperRegistry.addMapper(type);
          ????}
          ????public??T?getMapper(Class?type,?SqlSession?sqlSession)?{
          ??????return?mapperRegistry.getMapper(type,?sqlSession);
          ????}
          ????...
          }

          其中兩個重要的方法:getMapper()和addMapper()

          • getMapper(): 用于創(chuàng)建接口的動態(tài)類
          • addMapper(): mybatis在解析配置文件時,會將需要生成動態(tài)代理類的接口注冊到其中

          1. Configuration#addMappper()

          Configuration將addMapper方法委托給MapperRegistry的addMapper進行的,源碼如下:

          public??void?addMapper(Class?type)?{
          ??//?這個class必須是一個接口,因為是使用JDK動態(tài)代理,所以需要是接口,否則不會針對其生成動態(tài)代理
          ??if?(type.isInterface())?{
          ????if?(hasMapper(type))?{
          ??????throw?new?BindingException("Type?"?+?type?+?"?is?already?known?to?the?MapperRegistry.");
          ????}
          ????boolean?loadCompleted?=?false;
          ????try?{
          ??????//?生成一個MapperProxyFactory,用于之后生成動態(tài)代理類
          ??????knownMappers.put(type,?new?MapperProxyFactory<>(type));
          ??????//以下代碼片段用于解析我們定義的XxxMapper接口里面使用的注解,這主要是處理不使用xml映射文件的情況
          ??????MapperAnnotationBuilder?parser?=?new?MapperAnnotationBuilder(config,?type);
          ??????parser.parse();
          ??????loadCompleted?=?true;
          ????}?finally?{
          ??????if?(!loadCompleted)?{
          ????????knownMappers.remove(type);
          ??????}
          ????}
          ??}
          }

          MapperRegistry內部維護一個映射關系,每個接口對應一個MapperProxyFactory(生成動態(tài)代理工廠類)

          private?final?Map,?MapperProxyFactory>?knownMappers?=?new?HashMap<>();

          這樣便于在后面調用MapperRegistry的getMapper()時,直接從Map中獲取某個接口對應的動態(tài)代理工廠類,然后再利用工廠類針對其接口生成真正的動態(tài)代理類。

          2. Configuration#getMapper()

          Configuration的getMapper()方法內部就是調用MapperRegistry的getMapper()方法,源代碼如下:

          public??T?getMapper(Class?type,?SqlSession?sqlSession)?{
          ??//根據Class對象獲取創(chuàng)建動態(tài)代理的工廠對象MapperProxyFactory
          ??final?MapperProxyFactory?mapperProxyFactory?=?(MapperProxyFactory)?knownMappers.get(type);
          ??if?(mapperProxyFactory?==?null)?{
          ????throw?new?BindingException("Type?"?+?type?+?"?is?not?known?to?the?MapperRegistry.");
          ??}
          ??try?{
          ????//這里可以看到每次調用都會創(chuàng)建一個新的代理對象返回
          ????return?mapperProxyFactory.newInstance(sqlSession);
          ??}?catch?(Exception?e)?{
          ????throw?new?BindingException("Error?getting?mapper?instance.?Cause:?"?+?e,?e);
          ??}
          }

          從上面可以看出,創(chuàng)建動態(tài)代理類的核心代碼就是在MapperProxyFactory.newInstance方法中,源碼如下:

          protected?T?newInstance(MapperProxy?mapperProxy)?{
          ??//這里使用JDK動態(tài)代理,通過Proxy.newProxyInstance生成動態(tài)代理類
          ??// newProxyInstance的參數:類加載器、接口類、InvocationHandler接口實現(xiàn)類
          ??//?動態(tài)代理可以將所有接口的調用重定向到調用處理器InvocationHandler,調用它的invoke方法
          ??return?(T)?Proxy.newProxyInstance(mapperInterface.getClassLoader(),?new?Class[]?{?mapperInterface?},?mapperProxy);
          }

          public?T?newInstance(SqlSession?sqlSession)?{
          ??final?MapperProxy?mapperProxy?=?new?MapperProxy<>(sqlSession,?mapperInterface,?methodCache);
          ??return?newInstance(mapperProxy);
          }

          PS: 關于JDK動態(tài)代理的詳細介紹這里就不再細說了,有興趣的可以參閱我之前寫的文章:動態(tài)代理的原理及其應用

          這里的InvocationHandler接口的實現(xiàn)類是MapperProxy,其源碼如下:

          public?class?MapperProxy?implements?InvocationHandler,?Serializable?{

          ??private?static?final?long?serialVersionUID?=?-6424540398559729838L;
          ??private?final?SqlSession?sqlSession;
          ??private?final?Class?mapperInterface;
          ??private?final?Map?methodCache;

          ??public?MapperProxy(SqlSession?sqlSession,?Class?mapperInterface,?Map?methodCache)?{
          ????this.sqlSession?=?sqlSession;
          ????this.mapperInterface?=?mapperInterface;
          ????this.methodCache?=?methodCache;
          ??}

          ??@Override
          ??public?Object?invoke(Object?proxy,?Method?method,?Object[]?args)?throws?Throwable?{
          ????
          ????try?{
          ??????//如果調用的是Object類中定義的方法,直接通過反射調用即可
          ??????if?(Object.class.equals(method.getDeclaringClass()))?{
          ????????return?method.invoke(this,?args);
          ??????}?else?if?(isDefaultMethod(method))?{
          ????????return?invokeDefaultMethod(proxy,?method,?args);
          ??????}
          ????}?catch?(Throwable?t)?{
          ??????throw?ExceptionUtil.unwrapThrowable(t);
          ????}
          ????//調用XxxMapper接口自定義的方法,進行代理
          ????//首先將當前被調用的方法Method構造成一個MapperMethod對象,然后掉用其execute方法真正的開始執(zhí)行。
          ????final?MapperMethod?mapperMethod?=?cachedMapperMethod(method);
          ????return?mapperMethod.execute(sqlSession,?args);
          ??}
          ??private?MapperMethod?cachedMapperMethod(Method?method)?{
          ????return?methodCache.computeIfAbsent(method,?k?->?new?MapperMethod(mapperInterface,?method,?sqlSession.getConfiguration()));
          ??}
          ??...
          }

          最終的執(zhí)行邏輯在于MapperMethod類的execute方法,源碼如下:

          public?class?MapperMethod?{

          ??private?final?SqlCommand?command;
          ??private?final?MethodSignature?method;

          ??public?MapperMethod(Class?mapperInterface,?Method?method,?Configuration?config)?{
          ????this.command?=?new?SqlCommand(config,?mapperInterface,?method);
          ????this.method?=?new?MethodSignature(config,?mapperInterface,?method);
          ??}
          ??public?Object?execute(SqlSession?sqlSession,?Object[]?args)?{
          ????Object?result;
          ????switch?(command.getType())?{
          ??????//insert語句的處理邏輯
          ??????case?INSERT:?{
          ????????Object?param?=?method.convertArgsToSqlCommandParam(args);
          ????????result?=?rowCountResult(sqlSession.insert(command.getName(),?param));
          ????????break;
          ??????}
          ??????//update語句的處理邏輯
          ??????case?UPDATE:?{
          ????????Object?param?=?method.convertArgsToSqlCommandParam(args);
          ????????result?=?rowCountResult(sqlSession.update(command.getName(),?param));
          ????????break;
          ??????}
          ??????//delete語句的處理邏輯
          ??????case?DELETE:?{
          ????????Object?param?=?method.convertArgsToSqlCommandParam(args);
          ????????result?=?rowCountResult(sqlSession.delete(command.getName(),?param));
          ????????break;
          ??????}
          ??????//select語句的處理邏輯
          ??????case?SELECT:
          ????????if?(method.returnsVoid()?&&?method.hasResultHandler())?{
          ??????????executeWithResultHandler(sqlSession,?args);
          ??????????result?=?null;
          ????????}?else?if?(method.returnsMany())?{
          ??????????result?=?executeForMany(sqlSession,?args);
          ????????}?else?if?(method.returnsMap())?{
          ??????????result?=?executeForMap(sqlSession,?args);
          ????????}?else?if?(method.returnsCursor())?{
          ??????????result?=?executeForCursor(sqlSession,?args);
          ????????}?else?{
          ??????????Object?param?=?method.convertArgsToSqlCommandParam(args);
          ??????????//調用sqlSession的selectOne方法
          ??????????result?=?sqlSession.selectOne(command.getName(),?param);
          ??????????if?(method.returnsOptional()
          ??????????????&&?(result?==?null?||?!method.getReturnType().equals(result.getClass())))?{
          ????????????result?=?Optional.ofNullable(result);
          ??????????}
          ????????}
          ????????break;
          ??????case?FLUSH:
          ????????result?=?sqlSession.flushStatements();
          ????????break;
          ??????default:
          ????????throw?new?BindingException("Unknown?execution?method?for:?"?+?command.getName());
          ????}
          ????if?(result?==?null?&&?method.getReturnType().isPrimitive()?&&?!method.returnsVoid())?{
          ??????throw?new?BindingException("Mapper?method?'"?+?command.getName()
          ??????????+?"?attempted?to?return?null?from?a?method?with?a?primitive?return?type?("?+?method.getReturnType()?+?").");
          ????}
          ????return?result;
          ??}
          ??...
          }

          在MapperMethod中還有兩個內部類,SqlCommand和MethodSignature類,在execute方法中首先用switch case語句根據SqlCommand的getType()方法,判斷要執(zhí)行的sql類型,比如INSET、UPDATE、DELETE、SELECT和FLUSH,然后分別調用SqlSession的增刪改查等方法。

          慢著,說了這么多,那么這個getMapper()方法什么時候被調用呀?實際是一開始我們調用SqlSession的getMapper()方法:

          UserMapper?userMapper?=?sqlSession.getMapper(UserMapper.class);

          public?class?DefaultSqlSession?implements?SqlSession?{

          ??private?final?Configuration?configuration;
          ??private?final?Executor?executor;
          ??@Override
          ??public??T?getMapper(Class?type)?{
          ????return?configuration.getMapper(type,?this);
          ??}
          ??...
          }

          所以getMapper方法的大致調用邏輯鏈是:SqlSession#getMapper() ——> Configuration#getMapper() ——> MapperRegistry#getMapper() ——> MapperProxyFactory#newInstance() ——> Proxy#newProxyInstance()

          還有一點我們需要注意:我們通過SqlSession的getMapper方法獲得接口代理來進行CRUD操作,其底層還是依靠的是SqlSession的使用方法

          小結

          根據上面的探究過程,簡單畫了一個邏輯圖(不一定準確):

          本篇文章主要介紹了MyBatis的動態(tài)原理,回過頭來,我們需要知道我們使用UserMapper的動態(tài)代理類進行CRUD操作,本質上還是通過SqlSession這個關鍵類執(zhí)行增刪改查操作,但是對于SqlSession如何具體執(zhí)行CRUD的操作并沒有仔細闡述,有興趣的同學可以查閱相關資料。


          往期推薦

          IDEA 卡成一個球了 !如何優(yōu)化 ?

          微服務項目優(yōu)雅上線、下線小技巧,你學廢了嗎?

          Nginx HTTPS 調優(yōu) - 看我如何提速 30%?

          Dockerd 資源泄露應該如何應對?


          看完文章,餓了點外賣,點擊 ??《無門檻外賣優(yōu)惠券,每天免費領!》

          END



          若覺得文章對你有幫助,隨手轉發(fā)分享,也是我們繼續(xù)更新的動力。


          長按二維碼,掃掃關注哦

          ?「C語言中文網」官方公眾號,關注手機閱讀教程??


          必備編程學習資料


          目前收集的資料包括:?Java,Python,C/C++,Linux,PHP,go,C#,QT,git/svn,人工智能,大數據,單片機,算法,小程序,易語言,安卓,ios,PPT,軟件教程,前端,軟件測試,簡歷,畢業(yè)設計,公開課?等分類,資源在不斷更新中...


          點擊“閱讀原文”,立即免費領取最新資料!
          ??????
          瀏覽 36
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

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

          手機掃一掃分享

          分享
          舉報
          <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>
                  1区2区综合 | 精品1卡二卡三卡四卡老狼 | 高清无码视频免费看 | 精品无码一区二区三区 | 成人在线视频黄色 |