最近在重構後端代碼,很多同學對Spring體系下的後端組件如Controller、Service、Repository、Component等認識不夠清晰,導致代碼里常常會出現Controller里直接使用RestTemplate、直接訪問資料庫的情況。下麵談談我對這些組件功能邊界的認識,一家之言,歡 ...
最近在重構後端代碼,很多同學對Spring體系下的後端組件如Controller、Service、Repository、Component等認識不夠清晰,導致代碼里常常會出現Controller里直接使用RestTemplate、直接訪問資料庫的情況。下麵談談我對這些組件功能邊界的認識,一家之言,歡迎討論。
1. Controller
Controller是整個後端服務的門面,他向外暴露了可用服務。你不關心Dispatcher、HandleMapping如何作用,但你肯定關心Controller中暴露的介面的HttpMethod、URL Path等。
官方對Controller的說明:
Indicates that an annotated class is a "Controller" (e.g. a web controller).
This annotation serves as a specialization of Component,
allowing for implementation classes to be autodetected through classpath scanning.
It is typically used in combination with annotated handler methods based on the annotation.
Component的一種、會被自動掃描、與RequestMapping合用--其實並沒涉及到Controller的功能邊界的說明。
雖然官方說明沒有涉及,但大量的最佳實踐還是告訴我們,Controller只做三件事:
- 校驗輸入:PathVariable\RequestBody\RequestParam合法性校驗
- 業務邏輯:Service層代碼調用,並且只調用單個service的單個方法(儘量一行代碼搞定),複雜的業務邏輯組裝需放在service中
- 控制輸出:根據校驗、業務邏輯給出合適的response
1.1 輸入校驗
對於特殊的輸入可以用一個if搞定;對於通用輸入的校驗(如介面的授權校驗),可以通過自定義Filter或者自定義切麵完成。
自定義Filter示例:
1 @Order(1) 2 @WebFilter(filterName = "authorizationFilter", urlPatterns = "/*") 3 public class AuthorizationFilter implements Filter 4 { 5 @Override 6 public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 7 throws IOException, ServletException 8 { 9 HttpServletRequest req = (HttpServletRequest)request; 10 HttpServletResponse res = (HttpServletResponse)response; 11 String path = req.getServletPath(); 12 if (!isWhiteList(path)) 13 { 14 String token = req.getHeader(AUTHORIZATION_HEADER_NAME); 15 if (isValidate(token)) 16 { 17 res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); 18 return; 19 } 20 } 21 chain.doFilter(request, response); 22 }
自定義切麵示例:
1 @Target(ElementType.METHOD) 2 @Retention(RetentionPolicy.RUNTIME) 3 @Documented 4 public @interface AuthInfo 5 { 6 String[] authId() default {"0001"}; 7 } 8 9 @Aspect 10 @Component 11 public class AuthService 12 { 13 @Around("within(com.company.product..*) && @annotation(authInfo)") 14 public Object aroundMethod(ProceedingJoinPoint joinPoint, AuthInfo authInfo) throws Throwable 15 { 16 String[] token = authInfo.authId(); 17 if (isValidate(token)) 18 { 19 return joinPoint.proceed(); 20 } 21 HttpServletResponse response = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getResponse(); 22 response.setStatus(401); 23 return null; 24 } 25 26 private boolean isValidate(String[] tokenArray) 27 { 28 return true; 29 } 30 } 31 32 @RestController 33 public class TestController 34 { 35 @GetMapping("/test/{userId}") 36 @AuthInfo //通用校驗 37 public String test(HttpServletRequest req, @PathVariable(value = "userId") String userId) 38 { 39 if(!isValidateUser(userId)){ //個別校驗 40 throw new MyException("Illegal userId"); 41 } 42 ... ... 43 } 44 }
以上兩種都屬於AOP的應用,如果不希望Controller內包含了大量的if校驗,可以考慮用上述兩種方法抽出來。推薦使用Filter,自定義切麵會造成額外的負擔。
1.2 業務邏輯
輸入校驗完成後,到了真正處理業務邏輯的地方,推薦的做法是一行代碼搞定。
1 @RestController 2 public class TestController 3 { 4 @Autowired 5 TestService testService; 6 7 @GetMapping("/test/{userId}") 8 @AuthInfo(authId = {"token"}) 9 public ResponseEntity test(HttpServletRequest req, @PathVariable(value = "userId") String userId) 10 { 11 if (!isValidateUser(userId)) 12 { 13 throw new MyException("Illegal userId"); 14 } 15 Object result = testService.getResult(userId); 16 return ResponseEntity.ok(result); 17 } 18 }
有人會問:我的實際業務邏輯中需要調用多個service怎麼辦?我的意見是,controller中不要涉及業務邏輯組裝,組裝的工作應該新建一個Service,在這個Service中完成。
1.3 控制輸出
在上面的示例中已經涉及到了一些輸出控制:自定義ResponseEntity和拋出異常。這兩種方法可以靈活運用,自定義返回比較直接,可以很直接的返回status和消息體。
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(message);
而拋出異常則需要與ControllerAdvice相配合:在Controller中拋出的異常可被ControllerAdvice捕獲,並根據異常的內容和種類,定製不同的返回。
1 @ControllerAdvice
2 public class MyExceptionHandler
3 {
4 private static Logger logger = LoggerFactory.getLogger(MyExceptionHandler.class);
5
6 @ExceptionHandler(MyException.class)
7 public ResponseEntity<ExceptionResponse> handleMyException(HttpServletRequest request, MyException ex)
8 {
9 String message = String.format("Request to %s failed, detail: %s", getUrl(request), ex.getMessage());
10 logger.error(message);
11 HttpStatus status = getHttpStatus(ex);
12 if (ExceptionCode.PARAM_CHECK_ERROR.equals(ex.getCode()))
13 {
14 status = HttpStatus.BAD_REQUEST;
15 }
16 return generateErrorResponse(status, getMessageDetail(ex));
17 }
18
19 @ExceptionHandler(JsonMappingException.class)
20 public ResponseEntity<ExceptionResponse> handleJsonMappingException(HttpServletRequest request, Exception ex)
21 {
22 String message = String.format("Parse response failed, url: %s, detail: %s", getUrl(request), ex.getMessage());
23 logger.error(message, ex);
24 return generateErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, getMessageDetail(ex));
25 }
26
27 @ExceptionHandler(Exception.class)
28 public ResponseEntity<ExceptionResponse> handleException(HttpServletRequest request, Exception ex)
29 {
30 String message = String.format("Request to %s failed, detail: %s", getUrl(request), ex.getMessage());
31 logger.error(message, ex);
32 return generateErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, getMessageDetail(ex));
33 }
34
35 private ResponseEntity<ExceptionResponse> generateErrorResponse(HttpStatus httpStatus, String message)
36 {
37 ExceptionResponse response = new ExceptionResponse();
38 response.setCode(String.valueOf(httpStatus.value()));
39 response.setMessage(message);
40 return ResponseEntity.status(httpStatus).body(response);
41 }
42 }
ControllerAdvice需要自定義異常MyException和自定義返回ExceptionResponse配合,定製自由度比較大,各微服務之間統一格式即可。
在SpringBoot應用里,ControllerAdvice是必備的,主要原因:
- RestTemplate大量使用,RestTemplate預設的ResponseErrorHandler中,非2XX的返回一律拋出異常;
- Service或其他組件中拋出的RuntimeException易被忽略;
- 異常返回統一在ControllerAdvice中定製,避免各個程式猿在各自的Controller中返回千奇百怪的Response。
2.Service
Service是真正的業務邏輯層,這一層的功能邊界:
- 基於單一職責的原則,每一個Service只處理單一事務;
- 如果某個業務需要調用多個業務事務,建議在Service上再擴展一層,專門用於組裝各個Service的調用;
- Service層不做任何形式的持久化工作:資料庫訪問、遠程調用等。
3.Repository
微服務不贊同任何形式的狀態如緩存,在多實例下,存在於各自JVM中的緩存由於互相不感知,可能會造成多實例之間的溝通問題。這就是為什麼Eureka核心功能只是個RestTemplate的Inteceptor,缺花費了大力氣做實例間的緩存同步的原因。
持久層Repository的功能是花樣百出的持久化:
- 資料庫訪問
- 本地文件
- HTTP調用
- ... ...
可以看出,Repository層做的工作實際上是對網路上各種資源的訪問。
4.Component
Controller、Service、Repository都是繼承自Component,當你實在不好註解你的類但又希望Spring上下文去管理它時,可以暫時將其註解為Component。
個人認為出現這種尷尬問題的主要原因是因為類的功能不夠單一,只要能夠拆分重構,是可以確切的找到合適的註解的。
5.Resource
將Resource列舉在此實際是不合適的,因為Resource是JDK的註解,但使用時確實易與其他幾個註解造成混淆。
Resouce的使用場景時這樣的:
你在微服務中將User信息持久化在MySQL中,並依此寫了一個UserMySQLRepository去進行交互;
但是boss突然覺得MySQL一點也不好,希望你改成Redis的同時,保持對MySQL的支持以免有問題時能夠回退。
這樣你的微服務中就有了兩個IUserRepository的實現類:UserMySQLRepository和UserRedisRepository。
在Service中如何調用它呢,如果還是使用以前的代碼調用:
@Autowired
IUserRepository userRepo;
這樣UserMySQLRepository和UserRedisRepository是要打架的:我也是IUserRepository,憑什麼你上?
如果你這樣調用:
@Autowired
UserMySQLRepository userMSRepo;
@Autowired
UserRedisRepository userRedisRepo;
代碼的擴展性被破壞的一干二凈:你的方法中必須用額外的代碼去判斷使用哪個repository;萬一哪天boss覺得redis又不好了,難道再加一個Autowired?
這時候Resource可以閃亮登場了,最佳的實踐如下:
@Repository("mysql") public class UserMySQLRepository implements IUserRepository {} @Repository("redis") public class UserRedisRepository implements IUserRepository {} @Service public class UserService implements IUserService { @Resource(name = "${user.persistence.type}") private IUserRepository userRepo; ... ... }
在application.properties中,可以添加一個配置去控制持久層到底使用MySQL還是Redis。
user.persistence.type=redis
#user.persistence.type=mysql
如果想切回MySQL,只要將user.persistence.type的值改回mysql即可。
至於Resource可以做到而Autowired做不到的原因,網上也有很多解釋,做簡單說明:
- Resource優先按照名稱(註解中的value:mysql和redis)裝配註入,也支持按照類型
- Autowired按照類型(class名)裝配註入
這篇文章也是想到哪寫到哪,不符合單一職責,有時間重構,裡面的很多點可以單獨成文。