美文网首页程序员
设计之道-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