MyBatis 源码分析 - 映射文件解析过程

1.简介

在上一篇文章中,我详细分析了 MyBatis 配置文件的解析过程。由于上一篇文章的篇幅比较大,加之映射文件解析过程也比较复杂的原因。所以我将映射文件解析过程的分析内容从上一篇文章中抽取出来,独立成文,于是就有了本篇文章。在本篇文章中,我将分析映射文件中出现的一些及节点,比如 ,,, <select | insert | update | delete> 等。除了分析常规的 XML 解析过程外,我还会向大家介绍 Mapper 接口的绑定过程等。综上所述,本篇文章内容会比较丰富,如果大家对此感兴趣,不妨花点时间读一读,会有新的收获。当然,本篇文章通篇是关于源码分析的,所以阅读本文需要大家对 MyBatis 有一定的了解。如果大家对 MyBatis 还不是很了解,建议阅读一下 MyBatis 的官方文档。

其他的就不多说了,下面开始我们的 MyBatis 源码之旅。

2.映射文件解析过程分析

我在前面说过,映射文件的解析过程是 MyBatis 配置文件解析过程的一部分。MyBatis 的配置文件由 XMLConfigBuilder 的 parseConfiguration 进行解析,该方法依次解析了 、、 等节点。至于 节点,parseConfiguration 则是在方法的结尾对其进行了解析。该部分的解析逻辑封装在 mapperElement 方法中,下面来看一下。


private void mapperElement(XNode parent) throws Exception {if (parent != null) {for (XNode child : parent.getChildren()) {if ("package".equals(child.getName())) {String mapperPackage = child.getStringAttribute("name");configuration.addMappers(mapperPackage);} else {String resource = child.getStringAttribute("resource");String url = child.getStringAttribute("url");String mapperClass = child.getStringAttribute("class");if (resource != null && url == null && mapperClass == null) {ErrorContext.instance().resource(resource);InputStream inputStream = Resources.getResourceAsStream(resource);XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());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.");}}}}
}

上面的代码比较简单,主要逻辑是遍历 mappers 的子节点,并根据节点属性值判断通过什么方式加载映射文件或映射信息。这里,我把配置在注解中的内容称为映射信息,以 XML 为载体的配置称为映射文件。在 MyBatis 中,共有四种加载映射文件或信息的方式。第一种是从文件系统中加载映射文件;第二种是通过 URL 的方式加载和解析映射文件;第三种是通过 mapper 接口加载映射信息,映射信息可以配置在注解中,也可以配置在映射文件中。最后一种是通过包扫描的方式获取到某个包下的所有类,并使用第三种方式为每个类解析映射信息。

以上简单介绍了 MyBatis 加载映射文件或信息的几种方式。需要注意的是,在 MyBatis 中,通过注解配置映射信息的方式是有一定局限性的,这一点 MyBatis 官方文档中描述的比较清楚。这里引用一下:

因为最初设计时,MyBatis 是一个 XML 驱动的框架。配置信息是基于 XML 的,而且映射语句也是定义在 XML 中的。而到了 MyBatis 3,就有新选择了。MyBatis 3 构建在全面且强大的基于 Java 语言的配置 API 之上。这个配置 API 是基于 XML 的 MyBatis 配置的基础,也是新的基于注解配置的基础。注解提供了一种简单的方式来实现简单映射语句,而不会引入大量的开销。

注意: 不幸的是,Java 注解的的表达力和灵活性十分有限。尽管很多时间都花在调查、设计和试验上,最强大的 MyBatis 映射并不能用注解来构建——并不是在开玩笑,的确是这样。

如上所示,重点语句我用黑体标注了出来。限于 Java 注解的表达力和灵活性,通过注解的方式并不能完全发挥 MyBatis 的能力。所以,对于一些较为复杂的配置信息,我们还是应该通过 XML 的方式进行配置。正因此,在接下的章节中,我会重点分析基于 XML 的映射文件的解析过程。如果能弄懂此种配置方式的解析过程,那么基于注解的解析过程也不在话下。

下面开始分析映射文件的解析过程,在展开分析之前,先来看一下映射文件解析入口。如下:


public void parse() {if (!configuration.isResourceLoaded(resource)) {configurationElement(parser.evalNode("/mapper"));configuration.addLoadedResource(resource);bindMapperForNamespace();}parsePendingResultMaps();parsePendingCacheRefs();parsePendingStatements();
}

如上,映射文件解析入口逻辑包含三个核心操作,分别如下:

  1. 解析 mapper 节点
  2. 通过命名空间绑定 Mapper 接口
  3. 处理未完成解析的节点

这三个操作对应的逻辑,我将会在随后的章节中依次进行分析。下面,先来分析第一个操作对应的逻辑。

2.1 解析映射文件

在 MyBatis 映射文件中,可以配置多种节点。比如 ,, 以及 <select | insert | update | delete> 等。下面我们来看一个映射文件配置示例。

<mapper namespace="xyz.coolblog.dao.AuthorDao"><cache/><resultMap id="authorResult" type="Author"><id property="id" column="id"/><result property="name" column="name"/></resultMap><sql id="table">author</sql><select id="findOne" resultMap="authorResult">SELECTid, name, age, sex, emailFROM<include refid="table"/>WHEREid = #{id}</select></mapper>

上面是一个比较简单的映射文件,还有一些的节点没有出现在上面。以上每种配置中的每种节点的解析逻辑都封装在了相应的方法中,这些方法由 XMLMapperBuilder 类的 configurationElement 方法统一调用。该方法的逻辑如下:

private void configurationElement(XNode context) {try {String namespace = context.getStringAttribute("namespace");if (namespace == null || namespace.equals("")) {throw new BuilderException("Mapper's namespace cannot be empty");}builderAssistant.setCurrentNamespace(namespace);cacheRefElement(context.evalNode("cache-ref"));cacheElement(context.evalNode("cache"));parameterMapElement(context.evalNodes("/mapper/parameterMap"));resultMapElements(context.evalNodes("/mapper/resultMap"));sqlElement(context.evalNodes("/mapper/sql"));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);}
}

上面代码的执行流程清晰明了。在阅读源码时,我们可以按部就班的分析每个方法调用即可。不过在写文章进行叙述时,需要做一些调整。下面我将会先分析 节点的解析过程,然后再分析 节点,之后会按照顺序分析其他节点的解析过程。接下来,我们来看看 节点的解析过程。

2.1.1 解析 节点

MyBatis 提供了一、二级缓存,其中一级缓存是 SqlSession 级别的,默认为开启状态。二级缓存配置在映射文件中,使用者需要显示配置才能开启。如果没有特殊要求,二级缓存的配置很容易。如下:

<cache/>

如果我们想修改缓存的一些属性,可以像下面这样配置。

<cacheeviction="FIFO"flushInterval="60000"size="512"readOnly="true"/>

根据上面的配置创建出的缓存有以下特点:

  1. 按先进先出的策略淘汰缓存项
  2. 缓存的容量为 512 个对象引用
  3. 缓存每隔60秒刷新一次
  4. 缓存返回的对象是写安全的,即在外部修改对象不会影响到缓存内部存储对象

除了上面两种配置方式,我们还可以给 MyBatis 配置第三方缓存或者自己实现的缓存等。比如,我们将 Ehcache 缓存整合到 MyBatis 中,可以这样配置。

<cache type="org.mybatis.caches.ehcache.EhcacheCache"/><property name="timeToIdleSeconds" value="3600"/><property name="timeToLiveSeconds" value="3600"/><property name="maxEntriesLocalHeap" value="1000"/><property name="maxEntriesLocalDisk" value="10000000"/><property name="memoryStoreEvictionPolicy" value="LRU"/>
</cache>

以上简单介绍了几种缓存配置方式,关于 MyBatis 缓存更多的知识,后面我会独立成文进行分析,这里就不深入说明了。下面我们来分析一下缓存配置的解析逻辑,如下:

private void cacheElement(XNode context) throws Exception {if (context != null) {String type = context.getStringAttribute("type", "PERPETUAL");Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);String eviction = context.getStringAttribute("eviction", "LRU");Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);Long flushInterval = context.getLongAttribute("flushInterval");Integer size = context.getIntAttribute("size");boolean readWrite = !context.getBooleanAttribute("readOnly", false);boolean blocking = context.getBooleanAttribute("blocking", false);Properties props = context.getChildrenAsProperties();builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);}
}

上面代码中,大段代码用来解析 节点的属性和子节点,这些代码没什么好说的。缓存的构建逻辑封装在 BuilderAssistant 类的 useNewCache 方法中,下面我们来看一下该方法的逻辑。


public Cache useNewCache(Class<? extends Cache> typeClass,Class<? extends Cache> evictionClass,Long flushInterval,Integer size,boolean readWrite,boolean blocking,Properties props) {Cache cache = new CacheBuilder(currentNamespace).implementation(valueOrDefault(typeClass, PerpetualCache.class)).addDecorator(valueOrDefault(evictionClass, LruCache.class)).clearInterval(flushInterval).size(size).readWrite(readWrite).blocking(blocking).properties(props).build();configuration.addCache(cache);currentCache = cache;return cache;
}

