最新资讯
reddit.com OpenAI 称 Astra 仅满足 10% 安全任务标准 dev.to FreeToken:在自有 GPU 上运行前沿 MoE 模型及其内存需求 dev.to 当 AI Agent 说“完成”时,究竟完成了什么? dev.to 部署 Underdog Bark 3.0 Pro:逐字转写、自定义后端与分块设置 dev.to 为艰难对话构建 AI:五个 Agent、一条安全正则与一位盲评监督者 techcrunch.com Palo Alto Networks 洽谈以 5 亿美元收购 AI Agent 安全初创公司 Clutch theinformation.com 黄仁勋被要求就涉华间谍指控向国会作简报 www.infoq.com Edera 推出 Object Capability Bounded Authority,试图将凭证与 AI Agent 工作负载隔离 wechat 这波杀疯了!旗舰级模型不限量、1个月视频额度免费领 bloomberg.com AI 抛售蔓延至信用市场,软银领跌亚洲美元债 dev.to 如何在专用 GPU 服务器上用 OpenAI 兼容 API 部署 LLM dev.to 审计 153 份 Claude Code 与 Codex 会话记录:User-Agent 与 URL 中的邮箱泄露
多元动态
登录 注册
行业动态 dev.to · @Thomas Tartrau

分层架构:用 Axum 与 SQLx 构建 Rust API

原文 我的收藏

作者介绍其工作流引擎 IronFlow 的 Rust 分层架构:项目分布在 20 个 crate 的 Cargo workspace 中,主要层包括 ironflow-store(实体与数据访问,含 traits 与实现)、postgres(基于 SQLx 的实现)和 memory(内存实现)。依赖只允许单向向下,REST handler 不能直接执行 SQL 查询,实体层不感知 Axum。文章给出真实代码、有效决策以及作者会重新考虑的部分。

AI推荐理由 作者以自建 IronFlow 工作流引擎为例,展示 20 个 crate 的 Cargo workspace 分层结构(store/entities/postgres/memory),依赖单向向下、handler 不得直接调用 SQL。属于具体工程经验分享,有代码与取舍说明,但为个人项目自述,无独立验证,也未给出性能或维护性数据。对 Rust/Axum 后端开发者有参考价值,对 AI/模型/Agent 主题相关性弱。

62

正文

