Mybatis二级缓存创建过程与使用详解

相关博文:
你尝试过在mybatis某个mapper上同时配置<cache/>和<cache-ref/>吗?
mybatis全局配置文件实例与详解
Mybatis中一级缓存和二级缓存使用详解

这里我们手动根据mybatis-config.xml来创建sqlsesionFactory,观察mybatis中二级缓存的创建过程。

【1】创建Cache的完整过程

mybatis创建二级缓存的时序图如下
在这里插入图片描述

① 获取sqlsesionFactorynew SqlSessionFactoryBuilder().build(reader)

我们从SqlSessionFactoryBuilder解析mybatis-config.xml配置文件开始:

Reader reader = Resources.getResourceAsReader("mybatis-config.xml");
//这里会进入代码2片段
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader);

② SqlSessionFactoryBuilder.build

代码2片段new SqlSessionFactoryBuilder().build(reader);的实现,方法属于SqlSessionFactoryBuilder类

 public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
    try {
      XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
      //这里会进入代码3片段
      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.
      }
    }
  }

代码解释如下:
这里会根据mybatis的配置文件、其他环境配置等创建XMLConfigBuilder。XMLConfigBuilder是用来解析mybatis的配置文件,解析过程中会将“解析结果”放到Configuration的对应成员变量中,然后返回Configuration实例。

这里需要注将“解析结果”放到Configuration的对应成员变量中,mybatis框架的基础就是Configuration实例中的一系列成员,后面会具体分析。

另外,创建XMLConfigBuilder实例时也创建了Configuration实例并进行了初始化

  private XMLConfigBuilder(XPathParser parser, String environment, Properties props) {
	  //这里创建Configuration实例
    super(new Configuration());
    ErrorContext.instance().resource("SQL Mapper Configuration");
    this.configuration.setVariables(props);
    this.parsed = false;
    this.environment = environment;
    this.parser = parser;
  }

Configuration实例初始化代码如下:

  public Configuration(Environment environment) {
    this();
    this.environment = environment;
  }

  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);
  }

③ XMLConfigBuilder.parse

源码如下:

public Configuration parse() {
    if (parsed) {
      throw new BuilderException("Each XMLConfigBuilder can only be used once.");
    }
    parsed = true;
    //这个实现在下面方法
    parseConfiguration(parser.evalNode("/configuration"));
    return configuration;
  }

代码解释如下:

  • 判断是否解析过
    • 如果解析过,抛出异常
    • 如果未解析则设置 parsed = true;然后调用 parseConfiguration(parser.evalNode("/configuration"));
  • 返回configuration实例

④ XMLConfigBuilder.parseConfiguration

这是解析mybatis配置文件的具体方法,会根据拿到的mybatis配置文件解析每个结点。在解析mappers结点时会获取每一个XXXXMapper.xml或者XXXMapper类进行解析获取一个个MappedStatement。

  //解析mybatis-config.xml
  private void parseConfiguration(XNode root) {
    try {
      //issue #117 read properties first
      propertiesElement(root.evalNode("properties"));
      Properties settings = settingsAsProperties(root.evalNode("settings"));
      loadCustomVfs(settings);
      typeAliasesElement(root.evalNode("typeAliases"));
      pluginElement(root.evalNode("plugins"));
      objectFactoryElement(root.evalNode("objectFactory"));
      objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
      reflectorFactoryElement(root.evalNode("reflectorFactory"));
      settingsElement(settings);
      // read it after objectFactory and objectWrapperFactory issue #631
      environmentsElement(root.evalNode("environments"));
      databaseIdProviderElement(root.evalNode("databaseIdProvider"));
      typeHandlerElement(root.evalNode("typeHandlers"));
      //处理系统中的Mapper.xml--⑤ XMLConfigBuilder.mapperElement
      mapperElement(root.evalNode("mappers"));
    } catch (Exception e) {
      throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
    }
  }

解释如下:

  • 解析properties结点
  • 解析settings结点
  • 解析typeAliases结点
  • 解析plugins结点
  • 解析objectFactory结点
  • 解析objectWrapperFactory结点
  • 解析reflectorFactory结点
  • 根据settings结点为configuration设置对应的成员变量
  • 解析environments结点
  • 解析databaseIdProvider结点
  • 解析typeHandlers结点
  • 解析mappers结点-这个也是我们着重关注的

