您当前的位置:首页 > 电脑百科 > 程序开发 > 框架

如何深度理解mybatis?

时间:2022-11-13 11:42:55  来源:网易号  作者:

深度自定义MyBatis

回顾mybatis的操作的核心步骤 编写核心类SqlSessionFacotryBuild进行解析配置文件 深度分析解析SqlSessionFacotryBuild干的核心工作 编写核心类SqlSessionFacotry 深度分析解析SqlSessionFacotry干的核心工作 编写核心类SqlSession 深度分析解析SqlSession干的核心工作 总结自定义mybatis用的技术点
一. 回顾mybatis的操作的核心步骤

 

声明一点我们本篇主要探讨的是mybatis的注解方式的操作, 完全从头开始都是小编从头开搞的, 如果与其他大神的代码思维有出入请多指教

我们首先需要准备mybatis的核心配置文件(当然导入相关的坐标这里不在啰嗦)

准备好结果的实体类以及在mApper接口上编写需要执行的sql语句

public class User { private Integer uid; private String username; private String password; private String nickname; } package cn.itcast.mapper; import cn.itcast.pojo.User; import org.Apache.ibatis.annotations.Select; import JAVA.util.List; public interface UserMapper { @Select("select * from users") List findAll(); }

使用mybatis的api来帮助我们完成sql语句的执行以及结果集的封装

//1.关联主配置文件 InputStream in = Resources.getResourceAsStream("mybatis-config.xml"); //2.解析配置文件 SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder(); SqlSessionFactory sqlSessionFactory = builder.build(in); //3.创建会话对象 SqlSession sqlSession = sqlSessionFactory.openSession(); //4.可以采用接口代理的方式 UserMapper mapper = sqlSession.getMapper(UserMapper.class); List all = mapper.findAll(); System.out.println(all); //5.释放资源 sqlSession.close();

思考: mybatis大致是如何帮我们完成相关操作的 ?

我们通过Resources的getResourceAsStream告诉了mybatis我们编写的核心配置文件的位置, mybatis就可以找到我们数据库的连接信息, 也同时找到我们编写的sql语句的地方, 然后可以将其解析按照某种规则存放起来, 我们通过调用接口代理的方式执行方法时, 可以找到对应方法上的sql语句然后执行将结果封装返回给我们

二. 编写核心类SqlSessionFacotryBuild进行解析配置文件

那么我们废话不多说开始我们自定义mybatis的旅程,

