Skip to content

仓储层实战

上一篇你理解了整体架构。这一篇专门讲一个关键的工程决策:为什么要在 DbContext 之上加一层 Repository,以及怎么加。

先看问题:没有仓储层的代码长什么样

假设你正在写博客项目的 BlogQueriesBlogService。目前代码直接依赖 AdminDbContext

cangjie
// BlogQueries.cj — 直接操作 DbContext
public class BlogQueries <: Queries {
    public BlogQueries(let context: AdminDbContext, let userAccessor: UserAccessor) {}

    public func getBlogPostList(model: BlogPostSearchModel): PageResult<VBlogPost> {
        var (list, total) = context
            .vBlogPosts
            .params(model)
            .whereIf("is_public = 1")
            .whereIf("author_id = @authorId", model.authorId.isSome())
            .toPageResult(model)
        return this.data(list, total)
    }
}

// UserService.cj — 也在直接操作 DbContext
public class UserService {
    public UserService(let context: AdminDbContext) {}

    public func getUserPosts(authorId: Int64): ArrayList<BlogPost> {
        let param = DbParameters()
        param.add("authorId", authorId)
        return context.blogPosts
            .params(param)
            .whereIf("author_id = @authorId AND is_public = 1")
            .toList()
    }
}

这里的 "author_id = @authorId AND is_public = 1" 已经在两个地方各写了一遍。如果你还有一个管理后台接口只查未公开文章,还得再写一遍。当"查询条件"散落在多个类里时,字段名一旦变化,你要全局搜索替换。

这就是仓储层要解决的第一个问题:把数据访问逻辑收拢到一处。

什么是仓储层

仓储(Repository)就是在 DbContext 和业务代码之间加的一层。它不是替代 DbContext,而是封装重复的查询模板

text
原来:Queries / Service → DbContext → 数据库
现在:Queries / Service → Repository → DbContext → 数据库

Repository 做的事情很简单:

  • whereIf("author_id = @authorId AND is_public = 1") 这种查询条件写成一个方法
  • 所有需要"查某个用户的公开文章"的地方,都调这个方法

第1步:定义仓储接口

先从最简单的开始。你只需要把当前散落在各处的数据访问操作,按表分组,抽象成接口:

cangjie
// infrastructure/repositories/IBlogRepository.cj
package admin.infrastructure.repositories

import std.collection.*
import admin.domain.entities.*

public interface IBlogRepository {
    // 查
    func findPublishedByAuthor(authorId: Int64): ArrayList<BlogPost>
    func findDetailById(id: Int64): ?BlogPost

    // 增
    func add(post: BlogPost): Unit

    // 改
    func update(post: BlogPost): Unit

    // 删
    func remove(id: Int64): Unit
}

接口方法命名用业务语言,不用数据库术语:

  • findPublishedByAuthor 而不是 queryWhereAuthorIdAndIsPublic
  • remove 而不是 deleteById

第2步:实现仓储

接口有了,实现类就是把原来散落在 Queries 和 Service 里的数据访问代码搬过来

cangjie
// infrastructure/repositories/BlogRepository.cj
package admin.infrastructure.repositories

import sqlsharp.*
import std.collection.*
import admin.domain.entities.*
import admin.infrastructure.AdminDbContext

public class BlogRepository <: IBlogRepository {
    private let context: AdminDbContext

    public init(context: AdminDbContext) {
        this.context = context
    }

    // 查某个作者的公开文章 —— 查询条件只写这一次
    public func findPublishedByAuthor(authorId: Int64): ArrayList<BlogPost> {
        let param = DbParameters()
        param.add("authorId", authorId)
        return context.blogPosts
            .params(param)
            .whereIf("author_id = @authorId AND is_public = 1")
            .orderByDesc("created_at")
            .toList()
    }

    // 按 id 查文章详情
    public func findDetailById(id: Int64): ?BlogPost {
        return context.blogPosts.find(id)
    }

    // 新增
    public func add(post: BlogPost): Unit {
        context.blogPosts.add(post)
    }

    // 更新
    public func update(post: BlogPost): Unit {
        context.blogPosts.update(post)
    }

    // 删除(软约定:传带 id 的实体即可)
    public func remove(id: Int64): Unit {
        let entity = BlogPost()
        entity.id = id
        context.blogPosts.remove(entity)
    }
}

第3步:注册到 DI 容器

InfrastructureModule 里注册接口和实现:

cangjie
// InfrastructureModule.cj — 新增两行
public class InfrastructureModule <: Module {
    public override func configureServices(context: ServiceConfigurationContext): Unit {
        // ... 原有的 DbContext 注册 ...

        // 注册仓储 —— 接口对实现,方便以后替换
        context.services.addScoped<IBlogRepository, BlogRepository>()
        context.services.addScoped<IUserRepository, UserRepository>()
        context.services.addScoped<ICommentRepository, CommentRepository>()
    }
}

