场景
现在流行充血领域层,在原本只存储对象的java类中,增加一些方法去替代原本写在service层的crud,
但是例如service这种一般都是托管给spring的,我们使用的ORM也都托管给spring,这样方便在service层调用mybatis的mapper、或者jpa这种和spring结合的类,使用自动注入 @Resource、@Autowired 就行了,
但是像entity、vo这种保存信息的对象,一般都是直接new的,或者从数据库中查出然后被映射出来的,以及从前端传入到接口层的,它们并没有被spring托管,
如果在他们自身中想去使用注解引入spring相关的类,则无法实现,通过SpringContext.getBean这种硬编码感觉又不大雅观。
需求
我的应用场景是从Rest接口传入的参数,例如 save接口、update接口,通过 @RequestBody 传入的对象自身能不能直接调用Spring中的Service来实现自身的CRUD?这样自身可以完成一些验证以及数据库交互操作,并且代码也内聚在自身逻辑里,不会让service中充斥过多的CRUD代码,造成阅读代码上的不方便,每个参数中有自己的逻辑,并且不会很多,读起来就会相对清晰些。
举例
例如下面的代码,我想让参数自身就可以进行对数据库的交互,而不是将params传给service,然后在service中进行处理,
需要考虑的问题就是,如何让 SaveParams 这种前端接收的参数能被spring托管,这样就可以使用spring的bean了。
/**
* 保存
* @param params
* @return
*/
@PostMapping("save")
public RestResponse save(@RequestBody @Validated SaveParams params) {
params.save();
return success();
}
/**
* 更新
* @param params
* @return
*/
@PostMapping("update")
public RestResponse update(@RequestBody @Validated UpdateParams params) {
params.update();
return success();
}
参数内部
参数内部是这个样子,但是这样肯定是不行的,用起来一定会报错,因为 SaveParams
并不属于spring的bean,
而是spring mvc的参数解析,将前端传入的参数构建成了 SaveParams
。
public class SaveParams extends Vo {
@Autowired
private Service Service;
/**
* 保存自身
*/
@Transactional
public void save() {
Service.save(this);
}
}
OK,知道问题所在,那么想让他成为spring的bean,第一步我们应该是给他头上也加上spring的注解,对吧?修改如下:
@Component
使用这个注解将类注册成spring bean的注解,为什么还要加上 @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
?
因为spring bean默认是单例模式,加上上面的Scope意味着每次都实例化创建一个新的bean,这符合我们的需求,
因为我们的接口每次收到请求都是一个全新的参数,自然不可能用单例的,每个接口都是自己的一个生命周期。
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class SaveParams extends Vo {
@Autowired
private Service Service;
/**
* 保存自身
*/
@Transactional
public void save() {
Service.save(this);
}
}
问题2
结合上面的接口代码和对params增加的注解就可以直接使用了吗?显然不是,因为接口接收参数时,并不会因为我们的参数类加上了注解就帮我们注册成一个spring的bean给到我们,
我们需要自己去做这个事情,就是在接口接收到这个参数的时候,我们需要将他变成spring的bean,我们需要做一个拦截,做一个参数的篡改。
实现将接收参数变成spring bean
如何篡改?选时机即可,就选在接口刚接收到这个参数并解析完毕的时候,
我们利用spring给我们的拦截点 RequestBodyAdvice
,在接口接收完毕参数后,检验参数是否存在 @Component
注解,
如果存在,则使用 SpringUtils.getBean
从spring容器中新创建一个bean出来,
然后将之前的参数复制到这个bean里面来,这样这个bean既拿到了参数,又拿到了spring容器中自动注入的其他bean,二者结合,这个params就可以自己玩了,
这里实体之间的复制我使用了 BeanUtils.copyProperties(o, newObject, ignoreFields.toArray(new String[]{}));
,因为老参数里面的自动注入bean一定会是null,直接用会把spring新创建的给覆盖掉,所以这里要忽略一下自动注入的属性,
这时候我们把新的bean,返回回去,在接口里,params自己调用自己的save方法就不会报错了。
@Slf4j
@ControllerAdvice
public class DDDParamAdvice implements RequestBodyAdvice {
...
@Override
public Object afterBodyRead(Object o, HttpInputMessage httpInputMessage, MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {
Object newObject = null;
try {
// 判断该对象类上是否存在 Component 注解
if (o.getClass().isAnnotationPresent(Component.class)) {
newObject = SpringUtils.getBean(o.getClass());
Field[] fields = ReflectUtil.getFields(o.getClass());
List<String> ignoreFields = new ArrayList<>();
if (ArrayUtil.isNotEmpty(fields)) {
for (Field field : fields) {
if (field.isAnnotationPresent(Autowired.class)) {
ignoreFields.add(field.getName());
}
}
}
BeanUtils.copyProperties(o, newObject, ignoreFields.toArray(new String[]{}));
}
} catch (Exception e) {
log.error("DDDParamAdvice error", e);
}
return newObject != null ? newObject : o;
}
...
}
还是有问题
到这一步虽然参数被转换成了spring中的bean,可以自己玩转了,但是并没有结束,
我在接口中使用 @Validated
验证时,发现验证不会通过,但是参数实际上是有值的,
通过排查我发现是因为spring的bean,cglib生成子类后,将属性拷贝一份到子类来,子类中的并没有值,
但是使用get方法还是可以正常获取到,具体情况如下图,看似没值,但是get其实有值,
真正的值其实被存储在 CGLIB$CALLBACK_1
中,并且可以看到Service其实也已经被注入:
如何解决
因为 @Validated
验证时机在 RequestBodyAdvice
之后,那么有没有一种办法在通过验证后,我们再将参数转成spring的bean呢?
答案当然是有,参考这篇blog:@Valid @Validated与先于AOP的执行顺序问题
使用AOP拦截controller方法,会让验证在前,AOP在后,所以我们使用AOP来替换参数为bean,而不使用 RequestBodyAdvice
就好了。
所以我们更改拦截如下:
@Component
@Aspect
public class DDDParamsAop {
@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public void aspect() {
}
@Around("aspect()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
if (joinPoint.getArgs().length == 1) {
Object o = joinPoint.getArgs()[0];
if (o.getClass().isAnnotationPresent(Component.class)) {
Object newObject = SpringUtils.getBean(o.getClass());
Field[] fields = ReflectUtil.getFields(o.getClass());
List<String> ignoreFields = new ArrayList<>();
if (ArrayUtil.isNotEmpty(fields)) {
for (Field field : fields) {
if (field.isAnnotationPresent(Autowired.class)) {
ignoreFields.add(field.getName());
}
}
}
BeanUtils.copyProperties(o, newObject, ignoreFields.toArray(new String[]{}));
joinPoint.getArgs()[0] = newObject;
return joinPoint.proceed(joinPoint.getArgs());
}
}
return joinPoint.proceed();
}
}
结束问题
通过更改篡改参数时机,我们绕过了验证器的问题,并且让我们的参数可以自身注入spring其他bean完成相应的逻辑。
结语
上面编写的代码并不完善,例如对参数的拦截点,只拦截了PostMapping, 忽略的参数只忽略了 @Autowired 注解,基本只覆盖了我自身使用的场景,
但是基于这个原理,可以自行拓展,对更多的场景进行适配,完成对Service 代码和逻辑的拆解,将独立的功能封装到各自的实体领域中,方便代码管理与阅读,并且职责清晰。