使用領域驅動設計來提高你的 .NET 專案品質 Enhancing Your .NET Projects with Domain-Driven Design Techniques
前言
Introduction
在開發程式時,不知道你有沒有遇過下面的問題:
When developing programs, have you ever encountered the following problems:
經常會跟使用者雞同鴨講,使用者口中的系統規則跟你眼前的程式碼像是兩個不同的世界。
Frequent miscommunication with users, where the system rules described by users seem completely different from the code you see.
使用者反應系統某筆資料有誤,但散落在各地的程式碼都會寫入或修改資料,很難知道為什麼目前資料會長這樣。你試著從一大堆 log 中推敲出原因,但看了半小時後才發現最關鍵的 log 沒有記錄到。
Users report data errors, but the scattered code makes it difficult to understand why the current data looks the way it does. You try to deduce the cause from a bunch of logs, only to find that the most critical log is missing after half an hour.
多個部門的使用者使用同一套系統,但每個人都各司其職,各自只在乎自己職掌下的業務流程。像人事只在乎人事的業務,會計只在乎會計的業務,人事不想管會計業務的死活,會計也不想管人事業務的死活。但偏偏人事與會計之間必須合作,彼此的業務會互相影響。
Multiple departments use the same system, but each person only cares about their own business processes. For example, HR only cares about HR business, and accounting only cares about accounting business. However, HR and accounting must cooperate, and their businesses affect each other.
如果你也遇到類似的困難,或許領域驅動設計可以幫助你!
If you have encountered similar difficulties, perhaps domain-driven design can help you!
學習領域驅動設計
Learning Domain-Driven Design
推薦 .NET 工程師可以閱讀 Alexey Zimarev 所寫的《領域驅動設計與 .NET Core》。另外這本書的作者目前還有開發一個供 .NET 專案使用的領域驅動設計套件,Eventuous。這篇文章會一步一步教你如何使用 Eventuous 來開發 .NET 專案,讓我們開始吧!
.NET engineers are recommended to read “Hands-On Domain-Driven Design with .NET Core” by Alexey Zimarev. The author has also developed a domain-driven design package for .NET projects called Eventuous. This article will teach you step-by-step how to use Eventuous to develop .NET projects. Let’s get started!
需要安裝的工具
Tools Needed
Visual Studio 2022 Community
SQL Server 2022 Developer
SQL Server Management Studio
Docker Desktop
MongoDB Compass
建立飯店專案
Creating a Hotel Project
這個專案會做一個飯店的內部管理系統,包括訂房、人事、會計、顧客等子系統,這次只會實作訂房的部分,其他子系統可以依類似的架構來觸類旁通。
This project will create an internal management system for a hotel, including subsystems such as booking, HR, accounting, and customers. This time, only the booking part will be implemented, and other subsystems can be extended similarly.
Create a new project
選擇 ASP.NET Core Web API
Choose ASP.NET Core Web API
輸入 Project Name (Hotel)、Location (自己喜歡的目錄就可以)、Solution Name (Hotel)
Enter Project Name (Hotel), Location (any preferred directory), Solution Name (Hotel)
Framework 選擇 .NET 8,其他照預設的就好
Choose .NET 8, and keep other settings as default
刪除不會用到的 Controllers 資料夾、Hotel.http、WeatherForecast.cs
Delete unused folders and files: Controllers, Hotel.http, WeatherForecast.cs
訊息
Messages
訊息分為系統內部間傳遞的事件,給外部系統呼叫的指令。有關訂房的內部事件如新增一筆訂單、訂單付款。有關訂房的外部溝通合約如提供給前端程式的 http api。Eventuous對事件的解釋如連結。
Messages are divided into internal events and commands for external systems. Internal events related to booking include adding a new order and order payment. External communication contracts related to booking include HTTP APIs provided to front-end programs. Eventuous explains events as follows: [link].
在 Solution 底下新增 Project
Create a new project
選擇 Class Library
Choose Class Library
Project name 取名為 Hotel.Bookings.Messages,存放與訂房有關的內外部訊息
Project name: Hotel.Bookings.Messages, to store internal and external messages related to booking
選擇 .NET 8
Choose .NET 8
刪除不會用到的 Class1.cs
Delete unused Class1.cs
在 Hotel.Bookings.Messages 專案底下安裝 NuGet 套件
Install NuGet packages in the Hotel.Bookings.Messages project
安裝 Eventuous.Shared 0.15.0-rc.2 與 NodaTime 3.1.11。Eventuous.Shared 提供的 EventType,可以把事件名稱與對應的事件類別關聯起來。
Install Eventuous.Shared 0.15.0-rc.2 and NodaTime 3.1.111. Eventuous.Shared provides EventType, which can associate event names with corresponding event classes.
在 Hotel.Bookings.Messages 專案底下新增 Events.cs,定義了5個事件。事件名稱需要跟使用者討論出一個雙方都能清楚理解的名稱,避免溝通上有誤會。
In the Hotel.Bookings.Messages project, add Events.cs and define five events. The event names need to be discussed with the users to ensure both parties clearly understand the names, avoiding any communication misunderstandings.
using Eventuous; using NodaTime; namespace Hotel.Bookings.Messages; public static class Events { public static class V1 { [EventType("V1.RoomBooked")] public record RoomBooked( string GuestId, string RoomId, LocalDate CheckInDate, LocalDate CheckOutDate, float BookingPrice, float PrepaidAmount, float OutstandingAmount, string Currency, DateTimeOffset BookingDate ); [EventType("V1.PaymentRecorded")] public record PaymentRecorded( float PaidAmount, float Outstanding, string Currency, string PaymentId, string PaidBy, DateTimeOffset PaidAt ); [EventType("V1.FullyPaid")] public record BookingFullyPaid(DateTimeOffset FullyPaidAt); [EventType("V1.Overpaid")] public record BookingOverpaid(DateTimeOffset OverpaidAt); [EventType("V1.BookingCancelled")] public record BookingCancelled(string CancelledBy, DateTimeOffset CancelledAt); } }
在 Hotel.Bookings.Messages 專案底下新增 Commands.cs。
Add Commands.cs in the Hotel.Bookings.Messages project
namespace Hotel.Bookings.Messages; public static class Commands { public static class V1 { public record BookRoom( string BookingId, string GuestId, string RoomId, DateTime CheckInDate, DateTime CheckOutDate, float BookingPrice, float PrepaidAmount, string Currency, DateTimeOffset BookingDate ); public record RecordPayment( string BookingId, float PaidAmount, string Currency, string PaymentId, string PaidBy ); } }
狀態
State
聚合會因各種不同的事件發生,而移轉至不同的狀態。Eventuous對狀態的解釋如連結。
Aggregates transition to different states based on various events. Eventuous explains states as follows: [link].
在 Solution 底下新增 Project
Create a new project
選擇 Class Library
Choose Class Library
Project name 取名為 Hotel.Bookings.Domain,存放與訂房有關的業務規則。
Project name: Hotel.Bookings.Domain, to store business rules related to booking
選擇 .NET 8
Choose .NET 8
刪除不會用到的 Class1.cs
Delete unused Class1.cs
在 Hotel.Bookings.Domain 專案底下安裝 NuGet 套件
Install NuGet package
安裝 Eventuous.Domain 0.15.0-rc.2。
Install NuGet package: Eventuous.Domain 0.15.0-rc.2
Hotel.Bookings.Domain 專案加入對 Hotel.Bookings.Messages 專案的參考。
Hotel.Bookings.Domain project adds a reference to the Hotel.Bookings.Messages project.
在 Hotel.Bookings.Domain 專案底下新增 Bookings 資料夾。Bookings 資料夾下放Bookings 聚合內的程式碼,Bookings 聚合以外的其他聚合或值物件程式碼放 Bookings 資料夾外面。
In the Hotel.Bookings.Domain project, create a Bookings folder. Place the code for the Bookings aggregate inside the Bookings folder. Code for other aggregates or value objects should be placed outside the Bookings folder.
Bookings 資料夾底下新增 BookingState.cs。當事件發生時,BookingState 會回傳新的狀態。
Add BookingState.cs under the Bookings folder12. When an event occurs, BookingState will return a new state.
using Eventuous; using System.Collections.Immutable; using static Hotel.Bookings.Messages.Events; namespace Hotel.Bookings.Domain.Bookings; public record BookingState : State<BookingState> { public string GuestId { get; init; } = null!; public RoomId RoomId { get; init; } = null!; public StayPeriod Period { get; init; } = null!; public Money Price { get; init; } = null!; public Money Outstanding { get; init; } = null!; public bool Paid { get; init; } public ImmutableArray<PaymentRecord> Payments { get; init; } = ImmutableArray<PaymentRecord>.Empty; internal bool HasPaymentBeenRegistered(string paymentId) => Payments.Any(x => x.PaymentId == paymentId); public BookingState() { On<V1.RoomBooked>(HandleBooked); On<V1.PaymentRecorded>(HandlePayment); On<V1.BookingFullyPaid>((state, paid) => state with { Paid = true }); } static BookingState HandlePayment(BookingState state, V1.PaymentRecorded e) => state with { Outstanding = new Money { Amount = e.Outstanding, Currency = e.Currency }, Payments = state.Payments.Add(new PaymentRecord(e.PaymentId, new Money(e.PaidAmount, e.Currency))) }; static BookingState HandleBooked(BookingState state, V1.RoomBooked booked) => state with { RoomId = new RoomId(booked.RoomId), Period = new StayPeriod(booked.CheckInDate, booked.CheckOutDate), GuestId = booked.GuestId, Price = new Money { Amount = booked.BookingPrice, Currency = booked.Currency }, Outstanding = new Money { Amount = booked.OutstandingAmount, Currency = booked.Currency } }; } public record PaymentRecord(string PaymentId, Money PaidAmount); public record DiscountRecord(Money Discount, string Reason);
新增 RoomId.cs
Add RoomId.cs
using Eventuous; namespace Hotel.Bookings.Domain; public record RoomId(string Value) : Id(Value);
新增 StayPeriod.cs
Add StayPeriod.cs
using Eventuous; using NodaTime; namespace Hotel.Bookings.Domain; public record StayPeriod { public LocalDate CheckIn { get; } public LocalDate CheckOut { get; } internal StayPeriod() { } public StayPeriod(LocalDate checkIn, LocalDate checkOut) { if (checkIn > checkOut) throw new DomainException("Check in date must be before check out date"); (CheckIn, CheckOut) = (checkIn, checkOut); } }
新增 Money.cs
Add Money.csusing Eventuous; namespace Hotel.Bookings.Domain; public record Money { public float Amount { get; internal init; } public string Currency { get; internal init; } = null!; static readonly string[] SupportedCurrencies = { "USD", "GPB", "EUR", "TWD" }; internal Money() { } public Money(float amount, string currency) { if (!SupportedCurrencies.Contains(currency)) throw new DomainException($"Unsupported currency: {currency}"); Amount = amount; Currency = currency; } public bool IsSameCurrency(Money another) => Currency == another.Currency; public static Money operator -(Money one, Money another) { if (!one.IsSameCurrency(another)) throw new DomainException("Cannot operate on different currencies"); return new Money(one.Amount - another.Amount, one.Currency); } public static implicit operator double(Money money) => money.Amount; }
聚合
Aggregate
聚合是領域驅動設計的核心。同一個聚合底下的資料異動時,需確保是完全成功或完全失敗,不可以部份成功部份失敗。聚合有一個唯一的識別碼。Eventuous對聚合的解釋如連結。
Aggregates are the core of domain-driven design. Data changes within the same aggregate must be completely successful or completely failed. Aggregates have a unique identifier. Eventuous explains aggregates as follows: [link].
Bookings 資料夾底下新增 BookingId.cs。是 Booking 聚合的識別碼。
Add BookingId.cs under the Bookings folder. It is the identifier for the Booking aggregate.
using Eventuous; namespace Hotel.Bookings.Domain.Bookings; public record BookingId(string Value) : Id(Value);
Bookings 資料夾底下新增 Booking.cs。是 Booking 聚合。Booking 會觸發各種事件。
Add Booking.cs under the Bookings folder. This is the Booking aggregate. Booking will trigger various events.
using Eventuous; using static Hotel.Bookings.Domain.Services; using static Hotel.Bookings.Messages.Events; namespace Hotel.Bookings.Domain.Bookings; public class Booking : Aggregate<BookingState> { public async Task BookRoom( string guestId, RoomId roomId, StayPeriod period, Money price, Money prepaid, DateTimeOffset bookedAt, IsRoomAvailable isRoomAvailable ) { EnsureDoesntExist(); await EnsureRoomAvailable(roomId, period, isRoomAvailable); var outstanding = price - prepaid; Apply( new V1.RoomBooked( guestId, roomId, period.CheckIn, period.CheckOut, price.Amount, prepaid.Amount, outstanding.Amount, price.Currency, bookedAt ) ); MarkFullyPaidIfNecessary(bookedAt); } public void RecordPayment( Money paid, string paymentId, string paidBy, DateTimeOffset paidAt ) { EnsureExists(); if (State.HasPaymentBeenRegistered(paymentId)) return; var outstanding = State.Outstanding - paid; Apply( new V1.PaymentRecorded( paid.Amount, outstanding.Amount, paid.Currency, paymentId, paidBy, paidAt ) ); MarkFullyPaidIfNecessary(paidAt); MarkOverpaid(paidAt); } void MarkFullyPaidIfNecessary(DateTimeOffset when) { if (State.Outstanding.Amount <= 0) Apply(new V1.BookingFullyPaid(when)); } void MarkOverpaid(DateTimeOffset when) { if (State.Outstanding.Amount < 0) Apply(new V1.BookingOverpaid(when)); } static async Task EnsureRoomAvailable(RoomId roomId, StayPeriod period, IsRoomAvailable isRoomAvailable) { var roomAvailable = await isRoomAvailable(roomId, period); if (!roomAvailable) throw new DomainException("Room not available"); } }
新增 Services.cs。Booking 聚合會使用一些外部的服務,Booking 聚合只需要知道如何呼叫這些外部服務,不需要知道服務具體如何實作。
Add Services.cs: The Booking aggregate will use some external services. The Booking aggregate only needs to know how to call these external services and does not need to know the specific implementation of the services.
namespace Hotel.Bookings.Domain; public static class Services { public delegate ValueTask<bool> IsRoomAvailable(RoomId roomId, StayPeriod period); public delegate Money ConvertCurrency(Money from, string targetCurrency); }
新增 DomainModule.cs。將 Booking 聚合底下的事件進行註冊。
Add DomainModule.cs: Register the events under the Booking aggregate.
命令查詢職責分離與事件溯源
Command Query Responsibility Segregation and Event Sourcing
我們會將寫入資料與讀取資料分開處理。使用者對系統的每一筆變更指令都會被儲存起來。過去所有發生過的事件加總起來,才能推導而出目前的系統狀況。
We will separate the processing of data writing and reading. Every change command from the user to the system will be stored. The current state of the system can only be derived by summing up all the events that have occurred in the past.
在 Solution 底下新增 Project
Create a new project
選擇 Class Library
Choose Class Library
Project name 取名為 Hotel.Bookings。
Project name: Hotel.Bookings
選擇 .NET 8
Choose .NET 8
刪除不會用到的 Class1.cs
Delete unused Class1.cs
在 Hotel.Bookings 專案底下安裝 NuGet 套件
Install NuGet packages in the Hotel.Bookings project
安裝 Eventuous.Application 0.15.0-rc.2、Eventuous.Extensions.DependencyInjection 0.15.0-rc.2、Eventuous.Projections.MongoDB 0.15.0-rc.2。
Install NuGet packages: Eventuous.Application 0.15.0-rc.2, Eventuous.Extensions.DependencyInjection 0.15.0-rc.2, Eventuous.Projections.MongoDB 0.15.0-rc.22
Hotel.Bookings 專案加入對 Hotel.Bookings.Domain 專案的參考。
Hotel.Bookings project adds a reference to the Hotel.Bookings.Domain project.
新增 BookingsCommandService.cs
Create BookingsCommandService.cs
using Eventuous; using Hotel.Bookings.Domain; using Hotel.Bookings.Domain.Bookings; using NodaTime; using static Hotel.Bookings.Messages.Commands; namespace Hotel.Bookings; public class BookingsCommandService : CommandService<Booking, BookingState, BookingId> { public BookingsCommandService(IEventStore store, Services.IsRoomAvailable isRoomAvailable) : base(store) { On<V1.BookRoom>() .InState(ExpectedState.New) .GetId(cmd => new BookingId(cmd.BookingId)) .ActAsync( (booking, cmd, _) => booking.BookRoom( cmd.GuestId, new(cmd.RoomId), new StayPeriod(LocalDate.FromDateTime(cmd.CheckInDate), LocalDate.FromDateTime(cmd.CheckOutDate)), new Money(cmd.BookingPrice, cmd.Currency), new Money(cmd.PrepaidAmount, cmd.Currency), DateTimeOffset.Now, isRoomAvailable ) ); On<V1.RecordPayment>() .InState(ExpectedState.Existing) .GetId(cmd => new BookingId(cmd.BookingId)) .Act((booking, cmd) => booking.RecordPayment( new Money(cmd.PaidAmount, cmd.Currency), cmd.PaymentId, cmd.PaidBy, DateTimeOffset.Now ) ); } }
在 Hotel.Bookings 專案底下新增 Queries 資料夾。存放命令查詢職責分離中查詢的程式碼。過去發生的事件有很多,但我們的查詢只關心事件中的一小部份,我們使用投影把我們感興趣的部份收集起來。
Create a Queries folder in the Hotel.Bookings project. This folder will store the code for queries in the Command Query Responsibility Segregation (CQRS) pattern. Although many events have occurred in the past, our queries only focus on a small portion of these events. We use projections to collect the parts we are interested in.
Queries 資料夾底下新增 BookingDocument.cs。紀錄有哪些感興趣的資料。
Add BookingDocument.cs under the Queries folder. Record the data of interest.
using Eventuous.Projections.MongoDB.Tools; using NodaTime; namespace Hotel.Bookings.Queries; public record BookingDocument : ProjectedDocument { public BookingDocument(string id) : base(id) { } public string GuestId { get; init; } = null!; public string RoomId { get; init; } = null!; public LocalDate CheckInDate { get; init; } public LocalDate CheckOutDate { get; init; } public float BookingPrice { get; init; } public float PaidAmount { get; init; } public float Outstanding { get; init; } public bool Paid { get; init; } }
Queries 資料夾底下新增 BookingProjection.cs。BookingProjection 說明如何從那麼多事件中投射出我們感興趣的 BookingDocument 資料。我們使用 MongoProjector 來把資料投射到 MongoDB 中。
Add BookingProjection.cs under the Queries folder. BookingProjection explains how to project the BookingDocument data we are interested in from the many events. We use MongoProjector to project the data into MongoDB.
using Eventuous.Projections.MongoDB; using Eventuous.Subscriptions.Context; using MongoDB.Driver; using static Hotel.Bookings.Messages.Events; namespace Hotel.Bookings.Queries; public class BookingProjection : MongoProjector<BookingDocument> { public BookingProjection(IMongoDatabase database) : base(database) { On<V1.RoomBooked>(stream => stream.GetId(), HandleRoomBooked); On<V1.PaymentRecorded>( b => b .UpdateOne .DefaultId() .Update((evt, update) => update.Set(x => x.Outstanding, evt.Outstanding)) ); On<V1.BookingFullyPaid>( b => b .UpdateOne .DefaultId() .Update((_, update) => update.Set(x => x.Paid, true)) ); } static UpdateDefinition<BookingDocument> HandleRoomBooked(IMessageConsumeContext<V1.RoomBooked> ctx, UpdateDefinitionBuilder<BookingDocument> update) { var evt = ctx.Message; return update.SetOnInsert(x => x.Id, ctx.Stream.GetId()) .Set(x => x.GuestId, evt.GuestId) .Set(x => x.RoomId, evt.RoomId) .Set(x => x.CheckInDate, evt.CheckInDate) .Set(x => x.CheckOutDate, evt.CheckOutDate) .Set(x => x.BookingPrice, evt.BookingPrice) .Set(x => x.Outstanding, evt.OutstandingAmount); } }
Queries 資料夾底下新增 MyBookings.cs。紀錄有哪些感興趣的資料。這次是某個顧客的訂單。
Add MyBookings.cs under the Queries folder. Record the data of interest. This time, it is the orders of a specific customer.
using Eventuous.Projections.MongoDB.Tools; using NodaTime; namespace Hotel.Bookings.Queries; public record MyBookings(string Id) : ProjectedDocument(Id) { public List<Booking> Bookings { get; init; } = []; public record Booking(string BookingId, LocalDate CheckInDate, LocalDate CheckOutDate, float Price); }
Queries 資料夾底下新增 MyBookingsProjection.cs。MyBookingsProjection 說明如何從那麼多事件中投射出我們感興趣的 MyBookings 資料。
Add MyBookingsProjection.cs under the Queries folder. MyBookingsProjection explains how to project the MyBookings data we are interested in from numerous events.
using Eventuous.Projections.MongoDB; using MongoDB.Driver; using static Hotel.Bookings.Messages.Events; namespace Hotel.Bookings.Queries; public class MyBookingsProjection : MongoProjector<MyBookings> { public MyBookingsProjection(IMongoDatabase database) : base(database) { On<V1.RoomBooked>(b => b .UpdateOne .Id(ctx => ctx.Message.GuestId) .UpdateFromContext((ctx, update) => update.AddToSet( x => x.Bookings, new MyBookings.Booking(ctx.Stream.GetId(), ctx.Message.CheckInDate, ctx.Message.CheckOutDate, ctx.Message.BookingPrice ) ) ) ); On<V1.BookingCancelled>( b => b.UpdateOne .Filter((ctx, doc) => doc.Bookings.Select(booking => booking.BookingId).Contains(ctx.Stream.GetId()) ) .UpdateFromContext((ctx, update) => update.PullFilter( x => x.Bookings, x => x.BookingId == ctx.Stream.GetId() ) ) ); } }
新增 BookingsModule.cs,將 CommandService 做相依注入,API就能很方便的取用到 CommandService。
Add BookingsModule.cs: Inject the CommandService dependency, making it convenient for the API to access the CommandService.
using Hotel.Bookings.Domain.Bookings; using Microsoft.Extensions.DependencyInjection; namespace Hotel.Bookings; public static class BookingsModule { public static IServiceCollection AddBookingsModule(this IServiceCollection services) { services.AddCommandService<BookingsCommandService, BookingState>(); return services; } }
API
完成了寫入資料的 CommandService,與讀取資料的 Projection,是時候提供接口讓其他系統使用了,這邊我們使用 Http API 來跟其他系統對接。
With the CommandService for writing data and the Projection for reading data completed, it’s time to provide an interface for other systems to use. Here, we use Http API to interface with other systems.
在 Hotel 專案底下安裝 NuGet 套件
Install NuGet packages in the Hotel project
安裝
Eventuous.Extensions.AspNetCore 0.15.0-rc.2、
Eventuous.SqlServer 0.15.0-rc.2、
MongoDb.Bson.NodaTime 3.0.0、MongoDB.Driver.Core.Extensions.DiagnosticSources 1.4.0。
我們使用SqlServer來儲存所有指令事件,使用 MongoDB 來儲存讀取的資料。
Install
Eventuous.Extensions.AspNetCore 0.15.0-rc.2、
Eventuous.SqlServer 0.15.0-rc.2、
MongoDb.Bson.NodaTime 3.0.0、 MongoDB.Driver.Core.Extensions.DiagnosticSources 1.4.0。
We use SQL Server to store all command events and MongoDB to store the read data.
Hotel 專案加入對 Hotel.Bookings 專案的參考。
Hotel project adds a reference to the Hotel.Bookings project.
在 Hotel 專案底下新增 HttpApi 資料夾,HttpApi 資料夾底下新增 Bookings 資料夾。這次只有實作飯店系統中的訂房子系統,未來其他子系統可以再開新資料夾來存放。在 Bookings 資料夾底下新增 CommandApi.cs,這是命令查詢職責分離中的命令API。
Create a new HttpApi folder under the Hotel project, and within the HttpApi folder, create a Bookings folder. This time, only the booking subsystem of the hotel system will be implemented. Future subsystems can be stored in new folders. In the Bookings folder, create CommandApi.cs, which is the command API in Command Query Responsibility Segregation (CQRS).
using Eventuous.Extensions.AspNetCore; using Eventuous; using Hotel.Bookings.Domain.Bookings; using Microsoft.AspNetCore.Mvc; using static Hotel.Bookings.Messages.Commands; namespace Hotel.HttpApi.Bookings; [Route("/booking")] public class CommandApi(ICommandService<BookingState> service) : CommandHttpApiBase<BookingState>(service) { [HttpPost] [Route("book")] public Task<ActionResult<Result<BookingState>.Ok>> BookRoom([FromBody] V1.BookRoom cmd, CancellationToken cancellationToken) => Handle(cmd, cancellationToken); [HttpPost] [Route("recordPayment")] public Task<ActionResult<Result<BookingState>.Ok>> RecordPayment([FromBody] V1.RecordPayment cmd, CancellationToken cancellationToken) => Handle(cmd, cancellationToken); }
Bookings 資料夾底下新增 QueryApi.cs,這是命令查詢職責分離中的查詢 API。
Add QueryApi.cs under the Bookings folder. This is the Query API in Command Query Responsibility Segregation.
using Eventuous; using Hotel.Bookings.Domain.Bookings; using Hotel.Bookings.Queries; using Microsoft.AspNetCore.Mvc; using MongoDB.Driver; namespace Hotel.HttpApi.Bookings; [Route("/bookings")] public class QueryApi(IEventStore store, IMongoDatabase mongo) : ControllerBase { private readonly StreamNameMap _streamNameMap = new(); [HttpGet] [Route("{id}")] public async Task<BookingState> GetBooking(string id, CancellationToken cancellationToken) { var booking = await store.LoadState<BookingState, BookingId>(_streamNameMap, new(id), cancellationToken: cancellationToken); return booking.State; } [Route("BookingProjection"), HttpGet] public async Task<ActionResult<List<BookingProjection>>> Get() { var collection = mongo.GetCollection<BookingProjection>(typeof(BookingProjection).Name); return await collection.AsQueryable().ToListAsync(); } }
在 appsettings.json 設定 SqlServer 與 Mongo 的連線字串
Setting up the connection strings for SqlServer and Mongo in appsettings.json
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "SqlServer": { "ConnectionString": "Data Source=.;Initial Catalog=Hotel;Integrated Security=True;TrustServerCertificate=True" }, "Mongo": { "ConnectionString": "mongodb://localhost:27017", "User": "mongoadmin", "Password": "secret", "Database": "Hotel" } }
新增 Mongo.cs,設定 MongoDB 連線
Add Mongo.cs and Configure MongoDB Connection
using MongoDb.Bson.NodaTime; using MongoDB.Driver; using MongoDB.Driver.Core.Extensions.DiagnosticSources; namespace Hotel; public static class Mongo { public static IMongoDatabase ConfigureMongo(IConfiguration configuration) { NodaTimeSerializers.Register(); var config = configuration.GetSection("Mongo").Get<MongoSettings>(); var settings = MongoClientSettings.FromConnectionString(config!.ConnectionString); if (config.User != null && config.Password != null) { settings.Credential = new MongoCredential( null, new MongoInternalIdentity("admin", config.User), new PasswordEvidence(config.Password) ); } settings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber()); return new MongoClient(settings).GetDatabase(config.Database); } public record MongoSettings { public string ConnectionString { get; init; } = null!; public string Database { get; init; } = null!; public string? User { get; init; } public string? Password { get; init; } } }
修改 Program.cs,設定 Eventuous 套件與一些服務。
Modify Program.cs to configure Eventuous packages and some services.
using Eventuous.Projections.MongoDB; using Eventuous.SqlServer; using Eventuous.SqlServer.Subscriptions; using Eventuous.Subscriptions.Registrations; using Hotel; using Hotel.Bookings; using Hotel.Bookings.Domain; using Hotel.Bookings.Domain.Bookings; using Hotel.Bookings.Queries; using static Hotel.Bookings.BookingsModule; var builder = WebApplication.CreateBuilder(args); string connectionString = builder.Configuration.GetValue<string>("SqlServer:ConnectionString")!; builder.Services.AddEventuousSqlServer(connectionString, initializeDatabase: true); builder.Services.AddEventStore<SqlServerStore>(); builder.Services.AddCommandService<BookingsCommandService, BookingState>(); builder.Services.AddSingleton<Services.IsRoomAvailable>((_, _) => new(true)); builder.Services.AddSingleton<Services.ConvertCurrency>((from, currency) => new Money(from.Amount * 2, currency)); builder.Services.AddSingleton(Mongo.ConfigureMongo(builder.Configuration)); builder.Services.AddCheckpointStore<MongoCheckpointStore>(); builder.Services.AddSubscription<SqlServerAllStreamSubscription, SqlServerAllStreamSubscriptionOptions>( "BookingsProjections", builder => builder .Configure(options => { options.ThrowOnError = true; options.ConnectionString = connectionString; }) .AddEventHandler<BookingProjection>() .AddEventHandler<MyBookingsProjection>() .WithPartitioningByStream(2) ); builder.Services.AddBookingsModule(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();
建立資料庫
Creating the Database
使用 SQL Server Management Studio 在 SQL Server 中建立 Hotel 資料庫
Use SQL Server Management Studio: Create the Hotel database in SQL Server.
使用 Docker Desktop 安裝 MongoDB Container 並執行
MONGO_INITDB_ROOT_USERNAME: mongoadmin
MONGO_INITDB_ROOT_PASSWORD: secret
Install MongoDB Container with Docker Desktop: Run the container with the following environment variables:
MONGO_INITDB_ROOT_USERNAME: mongoadmin MONGO_INITDB_ROOT_PASSWORD: secret
使用 MongoDB Compass 連線至 MongoDB
Username: mongoadmin
Password: secret
Connect to MongoDB with MongoDB Compass:
Username: mongoadmin
Password: secret
測試成果
Testing Results
開始運行 VIsual Studio 專案
Run the Visual Studio Project
一運行專案,可以看到 Eventuous 在 Sql Server 的 Hotel 資料庫底下建立了資料表,資料表的用途說明如連結。
Once the project is running, you can see that Eventuous has created tables in the Hotel database in SQL Server. The purpose of these tables is explained in the link provided.
新增一筆訂單看看
Add a New Order
{ "bookingId": "ef08290c-b3c5-4bc1-b6a1-95b10590a998", "guestId": "71d23f24-f1f0-48fd-969d-c9ce1c3c47fb", "roomId": "9a40d725-150d-42f1-9d6d-57d4ff4482bb", "checkInDate": "2024-07-25T13:34:49.093Z", "checkOutDate": "2024-07-25T13:34:49.093Z", "bookingPrice": 20, "prepaidAmount": 10, "currency": "EUR", "bookingDate": "2024-07-25T13:34:49.093Z" }
可以看到 Sql Server 中有新增對應的訂單事件
You can see the corresponding order event added in SQL Server.
另外 MongoDB 中也有對應的查詢資料。
Additionally, MongoDB also has the corresponding query data.
完整程式碼連結: Hotel
Complete Code Link: Hotel
有任何問題或是程式系統建置方面的需求,都歡迎寄信聯絡: hsuantangchiu@gmail.com
If you have any questions or need assistance with system setup, feel free to contact: hsuantangchiu@gmail.com
Subscribe to my newsletter
Read articles from 邱炫棠 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by