作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
和我一样,大部分野生程序员对日志的认识总是不够深刻,甚至觉得这玩意可有可无。关于日志,我印象最深的是在软件大道的一场面试。
面试官问了很多Java基础相关的问题,诸如反射、注解、动态代理以及MySQL优化相关的。看我答得还算中规中矩,面试官换了个方向,打算考察我解决问题的能力:
如果现在线上出了一个bug,你要怎么排查呢?
我当时脱口而出:
先看看对应的是哪个接口,然后打个断点观察参数顺便走读一遍代码。
现在想想,当时的自己听到这么简单的问题,觉得捡了一个大便宜,甚至还暗暗佩服自己考虑得很周到:要先推断出对应的接口。
不出所料,听完后面试官微笑地对我说:门口在那边。
我在第一家公司写代码时,从来没看过任何同事打日志,大家都是本地开发时简单粗暴地使用System.out.println()打印想看的信息,或者直接全程断点调试。加上系统本身用户量小,如果本地运行成功,那么线上大概率也不会有什么问题,即使有问题也可以直接本地启动断点排查。
但对于一些稍微有点规模的系统而言,这种方法完全行不通:
较为妥当的解决办法是:打日志、看日志。可以登录服务器看log,或者有些公司的运维会搭建专门的ELK日志系统,可以在页面上选择对应的机器并配合Lucene语句等进行日志查看。
AOP相信大家并不陌生,毕竟Spring除了IOC,最被人津津乐道的就是AOP。要利用AOP完成一个需求,通常包含以下几个部分:
比如,现在我希望编写一个AOP,计算接口请求的耗时:
org.springframework.boot
spring-boot-starter-aop
@Slf4j
@Aspect // 第一步:声明这是一个切面类
public class ApiTimeLogAspect {
// 第三步:定义切点表达式,明确要对那些方法起作用(比如,只对com.bravo.demo.controller包的方法计算接口耗时)
@Pointcut("execution(* com.bravo.demo.controller.*.*(..))")
public void controllerPointcut() {
}
// 第四步:1.通过引用切点表达式,明确这个增强的应用规则。 2.编写增强逻辑
@Around("controllerPointcut()")
public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
// 记录接口执行前的时间戳
long startTime = System.currentTimeMillis();
// 实际执行目标方法,类似动态代理的invoke()执行目标方法
Object result = proceedingJoinPoint.proceed();
// 计算接口耗时
log.info("------------ 耗时: {} ms ------------", System.currentTimeMillis() - startTime);
// 只做增强不做改变,还是要把接口原本的结果返回
return result;
}
}
你会发现少了第二步:把切面类交给Spring容器管理。
直接在ApiTimeLogAspect上加@Component是最省事的,但我个人喜欢另外封装一个@EnableXxx注解,单独作为开关:
/**
* 开启接口耗时计算
*/
@Import(ApiTimeLogAspect.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface EnableApiTimeLog {
}
这个注解最重要的其实是@Import注解,它是Spring定义的注解,由Spring读取并执行:把对应的Bean实例化并加载到容器。所以
这三种形式本质是一样的,都是要把Bean交给Spring管理。
现在,我把@EnableApiTimeLog加在启动类上,开启AOP:
@EnableApiTimeLog
@MapperScan("com.bravo.demo.dao")
@SpringBootApplication
public class MybatisPlusDemoApplication {
public static void main(String[] args) {
SpringApplication.run(MybatisPlusDemoApplication.class, args);
}
}
一般来说,配合@Slf4j我们会在一些关键代码里打印日志:
@Override
public ApiResultTO getUser(UserDTO userDTO) {
try {
userDTO.setUid(AirborneContext.getContext().getUid());
return userService.getUser(userDTO);
} catch (Exception e) {
log.error("getUser接口错误, userDTO:{},错误信息:{}", JSON.toJSONString(userDTO), JSON.toJSONString(e));
return ApiResultTO.buildFailed(ErrorCodeEnum.ERR_INTEGRATION_ERROR.getMessage());
}
}
但这个是局部的、特殊的日志,如果出错的接口刚好没有任何日志,线上出现问题时基本就是“抓瞎”,所以全局的、一般的日志记录很有必要。
通常来说,我们只关心一下几点:
/**
* 参数日志切面,用于打印请求参数、返回值、接口耗时
*/
@Slf4j
@Aspect
public class ApiLogAspect {
// request获取的方式:1.自动注入 2.通过RequestContextHolder
// @Autowired
// private HttpServletRequest request;
/**
* SpringMVC自带的jackson,用来输出JSON
*/
@Autowired
private ObjectMapper objectMapper;
/**
* 如果你拷贝这个切面,只需要修改切点表达式
*/
@Pointcut("execution(* com.bravo.demo.controller.*.*(..))")
public void controllerPointcut() {
}
@Before("controllerPointcut()")
public void doBefore(JoinPoint joinPoint) throws JsonProcessingException {
// 通过Spring提供的请求上下文工具,获取request
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
// signature是方法签名,可以理解为对方法信息的封装
Signature signature = joinPoint.getSignature();
String methodName = signature.getName();
// 根据signature判断当前请求属于哪类操作,前提是工程遵守标准的命名规范
String operation = "";
if (methodName.startsWith("list") || methodName.startsWith("query") || methodName.startsWith("get") || methodName.startsWith("select")) {
operation = "查询";
} else if (methodName.startsWith("save") || methodName.startsWith("add") || methodName.startsWith("insert")) {
operation = "新增";
} else if (methodName.startsWith("delete")) {
operation = "删除";
} else if (methodName.startsWith("update") || methodName.startsWith("modify")) {
operation = "更新";
}
Class> controllerClazz = signature.getDeclaringType();
// com.bravo.demo.controller.UserController
String controllerName = controllerClazz.getName();
// UserController
String controllerSimpleName = controllerName.substring(controllerName.lastIndexOf(".") + 1);
// 打印请求信息
log.info("------------【{}】{}操作 ------------", controllerSimpleName, operation);
log.info("接口URL: {} {}", request.getRequestURL().toString(), request.getMethod());
log.info("类名方法: {}#{}()", controllerName, methodName);
log.info("远程地址: {}", getClientIp(request));
// 打印请求参数 TODO 敏感字段排除
log.info("请求参数: {}", getParamJSon(request, joinPoint));
}
@Around("controllerPointcut()")
public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
Object result = proceedingJoinPoint.proceed();
// TODO 敏感字段排除
log.info("返回结果: {}", objectMapper.writeValueAsString(result));
log.info("------------ 耗时: {} ms ------------", System.currentTimeMillis() - startTime);
return result;
}
// --------- private methods ----------
private String getParamJSon(HttpServletRequest request, JoinPoint joinPoint) throws JsonProcessingException {
String requestType = request.getMethod();
if ("GET".equals(requestType)) {
// 如果是GET请求,直接返回QueryString
return request.getQueryString();
}
// args长什么样呢?类似于Controller方法中的getUser(Long id, HttpServletRequest request)这种,可能有多个参数
Object[] args = joinPoint.getArgs();
Object[] arguments = new Object[args.length];
for (int i = 0; i < args.length; i++) {
// 只打印客户端传递的参数,排除Spring注入的参数,比如HttpServletRequest
if (args[i] instanceof ServletRequest
|| args[i] instanceof ServletResponse
|| args[i] instanceof MultipartFile) {
continue;
}
arguments[i] = args[i];
}
return objectMapper.writeValueAsString(arguments);
}
private String getClientIp(HttpServletRequest request) {
// 一般都会有代理转发,真实的ip会放在X-Forwarded-For
String xff = request.getHeader("X-Forwarded-For");
if (xff == null) {
return request.getRemoteAddr();
} else {
return xff.contains(",") ? xff.split(",")[0] : xff;
}
}
}
/**
* 开启参数日志打印
*/
@Import(ApiLogAspect.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface EnableApiLog {
}
@EnableApiLog
@MapperScan("com.bravo.demo.dao")
@SpringBootApplication
public class MybatisPlusDemoApplication {
public static void main(String[] args) {
SpringApplication.run(MybatisPlusDemoApplication.class, args);
}
}
测试:
@Slf4j
@Validated
@RestController
public class UserController {
@GetMapping("getUser")
public MpUserPojo getUser(@NotNull(message = "id不能为空") Long id) {
test();
return null;
}
private void test(){
System.out.println("test");
}
@PostMapping("updateBatchUser")
public Result updateBatchUser(@Validated @RequestBody ValidationList userList) {
System.out.println(userList);
return Result.success(null);
}
}
接口内部调用private方法不会走AOP。
如果你觉得切点表达式细粒度不够,可以试着改成注解的方式。
比如:
/**
* 在需要记录接口请求信息的类或方法上添加
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface ApiLog {
}
修改ApiLogAspect的切点表达式:
/**
* 对所有标记了@ApiLog的方法进行AOP日志记录
*/
@Pointcut("@annotation(com.bravo.demo.aspect.ApiLog)")
public void controllerPointcut() {
}
@Slf4j
@RestController
public class UserController {
@GetMapping("getUser")
public MpUserPojo getUser(@NotNull(message = "id不能为空") Long id) {
test();
return null;
}
private void test() {
System.out.println("test");
}
/**
* 给需要打印日志的接口方法加@ApiLog
*
* @param userList
* @return
*/
@ApiLog
@PostMapping("updateBatchUser")
public Result updateBatchUser(@Validated @RequestBody ValidationList userList) {
return Result.success(null);
}
}
如果你希望更灵活一些,比如在Controller上加@ApiLog对所有方法记录日志的同时,为个别方法增加@IngoreApiLog排除日志,可以这样:
/**
* 忽略日志
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface IgnoreApiLog {
}
/**
* 类或方法上有@ApiLog && 没有@IgnoreApiLog
*/
@Pointcut("!@annotation(com.bravo.demo.aspect.IgnoreApiLog) " +
"&& (@within(com.bravo.demo.aspect.ApiLog) || @annotation(com.bravo.demo.aspect.ApiLog))")
public void controllerPointcut() {
}
/**
* Controller下所有方法都打印日志
*/
@Slf4j
@ApiLog
@RestController
public class UserController {
@GetMapping("getUser")
public MpUserPojo getUser(@NotNull(message = "id不能为空") Long id) {
test();
return null;
}
private void test() {
System.out.println("test");
}
/**
* 不打印日志
*
* @param userList
* @return
*/
@IgnoreApiLog
@PostMapping("updateBatchUser")
public Result updateBatchUser(@Validated @RequestBody ValidationList userList) {
return Result.success(null);
}
}
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