xml文件注释_MyBatis 源码解析:配置文件的加载与解析

598d37e36826f1f812b342bb23b5d895.png

上一篇我们曾约定 mybatis-config.xml 文件为配置文件,SQL 语句配置文件为映射文件,本文我们将沿用上一篇中的示例程序,一起探究一下 MyBatis 加载和解析配置文件(即 mybatis-config.xml)的过程。

配置文件的加载过程

在示例程序中,执行配置文件(包括后面要介绍的映射文件)加载与解析的过程位于第一行代码中(如下)。其中,Resources 是一个简单的基于类路径或其它位置获取数据流的工具类,借助该工具类可以获取配置文件的 InputStream 流对象,然后将其传递给 SqlSessionFactoryBuilder#build 方法以构造 SqlSessionFactory 对象。

SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder()
                .build(Resources.getResourceAsStream("mybatis-config.xml"));

SqlSessionFactoryBuilder 由名字可知它是一个构造器,用于构造 SqlSessionFactory 对象。按照 MyBatis 的官方文档来说,SqlSessionFactoryBuilder 一旦构造完 SqlSessionFactory 对象便完成了其使命。其实现也比较简单,只定义了 SqlSessionFactoryBuilder#build 这一个方法及其重载版本,如下:

public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
    try {
        // 创建 XML 配置文件解析器,期间会创建 Configuration 对象
        XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
        // 解析配置文件填充 Configuration 对象,并基于配置构造 SqlSessionFactory
        return this.build(parser.parse());
    } catch (Exception e) {
        throw ExceptionFactory.wrapException("Error building SqlSession.", e);
    } finally {
        // 执行关闭前的清理工作
        ErrorContext.instance().reset();
        try {
            inputStream.close();
        } catch (IOException e) {
            // Intentionally ignore. Prefer previous error.
        }
    }
}

public SqlSessionFactory build(Configuration config) {
    return new DefaultSqlSessionFactory(config);
}

上述实现的核心在于如下两行:

1. XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
2. return this.build(parser.parse());

第一行用来构造 XMLConfigBuilder 对象,XMLConfigBuilder 可以看作是 mybatis-config.xml 配置文件的解析器;第二行则调用该对象的 XMLConfigBuilder#parse 方法对配置文件进行解析,并记录相关配置项到 Configuration 对象中,然后基于该配置对象创建 SqlSessionFactory 对象返回。Configuration 可以看作是 MyBatis 框架内部全局唯一的配置类,用于记录几乎所有的配置和映射,以及运行过程中的中间值。后面我们会经常遇到这个类,现在可以将其理解为 MyBatis 框架的配置中心。

我们来看一下 XMLConfigBuilder 对象的构造过程:

public XMLConfigBuilder(InputStream inputStream, String environment, Properties props) {
    this(
        // 构造 XPath 解析器
        new XPathParser(inputStream, true, props, new XMLMapperEntityResolver()),
        environment,
        props);
}

private XMLConfigBuilder(XPathParser parser, // XPath 解析器
                         String environment, // 当前使用的配置文件组 ID
                         Properties props) // 参数指定的配置项
{
    // 构造 Configuration 对象
    super(new Configuration());
    ErrorContext.instance().resource("SQL Mapper Configuration");
    // 将参数指定的配置项记录到 Configuration#variables 属性中
    this.configuration.setVariables(props);
    // 标识配置文件还未被解析
    this.parsed = false;
    this.environment = environment;
    this.parser = parser;
}

构造方法各参数的释义见代码注释。这里针对一些比较不太直观的参数作进一步说明,首先看一下 XPathParser 类型的构造参数。我们需要知道的一点是,MyBatis 基于 DOM 树对 XML 配置文件进行解析,而操作 DOM 树的方式则是基于 XPath(XML Path Language)。它是一种能够极大简化 XML 操作的路径语言,优点在于简单、直观,并且好用,没有接触过的同学可以针对性的学习一下。XPathParser 基于 XPath 语法对 XML 进行解析,其实现比较简单,这里不展开说明。

接着看一下 environment 参数。基于配置的框架一般都允许配置多套环境,以应对开发、测试、灰度,以及生产环境。除了后面会讲到的 <environment/> 配置,MyBatis 也允许我们通过参数指定实际生效的配置环境,我们在调用 SqlSessionFactoryBuilder#build 方法时,可以以参数形式指定当前使用的配置环境。

配置文件的解析过程

完成了 XMLConfigBuilder 对象的构造,下一步会调用其 XMLConfigBuilder#parse 方法执行对配置文件的解析操作。在具体分析配置文件的解析过程之前,先简单介绍一下后续过程依赖的一些基础组件。

上面用到的 XMLConfigBuilder 类派生自 BaseBuilder 抽象类,包括后面会介绍的 XMLMapperBuilder、XMLStatementBuilder,以及 SqlSourceBuilder 等都继承自该抽象类。先来看一下 BaseBuilder 的字段定义:

/** 全局唯一的配置对象 */
protected final Configuration configuration;
/** 记录别名与类型的映射关系 */
protected final TypeAliasRegistry typeAliasRegistry;
/** 记录类型对应的类型处理器 */
protected final TypeHandlerRegistry typeHandlerRegistry;

BaseBuilder 仅定义了三个属性,各属性的作用见代码注释。XMLConfigBuilder 构造方法调用了父类 BaseBuilder 的构造方法以实现对这三个属性的初始化,前面我们提及到的封装全局配置的 Configuration 对象就记录在这里。接下来分析一下属性 BaseBuilder#typeAliasRegistry 和 BaseBuilder#typeHandlerRegistry 分别对应的 TypeAliasRegistry 类和 TypeHandlerRegistry 类的功能和实现。

TypeAliasRegistry

我们都知道在编写 SQL 语句时可以为表名或列名定义别名(alias),以减少书写量,而 TypeAliasRegistry 是对别名这一机制的延伸,借助于此,我们可以为任意类型定义别名。

TypeAliasRegistry 中仅定义了一个 Map 类型的属性 TypeAliasRegistry#typeAliases 充当内存数据库,记录着别名与具体类型之间的映射关系。TypeAliasRegistry 持有一个无参数的构造方法,其中只做一件事,即调用 TypeAliasRegistry#registerAlias 方法为常用类型注册对应的别名。该方法的实现如下:

