ICode9

精准搜索请尝试: 精确搜索
首页 > 数据库> 文章详细

Mybatis 解析 SQL 源码分析一

2021-01-27 23:01:40  阅读:88  来源: 互联网

标签:configuration String 源码 context SQL Mybatis 解析 节点


Mybatis 解析 SQL 源码分析一

TSMYK Java技术编程

相关文章

Mybatis Mapper 接口源码解析
Mybatis 数据库连接池源码解析
Mybatis 类型转换源码分析
Mybatis 解析配置文件的源码解析

前言

在使用 Mybatis 的时候,我们在 Mapper.xml 配置文件中书写 SQL;文件中还配置了对应的dao,SQL 中还可以使用一些诸如for循环,if判断之类的高级特性,当数据库列和JavaBean属性不一致时定义的 resultMap等,接下来就来看下Mybatis 是如何从配置文件中解析出 SQL 并把用户传的参数进行绑定;

在 Mybatis 解析 SQL的时候,可以分为两部分来看,一是从 Mapper.xml 配置文件中解析SQL,二是把 SQL 解析成为数据库能够执行的原始 SQL,把占位符替换为 ? 等。

这篇文章先来看下第一部分,Mybatis 是如何从 Mapper.xml 配置文件中解析出 SQL 的。

配置文件的解析使用了大量的建造者模式(builder)

mybatis-config.xml

Mybatis 有两个配置文件,mybaits-config.xml 配置的是 mybatis 的一些全局配置信息,而 mapper.xml 配置的是 SQL 信息,在 Mybatis 初始化的时候,会对这两个文件进行解析,mybatis-config.xml 配置文件的解析比较简单,不再细说,使用的 XMLConfigBuilder 类来对 mybatis-config.xml 文件进行解析。


 1  public Configuration parse() {
 2    // 如果已经解析过,则抛异常
 3    if (parsed) {
 4      throw new BuilderException("Each XMLConfigBuilder can only be used once.");
 5    }
 6    parsed = true;
 7    parseConfiguration(parser.evalNode("/configuration"));
 8    return configuration;
 9  }
10  // 解析 mybatis-config.xml 文件下的所有节点
11  private void parseConfiguration(XNode root) {
12      propertiesElement(root.evalNode("properties"));
13      Properties settings = settingsAsProperties(root.evalNode("settings"));
14      // .... 其他的节点........
15      // 解析 mapper.xml 文件
16      mapperElement(root.evalNode("mappers"));
17  }
18
19 // 解析 mapper.xml 文件
20 private void mapperElement(XNode parent) throws Exception {
21    // ......
22    InputStream inputStream = Resources.getUrlAsStream(url);
23    XMLMapperBuilder mapperParser = 
24           new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
25    mapperParser.parse();
26 }

从上述代码可以看到,解析 Mapper.xml 配置文件是通过 XMLMapperBuilder 来解析的。接下来看下该类的实现:

XMLMapperBuilder

XMLMapperBuilder 类是用来解析 Mapper.xml 文件的,它继承了 BaseBuilder ,BaseBuilder 类一个建造者基类,其中包含了 Mybatis 全局的配置信息 Configuration ,别名处理器,类型处理器等,如下所示:


 1public abstract class BaseBuilder {
 2  protected final Configuration configuration;
 3  protected final TypeAliasRegistry typeAliasRegistry;
 4  protected final TypeHandlerRegistry typeHandlerRegistry;
 5
 6  public BaseBuilder(Configuration configuration) {
 7    this.configuration = configuration;
 8    this.typeAliasRegistry = this.configuration.getTypeAliasRegistry();
 9    this.typeHandlerRegistry = this.configuration.getTypeHandlerRegistry();
10  }
11}

关于 TypeAliasRegistry, TypeHandlerRegistry 可以参考 Mybatis 类型转换源码分析

