mybatis 源码解析之 cache

预备知识

对 Mybatis 的执行流程有一定的了解。

一级缓存

在读多写少的情况下,减少了网络的开销,显著提升性能。

配置

public enum LocalCacheScope {
  SESSION,STATEMENT
}

对应到配置文件

<setting name="localCacheScope" value="SESSION"/>

支持 SESSION 和 STATEMENT 两种。其中

SESSION :指在当前事务中都使用查询缓存。

STATEMENT:指在当前 Sql 语句中使用查询缓存

注:如果是 SESSION 的情况下,如果有多个 SqlSession 同时操作操作数据库的同一张表,就可能出现脏读。

实现原理

一级缓存的实现在 BaseExecutor:每一个SqlSession中持有了自己的Executor,每一个Executor(都继承自 BaseExecutor)中有一个Local Cache。

当用户发起查询时,Mybatis会根据当前执行的MappedStatement生成一个key,去Local Cache中查询,

  1. 如果缓存命中的话
  2. 如果缓存没有命中的话,将写一个占位符,这样后续的读,就可以利用缓存了。之后查询 DB,将结果写入Local Cache。
  3. 如果配置为 STATEMENT,清除缓存

最后返回结果给用户。

public abstract class BaseExecutor implements Executor {
    
  //本地缓存,一个 Executor 对应一个 localCache
  protected PerpetualCache localCache;
  protected PerpetualCache localOutputParameterCache;
  
  //key 为 Statement Id + Offset + Limmit + Sql + Params
  @Override
  public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
    List<E> list;
    try {
      queryStack++;
      //从本地缓存查
      list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
      if (list != null) {
        //本地缓存命中
        handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
      } else {
        //本地缓存没有命中
        list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
      }
    } finally {
      queryStack--;
    }
    if (queryStack == 0) {
      for (DeferredLoad deferredLoad : deferredLoads) {
        deferredLoad.load();
      }
      // issue #601
      deferredLoads.clear();
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        // STATEMENT 级别,清除缓存
        clearLocalCache();
      }
    }
    return list;
  }
  
  @Override
  public void clearLocalCache() {
    if (!closed) {
      localCache.clear();
      localOutputParameterCache.clear();
    }
  }
    
  private void handleLocallyCachedOutputParameters(MappedStatement ms, CacheKey key, Object parameter, BoundSql boundSql) {
    if (ms.getStatementType() == StatementType.CALLABLE) {
      final Object cachedParameter = localOutputParameterCache.getObject(key);
      if (cachedParameter != null && parameter != null) {
        final MetaObject metaCachedParameter = configuration.newMetaObject(cachedParameter);
        final MetaObject metaParameter = configuration.newMetaObject(parameter);
        for (ParameterMapping parameterMapping : boundSql.getParameterMappings()) {
          if (parameterMapping.getMode() != ParameterMode.IN) {
            final String parameterName = parameterMapping.getProperty();
            final Object cachedValue = metaCachedParameter.getValue(parameterName);
            metaParameter.setValue(parameterName, cachedValue);
          }
        }
      }
    }
  }
    
  //这里缓存更新是有讲究的。
  private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    List<E> list;
    //写缓存的占位符,下次查询就不会走这里了。避免了缓存穿透
    localCache.putObject(key, EXECUTION_PLACEHOLDER);
    try {
      //从数据库查询
      list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
      localCache.removeObject(key);
    }
    //将数据库数据写入缓存。如果在此之前将来了新的查询,将返回 EXECUTION_PLACEHOLDER
    localCache.putObject(key, list);
    if (ms.getStatementType() == StatementType.CALLABLE) {
      localOutputParameterCache.putObject(key, parameter);
    }
    return list;
  }
    
  @Override
  public int update(MappedStatement ms, Object parameter) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    //每次更新的时候都会清空缓存。update,delete, insert 都调用 update 来完成。
    clearLocalCache();
    return doUpdate(ms, parameter);
  }
}

一级缓存演示&失效情况

同一次会话期间只要查询过的数据都会保存在当前SqlSession的一个Map中

  • key:hashCode+查询的SqlId+编写的sql查询语句+参数
  • 一级缓存失效的四种情况

