ZooKeeper開發手冊中文翻譯

本文Github地址:https://github.com/sundiontheway/zookeeper-guide-cnhtml

 

本文假設你已經具備必定分佈式計算的基礎知識。你將在第一部分看到如下內容:java

  • ZooKeeper數據模型node

  • ZooKeeper Sessionsgit

  • ZooKeeper Watchesgithub

  • 一致性保證(Consistency Guarantees)算法

接下來的4小節講述了程序開發的實際應用:數據庫

  • 建立模塊——ZooKeeper操做指引express

  • 編程語言接口apache

  • 簡單示例演示程序的結構編程

  • 常見問題和故障

本文的附錄中包含和ZooKeeper相關的有用信息。

ZooKeeper的數據模型

ZooKeeper有一個相似分佈式文件系統的命名體系。區別在於Zookeeper每一個一個節點或子節點均可以擁有數據。節點路徑是一個由斜線分開的絕對路徑,注意沒有相對路徑。只要知足下面要求的unicode字符均可以做爲節點路徑:

  • 空字符不能出如今路徑名

  • 不能出現如下字符: \u0001 - \u0019 and \u007F - \u009F

  • 如下字符不容許使用: \ud800 -uF8FFF, \uFFF0-uFFFF, \uXFFFE - \uXFFFF (where X is a digit 1 - E), \uF0000 - \uFFFFF

  • 字符"."能夠做爲一個名字的一部分, 可是"."和".."不能單獨做爲相對路徑使用, 如下用法都是無效的: "/a/b/./c"或者"/a/b/../c"

  • "zookeeper"爲保留字符

ZNodes

ZooKeeper樹結構中的節點被稱爲znode。各個znode維護着一組用來標記數據和訪問權限發生變化的版本號。這些版本號組成的狀態結構 具備時間戳。Zookeeper使用版本號和時間戳來驗證緩存狀態,調整更新。 每次znode中的數據發生變化,znode的版本號增長。例如,每當一個客戶端恢復數據時,它就接收這個版本的數據,而當一個客戶端提交了更新或刪除記 錄,它必須同時提供這個znode當前正在發生變化的數據的版本。若是這個版本和目前真實的版本不匹配,則提交無效。 __提示,在分佈式程序中,一個字節點能夠表明一個通用的主機,服務器,集羣中的一員,客戶端程序等。可是在Zookeeper中,znode表明數據節 點,Servers表明組成了Zookeeper服務的機器; quorum peers refer to the servers that  make up an ensemble; 客戶端表明任何使用ZooKeeper服務的主機或程序。

znode做爲對程序開發來講最重要的信息,有幾個特性須要特別關注下:

Watches 客戶端能夠在znode上設置Watch。znode發生的變化會觸發watch而後清除watch。當一個watch被觸發,Zookeeper給客戶端發送一個通知。更多關於watch的內容請查看ZooKeeper Watches一節。

數據存取 命名空間中每一個znode中的數據讀寫是原子操做。讀操做讀取znode中的全部數據位,寫操做則替換全部數據。每一個節點都有一個訪問權限控制表 (ACL)來標記誰能夠作什麼。 zookeeper不是設計成普通的數據庫或大型對象存儲的。它是用來管理coordination data。coordination  data包括配置文件、狀態信息、rendezvous等。這些數據結構的一個共同特色就是相對較小——以千字節爲準。Zookeeper的客戶端和服務 會檢查確保每一個znode上的數據小於1M,實際平均數據要遠遠小於1M。 大規模數據的操做會引起一些潛在的問題而且延長在網絡和介質之間傳輸的時間。若是確實須要大型數據的存儲,那麼能夠採用如NFS或HDFS之類的大型數據 存儲系統,亦或是在zookeeper中存儲指向存儲位置的指針。

臨時節點(Ephemeral Nodes) zookeeper還有臨時節點的概念,這些節點的生命週期依賴於建立它們的session是否活躍。session結束時節點即被銷燬。也因爲這種特性,臨時節點不容許有子節點。

序列節點——命名不惟一 當你建立節點的時候,你會須要zookeeper提供一組單調遞增的計數來做爲路徑結尾。這個計數對父znode是惟一的。用%010d的格式——用0來填充的10位數(計數如此命名是爲了簡單排序)。例如"0000000001",注意計數器是有符號整型,超過表示範圍會溢出。

