MyBatis 学习笔记

打印 上一主题 下一主题

主题 907|帖子 907|积分 2721

MyBatis 执行器

JDBC 的执行过程分为四步:

  • 获取数据库连接(Connection)
  • 预编译 SQL(PrepareStatement)
  • 设置参数
  • 执行 SQL(ResultSet)
MyBatis 提供了执行器 Executor 将这一过程进行封装,对外提供 SqlSession 让用户通过调用其 API 直接操作数据库,由于 SqlSession 持有执行器 Executor 的引用

MyBatis 提供了实现 Executor 接口的 BaseExecutor 类,以及继承 BaseExecutor 的三个子类,分别是:

  • SimpleExecutor:简单执行器
  • ReuseExecutor:可重用执行器,能重用预编译的 SQL,好比一次查询执行 100 次,如果利用  SimpleExecutor 就需要重复预编译 100 次相同的 SQL,而 ReuseExecutor 只需要预编译一次即可
  • BatchExecutor:批处理执行器,能批量处理修改操作
这三个执行器都实现了父类的抽象方法 doQuery 和 doUpdate,而父类 BaseExecutor 又会在自身的 query 和 update 方法中分别调用子类的 doQuery 和 doUpdate 方法,这是由于 BaseExecutor 在真正调用子类方法查询数据前会先查找一级缓存,如果一级缓存有数据则直接返回
然而,SqlSession 持有的并不是 BaseExecutor 的引用,而是 CachingExecutor。CachingExecutor 不是 BaseExecutor 的子类,而是一个包装类,对 BaseExecutor 的功能进行加强,增加了二级缓存。如果在二级缓存查询到数据就直接返回,否则才会执行 BaseExecutor 的查询方法
无论一级缓存照旧二级缓存,默认更新数据后会清空缓存

一级缓存

一级缓存在 BaseExecutor 实现,以 key/Value 的情势生存
一级缓存生效有四个条件:

  • SQL 语句和参数必须相同
  • 必须是相同的 statementId
  • 必须是同一个 SqlSession(会话)
  • RowBounds 返回行范围必须相同
第一种情况好明白,SQL 语句和参数都是缓存 Key 的组成部分
第二种情况如下代码所示,UserMapper 界说的 selectById1 和 selectById2 的 SQL 语句和参数都相同,但不是同一个 Statement,StatementId 也是缓存 Key 的组成部分,所以无法掷中缓存
  1. UserMapper mapper = sqlSession.getMapper(UserMapper.class);
  2. User user1 = mapper.selectById1(10);
  3. User user2 = mapper.selectById2(10);
复制代码
第三种情况,由于 SqlSession 和 Executor 是一对一的绑定关系,所以不同的 SqlSession 利用的不是同一个缓存
第四种情况,RowBounds 也是缓存 Key 的组成部分
清空一级缓存的场景有四个:

  • 手动提交或回滚
  • 设置属性 flushCache = true
  • 执行更新操作
  • 配置缓存作用域为 STATEMENT
当 Spring 整合 MyBatis 时,有时会出现一级缓存未被利用情况,是由于 Spring 利用动态代理生成 Mapper 代理对象,而每个代理对象在执行方法时实际上会新建一个 SqlSession 进行操作,由于每个方法的 SqlSession 不一致,所以无法利用一级缓存。解决办法是将多个方法放在同一个事务,当处于同一个事务时,就会将创建的 SqlSession 生存在该事务对应的事务管理器,每一次都从里面获取

二级缓存

一级缓存在 CachingExecutor 实现,实现方式与一级缓存类似,但它的作用范围是整个应用,可以跨线程利用
MyBatis 提供 Cache 接口界说二级缓存,并提供六个实现类:

  • SynchronizedCache:线程安全
  • LoggingCache:记录掷中率
  • LRUCache:溢出清算
  • ScheduledCache:过期清算
  • BlockingCache:防止缓存穿透
  • PerpetualCache:利用内存存储
这六个实现类基于装饰器 + 责任链模式实现 MyBatis 二级缓存,一个实现类完成自己的工作会交由下一个实现类继承工作,直到走到末端

