abp学习笔记 - 工作单元(UOW)基础

简介

最近在梳理项目系统架构,发现团队虽然使用了abp,但实际还是三层架构那套思想,尤其是对于dbcontext的滥用,加上对UOW(unit of work)的不了解,导致了很多奇葩写法。

而我也对ABP一知半解,导致codereview并没有发现异常。故此来系统化的学习并分享自己的踩坑经验。

工作单元基础

我现在写的代码,跑在哪个工作单元里?它会在什么时候提交

工作单元是什么

工作单元(UOW)是一次业务操作边界,它同时管理四件事情:

  1. 数据库上下文
    整个业务操作共用同一个DbContext实例
  2. 事务
    可选,官方明确说"UOW"不一定是事务
  3. 保存时机
    默认在业务操作边界结束时SaveChanges
  4. 提交后的回调
    OnCompleted 在成功提交后执行

简单来说,UOW = 一个 DbContext + 可选事务 + 统一保存和提交的时机。

工作单元怎么开启

下面四种方法就会默认开启

  1. Controller的Action
  2. Razor Page的Handler
  3. Application Service的方法
  4. Repository 的方法

默认开启的前提是:外部还没有一个

举个例子,一个接口从Action=>Service=>Repository,如果都开启UOW就会有3个,这样就乱套了。实际上如果外部已经有一个UOW,默认情况下会并入同一个UOW。

HTTP方法决定是否带事务

请求 UoW 数据库事务
GET(以及 QUERY) 开 不开
POST / PUT / DELETE 开 开(数据库支持的话)

默认值由AbpUnitOfWorkDefaultOptions控制,默认是Auto。

如果是手动开启UOW,是不是事务由isTransactional决定

眼见为实

复制代码
    /// <summary>
    /// 论断:不在任何工作单元里时,IUnitOfWorkManager.Current 是 null。
    /// 所以要么是WEB接口,否则要自己开。
    /// </summary>
    [Fact]
    public async Task Lesson01_OutsideAnyUnitOfWork_ThereIsNoCurrentOne()
    {
        GetRequiredService<IUnitOfWorkManager>().Current.ShouldBeNull();
        await Task.CompletedTask;
    }

踩坑记录:如果你的系统中有消费Message Queue等非WEB场景,是不会开启UOW,需要自己手动开启

UOW与事务的关系

很多人都容易弄混淆,也包括我。都认为UOW = Transaction。

实际上,UOW > Transaction,是更健壮的实现。

UOW的作用不止是Transaction,Transaction只是它的一个可选项。关掉事务后,它仍然负责下面这几件事

作用 说明
共用一个DbContext 同一个UOW里拿到的都是同一个DbContext,实体只被跟踪一份,互相可以看到改动
延迟保存,批量写入 改动先留在内存里,UOW结束时统一SaveChanges,只更新有变化的内容
管理DbContext的生命周期 UOW开始时创建,结束时释放,没有它,GetQueryableAsync返回的查询会报错
统一的边界 OnCompleted只在成功完成后执行,我们用它来做消息发布,失败也有统一的出口

非事务UOW少了什么

少了原子性和回滚

  1. 事务UOW
    中途失败,autoSave写入的行也会随事务回滚
  2. 非事务UOW
    每次保存各自生效,多次保存独立生效。

如果是使用EF,那么一次SaveChanges即使在非事务UOW里也是原子的,因为这是EF SaveChanges的通用行为

为什么官方允许不要事务

  1. GET查询不需要
    只读接口开事务只会白白占用连接,浪费资源
  2. 事务消耗大
    事务会占住数据库连接,持有快照与锁,如果事务里还有HTTP,GPRC请求,调用耗时会被事务放大
  3. 有些流程本来就需要"逐步独立提交"
    并不是所有流程都需要原子提交,很多时候我们要立即保存,失败就跳过。如果套一个事务,反而添麻烦。

眼见为实

