牌类游戏使用微服务重构笔记(八): 游戏网关服务器
網(wǎng)關(guān)服務(wù)器
所謂網(wǎng)關(guān),其實就是維持玩家客戶端的連接,將玩家發(fā)的游戲請求轉(zhuǎn)發(fā)到具體后端服務(wù)的服務(wù)器,具有以下幾個功能點:
- 長期運行,必須具有較高的穩(wěn)定性和性能
- 對外開放,即客戶端需要知道網(wǎng)關(guān)的IP和端口,才能連接上來
- 多協(xié)議支持
- 統(tǒng)一入口,架構(gòu)中可能存在很多后端服務(wù),如果沒有一個統(tǒng)一入口,則客戶端需要知道每個后端服務(wù)的IP和端口
- 請求轉(zhuǎn)發(fā),由于統(tǒng)一了入口,所以網(wǎng)關(guān)必須能將客戶端的請求轉(zhuǎn)發(fā)到準(zhǔn)確的服務(wù)上,需要提供路由
- 無感更新,由于玩家連接的是網(wǎng)關(guān)服務(wù)器,只要連接不斷;更新后端服務(wù)器對玩家來說是無感知的,或者感知很少(根據(jù)實現(xiàn)方式不同)
- 業(yè)務(wù)無關(guān)(對于游戲服務(wù)器網(wǎng)關(guān)不可避免的可能會有一點業(yè)務(wù))
對于http請求來說,micro框架本身已經(jīng)實現(xiàn)了api網(wǎng)關(guān),可以參閱之前的博客
牌類游戲使用微服務(wù)重構(gòu)筆記(二): micro框架簡介:micro toolkit
但是對于游戲服務(wù)器,一般都是需要長鏈接的,需要我們自己實現(xiàn)
連接協(xié)議
網(wǎng)關(guān)本身應(yīng)該是支持多協(xié)議的,這里就以websocket舉例說明我重構(gòu)過程中的思路,其他協(xié)議類似。首先選擇提供websocket連接的庫 推薦使用melody,基于websocket庫,語法非常簡單,數(shù)行代碼即可實現(xiàn)websocket服務(wù)器。我們的游戲需要websocket網(wǎng)關(guān)的原因在于客戶端不支持HTTP2,不能和grpc服務(wù)器直連
package mainimport ("github.com/micro/go-web""gopkg.in/olahol/melody.v1""log""net/http" )func main() {// New web serviceservice := web.NewService(web.Name("go.micro.api.gateway"))// parse command lineservice.Init()// new melodym := melody.New()// Handle websocket connectionservice.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {_ = m.HandleRequest(w, r)})// handle connection with new sessionm.HandleConnect(func(session *melody.Session) {})// handle disconnectionm.HandleDisconnect(func(session *melody.Session) {})// handle messagem.HandleMessage(func(session *melody.Session, bytes []byte) {})// run serviceif err := service.Run(); err != nil {log.Fatal("Run: ", err)} } 復(fù)制代碼請求轉(zhuǎn)發(fā)
網(wǎng)關(guān)可以收取或發(fā)送數(shù)據(jù),并且數(shù)據(jù)結(jié)構(gòu)比較統(tǒng)一都是[]byte,這一點是不是很像grpc stream,因此就可以使用protobuf的oneof特性來定義請求和響應(yīng),可參照上期博客
牌類游戲使用微服務(wù)重構(gòu)筆記(六): protobuf爬坑
定義gateway.basic.proto,對網(wǎng)關(guān)收/發(fā)的消息進行歸類
message Message {oneof message {Req req = 1; // 客戶端請求 要求響應(yīng)Rsp rsp = 2; // 服務(wù)端響應(yīng)Notify notify = 3; // 客戶端推送 不要求響應(yīng)Event event = 4; // 服務(wù)端推送Stream stream = 5; // 雙向流請求Ping ping = 6; // pingPong pong = 7;// pong} } 復(fù)制代碼對于req、notify都是客戶端的無狀態(tài)請求,對應(yīng)后端的無狀態(tài)服務(wù)器,這里僅需要實現(xiàn)自己的路由規(guī)則即可,比如
message Req {string serviceName = 1; // 服務(wù)名string method = 2; // 方法名bytes args = 3; // 參數(shù)google.protobuf.Timestamp timestamp = 4; // 時間戳... } 復(fù)制代碼- serviceName 調(diào)用rpc服務(wù)器的服務(wù)名
- method 調(diào)用rpc服務(wù)器的方法名
- args 調(diào)用參數(shù)
- timestamp 請求時間戳,用于客戶端對服務(wù)端響應(yīng)做匹配識別,模擬http請求req-rsp
思路與micro toolkit的api網(wǎng)關(guān)類似(rpc 處理器),比較簡單,可參照之前的博客。
我們的項目對于此類請求都走h(yuǎn)ttp了,并沒有通過這個網(wǎng)關(guān), 僅有一些基本的req,比如authReq處理session認(rèn)證。主要考慮是http簡單、無狀態(tài)、好維護,再加上此類業(yè)務(wù)對實時性要求也不高。
grpc stream轉(zhuǎn)發(fā)
游戲服務(wù)器一般都是有狀態(tài)的、雙向的、實時性要求較高,req-rsp模式并不適合,就需要網(wǎng)關(guān)進行轉(zhuǎn)發(fā)。每添加一種grpc后端服務(wù)器,僅需要在oneof中添加一個stream來拓展
message Stream {oneof stream {room.basic.Message roomMessage = 1; // 房間服務(wù)器game.basic.Message gameMessage = 2; // 游戲服務(wù)器mate.basic.Message mateMessage = 3; // 匹配服務(wù)器} } 復(fù)制代碼并且需要定義一個對應(yīng)的路由請求,來處理轉(zhuǎn)發(fā)到哪一臺后端服務(wù)器上(實現(xiàn)不同也可以不需要),這里會涉及到一點業(yè)務(wù),例如
message JoinRoomStreamReq {room.basic.RoomType roomType = 1;string roomNo = 2; } 復(fù)制代碼這里根據(jù)客戶端的路由請求的房間號和房間類型,網(wǎng)關(guān)來選擇正確的房間服務(wù)器(甚至可能鏈接到舊版本的房間服務(wù)器上)
選擇正確的服務(wù)器后,建立stream 雙向流
address := "xxxxxxx" // 選擇后的服務(wù)器地址 ctx := context.Background() // 頂層context m := make(map[string]string) // some metadata streamCtx, cancelFunc := context.WithCancel(ctx) // 復(fù)制一個子context// 建立stream 雙向流 stream, err := xxxClient.Stream(metadata.NewContext(streamCtx, m), client.WithAddress(address))// 存儲在session上 session.Set("stream", stream) session.Set("cancelFunc", cancelFunc)// 啟動一個goroutine 收取stream消息并轉(zhuǎn)發(fā) go func(c context.Context, s pb.xxxxxStream) {// 退出時關(guān)閉 streamdefer func() {session.Set("stream", nil)session.Set("cancelFunc", nil)if err := s.Close(); err != nil {// do something with close err}}()for {select {case <-c.Done():// do something with ctx cancelreturndefault:res, err := s.Recv()if err != nil {// do something with recv errreturn}// send to session 這里可以通過oneof包裝告知客戶端是哪個stream發(fā)來的消息...}} }(streamCtx, stream) 復(fù)制代碼轉(zhuǎn)發(fā)就比較簡單了,直接上代碼
對于某個stream的請求
message Stream {oneof stream {room.basic.Message roomMessage = 1; // 房間服務(wù)器game.basic.Message gameMessage = 2; // 游戲服務(wù)器mate.basic.Message mateMessage = 3; // 匹配服務(wù)器} } 復(fù)制代碼添加轉(zhuǎn)發(fā)代碼
s, exits := session.Get("stream") if !exits {return }if stream, ok := s.(pb.xxxxStream); ok {err := stream.Send(message)if err != nil {log.Println("send err:", err)return} } 復(fù)制代碼當(dāng)需要關(guān)閉某個stream時, 只需要調(diào)用對應(yīng)的cancelFunc即可
if v, e := session.Get("cancelFunc"); e {if c, ok := v.(context.CancelFunc); ok {c()} } 復(fù)制代碼使用oneOf的好處
由于接收請求的入口統(tǒng)一,使用oneof就可以一路switch case,每添加一個req或者一種stream只需要添加一個case, 代碼看起來還是比較簡單、清爽的
func HandleMessageBinary(session *melody.Session, bytes []byte) {var msg pb.Messageif err := proto.Unmarshal(bytes, &msg); err != nil {// do somethingreturn}defer func() {err := recover()if err != nil {// do something with panic}}()switch x := msg.Message.(type) {case *pb.Message_Req:handleReq(session, x.Req)case *pb.Message_Stream:handleStream(session, x.Stream)case *pb.Message_Ping:handlePing(session, x.Ping)default:log.Println("unknown req type")} }func handleStream(session *melody.Session, message *pb.Stream) {switch x := message.Stream.(type) {case *pb.Stream_RoomMessage:handleRoomStream(session, x.RoomMessage)case *pb.Stream_GameMessage:handleGameStream(session, x.GameMessage)case *pb.Stream_MateMessage:handleMateStream(session, x.MateMessage)} } 復(fù)制代碼熱更新
對于游戲熱更新不停服還是挺重要的,我的思路將會在之后的博客里介紹,可以關(guān)注一波 嘿嘿
坑!
- 這樣的網(wǎng)關(guān),看似沒什么問題,然而跑上一段時間使用pprof觀測會發(fā)現(xiàn)goroutine和內(nèi)存都在緩慢增長,也就是存在goroutine leak!,原因在于 micro源碼在包裝grpc時,沒有對關(guān)閉stream完善,只有收到io.EOF的錯誤時才會關(guān)閉grpc的conn連接
并且有一個TODO
// Close the gRPC send stream // #202 - inconsistent gRPC stream behavior // The underlying gRPC stream should not be closed here since the // stream should still be able to receive after this function call // TODO: should the conn be closed in another way? func (g *grpcStream) Close() error {return g.stream.CloseSend() } 復(fù)制代碼解決方法也比較簡單,自己fork一份源碼改一下關(guān)閉stream的時候同時關(guān)閉conn(我們的業(yè)務(wù)是可以的因為在grpc stream客戶端和服務(wù)端均實現(xiàn)收到err后關(guān)閉stream),或者等作者更新用更科學(xué)的方式關(guān)閉
- melody的session在get和set數(shù)據(jù)時會發(fā)生map的讀寫競爭而panic,可以查看issue,解決方法也比較簡單
一起學(xué)習(xí)
本人學(xué)習(xí)golang、micro、k8s、grpc、protobuf等知識的時間較短,如果有理解錯誤的地方,歡迎批評指正,可以加我微信一起探討學(xué)習(xí)
總結(jié)
以上是生活随笔為你收集整理的牌类游戏使用微服务重构笔记(八): 游戏网关服务器的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 晚上做梦梦到刮大风是什么意思
- 下一篇: 洛谷P1828 香甜的黄油 Sweet