Post

SqlSugar 使用有感:便利性背后的架构代价

在 .NET 生态里,SqlSugar 可能是中文社区使用最广泛的 ORM 之一。它的文档详尽、上手成本低、支持的数据库类型极为丰富,加上活跃的社区维护,让它在国内中小团队中几乎成了事实上的”首选”。

我自己也用过两年多。总体感受是:SqlSugar 足够好用,但它的文档有意无意地把开发者引向了一种”便利优先”的思维定式,这种定式在小项目里无害,但一旦系统规模增长,就会留下难以消解的架构债务。

这篇文章不是 SqlSugar 的使用教程,也不是和 EF Core 的性能对比。我想聊的是一个更根本的问题:一个工具的文档,会如何塑造使用者的架构认知。


仓储模式的本意

要理解 SqlSugar 文档里的问题,我们必须先清楚”仓储模式(Repository Pattern)”是什么。

Eric Evans 在 《领域驱动设计》里定义仓储的目的是:为领域层提供一个类似集合的接口,以隐藏底层持久化的所有细节。Martin Fowler 在 PoEAA 中也有类似表述:仓储居于领域层与数据映射层之间,作为内存中的领域对象集合运作。

这个定义里有两个关键点:

  1. 接口归属于领域层IOrderRepository 这个接口应该定义在领域层,它反映的是业务语义(”给我找到这张订单”),而不是数据库操作语义(”SELECT * FROM orders WHERE id = ?”)。

  2. 实现归属于基础设施层:具体的 SQL 拼接、ORM 调用,全部封装在基础设施层的实现类里,领域层完全不依赖任何 ORM 框架。

对应到分层图,是这样的结构:

1
2
3
4
5
6
7
8
领域层 (Domain)
  └─ IOrderRepository(接口)

基础设施层 (Infrastructure)
  └─ OrderRepository : IOrderRepository(实现,内部使用 SqlSugar)

应用层 (Application)
  └─ OrderService(依赖 IOrderRepository,不关心实现细节)

这种设计保证了领域层对 ORM 框架零依赖。今天用 SqlSugar,明天换成 EF Core 或 Dapper,改动被完全限制在基础设施层。

而与之容易混淆的是 DAO(Data Access Object),它是另一种模式:直接封装数据库访问操作,本质上是对 SQL/ORM 的轻量包装,并不承载领域语义。两者功能相近,但定位不同——Repository 是领域的一部分,DAO 是数据访问工具。

理解了这个区别,我们就可以分析 SqlSugar 文档里的问题了。


文档中的”反模式”解析

Case 1:仓储暴露 Context——抽象的漏洞

SqlSugar 文档在演示仓储用法时,提供了如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Repository<T> : SimpleClient<T> where T : class, new()
{
    public Repository(ISqlSugarClient db)
    {
        base.Context = db;
    }

    // 扩展方法:自带方法不能满足时,可以这样操作
    public List<T> CommQuery(string json)
    {
        // base.Context 可以拿到 ISqlSugarClient,做复杂操作
        return base.Context.Queryable<T>().ToList();
    }
}

在 Section 4.1(通过 IOC 使用仓储)里,文档进一步演示了这样的用法:

1
2
3
4
5
6
7
8
9
10
11
12
// 注入仓储
public WeatherForecastController(
    Repository<Order> repOrder,
    Repository<Custom> repCustom)
{
    _repOrder = repOrder;
    _repCustom = repCustom;
}

// 使用
_repOrder.Insert(data);
_repOrder.Context.Queryable<T>().ToList(); // .Context 可以拿到 db 对象

注意最后一行:.Context 直接返回 ISqlSugarClient。文档将这个视为理所当然地展示,甚至视为”灵活性”的体现。

但这恰恰是最根本的问题所在。

一旦调用方可以通过 .Context 拿到 ISqlSugarClient,仓储的抽象就完全失效了。调用方(通常是 Controller 或 Service)现在拥有完整的 ORM 上下文,可以执行任意查询、任意更新,甚至开始事务。此时仓储层的存在没有任何意义——它不再是”领域对象的集合”,而是一个薄薄的转发层,随时可以被绕过。

更严重的是,这种用法会在代码库里传播。一旦有人写了 repOrder.Context.Queryable<...>(),后来者就会效仿,最终的结果是业务逻辑散落在各处,充斥着直接的 ORM 调用。

