发布于 2026-01-05 0 阅读
0

整洁架构要点 “软件架构” 可插拔用户界面 演进式架构 .NET Core 和 React+Redux 的整洁架构总结 🌀

清洁架构要点

“软件架构”

可插拔用户界面

进化架构

总结

使用 .NET Core 和 React+Redux 构建整洁架构🌀

“软件架构”

我们通常会看到这样的软件架构描述:“该软件架构是一个基于 ASP.NET Web API、Entity Framework Core 和 SQL Server 的架构”。本文将解释为什么应该根据用例而不是架构层和所使用的框架来描述软件。

其次,我将提炼出整洁架构原则。

建筑的本质在于使用。

只需快速浏览一下以下平面图,您就能轻易猜到这是一座教堂、剧院或人们可以聚集的场所。主要是因为图中有一个开放空间,摆放着许多朝向同一方向的长椅,还有方便大量人群快速进出的大门。

教会蓝图

这不是房屋设计图,对吧?

软件设计的挑战在于如何在源代码中清晰地体现用例,以便我们第一眼就能看出软件的功能,而不是它由哪些框架构成。

软件开发的默认方法是优先考虑框架和技术细节。一个电子商务网站会着重强调Web、模型-视图-控制器(MVC)或其他任何框架构建模块。

我们能否采用不同的软件设计方式?不妨引入整洁架构。

清洁建筑

整洁架构风格旨在实现松耦合的、以用例为中心的架构,其概括如下:

  1. 这是一种以用例为核心组织结构的架构风格。
  2. 遵循端口和适配器模式。
    • 实现过程以测试为指导(由外而内的TDD)。
    • 与技术细节脱钩。
  3. 遵循许多原则(稳定抽象原则、稳定依赖原则、SOLID 原则等等)。

用例

用例是解释输入以生成输出数据的算法,其实现应尽可能贴近业务词汇。

谈到用例,无论是移动应用还是桌面应用,用例都与交付方式无关。用例最重要的在于它们如何与参与者交互。

  • 主要参与者发起用例。他们可以是最终用户、其他系统或时钟。
  • 次要参与者会受到用例的影响。

一系列用例用于描述软件。左侧是主要参与者——客户,中间是售票终端系统,右侧是次要参与者:

售票终端使用案例

本文使用了示例应用程序和演讲中的代码片段。如果您熟悉 .NET,以下 GitHub 项目托管了完整的实现:

项目结构如下:

结构

以下是注册用例的实现:

public sealed class Register : IUseCase
{
    public async Task Execute(RegisterInput input)
    {
        var customer = _entityFactory.NewCustomer(input.SSN, input.Name);
        var account = _entityFactory.NewAccount(customer);

        var credit = account.Deposit(_entityFactory, input.InitialAmount);
        customer.Register(account);

        await _customerRepository.Add(customer);
        await _accountRepository.Add(account, credit);
        await _unitOfWork.Save();

        var output = new RegisterOutput(customer, account);
        _outputPort.Standard(output);
    }

    // properties and constructor ommited
}
Enter fullscreen mode Exit fullscreen mode

用例在应用层和 WebApi 层都是一等公民。只需快速查看源代码树中的用例名称,即可推测出源代码是用于钱包软件的。

用自然语言来说,RegisterUseCase 的步骤如下:

  • 创建客户对象。
  • 开户后存入初始金额。
  • 保存数据。
  • 向输出端口写入一条消息。

端口和适配器,又称六边形架构

整洁架构通过端口和适配器模式应用了关注点分离原则。这意味着应用层暴露端口(接口),而适配器则在基础设施层实现。

  • 端口可以​​是输入端口,也可以是输出端口。输入端口由主要参与者调用,输出端口由用例调用。
  • 适配器是针对特定技术的。

端口和适配器

这是微服务架构的首选风格。遗憾的是,我看到很多实现都不完整,源代码也没有充分利用这种模式。

上图展示了每个依赖项的两个实现:一个是伪实现(测试替身),一个是实际实现。这样做的目的是为了让软件能够独立于外部依赖项运行。

伪实现会造成外部依赖的假象,它具有与真实实现相同的功能,并且无需 I/O 即可运行。