1、不同的SqlSession对应不同的一级缓存
2、同一个SqlSession但是查询条件不同
3、同一个SqlSession两次查询期间执行了任何一次增删改操作
4、同一个SqlSession两次查询期间手动清空了缓存

验证

配置为 SESSION

  1. 同一个事务 sqlSession 3次查询同一个表,检查后续请求是否查询缓存
  2. 三个事务,每个事实的 sqlSession 各查询同一张表一次。检查后续请求是否查询缓存
  3. 两个 sqlSession,一个事务 sqlSession1 连续两次查询表,sqlSession2 在 sqlSession1 的两次查询中间修改表
  4. 两个 sqlSession,两个事务 sqlSession1 各一次查询表,sqlSession2 在两次查询中间修改表

配置为 STATEMENT

  1. 同一个事务 sqlSession 3次查询同一个表,检查后续请求是否查询缓存
  2. 三个事务,每个事实的 sqlSession 各查询同一张表一次。检查后续请求是否查询缓存
  3. 两个 sqlSession,一个事务 sqlSession1 连续两次查询表,sqlSession2 在 sqlSession1 的两次查询中间修改表
  4. 两个 sqlSession,两个事务 sqlSession1 各一次查询表,sqlSession2 在两次查询中间修改表

总结

  1. Mybatis一级缓存的生命周期和SqlSession一致。
  2. 不管是 SESSION 还是 STATEMENT 在跨事务都不共享缓存。因为在事务提交的时候会清空缓存。
  3. Mybatis的缓存是一个粗粒度的缓存,没有更新缓存和缓存过期的概念,同时只是使用了默认的hashmap,也没有做容量上的限定。
  4. Mybatis的一级缓存最大范围是SqlSession内部,有多个SqlSession或者分布式的环境下,有操作数据库写的话,会引起脏数据,建议是把一级缓存的默认级别设定为Statement,即不使用一级缓存。

Cache 接口

public interface Cache {

  /**
   * @return The identifier of this cache
   */
  String getId();

  /**
   * @param key Can be any object but usually it is a {@link CacheKey}
   * @param value The result of a select.
   */
  void putObject(Object key, Object value);

  /**
   * @param key The key
   * @return The object stored in the cache.
   */
  Object getObject(Object key);

  /**
   * As of 3.3.0 this method is only called during a rollback 
   * for any previous value that was missing in the cache.
   * This lets any blocking cache to release the lock that 
   * may have previously put on the key.
   * A blocking cache puts a lock when a value is null 
   * and releases it when the value is back again.
   * This way other threads will wait for the value to be 
   * available instead of hitting the database.
   *
   * 
   * @param key The key
   * @return Not used
   */
  Object removeObject(Object key);

  /**
   * Clears this cache instance
   */  
  void clear();

  /**
   * Optional. This method is not called by the core.
   * 
   * @return The number of elements stored in the cache (not its capacity).
   */
  int getSize();
}

实现类:

  • PerpetualCache:HashMap 保存 key,value

装饰器类

  • blockingCache : 基于 key 的锁。每个 key 一个锁。注:只有当前线程才能释放锁。也可以通过设置超时时间,避免一直阻塞等待锁。问题:putObject 为什么没有加锁?
  • FifoCache:FIFO 的淘汰策略。默认队列大小为 1024。
  • LoggingCache:用日志记录查询的命中率
  • LruCache:利用 LinkHashMap 实现 Lru 算法。由于 LinkHashMap 实现了 LRU 算法,这里非常取巧
  • ScheduledCache:每间隔 clearInterval(默认一小时)将所有缓存清空。
  • SerializedCache:将 value 用 ObjectInputStream,ByteArrayInputStream 序列化,对 key 没有序列化。
  • SoftCache:第一次将值存储在软引用对象中,每次操作(put 和 remove)时都将已经被 GC 的 valuel 删除。每次获取(get),对应的软引用没有被删除,就加入Deqeue队列。Deque 是一个默认长度为 256 的先进先出队列。这种实现机制非常类似 Java 的 GC 的简化版。
  • SynchronizedCache:每个操作前面加了 synchronized 关键词
  • TransactionalCache:类似数据库事务机制。将 put 缓存在临时 Map 中,当调用 commit 的时候,才清除临时Map,写入真正 cache 中。
  • WeakCache:第一次将值存储在弱引用对象中,每次操作(put 和 remove)时都将已经被 GC 的 valuel 删除。每次获取(get),对应的弱引用没有被删除,就加入Deqeue队列。Deque 是一个默认长度为 256 的先进先出队列。这种实现机制非常类似 Java 的 GC 的简化版。
  • TransactionalCacheManager:保存 Cache 和 TransactionalCache 的映射关系