上面使用了建造模式构建 Cache 实例,Cache 实例的构建过程略为复杂,我们跟下去看看。


public Cache build() {setDefaultImplementations();Cache cache = newBaseCacheInstance(implementation, id);setCacheProperties(cache);if (PerpetualCache.class.equals(cache.getClass())) {for (Class<? extends Cache> decorator : decorators) {cache = newCacheDecoratorInstance(decorator, cache);setCacheProperties(cache);}cache = setStandardDecorators(cache);} else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {cache = new LoggingCache(cache);}return cache;
}

上面的构建过程流程较为复杂,这里总结一下。如下:

  1. 设置默认的缓存类型及装饰器
  2. 应用装饰器到 PerpetualCache 对象上
    • 遍历装饰器类型集合,并通过反射创建装饰器实例
    • 将属性设置到实例中
  3. 应用一些标准的装饰器
  4. 对非 LoggingCache 类型的缓存应用 LoggingCache 装饰器

在以上4个步骤中,最后一步的逻辑很简单,无需多说。下面按顺序分析前3个步骤对应的逻辑,如下:

private void setDefaultImplementations() {if (implementation == null) {implementation = PerpetualCache.class;if (decorators.isEmpty()) {decorators.add(LruCache.class);}}
}

以上逻辑比较简单,主要做的事情是在 implementation 为空的情况下,为它设置一个默认值。如果大家仔细看前面的方法,会发现 MyBatis 做了不少判空的操作。比如:


String type = context.getStringAttribute("type", "PERPETUAL");
String eviction = context.getStringAttribute("eviction", "LRU");Cache cache = new CacheBuilder(currentNamespace).implementation(valueOrDefault(typeClass, PerpetualCache.class)).addDecorator(valueOrDefault(evictionClass, LruCache.class)).build();

既然前面已经做了两次判空操作,implementation 不可能为空,那么 setDefaultImplementations 方法似乎没有存在的必要了。其实不然,如果有人不按套路写代码。比如:

Cache cache = new CacheBuilder(currentNamespace).build();

这里忘记设置 implementation,或人为的将 implementation 设为空。如果不对 implementation 进行判空,会导致 build 方法在构建实例时触发空指针异常,对于框架来说,出现空指针异常是很尴尬的,这是一个低级错误。这里以及之前做了这么多判空,就是为了避免出现空指针的情况,以提高框架的健壮性。好了,关于 setDefaultImplementations 方法的分析先到这,继续往下分析。

我们在使用 MyBatis 内置缓存时,一般不用为它们配置自定义属性。但使用第三方缓存时,则应按需进行配置。比如前面演示 MyBatis 整合 Ehcache 时,就为 Ehcache 配置了一些必要的属性。下面我们来看一下这部分配置是如何设置到缓存实例中的。

private void setCacheProperties(Cache cache) {if (properties != null) {MetaObject metaCache = SystemMetaObject.forObject(cache);for (Map.Entry<Object, Object> entry : properties.entrySet()) {String name = (String) entry.getKey();String value = (String) entry.getValue();if (metaCache.hasSetter(name)) {Class<?> type = metaCache.getSetterType(name);if (String.class == type) {metaCache.setValue(name, value);} else if (int.class == type || Integer.class == type) {metaCache.setValue(name, Integer.valueOf(value));} else if (long.class == type || Long.class == type) {metaCache.setValue(name, Long.valueOf(value));} else if (short.class == type || Short.class == type) {...} else if (byte.class == type || Byte.class == type) {...} else if (float.class == type || Float.class == type) {...} else if (boolean.class == type || Boolean.class == type) {...} else if (double.class == type || Double.class == type) {...} else {throw new CacheException("Unsupported property type for cache: '" + name + "' of type " + type);}}}}if (InitializingObject.class.isAssignableFrom(cache.getClass())) {try {((InitializingObject) cache).initialize();} catch (Exception e) {throw new CacheException("Failed cache initialization for '" +cache.getId() + "' on '" + cache.getClass().getName() + "'", e);}}
}

上面的大段代码用于对属性值进行类型转换,和设置转换后的值到 Cache 实例中。关于上面代码中出现的 MetaObject,大家可以自己尝试分析一下。最后,我们来看一下设置标准装饰器的过程。如下:

private Cache setStandardDecorators(Cache cache) {try {MetaObject metaCache = SystemMetaObject.forObject(cache);if (size != null && metaCache.hasSetter("size")) {metaCache.setValue("size", size);}if (clearInterval != null) {cache = new ScheduledCache(cache);((ScheduledCache) cache).setClearInterval(clearInterval);}if (readWrite) {cache = new SerializedCache(cache);}cache = new LoggingCache(cache);cache = new SynchronizedCache(cache);if (blocking) {cache = new BlockingCache(cache);}return cache;} catch (Exception e) {throw new CacheException("Error building standard cache decorators.  Cause: " + e, e);}
}

以上代码用于为缓存应用一些基本的装饰器,除了 LoggingCache 和 SynchronizedCache 这两个是必要的装饰器,其他的装饰器应用与否,取决于用户的配置。

到此,关于缓存的解析过程就分析完了。这一块的内容比较多,不过好在代码逻辑不是很复杂,耐心看还是可以弄懂的。其他的就不多说了,进入下一节的分析。

2.1.2 解析 节点

在 MyBatis 中,二级缓存是可以共用的。这需要使用 节点配置参照缓存,比如像下面这样。


<mapper namespace="xyz.coolblog.dao.Mapper1"><cache-ref namespace="xyz.coolblog.dao.Mapper2"/>
</mapper><mapper namespace="xyz.coolblog.dao.Mapper2"><cache/>
</mapper>

接下来,我们对照上面的配置分析 cache-ref 的解析过程。如下:

private void cacheRefElement(XNode context) {if (context != null) {configuration.addCacheRef(builderAssistant.getCurrentNamespace(), context.getStringAttribute("namespace"));CacheRefResolver cacheRefResolver = new CacheRefResolver(builderAssistant, context.getStringAttribute("namespace"));try {cacheRefResolver.resolveCacheRef();} catch (IncompleteElementException e) {configuration.addIncompleteCacheRef(cacheRefResolver);}}
}

如上所示, 节点的解析逻辑封装在了 CacheRefResolver 的 resolveCacheRef 方法中。下面,我们一起看一下这个方法的逻辑。


public Cache resolveCacheRef() {return assistant.useCacheRef(cacheRefNamespace);
}public Cache useCacheRef(String namespace) {if (namespace == null) {throw new BuilderException("cache-ref element requires a namespace attribute.");}try {unresolvedCacheRef = true;Cache cache = configuration.getCache(namespace);if (cache == null) {throw new IncompleteElementException("No cache for namespace '" + namespace + "' could be found.");}currentCache = cache;unresolvedCacheRef = false;return cache;} catch (IllegalArgumentException e) {throw new IncompleteElementException("No cache for namespace '" + namespace + "' could be found.", e);}
}

以上是 cache-ref 的解析过程,逻辑并不复杂。不过这里要注意 cache 为空的情况,我在代码中已经注释了可能导致 cache 为空的两种情况。第一种情况比较好理解,第二种情况稍微复杂点,但是也不难理解。我会在 2.3 节进行解释说明,这里先不说。

到此,关于 节点的解析过程就分析完了。本节的内容不是很难理解,就不多说了。

2.1.3 解析 节点

resultMap 是 MyBatis 框架中常用的特性,主要用于映射结果。resultMap 是 MyBatis 提供的一个强力武器,这一点官方文档中有所描述,这里引用一下。

resultMap 元素是 MyBatis 中最重要最强大的元素。它可以让你从 90% 的 JDBC ResultSets 数据提取代码中解放出来, 并在一些情形下允许你做一些 JDBC 不支持的事情。 实际上,在对复杂语句进行联合映射的时候,它很可能可以代替数千行的同等功能的代码。 ResultMap 的设计思想是,简单的语句不需要明确的结果映射,而复杂一点的语句只需要描述它们的关系就行了。

如上描述,resultMap 元素是 MyBatis 中最重要最强大的元素,它可以把大家从 JDBC ResultSets 数据提取的工作中解放出来。通过 resultMap 和自动映射,可以让 MyBatis 帮助我们完成 ResultSet → Object 的映射,这将会大大提高了开发效率。关于 resultMap 的用法,我相信大家都比较熟悉了,所以这里我就不介绍了。当然,如果大家不熟悉也没关系,MyBatis 的官方文档上对此进行了详细的介绍,大家不妨去看看。

好了,其他的就不多说了,下面开始分析 resultMap 配置的解析过程。


