Arthas原理系列(五):watch命令的实现原理

1,538 阅读7分钟

历史文章

  1. OGNL语法规范
  2. 消失的堆栈
  3. Arthas原理系列(一):利用JVM的attach机制实现一个极简的watch命令
  4. Arthas原理系列(二):总体架构和项目入口
  5. Arthas原理系列(三):服务端启动流程
  6. Arthas原理系列(四):字节码插装让一切变得有可能

前言

通过前面几篇文章的介绍,我们可以看到Arthas是如何通过插装来获取运行时信息的,从这篇文章开始,我们开始看Arthas里面的命令具体是如何实现的,涉及到的命令有watch, trace, monitor, stacktime tunnel, 这几条命令相应的Command类都继承了EnhancerCommand, 因此他们的实现离不开代码插装。

本文首先会介绍命令实现的通用流程,以便后续文章的开展,然后会着重看一下上面5条命令中最简单的一条watch是如何实现的。

AdviceListener是如何工作的

从上篇文章的分析中我们可以看到,Arthas会在所有的待插装的代码的特定位置插装一个函数,相关的代码片段如下:

// TODO 要检查 binding 和 回调的函数的参数类型是否一致。回调函数的类型可以是 Object,或者super。但是不允许一些明显的类型问题,比如array转到int

toInsert.add(new MethodInsnNode(Opcodes.INVOKESTATIC, interceptorMethodConfig.getOwner(), interceptorMethodConfig.getMethodName(),
        interceptorMethodConfig.getMethodDesc(), false));