接下来看下 XMLMapperBuilder 类的属性定义:


 1public class XMLMapperBuilder extends BaseBuilder {
 2  // xpath 包装类
 3  private XPathParser parser;
 4  // MapperBuilder 构建助手
 5  private MapperBuilderAssistant builderAssistant;
 6  // 用来存放sql片段的哈希表
 7  private Map<String, XNode> sqlFragments;
 8  // 对应的 mapper 文件
 9  private String resource;
10
11  private XMLMapperBuilder(XPathParser parser, Configuration configuration, String resource, Map<String, XNode> sqlFragments) {
12    super(configuration);
13    this.builderAssistant = new MapperBuilderAssistant(configuration, resource);
14    this.parser = parser;
15    this.sqlFragments = sqlFragments;
16    this.resource = resource;
17  }
18  // 解析文件
19  public void parse() {
20    // 判断是否已经加载过该配置文件
21    if (!configuration.isResourceLoaded(resource)) {
22      // 解析 mapper 节点
23      configurationElement(parser.evalNode("/mapper"));
24      // 将 resource 添加到 configuration 的 addLoadedResource 集合中保存,该集合中记录了已经加载过的配置文件
25      configuration.addLoadedResource(resource);
26      // 注册 Mapper 接口
27      bindMapperForNamespace();
28    }
29    // 处理解析失败的 <resultMap> 节点
30    parsePendingResultMaps();
31    // 处理解析失败的 <cache-ref> 节点
32    parsePendingChacheRefs();
33    // 处理解析失败的 SQL 节点
34    parsePendingStatements();
35  }

从上面的代码中,使用到了 MapperBuilderAssistant 辅助类,该类中有许多的辅助方法,其中有个 currentNamespace 属性用来表示当前的 Mapper.xml 配置文件的命名空间,在解析完成 Mapper.xml 配置文件的时候,会调用 bindMapperForNamespace 进行注册Mapper接口,表示该配置文件对应的Mapper接口`,关于 Mapper 的注册可以参考 Mybatis Mapper 接口源码解析


 1  private void bindMapperForNamespace() {
 2    // 获取当前的命名空间
 3    String namespace = builderAssistant.getCurrentNamespace();
 4    if (namespace != null) {
 5      Class<?> boundType = Resources.classForName(namespace);
 6      if (boundType != null) {
 7        // 如果还没有注册过该 Mapper 接口,则注册
 8        if (!configuration.hasMapper(boundType)) {
 9          configuration.addLoadedResource("namespace:" + namespace);
10          // 注册
11          configuration.addMapper(boundType);
12        }
13     }
14  }

现在就来解析Mapper.xml 文件的每个节点,每个节点的解析都封装成一个方法,很好理解:


 1  private void configurationElement(XNode context) {
 2      // 命名空间
 3      String namespace = context.getStringAttribute("namespace");
 4      // 设置命名空间
 5      builderAssistant.setCurrentNamespace(namespace);
 6      // 解析 <cache-ref namespace=""/> 节点
 7      cacheRefElement(context.evalNode("cache-ref"));
 8      // 解析 <cache /> 节点
 9      cacheElement(context.evalNode("cache"));
10      // 已废弃,忽略
11      parameterMapElement(context.evalNodes("/mapper/parameterMap"));
12      // 解析 <resultMap /> 节点
13      resultMapElements(context.evalNodes("/mapper/resultMap"));
14      // 解析 <sql> 节点
15      sqlElement(context.evalNodes("/mapper/sql"));
16      // 解析 select|insert|update|delete 这几个节点
17      buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
18  }

解析 <cache> 节点

Mybatis 默认情况下是没有开启二级缓存的,除了局部的 session 缓存。如果要为某个命名空间开启二级缓存,则需要在 SQL 映射文件中添加<cache>标签来告诉 Mybatis 需要开启二级缓存,先来看看 <cache> 标签的使用说明:


1<cache eviction="LRU" flushInterval="1000" size="1024" readOnly="true" type="MyCache" blocking="true"/>

<cache> 一共有 6 个属性,可以用来改变Mybatis 缓存的默认行为:

  1. eviction: 缓存的过期策略,可以取 4 个值:
  • LRU – 最近最少使用的:移除最长时间不被使用的对象。(默认)
  • FIFO – 先进先出:按对象进入缓存的顺序来移除它们。
  • SOFT – 软引用:移除基于垃圾回收器状态和软引用规则的对象。
  • WEAK – 弱引用:更积极地移除基于垃圾收集器状态和弱引用规则的对象。
    2.flushInterval: 刷新缓存的时间间隔,默认情况是不设置,也就是没有刷新间隔,缓存仅仅调用语句时刷新
    3.size: 缓存大小
    4.readOnly: 是否是只读
    5.type : 自定义缓存的实现
    6.blocking:是否是阻塞
    该类中主要使用 cacheElement 方法来解析 <cache> 节点:

 1  // 解析 <cache> 节点
 2  private void cacheElement(XNode context) throws Exception {
 3    if (context != null) {
 4      // 获取 type 属性,默认为 PERPETUAL
 5      String type = context.getStringAttribute("type", "PERPETUAL");
 6      Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
 7      // 获取过期策略 eviction 属性
 8      String eviction = context.getStringAttribute("eviction", "LRU");
 9      Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
10      Long flushInterval = context.getLongAttribute("flushInterval");
11      Integer size = context.getIntAttribute("size");
12      boolean readWrite = !context.getBooleanAttribute("readOnly", false);
13      boolean blocking = context.getBooleanAttribute("blocking", false);
14      // 获取 <cache> 节点下的子节点,将用于初始化二级缓存
15      Properties props = context.getChildrenAsProperties();
16      // 创建 Cache 对象,并添加到 configuration.caches 集合中保存
17      builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
18    }
19  }

接下来看下 MapperBuilderAssistant 辅助类如何创建缓存,并添加到 configuration.caches 集合中去:


 1  public Cache useNewCache(Class<? extends Cache> typeClass, Class<? extends Cache> evictionClass,
 2      Long flushInterval, Integer size, boolean readWrite, boolean blocking, Properties props) {
 3    // 创建缓存,使用构造者模式设置对应的属性
 4    Cache cache = new CacheBuilder(currentNamespace)
 5        .implementation(valueOrDefault(typeClass, PerpetualCache.class))
 6        .addDecorator(valueOrDefault(evictionClass, LruCache.class))
 7        .clearInterval(flushInterval)
 8        .size(size)
 9        .readWrite(readWrite)
10        .blocking(blocking)
11        .properties(props)
12        .build();
13    // 进入缓存集合
14    configuration.addCache(cache);
15    // 当前缓存
16    currentCache = cache;
17    return cache;
18  }

再来看下 CacheBuilder 是个什么东西,它是 Cache 的建造者,如下所示:


 1public class CacheBuilder {
 2  // Cache 对象的唯一标识,对应配置文件中的 namespace
 3  private String id;
 4  // Cache 的实现类
 5  private Class<? extends Cache> implementation;
 6  // 装饰器集合
 7  private List<Class<? extends Cache>> decorators;
 8  private Integer size;
 9  private Long clearInterval;
10  private boolean readWrite;
11  // 其他配置信息
12  private Properties properties;
13  // 是否阻塞
14  private boolean blocking;
15
16  // 创建 Cache 对象
17  public Cache build() {
18    // 设置 implementation 的默认值为 PerpetualCache ,decorators 的默认值为 LruCache
19    setDefaultImplementations();
20    // 创建 Cache
21    Cache cache = newBaseCacheInstance(implementation, id);
22    // 设置 <properties> 节点信息
23    setCacheProperties(cache);
24    if (PerpetualCache.class.equals(cache.getClass())) {
25      for (Class<? extends Cache> decorator : decorators) {
26        cache = newCacheDecoratorInstance(decorator, cache);
27        setCacheProperties(cache);
28      }
29      cache = setStandardDecorators(cache);
30    } else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
31      cache = new LoggingCache(cache);
32    }
33    return cache;
34  }
35}

解析 <cache-ref> 节点

在使用了 <cache> 配置了对应的缓存后,多个 namespace 可以引用同一个缓存,使用 <cache-ref> 进行指定


1<cache-ref namespace="com.someone.application.data.SomeMapper"/>
2
3cacheRefElement(context.evalNode("cache-ref"));

解析的源码如下,比较简单:


 1  private void cacheRefElement(XNode context) {
 2      // 当前文件的namespace
 3      String currentNamespace = builderAssistant.getCurrentNamespace();
 4      // ref 属性所指向引用的 namespace
 5      String refNamespace = context.getStringAttribute("namespace");
 6      // 会存入到 configuration 的一个 map 中, cacheRefMap.put(namespace, referencedNamespace);
 7      configuration.addCacheRef(currentNamespace , refNamespace );
 8      CacheRefResolver cacheRefResolver = new CacheRefResolver(builderAssistant, refNamespace);
 9      // 实际上调用 构建助手 builderAssistant 的 useCacheRef 方法进行解析
10      cacheRefResolver.resolveCacheRef();
11    }
12  }

构建助手 builderAssistant 的 useCacheRef 方法:


 1  public Cache useCacheRef(String namespace) {
 2      // 标识未成功解析的 Cache 引用
 3      unresolvedCacheRef = true;
 4      // 根据 namespace 中 configuration 的缓存集合中获取缓存
 5      Cache cache = configuration.getCache(namespace);
 6      if (cache == null) {
 7        throw new IncompleteElementException("....");
 8      }
 9      // 当前使用的缓存
10      currentCache = cache;
11      // 已成功解析 Cache 引用
12      unresolvedCacheRef = false;
13      return cache;
14  }

解析 <resultMap> 节点

resultMap 节点很强大,也很复杂,会单独另写一篇文章来介绍。

解析 <sql> 节点

<sql> 节点可以用来定义重用的SQ片段,


1    <sql id="commSQL" databaseId="" lang="">
2        id, name, job, age
3    </sql>
4
5    sqlElement(context.evalNodes("/mapper/sql"));

sqlElement 方法如下,一个 Mapper.xml 文件可以有多个 sql 节点:


 1  private void sqlElement(List<XNode> list, String requiredDatabaseId) throws Exception {
 2    // 遍历,处理每个 sql 节点
 3    for (XNode context : list) {
 4      // 数据库ID
 5      String databaseId = context.getStringAttribute("databaseId");
 6      // 获取 id 属性
 7      String id = context.getStringAttribute("id");
 8      // 为 id 加上 namespace 前缀,如原来 id 为 commSQL,加上前缀就变为了 com.aa.bb.cc.commSQL
 9      id = builderAssistant.applyCurrentNamespace(id, false);
10      if (databaseIdMatchesCurrent(id, databaseId, requiredDatabaseId)) {
11        // 如果 SQL 片段匹配对应的数据库,则把该节点加入到缓存中,是一个 map
12        // Map<String, XNode> sqlFragments
13        sqlFragments.put(id, context);
14      }
15    }
16  }

为ID 加上namespace前缀的方法如下:


 1  public String applyCurrentNamespace(String base, boolean isReference) {
 2    if (base == null) {
 3      return null;
 4    }
 5     // 是否已经包含 namespace 了
 6    if (isReference) {
 7      if (base.contains(".")) {
 8        return base;
 9      }
10    } else {
11      // 是否是一 namespace. 开头
12      if (base.startsWith(currentNamespace + ".")) {
13        return base;
14      }
15    }
16    // 返回 namespace.id,即 com.aa.bb.cc.commSQL
17    return currentNamespace + "." + base;
18  }

insert | update | delete | select 节点的解析

关于这些与操作数据库的SQL的解析,主要是由 XMLStatementBuilder 类来进行解析。在 Mybatis 中使用 SqlSource 来表示 SQL语句,但是这些SQL 语句还不能直接在数据库中进行执行,可能还有动态SQL语句和占位符等。

接下来看下这类节点的解析:

 1buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
 2
 3private void buildStatementFromContext(List<XNode> list) {
 4// 匹配对应的数据库
 5if (configuration.getDatabaseId() != null) {
 6  buildStatementFromContext(list, configuration.getDatabaseId());
 7}
 8buildStatementFromContext(list, null);
 9}
10
11private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) {
12for (XNode context : list) {
13  // 为 XMLStatementBuilder 对应的属性赋值
14  final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId);
15  // 解析每个节点
16  statementParser.parseStatementNode();
17}

可以看到 selelct | insert | update | delete 这类节点是使用 XMLStatementBuilder 类的 parseStatementNode() 方法来解析的,接下来看下该方法的实现:


 1  public void parseStatementNode() {
 2    // id 属性和数据库标识
 3    String id = context.getStringAttribute("id");
 4    String databaseId = context.getStringAttribute("databaseId");
 5    // 如果数据库不匹配则不加载
 6    if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
 7      return;
 8    }
 9    // 获取节点的属性和对应属性的类型
10    Integer fetchSize = context.getIntAttribute("fetchSize");
11    Integer timeout = context.getIntAttribute("timeout");
12    Integer fetchSize = context.getIntAttribute("fetchSize");
13    Integer timeout = context.getIntAttribute("timeout");
14    String parameterMap = context.getStringAttribute("parameterMap");
15    String parameterType = context.getStringAttribute("parameterType");
16    // 从注册的类型里面查找参数类型
17    Class<?> parameterTypeClass = resolveClass(parameterType);
18    String resultMap = context.getStringAttribute("resultMap");
19    String resultType = context.getStringAttribute("resultType");
20    String lang = context.getStringAttribute("lang");
21    LanguageDriver langDriver = getLanguageDriver(lang);
22    // 从注册的类型里面查找返回值类型
23    Class<?> resultTypeClass = resolveClass(resultType);
24    String resultSetType = context.getStringAttribute("resultSetType");
25    StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));
26    ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);
27
28    // 获取节点的名称
29    String nodeName = context.getNode().getNodeName();
30    // 根据节点的名称来获取节点的类型,枚举:UNKNOWN, INSERT, UPDATE, DELETE, SELECT, FLUSH;
31    SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));
32    // 下面这三行代码,如果是select语句,则不会刷新缓存和需要使用缓存
33    boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
34    boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);
35    boolean useCache = context.getBooleanAttribute("useCache", isSelect);
36    boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false);
37
38    // 解析 <include> 节点
39    XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);
40    includeParser.applyIncludes(context.getNode());
41
42    // 解析 selectKey 节点
43    processSelectKeyNodes(id, parameterTypeClass, langDriver);
44    // 创建 sqlSource 
45    SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
46    // 处理 resultSets keyProperty keyColumn  属性
47    String resultSets = context.getStringAttribute("resultSets");
48    String keyProperty = context.getStringAttribute("keyProperty");
49    String keyColumn = context.getStringAttribute("keyColumn");
50    // 处理 keyGenerator
51    KeyGenerator keyGenerator;
52    String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX;
53    keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true);
54    if (configuration.hasKeyGenerator(keyStatementId)) {
55      keyGenerator = configuration.getKeyGenerator(keyStatementId);
56    } else {
57      keyGenerator = context.getBooleanAttribute("useGeneratedKeys",
58          configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType))
59          ? new Jdbc3KeyGenerator() : new NoKeyGenerator();
60    }
61    // 创建 MapperedStatement 对象,添加到 configuration 中
62    builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
63        fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
64        resultSetTypeEnum, flushCache, useCache, resultOrdered, 
65        keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
66}

该方法主要分为几个部分:

  1. 解析属性
  2. 解析 include 节点
  3. 解析 selectKey 节点
  4. 创建MapperedStatment对象并添加到configuration对应的集合中
    解析属性比较简单,接下来看看后面几个部分:

解析 include 子节点

解析include节点就是把其包含的SQL片段替换成 <sql> 节点定义的SQL片段,并将 ${xxx} 占位符替换成真实的参数:

它是使用 XMLIncludeTransformer 类的 applyIncludes 方法来解析的:


 1  public void applyIncludes(Node source) {
 2    // 获取参数
 3    Properties variablesContext = new Properties();
 4    Properties configurationVariables = configuration.getVariables();
 5    if (configurationVariables != null) {
 6      variablesContext.putAll(configurationVariables);
 7    }
 8    // 解析
 9    applyIncludes(source, variablesContext, false);
10  }
11
12  private void applyIncludes(Node source, final Properties variablesContext, boolean included) {
13    if (source.getNodeName().equals("include")) {
14      // 这里是根据 ref 属性对应的值去 <sql> 节点对应的集合查找对应的SQL片段,
15      // 在解析 <sql> 节点的时候,把它放到了一个map中,key为namespace+id,value为对应的节点,
16      // 现在要拿 ref 属性去这个集合里面获取对应的SQL片段
17      Node toInclude = findSqlFragment(getStringAttribute(source, "refid"), variablesContext);
18      // 解析include的子节点<properties>
19      Properties toIncludeContext = getVariablesContext(source, variablesContext);
20      // 递归处理<include>节点
21      applyIncludes(toInclude, toIncludeContext, true);
22      if (toInclude.getOwnerDocument() != source.getOwnerDocument()) {
23        toInclude = source.getOwnerDocument().importNode(toInclude, true);
24      }
25      // 将 include 节点替换为 sql 节点
26      source.getParentNode().replaceChild(toInclude, source);
27      while (toInclude.hasChildNodes()) {
28        toInclude.getParentNode().insertBefore(toInclude.getFirstChild(), toInclude);
29      }
30      toInclude.getParentNode().removeChild(toInclude);
31    } else if (source.getNodeType() == Node.ELEMENT_NODE) {
32      // 处理当前SQL节点的子节点
33      NodeList children = source.getChildNodes();
34      for (int i = 0; i < children.getLength(); i++) {
35        applyIncludes(children.item(i), variablesContext, included);
36      }
37    } else if (included && source.getNodeType() == Node.TEXT_NODE
38        && !variablesContext.isEmpty()) {
39      // 绑定参数
40      source.setNodeValue(PropertyParser.parse(source.getNodeValue(), variablesContext));
41    }
42  }

selectKey 就是生成主键,可以不用看。

到这里,mapper.xml 配置文件中的节点已经解析完毕了 除了 resultMap 节点,在文章的开头部分,在解析节点的时候,有时候可能会出错,抛出异常,在解析每个解析抛出异常的时候,都会把该解析放入到对应的集合中再次进行解析,所以在解析完成后,还有如下三行代码:


1    // 处理解析失败的 <resultMap> 节点
2    parsePendingResultMaps();
3    // 处理解析失败的 <cache-ref> 节点
4    parsePendingChacheRefs();
5    // 处理解析失败的 SQL 节点
6    parsePendingStatements();

就是用来从新解析失败的那些节点的。

到这里,Mapper.xml 配置文件就解析完毕了。

标签:configuration,String,源码,context,SQL,Mybatis,解析,节点
来源: https://blog.51cto.com/15077536/2608603

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有