在 MyBatis 中,每个 Mapper 都必须界说自己的命名空间 NameSpace,每个 NameSpace 都有自己的二级缓存,好比 UserMapper 的二级缓存和 OrderMapper 拥有不同的命名空间,所以它们拥有各自的二级缓存,缓存的数据就是自身 Mapper 所界说查询语句所能得到的数据。不同的 SqlSession 访问同一个命名空间才是访问同一个二级缓存,因此要想利用二级缓存必须在 Mapper 对应的xml中添加  标签或在 Mapper 接口添加 @CacheNamespace 注解声明命名空间
也可以利用  标签或 @CacheNamespaceRef 注解声明一个关联的命名空间,将两个命名空间的二级缓存组合在一起,这样对数据的生存和修改都能一起进行了,通常用于多表联查的场景。好比在 AMapper 连接查询 A 表和 B 表的数据,结果会生存在 AMapper 的二级缓存,如果 BMapper 修改 B 表的数据,此时再连接查询,得到的照旧之前缓存的 B 表数据,此时可以在 A 命名空间关联 B 命名空间来解决
二级缓存是线程共享的,所以查询到的数据必须提交后才会放入二级缓存,这是为了防止脏读问题。假设会话一的事务先修改了 A 数据,再查询 A 数据并立即更新到二级缓存,这时会话二从二级缓存查询到更新后的 A 数据,而会话一又对 A 数据进行回滚,那么会话二读到的就是脏数据

正是由于二级缓存的这个特性,因此查询到的数据在未提交前会放到一个暂存区,只有提交后才会真正更新到缓存。具体实现为 CachingExecutor 持有一个 TransactionalCacheManager,TransactionalCacheManager 生存了每个二级缓存对应的暂存区。查询时,首先通过 MappedStatement 获取对应的二级缓存,再获取其对应暂存区,暂存区会持有对应二级缓存的引用,通过二级缓存查找数据,如果不存在则查找数据库,并生存在暂存区,等到提交后才更新到缓存,并清空暂存区。更新时,由于提交后会清空二级缓存,所以暂存区也会被清空

StatementHandler

在 JDBC 中会构建 Statement 并设置参数,然后执行 SQL,StatementHandler 的作用是用于构建 Statement,绑定参数,并组装结果返回
和 Executor 一样,MyBatis 提供了 StatementHandler 接口以及实现该接口的 BaseStatementHandler 类,而其子类又有四个:

  • RoutingStatementHandler:不提供具体的实现,只是根据范例创建不同的 StatementHandler
  • SimpleStatementHandler:对应 Statement 对象,用于没有预编译参数的 SQL 的运行
  • PreparedStatementHandler:对应 PreparedStatement 对象,用于预编译参数 SQL 的运行
  • CallableStatementHandler:对应 CallableStatement 对象,用于存储过程运行
下面以常用的 PreparedStatementHandler 为例讲解其执行过程:
1. 创建 PreparedStatement

首先通过 Configuration 对象创建 RoutingStatementHandler,RoutingStatementHandler会根据设置的范例返回具体的 StatementHandler,通过调用 StatementHandler 的 instantiateStatement 方法得到对应的 Statement
2. 参数设置

MyBatis 会利用 ParamNameResolver 剖析方法参数,以键值对的情势生存,一般会按参数顺序设置为 param0-value0,param1-value1 以此类推,如果对参数设置 @Param 注解则是利用其配置的参数名,jdk8 之后可以通过反射直接获取参数名。再通过 BoundSql 获取 xml 或注解中 sql 的参数占位符,通过名称映射利用对应 TypeHandler 进行范例转换,并在 PrepareStatement 中设置参数
3. 处理结果集

MyBatis 执行 SQL 后获得结果集,调用 DefaultResultSetHandler 的 handleResultSets 方法处理结果集,处理结果集的每一行并映射为对应的 Java 对象,末了放入 multipleResults 并返回

MetaObject

MetaObject 类是一个用于操作 Java 对象属性的工具类,它提供了一种统一的方式来访问和操作 Java 对象的属性
MetaObject 主要方法如下:

  • hasGetter(name):判断是否有属性 name 或 name 的 getter 方法
  • getGetterNames():获取含有 getter 相干的属性名称
  • getGetterType(name):获取 getter 方法的返回范例
  • getValue(name):获取属性值
  • hasSetter(name):判断是否有属性 name 或 name 的 setter 方法
  • getSetterNames():获取含有 setter 相干的属性名称
  • getSetterType(name):获取 setter 方法的参数范例
  • setValue(name, value):设置属性值
