牌类游戏使用微服务重构笔记(八): 游戏网关服务器

网关服务器

所谓网关,其实就是维持玩家客户端的连接,将玩家发的游戏请求转发到具体后端服务的服务器,具有以下几个功能点:

  • 长期运行,必须具有较高的稳定性和性能
  • 对外开放,即客户端需要知道网关的IP和端口,才能连接上来
  • 多协议支持
  • 统一入口,架构中可能存在很多后端服务,如果没有一个统一入口,则客户端需要知道每个后端服务的IP和端口
  • 请求转发,由于统一了入口,所以网关必须能将客户端的请求转发到准确的服务上,需要提供路由
  • 无感更新,由于玩家连接的是网关服务器,只要连接不断;更新后端服务器对玩家来说是无感知的,或者感知很少(根据实现方式不同)
  • 业务无关(对于游戏服务器网关不可避免的可能会有一点业务)

对于http请求来说,micro框架本身已经实现了api网关,可以参阅之前的博客

牌类游戏使用微服务重构笔记(二): micro框架简介:micro toolkit

但是对于游戏服务器,一般都是需要长链接的,需要我们自己实现

连接协议

网关本身应该是支持多协议的,这里就以websocket举例说明我重构过程中的思路,其他协议类似。首先选择提供websocket连接的库 推荐使用melody,基于websocket库,语法非常简单,数行代码即可实现websocket服务器。我们的游戏需要websocket网关的原因在于客户端不支持HTTP2,不能和grpc服务器直连

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)}
}
复制代码

请求转发

网关可以收取或发送数据,并且数据结构比较统一都是[]byte,这一点是不是很像grpc stream,因此就可以使用protobufoneof特性来定义请求和响应,可参照上期博客

牌类游戏使用微服务重构笔记(六): protobuf爬坑

定义gateway.basic.proto,对网关收/发的消息进行归类

message Message {oneof message {Req req = 1; // 客户端请求 要求响应Rsp rsp = 2; // 服务端响应Notify notify = 3; // 客户端推送 不要求响应Event event = 4; // 服务端推送Stream stream = 5; // 双向流请求Ping ping = 6; // pingPong pong = 7;// pong}
}
复制代码

对于reqnotify都是客户端的无状态请求,对应后端的无状态服务器,这里仅需要实现自己的路由规则即可,比如

message Req {string serviceName = 1; // 服务名string method = 2; // 方法名bytes args = 3; // 参数google.protobuf.Timestamp timestamp = 4; // 时间戳...
}
复制代码
  • serviceName 调用rpc服务器的服务名
  • method 调用rpc服务器的方法名
  • args 调用参数
  • timestamp 请求时间戳,用于客户端对服务端响应做匹配识别,模拟http请求req-rsp

思路与micro toolkit的api网关类似(rpc 处理器),比较简单,可参照之前的博客。

我们的项目对于此类请求都走http了,并没有通过这个网关, 仅有一些基本的req,比如authReq处理session认证。主要考虑是http简单、无状态、好维护,再加上此类业务对实时性要求也不高。

grpc stream转发

游戏服务器一般都是有状态的、双向的、实时性要求较高,req-rsp模式并不适合,就需要网关进行转发。每添加一种grpc后端服务器,仅需要在oneof中添加一个stream来拓展

message Stream {oneof stream {room.basic.Message roomMessage = 1; // 房间服务器game.basic.Message gameMessage = 2; // 游戏服务器mate.basic.Message mateMessage = 3; // 匹配服务器}
}
复制代码

并且需要定义一个对应的路由请求,来处理转发到哪一台后端服务器上(实现不同也可以不需要),这里会涉及到一点业务,例如

message JoinRoomStreamReq {room.basic.RoomType roomType = 1;string roomNo = 2;
}
复制代码

这里根据客户端的路由请求的房间号和房间类型,网关来选择正确的房间服务器(甚至可能链接到旧版本的房间服务器上)

选择正确的服务器后,建立stream 双向流

address := "xxxxxxx" // 选择后的服务器地址
ctx := context.Background() // 顶层context
m := make(map[string]string) // some metadata
streamCtx, cancelFunc := context.WithCancel(ctx) // 复制一个子context// 建立stream 双向流
stream, err := xxxClient.Stream(metadata.NewContext(streamCtx, m), client.WithAddress(address))// 存储在session上
session.Set("stream", stream)
session.Set("cancelFunc", cancelFunc)// 启动一个goroutine 收取stream消息并转发
go func(c context.Context, s pb.xxxxxStream) {// 退出时关闭 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发来的消息...}}
}(streamCtx, stream)
复制代码

转发就比较简单了,直接上代码

对于某个stream的请求

message Stream {oneof stream {room.basic.Message roomMessage = 1; // 房间服务器game.basic.Message gameMessage = 2; // 游戏服务器mate.basic.Message mateMessage = 3; // 匹配服务器}
}
复制代码

添加转发代码

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}
}
复制代码

当需要关闭某个stream时, 只需要调用对应的cancelFunc即可

if v, e := session.Get("cancelFunc"); e {if c, ok := v.(context.CancelFunc); ok {c()}
}
复制代码

使用oneOf的好处

由于接收请求的入口统一,使用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)}
}
复制代码

