2023年12月19日
六角形架構(Ports & Adapters)在 .NET 的實作:讓圖片從本機換到 S3 不用改一行業務程式
傳統分層架構的問題
最常見的三層架構是 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 提出。六角形本身沒有特別的意義,只是畫起來比圓形容易標出很多個「面」,每一面代表一種和外界溝通的方式。
- 核心(Core):Domain(實體、聚合根、領域事件、商業規則)與 Application(UseCase,也就是應用程式提供的功能)。核心只包含業務,不引用任何技術框架
- Port(埠):核心定義的介面,描述「我需要什麼」或「我提供什麼」
- In Port(Driving Port):核心對外提供的功能,例如
IChatRoomService.CreateAsync() - Out Port(Driven Port):核心需要外部提供的能力,例如「我需要一個能儲存圖片的東西」
IImageStorage
- In Port(Driving Port):核心對外提供的功能,例如
- Adapter(轉接器):把外部技術接到 Port 上的實作
- Driving Adapter:呼叫核心的一方,例如 Web API Controller、SignalR Hub、排程、單元測試
- Driven Adapter:被核心呼叫的一方,例如 EF Core Repository、S3 圖片儲存、OpenAI 用戶端
最重要的規則:依賴方向往內
六角形架構的核心規則只有一條:所有依賴都指向核心,核心不依賴任何外部技術。
Out Port 的介面定義在核心裡,實作放在外圍。這就是依賴反轉原則(Dependency Inversion Principle):核心說「我需要一個 IImageStorage」,至於它是本機磁碟還是 S3,核心完全不知道,也不在乎。
// 核心:定義 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
圖片儲存有兩種實作,各自放在獨立的專案:
// 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}";
}
}
要用哪一個,只在程式進入點決定:
if (builder.Configuration["AWS_BUCKET"] is not null)
builder.Services.AddS3ImageStorage(builder.Configuration);
else
builder.Services.AddLocalImageStorage(builder.Configuration);
換儲存方式時,AddImageMessageService 和其他所有業務程式一行都不用改。
專案怎麼切
用專案(csproj)的參考關係,讓編譯器幫忙守住依賴方向:
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:
[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(介面)隔開,所有依賴都指向核心;再用專案參考關係讓編譯器守住這個方向,就能做到換資料庫、換儲存、換外部服務都不必修改業務程式,單元測試也不再需要任何外部環境。