public void registerAlias(String alias, Class<?> value) {
    if (alias == null) {
        throw new TypeException("The parameter alias cannot be null");
    }
    // 将别名转换成小写
    String key = alias.toLowerCase(Locale.ENGLISH);
    // 防止重复注册
    if (typeAliases.containsKey(key) && typeAliases.get(key) != null && !typeAliases.get(key).equals(value)) {
        throw new TypeException("The alias '" + alias + "' is already mapped to the value '" + typeAliases.get(key).getName() + "'.");
    }
    // 建立映射关系记录到 Map 中
    typeAliases.put(key, value);
}

整个方法的执行过程本质上就是将 (alias, value) 键值对写入 Map 集合中,只是在插入之前需要保证 alias 不为 null,且不允许相同的别名和类型重复注册。除了这里的单个注册,TypeAliasRegistry 还提供了 TypeAliasRegistry#registerAliases 方法,允许扫描注册指定 package 下面的所有类或指定类型及其子类型。在批量扫描注册时,我们可以利用 @Alias 注解为类指定别名,否则 MyBatis 将会以当前类的 simple name 作为类型别名。

当然,能够注册就能够获取,方法 TypeAliasRegistry#resolveAlias 提供了获取指定别名对应类型的能力。实现比较简单,无非就是从 Map 集合中获取指定 key 对应的 value。

TypeHandlerRegistry

再来看一下 TypeHandlerRegistry 类,在开始分析之前我们必须对 TypeHandler 接口有一个了解。我们都知道 JDBC 定义的类型(枚举类 JdbcType 对已有 JDBC 类型进行了封装)与 java 定义的类型并不是完全匹配的,所以就需要在这中间执行一些转换操作,而 TypeHandler 的职责就在于此。TypeHandler 是一个接口,其中定义了 4 个方法:

public interface TypeHandler<T> {

    /** 为 {@link PreparedStatement} 对象绑定参数(将数据由 java 类型转换成 JDBC 类型) */
    void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException;

    /** 获取结果集中对应的参数值(将数据由 JDBC 类型转换成 java 类型) */
    T getResult(ResultSet rs, String columnName) throws SQLException;
    T getResult(ResultSet rs, int columnIndex) throws SQLException;

    /** 获取存储过程中输出类型的参数值(将数据由 JDBC 类型转换成 java 类型) */
    T getResult(CallableStatement cs, int columnIndex) throws SQLException;

}

围绕 TypeHandler 接口的实现类用于处理特定类型,具体可以参考 官方文档。

对 TypeHandler 有一个基本认识之后,继续来看 TypeHandlerRegistry。顾名思义,这是一个 TypeHandler 的注册中心。TypeHandlerRegistry 中定义了多个 final 类型 Map 类型属性,以记录类型及其类型处理器 TypeHandler 之间的映射关系,其中最核心的两个属性定义如下:

/**
 * 记录 JDBC 类型与 {@link TypeHandler} 之间映射关系,
 * 用于从结果集读取数据时,将 JDBC 类型转换对应的 java 类型
 */
private final Map<JdbcType, TypeHandler<?>> jdbcTypeHandlerMap = new EnumMap<>(JdbcType.class);

/**
 * 记录 java 类型转 JDBC 类型时所需要的 {@link TypeHandler},
 * 一个 java 类型可能存在多个 JDBC 类型
 */
private final Map<Type, Map<JdbcType, TypeHandler<?>>> typeHandlerMap = new ConcurrentHashMap<>();

在构造 TypeHandlerRegistry 对象时,会调用TypeHandlerRegistry#register方法注册类型及其对应的类型处理器,实现如下:

private void register(Type javaType, JdbcType jdbcType, TypeHandler<?> handler) {
    // 如果 javaType 不为空,则添加对应的类型处理器到 typeHandlerMap 集合中
    if (javaType != null) {
        Map<JdbcType, TypeHandler<?>> map = typeHandlerMap.get(javaType);
        if (map == null || map == NULL_TYPE_HANDLER_MAP) {
            map = new HashMap<>();
        }
        map.put(jdbcType, handler);
        typeHandlerMap.put(javaType, map);
    }
    // 记录所有的 TypeHandler 对象
    allTypeHandlersMap.put(handler.getClass(), handler);
}

上述方法的核心逻辑在于往 TypeHandlerRegistry#typeHandlerMap 属性中注册 java 类型及其类型处理器。MyBatis 基于该方法封装了多层重载版本,其中大部分实现都比较简单,下面就基于注解 @MappedJdbcTypes 和注解 @MappedTypes 指定对应类型的版本进一步说明。

注解 @MappedJdbcTypes 用于指定类型处理器 TypeHandler 关联的 JDBC 类型列表,对应的解析实现如下:

private <T> void register(Type javaType, TypeHandler<? extends T> typeHandler) {
    // 获取 MappedJdbcTypes 注解配置
    MappedJdbcTypes mappedJdbcTypes = typeHandler.getClass().getAnnotation(MappedJdbcTypes.class);
    if (mappedJdbcTypes != null) {
        // 一个 TypeHandler 可以关联多个 JDBC 类型,遍历逐一注册
        for (JdbcType handledJdbcType : mappedJdbcTypes.value()) {
            this.register(javaType, handledJdbcType, typeHandler);
        }
        // 允许处理 null 值
        if (mappedJdbcTypes.includeNullJdbcType()) {
            this.register(javaType, null, typeHandler);
        }
    } else {
        this.register(javaType, null, typeHandler);
    }
}

上述方法首先获取注解@MappedJdbcTypes配置的 JDBC 类型列表,然后遍历挨个注册。注解@MappedJdbcTypes定义如下:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface MappedJdbcTypes {

    /** 当前类型处理器能够处理的 JDBC 类型列表 */
    JdbcType[] value();

    /** 是否允许处理 null 值 */
    boolean includeNullJdbcType() default false;
}

该注解还允许通过 MappedJdbcTypes#includeNullJdbcType 属性指定是否允许当前类型处理器处理 null 值。

能够指定 JDBC 类型,当然也就能够指定 JAVA 类型。注解 @MappedTypes 用于指定与类型处理器 TypeHandler 关联的 java 类型,对应的解析实现如下:

