用Rust写一个极简的数据库【2】

2023.02.06 21:41


用户系统和网络通信

 

开篇

  我在写这篇文章时已经把这个数据库的内容都完成了,但是因为内容太长了,我只能再分成两部分来写。仅博客将这两个部分分开发布。

  首先,我检讨一下,我在写代码的过程中犯过很多很愚蠢的错误,基本上你去看我在github的commit记录都能看到。很多地方是反复的改来改去,而到后面这个项目开始变得复杂,可能会有一些地方仍有很愚蠢的做法(错误)没有被我发现,如果你有发现,非常欢迎直接提出。
  你当然可以去看ROR Key-Value Database的原项目仓库(文末),但是由于针对使用场景等原因,它的内容与example内的会有出入,例如某些地方有简化,某些地方有更改,甚至删除,这可能是在本文发布后我才做的。我会尽可能想象不同的使用情况,以及提出你可以如何针对使用场景改进;)至少你可以清楚你在做什么,以及思考如何改进你的数据库。

  开始前请掌握:

  尽管这是Part2,你也可以在完全对数据库不了解,完全没有看过Part1的情况下完成(只需要从Github下载Part1的进度就好了)因为我们已经在上一篇完成了数据存储的模块,关于数据存储的细节已经不需要了解了,我们本篇只需要考虑Part2的内容。

 

漏洞和一些改变

 

compact漏洞

  首先,我在写代码时发现在Part1的compact有一个很严重的漏洞,我已经将它修改了。
  当冗余数据被compact后,索引哈希表的偏移量并不会因此更新,例如在这种情况下(size是我瞎编的):

DataFile Invalid size(Bytes)
add("name","Aaron") true 24
add("name","Makiror") false 30

  在哈希表中,key【name】所对应的偏移量是24,但执行compact后这个表会变成:

DataFile Invalid size(Bytes)
add("name","Makiror") false 30

  在这个时候,索引哈希表中key【name】所对应的偏移量仍然是24,但实际上应该是0,这就导致了,如果再进行一次compact操作,因为程序会根据偏移量检查“一个Add命令是否是最新的”,就导致这个数据被当成旧的add命令被清除。所以我们需要在compact时重新建立一个索引哈希表,以下是更改内容:

 

    pub fn compact(&mut self) -> Result<()> {
        ...
        let mut new_hashmap: HashMap<String, u64> = HashMap::new();
        loop {
            ...
                    if let Some(pos) = self.index.get(&entry.key) {
                        if entry.meta.command == Command::Add && *pos == offset {
                            new_hashmap.insert(entry.key.clone(),new_position);
                            ...
                        }
                    }
            ...
        }
        ...
        self.index = new_hashmap;
        Ok(())
    }

 

 

模块式

  我们在Part2将改变项目的结构,彻底的改变,在Part1中的KV会变成其中一个模块。在改变后我们的项目将有两个基础的模块,以及服务器模块和REPL模块(后面会详细介绍)
  作为最基础的存储模块(Store)和用户模块(User),它们没有对本项目其他模块的依赖,是完全可以被单独抽离出来使用的,而不是仅仅在这个项目的这个程序。我一开始就希望如此的,而你完全可以将存储模块/用户模块抽出来,用在自己的项目。
  既然如此,我就除了修Bug之外,没有再为了其他模块而改变存储模块的功能。但是我将部分函数的参数从String改为了&str,这个改动可能在Server模块等地方会带来更多麻烦,例如多次不必要的类型转换,请理解我这个行为,因为我希望的是存储部分可以被更方便地单独抽离出来用于开发,我考虑的不仅是这个小项目。我相信你不希望看到这样的代码,更不希望使用它:

 

kv.add("name".to_string(),"makiror".to_string())?;

  相比这个,我无非是在其他模块多写几个&*.as_str(),又算什么呢?当然,我还是尽可能在这基础上避免不必要的转换。

 

我们要做什么?

  让我们先来简略的思考一下接下来我们要做什么。

 