private void resultMapElements(List<XNode> list) throws Exception {for (XNode resultMapNode : list) {try {resultMapElement(resultMapNode);} catch (IncompleteElementException e) {}}
}private ResultMap resultMapElement(XNode resultMapNode) throws Exception {return resultMapElement(resultMapNode, Collections.<ResultMapping>emptyList());
}private ResultMap resultMapElement(XNode resultMapNode, List<ResultMapping> additionalResultMappings) throws Exception {ErrorContext.instance().activity("processing " + resultMapNode.getValueBasedIdentifier());String id = resultMapNode.getStringAttribute("id", resultMapNode.getValueBasedIdentifier());String type = resultMapNode.getStringAttribute("type",resultMapNode.getStringAttribute("ofType",resultMapNode.getStringAttribute("resultType",resultMapNode.getStringAttribute("javaType"))));String extend = resultMapNode.getStringAttribute("extends");Boolean autoMapping = resultMapNode.getBooleanAttribute("autoMapping");Class<?> typeClass = resolveClass(type);Discriminator discriminator = null;List<ResultMapping> resultMappings = new ArrayList<ResultMapping>();resultMappings.addAll(additionalResultMappings);List<XNode> resultChildren = resultMapNode.getChildren();for (XNode resultChild : resultChildren) {if ("constructor".equals(resultChild.getName())) {processConstructorElement(resultChild, typeClass, resultMappings);} else if ("discriminator".equals(resultChild.getName())) {discriminator = processDiscriminatorElement(resultChild, typeClass, resultMappings);} else {List<ResultFlag> flags = new ArrayList<ResultFlag>();if ("id".equals(resultChild.getName())) {flags.add(ResultFlag.ID);}resultMappings.add(buildResultMappingFromContext(resultChild, typeClass, flags));}}ResultMapResolver resultMapResolver = new ResultMapResolver(builderAssistant, id, typeClass, extend,discriminator, resultMappings, autoMapping);try {return resultMapResolver.resolve();} catch (IncompleteElementException e) {configuration.addIncompleteResultMap(resultMapResolver);throw e;}
}

上面的代码比较多,看起来有点复杂,这里总结一下:

  1. 获取 节点的各种属性
  2. 遍历 的子节点,并根据子节点名称执行相应的解析逻辑
  3. 构建 ResultMap 对象
  4. 若构建过程中发生异常,则将 resultMapResolver 添加到 incompleteResultMaps 集合中

如上流程,第1步和最后一步都是一些常规操作,无需过多解释。第2步和第3步则是接下来需要重点分析的操作,这其中,鉴别器 discriminator 不是很常用的特性,我觉得大家知道它有什么用就行了,所以就不分析了。下面先来分析 和 节点的解析逻辑。

2.1.3.1 解析 和 节点

在 节点中,子节点 和 都是常规配置,比较常见。相信大家对此也比较熟悉了,我就不多说了。下面我们直接分析这两个节点的解析过程。如下:

private ResultMapping buildResultMappingFromContext(XNode context, Class<?> resultType, List<ResultFlag> flags) throws Exception {String property;if (flags.contains(ResultFlag.CONSTRUCTOR)) {property = context.getStringAttribute("name");} else {property = context.getStringAttribute("property");}String column = context.getStringAttribute("column");String javaType = context.getStringAttribute("javaType");String jdbcType = context.getStringAttribute("jdbcType");String nestedSelect = context.getStringAttribute("select");String nestedResultMap = context.getStringAttribute("resultMap", processNestedResultMappings(context, Collections.<ResultMapping>emptyList()));String notNullColumn = context.getStringAttribute("notNullColumn");String columnPrefix = context.getStringAttribute("columnPrefix");String typeHandler = context.getStringAttribute("typeHandler");String resultSet = context.getStringAttribute("resultSet");String foreignColumn = context.getStringAttribute("foreignColumn");boolean lazy = "lazy".equals(context.getStringAttribute("fetchType", configuration.isLazyLoadingEnabled() ? "lazy" : "eager"));Class<?> javaTypeClass = resolveClass(javaType);Class<? extends TypeHandler<?>> typeHandlerClass = (Class<? extends TypeHandler<?>>) resolveClass(typeHandler);JdbcType jdbcTypeEnum = resolveJdbcType(jdbcType);return builderAssistant.buildResultMapping(resultType, property, column, javaTypeClass, jdbcTypeEnum, nestedSelect,nestedResultMap, notNullColumn, columnPrefix, typeHandlerClass, flags, resultSet, foreignColumn, lazy);
}

上面的方法主要用于获取 和 节点的属性,其中,resultMap 属性的解析过程要相对复杂一些。该属性存在于 和 节点中。下面以 节点为例,演示该节点的两种配置方式,分别如下:

第一种配置方式是通过 resultMap 属性引用其他的 节点,配置如下:

<resultMap id="articleResult" type="Article"><id property="id" column="id"/><result property="title" column="article_title"/><association property="article_author" column="article_author_id" javaType="Author" resultMap="authorResult"/>
</resultMap><resultMap id="authorResult" type="Author"><id property="id" column="author_id"/><result property="name" column="author_name"/>
</resultMap>

第二种配置方式是采取 resultMap 嵌套的方式进行配置,如下:

<resultMap id="articleResult" type="Article"><id property="id" column="id"/><result property="title" column="article_title"/><association property="article_author" javaType="Author"><id property="id" column="author_id"/><result property="name" column="author_name"/></association>
</resultMap>

如上配置, 的子节点是一些结果映射配置,这些结果配置最终也会被解析成 ResultMap。我们可以看看解析过程是怎样的,如下:

private String processNestedResultMappings(XNode context, List<ResultMapping> resultMappings) throws Exception {if ("association".equals(context.getName())|| "collection".equals(context.getName())|| "case".equals(context.getName())) {if (context.getStringAttribute("select") == null) {ResultMap resultMap = resultMapElement(context, resultMappings);return resultMap.getId();}}return null;
}

如上, 的子节点由 resultMapElement 方法解析成 ResultMap,并在最后返回 resultMap.id。对于 节点,id 的值配置在该节点的 id 属性中。但 节点无法配置 id 属性,那么该 id 如何产生的呢?答案在 XNode 类的 getValueBasedIdentifier 方法中,这个方法具体逻辑我就不分析了。下面直接看一下以上配置中的 节点解析成 ResultMap 后的 id 值,如下:

id = mapper_resultMap[articleResult]_association[article_author]

关于嵌套 resultMap 的解析逻辑就先分析到这,下面分析 ResultMapping 的构建过程。

public ResultMapping buildResultMapping(Class<?> resultType, String property, String column, Class<?> javaType,JdbcType jdbcType, String nestedSelect, String nestedResultMap, String notNullColumn, String columnPrefix,Class<? extends TypeHandler<?>> typeHandler, List<ResultFlag> flags, String resultSet, String foreignColumn, boolean lazy) {Class<?> javaTypeClass = resolveResultJavaType(resultType, property, javaType);TypeHandler<?> typeHandlerInstance = resolveTypeHandler(javaTypeClass, typeHandler);List<ResultMapping> composites = parseCompositeColumnName(column);return new ResultMapping.Builder(configuration, property, column, javaTypeClass).jdbcType(jdbcType).nestedQueryId(applyCurrentNamespace(nestedSelect, true)).nestedResultMapId(applyCurrentNamespace(nestedResultMap, true)).resultSet(resultSet).typeHandler(typeHandlerInstance).flags(flags == null ? new ArrayList<ResultFlag>() : flags).composites(composites).notNullColumns(parseMultipleColumnNames(notNullColumn)).columnPrefix(columnPrefix).foreignColumn(foreignColumn).lazy(lazy).build();
}public ResultMapping build() {resultMapping.flags = Collections.unmodifiableList(resultMapping.flags);resultMapping.composites = Collections.unmodifiableList(resultMapping.composites);resolveTypeHandler();validate();return resultMapping;
}

ResultMapping 的构建过程不是很复杂,首先是解析 javaType 类型,并创建 typeHandler 实例。然后处理复合 column。最后通过建造器构建 ResultMapping 实例。关于上面方法中出现的一些方法调用,这里接不跟下去分析了,大家可以自己看看。

到此关于 ResultMapping 的解析和构建过程就分析完了,总的来说,还是比较复杂的。不过再难也是人写的,静下心都可以看懂。好了,其他就不多说了,继续往下分析。

2.1.3.2 解析

一般情况下,我们所定义的实体类都是简单的 Java 对象,即 POJO。这种对象包含一些私有属性和相应的 getter/setter 方法,通常这种 POJO 可以满足大部分需求。但如果你想使用不可变类存储查询结果,则就需要做一些改动。比如把 POJO 的 setter 方法移除,增加构造方法用于初始化成员变量。对于这种不可变的 Java 类,需要通过带有参数的构造方法进行初始化(反射也可以达到同样目的)。下面举个例子说明一下:

public class ArticleDO {public ArticleDO(Integer id, String title, String content) {this.id = id;this.title = title;this.content = content;}}

如上,ArticleDO 的构造方法对应的配置如下:

<constructor><idArg column="id" name="id"/><arg column="title" name="title"/><arg column="content" name="content"/>
</constructor>

下面,分析 constructor 节点的解析过程。如下:

