核心处理层-SqlNode&SqlSource

2020-08-21 书籍 《MyBatis技术内幕》

核心处理层以基础支持层为基础,实现了MyBatis的核心功能。这个部分将从MyBatis的初始化、动态SQL语句的解析、结果集的映射、参数解析以及SQL语句的执行等几个方面分析MyBatis的核心处理层,了解MyBatis的核心原理。

image-20200622175512211

本篇介绍SqINode&SqISource

映射配置文件中定义的SQL节点会被解析成MappedStatement对象,其中的SQL语句会被解析成SqlSource对象,SQL语句中定义的动态SQL节点、文本节点等,则由SqlNode接口的相应实现表示。

SqlSource接口的定义:

public interface SqlSource {

  BoundSql getBoundSql(Object parameterObject);

}

SqlSource接口的实现类图:

image-20200824173236987

  • DynamicSqlSource负责处理动态SQL语句,RawSqlSource负责处理静态语句,两者最终都会将处理后的SQL语句封装成StaticSqlSource返回。
  • DynamicSqlSourceStaticSqlSource的主要区别:
    • StaticSqlSource中记录的SQL语句中可能含有?占位符,但是可以直接提交给数据库执行
    • DynamicSqlSource中封装的SQL语句还需要进行一系列解析,才会最终形成数据库可执行的SQL语句

组合模式

组合模式是将对象组合成树形结构,以表示部分-整体的层次结构(一般是树形结构),用户可以像处理一个简单对象一样来处理一个复杂对象,从而使得调用者无须了解复杂元素的内部结构。

image-20200824174003381

组合模式中的各模式如下:

  • 抽象组件(Component)

    Component接口定义了树形结构中所有类的公共行为,例如这里的operation()方法。

    一般情况下,其中还会定义一些用于管理子组件的方法,例如这里的add()remove()getChild()方法。

  • 树叶(Leaf)

    Leaf在树形结构中表示叶节点对象,叶节点没有子节点。

  • 树枝(Composite)

    定义有子组件的那些组件的行为。该角色用于管理子组件,并通过operation()方法调用其管理的子组件的相关操作。

  • 调用者(Client)

    通过Component接口操纵整个树形结构。

组合模式主要有两点好处首先组合模式可以帮助调用者屏蔽对象的复杂性

对于调用者来说,使用整个树形结构与使用单个Component对象没有任何区别,也就是说,调用者并不必关心自己处理的是单个Component对象还是整个树形结构,这样就可以将调用者与复杂对象进行解耦。

另外,使用了组合模式之后,我们可以通过增加树中节点的方式,添加新的Component对象,从而实现功能上的扩展,这符合开放-封闭原则,也可以简化日后的维护工作。

组合模式在带来上述好处的同时,也会引入一些问题

例如,有些场景下程序希望一个组合结构中只能有某些特定的组件,此时就很难直接通过组件类型进行限制(因为都是Component接口的实现类),这就必须在运行时进行类型检测。而且,在递归程序中定位问题也是一件比较复杂的事情。

MyBatis在处理动态SQL节点时,应用到了组合设计模式。MyBatis会将动态SQL节点解析成对应的SqlNode实现,并形成树形结构。

OGNL表达式

OGNL(Object Graphic Navigation Language,对象图导航语言)表达式在StrutsMyBatis等开源项目中有广泛的应用,其中Struts框架更是将OGNL作为默认的表达式语言。

MyBatis中涉及的OGNL表达式的功能主要是:存取Java对象树中的属性调用Java对象树中的方法等

OGNL中的几个概念:

  • 表达式

    OGNL表达式执行的所有操作都是根据表达式解析得到的。

    例如:

    对象名.方法名表示调用指定对象的指定方法

    @[类的完全限定名]@[静态方法或静态字段]表示调用指定类的静态方法或访问静态字段

    OGNL表达式还可以完成变量赋值操作集合等操作。

  • root对象

    OGNL表达式指定了具体的操作,而root对象指定了需要操作的对象。

  • OgnlContext(上下文对象)

    OgnlContext类继承了Map接口,OgnlContext对象说白了也就是一个Map对象。

    既然如此,OgnIContext对象中就可以存放除root对象之外的其他对象。

    在使用OGNL表达式操作非root对象时,需要使用#前缀,而操作root对象则不需要使用#前缀。

