1 概述
本篇文章以Spring Boot為基礎,從以下三個方向講述了如何設計一個優秀的后端接口體系:
- 參數校驗:涉及
Hibernate Validator的各種注解,快速失敗模式,分組,組序列以及自定義注解/Validator - 異常處理:涉及
ControllerAdvice/@RestControllerAdvice以及@ExceptionHandler - 數據響應:涉及如何設計一個響應體以及如何包裝響應體
有了一個優秀的后端接口體系,不僅有了規范,同時擴展新的接口也很容易,本文演示了如何從零一步步構建一個優秀的后端接口體系。
2 新建工程
打開熟悉的IDEA,選擇依賴:

首先創建如下文件:

TestController.java:
@RestController
@RequestMapping("/")
@CrossOrigin(value = "http://localhost:3000")
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class TestController {
private final TestService service;
@PostMapping("test")
public String test(@RequestBody User user)
{
return service.test(user);
}
}
使用了@RequiredArgsConstructor代替@Autowired,由於筆者使用Postwoman測試,因此需要加上跨域注解@CrossOrigin,默認3000端口(Postwoman端口)。
TestService.java:
@Service
public class TestService {
public String test(User user)
{
if(StringUtils.isEmpty(user.getEmail()))
return "郵箱不能為空";
if(StringUtils.isEmpty(user.getPassword()))
return "密碼不能為空";
if(StringUtils.isEmpty(user.getPhone()))
return "電話不能為空";
// 持久化操作
return "success";
}
}
業務層首先進行了參數校驗,這里省略了持久化操作。
User.java:
@Data
public class User {
private String phone;
private String password;
private String email;
}
3 參數校驗
首先來看一下參數校驗,上面的例子中在業務層完成參數校驗,這是沒有問題的,但是,還沒進行業務操作就需要進行這么多的校驗顯然這不是很好,更好的做法是,使用Hibernate Validator。
3.1 Hibernate Validator
3.1.1 介紹
JSR是Java Specification Requests的縮寫,意思是Java規范提案,是指向JCP(Java Community Process)提出新增一個標准化技術規范的正式請求。JSR-303是Java EE6中的一項子規范,叫作Bean Validation,Hibernate Validator是Bean Validator的參考實現,除了實現所有JSR-303規范中的內置constraint實現,還有附加的constraint,詳細如下:
@Null:被注解元素必須為null(為了節省篇幅下面用“元素”代表“被注解元素必須為”)@NotNull:元素不為null@AssertTrue:元素為true@AssertFalse:元素為false@Min(value):元素大於或等於指定值@Max(value):元素小於或等於指定值@DecimalMin(value):元素大於指定值@DecimalMax(value):元素小於指定值@Size(max,min):元素大小在給定范圍內@Digits(integer,fraction):元素字符串中的整數位數規定最大integer位,小數位數規定最大fraction位@Past:元素是一個過去日期@Future:元素是將來日期@Pattern:元素需要符合正則表達式
其中Hibernate Validator附加的constraint如下:
@Eamil:元素為郵箱@Length:字符串大小在指定范圍內@NotEmpty:字符串必須非空(目前最新的6.1.5版本已棄用,建議使用標准的@NotEmpty)@Range:數字在指定范圍內
而在Spring中,對Hibernate Validation進行了二次封裝,添加了自動校驗,並且校驗信息封裝進了特定的BindingResult中。下面看看如何使用。
3.1.2 使用
在各個字段加上@NotEmpty,並且郵箱加上@Email,電話加上11位限制,並且在各個注解加上message,表示對應的提示信息:
@Data
public class User {
@NotEmpty(message = "電話不能為空")
@Length(min = 11,max = 11,message = "電話號碼必須11位")
private String phone;
@NotEmpty(message = "密碼不能為空")
@Length(min = 6,max = 20,message = "密碼必須為6-20位")
private String password;
@NotEmpty(message = "郵箱不能為空")
@Email(message = "郵箱格式不正確")
private String email;
}
對於String來說有時候會使用@NotNull或@NotBlank,它們的區別如下:
@NotEmpty:不能為null並且長度必須大於0,除了String外,對於Collection/Map/數組也適用@NotBlank:只用於String,不能為null,並且調用trim()后,長度必須大於0,也就是必須有除空格外的實際字符@NotNull:不能為null
接着把業務層的參數校驗操作刪除,並把控制層修改如下:
@PostMapping("test")
public String test(@RequestBody @Valid User user, BindingResult bindingResult)
{
if(bindingResult.hasErrors())
{
for(ObjectError error:bindingResult.getAllErrors())
return error.getDefaultMessage();
}
return service.test(user);
}
在需要校驗的對象上加上@Valid,並且加上BindingResult參數,可以從中獲取錯誤信息並返回。
3.1.3 測試
全部都使用錯誤的參數設置,返回”郵箱格式不正確“:

