掘金 后端 ( ) • 2024-05-08 10:16

大家好,我是王有志。 今天给大家带来的是一道来自京东的 MyBatis 面试题:MyBatis映射文件中,A元素通过include引入B元素定义的SQL语句,B元素只能定义在A元素之前吗?

答案是否定的,B 元素可以定义在 MyBatis 映射器文件中的任何位置。

首先要明确一点,include 元素只能引入由 sql 元素定义的 SQL 语句片段,而不能引入由其它元素定义的 SQL 语句。而能够通过 include 元素引入 SQL 语句片段的有以下 14 个元素:

  • select 元素
  • insert 元素
  • update 元素
  • delete 元素
  • include 元素
  • sql 元素
  • selectKey 元素
  • trim 元素
  • where 元素
  • set 元素
  • foreach 元素
  • when 元素
  • if 元素
  • otherwise 元素

MyBatis 在读取并解析映射器文件时会优先解析 sql 元素定义的语句,并存储在 Map 容器中,最后再去解析由 select 元素,insert 元素,update 元素和 delete 元素定义的 SQL 语句,这部分源码位于XMLMapperBuilde#rconfigurationElement方法中,源码如下:

private void configurationElement(XNode context) {
  try {
    String namespace = context.getStringAttribute("namespace");
    if (namespace == null || namespace.isEmpty()) {
      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);
  }
}

第 13 行中调用了XMLMapperBuilder#sqlElement方法解析 MyBatis 映射器文件中的 sql 元素定义的 sql 语句片段,源码如下:

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

private void sqlElement(List<XNode> list, String requiredDatabaseId) {
  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);
    }
  }
}

可以看到,最终解析完成的由 sql 元素定义的 SQL 语句片段会存储到成员变量 sqlFragments 中。

接着我们来看XMLMapperBuilder#buildStatementFromContext方法,该方法负责解析由 select 元素,insert 元素,update 元素和 delete 元素定义的 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);
    }
  }
}

我们一路追踪到调用XMLStatementBuilder#parseStatementNode方法,该方法很长,我们只关注解析 include 元素的部分:

public void parseStatementNode() {
  // 省略
  XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);
  includeParser.applyIncludes(context.getNode());
  // 省略
}

最终调用到XMLIncludeTransformer#applyIncludes方法,这里我跳过了一个重载方法,我们直接看处理的核心部分:

private void applyIncludes(Node source, final Properties variablesContext, boolean included) {
  if ("include".equals(source.getNodeName())) {
    // 通过refId获取由sql元素定义的SQL语句片段
    Node toInclude = findSqlFragment(getStringAttribute(source, "refid"), variablesContext);
    Properties toIncludeContext = getVariablesContext(source, variablesContext);

    // 使用sql元素定义的SQL语句片段进入递归调用,用于处理SQL语句片段中由include引入的内容
    applyIncludes(toInclude, toIncludeContext, true);
    if (toInclude.getOwnerDocument() != source.getOwnerDocument()) {
      toInclude = source.getOwnerDocument().importNode(toInclude, true);
    }
    // 使用toInclude节点替换source节点的父节点的子节点(即source节点),这里用于重新构建节点间的关系
    // 即使用将原始节点中通过include元素引入的内容替换为sql元素中定义的SQL语句片段
    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 || source.getNodeType() == Node.CDATA_SECTION_NODE) && !variablesContext.isEmpty()) {
    source.setNodeValue(PropertyParser.parse(source.getNodeValue(), variablesContext));
  }
}

private Node findSqlFragment(String refid, Properties variables) {
  refid = PropertyParser.parse(refid, variables);
  refid = builderAssistant.applyCurrentNamespace(refid, true);
  try {
    // 通过refid获取由sql元素定义的SQL语句片段
    XNode nodeToInclude = configuration.getSqlFragments().get(refid);
    return nodeToInclude.getNode().cloneNode(true);
  } catch (IllegalArgumentException e) {
    throw new IncompleteElementException("Could not find SQL statement to include with refid '" + refid + "'", e);
  }
}

XMLIncludeTransformer#applyIncludes方法是个递归调用,如果直接通过源码和我标注的注释来构建调用链路的话可能会比较困难,建议通过断点调试的方式来分析这段代码的功能,或者通过一些 AI 助手去解释这段代码后再去调试,会更加清晰。


好了,今天的内容就到这里了,如果本文对你有帮助的话,希望多多点赞支持,如果文章中出现任何错误,还请批评指正。最后欢迎大家关注分享硬核 Java 技术的金融摸鱼侠王有志,我们下次再见!