collie
使用Java实现一个分布式调用链追踪系统
现在项目已经开源,欢迎提pr和star,项目地址:分布式调用链追踪系统Collie
项目系列博客地址:
柠檬好酸啊:用Java实现一个分布式调用链追踪系统 (一)聊聊自己的想法
柠檬好酸啊:用java实现一个分布式调用链追踪系统(二)项目搭建过程中的一些注意事项
柠檬好酸啊:用java实现一个分布式调用链追踪系统(三)最核心的实现之Javassist
柠檬好酸啊:用java实现一个分布式调用链追踪系统(四)项目具体实现
柠檬好酸啊:用java实现一个分布式调用链追踪系统(五)总结
项目创建
我们的项目分为三个部分,分别是spy、agent和core,项目目录如下所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-j9tRM5rF-1637399360000)(/Users/dongzhonghua03/Library/Application Support/typora-user-images/image-20211115234735325.png)]
其中spy在第二节里面已经介绍过了,很简单就一个文件。今天重点介绍一下agent和core的实现。
agent 的实现
agent应用最重要的是要在menifest文件中指定Premain-Class和Agent-Class,我这里的设置如下:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>single</goal>
</goals>
<phase>package</phase>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifestEntries>
<!-- 注册premain的class -->
<Premain-Class>xyz.dsvshx.collie.agent.CollieAgent</Premain-Class>
<!-- 注册agentmain的class -->
<Agent-Class>xyz.dsvshx.collie.agent.CollieAgent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
<Specification-Title>${project.name}</Specification-Title>
<Specification-Version>${project.version}</Specification-Version>
<Implementation-Title>${project.name}</Implementation-Title>
<Implementation-Version>${project.version}</Implementation-Version>
<Boot-Class-Path>javassist-3.27.0-GA.jar</Boot-Class-Path>
</manifestEntries>
</archive>
</configuration>
</execution>
</executions>
</plugin>
agent入口类最重要的两个函数如下:
private static final String USER_HOME = System.getProperty("user.home");
private static final String COLLIE_CORE_JAR = USER_HOME + "/.collie/collie-core.jar";
private static final String COLLIE_SPY_JAR = USER_HOME + "/.collie/collie-spy.jar";
private static final String TRANSFORMER = "xyz.dsvshx.collie.core.instrumentation.CollieClassFileTransformer";
private static ClassLoader collieClassLoader;
private static ScheduledExecutorService EXECUTOR_SERVICE;
/**
* jvm启动时运行这个函数
*/
public static void premain(String agentOps, Instrumentation instrumentation) {
main(agentOps, instrumentation);
}
public static void agentmain(String agentOps, Instrumentation instrumentation) {
main(agentOps, instrumentation);
}
main方法具体的实现如下:
private static synchronized void main(String agentOps, Instrumentation instrumentation) {
try {
log.info(">>>>>>>collie agent 启动...");
// 使用启动类加载器load spy
File agentSpyFile = new File(COLLIE_SPY_JAR);
if (!agentSpyFile.exists()) {
System.out.println("Spy jar file does not exist: " + agentSpyFile);
return;
}
// 使用启动类加载器加载agentSpy
instrumentation.appendToBootstrapClassLoaderSearch(new JarFile(agentSpyFile));
// load core
File agentCoreFile = new File(COLLIE_CORE_JAR);
if (!agentCoreFile.exists()) {
System.out.println("Agent jar file does not exist: " + agentCoreFile);
return;
}
// 使用自定义的类加载器加载core包
ClassLoader collieClassLoader = getClassLoader(instrumentation, agentCoreFile);
// addTransformer 使用transformer来进行插桩
Class<?> collieClassFileTransformer = collieClassLoader.loadClass(TRANSFORMER);
Constructor<?> transform = collieClassFileTransformer.getDeclaredConstructor(String.class);
instrumentation.addTransformer((ClassFileTransformer) transform.newInstance(COLLIE_SPY_JAR));
// jvm信息,暂时关闭
// processJvmInfo();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
其中,core包需要用自定义类加载器来加载,自定义类加载器的逻辑为:
@Slf4j
public class CollieClassLoader extends URLClassLoader {
public CollieClassLoader(URL[] urls) {
super(urls, getSystemClassLoader().getParent());
}
@Override
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
final Class<?> loadedC = findLoadedClass(name);
if (loadedC != null) {
return loadedC;
}
// 优先从parent(SystemClassLoader)里加载系统类,避免抛出ClassNotFoundException
if (name != null && (name.startsWith("sun.") || name.startsWith("java."))) {
return super.loadClass(name, resolve);
}
if (name != null && name.contains("xyz.dsvshx.collie.point")) {
return super.loadClass(name, resolve);
}
try {
Class<?> loadedClass = findClass(name);
if (loadedClass != null) {
if (resolve) {
resolveClass(loadedClass);
}
return loadedClass;
}
} catch (ClassNotFoundException ignored) {
}
return super.loadClass(name, resolve);
}
}
在agent中有这个一条语句:
instrumentation.addTransformer((ClassFileTransformer) transform.newInstance(COLLIE_SPY_JAR));
这条语句的意思是给instrumentation添加一个transformer,在transformer里就可以使用javassist对加载的类做一次转换,插入我们想要的埋点。
埋点主要分为两个部分:
一个部分是针对方法进行埋点,采用的方式是使用javassist。
第二个是针对框架进行适配,也就是说需要把spanId和traceId写入ThreadLocal,那样在最后的时候就可以从ThreadLocal中获取并且传递到下一个组件中。这里作为例子我适配的是我自己写的一个简单的spring的框架summer。当然针对spring和其他的rpc等框架适配也是可以的。
Spy的实现类
首先创建一个AbstactSpy的实现类:public class SpyImpl extends AbstractSpy,拿方法入口来说,方法中插入的埋点最终会调用这个函数,然后再执行自定义的MethodAspect。对于框架的适配器来说,道理是一样的。
@Override
public void atEnter(Class<?> clazz, String methodInfo, Object target, Object[] args) {
try {
if (SamplingRate.needSampling()) {
String[] methodInfos = splitMethodInfo(methodInfo);
String methodName = methodInfos[0];
String methodDesc = methodInfos[1];
for (MethodAspect methodAspect : methodAspects) {
methodAspect.before(clazz, methodName, methodDesc, target, args);
}
}
} catch (Throwable e) {
e.printStackTrace();
}
}
@Override
public void atFrameworkEnter(String traceId, String spanId, String parentSpanId) {
for (FrameworkAspect frameworkAspect : frameworkAspects) {
try {
frameworkAspect.entry(traceId,spanId,parentSpanId);
} catch (Throwable throwable) {
//
}
}
}
方法切面
方法切面分为两个部分,第一部分是实现方法切面的字节码插桩,就是将我们的埋点在类加载的时候通过修改字节码的方式植入进去。我们的方法埋点的方式如下所示:
public byte[] modifyClass(String className, byte[] classfileBuffer, String spyJarPath) {
try {
ClassPool classPool = ClassPool.getDefault();
// 必须要有这个,否则会报point找不到,搞了大半天 https://my.oschina.net/xiaominmin/blog/3153685
classPool.appendClassPath(spyJarPath);
String clazzname = className.replace("/", ".");
CtClass ctClass = classPool.get(clazzname);
// 排除掉注解,接口,枚举
if (!ctClass.isAnnotation() && !ctClass.isInterface() && !ctClass.isEnum()) {
// 针对所有函数操作
for (CtBehavior ctBehavior : ctClass.getDeclaredMethods()) {
addMethodAspect(clazzname, ctBehavior, false);
}
}
return ctClass.toBytecode();
} catch (Exception e) {
e.printStackTrace();
return classfileBuffer;
}
}
private static void addMethodAspect(String clazzname, CtBehavior ctBehavior, boolean isConstructor)
throws Exception {
if (isNative(ctBehavior)
|| isAbstract(ctBehavior)
|| "toString".equals(ctBehavior.getName())
|| "getClass".equals(ctBehavior.getName())
|| "equals".equals(ctBehavior.getName())
|| "hashCode".equals(ctBehavior.getName())) {
return;
}
// 方法前增强
// 如果是基本数据类型的话,传参为Object是不对的,需要转成封装类型
// 转成封装类型的话非常方便,使用$w就可以,不影响其他的Object类型
String methodName = isConstructor ? ctBehavior.getName() + "#" : ctBehavior.getName();
String methodInfo = methodName + "|" + ctBehavior.getMethodInfo().getDescriptor();
String target = isStatic(ctBehavior) ? "null" : "this";
ctBehavior.insertBefore(
String.format("{xyz.dsvshx.collie.point.SpyAPI.atEnter(%s, \"%s\", %s, %s);}",
"$class", methodInfo, target, "($w)$args")
);
// 方法后增强
ctBehavior.insertAfter(
String.format("{xyz.dsvshx.collie.point.SpyAPI.atExit(%s, \"%s\", %s, %s, %s);}",
"$class", methodInfo, target, "($w)$args", "($w)$_")
);
// 异常出增强
ctBehavior.addCatch(
String.format("{xyz.dsvshx.collie.point.SpyAPI.atExceptionExit(%s, \"%s\", %s, %s, %s);"
+ "throw $e;}",
"$class", methodInfo, target, "($w)$args", "$e"),
ClassPool.getDefault().get("java.lang.Throwable")
);
}
上面是把埋点插入了calss,当真正调用的时候就会出发Spy中的方法,最终会调用我们自定义的实现类。
然后我们来定义一个方法切面接口,它的实现类就是我们自己想实现的逻辑,比如打印日志或者将日志发送到kafka或者其他数据库。
public interface MethodAspect {
void before(Class<?> clazz, String methodName, String methodDesc, Object target, Object[] args) throws Throwable;
void after(
Class<?> clazz, String methodName, String methodDesc, Object target, Object[] args, Object returnObject)
throws Throwable;
void error(
Class<?> clazz, String methodName, String methodDesc, Object target, Object[] args, Throwable throwable)
throws Throwable;
}
框架适配
框架的适配也包含两个部分,第一个部分是需要在一个框架入口处在ThreadLocal中埋入traceId和spanId等信息。第二个部分是需要插桩并进一步实现一些自己的逻辑,比如打印日志等。
第一部分,在入口处插入trace信息的代码如下:
private final static ThreadLocal<TransactionInfo> TRANSACTION_INFO_THREAD_LOCAL = new ThreadLocal<>();
private static TransactionInfo TRANSACTION_INFO;
public void entry(String traceId, String spanId, String parentSpanId) {
try {
if (traceId == null || traceId.trim().length() == 0) {
traceId = UUID.randomUUID().toString();
}
if (spanId == null || spanId.trim().length() == 0) {
spanId = UUID.randomUUID().toString();
}
if (parentSpanId == null || parentSpanId.trim().length() == 0) {
parentSpanId = "-1";
}
TRANSACTION_INFO = new TransactionInfo(traceId, spanId, parentSpanId);
TRANSACTION_INFO_THREAD_LOCAL.set(TRANSACTION_INFO);
System.out.printf(">>>>>>>>>Thread %s set, transaction info :%s%n", Thread.currentThread().getName(),
TRANSACTION_INFO);
} catch (Exception e) {
e.printStackTrace();
}
}
第二部分,也有一些值得注意的地方。前面说过,我们的traceId之类的参数需要一直在不同的中间件里传递下去,其中的方法就是使用ThreadLocal,我们可以在一个框架开始的地方吧traceId插入到ThreadLocal中,最后读出来传递到下一个中间件中。我这里做适配的是一个框架,是我自己模仿Spring的一个小工具,我叫他Summer。
public byte[] modifyClass(String className, byte[] classBytes, String spyJarPath) {
try {
if (SUMMER_ADAPTOR_CLASS.equals(className)) {
ClassPool classPool = ClassPool.getDefault();
classPool.appendClassPath(spyJarPath);
String clazzname = className.replace("/", ".");
CtClass ctClass = classPool.get(clazzname);
CtMethod doHandlerMethod = ctClass.getDeclaredMethod("doHandler");
// 没想到这么简单就成了?
doHandlerMethod.insertBefore("{"
+ "String traceId = fullHttpRequest.headers().get(\"collie-trace-id\");"
+ "String parentSpanId = fullHttpRequest.headers().get(\"collie-span-id\");"
+ "xyz.dsvshx.collie.point.SpyAPI.atFrameworkEnter(traceId, \"\", parentSpanId);"
+ "}");
doHandlerMethod.insertAfter("{"
+ "String traceId = fullHttpRequest.headers().get(\"collie-trace-id\");"
+ "String parentSpanId = fullHttpRequest.headers().get(\"collie-span-id\");"
+ "xyz.dsvshx.collie.point.SpyAPI.atFrameworkExit(traceId + \"|\" + parentSpanId);"
+ "}");
return ctClass.toBytecode();
}
} catch (Exception e) {
e.printStackTrace();
}
return classBytes;
}
通过上面说的过程,最终的一个方法会被转化成这个样子:
public static <T> T getInstance(Class<T> clz, Constructor<T> constructor, Object[] args) {
try {
SpyAPI.atEnter(Desc.getClazz("xyz.dsvshx.ioc.util.BeanUtils"), "getInstance|(Ljava/lang/Class;Ljava/lang/reflect/Constructor;[Ljava/lang/Object;)Ljava/lang/Object;", (Object)null, new Object[]{clz, constructor, args});
Object var10000;
try {
var10000 = constructor == null ? clz.newInstance() : constructor.newInstance(args);
} catch (Exception var7) {
var7.printStackTrace();
log.error("创建对象失败");
var10000 = null;
}
Object var5 = var10000;
SpyAPI.atExit(Desc.getClazz("xyz.dsvshx.ioc.util.BeanUtils"), "getInstance|(Ljava/lang/Class;Ljava/lang/reflect/Constructor;[Ljava/lang/Object;)Ljava/lang/Object;", (Object)null, new Object[]{clz, constructor, args}, var5);
return var5;
} catch (Throwable var8) {
SpyAPI.atExceptionExit(Desc.getClazz("xyz.dsvshx.ioc.util.BeanUtils"), "getInstance|(Ljava/lang/Class;Ljava/lang/reflect/Constructor;[Ljava/lang/Object;)Ljava/lang/Object;", (Object)null, new Object[]{clz, constructor, args}, var8);
throw var8;
}
}
对一个框架的改造最终的结果是这样:
public static FullHttpResponse doHandler(WebApplicationContext webApplicationContext, FullHttpRequest fullHttpRequest) {
String var2 = fullHttpRequest.headers().get("collie-trace-id");
String var3 = fullHttpRequest.headers().get("collie-span-id");
SpyAPI.atFrameworkEnter(var2, "", var3);
HttpMethod method = fullHttpRequest.method();
FullHttpResponse var10000;
if (HttpMethod.GET.equals(method)) {
var10000 = handleGet(webApplicationContext, fullHttpRequest);
} else if (HttpMethod.POST.equals(method)) {
var10000 = handlePost(webApplicationContext, fullHttpRequest);
} else {
log.error("不支持该方法");
var10000 = null;
}
FullHttpResponse var5 = var10000;
String var6 = fullHttpRequest.headers().get("collie-trace-id");
String var7 = fullHttpRequest.headers().get("collie-span-id");
SpyAPI.atFrameworkExit(var6 + "|" + var7);
return var5;
}
到这里,最核心的部分已经介绍完了,下一篇是本系列的最后一篇,介绍几个遗留的问题,然后再介绍一下项目如何运行以及结果的展示。
如果你喜欢,欢迎点赞关注加收藏,后续我还会继续做一些类似的有意思的java玩具。