Unity3D遊戲開發之網絡遊戲服務器架構設計培訓

下面我們開始今天的Unity3D遊戲開發技能培訓。 我們專業培養”遊戲主程”,挑戰20W年薪,初期學習Unity3D培訓目標:讓U3D初學者可以更快速的掌握U3D技術,自行製作修改素材,可以獨立完成2D、3D小規模遊戲及網頁遊戲開發。

今天給大家講一下如何做一個好的主程

入手

假如,我現在接手一個新項目,我的身份還是主程序。在下屬人員一一到位之前,在和製作人以及主策劃充分溝通後,我需要先獨自思考以下問題:

1、服務器跑在什麼樣的操作系統環境下?
2、採用哪幾種語言開發?主要是什麼?
3、服務器和客戶端以什麼樣的接口通訊?
4、採用哪些第三方的類庫?

除了技術背景之外,考慮這些問題的時候一定要充分考慮項目需求和所能擁有的資源。

我覺得,先不要想一組需要幾臺機器各有什麼功能這樣的問題,也不要想需要多少個daemon進程。假設就一臺服務器,就一個進程,把所需要的資源往最小了考慮,把架構往最簡單的方向想,直到發現,“哦,這麼做無法滿足策劃要求的併發量”,再去修改設計方案。

操作系統:越單一越好。雖然FreeBSD的網絡性能更好、雖然Solaris非常穩定,但選什麼就是什麼,最好別混着來。前端是FreeBSD,後端是Solaris,運營的人會苦死。也不要瞧不起用Windows的人,用Windows照樣也能支持一組一萬人在線,總之,能滿足策劃需求,好招程序員,運營成本低是要點。不同的操作系統有不同的特性,如果你真的對它們都很熟悉,那麼必定能找到一個理由,一個足夠充分的理由讓你選擇A而不是B而不是C。但做決策的時候要注意不要因小失大。

Programming Language:傳統來說,基本都是C/C++。但是你也知道,這東西門檻很高,好的C/C++程序員很難招。用Perl/Python/Lua行不行?當然可以。但是純腳本也不好,通常來說是混合着來。你要明白哪些是關鍵部分,我是說執行次數最多的地方而不是說元寶,這些必須用性能高的語言實現(比如C/C++比如Java),其它像節日活動這樣很久才執行一次的,隨便吧。腳本的好處是,可以快速搭原型。所以,儘早的,在你做完基本的地圖和戰鬥模塊之後,立馬跑機器人測試吞吐量。這時候項目開發進度還不到10%,不行就趕緊改。
此處特別舉個例子就是Java GC的問題。既然你要用java,而jvm需要通過執行garbage collection來回收內存,而garbage collection會使整個應用停頓,那你不妨試一試,內存在達到峯值的時候會停多久?策劃可以接受嗎?如果不可以,你可以採用其它的GC策略再試一試。這個問題應該不是Java獨有的。網遊和網站應用相比它很注重流暢性。這是你務必需要考慮的。

至於選擇什麼樣的腳本語言,以及腳本在你的遊戲中究竟是佔80%還是20%?需要根據需求來看。有沒有遊戲完全不用腳本?有。有沒有遊戲濫用腳本?也有。如果你引入腳本的目的是因爲策劃不會C/C++而你希望策劃能自己獨立實現更多的遊戲功能。你希望策劃去寫腳本?腳本也是程序,策劃寫的腳本難道就比程序員寫腳本好?還是因爲策劃工資便宜?策劃因爲腳本寫錯了導致大故障還少嗎(此處特別以網易的產品舉例)?綜合權衡下,還是算了吧。問問你一起工作的程序員哥們兒,他們最喜歡什麼語言,什麼用起來最順手,就用什麼當腳本。注意不光要考慮開發速度快,還要考慮調試方便。

總體來說,操作系統和編程語言的選擇,隨大流即可。標新立異沒什麼好處。小地方的實現你可以玩玩,整體還是要越保守越好。

通信

然後說通訊的問題。服務器和客戶端怎麼連接上的?