⑤ XMLConfigBuilder.mapperElement

源码如下:

private void mapperElement(XNode parent) throws Exception {
    if (parent != null) {
    //这里会遍历循环解析每一个mapper结点
      for (XNode child : parent.getChildren()) {
      //判断是否包注入
        if ("package".equals(child.getName())) {
          String mapperPackage = child.getStringAttribute("name");
          configuration.addMappers(mapperPackage);
        } else {
        //否则使用resource
          String resource = child.getStringAttribute("resource");
          String url = child.getStringAttribute("url");
          String mapperClass = child.getStringAttribute("class");
          //创建mapperParser ,解析mapper xml
          if (resource != null && url == null && mapperClass == null) {
            ErrorContext.instance().resource(resource);
            InputStream inputStream = Resources.getResourceAsStream(resource);
            XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
            mapperParser.parse();
            //是否使用url配置
          } else if (resource == null && url != null && mapperClass == null) {
            ErrorContext.instance().resource(url);
            InputStream inputStream = Resources.getUrlAsStream(url);
            XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
            mapperParser.parse();
            //是否配置具体类
          } else if (resource == null && url == null && mapperClass != null) {
            Class<?> mapperInterface = Resources.classForName(mapperClass);
            configuration.addMapper(mapperInterface);
          } else {
            throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one.");
          }
        }
      }
    }
  }

如下图所示,mappers中每一个mapper结点可以有如下方式配置。
在这里插入图片描述

这里我们实践的背景是mapper结点中使用resource配置了xml路径。那么我们继续跟踪如下代码:

InputStream inputStream = Resources.getResourceAsStream(resource);
XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
mapperParser.parse();

解析如下:

  • 创建XMLMapperBuilder实例,这里source就是你配置的XXXMapper.xml文件包路径
    • 创建XMLMapperBuilder实例时也创建了MapperBuilderAssistant实例
      private XMLMapperBuilder(XPathParser parser, Configuration configuration, String resource, Map<String, XNode> sqlFragments) {
        super(configuration);
        this.builderAssistant = new MapperBuilderAssistant(configuration, resource);
        this.parser = parser;
        this.sqlFragments = sqlFragments;
        this.resource = resource;
      }
      
  • mapperParser.parse();方法解析具体的xxxxMapper.xml文件

需要注意的是,XMLConfigBuilder、XMLMapperBuilder以及MapperBuilderAssistant拥有一个共同的抽象父类BaseBuilder:
在这里插入图片描述


⑥ XMLMapperBuilder.parse

源码如下:

  public void parse() {
    if (!configuration.isResourceLoaded(resource)) {
      configurationElement(parser.evalNode("/mapper"));
      configuration.addLoadedResource(resource);
      bindMapperForNamespace();
    }
//下面代码可以理解为mybatis"后手",对前面不能确定的ResultMap、ChacheRef、Statement再次处理
    parsePendingResultMaps();

//若是在某个地方看到了parsePendingChacheRefs(),也是正确的。这是mybatis框架开发工程师写错单词了;不过已经修正
    parsePendingCacheRefs();
    parsePendingStatements();
  }

代码解释如下

  • ① 判断当前资源是否被加载。通过判断configuration实例的成员属性Set<String> loadedResources = new HashSet<>()是否contains当前resource来判断
  • ② 如果没有加载,执行以下步骤
    • ③ 解析mapper结点
    • ④ 将当前resource放到configuration实例中loadedResources属性里
    • ⑤ bindMapperForNamespace();
      • 判断当前resource对应的namespace是否已经在configuration实例的MapperRegistry成员实例的knownMappers集合中。Map<Class<?>, MapperProxyFactory<?>> knownMappers = new HashMap<>()
      • 如果不在,则执行以下步骤
        • "namespace:" + namespace添加到configuration实例的成员loadedResources中
        • configuration.addMapper(boundType);
          • knownMappers.put(type, new MapperProxyFactory<>(type))
          • 创建MapperAnnotationBuilder实例,解析命名空间对应的mapper接口(也就是解析接口上面的注解)
  • ⑥ 尝试处理待解决的ResultMaps
  • ⑦ 尝试处理待解决的CacheRefs
  • ⑧ 尝试处理待解决的Statements