private void processConstructorElement(XNode resultChild, Class<?> resultType, List<ResultMapping> resultMappings) throws Exception {List<XNode> argChildren = resultChild.getChildren();for (XNode argChild : argChildren) {List<ResultFlag> flags = new ArrayList<ResultFlag>();flags.add(ResultFlag.CONSTRUCTOR);if ("idArg".equals(argChild.getName())) {flags.add(ResultFlag.ID);}resultMappings.add(buildResultMappingFromContext(argChild, resultType, flags));}
}

如上,上面方法的逻辑并不复杂。首先是获取并遍历子节点列表,然后为每个子节点创建 flags 集合,并添加 CONSTRUCTOR 标志。对于 idArg 节点,额外添加 ID 标志。最后一步则是构建 ResultMapping,该步逻辑前面已经分析过,这里就不多说了。

分析完 的子节点 , 以及

2.1.3.3 ResultMap 对象构建过程分析

前面用了不少的篇幅来分析 子节点的解析过程。通过前面的分析,我们可知 , 等节点最终都被解析成了 ResultMapping。在得到这些 ResultMapping 后,紧接着要做的事情是构建 ResultMap。如果说 ResultMapping 与单条结果映射相对应,那 ResultMap 与什么对应呢?答案是…。答案暂时还不能说,我们到源码中去找寻吧。下面,让我们带着这个疑问开始本节的源码分析。

前面分析了很多源码,大家可能都忘了 ResultMap 构建的入口了。这里再贴一下,如下:

private ResultMap resultMapElement(XNode resultMapNode, List<ResultMapping> additionalResultMappings) throws Exception {ResultMapResolver resultMapResolver = new ResultMapResolver(builderAssistant, id, typeClass, extend,discriminator, resultMappings, autoMapping);try {return resultMapResolver.resolve();} catch (IncompleteElementException e) {configuration.addIncompleteResultMap(resultMapResolver);throw e;}
}

如上,ResultMap 的构建逻辑分装在 ResultMapResolver 的 resolve 方法中,下面我从该方法进行分析。


public ResultMap resolve() {return assistant.addResultMap(this.id, this.type, this.extend, this.discriminator, this.resultMappings, this.autoMapping);
}

上面的方法将构建 ResultMap 实例的任务委托给了 MapperBuilderAssistant 的 addResultMap,我们跟进到这个方法中看看。


public ResultMap addResultMap(String id, Class<?> type, String extend, Discriminator discriminator,List<ResultMapping> resultMappings, Boolean autoMapping) {id = applyCurrentNamespace(id, false);extend = applyCurrentNamespace(extend, true);if (extend != null) {if (!configuration.hasResultMap(extend)) {throw new IncompleteElementException("Could not find a parent resultmap with id '" + extend + "'");}ResultMap resultMap = configuration.getResultMap(extend);List<ResultMapping> extendedResultMappings = new ArrayList<ResultMapping>(resultMap.getResultMappings());extendedResultMappings.removeAll(resultMappings);boolean declaresConstructor = false;for (ResultMapping resultMapping : resultMappings) {if (resultMapping.getFlags().contains(ResultFlag.CONSTRUCTOR)) {declaresConstructor = true;break;}}if (declaresConstructor) {Iterator<ResultMapping> extendedResultMappingsIter = extendedResultMappings.iterator();while (extendedResultMappingsIter.hasNext()) {if (extendedResultMappingsIter.next().getFlags().contains(ResultFlag.CONSTRUCTOR)) {extendedResultMappingsIter.remove();}}}resultMappings.addAll(extendedResultMappings);}ResultMap resultMap = new ResultMap.Builder(configuration, id, type, resultMappings, autoMapping).discriminator(discriminator).build();configuration.addResultMap(resultMap);return resultMap;
}

上面的方法主要用于处理 resultMap 节点的 extend 属性,extend 不为空的话,这里将当前 resultMappings 集合和扩展 resultMappings 集合合二为一。随后,通过建造模式构建 ResultMap 实例。过程如下:


public ResultMap build() {if (resultMap.id == null) {throw new IllegalArgumentException("ResultMaps must have an id");}resultMap.mappedColumns = new HashSet<String>();resultMap.mappedProperties = new HashSet<String>();resultMap.idResultMappings = new ArrayList<ResultMapping>();resultMap.constructorResultMappings = new ArrayList<ResultMapping>();resultMap.propertyResultMappings = new ArrayList<ResultMapping>();final List<String> constructorArgNames = new ArrayList<String>();for (ResultMapping resultMapping : resultMap.resultMappings) {resultMap.hasNestedQueries = resultMap.hasNestedQueries || resultMapping.getNestedQueryId() != null;resultMap.hasNestedResultMaps =resultMap.hasNestedResultMaps || (resultMapping.getNestedResultMapId() != null && resultMapping.getResultSet() == null);final String column = resultMapping.getColumn();if (column != null) {resultMap.mappedColumns.add(column.toUpperCase(Locale.ENGLISH));} else if (resultMapping.isCompositeResult()) {for (ResultMapping compositeResultMapping : resultMapping.getComposites()) {final String compositeColumn = compositeResultMapping.getColumn();if (compositeColumn != null) {resultMap.mappedColumns.add(compositeColumn.toUpperCase(Locale.ENGLISH));}}}final String property = resultMapping.getProperty();if (property != null) {resultMap.mappedProperties.add(property);}if (resultMapping.getFlags().contains(ResultFlag.CONSTRUCTOR)) {resultMap.constructorResultMappings.add(resultMapping);if (resultMapping.getProperty() != null) {constructorArgNames.add(resultMapping.getProperty());}} else {resultMap.propertyResultMappings.add(resultMapping);}if (resultMapping.getFlags().contains(ResultFlag.ID)) {resultMap.idResultMappings.add(resultMapping);}}if (resultMap.idResultMappings.isEmpty()) {resultMap.idResultMappings.addAll(resultMap.resultMappings);}if (!constructorArgNames.isEmpty()) {final List<String> actualArgNames = argNamesOfMatchingConstructor(constructorArgNames);if (actualArgNames == null) {throw new BuilderException("Error in result map '" + resultMap.id+ "'. Failed to find a constructor in '"+ resultMap.getType().getName() + "' by arg names " + constructorArgNames+ ". There might be more info in debug log.");}Collections.sort(resultMap.constructorResultMappings, new Comparator<ResultMapping>() {@Overridepublic int compare(ResultMapping o1, ResultMapping o2) {int paramIdx1 = actualArgNames.indexOf(o1.getProperty());int paramIdx2 = actualArgNames.indexOf(o2.getProperty());return paramIdx1 - paramIdx2;}});}resultMap.resultMappings = Collections.unmodifiableList(resultMap.resultMappings);resultMap.idResultMappings = Collections.unmodifiableList(resultMap.idResultMappings);resultMap.constructorResultMappings = Collections.unmodifiableList(resultMap.constructorResultMappings);resultMap.propertyResultMappings = Collections.unmodifiableList(resultMap.propertyResultMappings);resultMap.mappedColumns = Collections.unmodifiableSet(resultMap.mappedColumns);return resultMap;
}

以上代码看起来很复杂,实际上这是假象。以上代码主要做的事情就是将 ResultMapping 实例及属性分别存储到不同的集合中,仅此而已。ResultMap 中定义了五种不同的集合,下面分别介绍一下这几种集合。

集合名称用途
mappedColumns用于存储 、、、 节点 column 属性
mappedProperties用于存储 和 节点的 property 属性,或 和 节点的 name 属性
idResultMappings用于存储 和 节点对应的 ResultMapping 对象
propertyResultMappings用于存储 和 节点对应的 ResultMapping 对象
constructorResultMappings用于存储 和 节点对应的 ResultMapping 对象

上面干巴巴的描述不够直观。下面我们写点代码测试一下,并把这些集合的内容打印到控制台上,大家直观感受一下。先定义一个映射文件,如下:

<mapper namespace="xyz.coolblog.dao.ArticleDao"><resultMap id="articleResult" type="xyz.coolblog.model.Article"><constructor><idArg column="id" name="id"/><arg column="title" name="title"/><arg column="content" name="content"/></constructor><id property="id" column="id"/><result property="author" column="author"/><result property="createTime" column="create_time"/></resultMap>
</mapper>

测试代码如下:

public class ResultMapTest {@Testpublic void printResultMapInfo() throws Exception {Configuration configuration = new Configuration();String resource = "mapper/ArticleMapper.xml";InputStream inputStream = Resources.getResourceAsStream(resource);XMLMapperBuilder builder = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());builder.parse();ResultMap resultMap = configuration.getResultMap("articleResult");System.out.println("\n-------------------+✨ mappedColumns ✨+--------------------");System.out.println(resultMap.getMappedColumns());System.out.println("\n------------------+✨ mappedProperties ✨+------------------");System.out.println(resultMap.getMappedProperties());System.out.println("\n------------------+✨ idResultMappings ✨+------------------");resultMap.getIdResultMappings().forEach(rm -> System.out.println(simplify(rm)));System.out.println("\n---------------+✨ propertyResultMappings ✨+---------------");resultMap.getPropertyResultMappings().forEach(rm -> System.out.println(simplify(rm)));System.out.println("\n-------------+✨ constructorResultMappings ✨+--------------");resultMap.getConstructorResultMappings().forEach(rm -> System.out.println(simplify(rm)));System.out.println("\n-------------------+✨ resultMappings ✨+-------------------");resultMap.getResultMappings().forEach(rm -> System.out.println(simplify(rm)));inputStream.close();}private String simplify(ResultMapping resultMapping) {return String.format("ResultMapping{column='%s', property='%s', flags=%s, ...}",resultMapping.getColumn(), resultMapping.getProperty(), resultMapping.getFlags());}
}