往最下面看,物理和鏈路層。有可能是以太網,有可能是ADSL,在北京還有很多像歌華寬帶這樣的採用75歐同軸電纜或者電力線上網的。你不要企圖在這一層做什麼優化,你要充分考慮的是不同的網絡傳輸媒質網絡延遲不一樣。更噁心的是你正常的數據包可能會被某些網吧的SB路由器當做P2P數據包給封掉,或是甚至被解析成Wake-On-Lan這樣的含義。楊建還會給你講,什麼是MTU,把數據包限制在多大才能儘量讓請求在一個包內發完。是的,這些很精細的東西,等咱遊戲做的差不多了再慢慢研究。先略過。

往上看,IP層。再往上,你要考慮用TCP還是UDP或是二者混合。UDP的優勢是overhead小、延遲低,典型的用例就是《天下貳》,據說是純UDP。再比如《龍之谷》,據說是有小部分是UDP。負面的一點呢,就是它太過於簡單所以用起來太過於複雜。你要是對自己沒信心,TCP吧,隨大流就好。

往上,採用什麼樣的應用協議。大多數rpc協議都是既支持TCP又支持UDP的。我所用過的有sun rpc、corba、webservice、json、java RMI以及一些專有協議。如果你有精力,還是自己搞一套吧,網遊所用的東西,還是越專有越好,給抓包做外掛的人加一點門檻。這裏非常強調的一點,你採用什麼樣的序列化方式與你採用什麼樣的網絡協議是無關的,你的應用協議和你傳輸協議應該也是無關的(既支持TCP又支持UDP的)。如果做框架的人把自己限制的太死或者耦合太緊,那麼用框架的人會非常痛苦。所以,沒必要在此爲了性能做過多優化。結構簡單清晰是王道。

很多人對網絡開發的認識還停留在定義一個struct、memcpy到socket buffer、send,然後一個勁的給別人強調遇到指針怎麼辦、數組的長度不能超過多少、整個包的長度不能超過多少等等。序列化其實是面向對象程序設計的一個很核心的要素。連glib/gtk/Berkeley DB這些純C的框架都是基於OOP設計的,所以我覺得您就算是C程序員也沒必要排斥它。我講這個是說,你應當做應用的人儘可能的避免用memcpy/memset這樣的方式初始化數據、傳送數據。如果你是C程序員,你多提供一些g_object_new這樣的函數;如果你是C++程序員,寫好你的構造和析構函數;如果你是JAVA程序員還死活不懂OOP,那算了吧,改行吧。

網絡這一層有些很精妙的東西,尤其是當你規模擴大需要分佈式擴展的時候。你想想看爲什麼sun rpc需要先去rpcbind詢問一次然後才連真正的進程呢?RMI返回的時候爲什麼需要同時返回IP和端口號呢?web service那麼通用,大部分瀏覽器都支持直接從瀏覽器調用web service那麼爲什麼主流的方式卻是json呢?

sun rpc是所有RPC機制中歷史最久的吧?它在設計第一版的時候,每個rpc調用都是由一問一答來組成,稱爲two-way messaging。客戶端在發出請求之後,一直等服務器的答覆,如果一直到指定時間後依然沒收到答覆,那麼執行timeout邏輯。在第一個請求收到答覆(或者timeout)之前,無法發起第二個答覆。直到某一天,Sun的程序發現他們需要異步處理一些事情,於是設計了one-way messaging,客戶端在發起請求的時候,只要把這個東西塞到本地的IO隊列裏,就返回。但是如果socket buffer滿了怎麼辦?還是會等在那裏。於是覺得這個還不徹底,於是又做了Non-Blocking Messaging,在kernel的socket buffer前面加了一個用戶態的rpc buffer,大多數時候它都是空的,當socket buffer堆滿了的時候,再往這裏面塞。如果這個buffer也滿了怎麼辦?我覺得無非就三種處理手段:

1、阻塞。如果這麼做,就是說本來是套非阻塞的設計但是某些情況下還是會阻塞?那麼給用的人解釋起來太麻煩用起來也太麻煩。算了。 
2、悄然丟棄。 不是所有的數據都可以丟。聊天的無所謂,但是交易的就不行。所以需要在消息類型上加判斷。 
3、關閉連接。 最簡單粗暴,卻也最有效。

在使用two-way messaging的時候,一定要記住設置超時,省得像某些傻瓜一樣因爲一個請求把整個server堵死。但是我覺得timeout設多久完全是個經驗值,太大了沒作用,太小了失敗的太多。

