SpringBoot到底該不該用統(tǒng)一包裝類詳解
在SpringBoot項(xiàng)目中,你一定見過這樣的代碼:
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
return Result.success(userService.getById(id));
}
或者這樣的:
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
return userService.getById(id);
}
支持統(tǒng)一包裝的人認(rèn)為這樣做規(guī)范、統(tǒng)一,前端對接方便;反對的人則認(rèn)為這是多此一舉,增加了代碼復(fù)雜度,而且HTTP本身就有狀態(tài)碼體系,沒必要重新發(fā)明輪子。
今天從實(shí)際使用場景出發(fā),把這個問題梳理清楚,希望能給你一些參考。
先說清楚什么是統(tǒng)一包裝類
所謂統(tǒng)一包裝類,就是將業(yè)務(wù)數(shù)據(jù)再包一層,常見形態(tài)如下:
// 形態(tài)一:code + msg + data
public class Result<T> {
private Integer code;
private String msg;
private T data;
}
// 形態(tài)二:帶上時間戳、traceId等
public class Response<T> {
private Integer code;
private String msg;
private T data;
private Long timestamp;
private String traceId;
}
這樣包裝的理由主要有三個:
第一,前端解析方便。所有接口返回結(jié)構(gòu)一致,前端只需要寫一套解析邏輯,不需要每個接口單獨(dú)處理。
第二,可以攜帶業(yè)務(wù)錯誤碼。比如"用戶不存在"對應(yīng)10001,"余額不足"對應(yīng)10002,"參數(shù)校驗(yàn)失敗"對應(yīng)10003,前端可以根據(jù)不同的錯誤碼做不同的處理,比如10002直接跳轉(zhuǎn)到充值頁面。
第三,方便統(tǒng)一做異常轉(zhuǎn)換。通過 @ControllerAdvice 可以把所有異常統(tǒng)一轉(zhuǎn)換成 Result 格式,避免異常信息直接暴露給前端。
上面主要介紹了包裝類的一些特點(diǎn),下面我們再看看包裝及不包裝的三種常見方案的具體實(shí)現(xiàn)方式和優(yōu)缺點(diǎn)。
三種常見方案
方案一:手動包裝
每個接口自己動手包一層:
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
User user = userService.getById(id);
return Result.success(user);
}
@PostMapping("/user")
public Result<Long> createUser(@RequestBody UserCreateDTO dto) {
Long userId = userService.create(dto);
return Result.success(userId);
}
這種方式的好處是明確、可控。你在代碼里一眼就能看出這個接口返回的是什么,想搞特殊也很方便,直接返回 ResponseEntity 就行。
但寫多了你會覺得很繁瑣,滿屏都是 Result.success()、Result.error(),改起來也很麻煩。而且這種方式有個更大的問題:某些場景根本無法包裝。
最典型的就是文件下載。文件下載需要設(shè)置 Content-Disposition 響應(yīng)頭,指定文件名,還需要設(shè)置正確的 Content-Type,如果用 Result 包一下,這些都沒法處理了:
@GetMapping("/download")
public ResponseEntity<ByteArrayResource> download() {
byte[] data = fileService.getReport();
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=report.xlsx")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(new ByteArrayResource(data));
}
ResponseEntity 需要直接返回,無法塞進(jìn) Result 里。類似的場景還有 SSE 推送、文件流、圖片直接輸出等。
所以如果采用手動包裝的方案,你還需要在文檔里明確約定哪些接口需要特殊處理,增加維護(hù)成本。
方案二:不包裝,直接返回
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
return userService.getById(id);
}
@PostMapping("/user")
public Long createUser(@RequestBody UserCreateDTO dto) {
return userService.create(dto);
}
這種方式代碼簡潔,沒有冗余,通用性好(比如一些負(fù)載、網(wǎng)關(guān)設(shè)備默認(rèn)識別的是標(biāo)準(zhǔn)HTTP狀態(tài)碼),Swagger 生成的文檔也很清晰,前端一眼就能看出這個接口返回什么結(jié)構(gòu)。
異常處理怎么解決呢?通過 @ControllerAdvice + @ExceptionHandler:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
return ResponseEntity
.status(e.getHttpStatus()) // 404、400等HTTP狀態(tài)碼
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnknown(Exception e) {
log.error("系統(tǒng)異常", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("系統(tǒng)繁忙,請稍后重試"));
}
}
這種方式直接使用 HTTP 標(biāo)準(zhǔn)狀態(tài)碼:200 表示成功,404 表示資源不存在,400 表示參數(shù)錯誤,401 表示未登錄,403 表示無權(quán)限,500 表示服務(wù)器錯誤。不需要再定義一套業(yè)務(wù)錯誤碼,前端也更容易理解。
不過有些團(tuán)隊(duì)習(xí)慣了自己定義錯誤碼體系,比如 10001、10002 這種,覺得 HTTP 狀態(tài)碼不夠細(xì)分。其實(shí)大可不必,HTTP 狀態(tài)碼已經(jīng)足夠覆蓋大部分場景了,真需要細(xì)分可以通過錯誤消息來區(qū)分。
接受這種方式的前提是團(tuán)隊(duì)統(tǒng)一認(rèn)知,不再搞自定義錯誤碼那套。如果團(tuán)隊(duì)已經(jīng)有一套成熟的錯誤碼體系,遷移成本會比較高。
方案三:ResponseBodyAdvice自動包裝
這是一種折中方案,Controller 代碼保持簡潔,由框架自動包裝:
@RestControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return !returnType.hasMethodAnnotation(NoWrap.class);
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType,
MediaType selectedContentType, Class selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof Result) {
return body;
}
return Result.success(body);
}
}
Controller 里就可以直接寫:
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
return userService.getById(id); // 被自動包裝成 Result<User>
}
這個方案看起來很完美,Controller 代碼簡潔,返回格式又統(tǒng)一。但實(shí)際用起來有幾個坑需要特別注意。
第一個坑是 String 類型的特殊處理。Spring 的消息轉(zhuǎn)換器鏈在處理 String 時,會優(yōu)先使用 StringHttpMessageConverter,如果我們返回 Result<String>,會導(dǎo)致類型轉(zhuǎn)換異常。所以需要單獨(dú)判斷:
if (body instanceof String) {
return objectMapper.writeValueAsString(Result.success(body));
}
第二個坑是調(diào)試不直觀。前端收到數(shù)據(jù)格式不對,你第一反應(yīng)是看 Controller 代碼,但代碼明明寫得很正常啊,最后排查半天才發(fā)現(xiàn)是 ResponseAdvice 里的邏輯出了問題。這種隱式的包裝,對不熟悉代碼的人來說就是個黑盒,調(diào)試成本較高。
第三個坑是特殊返回類型需要排除。ResponseEntity、SseEmitter、StreamingResponseBody 這些類型不能被包裝,否則就廢了。你需要在 supports() 方法里把這些類型排除掉,或者定義一個 @NoWrap 注解,需要例外的接口自己標(biāo)注。
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
// 排除 ResponseEntity
if (ResponseEntity.class.isAssignableFrom(returnType.getParameterType())) {
return false;
}
// 排除標(biāo)注了 @NoWrap 的方法
return !returnType.hasMethodAnnotation(NoWrap.class);
}
按場景選擇
三種方案沒有絕對優(yōu)劣,關(guān)鍵是根據(jù)場景選擇。
| 場景 | 建議方案 | 理由 |
|---|---|---|
| 內(nèi)部前后端對接接口 | 統(tǒng)一包裝 | 前端解析省心,只需一套邏輯 |
| 對外開放 RESTful API | 直接返回 | HTTP 狀態(tài)碼語義更清晰,符合REST規(guī)范 |
| 文件下載/流式接口 | 直接返回 | 無法包裝,必須直接控制響應(yīng) |
| 第三方回調(diào)接口 | 按對方要求 | 對方規(guī)定什么格式就返回什么格式 |
具體項(xiàng)目中可能會出現(xiàn)多種方案混用的情況,可以通過三種方式區(qū)分是否返回包裝對象:
方式一:按接口路徑
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
String path = getPath();
// 只有 /api 開頭的內(nèi)部接口才包裝
return path != null && path.startsWith("/api/");
}
約定 /api 開頭的是內(nèi)部接口,自動包裝;其他路徑保持原樣。這種方式簡單粗暴,不需要改動任何業(yè)務(wù)代碼,但需要團(tuán)隊(duì)遵守路徑規(guī)范。
方式二:按注解
@GetMapping("/download")
@NoWrap
public ResponseEntity<ByteArrayResource> download() {
// ...
}
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
// 默認(rèn)包裝
}
定義一個 @NoWrap 注解,需要例外的接口標(biāo)注一下。這種方式靈活,但缺點(diǎn)是容易漏,每次新增特殊接口都得記得加注解。
方式三:按返回類型
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
Class<?> type = returnType.getParameterType();
// ResponseEntity、SseEmitter 等類型不包裝
if (ResponseEntity.class.isAssignableFrom(type) ||
SseEmitter.class.isAssignableFrom(type) ||
StreamingResponseBody.class.isAssignableFrom(type)) {
return false;
}
return true;
}
這種方式最省心,不用記路徑規(guī)范也不用在每個接口上加注解,框架自動識別特殊類型。但前提是你的代碼風(fēng)格要統(tǒng)一,不要一會兒用 ResponseEntity,一會兒用 Result。
總結(jié)
無論選哪種方案,關(guān)鍵是規(guī)則清晰并執(zhí)行到位。前端對接的成本,很大程度上取決于能不能把規(guī)則說清楚并堅持執(zhí)行下去。
到此這篇關(guān)于SpringBoot到底該不該用統(tǒng)一包裝類的文章就介紹到這了,更多相關(guān)SpringBoot統(tǒng)一包裝類內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Springboot工具類ReflectionUtils使用教程
這篇文章主要介紹了Springboot內(nèi)置的工具類之ReflectionUtils的使用,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)吧2022-12-12
IDEA 2021.1 操作SVN 最新超詳細(xì)教程(圖文)
本教程將通過idea從svn服務(wù)器中的任意一個分支檢出代碼(本文采用branches),然后再idea中創(chuàng)建新的分支、提交代碼、拉取代碼、合并分支等操作進(jìn)行一一記錄,暫不包含代碼合并,對idea2021.1操作svn相關(guān)知識感興趣的朋友一起學(xué)習(xí)下吧2021-05-05
Java動態(tài)初始化數(shù)組,元素默認(rèn)值規(guī)則詳解
動態(tài)初始化數(shù)組涉及先定義數(shù)組長度,后填充具體數(shù)據(jù),適用于數(shù)據(jù)量已知但具體值未定的情況,這種初始化方式允許程序運(yùn)行過程中賦值,并會根據(jù)數(shù)據(jù)類型設(shè)定默認(rèn)值,如整型為0,字符串為null,動態(tài)初始化與靜態(tài)初始化格式不能混用2024-10-10
EntityWrapper如何在and條件中嵌套o(hù)r語句
這篇文章主要介紹了EntityWrapper如何在and條件中嵌套o(hù)r語句,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-03-03
jpa實(shí)現(xiàn)多對多的屬性時查詢的兩種方法
這篇文章主要介紹了jpa實(shí)現(xiàn)多對多的屬性時查詢的兩種方法,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-11-11
基于application和bootstrap的加載順序及區(qū)別說明
這篇文章主要介紹了application和bootstrap的加載順序及區(qū)別說明,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-07-07

