温馨提示×

温馨提示×

您好,登录后才能下订单哦!

密码登录×
登录注册×
其他方式登录
点击 登录注册 即表示同意《亿速云用户服务条款》

Mybatis源码分析之如何理解SQLSession初始化

发布时间:2021-10-21 16:48:15 来源:亿速云 阅读:145 作者:iii 栏目:编程语言

这篇文章主要介绍“Mybatis源码分析之如何理解SQLSession初始化”,在日常操作中,相信很多人在Mybatis源码分析之如何理解SQLSession初始化问题上存在疑惑,小编查阅了各式资料,整理出简单好用的操作方法,希望对大家解答”Mybatis源码分析之如何理解SQLSession初始化”的疑惑有所帮助!接下来,请跟着小编一起来学习吧!

这次打算写一个 Mybatis 源码分析的系列,大致分为

  • Mybatis 启动流程分析

  • Mybatis 的SQL 执行流程分析

  • Mybatis 的拓展点以及与 Spring Boot 的整合

这篇文章先来分析 Mybati初始化流程,如何读取配置文件到,以及创建出 SqlSession 示例.主要内容包括

  • 读取、解析mybatis 全局配置文件

  • 映射 mapper.java 文件

  • 解析 mapper.xml 文件

  • 解析 mapper.xml 各个节点配置,包括 namespace、缓存、增删改查节点

  • Mybatis 缓存机制

  • 构建DefaultSqlSessionFactory

什么是 SQLSession

SQLSession对外提供了用户和数据库之间交互需要的所有方法,隐藏了底层的细节。默认实现类是DefaultSqlSession

SQLSession 创建示例

通过一个mybatis 官方提供的示例,看下如何手动创建 SQLSession

//Mybatis 配置文件,通常包含:数据库连接信息,Mapper.class 全限定名包路径,事务配置,插件配置等等
String resource = "org/mybatis/builder/mybatis-config.xml";
//以输入流的方式读取配置
InputStream inputStream = Resources.getResourceAsStream(resource);
//实例化出 SQLSession 的必要步骤 SqlSessionFactoryBuilder --> SqlSessionFactory  --> SqlSession
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
SqlSessionFactory factory = builder.build(inputStream);
SqlSession session = factory.openSession();

通过输入流读取mybatis-config.xml 配置文件

接下来就通过new SqlSessionFactoryBuilder() 开始我们的构建 SQLSession 源码分析

//SqlSessionFactory 有4 个构造方法,最终都会执行到全参的构造方法
public SqlSessionFactory build(Reader reader, String environment, Properties properties) {
  try {
    //首先会实例化一个 XMLConfigBuilder ,这里先有个基本的认知:XMLConfigBuilder 就是用来解析 XML 文件配置的
    XMLConfigBuilder parser = new XMLConfigBuilder(reader, environment, properties);
    //经过parser.parse()之后,XML配置文件已经被解析成了Configuration ,Configuration 对象是包含着mybatis的所有属性.
    return build(parser.parse());
  } catch (Exception e) {
    throw ExceptionFactory.wrapException("Error building SqlSession.", e);
  } finally {
   //... 关闭流,抛异常
  }
}
插句嘴,先看下XMLConfigBuilder 类图

Mybatis源码分析之如何理解SQLSession初始化

  • MLConfigBuilder : 解析全局配置文件即 mybatis-config.xml

  • XMLMapperBuilder : 解析 Mapper 文件,配置在mybatis-config.xml 文件中 mapper.java 的包路径

  • XMLStatementBuilder :解析 mapper 文件的节点中 ,SQL 语句标签:select,update,insert,delete

  • SQLSourceBuilder:动态解析 SQL 语句,根据 SqlNode 解析 Sql 语句中的标签,比如<trim>,<if>等标签

当然 BaseBuilder 的实现类不仅这 4 个,这里只介绍这 4 类,在后续一步步分析中都能看到这几个的身影 点进去看一下 parser.parse()