MetaClass 则用于获取类相干的信息
MetaClass 主要方法如下:

  • 静态方法 forClass(type, reflectorFactory):创建 MetaClass 对象
  • hasDefaultConstructor():判断是否有默认构造方法
  • hasGetter(name):判断是否有属性 name 或 name 的 getter 方法
  • getGetterNames():获取含有 getter 相干的属性名称
  • getGetInvoker(name):获取 name 的 getter 方法的 Invoker
  • hasSetter(name):判断是否有属性 name 或 name 的 setter 方法
  • getSetterNames():获取含有 setter 相干的属性名称
  • getSetterType(name):获取 setter 方法的参数范例
  • getSetInvoker(name):name 的 setter 方法的 Invoker

MyBatis 解决循环依赖

MyBatis 手动映射结果集时,ResultMap 可以包含子查询,如果 A 查询的 resultMap 包含了 B 属性,而 B 属性又是通过子查询获得,而这个子查询又是 A 查询,就会出现循环依赖问题
如下例子,调用 selectBlogById 会触发子查询 selectCommentsByBlogId,而子查询又触发 selectBlogById,造成循环依赖
  1. <resultMap id="blogMap" type="blog" autoMapping="true">
  2.   <result column="title" property="title"/>
  3.   <collection property="comments" column="id" select="selectCommentsByBlogId"/>
  4. </resultMap>
  5. <resultMap id="commentMap"type="comment">
  6.   <association property="blog" column="blog_id" select="selectBlogById"/>
  7. </resultMap>
  8. <select id="selectBlogById" resultMap="blogMap">
  9.   select * from blog where id=#{id}
  10. </select>
  11. <select id="selectCommentsByBlogId" resultMap="commentMap">
  12.   select * from comment where blog_id=#{blogId}
  13. </select>
  14. </mapper>
复制代码
MyBatis 为了解决该问题,利用了延迟加载方案。MyBatis 在执行查询时,会将结果放入一级缓存,但一开始放入的值只是一个没有意义的占位符,只有等到查询过程结束才会真正将值生存到缓存。子查询在执行前会先查找一级缓存,如果存在可用的值则直接利用,如果不存在或者存在但不可用(生存的是占位符),则会构建一个能唯一表现该子查询的 DeferredLoad 生存起来。等到所有子查询走完,回到最顶层的查询时,才会遍历所有 DeferredLoad 从缓存中获取值填充属性

MyBatis 懒加载

所谓懒加载,就是在真正利用到对应数据时才从数据库获取,好比有如下 Blog 类:
  1. public class Blog implements Serializable {
  2.     private Integer id;
  3.     ...
  4.     private List<Comment> comments;
  5.   // set/get/toString 等方法
  6.     ......
  7. }
  8. public class Comment implements Serializable {
  9.     ...
  10. }
复制代码
界说 Mapper
  1. <resultMap id="blogMap" type="Blog" autoMapping="true">
  2.     <id property="id" column="id"></id>
  3.     ...
  4.     <association property="comments" column="id" select="selectCommentsByBlog" fetchType="lazy">
  5.         ...
  6.     </association>
  7. </resultMap>
  8. <select id="selectBlogById" resultMap="blogMap">
  9.    SELECT * FROM blog WHERE id = #{id}
  10. </select>
  11. <select id="selectCommentsByBlog" resultType="Comment">
  12.     select * from comment where blog_id = #{id}
  13. </select>
