出于毕设和项目实战等需要,笔者决定花 2 小时重温基于 Spring Boot 的 Web 架构。这里使用慕课网的一个 "红包" ( luckymoney ) 收发功能的 CRUD 项目为例,回顾以下功能:
- 多环境文件配置;
- Sping Boot 2.x 版本的新增注解;
- 基于 JPA 的持久层 ( 可以使用 Mybatis 替代 ),数据库选用 MySQL 8.0 版本;
- AOP 切面的基本使用方式;
- 统一异常处理;
- 基于 MockMvc 的单元测试;
从收发红包的功能来划分,项目 Api 可分为:
| url | 方法 | 功能 |
|---|---|---|
| /luckymoney/all | GET | 查询数据库内所有的红包 |
| /luckymoney/save | POST | 发送红包 |
| /luckymoney/check/{who} | GET | 获取某人发过的,还未被领取的红包 |
| /luckymoney/put | PUT | 领取某人发送的红包 |
1. 在 IntelliJ IDEA 中构建新项目
IntelliJ IDEA 支持通过 start.spring.io 直接创建一个 Spring 项目,可以选择项目开发语言及版本,构建工具 ( Maven / Gradle ) 以及包形式 ( jar / war )。
在依赖项选择中,暂时只需要勾选 Web -> Spring Web,IDE 即可够建出一个基本的项目目录:
src // 项目源码
↳ main
| ↳ java
| ↳ com.iproject.luckymoney
↳ resources
↳ static // 存储静态资源
↳ templates
↳ application.yml
test // 单元测试
↳ java
↳ com.iproject.luckymoney
笔者选择的 Spring Boot 版本为 2.3.x 。
2. 项目配置
2.1 多环境配置文件
这里选用 .yml 文件格式进行配置,它将复杂的配置项划分成了层级结构。如下面的写法:
spring:
profiles:
active: dev
该写法和原 .properties 文件中的以下写法等效:
spring.profiles.active=dev
另外,.yml 文件使用两个空格 ( 而不是 tab ) 进行缩进,配置项的值和冒号 : 之间要以一个空格分开。此外,我们通常在开发 ( dev ),测试 ( test ),生产 ( prod ) 三个不同部署环境中选择不同的配置文件,因此 resources 目录下通常存放着三个 application.yml 的 "拷贝" 版本:
application.yml
application-dev.yml // 开发环境中使用的配置
application.test.yml // 测试环境中使用的配置
application-prod.yml // 生产环境中使用的配置
而application.yml 中配置的 spring.profiles.active 项则决定了项目在 IDE 开发环境启动时该读取何种配置。当在 JDK 环境下运行项目 jar 包时,可通过 java 的启动参数来指定读取的配置文件:
java -jar -Dspring.profiles.active=prod xxx.jar
2.2 基本的启动配置
首先配置项目的启动端口/访问根目录,这些内容笔者默认配置在 application-dev.yml 文件当中,下同。
server:
# 配置启动端口
port: 8081
servlet:
# 配置项目根目录
context-path: /luckymoney
由于 Spring Boot 内置了服务器,因此只需要运行根目录下的 XXXApplication ( 它被 @SpringBootApplication 注解标注 ) 主程序,服务即可启动。现在,在外部通过 http://localhost:8081/luckmoney/... ( 非 HTTPS 协议 ) 可访问到本机服务。
2.3 注入配置项
配置文件支持自定义全局配置,并且可通过注解形式将这些配置注入给 Component 组件中。比如:
limit:
minMoney: 1
maxMoney: 99
description: 最少金额 ${limit.minMoney} 元,最高金额 ${limit.maxMoney} 元
这里可以将 limit 项看作是一个结构体 ( 或称作是一个类 ),另创建一个对应的配置类 LimitConfig.java ,配置项和属性名要相对应,而且要补充各个属性的 setter 方法:
// 配置相关的 Componet 被转移到 config 文件夹下;
package com.iproject.luckymoney.config;
// 注解的全限定名称;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
// 业务相关,这里使用 BigDecimal 表示红包的金额;
import java.math.BigDecimal;
// 注册为 Component;
@Component
// 指定配置文件中的 limit 项;
@ConfigurationProperties(prefix = "limit")
public class LimitConfig {
private BigDecimal minMoney;
private BigDecimal maxMoney;
private String description;
// 省略了 set 和 get 方法,自行补充。
}
不过,IDE 会提示:Spring Boot Configuration Annotation Processor not found in classpath;它提示项目路径下没有关于配置注解的处理工具。针对这一问题,我们只需要添加以下依赖:
<!--解决 'Spring Boot Configuration Annotation Processor not found in claspath' 错误-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
3. 创建 Controller
为了测试链接,不如首先创建一个简单的控制器 HelloController.java。
// @RestController = @Controller + @ResponseBody
// @RequestMapping(...)
@RestController
public class HelloController {
private finalLimitConfig limitConfig;
@Autowired
public HelloController(LimitConfig limitConfig) {this.limitConfig = limitConfig;}
//@RequestMapping(value = "hello",method = RequestMethod.GET)
@GetMapping("/hello")
public String t1() {return limitConfig.getDescription();}
// RESTful 请求
@GetMapping("/getid/{id}")
public String t2(@PathVariable("id") Integer id) {return "id is" + id;}
// /getid?id=10
@GetMapping("/getid2")
public String t3(@RequestParam("id") Integer id) {return "id is" + id;}
// 非 RESTful 请求可以设置默认值
@GetMapping("/getid3")
public String t4(@RequestParam(value = "id", required = false, defaultValue = "001") Integer id) {return "id is" + id;}
}
该控制器通过多个实例方法演示了以下内容:
一,通过 @Autowired + 构造器注入方式实现了对配置类 LimitConfig 的松耦合依赖。若仅在字段域实现注入,则推荐使用注解 @Resource。
@Resource
private LimitConfig limitConfig;
二,@RestController 是 Spring Boot 2.x 版本提供的新注解,可用来替代原 @Controller + @ResponseBody / @RequestBody 的组合。
三,@GetMapping 是 Spring Boot 2.x 版本提供的新注解,之前需要通过 @RequestMapping(value,method) 注解控制外界访问方法 ( 非法访问返回 405 错误码 ),类似的还有 @PostMapping,@PutMapping 等。
四,对于 RESTful 风格的 Api,可使用 @PathVariable 注解来接受请求 ( 不限于 GET 方法 ) 参数,但是不允许设默认值。
五,对于非 RESTful 风格的 Api,可使用 @RequestParam 注解来接受请求 ( 不限于 GET 方法 ) 参数,允许通过 defaultValue 设置默认值。
4. 创建数据库连接
4.1 什么是 JPA?
JPA ( Java Persistence API ) 是 Sun 官方提出的 Java 持久化的规范,总体来说,它允许 Java 程序员在不手写任何 SQL 语句的前提条件下来对数据库进行 CRUD 功能,而 Hibernate 便是 JPA 的一种实现方式。
对于小型项目,使用 JPA 可以快速提高开发效率,不过,相较于 Mybatis 这类可自由管理 SQL 语句的框架而言,JPA 则显得更加约束一些。
4.2 补充数据库驱动依赖和配置
为了使 Dao 层能够通过 JPA 访问 MySQL 数据库,需要在 pom.xml 文件中补充 MySQL 的数据库驱动依赖。
<!--引入 JPA 依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!--数据库连接驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
除此之外,需要在配置文件中创建数据库链接 ( 在此之前,需要提前在 MySQL 中建立一个新的空数据库 luckymoney,以便和配置项中的 url 对应 ),以及 Hibernate 的相关配置。
update 是 Hibernate 维护数据库表的五大策略之一:每次运行程序,没有表会新建表,但是表内有数据不会被清空,只会更新表结构。
show-sql 是非必须项,当它被开启时,Hibernate 每执行一次 SQL 语句,它都会在控制台中打印结果。
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/luckymoney
username: root
password: ljh20176714
jpa:
hibernate:
ddl-auto: update
show-sql: true
在本项目中不需要通过手写 SQL 语句来直接和 MySQL 数据库进行交互,JPA 将根据项目内的 实体 ( Entity ) ** 对表结构自动进行维护,或者说实体即为表**。
我们仅需专注于 Java 层面的代码,而不需关注数据库那边是用哪些表存储的,甚至表的具体名称都是什么。下图中的两个表便是 Hibernate 自动生成的:其中一个表维护着主键序号,另一个表则对应 Luckymoney 实体。
对于本项目而言,"红包" 即是实体,它包含了主键 id,发送者 producer ,消费者 consumer ,金额 money。
@Entity
public class Luckymoney {
@Id // 设置唯一主键
@GeneratedValue // 设置自增
private Integer id;
private BigDecimal money;
private String producer;
private String consumer;
// 省略了 get 和 set 方法
// 省略了无参和有参数构造方法
}
}
Hibernate 将每一个 @Entity 注解标注的实体类视作是一种数据库表结构。对于充当表 "主键" 的属性,需要使用 @Id 注解标注。笔者将主键声明为 Integer 类型,并且通过 @GeneratedValue 将其设置为自增。
在下一次启动 Spring Boot 时,Hibernate 将根据这个实体自动创建与之对应的表结构,注意表引擎应设置为 Innodb 。另外,笔者使用的是 MySQL 8.0 版本,这需要以手动方式修正系统时差,否则会出现数据库无法连接的问题1。
4.3 JpaRepository 接口
针对指定实体的持久化工具通过继承 JpaRepository<E,P> ( E 代表了实体类型,P 代表实体内的主键类型 ) 父接口来实现,且不需要额外的注解。
import com.iproject.luckymoney.entity.Luckymoney;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* JpaRepository 是一个被 @NoRepositoryBean 注解修饰的接口,这意味着它只会作为一个父接口,而不需要创建一个它的实现类。
* 此处的 LuckymoneyRepository 继承了此接口之后无需再使用 @Repository 注解标记。
*/
public interface LuckymoneyRepository extends JpaRepository<Luckymoney,Integer> {}
在创建了实体和持久化层之后,一个最基本的请求-处理-响应模型就构建出来了。
5. 实现 "红包" 业务
5.1 JPA 的无条件查询与提交
现在,新建一个 LuckymoneyController.java ,我们在此控制器中演示如何通过 LuckymoneyRepository 实现数据增改。首先是的 "查询所有红包" 和 "发送红包功能":
/**
* 引入 LuckymoneyRepository 组件
*/
@Resource
private LuckymoneyRepository repository;
/**
* 查询所有的红包
* @return 返回所有的红包
*/
@GetMapping("/all")
public List<Luckymoney> all() {return repository.findAll();}
/**
* 新发的红包处于 "无人认领" 的状态,因此红包的消费者 consumer 会暂时为 null。
* 红包的唯一标识符 id 由 hibernate 自动维护,该项也为 null。
* @param who 发红包的人
* @param howMuch 发送的金额
* @return 返回操作结果。
*/
@PostMapping("/save")
public Luckymoney save(
@RequestParam("who") String who,
@RequestParam("howMuch") BigDecimal howMuch
) {
Luckymoney luckymoney = new Luckymoney();
luckymoney.setProducer(who);
luckymoney.setMoney(howMuch);
return repository.save(luckymoney);
}
这里演示了 JpaRepository 的两个基本用法:findall() 可以查询出数据库中所有的 Luckymoney 实体并以 List 形式返回;save(E e) 方法将 Luckymoney 实体作为 "一行数据" 提交到了数据库的表中。
5.2 JPA 的简单条件查询
当然,实际业务中几乎不会执行无条件的查询,我们都要通过 Where ... 给定查询条件。在 JPA 中,这个查询条件被抽象成了一个实体,它被装入到一个名为 Example 的样例类内部。
下面演示了如何根据人名查询红包。出于业务需要,笔者用 λ 表达表达式筛去了 consumer 非空的项,因为 "被领取过的红包" 数据就没有必要再反馈给用户了。
/**
* 查询某个人发的红包
*
* @param who 指定发红包的人名
* @return 返回这个红包对应的 java 实体。
*/
@GetMapping("/check/{who}")
public List<Luckymoney> find(@PathVariable("who") String who) {
Luckymoney likeThis = new Luckymoney();
likeThis.setProducer(who);
return repository.findAll(Example.of(likeThis))
.stream().filter(p -> p.getConsumer() == null)
.collect(Collectors.toList());
}
这里没有提及更加复杂的条件查询,比如涉及到 join ,union,between,in 等操作的查询。这种情况下,笔者推荐使用 SQL 语句的形式来实现。
5.3 将请求数据抽象为实体
对于 ( 尤其是 POST ) 数据提交的请求,控制器亦可以将请求体中携带的表单数据也抽象成实体类接收。不过,这要求表单中的 key 和实体的属性名一一对应。另外,表单中的缺失项,其对应的字段属性将会被赋值为 null,或 0 。
/**
* @param luckymoney 将请求参数绑定到实体中
* @param result 返回数据提交结果
*/
@PostMapping("/save1")
public Luckymoney save1(Luckymoney luckymoney) {
return repository.save(luckymoney);
}
下面是通过 Postman 得到的验证结果:
5.4 表单验证功能
上面的代码虽然能够正常运行,但这个请求显然不合理:当发起红包时,金额是一个必填项,而后端却不加验证地将其提交到了数据库。Hibernate 组件提供了 @Valid 注解来快捷实现表单验证的功能,这样控制器就可以提前拒绝插入一些无效数据。在此之前需要引入依赖:
<!-- 和后续的表单验证部分有关-->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>5.2.4.Final</version>
</dependency>
回到 Luckymoney 实体类中,通过注解形式添加对 producer 和 money 字段的检查:
@DecimalMin(value = "0.01",message = "最少发送金额为 0.01 元")
@NotNull(message = "必须输入金额")
private BigDecimal money;
@NotNull(message = "必须记录发起红包的人")
private String producer;
控制器则需要在实体类对象前标记一个 @Valid 注解表示对其进行表单验证。
/**
* 通过表单验证接收的红包数据是否合法。
* @param luckymoney 将请求参数绑定到实体中
* @param result 返回验证后的结果
*/
@PostMapping("/safeSave")
public Luckymoney safeSave(@Valid Luckymoney luckymoney, BindingResult result) {
if (result.hasErrors()) {
assert result.getFieldError()!=null;
System.out.println(result.getFieldError().getDefaultMessage());
return null;
}
return repository.save(luckymoney);
}
程序会将验证结果绑定在 BindingResult 类对象当中,一旦前端提交了非法的数据,程序就能够通过该对象提取出注解携带的 message 信息。
6. 简单的事务实现
Spring 允许通过注解来将方法声明成一个事务2。一个事务通常涉及到多处的数据库提交操作,而这些操作要么都做,要么都不做 ( 保证事务的一致性 ) 。另外,为了避免在执行一个事务的同时导致另一个事务读脏,幻读,不可重复读,事务之间还要具备隔离性。
Spring 的事务隔离级别取决于选择的数据库管理系统本身。在默认情况下,事务的隔离级别为读提交 ( READ COMMITTED )。
比如,现在规定两个连续的 "表白红包" 为一个事务,两个 "520" 和 "1314" 红包要么都发出去,要么就都没发。
package com.iproject.luckymoney.service;
import com.iproject.luckymoney.dao.LuckymoneyRepository;
import com.iproject.luckymoney.entity.Luckymoney;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class LuckymoneyService {
private final LuckymoneyRepository repository;
@Autowired
public LuckymoneyService(LuckymoneyRepository repository) {
this.repository = repository;
}
@Transactional
public void create2(String who){
Luckymoney luckymoney = new Luckymoney();
luckymoney.setProducer(who);
luckymoney.setMoney(new BigDecimal(500));
repository.save(luckymoney);
Luckymoney luckymoney2 = new Luckymoney();
luckymoney2.setProducer(who);
luckymoney2.setMoney(new BigDecimal(1314));
repository.save(luckymoney2);
}
}
而外部的控制器 LuckymoneyController 则仅需要接受外部参数并对该 Service 组件调用即可:
@PostMapping("/putTwo/{who}")
public void createTwo(@PathVariable("who") String who) {luckymoneyService.create2(who);}
当然,LuckymoneyController 内部需要注入对 LuckymoneyService 的依赖:
@Resource
private LuckymoneyService luckymoneyService;
7. 基于 AOP 的日志记录实现
日志记录是一个与任何业务无关,而又必须在业务执行前后进行的工作。在支持函数式编程的开发语言中,这仅需要一个高阶函数:
// Action 是一个 hof,也可以理解成是一个完整的 "业务"。
// 它概述了业务应具备的所有步骤,但是没有具体规定每一个步骤的具体流程。
def Action[T](before : ()=> Unit)(commit : ()=> T)(after : T => Unit) : Unit = {
before()
after(commit())
}
Spring 则引入 AOP 概念实现了业务内的 "步骤分离" ( 或称业务切片 ) 效果。首先通过 @Pointcut 注解 + execution 表达式定义了指定 "切点"3 ( 充当切点的方法不要定义实质的代码块 ),随后通过 @After 和 @Before 注解将日志记录的行为织入到了该切点当中。
同时,luckmoneyAspect 作为一个切面,应使用 @Aspect 注解标注。
@Aspect
@Component
public class luckymoneyAspect {
private final static Logger log = LoggerFactory.getLogger(luckymoneyAspect.class);
// 这个 LuckymoneyController() 方法仅仅是为了保存这个切点,然后以 "LuckymoneyController()" 命名它。
@Pointcut("execution(public * com.iproject.luckymoney.controller.LuckymoneyController.*(..))")
public void LuckymoneyController() {}
@Before("LuckymoneyController()")
public void doBefore(JoinPoint joinPoint){
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
assert attributes != null;
HttpServletRequest request = attributes.getRequest();
// 记录访问的 url
log.info("url={}",request.getRequestURL());
log.info("method={}",request.getMethod());
log.info("ip={}",request.getRemoteAddr());
log.info("classMethod={}",joinPoint.getSignature().getDeclaringTypeName());
}
@After("LuckymoneyController()")
public void doAfter(){log.info("done..");}
// 获取每个切点的返回值,这个类可以是 Object.
@AfterReturning(returning = "object",pointcut = "LuckymoneyController()")
public void doAfterReturning(Object object){
if(object == null) object = "Void";
log.info("obj={}",object.toString());
}
}
8. 优化数据传输
8.1 统一数据交互格式
下面的代码块是前文提到的 "发红包" 功能:由于后端返回的数据类型出现了两种情况 ( 成功时返回 Luckymoney 的 json 格式 ),这导致了前端开发人员无法用统一的逻辑来处理数据。
/**
* 通过表单验证接收的红包数据是否合法。
* @param luckymoney 将请求参数绑定到实体中
* @param result 返回验证后的结果
*/
@PostMapping("/safeSave")
public Luckymoney safeSave(@Valid Luckymoney luckymoney, BindingResult result) {
if (result.hasErrors()) {
assert result.getFieldError()!=null;
System.out.println(result.getFieldError().getDefaultMessage());
return null;
}
return repository.save(luckymoney);
}
不妨将前后端的数据交互数据做一层封装:假定这个数据结构为 Result<T>,它通常应该有三部分信息构成:状态码 code,状态码含义 msg ,数据 data。
现在规定,当后端的业务逻辑内部出现异常时,返回的 data 应为空,并且应尽可能将错误原因反映到 code 和 msg 当中。这样,前端只需要通过 js 脚本对 code 进行简单的值判断,就可以获悉到后端的功能是否正常完成。
下面是 "数据报" Result<T> 的标准格式,前端最终会以 json 格式收到此数据:
package com.iproject.luckymoney.utils;
public class Result<T> {
private Integer code;
private String mgs;
private T data;
// 忽略了 get / set 和构造方法,toString 方法。
}
另通过工具接口 ResultsUtil 封装当业务执行成功 ( 但无返回结果 ),执行成功并返回数据,出现异常的三类不同的 Result 实例:
public interface ResultsUtil {
static <T> Result success(T data) {return new Result<>(0, "ok", data);}
static Result success(){ return success(null);}
static Result failure(Integer code, String msg){return new Result<>(code,msg,null);}
}
现在,我们可以借助这个工具接口重新优化之前的 safeSave 方法,使其统一地返回一个 Result 数据类型:
/**
* 通过表单验证接收的红包数据是否合法。
* @param luckymoney 将请求参数绑定到实体中
* @param result 返回验证后的结果,统一为 Result 类型。
*/
@PostMapping("/safeSave")
public Result safeSave(@Valid Luckymoney luckymoney, BindingResult result) {
if (result.hasErrors()) {
assert result.getFieldError() != null;
return ResultsUtil.failure(-3, "表单验证不通过,原因:" + result.getFieldError().getDefaultMessage());
}
return ResultsUtil.success(repository.save(luckymoney));
}
8.2 优雅地处理异常
随着项目的开发,越来越多的潜在业务异常会被挖掘出来,需要添加的状态码也会越来越多。为了能够在统一的地方维护状态码及其描述信息,这里使用了枚举类来实现。
package com.iproject.luckymoney.enums;
public enum {
NO_SUCH_LUCKYMONEY(-1,"没有这样的红包"),
CONSUMED_LUCKYMONEY(-2,"这个红包已经被使用过了"),
INVALID_POSTFORM(-3,"表单验证不通过")
;
ResultCodeTable(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
private Integer code;
private String msg;
public Integer getCode() { return code;}
public String getMsg() {return msg;}
}
对于业务内部问题导致的 "异常" 情况 ( 如数据验证不通过,主键冲突导致数据库写入失败等 ),可以自定义一个继承于 RuntimeException 的异常类来描述它们。这种做法不仅更规范,而且有助于后续我们将这些异常有针对性地捕获并集中处理。
public class InnerTransactionException extends RuntimeException {
private Integer code;
public Integer getCode() {return code;}
public InnerTransactionException(ResultCodeTable itt) {
super(itt.getMsg());
this.code = itt.getCode();
}
}
相较于父类的 RuntimeException 而言,自定义的 InnerTransactionException 类还应该具备额外的 code 属性,这样,每项错误的状态码和提示信息都可以对应到 ResultCodeTable 当中。
我们来实现最后一个方法,接收红包。这需要考虑两个先决条件:一是这个红包必须存在,二是这个红包的 consumer 需为空 ( 代表这个红包还没有人被领过 )。
/**
* 接受红包。
*
* @param id 需要发送红包的 id 号
* @param whom 提交接受红包的人
* @return 返回这个红包对应的 java 实体。
*/
@PutMapping("/put")
public Result put(
@RequestParam("id") Integer id,
@RequestParam("whom") String whom
) {
Luckymoney res1 = repository.findById(id).orElse(null);
if (res1 == null) throw new InnerTransactionException(NO_SUCH_LUCKYMONEY);
if (res1.getConsumer() != null) throw new InnerTransactionException(CONSUMED_LUCKYMONEY);
res1.setConsumer(whom);
return ResultsUtil.success(repository.save(res1));
}
额外地,为了能够在 LuckymoneyController 控制器中直接使用枚举类 ResultCodeTable 的各种枚举值,这里需要进行一步静态引用:
import static com.iproject.luckymoney.enums.ResultCodeTable.*;
可以看到,现在若出现了业务上的问题,程序会毫不犹豫地抛出一个 InnerTransactionException 实例,并附带上错误原因。最后一个问题是:这个被抛出的异常将由谁处理?如果这个异常最终抛给 Spring Boot 来处理,那么它就会在控制台中输出一大段的 ErrorStack,然后向前端返回 500 错误。比如:
{
"timestamp": "2020-12-08T14:56:21.034+00:00",
"status": 500,
"error": "Internal Server Error",
"message": "",
"path": "/luckymoney/put"
}
这违背了之前 "在任何情况下都只以 Result 类型进行数据交互" 的承诺。
显然,我们还需定制一个组件 ( 该组件通过 @ControllerAdvice 注解标记,官方名为 "控制器增强组件" ) 来对这些异常作统一拦截,经处理后返回一个附带异常信息的 Result 类型实例数据。由于该组件不使用 @RestController 注解,因此内部的方法要添加 @ResponseBody 注解表示返回的是数据而非视图。
package com.iproject.luckymoney.handle;
import com.iproject.luckymoney.utils.Result;
import com.iproject.luckymoney.utils.ResultsUtil;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
@ControllerAdvice
public class LuckymoneyHandler {
/**
* 捕获各种 Controller 可能抛出的 InnerTransactionException 异常。
* @return 最终,向外界返回带异常信息提示的 Result 类型。
*/
@ExceptionHandler(InnerTransactionException.class)
@ResponseBody
public Result exceptionHandler(InnerTransactionException e){
return ResultsUtil.failure(e.getCode(),e.getMessage());
}
}
至此,一个简易的红包收发后台系统已基本完成:
9. 使用 MockMvc 进行单元测试
在开发过程中,除了使用 Postman 对接口进行测试之外,还能以代码操作模拟发送请求的方式对接口进行测试。
单元测试类需要两个注解,分别为 @SpringBootTest 和 @AutoConfigureMockMvc 。一般情况下,单元测试都会保存放在 test.java 目录,该目录的结构习惯上应该和 src.main.java 目录相对应。
为了模拟发送请求,这里需要注入对 MockMvc 的依赖:
@SpringBootTest
@AutoConfigureMockMvc
class LuckymoneyApplicationTests {
@Resource
private MockMvc mvc;
}
测试的过程大概可以分为三步:传入请求参数或表单,模拟发送请求,验证响应报文是否符合预期。如果是,则该测试的方法通过检验。以模拟请求 /all 方法为例,假设期望得到的响应报文的状态码为 200:
@Test
void contextLoads() throws Exception {
// 模拟请求 url = "/all", 这里的 url 不包含 server.servlet.context-path 的 "/luckymoney"
mvc.perform(MockMvcRequestBuilders.get("/all")).andExpect(
MockMvcResultMatchers.status().is(200)
);
}
传入请求参数和表单需要用一个名为 MultiValueMap<String,String> 的数据结构来封装,下面演示了如何模拟 POST 方法提交数据:
@Test
@Transactional
@Rollback
void contextLoads2() throws Exception {
// 模拟 post 请求 url = "/save", 这里的 url 不包含 server.servlet.context-path 的 "/luckymoney"
MultiValueMap<String, String> testParams = new LinkedMultiValueMap<>();
testParams.add("who", "超人");
testParams.add("howMuch", "200");
MvcResult aReturn = mvc.perform(
MockMvcRequestBuilders.post("/save")
.contentType("application/x-www-form-urlencoded")
.characterEncoding("utf-8")
.params(testParams)
).andExpect(
MockMvcResultMatchers.status().is(200)
).andReturn();
MockHttpServletResponse response = aReturn.getResponse();
// 防止汉字内容乱码,在输出响应报文之前设置编码格式:
response.setCharacterEncoding("utf-8");
System.out.println(response.getContentAsString());
}
对于测试方法,如果不希望它改写数据库内容,则可以使用 @Transactional 和 @Rollback 进行回滚。
10. 参考资料
[1] spring的@Transactional注解详细用法 - wangfg - 博客园 (cnblogs.com)
[2] Spring Boot Configuration Annotation Processor not found in claspath_小志的博客-CSDN博客
[3] 自定义Exception异常 - 秋夜雨巷 - 博客园 (cnblogs.com)
[4] spring的@ControllerAdvice注解 - 杨冠标 - 博客园 (cnblogs.com)
[5] 小记Spring工具类MultiValueMap_悲观是一种远见-CSDN博客
[6] Junit测试Controller(MockMVC使用),传输@RequestBody数据解决办法 - WhyWin - 博客园 (cnblogs.com)
Footnotes
-
可以参考笔者之前的文章对 MySQL 8.0 版本的额外配置 Docker入门篇(四):Docker 数据卷 ↩
-
有关事务以及事务隔离的详细内容可参考:MySQL事务隔离级别和实现原理(看这一篇文章就够了!) - 知乎 (zhihu.com) ↩
-
根据定义,一切 Controller 的方法前后都可视作是切点,各种前向通知,后向通知,环绕通知都是在切点处进行的。 ↩