什麼是 Arthas

本篇文章是個人「三同」好朋友歸然原創,三同是「同小區」、「前同事」、「現同事」的意思,歸然是他的花名。html

個人花名是逅弈,這是我從大學開始就沿用至今的一個暱稱,是當時和女友玩QQ炫舞,取的情侶暱稱,女友如今已經成了我老婆。java

言歸正傳,本篇文章咱們要了解的是一個線上問題排查利器:Arthas。我也用它在預發環境排查過問題,確實很是方便,當時也想着寫一篇文章總結一下,結果一拖就沒了下文。git

如今歸然寫了一篇很是詳盡的介紹 Arthas 的文章,我看過以後以爲很好,因此厚着臉皮讓他把文章在我公衆號上發佈,讓更多的同窗也可以學習到,但願對你們有所幫助。github

熟悉個人人都知道,個人公衆號內都是原創文章,也沒有作過任何的商業互吹、互推來吸引粉絲,也沒有爲了吸引關注作一些福利活動,徹底是自由發展。我相信專一於原創,用心寫好每一篇文章,能爲讀者帶來實質性的幫助,這樣就很好。算法

============如下是原文=========shell

記得前段時間遇到了一個頁面加載過長的問題,當時就想排查下在哪一步消耗的時間比較長,因爲是線上問題,第一反應就是有沒有什麼辦法能夠無侵入式的查詢調用鏈路耗時呢?apache

這時 Arthas 走進了個人眼簾,併成功幫我定位到了問題,就是這樣引發了我對 Arthas 的興趣,因而花了點時間對 Arthas 做了一個瞭解。bootstrap

什麼是 Arthas

摘錄一段官方 Github 上的簡介api

Arthas 是Alibaba開源的Java診斷工具,深受開發者喜好。網絡

當你遇到如下相似問題而一籌莫展時,Arthas 能夠幫助你解決:

  1. 這個類從哪一個 jar 包加載的?爲何會報各類類相關的 Exception?
  2. 我改的代碼爲何沒有執行到?難道是我沒 commit?分支搞錯了?
  3. 遇到問題沒法在線上 debug,難道只能經過加日誌再從新發布嗎?
  4. 線上遇到某個用戶的數據處理有問題,但線上一樣沒法 debug,線下沒法重現!
  5. 是否有一個全局視角來查看系統的運行情況?
  6. 有什麼辦法能夠監控到JVM的實時運行狀態?

Arthas 支持JDK 6+,支持Linux/Mac/Windows,採用命令行交互模式,同時提供豐富的 Tab 自動補全功能,進一步方便進行問題的定位和診斷。

開源地址:https://github.com/alibaba/arthas/

Arthas 基於哪些工具開發而來

  • greys-anatomy: Arthas代碼基於Greys二次開發而來,很是感謝Greys以前全部的工做,以及Greys原做者對Arthas提出的意見和建議!
  • termd: Arthas的命令行實現基於termd開發,是一款優秀的命令行程序開發框架,感謝termd提供了優秀的框架。
  • crash: Arthas的文本渲染功能基於crash中的文本渲染功能開發,能夠從這裏看到源碼,感謝crash在這方面所作的優秀工做。
  • cli: Arthas的命令行界面基於vert.x提供的cli庫進行開發,感謝vert.x在這方面作的優秀工做。
  • compiler Arthas裏的內存編繹器代碼來源
  • Apache Commons Net Arthas裏的Telnet Client代碼來源
  • JavaAgent:運行在 main方法以前的攔截器,它內定的方法名叫 premain ,也就是說先執行 premain 方法而後再執行 main 方法
  • ASM:一個通用的Java字節碼操做和分析框架。它能夠用於修改現有的類或直接以二進制形式動態生成類。ASM提供了一些常見的字節碼轉換和分析算法,能夠從它們構建定製的複雜轉換和代碼分析工具。ASM提供了與其餘Java字節碼框架相似的功能,可是主要關注性能。由於它被設計和實現得儘量小和快,因此很是適合在動態系統中使用(固然也能夠以靜態方式使用,例如在編譯器中)

工程目錄

