1. 程式人生 > 程式設計 >Bistoury原理解析

Bistoury原理解析

今天想和大家聊聊Java中的APM,簡單介紹Java中的Instrumentation技術,然後重點分析bistoury的實現原理

Instrumentation

即Java探針技術,通過Instrumentation,開發者可以構建一個獨立於應用程式的代理程式(Agent),用來監測和協助執行在JVM上的程式,甚至能夠替換和修改某些類的定義而對業務程式碼沒有侵入,主要場景如APM,常見的框架有:SkyWalkingPinpointZipkinCATarthasbistoury其實也算吧。

推薦一篇部落格:Instrumentation

靜態Instrumentation

從JDK1.5開始支援

Agent邏輯在main方法之後執行,兩個關鍵方法:

// 優先順序高
public static void premain(String agentOps,Instrumentation instrumentation);
public static void premain(String agentOps);
複製程式碼

通常agent的包裡面MATE-INF目錄下的MANIFEST.MF中會有這樣一段宣告

Premain-Class: Agent全類名
複製程式碼

在啟動應用的時候,新增Agent引數觸,Agent邏輯在main方法之後執行

java -javaagent:agentJar.jar="Hello World"
-jar agent-demo.jar 複製程式碼

動態Instrumentation

從JDK1.6開始支援

Agent邏輯在main方法之後執行,兩個關鍵方法:

// 優先順序高
public static void agentmain(String agentArgs,Instrumentation inst); 
public static void agentmain(String agentArgs);  
複製程式碼

可以動態觸發,通過VirtualMachine這個類attach到對應的JVM,然後執行VirtualMachine#loadAgent方法

VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent(agentJar路徑);
複製程式碼

在程式執行的過程中,可以通過 Instrumentation API 動態新增自己實現的 ClassFileTransformer

Instrumentation#addTransformer(ClassFileTransformer)
複製程式碼

ClassFileTransformer

An agent provides an implementation of this interface in order to transform class files. The transformation occurs before the class is defined by the JVM

代理程式(即自己的Agent)提供實現類,用於修改class檔案,該操作發生在 JVM 載入 class 之前。它只有一個transform方法,實現該方法可以修改 class位元組碼,並返回修改後的 class位元組碼,有兩點要注意:

  1. 如果此方法返回null,表示我們不對類進行處理直接返回。否則,會用我們返回的byte[]來代替原來的類
  2. ClassFileTransformer必須新增進Instrumentation才能生效 Instrumentation#addTransformer(ClassFileTransformer)
  3. 當存在多個Transformer時,一個Transformer呼叫返回的byte陣列將成為下一個Transformer呼叫的輸入
byte[] transform(ClassLoader         loader,String              className,Class<?>            classBeingRedefined,ProtectionDomain    protectionDomain,byte[]              classfileBuffer) throws IllegalClassFormatException;
複製程式碼

例如

// 定義一個 ClassFileTransformer
public abstract class Transformer implements ClassFileTransformer {
    private static final Logger logger = BistouryLoggger.getLogger();

    @Override
    public byte[] transform(ClassLoader loader,String className,Class<?> classBeingRedefined,ProtectionDomain protectionDomain,byte[] classfileBuffer) throws IllegalClassFormatException {
        try {
            if(className.equals(xxxx)){
                通過ASM修改位元組碼,並返回修改後的位元組碼
            }
            return null;
        } catch (Throwable e) {
            logger.error("","transform failed","Classs: {},ClassLoader: {} transform failed.",className,loader,e);
        }
    }
}

// 新增一個Agent  JDK1.5
public static void premain(String agentArgs,Instrumentation inst) {
    inst.addTransformer(new Transformer());
}
// 觸發Agent JDK1.5
java -javaagent:/agent.jar="傳遞的引數" -jar test.jar


// 新增一個Agent  JDK1.6
public static void agentmain (String agentArgs,Instrumentation inst) {
    inst.addTransformer(new Transformer());
}
// 觸發Agent JDK1.6
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent("agent.jar");
複製程式碼

ASM

