SqlSugar 使用有感:便利性背后的架构代价
在 .NET 生态里,SqlSugar 可能是中文社区使用最广泛的 ORM 之一。它的文档详尽、上手成本低、支持的数据库类型极为丰富,加上活跃的社区维护,让它在国内中小团队中几乎成了事实上的”首选”。
我自己也用过两年多。总体感受是:SqlSugar 足够好用,但它的文档有意无意地把开发者引向了一种”便利优先”的思维定式,这种定式在小项目里无害,但一旦系统规模增长,就会留下难以消解的架构债务。
这篇文章不是 SqlSugar 的使用教程,也不是和 EF Core 的性能对比。我想聊的是一个更根本的问题:一个工具的文档,会如何塑造使用者的架构认知。
仓储模式的本意
要理解 SqlSugar 文档里的问题,我们必须先清楚”仓储模式(Repository Pattern)”是什么。
Eric Evans 在 《领域驱动设计》里定义仓储的目的是:为领域层提供一个类似集合的接口,以隐藏底层持久化的所有细节。Martin Fowler 在 PoEAA 中也有类似表述:仓储居于领域层与数据映射层之间,作为内存中的领域对象集合运作。
这个定义里有两个关键点:
接口归属于领域层:
IOrderRepository这个接口应该定义在领域层,它反映的是业务语义(”给我找到这张订单”),而不是数据库操作语义(”SELECT * FROM orders WHERE id = ?”)。实现归属于基础设施层:具体的 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 提供的 OnLogExecuting、OnExecuting、OnExecuted 等 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,而在于你是否在由架构目标驱动工具的使用,而不是被工具的文档定型你的架构。