← 回文章列表

2023年12月19日

六角形架構(Ports & Adapters)在 .NET 的實作:讓圖片從本機換到 S3 不用改一行業務程式

Hexagonal ArchitectureClean Architecture.NET架構設計DDD

傳統分層架構的問題

最常見的三層架構是 Presentation → Business Logic → Data Access,依賴一路往下。問題在於業務邏輯依賴了資料存取層:

  • 業務邏輯直接 new 或注入具體的 Repository 類別,裡面是 EF Core 的 DbContext
  • 要上傳圖片,業務邏輯裡直接呼叫 AWS SDK 的 PutObjectAsync
  • 要呼叫 GPT,業務邏輯裡直接組 HTTP 請求

結果是:想測試「新增訊息時,聊天室的標題要自動以第一句話命名」這個規則,得先準備資料庫、S3、OpenAI 的金鑰。想把圖片從 S3 換成本機磁碟(開發環境不想連 AWS),得修改業務邏輯的程式碼。

六角形架構:核心在中間,外部世界在外圍

六角形架構(Hexagonal Architecture),又稱 Ports & Adapters,由 Alistair Cockburn 提出。六角形本身沒有特別的意義,只是畫起來比圓形容易標出很多個「面」,每一面代表一種和外界溝通的方式。

六角形架構示意圖:中心是 Domain(ChatRoom 聚合根與領域事件),外圍是 Application UseCase;左側 Driving Adapter(Web API Controller、SignalR Hub、單元測試)呼叫核心定義的 In Port;右側 Out Port(IChatRoomRepository、IImageStorage、IChatCompletion)由 Driven Adapter 實作(EF Core SQL Server、Local 或 S3 圖片儲存、OpenAI API 或測試用假物件);所有箭頭都是依賴方向,Adapter 依賴核心定義的 Port,核心不認識任何 Adapter

  • 核心(Core):Domain(實體、聚合根、領域事件、商業規則)與 Application(UseCase,也就是應用程式提供的功能)。核心只包含業務,不引用任何技術框架
  • Port(埠):核心定義的介面,描述「我需要什麼」或「我提供什麼」
    • In Port(Driving Port):核心對外提供的功能,例如 IChatRoomService.CreateAsync()
    • Out Port(Driven Port):核心需要外部提供的能力,例如「我需要一個能儲存圖片的東西」IImageStorage
  • Adapter(轉接器):把外部技術接到 Port 上的實作
    • Driving Adapter:呼叫核心的一方,例如 Web API Controller、SignalR Hub、排程、單元測試
    • Driven Adapter:被核心呼叫的一方,例如 EF Core Repository、S3 圖片儲存、OpenAI 用戶端

最重要的規則:依賴方向往內

六角形架構的核心規則只有一條:所有依賴都指向核心,核心不依賴任何外部技術。

Out Port 的介面定義在核心裡,實作放在外圍。這就是依賴反轉原則(Dependency Inversion Principle):核心說「我需要一個 IImageStorage」,至於它是本機磁碟還是 S3,核心完全不知道,也不在乎。

C#
// 核心:定義 Port(介面放在 UseCase 專案中)
public interface IImageStorage
{
    Task<string> SaveAsync(string fileName, Stream content, CancellationToken cancellationToken);
}

// 核心:UseCase 只依賴 Port
public class AddImageMessageService
{
    private readonly IImageStorage _imageStorage;
    private readonly IChatRoomRepository _repository;

    public AddImageMessageService(IImageStorage imageStorage, IChatRoomRepository repository)
    {
        _imageStorage = imageStorage;
        _repository = repository;
    }

    public async Task HandleAsync(Guid chatRoomId, string prompt, Stream image, CancellationToken ct)
    {
        var chatRoom = await _repository.GetAsync(chatRoomId, ct);
        var url = await _imageStorage.SaveAsync($"{Guid.NewGuid()}.png", image, ct);

        chatRoom.AddImageMessage(prompt, url);   // 商業規則在聚合根裡
        await _repository.SaveAsync(chatRoom, ct);
    }
}

同一個 Port,兩種 Adapter

圖片儲存有兩種實作,各自放在獨立的專案:

C#
// Adapter:本機檔案系統(開發、測試環境)
public class LocalImageStorage : IImageStorage
{
    private readonly string _root;
    public LocalImageStorage(IOptions<LocalStorageOptions> options) => _root = options.Value.Path;

    public async Task<string> SaveAsync(string fileName, Stream content, CancellationToken ct)
    {
        var path = Path.Combine(_root, fileName);
        await using var file = File.Create(path);
        await content.CopyToAsync(file, ct);
        return $"/images/{fileName}";
    }
}

