SSM源码分析之Spring11-手写SpringORM

前言

使用 Spring 进行基本的 JDBC 访问数据库有多种选择。Spring 至少提供了三种不同的工作模式: JdbcTemplate, 一个在 Spring2.5 中新提供的 SimpleJdbc 类能够更好的处理数据库元数据; 还有一种称之为 RDBMS Object 的风格的面向对象封装方式, 有点类似于 JDO 的查询设计。 我们在这里简要列举你采取某一种工作方式的主要理由. 不过请注意, 即使你选择了其中的一种工作模式, 你依然可以在你的代码中混用其他任何一种模式以获取其带来的好处和优势。 所有的工作模式都必须要求 JDBC 2.0 以上的数据库驱动的支持, 其中一些高级的功能可能需要 JDBC 3.0 以上的数据库驱动支持。

JdbcTemplate - 这是经典的也是最常用的 Spring 对于 JDBC 访问的方案。这也是最低级别的封装, 其他的工作模式事实上在底层使用了 JdbcTemplate 作为其底层的实现基础。JdbcTemplate 在 JDK 1.4 以上的环境上工作得很好。

NamedParameterJdbcTemplate - 对 JdbcTemplate 做了封装,提供了更加便捷的基于命名参数的使用方式而不是传统的 JDBC 所使用的“?”作为参数的占位符。这种方式在你需要为某个 SQL 指定许多个参数时,显得更加直观而易用。该特性必须工作在 JDK 1.4 以上。

SimpleJdbcTemplate - 这个类结合了 JdbcTemplate 和 NamedParameterJdbcTemplate 的最常用的功能,同时它也利用了一些 Java 5 的特性所带来的优势,例如泛型、varargs 和 autoboxing 等,从而提供了更加简便的 API 访问方式。需要工作在 Java 5 以上的环境中。

SimpleJdbcInsert 和 SimpleJdbcCall - 这两个类可以充分利用数据库元数据的特性来简化配置。

通过使用这两个类进行编程,你可以仅仅提供数据库表名或者存储过程的名称以及一个 Map 作为参数。

其中 Map 的 key 需要与数据库表中的字段保持一致。这两个类通常和 SimpleJdbcTemplate 配合使用。

这两个类需要工作在 JDK 5 以上,同时数据库需要提供足够的元数据信息。

RDBMS 对象包括 MappingSqlQuery, SqlUpdate and StoredProcedure - 这种方式允许你在初始

化你的数据访问层时创建可重用并且线程安全的对象。该对象在你定义了你的查询语句,声明查询参数并编译相应的 Query 之后被模型化。一旦模型化完成,任何执行函数就可以传入不同的参数对之进行多次调用。这种方式需要工作在 JDK 1.4 以上。

SpringJDBC

异常处理

异常结构如下:
在这里插入图片描述

SQLExceptionTranslator 是 一 个 接 口 , 如 果 你 需 要 在 SQLException 和 org.springframework.dao.DataAccessException 之间作转换,那么必须实现该接口。 转换器类的实现可以采用一般通用的做法(比如使用 JDBC 的 SQLState code),如果为了使转换更准确,也可以进行定制(比如使用 Oracle 的 error code)。

SQLErrorCodeSQLExceptionTranslator 是 SQLExceptionTranslator 的默认实现。 该实现使用指定数据库厂商的 error code,比采用 SQLState 更精确。转换过程基于一个 JavaBean(类型为SQLErrorCodes)中的 error code。 这个 JavaBean 由 SQLErrorCodesFactory 工厂类创建,其中
的内容来自于 “sql-error-codes.xml”配置文件。该文件中的数据库厂商代码基于 Database MetaData 信息中的 DatabaseProductName,从而配合当前数据库的使用。

SQLErrorCodeSQLExceptionTranslator 使用以下的匹配规则:

首先检查是否存在完成定制转换的子类实现。通常 SQLErrorCodeSQLExceptionTranslator 这个类可以作为一个具体类使用,不需要进行定制,那么这个规则将不适用。

接着将 SQLException 的 error code 与错误代码集中的 error code 进行匹配。 默认情况下错误代码集将从 SQLErrorCodesFactory 取得。 错误代码集来自 classpath 下的 sql-error-codes.xml 文件,它们将与数据库 metadata 信息中的 database name 进行映射。