有關於ASM的簡單介紹,推薦兩篇部落格:ASM訪問者模式ASM使用

  1. ClassReader: 資料結構。將位元組陣列或者class檔案讀入到記憶體當中,並以樹的資料結構表示,樹中的一個節點代表著class檔案中的某個區域
  2. ClassVisitor: 操作。呼叫ClassReader#accept方法,傳入一個ClassVisitor物件,在ClassReader中遍歷樹結構的不同節點時會呼叫不同ClassVisitor物件中的visit方法,從而實現對位元組碼的修改
  3. ClassWriter: 操作。ClassWriterClassVisitor的實現類,它是生成位元組碼的工具類, 將位元組 輸出為 byte[],在責任鏈的末端,呼叫ClassWriter#visitor 進行修改後的位元組碼輸出工作

JMX

JMX(Java Management Extensions)是一個為應用程式植入管理功能的框架。JMX是一套標準的代理和服務,實際上,使用者可以在任何Java應用程式中使用這些代理和服務實現管理

說的有點抽象,推薦一篇部落格 JMX

我自己的理解,JMX分為ServerClient,MBean是它的核心概念

  1. Server: MBean的容器,負責管理所有的MBean,同時我認為它就是一個Agent程式,在Java應用啟動的時候自己啟動。讓我不太明白的是,為什麼通過jps命令不能看到這個程式呢?
  2. Client: 即客戶端,可以和Server建立連線,常見的客戶端有:jvisualvm、jconsole、自己小工具
  3. MBean: JMX裡面的一個概念,可以通過自定義MBean做一些事情,動態改改屬性值啥的,也就是說,JMX只認識MBean,不認識別的。基於內建的一些MBean,可以獲取記憶體、執行緒、系統等指標資訊。所以如果想做一些監控上的事情,可以基於它內建的MBean

Bistoury

去哪兒網開源的一個對應用透明無侵入的Java應用診斷工具,可以讓開發人員無需登入機器或修改系統,就可以從日誌、記憶體、執行緒、類資訊、除錯、機器和系統屬性等各個方面對應用進行診斷,提升開發人員診斷問題的效率和能力。內部集成了arthas,所以它是arthas的超集。其中兩個比較有特色的功能:線上DEBUG、動態監控,就是基於 Instrumentation + ASM 做的。

在開始分析這個框架之前,可以先看看它的整體架構 Bistoury設計檔案

啟動流程

Agent

前置處理

bistoury-instrument-agent模組,這就是Agent,裡面有一個核心類 AgentBootstrap2,該類同時持有 premain 和 agentmain 方法,並在pom.xml檔案中配置了 Premain-ClassAgent-Class

public static void premain(String args,Instrumentation inst) {
    main(args,inst);
}

public static void agentmain(String args,inst);
}

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <showDeprecation>true</showDeprecation>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <version>2.4</version>
            <configuration>
                <archive>
                    <manifestEntries>
                        <Premain-Class>qunar.tc.bistoury.instrument.agent.AgentBootstrap2</Premain-Class>
                        <Agent-Class>qunar.tc.bistoury.instrument.agent.AgentBootstrap2</Agent-Class>
                        <Can-Redefine-Classes>true</Can-Redefine-Classes>
                        <Can-Retransform-Classes>true</Can-Retransform-Classes>
                    </manifestEntries>
                </archive>
            </configuration>
        </plugin>
    </plugins>
</build>
複製程式碼

從上可以看出,不管是 premain 和 agentmain 方法,裡面都呼叫了 main 方法,而 main 方法主要負責以下功能

  1. 類載入器相關,自定義類載入器
  2. 初始化arthas的java.arthas.Spy類,將AdviceWeaver中的各個方法引用賦值給Spy
  3. 初始化bistoury的qunar.tc.bistoury.instrument.spy.BistourySpys1類,將GlobalDebugContext,SnapshotCapture,AgentMonitor中的各個方法引用賦值給BistourySpys1,這些方法最總通過ASM方式進行呼叫
  4. 執行 BistouryBootstrap#bind方法,啟動一個telnetServer端,所以我們可以通過telnet向其傳送命令
GlobalDebugContext

和線上DEBUG相關,涉及到兩個方法, isHithasBreakpointSet,這兩個方法最終通過位元組碼的形式進行呼叫

  1. isHit: 判斷是否到達斷點
  2. hasBreakpointSet: 判斷是否已經存在斷點