复制代码
当调用 selectBlogById 获取 Blog 对象后,其中的 comments 属性着实并没有值,只有等到利用时如调用 getComments 方法才会真正执行子查询去获取数据
当启用懒加载时,查询返回的对象是一个利用 javassist 框架创建的代理对象,该代理对象重写了被代理类的所有方法,并为每一个需要懒加载的属性创建一个装载器 ResultLoader,以 Map 的情势生存在代理对象中。当调用代理对象的方法时,会判断该方法是否是懒加载属性的 get 方法,或者是 clone()、equals()、hashCode()、toString() 方法,如果是前者就找到对应属性的 ResultLoader 执行查询,后者则会调用所有 ResultLoader 为所有懒加载属性赋值。ResultLoader 利用完毕后就会删除,因此如果 ResultLoader 只能利用一次,如果中途发生异常,那么下一次调用就不会触发懒加载了。另外,如果在调用懒加载属性的 get 方法之前利用 set 方法赋值,那么 MyBatis 会自动把对应的 ResultLoader 删除,也就不会触发懒加载了

联合查询 & 嵌套映射

利用联合查询获取结果,除了新增字段,还可以利用嵌套映射
  1. <resultMap id="blogMap" type="blog" autoMapping="true">
  2.   <id column="id" property="id" />
  3.   <collection property="comments" ofType="comment" autoMapping="true" columnPrefix="comment_">
  4.   </collection>
  5. </resultMap>
  6. <select id="selectBlogById" resultMap="blogMap">
  7.   select a.id,a.title,
  8.   c.id as comment_id,
  9.   c.body as comment_body
  10.   from blog a
  11.   left join comment c on a.id=c.blog_id
  12.   where a.id = #{id};
  13. </select>
复制代码
机制分析如下:


测试代码如下:
  1. String resource = "mybatis-config.xml";
  2. InputStream inputStream = Resources.getResourceAsStream(resource);
  3. SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
  4. SqlSession session = sqlSessionFactory.openSession();
  5. Object o = session.selectOne("com.xxx.xxx.mapper.UserMapper.selectUserById", 1);
  6. session.close();
复制代码
前两行代码是加载 mybatis 的配置文件获取流对象,重点从第三行开始,此次会根据配置文件初始化一个会话工厂对象,具体过程如下:

  • 剖析主配置文件,把主配置文件里的所有信息封装到 Configuration 对象
  • 剖析配置的 mappers 路径下所有 xml 文件
  • 把每个 xml 的每个 sql 标签剖析成 MapperStatement 对象,MapperStatement 对象生存该 sql 标签的所有数据,并生存到 Configuration 维护的 Map 聚集中,其中 key 是 sql 标签上界说的 id,value 是 MapperStatement 对象
  • 通过 xml 界说的命名空间反射生成 class 对象,并生成对应 Mapper  的 MapperProxy 对象,MapperProxy 对象可用于生成对应 Mapper 的代理对象。将 class 对象和 MapperProxy 对象生存到 Configuration 维护的 Map 聚集中,其中 key 是 class 对象,value 是对应的 MapperProxy 对象
完成初始化后,就可以通过会话工厂获取会话 session 并利用了,具体过程如下:

  • 创建一个执行器 Executor,默认利用 SimpleExecutor
  • 根据 id 从 Configuration 获取 MapperStatement 对象
  • 先在缓存查找,没有就获取数据库连接,处理 sql 和入参并通过 JDBC 的方式执行,处理结果集
  • 将结果放入缓存并返回
还可以利用动态代理的方式利用 MyBatis
  1. SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
  2. SqlSession session = sqlSessionFactory.openSession();
  3. UserDao mapper = session.getMapper(UserDao.class);
  4. User user = mapper.selectUserById(1);
复制代码
MyBatis 会根据传入的 class 对象在 Configuration 中找到对应的 MapperProxy 代理类,并根据 MapperProxy 基于 JDK 动态代理生成代理对象。在 invoke 方法中,先获取 MapperMethod 类,然后调用 mapperMethod.execute() 方法
  1. public class MapperProxy<T> implements InvocationHandler, Serializable {
  2.   ......
  3.   @Override
  4.   public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
  5.     try {
  6.       if (Object.class.equals(method.getDeclaringClass())) {
  7.         return method.invoke(this, args);
  8.       } else if (isDefaultMethod(method)) {
  9.         return invokeDefaultMethod(proxy, method, args);
  10.       }
  11.     } catch (Throwable t) {
  12.       throw ExceptionUtil.unwrapThrowable(t);
  13.     }
  14.     final MapperMethod mapperMethod = cachedMapperMethod(method);
  15.     return mapperMethod.execute(sqlSession, args);
  16.   }
  17.   private MapperMethod cachedMapperMethod(Method method) {
  18.     MapperMethod mapperMethod = methodCache.get(method);
  19.     if (mapperMethod == null) {
  20.       mapperMethod = new MapperMethod(mapperInterface, method, sqlSession.getConfiguration());
  21.       methodCache.put(method, mapperMethod);
  22.     }
  23.     return mapperMethod;
  24. }
  25.   ......
  26. }