Cache 建造者

CacheBuilder。

二级缓存

  1. 二级缓存(second level cache),全局作用域缓存;二级缓存默认不开启,需要手动配置
  2. MyBatis提供二级缓存的接口以及实现,缓存实现要求 POJO实现Serializable接口
  3. 二级缓存在 SqlSession 关闭或提交之后才会生效

配置

<setting name="cacheEnabled" value="true"/>

每个 Mapper 支持 CacheNameSpace 和 CacheNameRef 来配置,先解析配置文件,后解析注解,注解的配置会覆盖配置文件的配置。配置文件先解析 cache-ref 标签,后解析 cache 标签。 注解先解析CacheNameSpace,后解析 CacheNameRef。CacheNameRef 会覆盖 CacheNameSpace的配置。因此,配置文件和接口注释是不能够配合使用的。

CacheNameSpace

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface CacheNamespace {
  Class<? extends org.apache.ibatis.cache.Cache> implementation() default PerpetualCache.class;

  Class<? extends org.apache.ibatis.cache.Cache> eviction() default LruCache.class;

  long flushInterval() default 0;

  int size() default 1024;

  boolean readWrite() default true;
  
  boolean blocking() default false;

  /**
   * Property values for a implementation object.
   * @since 3.4.2
   */
  Property[] properties() default {};
}
  private void parseCache() {
    CacheNamespace cacheDomain = type.getAnnotation(CacheNamespace.class);
    if (cacheDomain != null) {
      Integer size = cacheDomain.size() == 0 ? null : cacheDomain.size();
      Long flushInterval = cacheDomain.flushInterval() == 0 ? null : cacheDomain.flushInterval();
      Properties props = convertToProperties(cacheDomain.properties());
      assistant.useNewCache(cacheDomain.implementation(), cacheDomain.eviction(), flushInterval, size, cacheDomain.readWrite(), cacheDomain.blocking(), props);
    }
  }

 private Properties convertToProperties(Property[] properties) {
    if (properties.length == 0) {
      return null;
    }
    Properties props = new Properties();
    for (Property property : properties) {
      props.setProperty(property.name(),
          PropertyParser.parse(property.value(), configuration.getVariables()));
    }
    return props;
  }

  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。

方式一:配置文件

解析 cache 标签

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.example.TestMapper">
 
    <cache type="com.example.MybatisRedisCache" eviction="FIFO" flushInterval="60000" readOnly="false" size="1024" blocking="false">
        <property name="key1" value="value1" />
        <property name="key2" value="value2" />
    </cache>

</mapper>
  • type:默认只实现了 PerPetualCache,默认也只能是PerPetualCache,如果你实现了自己的 Cache,可以配置。比如,你可以实现用 Redis 或 ECache,实现 Cache 接口,即可。

  • eviction:实现了 LruCache、FifoCache、SoftCache、WeakCache

  • flushInterval:多长时间将所有缓存全部清空。实际为 ScheculedCache

  • size:缓存数量

  • readOnly:默认 false,如果为 false,对 value 进行序列化。否则不进行序列化

  • blocking:默认 false。是否是阻塞式的,如果拿不到锁,一直阻塞。

方式二:注解方式

@CacheNamespace(implementation = MybatisRedisCache.class, size=256, flushInterval=10000, readWrite=true, blocking=false, eviction=FiFoCache.class, properties={"eviction":"LRU", "flushInterval":"100",})
public interface TestMapper(
    @Select("select t_user.* from t_user where t_user.t_user_id = #{tUserId}")
    @Options(useCache = true)
    List<PeoplePo> getUser(PeopleVo p);
}

