# gogame **Repository Path**: corbie_zwl/gogame ## Basic Information - **Project Name**: gogame - **Description**: go实现的游戏逻辑框架 - **Primary Language**: Go - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 7 - **Created**: 2023-03-03 - **Last Updated**: 2023-03-03 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # gogame ## 介绍 go实现的分布式游戏框架 - 微服务通讯: 基于[rpcx](https://rpcx.io/)的二次封装 - 服务发现: etcd - 数据存储: mongodb - 缓存: redis ### 架构图 ![gogame_png_1](png/Snipaste_2022-08-09_15-22-36.png) - 目录文件 - 命令行相关命令 - 通讯流程 - 功能开发流程 - 数据层 ### 目录文件 ``` cmd: 命令行 root.go: 根命令 reload.go: 热更 stop.go: 服务停止 game 游戏逻辑模块 manage configmanange 配置管理 【g.GC】 采用LuBan生成json和sturt 在此基础上2次封装 modulemanange 功能模块管理 【g.M】 modelmanange 数据层管理 【g.DATA】 module(存放所有的功能模块) user:用户模块 api 接口基类 api_login 用户登录 api_loginout 用户登出 fun:模块公共逻辑方法 hero:同上 route:所有模块接口注册 common:游戏逻辑常用方法封装 g:游戏全局使用封装 globalplayer:玩家全局数据 gameconfig:游戏配置相关 crossserver:跨服配置处理 serverconfig:服务器配置处理 gameservice:游戏服务 gateway(网关服务,维护客户端链接) app: 网关服务载体 handle: 用户网关实例 rpcmanage: 管理rpcXClient sender: 玩家数据推送 worker(游戏逻辑服务) app: 游戏逻辑服务载体 handle: 接口分发,注射 modellog(数据入库) app: 具体逻辑实现 timer(定时器) app: 基于lib.timer service:基类service servicemanage:服务管理 log:存在游戏日志,以小时切割文件 worker: 2022-7-14-15.log gateway: 2022-7-14-15.log timer: 2022-7-14-15.log modellog: 2022-7-14-15.log json:自定义json crosstimer.json 跨服定时器配置 timer.json 本服定时器配置 errormsg.json 异常拦截语言包 samejson:策划json hero.json 英雄表 item.json 道具表 logger:日志模块 server:网络服务 go_rpc: 自实现rpc rpcx:基于rpcx的2次封装 client client selector 自定义worker选择器 server 服务端 server serverplugin 中间件 network:基础服务 tcp:tcp ws:websocket lib: 第三方库封装,自定义工具 database:数据库相关 datatype:自定义类型 timer:定时器 base64:base64加解密 common:通用方法集合 convert:类型转换 dfa:dfa敏感词算法实现 emitter:event事件管理(观察者模式) json:json编解码 file:文件操作 protobuf:protobuf操作 random:随机相关处理 time:时间相关处理 sys:通用方法 pb: protobuf存储 pb_python pb.python存储 pb_js pb.js存储 proto 定义所有protobuf文件 模块名 模块名_api.proto 所有接口的proto 模块名_mongo.proto mongodb表对应的proto pb.go都存储在当前目录 test:测试模块 debug:各种调试都可以 python python相关逻辑 yace 压测脚本 config.json: 服务配置文件 crossserver.json: 跨服配置文件 main: 所有服务一键启动(一台物理机,分布式到对应服务下面的run目录去启动) pb2go.py: protobuf转go、python、js json2go.py: json转go结构体文件用于处理配置 websocket.html: 模拟前端接口调用(json版本) 接口网页测试客户端.html: 模拟前端接口调用(protobuf版本) ``` ### 命令行相关命令 - 配置热更新 - 热更所有配置:go run ./cmd/main reload - 热更指定配置:go run ./cmd/main reload 配置文件名1,配置文件名2 - 服务停止 - 停止全部服务: go run ./cmd/main stop - 停止多个服务:go run ./cmd/main stop 服务名1 服务名2 ... - 停止所有游戏逻辑服务: go run ./cmd/main stop worker - 默认会停止定时器和数据写入服务 - 停止指定游戏逻辑服务: go run ./cmd/main stop worker --workerUrl=10.0.0.153:7004 - 停止网关服务: go run ./cmd/main stop gateway - 停止指定网关服务: go run ./cmd/main stop worker --gatewayUrl=10.0.0.153:8001 - 停止定时器服务: go run ./cmd/main stop timer - 停止数据入库服务: go run ./cmd/main stop modellog - 服务启动 - 启动全部服务: go run ./cmd/main start - 启动多个服务:go run ./cmd/main start 服务名1 服务名2 ... - 启动所有游戏逻辑服务: go run ./cmd/main start worker - 启动网关服务: go run ./cmd/main start gateway - 启动定时器服务: go run ./cmd/main start timer - 启动数据入库服务: go run ./cmd/main start modellog ### 通讯流程 #### 通讯方式: protobuf #### [protobuf转换说明](https://gitee.com/wnp0818/gogame/blob/master/protobuf%E8%BD%AC%E6%8D%A2%E8%AF%B4%E6%98%8E.md) ##### 1:客户端发送pb消息到gateway ```protobuf // ws请求pb message Request { string ModuleName = 1; // 功能模块名 例如:user、hero string ApiName = 2; //接口名 例如: Login、LoginOut google.protobuf.Any Data = 3; // 请求参数,接口分发时动态解析 string Sec = 4; // 接口秘钥 } ``` ##### 2:gateway格式化Request为ApiRequest作为接口Request,转发到worker处理 ```protobuf // 接口请求pb message ApiRequest { string ModuleName = 1; // 功能模块名 例如:user、hero string ApiName = 2; //接口名 例如: Login、LoginOut string Api = 3; // 拼接名 例如:user.Login google.protobuf.Any Data = 4; // 请求参数,接口分发时动态解析 string Ip = 5; // rpcXClient地址 string Uid = 6; // 玩家uid string GatewayUrl = 7; // gateway地址 string WorkerUrl = 8; // 请求的worker地址 } ``` ##### 3:worker处理完成之后返回ApiResponse ```protobuf // 接口响应pb message ApiResponse { // ResponseApi不为“”则为正常pb响应,取Data,否则就是字符串响应,取StringMsg int64 S = 1; // 状态码 不为1表示请求失败 string ErrorMsg = 2; // 错误信息 string ResponseApi = 3; // 响应的api 例如:user.Login google.protobuf.Any Data = 4; // pb响应 string StringMsg = 5; // 字符串响应 string Uid = 6; // 玩家uid repeated Response FirstResponse = 7; // 需要提前返回的消息 int64 IsCheck = 8; // 接口检测标识 接口必须要进程接口检测,并将该字段置未1,否则调用不通过 } ``` ##### 4:gateway收到消息后统一转换为Response返回给客户端 ```protobuf // ws响应pb message Response { // ResponseApi不为“”则为正常pb响应,取Data,否则就是字符串响应,取StringMsg int64 S = 1; // 状态码 不为1表示请求失败 string ErrorMsg = 2; // 错误信息 string ResponseApi = 3; // 响应的api 例如:user.Login google.protobuf.Any Data = 5; // pb响应 string StringMsg = 6; // 字符串响应 } ``` #### 接口测试:根目录下的 接口网页测试客户端.html ##### 效果演示: ![接口测试演示](接口测试演示.gif) #### 接口异常捕获 ![gogame_png_1](png/Snipaste_2022-07-19_17-39-35.png) ### 功能模块开发流程 #### 一:一个功能模块对应一个文件夹,存放在game/module #### 二:接口规范 - 1 一个接口一个go文件 - 2 api.go是接口基类,文件名统一格式为:api_接口 - 3 接口必须包含对应的check方法,例如,Login接口对应LoginCheck,接口必须调用Check方法进行校验并在Check方法中 将res.IsCheck置为1,否则接口调用失效 ##### 例如 ```go func (h *Api) LoginCheck(ctx context.Context, args *pb.LoginArgs, res *rpcx.Response) { res.IsCheck = 1 res.S = 1 _sid := args.Sid // 区服id错误 if !g.Config.HasSid(_sid) { res.S = -1 res.ErrorMsg = "区服id错误" return } } // Login 登陆 func (h *Api) Login(ctx context.Context, request *pb.ApiRequest, args *pb.LoginArgs, res *rpcx.Response) { h.LoginCheck(ctx, args, res) if res.S != 1 { return } ``` #### 三:fun文件存放功能相关逻辑方法,fun中一定要有init方法,服务启动会注册所有接口和注册模块到modulemanage,需要在module下的route导入模块 ```go type ModuleUser struct { *base.ModuleBase Api } var User *ModuleUser func init() { User = &ModuleUser{ ModuleBase: base.NewModuleBase(), } // 手动定义接口 手动定义的接口args是any类型 User.Api2Func = worker.Api2FuncInfo{ // key:接口名,需要驼峰,和反射调用保持一致 value: [接口func,接口args] "GetInfo": worker.FmtFuncInfo(User.GetInfo, new(pb.UserGetInfoArgs)), } _moduleName := "user" // 路由注册 未定义Api2Func默认采用反射筛选符合条件的方法注册和调用 worker.Register(_moduleName, User) // 模块注册 module.Manage.User = User // 定时器注册 测试 _name2Func := timer.Api2Func{ // key就是定时器文件名 values定时器方法名 "timer_hello": TimerHello, } timer.Register(_moduleName, _name2Func) } ``` ##### 注:接口注册支持手动注册和反射注册两种,可以相互结合使用,例如上述代码,GetInfo通过func直接执行,其余未定义的接口就通过反射执行 ```go package module import ( "fmt" _ "gogame/game/module/hero" _ "gogame/game/module/user" ) func InitRoute() { fmt.Println() } ``` #### 四:各模块之间的调用统一格式: g.M.模块名.方法名 具体实现看game/manage/modulemanage ```text // 获取所有英雄列表 _heroList = g.M.Hero.GetHeroList("uid") // 获取英雄数据 _heroInfo = g.M.Hero.GetHeroList("oid") // 创建玩家 g.M.User.CreateUser("wnp001", "127.0.0.1", 0) // 获取玩家数据 g.M.User.GetUserInfo("uid") ``` #### 五:配置规范 ##### 1:配置分为两种 - 1----功能用json(excel通过LuBan转出来的json) - 2----自定义的json(通过服务端自己用,放在json目录下)----- ##### 2:一个配置文件一个go文件,有新的配置后,执行json2go.py,会自动转换成配置对应的服务go文件,文件名格式统一为"config_配置名",统一放在./manage/configmanage下 ###### 例如 功能用json----config_Hero.go ```go package config import cfg "gogame/game/manage/config/structs" type Hero struct { StructHandle *cfg.Game_hero NewFunc func(_buf []map[string]any) (*cfg.Game_hero, error) // 数据生成方法 // 预处理字段定义区域---start // 预处理字段定义区域---end } func init() { config := &Hero{ StructHandle: NewStructHandle("game_Hero"), NewFunc: cfg.NewGame_hero, } Manage.configs.Hero = config RegisterConfigInterface(config.Name, config) } // Load 加载配置 func (self *Hero) Load(jsonData any) error { data, err := self.NewFunc(jsonData.([]map[string]any)) if err != nil { return err } self.Game_hero = data self.PreConfig() return nil } // PreConfig 配置预处理 func (self *Hero) PreConfig() { } // ------------------------ 以上文件内容自动生成,请勿随便修改! ------------------------ // ------------------------ 下方、定义配置diy方法 ------------------------ ``` 自定义json----config_ErrorMsg.go ```go package config type ErrorMsg struct { *JsonHandle // 预处理字段定义区域---start // 预处理字段定义区域---end } func init() { config := &ErrorMsg{ JsonHandle: NewJsonHandle("ErrorMsg"), } Manage.configs.ErrorMsg = config RegisterConfigInterface(config.Name, config) } // Load 加载配置 func (self *ErrorMsg) Load(jsonData any) error { err := self.JsonHandle.Load(jsonData) if err != nil { return err } self.PreConfig() return nil } // PreConfig 配置预处理 func (self *ErrorMsg) PreConfig() { } // ------------------------ 以上文件内容自动生成,请勿随便修改! ------------------------ // ------------------------ 下方、定义配置diy方法 ------------------------ ``` ##### 3:通过json2go.py生成go文件之后,在config.go中注册对应配置使用即可 ![gogame_png_1](png/Snipaste_2022-08-09_14-13-10.png) ![gogame_png_1](png/Snipaste_2022-08-09_14-14-27.png) #### 六:各配置之间的调用统一格式: g.GC.配置名().方法名 具体实现看game/manage/config ```text // 获取所有英雄配置 g.GC.Hero().GetDataList() // 获取单个英雄配置 g.GC.Hero().Get(101) // 获取star大于3星的所有英雄 g.GC.Hero().GetListByStar(3) // 获取所有英雄配置 g.GC.Item().GetDataList() // 获取单个英雄配置 g.GC.Item().Get(101) // 获取color大于3星的所有英雄 g.GC.Item().GetListByColor(3) ``` #### 七:定时器 ##### 使用举例 ```text 每隔5秒执行一次: */5 * * * * * 每隔1分钟执行一次: 0 */1 * * * * 每天23点执行一次: 0 0 23 * * * 每月1号凌晨1点执行一次: 0 0 1 1 * * 在26分、29分、33分执行一次: 0 26,29,33 * * * * 每天的0点、13点、18点、21点都执行一次: 0 0 0,13,18,21 * * * ``` ##### 1:一个定时器一个文件,文件名统一:timer_行为,放在模块目录下 ##### 2:定时器写完需要在fun文件中注册,如下图 ![gogame_png_1](png/Snipaste_2022-07-19_17-09-13.png) ##### 3:定时器json添加,本服定时器添加到timer.json,跨服添加到crosstimer.json,格式如下 ```text { "timerstr": "*/2 * * * * *", // cron格式定义 "folder": "user", // 所属模块 "api": "hello", // 接口名,注册时候func对应的key "args": [], // 接口参数 "desc": "这是测试用的" // 定时器描述 } ``` ##### 4:服务启动后,会加载timer.json和crosstimer.json中的所有定时器,效果预览: ```text 【2022-07-19 11:03:12】 heroinit route success 【2022-07-19 11:03:12】 userinit route success 【2022-07-19 11:03:12】 ********************************************************************************************** 【2022-07-19 11:03:12】 addTimer: user.hello timerStr: */2 * * * * * desc: 这是测试用的 【2022-07-19 11:03:12】 ********************************************************************************************** ``` ##### 5:定时器执行完毕之后必须返回success,没有执行完毕也需要返回具体原因,效果如下 ###### 成功执行 ```text 【2022-07-19 17:05:38】 heroinit route success 【2022-07-19 17:05:38】 userinit route success 【2022-07-19 17:05:38】 ********************************************************************************************** 【2022-07-19 17:05:38】 定时器加载ing... 【2022-07-19 17:05:38】 addTimer: user.timer_hello timerStr: */2 * * * * * desc: 这是测试用的 【2022-07-19 17:05:38】 ********************************************************************************************** 【2022-07-19 17:05:40】 user timer test....hello world 【2022-07-19 17:05:40】 定时器【user.timer_hello】执行成功!! 【2022-07-19 17:05:42】 user timer test....hello world 【2022-07-19 17:05:42】 定时器【user.timer_hello】执行成功!! ``` ###### 错误返回 ```text 【2022-07-19 17:07:35】 heroinit route success 【2022-07-19 17:07:35】 userinit route success 【2022-07-19 17:07:35】 ********************************************************************************************** 【2022-07-19 17:07:35】 定时器加载ing... 【2022-07-19 17:07:35】 addTimer: user.timer_hello timerStr: */2 * * * * * desc: 这是测试用的 【2022-07-19 17:07:35】 ********************************************************************************************** 【2022-07-19 17:07:36】 user timer test....hello world 【2022-07-19 17:07:36】 定时器【user.timer_hello】执行失败!! msg-->测试,不允许执行 ``` ###### 异常捕获 ```text 【2022-07-19 17:29:19】 heroinit route success 【2022-07-19 17:29:19】 userinit route success 【2022-07-19 17:29:19】 ********************************************************************************************** 【2022-07-19 17:29:19】 定时器加载ing... 【2022-07-19 17:29:19】 addTimer: user.timer_hello timerStr: */2 * * * * * desc: 这是测试用的 【2022-07-19 17:29:19】 ********************************************************************************************** 【2022/07/19 17:29:20.014】 ERROR 【timer/job.go:27】 Timer.Run error!! api:【user.timer_hello】 args: [] err--->: 【runtime error: index out of range [0] with length 0】 goroutine 15 [running]: runtime/debug.Stack() C:/Users/mayn/go1.18/go1.18/src/runtime/debug/stack.go:24 +0x65 gogame/lib/timer.(*Job).Run.func1() E:/wnp/go_project/gogame/lib/timer/job.go:25 +0x77 panic({0x1c1d160, 0xc0002f2dc8}) C:/Users/mayn/go1.18/go1.18/src/runtime/panic.go:838 +0x207 gogame/game/module/user.TimerHello({0x26ff0b0?, 0x0?, 0x0?}) E:/wnp/go_project/gogame/game/module/user/timer_hello.go:6 +0xa6 gogame/lib/timer.(*Job).Run(0xc00031a510) E:/wnp/go_project/gogame/lib/timer/job.go:30 +0x82 github.com/robfig/cron/v3.(*Cron).startJob.func1() C:/Users/mayn/go/pkg/mod/github.com/robfig/cron/v3@v3.0.1/cron.go:312 +0x6a created by github.com/robfig/cron/v3.(*Cron).startJob C:/Users/mayn/go/pkg/mod/github.com/robfig/cron/v3@v3.0.1/cron.go:310 +0xad ``` ### 数据层 #### 设计思路 - 获取数据------ redis(redis采用集群)-->mongo,游戏数据读写比例大概在37,redis的高性能读可以有效减轻mongo压力 - redis数据存储格式------hash - 修改数据------玩家对数据的增删改会即可同步到redis,然后异步批量插入到mongo的model_log表,接口io延迟零体验 - 数据同步------专门的消费服务处理model_log表中的数据,减轻数据库并发写压力 - 数据过期------每个model默认2小时过期,每次访问model的时候会更新过期时间,model过期或玩家离线会触发清理玩家数据,过期时间每个model可单独设置,避免热数据转冷,占用redis内存 ```text 例: 1000个玩家产生了1万条数据,正常会与mongo产生1万次io model类会整合这1万条数据,insertMany入库到mongo的model_log表,只进行1次io 消费服务会监听mongo的model_log表,有数据就会即时消费掉这些数据 ``` - 注:model类的insertMany和消费服务都是1s执行1次,就是说极限会丢失1s的数据 - 后续针对某些访问特别频繁的数据(比如玩家的userinfo),准备引入内存机制,访问机制改为:内存-->redis-->mongo #### 一:一个mongo的表对应一个model文件,文件命名统一“model_表名”,存放在game/manage/model下 ##### 例如: - userinfo表对应的model类,以下是代码直接复制,修改表名即可 - ModelBase是model基类,NewModelBase传入的参数依次是(表名,玩家数据唯一key) ```text 例如: userinfo表的唯一key是uid,一个玩家只有一条数据 hero表的唯一key是_id,一个玩家有多个英雄 ``` ``` ```go type UserInfo struct { *ModelBase } func init() { model := &UserInfo{ ModelBase: NewModelBase("userinfo", "uid", SetExpireTime(time.Hour*6)), } Manage.models.UserInfo = model } ``` #### 二:数据相关操作的方法全部定义在model类文件中,model类文件定义完成之后需要在model.go中注册对应的model类 ```go // 这里的单独使用 g.GC会导致循环引用 var ( GC = config.Manage M = module.Manage C = lib.Common ) type models struct { UserInfo *UserInfo Hero *Hero } ``` #### 三:在model类中使用g.GC g.M g.C,全部替换为GC M C,主要是避免循环导入 #### 四:方法使用 ##### 1:获取一条数据 Get ```go // GetInfo 获取英雄数据 外部调用就是g.DATA.Hero.GetInfo(oid) func (self *Hero) GetInfo(id string) *pb.ModelHero { heroInfo := new(pb.ModelHero) self.Get(id, heroInfo) return heroInfo } ``` ##### 2:获取列表数据 GetList ```go // GetList 获取玩家英雄列表 外部调用就是g.DATA.Hero.GetList(uid) func (self *Hero) GetList(uid string) []*pb.ModelHero { var heroList []*pb.ModelHero self.ModelBase.GetList(uid, &heroList) // 测试,没有英雄数据增加几个 if len(heroList) == 0 { _hid2Hero := self.AddHero(uid, 11001, 11002, 11003) for _, h := range _hid2Hero { heroList = append(heroList, h) } } return heroList } ``` ##### 3:修改数据 Set ```go // 修英雄的等级修改为666级 _setData := map[string]any{ "lv": 666 } g.DATA.Hero.Set("62df6524ae0dd951b1daaafc", _setData) ``` ##### 4:删除数据 Remove ```go g.DATA.Hero.Remove("62df6524ae0dd951b1daaafc") ``` ##### 5:插入一条数据 InsertOne ```go g.DATA.Hero.InsertOne("62df6524ae0dd951b1daaafc") 例如: // AddHero 增加一个英雄 func (self *Hero) AddHero(uid string, hid int32) *pb.ModelHero { _heroInfo := M.Hero.GetDefHeroInfo(uid, hid) // 英雄不存在 if _heroInfo == nil { return nil } _heroInfo.Id = C.GetObjectId() self.InsertOne(uid, _heroInfo) return _heroInfo } ``` ##### 6:插入多条数据 InsertMany ```go g.DATA.Hero.InsertMany([]any) 例如: _heroList := []any{ g.M.Hero.GetDefHeroInfo(uid, 11001), g.M.Hero.GetDefHeroInfo(uid, 11001), g.M.Hero.GetDefHeroInfo(uid, 11001), } g.DATA.Hero.InsertMany(uid, _heroList) ``` #### 五:数据监听 在游戏中我们会针对玩家的数据改变做出对应的逻辑处理,最典型的就是任务了 例如: - 玩家等级升到x级 需要监听玩家lv的改变 - 玩家英雄升到x星 需要监听英雄star的改变 - 累计登陆x次游戏 需要监听累计登陆的次数 如果没有统一管理的地方,那么实现起来就是各种数据监听就分布在很多文件中了,不利于后续管理和排查问题 例如: 我们需要监听玩家的等级变化,并触发任务监听,可能n个接口中都有可能导致玩家等级发生改变,或者n个方法中,那么我们的任务监听就要写n个, 解决方案: 我们的数据层基类所有的增删改查,最后都是会经过基类的dataChange,所以直接在这里通知上层具体什么数据发生了改变,就能统一的再数据层中监听数据改变了,不管是在什么地方对数据做出了修改,只需要在DataChange中做出处理即可 例如:监听玩家登陆次数 model类注册数据监听,可以不注册(就接受不到数据变化了) ![gogame_png_1](png/Snipaste_2022-09-09_13-33-53.png) 所有增删改查数据层底层通知上层DataChange ![gogame_png_1](png/Snipaste_2022-09-09_13-35-33.png) 上层DataChange对数据进行监听 ![gogame_png_1](png/Snipaste_2022-09-09_13-36-36.png) #### 六:部分数据不写入redis 在常规业务开发中,我们的数据层都会将增删改查全部同步到redis 但对于部分数据,例如玩家的行为日志,都是只插不查的(游戏分析后台查),就没必要写入redis,浪费内存 ![gogame_png_1](png/Snipaste_2022-09-20_11-49-03.png) 通过WriteRedis方法传入false,数据层针对该model将不再写入redis