五邑隱俠,本名關健昌,12年游戲生涯。 本教程以Go語言為例。
一、網絡層
網絡游戲客戶端除了全局登錄使用http請求外,一般通過socket長連接與服務端保持連接。go語言的net包提供網絡socket長連接相關操作。
對於服務端,一般經歷 Listen、Accept兩個步驟實現與客戶端連接。
func main() {
l, err := net.Listen("tcp4", ":8080")
if err != nil {
return
}
for {
c, err := l.Accept()
if err != nil {
break
}
fmt.Println("accept connect: ", c.RemoteAddr())
}
}
客戶端通過 Dial 連接服務端
func main() {
c, err := net.DialTimeout("tcp4", "127.0.0.1:8080", time.Second*time.Duration(8))
if err != nil {
return
}
fmt.Println("connect with: ", c.LocalAddr())
}
連接 c(net.Conn類型)的主要方法是 Read、Write,
func Read(b []byte) (n int, err error) func Write(b []byte) (n int, err error)
一般連接建立后,每個連接分別創建讀和寫兩個 go rountine 進行讀循環和寫循環。
func (c *Conn) open() {
go c.readLoop()
go c.writeLoop()
}
雖然go底層是基於epoll邊緣觸發,但是並沒有暴露接口通知什么時候有可讀數據、可寫等。為了避免輪詢,一般在 go rountine 里阻塞的讀、寫。由於net.Conn只提供 Close() error,沒辦法只停止讀,等待寫結束再關閉連接。一般讀做超時處理,超時后如果有關閉標記,則不再嘗試讀。
func SetReadDeadline(t time.Time) error
連接層需要通知使用者連接的狀態,所以引入連接監聽 interface
type ConnListener interface {
OnConnOpen(c net.Conn) error
OnConnClose(c net.Conn) error
OnConnError(c net.Conn, err error)
OnConnRead(c net.Conn) error
OnConnWrite(c net.Conn) error
}
使用者只需要實現自己的監聽者監聽連接的各個生命周期,由讀寫的 go rountine 驅動業務邏輯執行。
二、P2P層
網絡層提供簡單的連接打開、關閉和讀寫操作。為了建立服務端進程之間,以及服務端進程與客戶端之間通信的基礎,引入P2P層(端對端層)。
P2P層包含端信息 P2pEnd、協議包P2pPack、P2P網絡P2pNet
1、P2pEnd定義這個端的類型、編號(例如:游戲分服編號1,網關服編號3)、寫隊列。還包含一些防御信息用於對連接進行監控
type P2pEnd struct {
EndType uint8
EndNo uint16
QueWritePacks chan *P2pPack
}
2、P2pPack定義包信息,包括源類型、編號,目標類型、編號,數據,這有利於包的路由。除此以外還有一些控制信息做更精細的處理。
type P2pPack struct {
SrcEnd uint8
SrcNo uint16
DstEnd uint8
DstNo uint16
Payload []byte
}
這里的包格式是通用包格式,Payload里包含業務包包頭,根據業務需求定義自己的包格式。
3、P2pNet是一個端對端網絡,維護該通信端所有的連接。作為一個通信端,它首先有自己的端類型、編號
type P2pNet struct {
endType uint8
endNo uint16
}
然后要記錄其他端與連接的互相映射
type P2pNet struct {
endType uint8
endNo uint16
mapConn2End map[net.Conn]*P2pEnd
mapId2Conn map[uint32]net.Conn
}
所有連接接收到的包放到一個chan里,方便做分發處理
type P2pNet struct {
endType uint8
endNo uint16
mapConn2End map[net.Conn]*P2pEnd
mapId2Conn map[uint32]net.Conn
queReadPacks chan *ReadPackWrap
}
還有一些其他的控制信息。
在P2P層維護的是端與端之間的連接,所以需要提供注冊協議,用於向服務方告知自己的端類型和編號。
func (r *P2pNet) Register(dstEnd uint8, dstNo uint16) error
服務方在 OnConnOpen(c net.Conn) error(自動分配編號) 或者 OnConnRead(c net.Conn) error 得到的包是注冊包時(由協議指定編號)對連接進行綁定。
除了注冊協議,底層的心跳 ping、pong 也在P2P層處理,還有一些防御相關的處理,對業務層透明。
這樣建立起一張端對端通信網。這張網的底層基於網絡層做通信,通過實現 ConnListener 驅動連接讀寫,讀包放到 P2pNet.queReadPacks,寫包放到對應端的 P2pEnd.QueWritePacks。
為了驅動業務邏輯,類似網絡層,在P2P層也引入監聽的 interface,使用者通過實現該interface來驅動業務邏輯
type P2pListener interface {
OnP2pConn(p2p *P2pNet, endType uint8, endNo uint16)
OnP2pCall(p2p *P2pNet, pack *P2pPack)
OnP2pClose(p2p *P2pNet, endType uint8, endNo uint16)
OnP2pError(p2p *P2pNet, err error)
}
三、關於防御
連接層的防御一般就是檢測異常連接,把異常連接踢掉,避免占用socket資源。
1、最簡單的,通過心跳來判斷連接是否活躍,清除非活躍連接復用這部分socket。連接可以分為主動活躍和被動活躍兩種模式。主動活躍的連接,會主動發心跳包過來,通過頻率去檢測心跳包,如果超時都沒收到心跳包,可以踢掉。被動活躍連接,需要定時給它發心跳報活,避免被對方踢掉。
2、沒有業務包的連接。如果一個連接從連接開始,只發心跳包,限定時間內從來不發業務包,這個連接要踢掉。
基於1、2點,連接從連接開始必須在限定時間內發業務包,后續必須通過發業務包或者心跳包來維護連接。
3、限制 IP 關聯的連接數,超過上限則踢掉連接。一般同個局域網的玩家 IP 會一樣,但是也可能是服務器在被攻擊。現在有些游戲上線,會被模擬玩家連接撐滿服務,導致真實玩家無法進入游戲。通過加 IP 關聯的連接數限制來增加攻擊成本。
4、發包頻率檢測,例如我們設定最大15幀/s,每隔2分鍾檢測一次,如果請求包間隔平均時間小於66ms,可以踢掉。
5、限制最大的包大小,收到超過最大限制的包,則踢掉連接。
6、設置寫超時(例如5秒),超時則踢掉連接,避免客戶端一直阻塞不接收導致緩存的包越來越多
業務層可以做這些限制:
1、連接驗證,沒做登錄或者重連,直接發其他業務請求包,踢掉連接
2、登錄失敗,踢掉連接
網絡通信介紹到這里,接下來聊聊業務的服務機制和rpc機制。