使用 fallback 翻译器。SQLStateSQLExceptionTranslator 类是缺省的 fallback 翻译器。

config 模块

NamespaceHandler 接口,DefaultBeanDefinitionDocumentReader 使用该接口来处理在 spring xml 配置文件中自定义的命名空间。
在这里插入图片描述
在jdbc 模块,我们使用 JdbcNamespaceHandler 来处理 jdbc 配置的命名空间,其代码如下:·

public class JdbcNamespaceHandler extends NamespaceHandlerSupport {
	@Override
	public void init() {
	registerBeanDefinitionParser("embedded-database", new EmbeddedDatabaseBeanDefinitionParser()); 
	registerBeanDefinitionParser("initialize-database", new InitializeDatabaseBeanDefinitionParser());
	}
}

其中,EmbeddedDatabaseBeanDefinitionParser 继承了 AbstractBeanDefinitionParser,解析 < embedded-database>元素,并使用 EmbeddedDatabaseFactoryBean 创建一个 BeanDefinition。顺便介绍一下用到的软件包 org.w3c.dom。

软件包 org.w3c.dom:为文档对象模型 (DOM) 提供接口,该模型是 Java API for XML Processing 的组件 API。该 Document Object Model Level 2 Core API 允许程序动态访问和更新文档的内容和结构。

Attr:Attr 接口表示 Element 对象中的属性。

CDATASection: CDATA 节用于转义文本块,该文本块包含的字符如果不转义则会被视为标记。

CharacterData: CharacterData 接口使用属性集合和用于访问 DOM 中字符数据的方法扩展节点。

Comment: 此接口继承自 CharacterData 表示注释的内容,即起始 ‘ ’ 之间的所有字符。

Document: Document 接口表示整个 HTML 或 XML 文档。

DocumentFragment: DocumentFragment 是“轻量级”或“最小”Document 对象。

DocumentType : 每个 Document 都有 doctype 属性,该属性的值可以为 null ,也可以为DocumentType 对象。

DOMConfiguration: 该 DOMConfiguration 接口表示文档的配置,并维护一个可识别的参数表。

DOMError: DOMError 是一个描述错误的接口。

DOMErrorHandler: DOMErrorHandler 是在报告处理 XML 数据时发生的错误或在进行某些其他处理(如验证文档)时 DOM 实现可以调用的回调接口。

DOMImplementation: DOMImplementation 接口为执行独立于文档对象模型的任何特定实例的操作提供了许多方法。

DOMImplementationList: DOMImplementationList 接口提供对 DOM 实现的有序集合的抽象,没有定义或约束如何实现此集合。

DOMImplementationSource:此接口允许 DOM 实现程序根据请求的功能和版本提供一个或多个实现,如下所述。

DOMLocator: DOMLocator 是一个描述位置(如发生错误的位置)的接口。

DOMStringList: DOMStringList 接口提供对 DOMString 值的有序集合的抽象,没有定义或约束此集合是如何实现的。
Element: Element 接口表示 HTML 或 XML 文档中的一个元素。

Entity: 此接口表示在 XML 文档中解析和未解析的已知实体。

EntityReference: EntityReference 节点可以用来在树中表示实体引用。

NamedNodeMap: 实现 NamedNodeMap 接口的对象用于表示可以通过名称访问的节点的集合。 NameList NameList 接口提供对并行的名称和名称空间值对(可以为 null 值)的有序集合的抽象,无需定义或约束如何实现此集合。

Node: 该 Node 接口是整个文档对象模型的主要数据类型。

NodeList: NodeList 接口提供对节点的有序集合的抽象,没有定义或约束如何实现此集合。

Notation: 此接口表示在 DTD 中声明的表示法。

ProcessingInstruction: ProcessingInstruction 接口表示“处理指令”,该指令作为一种在文档的文本中保持特定于处理器的信息的方法在 XML 中使用。

Text: 该 Text 接口继承自 CharacterData,并且表示 Element 或 Attr 的文本内容(在 XML 中称为 字符数据)。

TypeInfo: TypeInfo 接口表示从 Element 或 Attr 节点引用的类型,用与文档相关的模式指定。

UserDataHandler: 当使用 Node.setUserData() 将一个对象与节点上的键相关联时,当克隆、导入或重命名该对象关联的节点时应用程序可以提供调用的处理程序。

