Rust游戏服务端开发随笔【游戏配置表解析】

537 阅读12分钟

定义配置表

假设我们有一张成就的配置表,格式大概如下:

idgrouptask_idconditonrewardpoint
intintintintvector3_array_intint
allkeyallallallallall
100011015100011,1,50;2,10021,55

第一行标识每个字段的含义,在程序中直接对应着变量名,第二行标识这个字段的解析格式,第三行标识这个字段的作用域(客户端需要、服务端需要、或者两边都需要),不同的项目的配置表格式不尽相同,可根据具体情况修改解析逻辑即可。

与之对应的,在程序中配置表的结构以及解析代码,我们希望它最后是这样的:

#[derive(Debug, Default, Clone, Serialize, Deserialize, Encode, Decode)]
pub struct AchievementConfig {
    pub id: i32,
    pub group: i32,
    pub task_id: i32,
    pub condition: i32,
    pub reward: Vec<(i32, i32, i32)>,
    pub point: i32,
}

#[derive(Debug, Default, Clone, Serialize, Deserialize, Encode, Decode)]
pub struct AchievementConfigs {
    pub achievement: HashMap<i32, AchievementConfig>,
}

impl AchievementConfigs {
    pub fn get_by_id(&self, id: &i32) -> anyhow::Result<&AchievementConfig> {
        self.achievement.get(id).ok_or(anyhow::anyhow!(
            "{} not found, id: {}",
            id,
            std::any::type_name::<AchievementConfig>()
        ))
    }
}

impl Deref for AchievementConfigs {
    type Target = HashMap<i32, AchievementConfig>;
    fn deref(&self) -> &Self::Target {
        &self.achievement
    }
}

impl DerefMut for AchievementConfigs {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.achievement
    }
}

impl Config for AchievementConfig {
    fn id(&self) -> Id {
        self.id.clone().into()
    }
    fn parse(row: Row) -> anyhow::Result<Self>
    where
        Self: Sized,
    {
        let id = row.get("id")?;
        let group = row.get("group")?;
        let task_id = row.get("task_id")?;
        let condition = row.get("condition")?;
        let reward = row.get("reward")?;
        let point = row.get("point")?;
        Ok(Self {
            id,
            group,
            task_id,
            condition,
            reward,
            point,
        })
    }
}

impl Configs for AchievementConfigs {
    fn ids(&self) -> Vec<Id> {
        self.achievement
            .keys()
            .map(|id| (*id).clone().into())
            .collect()
    }
    fn name() -> &'static str
    where
        Self: Sized,
    {
        "achievement"
    }
    fn load(&mut self, excel_config: &ExcelConfig) -> anyhow::Result<()> {
        let name_index = excel_config.name_index();
        for (row_number, row) in excel_config.data.iter().enumerate() {
            let config = AchievementConfig::parse(Row {
                excel_name: Self::name(),
                index: &name_index,
                row: &row,
                row_number,
            })?;
            self.achievement.insert(config.id.clone(), config);
        }
        Ok(())
    }
    fn as_any(&self) -> &dyn Any {
        self
    }
    fn as_any_mut(&mut self) -> &mut dyn Any {
        self
    }
    fn into_any(self: Box<Self>) -> Box<dyn Any> {
        self
    }
}

配置表在Rust中的定义

首先定义一个Config trait,用来把配置表数据解析为Rust中的数据结构。

pub trait Config: Debug + Send + Sync + Clone {
    fn id(&self) -> Id;

    fn parse(row: Row) -> anyhow::Result<Self> where Self: Sized;
}

这里其实就相当于配置表中的一行数据,其中parse方法会把读取到的配置表数据解析为具体的数据结构。

Row的定义如下

  • excel_name 这个是配置表的名字
  • index 这个是表头的数据的名字对应所在列的索引
  • row 这个配置表的一行数据
  • row_number 这一行数据在配置表中具体哪一行
#[derive(Debug, Clone)]
pub struct Row<'a> {
    pub excel_name: &'a str,
    pub index: &'a HashMap<String, usize>,
    pub row: &'a Vec<String>,
    pub row_number: usize,
}

const ROW_HEADER_SIZE: usize = 5;

