或许,应该先定义下 高效能(我心中的):
- 健壮:程序缺陷和bug少,能够在编译时发现大多数错误。
- 高性能:API 服务应具备低延迟和高吞吐量的能力,以适应高并发场景。
- 低资源消耗:在相同吞吐量和响应速度下,占用的系统资源(CPU、内存)越低越好。
- 开发效率:开发效率应高于大部分语言/框架,以减少开发成本和提升业务快速响应效率。这里强调一点,我认为的开发效率聚集的真实开发的效率,不包括学习成本。因为对于学习成本来说,不同人的学习能力是不一样的,而且若团队决定选用某一技术,那自然会寻找会这门技术的人员。
- 可扩展性:API 服务应具备良好的扩展性,以适应业务需求和用户需求。结合现在的技术生态,这一点对于大部分语言和框架来说都不太是问题了,实在不行前面再挂个反向代理或LSB服务也能做到服务节点可扩展。而扩展性要做的工作量,更多在后面数据存储和一致性上。
快速入门
目录结构
首先我们来看看项目工程目录结构:
1 | api-example |
这个简单的项目实现了较为全面的用户认证和用户管理功能。
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 | # 访问 github 有困难的可以使用 gitee |
在了解了基本的目录结构以后,我们先来启动服务并测试一下:
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 | curl --location 'http://localhost:8888/auth/login/pwd' \ |
登录成功返回 token
1 | { |
用户-分页查询
你需要使用上面 使用密码登录 来获取 token,并替换到下面 Bearer <token>
位置。
1 | curl -v --location 'http://localhost:8888/v1/user/page' \ |
上面,我们使用了类似 mongodb 的查询语法,查询出创建时间在 2024-08-15 到 2024-08-16 之间的用户。相应的 SQL 语句如下:
1 | 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' |
注: 还记得之前使用 docker 时的
docker compose logs -f db
命令吗?它会监听 db container 的日志输出,显示每一条 SQL语句的内容。这在开发阶段可以很方便我们查看程序生成的实际 SQL 语句
运行效能
程序大小
可以看到,生成的可执行程序在 13M
左右。具体文件大小会根据你引入的库及实现功能有关,我们这个示例程序引入的主要库有:tokio
, axum
, hyper
, tower-http
, sqlx
, sea_query
, serde
, chrono
, tikv-jemallocator
, ……
1 | $ ll target/release/api-example |
运行资源占用
通过 ps
看看程序启动后的资源消耗情况。
1 | $ ps -p 57423 -o %cpu,%mem,vsz,rss,command |
可以看到,程序刚启动时占用了 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
1 | pub fn user_routes() -> Router<AppState> { |
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 来实现,在请求处理时,会自动注入该依赖。实现逻辑也非常简单,代码如下:
1 |
|
使用提取器,我们就可以在每个 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>
提取器来类型安全的获取全局状态。
1 | pub fn new_api_router(app_state: AppState) -> Router { |
model.rs
在数据模型定义上,Rust 很有特色。我们先来看看 User 相关的数据字义:
User 实体
1 |
|
在 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 |
|
这里使用了 Constructor
宏来自动生成 new
构造函数(注:Rust 中并没有构建函数的概念,但社区约定熟成使用 new 来构建对象。
),宏展开后生成的代码类似如下:
1 | impl UserServ { |
然后是 UserServ
的实现,对于我们这个程序,服务的逻辑比较简单,大部分实现都是对 UserBmc
的调用。
1 | impl UserServ { |
bmc.rs
1 | use ultimate_db::{base::DbBmc, generate_common_bmc_fns}; |
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 的开发体验更加友好和快速。