go語言遊戲服務端開發(二)——網路通訊

五邑隱俠發表於2021-09-17
五邑隱俠,本名關健昌,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、限制最大的包大小,收到超過最大限制的包,則踢掉連線。
 
網路通訊介紹到這裡,接下來聊聊業務的服務機制和rpc機制。

相關文章