如果你是刚刚学习完Mybatis那么恭喜你,你竟然在起步阶段,就发现了一款可以让Mybatis起飞的东西;如果你是Mybatis熟客,或者是会使用Mybatis-generator、Mybatis-PageHelper、Mybatis通用mapper,那么也提前恭喜你,你可以放弃这些“散件”,只需要掌握今天这个东西,上面这些过客可以统统说“拜拜”了。Mybatis Plus看似有着一统Mybatis所有工具(插件)集的架势,那么等什么呢,开始吧!
Spring Boot项目添加Mybatis-plus的依赖如下:
com.baomidou
mybatis-plus-boot-starter
3.1.2
Spring MVC项目添加Mybatis-plus的依赖如下:
com.baomidou
mybatis-plus
3.1.2
@SpringBootApplication
@MapperScan("com.xxx.xx.mapper") // 自己mapper所在的报名
public class Application {
public static void main(String[] args) {
SpringApplication.run(QuickStartApplication.class, args);
}
}
配置mapper的xml映射文件
mybatis-plus.mapper-locations=classpath*:mapper/*.xml
MyBatis-Plus 提供了大量的个性化配置来满足不同复杂度的工程,大家可根据自己的项目按需取用,详细配置请参考官方配置一文,详细的注解请参考官方注解一文。
使用其实很简单,只是让自己的Mapper继承MP的BaseMapper
public class UserMapper extends BaseMapper {
// 已经可以使用BaseMapper中的大量CRUD方法
}
怎么样除了在Mapper中继承了一个类,xml中没有任何修改,哪怕没有xml都可以对数据库进行操作了,简单使用就是这样,接下来看看MP有哪些有用的功能吧。
AutoGenerator 是 MyBatis-Plus 的代码生成器,通过 AutoGenerator 可以快速生成 Entity、Mapper、Mapper XML、Service、Controller 等各个模块的代码,极大的提升了开发效率。
4.1.1 添加 代码生成器 依赖
MyBatis-Plus 从3.0.3之后移除了代码生成器与模板引擎的默认依赖,需要手动添加代码生成器的依赖:
com.baomidou
mybatis-plus-generator
latest-version
4.1.2 添加 模板引擎 依赖
MyBatis-Plus 支持 Velocity(默认)、Freemarker、Beetl,用户可以选择自己熟悉的模板引擎,如果都不满足您的要求,可以采用自定义模板引擎。
org.apache.velocity
velocity-engine-core
latest-velocity-version
org.freemarker
freemarker
latest-freemarker-version
注意!如果您选择了非默认引擎,需要在 AutoGenerator 中 设置模板引擎。
AutoGenerator generator = new AutoGenerator(); // set freemarker engine generator.setTemplateEngine(new FreemarkerTemplateEngine());
代码生成器 代码演示:
public class MPGenerator {
// 演示例子,执行 main 方法控制台输入模块表名回车自动生成对应项目目录中
public static String scanner(String tip) {
Scanner scanner = new Scanner(System.in);
StringBuilder help = new StringBuilder();
help.append("请输入" + tip + ":");
if (scanner.hasNext()) {
String ipt = scanner.next();
if (StringUtils.isNotEmpty(ipt)) {
return ipt;
}
}
throw new MybatisPlusException("请输入正确的" + tip + "!");
}
public static void main(String[] args) {
// 代码生成器
AutoGenerator mpg = new AutoGenerator();
// 全局配置
GlobalConfig gc = new GlobalConfig();
String projectPath = System.getProperty("user.dir");
gc.setOutputDir(projectPath + "/src/main/java");
gc.setFileOverride(false);
gc.setAuthor("wtao");
gc.setOpen(false);
gc.setBaseColumnList(true);
gc.setBaseResultMap(true);
gc.setIdType(IdType.AUTO);
// gc.setSwagger2(true); 实体属性 Swagger2 注解
mpg.setGlobalConfig(gc);
// 数据源配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setUrl("jdbc:mysql://127.0.0.1/mp?useUnicode=true&characterEncoding=utf-8");
// dsc.setSchemaName("public");
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
dsc.setUsername("root");
dsc.setPassword("123456");
mpg.setDataSource(dsc);
// 包配置
PackageConfig pc = new PackageConfig();
pc.setModuleName(scanner("模块名"));
pc.setParent("com.eyaoshun.crm");
mpg.setPackageInfo(pc);
// 自定义配置
InjectionConfig cfg = new InjectionConfig() {
@Override
public void initMap() {
// to do nothing
}
};
// 如果模板引擎是 freemarker
//String templatePath = "/templates/mapper.xml.ftl";
// 如果模板引擎是 velocity
String templatePath = "/templates/mapper.xml.vm";
// 自定义输出配置
List focList = new ArrayList<>();
// 自定义配置会被优先输出
focList.add(new FileOutConfig(templatePath) {
@Override
public String outputFile(TableInfo tableInfo) {
// 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
return projectPath + "/src/main/resources/mapper/" + pc.getModuleName()
+ "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
}
});
/*
cfg.setFileCreate(new IFileCreate() {
@Override
public boolean isCreate(ConfigBuilder configBuilder, FileType fileType, String filePath) {
// 判断自定义文件夹是否需要创建
checkDir("调用默认方法创建的目录");
return false;
}
});
*/
cfg.setFileOutConfigList(focList);
mpg.setCfg(cfg);
// 配置模板
TemplateConfig templateConfig = new TemplateConfig();
// 配置自定义输出模板
//指定自定义模板路径,注意不要带上.ftl/.vm, 会根据使用的模板引擎自动识别
// templateConfig.setEntity("templates/entity2.java");
// templateConfig.setService();
// templateConfig.setController();
templateConfig.setXml(null);
mpg.setTemplate(templateConfig);
// 策略配置
StrategyConfig strategy = new StrategyConfig();
strategy.setNaming(NamingStrategy.underline_to_camel);
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
strategy.setRestControllerStyle(true);
strategy.setInclude("t_.*");
strategy.setControllerMappingHyphenStyle(true);
strategy.setTablePrefix("t_");
mpg.setStrategy(strategy);
// 模板引擎
mpg.setTemplateEngine(new VelocityTemplateEngine());
// 执行生成器
mpg.execute();
}
}
更多详细的代码生成器配置,请参考代码生成器配置一文。
运行main->控制台输入"demo"后将生成的结构如下(resources下还有xml文件):
- 通用 CRUD 封装BaseMapper 接口,为
Mybatis-Plus
启动时自动解析实体表关系映射转换为Mybatis
内部对象注入容器- 泛型
T
为任意实体对象- 参数
Serializable
为任意类型主键Mybatis-Plus
不推荐使用复合主键约定每一张表都有自己的唯一id
主键- 对象
Wrapper
为 条件构造器
其实我们真正在使用Mybatis时候核心就是使用接口编程,MP帮助我们创建了大量的公共接口和实现,我们只需要再在自己的Mapper接口中继承BaseMapper接口即可使用其内部的各CRUD方法了。提供的方法如下:
//// 插入一条记录
int insert(T entity);
// 根据 ID 删除
int deleteById(Serializable id);
// 根据 columnMap 条件,删除记录, 参数为表字段 map 对象
int deleteByMap(@Param(Constants.COLUMN_MAP) Map columnMap);
// 根据 entity 条件,删除记录
int delete(@Param(Constants.WRAPPER) Wrapper wrapper);
// 删除(根据ID 批量删除)
int deleteBatchIds(@Param(Constants.COLLECTION) Collection extends Serializable> idList);
// 根据 ID 修改
int updateById(@Param(Constants.ENTITY) T entity);
// 根据 whereEntity 条件,更新记录
int update(@Param(Constants.ENTITY) T entity, @Param(Constants.WRAPPER) Wrapper updateWrapper);
// 根据 ID 查询
T selectById(Serializable id);
// 查询(根据ID 批量查询)
List selectBatchIds(@Param(Constants.COLLECTION) Collection extends Serializable> idList);
// 查询(根据 columnMap 条件)
List selectByMap(@Param(Constants.COLUMN_MAP) Map columnMap);
// 根据 entity 条件,查询一条记录
T selectOne(@Param(Constants.WRAPPER) Wrapper queryWrapper);
// 根据 Wrapper 条件,查询总记录数
Integer selectCount(@Param(Constants.WRAPPER) Wrapper queryWrapper);
// 根据 entity 条件,查询全部记录
List selectList(@Param(Constants.WRAPPER) Wrapper queryWrapper);
// 根据 Wrapper 条件,查询全部记录
List
get 查询单行
remove 删除
list 查询集合
page 分页
前缀命名方式区分 Mapper
层避免混淆,T
为任意实体对象IBaseService
继承 Mybatis-Plus
提供的基类Wrapper
为 条件构造器(划重点)Service层还有两个牛逼的方法,是通过链式调用查询和更新,使用lambdaQuery和lambdaUpate两个方法可以不用构造Wrapper也可以进行查询和更新,多使用如下的方法,让你自己嗨到站不起来。
List userList = userService.lambdaQuery.eq(User::getName, "老王").eq(User:getAge).list();
boolean b = userService.lambdaUpdate.eq(User::getName, "老王").set(User::getAge, 18).update();
MP对service层也做了大量公共方法的定义,我们只需要在自己的Service接口中继承MP的IService,即可拥有比mapper更多的方法操作数据库。Service层提供的方法如下:
// 插入一条记录(选择字段,策略插入)
boolean save(T entity);
// 插入(批量)
boolean saveBatch(Collection entityList);
// 插入(批量) @param batchSize 插入批次数量
boolean saveBatch(Collection entityList, int batchSize);
// 批量修改插入
boolean saveOrUpdateBatch(Collection entityList);
// 批量修改插入
boolean saveOrUpdateBatch(Collection entityList, int batchSize);
// 根据 ID 删除
boolean removeById(Serializable id);
// 根据 columnMap 条件,删除记录
boolean removeByMap(Map columnMap);
// 根据 entity 条件,删除记录
boolean remove(Wrapper queryWrapper);
// 删除(根据ID 批量删除)
boolean removeByIds(Collection extends Serializable> idList);
// 根据 ID 选择修改
boolean updateById(T entity);
// 根据 whereEntity 条件,更新记录
boolean update(T entity, Wrapper updateWrapper);
// 根据ID 批量更新
boolean updateBatchById(Collection entityList, int batchSize);
// TableId 注解存在更新记录,否插入一条记录
boolean saveOrUpdate(T entity);
// 根据 ID 查询
T getById(Serializable id);
// 查询(根据ID 批量查询)
Collection listByIds(Collection extends Serializable> idList);
// 查询(根据 columnMap 条件)
Collection listByMap(Map columnMap);
// 根据 Wrapper,查询一条记录
T getOne(Wrapper queryWrapper, boolean throwEx);
// 根据 Wrapper,查询一条记录
Map getMap(Wrapper queryWrapper);
// 根据 Wrapper,查询一条记录
Object getObj(Wrapper queryWrapper);
// 根据 Wrapper 条件,查询总记录数
int count(Wrapper queryWrapper);
// 查询列表
List list(Wrapper queryWrapper);
// 翻页查询
IPage page(IPage page, Wrapper queryWrapper);
// 查询列表
List
Mapper层和Service层的接口中看到了许多Wrapper(条件构造器),它可以想象成它一个创建where后面所有语句的对象,它通过各种方法可以创建出各种where语句中的“条件”,所以叫他条件构造器。
- 以下出现的第一个入参
boolean condition
表示该条件是否加入最后生成的sql中- 以下代码块内的多个方法均为从上往下补全个别
boolean
类型的入参,默认为true
- 以下出现的泛型
Param
均为Wrapper
的子类实例(均具有AbstractWrapper
的所有方法)- 以下方法在入参中出现的
R
为泛型,在普通wrapper中是String
,在LambdaWrapper中是函数(例:Entity::getId
,Entity
为实体类,getId
为字段id
的getMethod)- 以下方法入参中的
R column
均表示数据库字段,当R
具体类型为String
时则为数据库字段名(字段名是数据库关键字的自己用转义符包裹!)!而不是实体类数据字段名!!!,另当R
具体类型为SFunction
时项目runtime不支持eclipse自家的编译器!!!- 以下举例均为使用普通wrapper,入参为
Map
和List
的均以json
形式表现!- 使用中如果入参的
Map
或者List
为空,则不会加入最后生成的sql中!!!- 有任何疑问就点开源码看,看不懂函数的点击我学习新知识
警告:
不支持以及不赞成在 RPC 调用中把 Wrapper 进行传输
- wrapper 很重
- 传输 wrapper 可以类比为你的 controller 用 map 接收值(开发一时爽,维护火葬场)
- 正确的 RPC 调用姿势是写一个 DTO 进行传输,被调用方再根据 DTO 执行相应的操作
- 我们拒绝接受任何关于 RPC 传输 Wrapper 报错相关的 issue 甚至 pr
说明:QueryWrapper(LambdaQueryWrapper) 和 UpdateWrapper(LambdaUpdateWrapper) 的父类
用于生成 sql 的 where 条件, entity 属性也用于生成 sql 的 where 条件
注意: entity 生成的 where 条件与 使用各个 api 生成的 where 条件没有任何关联行为
allEq(Map params)
allEq(Map params, boolean null2IsNull)
allEq(boolean condition, Map params, boolean null2IsNull)
个别参数说明:
params
:key
为数据库字段名,value
为字段值null2IsNull
: 为true
则在map
的value
为null
时调用 isNull 方法,为false
时则忽略value
为null
的
allEq({id:1,name:"老王",age:null})
--->id = 1 and name = '老王' and age is null
allEq({id:1,name:"老王",age:null}, false)
--->id = 1 and name = '老王'
allEq(BiPredicate filter, Map params)
allEq(BiPredicate filter, Map params, boolean null2IsNull)
allEq(boolean condition, BiPredicate filter, Map params, boolean null2IsNull)
个别参数说明:
filter
: 过滤函数,是否允许字段传入比对条件中params
与null2IsNull
: 同上
allEq((k,v) -> k.indexOf("a") > 0, {id:1,name:"老王",age:null})
--->name = '老王' and age is null
allEq((k,v) -> k.indexOf("a") > 0, {id:1,name:"老王",age:null}, false)
--->name = '老王'
eq(R column, Object val)
eq(boolean condition, R column, Object val)
以下方法和上面eq方法的参数几乎相同,仅写出方法名称参考,(between方法参数会多一个值)
isNull(R column)
isNull(boolean condition, R column)
notIn(R column, Collection> value)
notIn(boolean condition, R column, Collection> value)
inSql(R column, String inValue)
inSql(boolean condition, R column, String inValue)
inSql("age", "1,2,3,4,5,6")
--->age in (1,2,3,4,5,6)
inSql("id", "select id from table where id < 3")
--->id in (select id from table where id < 3)
groupBy(R... columns)
groupBy(boolean condition, R... columns)
groupBy("id", "name")
--->group by id,name
orderByAsc(R... columns)
orderByAsc(boolean condition, R... columns)
orderByDesc(R... columns)
orderByDesc(boolean condition, R... columns)
orderByAsc("id", "name")
--->order by id ASC,name ASC
orderBy(boolean condition, boolean isAsc, R... columns)
orderBy(true, true, "id", "name")
--->order by id ASC,name ASC
having(String sqlHaving, Object... params)
having(boolean condition, String sqlHaving, Object... params)
having("sum(age) > 10")
--->having sum(age) > 10
having("sum(age) > {0}", 11)
--->having sum(age) > 11
or()
or(boolean condition)
eq("id",1).or().eq("name","老王")
--->id = 1 or name = '老王'
注意事项:主动调用
or
表示紧接着下一个方法不是用and
连接!(不调用or
则默认为使用and
连接)
以下的三个方法or(Function func),and(Function func),nested(Function func)是嵌套方法,参数是通过lambda表达式写出一串条件,查询的时候这些条件会被使用括起来再去数据库查询。比如我们手写sql必须在条件时加入括号才可以,下面三个方法是干这个用的,如下面sql对应3个方法的例子:
SELECT * FROM user WHERE name = '老王' or (age < 30 and money > 100000000)
SELECT * FROM user WHERE age < 30 and (money > 10000000 or name='李现')
SELECT * FROM user WHERE (money > 1000000 or age < 30) and age < 30
or(Function func)
or(boolean condition, Function func)
or(i -> i.eq("name", "李白").ne("status", "活着"))
--->or (
name = '李白' and status <> '活着'
)
and(Function func)
and(boolean condition, Function func)
and(i -> i.eq("name", "李白").ne("status", "活着"))
--->and (
name = '李白' and status <> '活着'
)
nested(Function func)
nested(boolean condition, Function func)
nested(i -> i.eq("name", "李白").ne("status", "活着"))
--->(name = '李白' and status <> '活着')
apply(String applySql, Object... params)
apply(boolean condition, String applySql, Object... params)
注意事项:该方法可用于数据库函数 动态入参的
params
对应前面applySql
内部的{index}
部分.这样是不会有sql注入风险的,反之会有!
apply("id = 1")
--->id = 1
apply("date_format(dateColumn,'%Y-%m-%d') = '2008-08-08'")
--->date_format(dateColumn,'%Y-%m-%d') = '2008-08-08'")
apply("date_format(dateColumn,'%Y-%m-%d') = {0}", "2008-08-08")
--->date_format(dateColumn,'%Y-%m-%d') = '2008-08-08'")
last(String lastSql)
last(boolean condition, String lastSql)
注意事项:
只能调用一次,多次调用以最后一次为准 有sql注入的风险,请谨慎使用
last("limit 1")
exists(String existsSql)
exists(boolean condition, String existsSql)
exists("select id from table where age = 1")
--->exists (select id from table where age = 1)
notExists(String notExistsSql)
notExists(boolean condition, String notExistsSql)
notExists("select id from table where age = 1")
--->not exists (select id from table where age = 1)
说明:
继承自 AbstractWrapper ,自身的内部属性 entity 也用于生成 where 条件
及 LambdaQueryWrapper, 可以通过 new QueryWrapper().lambda() 方法获取
select(String... sqlSelect)
select(Predicate predicate)
select(Class entityClass, Predicate predicate)
说明:
以上方分法为两类.
第二类方法为:过滤查询字段(主键除外),入参不包含 class 的调用前需要wrapper
内的entity
属性有值! 这两类方法重复调用以最后一次为准
select("id", "name", "age")
select(i -> i.getProperty().startsWith("test"))
说明:
继承自
AbstractWrapper
,自身的内部属性entity
也用于生成 where 条件
及LambdaUpdateWrapper
, 可以通过new UpdateWrapper().lambda()
方法获取!
set(String column, Object val)
set(boolean condition, String column, Object val)
set("name", "老李头")
set("name", "")
--->数据库字段值变为空字符串set("name", null)
--->数据库字段值变为null
setSql(String sql)
setSql("name = '老李头')
LambdaWrapper
QueryWrapper
中是获取LambdaQueryWrapper
UpdateWrapper
中是获取LambdaUpdateWrapper
需求来源:
在使用了
mybatis-plus
之后, 自定义SQL的同时也想使用Wrapper
的便利应该怎么办? 在mybatis-plus
版本3.0.7
得到了完美解决 版本需要大于或等于3.0.7
, 以下两种方案取其一即可
mysqlMapper.getAll(Wrappers.lambdaQuery().eq(MysqlData::getGroup, 1));
方案一 注解方式 Mapper.java
@Select("select * from mysql_data ${ew.customSqlSegment}")
List getAll(@Param(Constants.WRAPPER) Wrapper wrapper);
方案二 XML形式 Mapper.xml
spring xml方法
spring boot方式
//Spring boot方式
@EnableTransactionManagement
@Configuration
@MapperScan("com.baomidou.cloud.service.*.mapper*")
public class MybatisPlusConfig {
/**
* 分页插件
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
// paginationInterceptor.setLimit(你的最大单页限制数量,默认 500 条,小于 0 如 -1 不受限制);
return paginationInterceptor;
}
}
已经配置了分页插件后,直接使用mapper中的分页方法就可以了。
// 先构造分页实体对象
Page page = new Page<>(1, 2);
// 查询出来对象包含查询结果集合和分页相关属性
Ipage ipage = userMapper.selectPage(page, queryWrapper);
如果分页查询时不需要使用总条数的信息(total)可以这样构造Page对象:
Page page = new Page<>(1, 2, false);
因为查询分页时会查询数据库两次,一次是查询总记录,一次是查询分页数据。构造器加了第3个参数false后,仅去数据库查询一次,就不会查询总记录数的sql语句了,性能肯定有优化,不过一般分页都会使用总记录数的。
MP中查询分页只有2个方法,如果想自己定义新方法(如连表查询等) 也需要分页的话,则需要这样:
public interface UserMapper{//可以继承或者不继承BaseMapper
/**
*
* 查询 : 根据state状态查询用户列表,分页显示
* 注意!!: 如果入参是有多个,需要加注解指定参数名才能在xml中取值
*
*
* @param page 分页对象,xml中可以从里面进行取值,传递参数 Page 即自动分页,必须放在第一位(你可以继承Page实现自己的分页对象)
* @param state 状态
* @return 分页对象
*/
IPage selectPageVo(Page page, @Param("state") Integer state);
}
public IPage selectUserPage(Page page, Integer state) {
// 不进行 count sql 优化,解决 MP 无法自动优化 SQL 问题,这时候你需要自己查询 count 部分
// page.setOptimizeCountSql(false);
// 当 total 为小于 0 或者设置 setSearchCount(false) 分页插件不会进行 count 查询
// 要点!! 分页返回的对象与传入的对象是同一个
return userMapper.selectPageVo(page, state);
}
先来解释下什么是逻辑删除:逻辑删除不是将数据直接delete,而是通过表中某个字段表示本条记录是否被删除,比如一个表中有个字段叫deleted(是否删除),0表示未删除,1表示已删除。什么时候使用逻辑删除呢,比如一个商场系统的订单模块,用户自己的订单是可以删除的,可对于这个系统来说,好不容易下一个单,怎么能就这么被删,所以引出了逻辑删除:前台查的时候查is_delete是0的,用户删除的时候将deleted字段值改为1即可。
mybatis-plus:
global-config:
db-config:
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
@TableLogic
注解@TableLogic
private Integer deleted;
使用起始很简单:依然使用MP自带的删除和查询的方法,查询时MP会自动在sql语句最后添加上deleted=0,删除的时候会自动改用update方法将deleted的值改为1;当然如果你是想忽略deleted的情况只能自己去Mapper添加方法了。
example
删除时 update user set deleted=1 where id =1 and deleted=0
查找时 select * from user where deleted=0
附件说明
- 逻辑删除是为了方便数据恢复和保护数据本身价值等等的一种方案,但实际就是删除。
- 如果你需要再查出来就不应使用逻辑删除,而是以一个状态去表示。
如: 员工离职,账号被锁定等都应该是一个状态字段,此种场景不应使用逻辑删除。
- 若确需查找删除数据,如老板需要查看历史所有数据的统计汇总信息,请单独手写sql。
意图:
当要更新一条记录的时候,希望这条记录没有被别人更新
乐观锁实现方式:
- 取出记录时,获取当前version
- 更新时,带上这个version
- 执行更新时, set version = newVersion where version = oldVersion
- 如果version不对,就更新失败
乐观锁配置需要2步 记得两步
spring xml定义一个乐观锁拦截器bean:
spring boot定义一个乐观锁拦截器bean:
@Bean
public OptimisticLockerInterceptor optimisticLockerInterceptor() {
return new OptimisticLockerInterceptor();
}
@Version
必须要!@Version
private Integer version;
特别说明:
- 支持的数据类型只有:int,Integer,long,Long,Date,Timestamp,LocalDateTime
- 整数类型下
newVersion = oldVersion + 1
newVersion
会回写到entity
中- 仅支持
updateById(id)
与update(entity, wrapper)
方法- 在
update(entity, wrapper)
方法下,wrapper
不能复用!!!
示例Java代码(参考test case代码)
int id = 100;
int version = 2;
User u = new User();
u.setId(id);
u.setVersion(version);
u.setXXX(xxx);
if(userService.updateById(u)){
System.out.println("Update successfully");
}else{
System.out.println("Update failed due to modified by others");
}
示例SQL原理
update tbl_user set name = 'update',version = 3 where id = 100 and version = 2
本文章大部分内容参考官网文档,如有纰漏请留言告知,谢谢。
(完)