手撕MyBatis源码

简介

什么是 MyBatis?

MyBatis 是一款优秀的持久层框架,它支持自定义 SQL、存储过程以及高级映射。MyBatis 免除了几乎所有的 JDBC 代码以及设置参数和获取结果集的工作。MyBatis 可以通过简单的 XML 或注解来配置和映射原始类型、接口和 Java POJO(Plain Old Java Objects,普通老式 Java 对象)为数据库中的记录。

摘自mybatis官网

官网地址 https://mybatis.org/mybatis-3/zh/index.html

分析mybatis主要作用,让程序员仅仅只关注sql语句分析为下图事mybatis的主要作用

三个步骤:

mybatis如何获取数据源

mybatis如何获取sql语句并且执行

mybatis如何获取链接并且返回处理结果集

源码分析技巧

宏观>微观>图解

1.断点(观察调用栈,利用条件断点)

2.反调

3.根据接口方法找到具体实现

4.猜测类名,方法名

5看控制台日志

先来解释一下mybatis几个常见的类或者接口吧

  • SqlSessionFactoryBuilder :用于构建SqlSession工厂
  • XMLConfigBuilder:用来解析mybatis的xml配置文件
  • XMLMapperBuilder: 用来解析mapper映射文件
  • Configuration: mybatis主管一样,管理mtbatis配置文件信息
  • SqlSessionFactory接口:工厂当然是用来创建sqlSession
  • Executor接口:mybatis执行器,增删改查时没它可不行
  • StatementHandler: 负责处理Mybatis与JDBC之间Statement的交互
  • ParamterHandler: 负责为 PreparedStatement 的 sql 语句参数动态赋值
  • ResultSetHandler:主要使用反射围绕 resultMap 按层次结构依次解析的
    这里我们大概知道这几个类或者接口了。接下来我们从开始创建sqlsessionFactory时开始深入解析它的配置文件吧😄

配置文件解析

首先在我们使用Mybatis时会创建工厂,让工厂去创建sqlsession进行增删改查。

 SqlSessionFactory factory = new SqlSessionFactoryBuilder()
        .build(Thread.currentThread().getContextClassLoader().getResourceAsStream("configuration.xml"));

mybatis封装后的代码如下,一步一步进行分析

package org.apache.ibatis.demo;


import org.apache.ibatis.demo.domain.User;
import org.apache.ibatis.domain.blog.Blog;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;

import java.io.IOException;
import java.io.InputStream;

/**
 * @auther tuhexuan
 * @date 2021/7/19 22:23
 */
public class MybatisMain {
  public static void main(String[] args) throws IOException {
    String resource = "mybatis-config.xml";
    InputStream inputStream = Resources.getResourceAsStream(resource);
    //上面是读取myabtis的配置文件从下面这行开始分析
    SqlSessionFactory sqlSessionFactory= new SqlSessionFactoryBuilder().build(inputStream);

    SqlSession sqlSession = sqlSessionFactory.openSession();
//    IUserDao userDao = sqlSession.getMapper(IUserDao.class);
//    User user = userDao.findUserById(1);
    User user =sqlSession.selectOne("org.apache.ibatis.demo.IUserDao.findUserById",1);
    System.out.println(user);
  }
}

直接进入build方法

 

 public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
    try {
      XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
      return 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.
      }
    }
  }

我们将配置文件比如configuration.xml 读取转化为inputstream之后会创建XmlConfigurationBuilder进行解析,他是如何解析的呢,让我们点进去一探究竟

大家应该能够很清楚的看到这里 parseConfiguration(parser.evalNode("/configuration"));解析配置,传入的参数就是我们xml配置文件的configuration根节点。解析我们的配置文件就需要分别解析其中的各个节点。

 可以通过断点中的XNode 对象,可以copy value 得到我们读取的配置文件也就是 config.xml文件

我们可以看到此方法解析了许多的节点,properties,settings...等许多接下来我们来一一深入。

1. 解析<properties>节点

 首相propertiesElement这个方法是第一个执行的,意味着我们必须在配置前去指定配置文件。例如 jdbcconfig.properties里面是写的jdbc中的driver,url,username,password.

<properties resource="org/mybatis/example/config.properties">
    <property name="username" value="dev_user"/>
    <property name="password" value="F2Fa3!33TYyg"/>
</properties>
  <!--或者读取properties文件-->
 <properties resource="database_config.properties" />