假设你有一个EmployeeMapper.xml,那么loadedResources最终会保存什么呢?会保存三种格式

  • EmployeeMapper.xml
  • namespace:com.mybatis.dao.EmployeeMapper
  • interface com.mybatis.dao.EmployeeMapper

6.1 XMLMapperBuilder.configurationElement

源码如下:

  private void configurationElement(XNode context) {
    try {
      String namespace = context.getStringAttribute("namespace");
      if (namespace == null || namespace.isEmpty()) {
        throw new BuilderException("Mapper's namespace cannot be empty");
      }
      builderAssistant.setCurrentNamespace(namespace);
      cacheRefElement(context.evalNode("cache-ref"));
      cacheElement(context.evalNode("cache"));
      parameterMapElement(context.evalNodes("/mapper/parameterMap"));
      resultMapElements(context.evalNodes("/mapper/resultMap"));
      sqlElement(context.evalNodes("/mapper/sql"));
      buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
    } catch (Exception e) {
      throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e);
    }
  }
  

代码解释如下:

  • ① 获取namespace
  • ② 为当前mapper的builderAssistant(MapperBuilderAssistant实例,是XMLMapperBuilder成员变量)设置namespace
  • ③ 解析cache-ref结点,处理缓存引用
  • ④ 解析cache结点,为当前namespace创建缓存实例
  • ⑤ 解析parameterMap结点,参数映射
  • ⑥ 解析resultMap结点,结果映射
  • ⑦ 解析sql结点,SQL片段
  • ⑧ 解析select|insert|update|delete结点
6.1.1 XMLMapperBuilder解析缓存引用结点

源码如下:

 private void cacheRefElement(XNode context) {
    if (context != null) {
      configuration.addCacheRef(builderAssistant.getCurrentNamespace(), context.getStringAttribute("namespace"));
      CacheRefResolver cacheRefResolver = new CacheRefResolver(builderAssistant, context.getStringAttribute("namespace"));
      try {
        cacheRefResolver.resolveCacheRef();
      } catch (IncompleteElementException e) {
        configuration.addIncompleteCacheRef(cacheRefResolver);
      }
    }
  }

解释如下:

  • ① 向configuration实例的成员cacheRefMap添加引用关系 cacheRefMap.put(namespace, referencedNamespace);

  • ② 创建缓存引用解析器实例CacheRefResolver,其包含了当前builderAssistant与引用的namespace。

    public class CacheRefResolver {
      private final MapperBuilderAssistant assistant;
      private final String cacheRefNamespace;
    	
      public CacheRefResolver(MapperBuilderAssistant assistant, String cacheRefNamespace) {
        this.assistant = assistant;
        this.cacheRefNamespace = cacheRefNamespace;
      }
    
      public Cache resolveCacheRef() {
        return assistant.useCacheRef(cacheRefNamespace);
      }
    }
    
  • ③ 尝试解析缓存引用

MapperBuilderAssistant中解析<cache-ref/>源码

 public Cache useCacheRef(String namespace) {
    if (namespace == null) {
      throw new BuilderException("cache-ref element requires a namespace attribute.");
    }
    try {
      unresolvedCacheRef = true;
      Cache cache = configuration.getCache(namespace);
      if (cache == null) {
        throw new IncompleteElementException("No cache for namespace '" + namespace + "' could be found.");
      }
      currentCache = cache;
      unresolvedCacheRef = false;
      return cache;
    } catch (IllegalArgumentException e) {
      throw new IncompleteElementException("No cache for namespace '" + namespace + "' could be found.", e);
    }
  }

解释如下:

  • ① 根据namespace尝试从configuration获取对应的cache实例

  • ② 如果获取到,则设置当前MapperBuilderAssistant实例的currentCache为①中获取到的cache实例。否则走③

  • ③ 如果没有获取到,则抛出异常IncompleteElementException 。该异常会被XMLMapperBuilder拦截,将未正常处理的CacheRefResolver放入configuration的Collection<CacheRefResolver> incompleteCacheRefs

        private void cacheRefElement(XNode context) {
          if (context != null) {
            configuration.addCacheRef(builderAssistant.getCurrentNamespace(), context.getStringAttribute("namespace"));
            CacheRefResolver cacheRefResolver = new CacheRefResolver(builderAssistant, context.getStringAttribute("namespace"));
            try {
              cacheRefResolver.resolveCacheRef();
            } catch (IncompleteElementException e) {
              configuration.addIncompleteCacheRef(cacheRefResolver);
            }
          }
        }
    

