用Rust写一个极简的数据库【2】
2023.02.06 21:41
用户系统和网络通信
开篇
我在写这篇文章时已经把这个数据库的内容都完成了,但是因为内容太长了,我只能再分成两部分来写。仅博客将这两个部分分开发布。
- Part2-3:简单的用户系统 和 网络通信及远程数据库
- Part4:REPL实现
首先,我检讨一下,我在写代码的过程中犯过很多很愚蠢的错误,基本上你去看我在github的commit记录都能看到。很多地方是反复的改来改去,而到后面这个项目开始变得复杂,可能会有一些地方仍有很愚蠢的做法(错误)没有被我发现,如果你有发现,非常欢迎直接提出。
你当然可以去看ROR Key-Value Database的原项目仓库(文末),但是由于针对使用场景等原因,它的内容与example内的会有出入,例如某些地方有简化,某些地方有更改,甚至删除,这可能是在本文发布后我才做的。我会尽可能想象不同的使用情况,以及提出你可以如何针对使用场景改进;)至少你可以清楚你在做什么,以及思考如何改进你的数据库。
开始前请掌握:
- 基础的计算机知识
- Rust编程语言基础
尽管这是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,所以你可以改成这两种做法:
- 定义一个读取错误的错误类型,在这种情况下,默认的Config会被包在这个错误类型被返回,这样更高级的程序就能得知,程序在读取配置时出现错误,然后返回了默认配置。(当然你也可以再写,携带错误信息一起返回)
- 在无法获取配置时直接返回错误。这种做法很粗暴,但是至少用户能知道【我的程序无法获取配置文件】,其实就是不经过第二个代码块修改的做法。
注册
因为json文件的语法,我们无法在注册用户时直接将内容追加到文件末尾,而是必须读取一次json文件并解析,追加内容后再序列化成json文件。(我已经STFW了,原谅我真的Google不出更好的办法)所以我们面临两个选择:
- 保持一个全局可更改的json字符串变量,在追加内容的同时基于它的内容而不是读取一次文件,这个做法避免了每次操作时都要读取一次文件并反序列化,但是它会面临一问题,就是从初始化开始,这坨有可能很巨大的json就霸着内存,并且直到程序结束才会释放,这简直太糟糕了!
- 每次注册时读取文件-反序列化-追加内容-序列化-写入。这是我选择的做法。虽然它会拖慢进行注册的效率,但是读取的东西可以在该操作结束后及时释放,只有在调用的极短的时间会占用内存。我相信一般情况注册操作不会很频繁,所以这种做法在这种情况会更好。
注册前我们应该检查内容的合法性,所以我对用户信息做了这些限制:
- 密码的长度应该介于4-16之间,且只能包含大小写字母和数字,其他会被视为非法字符。
- 用户名长度应该介于2-20之间(不含2和20),虽然理论上它兼容中文字符,但是仍然不建议用大小写字母和数字以外的字符作为用户名。
- 用户权限等级为0-3。
当用户信息不符合上述条件时会返回错误,所以我们就直接定义错误类型,分别是未知的用户等级、密码格式错误、名字长度不正确以及用户数量超出限制。另外我们一起把解析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将把网络模块完成。
首先,最终这个程序,你可以把它当成服务器/客户端/本地数据库启动:
- 服务器是没有接受输入的,直接创建一个循环,处理请求且直接操作两个基础模块。
- 客户端模式下,会有一个RemoteRepl循环处理用户的操作,并且有一个负责与服务端通信的Client部分与其进行交互。
- 本地数据库模式下,Repl模块循环处理用户操作并直接对数据库模块进行操作。
最终我们将会完成一个能直接选择子命令并处理参数,然后启动的数据库。