引言

Zino致力于打造基于Rust语言的新一代组装式应用开发框架, 提供一站式跨平台多端解决方案,可用于后端API开发、桌面应用开发等。 我们奉行『约定优于配置』的原则,提供开箱即用的功能模块,极大提升开发效率; 并通过应用接口抽象与actix-webaxumdioxusntex等框架集成, 打通社区生态。

Star History Chart

入门指南

快速开始

你可以从代码仓库中的示例actix-appaxum-appdioxus-desktop或者ntex-app开始体验Zino。

git clone https://github.com/zino-rs/zino.git
cd zino/examples/axum-app
cargo run

如果你能在终端中看到日志输出,那就表明axum-app成功运行了!需要注意的是:

创建应用

我们使用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模板文件,目前支持TeraMiniJinja模板。

配置文件

Zino框架支持根据运行环境来加载配置,不同环境的配置项定义在config/config.{env}.toml文件中。 具体运行环境的选择,按照以下优先顺序来判断:

  1. 应用启动时传入的--env参数,如cargo run -- --env=dev
  2. 如果命令参数不存在,将尝试读取环境变量ZINO_APP_ENV1
  3. 假如环境变量也不存在,将根据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"

需要注意的是,这里的nameversion是指应用的名称和版本,与Cargo.toml[package]nameversion不是一个概念。

在这里,我们只列出全局配置项,具体功能的配置项将在后面的功能模块里给出。

  • name:应用名称
    name = "DataCube"
    
  • version: 应用版本
    version = "1.0"
    
  • secret: 应用密文,用于推导Applicationsecret_key。 当缺失时,会根据应用名称和版本自动生成。
    secret = "SecretPhrase"
    
1

当开启了feature = "dotenv"时,Zino框架也会自动加载项目目录中的.env文件。

基础模块

Zino框架的主要功能都由zino-core提供,这是与具体Web框架(如actix-webaxum)无关的抽象, 其中最基础的就是以下七大模块:

应用接口抽象

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方法的实现中指定。对于同步任务,不涉及到异步运行时的选择, 我们就在Applicationspawn方法中提供了默认实现。

这就是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-webaxumdioxus-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-webaxum中的路由、中间件可以直接在我们的Zino框架中使用。 确保充分理解了这一点,对我们的应用开发至关重要。

1

虽然大部分情况下我们还是会优先选择tokio

状态管理

在Zino框架中,应用状态是由State类型提供的:

#[derive(Debug, Clone)]
pub struct State<T = ()> {
    env: Env,
    config: Table,
    data: T,
}

它包含有运行环境Env、TOML格式的配置文件Table以及自定义数据类型T

不同于很多Web框架,我们的应用状态管理是基于惰性初始化的全局变量来实现的,主要考量如下:

  1. 应用状态通常存在于整个运行期间,也就是说它的生命周期为'static
  2. 应用状态通常只需要初始化加载一次,并且在运行期间并不会被修改;
  3. 尽可能避免在controllerservice方法中传递应用状态参数。

回到具体实现上,最核心的几行代码就是

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; 然后定义一个空结构体,进而封装一些自定义方法。仔细想想,这不就类似于其他语言中的“单例模式”吗?

1

配置项中的密码也可以先写为明文,运行后会在终端里提醒你修改为加密后的。

错误处理

在Zino框架中,我们定义了一个通用的错误类型Error,主要目的是实现以下功能:

  1. 基于字符串将任意错误包装成同一类型;
  2. 支持错误溯源,并能追溯到原始错误;
  3. 支持传递自定义错误信息上下文类型;
  4. 支持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!就会自动生成一条记录。

1

当然,你也可以自定义可枚举的错误类型,并为其实现std::error::Error trait。

2

我们的Error类型对于静态字符串的处理有巨大的性能优势,具体可以参考我们的box_error测试。

请求上下文

actix-webaxumntex等框架不同,我们并不推荐使用提取器(Extractor)模式1, 而是采取类似于ExpressFiber那种把很多方法都挂在请求上下文的做法,这会带来几个好处:

  1. 保持controller中只有Request一个参数,便于批量实现接口以及代码自动生成;
  2. 允许使用者动态判断是否需要从请求中获取信息,避免使用不必要的Option类型;
  3. 能够提供非异步的实现,比如从URI中提取查询参数这种本来就应该是同步的2

当然,由于我们提供了对actix-webaxumntex等框架的集成,它们所支持的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,但是它的重复调用并不保证会给出相同的结果, 具体处理方式由实现决定。

1

有些人总觉得Rust中的提取器很神奇,其实无外乎就是泛型加上宏批量实现罢了,Rust本身并不支持可变参数函数。

2

axumQuery提取器尽管提供了同步的try_from_uri方法,但它实现的from_request_parts却是异步的。

3

axum中,实现了FromRequest的提取器总会消费Request对象,所以它们只能使用一次,并且只能作为Handler的最后一个参数。

更多资源

微信公众号

Zino开发框架

知乎专栏

Zino开发框架技术解读