复制代码
找到类 MapperMethod 类的 execute 方法,发现 execute 通过调用本类中的其他方法获取并封装返回结果。该类里有两个内部类 SqlCommand 和 MethodSignature,SqlCommand 用来封装 CRUD 操作,也就是我们在 xml 中配置的操作的节点,MethodSignature 用来封装方法的参数以及返回范例。execute 方法根据不同的操作范例分别处理,也就是说动态代理的方式本质上照旧利用 SqlSession 的接口调用
  1. public class MapperMethod {
  2.   private final SqlCommand command;
  3.   private final MethodSignature method;
  4.   public MapperMethod(Class<?> mapperInterface, Method method, Configuration config) {
  5.     this.command = new SqlCommand(config, mapperInterface, method);
  6.     this.method = new MethodSignature(config, mapperInterface, method);
  7.   }
  8.   ......
  9.   public Object execute(SqlSession sqlSession, Object[] args) {
  10.     Object result;
  11.     switch (command.getType()) {
  12.       case INSERT: {
  13.         Object param = method.convertArgsToSqlCommandParam(args);
  14.         result = rowCountResult(sqlSession.insert(command.getName(), param));
  15.         break;
  16.       }
  17.       case UPDATE: {
  18.         Object param = method.convertArgsToSqlCommandParam(args);
  19.         result = rowCountResult(sqlSession.update(command.getName(), param));
  20.         break;
  21.       }
  22.       case DELETE: {
  23.         Object param = method.convertArgsToSqlCommandParam(args);
  24.         result = rowCountResult(sqlSession.delete(command.getName(), param));
  25.         break;
  26.       }
  27.       case SELECT:
  28.         if (method.returnsVoid() && method.hasResultHandler()) {
  29.           executeWithResultHandler(sqlSession, args);
  30.           result = null;
  31.         } else if (method.returnsMany()) {
  32.           result = executeForMany(sqlSession, args);
  33.         } else if (method.returnsMap()) {
  34.           result = executeForMap(sqlSession, args);
  35.         } else if (method.returnsCursor()) {
  36.           result = executeForCursor(sqlSession, args);
  37.         } else {
  38.           Object param = method.convertArgsToSqlCommandParam(args);
  39.           result = sqlSession.selectOne(command.getName(), param);
  40.         }
  41.         break;
  42.       case FLUSH:
  43.         result = sqlSession.flushStatements();
  44.         break;
  45.       default:
  46.         throw new BindingException("Unknown execution method for: " + command.getName());
  47.     }
  48.     if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {
  49.       throw new BindingException("Mapper method '" + command.getName()
  50.           + " attempted to return null from a method with a primitive return type (" + method.getReturnType() + ").");
  51.     }
  52.     return result;
  53.   }
  54.   private <E> Object executeForMany(SqlSession sqlSession, Object[] args) {
  55.     List<E> result;
  56.     Object param = method.convertArgsToSqlCommandParam(args);
  57.     if (method.hasRowBounds()) {
  58.       RowBounds rowBounds = method.extractRowBounds(args);
  59.       result = sqlSession.<E>selectList(command.getName(), param, rowBounds);
  60.     } else {
  61.       result = sqlSession.<E>selectList(command.getName(), param);
  62.     }
  63.     if (!method.getReturnType().isAssignableFrom(result.getClass())) {
  64.       if (method.getReturnType().isArray()) {
  65.         return convertToArray(result);
  66.       } else {
  67.         return convertToDeclaredCollection(sqlSession.getConfiguration(), result);
  68.       }
  69.     }
  70.     return result;
  71.   }
  72.   ......
  73. }
复制代码
Configuration