SnapshotCapture

儲存實 屬性靜態屬性區域性變數方法呼叫堆疊 等資訊,最後返回將這些資訊返回給前端,涉及的方法列表如下:

  1. putLocalVariable: 儲存區域性變數資訊
  2. putField: 儲存屬性資訊
  3. putStaticField: 儲存靜態屬性資訊
  4. fillStacktrace: 儲存方法呼叫堆疊資訊
  5. dump: 將上面這些資訊存到DefaultSnapshotStore中
  6. endReceive: 有點不太懂?
AgentMonitor

AgentMonitor和動態監控相關,動態監控可以監控方法的呼叫次數、異常次數和執行時間,同時也保留最近幾天的監控資料。而動態監控的實現原理也很簡單,就是在方法執行前後記錄呼叫次數和響應時間,而這部分邏輯就是通過ASM動態插入位元組碼來實現的

  1. start: 記錄開始時間
  2. stop: 計算呼叫次數和耗時
  3. exception : 計算異常數
BistouryBootstrap

上面已經說過,在main方法中會呼叫BistouryBootstrap2#bind 方法,該方法用於啟動一個ShellServer,這裡指的是TelnetServer。 這個類參考了ArthasBootstrap,ArthasBootstrap#bind方法中,主要啟動了兩個ShellServer,即: TelnetServerHttpServer, 所以我們在使用arthas的時候可以通過web和telnet方式訪問。

BistouryBootstrapArthasBootstrap有些不同

  1. BistouryBootstrap只建立了TelnetServer, 並沒有建立HttpServer
  2. BistouryBootstraparthas的基礎上實現了一個自己的CommandResolver,即 QBuiltinCommandPack,該類負責管理所有的Command,也就是說,從功能上來講, bistouryarthas的超集;

核心bind方法如下,原始碼感興趣的自己看一下

public void bind(Configure configure) throws Throwable {
    long start = System.currentTimeMillis();
    if (!isBindRef.compareAndSet(false,true)) {
        throw new IllegalStateException("already bind");
    }

    try {
        /**
            * 涉及到各個 Client 的初始化, 將引數 instrumentation 傳到各個 client 中
            *
            * JarDebugClient
            * AppConfigClient
            * QDebugClient
            * QMonitorClient
            * JarInfoClient
            */
        InstrumentClientStore.init(instrumentation);

        ShellServerOptions options = new ShellServerOptions()
                .setInstrumentation(instrumentation)
                .setPid(pid)
                .setWelcomeMessage(BistouryConstants.BISTOURY_VERSION_LINE_PREFIX + BistouryConstants.CURRENT_VERSION);
        shellServer = new ShellServerImpl(options,this);
        QBuiltinCommandPack builtinCommands = new QBuiltinCommandPack();
        List<CommandResolver> resolvers = new ArrayList<CommandResolver>();
        resolvers.add(builtinCommands);
        shellServer.registerTermServer(new TelnetTermServer(
                configure.getIp(),configure.getTelnetPort(),options.getConnectionTimeout()));

        for (CommandResolver resolver : resolvers) {
            shellServer.registerCommandResolver(resolver);
        }
        shellServer.listen(new BindHandler(isBindRef));
    } catch (Throwable e) {
        if (shellServer != null) {
            shellServer.close();
        }
        InstrumentClientStore.destroy();
        isBindRef.compareAndSet(true,false);
        throw e;
    }
}
複製程式碼

Main

上面提到的只是前置處理會觸發的邏輯,即Java Instrumentation 會觸發的邏輯,而Agent模組的main方法,其實在是qunar.tc.bistoury.indpendent.agent.Main中,執行這個方法會觸發以下邏輯

  1. 根據啟動入參bistoury.proxy.host獲取Proxy地址
  2. Proxy傳送一個Http請求,請求地址為proxyIp:9090/proxy/config/
  3. Proxy返回與Agent建立連線的Ip和埠
  4. 執行AgentClient#initNettyClient方法與Agent建立TCP連線
  5. 根據SPI載入所有的AgentGlobalTaskFactory實現類,然後呼叫他們的start方法
  6. 開啟一個失敗重試的定時任務,每分鐘執行一次
