YukiLog - 4 - Repository 层

ORM 打通了后端与数据库的信道,但我们不想让 SQL 的影子渗透到整个项目。本篇介绍 Repository 层如何在这里竖起一道墙

引言

上一篇我们用 SeaORM 打通了后端与数据库的连接,生成了对应每张表的 Entity

但现在有一个问题:如果在业务逻辑里直接写 SeaORM 查询,整个项目就会到处散落着 posts::Entity::find()Column::Status.eq("published") 这样的代码。一旦数据库表结构变动,或者哪天想换一个 ORM,改动就会蔓延到每一个角落

Repository 层的职责就是在这里竖起一道墙

墙的这一侧是数据库的世界:SQL、ORM、ModelActiveModel

墙的另一侧是纯粹的 Rust 世界:结构体、枚举、Result

越过这道墙之后,上层代码永远不需要知道数据是怎么存的,只需要调用函数、拿到结果


DTO:跨越边界的数据载体

Entity 层生成的 Model 是数据库行的直接映射,它的类型反映的是数据库的现实:

// Entity 生成的 Model,status 是 Option<String>
pub struct Model {
    pub id: i64,
    pub title: String,
    pub status: Option<String>,  // 数据库存的是字符串
    // ...
}

但在 Rust 的世界里,一个只能是 "draft""published" 的字段,不应该是 StringString 意味着任何值都合法,这不是我们想要的

所以 Repository 层定义了自己的数据结构:DTO (Data Transfer Object)

pub struct PostDto {
    pub id: i64,
    pub title: String,
    pub status: Option<PostStatus>,  // Rust 枚举,只有合法值
    // ...
}

ModelPostDto 的转换发生在边界上,通过 TryFrom 实现:

impl TryFrom<posts::Model> for PostDto {
    type Error = RepoError;

    fn try_from(model: posts::Model) -> Result<Self, Self::Error> {
        let status = match model.status.as_deref() {
            None => None,
            Some(s) => Some(PostStatus::try_from(s)?),
        };

        Ok(Self {
            id: model.id,
            title: model.title,
            status,
            // ...
        })
    }
}

这个转换是有可能失败的,所以返回的是 Result。如果数据库里存了一个不认识的 status 字符串,这里会返回错误,而不是把脏数据悄悄传递给上层


Enum 约束:业务语义属于代码

这里值得停下来聊一个设计决策:为什么不在数据库里直接用 Enum 类型?

PostgreSQL 原生支持 CREATE TYPE post_status AS ENUM ('draft', 'published'),这样数据库本身就能拒绝非法值。但我没有这么做,原因是我对数据库的定位是:数据库只是存储的抽象,业务语义由代码负责

数据库不应该承载业务规则。它的职责是可靠地存取数据,至于这些数据代表什么含义、有哪些合法状态,是应用层的事情。这个边界一旦模糊,业务逻辑就会开始向数据库迁移,维护成本会越来越高

