主頁 >  其他 > Zookeeper原始碼決議-事務日志查看與分析

Zookeeper原始碼決議-事務日志查看與分析

2021-10-15 21:44:46 其他

前言:

總體而言,Zookeeper服務端的日志分為三種:事務日志、快照日志、log4j日志,

log4j日志無需多言,我們在%ZOOKEEPER_DIR%/conf/log4j.properties中配置了日志的詳細資訊,

本文主要介紹下事務日志的內容和Zookeeper如何生成事務日志以及其作用,快照日志的話,下一篇會著重介紹,

1.什么是事務日志?

我們在%ZOOKEEPER_DIR%/conf/zoo.cfg中配置的dataDir引數,是專門用于存盤事務日志和快照日志的檔案夾路徑,當然,我們也可以將兩個日志分開(事務讀寫比較頻繁時事務日志會比較大,將兩者分開可以提高系統性能),這時可以在zoo.cfg中配置dataLogDir路徑,

那么什么是事務日志呢?

就是Zookeeper服務端針對客戶端的所有事務請求(create、update、delete)等操作,在回傳成功之前,都會將本次操作的內容持久化到磁盤上,完成之后,才回傳客戶端成功標志,

2.查看事務日志資訊

在筆者的機器上,我們在%ZOOKEEPER_DIR%/data/version-2目錄下,看到以下幾個檔案

這個就是事務日志,直接打開的話是二進制內容,不利于查看,那么我們可以通過Zookeeper原始碼中提供的org.apache.zookeeper.server.LogFormatter來查看,

通過在main()方法中指定需要查看的事務日志檔案路徑即可以查看,筆者在查看log.1檔案時,生成以下輸出:

...
// 創建節點 /hello20040   
21-10-5 下午05時13分46秒 session 0x10000d4a6d50002 cxid 0x29 zxid 0x3ac8 create '/hello20040,#776f726c643230303430,v{s{31,s{'world,'anyone}}},F,15041

// 創建一個Session會話
21-10-7 下午01時21分41秒 session 0x10000d4a6d50005 cxid 0x0 zxid 0x12505 createSession 40000

// 設定/hello20040 值
21-10-7 下午01時21分41秒 session 0x10000d4a6d50005 cxid 0x1 zxid 0x12506 setData '/hello20040,#3137,1
// 洗掉/hello20040節點    
21-10-7 下午01時22分33秒 session 0x10000d4a6d50006 cxid 0x1 zxid 0x1250a delete '/hello20040
// 關倍訓話
21-10-7 下午01時22分41秒 session 0x10000d4a6d50007 cxid 0x0 zxid 0x1250b createSession 40000  

通過以上日志可以很清楚的看到每一次事務操作時的具體資訊,這樣方便我們進行問題排查,

當然,不僅可以直接通過debug代碼的方式來查看,我們同樣可以通過Zookeeper.jar的方式來查看,大家可以參考這篇博文: https://blog.csdn.net/qq_34291777/article/details/86644347

3.事務日志請求執行程序

有了前面對Zookeeper server端處理請求的分析,我們知道事務日志的添加呼叫入口是通過SyncRequestProcessor來完成的,下來就一起來分析下其是如何將事務日志落入磁盤的,

我們就以create()方法為示例,來看下整個程序,前面server處理會話創建請求的文章中,我們知道,最終交由三個requestProcessor來處理,處理順序為 PrepRequestProcessor --> SyncRequestProcessor --> FinalRequestProcessor

3.1 PrepRequestProcessor.pRequest() 創建事務請求物件

public class PrepRequestProcessor extends ZooKeeperCriticalThread implements RequestProcessor {
    protected void pRequest(Request request) throws RequestProcessorException {
            // 事務請求request,分為hdr請求頭和txn請求體
            request.hdr = null;
            request.txn = null;

            try {
                switch (request.type) {
                    case OpCode.create:
                    // 這里的CreateRequest就是請求體    
                    CreateRequest createRequest = new CreateRequest();
                    // 交由pRequest2Txn()方法處理
                    pRequest2Txn(request.type, zks.getNextZxid(), request, createRequest, true);
                    break;
                }
                ...
            }
        
        	// 交由下一個processor執行
            request.zxid = zks.getZxid();
            nextProcessor.processRequest(request);
    }
    
