使用 Rust 实现自己的评论系统
最近发现博客的评论系统后端有点问题,占用内存过于的大了(基于 Node.js + PostgreSQL,RES 内存占用 200MB+55MB),这不正常。而且我在国内的唯一一个服务器只有 2G 内存,很容易就被干满了。所以决定使用一个编译型语言重写一下。
最开始选的其实是 Golang,因为被网上传的 Rust 入门太难吓住了😂,试了一下发现 Golang 的 ORM 实在是太难用了,而且我非常讨厌 Golang 的语法,所以还是决定用 Rust 了。
挑选各种 crate
不得不说,Rust 确实有被吹的资格,在编译型无 GC 语言中,cargo这个包管理感觉能排进第一梯队。
另外,Rust 的很大基本功能也是在 crate 中实现,例如 异步运行时、随机数、加密、数据库等,都没有一个官方的实现,而是由社区维护的 crate 提供。
这么做可以说是有优点也有缺点,优点是社区可以更快的迭代,缺点是可能产生很多重复的工作,以及各个库之间需要手动糊在一起,不能统一接口。
最终我选择了以下 crate:
-
异步运行时:
tokio
这个没什么好说的,就俩选择,就挑了一个感觉更火的。
-
数据库:
sqlx
干简单活嘛,SQL 一共就没几句,用这个就足够了,不需要 ORM。但这个库有点坑,后面提到。
-
序列号:
serde
这个也没什么好说的,基本上以经成为既定标准了。
-
HTTP 服务器:
axum
据说这个与
tokio
配合的很好,而且写起来符合我的习惯(或者说很像express
)。 -
模板引擎:
tera
据说用的是
jinja2
的语法,很出名。
嘛, 主要就这些了,后面一些小工具陆陆续续加了点,提到的话再说。
一点小坑
由于是第一次写 Rust,很多东西都是边学边用,所以遇到了不少坑,这里简单记录一下。
sqlx
的坑
首先来拷打这个库,这个库的文档写地有点不清不楚,对 sqlx::query!
的在线模式的描述不够清楚,导致调试运行没问题,release 编译不通过。。。
在线模式指的是,在项目文件夹创建一个 .env
文件,里面配置 DATABASE_URL
环境变量,sqlx::query!
会读取这个环境变量,以提供实时的 SQL 语句检查和类型生成。这个 DATABASE_URL
的格式是 sqlite:C:/path/to/db.sqlite
。
然而,这个 .env
文件不能添加到 git 里!这会导致 CI 里编译不通过!因为 CI 里并没有调试用的数据库,而如果想要生成这个数据库需要 cargo install sqlx-cli
,编译一次需要十几分钟。。。
后来,在一个文档的拐角找的了:运行 cargo sqlx prepare
会生成一个 .sqlx
文件夹,把这个文件夹加到 git 里,在 CI 里编译的时候,sqlx 会读取这个文件夹里的配置以生成类型。
此外,SqliteConnectOptions::new().filename(database_path)
中的 database_path
并不是上面的 DATABASE_URL
那样的格式,而是数据库文件的路径 C:/path/to/db.sqlite
或相对路径 ./db.sqlite
。试了半天才发现这个坑。
tera
的坑
我想要实现最终只有一个可执行程序,不需要别的资源文件,因此必须把模板文件编译进编译产物里。tera
的文档里没有内设这个功能,但是可以提供 include_dir!
这个宏配合 tera
的手动加载模板的功能实现。类似这样:
#[cfg(not(debug_assertions))]
static EMBED_TEMPLATE: include_dir::Dir = include_dir!("templates");
#[cfg(not(debug_assertions))]
pub fn make_template() -> Tera {
let mut tera = Tera::default();
for file in EMBED_TEMPLATE.files() {
let name = match file.path().to_str() {
Some(name) => name,
None => continue,
};
let content = match file.contents_utf8() {
Some(content) => content,
None => continue,
};
if let Err(_) = tera.add_raw_template(name, content) {
std::process::exit(1);
}
}
tera
}
#[cfg(debug_assertions)]
pub fn make_template() -> Tera {
Tera::new("templates/**/*").expect("Failed to load templates")
}
通过 cfg
宏来判断是否在 debug 模式下,如果是则从文件夹加载模板,否则从编译产物加载模板。兼具了开发和生产的需求。
嵌入静态资源
嵌入静态资源其实和嵌入模板差不多,只是需要让 axum
和 include_dir
配合。通过一个 /static/{*path}
路由来处理静态资源:
static EMBED_STATIC: include_dir::Dir = include_dir!("static");
pub async fn static_path(Path(path): Path<String>) -> impl IntoResponse {
let path = path.trim_start_matches('/');
let mime_type = mime_guess::from_path(path).first_or_text_plain();
match EMBED_STATIC.get_file(path) {
None => (StatusCode::NOT_FOUND, "Not Found").into_response(),
Some(file) => {
let body = file.contents();
let mut header = HeaderMap::new();
if let Ok(mine_type) = mime_type.to_string().parse() {
header.insert(http::header::CONTENT_TYPE, mine_type);
}
#[cfg(not(debug_assertions))]
if let Ok(cache_control) = "public, max-age=86400".parse() {
header.insert(http::header::CACHE_CONTROL, cache_control);
}
(StatusCode::OK, header, body).into_response()
}
}
}
管理后台的身份认证
管理后台的身份认证我选择使用 HTTP Basic Auth
,但是没找到合适的中间件,所幸这个功能并不复杂,也就自己试着写了一个,核心思想就是用 axum
提供的 axum::middleware::from_fn_with_state
简化编写中间件的复杂度,和写 header 其实差不多。
#[derive(Clone)]
pub struct BasicAuthorize {
name: String,
password: String,
}
impl BasicAuthorize {
pub fn new(name: String, password: String) -> BasicAuthorize {
BasicAuthorize { name, password }
}
pub async fn handler(
State(state): State<BasicAuthorize>,
request: Request,
next: Next,
) -> impl IntoResponse {
let auth = request.headers().get("authorization");
if let Some(token) = auth {
if let Ok(token) = token.to_str() {
if token.starts_with("Basic ") {
let token = token.trim_start_matches("Basic ");
if let Ok(token) = base64::prelude::BASE64_STANDARD.decode(token.as_bytes()) {
if let Ok(token) = String::from_utf8(token) {
let parts: Vec<&str> = token.splitn(2, ':').collect();
if parts.len() == 2 {
if parts[0] == state.name && parts[1] == state.password {
return next.run(request).await;
}
}
}
}
}
}
}
let mut headers = HeaderMap::new();
headers.insert("WWW-Authenticate", "Basic".parse().unwrap());
(http::StatusCode::UNAUTHORIZED, headers, "Unauthorized").into_response()
}
}
然后和通过 axum::middleware::from_fn_with_state
注册这个中间件:
let auth = auth::BasicAuthorize::new(admin_username, admin_password);
let private_api = Router::new()
.nest("/api/admin", admin::admin_routes())
.layer(axum::middleware::from_fn_with_state(auth.clone(), auth::BasicAuthorize::handler));
还是挺直观的。
总结
首先讲讲个人感受,一句话:Rust 的学习难度不过如此嘛~。从0开始写一个简单的服务我只用了 2 天(业余时间)。
可以看出,Rust 确实融合了很多语言的特性,但这都不是事,因为:
- Rust 的所有权说的复杂,其实和 C++ 的智能指针差不多,只是 Rust 是强制性的。由于之前写过 V8 的拓展,对 RAII 也有些熟悉,所以这个没什么难度,只是需要适应思维的转变。
- Rust 的异步和 Kotlin 的协程差不多,看两眼就猜的出来。恰巧之前大量用过 Kotlin Coroutine,这一点基本没废什么时间。
- Rust 的模式匹配和函数式编程,但我写过 Haskell,满满的熟悉感。
- Rust 的
impl
和trait
说实话让我想到了 Kotlin 的 Extension Function,只是 Rust 用trait
替代了interface
。此外这和 Golang 的interface
也有点像。我本身也是不太喜欢完全的 OOP,所以 Rust 的这种设计我还挺喜欢的。
总的来说,这次写这个服务还是挺顺利的,Rust 的文档和社区都很好,遇到问题基本上都能在网上找到答案。但是 Rust 的生态还是不够完善,很多库都是半成品,需要自己去糊在一起,这一点和 Node.js 的生态差距还是很大的。
通过这次对 comment-server 的重构,内存占用从 200MB+55MB 降到了 10MB,可以说是非常成功了。而且 Rust 的编译产物也非常小,只有 17MB (在内嵌模板和静态资源的情况下)。
偏点题,评论系统的前端使用
React
+Material-UI
。管理面板使用前面提到的Tera
模板渲染,使用TailwindCSS
+daisyui
。以前一直觉得 React 更好,这次发现拿模板渲染真的快,4 个页面一小时就写好了。
通知系统用的是 TelegramBot ,简单方便。后续计划实现基于 Email 的回复通知。
【完】感谢阅读!