天天看點

初探 Java agent

引言

在本篇文章中,我會通過幾個簡單的程式來說明 agent 的使用,最後在實戰環節我會通過 asm 位元組碼架構來實作一個小工具,用于在程式運作中采集指定方法的參數和傳回值。有關 asm 位元組碼的内容不是本文的重點,不會過多的闡述,不明白的同學可以自己 google 下。

簡介

Java agent 提供了一種在加載位元組碼時,對位元組碼進行修改的方式。他共有兩種方式執行,一種是在 main 方法執行之前,通過 premain 來實作,另一種是在程式運作中,通過 attach api 來實作。

在介紹 agent 之前,先給大家簡單說下 Instrumentation 。它是 JDK1.5 提供的 API ,用于攔截類加載事件,并對位元組碼進行修改,它的主要方法如下:

public interface Instrumentation {
    //注冊一個轉換器,類加載事件會被注冊的轉換器所攔截
     void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
    //重新觸發類加載
     void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
    //直接替換類的定義
     void redefineClasses(ClassDefinition... definitions) throws  ClassNotFoundException, UnmodifiableClassException;
}           

premain

premain 是在 main 方法之前運作的方法,也是最常見的 agent 方式。運作時需要将 agent 程式打包成 jar 包,并在啟動時添加指令來執行,如下文所示:

java -javaagent:agent.jar=xunche HelloWorld           

premain 共提供以下 2 種重載方法, Jvm 啟動時會先嘗試使用第一種方法,若沒有會使用第二種方法:

public static void premain(String agentArgs, Instrumentation inst);
public static void premain(String agentArgs);           

一個簡單的例子

下面我們通過一個程式來簡單說明下 premain 的使用,首先我們準備下測試代碼,測試代碼比較簡單,運作 main 方法并輸出 hello world 。

package org.xunche.app;
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}           

接下來我們看下 agent 的代碼,運作 premain 方法并輸出我們傳入的參數。

package org.xunche.agent;
public class HelloAgent {
  public static void premain(String args) {
    System.out.println("Hello Agent:  " + args);
  }
}           

為了能夠 agent 能夠運作,我們需要将 META-INF/MANIFEST.MF 檔案中的 Premain- Class 為我們編寫的 agent 路徑,然後通過以下方式将其打包成 jar 包,當然你也可以使用 idea 直接導出 jar 包。

echo 'Premain-Class: org.xunche.agent.HelloAgent' > manifest.mf
javac org/xunche/agent/HelloAgent.java
javac org/xunche/app/HelloWorld.java
jar cvmf manifest.mf hello-agent.jar org/           

接下來,我們編譯下并運作下測試代碼,這裡為了測試簡單,我将編譯後的 class 和 agent 的 jar 包放在了同級目錄下.

java -javaagent:hello-agent.jar=xunche org/xunche/app/HelloWorld           

可以看到輸出結果如下,agent中的premain方法有限于main方法執行

Hello Agent: xunche
Hello World           

稍微複雜點的例子

通過上面的例子,是否對 agent 有個簡單的了解呢?

下面我們來看個稍微複雜點,我們通過 agent 來實作一個方法監控的功能。思路大緻是這樣的,若是非 jdk 的方法,我們通過 asm 在方法的執行入口和執行出口處,植入幾行記錄時間戳的代碼,當方法結束後,通過時間戳來擷取方法的耗時。

首先還是看下測試代碼,邏輯很簡單, main 方法執行時調用 sayHi 方法,輸出 hi , xunche ,并随機睡眠一段時間。

package org.xunche.app;
public class HelloXunChe {
    public static void main(String[] args) throws InterruptedException {
        HelloXunChe helloXunChe = new HelloXunChe();
        helloXunChe.sayHi();
    }
    public void sayHi() throws InterruptedException {
        System.out.println("hi, xunche");
        sleep();
    }
    public void sleep() throws InterruptedException {
        Thread.sleep((long) (Math.random() * 200));
    }
}           

接下來我們借助 asm 來植入我們自己的代碼,在 jvm 加載類的時候,為類的每個方法加上統計方法調用耗時的代碼,代碼如下,這裡的 asm 我使用了 jdk 自帶的,當然你也可以使用官方的 asm 類庫。

