Rust游戏服务端开发随笔【热更新】

545 阅读4分钟

简介

热更新在游戏业务中算是比较重要的一环,不论是客户端还是服务端。这里的热更新,指的是逻辑热更新,一般是用来紧急修复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运行时,或者用一些其它手段,这里作者也没怎么研究,暂时不讨论。