// Adapter:AWS S3(正式環境)
public class S3ImageStorage : IImageStorage
{
    private readonly IAmazonS3 _s3;
    private readonly string _bucket;

    public S3ImageStorage(IAmazonS3 s3, IOptions<S3StorageOptions> options)
    {
        _s3 = s3;
        _bucket = options.Value.Bucket;
    }

    public async Task<string> SaveAsync(string fileName, Stream content, CancellationToken ct)
    {
        await _s3.PutObjectAsync(new PutObjectRequest
        {
            BucketName = _bucket,
            Key = fileName,
            InputStream = content
        }, ct);
        return $"https://{_bucket}.s3.amazonaws.com/{fileName}";
    }
}

要用哪一個,只在程式進入點決定:

C#
if (builder.Configuration["AWS_BUCKET"] is not null)
    builder.Services.AddS3ImageStorage(builder.Configuration);
else
    builder.Services.AddLocalImageStorage(builder.Configuration);

換儲存方式時,AddImageMessageService 和其他所有業務程式一行都不用改。

專案怎麼切

用專案(csproj)的參考關係,讓編譯器幫忙守住依賴方向:

text
NexusGPT.Entities                         ← Domain:聚合根、領域事件(不參考任何專案)
NexusGPT.UseCase                          ← Application:UseCase + Port 介面(只參考 Entities)
NexusGPT.Adapter.Out                      ← Driven Adapter:EF Core Repository(參考 UseCase)
NexusGPT.Adapter.Out.ImageStorage.Local   ← Driven Adapter:本機圖片儲存
NexusGPT.Adapter.Out.ImageStorage.S3      ← Driven Adapter:S3 圖片儲存
NexusGPT.WebApplication                   ← Driving Adapter + 組裝:Controller、Hub、DI 註冊

NexusGPT.UseCase 不參考任何 Adapter 專案,所以在 UseCase 裡根本寫不出 new AmazonS3Client()——編譯器會直接報錯。比起靠 Code Review 提醒「不要在業務邏輯裡呼叫 SDK」,讓專案參考關係擋住要可靠得多。

只有 WebApplication 這個最外層的專案會參考所有其他專案,負責把 Adapter 組裝起來,這個位置也稱為 Composition Root。

帶來的好處

測試變簡單:UseCase 只依賴介面,單元測試直接給假的實作,不需要資料庫、S3、OpenAI:

C#
[Fact]
public async Task HandleAsync_圖片訊息_訊息會帶有儲存後的圖片網址()
{
    var storage = Substitute.For<IImageStorage>();
    storage.SaveAsync(default!, default!, default).ReturnsForAnyArgs("https://img/1.png");
    var repository = new InMemoryChatRoomRepository(ChatRoom.Create("新對話"));

    var sut = new AddImageMessageService(storage, repository);
    await sut.HandleAsync(repository.Id, "一隻貓", Stream.Null, CancellationToken.None);

    repository.Saved.Messages.Last().ImageUrl.Should().Be("https://img/1.png");
}

技術決策可以延後、可以反悔:一開始用 SQL Server,之後想支援 DynamoDB 或 MongoDB,只要為 IChatRoomRepository 多寫一個 Adapter。

業務邏輯集中:商業規則都在核心裡,閱讀程式碼時不會被 SDK 的細節淹沒。

什麼時候不需要

六角形架構會讓專案數量和介面數量變多。如果系統只是單純的 CRUD、沒有什麼商業規則、外部依賴也不會更換,一個專案加上清楚的資料夾分層就夠了。它最有價值的地方,是商業規則複雜、或外部依賴明確需要抽換(不同環境、不同客戶、不同雲端)的系統。

使用情境:內部 GPT 平台的開源版本

公司內部 GPT 平台的核心程式碼整理成開源專案時,就是以六角形架構切分:

  • 聊天室、訊息、圖片訊息的商業規則放在 Domain 與 UseCase
  • 資料庫存取、圖片儲存、OpenAI 呼叫都是 Driven Adapter
  • 圖片儲存提供 Local 與 S3 兩種 Adapter:本機開發掛載一個資料夾就能跑,正式環境設定 S3 的環境變數即可,Docker 映像完全相同

一句話總結

六角形架構把系統分成「只有業務的核心」和「接外部技術的 Adapter」,兩者之間用核心定義的 Port(介面)隔開,所有依賴都指向核心;再用專案參考關係讓編譯器守住這個方向,就能做到換資料庫、換儲存、換外部服務都不必修改業務程式,單元測試也不再需要任何外部環境。