本文为《从零打造项目》系列第三篇文章,首发于个人网站。
《从零打造项目》系列文章
比MyBatis Generator更强大的代码生成器
SpringBoot项目基础设施搭建
前言
基于 orm-generate 项目可以实现项目模板代码,集成了三种 ORM 方式:Mybatis、Mybatis-Plus 和 Spring JPA,JPA 是刚集成进来的,该项目去年就已经发布过一版,也成功实现了想要的功能,关于功能介绍可以参考我之前的这篇文章。
如何搭建一个项目是每个开发人员会面临的问题。新人一般很难参与项目基础框架搭建,只会在项目基础框架布置完毕后才加入进来,接着进行功能模块代码开发,无法体验项目搭建的过程,其中包括一些通用代码。等到换个项目,可能还是重复之前的功能开发,而且每个项目基础框架可能不太一样,但我们能做的只是按照现有规定进行开发,对于一个项目的基础设施了解不多,也无法明白为何要选某某框架。为了突破,为了成长,新人有必要跟着参与一次项目基础框架搭建,切身体会各种框架的差异,参与一些通用代码的开发,而不仅仅是 CRUD。
上面提到的项目基础架构,比如说选择 SpringBoot 或者 SpringMVC,再比如流行的三种 ORM 框架:Mybatis、Mybatis-Plus 和 Spring JPA,这里我们暂时不关注 SpringCloud 框架,因为每个微服务还是基于 SpringBoot,至于其他各种中间件,暂时也不做考虑(我的视角暂时无法达到那样的高度)。
运行 orm-generate 项目可以从现有的数据库中获取模版代码,大致包含如下几部分内容:
- entity, 实体层,用于存放我们的实体类,与数据库中的属性值基本保持一致,实现set和get的方法 ;
- mapper/dao/repository,对数据库进行数据持久化操作,它的方法语句是直接针对数据库操作的,主要实现一些增删改查操作,在 mybatis 中方法主要与与 xxx.xml 内相互一一映射;
- service,业务 service 层,给 controller 层的类提供接口进行调用。一般就是自己写的方法封装起来,就是声明一下,具体实现在 serviceImpl 中;
- controller,控制层,负责具体模块的业务流程控制,需要调用 service 逻辑设计层的接口来控制业务流程。因为 service 中的方法是我们使用到的,controller 通过接收前端 H5 或者 App 传过来的参数进行业务操作,再将处理结果返回到前端。
- dto文件,用来分担实体类的功效,可以将查询条件单独封装一个类,以及前后端交互的实体类(有时候我们可能会传入 entity 实体类中不存在的字段);
- vo文件,后台返回给前台的数据结构,同样可以自定义字段。
- struct文件,dto、entity与vo文件相互转换。
有了这些模版代码,我们还需要构建项目基础架构,用来存放这些代码,并对其进行修改,最终实现功能开发。
本文将实现 SpringBoot+Mybatis 的项目搭建,除此之外,还有一些通用代码配置:
- 统一返回格式
- 全局异常处理
- Mybatis 操作工具类
- 字符串等工具类
- 请求日志记录
不说废话了,我们直接进入主题。
数据库
本项目采用的是 MySQL 数据库,版本为 8.x,建表语句如下:
CREATE TABLE `user` (
`id` varchar(36) NOT NULL,
`name` varchar(20) DEFAULT NULL,
`age` int(11) DEFAULT NULL,
`address` varchar(100) DEFAULT NULL,
`created_date` timestamp NULL DEFAULT NULL,
`last_modified_date` timestamp NULL DEFAULT NULL,
`del_flag` tinyint(1) NOT NULL DEFAULT '0',
`create_user_code` varchar(36) DEFAULT NULL,
`create_user_name` varchar(50) DEFAULT NULL,
`last_modified_code` varchar(36) DEFAULT NULL,
`last_modified_name` varchar(50) DEFAULT NULL,
`version` int(11) NOT NULL DEFAULT '1',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户';
CREATE TABLE `job` (
`id` varchar(36) NOT NULL,
`name` varchar(20) DEFAULT NULL,
`user_id` varchar(36) NOT NULL,
`address` varchar(100) DEFAULT NULL,
`created_date` timestamp NULL DEFAULT NULL,
`last_modified_date` timestamp NULL DEFAULT NULL,
`del_flag` tinyint(1) NOT NULL DEFAULT '0',
`create_user_code` varchar(36) DEFAULT NULL,
`create_user_name` varchar(50) DEFAULT NULL,
`last_modified_code` varchar(36) DEFAULT NULL,
`last_modified_name` varchar(50) DEFAULT NULL,
`version` int(11) NOT NULL DEFAULT '1',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='工作';
构建的数据
INSERT INTO mysql_db.`user` (id,name,age,address,created_date,last_modified_date,del_flag,create_user_code,create_user_name,last_modified_code,last_modified_name,version) VALUES
('1f5ffce8eda44809b91af9857cde1870',NULL,25,'中国武汉','2022-09-20 23:15:43','2022-09-20 23:15:43',0,'1','hresh','1','hresh',0),
('23dc1219d58c427884212127606fc830','clearLove',28,'中国上海','2022-09-19 21:14:56','2022-09-19 21:14:56',0,'1','hresh','1','hresh',0),
('4f5f617e651a4126b2847f2f25537995','',25,'中国武汉','2022-09-20 22:53:29','2022-09-20 22:53:29',0,'1','hresh','1','hresh',0),
('55dc89810e394306b66ab9567b568534','ascii0',21,'中国广东','2022-09-19 21:30:06','2022-09-19 21:30:06',0,'1','hresh','1','hresh',0),
('8ac44600842b427c8ef12978c5e8c501',NULL,25,'中国武汉','2022-09-20 22:58:34','2022-09-20 22:58:34',0,'1','hresh','1','hresh',0),
('93473b532494477b9d8d34b3165d216a','ascii1',21,'中国广东','2022-09-19 21:30:06','2022-09-19 21:30:06',0,'1','hresh','1','hresh',0),
('a45908e8bc874940a6d682370a0ca8d7','clearLove3',28,'中国上海','2022-09-19 22:46:18','2022-09-19 22:46:18',0,'1','hresh','1','hresh',0),
('cd613ce660264dc18b15b3333a6421da','ascii2',21,'中国广东','2022-09-19 21:30:06','2022-09-19 21:30:06',0,'1','hresh','1','hresh',0),
('e30606f8ee7649499811e74fbc7df583',NULL,25,'中国武汉','2022-09-20 23:11:31','2022-09-20 23:11:31',0,'1','hresh','1','hresh',0),
('f68073dc14be4be4a1042fcf78f8f7df','hresh',25,'中国武汉','2022-09-20 07:22:06','2022-09-20 07:22:06',0,'1','hresh','1','hresh',0);
INSERT INTO mysql_db.`user` (id,name,age,address,created_date,last_modified_date,del_flag,create_user_code,create_user_name,last_modified_code,last_modified_name,version) VALUES
('fe4f62a77f75468e956fb285475ba3f3','clearLove2',28,'中国上海','2022-09-19 21:16:46','2022-09-19 21:16:46',0,'1','hresh','1','hresh',0);
INSERT INTO mysql_db.job (id,name,user_id,address,created_date,last_modified_date,del_flag,create_user_code,create_user_name,last_modified_code,last_modified_name,version) VALUES
('55dc89810e394306b66ab9567b568512','程序员','55dc89810e394306b66ab9567b568534','中国湖北',NULL,NULL,0,NULL,NULL,NULL,NULL,0),
('55dc89810e394306b66ab9567b568513','外卖员','55dc89810e394306b66ab9567b568534','中国湖北',NULL,NULL,0,NULL,NULL,NULL,NULL,0),
('55dc89810e394306b66ab9567b568514','外卖员','93473b532494477b9d8d34b3165d216a','中国湖北',NULL,NULL,0,NULL,NULL,NULL,NULL,0),
('55dc89810e394306b66ab9567b568515','厨师','93473b532494477b9d8d34b3165d216a','中国湖北',NULL,NULL,0,NULL,NULL,NULL,NULL,0);
搭建SpringBoot项目
使用 IDEA 新建一个 Maven 项目,叫做 mybatis-springboot。
一些共用的基础代码可以参考上篇文章,这里不做重复介绍,会介绍一些 Mybatis 相关的代码。
引入依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.3</version>
</parent>
<properties>
<java.version>1.8</java.version>
<fastjson.version>1.2.73</fastjson.version>
<hutool.version>5.5.1</hutool.version>
<mysql.version>8.0.19</mysql.version>
<mybatis.version>2.1.4</mybatis.version>
<mapper.version>4.1.5</mapper.version>
<org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
<org.projectlombok.version>1.18.20</org.projectlombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${org.projectlombok.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper</artifactId>
<version>${mapper.version}</version>
</dependency>
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.3</version>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
<version>2.4.6</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.6.9</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.18</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
有些依赖不一定是最新版本,而且你看到这篇文章时,可能已经发布了新版本,到时候可以先模仿着将项目跑起来后,再根据自己的需求来升级各项依赖,有问题咱再解决问题。
分页处理
某些业务场景是需要分页查询和排序功能的,所以我们需要考虑前端如何传递参数给后端,后端如何进行分页查询或者是排序查询。我们使用的是 Mybatis,该框架有个配套的分页插件——PageHelper。
分页基础类
public class SimplePageInfo {
private Integer pageNum = 1;
private Integer pageSize = 10;
public Integer getPageNum() {
return pageNum;
}
public void setPageNum(Integer pageNum) {
this.pageNum = pageNum;
}
public Integer getPageSize() {
return pageSize;
}
public void setPageSize(Integer pageSize) {
this.pageSize = pageSize;
}
}
排序包装类
@Getter
@Setter
public class OrderInfo {
private boolean asc = true;
private String column;
}
分页且排序包装类
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
public class PageSortInfo extends SimplePageInfo {
@Schema(name = "排序信息")
private List<OrderInfo> orderInfos;
public String parseSort() {
if (CollectionUtils.isEmpty(orderInfos)) {
return null;
}
StringBuilder sb = new StringBuilder();
for (OrderInfo orderInfo : orderInfos) {
sb.append(orderInfo.getColumn()).append(" ");
sb.append(orderInfo.isAsc() ? " ASC," : " DESC,");
}
sb.deleteCharAt(sb.length() - 1);
return sb.toString();
}
}
前端分页查询的请求体对象
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserQueryPageDTO {
private String name;
@JsonUnwrapped
private PageSortInfo pageSortInfo;
}
服务层分页查询
PageHelper.startPage(dto.getPageSortInfo().getPageNum(), dto.getPageSortInfo().getPageSize(),
dto.getPageSortInfo().parseSort());
Page<User> userPage = (Page<User>) userMapper.select(user);
关于 PageHelper 的使用这里就不多做介绍了。
我们得到的分页查询结果是 Page 对象,可以直接使用,也可以根据需要进行修改,比如下面这个文件:
@Getter
@Setter
public class PageResult<T> {
/**
* 总条数
*/
private Long total;
/**
* 总页数
*/
private Integer pageCount;
/**
* 每页数量
*/
private Integer pageSize;
/**
* 当前页码
*/
private Integer pageNum;
/**
* 分页数据
*/
private List<T> data;
/**
* 处理Mybatis分页结果
*/
public static <T> PageResult<T> ok(Page<T> page) {
PageResult<T> result = new PageResult<T>();
result.setPageCount(page.getPages());
result.setPageNum(page.getPageNum());
result.setPageSize(page.getPageSize());
result.setTotal(page.getTotal());
result.setData(page.getResult());
return result;
}
}
Mybatis 分页结果除了 Page,还有 PageInfo,Page 继承 ArrayList,PageInfo 对象中的字段更多,这块可以结合项目实际情况进行选择。
Mybatis基础实体类
作为其他实体类的父类,封装了所有的公共字段,包括逻辑删除标志,版本号,创建人和修改人信息。到底是否需要那么多字段,结合实际情况,这里的示例代码比较全,其中@LogicDelete 和@Version 是 Mybatis 特有的注解,@CreatedBy、@CreatedDate 是Springframework 自带的注解,如果我们需要新建人和修改人姓名,则需要自定义注解。
@Data
@Schema(title = "核心基础实体类")
@NoArgsConstructor
@AllArgsConstructor
public class BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "删除标记")
@LogicDelete
@Column(name = "del_flag")
private Boolean delFlag;
@Schema(description = "创建人代码")
@CreatedBy
@Column(name = "create_user_code")
private String createUserCode;
@Schema(name = "创建人姓名")
@CreatedName
@Column(name = "create_user_name")
private String createUserName;
@Schema(name = "创建时间")
@CreatedDate
@Column(name = "created_date")
private LocalDateTime createdDate;
@Schema(name = "修改人代码")
@LastModifiedBy
@Column(name = "last_modified_code")
private String lastModifiedCode;
@Schema(name = "修改人姓名")
@LastModifiedName
@Column(name = "last_modified_name")
private String lastModifiedName;
@Schema(name = "修改时间")
@LastModifiedDate
@Column(name = "last_modified_date")
private LocalDateTime lastModifiedDate;
@Schema(name = "版本号")
@Version
@Column(name = "version")
private Integer version;
}
自定义注解如下:
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CreatedName {
}
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface LastModifiedName {
}
Mybatis拦截器
MyBatis 允许你在映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:
- Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
- ParameterHandler (getParameterObject, setParameters)
- ResultSetHandler (handleResultSets, handleOutputParameters)
- StatementHandler (prepare, parameterize, batch, update, query)
这些类中方法的细节可以通过查看每个方法的签名来发现,或者直接查看 MyBatis 发行包中的源代码。 如果你想做的不仅仅是监控方法的调用,那么你最好相当了解要重写的方法的行为。 因为在试图修改或重写已有方法的行为时,很可能会破坏 MyBatis 的核心模块。 这些都是更底层的类和方法,所以使用插件的时候要特别当心。
通过 MyBatis 提供的强大机制,使用插件是非常简单的,只需实现 Interceptor 接口,并指定想要拦截的方法签名即可。
// ExamplePlugin.java
@Intercepts({@Signature(
type= Executor.class,
method = "update",
args = {MappedStatement.class,Object.class})})
public class ExamplePlugin implements Interceptor {
private Properties properties = new Properties();
public Object intercept(Invocation invocation) throws Throwable {
// implement pre processing if need
Object returnObject = invocation.proceed();
// implement post processing if need
return returnObject;
}
public void setProperties(Properties properties) {
this.properties = properties;
}
}
全局xml配置:
<plugins>
<plugin interceptor="org.format.mybatis.cache.interceptor.ExamplePlugin"></plugin>
</plugins>
这个拦截器拦截 Executor 接口的 update 方法(其实也就是 SqlSession 的新增,删除,修改操作),所有执行executor 的 update 方法都会被该拦截器拦截到。
Mybatis 拦截器接口定义如下:
public interface Interceptor {
Object intercept(Invocation var1) throws Throwable;
default Object plugin(Object target) {
return Plugin.wrap(target, this);
}
default void setProperties(Properties properties) {
}
}
查看我们自定义的拦截器实现类
@Slf4j
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class,
Object.class})})
public class AutoFillFieldInterceptor implements Interceptor {
private static final Logger logger = LoggerFactory.getLogger(AutoFillFieldInterceptor.class);
@Override
public Object intercept(Invocation invocation) throws Throwable {
logger.info("执行intercept方法:{}", invocation.toString());
Object[] args = invocation.getArgs();
MappedStatement mappedStatement = (MappedStatement) args[0];
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
if (sqlCommandType != SqlCommandType.INSERT && sqlCommandType != SqlCommandType.UPDATE) {
return invocation.proceed();
}
Object parameter = args[1];
Class<?> clazz = parameter.getClass();
// 批量SQL操作
if (Map.class.isAssignableFrom(clazz)) {
Map<String, Object> paramMap = (Map<String, Object>) parameter;
if (paramMap.containsKey("recordList")) {
processData(sqlCommandType, paramMap.get("recordList"));
} else if (paramMap.containsKey("collection")) {
processData(sqlCommandType, paramMap.get("collection"));
} else if (paramMap.containsKey("list")) {
processData(sqlCommandType, paramMap.get("list"));
}
} else {
// 单个SQL操作
processData(sqlCommandType, parameter);
}
return invocation.proceed();
}
private boolean isSkipInject(Class clazz) {
return clazz.getAnnotation(Table.class) == null;
}
private void processData(SqlCommandType sqlCommandType, Object parameter) {
Class<?> clazz = parameter.getClass();
if (Collection.class.isAssignableFrom(clazz)) {
Collection<?> collection = (Collection<?>) parameter;
for (Object object : collection) {
processData(sqlCommandType, object);
}
return;
}
if (isSkipInject(clazz)) {
return;
}
List<EntityField> entityFieldList = getFields(clazz);
MetaObject metaObject = MetaObjectUtil.forObject(parameter);
for (EntityField field : entityFieldList) {
if (sqlCommandType == SqlCommandType.INSERT) {
if (field.isAnnotationPresent(CreatedDate.class)) {
metaObject.setValue(field.getName(), LocalDateTime.now());
}
if (field.isAnnotationPresent(CreatedBy.class)) {
String id = "1";
metaObject.setValue(field.getName(), id);
}
if (field.isAnnotationPresent(CreatedName.class)) {
metaObject.setValue(field.getName(), "hresh");
}
if (field.isAnnotationPresent(Version.class)) {
metaObject.setValue(field.getName(), 0);
}
if (field.isAnnotationPresent(LogicDelete.class)) {
metaObject.setValue(field.getName(), false);
}
}
if (field.isAnnotationPresent(LastModifiedDate.class)) {
metaObject.setValue(field.getName(), LocalDateTime.now());
}
if (field.isAnnotationPresent(LastModifiedBy.class)) {
metaObject.setValue(field.getName(), "1");
}
if (field.isAnnotationPresent(LastModifiedName.class)) {
metaObject.setValue(field.getName(), "hresh");
}
}
}
private List<EntityField> getFields(Class clazz) {
return EntityHelper.getColumns(clazz).stream().map(EntityColumn::getEntityField)
.collect(Collectors.toList());
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
}
}
关于上述代码根据项目实际需求进行调整,来填充非关键数据。
那么该拦截器实现类是如何注册到 Spring 容器中的呢?还是全局搜索查看位置。
@Configuration
@ConditionalOnBean({SqlSessionFactory.class})
public class MybatisAutoConfiguration {
@Autowired
private SqlSessionFactory sqlSessionFactory;
@PostConstruct
public void init() {
sqlSessionFactory.getConfiguration().addInterceptor(new AutoFillFieldInterceptor());
}
}
最后在 resources 目录下创建META-INF目录下,在 META-INF 目录下创建 spring.factories 文件,文件内容如下:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.msdn.orm.hresh.common.mybatis.config.MybatisAutoConfiguration
批量操作功能
批量操作包括批量新增、修改、删除等功能。
虽然 Mybatis 提供了 IdListMapper<T, Long> InsertListMapper 这两个接口来实现批量操作,但功能有限,所以一般情况下我们会自定义批量操作类。
此时可以自定义一个通用 BaseMapper,如以下接口,再让编写的 mapper 继承这个 BaseMapper。
需要注意的是:自定义的通用 mapper,想要生效,必须要加上@RegisterMapper 注解。
@RegisterMapper
public interface ListMapper<T, PK> {
/**
* 批量插入,支持批量插入的数据库可以使用,
*
* @param recordList
* @return
*/
@InsertProvider(type = ListProvider.class, method = "dynamicSQL")
int insertList(List<? extends T> recordList);
/**
* 批量更新
*
* @return
*/
@UpdateProvider(type = ListProvider.class, method = "dynamicSQL")
int updateBatchByPrimaryKeySelective(List<? extends T> recordList);
/**
* 根据主键字符串进行查询,类中只有存在一个带有@Id注解的字段
*
* @param idList
* @return
*/
@SelectProvider(type = ListProvider.class, method = "dynamicSQL")
List<T> selectByIdList(@Param("idList") List<PK> idList);
/**
* 根据主键字符串进行删除,类中只有存在一个带有@Id注解的字段
*
* @param idList
* @return
*/
@DeleteProvider(type = ListProvider.class, method = "dynamicSQL")
int deleteByIdList(@Param("idList") List<PK> idList);
}
关于批量操作的具体代码位于 ListProvider 文件中,因为代码比较多,这里只贴出批量新增的代码:
/**
* 填充主键值
*
* @param list
*/
public static void fillId(List<?> list, String fieldName) {
for (Object object : list) {
MetaObject metaObject = MetaObjectUtil.forObject(object);
if (metaObject.getValue(fieldName) == null) {
metaObject.setValue(fieldName, IdUtils.genId());
}
}
}
/**
* 批量插入
*
* @param ms
*/
public String insertList(MappedStatement ms) {
final Class<?> entityClass = getEntityClass(ms);
//开始拼sql
StringBuilder sql = new StringBuilder();
List<EntityColumn> pkColumns = new ArrayList<>(EntityHelper.getPKColumns(entityClass));
sql.append(
"<bind name=\"listNotEmptyCheck\" value=\"@tk.mybatis.mapper.util.OGNL@notEmptyCollectionCheck(list, '"
+ ms.getId() + " 方法参数为空')\"/>");
sql.append(
"<bind name=\"fillIdProcess\" value=\"@com.msdn.orm.hresh.common.mybatis.ListProvider@fillId(list, '"
+ pkColumns.get(0).getProperty() + "')\"/>");
sql.append(SqlHelper.insertIntoTable(entityClass, tableName(entityClass), "list[0]"));
sql.append(SqlHelper.insertColumns(entityClass, false, false, false));
sql.append(" VALUES ");
sql.append("<foreach collection=\"list\" item=\"record\" separator=\",\" >");
sql.append("<trim prefix=\"(\" suffix=\")\" suffixOverrides=\",\">");
//获取全部列
Set<EntityColumn> columnList = EntityHelper.getColumns(entityClass);
//当某个列有主键策略时,不需要考虑它的属性是否为空,因为如果为空,一定会根据主键策略给他生成一个值
for (EntityColumn column : columnList) {
if (column.isInsertable()) {
sql.append(column.getColumnHolder("record") + ",");
}
}
sql.append("</trim>");
sql.append("</foreach>");
// 反射把MappedStatement中的设置主键名
EntityHelper.setKeyProperties(EntityHelper.getPKColumns(entityClass), ms);
return sql.toString();
}
动态链式查询
虽然我们可以在 mapper.xml 文件中自定义 SQL 查询,但这样做有些麻烦,如果能够在代码中编写类似于 SQL 条件的 Code,对于开发人员来说,更加便捷,可读性也比较好。注意,特别麻烦的 SQL 语句还是要在 mapper.xml 文件中自定义。
这里借助 tk 的通用 mapper 实在 mybatis 中使用 Example 实现动态查询,大概有四种方式,可以参考本文。
其中方式三是:Example.builder 方式(其中 where 从句中内容可以拿出来进行动态 sql 拼接)
Example example = Example.builder(MybatisDemo.class)
.select("cabId","cabName")
.where(Sqls.custom().andEqualTo("count", 0)
.andLike("name", "%d%"))
.orderByDesc("count","name")
.build();
List<MybatisDemo> demos = mybatisDemoMapper.selectByExample(example);
可以看到上述实现方式,需要手动输入属性名,一旦数据库有变动或输入错误就会出错,不符合我们的期望。因此我们选择方式四。
//获得seekendsql
WeekendSqls<MybatisDemo> sqls = WeekendSqls.<MybatisDemo>custom();
//可进行动态sql拼接
sqls = sqls.andEqualTo(MybatisDemo::getCount,0).andLike(MybatisDemo::getName,"%d%");
//获得结果
List<MybatisDemo> demos = mybatisDemoMapper.selectByExample(Example.builder(MybatisDemo.class).where(sqls).orderByDesc("count","name").build());
在本项目中对于 Example 的使用有一个集成实现类,ExampleBuilder 类,该类有以下几个属性:
private Class<T> entityClass;//实体类
private Class<? extends Mapper<T>> mapperClass;//实体类对应的mapper接口类
private WeekendSqls<T> weekendSqls = WeekendSqls.custom();//用于拼接sql
private LinkedHashMap<String, Boolean> orderList = new LinkedHashMap<>();//用于存放排序的字段
private Map<String, Object> setterList = new HashMap<>();//存放需要更新的字段以及新值
定义的方法过多,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ghDvF00F-1668951219082)(https://www.hreshhao.com/wp-content/uploads/2022/11/微信截图_20201010113049.png)]
在 service 服务类中的查询方法定义如下,不仅可以实现分页查询,还可以排序。
@Override
public Page<BankAreaVO> queryPage(BankAreaQueryPageDTO dto) {
PageHelper.startPage(dto.getPageSortInfo().getPageNum(), dto.getPageSortInfo().getPageSize(), dto.getPageSortInfo().parseSort());
List<BankArea> bankAreaList = ExampleBuilder.create(BankAreaMapper.class).
andEqualTo(dto.getEnableFlag() != null, BankArea::getEnableFlag, dto.getEnableFlag()).
andEqualTo(StringUtils.isNotEmpty(dto.getCountry()), BankArea::getCountry, dto.getCountry()).
andEqualTo(StringUtils.isNotEmpty(dto.getProvince()), BankArea::getProvince, dto.getProvince()).
andLike(StringUtils.isNotEmpty(dto.getBankAreaCode()), BankArea::getBankAreaCode, "%" + dto.getBankAreaCode() + "%").
andLike(StringUtils.isNotEmpty(dto.getBankAreaName()), BankArea::getBankAreaName, "%" + dto.getBankAreaName() + "%").
orderByAsc(BankArea::getBankAreaCode).
select();
Page<BankArea> bankAreaPage = (Page<BankArea>) bankAreaList;
return BeanUtils.copyProperties(bankAreaPage, BankAreaVO.class);
}
我们重点关注经常使用到的方法,比如 create 方法等。
public static <T, M extends Mapper<T>> ExampleBuilder<T> create(Class<M> clazz) {
ExampleBuilder<T> exampleBuilder = new ExampleBuilder<>();
exampleBuilder.mapperClass = clazz;
//获取 mapper对应的实体类
exampleBuilder.entityClass = (Class<T>) ((ParameterizedType) clazz.getGenericInterfaces()[0]).getActualTypeArguments()[0];
return exampleBuilder;
}
然后将查询条件拼接起来,andEqualTo()处理等值判断,andLike()处理模糊查询,orderByAsc()处理字段排序。
public ExampleBuilder<T> andEqualTo(boolean condition, Fn<T, Object> fn, Object value) {
if (condition) {
weekendSqls.andEqualTo(Reflections.fnToFieldName(fn), value);
}
return this;
}
public ExampleBuilder<T> andLike(boolean condition, Fn<T, Object> fn, String value) {
if (condition) {
weekendSqls.andLike(Reflections.fnToFieldName(fn), value);
}
return this;
}
public ExampleBuilder<T> orderByAsc(Fn<T, Object> fn) {
String fieldName = Reflections.fnToFieldName(fn);
this.orderList.put(fieldName, true);
return this;
}
最后执行 select 方法
public List<T> select() {
Example example = this.build();
return SpringUtils.getBean(mapperClass).selectByExample(example);
}
public Example build() {
Example.Builder builder = Example.builder(entityClass);
//拼接到where条件后
if (weekendSqls.getCriteria().getCriterions().size() > 0) {
builder.where(weekendSqls);
}
//遍历字段排序列表,获取指定排序的结果集
for (Map.Entry<String, Boolean> entry : this.orderList.entrySet()) {
if (entry.getValue()) {
builder.orderByAsc(entry.getKey());
} else {
builder.orderByDesc(entry.getKey());
}
}
return builder.build();
}
在 select()方法中的 SpringUtils.getBean(mapperClass)
是为了获取已经注册到 Spring 上下文中的实例,我们可以简单查看一下 SpringUtils 类。
@Service
public class SpringUtils implements ApplicationContextAware {
private static ApplicationContext applicationContext;
public void setApplicationContext(ApplicationContext applicationContext)
throws BeansException {
SpringUtils.applicationContext = applicationContext;
}
public static <T> T getBean(Class<T> clazz) {
return (T) applicationContext.getBean(clazz);
}
public static Object getBean(String name) throws BeansException {
return applicationContext.getBean(name);
}
}
至此,关于本项目中有价值的内容已经讲述完毕,因篇幅有限,未能展示所有代码。基于上述核心代码,我们只需要往项目中添加相关业务代码即可,接下来我们就可以运行之前写的脚本工具,根据数据库表信息快速生成模板代码。
一键式生成模版代码
运行 orm-generate 项目,在 swagger 上调用 /build 接口,调用参数如下:
{
"database": "mysql_db",
"flat": true,
"type": "mybatis",
"group": "hresh",
"host": "127.0.0.1",
"module": "orm",
"password": "root",
"port": 3306,
"table": [
"user",
"job"
],
"username": "root",
"tableStartIndex":"0"
}
先将代码下载下来,解压出来目录如下:
代码文件直接移到项目中就行了,稍微修改一下引用就好了。
功能实现
请求日志输出
比如说我们访问 /users/queryPage 接口,看看控制台输出情况:
Request Info : {"classMethod":"com.msdn.orm.hresh.controller.UserController.queryPage","ip":"127.0.0.1","requestParams":{"dto":{"pageSortInfo":{"pageSize":5,"pageNum":1}}},"httpMethod":"GET","url":"http://localhost:8801/users/queryPage","result":{"code":"200","message":"操作成功","success":true},"methodDesc":"获取用户分页列表","timeCost":69}
可以看到,日志输出中包含前端传来的请求体,请求 API,返回结果,API 描述,API 耗时。
统一返回格式
比如说分页查询,返回结果如下:
{
"data": {
"total": 5,
"pageCount": 1,
"pageSize": 5,
"pageNum": 1,
"data": [
{
"id": null,
"name": "clearLove",
"age": 28,
"address": "中国上海"
},
{
"id": null,
"name": "ascii0",
"age": 21,
"address": "中国广东"
},
{
"id": null,
"name": "ascii1",
"age": 21,
"address": "中国广东"
},
{
"id": null,
"name": "ascii2",
"age": 21,
"address": "中国广东"
},
{
"id": null,
"name": "clearLove2",
"age": 28,
"address": "中国上海"
}
]
},
"code": "200",
"message": "操作成功",
"success": true
}
如果是新增请求,返回结果为:
{
"data": null,
"code": "200",
"message": "操作成功",
"success": true
}
此处 data 没有新增的数据,如果项目需要新增的实体信息,可以稍作修改。
异常处理
下面简单演示一下参数异常的情况,在 add user 时校验参数值是否为空。
public int add(UserDTO dto) {
if (StringUtils.isBlank(dto.getName())) {
BusinessException.validateFailed("userName不能为空");
}
User user = userStruct.dtoToModel(dto);
return userMapper.insertSelective(user);
}
如果传递的 name 值为空,则返回结果为:
{
"data": null,
"code": "400",
"message": "userName不能为空",
"success": false
}
补全操作者信息
此处依赖于 Mybatis 拦截器,重点在 AutoFillFieldInterceptor 文件。
for (EntityField field : entityFieldList) {
if (sqlCommandType == SqlCommandType.INSERT) {
if (field.isAnnotationPresent(CreatedDate.class)) {
metaObject.setValue(field.getName(), LocalDateTime.now());
}
if (field.isAnnotationPresent(CreatedBy.class)) {
String id = "1";
metaObject.setValue(field.getName(), id);
}
if (field.isAnnotationPresent(CreatedName.class)) {
metaObject.setValue(field.getName(), "hresh");
}
if (field.isAnnotationPresent(Version.class)) {
metaObject.setValue(field.getName(), 0);
}
if (field.isAnnotationPresent(LogicDelete.class)) {
metaObject.setValue(field.getName(), false);
}
}
if (field.isAnnotationPresent(LastModifiedDate.class)) {
metaObject.setValue(field.getName(), LocalDateTime.now());
}
if (field.isAnnotationPresent(LastModifiedBy.class)) {
metaObject.setValue(field.getName(), "1");
}
if (field.isAnnotationPresent(LastModifiedName.class)) {
metaObject.setValue(field.getName(), "hresh");
}
}
上述代码中关于新增者信息和修改者信息,暂时是写死的状态,实际项目中,可以根据 token 信息进行解析,然后来填充新增者和修改者信息。
批量操作
这里简单演示一下关于批量新增的代码
@Override
public int batchAdd(UserDTO dto) {
List<User> users = new ArrayList<>();
for (int i = 0; i < 3; i++) {
User user = User.builder().name("ascii" + i).age(21).address("中国广东").build();
users.add(user);
}
return userMapper.insertList(users);
}
注意:这里的 UserMapper 需要继承我们自定义的 ListMapper,如下所示:
public interface UserMapper extends Mapper<User>, ListMapper<User,Long> {
}
执行效果如下:
分页查询
前端参数传递:
{
"pageNum": 1,
"pageSize": 5,
"name": "ascii",
"orderInfos":[
{
"column": "name",
"asc": true
}
]
}
后端代码处理:
public Page<UserVO> queryPage(UserQueryPageDTO dto) {
PageHelper.startPage(dto.getPageSortInfo().getPageNum(), dto.getPageSortInfo().getPageSize(),
dto.getPageSortInfo().parseSort());
List<User> users = ExampleBuilder.create(UserMapper.class)
.andLike(User::getName, dto.getName() + "%")
.orderByDesc(User::getName)
.select();
Page<User> userPage = (Page<User>) users;
return PageUtils.convert(userPage, UserVO.class);
}
返回结果为:
{
"data": {
"total": 3,
"pageCount": 1,
"pageSize": 5,
"pageNum": 1,
"data": [
{
"name": "ascii0",
"age": 21,
"address": "中国广东",
"jobVOS": null
},
{
"name": "ascii1",
"age": 21,
"address": "中国广东",
"jobVOS": null
},
{
"name": "ascii2",
"age": 21,
"address": "中国广东",
"jobVOS": null
}
]
},
"code": "200",
"message": "操作成功",
"success": true
}
动态查询
查询方法如下:
public List<UserVO> queryList(UserDTO dto) {
List<User> users = ExampleBuilder.create(UserMapper.class)
.andLike(User::getName, dto.getName() + "%")
.orderByDesc(User::getName)
.select();
return BeanUtils.copyProperties(users, UserVO.class);
}
执行结果如下:
{
"data": [
{
"name": "ascii2",
"age": 21,
"address": "中国广东"
},
{
"name": "ascii1",
"age": 21,
"address": "中国广东"
},
{
"name": "ascii0",
"age": 21,
"address": "中国广东"
}
],
"code": "200",
"message": "操作成功",
"success": true
}
如果是分页查询,可以这样处理:
public Page<UserVO> queryPage(UserQueryPageDTO dto) {
PageHelper.startPage(dto.getPageSortInfo().getPageNum(), dto.getPageSortInfo().getPageSize(),
dto.getPageSortInfo().parseSort());
List<User> users = ExampleBuilder.create(UserMapper.class)
.andLike(User::getName, dto.getName() + "%")
.orderByDesc(User::getName)
.select();
Page<User> userPage = (Page<User>) users;
return PageUtils.convert(userPage, UserVO.class);
}
查询结果为:
{
"data": {
"total": 3,
"pageCount": 1,
"pageSize": 5,
"pageNum": 1,
"data": [
{
"name": "ascii2",
"age": 21,
"address": "中国广东"
},
{
"name": "ascii1",
"age": 21,
"address": "中国广东"
},
{
"name": "ascii0",
"age": 21,
"address": "中国广东"
}
]
},
"code": "200",
"message": "操作成功",
"success": true
}
一对多查询
比如说我们定义的 User 和 Job 类,存在着一对多的关系,所以查询 User 信息时,还需要返回关联的 Job 数据。
关于 Mybatis 一对多、多对一处理手段,可以参考我之前的文章。
本项目采用的是结果嵌套处理方式。
JobMapper.xml 内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.msdn.orm.hresh.mapper.JobMapper">
<resultMap id="jobResultMap" type="com.msdn.orm.hresh.model.Job">
<id column="id" property="id"/>
<result column="name" property="name"/>
<result column="user_id" property="userId"/>
<result column="address" property="address"/>
<result column="created_date" property="createdDate"/>
<result column="last_modified_date" property="lastModifiedDate"/>
<result column="del_flag" property="delFlag"/>
<result column="create_user_code" property="createUserCode"/>
<result column="create_user_name" property="createUserName"/>
<result column="last_modified_code" property="lastModifiedCode"/>
<result column="last_modified_name" property="lastModifiedName"/>
<result column="version" property="version"/>
</resultMap>
</mapper>
UserMapper.xml 文件
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.msdn.orm.hresh.mapper.UserMapper">
<resultMap id="userResultMap" type="com.msdn.orm.hresh.model.User">
<id column="id" property="id"/>
<result column="name" property="name"/>
<result column="age" property="age"/>
<result column="address" property="address"/>
<result column="created_date" property="createdDate"/>
<result column="last_modified_date" property="lastModifiedDate"/>
<result column="del_flag" property="delFlag"/>
<result column="create_user_code" property="createUserCode"/>
<result column="create_user_name" property="createUserName"/>
<result column="last_modified_code" property="lastModifiedCode"/>
<result column="last_modified_name" property="lastModifiedName"/>
<result column="version" property="version"/>
</resultMap>
<resultMap id="userVoResultMap" type="com.msdn.orm.hresh.model.User"
extends="com.msdn.orm.hresh.mapper.UserMapper.userResultMap">
<collection property="jobs" resultMap="com.msdn.orm.hresh.mapper.JobMapper.jobResultMap"
columnPrefix="job_"/>
</resultMap>
<select id="queryList" resultMap="userVoResultMap">
SELECT u.*,
j.name job_name,
j.address job_address
FROM
user u
LEFT JOIN job j ON u.id = j.user_id
<where>
<if test="name!=null and name!=''">
and u.name like concat('%',#{name},'%')
</if>
<if test="address != null and address !=''">
and u.address like concat('%',#{address},'%')
</if>
</where>
</select>
</mapper>
关于上述 xml 配置,我们最终获取的是关于 User 的返回结果,还需要再转换为 UserVO 才返回给前端。如果服务层获取到返回结果后,不需要其他业务操作,可以直接获取 UserVO,反之,我们需要查询得到 User,处理完其他操作后,再转换为 UserVO。需要修改的
<resultMap id="userVoResultMap" type="com.msdn.orm.hresh.vo.UserVO"
extends="com.msdn.orm.hresh.mapper.UserMapper.userResultMap">
<collection property="jobVOS" resultMap="com.msdn.orm.hresh.mapper.JobMapper.jobResultMap"
columnPrefix="job_"/>
</resultMap>
<resultMap id="userResultMap2" type="com.msdn.orm.hresh.model.User"
extends="com.msdn.orm.hresh.mapper.UserMapper.userResultMap">
<collection property="jobs" resultMap="com.msdn.orm.hresh.mapper.JobMapper.jobResultMap"
columnPrefix="job_"/>
</resultMap>
userVoResultMap 对应 UserVO 返回结果,userResultMap2 对应 User 结果。
对应的 UserMapper 文件
public interface UserMapper extends Mapper<User>, ListMapper<User,Long> {
List<User> queryList(UserDTO userDTO);
}
我们修改 UserService 中的查询方法如下:
public List<UserVO> queryList(UserDTO dto) {
// List<User> users = ExampleBuilder.create(UserMapper.class)
// .andLike(User::getName, dto.getName() + "%")
// .orderByDesc(User::getName)
// .select();
List<User> users = userMapper.queryList(dto);
return userStruct.modelToVO(users);
}
同时在 application.yml 文件中打开 SQL 输出配置:
mybatis:
mapper-locations: classpath:mapper/*Mapper.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
调用接口,可以发现控制台输出如下:
==> Preparing: SELECT u.*, j.name job_name, j.address job_address FROM user u LEFT JOIN job j ON u.id = j.user_id WHERE u.name like concat('%',?,'%')
==> Parameters: ascii(String)
<== Columns: id, name, age, address, created_date, last_modified_date, del_flag, create_user_code, create_user_name, last_modified_code, last_modified_name, version, job_name, job_address
<== Row: 55dc89810e394306b66ab9567b568534, ascii0, 21, 中国广东, 2022-09-19 21:30:06, 2022-09-19 21:30:06, 0, 1, hresh, 1, hresh, 0, 程序员, 中国湖北
<== Row: 55dc89810e394306b66ab9567b568534, ascii0, 21, 中国广东, 2022-09-19 21:30:06, 2022-09-19 21:30:06, 0, 1, hresh, 1, hresh, 0, 外卖员, 中国湖北
<== Row: 93473b532494477b9d8d34b3165d216a, ascii1, 21, 中国广东, 2022-09-19 21:30:06, 2022-09-19 21:30:06, 0, 1, hresh, 1, hresh, 0, 外卖员, 中国湖北
<== Row: 93473b532494477b9d8d34b3165d216a, ascii1, 21, 中国广东, 2022-09-19 21:30:06, 2022-09-19 21:30:06, 0, 1, hresh, 1, hresh, 0, 厨师, 中国湖北
<== Row: cd613ce660264dc18b15b3333a6421da, ascii2, 21, 中国广东, 2022-09-19 21:30:06, 2022-09-19 21:30:06, 0, 1, hresh, 1, hresh, 0, null, null
<== Total: 5
返回结果为:
{
"data": [
{
"name": "ascii0",
"age": 21,
"address": "中国广东",
"jobVOS": [
{
"name": "程序员",
"address": "中国湖北"
},
{
"name": "外卖员",
"address": "中国湖北"
}
]
},
{
"name": "ascii1",
"age": 21,
"address": "中国广东",
"jobVOS": [
{
"name": "外卖员",
"address": "中国湖北"
},
{
"name": "厨师",
"address": "中国湖北"
}
]
},
{
"name": "ascii2",
"age": 21,
"address": "中国广东",
"jobVOS": []
}
],
"code": "200",
"message": "操作成功",
"success": true
}
看到这里,你可能发现了这样一个问题,如果使用我们自定义的工具类 ExampleBuilder,是无法完成连表查询的,也无法实现懒加载查询。尝试修改过 ExampleBuilder,但未能实现类似于 left join 的查询,不过我在构建 Spring JPA 动态查询工具类的时候,实现了连表查询,可以关注后续文章的发布。
Swagger
启动项目后,访问 swagger,页面展示如下:
总结
以上便是本项目所包含的内容,关于基础代码,可以结合实际需要继续深入挖掘,可能也有不足的地方,或者我所不了解的基础代码,望各位大佬多多指教。
身为过来人,我也体验过 CRUD 的工作,对项目搭建知之甚少,而在工作中很少遇到从零开发一个项目的机会,这也是我所苦恼的。希望这篇文章能够对大家有所帮助,尤其是那些还未毕业的同学们,如果你们想实操一个项目,可以先去 Github 上找一个感兴趣的项目,然后复用本文章中提到的基础设施,亲自动手去完成一个项目。
感兴趣的朋友可以去我的 Github 下载相关代码,如果对你有所帮助,不妨 Star 一下,谢谢大家支持!
参考文献
写个日志请求切面,前后端甩锅更方便
maven之自定义插件
liquibase的changelog详解