    protected void pRequest2Txn(int type, long zxid, Request request, Record record, boolean deserialize)
        throws KeeperException, IOException, RequestProcessorException
    {
        // 創建請求頭
        request.hdr = new TxnHeader(request.sessionId, request.cxid, zxid,
                                    Time.currentWallTime(), type);

        switch (type) {
            case OpCode.create:                
                zks.sessionTracker.checkSession(request.sessionId, request.getOwner());
                CreateRequest createRequest = (CreateRequest)record;   
                if(deserialize)
                    ByteBufferInputStream.byteBuffer2Record(request.request, createRequest);
                // 檢查path合法性及ACL權限控制
                String path = createRequest.getPath();
                int lastSlash = path.lastIndexOf('/');
                if (lastSlash == -1 || path.indexOf('\0') != -1 || failCreate) {
                    LOG.info("Invalid path " + path + " with session 0x" +
                            Long.toHexString(request.sessionId));
                    throw new KeeperException.BadArgumentsException(path);
                }
                List<ACL> listACL = removeDuplicates(createRequest.getAcl());
                if (!fixupACL(request.authInfo, listACL)) {
                    throw new KeeperException.InvalidACLException(path);
                }
                // 檢查pathACL
                String parentPath = path.substring(0, lastSlash);
                ChangeRecord parentRecord = getRecordForPath(parentPath);

                checkACL(zks, parentRecord.acl, ZooDefs.Perms.CREATE,
                        request.authInfo);
                int parentCVersion = parentRecord.stat.getCversion();
                // 根據節點是否持久化和順序化進行不同的驗證
                CreateMode createMode =
                    CreateMode.fromFlag(createRequest.getFlags());
                if (createMode.isSequential()) {
                    path = path + String.format(Locale.ENGLISH, "%010d", parentCVersion);
                }
                validatePath(path, request.sessionId);
                try {
                    if (getRecordForPath(path) != null) {
                        throw new KeeperException.NodeExistsException(path);
                    }
                } catch (KeeperException.NoNodeException e) {
                    // ignore this one
                }
                
                boolean ephemeralParent = parentRecord.stat.getEphemeralOwner() != 0;
                if (ephemeralParent) {
                    throw new KeeperException.NoChildrenForEphemeralsException(path);
                }
                int newCversion = parentRecord.stat.getCversion()+1;
                // 生成事務請求體
                request.txn = new CreateTxn(path, createRequest.getData(),
                        listACL,
                        createMode.isEphemeral(), newCversion);
                StatPersisted s = new StatPersisted();
                if (createMode.isEphemeral()) {
                    s.setEphemeralOwner(request.sessionId);
                }
                // 將父節點的變更資訊和當前節點的變更資訊推送到ZooKeeperServer.outstandingChanges中
                parentRecord = parentRecord.duplicate(request.hdr.getZxid());
                parentRecord.childCount++;
                parentRecord.stat.setCversion(newCversion);
                addChangeRecord(parentRecord);
                addChangeRecord(new ChangeRecord(request.hdr.getZxid(), path, s,
                        0, listACL));
                break;
        }
        ...
    }
}

總結:事務請求物件Request,包含請求頭TxnHeader hdr和請求體Record txn,所以PrepRequestProcessor的主要作業就是堆hdr和txn的封裝

3.2 SyncRequestProcessor 事務日志添加

public class SyncRequestProcessor extends ZooKeeperCriticalThread implements RequestProcessor {
    // 事務請求Request被添加到queuedRequests中
	public void processRequest(Request request) {
        queuedRequests.add(request);
    }
    
