引言
Zino
致力于打造基于Rust语言的新一代组装式应用开发框架,
提供一站式跨平台多端解决方案,可用于后端API开发、桌面应用开发等。
我们奉行『约定优于配置』的原则,提供开箱即用的功能模块,极大提升开发效率;
并通过应用接口抽象与actix-web
、axum
、dioxus
、ntex
等框架集成,
打通社区生态。
入门指南
快速开始
你可以从代码仓库中的示例actix-app
、axum-app
、dioxus-desktop
或者ntex-app
开始体验Zino。
git clone https://github.com/zino-rs/zino.git
cd zino/examples/axum-app
cargo run
如果你能在终端中看到日志输出,那就表明axum-app
成功运行了!需要注意的是:
- 当前Zino框架运行在
Rust 1.75+
,请使用rustup
安装合适的版本; - 示例
axum-app
中需要连接MySQL数据库,具体配置参见axum-app/config/config.dev.toml
。
创建应用
我们使用Rust的构建工具Cargo
来管理应用。首先新建一个项目
cargo new zino-app --bin
然后在Cargo.toml
中添加以下依赖
[package]
name = "zino-app"
version = "0.1.0"
edition = "2021"
[dependencies]
zino = { version = "0.22", features = ["axum"] }
这里我们使用的是axum
框架。如果要用actix-web
框架,那就把features
替换为["actix"]
。
进而,我们在src
目录的main.rs
中添加以下代码:
use zino::prelude::*;
fn main() {
zino::Cluster::boot().run()
}
此时,我们的应用已经可以运行了:
cargo run
打开浏览器地址http://localhost:6080/rapidoc
,你将能够看到RapiDoc文档页面。
这是一个极简的示例,没有太多实际功能。但是如果你在项目目录中添加一个public
目录,那么这就可以作为静态文件服务器,
并且Zino框架会自动使用public/index.html
来渲染根路由/
。在前后端分离的项目中,这一特性可用于部署打包后的单页面应用。
目录结构
Zino开发框架的应用目录组织方式只是一种推荐,可根据实际需求进行调整。尤其是
src
目录,可以使用Rust模块自由组织, 我们并没有严格限定各个模块之间的调用关系,只要能通过编译皆可。
我们采用了类似于Egg.js的应用目录约定规范:
zino-app
├─ Cargo.toml
├─ config
│ ├─ config.dev.toml
│ ├─ config.prod.toml
│ ├─ locale
│ │ ├─ en-US.ftl
│ │ └─ zh-CN.ftl
│ ├─ opa
│ │ └─ user.opa
│ └─ openapi
│ ├─ OPENAPI.toml
│ ├─ auth.toml
│ └─ user.toml
├─ local
│ ├─ data
│ │ └─ mock
│ │ ├─ logs.ndjson
│ │ └─ users.csv
│ └─ docs
│ └─ rapidoc.html
├─ logs
├─ public
│ ├─ 404.html
│ ├─ data
│ │ └─ logs.ndjson
│ └─ index.html
├─ src
│ ├─ controller
│ │ ├─ mod.rs
│ │ ├─ stats.rs
│ │ ├─ task.rs
│ │ └─ user.rs
│ ├─ extension
│ │ ├─ casbin.rs
│ │ ├─ header.rs
│ │ └─ mod.rs
│ ├─ logic
│ │ ├─ mod.rs
│ │ ├─ task.rs
│ │ └─ user.rs
│ ├─ main.rs
│ ├─ middleware
│ │ ├─ access.rs
│ │ └─ mod.rs
│ ├─ router
│ │ └─ mod.rs
│ ├─ schedule
│ │ ├─ job.rs
│ │ └─ mod.rs
│ └─ service
│ ├─ mod.rs
│ ├─ task.rs
│ └─ user.rs
└─ templates
├─ layout.html
└─ output.html
Cargo.toml
为应用的Cargo配置文件。config/config.{env}.toml
用于编写不同运行环境的配置文件。config/locale/{lang-id}.ftl
于编写i18n多语言文件,目前仅支持Fluent
规范。config/opa/{policy}.rego
用于编写OpenPolicyAgent策略。config/openapi/{tag}.toml
用于编写OpenAPI规范文档。local/
为本地静态资源目录,data/
为本地数据目录,docs/
为文档目录。logs/
用于日志文件输出。public/
为通过网络访问的静态资源目录,index.html
为默认首页文件,404.html
为404文件,data/
为共享的数据目录。src/controller/
用于编写控制器。src/extension/
用于编写辅助函数。src/logic/
用于编写业务逻辑。src/main.rs
用于启动应用以及自定义初始化。src/middleware/
用于编写中间件。src/router/
用于配置URL路由规则。src/schedule/
用于编写定时任务。src/service/
用于编写业务接口服务,供controller
调用。templates/
用于编写HTML模板文件,目前支持Tera
和MiniJinja
模板。
配置文件
Zino框架支持根据运行环境来加载配置,不同环境的配置项定义在config/config.{env}.toml
文件中。
具体运行环境的选择,按照以下优先顺序来判断:
- 应用启动时传入的
--env
参数,如cargo run -- --env=dev
; - 如果命令参数不存在,将尝试读取环境变量
ZINO_APP_ENV
1; - 假如环境变量也不存在,将根据
cfg!(debug_assertions)
给出默认值:取值为true
,则运行环境为dev
, 否则为prod
。
开发环境dev
和生产环境prod
是预定义的两个取值。如有需要,你也可以自行添加其他运行环境,比如test
环境,
对应的配置文件为config/config.test.toml
。
当然,除了Rust社区中最常用的TOML格式,我们也支持JSON格式的配置文件,
可以通过环境变量ZINO_APP_CONFIG_FORMAT
进行选择。默认情况下,配置文件是从本地加载的;
如果你需要从远程URL加载,那就请设置环境变量ZINO_APP_CONFIG_URL
,
此时配置文件格式是通过请求响应的content_type
来判断的。
鉴于Zino框架的配置项比较多,在项目开发的初始阶段我们推荐你采用默认值,这样可以省略绝大多数的配置项。 如果确有需要,在项目开发的过程中再逐渐添加。
一个最简单的配置文件示例如下:
name = "DataCube"
version = "1.0"
需要注意的是,这里的name
和version
是指应用的名称和版本,与Cargo.toml
里[package]
的name
和version
不是一个概念。
在这里,我们只列出全局配置项,具体功能的配置项将在后面的功能模块里给出。
name
:应用名称name = "DataCube"
version
: 应用版本version = "1.0"
secret
: 应用密文,用于推导Application
的secret_key
。 当缺失时,会根据应用名称和版本自动生成。secret = "SecretPhrase"
当开启了feature = "dotenv"
时,Zino框架也会自动加载项目目录中的.env
文件。
基础模块
Zino框架的主要功能都由zino-core
提供,这是与具体Web框架(如actix-web
或axum
)无关的抽象,
其中最基础的就是以下七大模块:
application
:应用接口抽象,这是与其他框架差异最大的地方;state
:应用状态管理,大量使用惰性初始化的全局变量;error
:通用的错误处理,搭配bail!
、warn!
、reject!
使用;request
:请求上下文,通过RequestContext
trait提供方法;response
:构建请求响应,统一处理不同的content-type
;schedule
:任务调度,提供Scheduler
、AsyncScheduler
抽象;model
:领域模型抽象,与具体的数据库和ORM无关。
应用接口抽象
Zino框架的应用接口抽象由Application
trait定义,它包含一个关联类型和两个必须实现的方法:
pub trait Application {
type Routes;
fn register(self, routes: Self::Routes) -> Self;
fn run_with<T: AsyncScheduler + Send + 'static>(self, scheduler: T);
}
其中register
用来注册路由,run_with
用来加载异步任务并运行应用。
需要注意的是,异步任务的执行涉及到异步运行时的选择,
而zino-core
本身并没有限定只能使用特定的运行时1,
所以需要实现者自行在run_with
方法的实现中指定。对于同步任务,不涉及到异步运行时的选择,
我们就在Application
的spawn
方法中提供了默认实现。
这就是Zino框架的起点!我们只要给其他Web框架实现这个trait,就能把这个框架的功能集成到Zino中,并使应用的启动方式保存一致:
mod router;
mod schedule;
use zino::prelude::*;
fn main() {
zino::Cluster::boot()
.register(router::routes())
.register_debug(router::debug_routes())
.spawn(schedule::job_scheduler())
.run_with(schedule::async_job_scheduler())
}
目前我们已经为actix-web
、axum
、dioxus-desktop
实现了Application
trait,
它们对应的关联类型Routes
分别为:
-
actix-web
:引入ActixCluster
类型,基于ServiceConfig
来定义路由。pub type RouterConfigure = fn(cfg: &mut actix_web::web::ServiceConfig); impl Application for ActixCluster { type Routes = Vec<RouterConfigure>; }
-
axum
:引入AxumCluster
类型,基于Router
来定义路由。impl Application for AxumCluster { type Routes = Vec<axum::Router>; }
-
dioxus-desktop
:引入DioxusDesktop<R>
类型,基于Routable
泛型约束来定义路由。impl Application for DioxusDesktop<R> where R: dioxus_router::routable::Routable, <R as FromStr>::Err: Display, { type Routes = R; }
可以看到,在以上框架的Application
实现中,我们并没有定义自己的路由类型,
这就使得actix-web
和axum
中的路由、中间件可以直接在我们的Zino框架中使用。
确保充分理解了这一点,对我们的应用开发至关重要。
虽然大部分情况下我们还是会优先选择tokio
。
状态管理
在Zino框架中,应用状态是由State
类型提供的:
#[derive(Debug, Clone)]
pub struct State<T = ()> {
env: Env,
config: Table,
data: T,
}
它包含有运行环境Env
、TOML格式的配置文件Table
以及自定义数据类型T
。
不同于很多Web框架,我们的应用状态管理是基于惰性初始化的全局变量来实现的,主要考量如下:
- 应用状态通常存在于整个运行期间,也就是说它的生命周期为
'static
; - 应用状态通常只需要初始化加载一次,并且在运行期间并不会被修改;
- 尽可能避免在
controller
或service
方法中传递应用状态参数。
回到具体实现上,最核心的几行代码就是
static SHARED_STATE: LazyLock<State> = LazyLock::new(|| {
let mut state = State::default();
state.load_config();
state
});
这定义了一个全局共享的静态变量,通过State::shared
方法可以得到它的一个&'static
引用,
然后就可以在整个应用中到处使用。
示例:如何使用Redis?
# config/config.dev.toml
[redis]
host = "127.0.0.1"
port = 6379
database = "dbnum"
username = "some_user"
password = "hsfU4Y3aRbxVNuLpVG5T+wb9jIDdQyaUIiPgeQrP0ZRM1g"
//! src/extension/redis.rs
use parking_lot::Mutex;
use redis::{Client, Connection, RedisResult};
use zino_core::{state::State, LazyLock};
#[derive(Debug, Clone, Copy)]
pub struct Redis;
impl Redis {
#[inline]
pub fn get_value(key: &str) -> RedisResult<String> {
REDIS_CONNECTION.lock().get(key)
}
#[inline]
pub fn set_value(key: &str, value: &str, seconds: u64) -> RedisResult<()> {
REDIS_CONNECTION.lock().set_ex(key, value, seconds)
}
}
static REDIS_CLIENT: LazyLock<Client> = LazyLock::new(|| {
let config = State::shared()
.get_config("redis")
.expect("the `redis` field should be a table");
let database = config
.get_str("database")
.expect("the `database` field should be a str");
let authority = State::format_authority(config, Some(6379));
let url = format!("redis://{authority}/{database}");
Client::open(url)
.expect("fail to create a connector to the redis server")
});
static REDIS_CONNECTION: LazyLock<Mutex<Connection>> = LazyLock::new(|| {
let connection = REDIS_CLIENT.get_connection()
.expect("fail to establish a connection to the redis server");
Mutex::new(connection)
});
这是Zino框架中推荐的使用模式:在配置文件里编写Redis连接信息1,通过Lazy的全局变量初始化Client; 然后定义一个空结构体,进而封装一些自定义方法。仔细想想,这不就类似于其他语言中的“单例模式”吗?
配置项中的密码也可以先写为明文,运行后会在终端里提醒你修改为加密后的。
错误处理
在Zino框架中,我们定义了一个通用的错误类型Error
,主要目的是实现以下功能:
- 基于字符串将任意错误包装成同一类型;
- 支持错误溯源,并能追溯到原始错误;
- 支持传递自定义错误信息上下文类型;
- 支持
tracing
,自动记录错误信息。
这四条需求对Zino框架至关重要,这也是为什么我们没有采用社区中流行的错误处理库,比如anyhow
。
在实际应用开发中,我们往往并不会对具体的错误类型做不同的处理1,而是直接返回错误消息,
所以我们采取基于字符串的错误处理:
#[derive(Debug)]
pub struct Error {
message: SharedString,
source: Option<Box<Error>>,
context: Option<Box<dyn Any + Send>>,
}
其中SharedString
是Zino中用来优化静态字符串处理的类型2。
我们可以调用sources
方法返回一个迭代器进行错误溯源,也可以使用root_source
方法来追溯到原始错误。
对于任意满足Send + 'static
约束的类型,我们可以将它作为错误信息上下文来传递:
pub fn set_context<T: Send + 'static>(&mut self, context: T) {
self.context = Some(Box::new(context));
}
pub fn get_context<T: Send + 'static>(&self) -> Option<&T> {
self.context
.as_ref()
.and_then(|ctx| ctx.downcast_ref::<T>())
}
对于任意实现了std::error::Error
trait的错误类型,我们可以将它转换为Error
类型:
impl<E: error::Error + Send + 'static> From<E> for Error {
fn from(err: E) -> Self {
Self {
message: err.to_string().into(),
source: err.source().map(|err| Box::new(Self::new(err.to_string()))),
context: Some(Box::new(err)),
}
}
}
这样在需要返回Result<T, zino_core::error::Error>
的函数中,我们就可以很方便地使用?
运算符。
需要注意的是,我们的Error
类型本身并没有实现std::error::Error
。
通过为Error
类型实现std::fmt::Display
,我们可以提供对tracing
的集成,让它自动记录错误信息:
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = self.message();
if let Some(source) = &self.source {
let source = source.message();
let root_source = self.root_source().map(|err| err.message());
if root_source != Some(source) {
tracing::error!(root_source, source, message);
} else {
tracing::error!(root_source, message);
}
} else {
tracing::error!(message);
}
write!(f, "{message}")
}
}
每当我们调用.to_string()
时,tracing::error!
就会自动生成一条记录。
当然,你也可以自定义可枚举的错误类型,并为其实现std::error::Error
trait。
请求上下文
与actix-web
、axum
、ntex
等框架不同,我们并不推荐使用提取器(Extractor)模式1,
而是采取类似于Express
和Fiber
那种把很多方法都挂在请求上下文的做法,这会带来几个好处:
- 保持
controller
中只有Request
一个参数,便于批量实现接口以及代码自动生成; - 允许使用者动态判断是否需要从请求中获取信息,避免使用不必要的
Option
类型; - 能够提供非异步的实现,比如从URI中提取查询参数这种本来就应该是同步的2。
当然,由于我们提供了对actix-web
、axum
、ntex
等框架的集成,它们所支持的Handler都是可以直接在Zino中使用的,
也包括提取器模式。
我们通过RequestContext
这一trait来提供请求上下文方法,它包含以下必须要实现的方法:
pub trait RequestContext {
type Method: AsRef<str>;
type Headers;
fn request_method(&self) -> &Self::Method;
fn original_uri(&self) -> &Uri;
fn matched_route(&self) -> Cow<'_, str>;
fn header_map(&self) -> &Self::Headers;
fn get_header(&self, name: &str) -> Option<&str>;
fn client_ip(&self) -> Option<IpAddr>;
fn get_context(&self) -> Option<Context>;
fn get_data<T: Clone + Send + Sync + 'static>(&self) -> Option<T>;
fn set_data<T: Clone + Send + Sync + 'static>(
&mut self,
value: T
) -> Option<T>;
async fn read_body_bytes(&mut self) -> Result<Vec<u8>, Error>;
}
值得注意的是,我们提供的read_body_bytes
方法不会消费Request
对象3,但是它的重复调用并不保证会给出相同的结果,
具体处理方式由实现决定。
有些人总觉得Rust中的提取器很神奇,其实无外乎就是泛型加上宏批量实现罢了,Rust本身并不支持可变参数函数。
axum
的Query
提取器尽管提供了同步的try_from_uri
方法,但它实现的from_request_parts
却是异步的。
在axum
中,实现了FromRequest
的提取器总会消费Request
对象,所以它们只能使用一次,并且只能作为Handler
的最后一个参数。