public static void main(String[] args) throws Exception {
    log();
    AgentClient instance = AgentClient.getInstance();
    instance.start();
    System.in.read();
}
複製程式碼

至此,Agent的啟動完成。

Proxy

Proxy的啟動邏輯在qunar.tc.bistoury.proxy.container.Bootstrap#main方法中,預設Tomcat埠9090

  1. 獲取配置檔案目錄地址,我們可以在啟動的時候新增一個引數-Dbistoury.conf=/Workspace/ZTO/forensic/bistoury-proxy/conf
  2. 啟動內建的Tomcat,整合了Spring
  3. NettyServerManagerBean初始化的之前,執行一些初始化操作
  4. 獲取ZKClient,Proxy的地址會註冊到ZK
  5. 執行NettyServerManager#startAgentServer方法啟動針對Agent的Server端, 處理來自Agent的請求,預設埠為9880
  6. 執行NettyServerManager#startUiServer方法啟動針對UI的Server端, 處理來自UI的Websocket連線,預設埠為9881

UI

UI的啟動邏輯在qunar.tc.bistoury.ui.container.Bootstrap#main方法中,預設Tomcat埠9091

  1. 獲取配置檔案目錄地址,我們可以在啟動的時候新增一個引數-Dbistoury.conf=/Workspace/ZTO/forensic/bistoury-ui/conf
  2. 啟動內建的Tomcat,整合了Spring

互動流程

命令的請求過程

UI -> Prosy -> Agent -> Proxy -> UI
複製程式碼
  1. Proxy 與 UI 維持了一個Websocket連線
  2. Proxy 和 Agent 維持了一個TCP連線
  3. 一般我們在前端操作的時候是:前端介面請求UI後臺介面返回 Proxy 的Websocket地址,然後瀏覽器與 Proxy 建立一個Websocket連線

UI傳送請求

在介面點選檢視主機資訊為例

  1. 介面點選檢視主機資訊,請求UI後端的ConfigController#getProxyWebSocketUrl介面,入參=agentIp
  2. 從註冊中心(ZK)獲取所有的Proxy
  3. agentIp為入參,請求proxyIP:9090/proxy/agent/get,此步驟用於判斷agentIp對應的那個Agent是否可用
  4. Proxy返回Agent資訊
  5. UI後後端介面返回前端一個Websocket地址,瀏覽器和Proxy通過Websocket連線 ws://10.10.134.174:9881/ws
  6. UI通過Websocket連線向Proxy傳送命令
  7. Proxy將命令轉發請求到Agent
  8. Agent收到命令進行邏輯處理,將結果回給Proxy
  9. Proxy將結果返回給UI

UI與Proxy互動

Proxy接收請求經過 解碼 -> 主機有效性校驗,最終請求來到UiRequestHandler#channelRead方法,UiRequestHandler建構函式包含4個關鍵入參

  1. CommunicateCommandStore: 預設實現類DefaultCommunicateCommandStore,建構函式會注入所有的UiRequestCommand
  2. UiConnectionStore: 預設實現類DefaultUiConnectionStore,維護ChannelUiConnection之間的關係,UiConnection#write方法返回的ListenableFuture可以添加回調
  3. AgentConnectionStore : 預設實現類DefaultAgentConnectionStore,維護agentIpAgentConnection之間的關係,AgentConnection#write方法返回的ListenableFuture可以添加回調
  4. SessionManager : 預設實現類DefaultSessionManager,維護請求IdSession的關係,Session中持有RequestData AgentConnection UiConnection 屬性,這是實現請求轉發的關鍵

有關於Session,下次再重點介紹,它是是實現請求轉發的關鍵

請求流程

  1. 根據code(code可以看作是命令的唯一標識)找到對應的CommunicateCommand, 然後獲取CommunicateCommandCommunicateCommandProcessor屬性
  2. 執行CommunicateCommandProcessor#preprocessor方法
  3. 根據AgentServerInfo找到對應的AgentConnection,執行sendMessage方法,即執行Session#writeToAgent方法,該方法用於向Agent傳送命令
  4. 在回撥中執行UiConnection#write方法,用於向UI返回結果