impl<'a> Row<'a> {
    pub fn get<T>(&self, name: &str) -> anyhow::Result<T>
        where
            T: Parser,
    {
        let index = self.index.get(name).ok_or(anyhow::anyhow!("{} not found", name))?;
        let value = self.row[*index].as_str();
        T::parse_from(value).context(format!("`{}` failed to parse field `{}` with value `{}` at row `{}`", self.excel_name, name, value, self.row_number + ROW_HEADER_SIZE + 1))
    }
}

在解析每一行数据的时候,直接传入数据的名字就可以拿到这个数据了,而不用关心这条数据在这一行的哪一列,Parser就是根据不同的数据类型,解析逻辑的具体实现,这个放到后面再说。row_number是用来帮助我们在数据解析出错的时候,定位到配置表中哪里的数据有问题的。

Id的定义如下 省略Display impl Into<Id> for u8,u16等的实现

#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)]
pub enum Id {
    U8(u8),
    U16(u16),
    U32(u32),
    U64(u64),
    U128(u128),
    I8(i8),
    I32(i32),
    I64(i64),
    I128(i128),
    Usize(usize),
    Isize(isize),
    String(String),
}

然后定义一个Configs的trait,表示这个配置表的所有数据。

pub trait Configs: Debug + Send + Sync + DynClone + Any {
    fn ids(&self) -> Vec<Id>;

    fn name() -> &'static str where Self: Sized;

    fn load(&mut self, excel_config: &ExcelConfig) -> anyhow::Result<()>;

    #[allow(unused_variables)]
    fn complete(&mut self, mgr: &GameConfigMgr) -> anyhow::Result<()> {
        Ok(())
    }

    #[allow(unused_variables)]
    fn validate(&self, mgr: &GameConfigMgr) -> anyhow::Result<()> {
        Ok(())
    }

    fn as_any(&self) -> &dyn Any;

    fn as_any_mut(&mut self) -> &mut dyn Any;

    fn into_any(self: Box<Self>) -> Box<dyn Any>;
}

dyn_clone::clone_trait_object!(Configs);
  • ids 返回配置表的主键,存到数据库中,如果这些主键不在游戏中存库,可以返回空,目的是为了防止策划意外的删除这些数据,导致线上事故
  • name 配置表名字
  • load 从ExcelConfig数据中解析配置表数据
  • complete 当所有的配置表数据都加载完成之后,提供一个接口重新组织当前配置表的数据结构的机会,例如遍历当前配置表中所有的数据,按id分组,然后存到另外的结构中去
  • validate 配置表校验方法,因为配置表的数据会相互引用id,需要校验配置表引用的数据的合法性

GameConfigMgr 定义如下

#[derive(Debug, Clone)]
pub struct GameConfigMgr {
    pub configs: HashMap<&'static str, Box<dyn Configs>>,
    pub complete_ordering: Vec<&'static str>,
}
  • complete_ordering 如果某些配置表的complete函数会依赖其它配置表执行complete之后的数据,可以在这里指定执行complete时的顺序

配置表解析

配置表数据类型映射到Rust数据类型

定义一个枚举结构,其中包含所有的配置表数据类型,可以直接通过配置表的字符串解析为对应的数据类型枚举,方便我们后面数据的解析。

#[derive(
    Debug,
    Clone,
    Serialize,
    Deserialize,
    Encode,
    Decode,
    strum::EnumString,
    strum::Display,
    strum::EnumIter
)]
#[strum(serialize_all = "snake_case")]
pub enum CellType {
    #[strum(serialize = "uint")]
    UInt,
    Int,
    Long,
    String,
    Bool,
    Vector3ArrayInt,
    #[strum(serialize = "vector[float,float,float]_array_float")]
    Vector3ArrayFloat,
    Vector3Int,
    #[strum(serialize = "vector2_int", serialize = "vector[int,int]")]
    Vector2Int,
    #[strum(serialize = "vector3_uint")]
    Vector3UInt,
    #[strum(serialize = "vector2_uint")]
    Vector2UInt,
    #[strum(serialize = "Vector2_array_int", serialize = "vector2_array_int")]
    Vector2ArrayInt,
    ArrayInt,
    #[strum(serialize = "array_uint")]
    ArrayUInt,
    DictionaryStringFloat,
    DictionaryStringInt,
    Lang,
    Float,
    Double,
    #[strum(serialize = "vector[float,float]")]
    Vector2Float,
    #[strum(serialize = "vector[float,float,float]")]
    Vector3Float,
    #[strum(serialize = "vector[string,string]")]
    Vector2String,
}