项目结构

  我们要完成一个既可以作为数据库服务端/客户端,也能在本地进行数据操作的程序,所以我们将这三种情况分为三个模式,它们都需要使用最底层的用户系统和存储模块,然后画一个简单的结构图:



  Server模式下,程序只需要处理请求并输出日志,而在Local模式和Client模式则需要获取用户输入来进行对应的操作,我选择的方式是REPL(Read Eval Print Loop)的方式来接受用户操作,而Local只需要直接操作本地文件,而Client只需要向服务端发送操作请求,并且通过服务端的回应判断操作是否成功。所以我们分别为Local和Client写一个REPL模块,进一步抽象命令处理的过程。经更改后这个结构图变成了这样:





  画出了这个简略的结构图,至少知道我们要做什么了。

 

需要考虑的问题

  用户系统?这个词会让你想到什么,数据库吗?但是我们正在写数据库啊喂。所以我们只需要用最原始的方式做一个用户系统。
  首先,我就着KISS(Keep It Simple, Stupid )。因为我使用的方法是最简单的json文件,单纯的把用户信息写进去取出来。

 

用户模块

 

修改目录结构

  在src下创建一个目录"store",并将这些文件改名并放进去:

 

src/kv.rs => src/store/kv.rs
src/error.rs => src/store/kv_error.rs

  创建一个mod文件。

 

// src/store/mod.rs

pub mod kv_error;
pub mod kv;

  这就是我们的存储模块了,然后我们就能在lib文件引用它了。

 

src/lib.rs

...
mod store;

  另外,我们只有一个启动的rs文件就是main,所以可以把它直接移动到src下,而不是src/bin目录里面,然后这个目录就可以不要了。

 

用户信息及存储方式

  一个简单的数据库并不需要一个复杂的用户系统,所以我们不用考虑的太复杂。首先,用户名必须是唯一的,因为用户在登陆时输入的是用户名和密码,其次,每个用户都有一个等级,这个等级决定了,它登陆后能做什么操作(详细见原项目README)。我这里写的只是一个例子,你可以根据自己的喜好修改。然后我们就会中本节完成一个可以独立存在的用户系统,它不仅仅可以用于数据库。
  明文存储用户的密码是不安全的,所以我们要选择一个方式将密码加密存储,这里我选的是最简单的Base64。
  然后为用户结构体实现Deserialize和Serialize。因为我决定让用户数据被序列化成json存储在某个文件,然后需要的时候再对它进行反序列化并处理。虽然用json并不是很高效,但是用户的操作并不是非常频繁的,所以相比高效,这里尽可能简单会更好。不要忘记实现Clone,因为有些时候我们要复制用户信息到别的地方。

 

// src/user/user.rs

use serde::{Serialize,Deserialize};

#[derive(Deserialize,Serialize,Clone)]
pub struct User {
    name: String,
    password: String,
    level: String,
}

 

 

用户配置与TOML

  在这之前我有为这个数据库做过一个uid生成机制(雪花算法),但发现它并没有存在的必要就删掉了。但是不要紧,我会将它作为单独的短文发布,所以让我们开始轻松地编写用户模块。

 

处理配置文件

  我希望用户可以自己决定数据库的用户存在哪里、用户量的上限等。我喜欢TOML干净简单的语法,仅此而已,所以我选择toml文件来作为数据库的配置文件。
  首先在项目根目录建立一个文件作为用户配置文件,它的相对路径被直接写在源代码。

 

# config/user.toml

users_path = "users.json"
user_max = 50

  这个路径是相对于项目根目录的。并且这里有个小细节:注释路径的第一行变成了#,这是toml文件的路径。所以该代码块的内容完全复制过去也是合法的。
  我们使用库toml来解析toml文件,因为它会用到serde的特征,我们使用json文件存储用户会用到serde-json,serde已经在Part1用过了,所以我们只需要加入这两个:

 

# Cargo.toml

[dependencies]
serde_json = "1.0.91"
toml = "0.5.10"

  我们直接在用户系统的代码文件定义私有结构体Config,并且编写一个获取配置的函数。它的逻辑很简单,读取文件,转换成String,解析toml成Config结构体。很多解析的细节已经被toml库抽象了,我们只管用就好了:

 

// src/user/user.rs

#[derive(Deserialize)]
struct Config {
    path: String,
    user_max: u16,
}

impl Config {
    fn get_config() -> Result<Self> {
        let mut file = match File::open("config/users.toml")?;
        let mut c = String::new();
        file.read_to_string(&mut c)?;
        let config: Config = toml::from_str(c.as_str())?;
        Ok(config)
    }
}

  我们需要为Config实现Deserialize,因为它需要被库解析。
  如果读取不到配置文件就返回错误,我不希望得到这样的结果,所以,在无法正确的到配置文件的内容时,它会返回默认的配置:

 

