使用 Rust 进行高效能的后端开发:00

或许,应该先定义下 高效能(我心中的):

  1. 健壮:程序缺陷和bug少,能够在编译时发现大多数错误。
  2. 高性能:API 服务应具备低延迟和高吞吐量的能力,以适应高并发场景。
  3. 低资源消耗:在相同吞吐量和响应速度下,占用的系统资源(CPU、内存)越低越好。
  4. 开发效率:开发效率应高于大部分语言/框架,以减少开发成本和提升业务快速响应效率。这里强调一点,我认为的开发效率聚集的真实开发的效率,不包括学习成本。因为对于学习成本来说,不同人的学习能力是不一样的,而且若团队决定选用某一技术,那自然会寻找会这门技术的人员。
  5. 可扩展性:API 服务应具备良好的扩展性,以适应业务需求和用户需求。结合现在的技术生态,这一点对于大部分语言和框架来说都不太是问题了,实在不行前面再挂个反向代理或LSB服务也能做到服务节点可扩展。而扩展性要做的工作量,更多在后面数据存储和一致性上。

快速入门

目录结构

首先我们来看看项目工程目录结构:

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
api-example
├── Cargo.toml
├── README.md
├── bin
│   └── api-example.rs
├── resources
│   └── app.toml
└── src
├── auth
│   ├── auth_serv.rs
│   ├── mod.rs
│   ├── model.rs
│   └── web.rs
├── ctx.rs
├── lib.rs
├── router.rs
├── state.rs
├── user
│   ├── mod.rs
│   ├── user_bmc.rs
│   ├── user_credential_bmc.rs
│   ├── user_credential_model.rs
│   ├── user_model.rs
│   ├── user_serv.rs
│   └── web.rs
└── util.rs

这个简单的项目实现了较为全面的用户认证和用户管理功能。

  • bin/api-example.rs 最终要执行的入口文件,也是生成的二进制程序的名字
  • src/lib.rs 库的主入口
  • src/router.rs 项目的路由
  • src/state.rs项目的全局状态
  • src/ctx.rs 项目的请求上下文
  • src/util.rs 项目的工具

项目功能使用模块化的方式,以领域上下文进行划分:

  • src/auth 认证领域模块
  • src/user 用户领域模块

而在领域模块内,通常会有以下几类文件:

  • web.rs 放置 Axum 路由的实现
  • <xxx_>model.rs 放置领域模型
  • <xxx_>serv.rs 放置领域服务
  • <xxx_>bmc.rs 放置领域数据访问层

启动服务

1
2
3
4
5
# 访问 github 有困难的可以使用 gitee
#git clone https://gitee.com/yangbajing/ultimate-common.git

git clone https://github.com/yangbajing/ultimate-common.git
cd ultimate-common/examples

在了解了基本的目录结构以后,我们先来启动服务并测试一下:

1
docker compose up -d --build && docker compose logs -f

新打开一个终端窗口并进入 ultimate-common/examples 目录,使用以下命令启动 API 示例服务:

1
cargo run --release --bin api-example

当终端输出类似如下内容 .... The Web Server listening on 0.0.0.0:8888 时,说明服务已经启动成功。

使用密码登录

接下来使用 curl 命令在终端发送登录请求:

1
2
3
4
5
6
curl --location 'http://localhost:8888/auth/login/pwd' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "admin@ultimate.com",
"pwd": "2024.Ultimate"
}' | python -m json.tool

登录成功返回 token

1
2
3
4
{
"token": "eyJ0eXAiOiJKV1QiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiZGlyIn0..EZwETCBq1CNs8yO5Zec09Q.g3JoMryHoq01ZO3TQ2Ja_ppJZb9SYdon-LfB6OGyH7s.sBCGn14NuoxujmAgRpkYPg",
"token_type": "Bearer"
}

用户-分页查询

你需要使用上面 使用密码登录 来获取 token,并替换到下面 Bearer <token> 位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
curl -v --location 'http://localhost:8888/v1/user/page' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiZGlyIn0..EZwETCBq1CNs8yO5Zec09Q.g3JoMryHoq01ZO3TQ2Ja_ppJZb9SYdon-LfB6OGyH7s.sBCGn14NuoxujmAgRpkYPg' \
--data '{
"page": {
"page": 1,
"page_size": 20
},
"filter": {
"ctime": {
"$gte": "2024-08-15T00:00:00+0800",
"$lt": "2024-08-16T00:00:00+0800"
}
}
}' | python -m json.tool --no-ensure-ascii

上面,我们使用了类似 mongodb 的查询语法,查询出创建时间在 2024-08-15 到 2024-08-16 之间的用户。相应的 SQL 语句如下:

1
2
3
db-1  | 2024-08-15 16:30:19.313 CST [33] 日志:  执行 sqlx_s_1: SELECT COUNT(*) FROM "iam"."user" WHERE "ctime" >= '2024-08-14 16:00:00 +00:00' AND "ctime" < '2024-08-15 16:00:00 +00:00'
db-1 | 2024-08-15 16:30:19.321 CST [34] 日志: 执行 sqlx_s_1: SELECT "id", "email", "phone", "name", "status", "cid", "ctime", "mid", "mtime" FROM "iam"."user" WHERE "ctime" >= $1 AND "ctime" < $2 LIMIT $3 OFFSET $4
db-1 | 2024-08-15 16:30:19.321 CST [34] 详细信息: 参数: $1 = '2024-08-14 16:00:00+00', $2 = '2024-08-15 16:00:00+00', $3 = '20', $4 = '0'

注: 还记得之前使用 docker 时的 docker compose logs -f db 命令吗?它会监听 db container 的日志输出,显示每一条 SQL语句的内容。这在开发阶段可以很方便我们查看程序生成的实际 SQL 语句

运行效能

程序大小

可以看到,生成的可执行程序在 13M 左右。具体文件大小会根据你引入的库及实现功能有关,我们这个示例程序引入的主要库有:tokio, axum, hyper, tower-http, sqlx, sea_query, serde, chrono, tikv-jemallocator, ……

1
2
$ ll target/release/api-example
-rwxr-xr-x@ 1 yangjing staff 13M 8 15 16:50 target/release/api-example

运行资源占用

通过 ps 看看程序启动后的资源消耗情况。

1
2
3
$ ps -p 57423 -o %cpu,%mem,vsz,rss,command
%CPU %MEM VSZ RSS COMMAND
0.0 0.0 410154176 14320 /Users/yangjing/workspaces/ultimate-common/target/release/api-example

可以看到,程序刚启动时占用了 14 MB 的内存,在执行多次 API 调用后内存占用会上升到 22 MB 左右,然后再回落并稳定在 17 MB 左右(这里未做全面的性能测试,后续文章再进行介绍)。

代码

下面来看看代码,以 用户领域 为例,由以下四部分代码组织:

  • web.rs 放置 Axum 路由的实现,类似于传统 MVC 里的 controller
  • model.rs 放置领域模型,类似于传统 MVC 里的 EntityDTOVO
  • serv.rs 放置领域服务,类似于传统 MVC 里的 Service
  • bmc.rs 放置领域数据访问层,类似于传统 MVC 里的 DAO

web.rs

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
pub fn user_routes() -> Router<AppState> {
Router::new()
.route("/", post(create_user))
.route("/page", post(page_user))
.route("/:id", get(get_user).put(update_user).delete(delete_user))
}

async fn page_user(
user_serv: UserServ,
Json(req): Json<UserForPage>
) -> AppResult<UserPage> {
let page = user_serv.page(req).await?;
ok(page)
}

async fn update_user(
user_serv: UserServ,
Path(id): Path<i64>,
Json(req): Json<UserForUpdate>
) -> AppResult<()> {
user_serv.update_by_id(id, req).await?;
ok(())
}

// .... 其它路由处理器函数定义

user_routes() 函数生成路由,并注册了 POST /POST /pageGET /:idPUT /:idDELETE /:id 5个路由处理器函数,分别为创建、分页查询、获取、更新、删除用户。

Axum 提供了强大的 extractors,能够做到强类型检查。内置了如:QueryPathJson 等,用于从请求中提取参数,并转换为指定的类型。Path 提取器从 URL 路径上提取值,Json 提取器将请求体数据转换为指定的 Rust struct 类型。

这里的 user_serv: UserServ 是一个依赖注入,由我们通过实现 FromRequestParts trait 来实现,在请求处理时,会自动注入该依赖。实现逻辑也非常简单,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
#[async_trait]
impl FromRequestParts<AppState> for UserServ {
type Rejection = (StatusCode, Json<AppError>);

async fn from_request_parts(
parts: &mut Parts,
state: &AppState
) -> core::result::Result<Self, Self::Rejection> {
let ctx = CtxW::from_request_parts(parts, state).await?;
Ok(UserServ::new(ctx))
}
}

使用提取器,我们就可以在每个 HTTP 请求的处理函数中,直接使用 user_serv: UserServ 参数,而不必再手动从请求上下文中获取。但是,我们注意到在每个请求中,都构造了一个新的 UserServ(见:UserServ::new(state.clone(), ctx)),那对于需要整个程序运行期间全局存在的状态要怎么处理呢?那就是 Axum 提供的 State 机制了。

State

在构建 Router 树时,使用 .with_state 函数将 AppState 注入到路由树中,这样所有嵌套路由都可以在函数参数签名中通过 State(app): State<AppState> 提取器来获取全局状态。这也是为什么我们会看到前面字义 user_routers 路由函数时返回值类型要明确类型参数为 AppStatepub fn user_routes() -> Router<AppState>),只有这样才能在路由处理函数中通过 State(app): State<AppState> 提取器来类型安全的获取全局状态。