1.首先我们需要用户编写配置文件, 然后通过我们自己的Resources来告诉我们配置文件所在位置 package com.itheima.ibatis.configuration; import java.io.InputStream; public class Resources { public static InputStream getResourceAsStream(String path) { return ClassLoader.getSystemClassLoader().getResourceAsStream(path); } } 2. 然后需要定义SqlSessionFacotryBuild来对配置文件进行解析分发 package com.itheima.ibatis.configuration; import com.itheima.ibatis.core.session.SqlSessionFactory; import com.itheima.ibatis.core.session.impl.DefaultSqlSessionFactory; import org.dom4j.Document; import org.dom4j.DocumentException; import org.dom4j.Element; import org.dom4j.Node; import org.dom4j.io.SAXReader; import javax.sql.DataSource; import java.io.File; import java.io.InputStream; import java.lang.reflect.Method; import java.util.List; import java.util.Properties; public class SqlSessionFactoryBuilder { private Configuration configuration = new Configuration(); public SqlSessionFactory build(InputStream in) { SAXReader saxReader = new SAXReader(); Document document = null; try { document = saxReader.read(in); } catch (DocumentException e) { e.printStackTrace(); } Element rootElement = document.getRootElement(); parseEnvironment(rootElement.element("environments")); parseMapper(rootElement.element("mappers")); return new DefaultSqlSessionFactory(configuration); } private void parseMapper(Element mapper) { String pack = mapper.element("package").attributeValue("name"); String directory = pack.replace(".", "/"); String path = ClassLoader.getSystemClassLoader().getResource("").getPath(); File mapperDir = new File(path, directory); if (!mapperDir.exists()) { throw new RuntimeException("找不到mapper映射"); } findMapper(mapperDir, pack); // System.out.println(configuration.getSql()); } private void findMapper(File mapperDir, String base) { File[] files = mapperDir.listFiles(); if (files != null) { for (File file : files) { if (file.isFile()) { if (file.getName().endsWith(".class")) { String name = file.getName(); name = name.substring(0, name.lastIndexOf(".")); String className = base + "." + name; initMapper(className); } } else { findMapper(file, base + "." + file.getName()); } } } } private void initMapper(String className) { try { Class clazz = Class.forName(className); Method[] methods = clazz.getMethods(); for (Method method : methods) { if(method.getAnnotations().length>0){ Mapper mapper = ParseMapper.parse(method); this.configuration.getMappers().put(className + "." + method.getName(), mapper); } } } catch (Exception e) { e.printStackTrace(); } } private void parseEnvironment(Element environments) { String defEnv = environments.attributeValue("default"); Node node = environments.selectSingleNode("//environment[@id='" + defEnv + "']"); List list = node.selectNodes("//property"); Properties properties = new Properties(); for (Element element : list) { String name = element.attributeValue("name"); String value = element.attributeValue("value"); properties.put(name, value); } DataSource dataSource = new DefaultDataSource().getDataSource(properties); configuration.setDataSource(dataSource); } } 三. 深度分析解析SqlSessionFacotryBuild干的核心工作 1. build(InputStream in) 方法做的工作

①借助Dom4j的来解析了xml文件, 将environments解析工作分发给了parseEnvironment(Element environments)

②将mappers的解析工作分发给了parseMapper(Element mapper)

2. parseEnvironment(Element environments)方法做的工作

①主要解析了连接数据库的参数们, 并且创建了数据库连接池

自定义连接池非本章节的重点,所以这里内部本质采用的Druid连接池来做了简化

package com.itheima.ibatis.configuration; import com.alibaba.druid.pool.DruidDataSourceFactory; import javax.sql.DataSource; import java.util.Properties; public class DefaultDataSource { public DataSource getDataSource(Properties properties) { try { return DruidDataSourceFactory.createDataSource(properties); } catch (Exception e) { e.printStackTrace(); } return null; } }

②将解析好的连接池放入configuration对象中,mappers成员变量先别纠结下一章节会讲解

package com.itheima.ibatis.configuration; import lombok.Data; import javax.sql.DataSource; import java.util.HashMap; import java.util.Map; @Data public class Configuration { private Map mappers = new HashMap<>(); private DataSource dataSource; }

详细图解如下图


 

3.parseMapper(Element mapper) 方法做的工作

①解析出用户配置的package找到sql语句所在接口的文件夹, 交给initMapper来处理


 

②递归找到这个包下所有的.class文件,并且获取到接口的全类名, 然后交给initMapper来处理


 

③initMapper通过反射获取类中的每一个方法,将方法交给一个专门解析方法上的注解的工具类ParseMapper的parse方法处理,处理完后将其放到configuration中的mappers的集合中


 

④ParseMapper的parse方法做的工作, 这是解析配置的核心地方

package com.itheima.ibatis.configuration; import java.lang.annotation.Annotation; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; public class ParseMapper { public static Mapper parse(Method method) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { Annotation[] annotations = method.getAnnotations(); Object value = annotations[0].getClass().getMethod("value").invoke(annotations[0]); Mapper mapper = new Mapper(); Class resultType = method.getReturnType(); String val = (String) value; Pattern pattern = Pattern.compile("\#\{\s*\w+\s*\}"); Matcher matcher = pattern.matcher(val); List paramNames = new ArrayList<>(); while (matcher.find()) { String group = matcher.group(); String fieldName = group.substring(2, group.length() - 1).trim(); paramNames.add(fieldName); } String sql = val.replaceAll("\#\{\s*\w+\s*\}", "?"); mapper.setSql(sql); mapper.setParameterNames(paramNames); mapper.setSql(sql); if (resultType == List.class) { mapper.setSelectList(true); Type genericReturnType = method.getGenericReturnType(); ParameterizedType parameterizedType = (ParameterizedType) genericReturnType; Type actualTypeArgument = parameterizedType.getActualTypeArguments()[0]; mapper.setResultType(actualTypeArgument.getTypeName()); mapper.setType("SELECT"); } else if (resultType == Integer.class || resultType == int.class) { mapper.setType("UPDATE"); } else { mapper.setType("SELECT"); mapper.setResultType(resultType.getName()); } return mapper; } }

首先拿到方法上的注解,得到用户填入的sql语句


 

然后处理sql语句#{参数}的这些数据, 然后将参数的顺序保存起来, 用来后期设置参数的数据做准备, 一个

方法对应一个Mapper对象


 

然后再根据结果类型, 判断是什么类型相关的操作,方便后期执行对应的sql语句


 

四. 编写核心类SqlSessionFacotry 1.回顾那个地方创建的SqlSessionFacotry对象

经过SqlSessionFacotryBuilder的努力, 我们成功的将配置文件中核心的信息解析出来并放入了configuration对象中了, 然后我们此时将解析好的configuration传入到SqlSessionFacotry中


 

SqlSessionFactory的实现类如下:

public class DefaultSqlSessionFactory implements SqlSessionFactory { private final Configuration configuration; private TransactionManagement defaultTransactionManagement; public DefaultSqlSessionFactory(Configuration configuration) { this.configuration =configuration; defaultTransactionManagement = new DefaultTransactionManagement(configuration.getDataSource()); } @Override public SqlSession openSession() { return new DefaultSqlSession(configuration,defaultTransactionManagement,false); } } 2.添加事务管理器

事务管理是一个小的功能, 里面希望使用ThreadLocal集合来保证一个用户拿到的链接是同一个


 

事务管理的代码如下:

public class DefaultTransactionManagement implements TransactionManagement { private ThreadLocal threadLocal = new ThreadLocal<>(); private DataSource dataSource; public DefaultTransactionManagement(DataSource dataSource) { this.dataSource = dataSource; } public Connection getConnection() { Connection connection = threadLocal.get(); if (connection == null) { try { connection = dataSource.getConnection(); } catch (SQLException e) { e.printStackTrace(); } threadLocal.set(connection); } return connection; } @Override public void commit() { Connection connection = threadLocal.get(); if (connection != null ) { try { connection.commit(); } catch (Exception e) { e.printStackTrace(); } } } @Override public void rollback() { Connection connection = threadLocal.get(); if (connection != null) { try { connection.rollback(); } catch (SQLException e) { e.printStackTrace(); } } } public void close() { Connection connection = threadLocal.get(); if (connection != null) { try { connection.close(); threadLocal.remove(); } catch (SQLException e) { e.printStackTrace(); } } } @Override public void begin() { Connection connection = threadLocal.get(); if (connection != null) { try { connection.setAutoCommit(false); } catch (SQLException e) { e.printStackTrace(); } } } } 五. 深度分析解析SqlSessionFacotry干的核心工作 1. SqlSession openSession() 方法做的工作

可以看的出来我们在这个方法创建了DefaultSqlSession对象,并传入封装好的configuration,默认的事务管理器

默认通过openSession事务是开启的等等相关的参数


 

六.编写核心类SqlSession

其实有SqlSession的接口,我们使用的实现类是DefaultSession, 这里记录了解析的配置对象configuration

默认事务管理器对象transactionManagement, 默认事务开启的状态tx标记

package com.itheima.ibatis.core.session.impl; import com.itheima.ibatis.configuration.Configuration; import com.itheima.ibatis.configuration.Mapper; import com.itheima.ibatis.core.BaseExecutor; import com.itheima.ibatis.core.annotation.Param; import com.itheima.ibatis.core.session.SqlSession; import com.itheima.ibatis.core.transaction.TransactionManagement; import java.lang.reflect.*; import java.util.HashMap; import java.util.List; import java.util.Map; public class DefaultSqlSession implements SqlSession { private final Configuration configuration; private final boolean tx; private TransactionManagement transactionManagement; public DefaultSqlSession(Configuration configuration, TransactionManagement transactionManagement, boolean tx) { this.configuration = configuration; this.transactionManagement = transactionManagement; this.tx = tx; } public void close() { transactionManagement.close(); } @Override public void commit() { transactionManagement.commit(); } @Override public void rollback() { transactionManagement.rollback(); } @Override public List selectList(String sqlId) { return selectList(sqlId, null); } @Override public List selectList(String sqlId, Object param) { List list = new BaseExecutor(transactionManagement, tx).queryList(getMapper(sqlId), param); return (List) list; } @Override public T selectOne(String sqlId) { return selectOne(sqlId, null); } @Override public T selectOne(String sqlId, Object param) { return new BaseExecutor(transactionManagement, tx).query(getMapper(sqlId), param); } @Override public int delete(String sqlId) { return update0(sqlId, null); } @Override public int delete(String sqlId, Object param) { return update0(sqlId, param); } @Override public int update(String sqlId) { return update0(sqlId, null); } @Override public int update(String sqlId, Object param) { return update0(sqlId, param); } @Override public int insert(String sqlId) { return update0(sqlId, null); } @Override public int insert(String sqlId, Object param) { return update0(sqlId, param); } @Override public T getMapper(Class clazz) { Object o = Proxy.newProxyInstance( clazz.getClassLoader(), new Class[]{clazz}, new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String sqlId = clazz.getName() + "." + method.getName(); Mapper mapper = configuration.getMappers().get(sqlId); String type = mapper.getType(); Object findParam = null; if (args != null) { if (args.length == 1) { Object param = args[0]; boolean isArray = param.getClass().isArray(); if (!isArray) { findParam = param; } } else { Map map = new HashMap<>(); Parameter[] parameters = method.getParameters(); for (int i = 0; i < parameters.length; i++) { Param param = parameters[i].getAnnotation(Param.class); String key = "arg"+i; if(param !=null){ key = param.value(); } map.put(key, args[i]); } findParam = map; } } if (type.equals("SELECT")) { boolean selectList = mapper.isSelectList(); if (selectList) return selectList(sqlId, findParam); else return selectOne(sqlId, findParam); } else { return update0(sqlId, findParam); } } }); return (T) o; } private int update0(String sqlId, Object param) { return new BaseExecutor(transactionManagement, tx).update(getMapper(sqlId), param); } public Mapper getMapper(String sqlId) { Mapper mapper = configuration.getMappers().get(sqlId); if (mapper == null) { throw new RuntimeException("没有找到sql映射,请检查"); } return mapper; } }七.深度分析解析SqlSession干的核心工作1.selectOne & selectList做的工作