至少在有一點我們可以大鬆一口氣,就是不用擔心數據量大到需要多網卡同時分擔中斷。通常來說網絡遊戲的流量都是很小的,對玩家來說一個56K的貓或者128K的DSL就夠了。如果你的策劃給你提了一個很BT的需求導致要耗費大量帶寬,那麼你最好把這個應用分到單獨的tcp 連接上,省得因爲它阻塞而導致關鍵的業務(比如地圖消息)停滯。

我一直想把rpc的部分實現塞到kernel裏。對客戶端的好處是增加了逆向工程的成本,對服務器的好處是網關可以很高效。就像LVS那樣,前端收完包之後在kernel裏處理完然後立刻轉出去,不用切換到用戶態。而GameServer處理完之後,甚至不用經過網關,直接回復。目的不在於分擔網關的壓力,而是說降低響應延遲。就算讓GameServer承擔部分加密和壓縮的計算量,它的CPU也足夠用。

不過對於網遊,考慮動態擴容爲時太早。一般都是新開幾組服務器。

 

數據

我在做服務器安裝包的時候,分的很清楚:程序、配置文件、數據庫。

程序,就是編譯好的二進制文件。最好是全靜態編譯,因爲它簡單。動態鏈接的優點以及其它一些高級話題我後面講,但是通常來說,動態的複雜的結構得不償失。

配置文件總體來說可以分爲文本文件和二進制文件(廢話)。文本文件的好處是開發過程中易於調試和修改,最終發佈後也易於追蹤問題。二進制文件的好處是小、精巧、不易把信息泄露給外人知道。java的打jar包的技術算是一個折衷的優勢吧?我最看重的是易於調試和修改,所以基本都用文本文件。而這其中,表現力最強的就是xml,所以基本都是xml。

但是xml多了怎麼管理就是個問題。我得整理份文檔,每個xml都是什麼格式,做什麼用途的,最好每個xml再寫一個xsd。事實是配置文件是隨着需求變化最頻繁的部分,而換個角度說我之前強調的序列化。所以,正確的思路是這樣:

1、程序員分析需求文檔,確定需要什麼樣的對象來表示配置
2、某套序列化框架,它利用某種xml解析庫把xml變成內存中的對象
3、策劃提供xml

只要這個框架做的好,根本不需要文檔或xsd來描述xml。我這裏說策劃提供xml,那麼策劃怎麼提供xml呢?按照我所看見的策劃的習慣,他們最喜歡的是兩種方式:

1、對於結構簡單的數據,編輯excel表
2、對於結構複雜的(如涉及樹、環的),提供專門的編輯工具

對於1,我們可以給excel做plugin,或者做一個工具從excel表導出成xml。對於2,讓編輯工具可以導出成xml。但是最終很重要很重要很重要的一點就是要讓所有的工具集成在一起,做好版本管理以及跨版本diff和merge。如何管理數據要比如何定義數據如何描述數據更難更重要。

很多同事和我的共識都是:要做一款好遊戲,工具很重要。多個項目做完後,外人能看見的最大的積累就是工具和流程。

數據庫

數據庫在遊戲中的重要性,是一個很令人玩味的東西。你可以聽見很多人告訴你說,我們做遊戲根本不需要數據庫。是的,像單機遊戲那樣,在某個目錄下創建一個文件,save/load就行了。這就是我所看到的當今的大型網遊的主流做法。

哦,你要反對了。你說你知道某某遊戲用的是mysql,某某遊戲用的是oracle,等等。是的,你手上的信息可能比我多很多很多倍,但是關鍵點在於,數據庫在整個系統中的角色到底是什麼?

典型的場景是這樣:啓動一個單獨的進程稱之爲DB Gate。當用戶登錄的時候,邏輯服務器找DB Gate要數據,DB Gate沒有於是就去找後面的Mysql要,然後讀過來之後就放在這裏,DB Gate就是一個類似於memcached的東西。所以後面無論是用mysql還是oracle還是plain text都可以,但實際上會在其它方面有些細微的差別。

它和網站應用相比,數據更容易做cache,把握好上線和下線這兩個點即可,cache的命中率很容易達到4個9或者更高。但是從另一個方面,網絡遊戲的數據關聯邏輯遠遠比網站複雜,而且對原子性、一致性、隔離性要求更高。現在是你自己來管理cache,於是併發控制就沒辦法交給數據庫來做。