public Configuration parse() {
  // 若已经解析过了 就抛出异常
  if (parsed) {
    throw new BuilderException("Each XMLConfigBuilder can only be used once.");
  }
  // 设置解析标志位
  parsed = true;
  // 解析mybatis-config.xml的节点,读取配置文件,加载到 Configuration 中 
  parseConfiguration(parser.evalNode("/configuration"));
  return configuration;
}

通过XPathParser 对象来解析 xml 文件成XNode 对象

解析成 Configuration 成之前会先将 xml 配置文件解析成 XNode 对象

public XNode evalNode(Object root, String expression) {
  //mybatis 自已定义了一个XPathParser 对象来解析 xml ,其实对Document做了封装 
  Node node = (Node) evaluate(expression, root, XPathConstants.NODE);
  if (node == null) {
    return null;
  }
  return new XNode(this, node, variables);
}

解析config.xml 中的各个节点

接下来就看看下mybatis 是如何一步步读取配置文件的

/**
 * 解析 mybatis-config.xml的 configuration节点
 * 解析 XML 中的各个节点
 */
private void parseConfiguration(XNode root) {
  try {
    /**
     * 解析 properties节点
     *     <properties resource="mybatis/db.properties" />
     *     解析到org.apache.ibatis.parsing.XPathParser#variables
     *     org.apache.ibatis.session.Configuration#variables
     */
    propertiesElement(root.evalNode("properties"));
    /**
     * 解析我们的mybatis-config.xml中的settings节点
     * 具体可以配置哪些属性:http://www.mybatis.org/mybatis-3/zh/configuration.html#settings
     * <settings>
          <setting name="cacheEnabled" value="true"/>
          <setting name="lazyLoadingEnabled" value="true"/>
         <setting name="mapUnderscoreToCamelCase" value="false"/>
         <setting name="localCacheScope" value="SESSION"/>
         <setting name="jdbcTypeForNull" value="OTHER"/>
          ..............
         </settings>
     *
     */
    Properties settings = settingsAsProperties(root.evalNode("settings"));
    /**
     * 基本没有用过该属性
     * VFS含义是虚拟文件系统;主要是通过程序能够方便读取本地文件系统、FTP文件系统等系统中的文件资源。
       Mybatis中提供了VFS这个配置,主要是通过该配置可以加载自定义的虚拟文件系统应用程序
       解析到:org.apache.ibatis.session.Configuration#vfsImpl
     */
    loadCustomVfs(settings);
    /**
     * 指定 MyBatis 所用日志的具体实现,未指定时将自动查找。
     * SLF4J | LOG4J | LOG4J2 | JDK_LOGGING | COMMONS_LOGGING | STDOUT_LOGGING | NO_LOGGING
     * 解析到org.apache.ibatis.session.Configuration#logImpl
     */
    loadCustomLogImpl(settings);
    /**
     * 解析我们的别名
     * <typeAliases>
         <typeAlias alias="User" type="com.xxx.entity.User"/>
      </typeAliases>
     <typeAliases>
        <package name="com.xxx.use"/>
     </typeAliases>
     解析到oorg.apache.ibatis.session.Configuration#typeAliasRegistry.typeAliases
     */
    typeAliasesElement(root.evalNode("typeAliases"));
    /**
     * 解析我们的插件(比如分页插件)
     * mybatis自带的
     * Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
       ParameterHandler (getParameterObject, setParameters)
       ResultSetHandler (handleResultSets, handleOutputParameters)
       StatementHandler (prepare, parameterize, batch, update, query)
      解析到:org.apache.ibatis.session.Configuration#interceptorChain.interceptors
     */
    pluginElement(root.evalNode("plugins"));

    objectFactoryElement(root.evalNode("objectFactory"));
    objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
    reflectorFactoryElement(root.evalNode("reflectorFactory"));
    // 设置settings 和默认值
    settingsElement(settings);
    // read it after objectFactory and objectWrapperFactory issue #631

    /**
     * 解析我们的mybatis环境,解析 DataSource
       <environments default="dev">
         <environment id="dev">
           <transactionManager type="JDBC"/>
           <dataSource type="POOLED">
           <property name="driver" value="${jdbc.driver}"/>
           <property name="url" value="${jdbc.url}"/>
           <property name="username" value="root"/>
           <property name="password" value="Zw726515"/>
           </dataSource>
         </environment>

       <environment id="test">
         <transactionManager type="JDBC"/>
         <dataSource type="POOLED">
         <property name="driver" value="${jdbc.driver}"/>
         <property name="url" value="${jdbc.url}"/>
         <property name="username" value="root"/>
         <property name="password" value="123456"/>
         </dataSource>
       </environment>
     </environments>
     *  解析到:org.apache.ibatis.session.Configuration#environment
     *  在集成spring情况下由 spring-mybatis提供数据源 和事务工厂
     */
    environmentsElement(root.evalNode("environments"));
    /**
     * 解析数据库厂商
     *     <databaseIdProvider type="DB_VENDOR">
              <property name="SQL Server" value="sqlserver"/>
              <property name="DB2" value="db2"/>
              <property name="Oracle" value="oracle" />
              <property name="MySql" value="mysql" />
           </databaseIdProvider>
     *  解析到:org.apache.ibatis.session.Configuration#databaseId
     */
    databaseIdProviderElement(root.evalNode("databaseIdProvider"));
    /**
     * 解析我们的类型处理器节点
     * <typeHandlers>
          <typeHandler handler="org.mybatis.example.ExampleTypeHandler"/>
        </typeHandlers>
        解析到:org.apache.ibatis.session.Configuration#typeHandlerRegistry.typeHandlerMap
     */
    typeHandlerElement(root.evalNode("typeHandlers"));
    /**
     * 最最重要的就是解析我们的mapper
     *
     resource:来注册我们的class类路径下的
     url:来指定我们磁盘下的或者网络资源的
     class:
     若注册Mapper不带xml文件的,这里可以直接注册
     若注册的Mapper带xml文件的,需要把xml文件和mapper文件同名 同路径
     -->
     <mappers>
        <mapper resource="mybatis/mapper/EmployeeMapper.xml"/>
        <mapper class="com.tuling.mapper.DeptMapper"></mapper>


          <package name="com.tuling.mapper"></package>
        -->
     </mappers>
     * 解析 mapper:
     * 1.解析mapper.java接口 解析到:org.apache.ibatis.session.Configuration#mapperRegistry.knownMappers
     * 2.解析 mapper.xml 配置
     */
    mapperElement(root.evalNode("mappers"));
  } catch (Exception e) {
    throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
  }
}