MyBatis中,使用OgnlCache对原生的OGNL进行了封装。OGNL表达式的解析过程是比较耗时的,为了提高效率,OgnlCache中使用expressionCache字段(静态成员,ConcurrentHashMap<String,Object>类型)对解析后的OGNL表达式进行缓存。

private static final Map<String, Object> expressionCache = new ConcurrentHashMap<String, Object>();

public static Object getValue(String expression, Object root) {
  try {
    Map<Object, OgnlClassResolver> context = Ognl.createDefaultContext(root, new OgnlClassResolver());
    return Ognl.getValue(parseExpression(expression), context, root);
  } catch (OgnlException e) {
    throw new BuilderException("Error evaluating expression '" + expression + "'. Cause: " + e, e);
  }
}

private static Object parseExpression(String expression) throws OgnlException {
  Object node = expressionCache.get(expression);
  if (node == null) {
    node = Ognl.parseExpression(expression);
    expressionCache.put(expression, node);
  }
  return node;
}

DynamicContext

DynamicContext主要用于记录解析动态SQL语句之后产生的SQL语句片段,可以认为它是一个用于记录动态SQL语句解析结果的容器。

其中有两个核心字段:

// 参数上下文
private final ContextMap bindings;
// 在SqlNode解析动态sql的时候,会将解析后的sql语句片段添加到该属性中保存,最终拼凑出一条完整的sql语句
private final StringBuilder sqlBuilder = new StringBuilder();

ContextMapDynamicContext中定义的内部类,它实现了HashMap并重写了get()方法。

static class ContextMap extends HashMap<String, Object> {
  private static final long serialVersionUID = 2977601501966151582L;

  // 将用户传的参数封装成MetaObject对象
  private MetaObject parameterMetaObject;
  public ContextMap(MetaObject parameterMetaObject) {
    this.parameterMetaObject = parameterMetaObject;
  }

  @Override
  public Object get(Object key) {
    String strKey = (String) key;
    if (super.containsKey(strKey)) {
      return super.get(strKey);
    }

    if (parameterMetaObject != null) {
      // issue #61 do not modify the context when reading
      return parameterMetaObject.getValue(strKey);
    }

    return null;
  }
}

DynamicContext的构造方法会初始化bindings集合,注意构造方法的第二个参数pammeterObject,它是运行时用户传入的参数,其中包含了后续用于替换#{}占位符的实参。

public DynamicContext(Configuration configuration, Object parameterObject) {
  if (parameterObject != null && !(parameterObject instanceof Map)) {
    // 对于不是Map类型的参数,会创装MetaObject对象,并封装成ContextMap对象
    MetaObject metaObject = configuration.newMetaObject(parameterObject);
    bindings = new ContextMap(metaObject);
  } else {
    bindings = new ContextMap(null);
  }
  bindings.put(PARAMETER_OBJECT_KEY, parameterObject);
  bindings.put(DATABASE_ID_KEY, configuration.getDatabaseId());
}

SqlNode

接下来看看SqlNode的实现类是如何解析其对应的SQL节点。

public interface SqlNode {
  // apply()是SqlNode接口中定义的唯一方法,该方法会根据用户传入的实参,参数解析该SqlNode所
  // 记录的动态SQL节点,并调用DynamicContext.appendSql()方法将解析后的SQL片段追加到
  // DynamicContext.sqlBuilder中保存
  // 当SQL节点下的所有SqlNode完成解析后,我们就可以从DynamicContext中获取一条动态生成的、
  // 完整的SQL语句
  boolean apply(DynamicContext context);
}

SqlNode接口有多个实现类,每个实现类对应一个动态SQL节点。按照组合模式的角色来划分,SqlNode扮演了抽象组件的角色,MixedSqlNode扮演了树枝节点的角色,TextSqlNode节点扮演了树叶节点的角色等等。

image-20200825163955175

1、StaticTextSqINode&MixedSqINode

StaticTextSqINode中使用text字段(String类型)记录了对应的非动态SQL语句节点,其apply()方法直接将text字段追加到DynamicContext.sqlBuilder字段中。