問題一:我不自己做cache,我就直接讀寫數據庫。就像php+mysql那樣,中間也不套memcache,行不行? 我不知道。你可以試一試。

問題二:SQL or NoSQL ? 我還是回答不了。你做個demo跑機器人試一試。

總之,東西是活的。沒有必要非要怎麼着非不能怎麼着。檢驗的標準很簡單:1、是否完成了策劃提出的功能需求 2、效率是否達到了預期目標

對於第一個,QA和策劃都會去檢查。對於2,跑機器人以及封測期間調優是王道。

對於數據庫開發,我還是很強調面向對象那套觀點。把數據庫裏的表映射到對象,把對象抽象成接口,每個模塊以接口對外提供服務,不同模塊不要直接通過表共享數據。或者,你可以讀我的表,但不要寫!因爲數據的約束條件未必是可以由DBMS完全保證的,某些約束是難以用數據庫本身的語言表述的。

數據是網遊的核心,網遊基本都是數據驅動的,所以數值策劃纔會這麼吃香。

或者換個角度想,DBMS它是什麼?

1、它管理數據。幫助我們高效的讀取和修改數據。因爲數據的動態性,所以我們需要Btree這樣的結構,而不是隨便找個TXT追加寫。但是換個角度想,網絡遊戲有什麼特點?插入多,但是刪除操作極少極少。那麼是否可以採用其它的結構呢?順序重要嗎?爲什麼不用Hash呢?

2、它負責備份和恢復數據。這基本是任何現代的數據庫系統必須提供的基本功能。但是網絡遊戲又特殊一點,它要求能按指定時間“回檔”。時間可以有半小時的誤差,但是這個功能必須有。於是數據庫能支持增量備份,或者它的備份能支持版本很重要。

3、它使用logging system保證在突然宕機的時候數據依然是完整和一致的。可是如果我們要自己做cache,那麼就要求我們在應用層面所做的原子性保證必須在cache中也能體現出來。這些cache要麼全刷,要麼全不刷。

4、它提供併發功能。拿傳統的php+mysql架構來說,爲什麼同一個應用可以被分佈式的部署在多臺機器上?魔力就在數據庫上。

既然有人輕視數據庫,那麼也可反其道重視數據庫。把90%的邏輯都放在數據庫裏完成。多招一些熟悉SQL熟悉存儲過程的,主要的邏輯都由他們完成。

併發

接着說我在併發上的考慮。

一臺機器還是多臺機器?單進程還是多進程?單線程還是多線程?等等。

我覺得併發問題是最沒章法可循的問題。你可以這麼做也可以那麼做。網絡遊戲的重點是在邏輯開發上,而做邏輯開發的人不要關心到底是epoll還是select。總之制定框架的時候需要定好一個規矩:單線程還是多線程、訪問哪些數據的時候需要加鎖(可能還需要跨進程的加鎖)、誰來做load balancer、如果有一臺機器宕了怎麼辦、哪些任務必須要以特定的順序執行,等等。規矩定下來,一切都順了。可這個規矩要足夠的簡單。

如果是多線程,我想過兩種模式:Thread per Connection和Task based thread pool。現在機器的內存越來越大了,所以前者的開銷是可以忍受的,1000人在線,就算每個線程要被系統佔去2M,那麼也才2G。而一般的3D遊戲做個 3-4千人在線就行了,配個大內存的機器,還剩下足夠多的內存給應用使用。多簡單啊!網絡遊戲中,很多請求都是只需要訪問單個角色的數據就夠了,反過來說很多數據都可以做成Thread Local的,免去了同步代價。

而Task based thread pool的伸縮性相對來說就好的多,但是併發問題也麻煩一些,況且從rpc請求被unmarshal完到扔到task pool裏面又多了一次線程切換,如果換成Leader-Follower那樣的模式,少了切換但是模型又更復雜了一些。

如果是單線程的,那麼一切都是事件驅動的並且事件的處理都是非阻塞的。那麼就得避開數據庫讀寫或者在處理的過程中再產生新的rpc請求,否則非常麻煩。

併發問題的瓶頸往往是在於怎麼降低鎖衝突上。Task Pool裏面的所有線程都在執行Task,但是都在等同一把鎖,多悲劇啊。難點在於降低模塊耦合、採用適當的排隊機制等等。我覺得這裏沒有什麼萬金油,降低模塊耦合本來就沒什麼套路可循,而排隊機制有很多種,沒有最好的,各有利弊。