package org.xunche.agent;
import jdk.internal.org.objectweb.asm.*;
import jdk.internal.org.objectweb.asm.commons.AdviceAdapter;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
public class TimeAgent {
    public static void premain(String args, Instrumentation instrumentation) {
        instrumentation.addTransformer(new TimeClassFileTransformer());
    }
    private static class TimeClassFileTransformer implements ClassFileTransformer {
        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
            if (className.startsWith("java") || className.startsWith("jdk") || className.startsWith("javax") || className.startsWith("sun") || className.startsWith("com/sun")|| className.startsWith("org/xunche/agent")) {
                //return null或者執行異常會執行原來的位元組碼
                return null;
            }
            System.out.println("loaded class: " + className);
            ClassReader reader = new ClassReader(classfileBuffer);
            ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
            reader.accept(new TimeClassVisitor(writer), ClassReader.EXPAND_FRAMES);
            return writer.toByteArray();
        }
    }
    public static class TimeClassVisitor extends ClassVisitor {
        public TimeClassVisitor(ClassVisitor classVisitor) {
            super(Opcodes.ASM5, classVisitor);
        }
        @Override
        public MethodVisitor visitMethod(int methodAccess, String methodName, String methodDesc, String signature, String[] exceptions) {
            MethodVisitor methodVisitor = cv.visitMethod(methodAccess, methodName, methodDesc, signature, exceptions);
            return new TimeAdviceAdapter(Opcodes.ASM5, methodVisitor, methodAccess, methodName, methodDesc);
        }
    }
    public static class TimeAdviceAdapter extends AdviceAdapter {
        private String methodName;
        protected TimeAdviceAdapter(int api, MethodVisitor methodVisitor, int methodAccess, String methodName, String methodDesc) {
            super(api, methodVisitor, methodAccess, methodName, methodDesc);
            this.methodName = methodName;
        }
        @Override
        protected void onMethodEnter() {
            //在方法入口處植入
            if ("<init>".equals(methodName)|| "<clinit>".equals(methodName)) {
                return;
            }
            mv.visitTypeInsn(NEW, "java/lang/StringBuilder");
            mv.visitInsn(DUP);
            mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
            mv.visitVarInsn(ALOAD, 0);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Object", "getClass", "()Ljava/lang/Class;", false);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Class", "getName", "()Ljava/lang/String;", false);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitLdcInsn(".");
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitLdcInsn(methodName);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
            mv.visitMethodInsn(INVOKESTATIC, "org/xunche/agent/TimeHolder", "start", "(Ljava/lang/String;)V", false);
        }
        @Override
        protected void onMethodExit(int i) {
            //在方法出口植入
            if ("<init>".equals(methodName) || "<clinit>".equals(methodName)) {
                return;
            }
            mv.visitTypeInsn(NEW, "java/lang/StringBuilder");
            mv.visitInsn(DUP);
            mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
            mv.visitVarInsn(ALOAD, 0);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Object", "getClass", "()Ljava/lang/Class;", false);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Class", "getName", "()Ljava/lang/String;", false);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitLdcInsn(".");
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitLdcInsn(methodName);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
            mv.visitVarInsn(ASTORE, 1);
            mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            mv.visitTypeInsn(NEW, "java/lang/StringBuilder");
            mv.visitInsn(DUP);
            mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
            mv.visitVarInsn(ALOAD, 1);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitLdcInsn(": ");
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitVarInsn(ALOAD, 1);
            mv.visitMethodInsn(INVOKESTATIC, "org/xunche/agent/TimeHolder", "cost", "(Ljava/lang/String;)J", false);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        }
    }
}           

上述的代碼略長, asm 的部分可以略過。我們通過 instrumentation.addTransformer 注冊一個轉換器,轉換器重寫了 transform 方法,方法入參中的 classfileBuffer 表示的是原始的位元組碼,方法傳回值表示的是真正要進行加載的位元組碼。

onMethodEnter 方法中的代碼含義是調用 TimeHolder 的 start 方法并傳入目前的方法名。

onMethodExit 方法中的代碼含義是調用 TimeHolder 的 cost 方法并傳入目前的方法名,并列印 cost 方法的傳回值。