project-structure.png

  • arthas-agent:基於JavaAgent技術的代理
  • bin:一些啓動腳本
  • arthas-boot:Java版本的一鍵安裝啓動腳本
  • arthas-client:telnet client代碼
  • arthas-common:一些共用的工具類和枚舉類
  • arthas-core:核心庫,各類arthas命令的交互和實現
  • arthas-demo:示例代碼
  • arthas-memorycompiler:內存編繹器代碼,Fork from https://github.com/skalogs/SkaETL/tree/master/compiler
  • arthas-packaging:maven打包相關的
  • arthas-site:arthas站點
  • arthas-spy:編織到目標類中的各個切面
  • static:靜態資源
  • arthas-testcase:測試

總體流程

首先咱們先放出一張總體宏觀的模塊調用圖,下面咱們會按照整個 Arthas 啓動流程逐步分析,紅色部分本篇文章將不涉及,會在後續文章中單獨分析

arthas-invoke.jpg

啓動方式

使用 arthas-boot 啓動(推薦)

下載 arthas-boot.jar,而後用 java -jar 的方式啓動:

wget https://alibaba.github.io/arthas/arthas-boot.jar
java -jar arthas-boot.jar

能夠加 -h 參數,打印幫助信息:

java -jar arthas-boot.jar -h

若是下載速度比較慢,可使用aliyun的鏡像:java -jar arthas-boot.jar --repo-mirror aliyun --use-http

使用 as.sh 腳本啓動

Arthas 支持在 Linux/Unix/Mac 等平臺上一鍵安裝,請複製如下內容,並粘貼到命令行中,敲回車執行便可:

curl -L https://alibaba.github.io/arthas/install.sh | sh

上述命令會下載啓動腳本文件 as.sh 到當前目錄,你能夠放在任何地方或將其加入到 $PATH 中。

直接在shell下面執行 ./as.sh ,就會進入交互界面。

也能夠執行 ./as.sh -h 來獲取更多參數信息。

Arthas 是如何啓動的

既然官方推薦用 arthas-boot 啓動,那下面咱們就一塊兒來看下 arthas-boot 是如何啓動的。

首先咱們在 arthas-boot 的 pom 文件中找到啓動類:

<archive>
    <manifest>
        <mainClass>com.taobao.arthas.boot.Bootstrap</mainClass>
    </manifest>
    <manifestEntries>
        <Created-By>core engine team, middleware group, alibaba inc.</Created-By>
        <Specification-Title>${project.name}</Specification-Title>
        <Specification-Version>${project.version}</Specification-Version>
        <Implementation-Title>${project.name}</Implementation-Title>
        <Implementation-Version>${project.version}</Implementation-Version>
    </manifestEntries>
</archive>

從pom文件中,咱們能夠發現arthas-boot的啓動類爲 com.taobao.arthas.boot.Bootstrap,下面咱們就去看看 Bootstrap 是如何啓動 arthas 的,有興趣的同窗也能夠自行看下另一種啓動方式 as.sh

歸然將整個啓動的過程所有經過註釋在代碼中體現出來了,因此:

如下代碼超級長,慎入!

如下代碼超級長,慎入!

如下代碼超級長,慎入!