主要是分发了下功能, 执行sql语句避免不了有参数和无参数的, 都让调用有参数的方便管理

在执行前, 考虑还有一种情况, 用户不是通过接口代理的方式来执行以上方法, 这样手动输入sqlId容易造成错误

这里做一个健壮性判断

BaseExecutor中的query以及queryList做的核心工作

首先这两个方法的特点都是查询, 其步骤基本类似, 所以这里可以合并一起转调query0功能

这里需要对参数进行设定, 还根据最后isOne的参数决定返回值是否是单个

参数设置这里比较复杂我们通过图解的方式来解释, (注: 参数是List集合类型的和数组类型的没有做!!!)

对结果的封装主要用到内省技术和数据库元数据等等知识点

2.update&delete&insert做的工作

BaseExecutor中的update做的核心工作

还是和query&queryList一样需要设置参数, 不管是增删改其本质其结果都是一致

3.getMapper代理模式开发的原理

主要使用的动态代理的技术创建接口的实现类, 内部主要整合了sqlId和参数, 省去用户自己拼sqlId拼错的风险

也同时解决用户手动合参数的麻烦, 但是最终工作的还是selectOne,selectList以及update0这些方法

总结自定义mybatis用的技术点

一款框架的诞生肯定不是一蹴而就的, 随着时间慢慢推进逐步更新出来, 所以一款好的框架肯定要经过