MixedSqINode中使用contents字段(List<SqlNode>类型)记录其子节点对应的SqlNode对象集合,其apply()方法会循环调用contents集合中所有SqlNode对象的apply()方法。

2、TextSqlNode

TextSqlNode表示的是包含占位符的动态SQL节点。TextSqlNode.apply()方法会使用GenericTokenParser解析${}占位符,并直接替换成用户给定的实际参数值。

@Override
public boolean apply(DynamicContext context) {
  // 创建GenericTokenParser解析器,
  GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
  // 将解析后的SQL片段添加到DynamicContext中
  context.appendSql(parser.parse(text));
  return true;
}

private GenericTokenParser createParser(TokenHandler handler) {
  return new GenericTokenParser("${", "}", handler);
}

BindingTokenParserTextSqlNode中定义的内部类,继承了TokenHandler接口,它的主要功能是根据DynamicContext.bindings集合中的信息解析SQL语句节点中的${}占位符BindingTokenParser.context字段指向了对应的DynamicContext对象。

private static class BindingTokenParser implements TokenHandler {

  private DynamicContext context;
  private Pattern injectionFilter;

  public BindingTokenParser(DynamicContext context, Pattern injectionFilter) {
    this.context = context;
    this.injectionFilter = injectionFilter;
  }

  @Override
  public String handleToken(String content) {
    Object parameter = context.getBindings().get("_parameter");
    if (parameter == null) {
      context.getBindings().put("value", null);
    } else if (SimpleTypeRegistry.isSimpleType(parameter.getClass())) {
      context.getBindings().put("value", parameter);
    }
    Object value = OgnlCache.getValue(content, context.getBindings());
    String srtValue = (value == null ? "" : String.valueOf(value)); // issue #274 return "" instead of "null"
    checkInjection(srtValue);
    return srtValue;
  }

  private void checkInjection(String value) {
    if (injectionFilter != null && !injectionFilter.matcher(value).matches()) {
      throw new ScriptingException("Invalid input. Please conform to regex" + injectionFilter.pattern());
    }
  }
}

3、IfSqlNode

IfSqlNode对应的动态SQL节点是<if>节点,以下是几个核心字段。

// 用于解析if节点的test表达式的值
private final ExpressionEvaluator evaluator;
// 记录了test表达式
private final String test;
// 记录子节点
private final SqlNode contents;

IfSqlNode.apply()方法首先会通过ExpressionEvaluator.evaluateBoolean()方法检测其test表达
式是否为true,然后根据test表达式的结果,决定是否执行其子节点的apply()方法。

@Override
public boolean apply(DynamicContext context) {
  if (evaluator.evaluateBoolean(test, context.getBindings())) {
    contents.apply(context);
    return true;
  }
  return false;
}

public boolean evaluateBoolean(String expression, Object parameterObject) {
  Object value = OgnlCache.getValue(expression, parameterObject);
  if (value instanceof Boolean) {
    return (Boolean) value;
  }
  if (value instanceof Number) {
    return new BigDecimal(String.valueOf(value)).compareTo(BigDecimal.ZERO) != 0;
  }
  return value != null;
}

4、TrimSqINode&WhereSqINode&SetSqINode

TrimSqlNode会根据子节点的解析结果,添加或删除相应的前缀或后缀。其中几个字段如下:

// 记录子节点
private final SqlNode contents;
// SQL语句添加的前缀
private final String prefix;
// SQL语句添加的后缀
private final String suffix;
// 
private final List<String> prefixesToOverride;
private final List<String> suffixesToOverride;

TrimSqlNode的构造函数中,会调用parseOverrides()方法对参数prefixesToOverride(对应<trim>节点的prefixOverrides属性)和参数suffixesToOverride(对应<trim>节点的suffixOverrides属性)进行解析,并初始化prefixesToOverridesufflxesToOverride

private static List<String> parseOverrides(String overrides) {
  if (overrides != null) {
    final StringTokenizer parser = new StringTokenizer(overrides, "|", false);
    final List<String> list = new ArrayList<String>(parser.countTokens());
    while (parser.hasMoreTokens()) {
      list.add(parser.nextToken().toUpperCase(Locale.ENGLISH));
    }
    return list;
  }
  return Collections.emptyList();
}