解析 mapper 文件

解析 mapper.java 接口到knowMappers 中

private void mapperElement(XNode parent) throws Exception {
  if (parent != null) {
    //获取我们mappers节点下的一个一个的mapper节点
    for (XNode child : parent.getChildren()) {
      /**
         * 指定 mapper 的 4 中方式:
         * 1.指定的 mapper 所在的包路径,批量注册
         * 2.通过 resource 目录指定
         * 3.通过 url 指定,从网络资源或者本地磁盘
         * 4.通过 class 路径注册
         */
      //判断我们mapper是不是通过批量注册的
      if ("package".equals(child.getName())) {
        String mapperPackage = child.getStringAttribute("name");
        configuration.addMappers(mapperPackage);
      } else {
        //判断从classpath下读取我们的mapper
        String resource = child.getStringAttribute("resource");
        //判断是不是从我们的网络资源读取(或者本地磁盘得)
        String url = child.getStringAttribute("url");
        //解析这种类型(要求接口和xml在同一个包下)
        String mapperClass = child.getStringAttribute("class");

        //解析 mapper 文件
        if (resource != null && url == null && mapperClass == null) {
          ErrorContext.instance().resource(resource);
          // 把mapper文件读取出一个流,是不是似曾相识,最开始的时候读取mybatis-config.xml 配置文件也是通过输入流的方式读取的
          InputStream inputStream = Resources.getResourceAsStream(resource);
          //创建读取XmlMapper构建器对象,用于来解析我们的mapper.xml文件,上面提到过的 XMLMapperBuilder对象
          /**
           * 读取的 mapper 文件会被放入到 MapperRegistry 中的 knownMappers中
           * Map<Class<?>, MapperProxyFactory<?>> knownMappers = new HashMap<>();
           * 为后续创建 Mapper 代理对象做准备
           */
          XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
          //真正的解析我们的mapper.xml配置文件,这里就会来解析我们的sql
          mapperParser.parse();
        } else if (resource == null && url != null && mapperClass == null) {
          ErrorContext.instance().resource(url);
          InputStream inputStream = Resources.getUrlAsStream(url);
          XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
          mapperParser.parse();
        } else if (resource == null && url == null && mapperClass != null) {
          Class<?> mapperInterface = Resources.classForName(mapperClass);
          configuration.addMapper(mapperInterface);
        } else {
          throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one.");
        }
      }
    }
  }
}