core 模块

JdbcTeamplate

在这里插入图片描述

RowMapper

在这里插入图片描述

元数据 metaData 模块

本节中 spring 应用到工厂模式,结合代码可以更具体了解。
在这里插入图片描述
CallMetaDataProviderFactory 创建 CallMetaDataProvider 的工厂类,其代码如下:

public static final List<String> supportedDatabaseProductsForProcedures = Arrays.asList( "Apache Derby",

"DB2",

"MySQL",

"Microsoft SQL Server",

"Oracle",

"PostgreSQL",

"Sybase"

);
/** List of supported database products for function calls */

public static final List<String> supportedDatabaseProductsForFunctions = Arrays.asList( "MySQL",
"Microsoft SQL Server",

"Oracle",

"PostgreSQL"

);


static public CallMetaDataProvider createMetaDataProvider(DataSource dataSource, final CallMetaDataContext context) {
try {

CallMetaDataProvider result = (CallMetaDataProvider) JdbcUtils.extractDatabaseMetaData(dataSource, databaseMetaData -> {
String databaseProductName = JdbcUtils.commonDatabaseName(databaseMetaData.getDatabaseProductName()); boolean accessProcedureColumnMetaData = context.isAccessCallParameterMetaData(); if (context.isFunction()) {

if (!supportedDatabaseProductsForFunctions.contains(databaseProductName)) { if (logger.isWarnEnabled()) {
logger.warn(databaseProductName + " is not one of the databases fully supported for function calls

"+

"-- supported are: " + supportedDatabaseProductsForFunctions);

}

if (accessProcedureColumnMetaData) {

logger.warn("Metadata processing disabled - you must specify all parameters explicitly"); accessProcedureColumnMetaData = false;
}

}

}

else {

if (!supportedDatabaseProductsForProcedures.contains(databaseProductName)) { if (logger.isWarnEnabled()) {
logger.warn(databaseProductName + " is not one of the databases fully supported for procedure

calls " +

"-- supported are: " + supportedDatabaseProductsForProcedures);

}

if (accessProcedureColumnMetaData) {

logger.warn("Metadata processing disabled - you must specify all parameters explicitly"); accessProcedureColumnMetaData = false;
}

}

}

CallMetaDataProvider provider;

if ("Oracle".equals(databaseProductName)) {

provider = new OracleCallMetaDataProvider(databaseMetaData);
}

else if ("DB2".equals(databaseProductName)) {

provider = new Db2CallMetaDataProvider((databaseMetaData));

}

else if ("Apache Derby".equals(databaseProductName)) {

provider = new DerbyCallMetaDataProvider((databaseMetaData));

}

else if ("PostgreSQL".equals(databaseProductName)) {

provider = new PostgresCallMetaDataProvider((databaseMetaData));

}

else if ("Sybase".equals(databaseProductName)) {

provider = new SybaseCallMetaDataProvider((databaseMetaData));

}

else if ("Microsoft SQL Server".equals(databaseProductName)) { provider = new SqlServerCallMetaDataProvider((databaseMetaData));
}

else if ("HDB".equals(databaseProductName)) {

provider = new HanaCallMetaDataProvider((databaseMetaData));

}

else {

provider = new GenericCallMetaDataProvider(databaseMetaData);

}

if (logger.isDebugEnabled()) {

logger.debug("Using " + provider.getClass().getName());

}

provider.initializeWithMetaData(databaseMetaData);

if (accessProcedureColumnMetaData) {

provider.initializeWithProcedureColumnMetaData(databaseMetaData,

context.getCatalogName(), context.getSchemaName(), context.getProcedureName());

}

return provider;

});

return result;

}

catch (MetaDataAccessException ex) {

throw new DataAccessResourceFailureException("Error retrieving database metadata", ex);

}

}

TableMetaDataProviderFactory 创建 TableMetaDataProvider 工厂类,其创建过程如下:

static public CallMetaDataProvider createMetaDataProvider(DataSource dataSource, final CallMetaDataContext

context) {

try {

CallMetaDataProvider result = (CallMetaDataProvider) JdbcUtils.extractDatabaseMetaData(dataSource, databaseMetaData -> {
String databaseProductName = JdbcUtils.commonDatabaseName(databaseMetaData.getDatabaseProductName()); boolean accessProcedureColumnMetaData = context.isAccessCallParameterMetaData(); if (context.isFunction()) {

if (!supportedDatabaseProductsForFunctions.contains(databaseProductName)) { if (logger.isWarnEnabled()) {
logger.warn(databaseProductName + " is not one of the databases fully supported for function calls

" +

"-- supported are: " + supportedDatabaseProductsForFunctions);

}

if (accessProcedureColumnMetaData) {

logger.warn("Metadata processing disabled - you must specify all parameters explicitly"); accessProcedureColumnMetaData = false;
}

}

}

else {

if (!supportedDatabaseProductsForProcedures.contains(databaseProductName)) { if (logger.isWarnEnabled()) {
logger.warn(databaseProductName + " is not one of the databases fully supported for procedure

calls " +

"-- supported are: " + supportedDatabaseProductsForProcedures);

}

if (accessProcedureColumnMetaData) {

logger.warn("Metadata processing disabled - you must specify all parameters explicitly"); accessProcedureColumnMetaData = false;
}

}

}

CallMetaDataProvider provider;

if ("Oracle".equals(databaseProductName)) {

provider = new OracleCallMetaDataProvider(databaseMetaData);

}

else if ("DB2".equals(databaseProductName)) {

provider = new Db2CallMetaDataProvider((databaseMetaData));

}

else if ("Apache Derby".equals(databaseProductName)) {

provider = new DerbyCallMetaDataProvider((databaseMetaData));

}

else if ("PostgreSQL".equals(databaseProductName)) {

provider = new PostgresCallMetaDataProvider((databaseMetaData));

}

else if ("Sybase".equals(databaseProductName)) {

provider = new SybaseCallMetaDataProvider((databaseMetaData));

}

else if ("Microsoft SQL Server".equals(databaseProductName)) { provider = new SqlServerCallMetaDataProvider((databaseMetaData));
}

else if ("HDB".equals(databaseProductName)) {

provider = new HanaCallMetaDataProvider((databaseMetaData));

}

else {

provider = new GenericCallMetaDataProvider(databaseMetaData);

}

if (logger.isDebugEnabled()) {

logger.debug("Using " + provider.getClass().getName());

}

provider.initializeWithMetaData(databaseMetaData);

if (accessProcedureColumnMetaData) {

provider.initializeWithProcedureColumnMetaData(databaseMetaData,

context.getCatalogName(), context.getSchemaName(), context.getProcedureName());

}

return provider;

});

return result;

}

catch (MetaDataAccessException ex) {

throw new DataAccessResourceFailureException("Error retrieving database metadata", ex);

}

}

使用 SqlParameterSource 提供参数值

使用 Map 来指定参数值有时候工作得非常好,但是这并不是最简单的使用方式。Spring 提供了一些其他 的 SqlParameterSource 实 现 类 来 指 定 参 数 值 。 我 们 首 先 可 以 看 看

BeanPropertySqlParameterSource 类,这是一个非常简便的指定参数的实现类,只要你有一个符合 JavaBean 规范的类就行了。它将使用其中的 getter 方法来获取参数值。

SqlParameter 封 装 了 定 义 sql 参 数 的 对 象 。 CallableStateMentCallback , PrePareStateMentCallback , StateMentCallback , ConnectionCallback 回 调 类 分 别 对 应 JdbcTemplate 中的不同处理方法。

在这里插入图片描述

simple 实现

在这里插入图片描述

DataSource

spring 通过 DataSource 获取数据库的连接。Datasource 是 jdbc 规范的一部分,它通过 ConnectionFactory 获取。一个容器和框架可以在应用代码层中隐藏连接池和事务管理。

当使用 spring 的 jdbc 层,你可以通过 JNDI 来获取 DataSource,也可以通过你自己配置的第三方连接池实现来获取。流行的第三方实现由 apache Jakarta Commons dbcp 和 c3p0.

在这里插入图片描述
TransactionAwareDataSourceProxy 作为目标 DataSource 的一个代理, 在对目标 DataSource 包装的同时,还增加了 Spring 的事务管理能力, 在这一点上,这个类的功能非常像 J2EE 服务器所提供的事务化的 JNDI DataSource。

Note

