iT邦幫忙

2024 iThome 鐵人賽

DAY 21
0

前言

終於把重複性質很高的 Feature 實作做完了,我其實一直很糾結到底要不要只介紹一部分實作,之後就讓大家自由發揮,這樣或許我還可以多介紹一些像是 Error Handling 的各式常見的 Pattern,但最終我還是選擇了新手模式,手把手帶著完成一個作品,畢竟,這題目主打的就是一個實作對吧?

Anyways,這篇我們要來介紹在微服務中不可或缺的 Event 了!

Mediator Pattern 和 MediatR

Mediator Pattern

Mediator Pattern 是一種設計模式,用於減少物件之間的直接相互依賴。當多個物件之間需要互相溝通時,它們會通過一個中心「仲介者」(Mediator)來管理彼此的交互。這樣的設計可以避免物件之間的高度耦合,提升系統的可維護性和擴展性。

https://ithelp.ithome.com.tw/upload/images/20241004/20168953R5VDenlZ1S.png

MediatR

MediatR 是一個實現了 Mediator Pattern 的 .NET Library。它提供了一種簡單的方式來實現請求/回應模式(Request/Response)和通知模式(Notification)。使用 MediatR,開發者可以將應用程式中的命令和查詢分離(CQRS),保持乾淨的架構。可以用三段程式碼簡短介紹 MediatR:
以下是 MediatR 最簡短的三種案例:

1. Command

Command:執行操作,但不返回結果。

public class CreateOrder : IRequest { }

public class CreateOrderHandler : IRequestHandler<CreateOrder>
{
    public Task<Unit> Handle(CreateOrder request, CancellationToken cancellationToken)
    {
        // 執行命令邏輯
        return Task.FromResult(Unit.Value);
    }
}

// 發送 Command
await mediator.Send(new CreateOrder());

2. Request

Request:執行操作並返回結果。

public class GetOrder : IRequest<Order> 
{ 
    public int OrderId { get; set; } 
}

public class GetOrderHandler : IRequestHandler<GetOrder, Order>
{
    public Task<Order> Handle(GetOrder request, CancellationToken cancellationToken)
    {
        // 返回結果
        return Task.FromResult(new Order { Id = request.OrderId });
    }
}

// 發送 Request 並取得回應
var order = await mediator.Send(new GetOrder { OrderId = 1 });

3. Notification

Notification:廣播事件,不返回結果。

public class OrderCreated : INotification { public int OrderId { get; set; } }

public class OrderCreatedHandler : INotificationHandler<OrderCreated>
{
    public Task Handle(OrderCreated notification, CancellationToken cancellationToken)
    {
        // 處理通知邏輯
        return Task.CompletedTask;
    }
}

// 發送 Notification
await mediator.Publish(new OrderCreated { OrderId = 1 });

因為我們沒有時間做 CQRS,在接下來的實作只會用到 Notification 的功能來讓 MediatR 幫我們管理微服務內部的 Eventbus。

如果想要更細緻的控制自己的 Eventbus,甚至想要自己做一個高度自定義的 Event Service,MediatR 也有 IPipelineBehavior 的 Pipeline 行為控制可以允許你在命令或查詢處理前後,加入自己的邏輯。

Event 實作 - Todo List Removed

介紹完基本的工具,現在需要實作 Todo List Removed Event。我們可以把 MediatR 的 Notification 分幾部分來實作。

Create Event

定義(Create)-> 推送(Publish)-> 處理(Handle)

回顧一下在 Day 11 - DDD Seedwork 實作 時做的 Entity 如何實作 Event 的功能