// src/user/user.rs

impl Config {
    fn default() -> Self {
        Config {
            path: "users.json".to_string(),
            user_max: 50,
        }
    }
    fn get_config() -> Result<Self> {
        let mut file = match File::open("config/users.toml") {
            Ok(f) => f,
            Err(_) => return Ok(Self::default()),
        };
        ...
    }
}

  另外,我们要在错误处理的文件定义这期间可能会出现的错误(读取文件发生的错误和解析TOML发生的错误):

 

// src/user/user_error.rs

#[derive(Error, Debug)]
pub enum UserError {
    #[error("IO error: {0}")]
    IOError(#[from] std::io::Error),
    #[error("{0}")]
    TomlDeError(#[from] toml::de::Error),
}

 

 

懒加载与静态变量

  我们写完上面的函数,其实就可以直接在操作时获取配置参数了。但是我并不乐意这样:每次进行任何会涉及到读取/验证的操作都要调用一次get_config(),也就是将配置文件读一遍并解析,虽然这样做的话,你可以在不重启程序的情况下更改配置文件并生效,但是如果每次都这样扫的话简直太浪费了,所以我换了一种做法。
  我们需要一个全局变量,它获取值后从头到尾都不会改变,调用一次就够了。这种时候你应该会想到常量/静态变量,但是很可惜!它们必须是在编译期就能计算的值,也就是它只能是常量表达式/数学表达式,所以我们不能通过调用函数定义它。我们需要在程序运行期获取这个值,而不是编译期。
  于是我们就需要用到lazy_static了,它是一个非常强大的宏,我们可以用它来惰性地初始化静态变量。虽然它在我们访问这个静态变量时会带来极小的性能损失,但是相比使用上面的那种方法,这个损失又算得上什么?于是我们添加这样的代码:

 

# Cargo.toml

[dependencies]
lazy_static = "1.4.0"

 

// src/user/user.rs

use lazy_static::lazy_static;

lazy_static! {
    static ref USER_PATH: String = Config::get_config().path;
    static ref USER_MAX: u16 = Config::get_config().user_max;
}

  因为它是惰性的,所以,程序在你第一次使用到这个变量才会对其进行初始化。

 

如何改进?

  你可能不希望读取配置会在出现错误时,毫无提示的使用default,所以你可以改成这两种做法:

 

 

注册

  因为json文件的语法,我们无法在注册用户时直接将内容追加到文件末尾,而是必须读取一次json文件并解析,追加内容后再序列化成json文件。(我已经STFW了,原谅我真的Google不出更好的办法)所以我们面临两个选择:

  注册前我们应该检查内容的合法性,所以我对用户信息做了这些限制:

  当用户信息不符合上述条件时会返回错误,所以我们就直接定义错误类型,分别是未知的用户等级、密码格式错误、名字长度不正确以及用户数量超出限制。另外我们一起把解析json会遇到的错误一起定义了。

 

// src/user/user_error.rs

#[derive(Error, Debug)]
pub enum UserError {
    #[error("Unknown user level '{0}'")]
    UnknownLevel(String),
    #[error("incorrect password format '{0}'")]
    PassWordFormatError(String),
    #[error("User name length is {0}, the length of the name should be between 2-20")]
    NameLengthError(usize),
    #[error("The number of users exceeds the limit")]
    UserLimit,
    #[error("{0}")]
    SerdeJsonError(#[from] serde_json::Error),
}

  验证名字的长度很简单,重点在于密码。我们需要用正则表达式匹配合法的密码,不过这很简单,因为Rust已经有一个名为regex的正则表达式库了。加入regex依赖,我们只需要用到std功能。

 

# Cargo.toml

[dependencies]
regex = { version = "1.3.1", default-features = false, features = ["std"] }

  然后开始写注册函数,验证部分的代码,此处包含密码的正则表达式验证以及名字长度验证。

 

// src/user/user.rs

use regex::Regex;

impl User {
    pub fn register( name: &str, password: &str, level: &str ) -> Result<()> {
        let password_regex = Regex::new(r"^[a-zA-Z0-9_-]{4,16}$")?;
        if !password_regex.is_match(&password) {
            return Err(UserError::PassWordFormatError(password.to_string()));
        }
        let name_len = name.chars().count();
        if name_len < 2 || name_len > 20 {
            return Err(UserError::NameLengthError(name_len));
        }
    }
}

  关注到正则表达式,即只能包含大小写字母和数字,长度4-16。

 

^[a-zA-Z0-9_-]{4,16}$

  然后再检查用户等级是否正确:

 

// src/user/user.rs

        let mut user = match level {
            "0" | "1" | "2" | "3" => User {
                name: name.to_string(),
                password: password.to_string(),
                level: level.to_string(),
            },
            _ => return Err(UserError::UnknownLevel(level.to_string())),
        };

  如果你输入的用户信息是合法的,那就继续,首先我们分别clone一份用户配置的路径和用户最大值。然后读取用户文件内容并解析。完成上述操作后,再检查用户数是否超过限制,但是,如果用户设置的数为0则代表没有用户数量限制。如果用户数量已经超出限制则返回错误:

 

// src/user/user.rs

        let config_path = USER_PATH.clone();
        let config_max = *USER_MAX;
        let original = fs::read_to_string(&config_path)?;
        let mut data: Vec<User> = serde_json::from_str(&original)?;
        if data.len() > config_max.into() && config_max != 0 {
            return Err(UserError::UserLimit);
        }

  你可能注意到了,我没有写“如果文件不存在则自动创建”的代码,而是直接读取文件,如果出错就直接返回。这是我在写文章时才注意到修改的,因为如果在程序运行时用户文件被删或者出各种问题,直接创建总感觉不太好,而且如果不是故意操作令文件出错的,那用户可能无法得知该问题。但是,我们还可以写一个函数,即检查用户文件是否存在,如果不存在就创建。该函数可以在程序启动时调用一次,这样保证了在一般情况单纯的文件不存在,就能直接创建,如果因为其他原因创建不了,在程序开始时就能得知。
  这个函数可以被服务端模块调用,如果文件存在就直接返回Ok(()),如果文件不存在就尝试创建,创建成功了仍然是返回Ok(()),失败则返回错误。另外,在创建文件时写入[],这是因为json语法,不然会解析失败。

 

// src/user/user.rs

impl User {
    ...
    pub fn test_file() -> Result<()> {
        let config_path = USER_PATH.clone();
        let path_slice = Path::new(&config_path);
        if !path_slice.exists() {
            let mut f = File::create(&config_path)?;
            write!(f, "{}", "[]")?;
            return Ok(());
        }
        Ok(())
    }
}

  然后就检查是否已经有相同名称的用户,这个搜索用户的动作在login功能也会用到,所以我们为它专门写一个函数。在改进前,该函数我写的是直接读取文件-解析-遍历,但是因为前面我们已经读取解析了一次文件,还不如直接把已经处理好的数据作为参数,它只负责遍历并返回结果。(该函数主要是为login写的,但是为了迁就register就变成了要原有数据作为参数)

 

// src/user/user.rs

    fn search(data: &Vec<User>, name: String) -> Result<Self> {
        for u in data {
            if u.name == name {
                let mut user = u.clone();
                user.decode()?; //functions not yet written
                return Ok(user);
            }
        }
        Err(UserError::UserNotFound(name))
    }

  如果搜索到同名用户则返回错误:

 

// src/user/user.rs

        if let Ok(_) = Self::search(&data,user.name.clone()) {
            return Err(UserError::UserNameExists(user.name));
        }

  直接将用户的密码明文存储在文件是很危险的,所以我们用一个简单的算法加密一下再写进去,自然,我们分别需要一个将用户密码加密和解密的函数。这里我选择使用base64,这里不讲算法细节了,直接调用一个简单的库就好了。我直接将user作为参数(它是可变的),然后进行加密/解密即可。

 

# Cargo.toml

[dependencies]
base64 = "0.21.0"

  定义加密/解密时会出现的错误类型,并编写加密解密的函数

 

// src/user/user_error.rs

use std::str::Utf8Error;
...
    #[error("{0}")]
    Base64Error(#[from] base64::DecodeError),
    #[error("{0}")]
    DecodeUtf8Error(#[from] Utf8Error),

 

// src/user/user.rs

use base64::{Engine as _, engine::general_purpose};

impl User {
    ...
    fn encode(&mut self) {
        self.password = general_purpose::STANDARD_NO_PAD.encode(self.password.clone());
    }
    fn decode(&mut self) -> Result<()> {
        let bytes = general_purpose::STANDARD_NO_PAD.decode(self.password.clone())?;
        self.password = std::str::from_utf8(&bytes)?.to_string();
        Ok(())
    }
}

  在注册时将加密过的user加入数组,一起序列化,覆盖写入文件。虽然这里使用create看似会【自动创建文件】,但是因为前面已经读取过文件了,所以如果程序能走到这一步且没有返回错误,那你不用担心它,它不存在的话早就报错了。完成后返回Ok(()),注册功能就完成了。

 

// src/user/user.rs

        user.encode();
        data.push(user);
        let json = serde_json::to_string(&data)?;
        let mut file = File::create(&config_path)?;
        write!(file, "{}", json)?;
        Ok(())

 

 

登录

  我们前面已经完成了用户系统的大部分代码,所以登录功能很简单。
  登录无非是这几种结果:成功/密码错误/用户不存在/程序出错,我选择把后三者都定义为“错误”,而在成功的情况,登录函数会返回一个User结构体。

 

// src/user/user_error.rs

#[derive(Error, Debug)]
pub enum UserError {
    #[error("User '{0}' not found")]
    UserNotFound(String),
    #[error("Wrong user password")]
    WrongPassWord,
}

  我们只需要在错误类型中定义密码错误和用户不存在的情况,在其他程序出错时函数会直接返回对应的错误,而在网络模块我们会对该错误进行统一处理。
  然后直接编写登录函数,我们只需要读取文件并解析,然后调用search函数获取结果并验证密码即可:

 

// src/user/user.rs

    pub fn login( name: String, password: String ) -> Result<Self> {
        let config_path = USER_PATH.clone();
        let str_data = fs::read_to_string(&config_path)?;
        let data: Vec<User> = serde_json::from_str(&str_data)?;
        let user = match Self::search(&data,name) {
            Ok(user) => user,
            Err(e) => return Err(e),
        };
        if password == user.password {
            return Ok(user);
        } else {
            return Err(UserError::WrongPassWord);
        }
    }

 

如何改进?

  在登录功能中我们将用户不存在和密码错误的情况归类为“错误”,但是你可以换一种思路,例如定义一个枚举类表示验证结果,而错误仅包含程序意外出错的情况。

 

mod

  创建mod.rs文件,注意snowflake是不公开的。

 

// src/user/mod.rs

pub mod user_error;
pub mod user;

 

 

为网络模块做准备

 

错误类型

  不出乎意料的话,现在你的目录结构应该长这样:

 

./
├── Cargo.lock
├── Cargo.toml
├── config
│   └── user.toml
└── src
    ├── main.rs
    ├── store
    │   ├── kv_error.rs
    │   ├── kv.rs
    │   └── mod.rs
    └── user
        ├── mod.rs
        ├── user_error.rs
        └── user.rs

  而main是空空如也的,直到REPL我们才会用到。两个模块下分别有两个错误枚举类,我们将在error.rs中把它们分别定义成两个错误类型,首先在lib.rs中引入它们:

 

// src/lib.rs

mod store;
mod user;
mod error;

  然后在error中定义它们两个:

 

// src/error.rs

use thiserror::Error;
use super::{
    store::kv_error::KvError,
    user::user_error::UserError,
};

#[derive(Error, Debug)]
pub enum RorError {
    #[error("Error from storage module :{0}")]
    KvError(#[from] KvError),
    #[error("Error from user module :{0}")]
    UserError(#[from] UserError),
}

pub type Result<T> = std::result::Result<T, RorError>;

  这样做是因为,接下来的部分(server和client)会直接用到这两个模块的功能,这两个模块有各自的错误类型,所以在进行错误处理时需要把它们都归于RorError下进行统一处理。

 

Next?

  我们下一Part将把网络模块完成。
  首先,最终这个程序,你可以把它当成服务器/客户端/本地数据库启动:

  最终我们将会完成一个能直接选择子命令并处理参数,然后启动的数据库。

Posted by: Anqiao

Contact information: [email protected]