解析 mapper.xml 文件

解析Mapper.xml 中的 SQL 标签

//解析的 SQL 语句节点会放在Configuration.MappedStatement.SqlSource 中,SqlSource 中包含了一个个的 SQLNode,一个标签对应一个 SQLNode
public void parse() {
  //判断当前的Mapper是否被加载过
  if (!configuration.isResourceLoaded(resource)) {
    //真正的解析我们的mapper
    configurationElement(parser.evalNode("/mapper"));
    //把资源保存到我们Configuration中
    configuration.addLoadedResource(resource);

    bindMapperForNamespace();
  }
  
  parsePendingResultMaps();
  parsePendingCacheRefs();
  parsePendingStatements();
}

解析 mapper.xml 中的各个节点

//解析我们的<mapper></mapper>节点
private void configurationElement(XNode context) {
  try {
    /**
     * 解析我们的namespace属性
     * <mapper namespace="com.xx.mapper.xxxMapper">
     */
    String namespace = context.getStringAttribute("namespace");
    if (namespace == null || namespace.equals("")) {
      throw new BuilderException("Mapper's namespace cannot be empty");
    }
    //保存我们当前的namespace  并且判断接口完全类名==namespace
    builderAssistant.setCurrentNamespace(namespace);
    /**
     * 解析我们的缓存引用
     * 说明我当前的缓存引用和DeptMapper的缓存引用一致
     * <cache-ref namespace="com.xx.mapper.xxxMapper"></cache-ref>
          解析到org.apache.ibatis.session.Configuration#cacheRefMap<当前namespace,ref-namespace>
          异常下(引用缓存未使用缓存):org.apache.ibatis.session.Configuration#incompleteCacheRefs
     */
    cacheRefElement(context.evalNode("cache-ref"));
    /**
     * 解析我们的cache节点
     * <cache type="org.mybatis.caches.ehcache.EhcacheCache"></cache>
        解析到:org.apache.ibatis.session.Configuration#caches
               org.apache.ibatis.builder.MapperBuilderAssistant#currentCache
     */
    cacheElement(context.evalNode("cache"));
    /**
     * 解析paramterMap节点
     */
    parameterMapElement(context.evalNodes("/mapper/parameterMap"));
    /**
     * 解析我们的resultMap节点
     * 解析到:org.apache.ibatis.session.Configuration#resultMaps
     *    异常 org.apache.ibatis.session.Configuration#incompleteResultMaps
     *
     */
    resultMapElements(context.evalNodes("/mapper/resultMap"));
    /**
     * 解析我们通过sql节点
     *  解析到org.apache.ibatis.builder.xml.XMLMapperBuilder#sqlFragments
     *   其实等于 org.apache.ibatis.session.Configuration#sqlFragments
     *   因为他们是同一引用,在构建XMLMapperBuilder 时把Configuration.getSqlFragments传进去了
     */
    sqlElement(context.evalNodes("/mapper/sql"));
    /**
     * 解析我们的select | insert |update |delete节点
     * 解析到org.apache.ibatis.session.Configuration#mappedStatements
     * 最终SQL节点会被解析成 MappedStatement,一个节点就是对应一个MappedStatement
     * 准确的说 sql 节点被解析成 SQLNode 封装在 MappedStatement.SqlSource 中
     * SQLNode 对应的就是 sql 节点中的子标签,比如<trim>,<if>,<where> 等
     */
    buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
  } catch (Exception e) {
    throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e);
  }
}