第二次測試中除了密碼都使用正確的參數,返回”密碼必須為6-20位“:

第三次測試全部使用正確的參數,返回”success“:

3.2 校驗模式設置
Hibernate Validator有兩種校驗模式:
- 普通模式:默認模式,會校驗所有屬性,然后返回所有的驗證失敗信息
- 快速失敗模式:只要有一個驗證失敗就返回
使用快速失敗模式需要通過HibernateValidateConfiguration以及ValidateFactory創建Validator,並且使用Validator.validate()進行手動驗證。
首先添加一個生成Validator的類:
@Configuration
public class FailFastValidator<T> {
private final Validator validator;
public FailFastValidator()
{
validator = Validation
.byProvider(HibernateValidator.class).configure()
.failFast(true).buildValidatorFactory()
.getValidator();
}
public Set<ConstraintViolation<T>> validate(T user)
{
return validator.validate(user);
}
}
修改控制層的代碼,通過@RequiredArgsConstructor注入FailFastValidator<User>,並把原來的在User上的@Valid去掉,在方法體進行手動驗證:
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class TestController {
private final TestService service;
private final FailFastValidator<User> validator;
@PostMapping("test")
public String test(@RequestBody User user, BindingResult bindingResult)
{
Set<ConstraintViolation<User>> message = validator.validate(user);
message.forEach(t-> System.out.println(t.getMessage()));
// if(bindingResult.hasErrors())
// {
// bindingResult.getAllErrors().forEach(t->System.out.println(t.getDefaultMessage()));
// for(ObjectError error:bindingResult.getAllErrors())
// return error.getDefaultMessage();
// }
return service.test(user);
}
}
測試(連續三次校驗的結果):

如果是普通模式(修改.failFast(false)),一次校驗便會連續輸出三個信息:

3.3 @Valid與@Validated
@Valid是javax.validation包里面的,而@Validated是org.springframework.validation.annotation里面的,是@Valid的一次封裝,相當於是@Valid的增強版,供Spring提供的校驗機制使用,相比起@Valid,@Validated提供了分組以及組序列的功能。下面分別進行介紹。
3.4 分組
當需要在不同的情況下使用不同的校驗方式時,可以使用分組校驗。比如在注冊時不需要校驗id,修改信息時需要校驗id,但是默認的校驗方式在兩種情況下全部都校驗,這時就需要使用分組校驗。
下面以不同的組別校驗電話號碼長度的不同進行說明,修改User類如下:
@Data
public class User {
@NotEmpty(message = "電話不能為空")
@Length(min = 11,max = 11,message = "電話號碼必須11位",groups = {GroupA.class})
@Length(min = 12,max = 12,message = "電話號碼必須12位",groups = {GroupB.class})
private String phone;
@NotEmpty(message = "密碼不能為空")
@Length(min = 6,max = 20,message = "密碼必須為6-20位")
private String password;
@NotEmpty(message = "郵箱不能為空")
@Email(message = "郵箱格式不正確")
private String email;
public interface GroupA{}
public interface GroupB{}
}
在@Length中加入了組別,GroupA表示電話需要為11位,GroupB表示電話需要為12位,GroupA/GroupB是User中的兩個空接口,然后修改控制層:
public String test(@RequestBody @Validated({User.GroupB.class}) User user, BindingResult bindingResult)
{
if(bindingResult.hasErrors())
{
bindingResult.getAllErrors().forEach(t->System.out.println(t.getDefaultMessage()));
for(ObjectError error:bindingResult.getAllErrors())
return error.getDefaultMessage();
}
return service.test(user);
}
在@Validated中指定為GroupB,電話需要為12位,測試如下:

3.5 組序列
默認情況下,不同組別的約束驗證的無序的,也就是說,對於下面的User類:
@Data
public class User {
@NotEmpty(message = "電話不能為空")
@Length(min = 11,max = 11,message = "電話號碼必須11位")
private String phone;
@NotEmpty(message = "密碼不能為空")
@Length(min = 6,max = 20,message = "密碼必須為6-20位")
private String password;
@NotEmpty(message = "郵箱不能為空")
@Email(message = "郵箱格式不正確")
private String email;
}
每次進行校驗的順序不同,三次測試結果如下:


有些時候順序並不重要,而有些時候順序很重要,比如:
- 第二個組中的約束驗證依賴於一個穩定狀態運行,而這個穩定狀態由第一個組來進行驗證
- 某個組的驗證比較耗時,CPU和內存的使用率相對較大,最優的選擇是將其放在最后進行驗證
因此在進行組驗證的時候需要提供一種有序的驗證方式,一個組可以定義為其他組的序列,這樣就可以固定每次驗證的順序而不是隨機順序,另外如果驗證組序列中,前面的組驗證失敗,則后面的組不會驗證。
例子如下,首先修改User類並定義組序列:
@Data
public class User {
@NotEmpty(message = "電話不能為空",groups = {First.class})
@Length(min = 11,max = 11,message = "電話號碼必須11位",groups = {Second.class})
private String phone;
@NotEmpty(message = "密碼不能為空",groups = {First.class})
@Length(min = 6,max = 20,message = "密碼必須為6-20位",groups = {Second.class})
private String password;
@NotEmpty(message = "郵箱不能為空",groups = {First.class})
@Email(message = "郵箱格式不正確",groups = {Second.class})
private String email;
public interface First{}
public interface Second{}
@GroupSequence({First.class,Second.class})
public interface Group{}
}
定義了兩個空接口First和Second表示順序,同時在Group中使用@GroupSequence指定了順序。
接着修改控制層,在@Validated中定義組:
public String test(@RequestBody @Validated({User.Group.class}) User user, BindingResult bindingResult)
這樣就能按照固定的順序進行參數校驗了。
3.6 自定義校驗
盡管Hibernate Validator中的注解適用情況很廣了,但是有時候需要特定的校驗規則,比如密碼強度,人為判定弱密碼還是強密碼。也就是說,此時需要添加自定義校驗的方式,有兩種處理方法:
- 自定義注解
- 自定義
Validator
首先來看一下自定義注解的方法。
3.6.1 自定義注解
這里添加一個判定弱密碼的注解WeakPassword:
@Documented
@Constraint(validatedBy = WeakPasswordValidator.class)
@Target({ElementType.METHOD,ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface WeakPassword{
String message() default "請使用更加強壯的密碼";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
同時添加一個實現了ConstraintValidator<A,T>的WeakPasswordValidator,當密碼長度大於10位時才符合條件,否則返回false表示校驗不通過:
public class WeakPasswordValidator implements ConstraintValidator<WeakPassword,String> {
@Override
public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
return s.length() > 10;
}
@Override
public void initialize(WeakPassword constraintAnnotation) {}
}
接着可以修改User如下,在對應的字段加上自定義注解@WeakPassword:
@Data
public class User {
//...
@WeakPassword(groups = {Second.class})
private String password;
//...
}
測試如下:

3.6.2 自定義Validator
除了自定義注解之外,還可以自定義Validator來實現自定義的參數校驗,需要實現Validator接口:
@Component
public class WeakPasswordValidator implements Validator{
@Override
public boolean supports(Class<?> aClass) {
return User.class.equals(aClass);
}
@Override
public void validate(Object o, Errors errors) {
ValidationUtils.rejectIfEmpty(errors,"password","password.empty");
User user = (User)o;
if(user.getPassword().length() <= 10)
errors.rejectValue("password","Password is not strong enough!");
}
}
實現其中的supports以及validate:
support:可以驗證該類是否是某個類的實例validate:當supports返回true后,驗證給定對象o,當出現錯誤時,向errors注冊錯誤
ValidationUtils.rejectIfEmpty校驗當對象o中某個字段屬性為空時,向其中的errors注冊錯誤,注意並不會中斷語句的運行,也就是即使password為空,user.getPassword()還是會運行,這時會拋出空指針異常。下面的errors.rejectValue同樣道理,並不會中斷語句的運行,只是注冊了錯誤信息,中斷的話需要手動拋出異常。
修改控制層中的返回值,改為getCode():
if(bindingResult.hasErrors())
{
bindingResult.getAllErrors().forEach(t-> System.out.println(t.getCode()));
for(ObjectError error:bindingResult.getAllErrors())
return error.getCode();
}
return service.test(user);
測試:

4 異常處理
到這里參數校驗就完成了,下一步是處理異常。
如果將參數校驗中的BindingResult去掉,就會將整個后端異常返回給前端:
//public String test(@RequestBody @Validated({User.Group.class}) User user, BindingResult bindingResult)
public String test(@RequestBody @Validated({User.Group.class}) User user)

這樣雖然后端是方便了,不需要每一個接口都加上BindingResult,但是前端不好處理,整個異常都返回了,因此后端需要捕捉這些異常,但是,不能手動去捕捉每一個,這樣還不如之前使用BindingResult,這種情況下就需要用到全局的異常處理。
4.1 基本使用
處理全局異常的步驟如下:
- 創建全局異常處理的類:加上
@ControllerAdvice/@RestControllerAdvice注解(取決於控制層用的是@Controller/@RestController,@Controller可以跳轉到相應頁面,返回JSON等加上@ResponseBody即可,而@RestController相當於@Controller+@ResponseBody,返回JSON無需加上@ResponseBody,但是視圖解析器無法解析jsp以及html頁面) - 創建異常處理方法:加上
@ExceptionHandler指定想要處理的異常類型 - 處理異常:在對應的處理異常方法中處理異常
這里增加一個全局異常處理類GlobalExceptionHandler:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public String methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e)
{
ObjectError error = e.getBindingResult().getAllErrors().get(0);
return error.getDefaultMessage();
}
}
首先加上@RestControllerAdvice,並在異常處理方法上加上@ExceptionHandler。
接着修改控制層,去掉其中的BindingResult:
@PostMapping("test")
public String test(@RequestBody @Validated({User.Group.class}) User user)
{
return service.test(user);
}
然后就可以進行測試了:

全局異常處理相比起原來的每一個接口都加上BindingResult方便很多,而且可以集中處理所有異常。
4.2 自定義異常
很多時候都會用到自定義異常,這里新增一個測試異常TestException:
@Data
public class TestException extends RuntimeException{
private int code;
private String msg;
public TestException(int code,String msg)
{
super(msg);
this.code = code;
this.msg = msg;
}
public TestException()
{
this(111,"測試異常");
}
public TestException(String msg)
{
this(111,msg);
}
}
接着在剛才的全局異常處理類中添加一個處理該異常的方法:
@ExceptionHandler(TestException.class)
public String testExceptionHandler(TestException e)
{
return e.getMsg();
}
在控制層進行測試:
@PostMapping("test")
public String test(@RequestBody @Validated({User.Group.class}) User user)
{
throw new TestException("出現異常");
// return service.test(user);
}
結果如下:

5 數據響應
在處理好了參數校驗以及異常處理之后,下一步就是要設置統一的規范化的響應數據,一般來說無論響應成功還是失敗都會有一個狀態碼,響應成功還會攜帶響應數據,響應失敗則攜帶相應的失敗信息,因此,第一步是設計一個統一的響應體。
5.1 統一響應體
統一響應體需要創建響應體類,一般來說,響應體需要包含:
- 狀態碼:
String/int - 響應信息:
String - 響應數據:
Object/T(泛型)
這里簡單的定義一個統一響應體Result:
@Data
@AllArgsConstructor
public class Result<T> {
private String code;
private String message;
private T data;
}
接着修改全局異常處理類:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<String> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e)
{
ObjectError error = e.getBindingResult().getAllErrors().get(0);
return new Result<>(error.getCode(),"參數校驗失敗",error.getDefaultMessage());
}
@ExceptionHandler(TestException.class)
public Result<String> testExceptionHandler(TestException e)
{
return new Result<>(e.getCode(),"失敗",e.getMsg());
}
}
使用Result<String>封裝返回值,測試如下:

可以看到返回了一個比較友好的信息,無論是響應成功還是響應失敗都會返回同一個響應體,當需要返回具體的用戶數據時,可以修改控制層接口直接返回Result<User>:
@PostMapping("test")
public Result<User> test(@RequestBody @Validated({User.Group.class}) User user)
{
return service.test(user);
}
測試:

5.2 響應碼枚舉
通常來說可以把響應碼做成枚舉類:
@Getter
public enum ResultCode {
SUCCESS("111","成功"),FAILED("222","失敗");
private final String code;
private final String message;
ResultCode(String code,String message)
{
this.code = code;
this.message = message;
}
}
枚舉類封裝了狀態碼以及信息,這樣在返回結果時,只需要傳入對應的枚舉值以及數據即可:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<String> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e)
{
ObjectError error = e.getBindingResult().getAllErrors().get(0);
return new Result<>(ResultCode.FAILED,error.getDefaultMessage());
}
@ExceptionHandler(TestException.class)
public Result<String> testExceptionHandler(TestException e)
{
return new Result<>(ResultCode.FAILED,e.getMsg());
}
}
5.3 全局包裝響應體
統一響應體是個很好的想法,但是還可以再深入一步去優化,因為每次返回之前都需要對響應體進行包裝,雖然只是一行代碼但是每個接口都需要包裝一下,這是個很麻煩的操作,為了更進一步“偷懶”,可以選擇實現ResponseBodyAdvice<T>來進行全局的響應體包裝。
修改原來的全局異常處理類如下:
@RestControllerAdvice
public class GlobalExceptionHandler implements ResponseBodyAdvice<Object> {
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<String> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e)
{
ObjectError error = e.getBindingResult().getAllErrors().get(0);
return new Result<>(ResultCode.FAILED,error.getDefaultMessage());
}
@ExceptionHandler(TestException.class)
public Result<String> testExceptionHandler(TestException e)
{
return new Result<>(ResultCode.FAILED,e.getMsg());
}
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
return !methodParameter.getParameterType().equals(Result.class);
}
@Override
public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
return new Result<>(o);
}
}
實現了ResponseBodyAdvice<Object>:
supports方法:判斷是否支持控制器返回方法類型,可以通過supports判斷哪些類型需要包裝,哪些不需要包裝直接返回beforeBodyWrite方法:當supports返回true后,對數據進行包裝,這樣在返回數據時就無需使用Result<User>手動包裝,而是直接返回User即可
接着修改控制層,直接返回實體類User而不是響應體包裝類Result<User>:
@PostMapping("test")
public User test(@RequestBody @Validated({User.Group.class}) User user)
{
return service.test(user);
}
測試輸出如下:

5.4 繞過全局包裝
雖然按照上面的方式可以使后端的數據全部按照統一的形式返回給前端,但是有時候並不是返回給前端而是返回給其他第三方,這時候不需要code以及msg等信息,只是需要數據,這樣的話,可以提供一個在方法上的注解來繞過全局的響應體包裝。
比如添加一個@NotResponseBody注解:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface NotResponseBody {
}
接着需要在處理全局包裝的類中,在supports中進行判斷:
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
return !(
methodParameter.getParameterType().equals(Result.class)
||
methodParameter.hasMethodAnnotation(NotResponseBody.class)
);
}
最后修改控制層,在需要繞過的方法上添加自定義注解@NotResponseBody即可:
@PostMapping("test")
@NotResponseBody
public User test(@RequestBody @Validated({User.Group.class}) User user)
6 總結

7 源碼
直接clone下來使用IDEA打開即可,每一次優化都做了一次提交,可以看到優化的過程,喜歡的話歡迎給個star:
8 參考
1、UncleChen的博客-SpringBoot自定義請求參數校驗
2、簡書-@Valid和@Validated的總結區分
3、博客園-@Controller與@RestController的區別
4、簡書-【項目實踐】-SpringBoot三招組合拳,手把手教你打出優雅的后端接口
5、簡書-【項目實踐】后端接口統一規范的同時,如何優雅得擴展規范