这里,我们把5个集合转给你的内容都打印出来,结果如下:

如上,结果比较清晰明了,不需要过多解释了。我们参照上面配置文件及输出的结果,把 ResultMap 的大致轮廓画出来。如下:

到这里, 节点的解析过程就分析完了。总的来说,该节点的解析过程还是比较复杂的。好了,其他的就不多说了,继续后面的分析。

2.1.4 解析 节点

节点用来定义一些可重用的 SQL 语句片段,比如表名,或表的列名等。在映射文件中,我们可以通过 节点引用 节点定义的内容。下面我来演示一下 节点的使用方式,如下:

<sql id="table">article
</sql><select id="findOne" resultType="Article">SELECT id, title FROM <include refid="table"/> WHERE id = #{id}
</select><update id="update" parameterType="Article">UPDATE <include refid="table"/> SET title = #{title} WHERE id = #{id}
</update>

如上,上面配置中, 和 节点通过 引入定义在 节点中的表名。上面的配置比较常规,除了静态文本, 节点还支持属性占位符 ${}。比如:

<sql id="table">${table_prefix}_article
</sql>

如果属性 table_prefix = blog,那么 节点中的内容最终为 blog_article。

上面介绍了 节点的用法,比较容易。下面分析一下 sql 节点的解析过程,如下:

private void sqlElement(List<XNode> list) throws Exception {if (configuration.getDatabaseId() != null) {sqlElement(list, configuration.getDatabaseId());}sqlElement(list, null);
}

这个方法需要大家注意一下,如果 Configuration 的 databaseId 不为空,sqlElement 方法会被调用了两次。第一次传入具体的 databaseId,用于解析带有 databaseId 属性,且属性值与此相等的 节点。第二次传入的 databaseId 为空,用于解析未配置 databaseId 属性的 节点。这里是个小细节,大家注意一下就好。我们继续往下分析。

private void sqlElement(List<XNode> list, String requiredDatabaseId) throws Exception {for (XNode context : list) {String databaseId = context.getStringAttribute("databaseId");String id = context.getStringAttribute("id");id = builderAssistant.applyCurrentNamespace(id, false);if (databaseIdMatchesCurrent(id, databaseId, requiredDatabaseId)) {sqlFragments.put(id, context);}}
}

这个方法逻辑比较简单,首先是获取 节点的 id 和 databaseId 属性,然后为 id 属性值拼接命名空间。最后,通过检测当前 databaseId 和 requiredDatabaseId 是否一致,来决定保存还是忽略当前的 节点。下面,我们来看一下 databaseId 的匹配逻辑是怎样的。

private boolean databaseIdMatchesCurrent(String id, String databaseId, String requiredDatabaseId) {if (requiredDatabaseId != null) {if (!requiredDatabaseId.equals(databaseId)) {return false;}} else {if (databaseId != null) {return false;}if (this.sqlFragments.containsKey(id)) {XNode context = this.sqlFragments.get(id);if (context.getStringAttribute("databaseId") != null) {return false;}}}return true;
}

下面总结一下 databaseId 的匹配规则。

  1. databaseId 与 requiredDatabaseId 不一致,即失配,返回 false
  2. 当前节点与之前的节点出现 id 重复的情况,若之前的 节点 databaseId 属性不为空,返回 false
  3. 若以上两条规则均匹配失败,此时返回 true

在上面三条匹配规则中,第二条规则稍微难理解一点。这里简单分析一下,考虑下面这种配置。


<sql id="table" databaseId="mysql">article
</sql><sql id="table">article
</sql>

在上面配置中,两个 节点的 id 属性值相同,databaseId 属性不一致。假设 configuration.databaseId = mysql,第一次调用 sqlElement 方法,第一个 节点对应的 XNode 会被放入到 sqlFragments 中。第二次调用 sqlElement 方法时,requiredDatabaseId 参数为空。由于 sqlFragments 中已包含了一个 id 节点,且该节点的 databaseId 不为空,此时匹配逻辑返回 false,第二个节点不会被保存到 sqlFragments。

上面的分析内容涉及到了 databaseId,关于 databaseId 的用途,这里简单介绍一下。databaseId 用于标明数据库厂商的身份,不同厂商有自己的 SQL 方言,MyBatis 可以根据 databaseId 执行不同 SQL 语句。databaseId 在 节点中有什么用呢?这个问题也不难回答。 节点用于保存 SQL 语句片段,如果 SQL 语句片段中包含方言的话,那么该 节点只能被同一 databaseId 的查询语句或更新语句引用。关于 databaseId,这里就介绍这么多。

好了,本节内容先到这里。继续往下分析。

2.1.5 解析 SQL 语句节点

前面分析了 、、 以及 节点,从这一节开始,我们要分析映射文件中剩余的几个节点,分别是 、、 以及 等。这几个节点中存储的是相同的内容,都是 SQL 语句,所以这几个节点的解析过程也是相同的。在进行代码分析之前,这里需要特别说明一下:为了避免和 节点混淆,同时也为了描述方便,这里把 、、 以及 等节点统称为 SQL 语句节点。好了,下面开始本节的分析。

private void buildStatementFromContext(List<XNode> list) {if (configuration.getDatabaseId() != null) {buildStatementFromContext(list, configuration.getDatabaseId());}buildStatementFromContext(list, null);
}private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) {for (XNode context : list) {final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId);try {statementParser.parseStatementNode();} catch (IncompleteElementException e) {configuration.addIncompleteStatement(statementParser);}}
}

上面的解析方法没有什么实质性的解析逻辑,我们继续往下分析。

public void parseStatementNode() {String id = context.getStringAttribute("id");String databaseId = context.getStringAttribute("databaseId");if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {return;}Integer fetchSize = context.getIntAttribute("fetchSize");Integer timeout = context.getIntAttribute("timeout");String parameterMap = context.getStringAttribute("parameterMap");String parameterType = context.getStringAttribute("parameterType");Class<?> parameterTypeClass = resolveClass(parameterType);String resultMap = context.getStringAttribute("resultMap");String resultType = context.getStringAttribute("resultType");String lang = context.getStringAttribute("lang");LanguageDriver langDriver = getLanguageDriver(lang);Class<?> resultTypeClass = resolveClass(resultType);String resultSetType = context.getStringAttribute("resultSetType");StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);String nodeName = context.getNode().getNodeName();SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));boolean isSelect = sqlCommandType == SqlCommandType.SELECT;boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);boolean useCache = context.getBooleanAttribute("useCache", isSelect);boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false);XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);includeParser.applyIncludes(context.getNode());processSelectKeyNodes(id, parameterTypeClass, langDriver);SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);String resultSets = context.getStringAttribute("resultSets");String keyProperty = context.getStringAttribute("keyProperty");String keyColumn = context.getStringAttribute("keyColumn");KeyGenerator keyGenerator;String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX;keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true);if (configuration.hasKeyGenerator(keyStatementId)) {keyGenerator = configuration.getKeyGenerator(keyStatementId);} else {keyGenerator = context.getBooleanAttribute("useGeneratedKeys",configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType)) ? Jdbc3KeyGenerator.INSTANCE : NoKeyGenerator.INSTANCE;}builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,resultSetTypeEnum, flushCache, useCache, resultOrdered,keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
}

上面的代码比较长,看起来有点复杂。不过如果大家耐心看一下源码,会发现,上面的代码中起码有一般的代码都是用来获取节点属性,以及解析部分属性等。抛去这部分代码,以上代码做的事情如下。

  1. 解析 节点
  2. 解析 节点
  3. 解析 SQL,获取 SqlSource
  4. 构建 MappedStatement 实例

以上流程对应的代码比较复杂,每个步骤都能分析出一些东西来。下面我会每个步骤都进行分析,首先来分析 节点的解析过程。

2.1.5.1 解析 节点

节点的解析逻辑封装在 applyIncludes 中,该方法的代码如下:

public void applyIncludes(Node source) {Properties variablesContext = new Properties();Properties configurationVariables = configuration.getVariables();if (configurationVariables != null) {variablesContext.putAll(configurationVariables);}applyIncludes(source, variablesContext, false);
}

上面代码创建了一个新的 Properties 对象,并将全局 Properties 添加到其中。这样做的原因是 applyIncludes 的重载方法会向 Properties 中添加新的元素,如果直接将全局 Properties 传给重载方法,会造成全局 Properties 被污染。这是个小细节,一般容易被忽视掉。其他没什么需要注意的了,我们继续往下看。