瀏覽器Proxy建立Websocket連線的時候,基於Channel建立一個UiConnection,然後基於UiConnectionAgentConnection建立一個DefaultSessionAgentConnection從哪裡來? AgentConnectionAgentProxy維持心跳時建立的,核心類AgentMessageHandlerProxyHeartbeatProcessor,建立之後快取到DefaultAgentConnectionStore, key就是agentIp

Proxy與Agent互動

Proxy

  1. NettyServerManager#startAgentServer
  2. NettyServerForAgent#start 啟動Server端
  3. AgentMessageHandler#channelRead0 訊息處理,有3種訊息 ProxyHeartbeatProcessor : 心跳訊息, 在收到心跳的時候,以 agentIp 和 Channel 建立 AgentConnection ,然後 迴應 Agent 的心跳 AgentResponseProcessor : Agent返回的資料,根據請求ID從SessionManager中獲取,然後執行 session#writeToUi 方法,將結果返回瀏覽器 AgentInfoRefreshProcessor : 從DB和配置中獲取最新的Agent資訊,然後返回給 Agent

Agent

  1. AgentClient#initNettyClient
  2. AgentNettyClient#start 啟動Client端
  3. RequestHandler#channelRead 訊息處理,從RemotingHeader獲取code和id,id作為Channel的唯一標識,根據code獲取 Processor
  4. 執行Processor#process方法,有以下幾種 CancelProcessor : 取消 TaskProcessor 中開啟的任務 HeartbeatProcessor : 心跳 MetaRefreshProcessor : 更新MetaStore裡面的屬性,幹嘛的? MetaRefreshTipProcessor : 更新Agent資訊 TaskProcessor : 處理任務

動態監控功能

  1. web介面點選新增動態監控按鈕
  2. 瀏覽器與Proxy建立了Websocket連線,瀏覽器向Proxy傳送一個指令qmonitoradd
  3. ProxyAgent通過Netty建立了TCP連線,Proxy將命令轉發給Agent
  4. Agent收到訊息,解析指令,通過TelnetClientShellServer建立telnet連線
  5. ShellServer收到指令,找到對應的Command,這裡指QMonitorAddCommand
  6. 執行QMonitorAddCommand#process方法,然後執行QMonitorClient#addMonitor方法,最後執行DefaultMonitor#doAddMonitor方法
  7. 然後執行DefaultMonitor#instrument方法,這裡面涉及到Java的Instrumentation技術和ASM技術
  8. 建立MonitorClassFileTransformer物件,它實現了ClassFileTransformer介面,織入代理邏輯,就是通過這個物件完成的
  9. 而有關於邏輯的具體織入,是通過MonitorClassVisitor完成。涉及到的知識點:ClassReaderClassWriterClassVisitor
  10. 代理邏輯裡面涉及到AgentMonitor相關方法的呼叫,而AgentMonitor的相關方法會將 呼叫次數響應時間異常數 存入Metrics
  11. qunar.tc.bistoury.agent.task.monitor.TaskRunner啟動時,呼叫順序如下:QMonitorClient#reportMonitor -> QMonitorMetricsReportor#report -> 獲取Metric

線上除錯功能

原理和動態監控一樣,也是通過 Instrumentation + ASM 實現

  1. 對應的指令為qdebugadd
  2. 對應的Command為QDebugAddCommand
  3. 呼叫鏈路:QDebugClient#registerBreakpoint -> DefaultDebugger#doRegisterBreakpoint -> DefaultDebugger#instrument
  4. ASM涉及到DebuggerClassFileTransformerDebuggerClassVisitorDebuggerMethodVisitor
  5. 在執行到對應斷點程式碼的時候,通過執行ASM插入的邏輯,將 本地區域性變數例項屬性靜態變數方法呼叫堆疊資訊 儲存到SnapshotCapture
  6. 執行DebuggerMethodVisitor#processForBreakpoint方法,將所有相關的資訊存到DefaultSnapshotStore的快取中
  7. 在前端點選新增斷點按鈕之後,即傳送qdebugadd指令之後,前端會開啟一個定時任務,每3s向服務端傳送一個qdebugsearch指令,直到服務端返回資料。服務端收到指令,從DefaultSnapshotStore中獲取資料返回前端

其它功能下次補充