ZooKeeper中的時間

zookeeper有不少記錄時間的方式:

  • Zxid(ZooKeeper Transaction Id): zookeeper每次發生改動都會增長zxid,zxid越大,發生的時間越靠後。

  • Version numbers: 對znode的改動會增長版本號。版本號包括version (znode上數據的修改數), cversion (znode的子節點的修改數), aversion (znode上ACL(權限)的修改數)。

  • Ticks : 多個server構成zookeeper服務時,各個server用ticks來標記如狀態上報、鏈接超時等事件。ticks  time還間接反映了session超時的最小值(兩次tick time);若是客戶端請求的最小session  timeout低於這個最小值,服務端會通知客戶端最小超時置爲這個最小值。

  • Real time : 除了每次znode建立或改動時候將時間戳記錄到狀態結構中外,zookeeper不使用時鐘時間。

ZooKeeper狀態結構(Stat Structure)

存在於znode中的狀態結構,由如下各個部分組成:

  • czxid - znode建立產生的zxid

  • mzxid - znode最後一次修改的zxid

  • ctime - znode建立的時間的絕對毫秒數

  • mtime - znode最後一次修改的絕對毫秒數

  • version - znode上數據的修改數

  • cversion - 子節點修改數

  • aversion - znode的ACL修改數

  • ephemeralOwner - 臨時節點的全部者的session id。若是此節點非臨時節點,該值爲0

  • dataLength - znode的數據長度

  • numChildren - znode子節點數

ZooKeeper Sessions

客戶端經過建立一個handle和服務端創建session鏈接。一旦建立完成,handle就進入了CONNECTING狀態,客戶端庫嘗試鏈接一臺構成zookeeper的server,屆時進入CONNECTED狀態。一般狀況下操做會介於這兩種狀態之間。 一旦出現了不可恢復的錯誤:如session停止,鑑權失敗或者應用直接結束handle,則handle會進入到CLOSED狀態。下圖是客戶端的狀態轉換圖:

應用在建立客戶端session時必須提供一串逗號分隔的主機號:端口號,每對主機端口號對應一個ZooKeeper的 server(如:"127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002"),客戶端庫會嘗試鏈接任意一臺服務 器,若是鏈接失敗或是客戶端主動斷開鏈接,客戶端會自動繼續與下一臺服務器鏈接,直到鏈接成功。

3.2.0版本新增內容:  一個新的操做「chroot」能夠添加在鏈接字符串的尾部,用來指明客戶端命令運行的根目錄地址。相似unix的chroot命令,例如: "127.0.0.1:4545/app/a" or  "127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002/app/a",說明客戶端會以"/app/a"爲根目 錄,全部路徑都相對於根目錄來設置,如"/foo/bar"的操做會運行在"/app/a/foo/bar"。 這一特性在多用戶環境下很是好用,每一個使用zookeeper服務的用戶能夠設置不一樣的根目錄。

當客戶端得到和zookeeper服務鏈接的handle時,zookeeper會建立一個Zookeeper  session分配給客戶端,用一個64-bit數字表示。一旦客戶端鏈接了其餘服務器,客戶端必須把這個session  id也做爲鏈接握手的一部分發送。出於安全目的,zookeeper給session id建立一個密碼,任何zookeeper服務器均可以驗證密碼。 當客戶端建立session時密碼和session id一塊兒發送到客戶端來,當客戶端從新鏈接其餘服務器時,同時要發送密碼和session id。

zookeeper客戶端庫裏有一個建立zookeeper session的參數,叫作session  timeout(超時),用毫秒錶示。客戶端發送請求超時,服務端在超時範圍內響應客戶端。session超時最小爲2個ticktime,最大爲20個 ticktime。zookeeper客戶端API能夠協調超時時間。 當客戶端和zookeeper服務器集羣斷開時,它會搜索session建立時的服務器列表。最後,當至少一個服務器和客戶端從新創建連 接,session或被從新置爲"connected"狀態(超時時間內從新鏈接),或被置爲"expired(過時)"狀態(超出超時時間)。不建議在 斷開鏈接後從新建立session。ZK客戶端庫會幫你從新鏈接。特別地,咱們將啓發式學習模式植入客戶的庫中來處理相似「羊羣效應」等問題。只有當你的 session過時時才從新建立(託管的)。 session過時的狀態轉換圖示例同過時session的watcher:

  1. 'connected' : session正確建立,客戶端和服務集羣正常鏈接

  2. .... 客戶端從服務器集羣斷開

  3. 'disconnected' : 客戶端失去和服務器集羣的鏈接

  4. .... 過了一段時間, 超過了集羣斷定session過時的超時時間, 客戶端並無發覺本身和服務集羣斷開了鏈接

  5. .... 又過一段時間, 客戶端恢復了同集羣的網絡鏈接

  6. 'expired' : 最終客戶端從新連上集羣,而後被通知已經到期