public <T> void register(TypeHandler<T> typeHandler) {
    boolean mappedTypeFound = false;
    // 获取 MappedTypes 注解配置
    MappedTypes mappedTypes = typeHandler.getClass().getAnnotation(MappedTypes.class);
    if (mappedTypes != null) {
        // 一个 TypeHandler 可以关联多个 java 类型,遍历逐一注册
        for (Class<?> handledType : mappedTypes.value()) {
            this.register(handledType, typeHandler);
            mappedTypeFound = true;
        }
    }
    // 尝试基于 typeHandler 自动发现对应的 java 类型,需要实现 TypeReference 接口(@since 3.1.0)
    if (!mappedTypeFound && typeHandler instanceof TypeReference) {
        try {
            TypeReference<T> typeReference = (TypeReference<T>) typeHandler;
            this.register(typeReference.getRawType(), typeHandler);
            mappedTypeFound = true;
        } catch (Throwable t) {
            // maybe users define the TypeReference with a different type and are not assignable, so just ignore it
        }
    }
    if (!mappedTypeFound) {
        this.register((Class<T>) null, typeHandler);
    }
}

上述方法首先获取 @MappedTypes 注解配置,并针对关联的 java 类型逐一注册。如果未指定 @MappedTypes 注解配置,则 MyBatis 会尝试自动发现并注册 TypeHandler 能够处理的 java 类型。

能够注册也就能够获取,TypeHandlerRegistry 中提供了 TypeHandlerRegistry#getTypeHandler 方法的多种重载实现,比较简单,不再展开。

回过头再来看一下 BaseBuilder 抽象类的实现,其中定义了许多方法,但是只要了解上面介绍的 TypeAliasRegistry 和 TypeHandlerRegistry 类,那么这些方法的作用在理解上应该非常容易,这里就不多做撰述,有兴趣的同学可以参考上面的分析去阅读一下源码。

下面正式进入主题,回到 XMLConfigBuilder#parse 方法分析配置文件的解析过程,实现如下:

public Configuration parse() {
    // 配置文件已经被解析过,避免重复解析
    if (parsed) {
        throw new BuilderException("Each XMLConfigBuilder can only be used once.");
    }
    parsed = true;
    // 解析 mybatis-config.xml 中的各项配置,填充 Configuration 对象
    this.parseConfiguration(parser.evalNode("/configuration"));
    return configuration;
}

配置文件 mybatis-config.xml 以 <configuration/>标签作为配置文件根节点,上述方法的核心在于触发调用 XMLConfigBuilder#parseConfiguration 方法对配置文件的各个元素进行解析,并封装解析结果到 Configuration 对象中,最终返回该配置对象。方法实现如下:

private void parseConfiguration(XNode root) {
    try {
        // 解析 <properties/> 配置
        this.propertiesElement(root.evalNode("properties"));
        // 解析 <settings/> 配置
        Properties settings = this.settingsAsProperties(root.evalNode("settings"));
        // 获取并设置 vfsImpl 属性
        this.loadCustomVfs(settings);
        // 获取并设置 logImpl 属性
        this.loadCustomLogImpl(settings);
        // 解析 <typeAliases/> 配置
        this.typeAliasesElement(root.evalNode("typeAliases"));
        // 解析 <plugins/> 配置
        this.pluginElement(root.evalNode("plugins"));
        // 解析 <objectFactory/> 配置
        this.objectFactoryElement(root.evalNode("objectFactory"));
        // 解析 <objectWrapperFactory/> 配置
        this.objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
        // 解析 <reflectorFactory/> 配置
        this.reflectorFactoryElement(root.evalNode("reflectorFactory"));
        // 将 settings 配置设置到 Configuration 对象中
        this.settingsElement(settings);
        // 解析 <environments/> 配置
        this.environmentsElement(root.evalNode("environments"));
        // 解析 <databaseIdProvider/> 配置
        this.databaseIdProviderElement(root.evalNode("databaseIdProvider"));
        // 解析 <typeHandlers/> 配置
        this.typeHandlerElement(root.evalNode("typeHandlers"));
        // 解析 <mappers/> 配置
        this.mapperElement(root.evalNode("mappers"));
    } catch (Exception e) {
        throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
    }
}

上述方法在实现上比较直观,各配置项的解析都采用专门的方法进行封装,接下来会逐一进行分析。其中 <plugins/> 标签用于配置自定义插件,以拦截 SQL 语句的执行过程,相应的解析过程暂时先不展开,留到后面专门介绍插件的实现机制的文章中一并分析。

解析 properties 标签

先来看一下 <properties/> 标签怎么玩,其中的配置项可以在整个配置文件中用来动态替换占位符。配置项可以从外部 properties 文件读取,也可以通过 <property/> 子标签指定。假设我们希望通过该标签指定数据源配置,如下:

<properties resource="datasource.properties">
    <property name="driver" value="com.mysql.jdbc.Driver"/>
    <!--为占位符启用默认值配置,默认关闭,需要采用如下方式开启-->
    <property name="org.apache.ibatis.parsing.PropertyParser.enable-default-value" value="true"/>
</properties>

文件datasource.properties内容:

url=jdbc:mysql://localhost:3306/test
username=root
password=123456

然后可以基于 OGNL 表达式在其它配置项中引用这些配置值,如下:

<dataSource type="POOLED"> <!--or UNPOOLED or JNDI-->
    <property name="driver" value="${driver}"/>
    <property name="url" value="${url}"/>
    <property name="username" value="${username:zhenchao}"/> <!--占位符设置默认值,需要专门开启-->
    <property name="password" value="${password}"/>
</dataSource>

其中,除了 driver 属性值来自 <property/> 子标签,其余属性值均是从 datasource.properties 配置文件中获取的。

MyBatis 针对配置的读取顺序约定如下:

1、‘在 <properties/> 标签体内指定的属性首先被读取;

2、然后,根据 <properties/> 标签中 resource 属性读取类路径下配置文件,或根据 url 属性指定的路径读取指向的配置文件,并覆盖已读取的同名配置项;

3、最后,读取方法参数传递的配置项,并覆盖已读取的同名配置项。