我发现很多代码库都存在一个问题,那就是过于注重针对模拟对象(Mock)进行测试,而这些模拟对象只能在单元测试中运行。其实,模拟对象可以在生产环境中运行,帮助开发者获得反馈,而且投入产出比很高。

让我们先从六边形建筑风格的意图说起:

在与外部设备隔离的环境下开发、测试和运行应用程序。这使得开发人员能够在每次新版本实现后获得反馈。

遵循由外而内的TDD方法来实现目标:

  1. 从测试用例开始开发,实现用例。
  2. 当你发现一个依赖项时,不要直接实现真正的依赖项,而是先创建一个假的(测试替身)。
  3. 获取反馈,并能够针对模拟对象运行您的应用程序。您甚至可以将其发布到生产环境。
  4. 单独实现真正的适配器。
  5. 最后一步是创建用户界面。

原则

整洁架构包含诸多原则,让我们分析一些代码片段,看看它们在稳定性和抽象程度上的表现:

IAccountRepository接口高度抽象通用稳定。它没有“实现”,而是一个高层次的概念,并且没有依赖关系。

public interface IAccountRepository
{
    Task<IAccount> Get(Guid id);
    Task Add(IAccount account, ICredit credit);
    Task Update(IAccount account, ICredit credit);
    Task Update(IAccount account, IDebit debit);
    Task Delete(IAccount account);
}
Enter fullscreen mode Exit fullscreen mode

AccountRepository是一个非常具体的 sealed class,它
非常特定于 Entity Framework,并且由于实现了接口和依赖库而变得不稳定。

public sealed class AccountRepository : IAccountRepository
{
    private readonly MangaContext _context;

    public AccountRepository(MangaContext context)
    {
        _context = context ??
            throw new ArgumentNullException(nameof(context));
    }

    public async Task Add(IAccount account, ICredit credit)
    {
        await _context.Accounts.AddAsync((EntityFrameworkDataAccess.Account) account);
        await _context.Credits.AddAsync((EntityFrameworkDataAccess.Credit) credit);
    }

    public async Task Delete(IAccount account)
    {
        string deleteSQL =
            @"DELETE FROM Credit WHERE AccountId = @Id;
                    DELETE FROM Debit WHERE AccountId = @Id;
                    DELETE FROM Account WHERE Id = @Id;";

        var id = new SqlParameter("@Id", account.Id);

        int affectedRows = await _context.Database.ExecuteSqlRawAsync(
            deleteSQL, id);
    }

    public async Task<IAccount> Get(Guid id)
    {
        Infrastructure.EntityFrameworkDataAccess.Account account = await _context
            .Accounts
            .Where(a => a.Id == id)
            .SingleOrDefaultAsync();

        if (account is null)
            throw new AccountNotFoundException($"The account {id} does not exist or is not processed yet.");

        var credits = _context.Credits
            .Where(e => e.AccountId == id)
            .ToList();

        var debits = _context.Debits
            .Where(e => e.AccountId == id)
            .ToList();

        account.Load(credits, debits);

        return account;
    }

    public async Task Update(IAccount account, ICredit credit)
    {
        await _context.Credits.AddAsync((EntityFrameworkDataAccess.Credit) credit);
    }

    public async Task Update(IAccount account, IDebit debit)
    {
        await _context.Debits.AddAsync((EntityFrameworkDataAccess.Debit) debit);
    }
}
Enter fullscreen mode Exit fullscreen mode

该类RegisterRequest具体的,通过暴露 getter 和 setter,它变得不一致,并且针对特定用户。

/// <summary>
/// Registration Request
/// </summary>
public sealed class RegisterRequest
{
    /// <summary>
    /// SSN
    /// </summary>
    [Required]
    public string SSN { get; set; }

    /// <summary>
    /// Name
    /// </summary>
    [Required]
    public string Name { get; set; }

