Skip to content

在DOTNET开发中保持整洁的架构

Published: at 08:00 AM

保持整洁的架构

摘要

本文翻译自 Julio Casal 的文章,介绍了 Clean Architecture 的概念和实际应用。

什么是 Clean Architecture?

Clean Architecture 是一种架构模式,强调:

它由Robert C. Martin (Uncle Bob)创建,并基于他同样提出的SOLID原则。

Clean Architecture 的环形结构是什么?

Clean Architecture 中有 4 个环形结构:

让我们简要描述每一个:

实体(Entities)

用例(Use Cases)

接口适配器(Interface Adapters)

框架和驱动(Frameworks and Drivers)

依赖规则

这是 Clean Architecture 中的关键规则:

源代码依赖只能向内指向,内部圈子中的任何内容都不知道外部圈子中的任何事物。

例如,用例只能依赖于实体,但永远不能依赖于控制器、端点或具体的仓库实现。

一个实际的 ASP.NET Core 实现

那么,这些圈子是如何转化为实际 ASP.NET Core 应用程序的结构的呢?

这里的指导意见因为 Uncle Bob 没有提及如何在任何特定技术栈上实施而各不相同。

已经有数十个参考实现存在,其中一些最受欢迎的是由Jason TaylorSteve Smith创建的模板。

但在这里,我将向你展示我一直在做的方式:

核心(Core)
我倾向于将实体(Entities)用例(Use Cases)圈子合并在一个称为 Core 的单个项目中,而不是为它们分别设立不同的项目。

是的,理论上你应该将实体分开,这样你就可以在组织中的多个系统中重用它们(也许作为一个 NuGet 包?)。

但实际上,我从未见过需要这样做的系统。尤其是当你做微服务时,每个微服务将完全拥有它们的领域,所以没有必要跨系统共享实体。

这是GameMatch实体(简短版):

public class GameMatch
{
    public GameMatch(Guid id, string player1)
    {
        // Validate parameters here

        Id = id;
        Player1 = player1;
        State = GameMatchState.WaitingForOpponent;
    }

    public Guid Id { get; }

    public string Player1 { get; }

    public string? Player2 { get; private set; }

    public GameMatchState State { get; private set; }

    // More properties here

    public void SetPlayer2(string player2)
    {
        ArgumentException.ThrowIfNullOrEmpty(player2);

        Player2 = player2;
        State = GameMatchState.MatchWaitingForGame;
    }
}

关于用例(Use Cases),我在这里称它们为处理器(Handlers),每一个处理器都是一个小类,专门用来处理一个,且仅一个用例。

这是MatchPlayerHandler(为简洁起见,移除了日志记录):

public class MatchPlayerHandler
{
    private readonly IGameMatchRepository repository;
    private readonly IBus bus;
    private readonly ILogger<MatchPlayerHandler> logger;

    public MatchPlayerHandler(IGameMatchRepository repository, IBus bus, ILogger<MatchPlayerHandler> logger)
    {
        this.repository = repository;
        this.bus = bus;
        this.logger = logger;
    }

    public async Task<GameMatchResponse> HandleAsync(JoinMatchRequest matchRequest)
    {
        string playerId = matchRequest.PlayerId;

        GameMatch? match = await repository.FindMatchForPlayerAsync(playerId);

        if (match is null)
        {
            match = await repository.FindOpenMatchAsync();

            if (match is null)
            {
                match = new GameMatch(Guid.NewGuid(), playerId);
                await repository.CreateMatchAsync(match);
            }
            else
            {
                match.SetPlayer2(playerId);
                await repository.UpdateMatchAsync(match);
                await bus.Publish(new MatchWaitingForGame(match.Id));
            }
        }

        return match.ToGameMatchResponse();
    }
}

这里还会有一个Repositories文件夹,它只包含仓库接口,而不包含具体的实现,具体实现属于**基础设施(Infrastructure)**项目。

这是IGameMatchRepository

public interface IGameMatchRepository
{
    Task CreateMatchAsync(GameMatch match);
    Task<GameMatch?> FindMatchForPlayerAsync(string playerId);
    Task<GameMatch?> FindOpenMatchAsync();
    Task UpdateMatchAsync(GameMatch match);
}

类似于仓库,有时我也会在这里有一个Services文件夹,里面有一堆接口用于与其他基础设施服务交互。

最后,有一个Extensions类提供了一个方法来注册所有核心依赖项:

public static IServiceCollection AddCore(
    this IServiceCollection services)
{
    services.AddSingleton<GetMatchForPlayerHandler>()
            .AddSingleton<MatchPlayerHandler>();

    return services;
}

契约(Contracts)
这个在 Clean Architecture 的理论中没有提及,但我觉得它是必需的。

这是所有用于与客户端或其他微服务交互的DTOs(数据传输对象)消息所在的地方。

由于其他团队通常希望我提供一个包含所有这些契约的 NuGet 包,因此最理想的做法是将它们保留在自己的项目中,这样就可以轻松地将它们转换成 NuGet 包。

这些契约被用作用例的输入和输出,因此核心项目(Core project)必须依赖于契约项目(Contracts project)。

这样做是否非常清晰?不能确定,但这是我能想到的最佳方案。

以下是MatchPlayerHandler使用的 DTOs:

public record GameMatchResponse(Guid Id, string Player1, string? Player2, string State, string? IpAddress, int? Port);
public record JoinMatchRequest(string PlayerId);

