自从《设计之道-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
接口的同时做日志打印,毕竟参数里都有ServerHttpRequest
和ServerHttpResponse
了嘛。但这有一个问题,这个方法是在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、反射、注解等其实大家都耳熟能详,但很多时候都只是只知其然而不知其所以然。我还是鼓励大家在写代码时多加思考,创造机会使用这些技术,而非一味地照搬安全的老代码,从而丧失了使自己技术精进和代码更优雅的机会。
网友评论