在开发项目过程中查看日志是解决问题的关键,尤其是线上项目,我们不可能连接线上打断点的形式来查找问题,打印日志方式非常重要了,一个项目,好的打印日志方式就那几种,但是差的打印方式有千万种【也就是打印和没有打印没有什么区别】,记得曾经经历过一段日志打印不好的痛苦经历,我们做了一个 APP,用户20万,有时候,程序出现 bug,用户数据不对,但又找不到问题,怎么办,先修改用户密码,登陆 app,看一下,确实金额错了,再将用户密码改回来,开发再查找问题,大家不要笑,真的,曾经我们就是这样排查问题的,这样做确实能找到问题,但是也带来巨大风险,有一次,一个测试小伙伴为了查找用户数据问题,update用户密码时,忘记加 where条件了,将20万用户的密码都改成了 admin,这事情大了,幸好数据库放在阿里云,没有办法,停机几个小时,将用户密码改回来,当然,这个事情被处罚了,处罚是小,但对用户的影响是无法估量的,自从此事之后,我对日志打印这一块格外重视了,曾经因为开发的经验不足,才弄出这么大事情,这件事情一直在我内心深处留下深刻的阴影,多年过去,一直心有余悸。
经过几年的沉淀,在工作中不断思考,总结了一套日志打印方式方法,今天写成博客,希望对大家排查问题有所帮助。
在现实中,很多有责任的开发会在方法请求开始和结束打印请求参数和返回值。
可能有人会笑,我们公司才不是这样打印的呢?真的,大家不要笑,我在之前一个大项目组中,有10多个开发,他们就是这样打印日志的,这样带来两个问题。
1.如果有些比较懒的开发,在写 Controller 方法时,没有打印请求参数和返回参数,那怎么办,当真正上线后,发现问题,无法找到,需先在项目中加好日志,再发布,再找问题,以前我也有过这样痛苦经历。
2.先来看打印效果:
这样打印也带来一个问题,请求日志和返回日志没在一行上,当线上访问量大时,无法找到请求参数和返回参数对应关系,同样给我们查找问题带来麻烦。
有人可能会想了,那好了,我将请求参数和返回参数打印在一行,这样总好了吧。效果如下
请求参数和返回参数在一行显示了,但聪明的读者有没有发现问题,假如方法在执行过程中抛出异常,是不是请求参数就无法打印了。当前的情况显然让我们进入进退两难的境地,那怎么办呢?又有聪明的读者会发现,那用Spring AOP不就解决此问题了吗,你说的没错,就是用 Spring AOP 来解决此类问题。那下面我们来看 Spring AOP 如何编写.
@Aspect @Component public class LogAspect { private Logger logger = LoggerFactory.getLogger(getClass()); //对项目中所有的 controller进行拦截 @Pointcut(value = "execution(* com..controller..*.*(..))") public void pointCut() { } @Around("pointCut()") public Object around(ProceedingJoinPoint point) throws Throwable { String logNo = OrderUtil.getUserPoolOrder("tr"); long start = System.currentTimeMillis(); Object result = null; String uri = ""; StringBuilder cm = new StringBuilder(); result = null; Object arg = result; String ip = ""; String m = ""; // 手机号可以从请求头中获取token,再从 redis中获取用户手机号码 String userName = "1845819xxxx"; String params = ""; try { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); Object[] args = point.getArgs(); List
目标方法。
这种方式,我们终于在不修改目标方法的情况下,打印了方法请求参数,返回值,同时还附加了 uri ,method, 来源 ip, 还有最重要的用户信息,前端请求过来的参数中肯定带了 token 信息,我们可以使用 token取出用户登陆信息,再将用户信息加到日志中,这样,就可以明确的知道这条日志属于哪一个用户了,更加方便查找问题。聪明的读者肯定会想,日志打印本来就应该如此嘛,没有什么好说的。但是细心的读者有没有发现问题,在查看线上日志过程中,怎样将方法内部用户打印的日志串联起来呢?如在方法内部,我打印了一行用户利息的日志,又打印了一条用户余额的日志,如果线上用户量大的情况,这两条日志可能相差千万行,当然可以在每条日志内部加上 userName,在服务器中查找时通过 cat all.log | grep “userName” 即可找到用户相关的日志,但是我又不想每次在日志内部加上用户userName信息,还有,如果开发者忘记加 userName,是不是问题也没法找了,即使关键信息的日志打印都加上了 userName,当同一个用户多次请求时,是不是查询出来的日志也是一大片,无法区分某次请求的日志信息。那么怎样解决呢?
我们知道切面日志中肯定有 userName信息,假如每次请求生成一个串联日志的 id【orderId】,并且将 id 加入到本次请求的所有日志中。我们可以先通过 cat all.log | grep “userName” 找到这个用户所有的请求切面日志,然后找代码出问题的那一条切面日志。从切面日志中找到 orderId,再通过 cat all.log | grep “orderId” ,获取出问题的那一次请求的所有日志,是否问题就解决了。当然有人会想,好是好,但是我每一条日志中都加上 orderId,这不累死了,而且只想打印一行日志,将这个和业务无关的 orderId传来传去,代码也太不优雅了。那我们有没有办法不需要orderId传来传去,在线程范围内就可以获取呢?哈哈,聪明的读者马上想到了ThreadLocal,线程范围内变量共享,对,就是用 ThreadLocal 来解决,在切面中方法调用前定义一个日志编号,那不就可以在方法调用过程中获取日志编号并打印了嘛,省去了日志编号传来传去。
来看看效果。
但有人会想,打印方式好是好,如果开发人员忘记在日志中添加LogAspect.logNoContain.get()怎么办,每一行日志中都加一串这么长的代码,也影响美观,如果能对开发不做任何侵入,同时又有这样的效果就好了。几年前,我也想过这个问题,那该怎么办呢?那我们就来看最终实现吧。
测试结果
细心的读者肯定发现,对于业务代码,我们没有做任何修改,就在每一条日志中增加了日志编号,同时还有一个重要的信息 exet,这个字段代表意思就是从切面中 threadLocal 变量被设置,到日志打印所花的时间,假如方法中某一块代码执行时间比较长,通过多打几行日志,就能找到问题所在。如此优雅,又是如何实现的呢?
多年前,我在日志源码中寻寻觅觅,终于在ch.qos.logback.classic.Logger这个类中找到了规律。
public final class Logger implements org.slf4j.Logger, LocationAwareLogger, AppenderAttachable, Serializable { private static final long serialVersionUID = 5454405123156820674L; public static final String FQCN = Logger.class.getName(); public static final ThreadLocal threadLocalNo = new ThreadLocal(); public static final ThreadLocal threadLocalTime = new ThreadLocal(); public static final ThreadLocal
上述代码看上去一大堆,实际原理却很简单,就是在bug,warn,info,error,trace方法内,取出msg,并加上前缀,而getLogPre()方法实现原理也很简单,就是获取threadLocal 变量并计算一下执行时间,加到msg 前面即可。因此,如果要使用这个功能,直接将 logback 包导入到项目中即可。logback包放置到了项目中,位置如下:
在LogAspect这样使用
也许有人会说你这样不优雅,直接替换掉logger 类的 class,万一出错的呢?这点我想过,出错的可能性不大,我们公司己用这套方案打印日志多年,并没有出错,优雅,确实不优雅,那有更好的办法吗?我想应该有。那来看看另外一种写法。
import ch.qos.logback.classic.pattern.ClassicConverter; import ch.qos.logback.classic.spi.ILoggingEvent; import com.admin.crawler.aspect.LogAspect; import lombok.extern.slf4j.Slf4j; @Slf4j public class LogPreConverter extends ClassicConverter { @Override public String convert(ILoggingEvent event) { if (LogAspect.threadLocalNo != null && LogAspect.threadLocalNo.get() != null) { StringBuffer sb = new StringBuffer(LogAspect.threadLocalNo.get()); sb.append("\t"); Long start = LogAspect.threadLocalTime.get(); Long end; if (start != null) { end = System.currentTimeMillis(); sb.append("exet=").append(end - start).append("\t"); } return sb.toString(); } else { return ""; } } }
日志前缀类实现很简单就是拼接上日志编号和执行到当前日志时耗时。
%d{yyyy-MM-dd HH:mm:ss.SSS} 【%p】 [%F:%L] %logPre %m%n INFO
测试结果
从执行结果中可以看到,和直接修改日志包效果一样。
有了请求参数和返回参数,不需要前端告诉我们,哪些值错了,从日志中即可发现问题,看一下请求参数,就能确定是前端请求参数问题,还是后端业务逻辑处理问题,能清楚的定位问题出现的位置,但大家有没有想过一个问题,假如请求中有子线程,或者线程池的情况,还能从ThreadLocal中获取日志编号吗?
public class TppHelperTest_error0 { public static ThreadLocalthreadLocal = new ThreadLocal<>(); public static void main(String[] args) { for (int i = 0; i < 10; i++) { String logNo = OrderUtil.getUserPoolOrder("rn"); System.out.println("i = " + i + " 主线程的线程编号 :" + logNo); threadLocal.set(logNo); threadTest(i); } } public static void threadTest(final int i) { new Thread(new Runnable() { @Override public void run() { System.out.println("i = " + i + "在子线程中获取线程编号 " + threadLocal.get()); } }).start(); } }
很遗憾的告诉你,在子线程中无法获取 ThreadLocal中的变量。我就想主线程和子线种共用一个日志编号,证明来源同一个请求,那有什么办法呢?
这时,想到了InheritableThreadLocal变量。我们看看InheritableThreadLocal的使用。
public class TppHelperTest_error000 { public static InheritableThreadLocalinheritableThreadLocal = new InheritableThreadLocal<>(); public static void main(String[] args) { for (int i = 0; i < 20; i++) { startThread(i); } } public static void startThread(final int i) { new Thread(new Runnable() { @Override public void run() { String logNo = OrderUtil.getUserPoolOrder("rn"); System.out.println("i = " + i + " 使用 主线程的线程编号 : " + logNo); inheritableThreadLocal.set(logNo); new Thread(new MycallableA(i)).start(); } }).start(); } static class MycallableA implements Runnable { private int i; public MycallableA(int i) { this.i = i; } @Override public void run() { Random random = new Random(); try { Thread.sleep(random.nextInt(1000)); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("i = " + i + " MycallableA->" + inheritableThreadLocal.get()); } } }
执行结果:
在主线程中设置线程编号,在子线程中能够获取到,并且日志编号没有乱。
那InheritableThreadLocal放到线程池中使用会怎样呢?
public class TppHelperTest_error0000 { public static InheritableThreadLocalinheritableThreadLocal = new InheritableThreadLocal<>(); private static ExecutorService pool = Executors.newFixedThreadPool(100); public static void main(String[] args) { for (int i = 0; i < 20; i++) { startThread(i); } } public static void startThread(final int i) { new Thread(new Runnable() { @Override public void run() { String logNo = OrderUtil.getUserPoolOrder("rn"); System.out.println("i = " + i + " 使用 主线程的线程编号 : " + logNo); inheritableThreadLocal.set(logNo); pool.submit(new MycallableA(i)); } }).start(); } static class MycallableA implements Runnable { private int i; public MycallableA(int i) { this.i = i; } @Override public void run() { Random random = new Random(); try { Thread.sleep(random.nextInt(1000)); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("i = " + i + " MycallableA->" + inheritableThreadLocal.get()); } } }
执行结果:
和放到子线程中执行效果一样嘛,不知道细心的读者有没有发现,使用Executors.newFixedThreadPool(100)创建线程池,参数为100,创建固定数量的线程池,当线程创建个数为1会怎样呢?如:Executors.newFixedThreadPool(1),来测试一下执行效果。
神奇的事情出现,所有子线程中日志编号都变成一样,说明线程池在复用线程时,并没有将之前InheritableThreadLocal中数据清除掉。这种情况,又该如何处理呢?在网上寻寻觅觅,终于找到了阿里开源包transmittable-thread-local来解决此类问题,使用其实很简单,请看:
public class TppHelperTest_error00000 { public static TransmittableThreadLocalinheritableThreadLocal = new TransmittableThreadLocal<>(); //public static InheritableThreadLocal inheritableThreadLocal = new InheritableThreadLocal<>(); private static ExecutorService pool = Executors.newFixedThreadPool(1); public static void main(String[] args) { for (int i = 0; i < 20; i++) { startThread(i); } } public static void startThread(final int i) { new Thread(new Runnable() { @Override public void run() { String logNo = OrderUtil.getUserPoolOrder("rn"); System.out.println("i = " + i + " 使用 主线程的线程编号 : " + logNo); inheritableThreadLocal.set(logNo); TtlExecutors.getTtlExecutorService(pool).execute(TtlRunnable.get(new MycallableA(i))); //pool.submit(new MycallableA(i)); } }).start(); } static class MycallableA implements Runnable { private int i; public MycallableA(int i) { this.i = i; } @Override public void run() { Random random = new Random(); try { Thread.sleep(random.nextInt(1000)); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("i = " + i + " MycallableA->" + inheritableThreadLocal.get()); } } }
有一个同事和我提了一些问题,我觉得很好,原话是【对于子线程这一块的跟踪,我看现在异步实现很多:比如CompletableFuture、@Async 这种使用默认线程池的时候 TtlExecutors还能起作用吗,还有在MQ的异步、分布式调用的场景好像也不太好跟踪】,那来一个一个研究。首先看CompletableFuture
public static void main(String[] args) throws Exception { CompletableFuturefuture = new CompletableFuture<>(); new Thread(new Runnable() { @Override public void run() { future.complete("返回任务结果"); } }).start(); System.out.println(future.get()); }
CompletableFuture主要是从主线程中拿到子线程返回的结果,而实质上还是简单的线程调用,而之前也分析过,通过InheritableThreadLocal即可将日志编号从主线程传入子线程,而TransmittableThreadLocal继承InheritableThreadLocal类,因此能够使用TransmittableThreadLocal来保存日志编号,并且不会错乱。
下面,我们来看看完整使用
public class TppHelperTest_5 { public static TransmittableThreadLocalinheritableThreadLocal = new TransmittableThreadLocal<>(); public static void main(String[] args) throws Exception { for (int i = 0; i < 10; i++) { test(i); } } public static void test(final int i) throws Exception { new Thread(new Runnable() { @Override public void run() { try { CompletableFuture future = new CompletableFuture<>(); String logNo = OrderUtil.getUserPoolOrder("rn"); System.out.println("i= " + i + " 主线程编号 :" + logNo); inheritableThreadLocal.set(logNo); childThread(i, future); System.out.println("i= " + i + " ,返回结果 :" + future.get()); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } } }).start(); } public static void childThread(final int i, CompletableFuture future) { new Thread(new Runnable() { @Override public void run() { Random random = new Random(); int c = random.nextInt(1000); try { Thread.sleep(c); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("i = " + i + " 子线程编号 :" + inheritableThreadLocal.get()); future.complete("返回任务结果" + i); } }).start(); } }
执行结果
CompletableFuture另外一种使用使用方式
public class TppHelperTest_6 { public static TransmittableThreadLocalinheritableThreadLocal = new TransmittableThreadLocal<>(); public static void main(String[] args) throws Exception { for (int i = 0; i < 3; i++) { a(i); } } public static void a(final int i) { new Thread(new Runnable() { @Override public void run() { b(i); } }).start(); } public static void b(int i) { List a = new ArrayList<>(); String logNo = OrderUtil.getUserPoolOrder("rn"); inheritableThreadLocal.set(logNo); System.out.println("i = " + i + " 主线程日志编号 =" + inheritableThreadLocal.get()); ExecutorService executor = Executors.newFixedThreadPool(1); CompletableFuture > listCompletableFuture = CompletableFuture.supplyAsync(() -> { return list(i,1); }, executor); CompletableFuture
> listCompletableFuture1 = CompletableFuture.supplyAsync(() -> { return list(i,4); }, executor); CompletableFuture
> listCompletableFuture2 = CompletableFuture.supplyAsync(() -> { return list(i,7); }, executor); try { addAll(a, listCompletableFuture); addAll(a, listCompletableFuture1); addAll(a, listCompletableFuture2); } catch (Exception e) { e.printStackTrace(); } } public static void addAll(List
a, CompletableFuture > listCompletableFuture) { new Thread(new Runnable() { @Override public void run() { try { a.addAll(listCompletableFuture.get(2000, TimeUnit.SECONDS)); } catch (Exception e) { e.printStackTrace(); } } }).start(); } public static List
list(int i, int j) { Random random = new Random(); try { int k = random.nextInt(1000); Thread.sleep(k); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("i = " + i + " 子线程日志编号 =" + inheritableThreadLocal.get()); List list = new ArrayList<>(); for (int z = 0 ; z < j + 3; z ++) { list.add(z + ""); } return list; } }
执行结果:
从测试结果来看,使用TransmittableThreadLocal保存日志编号并没有错乱。
首先来看看 Spring Boot 中@Async注解的使用。需要在Application中配置EnableAsync注解,不然不起作用
写测试方法
public interface TestUserService { void testAsync(int i ); }
@Override @Async public void testAsync( int i ) { Random random = new Random(); int sleep = random.nextInt(1000); try { Thread.sleep(sleep); } catch (InterruptedException e) { e.printStackTrace(); } log.info("i = " + i + " , sleep " + sleep); }
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Async { String value() default ""; }
从源码中可以看到 Async 注解接收一个参数,这个参数的用途是什么呢?去网上寻寻觅觅,发现
有了这个发现,问题就好办了,指定我们自定义的线程池即可。
public class MyThreadPoolTaskExecutor extends ThreadPoolTaskExecutor { @Override protected ExecutorService initializeExecutor( ThreadFactory threadFactory, RejectedExecutionHandler rejectedExecutionHandler) { ExecutorService executorService = super.initializeExecutor(threadFactory, rejectedExecutionHandler); return TtlExecutors.getTtlExecutorService(executorService); } @Override public void execute(Runnable task) { Executor executor = getThreadPoolExecutor(); try { executor.execute(TtlRunnable.get(task)); } catch (RejectedExecutionException ex) { throw new TaskRejectedException("Executor [" + executor + "] did not accept task: " + task, ex); } } @Override publicFuture submit(Callable task) { ExecutorService executor = getThreadPoolExecutor(); try { return executor.submit(TtlCallable.get(task)); } catch (RejectedExecutionException ex) { throw new TaskRejectedException("Executor [" + executor + "] did not accept task: " + task, ex); } } }
自定义线程池主要对executorService和Runnable及Callable包装。和普通使用方式一样。
@Configuration public class AsyncConfig { private static final int MAX_POOL_SIZE = 50; private static final int CORE_POOL_SIZE = 20; @Bean("asyncTaskExecutor") public AsyncTaskExecutor asyncTaskExecutor() { MyThreadPoolTaskExecutor asyncTaskExecutor = new MyThreadPoolTaskExecutor(); asyncTaskExecutor.setMaxPoolSize(MAX_POOL_SIZE); asyncTaskExecutor.setCorePoolSize(CORE_POOL_SIZE); asyncTaskExecutor.setThreadNamePrefix("async-task-thread-pool-"); asyncTaskExecutor.initialize(); return asyncTaskExecutor; } }
3.为@Async指定线程池
@Override @Async("asyncTaskExecutor") public void testAsync( int i ) { Random random = new Random(); int sleep = random.nextInt(1000); try { Thread.sleep(sleep); } catch (InterruptedException e) { e.printStackTrace(); } log.info("i = " + i + " , sleep " + sleep); }
关于分布式这一块,先来弄一个项目,因为本文重点不是讲如何创建分布式项目,因此,我在网上随便弄了一个项目进行测试,代码己经放到文章结尾,有兴趣的同学下载测试一下。
项目分为三个模块,注册中心,生产者,消费者,消费者从生产者中获取数据,下面来看关键代码。
org.springframework.cloud spring-cloud-starter-sleuth io.zipkin.brave brave-instrumentation-kafka-clients ch.qos.logback logback-core 1.2.3 ch.qos.logback logback-classic 1.2.3
%d{yyyy-MM-dd HH:mm:ss.SSS} 【%p】 [%X{X-B3-TraceId:-},%X{X-B3-SpanId:-}] [%F:%L] %m%n INFO
需要在日志Pattern 中配置%X{X-B3-TraceId:-},%X{X-B3-SpanId:-},不配置不起作用。
@RestController @Slf4j public class ProductController { @RequestMapping(value = "getProduct") public String getProduct() { Product product = new Product(); //看是否有日志编号产生 log.info("生产者生产数据:" + product.toString()); return product.toString(); } }
@RestController @Slf4j public class ConsumerController { @Autowired private ProductService productService; @Autowired private TestService testService; @Autowired @Value("${name}") private String name; private static ExecutorService pool = Executors.newFixedThreadPool(1); @RequestMapping(value = "getConsumer") public String getConsumer() { String str = productService.getProduct(); log.info("消费者从生产者中获取到的数据 :" + str); testService.testLog(); new Thread(new Runnable() { @Override public void run() { //子线程中测试日志编号 log.info("子线程执行"); } }).start(); for (int i = 0; i < 10; i++) { //测试线程池中日志编号 startThread(i); } Consumer consumer = new Consumer(); log.info(consumer.toString()); log.info(consumer.getAdd()); log.info(consumer.getAge() + ""); consumer.setAge(12333); log.info(consumer.getAge() + ""); log.info(name); return str; } public static void startThread(final int i) { new Thread(new Runnable() { @Override public void run() { log.info("i = " + i + " 使用 主线程的线程编号 测试 "); pool.submit(new MycallableA(i)); } }).start(); } static class MycallableA implements Runnable { private int i; public MycallableA(int i) { this.i = i; } @Override public void run() { log.info("i = " + i + " MycallableA-> 子线程中拿到线程编号 "); } } }
public interface TestService { public void testLog(); } @Slf4j @Service public class TestServiceImpl implements TestService { @Override @Async //@Async日志编号测试 public void testLog() { log.info("testLog"); } }
消费端日志打印
生产端日志打印
显然通过sleuth包分布式实现了日志编号的统一,而且还帮我们实现了@Async 异步方法内部日志编号统一,遗憾的是,手动实现的子线程及线程池中并没有加入日志编号。那怎么办呢?那就将我们自己实现的日志打印方式和 Spring Boot 提供的合并起来使用吧。
在项目中加入日志切面。
@Aspect @Component public class LogAspect { private Logger logger = LoggerFactory.getLogger(getClass()); @Pointcut(value = "execution(* com..controller..*.*(..))") public void pointCut() { } @Around("pointCut()") public Object around(ProceedingJoinPoint point) throws Throwable { String logNo = OrderUtil.getUserPoolOrder("tr"); long start = System.currentTimeMillis(); ch.qos.logback.classic.Logger.inheritableThreadLocalNo.set(logNo); ch.qos.logback.classic.Logger.inheritableThreadLocalTime.set(start); Object result = null; String uri = ""; StringBuilder cm = new StringBuilder(); result = null; Object arg = result; String ip = ""; String m = ""; String userName = ""; String params = ""; try { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); Object[] args = point.getArgs(); if (args != null && args.length > 0) { for (Object arg1 : args) { if (arg instanceof HttpServletResponse) { continue; } else if (arg1 instanceof HttpServletRequest) { continue; } else if (arg1 instanceof MultipartFile) { continue; } else if (arg1 instanceof MultipartFile[]) { continue; } else if (arg1 instanceof ResponseFacade) { continue; } else { arg = arg1; } } } m = request.getMethod(); uri = request.getRequestURI(); ip = ServletUtils.getIpAddress(request); // result的值就是被拦截方法的返回值 String classMethod = point.getSignature().getDeclaringTypeName() + "." + point.getSignature().getName(); if (StringUtils.isNotBlank(classMethod) && classMethod.length() > 0 && classMethod.contains(".")) { String classMethods[] = classMethod.split("\\."); if (classMethod.length() >= 2) { cm.append(classMethods[classMethods.length - 2]).append(".").append(classMethods[classMethods.length - 1]); } } params = JSON.toJSONString(arg); result = point.proceed(); return result; } catch (Exception e) { result = R.error(e.getMessage()); logger.error("controller error.", e); } finally { logger.info(StringUtil.appendStrs( " ", "cm=", cm.toString(), " ", "m=", m, " ", "uri=", uri, " ", "userName=", userName, " ", "ip=", ip, " ", "params=", params, " ", "result=", JSON.toJSONString(result) )); ch.qos.logback.classic.Logger.inheritableThreadLocalNo.remove(); ch.qos.logback.classic.Logger.inheritableThreadLocalTime.remove(); } return result; } }
在上述中,先通过自定义日志编号查找问题,如果需要查找被调用端的日志,先找到sleuth提供的日志编号,拿这个日志编号找到被调用端日志,再找到自定义日志编号,即可定位出相关业务问题了。当然最好的办法是用自定义日志编号替换掉sleuth帮我们生成的日志编号,这样能节省日志存储空间,这个问题先留在这里,后续有时间再来完善。
看了分布式日志以后,发现它有一个主线程日志编号,一个子线程日志编号,经过同事的提示,我萌生出一个想法,如果我们的日志中也有一个主线程日志编号,和一个子线程日志编号,那该多好啊,昨天晚上,我花了一两个小时研究,最终得出结果,先将实现过程及效果写下来,如果读者发现有问题,请给我留言,或者你有更好的方案,也给留言吧,我们一起来将日志这一块做得更好。
TransmittableThreadLocal存储的数据无论是主线程,子线程,线程池,其值都不会改变,因此用它来做主线程编号毋庸置疑,而子线程编号该怎样来创建和区分呢,不知道细心的读者有没有想到,ThreadLocal不就是线程范围内的变量共享嘛,当ThreadLocal变量为空时,手动帮他创建一个,不就好了嘛,对于普通线程解决了,那么线程池怎么办呢?没有办法,只能到transmittable-thread-local包中寻寻觅觅,说不定有解决方案,在ttl包中找到了如下代码
在ttl包中,发现TtlRunnable类实现了run方法,在调用我们传入的runnable的run方法前对线程数据做了备份,在执行完run方法后对线程数据做了恢复操作,但是遗憾的是TtlRunnable类是final修饰,不能被继承,既然不能被继承,那就模仿吧。
模仿ttl包实现。
public final class MyCallableimplements Callable { private final Callable callable; public MyCallable(Callable callable) { this.callable = callable; } public static MyCallable get(Callable callable) { return new MyCallable(callable); } @Override public V call() throws Exception { try { return callable.call(); } finally { LogAspect.myThreadLocalNo.remove(); } } } public final class MyRunnable implements Runnable { private final Runnable runnable; public MyRunnable(Runnable runnable) { this.runnable = runnable; } public static MyRunnable get(Runnable runnable) { return new MyRunnable(runnable); } @Override public void run() { try { runnable.run(); } catch (Exception e) { e.printStackTrace(); } finally { LogAspect.myThreadLocalNo.remove(); } } }
上述两个类的目的就是当线程池执行完线程后,移除掉当前线程的日志编号,否则线程池中所有线程执行的日志编号都一样。
对线程增加MyRunnable和MyCallable包裹,保证不同子线程日志编号不一致。
@Aspect @Component public class LogAspect { private Logger logger = LoggerFactory.getLogger(getClass()); public static final ThreadLocal threadLocalNo = new ThreadLocal(); public static final ThreadLocal myThreadLocalNo = new ThreadLocal(); public static final ThreadLocal threadLocalTime = new ThreadLocal(); public static final TransmittableThreadLocal inheritableThreadLocalTime = new TransmittableThreadLocal(); public static final TransmittableThreadLocal inheritableThreadLocalNo = new TransmittableThreadLocal(); @Pointcut(value = "execution(* com..controller..*.*(..))") public void pointCut() { } @Around("pointCut()") public Object around(ProceedingJoinPoint point) throws Throwable { String logNo = OrderUtil.getUserPoolOrder("tr"); long start = System.currentTimeMillis(); threadLocalNo.set(logNo); inheritableThreadLocalNo.set(logNo); inheritableThreadLocalTime.set(start); ... result = point.proceed(); return result; } catch (Exception e) { result = R.error(e.getMessage()); logger.error("controller error.", e); PDingDingUtils.sendText("异常 " + ch.qos.logback.classic.Logger.inheritableThreadLocalNo.get() + "\n"+ ExceptionUtils.dealException(e)); } finally { logger.info(StringUtil.appendStrs( " ", "cm=", cm.toString(), " ", "m=", m, " ", "uri=", uri, " ", "userName=", userName, " ", "ip=", ip, " ", "params=", params, " ", "result=", JSON.toJSONString(result) )); threadLocalNo.remove(); inheritableThreadLocalNo.remove(); inheritableThreadLocalTime.remove(); } return result; } }
public class LogPreConverter extends ClassicConverter { @Override public String convert(ILoggingEvent event) { if (LogAspect.inheritableThreadLocalNo != null && LogAspect.inheritableThreadLocalNo.get() != null) { StringBuffer sb = new StringBuffer(); String threadNo = LogAspect.threadLocalNo.get(); if (threadNo == null || threadNo.length() == 0) { //当前线程中的日志编号不存在时,手动创建它 if(LogAspect.myThreadLocalNo.get() ==null ){ LogAspect.myThreadLocalNo.set(OrderUtil.getUserPoolOrder("cr")); } threadNo = LogAspect.myThreadLocalNo.get(); } sb.append("[").append(LogAspect.inheritableThreadLocalNo.get()).append(",").append(threadNo).append("]").append("\t"); Long start = LogAspect.inheritableThreadLocalTime.get(); Long end; if (start != null) { end = System.currentTimeMillis(); sb.append("exet=").append(end - start).append("\t"); } return sb.toString(); } else { return ""; } } }
@RequestMapping(value = "getConsumer") public String getConsumer() { log.info("主线程中测试 :" ); for (int i = 0; i < 5; i++) { testUserService.testAsync(i); } log.info("子线程测试"); for(int i = 0 ;i < 5;i ++){ startThread(i); } log.info("线程池测试"); for (int i = 5; i < 11; i++) { startThreadPoll(i); } return "xxxxx"; } public static void startThread(final int i) { new Thread(new Runnable() { @Override public void run() { log.info("i = " + i + " 子线程日志1 "); log.info("i = " + i + " 子线程日志2 "); Random random = new Random(); int sleep = random.nextInt(1000); try { Thread.sleep(sleep); } catch (InterruptedException e) { e.printStackTrace(); } log.info("i = " + i + " 子线程日志3"); log.info("i = " + i + " 子线程日志4 "); } }).start(); } public static void startThreadPoll(final int i) { new Thread(new Runnable() { @Override public void run() { TtlExecutors.getTtlExecutorService(pool).submit(MyRunnable.get(new MyRunnableA(i))); } }).start(); } static class MyRunnableA implements Runnable { private int i; public MyRunnableA(int i) { this.i = i; } @Override public void run() { log.info("i = " + i + " MyRunnableA-> 线程池中日志1 "); log.info("i = " + i + " MyRunnableA-> 线程池中日志2 "); Random random = new Random(); int sleep = random.nextInt(1000); try { Thread.sleep(sleep); } catch (InterruptedException e) { e.printStackTrace(); } log.info("i = " + i + " MyRunnableA-> 线程池中日志3 "); log.info("i = " + i + " MyRunnableA-> 线程池中日志4 "); } }
实现了每一个请求共用一个日志编号,每个子线程都有自己的日志编号了。这样就更加方便定位问题了。
定时任务手动定义日志编号
在执行定时任务复杂逻辑时,如果所有的日志编号都一样,日志编号也就没有什么意义,因此需要手动来添加和移除日志编号,使用如下:
@Slf4j public class TppHelperTest_9 { private static ExecutorService pool = Executors.newFixedThreadPool(1); public static void main(String[] args) { for (int i = 0; i < 10; i++) { try { String logNo = OrderUtil.getUserPoolOrder("rn"); Logger.inheritableThreadLocalNo.set(logNo); Logger.inheritableThreadLocalTime.set(System.currentTimeMillis()); log.info("执行复杂逻辑 xxxx " +i); log.info("执行复杂逻辑 yyyy " +i); } catch (Exception e) { e.printStackTrace(); } finally { Logger.inheritableThreadLocalNo.remove(); Logger.inheritableThreadLocalTime.remove(); } } } }
执行结果:
钉钉机器人巧妙使用
为了让程序员及时发现系统异常,我们借助钉钉机器人来提醒我们。
可能这个包要到钉钉开发中心里去下载了,我是自己下载下来,上传到公司的 maven 仓库里,为了方便,也将其放到项目中。
创建群,群主创建钉钉机器人。点击群助手。
添加机器人
选择自定义
开始测试
测试结果
钉钉机器人这么好用,事情就可以举一反三了,如监听每一次请求时长,如果超过5秒,即发出钉钉消息,开发就可以去优化代码了,对慢 sql也可以添加监听,对数据库中比较重要的数据添加监听,也可以对数据库中的数据动态变化添加监听,如
我只是提了一个想法,大家可以根据自己公司的业务适当添加。
在使用的过程中一定将 pom.xml 中的 logback 包
ch.qos.logback logback-core 1.2.3 ch.qos.logback logback-classic 1.2.3
替换成,下面的包,1.1.11和1.2.3随便一个版本都可以。
曾经我在想一个问题,如何让日志中打印方法调用链呢? 为什么我会有这个想法呢?我们的项目做得越来越大,service之间的调用关系也变得越来越复杂, 如getUserMainByUniqueCode方法中打印了一行日志 , 但是我想知道到底是哪个方法调用了getUserMainByUniqueCode()方法呢?从而去分析业务逻辑 ,从目前的日志中无法得知。
我曾经想过如下办法,通过new Throwable();从堆栈中获取类,方法及行号信息。
/**** * 日志工具类,升级版 */ @Slf4j public class LoggerUtils { private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(LoggerUtils.class); /** * 不需要传递参数 * * @param msg */ public static void info(String msg) { log.info(msg); } /** * 不需要传递参数 * * @param msg */ public static void all(String msg) { Throwable throwable = new Throwable(); StringBuffer sb = getStringBuffer(msg, throwable, -1); log.info(sb.toString()); } /** * 不需要传递参数 * @param msg */ public static void info(String msg, int level) { try { Throwable throwable = new Throwable(); StringBuffer sb = getStringBuffer(msg, throwable, level); log.info("【logstack】 " + sb.toString()); } catch (Exception e) { log.error(" 打印日志异常",e); } } public static void error(Exception e) { Throwable throwable = new Throwable(); StringBuffer sb = getStringBuffer("", throwable, getLevel(throwable)); log.error(sb.append("error=").toString(), e); } public static void error(String msg, Exception e) { Throwable throwable = new Throwable(); StringBuffer sb = getStringBuffer(msg, throwable, getLevel(throwable)); log.error(sb.append("error=").toString(), e); } public static int getLevel(Throwable throwable) { return throwable.getStackTrace().length - 1; } private static StringBuffer getStringBuffer(String msg, Throwable throwable, int level) { StringBuilder cml = getRelate(throwable, level); String result = cml.toString().trim(); if (result.endsWith("=>")) { result = result.substring(0, result.lastIndexOf("=>")); } StringBuffer sb = appendSb(" ", result, " ", msg); return sb; } public static StringBuilder getRelate(Throwable throwable, int level) { StringBuilder cml = new StringBuilder(); if (level <= 0 || level >= throwable.getStackTrace().length) { level = throwable.getStackTrace().length - 1; } getLationByN(throwable, cml, level); return cml; } private static void getLationByN(Throwable throwable, StringBuilder cml, int n) { for (int i = n; i > 0; i--) { getNLation(throwable, cml, i); cml.append(" => "); } } private static void getNLation(Throwable throwable, StringBuilder cml, int i) { String method = throwable.getStackTrace()[i].getMethodName(); cml.append(method); cml.append(":"); cml.append(getClassName(throwable.getStackTrace()[i].getClassName())); cml.append(":"); cml.append(throwable.getStackTrace()[i].getLineNumber()); } public static String dealException(Exception e) { StringWriter sw = null; String str = null; try { e.printStackTrace(); sw = new StringWriter(); //将出错的栈信息输出到printWriter中 e.printStackTrace(new PrintWriter(sw, true)); str = sw.toString(); sw.flush(); } finally { if (sw != null) { try { sw.close(); } catch (IOException e1) { e1.printStackTrace(); } } } return str; } public static String getClassName(String className) { if (isNotBlank(className) && className.contains(".")) { String classNames[] = className.split("\\.", className.length()); if (classNames != null && classNames.length > 0) { return classNames[classNames.length - 1]; } } return ""; } public static boolean isNotBlank(String str) { return !isBlank(str); } public static boolean isBlank(String str) { if (str == null) return true; if (str.length() == 0 || str.equals("null")) return true; return false; } /** * 通过StringBuffer来组装字符串 * * @param strings * @return */ public static StringBuffer appendSb(Object... strings) { StringBuffer sb = new StringBuffer(); for (Object str : strings) { sb.append(str); } return sb; } public static void test(){ LoggerUtils.info("329832832" +"3232",3); } public static void test1(){ test(); } public static void main(String[] args) { test1(); } }
测试结果:
如main方法调用了test1方法,test1方法调用了test方法,在日志中一目然,但是大家看到没有,在每一行日志中都去 new Throwable(); 这样,在性能上也是极其低下的,难道我在每一个Service方法中都打印一行日志吗?显然做不到,那在切面中使用 new Throwable(); 获取堆栈信息,在Spring中很多的类已经变成代理类了,无法准确的获取到业务类的信息,从各个方面来讲,这是不可行的。 但是这种方式也不是一无是处,我们来看一个应用场景 ,如下:
我们代码中抛出了很多的异常, 很多的异常Code或message都是一样的,如果发现异常,还需要经过一番分析,才能找到异常真正代码抛出位置,因此在异常方法中加上LoggerUtils.info(" code=" + code + “,msg=”+msg, 2);,就起到一个画龙点眼的作用,从日志中一眼就能找准抛出异常的位置。 但如何解决之前提出的问题呢? 我想了几年,甚至将Spring 源码都撸了一遍,还是没有找到好的解决办法,终于在一个风清云淡的日子里,灵感来了,写下了如下代码 。
@Aspect @Component @Order(1) public class ServiceAop { @Pointcut(value = "(@within(org.springframework.stereotype.Service) " + " || @within(org.springframework.stereotype.Component) " ) public void pointcut() { } @Around("pointcut()") public Object doAround(ProceedingJoinPoint point) throws Throwable { String no = ch.qos.logback.classic.Logger.inheritableThreadLocalNo.get(); if (StringUtil.isBlank(no)) { try { return point.proceed(); } catch (Exception e) { throw e; } } Object result = null; Signature signature = point.getSignature(); MethodSignature methodSignature = (MethodSignature) signature; Method method = methodSignature.getMethod(); String className = method.getDeclaringClass().getSimpleName(); String methodName = method.getName(); String key = "=>" + className + ":" + methodName; String oldNo = ""; try { oldNo = ch.qos.logback.classic.Logger.inheritableThreadLocalNo.get(); ch.qos.logback.classic.Logger.inheritableThreadLocalNo.set(oldNo + key); result = point.proceed(); return result; } catch (Exception e) { throw e; } finally { ch.qos.logback.classic.Logger.inheritableThreadLocalNo.set(oldNo); } } }
不知道大家看懂上面的代码没有,在进入service方法调用前,将当前方法所在类和方法追加到之前的日志编号中去,当离开时恢复之前的日志编号 , 这不就像剥洋葱一样,一层一层往里剥,而对性能上的损耗也是极其小的,我们来看一下效果,
显然能看到方法之间的调用链了,但是美中仍然有一点不足
虽然有不足之处,但是对于我们日常解决问题已经足够了,有兴趣的小伙伴可以去尝试一下,需要注意的是 ch.qos.logback.classic.Logger.inheritableThreadLocalNo中一定需要有值,并且值是在Controller切面中织入的。
之前说的不足之外 ,经过几个月的学习,今天(2022-04-05)又有了新的方案,之前的方法调用链中缺少了行号,拿到日志,我们找代码对应的位置时,始终不太方法 ,经过我人思考,今天想出了新的方法 。
大家肯定很奇怪,怎么能在Aop切面中获取到方法所在行呢?之前我也觉得不可能,在一次面试中,面试者给了我启发,Spring 不也是通过ASM解析得到方法的注解的不? 我为什么不能通过读取字节码得到方法的行号呢?
通过上图,我们读取Code属性的LineNumberTable表的第1个行号,不就得到方法所在的行不?有了这个思路,那我就自己写一个jar包,来读取方法的行号表。因此,我又开发了另外一个jar包来读取类的字节码。源码在github上,有兴趣自己去下载 https://github.com/quyixiao/classparser,接下来,我们继续修改AOP的代码 。
@Aspect @Component @Order(1) @Slf4j public class ServiceAop { public static MaplineCache = new ConcurrentHashMap<>(); @Pointcut(value = "(@within(org.springframework.stereotype.Service) " + " || @within(org.springframework.stereotype.Component) " + " || @within(com.lz.eb.api.annontion.ProductHandle))") public void pointcut() { } @Around("pointcut()") public Object doAround(ProceedingJoinPoint point) throws Throwable { String no = ch.qos.logback.classic.Logger.inheritableThreadLocalNo.get(); if (StringUtil.isBlank(no)) { try { return point.proceed(); } catch (Exception e) { throw e; } } Object result = null; Signature signature = point.getSignature(); MethodSignature methodSignature = (MethodSignature) signature; Method method = methodSignature.getMethod(); String allClassName = method.getDeclaringClass().getName(); // 全类名 // 获取方法所在行号 Integer lineNumber = getLineNumber(allClassName, method.getName(), method.getParameterTypes()); String className = method.getDeclaringClass().getSimpleName(); String methodName = method.getName(); String key = "=>" + className + ":" + lineNumber + ":" + methodName; String oldNo = ""; try { oldNo = ch.qos.logback.classic.Logger.inheritableThreadLocalNo.get(); ch.qos.logback.classic.Logger.inheritableThreadLocalNo.set(oldNo + key); result = point.proceed(); return result; } catch (Exception e) { throw e; } finally { ch.qos.logback.classic.Logger.inheritableThreadLocalNo.set(oldNo); } } public static Integer getLineNumber(String className,String methodName, Class>[] classes ){ StringBuilder sb = new StringBuilder(); for(Class cla: classes){ sb.append(cla.getName()); } String descriptor = sb.toString(); String key = className + methodName + descriptor; // 全类名+方法名+方法描述 唯一确定一个方法 log.info(" line key = " + key); InputStream is = null; try { Integer value = lineCache.get(key); if(value !=null){ return value; } String resourcePath = ClassUtils.convertClassNameToResourcePath(className) + ClassUtils.CLASS_FILE_SUFFIX; ClassLoader classLoader = ClassUtils.getDefaultClassLoader(); // 获取class的输入流 is = ClassParser.getInputStream(classLoader, resourcePath); // 解析class方法 ClassFile classFile = ClassFile.Parse(ClassReaderUtils.readClass(is)); for (MemberInfo memberInfo : classFile.getMethods()) { boolean flag = false; MethodDescriptorParser parser = new MethodDescriptorParser(); MethodDescriptor parsedDescriptor = parser.parseMethodDescriptor(memberInfo.Descriptor()); StringBuilder parameterTypeStr = new StringBuilder(); for (String parameterType : parsedDescriptor.parameterTypes) { String d = ClassNameHelper.toStandClassName(parameterType); parameterTypeStr.append(d); } AttributeInfo attributeInfos[] = memberInfo.getAttributes(); // 获取Code下的第一个LineNumberTable for (AttributeInfo codeAttribute : attributeInfos) { if (codeAttribute instanceof CodeAttribute) { AttributeInfo codeAttributeInfos[] = ((CodeAttribute) codeAttribute).getAttributes(); for (AttributeInfo attributeInfo : codeAttributeInfos) { if (attributeInfo instanceof LineNumberTableAttribute) { int i = ((LineNumberTableAttribute) attributeInfo).GetFirstLineNumber(); lineCache.put(className + methodName + parameterTypeStr.toString(), i); flag = true; break; } } break; } } if (!flag) { lineCache.put(key, 0); } } } catch (Exception e ){ lineCache.put(key,0); log.error("解析类信息异常",e); }finally { if(is !=null){ try { is.close(); } catch (IOException e) { log.error("关闭流异常",e); } is = null; } } return lineCache.get(key); } }
加粗代码,即为本次新增加的代码,主要是getLineNumber()方法的实现,这个方法主要做了哪些事情呢?
细心的小伙伴肯定发现 ,上面的实现还是有问题,在找调用当前方法的行号是,并不是准确的行号,如
假如在borrowConfirmBanner()方法中调用了LtStageBorrowDo stageBorrowByBorrowNo = stageBorrowService.getStageBorrowByBorrowNo(…)方法,而在getStageBorrowByBorrowNo()方法中打印了日志,日志中显示的行号是331,而不是341,即使我们用快捷键定位到了331行代码,还需要仔细的去找getStageBorrowByBorrowNo()方法在哪里调用,显然行号不准,因为我在getLineNumber()方法中,是通过读取borrowConfirmBanner()方法的字节码,取行号表的第一行,显然美中不足。
我不满于行号的不完美。我研究了日志系统的源码,发现只有在运行时,才能真正的获取调用者所在行号,而其底层是用Throwable throwable = new Throwable();来实现,既然日志都不怕调用new Throwable();带来的性能问题,那我也可以用new Throwable();来实现了。
@Aspect @Component @Order(10) @Slf4j public class ServiceAop { public final static ThreadLocalarrayStackThreadLocal = new ThreadLocal(); @Pointcut(value = "(@within(org.springframework.stereotype.Service) " + " || @within(org.springframework.stereotype.Component) " + " || execution(* com.lz.eb.api..*.*Controller.*(..)) " + " || @within(com.lz.eb.api.annontion.ProductHandle))") public void pointcut() { } @Around("pointcut()") public Object doAround(ProceedingJoinPoint point) throws Throwable { String no = ch.qos.logback.classic.Logger.inheritableThreadLocalNo.get(); if (StringUtil.isBlank(no)) { try { return point.proceed(); } catch (Exception e) { throw e; } } Object result = null; Signature signature = point.getSignature(); MethodSignature methodSignature = (MethodSignature) signature; Method method = methodSignature.getMethod(); String allClassName = method.getDeclaringClass().getName(); // 全类名 Throwable throwable = new Throwable(); String lineNumber = getLineNumber(allClassName, method.getName(), throwable); String className = method.getDeclaringClass().getSimpleName(); String methodName = method.getName(); String key = lineNumber + "=>" + methodName + ":" + className; String oldNo = ""; try { oldNo = ch.qos.logback.classic.Logger.inheritableThreadLocalNo.get(); ch.qos.logback.classic.Logger.inheritableThreadLocalNo.set(oldNo + key); result = point.proceed(); return result; } catch (Exception e) { throw e; } finally { ch.qos.logback.classic.Logger.inheritableThreadLocalNo.set(oldNo); pop(); if(className.endsWith("Controller")){ arrayStackThreadLocal.remove(); } } } public static String getLineNumber(String className, String methodName, Throwable throwable ) { String classInfo = className + methodName; try { if(className.endsWith("Controller")){ return ""; } String peek = peek(); if(StringUtil.isBlank(peek)){ return ""; } StackTraceElement[] elements =throwable.getStackTrace(); for (StackTraceElement stackTraceElement : elements) { String c = stackTraceElement.getClassName(); String mName = stackTraceElement.getMethodName(); if(peek.equals(c + mName)){ return ":"+stackTraceElement.getLineNumber(); } } } catch (Exception e) { log.error("解析类信息异常", e); } finally { push(classInfo); } return ""; } public static void push(String classInfo ){ ArrayStack arrayStack = arrayStackThreadLocal.get(); if(arrayStack == null){ arrayStack = new ArrayStack(); } arrayStack.push( classInfo); arrayStackThreadLocal.set(arrayStack); } public static String peek(){ ArrayStack arrayStack = arrayStackThreadLocal.get(); if(arrayStack == null){ return null; } if(arrayStack.size() > 0 ){ return (String) arrayStack.peek(); } return null; } public static String pop(){ ArrayStack arrayStack = arrayStackThreadLocal.get(); if(arrayStack == null){ return null; } if(arrayStack.size() > 0 ){ return (String) arrayStack.pop(); } return null; } }
每一次进入切面时,都将当前的类名和方法加入到stack中,每次退出时,将其中stack中移除,如A.a() -> B.b() ->C.c() 的调用链,在A.a()方法调用时,会将A.a存储于栈中,B.b()方法调用时,取出A.a()方法和当前调用栈比对,找到A.a()所在的StackTraceElement,从中取出行号,打印到日志中。
不过上面需要注意,如果有其他切面也对Controller进行拦截,将@Order(10) 注解的值尽量设置大一些。 这样性能会更好。
我们随便找一行日志看看。
从效果来看,现在的行号就准确了,UpsCallBackController的collect()方法的31行调用了UpsBusiness的repayCall方法。UpsBusiness的repayCall方法中115行调用了RepayBusiness的dealRepaymentSuccess方法, RepayBusiness的698行调用了XsRepayHandler的dealRepaymentSuccess方法,行号 是不是很精准了。
我花了近4年时间,才将日志打印完善了,我相信这些东西对你的工作有帮助,可能还有其他问题,我要在线上试用一段时间,如果有问题则继续完善。
相信现在的日志系统比之前的更加好用,希望对大家有帮助 。当然如何解析class类文件结构,之前 在自己动手写Java虚拟机 (Java核心技术系列)_java版中也做过详细的分析,感兴趣可以去看我的那篇博客,其他的也不再赘述。
随着开发过程中遇到的问题,发现日志中还有一点美中不足,有些小伙伴不喜欢打日志,所以在测试环境中,我习惯将sql语句打印出来,这样,即使其他小伙伴不打日志,我也根据sql语句的执行,找到整个调用链。
先来看个例子。
上面的例子是我们之前实现的功能,能看到方法的调用链,也就是说, 在userPhoneService的testLogInfo方法的日志中,能看到是TestController类的test20()方法的331行,调用了 userPhoneService.testLogInfo()方法,但是细心的小伙伴肯定发现一个问题。
如果我能具体知道每条SQL是哪一行执行代码执行的那该多好啊。
其实问题不难, 还是之前的老套路,只要在SQL执行时调用链最内层的Component,这不就是调用Mapper的类及方法不?有了这个思路,需要对之前的ServiceAop做一点点修改。
在SQL 打印的插件中添加一段代码。
... Throwable throwable = new Throwable(); String sqlCommandTypePre = mapperdId + " | "; String peek0 = ServiceAop.peek(); if (StringUtils.isNotBlank(peek0)) { StackTraceElement[] stackTraceElements = throwable.getStackTrace(); String classInfos[] = peek0.split(":"); int i = 0; for (StackTraceElement stackTraceElement : stackTraceElements) { i++; if (stackTraceElement.getClassName().equals(classInfos[0]) && stackTraceElement.getMethodName().equals(classInfos[1])) { String className = stackTraceElement.getClassName(); int lastIndexOf = className.lastIndexOf("."); className = className.substring(lastIndexOf + 1 ); sqlCommandTypePre = className + ":" + stackTraceElement.getLineNumber() + ":" + sqlCommandTypePre + " "; } if (i > 100) { // 为了性能考虑,最多找100个栈吧 break; } } } ....
这段代码就是从ServiceAop中获取调用链最内层的类和方法,然后遍历整个调用链,直接找到类名和方法名与调用栈顶相同StackTraceElement,如果相同,则类名和行号即是我们要找到内容 。
执行结果如下 。
再画个图来理解,下图和生产环境很像了。
我们以这个图为基准,来写一个例子。
当然生产中还有一种情况,并不是methodC方法直接调用Dao,而在是ServiceC中的methodC调用了methodD()方法,而methodD()方法再调用Dao的方法。再来看效果 。
从效果上来看,依然有效,但如果在methodC()中,直接调用methodD();会怎样呢?
对于这种情况,确实没有办法了。只能拿到methodD()方法调用的行号,原因在于methodC()方法调用methodD()方法时,并没有被ServiceAop切面切到,因此在调用栈中只拿到了ServiceCImpl.methodC()的信息,这个问题,Spring本身也没有解决,这里我也不去解决了,我相信SQL调用时打印行号已经能解决大部分问题,希望这种解决方案能对你有所帮助 。
相关代码在 https://github.com/quyixiao/spring-data-en-decoder.git 中。
今天我又发现一个问题,在日志中打印每条sql,假如一张表没有明确的字段提供查询 。 如下图所示
这样,我肯定不方便通过日志查询什么样的业务向数据库中添加数据。如果能在SQL日志中打印出id,打印出ID,那该多好啊。 有了想法,就去实现吧。
当然在之前打印SQL日志的地方过滤掉insert操作的语句 。
执行结果如下。
这样,我是不是可以通过grep all.log | grep “insert_id=9” | grep “lt_user_phone” ,就能查询到相关sql,再通过日志编号 grep all.log | grep “on28620221008184431071” 就能查找到与此SQL插入的相关业务了。
相关的源码在 https://github.com/quyixiao/spring-data-en-decoder.git
关于日志的打印方式,就分享到这里了,而transmittable-thread-local包源码,留在以后再来分析,transmittable-thread-local包还有另外一种使用方式,项目在启动时使用 java -javaagent:/Users/transmittable-thread-local.jar -jar xxx.jar 的方式来启动,就可以简写TtlExecutors.getTtlExecutorService(pool).execute(TtlRunnable.get(new MycallableA(i)));这一行代码,线程池无需TtlExecutors来包装,这需要开发根据具体的业务,看哪种成本比较少来决定。感兴趣的小伙伴可以自行研究一下。
如 MQ ,http 请求,可以将日志编号作为参数传给被调用方,在被调用方取出日志编号,并设置到自己系统中,即可实现不同系统日志编号的统一。
我相信此时此刻你对日志的打印方式,有了深刻的认识了,同时也希望这篇文章对你有帮助。
关于日志分析就到这里了,本文用到的源码如下:
https://github.com/quyixiao/lz_mybatis_plugin_project
https://github.com/quyixiao/ttl_test
https://github.com/quyixiao/springbootdemo.git
mybatis小插件包,也可以引入到项目中,增加开发速度,之前也写过博客的,链接如下 https://blog.csdn.net/quyixiao/article/details/114735913
https://github.com/quyixiao/lz_mybatis_plugin