基础设施(Infrastructure)
这是接口适配器(Interface Adapters)所在的地方,因此这个项目被允许依赖于几乎任何外部框架。

在我的示例中它看起来很小,但通常这部分会相当大,因为这里是你会找到 Core 项目中定义的任何接口的具体实现,这些接口是处理程序驱动用例所需的。

注意,这是唯一知道我们正在使用 Mongo DB 的项目。Core 项目对此一无所知。

而且,在你问之前:

是的,如果你正在使用 Entity Framework,这里就是你实现具体 EF 仓库的地方。你的 Core 项目应该不知道你正在使用 Entity Framework。

这是MongoGameMatchRepository(为简洁起见,移除了大部分方法实现):

public class MongoGameMatchRepository : IGameMatchRepository
{
    private const string collectionName = "matches";
    private readonly IMongoCollection<GameMatch> dbCollection;
    private readonly FilterDefinitionBuilder<GameMatch> filterBuilder = Builders<GameMatch>.Filter;

    public MongoGameMatchRepository(IMongoDatabase mongoDatabase)
    {
        dbCollection = mongoDatabase.GetCollection<GameMatch>(collectionName);
    }

    public async Task<GameMatch?> FindMatchForPlayerAsync(string playerId)
    {
        var filter = filterBuilder.Or(
            filterBuilder.Eq(match => match.Player1, playerId),
            filterBuilder.Eq(match => match.Player2, playerId));
        return await dbCollection.Find(filter).FirstOrDefaultAsync();
    }

    public async Task<GameMatch?> FindOpenMatchAsync()
    {
        // Find an open match
    }

    public async Task CreateMatchAsync(GameMatch match)
    {
        // Create the match
    }

    public async Task UpdateMatchAsync(GameMatch match)
    {
        // Update the match
    }
}

我还在那里有一个Extensions类,提供了一个方法来注册所有基础设施依赖项:

public static IServiceCollection AddInfrastructure(
    this IServiceCollection services,
    IConfiguration configuration)
{
    services.AddSingleton(serviceProvider =>
    {
        MongoClient mongoClient = new(configuration["DatabaseConnectionString"]);
        return mongoClient.GetDatabase(configuration["DatabaseName"]);
    })
    .AddSingleton<IGameMatchRepository, MongoGameMatchRepository>();

    services.AddMassTransit(configurator =>
    {
        configurator.UsingRabbitMq();
    });

    return services;
}

请注意,AddInfrastructure最终会进行这样的调用:

services.AddSingleton<IGameMatchRepository, MongoGameMatchRepository>();

这就是依赖反转魔法发生的地方。

因此,当MatchPlayerHandler被实例化时,它将接收到一个MongoGameMatchRepository的实例,但它将不知道这是一个 Mongo 仓库,因为它只知道IGameMatchRepository

很酷的东西!

API
这里是你将定义所有控制器(controllers)端点(endpoints)的地方。

以下是端点:

public static class MatchMakerEndpoints
{
    public static RouteGroupBuilder MapMatchMakerEndpoints(this IEndpointRouteBuilder routes)
    {
        var group = routes.MapGroup("/matches");

        group.MapPost("/", async (JoinMatchRequest request, MatchPlayerHandler handler) =>
        {
            return await handler.HandleAsync(request);
        });

        group.MapGet("/", async (string playerId, GetMatchForPlayerHandler handler) =>
        {
            return await handler.HandleAsync(playerId);
        });

        return group;
    }
}

而且,在启动时,在Program.cs中,你会有类似这样的内容:

builder.Services.AddInfrastructure(builder.Configuration)
                .AddCore();

这负责注册 Core 和 Infrastructure 项目中的所有依赖项。

测试(Tests)
最后,我们有了测试项目,这是所有自动化测试所在的地方。

这里体现了 Clean Architecture 的一个关键好处:

你可以专注于对业务规则(实体和用例)进行单元测试,而不必担心任何外部依赖。

这是可能的,因为 Core 项目不依赖于任何外部框架,所以你可以通过在 Core 中定义的仓库和服务接口轻松地模拟任何协作者。

这是MatchPlayerHandler单元测试之一:

[Fact]
public async Task HandleAsync_ExistingOpenMatch_ReturnsMatch()
{
    // Arrange
    GameMatch match = new(Guid.NewGuid(), "P1");
    repositoryStub.Setup(repo => repo.FindMatchForPlayerAsync(It.IsAny<string>()))
        .ReturnsAsync((GameMatch?)null);
    repositoryStub.Setup(repo => repo.FindOpenMatchAsync())
        .ReturnsAsync(match);
    GameMatchResponse expected = new(match.Id, match.Player1, "P2", GameMatchState.MatchWaitingForGame.ToString(), null, null);

    var handler = new MatchPlayerHandler(repositoryStub.Object, busStub.Object, loggerStub.Object);

    // Act
    var actual = await handler.HandleAsync(new JoinMatchRequest(expected.Player2!));

    // Assert
    actual.Should().Be(expected);
}

当你完成对 Core 的测试添加后,你就知道你有了一个坚实的基础,可以在其上构建。

使用 Clean Architecture 能得到什么?

具体来说,这里是我在我的项目中通过使用 Clean Architecture 获得的好处:

但我认为最重要的好处是这个:

它鼓励我不将业务规则与基础设施问题混合,这导致了一个更容易维护和随时间发展的系统。