SpringBoot集成Mybatis项目实操

1,829

《从零打造项目》系列文章

工具

ORM框架选型

数据库变更管理

定时任务框架

缓存

安全框架

开发规范

前言

基于 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<>();//存放需要更新的字段以及新值

定义的方法过多,如下图所示:

Mybatis工具类

在 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"
}

先将代码下载下来,解压出来目录如下:

SpringBoot项目模版代码

代码文件直接移到项目中就行了,稍微修改一下引用就好了。

功能实现

请求日志输出

比如说我们访问 /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> {

}

执行效果如下:

SQL查询结果

分页查询

前端参数传递:

{
	"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,页面展示如下:

swagger

总结

以上便是本项目所包含的内容,关于基础代码,可以结合实际需要继续深入挖掘,可能也有不足的地方,或者我所不了解的基础代码,望各位大佬多多指教。

身为过来人,我也体验过 CRUD 的工作,对项目搭建知之甚少,而在工作中很少遇到从零开发一个项目的机会,这也是我所苦恼的。希望这篇文章能够对大家有所帮助,尤其是那些还未毕业的同学们,如果你们想实操一个项目,可以先去 Github 上找一个感兴趣的项目,然后复用本文章中提到的基础设施,亲自动手去完成一个项目。

感兴趣的朋友可以去我的 Github 下载相关代码,如果对你有所帮助,不妨 Star 一下,谢谢大家支持!

参考文献

写个日志请求切面,前后端甩锅更方便

maven之自定义插件

liquibase的changelog详解