着重分析几个解析过程

解析缓存
  private void cacheElement(XNode context) {
    if (context != null) {
    /**
     * cache元素可指定如下属性,每种属性的指定都是针对都是针对底层Cache的一种装饰,采用的是装饰器的模式
     * 缓存属性:
     * 1.eviction: 缓存过期策略:默认是LRU
     *      LRU – 最近最少使用的:移除最长时间不被使用的对象。--> LruCache
     *      FIFO – 先进先出:按对象进入缓存的顺序来移除它们。--> FifoCache
     *      SOFT – 软引用:移除基于垃圾回收器状态和软引用规则的对象。--> SoftCache
     *      WEAK – 弱引用:更积极地移除基于垃圾收集器状态和弱引用规则的对象。--> WeakCache
     * 2.flushInterval: 清空缓存的时间间隔,单位毫秒,默认不清空,指定了之后将会用 ScheduleCache 封装
     * 3.size :缓存对象的大小,默认是 1024,其是针对LruCache而言的,LruCache默认只存储最多1024个Key
     * 4.readOnly :默认是false,底层SerializedCache包装,会在写缓存的时候将缓存对象进行序列化,然后在读缓存的时候进行反序列化,这样每次读到的都将是一个新的对象,即使你更改了读取到的结果,也不会影响原来缓存的对象;true-给所有调用者返回缓存对象的相同实例
     * 5.blocking : 默认为false,当指定为true时将采用BlockingCache进行封装,在进行增删改之后的并发查询,只会有一条去数据库查询,而不会并发访问
     * 6.type: type属性用来指定当前底层缓存实现类,默认是PerpetualCache,如果我们想使用自定义的Cache,则可以通过该属性来指定,对应的值是我们自定义的Cache的全路径名称
     */
      //解析cache节点的type属性
      String type = context.getStringAttribute("type", "PERPETUAL");
      //根据type的String获取class类型
      Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
      //获取缓存过期策略:默认是LRU
      String eviction = context.getStringAttribute("eviction", "LRU");
      Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
      //flushInterval(刷新间隔)属性可以被设置为任意的正整数,设置的值应该是一个以毫秒为单位的合理时间量。 默认情况是不设置,也就是没有刷新间隔,缓存仅仅会在调用语句时刷新。
      Long flushInterval = context.getLongAttribute("flushInterval");
      //size(引用数目)属性可以被设置为任意正整数,要注意欲缓存对象的大小和运行环境中可用的内存资源。默认值是 1024。
      Integer size = context.getIntAttribute("size");
      //只读)属性可以被设置为 true 或 false。只读的缓存会给所有调用者返回缓存对象的相同实例。 因此这些对象不能被修改。这就提供了可观的性能提升。而可读写的缓存会(通过序列化)返回缓存对象的拷贝。 速度上会慢一些,但是更安全,因此默认值是 false
      boolean readWrite = !context.getBooleanAttribute("readOnly", false);
      boolean blocking = context.getBooleanAttribute("blocking", false);
      Properties props = context.getChildrenAsProperties();
      //把缓存节点加入到Configuration中
      //这里的 builder()方法利用责任链方式循环实例化Cache 对象
      builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
    }
  }
Mybatis 缓存机制
MyBatis自带的缓存有一级缓存和二级缓存

一级缓存

Mybatis一级缓存是指Session缓存。作用域默认是一个SqlSession。默认开启一级缓存,范围有SESSION和STATEMENT两种,默认是SESSION,如果需要更改一级缓存的范围,可以在Mybatis的配置文件中,通过localCacheScope指定

<setting name="localCacheScope" value="STATEMENT"/>

二级缓存

Mybatis的二级缓存是指mapper映射文件。二级缓存的作用域是同一个namespace下的mapper映射文件内容,多个SqlSession共享。二级缓存是默认启用的,但是需要手动在 mapper 文件中设置启动二级缓存

//在 mapper.xml 文件加上此配置,该 mapper 文件对应的 SQL就开启了缓存
  <cache />

