我的天,你工作5年了,連Java agent都不知道...

點擊上方“碼農突圍”,馬上關注

這裏是碼農充電第一站,回覆“666”,獲取一份專屬大禮包

真愛,請設置“星標”或點個“在看”

作者:洵澈

# 引言

在本篇文章中,我會通過幾個簡單的程序來說明 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.mfjavac org/xunche/agent/HelloAgent.javajavac org/xunche/app/HelloWorld.javajar 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: xuncheHello 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 。

---END---

重磅!魚哥微信好友坑位限時開放啦!

掃碼直接加魚哥微信號,不僅可以圍觀魚哥平時所思和覆盤的內容。還可以幫你免費內推大廠,技術交流,一起探索職場突圍,收入突圍,技術突圍。一定要備註:開發方向+地點+學校/公司+暱稱(如Java開發+上海+拼夕夕+猴子)

▲長按加魚哥微信,趕緊上車

推薦閱讀

•   工作8年,年薪資從1w漲到7w,網友:本科的話有點牛逼了

•   CTO:不要在 Java 代碼中寫 set/get 方法了,逮一次罰款***

•   一次滿足你!彙總一下Intellij IDEA常用的牛逼插件

•   再見,“阿里三代掌門人”蔣凡!

•   實錘了!Chrome存在嚴重漏洞!

•   GitHub上最勵志的計算機自學教程:8個月,從中年Web前端到亞馬遜百萬年薪軟件工程師 | 中文版

歡迎關注我的公衆號“碼農突圍”,如果喜歡,麻煩點一下“在看”~

如有收穫,點個在看,誠摯感謝

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章