public static void main(String[] args) throws ParserConfigurationException, SAXException, IOException,
                    ClassNotFoundException, NoSuchMethodException, SecurityException, IllegalAccessException,
                    IllegalArgumentException, InvocationTargetException {
        Package bootstrapPackage = Bootstrap.class.getPackage();
        if (bootstrapPackage != null) {
            String arthasBootVersion = bootstrapPackage.getImplementationVersion();
            if (arthasBootVersion != null) {
                AnsiLog.info("arthas-boot version: " + arthasBootVersion);
            }
        }

        String mavenMetaData = null;

        Bootstrap bootstrap = new Bootstrap();

        //解析類中全部帶@Option、@Argument的方法,初始化CLI
        CLI cli = CLIConfigurator.define(Bootstrap.class);
        //解析用戶的輸入參數,初始化全部命令行參數
        CommandLine commandLine = cli.parse(Arrays.asList(args));

        try {
            CLIConfigurator.inject(commandLine, bootstrap);
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(usage(cli));
            System.exit(1);
        }
        //設置日誌級別
        if (bootstrap.isVerbose()) {
            AnsiLog.level(Level.ALL);
        }
        //根據啓動參數,判斷是不是查看幫助(-h或者--help),若是是查看幫助,則打印usage
        if (bootstrap.isHelp()) {
            System.out.println(usage(cli));
            System.exit(0);
        }

        if (bootstrap.getRepoMirror() == null || bootstrap.getRepoMirror().trim().isEmpty()) {
            bootstrap.setRepoMirror("center");
            // 若是在國內,則設置maven源爲aliyun
            if (TimeUnit.MILLISECONDS.toHours(TimeZone.getDefault().getOffset(System.currentTimeMillis())) == 8) {
                bootstrap.setRepoMirror("aliyun");
            }
        }
        AnsiLog.debug("Repo mirror:" + bootstrap.getRepoMirror());
        //若是啓動參數爲查看版本,則顯示版本
        if (bootstrap.isVersions()) {
            if (mavenMetaData == null) {
                mavenMetaData = DownloadUtils.readMavenMetaData(bootstrap.getRepoMirror(), bootstrap.isuseHttp());
            }
            System.out.println(UsageRender.render(listVersions(mavenMetaData)));
            System.exit(0);
        }
        //判斷當前環境是否JDK6或者JDK7,若是是,則只支持Http方式啓動
        if (JavaVersionUtils.isJava6() || JavaVersionUtils.isJava7()) {
            bootstrap.setuseHttp(true);
            AnsiLog.debug("Java version is {}, only support http, set useHttp to true.",
                            JavaVersionUtils.javaVersionStr());
        }

        //檢查http和telnet端口是否被佔用
        int telnetPortPid = -1;
        int httpPortPid = -1;
        if (bootstrap.getTelnetPort() > 0) {
            telnetPortPid = SocketUtils.findTcpListenProcess(bootstrap.getTelnetPort());
            if (telnetPortPid > 0) {
                AnsiLog.info("Process {} already using port {}", telnetPortPid, bootstrap.getTelnetPort());
            }
        }
        if (bootstrap.getHttpPort() > 0) {
            httpPortPid = SocketUtils.findTcpListenProcess(bootstrap.getHttpPort());
            if (httpPortPid > 0) {
                AnsiLog.info("Process {} already using port {}", httpPortPid, bootstrap.getHttpPort());
            }
        }
        //獲取用戶指定進程ID,若是沒有指定,默認取Telnet端口占用的進程ID
        int pid = bootstrap.getPid();
        // select pid
        if (pid < 0) {
            try {
                pid = ProcessUtils.select(bootstrap.isVerbose(), telnetPortPid);
            } catch (InputMismatchException e) {
                System.out.println("Please input an integer to select pid.");
                System.exit(1);
            }
            if (pid < 0) {
                System.out.println("Please select an available pid.");
                System.exit(1);
            }
        }
        //校驗目標進程ID與Telnet或者http端口是否一致
        if (telnetPortPid > 0 && pid != telnetPortPid) {
            AnsiLog.error("Target process {} is not the process using port {}, you will connect to an unexpected process.",
                            pid, bootstrap.getTelnetPort());
            AnsiLog.error("1. Try to restart arthas-boot, select process {}, shutdown it first.",
                            telnetPortPid);
            AnsiLog.error("2. Or try to use different telnet port, for example: java -jar arthas-boot.jar --telnet-port 9998 --http-port -1");
            System.exit(1);
        }

        if (httpPortPid > 0 && pid != httpPortPid) {
            AnsiLog.error("Target process {} is not the process using port {}, you will connect to an unexpected process.",
                            pid, bootstrap.getHttpPort());
            AnsiLog.error("1. Try to restart arthas-boot, select process {}, shutdown it first.",
                            httpPortPid);
            AnsiLog.error("2. Or try to use different http port, for example: java -jar arthas-boot.jar --telnet-port 9998 --http-port 9999", httpPortPid);
            System.exit(1);
        }

        //校驗arthas目錄中是否存在"arthas-core.jar", "arthas-agent.jar", "arthas-spy.jar"
        File arthasHomeDir = null;
        if (bootstrap.getArthasHome() != null) {
            verifyArthasHome(bootstrap.getArthasHome());
            arthasHomeDir = new File(bootstrap.getArthasHome());
        }
        //指定版本的處理過程
        if (arthasHomeDir == null && bootstrap.getUseVersion() != null) {
            // try to find from ~/.arthas/lib
            File specialVersionDir = new File(System.getProperty("user.home"), ".arthas" + File.separator + "lib"
                            + File.separator + bootstrap.getUseVersion() + File.separator + "arthas");
            if (!specialVersionDir.exists()) {
                // try to download arthas from remote server.
                DownloadUtils.downArthasPackaging(bootstrap.getRepoMirror(), bootstrap.isuseHttp(),
                                bootstrap.getUseVersion(), ARTHAS_LIB_DIR.getAbsolutePath());
            }
            verifyArthasHome(specialVersionDir.getAbsolutePath());
            arthasHomeDir = specialVersionDir;
        }

        //若是在上面都沒有確認arthas home,則獲取當前Jar包的父目錄
        if (arthasHomeDir == null) {
            CodeSource codeSource = Bootstrap.class.getProtectionDomain().getCodeSource();
            if (codeSource != null) {
                try {
                    // https://stackoverflow.com/a/17870390
                    File bootJarPath = new File(codeSource.getLocation().toURI().getSchemeSpecificPart());
                    verifyArthasHome(bootJarPath.getParent());
                    arthasHomeDir = bootJarPath.getParentFile();
                } catch (Throwable e) {
                    // ignore
                }

            }
        }


        //若是仍然沒有肯定arthas home,則查看ARTHAS_LIB_DIR下是否存在本地版本,並獲取遠程的最新版本號,對比本地和遠程,若是本地版本低於遠程,則嘗試去下載遠端最新版本
        if (arthasHomeDir == null) {
            boolean checkFile =  ARTHAS_LIB_DIR.exists() || ARTHAS_LIB_DIR.mkdirs();
            if(!checkFile){
                AnsiLog.error("cannot create directory {}: maybe permission denied", ARTHAS_LIB_DIR.getAbsolutePath());
                System.exit(1);
            }

            /**
             * <pre>
             * 1. get local latest version
             * 2. get remote latest version
             * 3. compare two version
             * </pre>
             */
            List<String> versionList = listNames(ARTHAS_LIB_DIR);
            Collections.sort(versionList);

            String localLastestVersion = null;
            if (!versionList.isEmpty()) {
                localLastestVersion = versionList.get(versionList.size() - 1);
            }

            if (mavenMetaData == null) {
                mavenMetaData = DownloadUtils.readMavenMetaData(bootstrap.getRepoMirror(), bootstrap.isuseHttp());
            }

            String remoteLastestVersion = DownloadUtils.readMavenReleaseVersion(mavenMetaData);

            boolean needDownload = false;
            if (localLastestVersion == null) {
                if (remoteLastestVersion == null) {
                    // exit
                    AnsiLog.error("Can not find Arthas under local: {} and remote: {}", ARTHAS_LIB_DIR,
                                    bootstrap.getRepoMirror());
                    System.exit(1);
                } else {
                    needDownload = true;
                }
            } else {
                if (remoteLastestVersion != null) {
                    if (localLastestVersion.compareTo(remoteLastestVersion) < 0) {
                        AnsiLog.info("local lastest version: {}, remote lastest version: {}, try to download from remote.",
                                        localLastestVersion, remoteLastestVersion);
                        needDownload = true;
                    }
                }
            }
            if (needDownload) {
                // try to download arthas from remote server.
                DownloadUtils.downArthasPackaging(bootstrap.getRepoMirror(), bootstrap.isuseHttp(),
                                remoteLastestVersion, ARTHAS_LIB_DIR.getAbsolutePath());
                localLastestVersion = remoteLastestVersion;
            }

            // get the latest version
            arthasHomeDir = new File(ARTHAS_LIB_DIR, localLastestVersion + File.separator + "arthas");
        }
        //再次校驗相關jar是否存在
        verifyArthasHome(arthasHomeDir.getAbsolutePath());

        AnsiLog.info("arthas home: " + arthasHomeDir);
        //啓動arthas-core.jar,並指定agent
        if (telnetPortPid > 0 && pid == telnetPortPid) {
            AnsiLog.info("The target process already listen port {}, skip attach.", bootstrap.getTelnetPort());
        } else {
            // start arthas-core.jar
            List<String> attachArgs = new ArrayList<String>();
            attachArgs.add("-jar");
            attachArgs.add(new File(arthasHomeDir, "arthas-core.jar").getAbsolutePath());
            attachArgs.add("-pid");
            attachArgs.add("" + pid);
            attachArgs.add("-target-ip");
            attachArgs.add(bootstrap.getTargetIp());
            attachArgs.add("-telnet-port");
            attachArgs.add("" + bootstrap.getTelnetPort());
            attachArgs.add("-http-port");
            attachArgs.add("" + bootstrap.getHttpPort());
            attachArgs.add("-core");
            attachArgs.add(new File(arthasHomeDir, "arthas-core.jar").getAbsolutePath());
            attachArgs.add("-agent");
            attachArgs.add(new File(arthasHomeDir, "arthas-agent.jar").getAbsolutePath());
            if (bootstrap.getSessionTimeout() != null) {
                attachArgs.add("-session-timeout");
                attachArgs.add("" + bootstrap.getSessionTimeout());
            }

            AnsiLog.info("Try to attach process " + pid);
            AnsiLog.debug("Start arthas-core.jar args: " + attachArgs);
            ProcessUtils.startArthasCore(pid, attachArgs);

            AnsiLog.info("Attach process {} success.", pid);
        }
        //判斷是否只作attach操做,若是隻作attach,則不鏈接
        if (bootstrap.isAttachOnly()) {
            System.exit(0);
        }

        // 啓動telnet客戶端
        URLClassLoader classLoader = new URLClassLoader(
                        new URL[] { new File(arthasHomeDir, "arthas-client.jar").toURI().toURL() });
        Class<?> telnetConsoleClas = classLoader.loadClass("com.taobao.arthas.client.TelnetConsole");
        Method mainMethod = telnetConsoleClas.getMethod("main", String[].class);
        List<String> telnetArgs = new ArrayList<String>();

        if (bootstrap.getCommand() != null) {
            telnetArgs.add("-c");
            telnetArgs.add(bootstrap.getCommand());
        }
        if (bootstrap.getBatchFile() != null) {
            telnetArgs.add("-f");
            telnetArgs.add(bootstrap.getBatchFile());
        }
        if (bootstrap.getHeight() != null) {
            telnetArgs.add("--height");
            telnetArgs.add("" + bootstrap.getHeight());
        }
        if (bootstrap.getWidth() != null) {
            telnetArgs.add("--width");
            telnetArgs.add("" + bootstrap.getWidth());
        }

        // telnet port ,ip
        telnetArgs.add(bootstrap.getTargetIp());
        telnetArgs.add("" + bootstrap.getTelnetPort());

        AnsiLog.info("arthas-client connect {} {}", bootstrap.getTargetIp(), bootstrap.getTelnetPort());
        AnsiLog.debug("Start arthas-client.jar args: " + telnetArgs);
        mainMethod.invoke(null, new Object[] { telnetArgs.toArray(new String[0]) });
    }

