Rust游戏服务端开发随笔【Protobuf协议处理】

458 阅读8分钟

本篇文章会介绍如何处理客户端发送来的protobuf协议请求,以及我们和客户端定义了一个protobuf协议之后,如何快速的编写一个Handler来处理该请求。文中使用protobuf这个库来将protobuf协议编译成rust代码。另外还有一个prost库,这个更轻量,不过没有实现反射的功能,因为我们需要用到protobuf的反射功能,所以没有用这个库。

定义Protobuf协议

假设我们有一个登录的请求定义如下。

message LoginRequest {
//省略具体内容
}


message LoginResponse {
}

定义协议编号

客户端和服务端进行数据交互的时候,数据包的格式为协议号、协议数据(具体的项目中数据包中可能会包含其它信息,这里为了解释就简化了),客户端和服务端收到数据包后,都会根据协议号把协议数据解析成具体的数据结构,所以我们需要有一个地方来定义每条消息对应的协议号是多少。

message CSMessage {
  LoginRequest login_request = 10101;
}

message SCMessage {
  LoginResponse login_response = 10101;
}

定义Handler trait

定义好具体的协议之后,我们就需要编写一个具体的Handler来处理这个请求,为此,我们定义一个trait来统一处理,当有新的协议要处理时,只需要实现这个trait就好了。

#[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<()>;
}

这个trait很简单,在trait中,我们定义了两个关联类型,第一个类型是实现了Actor这个trait的数据结构,第二个类型则没有具体的要求,这个是这个Actor需要处理的消息类型。

然后我们还需要一个结构来容纳所有的Handler,当数据包到来时,根据具体的消息,调用对应的Handler进行处理。