對於死鎖,我的容忍度比以前大了很多。我覺得每臺機器每天的死鎖數量在10個以內都是可以忍受的,要有死鎖檢測、打斷機制並且重做的時候不會產生副作用。對玩家的感受而言就是突然卡了一下,可是網絡不也經常會突然卡一下嗎?不頻繁就好。

我最鍾愛的模式就是“生產者-消費者”模式,萬能的利器。例如Task Pool就是基於這樣的模式。它的核心東西無非就是一個隊列,如果要支持定時,那麼就是一個優先隊列(deadline time作爲優先級)。講個細節,我面試的時候問了很多面試者,優先隊列應該用什麼樣的數據結構實現,結果都挺讓我失望的。

順便發個牢騷,Sun JDK的executor的實現,BUG太多了。還那麼巧,都被我遇上了?

其它

說些雜七雜八的東西吧。
我剛入行的時候就一直在問,爲什麼網遊服務器經常要停機維護?爲什麼經常都是好幾個小時?爲什麼非要分成不同組的服務器並且數據基本不互通?爲什麼不構造一個大世界把所有玩家放在一起?
我現在不問了,這些問題基本都找到了答案。不是技術做不到,而且有很多它以外的東西在左右這些。至少我在盡力不回檔這件事情上已經做的比較好了。
我想說的就是,入這行就得遵守這行的規矩。如果你是個老手了,根本沒必要來看我這一系列的P話。如果你是新手,那麼我是在向你介紹現狀。策劃是甲方,我們是乙方,在盡力滿足策劃的需求且不會顯著增加成本的前提下做有限的創新,這是我給自己定的設計原則。
(支付寶剛通知我,我又收到了5塊錢的捐贈。謝謝,謝謝大家)
如果你是一個受過良好訓練的程序員,那麼以下基本規則是懂的:
1、不要把需要翻譯的常量字符串寫在代碼裏
2、不要直接在代碼中間寫498595這樣的magic number
3、向版本控制系統提交代碼的時候應該寫註釋
4、需求是經常變的,並且經常是災難性的
可往往知道是一回事兒,做又是另外一回事。尤其是不要相信策劃那張嘴,寫成word文檔纔算數。

和大家分享一些我在版本控制上的經驗和教訓。

最早接觸這個問題,是在sina的時候,由QA部門的同事以及周琦單獨專門給我講jira、svn。當時受益很大。

周琦一再給我強調,在產品生命週期中,源代碼版本管理和發佈部署是獨立的兩套東西。源代碼版本管理是用subversion這樣的東西來做(更早一點我們還在用cvs)。發佈部署,一是編譯的過程,二是對外推送部署的過程,是一套相對獨立的東西。周琦的特色在於他把這二者通過svn hook腳本的方式給自動串起來了。

我一直想要做一套OBS這樣的東西找一臺服務器專門作build server,可惜一直沒時間去寫。就自己寫了一個腳本(本來是sh的,後來成perl,後來成groovy),它的作用是根據分支名和版本號從subversion下載代碼,然後編譯,然後放到指定位置。然後通知發佈服務器從那裏拿東西推到外邊。缺點它缺乏併發控制,並且沒有UI界面。導致做完之後就成個人專屬的了。