TrimSqlNode.apply()方法首先解析子节点,然后根据子节点的解析结果处理前缀和后缀。

public boolean apply(DynamicContext context) {
  // 创建FilteredDynamicContext对象,其中封装了DynamicContext
  FilteredDynamicContext filteredDynamicContext = new FilteredDynamicContext(context);
  // 调用子节点的apply方法进行解析
  boolean result = contents.apply(filteredDynamicContext);
  // 处理前缀和后缀
  filteredDynamicContext.applyAll();
  return result;
}

处理前缀和后缀的主要逻辑是在FilteredDynamicContext中实现的,它继承了DynamicContext,同时也是DynamicContext的代理类。

FilteredDynamicContext除了将对应方法调用委托给其中封装的DynamicContext对象,还提供了处理前缀和后缀的applyAll()方法。

private class FilteredDynamicContext extends DynamicContext {
  // 底层封装的DynamicContext对象
  private DynamicContext delegate;

  // 是否已经处理过前缀和后缀
  private boolean prefixApplied;
  private boolean suffixApplied;

  // 记录子节点解析过后的结果
  private StringBuilder sqlBuffer;

  // ...
  // ...

  public void applyAll() {
    // 获取子节点解析过后的结果,并全部转换为大写
    sqlBuffer = new StringBuilder(sqlBuffer.toString().trim());
    String trimmedUppercaseSql = sqlBuffer.toString().toUpperCase(Locale.ENGLISH);
    if (trimmedUppercaseSql.length() > 0) {
      applyPrefix(sqlBuffer, trimmedUppercaseSql);// 处理前缀
      applySuffix(sqlBuffer, trimmedUppercaseSql);// 处理后缀
    }
    delegate.appendSql(sqlBuffer.toString());
  }

  // ...
  // ...

  // 处理前缀
  private void applyPrefix(StringBuilder sql, String trimmedUppercaseSql) {
    if (!prefixApplied) {
      prefixApplied = true;
      if (prefixesToOverride != null) {
        for (String toRemove : prefixesToOverride) {
          if (trimmedUppercaseSql.startsWith(toRemove)) {
            sql.delete(0, toRemove.trim().length());
            break;
          }
        }
      }
      if (prefix != null) {
        sql.insert(0, " ");
        sql.insert(0, prefix);
      }
    }
  }

  // 处理后缀
  private void applySuffix(StringBuilder sql, String trimmedUppercaseSql) {
    if (!suffixApplied) {
      suffixApplied = true;
      if (suffixesToOverride != null) {
        for (String toRemove : suffixesToOverride) {
          if (trimmedUppercaseSql.endsWith(toRemove) || 
              trimmedUppercaseSql.endsWith(toRemove.trim())) {
            int start = sql.length() - toRemove.trim().length();
            int end = sql.length();
            sql.delete(start, end);
            break;
          }
        }
      }
      if (suffix != null) {
        sql.append(" ");
        sql.append(suffix);
      }
    }
  }
}

WhereSqlNodeSetSqlNode都继承了TrimSqlNode

其中WhereSqlNode指定了prefix字段为WHEREprefixesToOverride集合中的项为ANDORsuffix字段和suffixesToOverride集合为null。也就是说,<where>节点解析后的SQL语句片段如果以ANDOR开头,则将开头处的ANDOR删除,之后再将WHERE关键字添加到SQL片段开始位置,从而得到该<where>节点最终生成的SQL片段。

SetSqlNode指定了prefix字段为SETsuffixesToOverride集合中的项只有suffix字段和prefixesToOverride集合为null。也就是说,<set>节点解析后的SQL语句片段如果以,结尾,则将结尾处的删除掉,之后再将SET关键字添加到SQL片段的开始位置,从而得到该<set>节点最终生成的SQL片段。

5、ForeachSqINode

在动态SQL语句中构建IN条件语句的时候,通常需要对一个集合进行迭代,MyBatis提供了<foreach>标签实现该功能。在使用<foreach>标签迭代集合时,不仅可以使用集合的元素和索引值,还可以在循环开始之前或结束之后添加指定的字符串,也允许在迭代过程中添加指定的分隔符。