到此,Arthas 的啓動流程就結束了,在這其中,咱們發現了兩個關鍵的 jar 包,arthas-core 和 arthas-agent,那麼這兩個jar又作了什麼事情呢,我們繼續往下走,想要了解這兩個jar包的做用,首先咱們要先普及一個知識點——Java探針。

Java探針

Java探針主要涉及兩個知識點:

JavaAgent

JavaAgent 是一種可以在不影響正常編譯的狀況下,修改字節碼的技術。java做爲一種強類型的語言,不經過編譯就不能可以進行jar包的生成。

有了 JavaAgent 技術,就能夠在字節碼這個層面對類和方法進行修改。也能夠把 JavaAgent 理解成一種代碼注入的方式,可是這種注入比起 Spring的 AOP 更加的優美。

從JDK6開始,有兩種代理方式:

  • 經過命令行(-javaagent)的形式在應用程序啓動前處理(premain方式)

  • 在應用程序啓動後的某個時機處理(agentmain方式)

ASM字節碼

ASM 是一個通用的 Java 字節碼操做和分析框架,它能夠用於修改現有類或直接以二進制形式動態生成類。

ASM 提供了一些常見的字節碼轉換和分析算法,能夠從中構建自定義複雜轉換和代碼分析工具。

ASM 提供與其餘Java字節碼框架相似的功能,但專一於性能。由於它的設計和實現儘量小並且快,因此它很是適合在動態系統中使用(但固然也能夠以靜態方式使用,例如在編譯器中)。

