在Clean Architecture中如何使用事务?

50

我在网上找不到任何一个提供框架无关且实用的实现方法。

我看过几个次优的解决方案:

  1. 将仓储方法设置为原子方法

  2. 将用例设置为原子方法

它们都不是理想的。

情况#1: 大多数用例需要调用多个仓储方法才能完成任务。当您“下订单”时,可能需要调用“订单仓储”的“插入”方法和“用户仓储”的“更新”方法(例如:扣除商店积分)。如果“插入”和“更新”是原子性的,这将是灾难性的-您可以下订单,但实际上未能让用户支付。或者让用户支付,但订单失败。这两种情况都不理想。


情况#2: 没有更好的解决方案。如果每个用例都独立存在,那么它可行,但除非您想复制代码,否则您经常会发现需要依赖其他用例的操作。

想象一下您有一个“下订单”用例和一个“赠送奖励积分”用例。两个用例可以独立使用。例如,老板可能希望在系统启动日期周年纪念日期间,向系统中的每个用户“赠送奖励积分”。当用户购买时,您当然会使用“下订单”用例。

现在你系统的推出已经迎来了10周年纪念。你的老板决定:“好吧,吉姆博,2018年7月,每当有人下订单,我都要给予奖励积分。”
为了避免直接修改“下订单”用例以实现这个即将被明年放弃的想法,你决定创建另一个用例(“促销期间下订单”),该用例只调用“下订单”和“给予奖励积分”。很好。
只是...你不能。我的意思是,你可以。但你回到了原点。你可以保证“下订单”成功,因为它是原子操作。你也可以保证“给予奖励积分”成功,因为同样的原因。但是,如果其中一个失败,你无法回滚另一个。它们不共享相同的事务上下文(因为它们内部“开始”和“提交”/“回滚”事务)。
以上场景有几种可能的解决方案,但没有一种是非常“干净”的(例如UoW-工作单元,共享工作单元用例之间的信息就能解决此问题,但UoW模式很丑陋,而且还需要知道哪个用例负责打开/提交/回滚事务)。
3个回答

9
我将交易放在控制器上。控制器了解更大的框架,因为它可能至少具有框架的元数据,例如注释。
关于工作单元,这是个好主意。您可以让每个用例启动一个事务。在内部,工作单元要么启动实际事务,要么增加调用开始的计数器。然后,每个用例都会调用提交或拒绝。当提交计数等于0时,调用实际提交。拒绝则跳过所有流程,回滚,然后出错(异常或返回代码)。
在您的示例中,包装用例调用start(c=1),下订单调用start(c=2),下订单提交(c=1),奖励调用start(c=2),奖励提交(c=1),包装提交(c=0),最终提交。
子事务由您处理。

我认为我更倾向于你的第一个想法,因为它会减轻用例的负担(将事务从用例外部处理 - 控制器或另一层可能负责创建共享事务的存储库,将这些传递给用例,并负责在任何用例失败时回滚它们)。不过,你的第二个想法很有趣,而且是我没有考虑过的。无论哪种方式,感谢你的回复! - aetheus
将其放在控制器层面上是一个我尚未考虑过的非常好的观点。不幸的是,这似乎意味着您无法在应用程序层面上描述事务的步骤顺序,如果超过一个应用程序使用您的应用程序项目(例如Web API和WPF),这将是一个问题 - 或者我错过了什么? - Flater
重要问题在于,将事务放在控制器上并不能解决六边形架构的根本问题:您不能也不应该假设存储库能够参与事务。如果要回滚对文件系统的写入或推送到消息队列中,该怎么办? - winson
@winson 你可以在幕后使用UnitOfWork。每个存储库都可以将回滚函数注册到工作单元中。如果工作单元失败,它将调用每个回滚函数。现在这完美吗?不是(或者至少不容易),但是可行的。然而,回滚不是域层的一部分。这是所使用的依赖注入系统的细节。当我面对这种情况时,我没有单例repos。每个请求都会获得一个完整的repo套件。这允许每个请求注册并响应工作单元:请求。在WebSockets中也适用,因为每个请求都会获得一个完整的repo套件。 - Virmundi
1
感谢您的回复,但问题仍然存在:存储库的实现可以是Web服务,因为它应该是完全抽象的。现在,虽然您可以尝试通过第二个调用来撤消操作,但没有任何保证或任何留下一致状态的东西。将事务性放在控制器中本质上是希望存储库彼此友好地协作。 - winson
两阶段提交是一个与事务控制器无关的非常真实的问题。它们在HTTP上变得更加严重。然而,您可以在此处了解有关它们的更多信息。https://en.m.wikipedia.org/wiki/Two-phase_commit_protocol - Virmundi

