Leptos 六边形架构实战:用 Rust 泛型与 Feature Flag 打造可测试的全栈应用
Leptos 六边形架构实战用 Rust 泛型与 Feature Flag 打造可测试的全栈应用【免费下载链接】leptosBuild fast web applications with Rust.项目地址: https://gitcode.com/GitHub_Trending/le/leptos在 Rust 全栈框架 Leptos 中业务逻辑与外部服务数据库、第三方 AI 接口的耦合往往会让测试变得困难。本文基于仓库中projects/hexagonal-architecture这一完整示例讲解如何将六边形架构Hexagonal Architecture又称端口与适配器架构的设计原则应用到 Leptos 项目中通过泛型参数 Trait 定义端口 Feature Flag 组装服务图的方式隔离业务逻辑、解耦外部依赖并借助mockall实现任意组合的 mock 测试。读完本文你将掌握一套可在 Leptos SSR 应用中直接落地的分层架构方案并理解为什么在这种约束下“尽量使用泛型、避免Boxdyn Trait”是更优选择。一、六边形架构的核心思想与两条设计约束原文文档开宗明义地指出该仓库/博文旨在将六边形设计原则应用到 Leptos 应用中具体包含三个目标将业务逻辑与子领域隔离Isolating Business Logic from Sub Domains通过解耦设计提升灵活性与可测试性Decoupling design to improve flexibility and testability分层地应用这些原则Applying the principles hierarchically即与外部服务打交道的子领域自身也实现六边形架构而不是只在顶层做一次隔离。同时示例的实现被两条来自 Leptos 生态的硬性约束所主导Server Functions 不能是泛型的Leptos 的#[server]宏生成的函数需要静态可解析的具体类型因此我们不能把泛型 handler 直接塞进 server functionBoxdyn Trait特征对象有运行时开销为了性能设计上尽量使用编译期泛型monomorphization避免特征对象带来的动态分派开销。这两条约束直接决定了整个项目泛型结构体嵌套 类型别名 配置函数的实现形态下文的所有代码都可以回溯到这两条前提。二、分层模型主领域、子领域与外部服务按照六边形架构的思路示例把整个应用的调用图分为三层**主领域Main Domain**负责定义业务问题与流程**子领域Sub Domain**是业务能力的具体单元**外部服务External Service**通常是应用架构图的末端节点是子领域的依赖。在源码中这一模型被精确地表达在 server_types.rs 里领域、子领域、外部服务都被建模为泛型结构体泛型参数即端口Port的抽象而具体的外部服务实现就是适配器Adapter。// 主领域处理器由两个子领域组合而成 #[derive(Clone, Default)] pub struct HandlerStructSubDomain1: SubDomainTrait1, SubDomain2: SubDomainTrait2 { pub sub_domain_1: SubDomain1, pub sub_domain_2: SubDomain2, } // 子领域 1依赖两个外部服务 #[derive(Clone, Default)] pub struct SubDomainStruct1 ExternalService1: ExternalServiceTrait1, ExternalService2: ExternalServiceTrait2, { pub external_service_1: ExternalService1, pub external_service_2: ExternalService2, } // 子领域 2仅依赖一个外部服务 #[derive(Clone, Default)] pub struct SubDomainStruct2ExternalService1: ExternalServiceTrait1 { pub external_service_1: ExternalService1, }每个结构体的泛型参数都受一个 Trait 约束SubDomainTrait1、SubDomainTrait2、ExternalServiceTrait1、ExternalServiceTrait2这些 Trait 就是六边形架构中的端口。而像ExternalService1_1、ExternalService1_2、ExternalService2_1、ExternalService2_2这样的空结构体则扮演适配器的角色分别实现对应端口见下文第四节的 trait_impl。值得一提的错误设计同一文件里错误类型也按照同样的层级结构组织。DomainError通过#[from]自动包裹SubDomain1Error/SubDomain2Error而后者又包裹各自的外部服务错误。这意味着领域层可以逐层把底层错误转换为上层语义同时保持thiserror带来的简洁?传播体验。#[derive(Clone, Error, Debug)] pub enum DomainError { #[error(Underlying Subdomain 1 Error)] SubDomain1Error(#[from] SubDomain1Error), #[error(Underlying Subdomain 2 Error)] SubDomain2Error(#[from] SubDomain2Error), }三、用 Trait 定义端口抽象依赖、生成 Mock端口Trait定义集中在 traits.rs。每个端口都标注了#[automock]来自mockall0.13与#[async_trait]来自axum::async_trait前者自动生成对应的MockXxx结构体后者让 async trait 方法可以在稳定 Rust 上工作。#[automock] #[async_trait] pub trait HandlerTrait { async fn server_fn_1(self) - ResultDomainData, DomainError; async fn server_fn_2(self) - ResultDomainData2, DomainError; async fn server_fn_3(self) - ResultDomainData3, DomainError; } #[automock] #[async_trait] pub trait SubDomainTrait1 { async fn sub_domain_1_method(self) - ResultSubDomain1Data, SubDomain1Error; } #[automock] #[async_trait] pub trait ExternalServiceTrait1 { async fn external_service_1_method(self) - ResultExternalService1Data, ExternalService1Error; }此外还定义了一个极简的Newtraitfn new() - Self用于统一构造入口。由于每个 Trait 都派生出了MockHandlerTrait、MockSubDomainTrait1/2、MockExternalServiceTrait1/2测试时可以用 mock 替换任意一层的真实实现这正是任意组合能力的来源。Trait 的具体实现集中在 trait_impl.rsHandlerStruct实现HandlerTrait、SubDomainStruct1实现SubDomainTrait1、SubDomainStruct2实现SubDomainTrait2、各外部服务结构体实现对应的ExternalServiceTrait。实现体内通过?调用下游端口方法并逐层into()向上转换数据#[async_trait] implSubDomain1, SubDomain2 HandlerTrait for HandlerStructSubDomain1, SubDomain2 where SubDomain1: SubDomainTrait1 Send Sync, SubDomain2: SubDomainTrait2 Send Sync, { async fn server_fn_1(self) - ResultDomainData, DomainError { Ok(self.sub_domain_1.sub_domain_1_method().await?.into()) } // server_fn_3 则组合两个子领域的结果 async fn server_fn_3(self) - ResultDomainData3, DomainError { Ok((self.sub_domain_1.sub_domain_1_method().await?, self.sub_domain_2.sub_domain_2_method().await?).into()) } }注意这里的泛型实现方式HandlerStruct是一个泛型结构体HandlerTrait的实现也是泛型的——只要SubDomain1: SubDomainTrait1、SubDomain2: SubDomainTrait2即可。这正是原文档强调的尽量用泛型代码、避免 Trait Object编译器会为每种具体组合生成专用代码没有任何动态分派开销。数据在各层之间通过From转换完成映射同文件 trait_impl.rs 后半部分ExternalService1Data ExternalService2Data → SubDomain1Data → DomainDataUI 层再映射为 ui_types.rs 中可序列化Serialize/Deserialize的UiMappingFromDomainData等类型由 server function 返回给客户端。四、用 Feature Flag 组装服务图配置即代码原文档指出我们的主应用通过配置标志configuration flags来构建它的服务布局。对应的实现就是 config.rs 中的config()函数它使用cfg_if宏根据 feature 开关选择不同的服务组合pub fn config() - HandlerStructAlias { cfg_if::cfg_if! { if #[cfg(featureconfig_1)] { fn server_handler_config_1() - HandlerStruct SubDomainStruct1ExternalService1_1, ExternalService2_1, SubDomainStruct2ExternalService1_1, { HandlerStruct::default() } server_handler_config_1() } else { fn server_handler_config_2() - HandlerStruct SubDomainStruct1ExternalService1_2, ExternalService2_2, SubDomainStruct2ExternalService1_2, { HandlerStruct::new() } server_handler_config_2() } } }其配套的类型别名定义在 server_types.rs#[cfg(feature config_1)] pub type HandlerStructAlias HandlerStruct SubDomainStruct1ExternalService1_1, ExternalService2_1, SubDomainStruct2ExternalService1_1, ; #[cfg(not(feature config_1))] pub type HandlerStructAlias HandlerStruct SubDomainStruct1ExternalService1_2, ExternalService2_2, SubDomainStruct2ExternalService1_2, ;这组代码同时解决了前文提到的Server Functions 不能泛型问题HandlerStructAlias把所有可能配置的服务变体收敛为一个具体的、编译期确定的类型server function 与中间件只要引用这个别名即可无需关心当前启用的是哪套配置。在 Cargo.toml 中feature 的定义如下config_1 []为空 feature仅作编译开关ssr、hydrate为服务端渲染与客户端水合 feature[features] config_1 [] hydrate [leptos/hydrate] ssr [ dep:axum, dep:tokio, dep:tower, dep:tower-http, dep:leptos_axum, leptos/ssr, leptos_meta/ssr, leptos_router/ssr, dep:tracing, ]同时 Cargo.toml 的[package.metadata.leptos]配置了bin-features [ssr]、lib-features [hydrate]、bin-default-features false、lib-default-features false即二进制目标默认启用 ssr、库目标默认启用 hydrate二者都不带默认 feature。要切换服务组合只需在编译时附加--features config_1或设置对应环境变量即可。五、把服务图注入 Leptos 路由ServerState 与 Context原文档展示了如何在main中构建应用的服务图并交给 Leptos 路由。仓库中的 main.rs 给出了完整可运行版本#[cfg(feature ssr)] #[tokio::main] async fn main() { use axum::Router; use leptos::logging::log; use leptos::prelude::*; use leptos_axum::{generate_route_list, LeptosRoutes}; use leptos_hexagonal_design::{ app::*, config::config, server_types::{HandlerStructAlias, ServerState}, }; let conf get_configuration(None).unwrap(); let addr conf.leptos_options.site_addr; let leptos_options conf.leptos_options; let routes generate_route_list(App); let handler config(); // feature flag 驱动的配置函数 let handler_c handler.clone(); let server_state ServerState { handler, leptos_options: leptos_options.clone(), }; let app Router::new() .leptos_routes_with_context( server_state, routes, move || provide_context(handler_c.clone()), // handler 作为 context 注入 { let leptos_options leptos_options.clone(); move || shell(leptos_options.clone()) }, ) .fallback(leptos_axum::file_and_error_handler:: ServerStateHandlerStructAlias, _, (shell)) .with_state(server_state); log!(listening on http://{}, addr); let listener tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app.into_make_service()).await.unwrap(); }这里的关键机制有两点ServerState承载整个服务图。server_types.rs 中的ServerStateHandler结构体同时持有 handler 与LeptosOptions作为 axum 的 state 传给路由并通过leptos_routes_with_context的闭包move || provide_context(handler_c.clone())把 handler 注入 Leptos 的 context之后在服务端任何 context 可用之处都能取到它——包括 server function 的中间件见 middleware.rsFromRef让旧代码无缝兼容。LeptosOptions需要通过 axum 的FromRef从ServerState中提取trait_impl.rs 中实现了implHandler: HandlerTrait Clone FromRefServerStateHandler for LeptosOptions这样leptos_axum内部依赖LeptosOptions的既有逻辑如静态文件处理无需改动即可工作。lib.rs中#[cfg(feature ssr)]门控了 config、middleware、server_types、trait_impl、traits 这些纯服务端模块客户端只编译app与ui_types从模块层面进一步保证领域/服务代码不会泄漏到 WASM 包中。六、Server Functions 与中间件从 Context 取回服务图原文档展示了 server function 中取用 handler 的写法。仓库中的 app.rs 提供了三个 server function 的完整实现每个都从 context 中取出HandlerStructAlias并调用其方法再把领域数据.into()成 UI 映射类型返回#[server] #[middleware(crate::middleware::SubDomain1Layer)] pub async fn server_function_1() - ResultUiMappingFromDomainData, ServerFnError { Ok(expect_context::HandlerStructAlias() .server_fn_1() .await? .into()) }值得展开的是#[middleware(crate::middleware::SubDomain1Layer)]这一行。原文档特别提醒handler 作为 context 注入后在任何 context 可用的地方都能取到包括我们在 server function 上定义的中间件见 middleware.rs。中间的 middleware.rs 实现了一个 tower 中间件SubDomain1Layer在请求真正进入 handler 之前它通过expect_context::ServerStateHandlerStructAlias()取出服务图并实际调用一次子领域 1 的业务方法作为前置校验——调用成功才放行请求失败则直接返回 403 Access deniedfn poll(self: Pinmut Self, cx: mut Context_) - PollSelf::Output { let this self.project(); let subdomain_1 expect_context::ServerStateHandlerStructAlias() .handler .sub_domain_1; let mut subdomain_1_fut subdomain_1.sub_domain_1_method(); match Pin::as_mut(mut subdomain_1_fut).poll(cx) { Poll::Ready(Ok(_)) { println!(Middleware for Subdomain 1 Passed, calling request...); this.req_fut.poll(cx) } Poll::Ready(Err(_)) Poll::Ready(Ok(Response::builder() .status(http::StatusCode::FORBIDDEN) .body(Body::from(Access denied)) .unwrap())), Poll::Pending Poll::Pending, } }这个例子很好地体现了六边形架构的额外收益由于业务逻辑被抽象为端口方法中间件可以在不感知具体外部服务实现的情况下复用同一套领域能力做鉴权、校验或限流。七、Mock 任意组合mockall 驱动的分层测试原文档强调我们可以用任意组合 mock 任意 service trait。这正是#[automock]的价值所在——它为每个端口生成 mock 结构体测试时既可以把外部服务全部 mock 掉只测真实子领域也可以把子领域 mock 掉只测主领域 handler甚至混合 mock 与真实实现。测试代码位于 lib.rs 的tests模块中共 4 个#[tokio::test]覆盖了四个层次的组合。第一层是原文档给出的示例——真实子领域 mock 外部服务#[tokio::test] pub async fn test_subdomain_1_with_mocks() - Result(), Boxdyn Error { let mut mock_external_service_1 MockExternalServiceTrait1::new(); mock_external_service_1 .expect_external_service_1_method() .returning(|| { println!(Mock external service 1); Ok(ExternalService1Data) }); let mut mock_external_service_2 MockExternalServiceTrait2::new(); mock_external_service_2 .expect_external_service_2_method() .returning(|| { println!(Mock external service 2); Ok(ExternalService2Data) }); let real_subdomain_1_with_mock_externals SubDomainStruct1 { external_service_1: mock_external_service_1, external_service_2: mock_external_service_2, }; let data real_subdomain_1_with_mock_externals .sub_domain_1_method() .await?; assert_eq!(data, SubDomain1Data); Ok(()) }其余三个测试test_subdomain_2_with_mocks、test_handler_with_mocks、test_handler_with_mock_and_real_mix分别是真实子领域 2 mock 外部服务真实 HandlerStruct mock 两个子领域直接验证server_fn_1/2/3三条业务链路以及子领域 1 用真实 mock 外部服务、子领域 2 用 mock的混合模式。这些测试之所以可行底层依赖两点一是每个端口 Trait 都被#[automock]生成出MockXxx::new()与expect_xxx().returning(...)API二是结构体字段全部pub允许测试直接构造任意组合。由于整个设计是泛型的mock 类型与真实类型只要实现同一端口即可互换编译器保证类型安全。八、运行与测试按照原文档的操作指引在本示例目录下执行# 运行全部 mock 分层测试需要 ssr feature因为测试模块引用了服务端类型 cargo test --features ssr # 启动开发服务器需要 cargo-leptos配置见 Cargo.toml 的 [package.metadata.leptos] cargo leptos serve启动后浏览器访问127.0.0.1:3000即可看到页面Cargo.toml 中site-addr 127.0.0.1:3000热重载监听端口为 3001。访问页面时HomePage组件会在Effect中依次 dispatch 三个ServerAction对应server_function_1/2/3从而触发完整的浏览器 → server function → context 取 handler → 子领域 → 外部服务调用链切换config_1feature 则能看到不同外部服务实现的打印输出。仓库还附带 end2end 的 Playwright 端到端测试骨架可通过cargo leptos end2end运行。九、小结回顾整个示例projects/hexagonal-architecture把六边形架构在 Leptos 中的落地归纳为四个可复用步骤用泛型结构体 Trait 端口建模领域、子领域、外部服务三层server_types.rs、traits.rs用 feature flag 类型别名收敛配置让编译期决定具体服务组合从而绕过Server Functions 不能泛型的限制config.rs、Cargo.toml用ServerStateprovide_context注入服务图在 server function 与中间件中随时取用main.rs、middleware.rs用#[automock]生成 mock实现真实/ mock 任意组合的分层测试lib.rs。这套方案的核心取舍在于以泛型约束换取零动态分派开销与编译期类型安全以类型别名换取 server function 的静态可解析性。如果你的 Leptos 项目正面临领域逻辑难以测试或外部服务绑定过死的问题可以直接参考本示例的目录结构与 trait 划分将其迁移到自己的业务模块中。【免费下载链接】leptosBuild fast web applications with Rust.项目地址: https://gitcode.com/GitHub_Trending/le/leptos创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考