Case 2:ChangeRepository——聚合边界的瓦解

Section 5 提供了一种跨仓储调用的方式:

1
2
3
// 在 UserRepository 里,直接切换到另一个仓储
var itemDal = _userRepository.ChangeRepository<Repository<OrderItem>>();
var orderDal = _userRepository.ChangeRepository<Repository<Order>>();

文档的解释是:”因为支持获取外部仓储,所以不需要像 ABP 那样构造函数写一堆。”

这句话折射出一种对 ABP 做法的误解。ABP 在构造函数里注入多个仓储,是因为每个仓储对应一个聚合根,它们之间的边界是刻意维护的。通过依赖注入声明依赖关系,既是强制的约束,也是清晰的文档——你一眼就能看出这个 Service 涉及哪些聚合。

ChangeRepository 让任何一个仓储都能在运行时”变形”为另一个仓储,聚合的隔离性被完全抹去。在 DDD 里,不同聚合根的仓储相互独立是一条基本原则,但这里把这种独立性变成了一个可以随时跨越的语法糖。

Case 3:事务职责的错位

SqlSugar 文档中的事务用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
try
{
    _userRepository.AsTenant().BeginTran();

    // 你的增查改方法

    _userRepository.AsTenant().CommitTran();
}
catch (Exception ex)
{
    _userRepository.AsTenant().RollbackTran();
    throw;
}

在技术层面,这段代码是可以工作的。但事务是工作单元(Unit of Work)的职责,而不是仓储的职责

仓储只应该知道如何存取一类领域对象,它不应该知道”当前有没有一个跨仓储的事务在运行”。而当事务通过某一个特定仓储来控制时,任何需要参与事务的操作都必须先拿到这个仓储的引用,这产生了奇怪的耦合。

正确的做法是将事务协调逻辑放到应用层(Application Service),或者通过专用的 IUnitOfWork 抽象来管理,让仓储只负责自己的数据访问职责。


SqlSugar 的本来面目:一个能力很强的数据访问工具

以上批评针对的是文档的引导方式,而不是 SqlSugar 本身的能力。SqlSugar 的底层 API 完全可以支撑严格的架构实践。

AOP 与拦截器:SqlSugar 提供的 OnLogExecutingOnExecutingOnExecuted 等 AOP 钩子,可以干净地实现审计日志、慢查询监控、软删除等横切关注点,完全不需要污染业务代码:

1
2
3
4
5
6
7
8
9
db.Aop.OnExecuting = (sql, pars) =>
{
    // 统一注入软删除过滤条件
};

db.Aop.OnLogExecuting = (sql, pars) =>
{
    logger.LogDebug("SQL: {Sql}", UtilMethods.GetNativeSql(sql, pars));
};

Queryable / Insertable API:SqlSugar 的链式查询 API 表达力很强,足以支持复杂查询的封装,而不必依赖 SimpleClient<T> 的高层包装:

1
2
3
4
5
6
7
// 在基础设施层内部使用,不向外暴露
public async Task<Order?> GetByIdAsync(OrderId id)
{
    return await _db.Queryable<Order>()
        .Where(o => o.Id == id.Value)
        .FirstAsync();
}

事务管理:SqlSugar 的事务 API 本身设计合理,问题不在 API 本身,而在于把它放在哪一层:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在应用服务层管理事务,不在仓储内部
await _db.AsTenant().BeginTranAsync();
try
{
    await _orderRepository.SaveAsync(order);
    await _inventoryRepository.DecreaseAsync(order);
    await _db.AsTenant().CommitTranAsync();
}
catch
{
    await _db.AsTenant().RollbackTranAsync();
    throw;
}

这些能力说明 SqlSugar 有足够的底层灵活性,关键在于开发者选择在什么层次上使用它。


实践建议:用 SqlSugar 构建真正的仓储

如果你的项目有一定规模,或者已经在践行 DDD / Clean Architecture,以下是我认为更合理的 SqlSugar 集成方式。

Step 1:在领域层定义接口,什么都不依赖 SqlSugar

1
2
3
4
5
6
7
8
// Domain/Repositories/IOrderRepository.cs
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id);
    Task<IReadOnlyList<Order>> GetByCustomerAsync(CustomerId customerId);
    Task SaveAsync(Order order);
    Task DeleteAsync(OrderId id);
}