    /// <summary>
    /// Initial Amount
    /// </summary>
    [Required]
    public decimal InitialAmount { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

该类RegisterInput具体、略微一致不太具体

public sealed class RegisterInput : IUseCaseInput
{
    public SSN SSN { get; }
    public Name Name { get; }
    public PositiveMoney InitialAmount { get; }

    public RegisterInput(
        SSN ssn,
        Name name,
        PositiveMoney initialAmount)
    {
        SSN = ssn;
        Name = name;
        InitialAmount = initialAmount;
    }
}
Enter fullscreen mode Exit fullscreen mode

最后一种是高度抽象通用稳定的IAccount接口

public interface IAccount : IAggregateRoot
{
    ICredit Deposit(IEntityFactory entityFactory, PositiveMoney amountToDeposit);
    IDebit Withdraw(IEntityFactory entityFactory, PositiveMoney amountToWithdraw);
    bool IsClosingAllowed();
    Money GetCurrentBalance();
}
Enter fullscreen mode Exit fullscreen mode

整洁架构原则将指导您根据以下范围放置具有特定属性的对象:

清洁建筑光谱

另一种Clean Architecture表示方法是用同心圆表示,其中:

  • 图中越往内层,该层就越稳定、越抽象。
  • 依赖方向指向中心。
  • 同时发生变化的类会被打包在一起。

清晰的架构层

以下另一个完整示例表明:

  • 用户界面和基础设施层非常不稳定且具体,高度依赖于它们所设计的特定设备。
  • 核心层高度抽象且通用,非常稳定。

订单票据

插件架构

在软件开发过程中,我们不可避免地会遇到关于哪个前端框架、数据库或 ORM 最好等问题的讨论。我们不应该陷入这种无休止的争论,像早期阶段那样拖延决策。想想鲍勃大叔的那句话:

好的架构允许推迟重大决策。

优秀的架构师会尽可能减少需要做出的决策。

有人很容易找到论据说 NoSQL 是最好的数据库,而另一位开发者也能找到充分的理由选择 SQL Server 作为数据库。

我的回答是,两者都应该实现。最佳方案是实现 Fake 存储,然后继续推进项目。

插件架构

请记住,你应该欣然接受这种变化,因为将来你会找到更好的云服务选择。

端口和适配器详解

替代文字

可插拔用户界面

解耦用户界面与解耦存储库和服务同等重要,但我们通常不会在这方面投入太多精力。这种做法会导致控制器看起来像上帝类,难以测试和维护。

假设您有一个 GetAccountDetailsUseCase 用例。它应该显示以下选项之一:

  1. 账户详情。
  2. 如果不存在,则显示“未找到”。

初始代码应如下所示:

public async Task<IActionResult> Get([FromRoute][Required] GetAccountDetailsRequest request)
{
    var input = new GetAccountDetailsInput(request.AccountId);
    try
    {
        var output = await _useCase.Execute(input);
        return Ok(output);
    }
    catch (AccountNotFoundException ex)
    {
        return NotFound(ex.Message);
    }
}
Enter fullscreen mode Exit fullscreen mode

我希望控制器在决定返回哪个视图时,不需要知道输出消息。让我们把这个职责委托给 Presenter。

用户界面

两种实现Controller方式UseCase都使用同一个Presenter实例。控制器并不知道输出消息,因此我们可以编写类似这样的操作:

/// <summary>
/// Get an account details
/// </summary>
[HttpGet("{AccountId}", Name = "GetAccount")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(GetAccountDetailsResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get([FromRoute][Required] GetAccountDetailsRequest request)
{
    var input = new GetAccountDetailsInput(request.AccountId);
    await _useCase.Execute(input);
    return _presenter.ViewModel;
}
Enter fullscreen mode Exit fullscreen mode

Presenter 负责将值对象转换为 WebApi 响应。幸运的是,值对象公开了ToDecimal()ToString()其转换为基本类型的方法。

同时GetAccountDetailsPresenter实现了模拟 Unix 系统中 StdOut 和 StdErr 的方法。这些方法会创建 ViewModel 对象。NotFoundStandard

public interface IOutputPort
{
    void NotFound(string message);
    void Standard(GetAccountDetailsOutput getAccountDetailsOutput);
}

public sealed class GetAccountDetailsPresenter : IOutputPort
{
    public IActionResult ViewModel { get; private set; }

    public void NotFound(string message)
    {
        ViewModel = new NotFoundObjectResult(message);
    }

    public void Standard(GetAccountDetailsOutput output)
    {
        var transactions = new List<TransactionModel>();

        foreach (var item in output.Transactions)
        {
            var transaction = new TransactionModel(
                item.Amount.ToMoney().ToDecimal(),
                item.Description,
                item.TransactionDate);

            transactions.Add(transaction);
        }

        var response = new GetAccountDetailsResponse(
            output.AccountId,
            output.CurrentBalance.ToDecimal(),
            transactions);

        ViewModel = new OkObjectResult(response);
    }
}
Enter fullscreen mode Exit fullscreen mode

GetAccountDetailsUseCase取决于IOutputPort接口,并相应地调用NotFound相应Standard方法。

public sealed class GetAccountDetails : IUseCase, IUseCaseV2
{
    private readonly IOutputPort _outputPort;
    private readonly IAccountRepository _accountRepository;

    public GetAccountDetails(
        IOutputPort outputPort,
        IAccountRepository accountRepository)
    {
        _outputPort = outputPort;
        _accountRepository = accountRepository;
    }

    public async Task Execute(GetAccountDetailsInput input)
    {
        IAccount account;

        try
        {
            account = await _accountRepository.Get(input.AccountId);
        }
        catch (AccountNotFoundException ex)
        {
            _outputPort.NotFound(ex.Message);
            return;
        }

        var output = new GetAccountDetailsOutput(account);
        _outputPort.Standard(output);
    }
}
Enter fullscreen mode Exit fullscreen mode

添加调解人

/// <summary>
/// Get an account details
/// </summary>
[HttpGet("{AccountId}", Name = "GetAccount")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(GetAccountDetailsResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get([FromRoute][Required] GetAccountDetailsRequest request)
{
    var input = new GetAccountDetailsInput(request.AccountId);
    await _mediator.PublishAsync(input);
    return _presenter.ViewModel;
}
Enter fullscreen mode Exit fullscreen mode

你会注意到我添加了mediator实例来解耦控制器和中介器UseCase,这意味着控制器会生成消息,将它们传递给中介器,然后中介器会将消息传递给相应的用例。当然,你也可以直接调用用例,验证哪种方式最适合你的项目。

根据定义,用例给出的输出消息是一致且不可变的对象,它使用值对象来描述业务状态。

进化架构

所有开发者至少都会开发一次任务列表应用。作为 .NET 开发者,我们通常会先创建 Web API,然后再深入研究业务细节。但对于这个示例应用,我想采取不同的方法。

待办事项用例

我从单元测试入手,独立实现各个用例,并为每个依赖项创建一个模拟实例。过了一段时间后,我决定创建一个 SQL Server 数据库,因为 .NET 开发人员就是这么做的,我们会启动一个 SQL Server 来持久化任务 ;)

简而言之,最终对我来说,一个控制台用户界面和一个存储到 GitHub gist 的功能就足够了。

进化架构

总结

  • 整洁架构的核心在于使用,而用例是其核心组织原则。
  • 用例的实现以测试为指导。
  • 用户界面和持久化设计是为了满足核心需求(而不是相反!)。
  • 先实施最简单的组件,推迟决策。

代码已上传至 GitHub,并会定期更新,添加新的视频和提交 pull request。快去看看吧!

GitHub 标志 ivanpaulovich / clean-architecture-manga

🌀 基于 .NET 6、C# 10 和 React+Redux 的简洁架构。以用例作为中心组织结构,完全可测试,与框架解耦。

使用 .NET Core 和 React+Redux 构建整洁架构🌀

所有贡献者 构建状态

这是使用 .NET Core实现整洁架构原则的示例。用例作为中心组织结构,与框架和技术细节解耦。它由独立开发和测试的小型组件构成。

我们支持两个版本:

点击WATCH按钮即可获取最新的 Clean Architecture 更新。

Manga 是一款虚拟钱包解决方案,客户注册账户后即可管理余额Deposit进行各种操作。WithdrawTransfer

我们也支持 React 客户端:

React+Redux 演示

构建与运行

要启动整个解决方案,请执行以下命令:

视窗:

PS cd .docker && ./setup.ps1
Enter fullscreen mode Exit fullscreen mode

macOS:

$ cd .docker && ./setup.sh
Enter fullscreen mode Exit fullscreen mode

那么以下容器应该在以下环境中运行docker ps





















应用 URL
NGINX https://wallet.local:8081
钱包SPA https://wallet.local:8081
账户 API





文章来源:https://dev.to/ivanpaulovich/clean-architecture-essentials-5a0m