N

.NET Core 中的清洁架构 清洁架构摘要

2025-05-25

.NET Core 中的清洁架构

清洁架构

概括

我最近写了一篇关于自动化代码库编码标准的重要性的文章。

无论是制表符还是空格,每个文件的类数量,还是注释的结构方式。当有一个标准且通用的主题时,代码的可读性就会大大提高。

我使用的标准 repo 可以在这里找到。现在我对这些标准有了更进一步的了解,并增加了我对清洁架构原则的理解。

清洁架构

清洁架构 (Clean Architecture) 是鲍勃·马丁叔叔在其同名书中首次提出的一种软件设计和构建方法。

替代文本

这张图片在网络上重复出现过很多次(全部归功于鲍勃叔叔),但它确实清楚地定义了应用程序的设计方式。

每个圈子应该只了解其内部圈子的情况,不应有任何依赖关系向外扩展。

那么,这对我们的软件意味着什么呢?以下所有示例都可以在我的 GitHub 仓库中找到。

实体

实体是业务领域最纯粹的表示。在我为本地银行管理贷款的示例应用中,实体如下。

using System;
using System.Collections.Generic;
using System.Text;
using CleanArchitecture.Entities.Exceptions;

namespace CleanArchitecture.Core.Entities
{
    /// <summary>
    /// Encapsulates all code for managing loans.
    /// </summary>
    public abstract class Loan
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="Loan"/> class.
        /// </summary>
        /// <param name="principal">The principal valie of the loan.</param>
        /// <param name="rate">The annual interest rate percentage.</param>
        /// <param name="period">The term of the loan.</param>
        internal Loan(decimal principal, decimal rate, decimal period)
        {
            this.Principal = principal;
            this.Rate = rate;
            this.Period = period;
            this.Balance = principal;
        }

        /// <summary>
        /// Gets the standard late fee to be charged.
        /// </summary>
        public virtual decimal LateFee
        {
            get
            {
                return 125M;
            }
        }

        /// <summary>
        /// Gets the initial principal of the loan.
        /// </summary>
        public decimal Principal { get; private set; }

        /// <summary>
        /// Gets the interest rate of the loan.
        /// </summary>
        public decimal Rate { get; private set; }

        /// <summary>
        /// Gets the period the loan will be repayed over.
        /// </summary>
        public decimal Period { get; private set; }

        /// <summary>
        /// Gets the current balance of the loan.
        /// </summary>
        public decimal Balance { get; private set; }

        /// <summary>
        /// Make a payment against the loan.
        /// </summary>
        /// <param name="paymentAmount">The value of the payment made.</param>
        public virtual void MakePayment(decimal paymentAmount)
        {
            var newCalculatedBalance = this.Balance - paymentAmount;

            if (newCalculatedBalance < 0)
            {
                throw new LoanOverpaymentException($"A payment of {paymentAmount} would take the current loan balance below 0");
            }

            this.Balance = this.Balance - paymentAmount;
        }

        /// <summary>
        /// Apply the interest for the elapsed period.
        /// </summary>
        /// <returns>The total accrued interest value.</returns>
        public virtual decimal ApplyInterest()
        {
            this.Balance = this.Principal * (1 + (this.Rate / 100));

            return this.Balance - this.Principal;
        }