很多考验才能够稳定靠谱, 但是纵观整篇用的技术点, 不难发现框架也是由基础代码编写而来,解决大量重复

的工作, 提供扩展性等等机制,比如本篇用核心的技术点有

① 反射

② 内省

③ 解析xml

④ 动态代理

⑤ 工厂设计模式

等等, 感谢大家耐心阅览, 附件有本篇的原码, 如果有更好的建议和想法欢迎和小编一起探讨交流



Tags:mybatis   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,不构成投资建议。投资者据此操作,风险自担。如有任何标注错误或版权侵犯请与我们联系,我们将及时更正、删除。
▌相关推荐
Mybatis参数映射搞不明白?来试试这个工具吧!
之前在《使用技巧-Mybatis参数映射》《使用技巧-Mybatis参数映射(2)》提到了Mybatis的一些参数映射技巧,但是平时使用的时候有些小伙伴可能不知道自己写的#{}表达式能不能获取...【详细内容】
2024-02-28  Search: mybatis  点击:(34)  评论:(0)  加入收藏
如何在Spring项目中配置MP(MyBatis-Plus)集成?
在Spring项目中集成MP,需要进行以下配置:1. 引入依赖:在项目的pom.xml文件中添加MP相关依赖,例如:```xml<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plu...【详细内容】
2024-01-09  Search: mybatis  点击:(87)  评论:(0)  加入收藏
Mybatis占位符#和$的区别?源码解读
本文针对笔者日常开发中对 Mybatis 占位符 #{} 和 ${} 使用时机结合源码,思考总结而来 &bull; Mybatis 版本 3.5.11 &bull; Spring boot 版本 3.0.2 &bull; mybatis-spring...【详细内容】
2023-10-27  Search: mybatis  点击:(399)  评论:(0)  加入收藏
看完这篇文章,你也可以手写MyBatis部分源码(JDBC)
一、持久化机制持久化(persistence): 把数据保存到可调电式存储设备中以供之后使用。大多数情况下,特别是企业级应用,数据持久化意味着将内存中的数据保存到硬盘上加以”固化...【详细内容】
2023-10-09  Search: mybatis  点击:(332)  评论:(0)  加入收藏
Mybatis-Flex初体验
本篇文章内容主要包括: MyBatis-Flex 介绍MyBatis-Flex 是一个优雅的 MyBatis 增强框架,它非常轻量、同时拥有极高的性能与灵活性。我们可以轻松的使用 Mybaits-Flex 链接任何...【详细内容】
2023-09-24  Search: mybatis  点击:(196)  评论:(0)  加入收藏
MyBatis简单易用的背后隐藏的挑战
MyBatis,作为一款备受欢迎的持久层框架,它的简单易用以及灵活的配置吸引了无数的开发者。然而,随着项目的不断发展,规模的逐渐扩大,MyBatis的一些挑战也开始逐渐浮出水面。首先,由...【详细内容】
2023-09-15  Search: mybatis  点击:(242)  评论:(0)  加入收藏
MyBatis缓存机制
MyBatis 的缓存机制属于本地缓存,适用于单机系统,它的作用是减少数据库的查询次数,提高系统性能。MyBaits 中包含两级本地缓存: 一级缓存:SqlSession 级别的,是 MyBatis 自带的缓...【详细内容】
2023-09-12  Search: mybatis  点击:(229)  评论:(0)  加入收藏
对比 MyBatis 和 MyBatis-Plus 批量插入、批量更新的性能和区别
1 环境准备1.1 搭建 MyBatis-Plus 环境 创建 maven springboot 工程 导入依赖:web 启动器、jdbc、、java 连接 mysql、Lombok、druid 连接池启动器、mybatis-plus 启动器 编...【详细内容】
2023-09-08  Search: mybatis  点击:(192)  评论:(0)  加入收藏
Spring Data JPA 和 MyBatis 谁更强?
我无法明确的告诉你JPA和MyBatis在国内哪个会更流行,我本人更喜欢JPA,但是我本人日常开发用MyBatis多。但是我的回答绝对不是在划水,而是我多年来自己的一点小小的思考。MyBati...【详细内容】
2023-08-22  Search: mybatis  点击:(336)  评论:(0)  加入收藏
Mybatis-Plus可能会导致数据库死锁
一、场景还原1.版本信息MySQL版本:5.6.36-82.1-logMybatis-Plus的starter版本:3.3.2存储引擎:InnoDB2.死锁现象A同学在生产环境使用了Mybatis-Plus提供的com.baomidou.mybatisp...【详细内容】
2023-08-14  Search: mybatis  点击:(171)  评论:(0)  加入收藏
▌简易百科推荐
Qt与Flutter:在跨平台UI框架中哪个更受欢迎?
在跨平台UI框架领域,Qt和Flutter是两个备受瞩目的选择。它们各自具有独特的优势,也各自有着广泛的应用场景。本文将对Qt和Flutter进行详细的比较,以探讨在跨平台UI框架中哪个更...【详细内容】
2024-04-12  刘长伟    Tags:UI框架   点击:(1)  评论:(0)  加入收藏
Web Components实践:如何搭建一个框架无关的AI组件库
一、让人又爱又恨的Web ComponentsWeb Components是一种用于构建可重用的Web元素的技术。它允许开发者创建自定义的HTML元素,这些元素可以在不同的Web应用程序中重复使用,并且...【详细内容】
2024-04-03  京东云开发者    Tags:Web Components   点击:(8)  评论:(0)  加入收藏
Kubernetes 集群 CPU 使用率只有 13% :这下大家该知道如何省钱了
作者 | THE STACK译者 | 刘雅梦策划 | Tina根据 CAST AI 对 4000 个 Kubernetes 集群的分析,Kubernetes 集群通常只使用 13% 的 CPU 和平均 20% 的内存,这表明存在严重的过度...【详细内容】
2024-03-08  InfoQ    Tags:Kubernetes   点击:(19)  评论:(0)  加入收藏
Spring Security:保障应用安全的利器
SpringSecurity作为一个功能强大的安全框架,为Java应用程序提供了全面的安全保障,包括认证、授权、防护和集成等方面。本文将介绍SpringSecurity在这些方面的特性和优势,以及它...【详细内容】
2024-02-27  风舞凋零叶    Tags:Spring Security   点击:(55)  评论:(0)  加入收藏
五大跨平台桌面应用开发框架:Electron、Tauri、Flutter等
一、什么是跨平台桌面应用开发框架跨平台桌面应用开发框架是一种工具或框架,它允许开发者使用一种统一的代码库或语言来创建能够在多个操作系统上运行的桌面应用程序。传统上...【详细内容】
2024-02-26  贝格前端工场    Tags:框架   点击:(47)  评论:(0)  加入收藏
Spring Security权限控制框架使用指南
在常用的后台管理系统中,通常都会有访问权限控制的需求,用于限制不同人员对于接口的访问能力,如果用户不具备指定的权限,则不能访问某些接口。本文将用 waynboot-mall 项目举例...【详细内容】
2024-02-19  程序员wayn  微信公众号  Tags:Spring   点击:(39)  评论:(0)  加入收藏
开发者的Kubernetes懒人指南
你可以将本文作为开发者快速了解 Kubernetes 的指南。从基础知识到更高级的主题,如 Helm Chart,以及所有这些如何影响你作为开发者。译自Kubernetes for Lazy Developers。作...【详细内容】
2024-02-01  云云众生s  微信公众号  Tags:Kubernetes   点击:(51)  评论:(0)  加入收藏
链世界:一种简单而有效的人类行为Agent模型强化学习框架
强化学习是一种机器学习的方法,它通过让智能体(Agent)与环境交互,从而学习如何选择最优的行动来最大化累积的奖励。强化学习在许多领域都有广泛的应用,例如游戏、机器人、自动驾...【详细内容】
2024-01-30  大噬元兽  微信公众号  Tags:框架   点击:(68)  评论:(0)  加入收藏
Spring实现Kafka重试Topic,真的太香了
概述Kafka的强大功能之一是每个分区都有一个Consumer的偏移值。该偏移值是消费者将读取的下一条消息的值。可以自动或手动增加该值。如果我们由于错误而无法处理消息并想重...【详细内容】
2024-01-26  HELLO程序员  微信公众号  Tags:Spring   点击:(88)  评论:(0)  加入收藏
SpringBoot如何实现缓存预热?
缓存预热是指在 Spring Boot 项目启动时,预先将数据加载到缓存系统(如 Redis)中的一种机制。那么问题来了,在 Spring Boot 项目启动之后,在什么时候?在哪里可以将数据加载到缓存系...【详细内容】
2024-01-19   Java中文社群  微信公众号  Tags:SpringBoot   点击:(86)  评论:(0)  加入收藏
站内最新
站内热门
站内头条