简介
热更新在游戏业务中算是比较重要的一环,不论是客户端还是服务端。这里的热更新,指的是逻辑热更新,一般是用来紧急修复bug用的,配置表更新比较简单,这里不讨论。在Java或者Lua这种带解释执行的语言,热更新比较简单,在Rust中作者没有找到一种比较好的方式进行热更新,这里暂且用了加载动态库的方式进行热更新,如果读者有更好的方案,欢迎一起探讨。
明确更新范围
热更新不是万能的,要热更新的地方通常都是预先埋点的,没有埋点的地方那自然不能进行更新。通常我们要对处理Protobuf协议的逻辑以及一些公共逻辑进行埋点,因为这些地方是最容易出问题的。为了使这些逻辑可以被热更新到,我们需要使用到trait
进行抽象,到运行的时候才可以动态的替换。例如我们使用Handler
处理每一个Protobuf协议,那么它的定义如下:
#[async_trait]
pub trait Handler: Send + Sync {
type A: Actor;
type M;
async fn handle(&self, context: &mut ActorContext, actor: &mut Self::A, message: Self::M) -> anyhow::Result<()>;
}
那么这些Handler
就可以存储到一个HashMap
当中可以进行运行时替换。
pub struct Handlers<A: Actor, M>(HashMap<&'static str, ArcSwap<Box<dyn Handler<A=A, M=M>>>>);
同样的,对于游戏中的公共逻辑,我们可以定义Service
这个trait
,这样也能做到运行时替换。
pub trait Service: Debug + Display + Send + Sync + 'static {}
impl<T> Service for T where T: Debug + Display + Send + Sync + 'static {}
#[derive(ServicesDebug)]
pub struct Services {
pub login_service: Box<dyn LoginService>,
pub player_service: Box<dyn PlayerService>,
pub asset_service: Box<dyn AssetService>,
}
Demo实现
下面会用一个简单的demo来完成上述最基本的热更流程。我们新建一个hotfix_demo工程,结构如下:
.
├── Cargo.lock
├── Cargo.toml
├── hotfix
│ ├── Cargo.lock
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
├── service
│ ├── Cargo.lock
│ ├── Cargo.toml
│ └── src
│ ├── lib.rs
│ └── login_service.rs
└── src
└── main.rs
在service中,是我们写逻辑的地方,定义了一个login_service的文件,内容如下:
pub trait LoginService: Send + Sync {
fn login(&self, username: &str, password: &str) -> anyhow::Result<bool>;
}
#[derive(Debug)]
pub struct LoginServiceImpl;
impl LoginService for LoginServiceImpl {
fn login(&self, username: &str, password: &str) -> anyhow::Result<bool> {
println!("default login");
if username == "admin" && password == "admin" {
Ok(true)
} else {
Ok(false)
}
}
}
然后我们需要在根目录的Cargo.toml中引入service的依赖:
[package]
name = "hotfix_demo"
version = "0.1.0"
edition = "2021"
[workspace]
members = ["hotfix", "service"]
[dependencies]
service = { path = "service" }
anyhow = "1.0.41"
libloading = "0.8.5"
在main中编写如下代码:
use service::login_service::{LoginService, LoginServiceImpl};
use std::collections::HashMap;
struct Services {
libs: HashMap<String, libloading::Library>,
login_service: Box<dyn LoginService>,
}
impl Services {
fn new() -> Services {
Services {
libs: Default::default(),
login_service: Box::new(LoginServiceImpl),
}
}
}
fn main() {
let mut services = Services::new();
let stdin = std::io::stdin();
let path = "/mnt/f/CLionProjects/hotfix_demo/target/debug/libhotfix.so";
loop {
let mut input = String::new();
stdin.read_line(&mut input).unwrap();
let input = input.trim();
match input {
"load" => {
unsafe {
let lib = libloading::Library::new(path).unwrap();
let login_service: libloading::Symbol<fn() -> Box<dyn LoginService>> = lib.get(b"get_login_service").unwrap();
services.login_service = login_service();
services.libs.insert(path.to_string(), lib);
println!("{} load done", path);
}
}
"unload" => {
services.login_service = Box::new(LoginServiceImpl);
services.libs.remove(path);
println!("{} unload done", path);
}
_ => {
services.login_service.login("admin", "admin").unwrap();
}
}
}
}
代码比较简单,首先我们的Services
中使用的是LoginService
的默认实现,通过控制台输入,加载外部库,替换成其它实现。这里需要注意的一点是我们要保证libloading::Library
不被销毁,如果被销毁了,动态库就卸载了,这样加载到的Box<dyn LoginService>
自然也就不合法了,程序会崩溃的。所以在卸载动态库的时候,一定要注意,卸载之后不能还有对库中对象的引用。在unload的时候,我们是先把service替换成了默认的实现再卸载,如果交换以下顺序,程序也会崩溃,因为赋值会触发原对象的析构,但是原对象已经不合法了。
在hotfix部分,我们直接写一个结构体实现LoginService
,然后用函数返回出去即可。
use service::login_service::LoginService;
struct LoginServiceImpl;
impl LoginService for LoginServiceImpl {
fn login(&self, username: &str, password: &str) -> anyhow::Result<bool> {
println!("login with username:{username} and password:{password}");
Ok(false)
}
}
#[no_mangle]
fn get_login_service() -> Box<dyn LoginService> {
Box::new(LoginServiceImpl)
}
下面我们实际运行一下这段代码的输出:
default login
load
/mnt/f/CLionProjects/hotfix_demo/target/debug/libhotfix.so load done
login with username:admin and password:admin
unload
/mnt/f/CLionProjects/hotfix_demo/target/debug/libhotfix.so unload done
default login
load
/mnt/f/CLionProjects/hotfix_demo/target/debug/libhotfix.so load done
login with username:admin and password:admin2
在unload之后我们进入到hotfix目录重新编译一下,然后在load,即可发现输出也发生了变化。
实际应用
上面只是实现了一个简单的demo,如果把这个逻辑应用到实际的项目中,需要将编译好的动态库分发到每一台服务器上,然后以某种逻辑触发动态库的加载来达到热更新的目的,当然,这种方式也可以执行一次性的脚本,区别就是脚本执行完毕之后动态库就立即卸载了。
其它可能会遇到的问题
如果你的动态库中有异步代码,并且使用了Tokio,那么在执行的过程中会找不到异步运行时,你需要自己在库中初始化Tokio运行时,或者用一些其它手段,这里作者也没怎么研究,暂时不讨论。