    public void run() {
        try {
            int logCount = 0;

            setRandRoll(r.nextInt(snapCount/2));
            while (true) {
                Request si = null;
                // 不斷從queuedRequests獲取事務請求資訊
                if (toFlush.isEmpty()) {
                    si = queuedRequests.take();
                } else {
                    si = queuedRequests.poll();
                    if (si == null) {
                        flush(toFlush);
                        continue;
                    }
                }
                if (si == requestOfDeath) {
                    break;
                }
                if (si != null) {
                    // 在這里將Request添加到事務日志中
                    if (zks.getZKDatabase().append(si)) {
                        logCount++;
                        // 如果需要重繪到磁盤則執行flush操作
                        if (logCount > (snapCount / 2 + randRoll)) {
                            setRandRoll(r.nextInt(snapCount/2));
                            // roll the log
                            zks.getZKDatabase().rollLog();
                            // take a snapshot
                            if (snapInProcess != null && snapInProcess.isAlive()) {
                                LOG.warn("Too busy to snap, skipping");
                            } else {
                                // 快照日志單獨啟動一個執行緒來執行,避免阻塞主執行緒執行,后續專門分析
                                snapInProcess = new ZooKeeperThread("Snapshot Thread") {
                                        public void run() {
                                            try {
                                                zks.takeSnapshot();
                                            } catch(Exception e) {
                                                LOG.warn("Unexpected exception", e);
                                            }
                                        }
                                    };
                                snapInProcess.start();
                            }
                            logCount = 0;
                        }
                    } else if (toFlush.isEmpty()) {
                        if (nextProcessor != null) {
                            nextProcessor.processRequest(si);
                            if (nextProcessor instanceof Flushable) {
                                ((Flushable)nextProcessor).flush();
                            }
                        }
                        continue;
                    }
                    toFlush.add(si);
                    // 執行flush操作
                    if (toFlush.size() > 1000) {
                        flush(toFlush);
                    }
                }
            }
        } catch (Throwable t) {
            handleException(this.getName(), t);
            running = false;
        }
        LOG.info("SyncRequestProcessor exited!");
    }
}

事務日志的磁盤寫入,默認分為兩步:寫入(append)、重繪(rollLog/commit)

寫入動作并不是真正的寫入磁盤(而是暫時快取下來),重繪操作才是真正將快取的內容寫入到磁盤中,

有了以上的分析,我們后面直接去分析append方法和flush方法的執行程序

4.事務日志的生成

主要就是對ZKDatabase.append()方法和ZKDatabase.rollLog()方法的呼叫

4.1 ZKDatabase相關方法

public class ZKDatabase {
    protected FileTxnSnapLog snapLog;
    
	public boolean append(Request si) throws IOException {
        return this.snapLog.append(si);
    }

    public void rollLog() throws IOException {
        this.snapLog.rollLog();
    }
    public void commit() throws IOException {
        this.snapLog.commit();
    }
}

本質上都交由snapLog來操作

4.2 FileTxnSnapLog相關方法

public class FileTxnSnapLog {
    // 事務日志操作類
    private final File dataDir;
    private TxnLog txnLog;
    
    // 快照日志操作類
    private final File snapDir;
    private SnapShot snapLog;
	public boolean append(Request si) throws IOException {
        return txnLog.append(si.hdr, si.txn);
    }

    /**
     * commit the transaction of logs
     * @throws IOException
     */
    public void commit() throws IOException {
        txnLog.commit();
    }

    /**
     * roll the transaction logs
     * @throws IOException 
     */
    public void rollLog() throws IOException {
        txnLog.rollLog();
    }
}

FileTxnSnapLog本質上只是一個包裝類,統一提供對事務日志和快照日志的操作API,

4.3 FileTxnLog 事務日志操作

在分析代碼之前,我們先看下FileTxnLog類的注釋,可以幫助我們很好的理解事務日志檔案的組成,如下圖所示:

事務日志檔案主要由三部分組成:檔案頭(FileHead)、事務內容(Txn組成的list,每一個Txn包含了checksum Txnlen TxnHeader Record 0x42等屬性)、填充數字

事務內容的組成,如下圖所示:

public class FileTxnLog implements TxnLog {
    // 最新的zxid
    long lastZxidSeen;
    // 事務日志流
    volatile BufferedOutputStream logStream = null;