在解析完配置表生成对应的rust代码时,我们使用quote这个库进行生成,所以只需要根据枚举的类型,转换成rust基本类型的TokenStream就行了。

impl CellType {
    pub fn token(&self) -> TokenStream {
        match self {
            CellType::UInt => quote!(u32),
            CellType::Int => quote!(i32),
            CellType::Long => quote!(i64),
            CellType::String => quote!(String),
            CellType::Bool => quote!(bool),
            CellType::Vector3ArrayInt => quote!(Vec<(i32,i32,i32)>),
            CellType::Vector3Int => quote!((i32,i32,i32)),
            CellType::Vector2Int => quote!((i32,i32)),
            CellType::Vector3UInt => quote!((u32,u32,u32)),
            CellType::Vector2UInt => quote!((u32,u32)),
            CellType::Vector2ArrayInt => quote!(Vec<(i32,i32)>),
            CellType::ArrayInt => quote!(Vec<i32>),
            CellType::ArrayUInt => quote!(Vec<u32>),
            CellType::DictionaryStringFloat => quote!(ahash::HashMap<String,f32>),
            CellType::DictionaryStringInt => quote!(ahash::HashMap<String,i32>),
            CellType::Lang => quote!(String),
            CellType::Float => quote!(f32),
            CellType::Double => quote!(f64),
            CellType::Vector2Float => quote!((f32,f32)),
            CellType::Vector3Float => quote!((f32,f32,f32)),
            CellType::Vector2String => quote!((String,String)),
            CellType::Vector3ArrayFloat => quote!(Vec<(f32,f32,f32)>),
        }
    }
}

定义配置表作用域枚举

#[derive(
    Debug,
    Clone,
    Serialize,
    Deserialize,
    Encode,
    Decode,
    strum::EnumString,
    strum::Display,
    strum::EnumIter
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
pub enum KeyType {
    #[strum(serialize = "allkey")]
    AllKey,
    All,
    Client,
    #[strum(serialize = "clientkey")]
    ClientKey,
    #[strum(serialize = "server", serialize = "sever")]
    Server,
    #[strum(serialize = "serverkey")]
    ServerKey,
}

服务端进行配置表代码生成的时候,只需要关系AllKey All Server ServerKey这些字段就行了,按需设计,比如可以只设计Id Client All 就行了,Id用来标识这一条数据的主键,为啥没有Server?因为服务端要读的数据一般客户端也要读,没必要单独再开一个。

定义Parser解析配置表数据

pub trait Parser: Sized {
    fn parse_from(value: &str) -> anyhow::Result<Self>;
}

#[macro_export]
macro_rules! default {
    ($value: ident) => {
        if $value.is_empty() || $value == "0" {
            return Ok(Default::default());
        }
    };
}

trait很简单,每个单元格的数据都读取成字符串类型,然后再根据Parser的具体类型解析为具体类型的数据。另外定义了一个default!宏,允许单元格数据为空或者0值的情况,这种情况处理为Parser实现结构的默认值。

解析基本类型

基本类型包括所有的数字类型i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64以及布尔类型bool,由于类型较多,且解析规则一样,因此我们定义一个宏来进行批量解析。

macro_rules! number_parser {
    ($ty: ty) => {
        impl crate::excel::parser::Parser for $ty {
            fn parse_from(value: &str) -> anyhow::Result<Self> {
                default!(value);
                value.parse::<$ty>().map_err(|e| anyhow::anyhow!(e))
            }
        }
    };
    ($($ty: ty)*) => {
        $(number_parser!($ty);)*
    };
}

number_parser!(i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64);

布尔类型单独解析

impl Parser for bool {
    fn parse_from(value: &str) -> anyhow::Result<Self> {
        default!(value);
        match value {
            "true" | "1" => Ok(true),
            "false" | "0" | "" => Ok(false),
            _ => Err(anyhow::anyhow!("Invalid boolean value: {}", value)),
        }
    }
}

解析元组类型(tuple2,tuple3)

impl<T1, T2> Parser for (T1, T2) where
    T1: Parser + Default,
    T2: Parser + Default,
{
    fn parse_from(value: &str) -> anyhow::Result<Self> {
        default!(value);
        let values = value.split(",").collect::<Vec<_>>();
        ensure!(values.len() == 2, "Expected 2 elements, found {}", values.len());
        let t1 = T1::parse_from(values[0])?;
        let t2 = T2::parse_from(values[1])?;
        Ok((t1, t2))
    }
}