3
一般建议将事务定义放置在UseCase层中,因为它具有适当的抽象级别和并发需求。在我看来,案例#2中你提供的解决方案是最好的。为解决重复使用不同UseCases的问题,一些框架使用了事务中的传播概念。例如,在Spring中,您可以将事务定义为REQUIRED或REQUIRES_NEW。
如果您在UseCase PlaceOrderAndGiveAwards中定义了事务REQUIRED,则该事务将在“基本”UseCases中被重用,并且内部方法中的回滚将使整个事务回滚。 https://www.byteslounge.com/tutorials/spring-transaction-propagation-tutorial

7
这个建议违背了“干净架构”的原则。使用案例应该对持久化数据的技术是不可知的。 - Orposuser
2
你是对的,UseCase层不应该包含任何外部依赖。但在UseCase层中使用事务并不意味着要使用具体的事务实现。事务是关于持久化层原子性的抽象。你可以根据持久化实现在基础设施层注入不同的事务实现。 - Alvaro Arranz
如果您在同一类中执行此操作,则会混淆问题。正确的方法是使用代理模式,因此代理执行事务管理并调用“真实”的用例。 - Orposuser
同意!使用代理模式来分离关注点是一个不错的方法。然而,请注意,问题陈述中提到的问题仍然存在:如果一个用例被代理了事务管理,那么所有使用该用例的其他用例在使用时都将具有该特定的事务管理。您必须想办法在一个用例调用另一个用例时使用相同的事务,并在内部失败时回滚整个用例。 - Alvaro Arranz

1
我知道这是一个老问题, 但希望这个答案能帮助寻找示例实现的人 自干架以来,所有层都指向内部而不是外部

All Layers pointing inward

因此,由于事务是应用程序层的问题,我会将其保留在应用程序层中。
在我所做的快速示例中,应用程序层具有接口IDBContext,其中包含我将使用的所有Dbset,如下所示。
public interface IDBContext
{
    DbSet<Blog> Blogs { get; set; }
    DbSet<Post> Posts{ get; set; }
    Task<int> SaveChangesAsync(CancellationToken cancellationToken);
    DatabaseFacade datbase { get; }
}

在持久层中,我有该接口的实现

public class ApplicationDbContext : DbContext, IDBContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) :base(options)
    {

    }
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }
    public DatabaseFacade datbase => Database;


    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken())
    {
        
        var result = await base.SaveChangesAsync(cancellationToken);
        return result;
    }
}

回到我通常使用IMediator并遵循CQRM的应用程序层,我制作了这个示例,希望它会有帮助。这是我开始事务的那一行。

await using var transaction = await context.datbase.BeginTransactionAsync();

这是命令处理程序,我在其中使用事务

public async Task<int> Handle(TransactionCommand request, CancellationToken cancellationToken)
    {
        int updated = 0;
        await using var transaction = await context.datbase.BeginTransactionAsync();
        try
        {
            var blog = new Core.Entities.Blog { Url = $"Just test the number sent = {request.number}" };
            await context.Blogs.AddAsync(blog);
            await context.SaveChangesAsync(cancellationToken);

            for (int i = 0; i < 10; i++)
            {
                var post = new Core.Entities.Post
                {
                    BlogId = blog.BlogId,
                    Title = $" Title {i} for {blog.Url}"
                };
                await context.Posts.AddAsync(post); 
                await context.SaveChangesAsync(cancellationToken);
                updated++;
            }

            var divresult = 5 / request.number;
            await transaction.CommitAsync();
            
        }
        catch (Exception ex)
        {
            var msg = ex.Message;
            return 0;
            
        }
        return updated;


    }

这里是我刚刚创建的样例的链接,以便详细解释我的答案。

请记住,我只花了大约15分钟来创建这个示例,仅供参考,如果存在一些不良命名,请谅解 :)

敬礼,


3
这个方案很简单有效,但根据清晰架构的原则,内部层不应该依赖于具体实现。你定义了IDbContext接口,但我认为它并不是抽象化的实现,因为它明显泄露了Entity Framework的概念。如果你现在想切换到NHibernate,甚至更糟的是某种NoSQL数据库,你就必须修改应用程序核心部分的处理程序。 - kamilz
@kamilz 完全同意,但是同样的论点也可以用于 DbSet 吗? 如果你将 EF 更改为其他内容,则应更改所有的 DbSets。 - khaled Dehia
3
可以的。因此,在纯粹的《Clean Architecture》中,存储库应该只操作领域概念。DbContext和DbSet都不是领域概念,所以如果我们考虑《Clean Architecture》,它们不应该成为存储库接口的一部分。但我知道在现实世界中,抽象化一切并不总是值得的。 - kamilz

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接