public abstract class Entity<TId> where TId : notnull
{
    //...

    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    public void AddDomainEvent(IDomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

在上述的 Entity 實作內,我們定義了一個 IDomainEvent 介面來使 Entity 管理其本身所有的事件。而這個 IDomainEvent 本身就繼承了 INotification 來利用 MediatR

public interface IDomainEvent : INotification

接著開始實作,首先要定義一個 TodoListRemovedEvent 繼承 IDomainEvent,才可以加入這個 EntityDomainEvents 內。

而這個 Event 的主要功能就是通知這個 ID 的 Todo List 被 Removed 了。

首先我們在 Todo.Domain 內開一個 Events 資料夾,新增 TodoListRemovedEvent.cs 如下:

using Common.Library.Seedwork;

namespace Todo.Domain.Events;

public class TodoListRemovedEvent : IDomainEvent
{
    public Guid TodoListId { get; }

    public TodoListRemovedEvent(Guid todoListId)
    {
        TodoListId = todoListId;
    }
}

然後我們在這個 Todo List 的 Aggregate 觸發 Remove 成功後加入這個 Event

    public void Remove()
    {
        Status = TodoListStatus.Removed;
        UpdatedDateTime = DateTime.UtcNow;
        AddDomainEvent(new TodoListRemovedEvent(this.Id.Value));
    }

Publish Event

定義(Create)-> 推送(Publish)-> 處理(Handle)

定義好了 Event,並且設定了甚麼時候要加入 Event,接著我們要考慮甚麼時機觸發(Publish)這些 Event 才是最好的。

想想看哦,Aggregate 有著全成功全失敗的特性,那我們要推送這個 Aggregate 改變時所有發生的事件,最好就是在 Aggregate 全成功之後,對吧?

那在我們的實作中,哪裡是 Aggregate 全成功的時候?是 Unit of Work 使用 SaveEntitiesAsync 的時候。

而我們的 UnitOfWork 本身又是 DbContext,UnitOfWork.SaveEntitiesAsync 就是在做 DbContext.SaveChangesAsync ,那我們就可以在 SaveChangesAsync 成功後推送這些 Events。

所以這裡可以利用 EF Core 提供的 Pipeline 工具 SaveChangesInterceptor 來在 SaveChangesAsync 成功後做些手腳,

首先先在 Common.Library 新增一個 DomainEventsInterceptor.cs 繼承 SaveChangesInterceptor,並且 Override SavedChangesAsync 做後續處理。

using Common.Library.Seedwork;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Common.Library.Interceptors;

public class DomainEventsInterceptor : SaveChangesInterceptor
{
    private readonly IPublisher _mediator;

    public DomainEventsInterceptor(IPublisher mediator)
    {
        _mediator = mediator;
    }

    public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
    {
        PublishDomainEvents(eventData.Context).Wait();
        return base.SavedChanges(eventData, result);
    }

    public override async ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
    {
        await PublishDomainEvents(eventData.Context);
        return await base.SavedChangesAsync(eventData, result, cancellationToken);
    }

    private async Task PublishDomainEvents(DbContext? dbContext)
    {
        if (dbContext is null) return;
    }
}

接著要來實作上述程式碼中的 Private Method PublishDomainEvents,我需要從 dbContext 拿出有 Events 的 Entities 後再 Publish 這些 Events,

但我先前在實作 SeedWork 的時候把 Event 跟 Entity 綁在一起,現在拿取 Events 變得非常不直觀,

之前沒有考慮到 SRP, Single Responsibility Principle,這時的 Entity 同時面對 Db Model 和 Event Handle 兩件不同的角色職責,

這時可以把 Event Handle 從 Entity 抽離出來,我們在 Seedwork 中新增一個介面 'IHasDomainEvents',把 Entity 的 Event 職責抽離給這個介面,

namespace Common.Library.Seedwork;

public interface IHasDomainEvents
{
    public IReadOnlyList<IDomainEvent> DomainEvents { get; }
    public void AddDomainEvent(IDomainEvent domainEvent);
    public void ClearDomainEvents();
}

並且讓 Entity 繼承這個介面

namespace Common.Library.Seedwork;

public abstract class Entity<TId> : IHasDomainEvents where TId : notnull
{
    private readonly List<IDomainEvent> _domainEvents = new();

    public required TId Id { get; init; }
    public DateTime CreatedDateTime { get; set; }
    public DateTime UpdatedDateTime { get; set; }

    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    public void AddDomainEvent(IDomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

這樣做所有的 Entity 都有 Event 的功能,如果希望劃分更細,你可以創造 EventEntityEntity 來切割不同情境,但在這裡我就不管這麼多了。

回到 PublishDomainEvents,這時候我們可以輕鬆的拿出有 Event 功能的 Entities 了,並且可以 Publish 這些 Events,最後再清空這些 Events,如下:

private async Task PublishDomainEvents(DbContext? dbContext)
{
    if (dbContext is null)
        return;

    var entities = dbContext.ChangeTracker
        .Entries<IHasDomainEvents>()
        .Where(entry => entry.Entity.DomainEvents.Any())
        .Select(entry => entry.Entity)
        .ToList();

    var events = entities
        .SelectMany(entry => entry.DomainEvents)
        .ToList();

    foreach (var entity in entities)
        entity.ClearDomainEvents();

    foreach (var domainEvent in events)
        await _mediator.Publish(domainEvent);
}

Event Handling

定義(Create)-> 推送(Publish)-> 處理(Handle)

當我們利用 MediatR 時做完 Notification 的產生與 Publish,接著就要來接收這些 Event 並處理,目標是在 Todo List Removed 成功後要同時 Remove 相關的 Todo Items,我們可以在 Todo.Application 中建立一個 TodoListRemovedEventHandler.cs 來處理 Domain Events 的具體操作,並使它繼承 INotificationHandler<TodoListRemovedEvent> 來實作 Handle 的功能。

using MediatR;
using Todo.Domain.Events;

namespace Todo.Application;

public class TodoListRemovedEventHandler : INotificationHandler<TodoListRemovedEvent>
{
    private readonly ITodoItemRepository _todoItemRepository;

    public TodoListRemovedEventHandler(ITodoItemRepository todoItemRepository)
    {
        this._todoItemRepository = todoItemRepository;
    }

    public Task Handle(TodoListRemovedEvent notification, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
}

為了可以塞選出相關的 Todo Items,首先要先在 Repository 把功能開出來。

ITodoItemRepository

ICollection<TodoItem>? FindByListId(Guid guid);

TodoItemRepository

public ICollection<TodoItem>? FindByListId(Guid guid)
{
    return _todoContext.TodoItems.Where(x => x.ListId == guid).ToList();
}

再回來實作 Handle

    public async Task Handle(TodoListRemovedEvent notification, CancellationToken cancellationToken)
    {
        var items = _todoItemRepository.FindByListId(notification.TodoListId);

        if (items == null) return;

        foreach (var item in items)
        {
            item.Remove();
        }

        await _todoItemRepository.UnitOfWork.SaveEntitiesAsync();
    }   

DI

到此,我們把一套完整的 Event 流程做好了,只剩下 DI 這些功能,首先是 MediatR 要到 Todo.Application 先安裝 MediatR.Extensions.Microsoft.DependencyInjection,版本記得要跟 Todo.Common 一致避免衝突。

之後在 TodoApplicationRegister 加上

services.AddMediatR(Assembly.GetExecutingAssembly());

另外還有我們在 Todo.Infrastructure 做的 Interceptor 要放到 DI Container 內並在 Configuring 的時候注入。

先在 TodoInfrastructureRegister 加上

services.AddScoped<DomainEventsInterceptor>();

最後在 TodoContextOnConfiguring 加入 Interceptor

public class TodoContext : DbContext, IUnitOfWork
{

    //...

    private readonly DomainEventsInterceptor _domainEventsInterceptor;

    public TodoContext(
        DbContextOptions<TodoContext> options,
        IConfiguration configuration,
        DomainEventsInterceptor domainEventsInterceptor
        ) : base(options)
    {
        _configuration = configuration;
        _domainEventsInterceptor = domainEventsInterceptor;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
        {
            optionsBuilder.UseSqlServer(_configuration.GetConnectionString(DEFAULT_CONNECTION_SECTION));
            optionsBuilder.AddInterceptors(_domainEventsInterceptor);
        }
    }

    //...
}

測試

產生測試資料

https://ithelp.ithome.com.tw/upload/images/20241004/20168953s1nWz85KVO.png

刪除 Todo List

刪除 Todo List 後確保相關的 Todo Items 都有被 Removed。

https://ithelp.ithome.com.tw/upload/images/20241004/20168953YDCMqjCGHp.png

結語

這篇章主要介紹 Service 內部 的 Event 如何實作,MediatR 提供了很便捷的工具讓我們可以不用實作完整的 Eventbus 也可以輕鬆完成事件廣播;下一篇章就要介紹 Service 外部 的 Event 如何透過 RabbitMQ 來完成。


上一篇
Day 20 - Todo Infrastructure 實作:複習 Entity Framework
下一篇
Day 22 - Integration Event:RabbitMQ 和 Producer 開發實作
系列文
DDD? Clean Architecture? Microservices? 帶你用.NET實作打造一個現代化微服務!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言