<properties>节点的解析工作由 propertiesElement() 这个方法完成的,在分析方法的源码前,我们先来看一下<properties>节点是如何配置的。

protiesElement()方法

// - 💫-XMLConfiguration 
private void propertiesElement(XNode context) throws Exception {
    if (context != null) {
      // 解析 propertis 的子节点,并将这些节点内容转换为属性对象 Properties
      Properties defaults = context.getChildrenAsProperties();
      //获取properties节点中resource和url的值
      String resource = context.getStringAttribute("resource");
      String url = context.getStringAttribute("url");
      //❌ 两者都不用空,则抛出异常
      //我们在写的时候要注意不可以两个路径都写上
      if (resource != null && url != null) {
        throw new BuilderException("properties元素不能同时指定URL和基于资源的属性文件引用。请指定一个或另一个.");
      }
      if (resource != null) {
        //从文件系统中加载并解析属性文件
        defaults.putAll(Resources.getResourceAsProperties(resource));
      } else if (url != null) {
        //从url中加载并解析属性文件
        defaults.putAll(Resources.getUrlAsProperties(url));
      }
      Properties vars = configuration.getVariables();
      if (vars != null) {
        defaults.putAll(vars);
      }
      parser.setVariables(defaults);
      // 将属性值设置到 configuration 中
      configuration.setVariables(defaults);
    }
  }

 

大家可以看到properties节点先读取子节点的属性,在进行扫描是否有属性文件并加以解析,如果自身子节点属性名与属性文件相同时,不会报错而是属性文件中的属性会覆盖掉子结点中的赋值。
让我们在深入看一下如何解析读取的子节点属性。


 

// - 💫-XNode
 public Properties getChildrenAsProperties() {
    Properties properties = new Properties();
    // 获取并遍历子节点
    for (XNode child : getChildren()) {
      // 获取 property 节点的 name 和 value 属性
      String name = child.getStringAttribute("name");
      String value = child.getStringAttribute("value");
      if (name != null && value != null) {
        //判断 > 设置属性到属性对象中
        properties.setProperty(name, value);
      }
    }
    return properties;
  }
// - 💫-XNode
public List<XNode> getChildren() {
    List<XNode> children = new ArrayList<>();
    // 获取子节点列表
    NodeList nodeList = node.getChildNodes();
    if (nodeList != null) {
      for (int i = 0, n = nodeList.getLength(); i < n; i++) {
        Node node = nodeList.item(i);
        if (node.getNodeType() == Node.ELEMENT_NODE) {
          // 将节点对象封装到 XNode 中,并将 XNode 对象放入 children 列表中
          children.add(new XNode(xpathParser, node, variables));
        }
      }
    }
    return children;
  }

总结来看解析properties节点主要分为解析子节点与读取properties属性文件,之后将我们读取到的属性信息设置到XPathParser与Configuration中。

2. 解析<setting>节点

setting当中的设置有很多,子节点也很多,如下面的代码。如果想要看详细信息大家可以到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>

让我们看一下它在解析setting结点的时候先将setting子节点将其转换为properties对象再进行设置

Properties settings = settingsAsProperties(root.evalNode("settings"));

下面我们开始深入代码

