1. 程式人生 > >棋牌遊戲伺服器開發心得

棋牌遊戲伺服器開發心得

一個多人線上的棋牌類網路遊戲的專案臨近尾聲,我參與了該專案的整個設計流程,並且完成了90%的核心程式碼。關於這個專案,有很多地方值得聊一聊。本系列不打算把這個專案將得多麼詳細規範,那是設計文件應該描述的,我打算只說說一些值得注意的地方。這個專案的一個特別之處是,客戶端是手機,使用者通過行動網路與伺服器通訊。和PC相比,手機的處理能力極弱,而且網路流量費用昂貴。因為除了要考慮普通網路遊戲的一些問題之外,這兩點也需要在設計中充分考慮。首先是開發語言的選擇,由於伺服器是Linux的環境,MS的技術直接排除,至於MONO嘛,我實在不放心。可供選擇的是C++和Java,Java勝在網路能力強大,開發週期短,有眾多框架和開源庫的支援,要寫出爛得不可接受的程式碼也不容易;C++則勝在速度快。綜合各方面因素,C++更容易把這個專案變成一堆程式碼噩夢,我們選擇了Java。

一、網路

網路遊戲,首先面臨的問題當然是如何進行網路通訊。首先考慮的是HTTP協議,因為所有的J2ME手機都支援這個,我們當然想盡可能的相容使用者。而且HTTP協議封裝程度已經非常高了,不用去考慮執行緒、同步、狀態管理、連線池,不過HTTP協議有兩個不爽的地方:
  ◇ 協議無狀態,這個問題已經困擾過很多人很多次了。我曾考慮過的解決辦法是改造HTTP協議,在資料傳輸完成之後不關閉socket,但是這樣做工作量非常大,在專案週期中,基本上就是Mission impossible,不予考慮。那麼客戶也就只能通過輪詢的方式向伺服器請求資料。
  ◇ 網路流量過大。就這個專案來說,網路間傳遞的只是指令,但是每次傳遞都要加上一堆毫無用處的HTTP Head,再加上客戶端需要做輪詢,這個流量對於手機來說簡直恐怖,經簡單測試,按照0.03元/K的GPRS網路費用計算,一局牌居然要消耗1元多的費用(每秒輪詢),實在不可接受。也許我們可以採用流量費包月的資費方式,不過這個話題與技術無關。
  以上問題導致我們選擇了Socket,這意味著我們將沒有一個web環境,很多東西都要靠自己去實現:執行緒管理、客戶狀態監控、物件池、控制檯……….網路部分打算採用Java NIO來實現,這是一種新的網路監聽方式,基於事件的非同步通訊,可以提高效能。每個客戶端連線之後,會有一個獨立的SocketChannel與它通訊,這個SocketChannel會在使用者的整個生存週期中存在。使用者如果斷開連線,伺服器會得到-1,並且會丟擲Connection reset異常,通過捕獲這兩個特徵,可以在使用者意外斷開連線後清理相關的資源。由於NIO是非同步通訊的,所以沒有複雜的執行緒管理。

二、通訊協議

這個專案並沒有複雜的通訊指令,命令數量很有限,但是還是有個關鍵問題需要關注:流量。為了儘量減小流量,我們使用位元組代替字串來儲存系統指令,這樣可以使流量減少一半,比如使用一個位元組來儲存一張撲克牌,位元組高位表示花色,位元組低位表示數字,如果0代表黑桃,那麼黑桃三就應該是0x03,這個需要靠位操作來實現:

  int m=0; 
  int n=3; 
  byte card=(byte)(m)<<4)|((byte)n; //m左移四位,然後與n左或操作 

遊戲中需要傳遞使用者的積分,這是一個大整數,使用四個位元組來儲存比較保險,將整數轉換為四個位元組的操作如下:

  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)); 
      }
    }