该类几乎很少被用到,除非现有代码在被调用的时候需要一个标准的 JDBC DataSource 接口实现作为参数。 这种情况下,这个类可以使现有代码参与 Spring 的事务管理。通常最好的做法是使用更高层的抽象 来对数据源进行管理,比如 JdbcTemplate 和 DataSourceUtils 等等。

注意:DriverManagerDataSource 仅限于测试使用,因为它没有提供池的功能,这会导致在多个请求获取连接时性能很差。

object 模块

在这里插入图片描述

JdbcTemplate是 core包的核心类

它替我们完成了资源的创建以及释放工作,从而简化了我们对 JDBC 的使用。 它还可以帮助我们避免一些常见的错误,比如忘记关闭数据库连接。 JdbcTemplate 将完成 JDBC 核心处理流程,比如 SQL 语句的创建、执行,而把 SQL 语句的生成以及查询结果的提取工作留给我们的应用代码。 它可以完成 SQL 查询、更新以及调用存储过程,可以对 ResultSet 进行遍历并加以提取。 它还可以捕获 JDBC 异常并将其转换成 org.springframework.dao 包中定义的,通用的,信息更丰富的异常。

使 用 JdbcTemplate 进 行 编 码 只 需 要 根 据 明 确 定 义 的 一 组 契 约 来 实 现 回 调 接 口 。 PreparedStatementCreator 回调接口通过给定的 Connection 创建一个 PreparedStatement,包含 SQL 和任何相关的参数。 CallableStatementCreateor 实现同样的处理,只不过它创建的是CallableStatement。 RowCallbackHandler 接口则从数据集的每一行中提取值。

我们可以在 DAO 实现类中通过传递一个 DataSource 引用来完成 JdbcTemplate 的实例化,也可以在 Spring 的 IOC 容器中配置一个 JdbcTemplate 的 bean 并赋予 DAO 实现类作为一个实例。需要注意的

是DataSource 在 Spring 的 IOC 容器中总是配制成一个 bean,第一种情况下,DataSource bean 将

传递给 service,第二种情况下 DataSource bean 传递给 JdbcTemplate bean。

7.NamedParameterJdbcTemplate 类为 JDBC 操作增加了命名参数的特性支持,而不是传统的使用(’?’)作为参数的占位符。NamedParameterJdbcTemplate 类对 JdbcTemplate 类进行了封装, 在底层,JdbcTemplate 完成了多数的工作。

开发ORM框架

ORM(对象关系映射Object Relation Mapping)。说的就是讲已经持久化的数据内容转换为一个Java对象,用Java对象来描述对象与对象之间的关系和数据内容。
常见的ORM框架:

  • Hibernate
  • MyBatis
  • JPA
  • SpringJDBC

Hibernate 全自动档 不需要写一句SQL语句、烧油(牺牲性能)
MyBatis 手自一体(半自动) 支持单表映射,多表关联需要配置,轻量级一些
SpringJDBC 手动挡 包括SQL语句,映射都是要自己实现的(最省油的)
喜欢轻装上阵 Spring本来就是万能胶IOC/AOP/DI/MVC/JDBC/BigData/Cloud/Boot, 因为Spring形成一个生态

为什么要自己写ORM框架?

  • 解决实际的业务问题(根据业务需要)
  • 自定义需求,如果要直接第三方开源框架的话,需要进行二次开发
  • 解决团队成员之间水平参差不齐的问题
  • 可以实现统一的管理、监控、排错等等一系列底层操作

实际场景应用

以某平台大数据检测系统为例:
痛点:

  • 数据吞吐量大
  • 数据存储方式多样化
  • 数据源需要频繁切换
  • API无法统一
    系统架构图:
    在这里插入图片描述

通过实现最底层的类解决问题:

1、统一方法名
select
insert
delete
update

find/get/load/query

//约定
如果是删、改,以ID作为唯一的检索条件,如果没有ID,那么要先查出来得到ID

2、统一参数
如果是做条件查询 QueryRule(自己封装)
批量更新和插入 ,方法名以All结尾 参数为List
删、改、插一条数据 ,参数用 T

3、统一返回结果

所有的分页操作返回Page

所有的集合查询返回 List

所有的单条查询返回 T

所有的ID采用Long类型

所有的删除、修改、增加操作返回boolean

对外输出都用ResultMsg

4、 统一封装Freamwork
java extends core common
javax.core.common.utils 操作工具包
javax.core.common.config 统一配置
javax.core.common.doc 文档生成