下面分析一下 <properties/> 标签的解析过程,由 XMLConfigBuilder#propertiesElement 方法实现:

private void propertiesElement(XNode context) throws Exception {
    if (context != null) {
        // 获取 <property/> 子标签列表,封装成 Properties 对象
        Properties defaults = context.getChildrenAsProperties();
        // 支持通过 resource 或 url 属性指定外部配置文件
        String resource = context.getStringAttribute("resource");
        String url = context.getStringAttribute("url");
        // 这两种类型的配置是互斥的
        if (resource != null && url != null) {
            throw new BuilderException("The properties element cannot specify both a URL " +
                "and a resource based property file reference.  Please specify one or the other.");
        }
        // 从类路径加载配置文件
        if (resource != null) {
            defaults.putAll(Resources.getResourceAsProperties(resource));
        }
        // 从 url 指定位置加载配置文件
        else if (url != null) {
            defaults.putAll(Resources.getUrlAsProperties(url));
        }
        // 合并已有的配置项
        Properties vars = configuration.getVariables();
        if (vars != null) {
            defaults.putAll(vars);
        }
        // 填充 XPathParser 和 Configuration 对象
        parser.setVariables(defaults);
        configuration.setVariables(defaults);
    }
}

由 MyBatis 的官方文档可知,标签 <properties/> 支持以 resource 属性或 url 属性指定配置文件所在的路径,由上述实现也可以看出这两个属性配置是互斥的。在将对应的配置加载成为 Properties 对象之后,上述方法会合并 Configuration 对象中已有的配置项,并将结果再次填充到 XPathParser 和 Configuration 对象中,以备后用。

解析 settings 标签

MyBatis 通过<settings/>标签提供一些全局性的配置,这些配置会影响 MyBatis 的运行行为。官方文档 对这些配置项进行了详细的说明,下面的配置摘自官方文档,其中各项的含义可以参考文档说明:

<settings>
    <setting name="cacheEnabled" value="true"/>
    <setting name="lazyLoadingEnabled" value="true"/>
    <setting name="multipleResultSetsEnabled" value="true"/>
    <setting name="useColumnLabel" value="true"/>
    <setting name="useGeneratedKeys" value="false"/>
    <setting name="autoMappingBehavior" value="PARTIAL"/>
    <setting name="autoMappingUnknownColumnBehavior" value="WARNING"/>
    <setting name="defaultExecutorType" value="SIMPLE"/>
    <setting name="defaultStatementTimeout" value="25"/>
    <setting name="defaultFetchSize" value="100"/>
    <setting name="safeRowBoundsEnabled" value="false"/>
    <setting name="mapUnderscoreToCamelCase" value="false"/>
    <setting name="localCacheScope" value="SESSION"/>
    <setting name="jdbcTypeForNull" value="OTHER"/>
    <setting name="lazyLoadTriggerMethods" value="equals,clone,hashCode,toString"/>
</settings>

MyBatis 对于该标签的解析实现十分简单,首先调用XMLConfigBuilder#settingsAsProperties方法获取配置项对应的 Properties 对象,同时会检查配置项是否是可识别的,实现如下:

private Properties settingsAsProperties(XNode context) {
    if (context == null) {
        return new Properties();
    }
    // 解析 <setting/> 配置,封装成 Properties 对象
    Properties props = context.getChildrenAsProperties();
    // 构造 Configuration 对应的 MetaClass 对象,用于对 Configuration 类提供反射操作
    MetaClass metaConfig = MetaClass.forClass(Configuration.class, localReflectorFactory);
    // 遍历配置项,确保配置项是 MyBatis 可识别的
    for (Object key : props.keySet()) {
        // 属性对应的 setter 方法不存在
        if (!metaConfig.hasSetter(String.valueOf(key))) {
            throw new BuilderException(
                "The setting " + key + " is not known.  Make sure you spelled it correctly (case sensitive).");
        }
    }
    return props;
}

接下来调用 XMLConfigBuilder#loadCustomVfs 方法和 XMLConfigBuilder#loadCustomLogImpl 方法分别解析 vfsImpl 和 logImpl 配置项,其中 vfsImpl 配置项用于设置自定义 VFS 的实现类全限定名,以逗号分隔。所有的 <settings/> 配置项最后都会通过 XMLConfigBuilder#settingsElement 方法记录到 Configuration 对象对应的属性中。

解析 typeAliases 和 typeHandlers 标签