这个接口里没有任何 ORM 类型,只有业务语义。

Step 2:在基础设施层用 SqlSugar 实现,但绝不向外暴露 ISqlSugarClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Infrastructure/Persistence/OrderRepository.cs
public class OrderRepository : IOrderRepository
{
    private readonly ISqlSugarClient _db;

    public OrderRepository(ISqlSugarClient db)
    {
        _db = db;
    }

    public async Task<Order?> GetByIdAsync(OrderId id)
    {
        return await _db.Queryable<Order>()
            .Where(o => o.Id == id.Value)
            .FirstAsync();
    }

    public async Task SaveAsync(Order order)
    {
        await _db.Storageable(order).ExecuteCommandAsync();
    }

    // ... 其他实现
}

这里的 _db 是私有的。任何使用 IOrderRepository 的代码,根本不知道背后用的是 SqlSugar、EF Core 还是 Dapper。

Step 3:应用层注入接口,完全不感知 SqlSugar

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Application/Services/PlaceOrderService.cs
public class PlaceOrderService
{
    private readonly IOrderRepository _orderRepo;
    private readonly IInventoryRepository _inventoryRepo;

    public PlaceOrderService(
        IOrderRepository orderRepo,
        IInventoryRepository inventoryRepo)
    {
        _orderRepo = orderRepo;
        _inventoryRepo = inventoryRepo;
    }

    public async Task HandleAsync(PlaceOrderCommand command)
    {
        var order = Order.Create(command);
        await _orderRepo.SaveAsync(order);
        // 应用层只看到业务操作,不感知任何 ORM 细节
    }
}

Step 4:通过独立的 IUnitOfWork 协调事务

如果多个聚合需要在同一个事务中提交,引入专用的工作单元抽象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Domain/IUnitOfWork.cs
public interface IUnitOfWork
{
    Task BeginAsync();
    Task CommitAsync();
    Task RollbackAsync();
}

// Infrastructure/SqlSugarUnitOfWork.cs
public class SqlSugarUnitOfWork : IUnitOfWork
{
    private readonly ISqlSugarClient _db;

    public SqlSugarUnitOfWork(ISqlSugarClient db)
    {
        _db = db;
    }

    public Task BeginAsync() => _db.AsTenant().BeginTranAsync();
    public Task CommitAsync() => _db.AsTenant().CommitTranAsync();
    public Task RollbackAsync() => _db.AsTenant().RollbackTranAsync();
}

这样,应用层通过 IUnitOfWork 控制事务边界,而事务的底层实现仍然是 SqlSugar,修改时只需改动基础设施层。


文档风格与生态的影响

EF Core 的官方文档会专门讨论仓储模式和工作单元的正反争议;它明确区分了”直接使用 DbContext”和”封装 DbContext”的适用场景,并对过度封装提出了警示。

SqlSugar 的文档则更多地展示”这样做能跑起来”,鲜少讨论”这样做的代价是什么”。这不是 SqlSugar 的过错——它从定位上就是面向快速开发的工具,这种取舍是有意为之的。

然而,文档的影响力是真实的。大量依赖文档学习的开发者,会把文档中的示例视为最佳实践,而不仅仅是可行案例。当他们把 .Context 暴露给 Controller、用 ChangeRepository 随意跨聚合操作数据时,他们可能真的以为自己在用”仓储模式”——因为文档是这么告诉他们的。

这就形成了一种架构认知的停滞:开发者误以为自己已经在使用正确的设计模式,因此也就没有动力去探索更深层的架构原则。


结语

SqlSugar 是一个可以写出好代码的工具,也是一个很容易写出坏架构的工具。两种结果的差异,不在于工具本身,而在于开发者是否有清晰的架构意图。

如果你只是在做一个内部管理系统,生命周期短、团队规模小,那么 SqlSugar 文档里那些”便利用法”都完全够用,过度设计才是真正的问题。

但如果你的系统在成长,如果你开始关心可测试性、可替换性和领域边界,那么这篇文章提到的这些区别就会开始变得重要:接口定义在哪一层?ORM 对象在哪里被暴露?事务由谁来协调?

答案不在于选用哪个 ORM,而在于你是否在由架构目标驱动工具的使用,而不是被工具的文档定型你的架构。

This post is licensed under CC BY 4.0 by the author.