解析<foreach>节点对应的sqlnode实现类是ForeachSqlNode,以下是其中定义的字段:

// 用于判断循环的终止条件,ForeachSqlNode构造方法中会创建该对象
private final ExpressionEvaluator evaluator;
// 迭代的集合表达式
private final String collectionExpression;
// 子节点
private final SqlNode contents;
// 循环开始前要添加的字符串
private final String open;
// 循环结束时要添加的字符串
private final String close;
// 分隔符
private final String separator;
// 本次迭代的元素
private final String item;
// 当前迭代的次数
private final String index;
// 配置
private final Configuration configuration;

ForeachSqINode中有两个内部类,分别是PrefixedContextFilteredDynamicContext,它们都继承了DynamicContext,同时也都是DynamicContext的代理类。

PreFixedContext

private class PrefixedContext extends DynamicContext {
  private final DynamicContext delegate;
  private final String prefix;
  // 是否已经处理过前缀
  private boolean prefixApplied;

  // ...

  @Override
  public void appendSql(String sql) {
    if (!prefixApplied && sql != null && sql.trim().length() > 0) { // 是否需要追加前缀
      delegate.appendSql(prefix); // 追加前缀
      prefixApplied = true;
    }
    delegate.appendSql(sql); // 追加sql
  }

}

FilteredDynamicContext

FilteredDynamicContext负责处理#{}占位符,但它并未完全解析#{}占位符。

private static class FilteredDynamicContext extends DynamicContext {
  // DynamicContext对象
  private final DynamicContext delegate;
  // 索引位置
  private final int index;
  // 对应集合项的index
  private final String itemIndex;
  private final String item;

  // ...

  @Override
  public void appendSql(String sql) {
    GenericTokenParser parser = new GenericTokenParser("#{", "}", new TokenHandler() {
      @Override
      public String handleToken(String content) {
        String newContent = 
          content.replaceFirst("^\\s*" + item + "(?![^.,:\\s])", itemizeItem(item, index));
        if (itemIndex != null && newContent.equals(content)) {
          newContent = 
            content.replaceFirst("^\\s*" + itemIndex + "(?![^.,:\\s])", 
                                 itemizeItem(itemIndex, index));
        }
        return new StringBuilder("#{").append(newContent).append("}").toString();
      }
    });

    delegate.appendSql(parser.parse(sql));
  }

  @Override
  public int getUniqueNumber() {
    return delegate.getUniqueNumber();
  }

}

6、ChooseSqlNode

如果在编写动态SQL语句时需要类似Java中的switch语句的功能,可以考虑使用<choose><when><otherwise>三个标签的组合。MyBatis会将<choose>标签解析成ChooseSqlNode,将<when>标签解析成IfSqlNode,将<otherwise>标签解析成MixedSqlNode

ChooseSqlNode.apply()方法的逻辑比较简单,首先遍历ifSqlNodes集合并调用其中SqlNode
对象的apply()方法,然后根据前面的处理结果决定是否调用defaultSqlNodeapply()方法。

7、VarDecISqINode

VarDeclSqlNode表示的是动态SQL语句中的<bind>节点,该节点可以从OGNL表达式中创建一个变量并将其记录到上下文中。

VarDecISqINode中通过name字段记录<bind>节点的name属性值,expression字段记录<bind>节点的value属性值。

SqlSourceBuilder

在经过SqlNode.apply()方法的解析之后,SQL语句会被传递到SqlSourceBuilder中进行进一步的解析。

SqISourceBuilder主要完成了两方面的操作,一方面是解析SQL语句中的#{}
占位符中定义的属性,格式类似于#{__frc_item_0,javaType=int,jdbcType=NUMERIC,typeHandler=MyTypeHandler},另一方面是将SQL语句中的#{}占位符替换成?占位符。

SqlSourceBuilder也是BaseBuilder的子类之一,其核心逻辑位于parse()方法中。

public SqlSource parse(
  String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
  // 第一个参数是经过SqlNode.apply()方法处理之后的sql语句
  // 第二个参数是用户传入的实参类型
  // 第三个参数记录了形参与实参的对应关系,其实就是经过SqlNode.apply()方法处理后的DynamicContext.bindings集合

  // 创建ParameterMappingTokenHandler对象, 它是解析#{}占位符中的参数属性以及替换占位符的核心
  ParameterMappingTokenHandler handler = 
    new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);

  // 配合ParameterMappingTokenHandler解析占位符
  GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
  String sql = parser.parse(originalSql);
  return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}