下面來看下 TimeHolder 的代碼:

package org.xunche.agent;
import java.util.HashMap;
import java.util.Map;
public class TimeHolder {
    private static Map<String, Long> timeCache = new HashMap<>();
    public static void start(String method) {
        timeCache.put(method, System.currentTimeMillis());
    }
    public static long cost(String method) {
        return System.currentTimeMillis() - timeCache.get(method);
    }
}            

至此,agent 的代碼編寫完成,有關 asm 的部分不是本章的重點,日後再單獨推出一篇有關 asm 的文章。通過在類加載時植入我們監控的代碼後,下面我們來看看,經過 asm 修改後的代碼是怎樣的。可以看到,與最開始的測試代碼相比,每個方法都加入了我們統計方法耗時的代碼。

package org.xunche.app;
import org.xunche.agent.TimeHolder;
public class HelloXunChe {
    public HelloXunChe() {
    }
    public static void main(String[] args) throws InterruptedException {
        TimeHolder.start(args.getClass().getName() + "." + "main");
        HelloXunChe helloXunChe = new HelloXunChe();
        helloXunChe.sayHi();
        HelloXunChe helloXunChe = args.getClass().getName() + "." + "main";
        System.out.println(helloXunChe + ": " + TimeHolder.cost(helloXunChe));
    }
    public void sayHi() throws InterruptedException {
        TimeHolder.start(this.getClass().getName() + "." + "sayHi");
        System.out.println("hi, xunche");
        this.sleep();
        String var1 = this.getClass().getName() + "." + "sayHi";
        System.out.println(var1 + ": " + TimeHolder.cost(var1));
    }
    public void sleep() throws InterruptedException {
        TimeHolder.start(this.getClass().getName() + "." + "sleep");
        Thread.sleep((long)(Math.random() * 200.0D));
        String var1 = this.getClass().getName() + "." + "sleep";
        System.out.println(var1 + ": " + TimeHolder.cost(var1));
    }
}           

agentmain

上面的 premain 是通過 agetn 在應用啟動前,對位元組碼進行修改,來實作我們想要的功能。實際上 jdk 提供了 attach api ,通過這個 api ,我們可以通路已經啟動的 Java 程序。并通過 agentmain 方法來攔截類加載。下面我們來通過實戰來具體說明下 agentmain 。

實戰

本次實戰的目标是實作一個小工具,其目标是能遠端采集已經處于運作中的 Java 程序的方法調用資訊。聽起來像不像 BTrace ,實際上 BTrace 也是這麼實作的。隻不過因為時間關系,本次的實戰代碼寫的比較簡陋,大家不必關注細節,看下實作的思路就好。

具體的實作思路如下:

agent 對指定類的方法進行位元組碼的修改,采集方法的入參和傳回值。并通過 socket 将請求和傳回發送到服務端

服務端通過 attach api 通路運作中的 Java 程序,并加載 agent ,使 agent 程式能對目标程序生效

服務端加載 agent 時指定需要采集的類和方法

服務端開啟一個端口,接受目标程序的請求資訊

老規矩,先看測試代碼,測試代碼很簡單,每隔 100ms 運作一次 sayHi 方法,并随機随眠一段時間。

package org.xunche.app;
public class HelloTraceAgent {
    public static void main(String[] args) throws InterruptedException {
        HelloTraceAgent helloTraceAgent = new HelloTraceAgent();
        while (true) {
            helloTraceAgent.sayHi("xunche");
            Thread.sleep(100);
        }
    }
    public String sayHi(String name) throws InterruptedException {
        sleep();
        String hi = "hi, " + name + ", " + System.currentTimeMillis();
        return hi;
    }
    public void sleep() throws InterruptedException {
        Thread.sleep((long) (Math.random() * 200));
    }
}           

接下看 agent 代碼,思路同監控方法耗時差不多,在方法出口處,通過 asm 植入采集方法入參和傳回值的代碼,并通過 Sender 将資訊通過 socket 發送到服務端,代碼如下:

package org.xunche.agent;
import jdk.internal.org.objectweb.asm.*;
import jdk.internal.org.objectweb.asm.commons.AdviceAdapter;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
import java.security.ProtectionDomain;
public class TraceAgent {
    public static void agentmain(String args, Instrumentation instrumentation) throws ClassNotFoundException, UnmodifiableClassException {
        if (args == null) {
            return;
        }
        int index = args.lastIndexOf(".");
        if (index != -1) {
            String className = args.substring(0, index);
            String methodName = args.substring(index + 1);
            //目标代碼已經加載,需要重新觸發加載流程,才會通過注冊的轉換器進行轉換
            instrumentation.addTransformer(new TraceClassFileTransformer(className.replace(".", "/"), methodName), true);
            instrumentation.retransformClasses(Class.forName(className));
        }
    }
    public static class TraceClassFileTransformer implements ClassFileTransformer {
        private String traceClassName;
        private String traceMethodName;
        public TraceClassFileTransformer(String traceClassName, String traceMethodName) {
            this.traceClassName = traceClassName;
            this.traceMethodName = traceMethodName;
        }
        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
            //過濾掉Jdk、agent、非指定類的方法
            if (className.startsWith("java") || className.startsWith("jdk") || className.startsWith("javax") || className.startsWith("sun")
                    || className.startsWith("com/sun") || className.startsWith("org/xunche/agent") || !className.equals(traceClassName)) {
                //return null會執行原來的位元組碼
                return null;
            }
            ClassReader reader = new ClassReader(classfileBuffer);
            ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
            reader.accept(new TraceVisitor(className, traceMethodName, writer), ClassReader.EXPAND_FRAMES);
            return writer.toByteArray();
        }
    }
    public static class TraceVisitor extends ClassVisitor {
        private String className;
        private String traceMethodName;
        public TraceVisitor(String className, String traceMethodName, ClassVisitor classVisitor) {
            super(Opcodes.ASM5, classVisitor);
            this.className = className;
            this.traceMethodName = traceMethodName;
        }
        @Override
        public MethodVisitor visitMethod(int methodAccess, String methodName, String methodDesc, String signature, String[] exceptions) {
            MethodVisitor methodVisitor = cv.visitMethod(methodAccess, methodName, methodDesc, signature, exceptions);
            if (traceMethodName.equals(methodName)) {
                return new TraceAdviceAdapter(className, methodVisitor, methodAccess, methodName, methodDesc);
            }
            return methodVisitor;
        }
    }
    private static class TraceAdviceAdapter extends AdviceAdapter {
        private final String className;
        private final String methodName;
        private final Type[] methodArgs;
        private final String[] parameterNames;
        private final int[] lvtSlotIndex;
        protected TraceAdviceAdapter(String className, MethodVisitor methodVisitor, int methodAccess, String methodName, String methodDesc) {
            super(Opcodes.ASM5, methodVisitor, methodAccess, methodName, methodDesc);
            this.className = className;
            this.methodName = methodName;
            this.methodArgs = Type.getArgumentTypes(methodDesc);
            this.parameterNames = new String[this.methodArgs.length];
            this.lvtSlotIndex = computeLvtSlotIndices(isStatic(methodAccess), this.methodArgs);
        }
        @Override
        public void visitLocalVariable(String name, String description, String signature, Label start, Label end, int index) {
            for (int i = 0; i < this.lvtSlotIndex.length; ++i) {
                if (this.lvtSlotIndex[i] == index) {
                    this.parameterNames[i] = name;
                }
            }
        }
        @Override
        protected void onMethodExit(int opcode) {
            //排除構造方法和靜态代碼塊
            if ("<init>".equals(methodName) || "<clinit>".equals(methodName)) {
                return;
            }
            if (opcode == RETURN) {
                push((Type) null);
            } else if (opcode == LRETURN || opcode == DRETURN) {
                dup2();
                box(Type.getReturnType(methodDesc));
            } else {
                dup();
                box(Type.getReturnType(methodDesc));
            }
            Type objectType = Type.getObjectType("java/lang/Object");
            push(lvtSlotIndex.length);
            newArray(objectType);
            for (int j = 0; j < lvtSlotIndex.length; j++) {
                int index = lvtSlotIndex[j];
                Type type = methodArgs[j];
                dup();
                push(j);
                mv.visitVarInsn(ALOAD, index);
                box(type);
                arrayStore(objectType);
            }
            visitLdcInsn(className.replace("/", "."));
            visitLdcInsn(methodName);
            mv.visitMethodInsn(INVOKESTATIC, "org/xunche/agent/Sender", "send", "(Ljava/lang/Object;[Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;)V", false);
        }
        private static int[] computeLvtSlotIndices(boolean isStatic, Type[] paramTypes) {
            int[] lvtIndex = new int[paramTypes.length];
            int nextIndex = isStatic ? 0 : 1;
            for (int i = 0; i < paramTypes.length; ++i) {
                lvtIndex[i] = nextIndex;
                if (isWideType(paramTypes[i])) {
                    nextIndex += 2;
                } else {
                    ++nextIndex;
                }
            }
            return lvtIndex;
        }
        private static boolean isWideType(Type aType) {
            return aType == Type.LONG_TYPE || aType == Type.DOUBLE_TYPE;
        }
        private static boolean isStatic(int access) {
            return (access & 8) > 0;
        }
    }
}           