或者直接关闭缓存

//在全局配置文件中关闭缓存
<settings>
  <setting name="cacheEnabled" value="false" />
</settings>

注意:如果开启了二级缓存,查询结果的映射对象一定要实现Serializable ,因为mybatis 缓存对象的时候默认是会对映射对象进行序列号操作的

解析select | insert |update |delete节点
private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) {
  //循环我们的select|delte|insert|update节点
  for (XNode context : list) {
    //创建一个xmlStatement的构建器对象
    final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId);
    try {
      //通过该步骤解析之后 mapper.xml 的 sql 节点就也被解析了  
      statementParser.parseStatementNode();
    } catch (IncompleteElementException e) {
      configuration.addIncompleteStatement(statementParser);
    }
  }
}

至此配置mybatis 的配置文件已经解析完成,配置文件已经解析成了Configuration,会到最初,我们的目标是获取 SqlSession 对象,通过new SqlSessionFactoryBuilder().build(reader) 已经构建出了一个SqlSessionFactory 工厂对象,还差一步 SqlSession session = sqlMapper.openSession();

根据 Configuration build 出 DefaultSqlSessionFactory

通过分析DefaultSqlSession 的 openSession() 来实例化 SQLSession 对象

//从session中开启一个数据源
private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
  Transaction tx = null;
  try {
    //获取环境变量
    final Environment environment = configuration.getEnvironment();
    // 获取事务工厂
    final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
    tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
    /**
     * 创建一个sql执行器对象
     * 一般情况下 若我们的mybaits的全局配置文件的cacheEnabled默认为ture就返回
     * 一个cacheExecutor,若关闭的话返回的就是一个SimpleExecutor
     */
    final Executor executor = configuration.newExecutor(tx, execType);
    //创建返回一个DeaultSqlSessoin对象返回
    return new DefaultSqlSession(configuration, executor, autoCommit);
  } catch (Exception e) {
    closeTransaction(tx); // may have fetched a connection so lets call close()
    throw ExceptionFactory.wrapException("Error opening session.  Cause: " + e, e);
  } finally {
    ErrorContext.instance().reset();
  }
}

仔细一点看你会发现configuration 就是刚才千辛万苦创建出来的 Configuration 对象,包含所有 mybatis 配置信息.至此SQLSession 的创建已分析完毕.

总结

总结一下上述流程:

第一步:通过输入流读取mybatis-config.xml 配置文件

1:通过XPathParser 读取xml 配置文件成 XNode 属性 2:通过 XMLConfigBuilder 解析 mybatis-config.xml 中的各个节点配置,包括

  • 解析properties 节点

  • 解析settings 节点

  • 加载日志框架

  • 解析 typeAliases

  • 解析拓展插架 plugins

  • 解析数据源 DataSource

  • 解析类型处理器 typeHandle

  • 解析 mapper文件

第二步:读取 mapper.java 类

读取方式有 package,resource,url,class ,最终都会放入到 Map<Class<?>, MapperProxyFactory<?>> knownMappers 中

第三步: 读取 mapper.xml 节点

1.同样以输入流的方式读取 mapper.xml 文件 2.通过 XMLMapperBuilder 实例解析 mapper.xml 文件中各个接点属性

  • 解析 namespace 属性

  • 解析缓存引用 cache-ref

  • 解析 cache 节点

  • 解析 resultMap 节点

  • 解析 sql 节点

  • 解析 select | insert |update |delete节点 3.通过 XMLStatementBuilder 解析SQL 标签

第四步:将所有配置属性都封装到 Configuration 对象中,构建出SqlSessionFactory 工厂实例
第五步:从session中开启一个数据源 SqlSessionFactory#openSession(),默认是 DefaultSqlSession

到此,关于“Mybatis源码分析之如何理解SQLSession初始化”的学习就结束了,希望能够解决大家的疑惑。理论与实践的搭配能更好的帮助大家学习,快去试试吧!若想继续学习更多相关知识,请继续关注亿速云网站,小编会继续努力为大家带来更多实用的文章!

向AI问一下细节

免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。

AI