Configuration 与配置文件(或者配置类)相对应,由 SqlSessionFactoryBuilder 构建而成,用于存放 MyBatis 所需要的配置项
Configuration 的配置元素有如下:

  • properties:全局参数
  • settings:开启配置项,如二级缓存,懒加载
  • environment:界说了 MyBatis 所利用的数据源
  • typeAliasRegistry:范例的别名注册机,内置了许多别名
  • typeHandlerRegistry:范例处理器注册机,TypeHandler 用于范例处理
  • MapperRegistry:映射器注册机,以 Map 的情势存储 MapperProxyFactory ,key 是对应 Mapper 的 Class 类,利用 MapperProxyFactory 可以生成对应 Mapper 的代理对象
  • MappedStatements:sql 的映射声明,key 为 Mapper 中对应的方法名,value 是对应的 MappedStatement
  • resultMaps:自界说的映射结果集

MyBatis 插件

MyBatis 提供的拦截器机制可以分别对 Executor、StatementHandler、ParameterHandler、ResultHandler 组件的操作进行拦截,用户可自界说创建拦截器添加逻辑
MyBatis 提供了拦截器链 InterceptorChain,InterceptorChain 生存了用户自界说的拦截器。当 MyBatis 创建上述四个组件时,都会利用  InterceptorChain 对自身进行包装,如果是对应的拦截器则通过动态代理返回一个包装后的代理对象
  1. public class InterceptorChain {
  2.   private final List<Interceptor> interceptors = new ArrayList>();
  3.   public Object pluginAll(Object target) {
  4.     for (Interceptor interceptor : interceptors) {
  5.       target = interceptor.plugin(target);
  6.     }
  7.     return target;
  8.   }
  9. }
  10. ...
复制代码
自界说拦截器需要实现 MyBatis 的 Interceptor 接口,该接口包含了两个核心方法:intercept 和 plugin,intercept 方法用于拦截和处理具体的逻辑,而 plugin 方法用于创建代理对象并绑定拦截器
@Intercepts 注解用于标记一个类是 MyBatis 拦截器,并指定拦截的方法和参数范例
@Signature 注解用于指定要拦截的方法签名,通常与 @Intercepts 注解一起利用

  • type:指定被拦截的目标范例,目标范例为 Executor.class,表现拦截 Executor 接口的方法
  • method:指定拦截的方法名,拦截的方法名为 update,表现拦截 Executor 接口的 update 方法
  • args:指定拦截的方法参数范例,拦截的方法参数范例为 {MappedStatement.class, Object.class},表现拦截的方法需要接受一个 MappedStatement 范例的参数和一个 Object 范例的参数
  1. @Intercepts({
  2.     @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
  3. })
  4. public class CustomInterceptor implements Interceptor {
  5.     @Override
  6.     public Object intercept(Invocation invocation) throws Throwable {
  7.         // 在执行前进行拦截逻辑
  8.         System.out.println("Before executing the database operation...");
  9.         // 执行原始操作
  10.         Object result = invocation.proceed();
  11.         // 在执行后进行拦截逻辑
  12.         System.out.println("After executing the database operation...");
  13.         return result;
  14.     }
  15.     @Override
  16.     public Object plugin(Object target) {
  17.         // 创建代理对象并绑定拦截器
  18.         return Plugin.wrap(target, this);
  19.     }
  20.     @Override
  21.     public void setProperties(Properties properties) {
  22.         // 可选实现,用于设置拦截器的属性
  23.     }
  24. }
复制代码
在 MyBatis 的配置文件中添加拦截器的配置,在  标签内添加一个  标签,并指定自界说拦截器类的完备路径
  1. <?xml version="1.0" encoding="UTF-8" ?>
  2. <!DOCTYPE configuration
  3.   PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
  4.          "http://mybatis.org/dtd/mybatis-3-config.dtd">
  5. <configuration>
  6.     <plugins>
  7.         <plugin interceptor="com.example.demo.mapper.plugin.CustomInterceptor">
  8.             <property name="key1" value="value1"/>
  9.             <property name="key2" value="value2"/>
  10.             ......
  11.         </plugin>
  12.     </plugins>
  13. </configuration>
复制代码
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

郭卫东

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表