CacheNamespaceRef

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface CacheNamespaceRef {
  /**
   * A namespace type to reference a cache (the namespace name become a FQCN of specified type)
   */
  Class<?> value() default void.class;
  /**
   * A namespace name to reference a cache
   * @since 3.4.2
   */
  String name() default "";
}
  private void parseCacheRef() {
    CacheNamespaceRef cacheDomainRef = type.getAnnotation(CacheNamespaceRef.class);
    if (cacheDomainRef != null) {
      Class<?> refType = cacheDomainRef.value();
      String refName = cacheDomainRef.name();
      //不能都不配置
      if (refType == void.class && refName.isEmpty()) {
        throw new BuilderException("Should be specified either value() or name() attribute in the @CacheNamespaceRef");
      }
      //不能都配置
      if (refType != void.class && !refName.isEmpty()) {
        throw new BuilderException("Cannot use both value() and name() attribute in the @CacheNamespaceRef");
      }
      //value 的优先级高于 name 的优先级
      String namespace = (refType != void.class) ? refType.getName() : refName;
      try {
        assistant.useCacheRef(namespace);
      } catch (IncompleteElementException e) {
        configuration.addIncompleteCacheRef(new CacheRefResolver(assistant, namespace));
      }
    }
  }

  public Cache useCacheRef(String namespace) {
    if (namespace == null) {
      throw new BuilderException("cache-ref element requires a namespace attribute.");
    }
    try {
      unresolvedCacheRef = true;
      //从已有的 Cache 中查找
      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);
    }
  }

用来引用已配置的 Cache。支持配置文件和注解两种,配置文件先解析,之后注解,注解会覆盖配置文件。

方式一:配置文件

CacheNamespaceRef 为 cache-ref 标签下的 namespace

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.example.TestMapper">
    <cache-ref namespace="MybatisRedisCache"</cache-ref>
</mapper>

方式二:注解方式

支持 value 和 name 两种方式,但只能配置一种,value 的优先级高于 name 的优先级。

@CacheNamespaceRef(implementation = MybatisRedisCache.class)
public interface TestMapper(
    @Select("select t_user.* from t_user where t_user.t_user_id = #{tUserId}")
    @Options(useCache = true)
    List<PeoplePo> getUser(PeopleVo p);
}

配置缓存的方式

1、全局setting的cacheEnable:

  • 配置二级缓存的开关。一级缓存一直是打开的。

2、select标签的useCache属性:

  • 配置这个select是否使用二级缓存。一级缓存一直是使用的

3、sql标签的flushCache属性:

  • 增删改默认flushCache=true。sql执行以后,会同时清空一级和二级缓存。
  • 查询默认flushCache=false。

4、sqlSession.clearCache():

  • 只是用来清除一级缓存。

5、二级缓存需要手动开启和配置,他是基于namespace级别的缓存。当在某一个作用域 (一级缓存Session/二级缓存Namespaces) 进行了 C/U/D 操作后,默认该作用域下所有 select 中的缓存将被clear。

缓存初始化

  public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? defaultExecutorType : executorType;
    executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
    Executor executor;
    if (ExecutorType.BATCH == executorType) {
      executor = new BatchExecutor(this, transaction);
    } else if (ExecutorType.REUSE == executorType) {
      executor = new ReuseExecutor(this, transaction);
    } else {
      executor = new SimpleExecutor(this, transaction);
    }
    if (cacheEnabled) {
      executor = new CachingExecutor(executor);
    }
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
  }

有了前面的基础,这里理解起来就非常容易了。

public class CachingExecutor implements Executor {

  private final Executor delegate;
  private final TransactionalCacheManager tcm = new TransactionalCacheManager();

  public CachingExecutor(Executor delegate) {
    this.delegate = delegate;
    delegate.setExecutorWrapper(this);
  }

  @Override
  public Transaction getTransaction() {
    return delegate.getTransaction();
  }

  @Override
  public void close(boolean forceRollback) {
    try {
      //issues #499, #524 and #573
      if (forceRollback) { 
        tcm.rollback();
      } else {
        tcm.commit();
      }
    } finally {
      delegate.close(forceRollback);
    }
  }

