前言
本文介绍如何使用Actix-web和MongoDB构建简单博客网站。其中Actix-web 是一个高效的 HTTP Server 框架(Web Framework Benchmarks 上位居榜首),Mongodb是一个流行的数据库软件。
本文完整源码见GITHUB Repo: https://github.com/nintha/demo-myblog
开始
我们使用cargo
包管理工具来创建项目,当前的rust版本为v1.38
1 | cargo new myblog |
创建成功后 myblog目录结构如下所示
1 | myblog/ |
日志打印
为了方便项目开发,日志输出必不可少,光靠println!
可不行,这里我们引入日志扩展依赖,在Cargo.toml文件中添加:
1 | log = "0.4.0" |
然后在main.rs
里添加日志初始化相关代码
1 | use log::info; |
我们运行下,看看效果
1 | 2019-09-28 14:12:40 INFO [myblog] env_logger initialized. |
嗯,友好的日志信息。
创建HTTP服务
现在引入actix-web所需要的依赖,在Cargo.toml文件中添加依赖:
1 | actix-web = "1.0" |
根据Actix官网的示例代码,创建http server的代码如下所示:
1 | use actix_web::{web, App, HttpRequest, HttpServer, Responder}; |
运行下程序,用浏览器访问http://localhost:8000/
,不出意外的话可以看到应答Hello World!
。
请求异常处理
为了代码更加健壮,我们需要对请求的异常处理进行自定义。
在src下面添加common.rs
文件,并在main.rs
中声明这个模块
1 | mod common; |
我们使用了failure
库来辅助错误处理以及serde
库对请求应答进行序列化,在依赖中加入它们
1 | serde = { version = "1.0", features = ["derive"] } |
我们定义个统一的返回值结构体Resp
,代码如下所示
1 |
|
当请求正常处理的时候,用ok()
进行返回
1 | Resp::ok("success").to_json_result() |
当出现业务错误的时候,如请求参数缺失,用err()
进行返回
1 | Resp::err(err_code, "error message").to_json_result() |
如果需要其他HTTP Response Code,比如404,可以这样写
1 | HttpResponse::NotFound().json(Resp::err(err_code, "error message") // code 404 |
同时我们需要自定义下业务异常
1 |
|
这里用枚举定义了两种业务错误,ValidationError
表示请求参数校验错误,InternalError
作为普通内部错误。
枚举值上的注解属性#[fail(display = "Validation error on field: {}", field)]
,是用了failure的功能,可以让错误信息更加友好,动态的错误信息可以更加直观的看到出错的具体参数信息。
error::ResponseError
是Actix-web处理错误返回的trait,fn error_response(&self) -> HttpResponse
方法是对错误进行处理,把我们自己定义的错误转换成Actix
可以处理的错误;fn render_response(&self) -> HttpResponse
是对错误信息进行序列化,成为前端接受到的内容。如果不重载render_response
,返回到前端的只会是#[fail(display = "Validation error on field: {}", field)]
中display
的部分,这样就很不JSON了。
common.rs
完整内容
1 | /// common.rs |
集成MongoDB
这里假设用户已经在本地已经有一个MongoDB服务器,可以通过mongodb://localhost:27017
进行访问,并且未设置密码。
添加依赖
1 | bson = "0.14.0" |
这里添加了lazy_static
的依赖主要是希望可以把MongoDB Client作为一个全局变量进行复用,
1 | use lazy_static::lazy_static; |
mongodb::Client
类型其实是Arc<ClientInner>
类型的别名,所以它可以在多个线程内安全地共享。
对于这个简单的项目我们只会用到一个database,所以把database的访问也可以封装一下:
1 | use mongodb::db::ThreadedDatabase; |
这样我们只需要关注集合(monogodb collection)
的逻辑就可以了,比如查询user集合的数据量,可以这么写
1 | let rs = collection("user").count(None, None); |
CRUD
我们的目标是完成一个博客,那么最基础功能是提供增删改查4个API。博客最主要的内容就是文章,因此我们先创建Article
结构体来描述文章这个实例。
在src
下面创建article
文件夹,并在article
文件夹下面创建mod.rs
和handler.rs
文件,现在src的目录结构是这样的
1 | src/ |
mod.rs
文件是用来定义article模块中共用的部分,handler.rs
文件用于存放请求处理相关的代码。
我们先看下mod.rs
1 | mod handler; |
我们定义了Article
结构体,它包含了4个字段,_id
是由MongoDB自动生成的,但在文章创建前,它是不存在的,所以我们用Option
包裹一下。为了方便,这个结构体不仅用于前端请求参数的接受,同时用于响应数据的返回,还用于同步数据库的模型。
由于我们希望对应的表名为article
,那么为Article
实现一个常量字符串;
1 | impl Article { |
现在可以尝试下编写新增逻辑了,先决定方法声明,如下所示
1 | pub fn save_article(article: web::Json<Article>) -> Result<HttpResponse, BusinessError> |
这个返回类型看起来有点长,而且基本不会改变,那我们可以用类型别名去简化
1 | type SimpleResp = Result<HttpResponse, BusinessError>; |
这下就简单多了。
web::Json<Article>
是actix提供用来接受json body的对象,可以用::into_inner()
方法直接获取反序列化好的结构体
1 | pub fn save_article(article: web::Json<Article>) -> SimpleResp { |
我们先测试下是否真的可以拿到请求的参数,把代码稍微补充一下:
1 | use super::Article; |
还需要在main.rs里面把handler绑定到路由上(hello world已经不在需要,这里先移除了)
1 | fn main() { |
我们把save_article
方法绑定到POST /articles
路由上,但是这样却没法通过编译
1 | ... |
友善的编译器告诉我们,article::Article
结构体提没有实现反序列化相关方法;从json变成article的确需要反序列化,如果我们需要把article作为结果返回,同时还需要序列化,接下来就实现一下
1 | use serde::{Serialize, Deserialize}; |
我们只需要声明Article
实现了serde::Serialize
和serde:: Deserialize
特性,然后serde就会帮我们自动完成背后的工作。现在项目可以正常启动了,尝试发送一个post请求
1 | curl --request POST \ |
可以看到一条日志,这个请求参数已经被我们成功获取并打印了。
1 | 2019-09-28 20:52:19 INFO [myblog::article::handler] save article, Article { _id: None, title: "简易博客指南", author: "栗子球", content: "本文介绍如何使用Actix-web和MongoDB构建简单博客网站..." } |
然后就是需要写入数据库了,当前rust上mongodb实现,在进行所有操作时,需要把结构体转换成Doucument
类型。同时我们需要对_id
字段进行移除,不然mongodb无法生成对应 ID了。
1 | // Article -> Bson -> Document |
写入数据库后,返回值会告诉我们这条记录的ID,同时需要对失败情况进行处理
1 | match result { |
我们再次运行程序,发送请求,成功的话响应json数据如下所示
1 | { |
接下来处理查询接口,把我们刚刚存储的数据查询出来,Collection::find
方法返回的值是一个游标(mongodb::cursor::Cursor
),我们可以把它转换成Vec,在common.rs里面添加如下代码
1 | use bson::Document; |
由于rust的孤儿原则,我们定义了一个新的trait,来为游标类型实现扩展方法。
查询处理如下所示, 我们仅仅是不加过滤参数地查询一下,把游标转换成动态数组,在对错误进行一下处理。
1 | pub fn list_article() -> SimpleResp { |
在main.rs
中绑定新路由,这次绑定到GET 上
1 | let server = HttpServer::new(|| { |
用GET请求http://127.0.0.1:8000/articles
,获得响应
1 | { |
可以看到数据已经被完整的读出,美中不足的是_id
字段显示不太符合我们的直觉;我们希望它直接显示那一段hash值,而不是一个嵌套字段。通过查询serde的文档可以得知,我们可以通过注释字段来处理某个字段的序列化方式。
1 | use serde::Serializer; |
现在再来看看效果
1 | { |
这下看起来舒服多了。
为了方便把变量转换成Document,我们把这部分逻辑提取出来。这里做了一个额外处理,就是把所有空值的key都删除,方便后续业务处理。
1 | // article/handler.rs |
字符串转换ObjectId有个错误,为了方便使用?
语法,我们添加了bson::oid::Error
到BusinessError
的转换.
1 | // common.rs |
然后就照葫芦画瓢把修改和删除写一下
1 | // article/handle.rs |
我们把修改逻辑绑定到 PUT /articles/{id}
,删除逻辑绑定到 DELETE /articles/{id}
,获取路径变量可以通过HttpRequest.match_info(&self).get("id")
来获取
1 | // main.rs |
后记
现在基本功能已经完成了,但还留有一些小小的问题
- 带条件参数的查询
- 请求时JSON格式异常或缺字段时,返回的信息不是JSON格式的
- 缺少前端页面
- ……
这些后续文章中再处理。