// - 💫-XMLConfigBuilder
private Properties settingsAsProperties(XNode context) {
    if (context == null) {
      return new Properties();
    }
    //获取setting的子节点
    Properties props = context.getChildrenAsProperties();
    // Check that all settings are known to the configuration class
    // 创建Configuration的元信息对象
    MetaClass metaConfig = MetaClass.forClass(Configuration.class, localReflectorFactory);
    for (Object key : props.keySet()) {
      //检测如果元信息中没有相关属性则抛出异常
      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;
  }

我们在看这段代码的时候,其实大致能明白此方式是干了什么,就是先读取子节点解析为properties对象props,然后为Configuration创建元信息对象,在props通过MetaClass的检测之后返回。大家主要陌生的可能就是MetaClass,接下来我们来看一下这是什么东西

//-💫-MetaClass
public class MetaClass {
  //这里又出现两个类反射器工厂与反射器。
  private final ReflectorFactory reflectorFactory;
  private final Reflector reflector;
  //这里构造方法设为私有,我们只能通过下面的forClass进行创建元信息对象
  private MetaClass(Class<?> type, ReflectorFactory reflectorFactory) {
    this.reflectorFactory = reflectorFactory;
    this.reflector = reflectorFactory.findForClass(type);
  }

  public static MetaClass forClass(Class<?> type, ReflectorFactory reflectorFactory) {
    return new MetaClass(type, reflectorFactory);
  }

//-💫-MetaClass
//让我们看一下它是如何确定是否含有相关属性的
public boolean hasSetter(String name) {
// 属性分词器,用于解析属性名
      PropertyTokenizer prop = new PropertyTokenizer(name);
      // hasNext 返回 true,则表明 name 是一个复合属性,后面会进行分析
      if (prop.hasNext()) {
        // 调用 reflector 的 hasSetter 方法
        if (reflector.hasSetter(prop.getName())) {
          // 为属性创建创建 MetaClass
          MetaClass metaProp = metaClassForProperty(prop.getName());
          // 再次调用 hasSetter
          return metaProp.hasSetter(prop.getChildren());
         } else {
          return false;
         }
       } else {
      // 调用 reflector 的 hasSetter 方法
      return reflector.hasSetter(prop.getName());
 } }

这里我们可以清楚的观察到MetaClass的hasSetter()方法其实就是调用了反射器的hasSetter()方法。元信息对象在创建的时候需要两个参数一个是Configuration对象类型,一个则是反射器工厂通过反射器工厂构造反射器进行赋值。ReflectorFactory是一个接口,我已我们需要移步到他的实现类DefaultReflectorFactory。

//-💫-DefaultRefaultorFactory
public class DefaultReflectorFactory implements ReflectorFactory {
  private boolean classCacheEnabled = true;
  private final ConcurrentMap<Class<?>, Reflector> reflectorMap = new ConcurrentHashMap<>();

  public DefaultReflectorFactory() {}
  @Override
  public boolean isClassCacheEnabled() {
    return classCacheEnabled;
  }
  @Override
  public void setClassCacheEnabled(boolean classCacheEnabled) {
    this.classCacheEnabled = classCacheEnabled;
  }
  @Override
  public Reflector findForClass(Class<?> type) {
     // classCacheEnabled 默认为 true
    if (classCacheEnabled) {
        // 从缓存中获取 Reflector 对象
        Reflector cached = reflectorMap.get(type);
        // 缓存为空,则创建一个新的 Reflector 实例,并放入缓存中
        if (cached == null) {
            cached = new Reflector(type);
            // 将 <type, cached> 映射缓存到 map 中,方便下次取用
            reflectorMap.put(type, cached);
      }
}

反射器工厂为我们构建一个反射器Reflector,这里还具有缓存的功能,如果缓存开启则会创建一个新的Reflector实例放入缓存中。对于Reflector这个类主要通过反射获取目标类的信息的,这里我们就不去细说了。

3.解析<typeAliases>节点

在 MyBatis 中,我们可以为自己写的一些类定义一个别名。这样在使用的时候,只需要输入别名即可,无需再把全限定的类名写出来。
第一种是仅配置包名,让 MyBatis 去扫描包中的类型,并根据类型得到相应的别名。这种方式可配合 Alias 注解使用,即通过注解为某个类配置别名,而不是让 MyBatis 按照默认规则生成别名。这种方式的配置如下:

    <typeAliases>
        <package name="domain"/>
    </typeAliases>

第二种方式是通过手动的方式,明确为某个类型配置别名。这种方式的配置如下:

    <typeAliases>
        <typeAlias type="domain.Student" alias="stu"/>
    </typeAliases>

对比这两种方式,第一种自动扫描的方式配置起来比较简单,缺点也不明显。唯一能想到缺点可能就是 MyBatis 会将某个包下所有符合要求的类的别名都解析出来,并形成映射关系。如果你不想让某些类被扫描,这个好像做不到,没发现 MyBatis 提供了相关的排除机制。不过我觉得这并不是什么大问题,最多是多解析并缓存了一些别名到类型的映射,在时间和空间上产生了一些的消耗而已。当然,如果无法忍受这些消耗,可以使用第二种配置方式,通过手工的方式精确配置某些类型的别名。不过这种方式比较繁琐,特别是配置项比较多时。至于两种方式怎么选择,这个看具体的情况了。配置项非常少时,两种皆可。比较多的话,还是让 MyBatis 自行扫描吧。接下来我们来看一下两种不同的配置是如何解析的:

  private void typeAliasesElement(XNode parent) {
    if (parent != null) {
      for (XNode child : parent.getChildren()) {
        // 从指定的包中解析别名和类型的映射
        if ("package".equals(child.getName())) {
          String typeAliasPackage = child.getStringAttribute("name");
          configuration.getTypeAliasRegistry().registerAliases(typeAliasPackage);
        // 从 typeAlias 节点中解析别名和类型的映射
        } else {
          // 获取 alias 和 type 属性值,alias 不是必填项,可为空
          String alias = child.getStringAttribute("alias");
          String type = child.getStringAttribute("type");
          try {
            // 加载 type 对应的类型
            Class<?> clazz = Resources.classForName(type);
            // 注册别名到类型的映射
            if (alias == null) {
              typeAliasRegistry.registerAlias(clazz);
            } else {
              typeAliasRegistry.registerAlias(alias, clazz);
            }
          } catch (ClassNotFoundException e) {
            throw new BuilderException("Error registering typeAlias for '" + alias + "'. Cause: " + e, e);
          }
        }
      }
    }
  }

1.从指定的包中解析并注册别名

//  TypeAliasRegistry
public void registerAliases(String packageName) {
    //调用内部重载方法
    registerAliases(packageName, Object.class);
  }

 public void registerAliases(String packageName, Class<?> superType) {
    ResolverUtil<Class<?>> resolverUtil = new ResolverUtil<>();
    // 查找某个包下的父类为 superType 的类。从调用栈来看,这里的
    // superType = Object.class,所以 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()) {
        // 为类型注册别名
        registerAlias(type);
      }
    }
  }

总结来看,扫描包的步骤就两步一是查找指定包下的所有类;二是遍历查找到的类型集合,为每个类型注册别名。其中最后注册别名没有详细为大家讲,我们接下来说从<typeAlias>节点中解析并注册别名来详细说一下此方法。

2. 从<typeAlias>节点中解析并注册别名

在别名的配置中,type 属性是必须要配置的,而 alias 属性则不是必须的。。如果使用者未配置 alias 属性,则需要 MyBatis 自行为目标类型生成别名。对于别名为空的情况,注册别名的任务交由 registerAlias(Class<?>) 方法处理。若不为空,则由 registerAlias(String,Class<?>) 进行别名注册。代码如下:

// TypeAliasRegistry
public void registerAlias(Class<?> type) {
    // 获取全路径类名的简称
    String alias = type.getSimpleName();
    Alias aliasAnnotation = type.getAnnotation(Alias.class);
    if (aliasAnnotation != null) {
      // 从注解中取出别名
      alias = aliasAnnotation.value();
    }
     // 调用重载方法注册别名和类型映射
    registerAlias(alias, type);
  }

  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);
    // 如果 TYPE_ALIASES 中存在了某个类型映射,这里判断当前类型与映射中的类型
    // 是否一致,不一致则抛出异常,不允许一个别名对应两种类型
    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() + "'.");
    }
    //缓存别名类型
    typeAliases.put(key, value);
  }