另外一個session創建時zookeeper須要的參數是默認watcher(監視者)。在客戶端發生任何變化時,watcher都會發出通知。 例如客戶端失去和服務器的鏈接、客戶端session到期等。watcher默認的初始狀態是disconnected。(也就是說任何狀態改變事件都由 客戶端庫發送到watcher)。當新建一個鏈接時,第一個發送給watcher的事件一般就是session鏈接事件。

客戶端發送請求會使session保持活動狀態。客戶端會發送ping包(譯者注:心跳包)以保持session不會超時。Ping包不只讓服務端 知道客戶端仍然活動,並且讓客戶端也知道和服務端的鏈接沒有中斷。Ping包發送時間正好能夠判斷是否鏈接中斷或是從新啓動一個新的服務器鏈接。

和服務器的鏈接創建成功,當一個同步或異步操做執行後,有兩種狀況會讓客戶端庫判斷失去鏈接:

  1. 應用在已經失效的session上調用了一個操做時

  2. zookeeper服務器有未完成的操做,客戶端這時會斷開鏈接。即服務器有未完成的異步調用時

3.2.0版本新增內容 —— SessionMovedException  一個客戶端沒法查看的內部異常SessionMovedException。這個異常發生在服務端收到一個請求,這個請求的session已經在另外一個服 務器上從新鏈接。發生這種狀況的緣由一般是客戶端發送完請求後,因爲網絡延時,客戶端超時從新和其餘服務器創建鏈接,當請求包到達第一臺服務器時,服務器 發現session已經移除並關閉了和客戶端的鏈接。客戶端通常不用理會這個問題,可是有一種狀況值得注意,當兩臺客戶端使用事先存儲的session  id和密碼試圖建立同一個鏈接時,第一臺客戶端重建鏈接,第二臺則會被中斷。

ZooKeeper Watches

全部zookeeper的讀操做——getData(), getChildren(),  exists()——均可以設置一個watch。Zookeeper的watch的定義是:watch事件是一次性觸發的,發送到客戶端的。在監視的數據 發生變化時產生watch事件。如下三點是watch(事件)定義的關鍵點:

  • 一次性觸發: 當數據發生變化時,一個watch事件被髮送給客戶端。例如,若是一個客戶端作了一次getData("/znode1", true)而後節點/znode1發生數據變化或刪除,這個客戶端將收到/znode1的watch事件。若是/znode1繼續發生改變,不會再有watch發送,除非客戶端又作了其餘讀操做產生了新的watch。

  • 發送給客戶端: 這就意味着,事件在發往客戶端的過程當中,可能沒法在修改操做成功的返回值到達客戶端以前到達客戶端。watch是異步發送給watchers的。 zookeeper提供一種保證順序的方法:客戶端在第一次看到某個watch事件以前不可能看到產生watch的修改的返回值。網絡延時或其餘因素可能 致使不一樣客戶端看到watch並返回不一樣時間更新的返回值。關鍵的一點是,不一樣的客戶端看到發生的一切都必須是按照相同順序的。

  • watch依附的數據: 這是說改變一個節點有不通方式。用好理解的話說,zookeeper維護兩組watch:data watch和child  watch。getData()和exists()產生data watch。getChildren()引發child  watch。watch根據數據返回的種類不一樣而不一樣。getData()和exists()返回關於節點的數據信息,而getChildren()返回 子節點列表。所以setData()觸發某個znode的data  watch(假設事件成功)。create()成功會觸發被建立的znode上的data watch和在它父節點上的child  watch。delete()成功會觸發data watch和child watch(由於沒有了子節點)。