复制代码
    /// <summary>
    /// 论断:在工作单元之外调用 GetQueryableAsync,拿到的查询无法执行(ObjectDisposedException)。
    /// 原因:GetQueryableAsync 自己开的工作单元在返回时就结束了,而查询要到 ToListAsync 才真正执行,
    ///       那时它绑定的 DbContext 已经随工作单元释放。
    /// 规则:"取查询 → 执行查询"必须在同一个工作单元里;或者把查询封装进自定义仓储方法。
    /// </summary>
    [Fact]
    public async Task Lesson02_GetQueryableAsync_OutsideAUnitOfWork_ReturnsAQueryThatCannotRun()
    {
        var repository = GetRequiredService<IRepository<AssetProcessResult, int>>();

        var query = await repository.GetQueryableAsync();

        await Should.ThrowAsync<ObjectDisposedException>(async () => await query.ToListAsync());
    }
	/// <summary>
    /// 论断:在【非事务】工作单元里,autoSave: true 的写入是立刻生效的,即使之后没有调用 CompleteAsync 也不会撤销。
    /// 意图:纠正"没 Complete 就等于回滚"的想象------那只在事务工作单元里成立。
    /// 非事务工作单元里每次 SaveChanges 各自生效,多次保存之间没有原子性。
    /// </summary>
    [Fact]
    public async Task Lesson02_AutoSaveInsideANonTransactionalUnitOfWork_IsKeptEvenWithoutComplete()
    {
        var manager = GetRequiredService<IUnitOfWorkManager>();
        var repository = GetRequiredService<IRepository<AssetProcessResult, int>>();

        using (manager.Begin())
        {
            await repository.InsertAsync(NewResult(), autoSave: true);
            // 故意不调用 CompleteAsync
        }

        (await CountAsync()).ShouldBe(1, "非事务工作单元:autoSave 写入后没有可回滚的事务,行已经留下了");
    }
	/// <summary>
    /// 论断:工作单元不一定是事务,是不是事务由 isTransactional 决定:
    ///   - 手动 Begin() 默认【不是】事务;Begin(isTransactional: true) 才是;
    /// </summary>
    [Fact]
    public async Task Lesson01_UnitOfWork_IsOnlyATransactionWhenItIsToldToBe()
    {
        var manager = GetRequiredService<IUnitOfWorkManager>();

        using (var plain = manager.Begin())
        {
            plain.Options.IsTransactional.ShouldBeFalse("手动 Begin() 默认不开事务");
        }

        using (var transactional = manager.Begin(isTransactional: true))
        {
            transactional.Options.IsTransactional.ShouldBeTrue();
        }
    }

环境工作单元(Ambient UOW)

IUnitOfWorkManager.Current底层基于AsyncLocal,因此同一个调用链路上,嵌套的方法看到的是同一个Current。

这也解释了,官方为什么说,仓储不需要知道UOW跟事务,同一个请求先后调用的多个Service,Repository,用的是同一个DbContext

手动开启UOW与UOW嵌套:Begin()与Begin(requiresNew: true)

当我们需要手动开启一个工作单元时,可以选择复用外层的UOW,还是创建一个全新的UOW,这取决于你的实际需要。

眼见为实

复制代码
    /// <summary>
    /// 论断:已有工作单元时,Begin() 默认"加入"它(共用同一个 Id、DbContext 和事务);
    /// 只有 requiresNew: true 才会另开一个独立的。
    /// </summary>
    [Fact]
    public async Task Lesson01_NestedBegin_JoinsTheAmbientUnitOfWork_UnlessRequiresNew()
    {
        var manager = GetRequiredService<IUnitOfWorkManager>();

        using var outer = manager.Begin();

        // 默认:加入外层,Id 相同
		// Begin() 返回的 Id 等于外层的 Id → 它是外层的子包装,共用同一个 DbContext 和事务
        using (var joined = manager.Begin())
        {
            joined.Id.ShouldBe(outer.Id);
        }

        // requiresNew:忽略外层,新开一个,Id 不同
		// Begin(requiresNew: true) 的 Id 不同 → 完全独立,有自己的 DbContext 和事务。
        using (var separate = manager.Begin(requiresNew: true))
        {
            separate.Id.ShouldNotBe(outer.Id);
        }

        await outer.CompleteAsync();
    }

记住一个法则,只有当你确实需要独立提交,独立回滚时才用requiresNew: true,并且你要清楚的知道,它看不到外层未提交的数据

更优雅的选择,【UnitOfWork】特性

从上面的例子中可以看到,手动创建UOW并不优雅,因为官方提供了特性来提高代码可读性。

复制代码
public class LessonUowProbe : Volo.Abp.DependencyInjection.ITransientDependency
{
    private readonly IUnitOfWorkManager _unitOfWorkManager;

    public LessonUowProbe(IUnitOfWorkManager unitOfWorkManager) => _unitOfWorkManager = unitOfWorkManager;

    [UnitOfWork(IsDisabled =true)]
    public virtual Task<bool> VirtualMethod() => Task.FromResult(_unitOfWorkManager.Current != null);

    [UnitOfWork(IsDisabled = true)]
    public Task<bool> NonVirtualMethod() => Task.FromResult(_unitOfWorkManager.Current != null);
}

但使用特性有四条规则需要注意

  1. 当类不是通过接口注入时,被标记的方法必须是virtual,否则无法拦截
  2. 只有Async方法会被拦截,同步方法不行
  3. 方法在已有环境UOW里被调用时,特性会被忽略,直接加入已有的
  4. 日志的categories会变换