这里的interceptorMethodConfig会在拦截器中设置插装函数(拦截器的工作原理见上一篇文章,Arthas原理系列(四):字节码插装让一切变得有可能,以AtEnter方法为例:

@AtEnter(inline = true)
public static void atEnter(@Binding.This Object target, @Binding.Class Class<?> clazz,
        @Binding.MethodInfo String methodInfo, @Binding.Args Object[] args) {
    SpyAPI.atEnter(clazz, methodInfo, target, args);
}

将会在目标方法的第一行前面插入atEnter这个方法,实际的执行将会转发到SpyAPI.atEnter中,我们接下来看下SpyAPI.atEnter中会具体做些什么工作。

SpyAPI是一个接口,这个接口的实例化在Enhancer初始化的时候就已经完成了

private static SpyImpl spyImpl = new SpyImpl();

static {
    SpyAPI.setSpy(spyImpl);
}

所以,我们直接看SpyImpl的实现就可以了:

@Override
public void atEnter(Class<?> clazz, String methodInfo, Object target, Object[] args) {
    ClassLoader classLoader = clazz.getClassLoader();

    String[] info = splitMethodInfo(methodInfo);
    String methodName = info[0];
    String methodDesc = info[1];
    // TODO listener 只用查一次,放到 thread local里保存起来就可以了!
    List<AdviceListener> listeners = AdviceListenerManager.queryAdviceListeners(classLoader, clazz.getName(),
            methodName, methodDesc);
    if (listeners != null) {
        for (AdviceListener adviceListener : listeners) {
            try {
                if (skipAdviceListener(adviceListener)) {
                    continue;
                }
                adviceListener.before(clazz, methodName, methodDesc, target, args);
            } catch (Throwable e) {
                logger.error("class: {}, methodInfo: {}", clazz.getName(), methodInfo, e);
            }
        }
    }

}

atEnter的入参是被插装方法的所有信息,Artahs如何获取这些动态信息的,在上一篇文章Arthas原理系列(四):字节码插装让一切变得有可能中做了详细分析。这段方法比较简单,核心的思路是获取一个AdviceListener的列表,然后调用其before方法,直到这里,我们的主角AdviceListener才正式登场,我们看下AdviceListener的列表是如何获取的:

public static List<AdviceListener> queryAdviceListeners(ClassLoader classLoader, String className,
        String methodName, String methodDesc) {
    classLoader = wrap(classLoader);
    className = className.replace('/', '.');
    ClassLoaderAdviceListenerManager manager = adviceListenerMap.get(classLoader);

    if (manager != null) {
        return manager.queryAdviceListeners(className, methodName, methodDesc);
    }

    return null;
}

可见查询全都转发到了ClassLoaderAdviceListenerManager#queryAdviceListeners,我们再深入看下去:

public List<AdviceListener> queryAdviceListeners(String className, String methodName, String methodDesc) {
    className = className.replace('/', '.');
    String key = key(className, methodName, methodDesc);

    List<AdviceListener> listeners = map.get(key);

    return listeners;
}

可以卡看到这个方法就是封装了一个map,没有其他的逻辑,那这个map中的值是何时被初始化进去的呢?还是这个类,我们稍微上下翻一下:

public static void registerAdviceListener(ClassLoader classLoader, String className, String methodName,
        String methodDesc, AdviceListener listener) {
    classLoader = wrap(classLoader);
    className = className.replace('/', '.');

    ClassLoaderAdviceListenerManager manager = adviceListenerMap.get(classLoader);

    if (manager == null) {
        manager = new ClassLoaderAdviceListenerManager();
        adviceListenerMap.put(classLoader, manager);
    }
    manager.registerAdviceListener(className, methodName, methodDesc, listener);
}

会发现在这个方法中将入参中的listener初始化到上文看到的map中, manager.registerAdviceListener中还有一点简单的逻辑,这里就不在详述。问题的关键在于listener是从外部传入的,我们再看下调用registerAdviceListener的上下文:

// enter/exist 总是要插入 listener
AdviceListenerManager.registerAdviceListener(inClassLoader, className, methodNode.name, methodNode.desc,
        listener);
affect.addMethodAndCount(inClassLoader, className, methodNode.name, methodNode.desc);

正是在Enhancer的的transform方法中,这里的listenerEnhancer初始化的时候传值进来的:

public Enhancer(AdviceListener listener, boolean isTracing, boolean skipJDKTrace, Matcher classNameMatcher,
        Matcher methodNameMatcher) {
    this.listener = listener;
    this.isTracing = isTracing;
    this.skipJDKTrace = skipJDKTrace;
    this.classNameMatcher = classNameMatcher;
    this.methodNameMatcher = methodNameMatcher;
    this.affect = new EnhancerAffect();
    affect.setListenerId(listener.id());
}

Enhancer的初始化只在一个地方,那就是EnhancerCommand#enhance

// 从CommandProcess对象中获取AdviceListener实例
AdviceListener listener = getAdviceListenerWithId(process);
if (listener == null) {
    logger.error("advice listener is null");
    String msg = "advice listener is null, check arthas log";
    process.appendResult(new EnhancerModel(effect, false, msg));
    process.end(-1, msg);
    return;
}
boolean skipJDKTrace = false;
if(listener instanceof AbstractTraceAdviceListener) {
    skipJDKTrace = ((AbstractTraceAdviceListener) listener).getCommand().isSkipJDKTrace();
}
// 初始化Enhancer
Enhancer enhancer = new Enhancer(listener, listener instanceof InvokeTraceable, skipJDKTrace, getClassNameMatcher(), getMethodNameMatcher());
AdviceListener getAdviceListenerWithId(CommandProcess process) {
    if (listenerId != 0) {
        AdviceListener listener = AdviceWeaver.listener(listenerId);
        if (listener != null) {
            return listener;
        }
    }
    return getAdviceListener(process);
}

getAdviceListenerWithId的实现比较简单,用map做了一层缓存,然后实际获取AdviceListener的过程都在getAdviceListener中, 而getAdviceListener却是一个抽象方法,具体的实现由下面的的这几个类提供,这几个方法正好就是我们要分析的几个需要插装才能实现的命令了image-20201221231705076

我们以本篇要讲的watch命令为例:

@Override
protected AdviceListener getAdviceListener(CommandProcess process) {
    return new WatchAdviceListener(this, process, GlobalOptions.verbose || this.verbose);
}

getAdviceListener会返回一个WatchAdviceListener的实例,这个类实现了before, afterReturningafterThrowing等方法,这些方法会按照他们的名字所示分别插装到目标方法的对应位置上。

watch命令的实现

通过前面的文章Arthas原理系列(三):服务端启动流程我们可以看到,命令的执行最终都会调用到Enhancerprocess方法中:

@Override
public void process(final CommandProcess process) {
    // ctrl-C support
    process.interruptHandler(new CommandInterruptHandler(process));
    // q exit support
    process.stdinHandler(new QExitHandler(process));

    // start to enhance
    enhance(process);
}

enhance通过SpyAPI调用了不同的命令的AdviceListener,从而实现不同命令不同的插装逻辑,我们看下watch命令的实现:

@Override
public void before(ClassLoader loader, Class<?> clazz, ArthasMethod method, Object target, Object[] args)
        throws Throwable {
    // 开始计算本次方法调用耗时
    threadLocalWatch.start();
    if (command.isBefore()) {
        watching(Advice.newForBefore(loader, clazz, method, target, args));
    }
}

before会插装到方法执行的起始位置,首先在方法执行前会启动一个本地的loacalWatch用于计时,如果命令指定了-b参数,将会调用watching命令直接输出结果:

private void watching(Advice advice) {
    try {
        // 本次调用的耗时
        double cost = threadLocalWatch.costInMillis();
        boolean conditionResult = isConditionMet(command.getConditionExpress(), advice, cost);
        if (this.isVerbose()) {
            process.write("Condition express: " + command.getConditionExpress() + " , result: " + conditionResult + "\n");
        }
        if (conditionResult) {
            // 根据OGNL表达式计算需要输出的表达式
            Object value = getExpressionResult(command.getExpress(), advice, cost);

            WatchModel model = new WatchModel();
            model.setTs(new Date());
            model.setCost(cost);
            model.setValue(value);
            model.setExpand(command.getExpand());
            model.setSizeLimit(command.getSizeLimit());

            process.appendResult(model);
            process.times().incrementAndGet();
            if (isLimitExceeded(command.getNumberOfLimit(), process.times().get())) {
                abortProcess(process, command.getNumberOfLimit());
            }
        }
    } catch (Throwable e) {
        logger.warn("watch failed.", e);
        process.end(-1, "watch failed, condition is: " + command.getConditionExpress() + ", express is: "
                      + command.getExpress() + ", " + e.getMessage() + ", visit " + LogUtil.loggingFile()
                      + " for more details.");
    }
}

watching命令的执行逻辑也不复杂,主要完成以下几个工作:

  1. 通过之前设置的threadLocalWatch获取本次调用的耗时
  2. 根据OGNL表达式计算要输出到客户端的表达式,比如:"{params,returnObj}",将会输出该方法的入参和返回值,有关OGNL表达式的语法,请看文章OGNL语法规范
  3. 新建一个WatchModel的实例,然后将方法执行的耗时和第2步获取到的表达式初始化到WatchModel实例中,WatchModel是Arthas返回给客户端的统一的结果
  4. 查看观察次数是否已经超过命令设置的上限,如果是,则直接终止。从代码中看,默认的观察次数上线是100,可以通过-n参数修改。
@Override
public void afterReturning(ClassLoader loader, Class<?> clazz, ArthasMethod method, Object target, Object[] args, Object returnObject) throws Throwable {
    Advice advice = Advice.newForAfterRetuning(loader, clazz, method, target, args, returnObject);
    if (command.isSuccess()) {
        watching(advice);
    }

    finishing(advice);
}

@Override
public void afterThrowing(ClassLoader loader, Class<?> clazz, ArthasMethod method, Object target, Object[] args,Throwable throwable) {
    Advice advice = Advice.newForAfterThrowing(loader, clazz, method, target, args, throwable);
    if (command.isException()) {
        watching(advice);
    }

    finishing(advice);
}
  • command.isSuccess())对应参数s,代表在在方法返回之后观察

  • command.isException()对应参数e, 代表在方法异常之后观察

  • finishing(advice)对应参数f,代表在方法结束之后(正常返回和异常返回)观察

  • command.isBefore()对应参数b, 代表在在方法调用之前观察

小结

我们在这篇文章总先是详细看了AdviceListener的实现过程,理解了它的工作原理就可以理解Arthas是如何将各种不同的命令的插装都统一在统一个框架中的,并且这个类的原理也是其他所有需要插装的基础,所以花费了比较多的笔墨进行分析。随后看了watch命令的实现,这是需要插装的命令中最简单的一个命令,在上一篇文章Arthas原理系列(四):字节码插装让一切变得有可能中我们详细分析了Arthas如何在运行时拿到方法的入参,返回值等信息的,watch命令在其基础上只加了一个计时的功能,因此逻辑是比较简单的