impl<T1, T2, T3> Parser for (T1, T2, T3) where
    T1: Parser + Default,
    T2: Parser + Default,
    T3: Parser + Default,
{
    fn parse_from(value: &str) -> anyhow::Result<Self> {
        default!(value);
        let values = value.split(",").collect::<Vec<_>>();
        ensure!(values.len() == 3, "Expected 3 elements, found {}", values.len());
        let t1 = T1::parse_from(values[0])?;
        let t2 = T2::parse_from(values[1])?;
        let t3 = T3::parse_from(values[2])?;
        Ok((t1, t2, t3))
    }
}

解析数组类型

同样的,数组类型会有很多种,我们依然要写宏来处理这种问题,防止写出重复代码。针对不同的数组类型,可能有不同的分隔符,分隔符不能写死。

macro_rules! impl_parser_for_vec {
    ($ty:ty, $pat:expr) => {
        impl crate::excel::parser::Parser for Vec<$ty> where $ty: crate::excel::parser::Parser {
            fn parse_from(value: &str) -> anyhow::Result<Self> {
                crate::default!(value);
                let mut vec = Vec::new();
                for item in value.split($pat) {
                    vec.push(<$ty as crate::excel::parser::Parser>::parse_from(item)?);
                }
                Ok(vec)
            }
        }
    };
    ($($ty:ty)*, $pat:expr) => {
        $(impl_parser_for_vec!($ty, $pat);)*
    };
}

impl_parser_for_vec!(i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 bool, ',');

impl_parser_for_vec!(Asset, ';');

impl_parser_for_vec!(AssetPackage, '|');

impl<T1, T2> Parser for Vec<(T1, T2)> where
    T1: Parser,
    T2: Parser,
{
    fn parse_from(value: &str) -> anyhow::Result<Self> {
        default!(value);
        let mut vec = Vec::new();
        for item in value.split(';') {
            let values = item.split(',').collect::<Vec<_>>();
            ensure!(values.len() == 2, "Expected 2 elements, found {}", values.len());
            let t1 = T1::parse_from(values[0])?;
            let t2 = T2::parse_from(values[1])?;
            vec.push((t1, t2));
        }
        Ok(vec)
    }
}

impl<T1, T2, T3> Parser for Vec<(T1, T2, T3)> where
    T1: Parser,
    T2: Parser,
    T3: Parser,
{
    fn parse_from(value: &str) -> anyhow::Result<Self> {
        default!(value);
        let mut vec = Vec::new();
        for item in value.split(';') {
            let values = item.split(',').collect::<Vec<_>>();
            ensure!(values.len() == 3, "Expected 3 elements, found {}", values.len());
            let t1 = T1::parse_from(values[0])?;
            let t2 = T2::parse_from(values[1])?;
            let t3 = T3::parse_from(values[2])?;
            vec.push((t1, t2, t3));
        }
        Ok(vec)
    }
}

可以看到这种解析方式比较灵活,当我们需要直接把配置表中的数据解析成游戏中对应的数据结构时,只需要对对应的结构实现Parser就好了,例如代码里面的AssetAssetPackage,又或者某个数字其实是代表的枚举,那我们直接解析成枚举就好了。

读取数据

为了同时满足把配置表结构生成到Rust配置表代码以及读取配置表数据的功能,先定义一个中间结构ExcelConfig,这个结构其实就代表了一张表的数据,可以根据namecell_namecell_typecell_key生成对应的rust配置表结构,也可以读取data做配置表解析工作。第一层Vec表示每一行的数据,第二层的Vec表示这一行数据的每一列数据。

这个结构中有一个name_index的方法,其主要的作用是可以直接根据数据的名字找到数据所在的索引,我们在读数据的时候,肯定是用名字最为直观。

#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)]
pub struct ExcelConfig {
    pub name: String,
    pub cell_name: Vec<String>,
    pub cell_type: Vec<CellType>,
    pub cell_key: Vec<KeyType>,
    pub data: Vec<Vec<String>>,
}

impl ExcelConfig {
    pub fn name_index(&self) -> HashMap<String, usize> {
        self.cell_name.iter().enumerate().map(|(i, name)| (name.clone(), i)).collect()
    }
}