6.1.2 XMLMapperBuilder解析cache结点

源码如下:

  private void cacheElement(XNode context) {
    if (context != null) {
      String type = context.getStringAttribute("type", "PERPETUAL");
      Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
      String eviction = context.getStringAttribute("eviction", "LRU");
      Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
      Long flushInterval = context.getLongAttribute("flushInterval");
      Integer size = context.getIntAttribute("size");
      boolean readWrite = !context.getBooleanAttribute("readOnly", false);
      boolean blocking = context.getBooleanAttribute("blocking", false);
      Properties props = context.getChildrenAsProperties();
      builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
    }
  }

解释如下:

  • ① 获取cache的实现类类型
  • ② 解析得到缓存回收策略处理类类型
  • ③ 解析flushInterval(缓存刷新间隔)属性配置
  • ④ 解析size属性(缓存存放多少元素)
  • ⑤ 解析readOnly属性(是否只读)
  • ⑥ 解析blocking属性(是否阻塞)
  • ⑦ 获取子属性
  • ⑧ 创建cache实例

MapperBuilderAssistant中解析<cache/>源码

  public Cache useNewCache(Class<? extends Cache> typeClass,
      Class<? extends Cache> evictionClass,
      Long flushInterval,
      Integer size,
      boolean readWrite,
      boolean blocking,
      Properties props) {
    Cache cache = new CacheBuilder(currentNamespace)
        .implementation(valueOrDefault(typeClass, PerpetualCache.class))
        .addDecorator(valueOrDefault(evictionClass, LruCache.class))
        .clearInterval(flushInterval)
        .size(size)
        .readWrite(readWrite)
        .blocking(blocking)
        .properties(props)
        .build();
    configuration.addCache(cache);
    currentCache = cache;
    return cache;
  }

解释如下

  • ① 根据命名空间、实现类、缓存过期策略…等使用CacheBuilder创建Cache实例
  • ② 以namespace:cache实例这样的键值对放入configuration的成员变量中protected final Map<String, Cache> caches = new StrictMap<>("Caches collection");
  • ③ 设置当前MapperBuilderAssistant实例的currentCache为①中获取到的cache实例

6.2 XMLMapperBuilder中parsePendingCacheRefs

方法源码如下:

 private void parsePendingCacheRefs() {
    Collection<CacheRefResolver> incompleteCacheRefs = configuration.getIncompleteCacheRefs();
    synchronized (incompleteCacheRefs) {
      Iterator<CacheRefResolver> iter = incompleteCacheRefs.iterator();
      while (iter.hasNext()) {
        try {
          iter.next().resolveCacheRef();
          iter.remove();
        } catch (IncompleteElementException e) {
          // Cache ref is still missing a resource...
        }
      }
    }
  }

解释如下:

  • ① 获取configuration中Collection<CacheRefResolver> incompleteCacheRefs
  • ② 循环遍历①中incompleteCacheRefs得到每一个CacheRefResolver,并调用resolveCacheRef方法
  • ③ 走前面MapperBuilderAssistant中解析<cache-ref/>源码的过程
  • ④ 如果③中没有正常处理,则从incompleteCacheRefs集合中移除当前元素;否则不移除。

【2】使用Cache过程

在系统中,使用Cache的地方在CachingExecutor中:

CachingExecutor.query(MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BoundSql)方法源码如下:

@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
   throws SQLException {
 Cache cache = ms.getCache();
 if (cache != null) {
   flushCacheIfRequired(ms);
   if (ms.isUseCache() && resultHandler == null) {
     ensureNoOutParams(ms, parameterObject, boundSql);
     @SuppressWarnings("unchecked")
     List<E> list = (List<E>) tcm.getObject(cache, key);
     if (list == null) {
       list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
       tcm.putObject(cache, key, list); // issue #578 and #116
     }
     return list;
   }
 }
 return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

代码解释如下:

  • ① 判断是否有二级缓存,如果缓存存在则执行如下步骤

    获取cache后,先判断是否有二级缓存。 只有通过<cache/>,<cache-ref/>@CacheNamespace,@CacheNamespaceRef标记使用缓存的Mapper.xml或Mapper接口(同一个namespace,不能同时使用)才会有二级缓存。

    • ② 根据sql配置(<insert>,<select>,<update>,<delete>的flushCache属性来确定是否清空缓存。
    • ③ 如果当前ms配置使用了缓存也就是useCache=true,并且resultHandler ==null,则执行如下代码
      • ④ 确保没有输出参数,这里是针对带有out类型的存储过程,也就是StatementType==CALLABLE并且ParameterMode!=IN。否则直接抛出异常
        在这里插入图片描述

      • ⑤ 从缓存里面获取数据。 如果数据为空则查询并将查询结果放入缓存, 如果缓存不为空则直接返回。

如果使用了二级缓存,那么查询结果首先会放在一级缓存中,当sqlsession关闭时,会将数据放到二级缓存里面。


【3】Cache使用时的注意事项

① 什么时候使用二级缓存

① 只能在只有单表操作的表上使用缓存

不只是要保证这个表在整个系统中只有单表操作,而且和该表有关的全部操作必须全部在一个namespace下。

② 在可以保证查询远远大于insert,update,delete操作的情况下使用缓存

这一点不需要多说,所有人都应该清楚。记住,这一点需要保证在①的前提下才可以!

② 避免使用二级缓存

可能会有很多人不理解这里,二级缓存带来的好处远远比不上他所隐藏的危害。

缓存是以namespace为单位的,不同namespace下的操作互不影响。

insert,update,delete操作会清空所在namespace下的全部缓存。

通常使用MyBatis Generator生成的代码中,都是各个表独立的,每个表都有自己的namespace。

为什么避免使用二级缓存

在符合① 什么时候使用二级缓存的要求时,并没有什么危害。其他情况就会有很多危害了。

针对一个表的某些操作不在他独立的namespace下进行。

例如在UserMapper.xml中有大多数针对user表的操作。但是在一个XXXMapper.xml中,还有针对user单表的操作。

这会导致user在两个命名空间下的数据不一致。如果在UserMapper.xml中做了刷新缓存的操作,在XXXMapper.xml中缓存仍然有效,如果有针对user的单表查询,使用缓存的结果可能会不正确。

更危险的情况是在XXXMapper.xml做了insert,update,delete操作时,会导致UserMapper.xml中的各种操作充满未知和风险。

有关这样单表的操作可能不常见。但是你也许想到了一种常见的情况。


多表操作一定不能使用缓存

首先不管多表操作写到那个namespace下,都会存在某个表不在这个namespace下的情况。

例如两个表:role和user_role,如果我想查询出某个用户的全部角色role,就一定会涉及到多表的操作。

<select id="selectUserRoles" resultType="UserRoleVO">
    select * from user_role a,role b where a.roleid = b.roleid and a.userid = #{userid}
</select>

像上面这个查询,你会写到那个xml中呢??

不管是写到RoleMapper.xml还是UserRoleMapper.xml,或者是一个独立的XxxMapper.xml中。如果使用了二级缓存,都会导致上面这个查询结果可能不正确。

如果你正好修改了这个用户的角色,上面这个查询使用缓存的时候结果就是错的。

在我看来,就以MyBatis目前的缓存方式来看是无解的。多表操作根本不能缓存。

如果你让他们都使用同一个namespace(通过<cache-ref>)来避免脏数据,那就失去了缓存的意义。


③ 挽救二级缓存**

想更高效率的使用二级缓存是解决不了了,但是解决多表操作避免脏数据还是有法解决的。

解决思路就是通过拦截器判断执行的sql涉及到那些表(可以用jsqlparser解析),然后把相关表的缓存自动清空。但是这种方式对缓存的使用效率是很低的。

设计这样一个插件是相当复杂的,还是建议,放弃二级缓存,在业务层使用可控制的缓存代替更好。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

流烟默

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

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

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

打赏作者

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

抵扣说明:

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

余额充值