若用户未明确配置 alias 属性,MyBatis 会使用类名的小写形式作为别名。若类中有@Alias 注解,则从注解中取值作为别名。
别名解析并不是很难让我们来看一下大致步骤:

  1. 通过 VFS(虚拟文件系统)获取指定包下的所有文件的路径名,
  2. 比如 domain/Student.class
  3. 筛选以.class 结尾的文件名
  4. 将路径名转成全限定的类名,通过类加载器加载类名
  5. 对类型进行匹配,若符合匹配规则,则将其放入内部集合中

3. mybatis内部当中注册的别名

//Configuration
public Configuration() {
    // 注册事务工厂的别名
    typeAliasRegistry.registerAlias("JDBC", JdbcTransactionFactory.class);
    typeAliasRegistry.registerAlias("MANAGED", ManagedTransactionFactory.class);

    typeAliasRegistry.registerAlias("JNDI", JndiDataSourceFactory.class);
    // 注册数据源的别名
    typeAliasRegistry.registerAlias("POOLED", PooledDataSourceFactory.class);
    typeAliasRegistry.registerAlias("UNPOOLED", UnpooledDataSourceFactory.class);

    typeAliasRegistry.registerAlias("PERPETUAL", PerpetualCache.class);
    // 注册缓存策略的别名
    typeAliasRegistry.registerAlias("FIFO", FifoCache.class);  //先进先出
    typeAliasRegistry.registerAlias("LRU", LruCache.class);   //最少使用
    typeAliasRegistry.registerAlias("SOFT", SoftCache.class);  //软引用缓存
    typeAliasRegistry.registerAlias("WEAK", WeakCache.class);  //弱引用缓存

    typeAliasRegistry.registerAlias("DB_VENDOR", VendorDatabaseIdProvider.class);

    typeAliasRegistry.registerAlias("XML", XMLLanguageDriver.class);
    typeAliasRegistry.registerAlias("RAW", RawLanguageDriver.class);

    // 注册日志类的别名
    typeAliasRegistry.registerAlias("SLF4J", Slf4jImpl.class);
    typeAliasRegistry.registerAlias("COMMONS_LOGGING", JakartaCommonsLoggingImpl.class);
    typeAliasRegistry.registerAlias("LOG4J", Log4jImpl.class);
    typeAliasRegistry.registerAlias("LOG4J2", Log4j2Impl.class);
    typeAliasRegistry.registerAlias("JDK_LOGGING", Jdk14LoggingImpl.class);
    typeAliasRegistry.registerAlias("STDOUT_LOGGING", StdOutImpl.class);
    typeAliasRegistry.registerAlias("NO_LOGGING", NoLoggingImpl.class);

    // 注册动态代理工厂的别名
    typeAliasRegistry.registerAlias("CGLIB", CglibProxyFactory.class);
    typeAliasRegistry.registerAlias("JAVASSIST", JavassistProxyFactory.class);

    languageRegistry.setDefaultDriverClass(XMLLanguageDriver.class);
    languageRegistry.register(RawLanguageDriver.class);
  }