关键字处理

由于配置表的字段可能和Rust的语言关键字冲突,所以当配置表的字段是Rust中的关键字时,需要在字段名前面加上r#,下面列出了Rust中的关键字,在生成Rust配置表结构的时候,需要遍历进行判断。

const KEYWORDS: [&str; 51] = ["as", "break", "const", "continue", "crate", "else", "enum", "extern",
    "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
    "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe",
    "use", "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
    "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try"];

读取excel

读取excel数据用到库是calamine,按照顺序来就好了,比较简单,如果只是生成配置表对应的Rust数据结构,可以不读data,只读表头就可以了,加快速度。

pub fn read_excel_config<P: AsRef<Path>>(path: P) -> anyhow::Result<ExcelConfig> {
    let path = path.as_ref();
    let display_path = path.display();
    info!("read: {}", display_path);
    let mut workbook: Xlsx<_> = open_workbook(path).context(format!("open excel: {} failed", display_path))?;
    let (sheet_name, data) = &workbook.worksheets()[0];
    let mut cell_name = vec![];
    let mut cell_type = vec![];
    let mut cell_key = vec![];
    let mut excel_data = vec![];
    for (i, row) in data.rows().enumerate() {
        let mut row_data = vec![];
        for data_type in row.iter() {
            match i {
                0 => {
                    match data_type {
                        Data::String(data) => {
                            //convert non snake case cell name to snake case
                            let data = prefix_if_starts_with_digit(data.trim()).to_case(Case::Snake);
                            cell_name.push(data);
                        }
                        other => {
                            return Err(anyhow!("excel {} `string` expected, got: `{}` at row {}", sheet_name, other, i + 1));
                        }
                    }
                }
                1 => {
                    match data_type {
                        Data::String(data) => {
                            cell_type.push(CellType::from_str(data.trim()).context(format!("convert string {} to enum CellType error", data))?);
                        }
                        other => {
                            return Err(anyhow!("excel {} `string` expected, got: `{}` at row {}", sheet_name, other, i + 1));
                        }
                    }
                }
                2 => {
                    match data_type {
                        Data::String(data) => {
                            let key_type = KeyType::from_str(data.trim()).unwrap_or(KeyType::Client);
                            cell_key.push(key_type);
                        }
                        other => {
                            return Err(anyhow!("excel {} `string` expected, got: `{}` at row {}", sheet_name, other, i + 1));
                        }
                    }
                }
                3 | 4 => {}
                _ => {
                    row_data.push(data_type.to_string().trim().to_string());
                }
            }
        }
        if i >= 5 {
            excel_data.push(row_data);
        }
    }
    let config = ExcelConfig {
        name: prefix_if_starts_with_digit(sheet_name).to_case(Case::Snake),
        cell_name,
        cell_type,
        cell_key,
        data: excel_data,
    };
    Ok(config)
}

配置表生成 序列化 反序列化

通过以上步骤,我们可以得到名为ExcelConfig结构的配置表数据,这个不是游戏业务中使用的配置表数据的最终形式,还需要解析此结构,得到GameConfigMgr,此时GameConfigMgr中已经是解析好的数据了,但是此时的配置表是以HashMap的形式存在的,在获取到配置表后,还需要向下转型为具体的类型才可以使用,比较麻烦,所以我们再引入一个配置表结构,叫做GameConfigs,把GameConfigMgr中的配置表数据直接拆成字段放到GameConfigs里面,这样引用配置表就方便了。

#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)]
pub struct GameConfigs {
    pub achievement_configs: Box<AchievementConfigs>,
    pub achievement_frame_configs: Box<AchievementFrameConfigs>,
    pub achievement_point_configs: Box<AchievementPointConfigs>,
    ...//省略若干字段
}

需要注意的是,由于配置表非常的多,这个结构的字段会非常的多,所以数据需要进行堆分配(用Box包裹)避免爆栈。生成以上数据结构之后,我们使用bincode将此结构序列化成二进制文件,供游戏启动时加载或者热更新时替换。

至此,将策划配置的excel配置表数据解析成可供程序使用的数据结构就完成了。

将配置表生成对应的Rust结构体的逻辑以及如何将ExcelConfig经过一系列步骤生成GameConfigs,另外开一篇文章进行讲解,代码比较多,这部分涉及到大量的样板代码生成,会使用到syn以及quote