假设你已经对Skywalking有所了解,并想知道Skywalking的追踪原理,对如何开发一个Skywalking插件有兴趣,本文能有所帮助。
链式追踪任务
如图所示,其中蓝色的原点串连起来就是一个请求通过API网关进来之后的一个调用链,同理还有另一个请求对应的绿色调用链。上图可以清晰地看出不同的请求在服务之间的走向,同时在请求在每个服务的停留时间都可以统计出来,这为我们分析优化或者预警告警提供了很大的帮助。那Skywalking是如何实现上述的追踪呢?
追踪原理
那么我们该如何在服务中拦截请求呢,这就是我们埋点过程了。
【需重点消化】埋点的流程可以总结为这张图,理解了这张图,你就看懂Skywalking插件的源码就没问题。
简单地讲,我们可以在每个被调用的服务中埋下的一个调用点(称为一个Span),等到调用结束的时候就将Span上传至分析中心。这样我们就知道被调用了哪些服务。但这些Span还无法被串联起来。因此可以规则每个Span都有一个TraceId,并且属于同个请求的Span都有同个TraceId,因此由点连成线,将所用同个TraceId的Span连接起来就可以形成一个完整的追踪链。下面我们具体分析ServiceA和ServiceB的埋点过程,来看看如何具体实现埋点: 对于Service A(同个线程内的追踪):
1、首先有请求进入,创建entry span(“进入span”)来标志这个请求,
2、提取一些信息(一般是TracId)放在span中(如果客户端并没有传递信息过来,那提取结果为空),进行业务处理
3、需要调用其他服务端时,则创建exit span("离开span")
4、同时在调用的头部注入一些信息(其中最重要的就是TraceId),该信息被服务端接收后,服务端创建的span也是同个TraceId,以此串成一个调用链。
5、服务端请求返回后,关闭exit span,表明当前一次调用已经完成。
6、整个业务结果完成后,关闭entry span,表明请求已经完成。
说明:被关闭的Span会被适时地发送给分析中心做汇总。
再接着看看Service B的追踪,包含了跨线程的调用:
1、Service B接到Service A的请求,首先创建entry span(“进入span”)来标志这个请求。
2、由于客户端是Service A,其在请求头部(http头部/RPC调用头部)放置了一些关于exit span/TraceId的信息。因此将其提取出来为Context carrier对象,并用于填充当前的entry span信息,那么调用关系就建立起来了。在图中保持和客户端紫色,说明同一个追踪链。
3、创建完毕之后,继续进行正常的业务逻辑。同时也可以像Service A那样创建exit span,然后调用其他服务端,调用后结束exit span。接下来讲讲如果在业务处理中调用了其他线程,调用该怎么继续追踪。
4、这里可以分为四步:
- A:在调用新线程之前,我们在当前线程创建一个local span(“本地”span)。
- B:然后调用Capture方法将当前线程的关于span的SnapShot(快照)保存下来,该快照保存了当前线程中追踪的相关信息,为了继续追踪,我们接着将该快照传递给新线程(可以通过函数参数传递方式,也可以其他方式)。
- C:在新线程中接收到快照后,第一时间将快照进行contiue操作,也就是重放这个快照,使得上个线程的追踪信息得以延续,再次将调用关系串联起来。
- D:调用新线程完毕后可以关闭Local span,表明一次本地调用结束。
5、出了调用线程的模块,可以继续处理业务逻辑。处理完毕后关闭entry span。
总结一下:
1、在跨进程的调用中,追踪信息被放在调用报文(HTTP报文、RPC报文等)中传播。
2、而在跨线程的调用中,需要将调用线程中包含有追踪信息的快照传递给被调用线程,这样被调用线程拿到快照后进行重放,那么整个调用就可以串起来了。
3、在Skywalking中,对一个请求的追踪可认为是一个Trace,而在每个服务停留所采集的数据称为TraceSegment,同时TraceSegment里包含多个Span,同个TarceId的Span串联成一个追踪链。
4、由于span是追踪调用关系,因此span的生命周期和调用关系相近。整个span的操作就像函数栈一样,例如:创建entry span -> 创建exit span1 ->创建exit span2 -> 关闭exit span2 -> 关闭exit span1 -> 关闭entry span。还要注意到函数有进有出,span也就有创建有关闭。
5、实现链式追踪的关键在于在合适的位置创建Span、设置Span的信息,并保证Span的信息随着调用而传播。而编写埋点插件就是干这事的。
编写埋点插件(jdk-http-plugin为例)
有了这个思路之后,我们以jdk-http-plugin为例,看看如何编写一个插件对jdk提供的HttpClient工具类进行增强。
为了用户方便地对服务进行拦截(实际上是对服务中的某个方法进行拦截),java-agent提供了一种开发插件的模板,理解起来非常容易,分为三步:
1、声明被拦截的方法
2、声明拦截动作(方法调用前,方法调用后),也就是写拦截器
3、将拦截器和被拦截方法关联起来。
下面针对jdk-http-plugin插件的代码来分析,如何实现上面三步:
1、声明被拦截的方法:只需要写一个类来继承ClassEnhancePluginDefine来声明需要拦截哪些方法(这个类一般后缀带Instrumentation,如HttpClientInstrumentation,这样容易联想起到该类起到埋点的作用。)具体如下:
public class HttpClientInstrumentation extends ClassEnhancePluginDefine {
// 拦截哪个类?可根据类名来匹配
@Override
protected ClassMatch enhanceClass() {
return NameMatch.byName("sun.net.www.http.HttpClient");
}
// 是否拦截该类的构造方法?如果返回空则不拦截,否则返回一个ConstructorInterceptPoint类型的数组即可。
@Override
public ConstructorInterceptPoint[] getConstructorsInterceptPoints() {
}
// 拦截哪些实例方法?
@Override
public InstanceMethodsInterceptPoint[] getInstanceMethodsInterceptPoints() {
}
// 拦截哪些静态方法?
@Override
public StaticMethodsInterceptPoint[] getStaticMethodsInterceptPoints() {
}
}
2、声明拦截动作:也就是写一个继承自InstanceMethodsAroundInterceptor的拦截器就可以拦截实例方法。
同理继承自StaticMethodsAroundInterceptor的类用来拦截类方法了。拦截器需要实现三个方法:
public class HttpsClientNewInstanceInterceptor implements StaticMethodsAroundInterceptor {
// 被拦截方法调用前,应该采取的拦截动作。
@Override
public void beforeMethod(Class clazz, Method method, Object[] allArguments, Class<?>[] parameterTypes,
MethodInterceptResult result) {
}
// 被拦截方法调用后,应该采取的拦截动作。
@Override
public Object afterMethod(Class clazz, Method method, Object[] allArguments, Class<?>[] parameterTypes,
Object ret) {
if (ret instanceof EnhancedInstance) {
((EnhancedInstance) ret).setSkyWalkingDynamicField(allArguments[6]);
}
return ret;
}
// 被拦截方法发生异常后,应该采取的动作。
@Override
public void handleMethodException(Class clazz, Method method, Object[] allArguments, Class<?>[] parameterTypes,
Throwable t) {
}
}
3、为了将被拦截的方法和拦截器关联起来,我们在Instrumentation类中返回对应的拦截器:
public class HttpClientInstrumentation extends ClassEnhancePluginDefine {
private static final String BEFORE_METHOD = "writeRequests";
private static final String INTERCEPT_WRITE_REQUEST_CLASS = "xxx.HttpsClientNewInstanceInterceptor";
public InstanceMethodsInterceptPoint[] getInstanceMethodsInterceptPoints() {
return new InstanceMethodsInterceptPoint[] {
new DeclaredInstanceMethodsInterceptPoint() {
// 拦截BEFORE_METHOD方法
@Override
public ElementMatcher<MethodDescription> getMethodsMatcher() {
return named(BEFORE_METHOD).and(takesArguments(2).or(takesArguments(1)));
}
// BEFORE_METHOD方法对应的拦截器是INTERCEPT_WRITE_REQUEST_CLASS
// 也就是上一步的HttpsClientNewInstanceInterceptor
@Override
public String getMethodsInterceptor() {
return INTERCEPT_WRITE_REQUEST_CLASS;
}
}
}
}
另外为了方便Java-Agent找到本插件的Instrumentation类,还需要在resource目录中的skywalking-plugin.def文件中说明本插件的所有Instrumentation类。
其中多了HttpsClientInstrumentation类是为了拦截HTTPS请求的,原理一样。
拦截器实现(jdk-http-plugin为例)
一般常用于发送http请求的是HttpClient类,梳理HTTPClient的用法,流程如下:
因此我们可以
1、拦截writeRequest方法来捕获发送的HTTP请求报文,这时代表着请求另一个服务,因此我们创建ExitSpan,并将一些必要信息放置到HTTP报文头部中,随着报文传播到服务端。
2、拦截parseHTTP方法来接收HTTP响应报文,判断请求是否成功。同时根据返回结果设置刚才ExitSpan的状态(成功与否),然后关闭ExitSpan来标志一次调用结束。
3、如果Span需要更进一步的信息,可拦截HttpClient的构造函数来获取。
接下看代码中怎么实现:
为了获取更多关于请求的信息,拦截HttpClient对象的构造方法,将信息存放在动态域中。(HttpClientNewInstanceInterceptor拦截器)
// 在构造方法调用完毕后拦截:
public Object afterMethod(Class clazz, Method method, Object[] allArguments, Class<?>[] parameterTypes,
Object ret) {
// ret是已经完成初始化的HttpClient对象
// 同时Java-Agent-core模块会自动地对ret做一个增强
// 使其实现EnhancedInstance接口
// (实际上就是对ret增加一个属性,用于存放一些数据,如TraceId等,方便该数据随着实例传播)
if (ret instanceof EnhancedInstance) {
// allArguments[4]是请求的HttpURLConnection对象
// 将URL放到ret中的动态属性中,方便后面埋点时使用
((EnhancedInstance) ret).setSkyWalkingDynamicField(allArguments[4]);
}
return ret;
}
public interface EnhancedInstance {
// 获取动态域的数据
Object getSkyWalkingDynamicField();
// 添加动态域的数据
void setSkyWalkingDynamicField(Object value);
}
拦截writeRequest方法,其中关键的Span操作都有注释
(HttpClientWriteRequestInterceptor拦截器)
@Override
public void beforeMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes,
MethodInterceptResult result) throws Throwable {
// 获取在构造函数拦截器中预埋的HttpURLConnection对象
HttpURLConnection connection = (HttpURLConnection) objInst.getSkyWalkingDynamicField();
MessageHeader headers = (MessageHeader) allArguments[0];
URL url = connection.getURL();
// 创建ContextCarrier对象,该对象的内容将随着HTTP报文,发送到服务端
ContextCarrier contextCarrier = new ContextCarrier();
// 由于这个调用的对象是其他服务,所以创建ExitSpan,同时设置contextCarrier的内容
AbstractSpan span = ContextManager.createExitSpan(getPath(url), contextCarrier, getPeer(url));
span.setComponent(ComponentsDefine.JDK_HTTP);
Tags.HTTP.METHOD.set(span, connection.getRequestMethod());
Tags.URL.set(span, url.toString());
SpanLayer.asHttp(span);
// 将ContextCarrier对象的内容放到HTTP头部中,传播到服务端。
CarrierItem next = contextCarrier.items();
while (next.hasNext()) {
next = next.next();
headers.set(next.getHeadKey(), next.getHeadValue());
}
}
拦截paresHTTP方法,其中关键的Span操作都有注释
(HttpClientParseHttpInterceptor拦截器)
@Override
public Object afterMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes,
Object ret) throws Throwable {
MessageHeader responseHeader = (MessageHeader) allArguments[0];
String statusLine = responseHeader.getValue(0);
Integer responseCode = parseResponseCode(statusLine);
// 返回HTTP状态码不正常,
if (responseCode >= 400) {
// 因为是同步方法,因此当前ContextManager中的span就是刚刚writeRequest方法放入的exitSpan,将其取出。
AbstractSpan span = ContextManager.activeSpan();
// 同时设置HTTP错误码
span.errorOccurred();
Tags.HTTP_RESPONSE_STATUS_CODE.set(span, responseCode);
}
// 调用结束,将当前ContextManager中的span(也就是writeRequest方法放入的ExitSpan)关闭
ContextManager.stopSpan();
return ret;
}
总结一下:
本文参考自Skywalking作者的演讲,若本人理解不当望读者指出。