本文将结合源码介绍mybatis-plus的原理,包括:
- BaseMapper API
- MybatisSqlSessionFactoryBean类
- BaseMapper API Statement解析
- Wrapper查询构建原理
系列文档:
mybatis-plus
MyBatis的增强工具,在MyBatis的基础上只做增强不做改变,为简化开发、提高效率而生。
- 无侵入:只做增强不做改变,引入它不会对现有工程产生影响
- 损耗小:启动即会自动注入基本CURD,性能基本无损耗,直接面向对象操作
- 强大的CRUD操作:内置通用Mapper、通用Service,仅仅通过少量配置即可实现单表大部分CRUD操作,更有强大的条件构造器,满足各类使用需求
- 支持Lambda形式调用:通过Lambda表达式,方便的编写各类查询条件,无需再担心字段写错
- 支持主键自动生成:支持多达4种主键策略(内含分布式唯一ID生成器 - Sequence),可自由配置,完美解决主键问题
- 支持ActiveRecord模式:支持ActiveRecord形式调用,实体类只需继承Model类即可进行强大的CRUD操作
- 支持自定义全局通用操作:支持全局通用方法注入(Write once, use anywhere)
- 内置代码生成器:采用代码或者Maven插件可快速生成Mapper、Model、Service、Controller层代码,支持模板引擎,更有超多自定义配置等您来使用
- 内置分页插件:基于MyBatis物理分页,开发者无需关心具体操作,配置好插件之后,写分页等同于普通List查询
- 分页插件支持多种数据库:支持MySQL、MariaDB、Oracle、DB2、H2、HSQL、SQLite、Postgre、SQLServer等多种数据库
- 内置性能分析插件:可输出SQL语句以及其执行时间,建议开发测试时启用该功能,能快速揪出慢查询
- 内置全局拦截插件:提供全表delete、update操作智能分析阻断,也可自定义拦截规则,预防误操作
源码分析
BaseMapper API
mybatis-plus会自动为BaseMapper API创建MappedStatement,后续我们会分析这个过程:
public interface BaseMapper<T> extends Mapper<T> {
int insert(T entity);
int deleteById(Serializable id);
int deleteByMap(@Param(Constants.COLUMN_MAP) Map<String, Object> columnMap);
int delete(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
int deleteBatchIds(@Param(Constants.COLLECTION) Collection<? extends Serializable> idList);
int updateById(@Param(Constants.ENTITY) T entity);
int update(@Param(Constants.ENTITY) T entity, @Param(Constants.WRAPPER) Wrapper<T> updateWrapper);
T selectById(Serializable id);
List<T> selectBatchIds(@Param(Constants.COLLECTION) Collection<? extends Serializable> idList);
List<T> selectByMap(@Param(Constants.COLUMN_MAP) Map<String, Object> columnMap);
T selectOne(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
Integer selectCount(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
List<T> selectList(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
List<Map<String, Object>> selectMaps(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
List<Object> selectObjs(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
<E extends IPage<T>> E selectPage(E page, @Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
<E extends IPage<Map<String, Object>>> E selectMapsPage(
E page, @Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
}
MybatisSqlSessionFactoryBean类
这个类也实现了FactoryBean接口,最终也会创建一个DefaultSqlSessionFactory对象,基本上和SqlSessionFactoryBean的作用一致,只是在中间加入了mybatis-plus的Statement注入等逻辑。
另外还有一些mybatis-plus的自定义配置参数,我们平时能够使用的只有GlobalConfig和DbConfig这两个类。
MybatisConfiguration类
这个类由mybatis-plus提供,继承了mybatis的Configuration类,用于封装mybatis和mybatis-plus的核心配置参数。
与本文相关的内容暂时只有这个:
protected final MybatisMapperRegistry mybatisMapperRegistry = new MybatisMapperRegistry(this);
在分析mybatis getMapper(Class)原理时,我们了解到mybatis是使用MapperRegistry.getMapper(Class)方法实现的Mapper接口扫描和创建代理。而在mybatis-plus中,使用的是MybatisMapperRegistry类,这个类由mybatis-plus提供,继承了MapperRegistry类,重写了getMapper(Class)和addMapper(Class)等核心方法,在这些方法中实现了mybatis-plus BaseMapper API Statement的解析和注入。后续有详细分析。
GlobalConfig类
封装全局配置信息:
public class GlobalConfig implements Serializable {
private boolean banner = true;
private boolean enableSqlRunner = false;
private DbConfig dbConfig;
private ISqlInjector sqlInjector = new DefaultSqlInjector();
private Class<?> superMapperClass = Mapper.class;
private SqlSessionFactory sqlSessionFactory;
private Set<String> mapperRegistryCache = new ConcurrentSkipListSet<>();
private MetaObjectHandler metaObjectHandler;
private IdentifierGenerator identifierGenerator;
}
DbConfig类
封装DB通用配置:
public static class DbConfig {
private IdType idType = IdType.ASSIGN_ID;
private String tablePrefix;
private String schema;
private String columnFormat;
private String propertyFormat;
private boolean replacePlaceholder;
private String escapeSymbol;
private boolean tableUnderline = true;
private boolean capitalMode = false;
private IKeyGenerator keyGenerator;
private String logicDeleteField;
private String logicDeleteValue = "1";
private String logicNotDeleteValue = "0";
private FieldStrategy insertStrategy = FieldStrategy.NOT_NULL;
private FieldStrategy updateStrategy = FieldStrategy.NOT_NULL;
private FieldStrategy selectStrategy = FieldStrategy.NOT_NULL;
}
这个类里面的insertStrategy、updateStrategy、selectStrategy后续深入分析时再做分析。
BaseMapper API Statement解析
代码入口
入口在**com.baomidou.mybatisplus.core.MybatisConfiguration.getMapper(Class, SqlSession)**方法:
public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
return mybatisMapperRegistry.getMapper(type, sqlSession);
}
这里的mybatisMapperRegistry就是上文介绍过的MybatisMapperRegistry类对象。
MybatisMapperRegistry的getMapper(Class)方法
public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
final MybatisMapperProxyFactory<T> mapperProxyFactory = knownMappers.get(type);
if (mapperProxyFactory == null) {
throw new BindingException("Type " + type + " is not known to the MybatisPlusMapperRegistry.");
}
try {
return mapperProxyFactory.newInstance(sqlSession);
} catch (Exception e) {
throw new BindingException("Error getting mapper instance. Cause: " + e, e);
}
}
MybatisMapperRegistry的addMapper(Class)方法
public <T> void addMapper(Class<T> type) {
if (type.isInterface()) {
if (hasMapper(type)) {
return;
}
boolean loadCompleted = false;
try {
knownMappers.put(type, new MybatisMapperProxyFactory<>(type));
MybatisMapperAnnotationBuilder parser = new MybatisMapperAnnotationBuilder(config, type);
parser.parse();
loadCompleted = true;
} finally {
if (!loadCompleted) {
knownMappers.remove(type);
}
}
}
}
MybatisMapperAnnotationBuilder的parse()方法
public void parse() {
String resource = type.toString();
if (!configuration.isResourceLoaded(resource)) {
loadXmlResource();
configuration.addLoadedResource(resource);
String mapperName = type.getName();
assistant.setCurrentNamespace(mapperName);
parseCache();
parseCacheRef();
InterceptorIgnoreHelper.InterceptorIgnoreCache cache =
InterceptorIgnoreHelper.initSqlParserInfoCache(type);
for (Method method : type.getMethods()) {
if (!canHaveStatement(method)) {
continue;
}
if (getAnnotationWrapper(method, false, Select.class, SelectProvider.class).isPresent()
&& method.getAnnotation(ResultMap.class) == null) {
parseResultMap(method);
}
try {
InterceptorIgnoreHelper.initSqlParserInfoCache(cache, mapperName, method);
SqlParserHelper.initSqlParserInfoCache(mapperName, method);
parseStatement(method);
} catch (IncompleteElementException e) {
configuration.addIncompleteMethod(new MybatisMethodResolver(this, method));
}
}
try {
if (GlobalConfigUtils.isSupperMapperChildren(configuration, type)) {
parserInjector();
}
} catch (IncompleteElementException e) {
configuration.addIncompleteMethod(new InjectorResolver(this));
}
}
parsePendingMethods();
}
parserInjector()方法
由于mybatis-plus注入CRUD SQL的核心逻辑在这个方法,我们重点看一下这个方法:
void parserInjector() {
GlobalConfigUtils.getSqlInjector(configuration).inspectInject(assistant, type);
}
这里获取到的是DefaultSqlInjector对象。前文分析过。
inspectInject方法:
public void inspectInject(MapperBuilderAssistant builderAssistant, Class<?> mapperClass) {
Class<?> modelClass = extractModelClass(mapperClass);
if (modelClass != null) {
String className = mapperClass.toString();
Set<String> mapperRegistryCache =
GlobalConfigUtils.getMapperRegistryCache(builderAssistant.getConfiguration());
if (!mapperRegistryCache.contains(className)) {
List<AbstractMethod> methodList = this.getMethodList(mapperClass);
if (CollectionUtils.isNotEmpty(methodList)) {
TableInfo tableInfo = TableInfoHelper.initTableInfo(builderAssistant, modelClass);
methodList.forEach(m -> m.inject(builderAssistant, mapperClass, modelClass, tableInfo));
} else {
logger.debug(mapperClass.toString() + ", No effective injection method was found.");
}
mapperRegistryCache.add(className);
}
}
}
public List<AbstractMethod> getMethodList(Class<?> mapperClass) {
return Stream.of(
new Insert(), new Delete(), new DeleteByMap(), new DeleteById(),
new DeleteBatchByIds(), new Update(), new UpdateById(), new SelectById(),
new SelectBatchByIds(), new SelectByMap(), new SelectOne(), new SelectCount(),
new SelectMaps(), new SelectMapsPage(), new SelectObjs(), new SelectList(), new SelectPage()
).collect(toList());
}
TableInfo类
TableInfo类,封装从model解析出来的数据库表信息,这个类还是比较简单的:
public class TableInfo implements Constants {
private Class<?> entityType;
private IdType idType = IdType.NONE;
private String tableName;
private String resultMap;
private boolean autoInitResultMap;
private boolean keyRelated;
private String keyColumn;
private String keyProperty;
private Class<?> keyType;
private KeySequence keySequence;
private List<TableFieldInfo> fieldList;
private String currentNamespace;
private Configuration configuration;
private boolean underCamel;
private String allSqlSelect;
private String sqlSelect;
private boolean withInsertFill;
private boolean withUpdateFill;
private boolean withLogicDelete;
private TableFieldInfo logicDeleteFieldInfo;
private boolean withVersion;
private TableFieldInfo versionFieldInfo;
TableFieldInfo类,封装表字段信息:
public class TableFieldInfo implements Constants {
private final Field field;
private final String column;
private final String property;
private final String el;
private final Class<?> propertyType;
private final boolean isPrimitive;
private final boolean isCharSequence;
private final FieldStrategy insertStrategy;
private final FieldStrategy updateStrategy;
private final FieldStrategy whereStrategy;
private final boolean version;
private boolean select = true;
private boolean logicDelete = false;
private String logicDeleteValue;
private String logicNotDeleteValue;
private String update;
private String condition = SqlCondition.EQUAL;
private FieldFill fieldFill = FieldFill.DEFAULT;
private boolean withInsertFill;
private boolean withUpdateFill;
private String sqlSelect;
private JdbcType jdbcType;
private Class<? extends TypeHandler<?>> typeHandler;
AbstractMethod.inject()方法
public void inject(
MapperBuilderAssistant builderAssistant, Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
this.configuration = builderAssistant.getConfiguration();
this.builderAssistant = builderAssistant;
this.languageDriver = configuration.getDefaultScriptingLanguageInstance();
injectMappedStatement(mapperClass, modelClass, tableInfo);
}
public abstract MappedStatement injectMappedStatement(
Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo);
SelectList类:
public class SelectList extends AbstractMethod {
@Override
public MappedStatement injectMappedStatement(
Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
SqlMethod sqlMethod = SqlMethod.SELECT_LIST;
String sql = String.format(
sqlMethod.getSql(), sqlFirst(), sqlSelectColumns(tableInfo, true), tableInfo.getTableName(),
sqlWhereEntityWrapper(true, tableInfo), sqlComment());
SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
return this.addSelectMappedStatementForTable(mapperClass, getMethod(sqlMethod), sqlSource, tableInfo);
}
}
public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
GlobalConfig.DbConfig config = GlobalConfigUtils.getDbConfig(configuration);
if (config.isReplacePlaceholder()) {
List<String> find = SqlUtils.findPlaceholder(script);
if (CollectionUtils.isNotEmpty(find)) {
try {
script = SqlUtils.replaceSqlPlaceholder(script, find, config.getEscapeSymbol());
} catch (MybatisPlusException e) {
throw new IncompleteElementException();
}
}
}
return super.createSqlSource(configuration, script, parameterType);
}
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
return builder.parseScriptNode();
}
public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
if (script.startsWith("<script>")) {
XPathParser parser =
new XPathParser(script, false, configuration.getVariables(), new XMLMapperEntityResolver());
return createSqlSource(configuration, parser.evalNode("/script"), parameterType);
} else {
script = PropertyParser.parse(script, configuration.getVariables());
TextSqlNode textSqlNode = new TextSqlNode(script);
if (textSqlNode.isDynamic()) {
return new DynamicSqlSource(configuration, textSqlNode);
} else {
return new RawSqlSource(configuration, script, parameterType);
}
}
}
this.addSelectMappedStatementForTable(mapperClass, getMethod(sqlMethod), sqlSource, tableInfo)就是创建MappedStatement对象并将其注册到Configuration中:
protected MappedStatement addSelectMappedStatementForTable(
Class<?> mapperClass, String id, SqlSource sqlSource, TableInfo table) {
String resultMap = table.getResultMap();
if (null != resultMap) {
return addMappedStatement(mapperClass, id, sqlSource, SqlCommandType.SELECT, null,
resultMap, null, new NoKeyGenerator(), null, null);
} else {
return addSelectMappedStatementForOther(mapperClass, id, sqlSource, table.getEntityType());
}
}
protected MappedStatement addMappedStatement(
Class<?> mapperClass, String id, SqlSource sqlSource, SqlCommandType sqlCommandType,
Class<?> parameterType, String resultMap, Class<?> resultType, KeyGenerator keyGenerator,
String keyProperty, String keyColumn) {
String statementName = mapperClass.getName() + DOT + id;
if (hasMappedStatement(statementName)) {
return null;
}
boolean isSelect = false;
if (sqlCommandType == SqlCommandType.SELECT) {
isSelect = true;
}
return builderAssistant.addMappedStatement(id, sqlSource, StatementType.PREPARED, sqlCommandType,
null, null, null, parameterType, resultMap, resultType,
null, !isSelect, isSelect, false, keyGenerator, keyProperty, keyColumn,
configuration.getDatabaseId(), languageDriver, null);
}
到这里为止,mybatis-plus注册BaseMapper API Statement的逻辑就分析完成了。
Wrapper查询构建原理
一个示例
List<Blog> list = this.blogMapper.selectList(new LambdaQueryWrapper<Blog>()
.like(Blog::getTitle, "spring")
.eq(Blog::getId, 2)
);
Wrapper抽象类
BaseMapper接口的大多数方法都接收一个Wrapper的实现类对象作为查询条件。
比如:
T selectOne(Wrapper<T> queryWrapper);
Integer selectCount(Wrapper<T> queryWrapper);
List<T> selectList(Wrapper<T> queryWrapper);
List<Map<String, Object>> selectMaps(Wrapper<T> queryWrapper);
List<Object> selectObjs(Wrapper<T> queryWrapper);
这个类实现了ISqlSegment接口,自己也定义了一些方法,这些方法在之后拼接动态SQL时会起到很大的作用:
public abstract class Wrapper<T> implements ISqlSegment {
public abstract T getEntity();
public String getSqlSelect();
public String getSqlSet();
public String getSqlComment();
public String getSqlFirst();
public abstract MergeSegments getExpression();
public String getCustomSqlSegment();
public boolean isEmptyOfWhere();
public boolean nonEmptyOfWhere();
public boolean isEmptyOfNormal();
public boolean nonEmptyOfNormal();
public boolean nonEmptyOfEntity();
private boolean fieldStrategyMatch(T entity, TableFieldInfo e);
public boolean isEmptyOfEntity();
public String getTargetSql();
abstract public void clear();
}
@FunctionalInterface
public interface ISqlSegment extends Serializable {
String getSqlSegment();
}
他有一个抽象子类:AbstractWrapper类。
AbstractWrapper抽象类
定义了核心的条件拼接方法,比如:
public Children eq(boolean condition, R column, Object val);
public Children ne(boolean condition, R column, Object val);
public Children gt(boolean condition, R column, Object val);
public Children ge(boolean condition, R column, Object val);
public Children lt(boolean condition, R column, Object val);
public Children le(boolean condition, R column, Object val);
public Children like(boolean condition, R column, Object val);
public Children notLike(boolean condition, R column, Object val);
public Children likeLeft(boolean condition, R column, Object val);
public Children likeRight(boolean condition, R column, Object val);
public Children between(boolean condition, R column, Object val1, Object val2);
public Children notBetween(boolean condition, R column, Object val1, Object val2);
public Children and(boolean condition, Consumer<Children> consumer);
public Children or(boolean condition, Consumer<Children> consumer);
public Children exists(boolean condition, String existsSql);
public Children notExists(boolean condition, String existsSql);
public Children isNull(boolean condition, R column);
public Children isNotNull(boolean condition, R column);
public Children in(boolean condition, R column, Collection<?> coll);
public Children notIn(boolean condition, R column, Collection<?> coll);
public Children groupBy(boolean condition, R... columns);
public Children orderBy(boolean condition, boolean isAsc, R... columns);
public Children having(boolean condition, String sqlHaving, Object... params);
方法的实现此处不做展开。
QueryWrapper类
这个类继承了AbstractWrapper抽象类,实现了Query接口,实现了Query中的方法:
public QueryWrapper<T> select(String... columns);
public QueryWrapper<T> select(Class entityClass, Predicate predicate);
public String getSqlSelect();
protected QueryWrapper<T> instance();
public void clear();
AbstractLambdaWrapper抽象类和LambdaQueryWrapper类
AbstractLambdaWrapper类重写了AbstractWrapper类的columnsToString方法,使用lambda转换列名。
此处的lambda解析还是很值得学习的,暂时不展开分析了。
实现原理
selectList的MappedStatement结构
首先看一下MappedStatement的结构,之后再做分析:
上面的四张图简单说明一下:
- 拼接select子句和from子句
- 拼接where查询条件,这里面包含两部分:一部分是使用entity作为查询条件,一部分是使用wrapper作为查询条件
在介绍完LambdaQueryWrapper之后我们在详细说明这里面的几个重要属性。
LambdaQueryWrapper.like方法
default Children like(R column, Object val) {
return like(true, column, val);
}
public Children like(boolean condition, R column, Object val) {
return likeValue(condition, LIKE, column, val, SqlLike.DEFAULT);
}
protected Children likeValue(
boolean condition, SqlKeyword keyword, R column, Object val, SqlLike sqlLike) {
return doIt(
condition,
() -> columnToString(column),
keyword,
() -> formatSql("{0}", SqlUtils.concatLike(val, sqlLike)));
}
protected Children doIt(boolean condition, ISqlSegment... sqlSegments) {
if (condition) {
expression.add(sqlSegments);
}
return typedThis;
}
MergeSegments类,从名字可以看出,这个类聚合了其他类型的ISqlSegment对象:
public class MergeSegments implements ISqlSegment {
private final NormalSegmentList normal = new NormalSegmentList();
private final GroupBySegmentList groupBy = new GroupBySegmentList();
private final HavingSegmentList having = new HavingSegmentList();
private final OrderBySegmentList orderBy = new OrderBySegmentList();
private String sqlSegment = StringPool.EMPTY;
private boolean cacheSqlSegment = true;
public void add(ISqlSegment... iSqlSegments) {
List<ISqlSegment> list = Arrays.asList(iSqlSegments);
ISqlSegment firstSqlSegment = list.get(0);
if (MatchSegment.ORDER_BY.match(firstSqlSegment)) {
orderBy.addAll(list);
} else if (MatchSegment.GROUP_BY.match(firstSqlSegment)) {
groupBy.addAll(list);
} else if (MatchSegment.HAVING.match(firstSqlSegment)) {
having.addAll(list);
} else {
normal.addAll(list);
}
cacheSqlSegment = false;
}
@Override
public String getSqlSegment() {
if (cacheSqlSegment) {
return sqlSegment;
}
cacheSqlSegment = true;
if (normal.isEmpty()) {
if (!groupBy.isEmpty() || !orderBy.isEmpty()) {
sqlSegment = groupBy.getSqlSegment() + having.getSqlSegment() + orderBy.getSqlSegment();
}
} else {
sqlSegment = normal.getSqlSegment() +
groupBy.getSqlSegment() + having.getSqlSegment() + orderBy.getSqlSegment();
}
return sqlSegment;
}
public void clear() {
}
}
LambdaQueryWrapper.eq方法
与like方法一样,不展开分析了。
SelectList类
public class SelectList extends AbstractMethod {
@Override
public MappedStatement injectMappedStatement(
Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
SqlMethod sqlMethod = SqlMethod.SELECT_LIST;
String sql = String.format(
sqlMethod.getSql(), sqlFirst(), sqlSelectColumns(tableInfo, true), tableInfo.getTableName(),
sqlWhereEntityWrapper(true, tableInfo), sqlComment());
SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
return this.addSelectMappedStatementForTable(mapperClass, getMethod(sqlMethod), sqlSource, tableInfo);
}
}
原始SQL:
<script>%s SELECT %s FROM %s %s %s\\n</script>
格式化后的动态SQL:
<script>
<choose>
<when test="ew != null and ew.sqlFirst != null">
${ew.sqlFirst}
</when>
<otherwise></otherwise>
</choose> SELECT
<!-- 拼接查询字段 -->
<choose>
<when test="ew != null and ew.sqlSelect != null">
${ew.sqlSelect}
</when>
<otherwise>id,title,content,create_time,update_time</otherwise>
</choose> FROM blog <!-- 拼接表名 -->
<!-- 拼接查询条件 -->
<if test="ew != null">
<where>
<!-- 拼接实体对象查询条件 -->
<if test="ew.entity != null">
<if test="ew.entity.id != null">id=#{ew.entity.id}</if>
<if test="ew.entity['title'] != null"> AND title=#{ew.entity.title}</if>
<if test="ew.entity['content'] != null"> AND content=#{ew.entity.content}</if>
<if test="ew.entity['createTime'] != null"> AND create_time=#{ew.entity.createTime}</if>
<if test="ew.entity['updateTime'] != null"> AND update_time=#{ew.entity.updateTime}</if>
</if>
<!-- 拼接Wrapper对象查询条件 -->
<if test="ew.sqlSegment != null and ew.sqlSegment != '' and ew.nonEmptyOfWhere">
<if test="ew.nonEmptyOfEntity and ew.nonEmptyOfNormal"> AND</if> ${ew.sqlSegment}
</if>
</where>
<!-- 拼接Wrapper对象查询条件,这里通常没有办法进来,存在疑问 -->
<if test="ew.sqlSegment != null and ew.sqlSegment != '' and ew.emptyOfWhere">
${ew.sqlSegment}
</if>
</if>
<choose>
<when test="ew != null and ew.sqlComment != null">
${ew.sqlComment}
</when>
<otherwise></otherwise>
</choose>
</script>
生成的SqlSource:
查询执行过程
之前分析过了,此处不再展开。
|