尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CodeGuide 手写 ORM 框架实现:从 JDBC 到 MyBatis 风格中间件(第 7 章实战)

CodeGuide 手写 ORM 框架实现:从 JDBC 到 MyBatis 风格中间件(第 7 章实战) 文档教程后端【免费下载链接】CodeGuide:books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总旨在为大家提供一个清晰详细的学习教程侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助请给予支持(关注、点赞、分享)项目地址https://gitcode.com/gh_mirrors/code/CodeGuide点击查看免费下载本篇技术指南以《SpringBoot 中间件设计和开发》专栏第 7 章为核心讲解如何从零实现一个类似 MyBatis 的 ORM 对象关系映射框架。文章会带你走通「配置文件加载 → XML 解析 → SqlSessionFactory 构建 → SqlSession 执行 SQL → 结果集反射映射」的完整链路并结合仓库中《基于 jdbc 实现一个 Demo 版的 Mybatis》等源码级资料说明每一层设计背后的原理与可扩展点帮助你理解 ORM 中间件屏蔽底层异构、提供统一服务的本质并为后续与 Spring、SpringBoot 整合打下基础。一、前言中间件如何屏蔽底层异构在互联网应用开发的演进过程中中间件这一层的建设基本来自业务系统中非业务逻辑的通用性核心功能抽离逐步形成各类中间件服务。中间件介于操作系统和应用软件之间为应用软件提供服务功能有消息中间件、通信中间件、应用服务器等。因为介于两种软件之间所以称为中间件。我们的生活中也有大量一站式服务为用户屏蔽底层异构装修公司解决设计、人工、材料、品牌、搭配的问题婚庆公司解决酒店、婚车、行程、司仪、布景的问题旅游公司解决路线、行程、吃住、门票、照片的问题。这些服务的共同点就是让用户有一个简单、单一、容易的方式来使用服务和功能。在代码开发中这种思想同样可以映射到中间件功能里——解决底层差异提供统一服务。ORM 框架正是这样一类中间件JDBC 直接操作数据库的底层 API 繁琐且异构而 ORM 把「数据库连接、SQL 执行、结果集转换」等细节封装起来让上层业务代码以对象的方式操作数据。二、需求背景从 JDBC 到 ORM 的演进初学 Java 时我们大多直接使用 JDBC 方式进行数据库连接操作。随着学习的深入陆续接触到 iBatis、MyBatis、Hibernate这些就是 ORM 组件的具体产品实现。ORMObject Relational Mapping对象关系映射是一种程序设计技术用于实现面向对象编程语言里不同类型系统的数据之间的转换让我们可以更方便地使用数据库。那么类似 MyBatis 这样的 ORM 框架是如何实现的呢本章节就以实现一个 ORM 框架为目标看看该怎么设计和实现。需要说明的是本章节的 ORM 框架实现只是模拟开发一个类似 MyBatis SpringBoot Starter 的第一步。在《SpringBoot 中间件设计和开发》专栏中这一章只是起点后续会逐步实现以下内容第 8 章ORM 框架与 Spring 集合 —— 把自己实现的 ORM 框架与 Spring 结合交给 Spring 管理实现类似 MyBatis-Spring 的效果第 9 章结合 SpringBoot 开发 ORM Starter —— 把这个自己实现的 MyBatis 与 SpringBoot 结合开发出类似 mybatis-spring-boot-starter 的 ORM 组件。也就是说本文介绍的 ORM 框架实现是整个手写 MyBatis 全家桶的根基第 8、9 章均建立在这一章实现的核心类之上。三、整体设计Demo 版 ORM 的核心类在动手写代码之前先梳理清楚一个 ORM 框架的核心骨架。仓库中的 《源码分析 | 基于 jdbc 实现一个 Demo 版的 Mybatis》 一文给出了完整的工程结构这个 Demo 版本并非把所有 MyBatis 全部实现一遍而是拨丝抽茧把最核心的内容展示出来。从使用上你会感受一模一样但实现类已经被全部替换核心类包括Configuration 配置类承载数据源、连接、mapper 语句映射 DefaultSqlSession 会话实现类执行 SQL、返回结果 DefaultSqlSessionFactory SqlSessionFactory 的实现类 Resources 资源加载类通过 ClassLoader 读取配置文件 SqlSession 会话接口定义查询与关闭方法 SqlSessionFactory 会话工厂接口 SqlSessionFactoryBuilder 构建入口解析 XML 生成配置与工厂 XNode XML 节点封装保存语句 id、参数、SQL 等信息这些类之间的关系很清晰Resources负责把配置文件路径转换成 Reader 流SqlSessionFactoryBuilder读取并解析 XML把数据源、连接、mapper 语句解析结果塞进Configuration拿着 Configuration 构建出DefaultSqlSessionFactory调用工厂的openSession()创建DefaultSqlSession会话根据 statement 定位XNode封装了 SQL、参数顺序、结果类型用 JDBC 的PreparedStatement执行再通过反射把 ResultSet 转换为结果对象。对照 MyBatis 的真实结构SqlSessionFactoryBuilder → SqlSessionFactory → SqlSession → Executor → StatementHandler 等这个 Demo 版把 MyBatis 最核心的调用骨架保留了下来只是做了大幅简化。四、环境与依赖准备实现该 ORM 框架所需的运行环境与依赖如下取自 Demo 版 Mybatis 案例工程JDK 1.8IDEA案例基于 2019.3.1 开发dom4j 1.6.1用于解析 XML 配置文件在 Maven 工程的pom.xml中引入 dom4j 依赖dependency groupIddom4j/groupId artifactIddom4j/artifactId version1.6.1/version /dependency五、代码实现核心链路逐层拆解1. 加载 XML 配置文件Resources 资源类整个框架的入口是Resources.getResourceAsReader(resource)通过配置文件地址获取读取流为后面的解析做基础。public class Resources { public static Reader getResourceAsReader(String resource) throws IOException { return new InputStreamReader(getResourceAsStream(resource)); } private static InputStream getResourceAsStream(String resource) throws IOException { ClassLoader[] classLoaders getClassLoaders(); for (ClassLoader classLoader : classLoaders) { InputStream inputStream classLoader.getResourceAsStream(resource); if (null ! inputStream) { return inputStream; } } throw new IOException(Could not find resource resource); } private static ClassLoader[] getClassLoaders() { return new ClassLoader[]{ ClassLoader.getSystemClassLoader(), Thread.currentThread().getContextClassLoader()}; } }这段代码依次做了两件事获取 ClassLoader 集合最大限度搜索配置文件。这里同时使用系统类加载器与线程上下文类加载器是为了兼容不同运行环境下的类加载策略例如在 Web 容器或 Spring 容器中资源可能由线程上下文类加载器加载通过classLoader.getResourceAsStream读取配置资源找到后立即返回否则抛出IOException(Could not find resource resource)。2. 配置文件格式数据源与 Mapper 映射我们采用 MyBatis 的配置文件结构进行解析在不破坏原有结构的情况下最大可能贴近源码。MyBatis 单独使用时会用到两个配置文件数据源配置、Mapper 映射配置。mybatis-config-datasource.xml —— 数据源配置?xml version1.0 encodingUTF-8? !DOCTYPE configuration PUBLIC -//mybatis.org//DTD Config 3.0//EN http://mybatis.org/dtd/mybatis-3-config.dtd configuration environments defaultdevelopment environment iddevelopment transactionManager typeJDBC/ dataSource typePOOLED property namedriver valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://127.0.0.1:3306/itstack?useUnicodetrue/ property nameusername valueroot/ property namepassword value123456/ /dataSource /environment /environments mappers mapper resourcemapper/User_Mapper.xml/ mapper resourcemapper/School_Mapper.xml/ /mappers /configurationUser_Mapper.xml —— Mapper 映射配置?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespaceorg.itstack.demo.dao.IUserDao select idqueryUserInfoById parameterTypejava.lang.Long resultTypeorg.itstack.demo.po.User SELECT id, name, age, createTime, updateTime FROM user where id #{id} /select select idqueryUserList parameterTypeorg.itstack.demo.po.User resultTypeorg.itstack.demo.po.User SELECT id, name, age, createTime, updateTime FROM user where age #{age} /select /mapper这里需要理解几个关键点namespace用于标识 Mapper 的命名空间在 Demo 版中与 DAO 接口全限定名对应id是 SQL 语句的唯一标识与namespace拼接后作为全局 key如org.itstack.demo.dao.IUserDao.queryUserInfoByIdparameterType声明入参类型resultType声明结果映射的 POJO 类型SQL 中使用#{id}占位符运行时需要替换为 JDBC 的?占位符并记录参数名与位置的对应关系。3. 解析 XML 配置文件SqlSessionFactoryBuilder配置文件加载后开始解析操作同样仿照 MyBatis 但进行简化。构建入口如下SqlSessionFactory sqlMapper new SqlSessionFactoryBuilder().build(reader);SqlSessionFactoryBuilder.build() —— 入口构建类public DefaultSqlSessionFactory build(Reader reader) { SAXReader saxReader new SAXReader(); try { Document document saxReader.read(new InputSource(reader)); Configuration configuration parseConfiguration(document.getRootElement()); return new DefaultSqlSessionFactory(configuration); } catch (DocumentException e) { e.printStackTrace(); } return null; }流程分两步通过读取流创建 XML 解析的Document类这里用的是 dom4j 的 SAXReaderparseConfiguration解析 XML 文件把连接池、数据源、mapper 关系设置到配置类中。parseConfiguration() —— 解析过程private Configuration parseConfiguration(Element root) { Configuration configuration new Configuration(); configuration.setDataSource(dataSource(root.selectNodes(//dataSource))); configuration.setConnection(connection(configuration.dataSource)); configuration.setMapperElement(mapperElement(root.selectNodes(mappers))); return configuration; }从 XML 内容可以看到需要解析出数据库连接池信息dataSource以及数据库语句映射关系mappers。dataSource() —— 解析出数据源private MapString, String dataSource(ListElement list) { MapString, String dataSource new HashMap(4); Element element list.get(0); List content element.content(); for (Object o : content) { Element e (Element) o; String name e.attributeValue(name); String value e.attributeValue(value); dataSource.put(name, value); } return dataSource; }这个过程比较简单只需要将数据源信息driver、url、username、password以键值对形式取出即可。connection() —— 获取数据库连接private Connection connection(MapString, String dataSource) { try { Class.forName(dataSource.get(driver)); return DriverManager.getConnection(dataSource.get(url), dataSource.get(username), dataSource.get(password)); } catch (ClassNotFoundException | SQLException e) { e.printStackTrace(); } return null; }这就是 JDBC 最原始的代码先Class.forName加载驱动再通过DriverManager.getConnection建立数据库连接。注意这个 Demo 版为简化流程直接持有单一 Connection真实生产环境应由连接池管理。mapperElement() —— 解析 SQL 语句private MapString, XNode mapperElement(ListElement list) { MapString, XNode map new HashMap(); Element element list.get(0); List content element.content(); for (Object o : content) { Element e (Element) o; String resource e.attributeValue(resource); try { Reader reader Resources.getResourceAsReader(resource); SAXReader saxReader new SAXReader(); Document document saxReader.read(new InputSource(reader)); Element root document.getRootElement(); //命名空间 String namespace root.attributeValue(namespace); // SELECT ListElement selectNodes root.selectNodes(select); for (Element node : selectNodes) { String id node.attributeValue(id); String parameterType node.attributeValue(parameterType); String resultType node.attributeValue(resultType); String sql node.getText(); // ? 匹配 MapInteger, String parameter new HashMap(); Pattern pattern Pattern.compile((#\\{(.*?)})); Matcher matcher pattern.matcher(sql); for (int i 1; matcher.find(); i) { String g1 matcher.group(1); String g2 matcher.group(2); parameter.put(i, g2); sql sql.replace(g1, ?); } XNode xNode new XNode(); xNode.setNamespace(namespace); xNode.setId(id); xNode.setParameterType(parameterType); xNode.setResultType(resultType); xNode.setSql(sql); xNode.setParameter(parameter); map.put(namespace . id, xNode); } } catch (Exception ex) { ex.printStackTrace(); } } return map; }这个过程的几个要点首先解析mappers下所有mapper resource...指向的 Mapper XML 文件目前为了测试只解析select相关标签所有的 SQL 语句为了确认唯一使用namespace select 中的 id拼接作为 key与 SQL 一起存放到 map 中MyBatis 的 SQL 语句配置中都有占位符用于传参如where id #{id}所以需要将占位符#{id}替换为 JDBC 的问号?同时把占位符的顺序位置与名称存放到 map 结构中方便后续设置查询时的入参。XNode就是这个映射节点的封装类保存 namespace、id、parameterType、resultType、sql已替换占位符与参数位置映射public class XNode { private String namespace; private String id; private String parameterType; private String resultType; private String sql; private MapInteger, String parameter; // getter / setter 略 }4. 构建 DefaultSqlSessionFactory最后将初始化后的配置类 Configuration 作为参数创建 DefaultSqlSessionFactory。public class DefaultSqlSessionFactory implements SqlSessionFactory { private final Configuration configuration; public DefaultSqlSessionFactory(Configuration configuration) { this.configuration configuration; } Override public SqlSession openSession() { return new DefaultSqlSession(configuration.connection, configuration.mapperElement); } }这个类非常简单构造函数只接收配置类入参实现SqlSessionFactory.openSession()用于创建 DefaultSqlSession从而可以执行 SQL 操作。5. 开启 SqlSessionSqlSession session sqlMapper.openSession();这一步就是创建 DefaultSqlSession。SqlSession接口定义如下public interface SqlSession { T T selectOne(String statement); T T selectOne(String statement, Object parameter); T ListT selectList(String statement); T ListT selectList(String statement, Object parameter); void close(); }接口能力与 MyBatis 的 SqlSession 对齐selectOne查询单条selectList查询集合close关闭会话。6. 执行 SQLselectOne 与参数绑定User user session.selectOne(org.itstack.demo.dao.IUserDao.queryUserInfoById, 1L);DefaultSqlSession 中的 selectOne 实现public T T selectOne(String statement, Object parameter) { XNode xNode mapperElement.get(statement); MapInteger, String parameterMap xNode.getParameter(); try { PreparedStatement preparedStatement connection.prepareStatement(xNode.getSql()); buildParameter(preparedStatement, parameter, parameterMap); ResultSet resultSet preparedStatement.executeQuery(); ListT objects resultSet2Obj(resultSet, Class.forName(xNode.getResultType())); return objects.get(0); } catch (Exception e) { e.printStackTrace(); } return null; }执行流程梳理通过statement从 map 中获取最初解析 XML 时存储的 select 标签信息XNode获取 SQL 语句后交给 JDBC 的PreparedStatement执行——这也是 ORM 框架天然防 SQL 注入的原因之一参数通过setXxx绑定而非字符串拼接执行查询preparedStatement.executeQuery()得到 ResultSet通过resultSet2Obj反射映射为resultType指定的 POJOselectOne返回objects.get(0)selectList则返回全部结果。参数绑定buildParameter的核心逻辑抽取展示private void buildParameter(PreparedStatement preparedStatement, Object parameter, MapInteger, String parameterMap) throws SQLException, IllegalAccessException { int size parameterMap.size(); // 单个参数 if (parameter instanceof Long) { for (int i 1; i size; i) { preparedStatement.setLong(i, Long.parseLong(parameter.toString())); } return; } if (parameter instanceof Integer) { for (int i 1; i size; i) { preparedStatement.setInt(i, Integer.parseInt(parameter.toString())); } return; } if (parameter instanceof String) { for (int i 1; i size; i) { preparedStatement.setString(i, parameter.toString()); } return; } // 对象参数反射读取字段 MapString, Object fieldMap new HashMap(); Field[] declaredFields parameter.getClass().getDeclaredFields(); for (Field field : declaredFields) { String name field.getName(); field.setAccessible(true); Object obj field.get(parameter); field.setAccessible(false); fieldMap.put(name, obj); } for (int i 1; i size; i) { String parameterDefine parameterMap.get(i); Object obj fieldMap.get(parameterDefine); if (obj instanceof Short) { preparedStatement.setShort(i, Short.parseShort(obj.toString())); continue; } if (obj instanceof Integer) { preparedStatement.setInt(i, Integer.parseInt(obj.toString())); continue; } if (obj instanceof Long) { preparedStatement.setLong(i, Long.parseLong(obj.toString())); continue; } if (obj instanceof String) { preparedStatement.setString(i, obj.toString()); continue; } if (obj instanceof Date) { preparedStatement.setDate(i, (java.sql.Date) obj); } } }两个分支逻辑单个参数Long、Integer、String...比较简单直接把值 set 到对应占位符位置对象参数则通过反射获取类对象的所有 Field 属性与解析 XML 时记录的参数 Map位置 → 参数名进行匹配后 set 值。7. 结果集映射反射封装 resultSet2Obj参数设置完成后执行查询接下来将查询结果转换为我们的类核心是反射操作private T ListT resultSet2Obj(ResultSet resultSet, Class? clazz) { ListT list new ArrayList(); try { ResultSetMetaData metaData resultSet.getMetaData(); int columnCount metaData.getColumnCount(); // 每次遍历行值 while (resultSet.next()) { T obj (T) clazz.newInstance(); for (int i 1; i columnCount; i) { Object value resultSet.getObject(i); String columnName metaData.getColumnName(i); String setMethod set columnName.substring(0, 1).toUpperCase() columnName.substring(1); Method method; if (value instanceof Timestamp) { method clazz.getMethod(setMethod, Date.class); } else { method clazz.getMethod(setMethod, value.getClass()); } method.invoke(obj, value); } list.add(obj); } } catch (Exception e) { e.printStackTrace(); } return list; }这里通过反射生成类对象类类型定义在 SQL 标签的resultType上要点包括通过ResultSetMetaData获取列数量与列名根据列名拼装setXxx方法名列名首字母大写如id→setId再通过clazz.getMethod找到对应 setter 反射调用时间类型需要特殊判断JDBC 返回的Timestamp与 Java 的Date不是同一个类型因此需要单独走Date.class分支处理。这个映射逻辑演示了 ORM 中最常见的「列名 → 属性 → setter」约定式映射真实 MyBatis 在此基础上还支持 resultMap、驼峰命名自动转换、TypeHandler 自定义类型处理器等但底层思想一致。8. 测试验证单条查询与集合查询先测试单条查询ApiLikeTest.test_queryUserInfoById()Test public void test_queryUserInfoById() { String resource spring/mybatis-config-datasource.xml; Reader reader; try { reader Resources.getResourceAsReader(resource); SqlSessionFactory sqlMapper new SqlSessionFactoryBuilder().build(reader); SqlSession session sqlMapper.openSession(); try { User user session.selectOne(org.itstack.demo.dao.IUserDao.queryUserInfoById, 1L); System.out.println(JSON.toJSONString(user)); } finally { session.close(); reader.close(); } } catch (Exception e) { e.printStackTrace(); } }一切顺利结果如下{age:18,createTime:1576944000000,id:1,name:水水,updateTime:1576944000000} Process finished with exit code 0可能乍一看这个测试类和 MyBatis 原生 API 的测试代码一模一样其实它们引入的包不同MybatisApiTest.java 引入的包MyBatis 原生import org.apache.ibatis.io.Resources; import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSessionFactory; import org.apache.ibatis.session.SqlSessionFactoryBuilder;ApiLikeTest.java 引入的包本次自研 Demoimport org.itstack.demo.like.Resources; import org.itstack.demo.like.SqlSession; import org.itstack.demo.like.SqlSessionFactory; import org.itstack.demo.like.SqlSessionFactoryBuilder;接口使用方式几乎完全一致这正是中间件设计的目标——让使用方无感知底层实现差异。再测试集合查询入参为对象类型ApiLikeTest.test_queryUserList()Test public void test_queryUserList() { String resource spring/mybatis-config-datasource.xml; Reader reader; try { reader Resources.getResourceAsReader(resource); SqlSessionFactory sqlMapper new SqlSessionFactoryBuilder().build(reader); SqlSession session sqlMapper.openSession(); try { User req new User(); req.setAge(18); ListUser userList session.selectList(org.itstack.demo.dao.IUserDao.queryUserList, req); System.out.println(JSON.toJSONString(userList)); } finally { session.close(); reader.close(); } } catch (Exception e) { e.printStackTrace(); } }测试结果[{age:18,createTime:1576944000000,id:1,name:水水,updateTime:1576944000000},{age:18,createTime:1576944000000,id:2,name:豆豆,updateTime:1576944000000}] Process finished with exit code 0从这两个测试可以看到SQL 查询有入参、有不需要入参、有查询一个、有查询集合只需在 SqlSession 上合理包装即可复用同一套解析、绑定、映射链路。六、设计要点与可扩展方向对照 MyBatis 源码可以归纳出这个 Demo 版 ORM 的设计要点以及后续演进方向配置解析层Configuration负责把 XML 中的 dataSource、mapper 解析成内存结构。真实 MyBatis 还会解析resultMap、if/where/foreach动态 SQL、缓存配置等本文 Demo 只解析了 select 与占位符会话与执行层SqlSession / ExecutorDemo 版把 SQL 执行逻辑直接放在 DefaultSqlSession 中MyBatis 则进一步拆出 Executor、StatementHandler、ParameterHandler、ResultSetHandler通过责任链各司其职这也是仓库中《手撸 Mybatis》专栏后续章节逐步完善的内容从第 2 章创建简单的映射器代理工厂一直到第 22 章MyBatis 设计模式参数与结果处理Demo 版通过 instanceof 分支处理常见类型MyBatis 用 TypeHandler 注册机制支持任意类型的读写转换扩展性更强占位符与 SQL 注入#{id}统一替换为?并通过 PreparedStatement 绑定参数从根本上避免了 SQL 拼接注入问题。关于预编译的更多细节可参考仓库中的《面经手册 · 第 32 篇MyBatis 从 SQL 注入到预编译深度解析》。另外ORM 框架对接口的代理支持只定义 Mapper 接口、不写实现类即可执行增删改查也是其重要特性这一机制的原理可参考仓库中的 《源码分析Mybatis 接口没有实现类为什么可以执行增删改查》以及《手撸 Mybatis 专栏》中关于映射器代理工厂的实现。七、总结本篇文章以 JDBC 为起点逐步实现了一个简化版 ORM 框架的核心链路通过Resources加载配置文件通过SqlSessionFactoryBuilder解析 XML构建Configuration数据源 连接 mapper 语句映射通过DefaultSqlSessionFactory创建SqlSession通过DefaultSqlSession.selectOne/selectList完成参数绑定、SQL 执行、结果集反射映射。学习完 MyBatis 核心源码再亲手实现一下核心过程就会非常清晰地理解整个流程不会觉得自己的知识栈有漏洞。只有深入的学习才能将这样的技术赋能到其他开发场景例如给 ES 写一个类似EsBatis的查询组件。同时本章的 ORM 框架只是第一步。在《SpringBoot 中间件设计和开发》专栏的后续章节中将把它与 Spring 结合第 8 章再结合 SpringBoot 开发出类似 mybatis-spring-boot-starter 的 ORM Starter第 9 章真正完成从会用手写 ORM到能开发中间件的能力跃迁。赞分享文档教程后端【免费下载链接】CodeGuide:books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总旨在为大家提供一个清晰详细的学习教程侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助请给予支持(关注、点赞、分享)项目地址https://gitcode.com/gh_mirrors/code/CodeGuide点击查看免费下载相关推荐CodeGuide 中间件实战自研 ORM 框架如何与 Spring 集合MyBatis-Spring 核心原理剖析CodeGuide 中间件实战自研 ORM 框架如何与 Spring 集合MyBatis Spring 核心原理剖析 本篇指南基于 CodeGuide 仓文档教程后端setup-gcloud进阶用法多服务账户切换与组件定制安装指南setup gcloud进阶用法多服务账户切换与组件定制安装指南 在Google Cloud CI/CD自动化流程中 setup gcloud GitHub7天从零实现Go语言ORM框架GeeORM7天从零实现Go语言ORM框架GeeORM 什么是ORM框架 ORMObject Relational Mapping对象关系映射是一种编程技术它通过在示例工程创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表