javax.core.common.jdbc JDBC依赖
javax.core.common.redis Redis
javax.core.common.mongodb MongoDB
javax.core.common.hbase Hbase

只要是Spring相关的配置都以 application- 开头
建议不要把所有的东西在一个文件中,这样不便于团队开发的维护

aop 配置切面,代理规则的
beans 配置单例对象的
common 配置通用的配置
context 主入口
db 数据库相关
web 跟页面打交道的、拦截器、过滤器、监听器、模板

在这里插入图片描述
具体的工具类读者可以在本文末给出的Github地址获取

实现ORM操作

public class JdbcTest {

    public static void main(String[] args) {

//        List<?> result = select(Member.class);
        Member condition = new Member();
        condition.setName("tom");
//        condition.setAge(20);
        List<?> result = select(condition);

        System.out.println(Arrays.toString(result.toArray()));

    }

    //我框架问世的时候,你的Member类都还没有从石头缝里蹦出来
    private static List<?> select(Object condition){
        try{


            Class<?> entityClass = condition.getClass();

            //1、加载驱动类
            Class.forName("com.mysql.jdbc.Driver");

            //2、建立连接
            Connection conn = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/gupaoedu_demo?characterEncoding=UTF-8&rewriteBatchedStatements=true","root","123456");

            Table table = entityClass.getAnnotation(Table.class);


            //3、创建语句开始事务
            //为了简便,暂时用select * from 代替,不要说我不严谨,OK?
            String sql = "select * from " + table.name();
            StringBuffer where = new StringBuffer(" where 1=1 ");
            Field[] fields = entityClass.getDeclaredFields();
            for (Field field : fields) {
                field.setAccessible(true);
                Object value = field.get(condition);
                if(null != value) {
                    Class<?> clazz = field.getType();
                    if(String.class == clazz){
                        where.append(" and " + field.getName() + " = '" + value + "'");
                    }else{
                        where.append(" and " + field.getName() + " = " + value);
                    }

                }
            }


            System.out.println(sql + where.toString());

            PreparedStatement pstmt = conn.prepareStatement(sql + where.toString());

            //4、执行语句集
            ResultSet rs = pstmt.executeQuery();

            //5、获取结果集

            //ORM:数据表中的记录要复制到Java的Object中
            //ORM原理:反射机制
            //自动赋值
            //拿到一共有多少个列
            List<Object> result = new ArrayList<Object>();

            int columnCount = rs.getMetaData().getColumnCount();
            while (rs.next()){ //游标


                //===========Begin ORM ============
                Object instance = entityClass.newInstance();
                for (int i = 1; i <= columnCount; i ++) {
                    String columnName = rs.getMetaData().getColumnName(i);
                    Field field = entityClass.getDeclaredField(columnName);
                    field.setAccessible(true);

                    //数据类型映射非常关键
//                    Object type = field.getType();
//                    if(type == Long.class){
//                        field.set(instance,rs.getLong(columnName));
//                    }else if(String.class == type){
//                        field.set(instance,rs.getString(columnName));
//                    }else if(Integer.class == type){
//                        field.set(instance,rs.getInt(columnName));
//                    }

                    field.set(instance,rs.getObject(columnName));


                    //各自的厂商实现自己的链接
                    //MySQL为例,以下类型Java语言中是不存在的
                    //bigint ,由开发厂商自动就映射好了
                    //varchar
                    //int
//                    System.out.println(rs.getObject(columnName).getClass());


                }
                //===========End ORM ==============
                result.add(instance);
            }


            //System.out.println(Arrays.toString(result.toArray()));




            //6、关闭结果集、关闭语句集、关闭连接
            rs.close();
            pstmt.close();
            conn.close();

            return  result;
        }catch(Exception e){
            e.printStackTrace();
        }finally {
            //关闭资源要在Finally块中
        }
        return  null;
    }


}

Member类:

@Entity
@Data
@Table(name="t_member")
public class Member implements Serializable{

    @Id private Long id;
    private String name;
    private String addr;
    private Integer age;
}

后记

springORM github代码地址
本系列参考的Spring源码版本是:Spring 5.0.2.RELEASE

发布了49 篇原创文章 · 获赞 5 · 访问量 3090

猜你喜欢

转载自blog.csdn.net/qq_34361283/article/details/104088553