眼见为实

复制代码
    /// <summary>
    /// 论断:[UnitOfWork] 只对 virtual 方法生效;非 virtual 方法上的特性会被静默忽略,没有任何编译错误或警告。
    /// 意图:防止给订阅方法加了 [UnitOfWork] 却忘了写 virtual,以为开了工作单元其实没有。
    /// 方法:被测类从容器解析,这样 ABP 才会给它生成代理(和 CAP 调用订阅方时一致);用 new 出来的对象没有代理。
    /// </summary>
    [Fact]
    public async Task Lesson01_UnitOfWorkAttribute_OnlyWorksOnVirtualMethods()
    {
        var probe = GetRequiredService<LessonUowProbe>();

        (await probe.VirtualMethod()).ShouldBeTrue("virtual 方法带 [UnitOfWork]:代理会为它开一个工作单元");
        (await probe.NonVirtualMethod()).ShouldBeFalse("非 virtual 方法无法被拦截,特性被静默忽略");
    }

完成,回滚与回调

  1. 调用CompleteAsync(),保存并提交
  2. 没有调用CompleteAsync(),就Dispose不提交
  3. OnCompleted(callback)
    成功完成后才执行回调。Failed / Disposed 事件分别在失败 / 释放时触发。

眼见为实

复制代码
    /// <summary>
    /// 论断:不在任何工作单元里时,IUnitOfWorkManager.Current 是 null。
    /// 所以订阅方里要用工作单元,必须自己开,或者用 [UnitOfWork]。
    /// </summary>
    [Fact]
    public async Task Lesson01_OutsideAnyUnitOfWork_ThereIsNoCurrentOne()
    {
        GetRequiredService<IUnitOfWorkManager>().Current.ShouldBeNull();
        await Task.CompletedTask;
    }

    /// <summary>
    /// 论断:OnCompleted 只在工作单元"成功完成"之后才执行;没有调用 CompleteAsync 就释放的话不会执行。
    /// 意图:解释为什么"提交后再发布消息"应该用 OnCompleted------事务回滚时消息不会发出去。
    /// (官方没有写明回调与提交的先后顺序,以及回调抛异常的后果,所以我们的回调自己 try/catch,不依赖这些。)
    /// </summary>
    [Fact]
    public async Task Lesson01_OnCompleted_RunsAfterCommit_AndNotWhenTheUnitOfWorkIsNotCompleted()
    {
        var manager = GetRequiredService<IUnitOfWorkManager>();
        var completedCallbackRan = false;
        var abandonedCallbackRan = false;

        // 正常完成:回调执行
        using (var uow = manager.Begin())
        {
            uow.OnCompleted(() => { completedCallbackRan = true; return Task.CompletedTask; });
            await uow.CompleteAsync();
        }

        // 没有调用 CompleteAsync 就释放(相当于处理过程中抛了异常):回调不执行
        using (var uow = manager.Begin())
        {
            uow.OnCompleted(() => { abandonedCallbackRan = true; return Task.CompletedTask; });
        }

        completedCallbackRan.ShouldBeTrue();
        abandonedCallbackRan.ShouldBeFalse();
    }

小结

要点 总结
UOW不等于事务 GET有UOW,但没有事务
谁开 控制器、应用服务、仓储方法;其他默认没有,比如消息队列订阅
UnitOfWork 必须 virtual + async,在已有 UoW 里被忽略
回调 OnCompleted只在成功后执行

课后小练习

第 1 题 HTTP GET 请求里,有没有 UoW?有没有数据库事务?
答案

有 UoW,没有 数据库事务(官方:GET 和 QUERY 是安全方法)。在 GET 里写数据,要么显式开启事务,要么接受每次仓储调用单独提交。

第 2 题 下面这个方法在 UoW 里被调用吗?它的 Current 是什么?

csharp 复制代码
[UnitOfWork]
public Task<bool> Handle() => Task.FromResult(_manager.Current != null);   // 注意:不是 virtual

答案

不在 ,Current 为 null。非 virtual 方法无法被拦截,特性被静默忽略(Lesson01_UnitOfWorkAttribute_OnlyWorksOnVirtualMethods)。改成 public virtual,并让调用方从容器解析该类。

第 3 题 外层 using var outer = manager.Begin(); 之后,内层分别写 manager.Begin() 和 manager.Begin(requiresNew: true)。它们和外层是同一个 DbContext 吗?
答案

Begin():是(加入外层,Id 相同);requiresNew: true:不是 (另一个 DbContext 和事务,Id 不同)。