美文网首页程序员
设计之道-controller层的设计补遗

设计之道-controller层的设计补遗

作者: SawyerZhou | 来源:发表于2019-08-19 10:37 被阅读0次

    自从《设计之道-controller层的设计》去年发布之后,收获了许多读者朋友和同僚的欢迎和喜爱,也收到了不少的意见和建议,首先十分感谢大家的支持。同时我也一直在思考如何进一步的优化这部分代码,在这里把最近的一些优化点总结一下。主要针对两部分进行了优化,统一返回对象的封装统一的请求/响应日志打印

    首先回顾下在上一篇当中讲到的controller层主要的职责:
    1.参数校验
    2.调用service层接口实现业务逻辑
    3.转换业务/数据对象
    4.组装返回对象
    5.异常处理

    当时遗漏了一点现在补上:
    6.请求日志打印
    接下来进入正题:

    1. 统一返回对象的封装

    这一点其实在上一篇中已经讲过,就是第4点:组装返回对象。只不过当时使用的是在BaseController中封装返回方法,在业务controller中调用responseOK/responseFail方法。文章发出去不久后,天草二十六_就建议我可以使用SpringMVC的ResponseBodyAdvice接口来实现统一的返回对象封装从而进一步优化代码(感谢天草)。他山之石可以攻玉,这里就先来讲一下该接口给我们的代码带来的变化。

    首先看下ResponseBodyAdvice这个接口:

    /**
     * Allows customizing the response after the execution of an {@code @ResponseBody}
     * or a {@code ResponseEntity} controller method but before the body is written
     * with an {@code HttpMessageConverter}.
     *
     * <p>Implementations may be registered directly with
     * {@code RequestMappingHandlerAdapter} and {@code ExceptionHandlerExceptionResolver}
     * or more likely annotated with {@code @ControllerAdvice} in which case they
     * will be auto-detected by both.
     */
    public interface ResponseBodyAdvice<T> {
    
        /**
         * Whether this component supports the given controller method return type
         */
        boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
    
        /**
         * Invoked after an HttpMessageConverter is selected and just before
         * its write method is invoked.
         */
        T beforeBodyWrite(T body, MethodParameter returnType, MediaType selectedContentType,
                Class<? extends HttpMessageConverter<?>> selectedConverterType,
                ServerHttpRequest request, ServerHttpResponse response);
    
    }
    

    上面的源码由于篇幅问题,删掉了一些注释,感兴趣的同学可以自己查看代码。通过查看注释可以发现,这个接口可以让我在controller方法(需有@ResponseBody@ResponseEntity注解)的返回对象被写到HTTP response body之前做一些事情。这里的两个方法都很重要,首先supports决定了哪些controller会被该接口拦截,其次beforeBodyWrite决定了拦截之后我们的操作。另外,注意到上面注释中建议了该接口的使用方法,其中有提到可以在实现该接口的类上使用@ControllerAdvice注解,从而同时实现异常捕获返回对象的封装

    弄明白了ResponseBodyAdvice的作用,我们便可摒弃之前的BaseController,让controller直接返回DTO,通过实现beforeBodyWrite方法来做统一的返回对象封装。既然BaseController不需要了,那其中的封装方法responseOK/responseFail又该何去何从呢?我这边的做法是参照了《Effective Java》中的建议,用静态方法代替构造函数,改写了统一返回包装类HttpResult:

    public class HttpResult<T> implements Serializable {
    
        private static final long serialVersionUID = -1L;
        private boolean success;
        private T data;
        private String code;
        private String message;
    
        private HttpResult(boolean success, T data, String code, String message) {
            this.success = success;
            this.data = data;
            this.code = code;
            this.message = message;
        }
    
        private HttpResult(boolean success, T data, ResultCode resultCode) {
            this.success = success;
            this.data = data;
            this.code = resultCode.getCode();
            this.message = resultCode.getMessage();
        }
    
        /**
         * 成功返回
         */
        public static <T> HttpResult<T> ok(T data) {
            return new HttpResult<>(Boolean.TRUE, data, ResultCode.SUCCESS);
        }
    
        /**
         * 异常返回-指定错误码
         */
        public static HttpResult fail(ResultCode resultCode) {
            return new HttpResult<>(Boolean.FALSE, null, resultCode);
        }
    
        /**
         * 异常返回-非指定异常
         */
        public static HttpResult fail(String code, String message) {
            return new HttpResult<>(Boolean.FALSE, null, code, message);
        }
        
        //getter and setter
    }
    

    并将先前的统一异常处理类ExceptionAdvice改名为ResponseAdvice,并实现ResponseBodyAdvice接口:

    /**
     * @Author: Sawyer
     * @Description: 统一异常处理及返回对象封装
     * @Date: Created in 上午11:17 17/8/11
     */
    @Slf4j
    @ControllerAdvice
    public class ResponseAdvice implements ResponseBodyAdvice {
    
        @Autowired
        HttpServletRequest httpServletRequest;
    
        @Override
        public boolean supports(MethodParameter methodParameter, Class aClass) {
            return true;
        }
    
        @Override
        public Object beforeBodyWrite(Object body, MethodParameter returnType,
                                      MediaType selectedContentType,
                                      Class selectedConverterType,
                                      ServerHttpRequest request,
                                      ServerHttpResponse response) {
            //返回对象封装
            return HttpResult.ok(body);
        }
    
    
        /**
         * 异常日志记录
         */
        private void logErrorRequest(Exception e) {
            log.error("报错API URL:{}", httpServletRequest.getRequestURL().toString());
            log.error("异常:{}", e.getMessage());
        }
    
        /**
         * 参数未通过@Valid验证异常,
         */
        @ExceptionHandler(MethodArgumentNotValidException.class)
        private HttpResult methodArgumentNotValid(MethodArgumentNotValidException exception) {
            logErrorRequest(exception);
            return HttpResult.fail(ResultCode.INVALID_PARAM);
        }
    
        /**
         * 参数格式有误
         */
        @ExceptionHandler({MethodArgumentTypeMismatchException.class, HttpMessageNotReadableException.class})
        private HttpResult typeMismatch(Exception exception) {
            logErrorRequest(exception);
            return HttpResult.fail(ResultCode.MISTYPE_PARAM);
        }
    
        /**
         * 缺少参数
         */
        @ExceptionHandler(MissingServletRequestParameterException.class)
        private HttpResult missingServletRequestParameter(MissingServletRequestParameterException exception) {
            logErrorRequest(exception);
            return HttpResult.fail(ResultCode.MISSING_PARAM);
        }
    
        /**
         * 不支持的请求类型
         */
        @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
        private HttpResult httpRequestMethodNotSupported(HttpRequestMethodNotSupportedException exception) {
            logErrorRequest(exception);
            return HttpResult.fail(ResultCode.UNSUPPORTED_METHOD);
        }
    
        /**
         * 业务层异常
         */
        @ExceptionHandler(ServiceEx.class)
        private HttpResult serviceExceptionHandler(ServiceEx exception) {
            logErrorRequest(exception);
            return HttpResult.fail(ResultCode.S_SYS_UNKNOWN.getCode(), exception.getMessage());
        }
    
        /**
         * 其他异常
         */
        @ExceptionHandler({HttpClientErrorException.class, IOException.class, Exception.class})
        private HttpResult commonExceptionHandler(Exception exception) {
            logErrorRequest(exception);
            return HttpResult.fail(ResultCode.S_SYS_UNKNOWN);
        }
    }
    

    这样改造后,我们的UserController就不再需要继承BaseController及返回HttpResult对象了:

    @RestController
    @RequestMapping("/v1/user")
    public class UserController {
    
        @Autowired
        UserService userService;
    
        @PutMapping("/{id}")
        public UserDTO updateUser(@PathVariable("id") Integer id, @Valid @RequestBody UserDTO userDTO) throws Exception {
            return UserDTO.convert(userService.updateUser(id, userDTO));
        }
    }
    

    这样,我们就完成了统一返回对象封装的优化。有的同学要问了,你这儿明明没有指定@ResponseBody,为啥也能被拦截呢?这里暴露了很多同学写代码的一个问题:无意识地写一些多余的代码。仔细看@RestController的源码,其实其中已经包含了@ResponseBody注解了:

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Controller
    @ResponseBody
    public @interface RestController {
    
        String value() default "";
    
    }
    

    2. 统一的请求/响应日志打印

    对于开发过web应用的同学来说,日志的重要性相信是不言而喻的。特别是排查问题的时候,如果如果少打了请求/响应日志,那查问题的难度简直一下子就上升了几个等级。那我们该如何做才能最简便呢?其实看了前一部分,你一定能想到可以在实现beforeBodyWrite接口的同时做日志打印,毕竟参数里都有ServerHttpRequestServerHttpResponse了嘛。但这有一个问题,这个方法是在responseBody被写入之前执行的,但如果controller本身就已经报错了,这个方法是不会被执行的,这个时候日志也就不会被打印了。

    其实对于ResponseBodyAdvice来说,还有一个对应的RequestBodyAdvice(这里就不展开了,感兴趣的同学可以自行研究),似乎可以在beforeBodyRead中打印请求日志,在beforeBodyWrite中打印正常返回日志,在@ExceptionHandler中打印异常返回日志。这个方案的确可行,但会有一个问题,这里卖个关子暂且不表,先来看我所采用的方法:单独创建一个切面来做统一的日志打印:

    /**
     * @Author: Sawyer
     * @Description: 请求日志切面
     * @Date: Created in 3:07 PM 2019/8/15
     */
    @Slf4j
    @Aspect
    @Component
    public class RequestLogAspect {
    
        @Autowired
        HttpServletRequest request;
    
        @Around("execution(* com.sawyer.api.controller..*.*(..))")
        public Object around(final ProceedingJoinPoint joinPoint) throws Throwable {
    
            log.info("请求url:{}", request.getRequestURL().toString());
    
            ObjectMapper mapper = new ObjectMapper();
            log.info("请求参数:{}", mapper.writeValueAsString(joinPoint.getArgs()));
    
            Object result = joinPoint.proceed();
            log.info("请求返回:{}", mapper.writeValueAsString(result));
    
            return result;
        }
    }
    
    

    这里稍微讲一下AOP表达式"execution(* com.sawyer.api.controller..*.*(..))"的含义:

    • execution()表示是最常用的切点函数,表示切面作用于方法执行时;
    • 第一个*表示不限制返回类型;
    • controller后面的..表示要拦截的包路径包含controller目录及其所有子目录;
    • 第二个*表示不限类名;
    • 第三个*表示不限方法名;
      -(..)表示不限参数;
    • @Around表示该切面的类型是包围类型;
      故总体的含义为:在com.sawyer.api.controller包下所有的类的所有方法的执行前后进行拦截。

    通过定义这样一个切面,我们就可以在controller的方法被调用前打印请求日志,被调用后打印响应日志。当然,在抛出异常的情况,日志还是打印在@ExceptionHandler里的。这个做法和之前的方法相比,有什么特别的好处吗?这里就要讲到刚刚卖的关子。

    真正在生产中,我们往往会遇到一个问题,就是有些接口的日志我们并不想打印出来。特别是一些批量查询接口的响应结果,一打就一堆,如果调用频繁,就可能会造成大量空间的浪费,也不方便日志的排查。那我们就需要针对不同的类,甚至方法进行区别对待。对于不同类,自定义切面和@ControllerAdvice都可以解决,对于AOP来说可以在表达式里使用'||'或者'or'来指定多个连接点,而@ControllerAdvice则可以用basePackages数组来指定多个类。但是如果同一个类中不同的方法有不同的日志需求,那@ControllerAdvice就爱莫能助了。不过,我们真的需要在切点表达式中维护那么复杂的又无聊的关系吗?有更好的做法吗?当然有。

    这里我的做法创建了一个自定义注解@LessLog用来指定是否要打日志、打什么日志。然后通过切面中的joinPoint及java反射机制来获取到方法上的注解,从而影响日志的行为,直接看代码:

    首先是忽略的日志内容,主要有url日志、请求日志、响应日志、全部忽略和全部不忽略这5种:

    /**
     * @Author: Sawyer
     * @Description: 忽略的日志类型
     * @Date: Created in 3:56 PM 2019/8/14
     */
    
    public enum LogType {
    
        /**
         * 请求url
         */
        URL,
    
        /**
         * 请求
         */
        REQUEST,
    
        /**
         * 返回
         */
        RESPONSE,
    
        /**
         * 全部
         */
        ALL,
    
        /**
         * 无
         */
        NONE
    }
    

    然后是注解@LessLog本身,这里制定了一个type参数,用来指定忽略的日志内容,默认是全部不忽略:

    /**
     * @Author: Sawyer
     * @Description: 忽略日志的注解
     * @Date: Created in 2:40 PM 2019/8/14
     */
    
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface LessLog {
        /**
         * 默认不忽略日志
         *
         * @return
         */
        LogType type() default LogType.NONE;
    }
    
    

    最后是改写我们的RequestLogAspect,利用反射机制获取到controller方法上的LessLog注解实例,并根据其type参数决定具体忽略的日志内容:

    @Slf4j
    @Aspect
    @Component
    public class RequestLogAspect {
    
        @Autowired
        HttpServletRequest request;
    
        @Around("execution(* com.yst.nfsq.vem.api.controller..*.*(..))")
        public Object around(final ProceedingJoinPoint joinPoint) throws Throwable {
    
            boolean urlLogRequired = Boolean.TRUE;
            boolean requestLogRequired = Boolean.TRUE;
            boolean responseLogRequired = Boolean.TRUE;
    
            Class<?> clazz = joinPoint.getTarget().getClass();
            String methodName = joinPoint.getSignature().getName();
            Class<?>[] args = ((MethodSignature) joinPoint.getSignature()).getParameterTypes();
            Method method = clazz.getMethod(methodName, args);
    
            if (method.isAnnotationPresent(LessLog.class)) {
                //减少日志的注解
                LessLog lessLog = method.getAnnotation(LessLog.class);
                LogType logType = lessLog.type();
                switch (logType) {
                    case URL:
                        urlLogRequired = Boolean.FALSE;
                        break;
                    case REQUEST:
                        requestLogRequired = Boolean.FALSE;
                        break;
                    case RESPONSE:
                        responseLogRequired = Boolean.FALSE;
                        break;
                    case ALL:
                        urlLogRequired = Boolean.FALSE;
                        requestLogRequired = Boolean.FALSE;
                        responseLogRequired = Boolean.FALSE;
                        break;
                    default:
                }
            }
            //url日志
            if (urlLogRequired) {
                log.info("请求url:{}", request.getRequestURL().toString());
            }
    
            ObjectMapper mapper = new ObjectMapper();
            //请求日志
            if (requestLogRequired) {
                log.info("请求参数:{}", mapper.writeValueAsString(joinPoint.getArgs()));
            }
            Object result = joinPoint.proceed();
            //响应日志
            if (responseLogRequired) {
                log.info("请求返回:{}", mapper.writeValueAsString(result));
            }
    
            return result;
        }
    }
    

    这样,我们就可以在具体的发放上使用@LessLog注解来控制日志打印的内容了,比如下面的方法就不会打印响应日志:

    @RestController
    @RequestMapping("/v1/user")
    public class UserController {
    
        @Autowired
        UserService userService;
    
        //不打印响应日志
        @LessLog(type = LogType.RESPONSE)
        @PutMapping("/{id}")
        public UserDTO updateUser(@PathVariable("id") Integer id, @Valid @RequestBody UserDTO userDTO) throws Exception {
            return UserDTO.convert(userService.updateUser(id, userDTO));
        }
    }
    

    写到这里,结合上一篇,我们已经完成了controller层的所有任务,再来回顾一下:
    1.参数校验
    2.调用service层接口实现业务逻辑
    3.转换业务/数据对象
    4.组装返回对象
    5.异常处理
    6.请求日志打印

    这篇文章中用到的技术包括AOP、反射、注解等其实大家都耳熟能详,但很多时候都只是只知其然而不知其所以然。我还是鼓励大家在写代码时多加思考,创造机会使用这些技术,而非一味地照搬安全的老代码,从而丧失了使自己技术精进和代码更优雅的机会。

    相关文章

      网友评论

        本文标题:设计之道-controller层的设计补遗

        本文链接:https://www.haomeiwen.com/subject/mglfsctx.html