watch在客戶端已鏈接上的服務器裏維護,這樣能夠保證watch輕量便於設置,維護和分發。當客戶端鏈接了一臺新的服務器,watch會在任何 session事件時觸發。當斷開和服務器的鏈接時,watch不會觸發。當客戶端從新鏈接上時,任何以前註冊過的watch都會從新註冊並在須要的時候 被觸發。通常來講這一切都是透明的。只有一種可能會丟失watch:當一個znode在斷開和服務器鏈接時被建立或刪除,那麼判斷這個znode存在的 watch因未建立而找不到。

ZooKeeper如何保證watch可靠性

zookeeper有以下方式:

  • watch與其餘事件、watch、異步回覆保持有序,Zookeeper客戶端庫確保任何分發都是有序的。

  • 客戶端會在某個監視的znode數據更新以前看到這個znode的watch事件。

  • watch事件的順序由Zookeeper服務端觀察到的更新順序決定。

watch注意事項

  • watch是一次性觸發的;若是你收到watch事件後還想繼續獲得後續更改的通知,你須要再生成(設置)一個watch。

  • 因爲watch是一次性觸發,你在獲取某事件和發送新的請求來獲得watch這個操做之間,沒法確保觀察到Zookeeper中那個節點在這期間 的全部修改。你要準備好應付這種狀況出現:znode會在收到事件和再次設置新事件(譯者注:對節點的操做)之間發生了屢次修改。(你可能並不關心,可是 必須瞭解這可能發生)

  • watch對象,或是function/context對,只會在獲得通知時觸發一次。例如,若是一個watch對象同時用來監控某個目標文件是否存在和監聽getData(),以後那個文件被刪除了。那麼這個watch對象只會觸發一次文件刪除事件通知。

  • 若是你斷開了同服務器的鏈接(例如服務器掛了),你在從新連上以前得不到任何watch。出於這種緣由,session  event會被髮送給全部重要的watch  handler。可使用session事件進入安全模式:當斷開鏈接時你收不到任何事件,這樣你的進程能夠在那種模式下穩健地執行。(譯者注:能夠經過 發送session event使客戶端進入安全模式(僞斷開鏈接狀態),在安全模式你能夠修改代碼而不用擔憂程序收到事件通知)

使用ACL控制ZooKeeper訪問權限

zookeeper使用ACL來控制對znode(zookeeper的數據節點)的訪問權限。ACL的實現方式和unix的文件權限相似:用不一樣 位來表明不一樣的操做限制和組限制。與標準unix權限不一樣的是,zookeeper的節點沒有三種域——用戶,組,其餘。zookeeper裏沒有節點的 全部者的概念。取而代之的是,一個由ACL指定的id集合和其相關聯的權限。 注意,一個ACL只從屬於一個特定的znode。對這個znode子節點也是無效的。例如,若是/app只有被ip172.16.16.1的讀權限,/app/status有被全部人讀的權限,那麼/app/status能夠被全部人讀,ACL權限不具備遞歸性。 zookeeper支持插件式認證方式,id使用scheme:id的形式。scheme是id對應的類型方式,例如ip:172.16.16.1就是一個地址爲172.16.16.1的主機id。 當客戶端鏈接zookeeper而且認證本身,zookeeper就在這個與客戶端的鏈接中關聯全部與客戶端一致的id。當客戶端訪問某個znode時,znode的ACL會從新檢查這些id。ACL的表達式爲(scheme:expression,perms)expression就是特殊的scheme,例如,(ip:19.22.0.0/16, READ)就是把任何以19.22開頭的ip地址的客戶端賦予讀權限。

ACL權限

ZooKeeper支持下列權限:

  • CREATE:容許建立子節點

  • READ:容許得到節點數據並列出全部子節點

  • WRITE:容許設置節點上的數據

  • DELETE:容許刪除子節點

  • ADMIN:容許設置權限

CREATE和DELETE操做是更細的粒度上的WRITE操做。有一種特殊的狀況:

  • 你想要A得到操做zookeeper上某個znode的權限,可是不能夠對其子節點進行CREATE和DELETE。

  • 只CREATE不DELETE:某個客戶端在上一級目錄上經過發送建立請求建立了一個zookeeper節點。你但願全部客戶端均可以在這個節點上添加,可是隻有建立者能夠刪除。(這就相似於文件的APPEND權限)