	public synchronized boolean append(TxnHeader hdr, Record txn)
        throws IOException
    {
        if (hdr == null) {
            return false;
        }

        if (hdr.getZxid() <= lastZxidSeen) {
            LOG.warn("Current zxid " + hdr.getZxid()
                    + " is <= " + lastZxidSeen + " for "
                    + hdr.getType());
        } else {
            lastZxidSeen = hdr.getZxid();
        }

        if (logStream==null) {

           // 若檔案為空,則默認以當前事務的zxid結尾來創建log檔案
           logFileWrite = new File(logDir, Util.makeLogName(hdr.getZxid()));
           fos = new FileOutputStream(logFileWrite);
           logStream=new BufferedOutputStream(fos);
           oa = BinaryOutputArchive.getArchive(logStream);
           // 先寫入fileheader
           FileHeader fhdr = new FileHeader(TXNLOG_MAGIC,VERSION, dbId);
           fhdr.serialize(oa, "fileheader");
           // Make sure that the magic number is written before padding.
           logStream.flush();
           filePadding.setCurrentSize(fos.getChannel().position());
           streamsToFlush.add(fos);
        }
        filePadding.padFile(fos.getChannel());
        // 將事務請求轉換為byte[]
        byte[] buf = Util.marshallTxnEntry(hdr, txn);
        if (buf == null || buf.length == 0) {
            throw new IOException("Faulty serialization for header " +
                    "and txn");
        }
        // 計算checksum,并寫入
        Checksum crc = makeChecksumAlgorithm();
        crc.update(buf, 0, buf.length);
        oa.writeLong(crc.getValue(), "txnEntryCRC");
        // 將事務請求體寫入,并添加EOR標志位
        Util.writeTxnBytes(oa, buf);

        return true;
    }
}

總結:append()的程序本質上就是將事務請求體不斷寫入的程序,按照標準的流操作執行即可,

而關于commit()等方法,就更簡單了,就是執行流的flush操作,筆者不再贅述,

public class FileTxnLog implements TxnLog {
	public synchronized void rollLog() throws IOException {
        if (logStream != null) {
            this.logStream.flush();
            this.logStream = null;
            oa = null;
        }
    }

	public synchronized void commit() throws IOException {
        if (logStream != null) {
            logStream.flush();
        }
        for (FileOutputStream log : streamsToFlush) {
            log.flush();
            ...
        }
    }
}

總結:

本文分析了Zookeeper事務日志的相關知識點,從如何查看到原始碼分析其寫入程序,代碼并不算復雜,了解了該日志的基本資訊后,我們在日常的問題排查中就可以考慮查看事務日志來還原客戶端操作程序,

后續會繼續對快照日志進行分析,

轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/316444.html

標籤:其他

上一篇:Hadoop之MapReduce統計單詞個數