ASM 用於許多項目,包括:

  • OpenJDK,生成lambda調用站點,以及Nashorn編譯器
  • Groovy 編譯器和 Kotlin 編譯器
  • Cobertura 和 Jacoco,爲了衡量代碼覆蓋率,儀器類
  • CGLIB,用於動態生成代理類(用於其餘項目,如Mockito和EasyMock)
  • Gradle,在運行時生成一些類

明白了這兩個知識點後,咱們一塊兒來看下 Arthas 中的 JavaAgent——arthas-agent

Arthas-Agent

首先咱們從Pom文件看起,找到premain和agentmain

arthas-agent.png

從這裏咱們很清楚地看到了 premain 和 agentmain 的方法被放在了com.taobao.arthas.agent.AgentBootstrap 中。

那麼接下來咱們就走進 AgentBootstrap 類中,瞭解下它的實現。

AgentBootstrap 類中,咱們很快發現了這兩個方法

agent-bootstrap.png

這兩個方法都同時指向當前類中的main方法,並傳遞了兩個參數,下面咱們先對着兩個參數作個解讀

  • String args

這個參數是咱們在 arthas-boot.jar 中啓動 arthas-core.jar 時傳入的參數

attach-args.png

  • Instrumentation inst

java.lang.instrument.Instrumentation 實例,由 JVM 自動傳入,集中了幾乎全部功能方法,如:類操做、classpath 操做等

