SpringMVC体系下各组件的功能边界及重构建议
最近在重构后端代码,很多同学对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名)装配注入
这篇文章也是想到哪写到哪,不符合单一职责,有时间重构,里面的很多点可以单独成文。
上一篇: Photoshop滤镜制作砂土纹理