zookeeper沒有文件全部者的概念,但有ADMIN權限。在某種意義上說,ADMIN權限指定了所謂的全部者。zookeeper雖然不支持 查找權限(在目錄上的執行權限雖然不能列出目錄內容,卻能夠查找),但每一個客戶端都隱含着擁有查找權限。這樣你能夠查看節點狀態,但僅此而已。(這有個問 題,若是你在不存在的節點上調用了zoo_exists(),你將無權查看)

內建ACL模式

ZooKeeper有下列內建模式:

  • world  有獨立id,anyone,表明任何用戶。

  • auth 不使用任何id,表明任何已經認證過的用戶

  • digest 以前使用了格式爲username:pathasowrd的字符串來生成一個MD5哈希表做爲ACL ID標識。在空文檔中發送username:password來完成認證。如今的ACL表達式格式爲username:base64, 用SHA1編碼密碼。

  • ip 用客戶端的ip做爲ACL ID標識。ACL表達式的格式爲addr/bits,addr中最有效的位匹配上主機ip最有效的位。

ZooKeeper C client API

插件式ZooKeeper認證

zookeeper運行於複雜的環境下,有各類不一樣的認證方式。所以zookeeper擁有一套插件式的認證框架。內建認證scheme也是使用這 套框架。 爲了便於理解認證框架的工做方式,你首先要了解兩種主要的認證操做。框架首先必須認證客戶端。這步操做一般在客戶端鏈接服務器的同時完成而且將從客戶端發 過來的(或從客戶端收集來的)認證信息關聯這次鏈接。認證框架的第二步操做是在ACL中尋找關聯的客戶端的條目。ACL條目是<idspec, permissions>格式。idspec多是一個關聯了鏈接的,和認證信息匹配的簡單字符串,也多是評估認證信息的表達式。這取決於認證插件如何實現匹配。下面是一個認證插件必須實現的接口:

public interface AuthenticationProvider {     String getScheme();     KeeperException.Code handleAuthentication(ServerCnxn cnxn, byte authData[]);     boolean isValid(String id);     boolean matches(String id, String aclExpr);     boolean isAuthenticated(); }

 

第一個方法getScheme返回一個標識該插件的字符串。因爲咱們支持多種認證方式,認證證書或者idspec必須一直加上scheme:做爲前綴。zookeeper服務器使用認證插件返回的scheme判斷哪一個id適用於該scheme。 當客戶端發送與鏈接關聯的認證信息時,handleAuthentication被調用。客戶端指定和認證信息相應的模式。zookeeper把信息傳給認證插件,認證插件的getScheme匹配scheme。實現handleAuthentication的方法一般在判斷信息錯誤後返回一個error,或者在確認鏈接後使用cnxn.getAuthInfo().add(new Id(getScheme(), data))

認證插件在設置和ACL中都有涉及。當對某個節點設置ACL時,zookeeper服務器會傳那個條目的id給isValid(String id)方法。插件須要判斷id的鏈接來源。例如,ip:172.16.0.0/16是有效id,ip:host.com是無效id。若是新的ACL包括一個"auth"條目,就用isAuthenticated判斷該scheme的認證信息是否關聯了鏈接,是否能夠被添加到ACL中。一些scheme不會被包含到auth中。例如,若是auth已經指定,客戶端的ip地址就不做爲id添加到ACL中。 在檢查ACL時zookeeper有一個matches(String id, String aclExpr)方法。ACL的條目須要和認證信息相匹配。爲了找到和客戶端對應的條目,zookeeper服務器尋找每一個條目的scheme,若是對某個scheme有那個客戶端的認證信息,matches(String id, String aclExpr)會被調用並傳入兩個值,分別是事先由handleAuthentication 加入鏈接信息中認證信息的id,和設置到ACL條目id的aclExpr。認證插件用本身的邏輯匹配scheme來判斷id是否在aclExpr中。

有兩個內置認證插件:ip和digest。附加插件可使用系統屬性添加。在zookeeper啓動過程當中,會掃描全部以"zookeeper.authProvider"開頭的系統屬性。而且把那些屬性值解釋爲認證插件的類名。這些屬性可使用-Dzookeeeper.authProvider.X=com.f.MyAuth或在服務器設置文件中添加條目來建立:

authProvider.1=com.f.MyAuth authProvider.2=com.f.MyAuth2

 

注意屬性的後綴是惟一的。若是出現重複的狀況-Dzookeeeper.authProvider.X=com.f.MyAuth -Dzookeeper.authProvider.X=com.f.MyAuth2,只有一個會被使用。一樣,全部服務器都必須統一插件定義,不然客戶端用插件提供的認證schemes鏈接服務器時會出錯。

一致性保證

ZooKeeper是一個高性能,可擴展的服務。讀和寫操做都很是快速。之因此如此,全由於zookeeper有數據一致性的保證:

順序一致性 客戶端的更新會按照它們發送的次序排序。

原子性 更新的失敗或成功,都不會出現半個結果。

單獨系統鏡像 無論客戶端連哪一個服務器,它看來都是同一個。

可靠性 一旦更新生效,它就會一直保存到下一次客戶端更新。這就有兩個推論:

  1. 若是客戶端獲得成功的返回值,說明更新生效了。在一些錯誤狀況下(鏈接錯誤,超時等)客戶端不會知道更新是否生效。雖然咱們使失敗的概率最小化,可是也只能保證成功的返回值狀況。(這就叫Paxos算法的單調性條件)

  2. 客戶端能看到的更新,即便是渡請求或成功更新,在服務器失敗時也不會回滾。

時效性 客戶端看到的系統狀態在某個時間範圍內是最新的(幾十秒內),任何系統更改都會在該時間範圍內被客戶端發現。不然客戶端會檢測到斷開服務。

用這些一致性保證能夠在客戶端中構造出更高級的程序如 leader election, barriers, queues, read/write revocable locks(無須在zookeeper中附加任何東西)。更多信息Recipes and Solutions

zookeeper不存在的一致性保證: 多客戶端同一時刻看到的內容相同 zookeeper不可能保證兩臺客戶端在同一時間看到的內容老是同樣,因爲網絡延遲等緣由。假設這樣一個場景,A和B是兩個客戶端,A設置節點/a下的 值從0變爲1,而後讓B讀/a,B可能讀到舊的數據0。若是想讓A和B讀到一樣的內容,B必須在讀以前調用zookeeper接口中的sync()方法。

編程接口

常見問題和故障

下面是一些常見的陷阱:

  1. 若是你使用watch,你必須監控好已經鏈接的watch事件。當ZooKeeper客戶端斷開和服務器的鏈接,直到從新鏈接上這段時間你都收不到任何通知。若是你正在監視znode是否存在,那麼你在斷開鏈接期間收不到它建立和銷燬的通知。

  2. 你必須測試ZooKeeper故障的狀況。在大多數服務器均可用的狀況下,ZooKeeper是能夠維持工做的。關鍵問題是你的客戶端程序是否能 察覺到。在實際狀況下,客戶端與ZooKeeper的鏈接有可能中斷(多數時候是由於Zookeeper故障或網絡中斷)。Zookeeper的客戶端庫 關注於如何讓你從新鏈接而且知道發生了什麼。可是同時你也必須確保可以恢復你的狀態和發送失敗的請求。努力在測試庫裏測出這些問題,而不是在產品裏——用 幾臺服務器組成的zookeeper集羣測試這個問題,嘗試讓它們重啓。

  3. 客戶端維護的服務器列表必須和現有的服務器列表一致。若是客戶端的列表是現有服務器列表的子集,還能夠在非最佳狀態工做,可是若是客戶端列表裏的服務器不在現有集羣裏你就悲劇了。

  4. 注意存放事務日誌的位置。性能評測最重要的部分就是日誌,ZooKeeper會在回覆響應以前先把日誌同步到磁盤上。爲了達到最佳性能,首選專用 的磁盤來存日誌。把日誌放在繁忙的磁盤上會下降效率。若是你只有一個磁盤,就把記錄文件放在NFS上而後增長SnapshotCount。這樣雖然沒法完 全解決問題,但能緩解一些。

  5. 正確地設置你java的堆空間大小。這是避免頻繁交換的有效措施。無用的訪問磁盤會讓你的效率大打折扣。記住,在ZooKeeper中,一切都是有序的,若是一個服務器訪問了磁盤,全部的服務器都會同步這個操做。

其餘資料連接請自行官網查看。

相關文章
相關標籤/搜索