YukiLog - 5 - Service 层

从增删改查到真正的业务逻辑——这篇记录了我意识到"系统"需要更多东西的那个时刻

引言

我最早写的程序是学生管理系统。那时候对"系统"的理解很简单:增删改查,加上持久化存储,就完事了。逻辑是线性的,一个操作对应一个数据库动作,没有什么需要协调的

但 YukiLog 不一样。它需要上线到公网,需要和前端交互,需要面对各种各样的请求。当我开始设计"创建文章"这个功能的时候,我意识到它根本不是一个简单的 INSERT

  • 文章有标签,标签可能还不存在,需要先创建
  • 文章属于某个主题,需要通过 slug 查找主题 ID
  • 如果文章直接发布,主题和标签的 post_count 都要 +1
  • 这些操作要么全部成功,要么全部回滚,不能出现中间状态
  • slug 格式需要校验,不能包含非法字符

这五件事分散在四张表里,没有任何一个 Repository 函数能单独完成。我需要一个地方来放这些"跨表的协调逻辑"

这就是 Service 层自然长出来的原因


细腰的形成

在我的设计文档里,我把这个架构叫做细腰模型

┌──────────────────────────────┐
│   现实世界 / 用户行为 / 输入
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│        前端(UI / 状态)
└──────────────┬───────────────┘
               │  JSON / HTTP
               ▼
┌──────────────────────────────┐
│     Handler 层(接口边界)
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│    Service 层(✨细腰核心✨)
│    · 业务规则
│    · 用例编排
│    · 与 HTTP / DB 完全无关
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│    Repo 层(数据访问)
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│    PostgreSQL
└──────────────────────────────┘

细腰模型不是设计出来的,而是复杂系统在演化中自然长出来的形态。上端是用户世界,复杂而不可控;下端是数据库,复杂且冷酷;中间的 Service 层,是我用抽象和约定把混沌压缩成秩序的地方

Service 层有一条铁律:它不认识任何基础设施。不知道有 PostgreSQL,不知道有 HTTP,不知道有 Redis。它只接受 Rust 类型,只返回 Rust 类型,只抛 Rust 错误


数据在边界上的变形

从 Repository 拿到数据之后,我发现一个烦人的问题:PostDto 里到处是 Option

pub struct PostDto {
    pub status: Option<PostStatus>,  // 数据库可能没有值
    pub view_count: Option<i64>,     // 数据库可能没有值
    pub created_at: Option<DateTime<FixedOffset>>,
}

这些 Option 忠实反映了数据库的现实——字段允许为 null,所以 DTO 用 Option 表达。但在业务逻辑里,一篇文章必然有状态,必然有浏览量。我不想在每个业务函数里都写 .unwrap_or(PostStatus::Draft)

所以 Service 层重新定义了自己的 Post,在边界上把这些 Option 消除掉:

pub struct Post {
    pub status: PostStatus,              // 一定有值,默认 Draft
    pub view_count: i64,                 // 一定有值,默认 0
    pub created_at: DateTime<FixedOffset>,
}

impl From<PostDto> for Post {
    fn from(dto: PostDto) -> Self {
        Self {
            status: dto.status.unwrap_or(PostStatus::Draft),
            view_count: dto.view_count.unwrap_or(0),
            // ...
        }
    }
}

从这一层往上,业务代码只会看到确定的类型,不再需要处理数据库层面的不确定性


编排:一个动作,多个操作

回到"创建文章"这个例子。Service 层把五步操作编排在一起,用事务保证原子性:

pub async fn create_post(db: &DatabaseConnection, input: CreatePostInput) -> ServiceResult<Post> {
    if !is_valid_slug(&input.slug) {
        return Err(ServiceError::InvalidInput("slug 格式不合法".to_string()));
    }

    let txn = db.begin().await?;  // 开启事务

    // 1. 获取/创建标签
    // 2. 查找主题 ID
    // 3. 插入文章记录
    // 4. 绑定标签关联
    // 5. 如果已发布,同步计数

    txn.commit().await?;  // 全部成功才提交
    Ok(post_dto.into())
}

事务的意义是:这五步操作要么全部成功,要么全部回滚。不会出现"文章创建了但标签没绑上"的中间状态。Repository 层的函数接受 ConnectionTrait,对事务无感知——它们不知道自己是在事务里还是在普通连接里运行,这个细节由 Service 层管理


计数器的维护

第二篇设计数据库时,themestags 表都有 post_count 字段,当时没有解释谁来维护它

post_count 是主题和标签自身的元数据。"这个主题下有多少篇已发布文章"是描述主题本身的信息,把它存在表里,是为了让每一行记录都能自描述——不需要跨表聚合,仅凭自身字段就能推断出有意义的状态

维护这个计数的逻辑分散在三个地方:创建文章、更新文章、删除文章。其中更新最复杂,因为需要感知状态的变化:

let delta = match (&old_status, &new_status) {
    (Draft, Published) => 1,   // 草稿变发布,+1
    (Published, Draft) => -1,  // 发布变草稿,-1
    _ => 0,
};

这种"感知前后状态差异"的逻辑,只有在 Service 层才能正确实现。Repository 层的单个函数只看到当前操作,没有上下文


防刷:意识到外界的恶意

开发到这里,我遇到了一个之前从没想过的问题

view_count 记录浏览量,每次文章被访问就 +1。但如果不加限制,任何人都可以写一个脚本疯狂请求,把浏览量刷爆——更严重的是,这会给数据库带来大量无意义的写操作

我发现,只要是涉及到"和外界交互",就免不了去讨论安全问题

解决方案是 Redis 限流:同一个 IP 在一段时间内只能触发一次计数。Service 层在处理浏览量增加之前,先检查 Redis:

pub async fn increment_view_count(
    db: &DatabaseConnection,
    redis: &redis::Client,
    post_id: i64,
    ip: &str,
) -> ServiceResult<()> {
    let cache_key = format!("view:post:{}:{}", post_id, ip);
    let allowed = check_rate_limit(redis, &cache_key, 3600).await?;

    if allowed {
        repo::posts::increment_view_count(db, post_id).await?;
    }
    Ok(())
}

Redis 在这里作为参数传入,不是全局依赖。Service 层和 Redis 之间的数据交换极其简单:传入一个 key 和 TTL,返回一个 bool。这个接口足够轻薄,Redis 是可插拔的


错误的归宿

Service 层定义了自己的错误类型,它是整条错误传播链的中间节点:

RepoError  →  ServiceError  →  HTTP 状态码
pub enum ServiceError {
    Repo(#[from] RepoError),      // 数据库错误自动转换
    InvalidInput(String),          // 业务校验失败
    NotFound,                      // 资源不存在
}

#[from]RepoError 可以用 ? 自动转换成 ServiceError,不需要手写 map_err。Service 层的函数只需要关心业务逻辑,错误会自动沿着链路向上传播,最终在 Handler 层被转成 HTTP 响应


小结

Service 层是细腰最窄的地方,也是整个系统最值得仔细阅读的地方

它做的事情可以用一句话概括:把复杂的业务需求压缩成干净的函数接口

下一篇,这些函数接口需要暴露给外界。前端不认识 Rust 函数,它只认识 HTTP——于是"接口"这个概念,终于要正面登场了

💬 评论区

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

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

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