下一篇:Kafka學習筆記(一):Kafka簡介與mac下的環境配置

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 網閘典型架構簡述

    網閘架構一般分為兩種:三主機的三系統架構網閘和雙主機的2+1架構網閘。 三主機架構分別為內端機、外端機和仲裁機。三機無論從軟體和硬體上均各自獨立。首先從硬體上來看,三機都用各自獨立的主板、記憶體及存盤設備。從軟體上來看,三機有各自獨立的作業系統。這樣能達到完全的三機獨立。對于“2+1”系統,“2”分為 ......

    uj5u.com 2020-09-10 02:00:44 more
  • 如何從xshell上傳檔案到centos linux虛擬機里

    如何從xshell上傳檔案到centos linux虛擬機里及:虛擬機CentOs下執行 yum -y install lrzsz命令,出現錯誤:鏡像無法找到軟體包 前言 一、安裝lrzsz步驟 二、上傳檔案 三、遇到的問題及解決方案 總結 前言 提示:其實很簡單,往虛擬機上安裝一個上傳檔案的工具 ......

    uj5u.com 2020-09-10 02:00:47 more
  • 一、SQLMAP入門

    一、SQLMAP入門 1、判斷是否存在注入 sqlmap.py -u 網址/id=1 id=1不可缺少。當注入點后面的引數大于兩個時。需要加雙引號, sqlmap.py -u "網址/id=1&uid=1" 2、判斷文本中的請求是否存在注入 從文本中加載http請求,SQLMAP可以從一個文本檔案中 ......

    uj5u.com 2020-09-10 02:00:50 more
  • Metasploit 簡單使用教程

    metasploit 簡單使用教程 浩先生, 2020-08-28 16:18:25 分類專欄: kail 網路安全 linux 文章標簽: linux資訊安全 編輯 著作權 metasploit 使用教程 前言 一、Metasploit是什么? 二、準備作業 三、具體步驟 前言 Msfconsole ......

    uj5u.com 2020-09-10 02:00:53 more
  • 游戲逆向之驅動層與用戶層通訊

    驅動層代碼: #pragma once #include <ntifs.h> #define add_code CTL_CODE(FILE_DEVICE_UNKNOWN,0x800,METHOD_BUFFERED,FILE_ANY_ACCESS) /* 更多游戲逆向視頻www.yxfzedu.com ......

    uj5u.com 2020-09-10 02:00:56 more
  • 北斗電力時鐘(北斗授時服務器)讓網路資料更精準

    北斗電力時鐘(北斗授時服務器)讓網路資料更精準 北斗電力時鐘(北斗授時服務器)讓網路資料更精準 京準電子科技官微——ahjzsz 近幾年,資訊技術的得了快速發展,互聯網在逐漸普及,其在人們生活和生產中都得到了廣泛應用,并且取得了不錯的應用效果。計算機網路資訊在電力系統中的應用,一方面使電力系統的運行 ......

    uj5u.com 2020-09-10 02:01:03 more
  • 【CTF】CTFHub 技能樹 彩蛋 writeup

    ?碎碎念 CTFHub:https://www.ctfhub.com/ 筆者入門CTF時時剛開始刷的是bugku的舊平臺,后來才有了CTFHub。 感覺不論是網頁UI設計,還是題目質量,賽事跟蹤,工具軟體都做得很不錯。 而且因為獨到的金幣制度的確讓人有一種想去刷題賺金幣的感覺。 個人還是非常喜歡這個 ......

    uj5u.com 2020-09-10 02:04:05 more
  • 02windows基礎操作

    我學到了一下幾點 Windows系統目錄結構與滲透的作用 常見Windows的服務詳解 Windows埠詳解 常用的Windows注冊表詳解 hacker DOS命令詳解(net user / type /md /rd/ dir /cd /net use copy、批處理 等) 利用dos命令制作 ......

    uj5u.com 2020-09-10 02:04:18 more
  • 03.Linux基礎操作

    我學到了以下幾點 01Linux系統介紹02系統安裝,密碼啊破解03Linux常用命令04LAMP 01LINUX windows: win03 8 12 16 19 配置不繁瑣 Linux:redhat,centos(紅帽社區版),Ubuntu server,suse unix:金融機構,證券,銀 ......

    uj5u.com 2020-09-10 02:04:30 more
  • 05HTML

    01HTML介紹 02頭部標簽講解03基礎標簽講解04表單標簽講解 HTML前段語言 js1.了解代碼2.根據代碼 懂得挖掘漏洞 (POST注入/XSS漏洞上傳)3.黑帽seo 白帽seo 客戶網站被黑帽植入劫持代碼如何處理4.熟悉html表單 <html><head><title>TDK標題,描述 ......

    uj5u.com 2020-09-10 02:04:36 more