热更新

对于游戏热更新不停服还是挺重要的,我的思路将会在之后的博客里介绍,可以关注一波 嘿嘿

坑!

  • 这样的网关,看似没什么问题,然而跑上一段时间使用pprof观测会发现goroutine和内存都在缓慢增长,也就是存在goroutine leak!,原因在于 micro源码在包装grpc时,没有对关闭stream完善,只有收到io.EOF的错误时才会关闭grpc的conn连接
func (g *grpcStream) Recv(msg interface{}) (err error) {defer g.setError(err)if err = g.stream.RecvMsg(msg); err != nil {if err == io.EOF {// #202 - inconsistent gRPC stream behavior// the only way to tell if the stream is done is when we get a EOF on the Recv// here we should close the underlying gRPC ClientConncloseErr := g.conn.Close()if closeErr != nil {err = closeErr}}}return
}
复制代码

并且有一个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()
}
复制代码

解决方法也比较简单,自己fork一份源码改一下关闭stream的时候同时关闭conn(我们的业务是可以的因为在grpc stream客户端和服务端均实现收到err后关闭stream),或者等作者更新用更科学的方式关闭

  • melody的session在getset数据时会发生map的读写竞争而panic,可以查看issue,解决方法也比较简单

一起学习

本人学习golang、micro、k8s、grpc、protobuf等知识的时间较短,如果有理解错误的地方,欢迎批评指正,可以加我微信一起探讨学习

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/387794.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

配置独立于系统的PYTHON环境

配置独立于系统的PYTHON环境 python 当前用户包 一种解决方案是在利用本机的python环境的基础上&#xff0c;将python的包安装在当前user的.local文件夹下 一共有两种方式来实现pip的时候安装到当前user 设置pip配置文件 pip.conf 一种是在~/.pip文件夹下的pip配置文件pip.conf…

好程序员技术教程分享JavaScript运动框架

好程序员技术教程分享JavaScript运动框架&#xff0c;有需要的朋友可以参考下。 JavaScript的运动&#xff0c;即让某元素的某些属性由一个值变到另一个值的过程。如让div的width属性由200px变到400px&#xff0c;opacity属性由0.3变到1.0&#xff0c;就是一个运动过程。 实现运…

linux 下mysql等php的安装 lnmp

访问https://lnmp.org/install.html按照步骤安装 当下载执行完 wget -c http://soft.vpser.net/lnmp/lnmp1.3.tar.gz && tar zxf lnmp1.3.tar.gz && cd lnmp1.3 && ./install.shlnmp 要到.install.sh下改一下下载地址&#xff0c;把http直接更换成…

单纯形法

单纯形法 如果目标函数中所有系数都非正&#xff0c;那么显然这些变量直接取0是最优的&#xff0c;所以此时答案为即为常数项。 我们要做的就是通过转化把目标函数的系数全部搞成非负。 思路就是用非基变量替换基变量。 先找到一个目标函数中系数为正的变量&#xff0c;在所有限…

洛谷P1828 香甜的黄油 Sweet Butter

香甜的黄油 Sweet Butter 黄油真的是这么做的吗&#xff1f;&#xff01;&#xff01;&#xff01;[惶恐] 这道题是Dijkstra算法的简单变形 通过题意我们要找到一个点使奶牛所在点的路程和最短。通过Dijkstra的模板我们可以求的一点到其他任一点的最短路径&#xff0c;那么我们…

JAVA List集合转Page(分页对象)

/*** version 1.0* author: fwjia*/ import java.util.List;public class PageModel<T> {/**** 当前页*/private int page 1;/**** 总页数*/public int totalPages 0;/**** 每页数据条数*/private int pageRecorders;/**** 总页数*/private int totalRows 0;/**** 每页…

分区分表实验用的语句

--查看索引 select * from DBA_IND_PARTITIONS &#xff54;; select status,t.* from dba_indexes t where t.OWNERGANSUSC; select count(*) from ACT_HI_VARINST SELECT ALTER INDEX || TABLE_OWNER || . || INDEX_NAME || UNUSABLE; UNUSABLE_INDEX FROM ALL_INDEX…

分布式数据库数据一致性的原理、与技术实现方案

http://youzhixueyuan.com/the-principle-and-technology-realization-of-distributed-data-consistency.html 背景 可用性&#xff08;Availability&#xff09;和一致性&#xff08;Consistency&#xff09;是分布式系统的基本问题&#xff0c;先有著名的CAP理论定义过分布式…

模块之re模块 —— 正则

#‘match’只匹配从左向右第一个值是否在中括号的范围内&#xff0c;如果没有就返回None 如果有就直接打印一个对象&#xff0c;加上.group()就可以返回你要找的区间里面的值&#xff0c;如果没有找到对应的值&#xff0c;加上‘.group()’会报错 #‘search’ 默认是从整个st…

centos7 docker

Docker 是一个开源工具&#xff0c;它可以让创建和管理 Linux 容器变得简单。容器就像是轻量级的虚拟机&#xff0c;并且可以以毫秒级的速度来启动或停止。Docker 帮助系统管理员和程序员在容器中开发应用程序&#xff0c;并且可以扩展到成千上万的节点。 容器和 VM&#xff08…

批处理ping指定ip列表

for /f in (filename) do (command) for /f %i in (C:\ip.txt) do (ping %i -n 1 && echo %i 通 >>IP.txt || echo %i 不通 >>IP1.txt) 有返回写入ip.txt 没有写入ip1.txt转载于:https://blog.51cto.com/2216859/2384188

Intellij Idea 2017创建web项目及tomcat部署实战

相关软件&#xff1a;Intellij Idea2017、jdk16、tomcat7 Intellij Idea直接安装&#xff08;可根据需要选择自己设置的安装目录&#xff09;&#xff0c;jdk使用1.6/1.7/1.8都可以&#xff0c;主要是配置好系统环境变量&#xff0c;tomcat7上tomcat的官网下载压缩包解压即可。…

docker ssh

1&#xff0c;首先&#xff0c;需要从Docker官网获得centos或Ubuntu镜像 2&#xff0c;当本地已有Ubuntu镜像后&#xff08;大概200M左右大小&#xff09;&#xff0c;使用如下命令 [cpp]view plaincopy docker run -t -i ubuntu /bin/bash 即可启动一个容器&#xff0c;并放…

[BFS]JZOJ 4672 Graph Coloring

Description 现在你有一张无向图包含n个节点m条边。最初&#xff0c;每一条边都是蓝色或者红色。每一次你可以将一个节点连接的所有边变色&#xff08;从红变蓝&#xff0c;蓝变红&#xff09;。找到一种步数最小的方案&#xff0c;使得所有边的颜色相同。Input 第一行包含两个…

实现继承的方式

/*** 借助构造函数实现继承*/function Parent1(){this.name "parent1";}Parent1.prototype.say function(){};function Child1(){//将父构造函数的this指向子构造函数的实例上Parent1.call(this);//applythis.type "child1";}console.log(new Child1);/…

Vue源码: 关于vm.$watch()内部原理

vm.$watch()用法 关于vm.$watch()详细用法可以见官网。 大致用法如下: <script>const app new Vue({el: "#app",data: {a: {b: {c: c}}},mounted () {this.$watch(function () {return this.a.b.c}, this.handle, {deep: true,immediate: true // 默认会初始化…

docker启动顺序

VMDocker: 用户名:root 密码:XXXXXXXXXXXXX docker run -i -t -d -p 8081:8080 -p 23:22 67591570dd29 /bin/bash 常用命令 启动停止: service docker start service docker stop 所有镜像:docker images 当前执行:docker ps 提交保存docker容器: docker commit 进入到对应服…

js时钟倒计时

JS倒计时Date 代码如下&#xff1a; 1 <style type"text/css">2 * {3 margin: 0;4 padding: 0;5 }6 7 #box {8 border: 1px solid cyan;9 background-color: #000; 10 height: 50px; 11 width: 500px; 12 margin: 100px auto 0; 13 border-radius: 20px; 14 te…

JAVA的值传递问题

为什么 Java 中只有值传递&#xff1f; 首先回顾一下在程序设计语言中有关将参数传递给方法&#xff08;或函数&#xff09;的一些专业术语。按值调用(call by value)表示方法接收的是调用者提供的值&#xff0c;而按引用调用&#xff08;call by reference)表示方法接收的是调…