private void applyIncludes(Node source, final Properties variablesContext, boolean included) {if (source.getNodeName().equals("include")) {Node toInclude = findSqlFragment(getStringAttribute(source, "refid"), variablesContext);Properties toIncludeContext = getVariablesContext(source, variablesContext);applyIncludes(toInclude, toIncludeContext, true);if (toInclude.getOwnerDocument() != source.getOwnerDocument()) {toInclude = source.getOwnerDocument().importNode(toInclude, true);}source.getParentNode().replaceChild(toInclude, source);while (toInclude.hasChildNodes()) {toInclude.getParentNode().insertBefore(toInclude.getFirstChild(), toInclude);}toInclude.getParentNode().removeChild(toInclude);} else if (source.getNodeType() == Node.ELEMENT_NODE) {if (included && !variablesContext.isEmpty()) {NamedNodeMap attributes = source.getAttributes();for (int i = 0; i < attributes.getLength(); i++) {Node attr = attributes.item(i);attr.setNodeValue(PropertyParser.parse(attr.getNodeValue(), variablesContext));}}NodeList children = source.getChildNodes();for (int i = 0; i < children.getLength(); i++) {applyIncludes(children.item(i), variablesContext, included);}} else if (included && source.getNodeType() == Node.TEXT_NODE && !variablesContext.isEmpty()) {source.setNodeValue(PropertyParser.parse(source.getNodeValue(), variablesContext));}
}

上面的代码如果从上往下读,不太容易看懂。因为上面的方法由三个条件分支,外加两个递归调用组成,代码的执行顺序并不是由上而下。要理解上面的代码,我们需要定义一些配置,并将配置带入到具体代码中,逐行进行演绎。不过,更推荐的方式是使用 IDE 进行单步调试。为了便于讲解,我把上面代码中的三个分支都用 ⭐️ 标记了出来,这个大家注意一下。好了,必要的准备工作做好了,下面开始演绎代码的执行过程。演绎所用的测试配置如下:

<mapper namespace="xyz.coolblog.dao.ArticleDao"><sql id="table">${table_name}</sql><select id="findOne" resultType="xyz.coolblog.dao.ArticleDO">SELECTid, titleFROM<include refid="table"><property name="table_name" value="article"/></include>WHERE id = #{id}</select>
</mapper>

我们先来看一下 applyIncludes 方法第一次被调用时的状态,如下:

参数值:
source = <select> 节点
节点类型:ELEMENT_NODE
variablesContext = [ ]  
included = false执行流程:
1. 进入条件分支2
2. 获取 <select> 子节点列表
3. 遍历子节点列表,将子节点作为参数,进行递归调用

第一次调用 applyIncludes 方法,source = ,代码进入条件分支2。在该分支中,首先要获取 节点的子节点列表。可获取到的子节点如下:

编号子节点类型描述
1SELECT id, title FROMTEXT_NODE文本节点
2ELEMENT_NODE普通节点
3WHERE id = #TEXT_NODE文本节点

在获取到子节点类列表后,接下来要做的事情是遍历列表,然后将子节点作为参数进行递归调用。在上面三个子节点中,子节点1和子节点3都是文本节点,调用过程一致。因此,下面我只会演示子节点1和子节点2的递归调用过程。先来演示子节点1的调用过程,如下:

节点1的调用过程比较简单,只有两层调用。然后我们在看一下子节点2的调用过程,如下:

上面是子节点2的调用过程,共有四层调用,略为复杂。大家自己也对着配置,把源码走一遍,然后记录每一次调用的一些状态,这样才能更好的理解 applyIncludes 方法的逻辑。

好了,本节内容先到这里,继续往下分析。

2.1.5.2 解析 节点

对于一些不支持自增主键的数据库来说,我们在插入数据时,需要明确指定主键数据。以 Oracle 数据库为例,Oracle 数据库不支持自增主键,但它提供了自增序列工具。我们每次向数据库中插入数据时,可以先通过自增序列获取主键数据,然后再进行插入。这里涉及到两次数据库查询操作,我们不能在一个 节点中同时定义两个 select 语句,否者会导致 SQL 语句出错。对于这个问题,MyBatis 的 可以很好的解决。下面我们看一段配置:

<insert id="saveAuthor"><selectKey keyProperty="id" resultType="int" order="BEFORE">select author_seq.nextval from dual</selectKey>insert into Author(id, name, password)values(#{id}, #{username}, #{password})
</insert>

在上面的配置中,查询语句会先于插入语句执行,这样我们就可以在插入时获取到主键的值。关于 的用法,这里不过多介绍了。下面我们来看一下 节点的解析过程。

private void processSelectKeyNodes(String id, Class<?> parameterTypeClass, LanguageDriver langDriver) {List<XNode> selectKeyNodes = context.evalNodes("selectKey");if (configuration.getDatabaseId() != null) {parseSelectKeyNodes(id, selectKeyNodes, parameterTypeClass, langDriver, configuration.getDatabaseId());}parseSelectKeyNodes(id, selectKeyNodes, parameterTypeClass, langDriver, null);removeSelectKeyNodes(selectKeyNodes);
}

从上面的代码中可以看出, 节点在解析完成后,会被从 dom 树中移除。这样后续可以更专注的解析 或 节点中的 SQL,无需再额外处理 节点。继续往下看。

private void parseSelectKeyNodes(String parentId, List<XNode> list, Class<?> parameterTypeClass,LanguageDriver langDriver, String skRequiredDatabaseId) {for (XNode nodeToHandle : list) {String id = parentId + SelectKeyGenerator.SELECT_KEY_SUFFIX;String databaseId = nodeToHandle.getStringAttribute("databaseId");if (databaseIdMatchesCurrent(id, databaseId, skRequiredDatabaseId)) {parseSelectKeyNode(id, nodeToHandle, parameterTypeClass, langDriver, databaseId);}}
}private void parseSelectKeyNode(String id, XNode nodeToHandle, Class<?> parameterTypeClass,LanguageDriver langDriver, String databaseId) {String resultType = nodeToHandle.getStringAttribute("resultType");Class<?> resultTypeClass = resolveClass(resultType);StatementType statementType = StatementType.valueOf(nodeToHandle.getStringAttribute("statementType", StatementType.PREPARED.toString()));String keyProperty = nodeToHandle.getStringAttribute("keyProperty");String keyColumn = nodeToHandle.getStringAttribute("keyColumn");boolean executeBefore = "BEFORE".equals(nodeToHandle.getStringAttribute("order", "AFTER"));boolean useCache = false;boolean resultOrdered = false;KeyGenerator keyGenerator = NoKeyGenerator.INSTANCE;Integer fetchSize = null;Integer timeout = null;boolean flushCache = false;String parameterMap = null;String resultMap = null;ResultSetType resultSetTypeEnum = null;SqlSource sqlSource = langDriver.createSqlSource(configuration, nodeToHandle, parameterTypeClass);SqlCommandType sqlCommandType = SqlCommandType.SELECT;builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,resultSetTypeEnum, flushCache, useCache, resultOrdered,keyGenerator, keyProperty, keyColumn, databaseId, langDriver, null);id = builderAssistant.applyCurrentNamespace(id, false);MappedStatement keyStatement = configuration.getMappedStatement(id, false);configuration.addKeyGenerator(id, new SelectKeyGenerator(keyStatement, executeBefore));
}

上面的源码比较长,但大部分代码都是一些基础代码,不是很难理解。以上代码比较重要的步骤如下:

  1. 创建 SqlSource 实例
  2. 构建并缓存 MappedStatement 实例
  3. 构建并缓存 SelectKeyGenerator 实例

在这三步中,第1步和第2步调用的是公共逻辑,其他地方也会调用,这两步对应的源码后续会分两节进行讲解。第3步则是创建一个 SelectKeyGenerator 实例,SelectKeyGenerator 创建的过程本身没什么好说的,所以就不多说了。下面分析一下 SqlSource 和 MappedStatement 实例的创建过程。

2.1.5.3 解析 SQL 语句

前面分析了 和 节点的解析过程,这两个节点解析完成后,都会以不同的方式从 dom 树中消失。所以目前的 SQL 语句节点由一些文本节点和普通节点组成,比如 、 等。那下面我们来看一下移除掉 和 节点后的 SQL 语句节点是如何解析的。


public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);return builder.parseScriptNode();
}public SqlSource parseScriptNode() {MixedSqlNode rootSqlNode = parseDynamicTags(context);SqlSource sqlSource = null;if (isDynamic) {sqlSource = new DynamicSqlSource(configuration, rootSqlNode);} else {sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);}return sqlSource;
}

如上,SQL 语句的解析逻辑被封装在了 XMLScriptBuilder 类的 parseScriptNode 方法中。该方法首先会调用 parseDynamicTags 解析 SQL 语句节点,在解析过程中,会判断节点是是否包含一些动态标记,比如 ${} 占位符以及动态 SQL 节点等。若包含动态标记,则会将 isDynamic 设为 true。后续可根据 isDynamic 创建不同的 SqlSource。下面,我们来看一下 parseDynamicTags 方法的逻辑。