所以 domain/status.rs 里定义了三组枚举,覆盖项目里所有有状态的实体:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PostStatus {
    Draft,
    Published,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommentStatus {
    Approved,
    Pending,
    Spam,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LinkStatus {
    Active,
    Pending,
    Broken,
}

每个枚举都实现了 TryFrom<&str>,负责从数据库字符串转换过来;以及 as_str(),负责写入数据库时转回字符串。转换的边界就在 Repository 层,出了这道门,上层代码只会看到 PostStatus::Published,永远不会看到裸字符串 "published"


CRUD:输入结构体的设计

Repository 层为每个实体定义了两个输入结构体:CreateXxxUpdateXxx

创建很直接,就是一组必填和可选字段:

pub struct CreatePost {
    pub title: String,
    pub slug: String,
    pub summary: Option<String>,
    pub content: String,
    pub cover_image: Option<String>,
    pub status: Option<String>,
    pub theme_id: Option<i64>,
}

更新稍微有意思一点。先想一个问题:调用方发来一个更新 summary 的请求,可能有三种意图:

1. 我没提 summary,你别动它
2. 我要把 summary 清空,设成 null
3. 我要把 summary 改成 "新的摘要"

用普通的 Option<String> 来表达:

summary: None              // 到底是意图1还是意图2?不知道
summary: Some("新的摘要")  // 意图3,没问题

None 的语义是模糊的,没办法区分"没提这个字段"和"想清空这个字段"

解决方案是 Option<Option<T>>,把三种意图全部表达清楚:

summary: None                    // 外层 None:没提这个字段,别动它
summary: Some(None)              // 外层 Some,内层 None:要清空它
summary: Some(Some("新的摘要"))  // 外层 Some,内层 Some:要改成这个值

外层 Option 回答:这次更新有没有涉及这个字段?
内层 Option 回答:如果涉及了,新值是什么(可以是 null)?

注意只有本身就可以为 null 的字段才需要这个模式,像 title 这种不能为空的字段,Option<String> 就够了——不存在"清空 title"这种操作

pub struct UpdatePost {
    pub title: Option<String>,           // 普通字段:None=不改,Some(v)=改成v
    pub summary: Option<Option<String>>, // 可空字段:None=不改,Some(None)=清空,Some(Some(v))=改成v
    pub cover_image: Option<Option<String>>,
    pub status: Option<Option<PostStatus>>,
    pub theme_id: Option<Option<i64>>,
}

写入时,只有 Some 的字段才会被 Set,其余保持 NotSet,SeaORM 生成的 SQL 只会 UPDATE 真正变动的列:

let mut active = model.into_active_model();

if let Some(v) = patch.title {
    active.title = Set(v);
}
if let Some(v) = patch.status {
    active.status = Set(v.map(|s| s.as_str().to_string()));
}
// 没有 Set 的字段,SeaORM 不会碰它们

let updated = active.update(db).await?;

错误处理

Repository 层定义了自己的错误类型,把所有可能出现的错误统一收口:

#[derive(Debug, thiserror::Error)]
pub enum RepoError {
    #[error("db error: {0}")]
    Db(#[from] DbErr),       // SeaORM 的数据库错误

    #[error("not found")]
    NotFound,                // 查询结果为空

    #[error(transparent)]
    InvalidStatus(#[from] InvalidStatus),  // 数据库里存了非法的 status 字符串
}

pub type RepoResult<T> = Result<T, RepoError>;

#[from]DbErrInvalidStatus 可以用 ? 自动转换成 RepoError,不需要手写 map_err

上层代码拿到的永远是 RepoResult<T>,不会接触到 DbErr 这种 ORM 内部的错误类型,边界再次被守住了


高级查询

基础 CRUD 之外,Repository 层还封装了一些上层会用到的复合查询

分页列表接受过滤条件,返回数据和总数:

pub async fn list_posts_filtered<C>(
    db: &C,
    theme_id: Option<i64>,
    status: Option<&str>,
    page: u64,
    page_size: u64,
) -> RepoResult<(Vec<PostDto>, u64)>

全文搜索需要跨 titlesummarycontent 三列模糊匹配,并按相关性排序。这类查询 SeaORM 的链式 API 表达起来比较繁琐,直接用原生 SQL 更清晰:

let sql = r#"
    SELECT * FROM posts
    WHERE status = 'published'
      AND (title ILIKE $1 OR summary ILIKE $1 OR content ILIKE $1)
    ORDER BY
      CASE WHEN title ILIKE $1 THEN 0 ELSE 1 END,
      created_at DESC
    LIMIT $2 OFFSET $3
"#;

SeaORM 允许在需要的地方退回到原生 SQL,这是一个务实的设计——ORM 不是枷锁,是工具


小结

Repository 层做的事情可以用一句话概括:让数据库的细节止步于此

  • DTO 替代 Model,切断上层对 Entity 类型的依赖
  • Enum 替代 String,把业务语义收归代码管理
  • RepoError 替代 DbErr,统一错误边界
  • 输入结构体替代裸参数,让调用方的意图清晰可读

从下一篇开始,我们进入 Service 层。那里不再有任何数据库的痕迹,只有业务逻辑

💬 评论区

留下你的足迹,分享你的想法

0 / 500
支持 Markdown 基础语法 · 提交后需等待审核
💬

这里还没有评论,来做第一个进来的人吧~ ~