bromon原創(chuàng) 版權(quán)所有
一個(gè)多人在線的棋牌類網(wǎng)絡(luò)游戲的項(xiàng)目臨近尾聲,我參與了該項(xiàng)目的整個(gè)設(shè)計(jì)流程,并且完成了90%的核心代碼。關(guān)于這個(gè)項(xiàng)目,有很多地方值得聊一聊。本系列不打算把這個(gè)項(xiàng)目將得多么詳細(xì)規(guī)范,那是設(shè)計(jì)文檔應(yīng)該描述的,我打算只說說一些值得注意的地方。這個(gè)項(xiàng)目的一個(gè)特別之處是,客戶端是手機(jī),用戶通過移動(dòng)網(wǎng)絡(luò)與服務(wù)器通信。和PC相比,手機(jī)的處理能力極弱,而且網(wǎng)絡(luò)流量費(fèi)用昂貴。因?yàn)槌艘紤]普通網(wǎng)絡(luò)游戲的一些問題之外,這兩點(diǎn)也需要在設(shè)計(jì)中充分考慮。首先是開發(fā)語言的選擇,由于服務(wù)器是Linux的環(huán)境,MS的技術(shù)直接排除,至于MONO嘛,我實(shí)在不放心。可供選擇的是C++和Java,Java勝在網(wǎng)絡(luò)能力強(qiáng)大,開發(fā)周期短,有眾多框架和開源庫(kù)的支持,要寫出爛得不可接受的代碼也不容易;C++則勝在速度快。綜合各方面因素,C++更容易把這個(gè)項(xiàng)目變成一堆代碼噩夢(mèng),我們選擇了Java。
一、 網(wǎng)絡(luò)
網(wǎng)絡(luò)游戲,首先面臨的問題當(dāng)然是如何進(jìn)行網(wǎng)絡(luò)通信。首先考慮的是HTTP協(xié)議,因?yàn)樗械腏2ME手機(jī)都支持這個(gè),我們當(dāng)然想盡可能的兼容用戶。而且HTTP協(xié)議封裝程度已經(jīng)非常高了,不用去考慮線程、同步、狀態(tài)管理、連接池,不過HTTP協(xié)議有兩個(gè)不爽的地方:
◇ 協(xié)議無狀態(tài),這個(gè)問題已經(jīng)困擾過很多人很多次了。我曾考慮過的解決辦法是改造HTTP協(xié)議,在數(shù)據(jù)傳輸完成之后不關(guān)閉socket,但是這樣做工作量非常大,在項(xiàng)目周期中,基本上就是Mission impossible,不予考慮。那么客戶也就只能通過輪詢的方式向服務(wù)器請(qǐng)求數(shù)據(jù)。
◇ 網(wǎng)絡(luò)流量過大。就這個(gè)項(xiàng)目來說,網(wǎng)絡(luò)間傳遞的只是指令,但是每次傳遞都要加上一堆毫無用處的HTTP Head,再加上客戶端需要做輪詢,這個(gè)流量對(duì)于手機(jī)來說簡(jiǎn)直恐怖,經(jīng)簡(jiǎn)單測(cè)試,按照0.03元/K的GPRS網(wǎng)絡(luò)費(fèi)用計(jì)算,一局牌居然要消耗1元多的費(fèi)用(每秒輪詢),實(shí)在不可接受。也許我們可以采用流量費(fèi)包月的資費(fèi)方式,不過這個(gè)話題與技術(shù)無關(guān)。
以上問題導(dǎo)致我們選擇了Socket,這意味著我們將沒有一個(gè)web環(huán)境,很多東西都要靠自己去實(shí)現(xiàn):線程管理、客戶狀態(tài)監(jiān)控、對(duì)象池、控制臺(tái)……….網(wǎng)絡(luò)部分打算采用Java NIO來實(shí)現(xiàn),這是一種新的網(wǎng)絡(luò)監(jiān)聽方式,基于事件的異步通信,可以提高性能。每個(gè)客戶端連接之后,會(huì)有一個(gè)獨(dú)立的SocketChannel與它通信,這個(gè)SocketChannel會(huì)在用戶的整個(gè)生存周期中存在。用戶如果斷開連接,服務(wù)器會(huì)得到-1,并且會(huì)拋出Connection reset異常,通過捕獲這兩個(gè)特征,可以在用戶意外斷開連接后清理相關(guān)的資源。由于NIO是異步通信的,所以沒有復(fù)雜的線程管理。
二、 通信協(xié)議
這個(gè)項(xiàng)目并沒有復(fù)雜的通信指令,命令數(shù)量很有限,但是還是有個(gè)關(guān)鍵問題需要關(guān)注:流量。為了盡量減小流量,我們使用字節(jié)代替字符串來保存系統(tǒng)指令,這樣可以使流量減少一半,比如使用一個(gè)字節(jié)來保存一張撲克牌,字節(jié)高位表示花色,字節(jié)低位表示數(shù)字,如果0代表黑桃,那么黑桃三就應(yīng)該是0x03,這個(gè)需要靠位操作來實(shí)現(xiàn):
int m=0;
int n=3;
byte card=(byte)(m)<<4)|((byte)n; //m左移四位,然后與n左或操作
游戲中需要傳遞用戶的積分,這是一個(gè)大整數(shù),使用四個(gè)字節(jié)來保存比較保險(xiǎn),將整數(shù)轉(zhuǎn)換為四個(gè)字節(jié)的操作如下:
package org.bromon.games;
public static byte[] translateLong(long mark)
{
byte[] b = new byte[4];
for (int i = 0; i < 4; i++)
{
b[i] = (byte) (mark >>> (24 - i * 8));
}
return b;
}
將四個(gè)字節(jié)轉(zhuǎn)回來的操作如下:
package org.bromon.games;
public static long translateByte(byte[] b)
{
int mask = 0xff;
int temp = 0;
int res = 0;
for (int i = 0; i < 4; i++)
{
res <<= 8;
temp = b[i] & mask;
res |= temp;
}
return res;
}
三、 數(shù)據(jù)庫(kù)連接池
由于沒有一個(gè)web環(huán)境,所以我們需要自己實(shí)現(xiàn)一個(gè)數(shù)據(jù)庫(kù)連接池,apache有一個(gè)項(xiàng)目叫做commons DBCP,這是一個(gè)基于apache自己的對(duì)象池(apache commons pool)實(shí)現(xiàn)的數(shù)據(jù)庫(kù)連接池,我們可以直接拿來使用,apache的軟件未必是最好的,但是極大可能比我們自己寫的要好。Commons DBCP需要三個(gè).jar:commons-collections-3.1.jar、commons-dbcp-1.2.1.jar、commons-pool-1.2.jar這三個(gè)文件都可以在apache – Jakarta – commons項(xiàng)目下下載,加入到工程中即可。構(gòu)造一個(gè)數(shù)據(jù)庫(kù)連接池的代碼如下:
package org.bromon.games;
import java.sql.*;
import com.gwnet.games.antiLord.util.*;
import org.apache.commons.dbcp.ConnectionFactory;
import org.apache.commons.dbcp.BasicDataSource;
import org.apache.commons.dbcp.DataSourceConnectionFactory;
private static BasicDataSource bds=new BasicDataSource();
private static ConnectionFactory fac=null;
//初始化連接池
bds.setDriverClassName(“org.postgresql.Driver”); //數(shù)據(jù)庫(kù)驅(qū)動(dòng)程序
bds.setUrl(“jdbc:postgresql://localhost:5432/myDB”); //數(shù)據(jù)庫(kù)url
bds.setUsername(“postgres”); //dba帳號(hào)
bds.setPassword(“XXXXXXXX”); //密碼
bds.setInitialSize(100); //初始化連接數(shù)量
bds.setMaxIdle(10); //最大idle數(shù)
bds.setMaxWait(1000*60); //超時(shí)回收時(shí)間
fac=new DataSourceConnectionFactory(bds); //得到連接工廠
Connection conn=fac.createConnection(); //從池中獲得連接
conn.close(); //釋放連接,回到池中
//銷毀連接池
bds.close();
bds=null;
fac=null;
請(qǐng)自行處理操作中的各種異常。
四、 撲克牌的生成
游戲中需要為用戶生成隨機(jī)的撲克牌,首先我們需要初始化一副牌,放到一個(gè)Hashmap中,每張牌以一個(gè)字節(jié)表示,高為代表花色,的為代表數(shù)字,生成整副牌:
package org.bromon.games;
private static HashMap cards = new HashMap();
int tmp=0;
for (int i = 0; i <4; i++) {
for (int m = 0; m < 13; m++) {
tmp=((byte)(i)<<4)|((byte)m); //使用位操作構(gòu)造一張牌
cards.put(new Integer(i * 13 + m),new Byte((byte)tmp));
}
}
cards.put(new Integer(53), new Byte((byte)0x4d)); //大王
cards.put(new Integer(54), new Byte((byte)0x4e)); //小王
如何隨機(jī)地得到其中的N張牌呢?我們的做法是生成一個(gè)0-55的隨機(jī)數(shù),用這個(gè)隨機(jī)數(shù)作主鍵從Hashmap中獲得對(duì)象,取得之后,把該對(duì)象從隊(duì)列中刪除,以免重復(fù)取得。由于java中的隨機(jī)數(shù)是根據(jù)時(shí)間生成的,所以有可能導(dǎo)致用戶得到的牌不夠散,每個(gè)用戶都摸到一條龍豈不是笑話?所以在生成隨機(jī)數(shù)的時(shí)候我們加入了一個(gè)大素?cái)?shù)來作運(yùn)算:
long cardId=new Long((Math.round(Math.random() * 87) % 55)).intValue();通過修改這個(gè)大素?cái)?shù),可以控制某個(gè)用戶的牌比較好。
五、 線程
實(shí)際上本系統(tǒng)并沒有復(fù)雜的線程管理,但是我想提供一個(gè)控制臺(tái)讓管理員可以管理游戲主線程,可以讓它停止、中段、恢復(fù)、重啟動(dòng),本來的設(shè)計(jì)是管理員通過與線程A打交道,通過A去管理主線程B,但是熟悉java線程的朋友都知道,線程互相管理基本上就是不實(shí)際的,舉個(gè)最簡(jiǎn)單的例子,A如何銷毀B?也許你會(huì)說調(diào)用B的destroy()方法就好了,網(wǎng)上很多講解java線程的資料也確實(shí)是這么說的,但是他們都是鬼扯的,自己去看看java源代碼吧,Thread.destroy()方法的實(shí)際代碼如下:
package org.bromon.games;
public void destroy()
{
throw new NoSuchMethodError();
}
事實(shí)真相是,Thread.destroy()方法自始至終就沒有被實(shí)現(xiàn)過。所有寫文章,教別人用這個(gè)方法銷毀線程的人,都去撞墻吧,丟人丟大了。最好的辦法是A負(fù)責(zé)生成一個(gè)B并且啟動(dòng)它,然后B自己管理生存周期,A和B通過使用可共享的方法來通信,這是sun推薦的做法。
六、 異步消息
用戶玩牌的過程中,有很多東西需要記錄下來,比如記錄用戶的積分、等級(jí)變化,記錄玩牌日志供數(shù)據(jù)統(tǒng)計(jì)等,當(dāng)用戶數(shù)量很多的時(shí)候,在數(shù)據(jù)庫(kù)中記錄這些信息會(huì)很耗費(fèi)資源,用戶玩了一局之后會(huì)可能會(huì)等待很長(zhǎng)時(shí)間。解決這個(gè)問題的方法是利用J2EE的消息bean來提供異步通信的機(jī)制,需要記錄數(shù)據(jù)的時(shí)候,系統(tǒng)會(huì)封裝一個(gè)值對(duì)象,發(fā)送給J2EE容器,這個(gè)操作是很快的,完成之后就返回,用戶可以繼續(xù)操作,不用關(guān)心消息何時(shí)被處理。J2EE的消息框架具備如下特征:
◇消息一定會(huì)被閱讀,而且只閱讀一次。JMS框架有自己的算法,把消息緩沖到硬盤,就算J2EE服務(wù)器死掉,消息也不會(huì)丟失。
◇系統(tǒng)采用點(diǎn)對(duì)點(diǎn)的Queue消息隊(duì)列,可以保證同等優(yōu)先級(jí)的消息先進(jìn)先出。
在Jboss 4.0中,部署消息Bean和Queue隊(duì)列,都比weblogic 8.1來的容易,只需要在jboss.xml中聲明消息目的地,如果jboss發(fā)現(xiàn)該目的地不存在的話,會(huì)自動(dòng)建立一個(gè),實(shí)在很簡(jiǎn)單。關(guān)于消息bean的開發(fā)與部署,我有專門的文章描述。
七、 啟動(dòng)與退出
為了讓系統(tǒng)具備讓人滿意的性能,應(yīng)該盡量多的重用對(duì)象,減少創(chuàng)建新對(duì)象。比如上面提到的消息發(fā)送,我們的操作是提供一個(gè)靜態(tài)類,在系統(tǒng)啟動(dòng)的時(shí)候就初始化,保持與JMS服務(wù)器的連接,系統(tǒng)發(fā)送消息的時(shí)候,不用再去查詢JNDI和生成QueueConnectionFactory,這樣可以提高系統(tǒng)響應(yīng)速度。
在數(shù)據(jù)庫(kù)連接池的問題上,我們也采用同樣的操作,啟動(dòng)的時(shí)候初始化N個(gè)連接。但是如果在關(guān)閉進(jìn)程的時(shí)候不做任何操作,會(huì)導(dǎo)致JMS拋出socket異常,雖然沒什么大的影響,但總顯得不專業(yè),而且池中的連接不被釋放的話,也可能導(dǎo)致問題。最好能夠讓系統(tǒng)像jboss等控制臺(tái)程序一樣,ctrl+c之后能夠執(zhí)行操作,釋放資源再退出。我們可以通過給進(jìn)程/線程加上一個(gè)Hook來實(shí)現(xiàn),windows程序員應(yīng)該對(duì)這個(gè)非常熟悉。
Hook應(yīng)該是一個(gè)線程方法,如下:
package org.bromon.games;
public class Hook extends Thread
{
public void run()
{
//釋放數(shù)據(jù)庫(kù)連接,銷毀連接池
//關(guān)閉與JMS的連接
}
}
在主線程中加入:Runtime.getRuntime().addShutdownHook(new Hook()) ;那么進(jìn)程/線程會(huì)在退出的時(shí)候執(zhí)行Hook的run方法,清理資源。