最新发布
  • 2023年最新微信小程式抓包教程

    01 開門見山 隔一個月發一篇文章,不過分。 首先回顧一下《微信系結手機號資料庫被脫庫事件》,我也是第一時間得知了這個訊息,然后跟蹤了整件事情的經過。下面是這起事件的相關截圖以及近日流出的一萬條資料樣本: 個人認為這件事也沒什么,還不如關注一下之前45億快遞資料查詢渠道疑似在近日復活的訊息。 訊息是 ......

    uj5u.com 2023-04-20 08:48:24 more
  • web3 產品介紹:metamask 錢包 使用最多的瀏覽器插件錢包

    Metamask錢包是一種基于區塊鏈技術的數字貨幣錢包,它允許用戶在安全、便捷的環境下管理自己的加密資產。Metamask錢包是以太坊生態系統中最流行的錢包之一,它具有易于使用、安全性高和功能強大等優點。 本文將詳細介紹Metamask錢包的功能和使用方法。 一、 Metamask錢包的功能 數字資 ......

    uj5u.com 2023-04-20 08:47:46 more
  • vulnhub_Earth

    前言 靶機地址->>>vulnhub_Earth 攻擊機ip:192.168.20.121 靶機ip:192.168.20.122 參考文章 https://www.cnblogs.com/Jing-X/archive/2022/04/03/16097695.html https://www.cnb ......

    uj5u.com 2023-04-20 07:46:20 more
  • 從4k到42k,軟體測驗工程師的漲薪史,給我看哭了

    清明節一過,盲猜大家已經無心上班,在數著日子準備過五一,但一想到銀行卡里的余額……瞬間心情就不美麗了。最近,2023年高校畢業生就業調查顯示,本科畢業月平均起薪為5825元。調查一出,便有很多同學表示自己又被平均了。看著這一資料,不免讓人想到前不久中國青年報的一項調查:近六成大學生認為畢業10年內會 ......

    uj5u.com 2023-04-20 07:44:00 more
  • 最新版本 Stable Diffusion 開源 AI 繪畫工具之中文自動提詞篇

    🎈 標簽生成器 由于輸入正向提示詞 prompt 和反向提示詞 negative prompt 都是使用英文,所以對學習母語的我們非常不友好 使用網址:https://tinygeeker.github.io/p/ai-prompt-generator 這個網址是為了讓大家在使用 AI 繪畫的時候 ......

    uj5u.com 2023-04-20 07:43:36 more
  • 漫談前端自動化測驗演進之路及測驗工具分析

    隨著前端技術的不斷發展和應用程式的日益復雜,前端自動化測驗也在不斷演進。隨著 Web 應用程式變得越來越復雜,自動化測驗的需求也越來越高。如今,自動化測驗已經成為 Web 應用程式開發程序中不可或缺的一部分,它們可以幫助開發人員更快地發現和修復錯誤,提高應用程式的性能和可靠性。 ......

    uj5u.com 2023-04-20 07:43:16 more
  • CANN開發實踐:4個DVPP記憶體問題的典型案例解讀

    摘要:由于DVPP媒體資料處理功能對存放輸入、輸出資料的記憶體有更高的要求(例如,記憶體首地址128位元組對齊),因此需呼叫專用的記憶體申請介面,那么本期就分享幾個關于DVPP記憶體問題的典型案例,并給出原因分析及解決方法。 本文分享自華為云社區《FAQ_DVPP記憶體問題案例》,作者:昇騰CANN。 DVPP ......

    uj5u.com 2023-04-20 07:43:03 more
  • msf學習

    msf學習 以kali自帶的msf為例 一、msf核心模塊與功能 msf模塊都放在/usr/share/metasploit-framework/modules目錄下 1、auxiliary 輔助模塊,輔助滲透(埠掃描、登錄密碼爆破、漏洞驗證等) 2、encoders 編碼器模塊,主要包含各種編碼 ......

    uj5u.com 2023-04-20 07:42:59 more
  • Halcon軟體安裝與界面簡介

    1. 下載Halcon17版本到到本地 2. 雙擊安裝包后 3. 步驟如下 1.2 Halcon軟體安裝 界面分為四大塊 1. Halcon的五個助手 1) 影像采集助手:與相機連接,設定相機引數,采集影像 2) 標定助手:九點標定或是其它的標定,生成標定檔案及內參外參,可以將像素單位轉換為長度單位 ......

    uj5u.com 2023-04-20 07:42:17 more
  • 在MacOS下使用Unity3D開發游戲

    第一次發博客,先發一下我的游戲開發環境吧。 去年2月份買了一臺MacBookPro2021 M1pro(以下簡稱mbp),這一年來一直在用mbp開發游戲。我大致分享一下我的開發工具以及使用體驗。 1、Unity 官網鏈接: https://unity.cn/releases 我一般使用的Apple ......

    uj5u.com 2023-04-20 07:40:19 more