Most Rust/Axum APIs I see on GitHub put everything in the same file: the handler extracts parameters, runs the SQL query, applies business logic, and returns JSON. That works for a demo project. On a production API with 30+ endpoints, state machines, SSE, JWT auth, and a worker lease system, it falls apart. I structured IronFlow - a workflow engine where workflows are Rust code (see why I chose Rust for this project) - into strict layers spread across a 20-crate Cargo workspace. Dependencies only go in one direction: downward. A REST handler cannot call a SQL query directly, and an entity has no idea Axum exists. This article shows this architecture with real code, the decisions that worked, and the ones I would reconsider. The workspace structure The Cargo workspace contains 20 crates. The main layers are: ironflow-store/ # Entities + data access (traits + implementations) src/entities/ # Structs, enums, FSM src/postgres/ # PostgreSQL implementation (SQLx) src/memory/ # In-memory implementation (tests/dev) ironflow-engine/ # Orchestration, execution, events ironflow-api/ # Axum handlers, DTOs, middleware, OpenAPI src/entities/ # API DTOs (separate from store entities) src/routes/ # Handlers by domain ironflow-core/ # AI providers, shell/HTTP/agent operations ironflow-auth/ # JWT, authentication extractors ironflow-types/ # Shared types (JSON envelopes) Enter fullscreen mode Exit fullscreen mode Each layer is a separate crate in the workspace. The ironflow-api crate cannot access sqlx directly: it goes through the Store trait defined in ironflow-store. Entities: the domain without a framework The entities layer lives in ironflow-store/src/entities/. It defines domain types without depending on Axum or SQLx for queries. One file per concept: run.rs, step.rs, run_status.rs, step_status.rs, trigger_kind.rs. The FSM in the type system The core of IronFlow is a finite state machine (FSM) that manages the lifecycle of each run. Valid transitions are defined in an exhaustive matches!: #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RunStatus { Pending, Running, Completed, Failed, Retrying, Cancelled, AwaitingApproval, Warning, } impl RunStatus { pub fn can_transition_to(&self, target: &RunStatus) -> bool { if self == target && self.is_terminal() { return true; // idempotent } matches!( (self, target), (RunStatus::Pending, RunStatus::Running) | (RunStatus::Pending, RunStatus::Cancelled) | (RunStatus::Running, RunStatus::Pending) // lease expired | (RunStatus::Running, RunStatus::Completed) | (RunStatus::Running, RunStatus::Failed) | (RunStatus::Running, RunStatus::Warning) | (RunStatus::Running, RunStatus::Retrying) | (RunStatus::Running, RunStatus::Cancelled) | (RunStatus::Running, RunStatus::AwaitingApproval) | (RunStatus::Retrying, RunStatus::Running) | (RunStatus::Retrying, RunStatus::Failed) | (RunStatus::Retrying, RunStatus::Cancelled) | (RunStatus::AwaitingApproval, RunStatus::Running) | (RunStatus::AwaitingApproval, RunStatus::Failed) | (RunStatus::AwaitingApproval, RunStatus::Cancelled) ) } pub fn is_terminal(&self) -> bool { matches!( self, RunStatus::Completed | RunStatus::Failed | RunStatus::Warning | RunStatus::Cancelled ) } } Enter fullscreen mode Exit fullscreen mode The matches! macro makes the transition table readable at a glance. Adding a transition means adding a line. Removing a state triggers a compiler warning everywhere it is used. An important detail: terminal-to-same-terminal transitions are idempotent. A run that is already Failed receiving Failed is not an error. This simplifies concurrency scenarios between workers. The generic FsmState For SQL-side transitions (via the lib_fsm library), IronFlow wraps the status with the state machine ID: #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct FsmState { pub state: T, pub state_machine_id: Uuid, } Enter fullscreen mode Exit fullscreen mode Handlers pattern-match on run.status.state, while SQL queries use run.status.state_machine_id for atomic transitions. A single type carries both pieces of information. The Run entity #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Run { pub id: Uuid, pub workflow_name: String, pub status: FsmState, pub trigger: TriggerKind, pub payload: Value, pub error: Option, pub retry_count: u32, pub max_retries: u32, pub cost_usd: Decimal, pub duration_ms: u64, pub created_at: DateTime, pub updated_at: DateTime, pub started_at: Option>, pub completed_at: Option>, pub labels: HashMap, pub scheduled_at: Option>, pub created_by: Option, } Enter fullscreen mode Exit fullscreen mode The Run is the store's internal model. The API never exposes it directly - it uses a RunResponse (DTO) that controls what goes out. IDs are UUID v7 (chronologically sorted, good for B-tree index performance). The store: traits and two implementations The store layer defines async traits for data access, with two implementations: PostgresStore for production and InMemoryStore for tests. The RunStore trait pub trait RunStore: Send + Sync { fn create_run(&self, req: NewRun) -> StoreFuture; fn get_run(&self, id: Uuid) -> StoreFuture>; fn list_runs( &self, filter: RunFilter, page: u32, per_page: u32 ) -> StoreFuture>; fn update_run_status( &self, id: Uuid, new_status: RunStatus ) -> StoreFuture; fn pick_next_pending( &self, lease: Option ) -> StoreFuture>; fn reap_expired_leases( &self, limit: u32 ) -> StoreFuture>; // ... create_step, update_step, list_steps, get_stats, delete_run } Enter fullscreen mode Exit fullscreen mode StoreFuture is a Pin> + Send + 'a>> - needed for object safety so the store can be used as Arc. The Store trait unifies all capabilities: pub trait Store: RunStore + UserStore + ApiKeyStore + SecretStore + AuditLogStore + ArtifactStore + LogStore {} impl Store for T {} Enter fullscreen mode Exit fullscreen mode The blanket impl means any type that implements all 7 sub-traits is automatically a Store. Both InMemoryStore and PostgresStore implement all 7. Two interchangeable backends The PostgreSQL implementation uses SELECT FOR UPDATE SKIP LOCKED for concurrent run picking: impl RunStore for PostgresStore { fn pick_next_pending( &self, lease: Option ) -> StoreFuture> { Box::pin(async move { // SELECT FOR UPDATE SKIP LOCKED inside a transaction // Atomic transition Pending -> Running }) } } Enter fullscreen mode Exit fullscreen mode The in-memory implementation uses an RwLock and a sorted Vec. Same API, same behavior, zero PostgreSQL required for tests: let store: Arc = Arc::new(InMemoryStore::new()); let run = store.create_run(NewRun { workflow_name: "deploy".to_string(), trigger: TriggerKind::Manual, payload: json!({}), max_retries: 3, // ... }).await?.into_run(); Enter fullscreen mode Exit fullscreen mode The ironflow-api crate tests use InMemoryStore. No Docker, no migrations, no cleanup between tests. The PostgresStore has its own integration tests. Store error handling Store errors are storage errors, not HTTP errors: #[derive(Debug, Error)] pub enum StoreError { #[error("run not found: {0}")] RunNotFound(Uuid), #[error("step not found: {0}")] StepNotFound(Uuid), #[error("invalid status transition: {from} -> {to}")] InvalidTransition { from: RunStatus, to: RunStatus }, #[error("lease lost on run {run_id}")] LeaseLost { run_id: Uuid, held_by: Option }, #[error("artifact {name:?} already exists on step {step_id}")] DuplicateArtifact { step_id: Uuid, name: String }, #[error("database error: {0}")] Database(String), } Enter fullscreen mode Exit fullscreen mode The conversion to HTTP status codes happens in the API crate, not here. The store has no concept of a StatusCode. The API layer: Axum handlers and DTOs The ironflow-api crate assembles everything. It contains Axum handlers, response DTOs (separate from store entities), middleware, and OpenAPI documentation. DTOs separate from entities The API crate defines its own response types in src/entities/: // ironflow-api/src/entities/run.rs - API DTO #[derive(Debug, Serialize, Deserialize)] pub struct RunResponse { pub id: Uuid, pub workflow_name: String, pub status: RunStatus, pub trigger: TriggerKind, pub error: Option, pub cost_usd: Decimal, pub duration_ms: u64, pub created_at: DateTime, pub created_by: Option, // ... } impl From for RunResponse { fn from(run: Run) -> Self { // Explicit conversion, controls what goes out } } Enter fullscreen mode Exit fullscreen mode RunResponse is the public contract. The store's Run is the internal model. The From conversion is the control point: you choose what is exposed and how. A typical handler #[utoipa::path( get, path = "/api/v1/runs/{id}", tags = ["runs"], params(("id" = Uuid, Path, description = "Run ID")), responses( (status = 200, body = RunDetailResponse), (status = 401), (status = 404), ), security(("Bearer" = [])) )] pub async fn get_run( _auth: Authenticated, State(state): State, Path(id): Path, ) -> Result { let run = state.get_run_or_404(id).await?; let (steps, deps, artifacts) = join!( state.store.list_steps(id), state.store.list_step_dependencies(id), state.store.list_artifacts_for_run(id) ); let response = RunDetailResponse { run: RunResponse::from(run), steps: steps?.into_iter().map(StepResponse::from).collect(), // ... }; Ok(ok(response)) } Enter fullscreen mode Exit fullscreen mode The handler does 3 things: Verify authentication (Authenticated extractor) Fetch data in parallel via tokio::join! Convert to DTOs and return The ? converts StoreError to ApiError via the From impl. Tracing is automatic. OpenAPI docs are generated by #[utoipa::path]. Error conversion StoreError converts automatically to ApiError via #[from]: #[derive(Debug, Error)] pub enum ApiError { #[error("run not found")] RunNotFound(Uuid), #[error("authentication required")] Unauthorized, #[error("invalid credentials")] InvalidCredentials, #[error("{0}")] Conflict(String), #[error("database error")] Store(#[from] StoreError), // ... } impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self { ApiError::RunNotFound(_) => StatusCode::NOT_FOUND, ApiError::Unauthorized => StatusCode::UNAUTHORIZED, ApiError::Store(StoreError::LeaseLost { .. }) => StatusCode::CONFLICT, ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR, // ... }; let envelope = ErrorEnvelope { code: self.code().to_string(), message: self.to_string(), }; (status, Json(json!({ "error": envelope }))).into_response() } } Enter fullscreen mode Exit fullscreen mode Each StoreError is translated to a precise HTTP code. A LeaseLost is a 409 Conflict (the client can retry), not a 500. A RunNotFound is a 404. The store does not decide the HTTP code, the API does. Router assembly pub fn create_router(state: AppState, config: RouterConfig) -> Router { let internal_routes = Router::new() .route("/runs/next", get(pick_next_run)) .route("/runs/{id}/status", put(update_run_status)) .route("/runs/{id}/lease", post(renew_lease)) .layer(from_fn(worker_token_auth)); let api_v1 = Router::new() .route("/runs", get(list_runs).post(create_run)) .route("/runs/{id}", get(get_run)) .route("/runs/{id}/cancel", post(cancel_run)) .route("/runs/{id}/approve", post(approve_run)) .route("/workflows", get(list_workflows)) .route("/stats", get(get_stats)) .route("/events", get(events)); Router::new() .nest("/api/v1/internal", internal_routes) .nest("/api/v1", api_v1) .layer(RequestBodyLimitLayer::new(2 * 1024 * 1024)) .layer(from_fn(security_headers)) } Enter fullscreen mode Exit fullscreen mode Two separate route groups: internal routes (worker-to-API, protected by a dedicated token) and public routes (JWT authentication). Internal routes use worker_token_auth, public routes use Authenticated. Both go through the same AppState. AppState: dependency injection #[derive(Clone)] pub struct AppState { pub store: Arc, pub engine: Arc, pub jwt_config: Arc, pub worker_token: String, } Enter fullscreen mode Exit fullscreen mode The Arc is the injection point. In production, it is a PostgresStore. In tests, it is an InMemoryStore. The handler does not know which one it uses. Tests: the concrete benefit of the architecture The get_run handler tests illustrate the benefit of traits: #[tokio::test] async fn existing_run() { let store = Arc::new(InMemoryStore::new()); let run = store.create_run(NewRun { workflow_name: "test".to_string(), trigger: TriggerKind::Manual, payload: json!({}), max_retries: 3, // ... }).await.unwrap().into_run(); let state = test_state_with_store(store); let app = Router::new() .route("/{id}", get(get_run)) .with_state(state); let resp = app.oneshot( Request::get(format!("/{}", run.id)) .header("authorization", auth_header) .body(Body::empty()).unwrap() ).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } Enter fullscreen mode Exit fullscreen mode No Docker, no database, no migrations. The test instantiates an InMemoryStore, creates a run, and verifies the handler returns 200. Execution takes a few milliseconds. What works well Store traits. Two interchangeable implementations (PostgresStore and InMemoryStore) simplify testing and allow starting the project without a database. The Arc in AppState makes injection transparent. DTOs separate from entities. The API's RunResponse and the store's Run are distinct types. Modifying the internal model does not break the API contract. The From conversion is the single control point. End-to-end typed errors. From StoreError to ApiError, every conversion is explicit. The matches! in IntoResponse documents the error-to-HTTP-code mapping. No hidden .unwrap(). Generated OpenAPI documentation. utoipa annotates handlers and types. The spec is always in sync with the code. The embedded dashboard consumes this spec directly. What I would change The missing service layer. Today, business logic lives in handlers (for simple cases) or in the engine (for orchestration). An explicit ironflow-services crate with status transition logic, cost limit validation, and event construction would make handlers thinner and tests more targeted. Boxed StoreFutures. The Pin> is needed for object safety of dyn RunStore, but adds one allocation per call. For non-dynamic usage (when the concrete type is known), direct async methods would be more performant. This is the classic flexibility vs. performance tradeoff. The numbers The IronFlow workspace: Metric Value Crates in the workspace 20 REST endpoints (public + internal) 30+ Lines of Rust code ~25,000 Unit tests 150+ Store backends 2 (PostgreSQL + in-memory) Supported AI providers 10 The project is open source on GitLab. The release profile uses lto = true, strip = true, codegen-units = 1, and panic = "abort". The resulting binary is compact and starts in under a second. Conclusion Layered architecture is not an invention. It is a classic pattern from Java/C#/.NET. What is specific to Rust is that the type system and Cargo workspaces make this separation enforced by the compiler, not by convention. A handler that tries to import sqlx directly will not compile if the ironflow-api crate does not list it in its dependencies. The key point of IronFlow: store traits with two implementations. PostgresStore for production, InMemoryStore for tests. This is what makes handler tests fast and reliable without external infrastructure. The cost is real: more crates, more From impls, more boilerplate for error conversions. But on a project with concurrent workers, leases, and state machines, the maintainability gains easily justify the investment. If you are starting a Rust API with Axum, begin by separating entities from the rest. Add a store trait when you want to test without a database. Add DTOs when the internal model diverges from the API contract. And split into crates when compilation times or responsibility boundaries justify it. To see how this architecture supports real use cases, read how IronFlow orchestrates AI agents for automated code review.

来源报道 · 1 篇 · 以下链接均前往原始媒体

Thomas Tartrau 分层架构:用 Axum 与 SQLx 构建 Rust API 09-12 · 原文发布
← 返回精选动态 原文

更多内容