1
2
3
4
5
6
pub fn new_api_router(app_state: AppState) -> Router {
Router::new()
.nest("/v1/user", user_routes())
.nest("/auth", auth_routes())
.with_state(app_state)
}

model.rs

在数据模型定义上,Rust 很有特色。我们先来看看 User 相关的数据字义:

User 实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[derive(Debug, Serialize, FromRow, Fields)]
#[enum_def]
pub struct User {
pub id: i64,
pub email: Option<String>,
pub phone: Option<String>,
pub name: String,
pub status: UserStatus,
pub cid: i64,
pub ctime: UtcDateTime,
pub mid: Option<i64>,
pub mtime: Option<UtcDateTime>,
}
impl DbRowType for User {}

User 实体上有应用了好些宏,并实现了一个 trait DbRowType

  • Serializae 是 serde 提供的宏,用于实现序列化、反序列化。
  • FromRow 是 sqlx 提供的宏,用于实现从数据库查询结果转换为实体。
  • Fields 是 modql 提供的宏(扩展了 sea-query 的对应实现),用于实现实体字段的枚举。
  • enum_def 是 sea-query 提供的宏,用于实现枚举类型的定义。让我们可以在动态 SQL 中使用枚举值来类型安全使用数据表列标识。
  • DbRowType 是一个公共接口 trait,可以简化我们需要实现的多个 trait。它的定义如下:pub trait DbRowType: HasSeaFields + for<'r> FromRow<'r, PgRow> + Unpin + Send {}

serv.rs

UserServ 封装了业务逻辑,它需要通过持有 CtxW 来获取数据库连接对象和用户会话信息。

首先来看 UserServ struct 定义。Rust 中没有 class,但是我们可以有 struct 默认,来模拟类似 class 的效果。而在 struct 中定义的属性可以通过 self 来访问。

1
2
3
4
#[derive(Constructor)]
pub struct UserServ {
ctx: CtxW,
}

这里使用了 Constructor 宏来自动生成 new 构造函数(注:Rust 中并没有构建函数的概念,但社区约定熟成使用 new 来构建对象。),宏展开后生成的代码类似如下:

1
2
3
4
5
impl UserServ {
pub new(ctx: CtxW) -> Self {
Self { ctx }
}
}

然后是 UserServ 的实现,对于我们这个程序,服务的逻辑比较简单,大部分实现都是对 UserBmc 的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
impl UserServ {
pub async fn create(&self, req: UserForCreate) -> Result<i64> {
let id = UserBmc::create(self.ctx.mm(), req.validate_and_init()?).await?;
Ok(id)
}

pub async fn page(&self, req: UserForPage) -> Result<UserPage> {
let page = UserBmc::page(
self.ctx.mm(),
req.page.unwrap_or_default(),
req.filter.unwrap_or_default()
).await?;
Ok(page.into())
}

// ....
}

bmc.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use ultimate_db::{base::DbBmc, generate_common_bmc_fns};

use super::{User, UserFilter, UserForCreate, UserForUpdate};

pub struct UserBmc;
impl DbBmc for UserBmc {
const SCHEMA: &'static str = "iam";
const TABLE: &'static str = "user";
}

generate_common_bmc_fns!(
Bmc: UserBmc,
Entity: User,
ForCreate: UserForCreate,
ForUpdate: UserForUpdate,
Filter: UserFilter,
);

UserBmc 是我们定义的数据访问功能,它定义了 User 实体相关的业务逻辑。它通过 generate_common_bmc_fns! 宏来生成大部分的 BMC 函数,包括 createcreate_manyget_by_idupdate_by_iddelete_by_iddelete_by_idscountlistpage 等。

小结

Rust 中没有 null,使用 Option 类型来表示可选值。并通过编译时检查来保证类型安全。

Rust 中也没有异常,而是使用 Result 类型来表示函数可能返回的错误。同时提供了 ? 操作符来简化处理错误。当 ? 作用的值是 Err 时,Rust 会直接以此 Err 值返回(终止之后的代码执行并从函数返回),这事实上也是大多时候的正确逻辑,很符合业务处理逻辑的 人体工程学。而对于不需要返回的 Err,也提供了 match(模式匹配)is_err()map_err() 等一系列处理方法。相比 Java 的 throw Exception、Go 的 if err != nil 满天飞等,是一种更优雅、安全,且对性能影响小的错误处理方式。

可以看到,虽然 Rust 也放有着“较陡峭”的学习曲线,但 Rust 的类型系统、编译器、并发模型、内存管理机制等特性,能够帮助我们写出更简洁、更安全的代码。通过泛型、trait、宏(Macro),可以实现类似动态语言、反射机制等能够提供的便利功能,使得 Rust 的开发体验更加友好和快速。

示例代码在

分享到