用 Rust 编写 RESTful Web API

本文需要用到以下工具/库:

  • Rust and Cargo toolchain
  • actix-web web 框架
  • sea-orm ORM 框架
  • serde 序列化/反序列化
  • dotenvy 环境变量配置

项目初始化

首先 cargo new workspace 创建一个新项目,但该项目只作为workspace工作空间,因此可以将src目录直接删掉,然后编辑Cargo.toml:

1
2
3
[workspace]
members = ["app"]
default-members=["app"]

设置default-members,这样cargo run的默认项目就是app

然后,cargo new app,新建app crate。接着给appCargo.toml添加如下依赖:

1
2
3
4
5
[dependencies]
actix-web="4"
sea-orm = { version = "^0", features = [ "sqlx-sqlite", "runtime-actix-native-tls", "macros" ] }
serde = { version = "1.0", features = ["derive"] }
dotenvy = "0.15.6"

actix 准备

首先我们到appmain.rs,把 actix 先跑起来看看。

1
2
3
4
5
6
7
8
9
use actix_web::{web, App, HttpServer};

#[actix_web::main]
async fn main() -> Result<(), std::io::Error> {
HttpServer::new(|| App::new().route("/ping", web::get().to(|| async { "pong!" })))
.bind(("127.0.0.1", 5000))?
.run()
.await
}

cargo run 然后 curl http://localhost:5000,你会看到一个”pong!”.

用 dotenvy 控制环境变量

监听选项写死是不太好的,这时可以使用dotenvy包。在workspace目录下创建一个.env文件,内容为:

1
LISTEN=0.0.0.0

然后,我们在main中通过读取环境变量来实现动态配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// main.rs
use actix_web::{web, App, HttpServer};
use dotenvy::dotenv;
use std::env;

#[actix_web::main]
async fn main() -> Result<(), std::io::Error> {
dotenv().ok();

let listen = env::var("LISTEN").unwrap_or("127.0.0.1".into());
let port = env::var("PORT").unwrap_or("5000".into());
let port = port
.parse()
.expect(format!("Error parsing port number PORT={}", port).as_str());

println!("listening on http://{}:{}", listen, port);

HttpServer::new(|| App::new().route("/ping", web::get().to(|| async { "pong!" })))
.bind((listen, port))?
.run()
.await
}

数据库准备

本例使用 Sea-Orm 作为 ORM 框架。 首先安装 sea-orm-cli 准备数据库迁移。

1
cargo install sea-orm-cli

Schema first 还是 Entity first?

SeaORM文档也提到了这个问题。

对于涉及数据库的业务,有一个重要的开发流程问题,那就是先有数据库还是先有代码?.NET EntityFrame Core 的文档给了很好的说明。大多数业务框架推荐 Entity First(Code First)。即先有业务模型,然后去生成数据库。这是因为数据库通常是通过 SQL 管理的,而在有ORM框架的情况下,应当尽可能减少原生SQL操作。

但是SeaORM采用了一个折中的工作流程,Schema First。首先,应当写一个迁移逻辑(用Rust而非SQL),然后根据这个迁移逻辑去调整数据库,最后从迁移过的数据库生成Entity,也就代码中的Model。这一套流程和EF CoreCode First模式是很像的,只不过EF Core会先从Model的变化生成迁移文件(这些推断并不总是准确的,而且可能造成毁灭性的后果)。另一些ORM框架,比如gorm,它们的自动迁移只会新增而不会修改或删除,为了避免不可挽回的数据丢失,但是这种情况下,修改一个现有字段的约束就会变得困难。事实上,SeaORM的工作模式虽然相比之下更麻烦了一点,但是开发者获得了更多的控制权,而且由于迁移文件也是用Rust代码写的,效率并不会很低。

执行sea-orm-cli migrate init,我们会得到一个新的Crate,名叫migration。然后删掉默认的一个示例迁移文件。

添加 migration Crate 到 WorkSpace

接下来,修改workspace即根目录的Cargo.toml以包含这个 migration Crate.

1
2
3
[workspace]
members=["app","migration"] #新增一个migration
default-members=["app"]

Migration File

通过sea-orm-cli migrate generate create_todo_table,可以得到一个以当前时间为前缀,名为create_todo_table的迁移文件。修改其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Todo::Table)
.if_not_exists()
.col(
ColumnDef::new(Todo::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Todo::ExpireAt).date_time().not_null())
.col(ColumnDef::new(Todo::Content).string())
.col(ColumnDef::new(Todo::IsFinished).boolean().not_null())
.to_owned(),
)
.await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Todo::Table).to_owned())
.await
}
}

/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
enum Todo {
Table,
Id,
ExpireAt,
Content,
IsFinished,
}

这样,就定义好了一次迁移的逻辑。up方法新建了一张表,并设置了字段的类型和约束。down方法应当是up的逆过程,由于up是新建表,因此down直接删除表就可以了。

执行迁移

接下来,我们要将写好的迁移文件应用到数据库。可以在应用程序中完成这一步,也可以使用sea-orm-cli完成。migration Crate是一个 bin crate,它编译的结果就是一个负责执行迁移的程序。sea-orm-cli提供了一个捷径来编译并执行迁移。
如果采用sea-orm-cli手动迁移,要注意为migrationcrate配置一个数据库驱动,就像appcrate一样,本文以sqlite为例。
需要用到环境变量DATABASE_URL,由于我们已经使用了dotenvy来管理环境变量,而sea-orm-cli也是支持这个的,因此直接在.env文件添加一行:

1
2
# .env
DATABASE_URL="sqlite:./sqlite.db?mode=rwc"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# ./migration/Cargo.toml
[package]
name = "migration"
# ......
[dependencies.sea-orm-migration]
version = "^0.10.0"
features = [
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
# e.g.
# "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
"runtime-tokio-rustls", # de-comment this line so that we have an async runtime
# "sqlx-postgres", # `DATABASE_DRIVER` feature
"sqlx-sqlite" # Add this line so that we can connect to a sqlite db
]

然后,执行sea-orm-cli migrate,等待编译并运行成功后,数据库就迁移完成了!

生成 Entity

要执行 CRUD,当然还需要 Entity 定义,可以用sea-orm-cli从线上数据库生成。注意是从数据库生成,而不是从前面编写的 migration 文件。因此务必确保所连接到的数据库Schema是最新的!
执行sea-orm-cli generate entity,会发现生成了三个文件,默认是一个mod的构造,即mod.rs,prelude.rs以及若干个 Entity 定义,由于我们只有一个todo,因此也只有一个todo.rs

不过默认生成在了当前目录下,显然不太合理。你可以根据自己的需要调整目录结果,通过-o参数来指定生成位置。

CRUD API

然后,我们就可以开始进入无聊的CRUD环节了。无聊的代码就不贴了,详见github.com/artiga033/rust_rest_api_demo

Permalink: http://blog.artiga.top/2022/write-web-api-with-rust/

本文采用CC BY-NC-SA 4.0许可

Comments