private void initNodeHandlerMap() {nodeHandlerMap.put("trim", new TrimHandler());nodeHandlerMap.put("where", new WhereHandler());nodeHandlerMap.put("set", new SetHandler());nodeHandlerMap.put("foreach", new ForEachHandler());nodeHandlerMap.put("if", new IfHandler());nodeHandlerMap.put("choose", new ChooseHandler());nodeHandlerMap.put("when", new IfHandler());nodeHandlerMap.put("otherwise", new OtherwiseHandler());nodeHandlerMap.put("bind", new BindHandler());
}protected MixedSqlNode parseDynamicTags(XNode node) {List<SqlNode> contents = new ArrayList<SqlNode>();NodeList children = node.getNode().getChildNodes();for (int i = 0; i < children.getLength(); i++) {XNode child = node.newXNode(children.item(i));if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {String data = child.getStringBody("");TextSqlNode textSqlNode = new TextSqlNode(data);if (textSqlNode.isDynamic()) {contents.add(textSqlNode);isDynamic = true;} else {contents.add(new StaticTextSqlNode(data));}} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) {String nodeName = child.getNode().getNodeName();NodeHandler handler = nodeHandlerMap.get(nodeName);if (handler == null) {throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");}handler.handleNode(child, contents);isDynamic = true;}}return new MixedSqlNode(contents);
}

上面方法的逻辑我前面已经说过,主要是用来判断节点是否包含一些动态标记,比如 ${} 占位符以及动态 SQL 节点等。这里,不管是动态 SQL 节点还是静态 SQL 节点,我们都可以把它们看成是 SQL 片段,一个 SQL 语句由多个 SQL 片段组成。在解析过程中,这些 SQL 片段被存储在 contents 集合中。最后,该集合会被传给 MixedSqlNode 构造方法,用于创建 MixedSqlNode 实例。从 MixedSqlNode 类名上可知,它会存储多种类型的 SqlNode。除了上面代码中已出现的几种 SqlNode 实现类,还有一些 SqlNode 实现类未出现在上面的代码中。但它们也参与了 SQL 语句节点的解析过程,这里我们来看一下这些幕后的 SqlNode 类。

上面的 SqlNode 实现类用于处理不同的动态 SQL 逻辑,这些 SqlNode 是如何生成的呢?答案是由各种 NodeHandler 生成。我们再回到上面的代码中,可以看到这样一句代码:

handler.handleNode(child, contents);

该代码用于处理动态 SQL 节点,并生成相应的 SqlNode。下面来简单分析一下 WhereHandler 的代码。


private class WhereHandler implements NodeHandler {public WhereHandler() {}@Overridepublic void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);WhereSqlNode where = new WhereSqlNode(configuration, mixedSqlNode);targetContents.add(where);}
}

如上,handleNode 方法内部会再次调用 parseDynamicTags 解析 节点中的内容,这样又会生成一个 MixedSqlNode 对象。最终,整个 SQL 语句节点会生成一个具有树状结构的 MixedSqlNode。如下图:

到此,SQL 语句的解析过程就分析完了。现在,我们已经将 XML 配置解析了 SqlSource,但这还没有结束。SqlSource 中只能记录 SQL 语句信息,除此之外,这里还有一些额外的信息需要记录。因此,我们需要一个类能够同时存储 SqlSource 和其他的信息。这个类就是 MappedStatement。下面我们来看一下它的构建过程。

2.1.5.4 构建 MappedStatement

SQL 语句节点可以定义很多属性,这些属性和属性值最终存储在 MappedStatement 中。下面我们看一下 MappedStatement 的构建过程是怎样的。

public MappedStatement addMappedStatement(String id, SqlSource sqlSource, StatementType statementType, SqlCommandType sqlCommandType,Integer fetchSize, Integer timeout, String parameterMap, Class<?> parameterType,String resultMap, Class<?> resultType, ResultSetType resultSetType, boolean flushCache,boolean useCache, boolean resultOrdered, KeyGenerator keyGenerator, String keyProperty,String keyColumn, String databaseId, LanguageDriver lang, String resultSets) {if (unresolvedCacheRef) {throw new IncompleteElementException("Cache-ref not yet resolved");}id = applyCurrentNamespace(id, false);boolean isSelect = sqlCommandType == SqlCommandType.SELECT;MappedStatement.Builder statementBuilder = new MappedStatement.Builder(configuration, id, sqlSource, sqlCommandType).resource(resource).fetchSize(fetchSize).timeout(timeout).statementType(statementType).keyGenerator(keyGenerator).keyProperty(keyProperty).keyColumn(keyColumn).databaseId(databaseId).lang(lang).resultOrdered(resultOrdered).resultSets(resultSets).resultMaps(getStatementResultMaps(resultMap, resultType, id)).flushCacheRequired(valueOrDefault(flushCache, !isSelect)).resultSetType(resultSetType).useCache(valueOrDefault(useCache, isSelect)).cache(currentCache);ParameterMap statementParameterMap = getStatementParameterMap(parameterMap, parameterType, id);if (statementParameterMap != null) {statementBuilder.parameterMap(statementParameterMap);}MappedStatement statement = statementBuilder.build();configuration.addMappedStatement(statement);return statement;
}

上面就是 MappedStatement,没什么复杂的地方,就不多说了。

2.1.6 小节

本章分析了映射文件的解析过程,总的来说,本章的内容还是比较复杂的,逻辑太多。不过如果大家自己也能把映射文件的解析过程认真分析一遍,会对 MyBatis 有更深入的理解。分析过程很累,但是在此过程中会收获了很多东西,还是很开心的。好了,本章内容先到这里。后面还有一些代码需要分析,我们继续往后看。

2.2 Mapper 接口绑定过程分析

映射文件解析完成后,并不意味着整个解析过程就结束了。此时还需要通过命名空间绑定 mapper 接口,这样才能将映射文件中的 SQL 语句和 mapper 接口中的方法绑定在一起,后续即可通过调用 mapper 接口方法执行与之对应的 SQL 语句。下面我们来分析一下 mapper 接口的绑定过程。


private void bindMapperForNamespace() {String namespace = builderAssistant.getCurrentNamespace();if (namespace != null) {Class<?> boundType = null;try {boundType = Resources.classForName(namespace);} catch (ClassNotFoundException e) {}if (boundType != null) {if (!configuration.hasMapper(boundType)) {configuration.addLoadedResource("namespace:" + namespace);configuration.addMapper(boundType);}}}
}public <T> void addMapper(Class<T> type) {mapperRegistry.addMapper(type);
}public <T> void addMapper(Class<T> type) {if (type.isInterface()) {if (hasMapper(type)) {throw new BindingException("Type " + type + " is already known to the MapperRegistry.");}boolean loadCompleted = false;try {knownMappers.put(type, new MapperProxyFactory<T>(type));MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type);parser.parse();loadCompleted = true;} finally {if (!loadCompleted) {knownMappers.remove(type);}}}
}

以上就是 Mapper 接口的绑定过程。这里简单一下:

  1. 获取命名空间,并根据命名空间解析 mapper 类型
  2. 将 type 和 MapperProxyFactory 实例存入 knownMappers 中
  3. 解析注解中的信息

以上步骤中,第3步的逻辑较多。如果大家看懂了映射文件的解析过程,那么注解的解析过程也就不难理解了,这里就不深入分析了。好了,Mapper 接口的绑定过程就先分析到这。

2.3 处理未完成解析的节点

在解析某些节点的过程中,如果这些节点引用了其他一些未被解析的配置,会导致当前节点解析工作无法进行下去。对于这种情况,MyBatis 的做法是抛出 IncompleteElementException 异常。外部逻辑会捕捉这个异常,并将节点对应的解析器放入 incomplet* 集合中。这个我在分析映射文件解析的过程中进行过相应注释,不知道大家有没有注意到。没注意到也没关系,待会我会举例说明。下面我们来看一下 MyBatis 是如何处理未完成解析的节点。


public void parse() {configurationElement(parser.evalNode("/mapper"));parsePendingResultMaps();parsePendingCacheRefs();parsePendingStatements();
}

如上,parse 方法是映射文件的解析入口。在本章的开始,我贴过这个源码。从上面的源码中可以知道有三种节点在解析过程中可能会出现不能完成解析的情况。由于上面三个以 parsePending 开头的方法逻辑一致,所以下面我只会分析其中一个方法的源码。简单起见,这里选择分析 parsePendingCacheRefs 的源码。下面看一下如何配置映射文件会导致 节点无法完成解析。


<mapper namespace="xyz.coolblog.dao.Mapper1"><cache-ref namespace="xyz.coolblog.dao.Mapper2"/>
</mapper><mapper namespace="xyz.coolblog.dao.Mapper2"><cache/>
</mapper>

如上,假设 MyBatis 先解析映射文件1,然后再解析映射文件2。按照这样的解析顺序,映射文件1中的 节点就无法完成解析,因为它所引用的缓存还未被解析。当映射文件2解析完成后,MyBatis 会调用 parsePendingCacheRefs 方法处理在此之前未完成解析的 节点。具体的逻辑如下:

private void parsePendingCacheRefs() {Collection<CacheRefResolver> incompleteCacheRefs = configuration.getIncompleteCacheRefs();synchronized (incompleteCacheRefs) {Iterator<CacheRefResolver> iter = incompleteCacheRefs.iterator();while (iter.hasNext()) {try {iter.next().resolveCacheRef();iter.remove();} catch (IncompleteElementException e) {}}}
}

上面代码不是很长,我也做了比较多的注释,应该不难理解。好了,关于未完成解析节点的解析过程就分析到这。

3.总结

本篇文章对映射文件的解析过程进行了较为详细的分析,全文篇幅比较大,写的也比较辛苦。本篇文章耗时7天完成,在这7天中,基本上一有空闲时间,就会用来写作。虽然很累,但是收获也很多。我目前正在努力的构建自己的知识体系,我觉得对于常用的技术,还是应该花一些时间和精力,去弄懂它的原理。这样以后才能走的更远,才能成为你想成为的样子。

好了,其他的就不多说了,本篇文章就到这吧。谢谢大家的阅读。

参考

  • 《MyBatis 技术内幕》- 徐郡明
  • MyBatis 官方文档
    原文地址:https://www.cnblogs.com/nullllun/p/9388667.html

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/823216.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

新标准日本语 课后练习

自学错误可能较多&#xff0c;听力题不需要听力的就没听录音 第二十課 スミスさんはピアノを弾くことができます 練習&#xff11;&#xff0d;&#xff11; &#xff11;張さんは日本の歌を歌うことができます 张先生会唱日本歌 &#xff12;小野さんは自転車に乗ることがで…

设计模式代码实战-模版方法模式

1、问题描述 小明喜欢品尝不同类型的咖啡&#xff0c;她发现每种咖啡的制作过程有一些相同的步骤&#xff0c;他决定设计一个简单的咖啡制作系统&#xff0c;使用模板方法模式定义咖啡的制作过程。系统支持两种咖啡类型&#xff1a;美式咖啡&#xff08;American Coffee&#…

RAID 5实训

Raid 5 配置实训 实验结构&#xff1a; 实验步骤 环境准备&#xff0c;Linux操作虚拟机下添加3个及以上的磁盘用于raid 10阵列构建所用&#xff08;本次实验4个&#xff0c;一个用于备用&#xff09;创建raid 5 磁盘阵列格式化并挂载磁盘阵列存储速度检查是否提升安全性能检查…

DevExpress WinForms中文教程 - 如何通过UI测试自动化增强应用可靠性?(二)

DevExpress WinForm拥有180组件和UI库&#xff0c;能为Windows Forms平台创建具有影响力的业务解决方案。DevExpress WinForm能完美构建流畅、美观且易于使用的应用程序&#xff0c;无论是Office风格的界面&#xff0c;还是分析处理大批量的业务数据&#xff0c;它都能轻松胜任…

C++进阶——继承

前言&#xff1a;从这篇文章开始&#xff0c;我们进入C进阶知识的分享&#xff0c;在此之前&#xff0c;我们需要先来回顾一个知识&#xff1a; C语言有三大特性&#xff0c;分别是封装、继承和多态&#xff0c;而我们前边所分享的各种容器类&#xff0c;迭代器等&#xff0c;…

数据结构----链表算法题目

1.移除链表的元素 这个题目我们有多种解决方案 &#xff08;1&#xff09;思路A&#xff1a;遍历整串数据&#xff0c;如果是我们想要删除的数据&#xff0c;就让这个数字后面的数字全部向前移动直到整传数字全部遍历完成&#xff1b;这个方法的时间复杂度是N的平方&#xff…

ARM作业day8

温湿度数据采集应用&#xff1a; 由上图可知&#xff1a; 控制温湿度采集模块的引脚是PF14&#xff08;串行时钟线&#xff09;和PF15&#xff08;串行数据线&#xff09;&#xff1a;控制温湿度采集模块的总线是AHB4&#xff0c;通过GPIOF串口和RCC使能完成初始化操作。 控制…

MAC-OS低版本升级到高版本——亲测有效

关于MAC-OS 10.13.6 升级到10.15的实战 一.MAC 欧司如何查看他的系统版本 查看信息如图 二.,去官网下载新的MAC OS 系统 官网地址&#xff1a;如何下载和安装 macOS - 官方 Apple 支持 (中国) 三.点击安装 四.具体步骤可以参考官网 在兼容的 Mac 电脑上下载并安装最新或以前…

最新版idea 合并分支方法

前言 以下是最新版的idea2024&#xff0c;如果有人找不到按键可能是因为版本不同。 操作步骤 看右小角我的分支是submit&#xff0c;现在我要将test合并到我的submit分支上 找到test分支&#xff0c;选择update&#xff0c;这一步会拉取相应分支内容等同于pull 选择merge 选…

I2C,UART,SPI(STM32、51单片机)

目录 基本理论知识&#xff1a; 并行通信/串行通信&#xff1a; 异步通信/同步通信&#xff1a; 半双工通信/全双工通信: UART串口&#xff1a; I2C串口&#xff1a; SPI串口&#xff1a; I2C在单片机中的应用&#xff1a; 软件模拟&#xff1a; 51单片机&#xff1a;…

PHP-file_get_contents(练习1)

[题目信息]&#xff1a; 题目名称题目难度PHP-file_get_contents(练习1)1 [题目考点]&#xff1a; file_get_contents() 把整个文件读入一个字符串中。 该函数是用于把文件的内容读入到一个字符串中的首选方法。如果服务器操作系统支持&#xff0c;还会使用内存映射技术来增…

同城O2O系统开发实战:外卖送餐APP的技术架构与实现

今天&#xff0c;我们将深入探讨同城O2O系统开发实战中&#xff0c;外卖送餐APP的技术架构与实现。 一、概述 外卖送餐APP是一种典型的O2O应用&#xff0c;通过移动互联网技术&#xff0c;将用户与商家连接起来&#xff0c;实现用户在线订餐&#xff0c;商家配送服务的模式。…

Git分布式版本控制系统——在IDEA中使用Git(一)

一、在IDEA中配置Git 本质上还是使用的本地安装的Git软件&#xff0c;所以需要在IDEA中配置Git 打开IDEA的设置页面&#xff0c;按照下图操作 二、在IDEA中使用Git获取仓库 1、本地初始化仓库 2、从远程仓库克隆 方法一&#xff1a; 方法二&#xff1a; 三、.gitignore文件…

简单的网站-表白墙(前后端交互)

提交信息后&#xff0c;就得到了下面的一行话 但是存在一些问题 在一个网站中&#xff0c;服务器起到的最主要的效果&#xff0c;就是 “存储数据” 因此服务器这边往往也就需要能够提供两种风格的接口。存数据 、取数据 二、实现前后端交互 1&#xff09;先规定此处请求和响…

2024-04-11最新dubbo+zookeeper下载安装,DEMO展示

dubbozookeeper下载安装 下载zookeeper&#xff1a; 下载地址 解压&#xff0c;并进入bin目录&#xff0c;启动 如果闪退可以编辑脚本&#xff0c;在指定位置加上暂停脚本 报错内容说没有conf/zoo.cfg&#xff0c;就复制zoo_sample.cfg重命名为zoo.cfg 再次启动脚本&#x…

前端网络 --- http缓存

什么是http缓存&#xff1f; 1、HTTP 缓存会存储与请求关联的响应&#xff0c;并将存储的响应复用于后续请求。 2、缓存的原理是在首次请求后保存一份请求资源的响应副本&#xff0c;当用户再次发起相同请求时&#xff0c;判断缓存是否命中&#xff0c;如果命中则将前面的响应…

react v18 项目初始化

按照以下命令进行傻瓜式操作即可&#xff1a; 全局安装脚手架工具&#xff1a; npm install -g create-react-app创建项目my-react-app&#xff1a; create-react-app my-react-app安装 antd: yarn add antd安装 react-router-dom&#xff1a; yarn add react-router-dom启动项…

图论学习总结

目录 图论学习总结前言一、基础知识图的存储图的遍历 二、最短路多源最短路 F l o y d Floyd Floyd​ 算法例题及变形 e g 1 &#xff1a; S o r t i n g I t A l l O u t eg1&#xff1a;Sorting\ It\ All\ Out eg1&#xff1a;Sorting It All Out ( 蓝书例题&#xff0c;传递…

软硬链接与动静态库

文章目录 1.软硬链接2.动态库和静态库2.1 见一见库2.2 动静态库2.2.1 静态库2.2.2 动态库 2.3 动静态库的对比 3.真实的应用场景(ncurses库)4.库加载---可执行程序和地址空间4.1可执行程序的加载4.2 库的加载 1.软硬链接 2.动态库和静态库 2.1 见一见库 我们用过很多库。C/C的…

如何节约上架时间,小程序管理平台推荐

继微信正式推出微信小程序后&#xff0c;各个大厂陆续发布了各自的小程序平台 —— 支付宝小程序、百度小程序、头条小程序&#xff0c;各家不同的小程序标准一度让开发者们激情开骂&#xff0c;虽然目前跨平台的小程序开发可以通过taro、mpvue、kbone等跨平台开发框架来解决&a…