瞭解了這兩個參數之後,咱們走進 main 方法看下實現

main.png

這裏主要重點講下上面圈出來的兩點,首先咱們來看下第一段的代碼。

get-class-loader.png

第一步先將咱們的 arthas-spy.jar 添加到 BootstrapClassLoader 中,在 Java Instrumention 的實現中,這行代碼應該是很常見的。爲何要這樣作呢?

在Java中,Java類加載器分爲 BootstrapClassLoader、ExtensionClassLoader和SystemClassLoader。

BootstrapClassLoader 主要加載的是JVM自身須要的類,因爲雙親委派機制的存在,越基礎的類由越上層的加載器進行加載,所以,若是須要在由 BootstrapClassLoader 加載的類的方法中調用由 SystemClassLoader 加載的arthas-spy.jar,這違反了雙親委派機制。

arthas-spy.jar 添加到 BootstrapClassLoader 的 classpath 中,由 BootstrapClassLoader加載,就解決了這個問題。

initSpy這個方法則使用 ArthasClassloader 加載 com.taobao.arthas.core.advisor.AdviceWeaver 類(在後續文章中會詳細解讀),並將其中的 methodOnBeginmethodOnReturnEndmethodOnThrowingEnd 等方法取出,並賦給Spy類。後面在經過ASM作類加強的時候,Spy就是鏈接業務類和Arthas類的橋樑。

接着咱們看下第二段藍色框中的代碼。這裏面主要是作了一些服務端啓動的事情。

bind.png

這段代碼中,主要經過反射的手段,調用了 ArthasBootstrap 類中的 bind 方法來啓動 Arthas 服務端,接下來咱們就一塊兒來看下 Arthas 服務端啓動的源碼。

Arthas服務端啓動

廢話很少說,先上代碼。

arthas-server-bind.png

這段代碼主要是圍繞 ShellServer 作一些配置,並調用 listen 方法啓動監聽

shell-server-listen.png

listen方法中,主要是根據以前註冊的TermServer來逐個啓動,這裏以TelnetTermServer爲例講解,接下來看下TelnetTermServer中的listen方法。

term-server-listen.png

咱們跟蹤下 start 代碼,發現最後調用的是 NettyTelnetBootstrapstart 方法。

netty-telnet-bootstrap-start.png

主要是經過 netty 來啓動網絡服務。下面咱們看下對輸入的處理類 TermServerTermHandler

term-server-term-handler.png

主要是經過調用shellServerhandleTerm方法。

shell-server-handle-term.png

這裏的 session 就是客戶端的鏈接,而readline方法就是用來處理用戶的輸入的。

至於每一個命令是如何工做的,且聽下回分解

總結

arthas中涉及到的知識點不少的瞭解

  • netty
  • termd
  • cli
  • asm
  • JavaAgent

你們若是感興趣的話,能夠花點時間研究下,相信這些框架會讓你們受益不淺。

逅弈逐碼,專一於原創分享,用通俗易懂的圖文描述源碼及原理

相關文章
相關標籤/搜索