  @Override
  public boolean isClosed() {
    return delegate.isClosed();
  }

  @Override
  public int update(MappedStatement ms, Object parameterObject) throws SQLException {
    flushCacheIfRequired(ms);
    return delegate.update(ms, parameterObject);
  }

  @Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

  @Override
  public <E> Cursor<E> queryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds) throws SQLException {
    flushCacheIfRequired(ms);
    return delegate.queryCursor(ms, parameter, rowBounds);
  }

  @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, 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);
          //加入二级缓存。问题:如果一级缓存返回 PLACEHOLDER,这里是否始终PLACEHOLDER,导致返不期望的结果?
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        return list;
      }
    }
    //缓存没有命中,从代理中查。继续查询一级缓存。
    return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

  @Override
  public List<BatchResult> flushStatements() throws SQLException {
    return delegate.flushStatements();
  }

  @Override
  public void commit(boolean required) throws SQLException {
    delegate.commit(required);
    tcm.commit();
  }

  @Override
  public void rollback(boolean required) throws SQLException {
    try {
      delegate.rollback(required);
    } finally {
      if (required) {
        tcm.rollback();
      }
    }
  }

  private void ensureNoOutParams(MappedStatement ms, BoundSql boundSql) {
    if (ms.getStatementType() == StatementType.CALLABLE) {
      for (ParameterMapping parameterMapping : boundSql.getParameterMappings()) {
        if (parameterMapping.getMode() != ParameterMode.IN) {
          throw new ExecutorException("Caching stored procedures with OUT params is not supported.  Please configure useCache=false in " + ms.getId() + " statement.");
        }
      }
    }
  }

  @Override
  public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    return delegate.createCacheKey(ms, parameterObject, rowBounds, boundSql);
  }

  @Override
  public boolean isCached(MappedStatement ms, CacheKey key) {
    return delegate.isCached(ms, key);
  }

  @Override
  public void deferLoad(MappedStatement ms, MetaObject resultObject, String property, CacheKey key, Class<?> targetType) {
    delegate.deferLoad(ms, resultObject, property, key, targetType);
  }

  @Override
  public void clearLocalCache() {
    delegate.clearLocalCache();
  }

  private void flushCacheIfRequired(MappedStatement ms) {
    Cache cache = ms.getCache();
    if (cache != null && ms.isFlushCacheRequired()) {      
      tcm.clear(cache);
    }
  }

  @Override
  public void setExecutorWrapper(Executor executor) {
    throw new UnsupportedOperationException("This method should not be called");
  }

}

验证

  1. 不提交事务,sqlSession1查询完数据后,sqlSession2相同的查询是否会从缓存中获取数据。

  2. 当提交事务时,sqlSession1查询完数据后,sqlSession2相同的查询是否会从缓存中获取数据。

  3. 同一个事务 sqlSession 3次查询同一个表,检查后续请求是否查询缓存

  4. 两个 sqlSession,一个事务 sqlSession1,sqlSession2 各连续两次查询表,sqlSession3 在 sqlSession1 的两次查询中间修改表,

    SqlSession1 SqlSession2 SqlSession3
    查询
    查询 查询
    提交
    查询
    更新
    查询
  5. 两个 sqlSession,两个事务 sqlSession1 各一次查询表,sqlSession2 在两次查询中间修改表

总结

  1. Mybatis的二级缓存相对于一级缓存来说,实现了SqlSession之间缓存数据的共享,同时粒度更加的细,能够到Mapper级别,通过Cache接口实现类不同的组合,对Cache的可控性也更强。
  2. Mybatis在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用的条件比较苛刻。
  3. 在分布式环境下,由于默认的Mybatis Cache实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将Mybatis的Cache接口实现,有一定的开发成本,不如直接用Redis,Memcache实现业务上的缓存就好了

总结

mybatis 的 cache 设计还是很赞的。尤其是初始化部分 CacheBuilder。

但是 mybatis 的缓存总体上来说不如直接上 Redis 好。原因在于一不小心就会出现脏读。

参考

实例参考 https://www.jianshu.com/p/c553169c5921

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章