SpringMVC体系下各组件的功能边界及重构建议

2018-09-01 05:41:35来源:博客园 阅读 ()

新老客户大回馈,云服务器低至5折

最近在重构后端代码,很多同学对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名)装配注入

这篇文章也是想到哪写到哪,不符合单一职责,有时间重构,里面的很多点可以单独成文。

 

标签:

版权申明:本站文章部分自网络,如有侵权,请联系:west999com@outlook.com
特别注意:本站所有转载文章言论不代表本站观点,本站所提供的摄影照片,插画,设计作品,如需使用,请与原作者联系,版权归原作者所有

上一篇:生产者与消费者-1:N-基于list

下一篇:今日头条面试题——LRU原理和Redis实现