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中定義組:
這樣就能按照固定的順序進行引數校驗了,
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:
- Github
- 碼云
作者:氷泠
鏈接:https://juejin.im/post/6860404263143604232
來源:掘金
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/208910.html
標籤:其他
上一篇:假設認識Java的第二天
下一篇:自增變數--自增自減運算子
