使用 Rust 进行高效能的后端开发:CRUD 服务或许,应该先定义下 高效能(我心中的): 健壮:程序缺陷和bug少,
或许,应该先定义下 高效能(我心中的):
- 健壮:程序缺陷和bug少,能够在编译时发现大多数错误。
- 高性能:API 服务应具备低延迟和高吞吐量的能力,以适应高并发场景。
- 低资源消耗:在相同吞吐量和响应速度下,占用的系统资源(CPU、内存)越低越好。
- 开发效率:开发效率应高于大部分语言/框架,以减少开发成本和提升业务快速响应效率。这里强调一点,我认为的开发效率聚集的真实开发的效率,不包括学习成本。因为对于学习成本来说,不同人的学习能力是不一样的,而且若团队决定选用某一技术,那自然会寻找会这门技术的人员。
- 可扩展性:API 服务应具备良好的扩展性,以适应业务需求和用户需求。结合现在的技术生态,这一点对于大部分语言和框架来说都不太是问题了,实在不行前面再挂个反向代理或LSB服务也能做到服务节点可扩展。而扩展性要做的工作量,更多在后面数据存储和一致性上。
快速入门
目录结构
首先我们来看看项目工程目录结构:
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
放置领域数据访问层
启动服务
# 访问 github 有困难的可以使用 gitee
#git clone https://gitee.com/yangbajing/ultimate-common.git
git clone https://github.com/yangbajing/ultimate-common.git
cd ultimate-common/examples
在了解了基本的目录结构以后,我们先来启动服务并测试一下:
docker compose up -d --build && docker compose logs -f
新打开一个终端窗口并进入 ultimate-common/examples
目录,使用以下命令启动 API 示例服务:
cargo run --release --bin api-example
当终端输出类似如下内容 .... The Web Server listening on 0.0.0.0:8888
时,说明服务已经启动成功。
使用密码登录
接下来使用 curl
命令在终端发送登录请求:
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
{
"token": "eyJ0eXAiOiJKV1QiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiZGlyIn0..EZwETCBq1CNs8yO5Zec09Q.g3JoMryHoq01ZO3TQ2Ja_ppJZb9SYdon-LfB6OGyH7s.sBCGn14NuoxujmAgRpkYPg",
"token_type": "Bearer"
}
用户-分页查询
你需要使用上面 使用密码登录 来获取 token,并替换到下面 Bearer <token>
位置。
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 语句如下:
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
, ……
$ ll target/release/api-example
-rwxr-xr-x@ 1 yangjing staff 13M 8 15 16:50 target/release/api-example
运行资源占用
通过 ps
看看程序启动后的资源消耗情况。
$ 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 里的Entity
、DTO
、VO
等serv.rs
放置领域服务,类似于传统 MVC 里的Service
bmc.rs
放置领域数据访问层,类似于传统 MVC 里的DAO
web.rs
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 /page
、GET /:id
、PUT /:id
、DELETE /:id
5个路由处理器函数,分别为创建、分页查询、获取、更新、删除用户。
Axum 提供了强大的 extractors,能够做到强类型检查。内置了如:Query
、Path
、Json
等,用于从请求中提取参数,并转换为指定的类型。Path
提取器从 URL 路径上提取值,Json
提取器将请求体数据转换为指定的 Rust struct 类型。
这里的 user_serv: UserServ
是一个依赖注入,由我们通过实现 FromRequestParts trait 来实现,在请求处理时,会自动注入该依赖。实现逻辑也非常简单,代码如下:
#[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
路由函数时返回值类型要明确类型参数为 AppState
(pub fn user_routes() -> Router<AppState>
),只有这样才能在路由处理函数中通过 State(app): State<AppState>
提取器来类型安全的获取全局状态。
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 实体
#[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
来访问。
#[derive(Constructor)]
pub struct UserServ {
ctx: CtxW,
}
这里使用了 Constructor
宏来自动生成 new
构造函数(注:Rust 中并没有构建函数的概念,但社区约定熟成使用 new 来构建对象。
),宏展开后生成的代码类似如下:
impl UserServ {
pub new(ctx: CtxW) -> Self {
Self { ctx }
}
}
然后是 UserServ
的实现,对于我们这个程序,服务的逻辑比较简单,大部分实现都是对 UserBmc
的调用。
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
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 函数,包括 create
、create_many
、get_by_id
、update_by_id
、delete_by_id
、delete_by_ids
、count
、list
、page
等。
小结
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 的开发体验更加友好和快速。
示例代码在
- Github: github.com/yangbajing/…
- Gitee: gitee.com/yangbajing/…
转载自:https://juejin.cn/post/7417077513133162559