        /// <summary>
        /// Charge a late payment fee to this loan.
        /// </summary>
        /// <returns>The new balance after the late fee has been added.</returns>
        public virtual decimal ChargeLateFee()
        {
            this.Balance = this.Balance + this.LateFee;

            return this.Balance;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

贷款是银行部门处理的最基本的业务实体。在每笔贷款中:

  • 可以从余额中付款
  • 可申请利息
  • 可能会收取滞纳金

遵循开放/封闭原则,该类被创建为抽象类,以便创建不同类型的贷款。具体示例可参见 BasicLoan 类。

using System;
using System.Collections.Generic;
using System.Text;

namespace CleanArchitecture.Core.Entities
{
    /// <summary>
    /// A basic loan implementation.
    /// </summary>
    public class BasicLoan : Loan
    {
        internal BasicLoan(decimal principal, decimal rate, decimal term)
            : base(principal, rate, term)
        {
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

基本贷款不会覆盖任何基本贷款功能,但将来可能会得到扩展。

银行也有客户。同样,这些客户的表示方式也尽可能与实际业务用例保持一致。使用与业务实体相同的术语,使得开发人员和领域专家之间的沟通变得极其轻松。

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;

[assembly: InternalsVisibleTo("CleanArchitecture.UnitTest")]

namespace CleanArchitecture.Core.Entities
{
    /// <summary>
    /// Encapsulates all logic for a customer entity.
    /// </summary>
    public class Customer
    {
        private int _age;

        /// <summary>
        /// Initializes a new instance of the <see cref="Customer"/> class.
        /// </summary>
        /// <param name="name">The new customers name.</param>
        /// <param name="address">The new customers address.</param>
        /// <param name="dateOfBirth">The new customers date of birth.</param>
        /// <param name="nationalInsuranceNumber">The new customers national insurance number.</param>
        internal Customer(string name, string address, DateTime dateOfBirth, string nationalInsuranceNumber)
        {
            this.CustomerId = Guid.NewGuid().ToString();
            this.Name = name;
            this.Address = address;
            this.DateOfBirth = dateOfBirth;
            this.NationalInsuranceNumber = nationalInsuranceNumber;
        }

        private Customer()
        {
        }

        /// <summary>
        /// Gets the internal identifier of this customer.
        /// </summary>
        public string CustomerId { get; private set; }

        /// <summary>
        /// Gets the name of the customer.
        /// </summary>
        public string Name { get; private set; }

        /// <summary>
        /// Gets the address of the customer.
        /// </summary>
        public string Address { get; private set; }

        /// <summary>
        /// Gets the customers date of birth.
        /// </summary>
        public DateTime DateOfBirth { get; private set; }

        /// <summary>
        /// Gets the national insurance number of the customer.
        /// </summary>
        public string NationalInsuranceNumber { get; private set; }

        /// <summary>
        /// Gets the customers credit score.
        /// </summary>
        public decimal CreditScore { get; private set; }

        /// <summary>
        /// Gets the age of the person based on their <see cref="Customer.DateOfBirth"/>.
        /// </summary>
        public int Age
        {
            get
            {
                if (this._age <= 0)
                {
                    this._age = new DateTime(DateTime.Now.Subtract(this.DateOfBirth).Ticks).Year - 1;
                }

                return this._age;
            }
        }

        internal void UpdateCreditScore(decimal newCreditScore)
        {
            if (newCreditScore != this.CreditScore)
            {
                this.CreditScore = newCreditScore;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

在实体“圈”内,我还拥有允许与客户数据库交互的界面。

清洁架构的最大收获之一是使业务逻辑和用例尽可能远离细节(dB 提供商、用户交互)。

正是由于这个原因,实体具有 ICustomers 数据存储的概念,但实际上并不理解或关心 Customers 数据存储的实际工作方式。

// ------------------------------------------------------------
// Copyright (c) James Eastham.
// ------------------------------------------------------------

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;

namespace CleanArchitecture.Core.Entities
{
    /// <summary>
    /// Encapsulates all persistance of customer records.
    /// </summary>
    public interface ICustomerDatabase
    {
        /// <summary>
        /// Store the customer object in the database.
        /// </summary>
        /// <param name="customer">The <see cref="Customer"/> to be stored.</param>
        public void Store(Customer customer);
    }
}
Enter fullscreen mode Exit fullscreen mode

一旦我们绘制出实体,我们就可以继续向外移动到下一个圆圈。

用例

用例包含应用程序的实际业务用途。如果实体包含核心业务对象,那么用例则包含这些对象如何协同工作的逻辑。

鲍勃叔叔说得很好(此处为释义)

以银行为例,贷款利息的计算将由实体负责,因为这是银行业务的根本规则,也是关键的业务规则。

然而,一个决定是否允许特定客户贷款的系统,如果实现自动化,将会受益匪浅。这就是一个用例。用例是对自动化系统使用方式的描述,它指定了用户需要提供的输入以及返回给用户的输出。

记住这个确切的示例,让我们添加一个用例来检查客户是否可以获得贷款。

首先,我们来看看预期的输入和输出。

从我们与银行领域专家的对话中,我们知道有一些关键的决定因素决定着是否接受贷款。

  • 客户必须拥有有效的姓名、地址和国民保险号码
  • 他们必须年满 18 岁
  • 他们的信用评分必须大于 500

将该逻辑转换为请求/响应对象。

// ------------------------------------------------------------
// Copyright (c) James Eastham.
// ------------------------------------------------------------

using System;

namespace CleanArchitecture.Core.Requests
{
    /// <summary>
    /// Gather required data for a new loan.
    /// </summary>
    protected class GatherContactInfoRequest
        : IRequest<GatherContactInfoResponse>
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="GatherContactInfoRequest"/> class.
        /// </summary>
        /// <param name="name">The applicants name.</param>
        /// <param name="address">The applicants address.</param>
        /// <param name="dateOfBirth">The applicants date of birth.</param>
        /// <param name="nationalInsuranceNumber">The applicants NI number.</param>
        public GatherContactInfoRequest(string name, string address, DateTime dateOfBirth, string nationalInsuranceNumber)
        {
            this.Name = name;
            this.Address = address;
            this.DateOfBirth = dateOfBirth;
            this.NationalInsuranceNumber = nationalInsuranceNumber;
        }

        /// <summary>
        /// Gets the name.
        /// </summary>
        public string Name { get; private set; }

        /// <summary>
        /// Gets the address.
        /// </summary>
        public string Address { get; private set; }

        /// <summary>
        /// Gets the date of birth.
        /// </summary>
        public DateTime DateOfBirth { get; private set; }

        /// <summary>
        /// Gets the National Insurance number.
        /// </summary>
        public string NationalInsuranceNumber { get; private set; }
    }
}
Enter fullscreen mode Exit fullscreen mode
// ------------------------------------------------------------
// Copyright (c) James Eastham.
// ------------------------------------------------------------

using System;
using System.Collections.Generic;

namespace CleanArchitecture.Core.Requests
{
    /// <summary>
    /// Response from a successul gather of ContractInfo.
    /// </summary>
    protected class GatherContactInfoResponse
        : BaseResponse
    {
        internal GatherContactInfoResponse(string name, string address, DateTime dateOfBirth, string nationalInsuranceNumber)
            : base()
        {
            this.Name = name;
            this.Address = address;
            this.DateOfBirth = dateOfBirth;
            this.NationalInsuranceNumber = nationalInsuranceNumber;
        }

        /// <summary>
        /// Gets or sets the applicants name.
        /// </summary>
        public string Name { get; set; }

        /// <summary>
        /// Gets or sets the applicants address.
        /// </summary>
        public string Address { get; set; }

        /// <summary>
        /// Gets or sets the applicants date of birth.
        /// </summary>
        public DateTime DateOfBirth { get; set; }

        /// <summary>
        /// Gets or sets the applicants national insurance number.
        /// </summary>
        public string NationalInsuranceNumber { get; set; }

        /// <summary>
        /// Gets or sets the applicants credit score.
        /// </summary>
        public decimal CreditScore { get; set; }

        /// <summary>
        /// Gets a value indicating if the customer has been accepted for a loan.
        /// </summary>
        public bool IsAcceptedForLoan  { get; private set; }
    }
}
Enter fullscreen mode Exit fullscreen mode

我们最终得到了上述两个类。输入的是姓名、地址、出生日期和国民保险号。输出的是信用评分和“IsAcceptedForLoan”。

牢记请求和响应,并使用推荐的最佳清洁架构实践,我们可以创建一个 GatherContactInfoInteractor。

// ------------------------------------------------------------
// Copyright (c) James Eastham.
// ------------------------------------------------------------

using System;
using System.Threading.Tasks;
using CleanArchitecture.Core.Entities;
using CleanArchitecture.Core.Services;

namespace CleanArchitecture.Core.Requests
{
    /// <summary>
    /// Handles a <see cref="GatherContactInfoRequest"/>.
    /// </summary>
    public class GatherContactInfoInteractor
        : IRequestHandler<GatherContactInfoRequest, GatherContactInfoResponse>
    {
        private readonly ICreditScoreService _creditScoreService;
        private readonly ICustomerDatabase _customerDatabase;

        /// <summary>
        /// Initializes a new instance of the <see cref="GatherContactInfoInteractor"/> class.
        /// </summary>
        /// <param name="creditScoreService">A <see cref="ICreditScoreService"/>.</param>
        /// <param name="customerDatabase">A <see cref="ICustomerDatabase"/>.</param>
        public GatherContactInfoInteractor(ICreditScoreService creditScoreService, ICustomerDatabase customerDatabase)
        {
            this._creditScoreService = creditScoreService;
            this._customerDatabase = customerDatabase;
        }

        /// <summary>
        /// Handle the given <see cref="GatherContactInfoRequest"/>.
        /// </summary>
        /// <param name="request">A <see cref="GatherContactInfoRequest"/>.</param>
        /// <returns>A <see cref="GatherContactInfoResponse"/>.</returns>
        public GatherContactInfoResponse Handle(GatherContactInfoRequest request)
        {
            if (request is null)
            {
                throw new ArgumentNullException(nameof(request));
            }

            var response = new GatherContactInfoResponse(
                request.Name,
                request.Address,
                request.DateOfBirth,
                request.NationalInsuranceNumber);

            if (string.IsNullOrEmpty(request.Name))
            {
                response.AddError("Name cannot be empty");
            }

            if (string.IsNullOrEmpty(request.Address))
            {
                response.AddError("Address cannot be empty");
            }

            if (string.IsNullOrEmpty(request.NationalInsuranceNumber))
            {
                response.AddError("National Insurance number cannot be empty.");
            }

            var customerRecord = new Customer(request.Name, request.Address, request.DateOfBirth, request.NationalInsuranceNumber);

            if (customerRecord.Age < 18)
            {
                response.AddError("A customer must be over the age of 18");
            }

            if (response.HasError == false)
            {
                response.CreditScore = this._creditScoreService.GetCreditScore(request.NationalInsuranceNumber);

                if (response.CreditScore > 500)
                {
                    this._customerDatabase.Store(customerRecord);
                }
                else
                {
                    response.AddError("Credit score is too low, sorry!");
                }
            }

            return response;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

我不会逐行讨论代码,但您可以看到业务用例清晰明确。

新手开发人员一眼就能看出这个类的原理,相当容易理解。它清晰简洁,非常贴近实际业务领域。

想象一下与非程序员坐下来逐行讨论该代码文件以解决任何问题,这也并不困难。

所以现在我们已经映射了我们业务的核心(这是一家非常小的银行),但是我们如何实际使用代码呢?

用户界面

现在我们已经制定了业务逻辑,我们可以开始考虑银行人员如何实际与软件进行交互。

在这种情况下,银行非常乐意拥有一个可以运行的小型可执行文件,手动输入信息并等待结果返回。

由于我们所有的业务逻辑都包含在核心库中,因此控制台应用程序变得非常简单。

// ------------------------------------------------------------
// Copyright (c) James Eastham.
// ------------------------------------------------------------

using System;
using CleanArchitecture.Core.Requests;

namespace CleanArchitecture.ConsoleApp
{
    /// <summary>
    /// Main application entry point.
    /// </summary>
    public static class Program
    {
        /// <summary>
        /// Main application entry point.
        /// </summary>
        /// <param name="args">Arguments passed into the application at runtime.</param>
        public static void Main(string[] args)
        {
            if (args == null)
            {
                throw new ArgumentException("args cannot be null");
            }

            var getContactInteractor = new GatherContactInfoInteractor(new MockCreditScoreService(), new CustomerDatabaseInMemoryImpl());

            Console.WriteLine("Name?");
            var name = Console.ReadLine();

            Console.WriteLine("Address?");
            var address = Console.ReadLine();

            Console.WriteLine("Date of birth (yyyy-MM-dd)?");
            var dateOfBirth = DateTime.Parse(Console.ReadLine());

            Console.WriteLine("NI Number?");
            var niNumber = Console.ReadLine();

            var getContactResponse = getContactInteractor.Handle(new GatherContactInfoRequest(name, address, dateOfBirth, niNumber));

            if (getContactResponse.HasError)
            {
                foreach (var error in getContactResponse.Errors)
                {
                    Console.WriteLine(error);
                }
            }
            else
            {
                var result = getContactResponse.IsAcceptedForLoan ? "accepted" : "rejected";

                Console.WriteLine($"Credit score is {getContactResponse.CreditScore} so customer has been {result}");
            }

            Console.WriteLine("Press any key to close");
            Console.ReadKey();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

UI 仅关心如何向用户呈现数据,而不关心如何验证客户。

如果 6 个月后银行决定允许潜在客户通过网站而不是手动输入来验证自己,那么这个过程就很简单了。

可以使用完全相同的库,但该接口如何提供则完全无关紧要。

突然之间,这家银行可以更加灵活地应对其无法控制的变化。

如果出现一种新的技术趋势,银行开始在背后识别潜在客户(银行没有听说过 GDPR),他们可以再次使用完全相同的库来运行验证。

在运行时也会注入 CreditScoreService 和 CustomersDatabase 的实现。

概括

我看到很多关于清洁架构的帖子和评论,指出它对于很多项目来说可能是过度的。

对于小型实用应用程序或任何用于各种修补程序的“一次性”程序,我确实同意这种说法。但是,我认为任何用于任何生产场景的应用程序都应该从头开始以正确的方式构建。

一些小的选择,例如将数据库与业务逻辑分离,使得应用程序更能适应变化。

文章来源:https://dev.to/jeastham1993/clean-architecture-in-net-core-56gh
PREV
轻松扩展 React 项目的 5 个最佳实践
NEXT
使用 Python 和 AWS Lambda 构建 Twitter 机器人