Go游戏服务器热更新
1.为什么需要做代码热更新?
- 什么是代码热更新
所谓代码热更一般是指服务程序在不停止服务运行的情况下动态替换其中部分代码对原有逻辑进行修正或者新增功能代码。 - 为什么需要代码热更新
服务程序一般需要为应用程序提供长时间724小时不间断的服务。
不停止服务程序的运行的情况下动态修改程序的行为逻辑。
游戏服务器频繁停服维护非常不友好。*
2.游戏服务器热更主流解决方案?
代码热更的基本原理
我们可以用一个公式来简单概括服务程序。服务程序=数据结构(数据管理)+算法(这里说的算法指代程序中对于数据的计算处理相关的部分)。大部分时候代码热更新只能热更算法的部分,数据部分是非常难热更的或者说不建议热更的。举个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
/////////////src////////////////// type GameData struct { A int B bool C string D float32 } /////////////src////////////////// /////////////hotswap////////////////// type GameData struct { A int B bool C string E float64 } /////////////hotswap////////////////// /////////////src////////////////// func ChangeGameData(gdata *GameData) { gdata.A = 100 gdata.B = false gdata.D = 0.5 } /////////////src////////////////// /////////////hotswap////////////////// func ChangeGameData(gdata *GameData) { gdata.A = 999 gdata.B = true gdata.C = "hotswap" } /////////////hotswap////////////////// |
上述代码可以进行热更的部分是ChangeGameData方法。试想一下如果修改了GameData数据结构,删除了D字段新增了E字段,那么此时数据结构与程序内存中的GameData对象的内存结构是不匹配的,那么对于字段的操作也不能达到预想的效果。到这里我们热更的目标很明确了,就是如何能够动态的替换掉ChangeGameData这类的对于数据的计算的函数?
代码热更方案:
基本原理
- 动态库替换
- 编译型语言+(运行时编译器(luajit/cpython)+动态解析内嵌脚本)
实践方案
- c/c++动态库(.so或者.dll)替换
- c/c++内嵌lua
- c/c++内嵌python
- node.js
- golang+plugin
3.Go游戏服务器如何做热更新
前面实践方案中已经剧透了Go语言中实现代码热更新的方式是采用plugin包加载动态库的方式。go编译器支持将一个go程序编译成一个插件(plugin)。plugin的基本原理类似于c/c++加载动态库的方式。
插件程序构建
- 程序目录结构
1234plugintest/├── main.go└── plugin└── main.go - 代码解析-插件代码(plugintest/plugin/main.go)
1 2 3 4 5 6 7 8 9 10 |
package main func main() { } // 示例程序只是为了说明plugin的基本原理,所以我们只需要简单的一个函数示例即可。 func PluginAdd(a, b int) int { return a + b } |
- 代码解析-宿主程序代码(plugintest/main.go)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
package main import ( "bufio" "fmt" "os" "plugin" "strings" ) func Add(a, b int) int { return a + b } func main() { var sum int sum = Add(1, 2) fmt.Printf("local func a+b=%v \n", sum) for { fmt.Printf("请输入插件名称:\n") reader := bufio.NewReader(os.Stdin) text, err := reader.ReadString('\n') if err != nil { fmt.Println(err) return } text = strings.TrimRightFunc(text, func(c rune) bool { //In windows newline is \r\n return c == '\r' || c == '\n' }) if text == "end" { break } loadPlugin(text) } } func loadPlugin(pname string) { plug, err := plugin.Open(pname) if err != nil { fmt.Println(err) os.Exit(1) } addf, err := plug.Lookup("PluginAdd") if err != nil { fmt.Println(err) os.Exit(1) } sum := addf.(func(a, b int) int)(1, 2) fmt.Printf("plugin func a+b=%v \n", sum) } |
- 编译-plugin编译
1 2 3 4 5 |
windows: $ go build -buildmode=plugin -o plugin.so -buildmode=plugin not supported on windows/amd64 linux: go build -buildmode=plugin -o plugin.so |
在windows上编译plugin时发现windows上不支持plugin模式,我们来看看go官方的说明。
- 编译-宿主程序编译
12[chet@10.1.26.194:~/workspace/chetgo]$ go build -o plugintest[chet@10.1.26.194:~/workspace/chetgo]$go build -buildmode=plugin -o plugin.so上面的示例已经示范了如何编写插件代码,如何编译插件代码,如何使用plugin加载插件代码,如何调用plugin代码的全部流程了。剩下的就是对plugin的封装以及梳理加载、重载的流程了。
插件程序避坑指南-plugin都有哪些坑?
同一个插件只会加载一次(宿主程序的整个生命周期)
1 2 3 4 5 6 7 8 9 |
[chet@10.1.26.194:~/workspace/chetgo]$./plugintest local func a+b=3 请输入插件名称: plugin.so plugin func a+b=3 请输入插件名称: plugin.so // 这里的plugin.so是已经修改过代码重新编译生成的 plugin func a+b=3 请输入插件名称: |
同一个插件必须在不同的路径名下进行编译否则依然被认为是同一个插件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
// ①先编译插件plugin.so go build -buildmode=plugin -o plugin.so // ②编译宿主程序 go build -o plugintest // ③执行./plugintest [chet@10.1.26.194:~/workspace/chetgo]$./plugintest local func a+b=3 请输入插件名称: plugin.so plugin func a+b=3 // ④修改PluginAdd函数的代码 func PluginAdd(a, b int) int { return a + b + 1 // 对a+b的值做+1操作 } // ⑤然后重新编译生成plugin.so go build -buildmode=plugin -o plugin.so // ⑦继续执行 [chet@10.1.1.74:~/go/plugintest]$./plugintest local func a+b=3 请输入插件名称: ./plugin/plugin.so plugin func a+b=3 请输入插件名称: ./plugin/plugin.so plugin func a+b=3 (我们发现结果没有变化,说明被认为是同一个插件没有进行2次加载) // ⑧变更插件名称为plugin1.so看看是否会重新加载新插件 go build -buildmode=plugin -o plugin1.so // ⑨继续执行 [chet@10.1.1.74:~/go/plugintest]$./plugintest local func a+b=3 请输入插件名称: ./plugin/plugin.so plugin func a+b=3 请输入插件名称: ./plugin/plugin.so plugin func a+b=3 请输入插件名称: ./plugin/plugin1.so plugin.Open("./plugin/plugin1"): plugin already loaded // ⑩变更插件目录plugin->plugin2,修改PluginAdd函数的代码 func PluginAdd(a, b int) int { return a + b + 1 // 对a+b的值做+1操作 } [chet@10.1.1.74:~/go/plugintest/plugin2]$pwd /home/chet/go/plugintest/plugin2 [chet@10.1.1.74:~/go/plugintest/plugin2]$go build -buildmode=plugin -o plugin2.so [chet@10.1.1.74:~/go/plugintest/plugin2]$ // ⑫重新执行 [chet@10.1.1.74:~/go/plugintest]$./plugintest local func a+b=3 请输入插件名称: ./plugin/plugin.so plugin func a+b=4 请输入插件名称: ./plugin2/plugin2.so plugin func a+b=13 // 变更了插件目录然后重新进行编译,可以正常载入新插件了 请输入插件名称: |
同一个插件与宿主程序公用的公共代码库必须没有变更,并且在同一个环境下编译才能进行加载
①为了验证这个问题我们新增一个公共package common
1 2 3 4 5 6 7 8 |
├── common │ └── common.go ├── go.mod ├── main.go ├── plugin │ └── main.go └── plugin2 └── main.go |
②修改宿主程序和plugin都引用package common
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
// common.go package common import "fmt" func Common() { fmt.Println("this is common package") } // main.go package main import ( "bufio" "fmt" "os" "plugin" "strings" "plugintest/common" ) func Add(a, b int) int { return a + b } func main() { common.Common() var sum int sum = Add(1, 2) fmt.Printf("local func a+b=%v \n", sum) for { fmt.Printf("请输入插件名称:\n") reader := bufio.NewReader(os.Stdin) text, err := reader.ReadString('\n') if err != nil { fmt.Println(err) return } text = strings.TrimRightFunc(text, func(c rune) bool { //In windows newline is \r\n return c == '\r' || c == '\n' }) if text == "end" { break } loadPlugin(text) } } // plugin2/main.go package main import ( "plugintest/common" ) func main() { } // 示例程序只是为了说明plugin的基本原理,所以我们只需要简单的一个函数示例即可。 func PluginAdd(a, b int) int { common.Common() return a + b + 1 } |
②修改package common的代码
1 2 3 4 5 6 7 |
package common import "fmt" func Common() { fmt.Println("this is common package (change by chet)") } |
③重新编译插件->plugin2.so
1 2 |
cd plugin2 go build -buildmode=plugin -o plugin2.so |
④重新执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
[chet@10.1.1.74:~/go/plugintest]$./plugintest this is common package local func a+b=3 请输入插件名称: ./plugin/plugin.so this is common package plugin func a+b=4 请输入插件名称: ./plugin2/plugin2.so plugin.Open("./plugin2/plugin2"): plugin was built with a different version of package plugintest/common 从上面的结果可以看出来公共代码发生变更后,插件无法插入宿主程序中了。 关于不同编译环境的测试感兴趣的可以在两台不同Go版本的机器上一个编译宿主程序,另一台上编译插件。这种情况下插件加载也会失败。 |
如何避免/解决这些坑呢?
所谓的这些坑本质上并不一定是坑只是我们对它的基本原理理解的没有那么清晰。分析出这些坑的原因后,其实很容易避免。我们只需要遵循它的基本准则就可以避免了。比方说上面提到的一些问题,只需要每次编译插件代码时将插件目录复制到一个临时目录进行编译即可。如果插件内部有多个目录包含多个package那么在将代码复制到临时目录后需要动态的将代码中引用这些package的路径和名称也做相应的修改即可。
4.Go游戏服务器代码热更工程应用
下面是一个在正式项目中应用plugin进行热更新的项目工程目录结构。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
. ├── bin ├── components ├── doc ├── internal ├── protobuf ├── scripts ├── service └── tools service ├── gateway ├── lobby │ ├── global │ ├── playermgr │ └── plugin │ ├── dbproc │ │ └── hotswapbureau │ ├── msgproc │ │ ├── hotswapbureau │ │ └── hotswapcmd │ │ └── hotswapcmd │ ├── nsqproc │ │ ├── hotswapbureau │ │ └── hotswapcmd │ └── rpcproc │ ├── hotswapbureau │ └── rpcdef ├── login │ ├── global │ ├── plugin │ │ └── httpproc │ │ ├── hotswapbureau │ │ └── httpdef │ └── servicelist └── scene ├── global ├── plugin │ ├── msgproc │ │ ├── hotswapbureau │ │ └── hotswapcmd │ └── rpcproc │ └── rpcdef └── scenemgr |
如何在正式项目中合理的应用plugin进行热更新的方法可以参考下图:
测试代码在这里
plugintest.zip (1.3 KB, 265 次)
也可以看看这个:https://github.com/edwingeng/hotswap
Hotswap为go语言代码热更提供了一套相当完整的解决方案,热更过程不会中断或阻塞任何执行中的函数,更不会重启服务器。此方案建立在go语言的plugin机制之上。