以上就是 agent 的代碼, onMethodExit 方法中的代碼含義是擷取請求參數和傳回參數并調用 Sender.send 方法。這裡的通路本地變量表的代碼參考了 Spring 的 LocalVariableTableParameterNameDiscoverer ,感興趣的同學可以自己研究下。接下來看下 Sender 中的代碼:

public class Sender {
    private static final int SERVER_PORT = 9876;
    public static void send(Object response, Object[] request, String className, String methodName) {
        Message message = new Message(response, request, className, methodName);
        try {
            Socket socket = new Socket("localhost", SERVER_PORT);
            socket.getOutputStream().write(message.toString().getBytes());
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    private static class Message {
        private Object response;
        private Object[] request;
        private String className;
        private String methodName;
        public Message(Object response, Object[] request, String className, String methodName) {
            this.response = response;
            this.request = request;
            this.className = className;
            this.methodName = methodName;
        }
        @Override
        public String toString() {
            return "Message{" +
                    "response=" + response +
                    ", request=" + Arrays.toString(request) +
                    ", className='" + className + '\'' +
                    ", methodName='" + methodName + '\'' +
                    '}';
        }
    }
}           

Sender 中的代碼不複雜,一看就懂,就不多說了。下面我們來看下服務端的代碼,服務端要實作開啟一個端口監聽,接受請求資訊,以及使用 attach api 加載 agent 。

package org.xunche.app;
import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class TraceAgentMain {
    private static final int SERVER_PORT = 9876;
    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        new Server().start();
        //attach的程序
        VirtualMachine vm = VirtualMachine.attach("85241");
        //加載agent并指明需要采集資訊的類和方法
        vm.loadAgent("trace-agent.jar", "org.xunche.app.HelloTraceAgent.sayHi");
        vm.detach();
    }
    private static class Server implements Runnable {
        @Override
        public void run() {
            try {
                ServerSocket serverSocket = new ServerSocket(SERVER_PORT);
                while (true) {
                    Socket socket = serverSocket.accept();
                    InputStream input = socket.getInputStream();
                    BufferedReader reader = new BufferedReader(new InputStreamReader(input));
                    System.out.println("receive message:" + reader.readLine());
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        public void start() {
            Thread thread = new Thread(this);
            thread.start();
        }
    }
}           

運作上面的程式,可以看到服務端收到了 org.xunche.app.HelloTraceAgent.sayHi 的請求和傳回資訊。

receive message:Message{response=hi, xunche, 1581599464436, request=[xunche], className='org.xunche.app.HelloTraceAgent', methodName='sayHi'}           

小結

在本章内容中,為大家介紹了 agent 的基本使用包括 premain 和 agentmain 。并通過 agentmain 實作了一個采集運作時方法調用資訊的小工具,當然由于篇幅和時間問題,代碼寫的比較随意,大家多體會體會思路。實際上, agent 的作用遠不止文章中介紹的這些,像 BTrace、arms、springloaded 等中也都有用到 agent 。

作者資訊:張帥,花名洵澈,國際化中台事業部進階開發工程師,負責物流表達和履約相關研發工作。熱衷于中間件相關技術。