前面介绍了 TypeAliasRegistry 和 TypeHandlerRegistry 两个类的功能和实现,本小节介绍的这两个标签分别对应这两个类的相关配置,前者用于配置类型及其别名的映射关系,后者用于配置类型及其类型处理器 TypeHandler 之间的映射关系。二者在实现上基本相同,这里以 <typeAliases/> 标签的解析过程为例进行分析(由 XMLConfigBuilder#typeAliasesElement 方法实现),有兴趣的读者可以自己阅读 <typeHandlers/> 标签的相关实现。

private void typeAliasesElement(XNode parent) {
    if (parent != null) {
        for (XNode child : parent.getChildren()) {
            // 子标签是 <package name=""/> 配置
            if ("package".equals(child.getName())) {
                /*
                 * 如果指定了一个包名,MyBatis 会在包名下搜索需要的 Java Bean,并处理 @Alias 注解,
                 * 在没有注解的情况下,会使用 Bean 的首字母小写的简单名称作为它的别名。
                 */
                String typeAliasPackage = child.getStringAttribute("name");
                configuration.getTypeAliasRegistry().registerAliases(typeAliasPackage);
            }
            // 子标签是 <typeAlias alias="" type=""/> 配置
            else {
                String alias = child.getStringAttribute("alias"); // 别名
                String type = child.getStringAttribute("type"); // 类型限定名
                try {
                    // 获取类型对应的 Class 对象
                    Class<?> clazz = Resources.classForName(type);
                    // 未配置 alias,先尝试获取 @Alias 注解,如果没有则使用类的简单名称
                    if (alias == null) {
                        typeAliasRegistry.registerAlias(clazz);
                    }
                    // 配置了 alias,使用该 alias 进行注册
                    else {
                        typeAliasRegistry.registerAlias(alias, clazz);
                    }
                } catch (ClassNotFoundException e) {
                    throw new BuilderException("Error registering typeAlias for '" + alias + "'. Cause: " + e, e);
                }
            }
        }
    }
}

标签<typeAliases/>具备两种配置方式,单一注册与批量扫描,具体使用可以参考 官方文档。对应的实现也需要区分这两种情况,如果是批量扫描,即子标签是<package/>,则会调用TypeAliasRegistry#registerAliases方法进行扫描注册:

public void registerAliases(String packageName) {
    this.registerAliases(packageName, Object.class);
}

public void registerAliases(String packageName, Class<?> superType) {
    // 获取指定 package 下所有 superType 类型及其子类型
    ResolverUtil<Class<?>> resolverUtil = new ResolverUtil<>();
    resolverUtil.find(new ResolverUtil.IsA(superType), packageName);
    Set<Class<? extends Class<?>>> typeSet = resolverUtil.getClasses();
    // 遍历处理扫描到的类型
    for (Class<?> type : typeSet) {
        // 忽略内部类、接口,以及抽象类
        if (!type.isAnonymousClass() && !type.isInterface() && !type.isMemberClass()) {
            // 尝试获取类的 @Alias 注解,如果没有则使用类的简单名称的小写形式作为别名进行注册
            this.registerAlias(type);
        }
    }
}

如果子标签是<typeAlias alias="" type=""/>这种配置形式,则会获取 alias 和 type 属性值,然后基于一定规则进行注册,具体过程如代码注释。

解析 objectFactory 标签

在具体分析 <objectFactory/> 标签的解析实现之前,我们必须先了解与之密切相关的 ObjectFactory 接口。由名字我们可以猜测这是一个工厂类,并且是创建对象的工厂,定义如下:

public interface ObjectFactory {
    /** 设置配置信息 */
    default void setProperties(Properties properties) { }
    /** 基于无参构造方法创建指定类型对象 */
    <T> T create(Class<T> type);
    /** 基于指定的构造参数(类型)选择对应的构造方法创建目标对象 */
    <T> T create(Class<T> type, List<Class<?>> constructorArgTypes, List<Object> constructorArgs);
    /** 检测指定类型是否是集合类型 */
    <T> boolean isCollection(Class<T> type);
}

各方法的作用如代码注释,DefaultObjectFactory 类是该接口的默认实现。下面重点看一下基于指定构造参数(类型)选择对应的构造方法创建目标对象的实现细节:

public <T> T create(Class<T> type, List<Class<?>> constructorArgTypes, List<Object> constructorArgs) {
    // 如果传入的是接口类型,则选择具体的实现类型以创建对象,毕竟接口类型不能被实例化
    Class<?> classToCreate = this.resolveInterface(type);
    // 基于入参选择合适的构造方法进行实例化
    return (T) this.instantiateClass(classToCreate, constructorArgTypes, constructorArgs);
}

方法首先会判断当前指定的类型是否是接口类型,因为接口类型无法实例化,所以需要选择相应的实现类代替。例如当我们传递的是一个 List 接口类型会返回相应的 ArrayList 实现类型。再来看一下 DefaultObjectFactory#instantiateClass 方法的实现:

private <T> T instantiateClass(Class<T> type, List<Class<?>> constructorArgTypes, List<Object> constructorArgs) {
    try {
        Constructor<T> constructor;
        // 如果没有传递构造参数或类型,则使用无参构造方法创建对象
        if (constructorArgTypes == null || constructorArgs == null) {
            constructor = type.getDeclaredConstructor();
            try {
                return constructor.newInstance();
            } catch (IllegalAccessException e) {
                if (Reflector.canControlMemberAccessible()) {
                    constructor.setAccessible(true);
                    return constructor.newInstance();
                } else {
                    throw e;
                }
            }
        }
        // 否则选择对应的构造方法创建对象
        constructor = type.getDeclaredConstructor(constructorArgTypes.toArray(new Class[0]));
        try {
            return constructor.newInstance(constructorArgs.toArray(new Object[0]));
        } catch (IllegalAccessException e) {
            if (Reflector.canControlMemberAccessible()) {
                constructor.setAccessible(true);
                return constructor.newInstance(constructorArgs.toArray(new Object[0]));
            } else {
                throw e;
            }
        }
    } catch (Exception e) {
        // ... 异常处理略
    }
}

上述方法主要基于传递的参数以决策具体创建对象的构造方法版本,并基于反射机制创建对象。

所以说 ObjectFactory 接口的作用主要是对我们传递的类型进行实例化,默认的实现版本比较简单。如果默认实现不能满足需求,则可以扩展 ObjectFactory 接口,并将相应的自定义实现通过 <objectFactory/> 标签进行注册,具体的使用方式参见 官方文档。我们继续分析针对该标签的解析过程,由 XMLConfigBuilder#objectFactoryElement 方法实现:

private void objectFactoryElement(XNode context) throws Exception {
    if (context != null) {
        // 获取 type 属性配置,对应自定义对象工厂类
        String type = context.getStringAttribute("type");
        // 获取 <property/> 子标签列表,封装成 Properties 对象
        Properties properties = context.getChildrenAsProperties();
        // 实例化自定义工厂类对象
        ObjectFactory factory = (ObjectFactory) this.resolveClass(type).getDeclaredConstructor().newInstance();
        // 设置属性配置
        factory.setProperties(properties);
        // 填充 Configuration 对象
        configuration.setObjectFactory(factory);
    }
}

解析 <objectFactory/> 标签的基本流程就是获取我们在标签中通过 type 属性指定的自定义 ObjectFactory 实现类的全限定名和相应属性配置;然后构造自定义 ObjectFactory 实现类对象,并将获取到的配置项列表记录到对象中;最后将自定义 ObjectFactory 对象填充到 Configuration 对象中。

解析 reflectorFactory 标签

标签 <reflectorFactory/> 用于注册自定义 ReflectorFactory 实现,该标签的解析过程与 <objectFactory/> 标签基本相同,不再重复撰述,本小节重点分析一下该标签涉及到相关类的功能与实现。

ReflectorFactory 顾名思义是一个 Reflector 工厂,接口定义如下:

public interface ReflectorFactory {
    /** 是否缓存 {@link Reflector} 对象 */
    boolean isClassCacheEnabled();
    /** 设置是否缓存 {@link Reflector} 对象 */
    void setClassCacheEnabled(boolean classCacheEnabled);
    /** 获取指定类型的 {@link Reflector} 对象 */
    Reflector findForClass(Class<?> type);
}

默认实现类 DefaultReflectorFactory 通过一个 boolean 变量 DefaultReflectorFactory#classCacheEnabled 记录是否启用缓存,并通过一个线程安全的 Map 集合 DefaultReflectorFactory#reflectorMap 记录缓存的 Reflector 对象,相应的方法实现都十分简单,不再展开。

ReflectorFactory 本质上是用来创建和管理 Reflector 对象,那么 Reflector 又是什么呢?我们先来看一下 Reflector 的属性和构造方法定义:

public class Reflector {

    /** 隶属的 Class 类型 */
    private final Class<?> type;
    /** 可读属性名称集合 */
    private final String[] readablePropertyNames;
    /** 可写属性名称集合 */
    private final String[] writablePropertyNames;
    /** 属性对应的 setter 方法(封装成 Invoker 对象) */
    private final Map<String, Invoker> setMethods = new HashMap<String, Invoker>();
    /** 属性对应的 getter 方法(封装成 Invoker 对象) */
    private final Map<String, Invoker> getMethods = new HashMap<String, Invoker>();
    /** 属性对应 setter 方法的入参类型 */
    private final Map<String, Class<?>> setTypes = new HashMap<String, Class<?>>();
    /** 属性对应 getter 方法的返回类型 */
    private final Map<String, Class<?>> getTypes = new HashMap<String, Class<?>>();
    /** 记录默认构造方法 */
    private Constructor<?> defaultConstructor;
    /** 记录所有的属性名称 */
    private Map<String, String> caseInsensitivePropertyMap = new HashMap<String, String>();

    public Reflector(Class<?> clazz) {
        type = clazz;
        // 解析获取默认构造方法(无参构造方法)
        this.addDefaultConstructor(clazz);
        // 解析获取所有的 getter 方法,并记录到 getMethods 与 getTypes 属性中
        this.addGetMethods(clazz);
        // 解析获取所有的 setter 方法,并记录到 setMethods 与 setTypes 属性中
        this.addSetMethods(clazz);
        // 解析获取所有没有 setter/getter 方法的字段,并添加到相应的集合中
        this.addFields(clazz);
        // 填充可读属性名称数组
        readablePropertyNames = getMethods.keySet().toArray(new String[getMethods.keySet().size()]);
        // 填充可写属性名称数组
        writablePropertyNames = setMethods.keySet().toArray(new String[setMethods.keySet().size()]);
        // 记录所有属性名称到 Map 集合中
        for (String propName : readablePropertyNames) {
            caseInsensitivePropertyMap.put(propName.toUpperCase(Locale.ENGLISH), propName);
        }
        for (String propName : writablePropertyNames) {
            caseInsensitivePropertyMap.put(propName.toUpperCase(Locale.ENGLISH), propName);
        }
    }
    // ... 省略方法实现
}

可以看到 Reflector 是对指定 Class 对象的封装,记录了对应的 Class 类型、属性、getter 和 setter 方法列表等信息,是反射操作的基础,其中的方法实现虽然较长,但是逻辑都比较简单,读者可以自行阅读源码。

解析 objectWrapperFactory 标签

标签<objectWrapperFactory/>用于注册自定义 ObjectWrapperFactory 实现,该标签的解析过程与<objectFactory/>标签基本相同,同样不再重复撰述,本小节重点分析该标签涉及到相关类的功能与实现。

ObjectWrapperFactory 顾名思义是一个 ObjectWrapper 工厂,其默认实现 DefaultObjectWrapperFactory 并没有实现有用的逻辑,所以可以忽略。然而,借助<reflectorFactory/>标签,我们可以注册自定义的 ObjectWrapperFactory 实现。

被 ObjectWrapperFactory 创建和管理的 ObjectWrapper 是一个接口,用于包装和处理对象,其中声明了多个操作对象的方法,包括获取、更新对象属性等,接口定义如下:

public interface ObjectWrapper {
    /** 获取对应属性的值(对于集合而言,则是获取对应下标的值) */
    Object get(PropertyTokenizer prop);
    /** 设置对应属性的值(对于集合而言,则是设置对应下标的值)*/
    void set(PropertyTokenizer prop, Object value);
    /** 查找属性表达式对应的属性 */
    String findProperty(String name, boolean useCamelCaseMapping);
    /** 获取可读属性名称集合 */
    String[] getGetterNames();
    /** 获取可写属性名称集合 */
    String[] getSetterNames();
    /** 获取属性表达式指定属性 setter 方法的入参类型 */
    Class<?> getSetterType(String name);
    /** 获取属性表达式指定属性 getter 方法的返回类型 */
    Class<?> getGetterType(String name);
    /** 判断属性是否有 setter 方法 */
    boolean hasSetter(String name);
    /** 判断属性是否有 getter 方法 */
    boolean hasGetter(String name);
    /** 为属性表达式指定的属性创建对应的 {@link MetaObject} 对象 */
    MetaObject instantiatePropertyValue(String name, PropertyTokenizer prop, ObjectFactory objectFactory);
    /** 是否是 {@link java.util.Collection} 类型 */
    boolean isCollection();
    /** 调用 {@link java.util.Collection} 对应的 add 方法 */
    void add(Object element);
    /** 调用 {@link java.util.Collection} 对应的 addAll 方法 */
    <E> void addAll(List<E> element);
}

由接口定义可以看出,ObjectWrapper 的主要作用在于简化调用方对于对象的操作。

解析 environments 标签

标签 <environments/> 用于配置多套数据库环境,典型的应用场景就是在开发、测试、灰度,以及生产等环境通过该标签分别指定相应的配置。当应用需要同时操作多套数据源时,也可以基于该标签分别配置,具体的使用请参阅 官方文档。MyBatis 解析该标签的过程由 XMLConfigBuilder#environmentsElement 方法实现:

private void environmentsElement(XNode context) throws Exception {
    if (context != null) {
        // 未通过参数指定生效的 environment 配置,获取 default 属性值
        if (environment == null) {
            environment = context.getStringAttribute("default");
        }
        // 遍历处理 <environment/> 子标签
        for (XNode child : context.getChildren()) {
            // 获取 id 属性配置
            String id = child.getStringAttribute("id");
            // 处理指定生效的 <environment/> 配置
            if (this.isSpecifiedEnvironment(id)) {
                // 处理 <transactionManager/> 子标签
                TransactionFactory txFactory = this.transactionManagerElement(child.evalNode("transactionManager"));
                // 处理 <dataSource/> 子标签
                DataSourceFactory dsFactory = this.dataSourceElement(child.evalNode("dataSource"));
                // 基于解析到的值构造 Environment 对象填充 Configuration 对象
                DataSource dataSource = dsFactory.getDataSource();
                Environment.Builder environmentBuilder = new Environment.Builder(id)
                    .transactionFactory(txFactory)
                    .dataSource(dataSource);
                configuration.setEnvironment(environmentBuilder.build());
            }
        }
    }
}

上述方法首先会判断是否通过参数指定了 environment 配置,如果没有则尝试获取 <environments/> 标签的 default 属性,说明参数指定相对于 default 属性配置优先级更高。然后开始遍历寻找并解析指定激活的 <environment/> 配置。整个解析过程主要是对 <transactionManager/> 和 <dataSource/> 两个子标签进行解析,前者用于指定 MyBatis 的事务管理器,后者用于配置数据源。

数据源的配置解析比较直观,下面主要看一下事务管理器配置的解析过程,由 XMLConfigBuilder#transactionManagerElement 方法实现:

private TransactionFactory transactionManagerElement(XNode context) throws Exception {
    if (context != null) {
        // 获取事务管理器类型配置:JDBC or MANAGED
        String type = context.getStringAttribute("type");
        // 获取 <property/> 子标签列表,封装成 Properties 对象
        Properties props = context.getChildrenAsProperties();
        // 构造对应的 TransactionFactory 对象,并填充属性值
        TransactionFactory factory = (TransactionFactory) this.resolveClass(type).getDeclaredConstructor().newInstance();
        factory.setProperties(props);
        return factory;
    }
    throw new BuilderException("Environment declaration requires a TransactionFactory.");
}

MyBatis 允许我们配置两种类型的事务管理器,即 JDBC 类型和 MANAGED 类型,引用官方文档的话来理解二者的区别:

在 MyBatis 中有两种类型的事务管理器(也就是 type="[JDBC|MANAGED]"):

JDBC:这个配置直接使用了 JDBC 的提交和回滚设施,它依赖从数据源获得的连接来管理事务作用域。

MANAGED:这个配置几乎没做什么。它从不提交或回滚一个连接,而是让容器来管理事务的整个生命周期(比如 JEE 应用服务器的上下文)。 默认情况下它会关闭连接。然而一些容器并不希望连接被关闭,因此需要将 closeConnection 属性设置为 false 来阻止默认的关闭行为。例如:

<transactionManager type="MANAGED">
    <property name="closeConnection" value="false"/>
</transactionManager>

提示:如果你正在使用 Spring + MyBatis,则没有必要配置事务管理器,因为 Spring 模块会使用自带的管理器来覆盖前面的配置。

Transaction 接口定义了事务,并为 JDBC 类型和 MANAGED 类型提供了相应的实现,即 JdbcTransaction 和 ManagedTransaction。正如上面引用的官方文档所说的那样,MyBatis 的事务操作实现的比较简单,考虑实际应用中更多是依赖于 Spring 的事务管理器,这里也就不再深究。

解析 databaseIdProvider 标签

生产环境中可能会存在同时操作多套不同类型数据库的场景,而 <databaseIdProvider/> 标签则用于配置数据库厂商标识。我们知道 SQL 不能完全做到数据库无关,且 MyBatis 暂时也还不能做到对上层完全屏蔽底层数据库的实现细节,所以在这种情况下执行 SQL 语句时,我们需要通过 databaseId 指定 SQL 应用的具体数据库类型。

该标签的解析过程由 XMLConfigBuilder#databaseIdProviderElement 方法实现,如下:

private void databaseIdProviderElement(XNode context) throws Exception {
    DatabaseIdProvider databaseIdProvider = null;
    if (context != null) {
        String type = context.getStringAttribute("type");
        // awful patch to keep backward compatibility
        if ("VENDOR".equals(type)) {
            type = "DB_VENDOR"; // 保持兼容
        }
        // 获取 <property/> 子节点配置
        Properties properties = context.getChildrenAsProperties();
        // 构造 DatabaseIdProvider 对象
        databaseIdProvider = (DatabaseIdProvider) resolveClass(type).newInstance();
        // 设置配置的属性
        databaseIdProvider.setProperties(properties);
    }
    Environment environment = configuration.getEnvironment();
    if (environment != null && databaseIdProvider != null) {
        // 获取当前数据库环境对应的 databaseId,并记录到 Configuration.databaseId 中,已备后用
        String databaseId = databaseIdProvider.getDatabaseId(environment.getDataSource());
        configuration.setDatabaseId(databaseId);
    }
}

析过程如上述代码注释,关于该标签的使用可以参考官方文档。

解析 mappers 标签

标签 <mappers/> 用于指定映射文件列表,这是一个我们非常熟悉的标签。MyBatis 广受欢迎的一个很重要的原因是支持自己定义 SQL 语句,这样就可以保证 SQL 的优化可控。抛去注解配置 SQL 的形式(注解对于复杂 SQL 的支持较弱,一般仅用于编写简单的 SQL),对于框架自动生成的 SQL 和用户自定义的 SQL 都记录在映射 XML 文件中,标签 <mappers/> 用于指定映射文件所在的路径。

我们可以通过 <mapper resource=""> 或 <mapper url=""> 子标签指定映射 XML 文件所在的位置,也可以通过 <mapper class=""> 子标签指定一个或多个具体的 Mapper 接口,甚至可以通过 <package name=""/> 子标签指定映射文件所在的包名,扫描注册。

该标签的解析过程由 XMLConfigBuilder#mapperElement 方法实现,如下:

private void mapperElement(XNode parent) throws Exception {
    if (parent != null) {
        for (XNode child : parent.getChildren()) {
            /*
             * 配置了 package 属性,从指定包下面扫描注册
             * <mappers>
             *      <package name="org.mybatis.builder"/>
             * </mappers>
             */
            if ("package".equals(child.getName())) {
                String mapperPackage = child.getStringAttribute("name");
                // 调用 MapperRegistry 进行注册
                configuration.addMappers(mapperPackage);
            }
            // 处理 resource、url,以及 class 配置的场景
            else {
                String resource = child.getStringAttribute("resource");
                String url = child.getStringAttribute("url");
                String mapperClass = child.getStringAttribute("class");
                /*
                 * <!-- Using classpath relative resources -->
                 * <mappers>
                 *      <mapper resource="org/mybatis/builder/AuthorMapper.xml"/>
                 *      <mapper resource="org/mybatis/builder/BlogMapper.xml"/>
                 *      <mapper resource="org/mybatis/builder/PostMapper.xml"/>
                 * </mappers>
                 */
                if (resource != null && url == null && mapperClass == null) {
                    ErrorContext.instance().resource(resource);
                    // 从类路径获取文件输入流
                    InputStream inputStream = Resources.getResourceAsStream(resource);
                    // 构建 XMLMapperBuilder 对象
                    XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
                    // 执行映射文件解析
                    mapperParser.parse();
                }
                /*
                 * <!-- Using url fully qualified paths -->
                 * <mappers>
                 *      <mapper url="file:///var/mappers/AuthorMapper.xml"/>
                 *      <mapper url="file:///var/mappers/BlogMapper.xml"/>
                 *      <mapper url="file:///var/mappers/PostMapper.xml"/>
                 * </mappers>
                 */
                else if (resource == null && url != null && mapperClass == null) {
                    ErrorContext.instance().resource(url);
                    // 基于 url 获取配置文件输入流
                    InputStream inputStream = Resources.getUrlAsStream(url);
                    // 构建 XMLMapperBuilder 对象
                    XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
                    // 执行映射文件解析
                    mapperParser.parse();
                }
                /*
                 * <!-- Using mapper interface classes -->
                 * <mappers>
                 *      <mapper class="org.mybatis.builder.AuthorMapper"/>
                 *      <mapper class="org.mybatis.builder.BlogMapper"/>
                 *      <mapper class="org.mybatis.builder.PostMapper"/>
                 * </mappers>
                 */
                else if (resource == null && url == null && mapperClass != null) {
                    // 获取指定接口 Class 对象
                    Class<?> mapperInterface = Resources.classForName(mapperClass);
                    // 调用 MapperRegistry 进行注册
                    configuration.addMapper(mapperInterface);
                } else {
                    throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one.");
                }
            }
        }
    }
}

上述方法首先会判断当前是否是 package 配置,如果是则会获取配置的 package 名称,然后执行扫描注册逻辑。如果是 resource 或 url 配置,则先获取指定路径映射文件的输入流,然后构造 XMLMapperBuilder 对象对映射文件进行解析。对于 class 配置而言,则会构建接口限定名对应的 Class 对象,并调用 MapperRegistry#addMapper 方法执行注册。

整个方法的运行逻辑还是比较直观的,其中涉及到对映射文件的解析注册过程,即 XMLMapperBuilder 相关类实现,将留到下一篇介绍映射文件加载与解析时专门介绍。

下面来重点分析一下 MapperRegistry 类及其周边类的功能和实现。我们在使用 MyBatis 框架时需要实现数据表对应的 Mapper 接口(以后统称为 Mapper 接口),其中声明了一系列数据库操作方法。我们可以通过注解的方式在方法上编写 SQL 语句,也可以通过映射 XML 文件的方式编写和关联对应的 SQL 语句。上面解析 <mappers/> 标签实现时我们看到方法通过调用 MapperRegistry#addMapper 方法注册相应的 Mapper 接口,包括以 package 配置的方式在扫描获取到相应的 Mapper 接口之后,也需要通过调用该方法进行注册。MapperRegistry 类中定义了两个属性:

/** 全局唯一配置对象 */
private final Configuration config;
/** 记录 Mapper 接口(Class 对象)与 {@link MapperProxyFactory} 之间的映射关系 */
private final Map<Class<?>, MapperProxyFactory<?>> knownMappers = new HashMap<>();

上面调用的MapperRegistry#addMapper方法实现如下:

public <T> void addMapper(Class<T> type) {
    if (type.isInterface()) {
        // 对应 Mapper 接口已注册
        if (this.hasMapper(type)) {
            throw new BindingException("Type " + type + " is already known to the MapperRegistry.");
        }
        // 标记整个过程是否成功完成
        boolean loadCompleted = false;
        try {
            // 注册 Mapper 接口 Class 对象与 MapperProxyFactory 之间的映射关系
            knownMappers.put(type, new MapperProxyFactory<>(type));
            // It's important that the type is added before the parser is run
            // otherwise the binding may automatically be attempted by the
            // mapper parser. If the type is already known, it won't try.
            MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type);
            // 解析 Mapper 接口中的注解 SQL 配置
            parser.parse();
            loadCompleted = true;
        } finally {
            if (!loadCompleted) {
                knownMappers.remove(type);
            }
        }
    }
}

Mapper 方法必须是一个接口才会被注册,这主要是为了配合 JDK 内置的动态代理机制。上一篇介绍 MyBatis 的基本运行原理时我们曾说过,MyBatis 通过为 Mapper 接口创建相应的动态代理类以执行具体的数据库操作,这一部分的详细过程将留到后面介绍 SQL 语句执行机制时再细讲,这里先知道有这样一个概念即可。如果当前 Mapper 接口还没有被注册,则会创建对应的 MapperProxyFactory 对象并记录到 MapperRegistry#knownMappers 属性中,然后解析 Mapper 接口中注解的 SQL 配置,这一过程留到下一篇分析映射文件解析过程时再一并介绍。

总结

到此,我们完成了对配置文件 mybatis-config.xml 加载和解析过程的分析。总的来说,对于配置文件的解析实际上就是将静态的 XML 配置解析成内存中的 Configuration 对象的过程。Configuration 可以看作是 MyBatis 全局的配置中心,后续对于映射文件的解析,以及 SQL 语句的执行都依赖于其中的配置项。下一篇,我们将一起来探究映射文件的加载和解析过程。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值