DynamicSqlSource

DynamicSqlSource负责解析动态SQL语句,也是最常用的Sqlource实现之一。SqlNode中使用了组合模式,形成了一个树状结构,DynamicSqlSource中使用rootSqlNode字段(SqlNode类型)记录了待解析的SqlNode树的根节点。

DynamicSqlSource.getBoundSql()方法:

public BoundSql getBoundSql(Object parameterObject) {
  // 创建DynamicContext对象
  DynamicContext context = new DynamicContext(configuration, parameterObject);

  // 通过调用rootSqlNode.apply()方法调用整个树形结构中全部SqlNode.apply()方法。
  // 每个SqlNode的apply()方法都将解析得到的SQL语句片段追加到context中,
  // 最终通过context.getSql()得到完整的SQL语句
  rootSqlNode.apply(context);
  SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
  Class<?> parameterType = parameterObject == null ? 
    Object.class : parameterObject.getClass();
  SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), 
                                              parameterType, context.getBindings());

  // 创建BoundSql对象,并将DynamicContext.bindings中的参数信息复制到其
  BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
  for (Map.Entry<String, Object> entry : context.getBindings().entrySet()) {
    boundSql.setAdditionalParameter(entry.getKey(), entry.getValue());
  }
  return boundSql;
}

image-20200828104415997

RawSqISource

RawSqISourceSqlSource的另一个实现,其逻辑与DynamicSqlSource类似,但是执行时机不一样,处理的SQL语句类型也不一样。

前面介绍XMLScriptBuilder.parseDynamicTags()方法时提到过,如果节点只包含#{}占位符,而不包含动态SQL节点或未解析的${}占位符的话,则不是动态SQL语句,会创建相应的StaticTextSqlNode对象。

XMLScriptBuilder.parseScriptNode()方法中会判断整个SQL节点是否为动态的,如果不是动态的SQL节点,则创建相应的RawSqlSource对象。

RawSqlSource在构造方法中首先会调用getSql()方法,其中通过调用SqlNode.apply()方法完成SQL语句的拼装和初步处理;之后会使用SqlSourceBuilder完成占位符的替换和ParameterMapping集合的创建,并返回StaticSqlSource对象。

public class RawSqlSource implements SqlSource {

  private final SqlSource sqlSource;

  public RawSqlSource(Configuration configuration, SqlNode rootSqlNode, Class<?> parameterType) {
    // 调用getsql()方法,完成SQL语句的拼装和初步解析
    this(configuration, getSql(configuration, rootSqlNode), parameterType);
  }

  public RawSqlSource(Configuration configuration, String sql, Class<?> parameterType) {
    SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
    Class<?> clazz = parameterType == null ? Object.class : parameterType;
    sqlSource = sqlSourceParser.parse(sql, clazz, new HashMap<String, Object>());
  }

  private static String getSql(Configuration configuration, SqlNode rootSqlNode) {
    DynamicContext context = new DynamicContext(configuration, null);
    rootSqlNode.apply(context);
    return context.getSql();
  }

  @Override
  public BoundSql getBoundSql(Object parameterObject) {
    return sqlSource.getBoundSql(parameterObject);
  }

}

无论是StaticSqlSourceDynamicSqlSource还是RawSqlSource,最终都会统一生成BoundSql对象,其中封装了完整的SQL语句(可能包含?占位符)参数映射关系(parameterMappings集合)以及用户传入的参数(additionalParameters集合)

另外,DynamicSqlSource负责处理动态SQL语句,RawSqlSource负责处理静态SQL语句,除此之外,两者解析SQL语句的时机也不一样,前者的解析时机是在实际执行SQL语句之前,而后者则是在MyBatis初始化时完成SQL语句的解析。

参考

  • 《MyBatis技术内幕》

  • 部分图片来源——《MyBatis技术内幕》

MyBatis 《MyBatis技术内幕》 核心处理层

相关推荐



版权声明




文章目录