pub struct Handlers<A: Actor, M>(HashMap<&'static str, ArcSwap<Box<dyn Handler<A=A, M=M>>>>);

impl<A, M> Handlers<A, M> where A: Actor {
    pub fn new() -> Self {
        Handlers(HashMap::new())
    }

    pub fn register<H>(&mut self, name: &'static str, handler: H) -> anyhow::Result<()> where H: Handler<A=A, M=M> + 'static {
        if self.contains_key(name) {
            return Err(anyhow::anyhow!("{} handler already exists", name));
        }
        self.insert(name, ArcSwap::from_pointee(Box::new(handler)));
        Ok(())
    }

    pub fn replace<H>(&self, name: &'static str, handler: H) -> anyhow::Result<()> where H: Handler<A=A, M=M> + 'static {
        match self.get(name) {
            None => {
                return Err(anyhow::anyhow!("{} handler not found", name));
            }
            Some(h) => {
                h.store(Arc::new(Box::new(handler)));
                Ok(())
            }
        }
    }

    pub fn load_handler(&self, name: &str) -> anyhow::Result<Guard<Arc<Box<dyn Handler<A=A, M=M>>>>> {
        match self.get(name) {
            None => {
                Err(anyhow::anyhow!("{} handler not found", name))
            }
            Some(handler) => {
                Ok(handler.load())
            }
        }
    }
}
  • register 这个是将定义好的Handler注册到Handlers中的函数
  • replace 将某个Handler替换为一个新的Handler,这个函数是用在需要在线热更新的时候用的
  • load_handler 这个则是具体的请求到来时,获取到对应的Handler进行逻辑处理

Handler进行类型擦除

从上面的Handlers的定义我们可以知道,我们的消息类型M是一个泛型参数,因此它只能是一个具体的类型,而我们的消息体则会有不同的类型,这样就会导致我们的Handlers其实只能存储某一类型的消息体的处理逻辑,这显然不是我们想要的,因此我们要将Handler都改变成处理同一种消息的类型。例如对于protobuf消息,我们可以统一表示为Vec<u8>,这样当我们调用handle逻辑的时候,再把这个二进制数据解析成具体的消息体,为此,我们需要把Handler再包装一层。

pub struct ErasedHandler<H, M> {
    pub handler: H,
    pub _phantom: std::marker::PhantomData<M>,
}

impl<H, M> ErasedHandler<H, M> {
    pub fn new(handler: H) -> Self {
        Self {
            handler,
            _phantom: Default::default(),
        }
    }
}

#[macro_export]
macro_rules! impl_erased_handler {
    ($actor:ty, $erased:ty) => {
        pub struct ErasedHandler<H, M> {
            pub handler: H,
            pub _phantom: std::marker::PhantomData<M>,
        }

        #[async_trait::async_trait]
        impl<H, M> shared::handler::Handler for ErasedHandler<H, M>
            where
                H: shared::handler::Handler<A=$actor, M=M>,
                M: protobuf::Message,
        {
            type A = $actor;
            type M = $erased;

            async fn handle(&self, context: &mut actor_core::actor::context::ActorContext, actor: &mut Self::A, message: Self::M) -> anyhow::Result<()> {
                let message = M::parse_from_bytes(&message)?;
                self.handler.handle(context, actor, message).await
            }
        }

        pub fn erased_handler<H, M>(handler: H, handlers: &mut shared::handler::Handlers<$actor, $erased>) -> anyhow::Result<()>
            where
                H: shared::handler::Handler<A=$actor, M=M> + 'static,
                M: protobuf::Message,
        {
            let handler = ErasedHandler {
                handler,
                _phantom: Default::default(),
            };
            handlers.register(M::NAME, handler)?;
            Ok(())
        }
    };
}

解释一下这段代码,其中ErasedHandler就是我们实际需要注册到Handlers中的Handler类型,我们处理具体消息类型的Handler包装到了ErasedHandler中,然后我们为ErasedHandler实现Handlertrait,此时关键的地方就来了,为ErasedHandler实现Handlertrait的时候,我们使用的是固定的类型$erased,然后我们再在这个handle逻辑中,将$erased类型解析成我们需要的其它类型消息,然后调用内部的handler处理这个解析好的消息。这样我们就完成了消息的类型擦除,这种方式在其它的语言中也比较常见,通过一个通用的闭包,将具体的类型信息放到闭包中,就可以完成类型擦除。

下面看一个实际的例子: 我们为PlayerActor实现消息类型为Vec<u8>ErasedHandler

use shared::impl_erased_handler;

use crate::player_actor::PlayerActor;

impl_erased_handler!(PlayerActor, Vec<u8>);

这个宏展开后的结果:

pub struct ErasedHandler<H, M> {
    pub handler: H,
    pub _phantom: std::marker::PhantomData<M>,
}
#[async_trait::async_trait]
impl<H, M> shared::handler::Handler for ErasedHandler<H, M>
where
    H: shared::handler::Handler<A=PlayerActor, M=M>,
    M: protobuf::Message,
{
    type A = PlayerActor;
    type M = Vec<u8>;

    async fn handle(&self, context: &mut actor_core::actor::context::ActorContext, actor: &mut Self::A, message: Self::M) -> anyhow::Result<()> {
        let message = M::parse_from_bytes(&message)?;
        self.handler.handle(context, actor, message).await
    }
}
pub fn erased_handler<H, M>(handler: H, handlers: &mut shared::handler::Handlers<PlayerActor, Vec<u8>>) -> anyhow::Result<()>
where
    H: shared::handler::Handler<A=PlayerActor, M=M> + 'static,
    M: protobuf::Message,
{
    let handler = ErasedHandler {
        handler,
        _phantom: Default::default(),
    };
    handlers.register(M::NAME, handler)?;
    Ok(())
}

注册Handler

现在假设我们需要处理LoginRequest这条消息,那么我需要编写如下代码:

pub(crate) struct LoginRequestHandler;

#[async_trait]
impl Handler for LoginRequestHandler {
    type A = SessionActor;
    type M = LoginRequest;

    async fn handle(&self, context: &mut ActorContext, actor: &mut Self::A, message: Self::M) -> anyhow::Result<()> {
        //在这里编写你的处理逻辑
        Ok(())
    }
}
pub fn handlers() -> anyhow::Result<shared::handler::Handlers<crate::session_actor::SessionActor, Vec<u8>>> {
    let mut handlers = shared::handler::Handlers::new();
    erased_handler::erased_handler(login_request_handler::LoginRequestHandler, &mut handlers)?;
    Ok(handlers)
}

这样我们就完成了Handler的编写与注册,是不是很方便?

构建Protobuf映射信息

前面说到我们在传输数据时,是以协议号以及协议二进制数据进行传输的,所以我们需要用某种方式知道如何反序列化和序列化这条protobuf消息,因此我们需要构建一个映射结构,可以通过协议号将二进制消息解析成具体类型的消息以及将具体类型的消息编码成二进制的消息。为此,我们定义了一下结构:

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ProtoMapping {
    pub cs_id_descriptor: HashMap<i32, MessageDescriptor>,
    pub cs_descriptor_id: HashMap<MessageDescriptor, i32>,
    pub cs_name_id: HashMap<String, i32>,
    pub cs_id_name: HashMap<i32, String>,
    pub sc_id_descriptor: HashMap<i32, MessageDescriptor>,
    pub sc_descriptor_id: HashMap<MessageDescriptor, i32>,
    pub sc_name_id: HashMap<String, i32>,
    pub sc_id_name: HashMap<i32, String>,
}

我们把从客户端发送到服务端和服务端发送到客户端的协议分开存储,以cs开头的是客户端发送给服务端的,以sc开头的是服务端发送给客户端的,ProtoMapping提供了通过协议号寻找对应协议的描述符号、通过协议的描述符号找到对应的协议号,通过协议名找协议号以及通过协议号找协议名这些功能。下面就是用我们的CSMessageSCMessage来填充这个结构。代码比较简单,就不解释了。

impl ProtoMapping {
    pub fn new() -> Self {
        let descriptor = CSMessage::descriptor();
        let mut cs_id_descriptor = HashMap::new();
        let mut cs_descriptor_id = HashMap::new();
        let mut cs_name_id = HashMap::new();
        let mut cs_id_name = HashMap::new();
        for field in descriptor.fields() {
            if let RuntimeType::Message(descriptor) = field.singular_runtime_type() {
                let name = descriptor.name().to_string();
                cs_id_descriptor.insert(field.number(), descriptor.clone());
                cs_descriptor_id.insert(descriptor, field.number());
                cs_name_id.insert(name.clone(), field.number());
                cs_id_name.insert(field.number(), name);
            } else {
                panic!("CSMessage expect a message field type, but got other type")
            }
        }

        let descriptor = SCMessage::descriptor();
        let mut sc_id_descriptor = HashMap::new();
        let mut sc_descriptor_id = HashMap::new();
        let mut sc_name_id = HashMap::new();
        let mut sc_id_name = HashMap::new();
        for field in descriptor.fields() {
            if let RuntimeType::Message(descriptor) = field.singular_runtime_type() {
                let name = descriptor.name().to_string();
                sc_id_descriptor.insert(field.number(), descriptor.clone());
                sc_descriptor_id.insert(descriptor, field.number());
                sc_name_id.insert(name.clone(), field.number());
                sc_id_name.insert(field.number(), name);
            } else {
                panic!("SCMessage expect a message field type, but got other type")
            }
        }

        Self {
            cs_id_descriptor,
            cs_descriptor_id,
            cs_name_id,
            cs_id_name,
            sc_id_descriptor,
            sc_descriptor_id,
            sc_name_id,
            sc_id_name,
        }
    }

    pub fn static_init() -> &'static Self {
        Box::leak(Box::new(Self::new()))
    }
}

impl Default for ProtoMapping {
    fn default() -> Self {
        Self::new()
    }
}

分发客户端消息

现在假设我们的protobuf消息已经通过一系列的解码,变成了如下结构:

pub(crate) struct InboundPlayerProto {
    pub(crate) id: i32,
    pub(crate) payload: Vec<u8>,
}

那么如何将这个条protobuf消息分发到具体的Handler进行处理就变得非常容易了。

#[async_trait]
impl Message for InboundPlayerProto {
    type A = PlayerActor;

    async fn handle(self: Box<Self>, context: &mut ActorContext, actor: &mut Self::A) -> anyhow::Result<()> {
        let InboundPlayerProto { id, payload } = *self;
        let cs_id_name = &actor.player_system.proto_mapping.cs_id_name;
        let name = cs_id_name.get(&id).ok_or(anyhow!("unknown proto id: {id}"))?;
        let handler = actor.player_system.handlers.load_handler(name)?;
        handler.handle(context, actor, payload.into()).await?;
        Ok(())
    }
}

在上述代码中,我们先根据协议号拿到消息的名字,然后通过消息名去找对应的Handler,然后调用Handler中的handle方法就可以将这条消息分发到具体的Handler中进行逻辑处理了。

结语

至此,我们处理客户端protobuf协议的流程就写完了,这个流程省略了一些不重要的细节,例如从网关收到客户端消息之后,我们可能要根据消息类型转发给不同的Actor进行处理,另外从文中可以看到,我们为了做到逻辑热更新,对Handler做了一些额外的处理,不再是简单的调用某个写死的函数,后面的文章中,我们还会看到类似的处理,为了能够做到热更新,需要做一些额外的处理以及一些性能上的妥协,因为游戏的热更新真的很重要!