爲什麼每次要選擇一個空目錄checkout然後編譯,而不是在上次的基礎上svn up然後編譯?這個和Java/Ant有點關係。在寫Makefile的時候,儘管可以指定把當前目錄下的.cpp文件全部都編譯,但是這是不推薦的做法。因爲相比於寫代碼的時間,把代碼文件添加到Makefile中的時間可以忽略不計。而我當時給ant寫build.xml時,是用**/*.java的方式去匹配,於是把src下的所有能編譯的全編譯了。可我在編譯之前會執行一些腳本用於生成一些代碼,某些是單獨存放的,但是某些和其它手寫的代碼放在了一起。所以爲了保持最終的jar包乾淨,寧可犧牲編譯的時間。

在提供給QA的測試環境中可以很方便的通過GM指令得到版本號,這個是編譯的時候打包工具寫進去的。而編譯系統務必保證相同版本號的東西每次編譯出來都是相同的東西。雖然二進制比對結果可能不一致,但是邏輯功能上是一致的。

對於svn的分支管理,有兩種普遍策略:

1、每個人一個單獨的分支。做完自己的功能後往主幹merge

2、都在主幹上工作。需要發版本的時候創建新分支。

前一種需要大家都比較熟悉svn的用法,熟悉版本管理的基本概念。後一種則把所有活堆給一個專門發版本的人。他來創建分支,他來merge(或是誰的功能誰merge)。並且這樣的話,絕大多數代碼是不需要merge的,所以我根據實際情況選擇了後一種。

於是在正在運行的系統中發現bug的時候,立馬獲取版本號,從那個版本上創建分支並且把分支名喊一聲告訴大家,然後找問題,把補丁merge到過去,編譯,發佈,測試,推到外面。

發版本很累,這件事情在去年秋天上線後,一直到春節,佔去了我90%的精力。其中最重要的就是比對功能和bug列表。經常,你分不清楚這到底算是一個bug呢,還是提需求的時候就沒說清楚所以這是一個新功能,反正都列一起的。挨個和svn提交記錄比對。

部署也是一個很有講究的過程。我的原則是,先刪除老的程序和配置文件,然後複製新的過去,數據庫的數據和日誌文件保留,審計日誌保留。這件事情本來還爭論過老的要不要刪,可不可以直接覆蓋,最終他們答應了我的需求。過程挺曲折的,中間有很多噁心的細節問題,比如NFS的本地cache的問題。

對於數據庫,我們能智能的感知數據庫結構更改並自動生成升級腳本(天哪,我這算不算泄密)。這居然也是一把雙刃劍。優點是減輕了開發人員的工作量,缺點是更改數據庫變得太隨意,隨意的添表添字段導致數據膨脹的厲害。

我的遺憾是沒有把上面這些東西和數據編輯器串起來。那麼做有點是數值策劃調整數據更容易看到真實效果,缺點是也很容易亂來。如果這中間要經過svn,那麼太慢太曲折。如果這中間不經過svn,那麼鬼知道他們現在測的是什麼版本的東西,他經常會發現最終出去的東西跟他當時測的還是不一樣,畢竟,是很多人在同一個服務器上測試。很難給他們解釋這個事情。

所以我當時還漏了一個東西一直想做但是沒做,就是一個很簡單的web gui能讓所有策劃自己啓動、停止服務器,自己編譯、同步數據。各弄各的,互不干擾。但是吧,策劃畢竟是策劃,它們缺乏基本的QA知識。他們不明白爲什麼一個底層功能好好的怎麼突然就不好使了(因爲上層某處要加新功能,所以底下的代碼要重構),他們不明白爲了一個bug被改掉之後反覆又出現了,甚至對於分支和版本號這個東西,絕大多數策劃都理解起來困難。但是整個產品的開發、發佈模型就是這樣,所以這些概念必須從一開始就溝通好、貫徹好。相比而下,這些倒和美術沒什麼事兒。

都是些小活兒。

另外我一直在想要不要在配置文件和game server之間套一個gconf這樣的東西,外部更改配置,gconf通知listener也就是game server,呃,一個很不成熟的想法。

另外很多人一直想,在不重啓進程的情況下,替換掉映像中的某個函數,修BUG。如果這個daemon程序是用C/C++寫的,這個時候用dlopen加載一個so,設置一個參數就可以了。如果是JAVA並且用JDWP開了DEBUG,那麼too easy。如果沒有,那麼unload jar/load jar吧。

我一直在構思一個可動態拆卸/替換/裝載的架構,一個簡單的不像OSGi那麼複雜的東西,可是想法一直不大成熟,因爲沒有找到太簡單的方法。我的基本想法是有一個object container,把service抽象成object,service和serivce之間的交互都要去這個object container中通過name lookup的方式得到一個句柄,然後通訊。配置文件不能視成一成不變的,它們也是動態數據的一部分,不能再通過靜態的getInstance獲得,也必須通過這個object container查找。但是未必是一個global object container,每個module可以有自己的object container。或是module instance持有reference,請求派發給module,module派發給object的時候把需要的reference傳給過去,意思就是module就是一個object container,不過不是被lookup,而是主動構造好塞進去。

更多精彩點擊http://www.gopedu.com


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