【源码系列】MyBatis原理源码
系列文章目录
【源码系列】MyBatis与Spring整合原理源码
文章目录
- 系列文章目录
- 前言
- 一、简介
- 原生Jdbc查询代码
- Mybatis查询代码
- 二、MyBatis中重要组件
- 三、Mybatis原理源码
- 1.准备工作
- 1.创建Configuration对象
- 1.1 Configuration对象源码总结
- 1.2 Configuration对象源码分析
- 2.获取SqlSessionFactory对象
- 3.获取SqlSession对象
- 4.获取Mapper接口代理对象
- 4.准备阶段总结
- 2. 执行查询过程中源码
- 1.插件调用相关代码
- 2.缓存相关代码
- 3.原生Jdbc查询代码
- 4. 结果集的处理
- 5.执行查询过程中总结
- 四、总结
前言
Mybatis作为使用最为广泛的ORM框架,懂得如何使用是必备技能,为了我们使用的可以更加得心应手,我们有必要知道它是如何工作的,为什么我们只需要指定一个接口,它就能将数据查询回来?以及它是怎么一步步将数据查询回来的。
一、简介
我们先看下原生的Jdbc代码,相信每个同学之前都写过这段代码吧,相信大家用了MyBatis框架以后,都没再写过这些代码了吧。我们只需要通过调用Mapper接口的方法并且在mapper.xml中编写对应的sql语句来完成Jdbl的工作, 框架只是让我们使用起来更加简单,万变不离其中,核心还是这几句代码,只不过MyBatis在Jdbc的基础上进行了一些封装,让我们无需关心Connect对象的创建、Statement对象的创建、参数的设置、结果集处理等等。Mybatis将这些步骤都封装成了一个个组件去完成对应的工作。
原生Jdbc查询代码
public static void main(String[] args) throws Exception {Class.forName("com.mysql.jdbc.Driver");// 获取数据库连接对象Connection conn= DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test","root","123456");String sql="select * from emp where empno = ?";// 创建Statement对象PreparedStatement stm = conn.prepareStatement(sql);// 参数设置stm.setInt(1, 7369);// 执行查询,结果返回到ResultSetResultSet rs = stm.executeQuery();// 处理结果集while(rs.next()){System.out.println(rs.getInt("empno"));System.out.println(rs.getString("ename"));}conn.close();}
Mybatis查询代码
public static void main(String[] args) throws Exception {// 配置文件String resource = "mybatis-config.xml";InputStream inputStream = Resources.getResourceAsStream(resource);// 生成SqlSessionFactorySqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);// 打开回话SqlSession sqlSession = sqlSessionFactory.openSession();// 获取代理类EmpDao mapper = sqlSession.getMapper(EmpDao.class);// 通过代理类调用查询方法Emp empByEmpno = mapper.findEmpByEmpno(7369);// 关闭会话sqlSession.close();}
二、MyBatis中重要组件
这是我之前画的mybatis源码图中关于组件及其作用的一张图,先让这些组件和大家见个面,避免后面源码分析的时候,大家一脸懵逼。
三、Mybatis原理源码
1.准备工作
1.创建Configuration对象
Configuration对象作为全局的配置对象,还是挺重要的,通过上面的组件图我们可以知道SqlSessionFactory、SqlSession都包含了该对象,可以说该整个查询过程中都可能会用到该对象。
我列举一下该类中比较重要的一些属性。
1.1 Configuration对象源码总结
这一块代码量也比较多,代码其实挺简单,就是看起来比较繁琐,我先总结一下,不想深入的同学可以跳过这里的源码。
- 解析给定的配置文件,加载配置文件中的配置属性设置到Configuration中
- 解析环境对象标签,设置到environment属性中,Environment对象包含了包含了TransactionFactory、DataSource
- 解析日志相关标签并设置logImpl实现类
- Configuration对象如果不指定执行器,默认为简单的执行器SimpleExecutor
- 解析配置的mappers包,并将每个Mapper接口生成一个MapperProxyFactory并放到mapperRegistry 属性中,方便下次用时获取。
- 第五步的同时,它会去解析每个mapper.xml文件的各个增删改查标签,每个语句都会构建成一个MappedStatement对象,并缓存到mappedStatements集合中。
这里涉及到了Executor、MapperProxyFactory、MappedStatement等组件,大家可以去组件图中再看下他们的作用,多熟悉熟悉。
1.2 Configuration对象源码分析
public class Configuration {//环境对象,包含了TransactionFactory、DataSourceprotected Environment environment;// 日志对象,我们平常配置的日志类实际就是配置这个属性protected Class<? extends Log> logImpl;//默认为简单执行器protected ExecutorType defaultExecutorType = ExecutorType.SIMPLE;// 所有的Mapper接口生成的MapperProxyFactoryprotected final MapperRegistry mapperRegistry = new MapperRegistry(this);// 自定义插件,说一个大家都熟悉的 PageHelper分页插件protected final InterceptorChain interceptorChain = new InterceptorChain();// mapper.xml每个增删改查标签都会生成一个MappedStatementprotected final Map<String, MappedStatement> mappedStatements;
}
我们先看下Mybatis配置文件的内容
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configurationPUBLIC "-//mybatis.org//DTD Config 3.0//EN""http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration><!-- 配置文件 --><properties resource="db.properties" ></properties><settings><!-- 日志实现类配置 --><setting name="logImpl" value="STDOUT_LOGGING"/><!-- 下划线映射成驼峰 --><setting name="mapUnderscoreToCamelCase" value="true"/></settings><!-- 别名 --><typeAliases><package name="com.demo.bean"/></typeAliases><!-- 插件 --><plugins><plugin interceptor="com.demo.MyInterceptor"></plugin></plugins><!-- 环境对象 --><environments default="development"><environment id="development"><!-- 获取事务管理器会用到 --><transactionManager type="JDBC"/><dataSource type="POOLED"><property name="driver" value="${driver}"/><property name="url" value="${url}"/><property name="username" value="${username}"/><property name="password" value="${password}"/></dataSource></environment></environments><databaseIdProvider type="DB_VENDOR"><property name="MySQL" value="mysql"/><property name="SQL Server" value="sqlserver"/><property name="Oracle" value="oracle"/></databaseIdProvider><!-- mapper接口包路径 --><mappers><package name="com.demo.dao"/></mappers>
</configuration>
创建Configuration对象核心方法org.apache.ibatis.builder.xml.XMLConfigBuilder#parseConfiguration
我们从这里开始分析
解析mappers节点核心方法
通过解析标签中各个子属性,通过构建者模式,各个属性构建出一个MappedStatement对象
// 解析语句(select|insert|update|delete) 和里面各个属性标签,通过这些值去生成MappedStatementpublic void parseStatementNode() {// 获取SQL节点的id以及databaseId属性String id = context.getStringAttribute("id");String databaseId = context.getStringAttribute("databaseId");// 省略部分代码// 根据SQL节点的名称决定其SqlCommandTypeString nodeName = context.getNode().getNodeName();SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));boolean isSelect = sqlCommandType == SqlCommandType.SELECT;boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);//是否要缓存select结果boolean useCache = context.getBooleanAttribute("useCache", isSelect);//仅针对嵌套结果 select 语句适用:如果为 true,就是假设包含了嵌套结果集或是分组了,这样的话当返回一个主结果行的时候,就不会发生有对前面结果集的引用的情况。//这就使得在获取嵌套的结果集的时候不至于导致内存不够用。默认值:false。boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false);// 在解析SQL语句之前,先处理其中的include节点XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);includeParser.applyIncludes(context.getNode());//参数类型String parameterType = context.getStringAttribute("parameterType");Class<?> parameterTypeClass = resolveClass(parameterType);//脚本语言,mybatis3.2的新功能String lang = context.getStringAttribute("lang");LanguageDriver langDriver = getLanguageDriver(lang);//解析之前先解析<selectKey>processSelectKeyNodes(id, parameterTypeClass, langDriver);// 新增操作返回主键生成器KeyGenerator keyGenerator;// 获取selectKey节点对应的SelectKeyGenerator的idString keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX;keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true);// SQL节点下存在SelectKey节点if (configuration.hasKeyGenerator(keyStatementId)) {keyGenerator = configuration.getKeyGenerator(keyStatementId);} else {// 根据SQL节点的useGeneratedKeys属性值、mybatis-config.xml中全局的useGeneratedKeys配置,以及是否为insert语句,决定使用的KeyGenerator接口实现keyGenerator = context.getBooleanAttribute("useGeneratedKeys",configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType))? Jdbc3KeyGenerator.INSTANCE : NoKeyGenerator.INSTANCE;}//解析成SqlSource,一般是DynamicSqlSourceSqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);//语句类型, STATEMENT|PREPARED|CALLABLE 的一种StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));// 获取每次批量返回的结果行数Integer fetchSize = context.getIntAttribute("fetchSize");// 获取超时时间Integer timeout = context.getIntAttribute("timeout");// 引用外部parameterMapString parameterMap = context.getStringAttribute("parameterMap");// 结果类型String resultType = context.getStringAttribute("resultType");Class<?> resultTypeClass = resolveClass(resultType);// 引用外部的resultMapString resultMap = context.getStringAttribute("resultMap");// 结果集类型,FORWARD_ONLY|SCROLL_SENSITIVE|SCROLL_INSENSITIVE 中的一种String resultSetType = context.getStringAttribute("resultSetType");ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);if (resultSetTypeEnum == null) {resultSetTypeEnum = configuration.getDefaultResultSetType();}//(仅对 insert 有用) 标记一个属性, MyBatis 会通过 getGeneratedKeys 或者通过 insert 语句的 selectKey 子元素设置它的值String keyProperty = context.getStringAttribute("keyProperty");//(仅对 insert 有用) 标记一个属性, MyBatis 会通过 getGeneratedKeys 或者通过 insert 语句的 selectKey 子元素设置它的值String keyColumn = context.getStringAttribute("keyColumn");String resultSets = context.getStringAttribute("resultSets");// 通过MapperBuilderAssistant创建MappedStatement对象,并添加到mappedStatements集合中保存builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,resultSetTypeEnum, flushCache, useCache, resultOrdered,keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);}public MappedStatement addMappedStatement(String id,SqlSource sqlSource,StatementType statementType,SqlCommandType sqlCommandType,Integer fetchSize,Integer timeout,String parameterMap, Class<?> parameterType,String resultMap, Class<?> resultType, ResultSetType resultSetType,boolean flushCache,boolean useCache,boolean resultOrdered, KeyGenerator keyGenerator,String keyProperty,String keyColumn,String databaseId,LanguageDriver lang,String resultSets) {// ...// 为id加上namespace前缀id = applyCurrentNamespace(id, false);// 是否是select语句boolean isSelect = sqlCommandType == SqlCommandType.SELECT;// 建造者模式MappedStatement.Builder statementBuilder = new MappedStatement.Builder(configuration, id, sqlSource, sqlCommandType).resource(resource).fetchSize(fetchSize).timeout(timeout).statementType(statementType).keyGenerator(keyGenerator).keyProperty(keyProperty).keyColumn(keyColumn).databaseId(databaseId) .lang(lang).resultOrdered(resultOrdered).resultSets(resultSets).resultMaps(getStatementResultMaps(resultMap, resultType, id)).resultSetType(resultSetType).flushCacheRequired(valueOrDefault(flushCache, !isSelect)).useCache(valueOrDefault(useCache, isSelect)).cache(currentCache);ParameterMap statementParameterMap = getStatementParameterMap(parameterMap, parameterType, id);if (statementParameterMap != null) {statementBuilder.parameterMap(statementParameterMap);}MappedStatement statement = statementBuilder.build();// 添加到configuration的mappedStatements集合中configuration.addMappedStatement(statement);return statement;}
到这里,创建Configuration基本完成了,我没有把所有代码都拉出来,我把我们平常用到的一些重要的东西代码拉了出来。
2.获取SqlSessionFactory对象
创建SqlSessionFactory过程就比较简单,就是将得到的Configuration对象设置给了SqlSessionFactory的属性中,方便后续用时,直接从这里拿。
这里涉及到了SqlSessionFactory组件,大家可以去组件图中再看下他们的作用,多熟悉熟悉。
3.获取SqlSession对象
// 获取数据库的会话,创建出数据库连接的会话对象(事务工厂,事务对象,执行器,如果有插件的话会进行插件的解析)SqlSession sqlSession = sqlSessionFactory.openSession();public SqlSession openSession() {// 获取默认的执行器类型return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, false);}
通过Configuration配置的执行器类型、隔离级别、事务自动提交来实例化SqlSession对象
创建Executor方法,我们可以看到关于插件的代码,插件放在interceptorChain的集合中,有多个会对Executor进行多次动态代理,具体插件代码需要在执行之前调用,后面调用到时我会框出来。
插件最后会调到该方法中,这里我们就能清楚的看到Jdk动态代理的代码啦
这里涉及到了SqlSession、Transaction、Environment、Executor组件,大家可以去组件图中再看下他们的作用,多熟悉熟悉。
4.获取Mapper接口代理对象
EmpDao mapper = sqlSession.getMapper(EmpDao.class);
通过前面的代码,我们知道再准备Configuration对象的时候,会为EmpDao创建一个MapperProxyFactory对象并缓存起来。
我们知道MapperProxyFactory是用来生成MapperProxy,MapperProxy类实现了InvocationHandler接口,我们知道它的invoke方法是用来执行代理的逻辑,所以它的invoke方法就很重要了,算是和数据库打交道的入口。后续我们会跟这个invoke方法。
4.准备阶段总结
- 首先创建了Configuration对象,该对象包含了整个MyBatis运行过程中需要用到的配置
- 准备好Configuration对象以后,开始创建SqlSessionFactory并将Configuration对象作为参数传到SqlSessionFactory中
- 准备好SqlSessionFactory后,开始创建SqlSession对象,我们知道所有的增删改查的交互工作都是由SqlSession来完成的,它包含了Configuration对象和Executor对象,通过组件图我们知道交付工作都是交给Executor来完成的,SqlSession只是一个装饰而已。
- 这些都准备好以后,开始创建代理对象,代理对象的InvocationHandler为MapperProxy类,所以后续调用代理对象的方法肯定会到MapperProxy#invoke方法中。
- 到这里所有准备工作都做完了
2. 执行查询过程中源码
// 具体查询代码
Emp empByEmpno = mapper.findEmpByEmpno(7369);
通过上面的讲解,该方法调用肯定会调到MapperProxy#invoke方法,我们直接进去,
通过源码我们知道进入invoke方法以后,会将方法包装成组件MapperMethod,然后再生成MapperInvoker对象。
组件MapperMethod它包含了sql类型、方法的入参、结果类型等,后面MapperMethod执行execute方法会根据sql类型来执行SqlSession对应的处理方法。
我们的语句是查询单个结果的操作,只要类型这种懂了别的类型也是一样的,就不分析了,毕竟各位同学也是很棒,相信能吃透。
这里我们遇到了组件MapperMethod,我们可以理解为用mapper的查询方法的一些属性信息组装成了MapperMethod对象,与之相对相应的mapper.xml中的增删改查标签被组装成了MappedStatement
下面我们要分析SqlSession#selectOne方法,虽然这里写着是selectOne,后续实际还是调用的selectList方法,只是最后对结果做了个判断,我们来看代码,一下就能理解了,还有我们经常遇到那个存在多个结果集的异常就是在这里抛出去的
1.插件调用相关代码
说到插件,有些同学不理解它是个啥,相信大家都用过PageHelper分页插件吧,它就是用户自定义的插件,只是别人给我们封装好了插件的逻辑,我们只需要使用就行,可能很多同学平常用的时候非常好奇,为啥它能实现我们的分页功能,实际上它拦截了所有的查询操作,执行查询语句之前,加上了limit 参数。
查询之前先通过方法名称获取了方法对应的MappedStatement,然后带着参数通过Executor去执行操作操作,如果有符合要求的插件,就会先执行插件的逻辑
插件调用会先进入下面这个方法
插件逻辑执行完成后通过反射调用Executor的query方法
2.缓存相关代码
查询前,会先生成一二级缓存的key,然后判断是否开启二级缓存,开了的话先从二级缓存中获取数据,没有再去一级缓存中取,还是没有再执行数据库的查询操作。
Mybatis缓存相关知识,我直接贴一篇博客,我觉得讲的挺好的:https://www.cnblogs.com/happyflyingpig/p/7739749.html
二级缓存代码
一级缓存代码
查询数据库代码
3.原生Jdbc查询代码
4. 结果集的处理
- 获取结果集ResultMap对象,根据ResultMap中定义的映射规则对ResultSet进行映射
- 根据ResultMap类型创建一个目标结果对象,通过目标对象创建MetaObject对象
- 拿到目标对象属性和数据库表中字段对应的映射集合
- 遍历映射集合,通过类型转换器将值转换成属性目标类型
- 通过MetaObject设置结果对象的该属性值
- 将结果对象添加到ResulHandler的结果集合中
5.执行查询过程中总结
- 调用mapper的查询方法,代理类会调到MapperProxy#invoke(这个就是动态代理传入的InvocationHandler对象)方法,这里是查询过程的入口
- 执行的方法会被包装成
MapperMethod
组件,它包含了Sql类型、方法的返回值,入参类型等信息,再将得到的MapperMethod包装成了MapperMethodInvoker - 根据MapperMethod中的Sql类型和返回值类型调用对应的SqlSession的增删改查方法(实际干活的是Exector)
- 调用查询方法之前,如果有插件的话,会先执行插件代码,插件实现原理也是通过JDK动态代理来实现的,多个插件就代理多次来实现
- 查询方法会先判断是否开启了二级缓存(默认关闭,作用范围SqlSessionFactory,通过装饰器模式来实现二级缓存的),先从二级缓存中取数据,没有再去一级缓存(作用范围:SqlSession)中取,还是没有的话就去数据库中查
- 查询数据库得到的结果,通过ResultHandler组件对结果集合目标结果对象进行值映射
- 将结果存入一级缓存中,开了二级缓存,也放入二级缓存
- 整个执行过程结束
四、总结
现在我们知道,Mybatis的核心是Jdk的动态代理和各个组件,整个运行可以分为两大步骤:
1. 准备阶段
1. 解析配置文件获取全局Configuration
对象,该过程中为每个Mapper接口创建MapperProxyFactory
对象,
2. 有了Configuration
对象以后通过构建者模式构建SqlSessionFactory
3. 通过SqlSessionFactory
创建SqlSession
对象,SqlSession
包含了Executor
(Sql的执行器),如果有插件,Executor
会织入插件逻辑(通过动态代理实现)
4. 通过SqlSession
对象加上动态代理,获取Mapper的代理对象(InvocationHandler是MapperProxy
),MapperProxy#invoke
作为执行查询时的入口
2. 查询阶段
- 执行的方法会被包装成
MappedMethod
(包含了方法的出入参和DB操作类型),再包装成MapperMethodInvoker
- 根据
MappedMethod
中的DB类型执行SqlSession对应的增删改查方法(实际调用时Executor
对象的方法) - 执行
Executor
织入的插件代码 - 开始查询的具体逻辑,有二级缓存先从二级缓存中取,没有从一级缓存中取,还是没有最终再查询数据库
- 得到查询结果后通过
ResulHanler
来完成结果集和目标对象的映射
终于写完了,写了两天,累死哥了。如果有写的不对的,请大佬指出,并给出意见,如果有说错了的我会修改的,最后附上我自己画的流程图,希望对大家对MyBatis源码理解有所帮助,其实MyBatis的源码还是比较好理解的,记住这些组件及功能,多看几遍它的运行流程,基本就能吃透Mybatis啦
Mybatis执行流程图:https://processon.com/view/61e6699507912906af03f2e6?fromnew=1
【源码系列】MyBatis原理源码相关推荐
- 小天带你轻松解决Mybatis延迟加载原理源码问题
Mybatis延迟加载原理源码解析 Mybatis基本结构图 由上图可以知道MyBatis延迟加载主要使⽤:JavassistProxyFactory,CgliProxyFactoryb实现类.这两种 ...
- Linux内核 eBPF基础:kprobe原理源码分析:源码分析
Linux内核 eBPF基础 kprobe原理源码分析:源码分析 荣涛 2021年5月11日 在 <Linux内核 eBPF基础:kprobe原理源码分析:基本介绍与使用>中已经介绍了kp ...
- 动态代理原理源码分析
看了这篇文章非常不错转载:https://www.jianshu.com/p/4e14dd223897 Java设计模式(14)----------动态代理原理源码分析 上篇文章<Java设计模 ...
- Linux内核 eBPF基础:kprobe原理源码分析:基本介绍与使用示例
Linux内核 eBPF基础 kprobe原理源码分析:基本介绍与使用示例 荣涛 2021年5月11日 kprobe调试技术是为了便于跟踪内核函数执行状态所设计的一种轻量级内核调试技术. 利用kpro ...
- Linux内核 eBPF基础:Tracepoint原理源码分析
Linux内核 eBPF基础 Tracepoint原理源码分析 荣涛 2021年5月10日 1. 基本原理 需要注意的几点: 本文将从sched_switch相关的tracepoint展开: 关于st ...
- PCL 实现 SAC_IA 算法原理源码解析
PCL 实现 SAC_IA 算法原理源码解析 采样一致性算法(SAC_IA)用于点云粗配准中,配准效果还不错,PCL 中也实现了该算法,本文深入 PCL 源码,学习一下 SAC_IA 算法在 PCL ...
- 深入解析SpringBoot核心运行原理和运作原理源码
SpringBoot核心运行原理 Spring Boot 最核心的功能就是自动配置,第 1 章中我们已经提到,功能的实现都是基于"约定优于配置"的原则.那么 Spring Boot ...
- 【nodejs原理源码赏析(6)】深度剖析cluster模块源码与node.js多进程(下)
目录 一. 引言 二.server.listen方法 三.cluster._getServer( )方法 四.跨进程通讯工具方法Utils 五.act:queryServer消息 六.轮询调度Roun ...
- 【nodejs原理源码赏析(6)】深度剖析cluster模块源码与node.js多进程
示例代码托管在:http://www.github.com/dashnowords/blogs 博客园地址:<大史住在大前端>原创博文目录 华为云社区地址:[你要的前端打怪升级指南] 文章 ...
- 【nodejs原理源码赏析(4)】深度剖析cluster模块源码与node.js多进程(上)
[摘要] 集群管理模块cluster浅析 示例代码托管在:http://www.github.com/dashnowords/blogs 一. 概述 cluster模块是node.js中用于实现和管理 ...
最新文章
- 探索 Swift 中的 MVC-N 模式
- DM8168 编译filesystem步骤
- imx6 i2c分析
- Firefox 18周岁
- react学习(15)-getTime selectedRowKeys是this.props取值的
- springcloud 入门 4 (rebbon源码解读)
- cadence快捷键修改文件_PCB快捷键设置
- ldap radius mysql_OpenLDAP+FreeRADIUS+MySQL+RP-PPPOE 构建PPPOE服务器
- 四则运算 来源:一位热心的网友 http://www.tqcto.com/article/software/336297.html
- Focus On Graphics Hardware 2007
- 【小程序】展示弹窗常见API详解
- 图片去水印的原理_去水印简单操作:图图去水印
- 把Android API文档的颜色改成不易疲劳的绿豆沙颜色
- 寓教于乐!一款游戏让你成为 Vim 高手!
- 使用LumaQQ来开发QQ机器人
- 可视化-three.js 城市 波浪特效 城市 扫光 掠过效果
- 微信小程序canvas绘图功能小例子
- pageoffice 骑缝章_PageOffice在Word文档中加盖电子印章
- python判断文件或文件夹是否存在、创建文件夹
- 为何你上了那么多软装培训班还是做不了整体软装设计?