addScoped 是因为 Repository 依赖 DbContext(也是 Scoped),它们生命周期一致:一次请求一个实例。

第4步:改造业务代码

原来 BlogQueriesUserService 直接注 AdminDbContext,现在改为注 IBlogRepository

cangjie
// BlogQueries.cj — 改造后
public class BlogQueries <: Queries {
    let blogRepo: IBlogRepository

    public init(blogRepo: IBlogRepository, let userAccessor: UserAccessor) {
        this.blogRepo = blogRepo
    }

    public func getBlogPostList(model: BlogPostSearchModel): PageResult<VBlogPost> {
        // 列表查询仍然可以直接用 DbContext 视图(视图查询不经过仓储也合理)
        // 或者你也可以在 IBlogRepository 上加 findPaged(model) 方法
        // ... 视情况而定
    }
}

// UserService.cj — 改造后,重复代码消失
public class UserService {
    let blogRepo: IBlogRepository

    public init(blogRepo: IBlogRepository) {
        this.blogRepo = blogRepo
    }

    public func getUserPosts(authorId: Int64): ArrayList<BlogPost> {
        return blogRepo.findPublishedByAuthor(authorId)  // 一行搞定
    }
}

对比一下改之前和改之后 UserService.getUserPosts 的代码量:

cangjie
// 改之前:5 行,SQL 条件硬编码
let param = DbParameters()
param.add("authorId", authorId)
return context.blogPosts
    .params(param)
    .whereIf("author_id = @authorId AND is_public = 1")
    .toList()

// 改之后:1 行,语义清晰
return blogRepo.findPublishedByAuthor(authorId)

仓储层的三个收益

1. 查询条件只写一次

"author_id = @authorId AND is_public = 1" 只存在于 BlogRepository.findPublishedByAuthor 这一个方法里。哪天 is_public 字段改成 status,你只改一个地方。

2. 测试可以替换数据访问

没有仓储时,要测 UserService 必须连真实数据库:

cangjie
// ❌ 测试时:必须起一个 MariaDB
let context = AdminDbContext(...)
let service = UserService(context)
// 要测 getUserPosts,得先往数据库里插数据

有仓储后,你可以写一个假的实现来测:

cangjie
// ✅ 测试时:用假仓储,不连数据库
class FakeBlogRepository <: IBlogRepository {
    public func findPublishedByAuthor(authorId: Int64): ArrayList<BlogPost> {
        // 直接返回假数据
        let post = BlogPost()
        post.title = "测试文章"
        return [post]
    }
    // ...
}

let repo = FakeBlogRepository()
let service = UserService(repo)
// 现在可以稳定地、快速地测试 UserService 的逻辑

3. 换数据库成本更低

换数据库时,只需要实现一套新的 Repository。业务代码(Queries、Service、Controller)一行不动。

针对 MariaDB:BlogRepository → 改 SQL 方言
针对 OpenGauss:BlogRepositoryForOpenGauss → 用 PG 语法
业务代码:完全不变

不是所有查询都要走仓储

仓储层也有边界。一个常见问题是:列表分页查询要不要走仓储?

建议这样分:

查询类型走不走仓储原因
按固定条件查单条/集合走仓储条件固定、重复使用
动态筛选的列表分页直接走 Queries + DbContext条件变化多,仓储方法参数会爆炸
聚合统计走仓储统计逻辑集中
简单 FindById走仓储太基础了,写一次到处用
cangjie
// ✅ 适合走仓储:条件固定
blogRepo.findPublishedByAuthor(authorId)
userRepo.findByUsername(username)

// ✅ 直接走 Queries:条件动态变化
context.vBlogPosts
    .params(model)
    .whereIf("POSITION(@search IN title) > 0", model.search.isSome())
    .whereIf("category_code = @categoryCode", model.categoryCode.isSome())
    .whereIf("id IN (SELECT pt.post_id FROM post_tag ... )", model.tagName.isSome())
    .toPageResult(model)

后者的 whereIf 条件完全由调用方传参决定,可能有搜索、可能没有、可能按分类、可能按标签,组合太多。这种场景强塞进仓储只会让接口变得臃肿。

从零开始加仓储的步骤

把这个过程总结成一个可操作的清单:

步骤做什么产出
1梳理现有代码里重复的数据访问模式找出被写了两次以上的查询条件
2按表定义仓储接口IBlogRepositoryIUserRepository
3实现仓储,把重复代码搬进去BlogRepositoryUserRepository
4在 Module 里注册接口和实现addScoped<IBlogRepository, BlogRepository>()
5改 Queries/Service 的构造函数,注入接口init(blogRepo: IBlogRepository)
6删掉原来散落的重复数据访问代码享受"只改一处"的自由