三、資料庫連線池

  
由於沒有一個web環境,所以我們需要自己實現一個數據庫連線池,apache有一個專案叫做commons DBCP,這是一個基於apache自己的物件池(apache commons pool)實現的資料庫連線池,我們可以直接拿來使用,apache的軟體未必是最好的,但是極大可能比我們自己寫的要好。Commons DBCP需要三個.jar:commons-collections-3.1.jar、commons-dbcp-1.2.1.jar、commons-pool-1.2.jar這三個檔案都可以在apache – Jakarta – commons專案下下載,加入到工程中即可。構造一個數據庫連線池的程式碼如下:

  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”); //資料庫驅動程式 
  bds.setUrl(“jdbc:postgresql://localhost:5432/myDB”); //資料庫url 
  bds.setUsername(“postgres”); //dba帳號 
  bds.setPassword(“XXXXXXXX”); //密碼 
  bds.setInitialSize(100); //初始化連線數量 
  bds.setMaxIdle(10); //最大idle數 
  bds.setMaxWait(1000*60); //超時回收時間 
  fac=new DataSourceConnectionFactory(bds); //得到連線工廠 
  Connection conn=fac.createConnection(); //從池中獲得連線 
  conn.close(); //釋放連線,回到池中 
  //銷燬連線池 
  bds.close(); 
  bds=null; 
  fac=null; 

  請自行處理操作中的各種異常。

四、撲克牌的生成

  遊戲中需要為使用者生成隨機的撲克牌,首先我們需要初始化一副牌,放到一個Hashmap中,每張牌以一個位元組表示,高為代表花色,的為代表數字,生成整副牌:

  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); //使用位操作構造一張牌 
          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)); //小王 

  如何隨機地得到其中的N張牌呢?我們的做法是生成一個0-55的隨機數,用這個隨機數作主鍵從Hashmap中獲得物件,取得之後,把該物件從佇列中刪除,以免重複取得。由於java中的隨機數是根據時間生成的,所以有可能導致使用者得到的牌不夠散,每個使用者都摸到一條龍豈不是笑話?所以在生成隨機數的時候我們加入了一個大素數來作運算:

  long cardId=new Long((Math.round(Math.random() * 87) % 55)).intValue();通過修改這個大素數,可以控制某個使用者的牌比較好。 
  return b; 

五、執行緒

  實際上本系統並沒有複雜的執行緒管理,但是我想提供一個控制檯讓管理員可以管理遊戲主執行緒,可以讓它停止、中段、恢復、重啟動,本來的設計是管理員通過與執行緒A打交道,通過A去管理主執行緒B,但是熟悉java執行緒的朋友都知道,執行緒互相管理基本上就是不實際的,舉個最簡單的例子,A如何銷燬B?也許你會說呼叫B的destroy()方法就好了,網上很多講解java執行緒的資料也確實是這麼說的,但是他們都是鬼扯的,自己去看看java原始碼吧,Thread.destroy()方法的實際程式碼如下:

package org.bromon.games; 

public void destroy() { 
  throw new NoSuchMethodError(); 
} 

  事實真相是,Thread.destroy()方法自始至終就沒有被實現過。所有寫文章,教別人用這個方法銷燬執行緒的人,都去撞牆吧,丟人丟大了。最好的辦法是A負責生成一個B並且啟動它,然後B自己管理生存週期,A和B通過使用可共享的方法來通訊,這是sun推薦的做法。
  

六、非同步訊息

  使用者玩牌的過程中,有很多東西需要記錄下來,比如記錄使用者的積分、等級變化,記錄玩牌日誌供資料統計等,當用戶數量很多的時候,在資料庫中記錄這些資訊會很耗費資源,使用者玩了一局之後會可能會等待很長時間。解決這個問題的方法是利用J2EE的訊息bean來提供非同步通訊的機制,需要記錄資料的時候,系統會封裝一個值物件,傳送給J2EE容器,這個操作是很快的,完成之後就返回,使用者可以繼續操作,不用關心訊息何時被處理。J2EE的訊息框架具備如下特徵:
  ◇訊息一定會被閱讀,而且只閱讀一次。JMS框架有自己的演算法,把訊息緩衝到硬碟,就算J2EE伺服器死掉,訊息也不會丟失。
  ◇系統採用點對點的Queue訊息佇列,可以保證同等優先順序的訊息先進先出。
  在Jboss 4.0中,部署訊息Bean和Queue佇列,都比weblogic 8.1來的容易,只需要在jboss.xml中宣告訊息目的地,如果jboss發現該目的地不存在的話,會自動建立一個,實在很簡單。關於訊息bean的開發與部署,我有專門的文章描述。
  

七、啟動與退出

  為了讓系統具備讓人滿意的效能,應該儘量多的重用物件,減少建立新物件。比如上面提到的訊息傳送,我們的操作是提供一個靜態類,在系統啟動的時候就初始化,保持與JMS伺服器的連線,系統傳送訊息的時候,不用再去查詢JNDI和生成QueueConnectionFactory,這樣可以提高系統響應速度。
  在資料庫連線池的問題上,我們也採用同樣的操作,啟動的時候初始化N個連線。但是如果在關閉程序的時候不做任何操作,會導致JMS丟擲socket異常,雖然沒什麼大的影響,但總顯得不專業,而且池中的連線不被釋放的話,也可能導致問題。最好能夠讓系統像jboss等控制檯程式一樣,ctrl+c之後能夠執行操作,釋放資源再退出。我們可以通過給程序/執行緒加上一個Hook來實現,windows程式設計師應該對這個非常熟悉。
  
Hook應該是一個執行緒方法,如下:

  package org.bromon.games; 
  public class Hook extends Thread { 
      public void run() { 
          //釋放資料庫連線,銷燬連線池 
          //關閉與JMS的連線 
        } 
  } 

在主執行緒中加入:Runtime.getRuntime().addShutdownHook(new Hook()) ;那麼程序/執行緒會在退出的時候執行Hook的run方法,清理資源。
將四個位元組轉回來的操作如下:

  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; 
  }