// -☆- TypeAliasRegistry
 public TypeAliasRegistry() {
    registerAlias("string", String.class);
    //这里配置都是我们经常使用了一些类型
    registerAlias("byte", Byte.class);
    registerAlias("long", Long.class);
    registerAlias("short", Short.class);
    registerAlias("int", Integer.class);
    registerAlias("integer", Integer.class);
    registerAlias("double", Double.class);
    registerAlias("float", Float.class);
    registerAlias("boolean", Boolean.class);
    .··· 省略  ···

下面就是最主要的mybatis解析配置文件的部分了

4. 解析<environments>节点

MyBatis 中,事务管理器(transactionManager)和数据源(dataSource)是配置在<environments>节点下的环境变量(environment)节点下的。
⭐即使environments节点下可以写多个环境,但是我们每个 SqlSessionFactory 实例只能选择一种环境。
如何配置呢,我们来看一下

<environments default="development">
  <environment id="development">
    <transactionManager type="JDBC">
      <property name="..." value="..."/>
    </transactionManager>
    <dataSource type="POOLED">
      <property name="driver" value="${driver}"/>
      <property name="url" value="${url}"/>
      <property name="username" value="${username}"/>
      <property name="password" value="${password}"/>
    </dataSource>
  </environment>
</environments>

注意一些关键点:

  • 默认使用的环境 ID(比如:default="development")。
  • 每个 environment 元素定义的环境 ID(比如:id="development")。
  • 事务管理器的配置(比如:type="JDBC")。
  • 数据源的配置(比如:type="POOLED")。
    默认环境和环境 ID 顾名思义。 环境可以随意命名,但务必保证默认的环境 ID 要匹配其中一个环境 ID。

如何解析的?

 private void environmentsElement(XNode context) throws Exception {
    if (context != null) {
      if (environment == null)
        // 获取 default 属性
        environment = context.getStringAttribute("default");
      }
      for (XNode child : context.getChildren()) {
        // 获取 id 属性
        String id = child.getStringAttribute("id");
        // 检测当前 environment 节点的 id 与其父节点 environments 的
        // 属性 default 内容是否一致,一致则返回 true,否则返回 false
        if (isSpecifiedEnvironment(id)) {
          // 解析 transactionManager 节点
          TransactionFactory txFactory = transactionManagerElement(child.evalNode("transactionManager"));
          // 解析 dataSource 节点,逻辑和插件的解析逻辑很相似
          DataSourceFactory dsFactory = dataSourceElement(child.evalNode("dataSource"));
          // 创建 DataSource 对象
          DataSource dataSource = dsFactory.getDataSource();
          // 构建 Environment 对象,并设置到 configuration 中
          Environment.Builder environmentBuilder = new Environment.Builder(id)
              .transactionFactory(txFactory)
              .dataSource(dataSource);
          configuration.setEnvironment(environmentBuilder.build());
        }
      }
    }
  }

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

全干程序员demo

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值