Contents

CQRS與MediatR(四)

CQRS與MediatR(四)

上一篇談到我們介紹了引入 MediatR 的改變,並簡化自行注入 CommandHandler Service。這篇我們先介紹多個處理 CommandHandler 的方法。

有時候不會像上一篇這麼單純,一個 Command 對應一個 CommandHandler,而是一個Command 對應多個 CommandHander。例如,訂購商品,先新增客戶資料,再填寫購物明細,或者達成交易時,寄送Email通知物流人員等。

在開始前,這邊記錄一篇 stackoverflow 上的文章,談到關於一個Handler去呼叫另一個Handler的問題,而 Steven(Simple Injector作者,同時也是依賴注入:原理、實作與設計模式,該書的共同作者),給出他的見解,覺得很受用。下面是翻譯文章

用例和命令之間存在一對一的映射是很自然的。在這種情況下,表示層應該(在單一使用者操作期間,例如單擊按鈕)只執行建立命令並執行它。 此外,它應該只執行單一命令,而不應該執行更多命令。執行該用例所需的一切都應該由該命令完成。 也就是說,發送文字訊息、寫入資料庫、進行複雜的計算、與 Web 服務通訊以及操作業務需求所需的所有其他操作都應該在該命令的上下文中完成(或可能排隊稍後進行)。 這並不意味著命令處理程序本身應該完成所有這些工作。 將許多邏輯轉移到處理程序所依賴的其他服務是很自然的。 因此,我可以想像您的處理程序取決於 ITextMessageSender 介面。(Google 翻譯)

我們修改範例,新增氣溫後,若低於0度,則發佈低溫特報,寄送Email通知相關人員。本篇介紹三種方法,沒有絕對正確的做法,依照個人偏好或團隊共識為主。

方法一:Controller 調用兩個 Command

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[HttpPost]
public async Task<IActionResult> CreateWeatherForecast(
    [FromBody] CreateWeatherForecastRequest request,
    [FromServices] IValidator<CreateWeatherForecastCommand> validator)
{
    var command = new CreateWeatherForecastCommand
    {
        Nation = request.Nation,
        City = request.City,
        Date = request.Date,
        TemperatureC = request.TemperatureC,
        Summary = request.Summary,
    };

    var validate = await validator.ValidateAsync(command);
    if (validate.IsValid is false) return BadRequest(validate.ToDictionary());

    var result = await _mediator.Send(command);

    // 寄送低溫特報
    if (result && request.TemperatureC <= 0)
    {
        var coldCurrent = new SendColdCurrentCommand
        {
            Nation = request.Nation,
            City = request.City,
            Date = request.Date,
            TemperatureC = request.TemperatureC,
        };
        await _mediator.Send(coldCurrent);
    }

    return result ? Ok() : BadRequest();
}

方法二:使用 MediatR 中 Notifications

在使用 Notifications 之前,要先留意一件事情,IMediator介面是實作ISenderIPublisher介面,而Publish方法來自IPublisher介面,屬於單向通訊機制,允許向應用程式內的多個訂閱者寄送訊息(命令),它的主要功能是幫助訊息(命令)的分發,預設是不返回任何回應或結果

  1. Command 實作 INotification
1
2
3
4
5
6
7
8
9
// 同一個 Command 執行兩個以上 CommandHandler
public class WeatherForecastCommand : INotification
{
    public string Nation { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public DateTime Date { get; set; }
    public int TemperatureC { get; set; }
    public string? Summary { get; set; }
}
  1. 第一個 CommandHandler 實作 INotificationHandler<WeatherForecastCommand>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CreateWeatherForecastCommandHandler : INotificationHandler<WeatherForecastCommand>
{
    private readonly IWeatherForecastRepository _weatherForecastRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateWeatherForecastCommandHandler(IWeatherForecastRepository weatherForecastRepository, IUnitOfWork unitOfWork)
    {
        _weatherForecastRepository = weatherForecastRepository ?? throw new ArgumentNullException(nameof(weatherForecastRepository));
        _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
    }

    public async Task Handle(WeatherForecastCommand notification, CancellationToken cancellationToken)
    {
        WeatherForecast weatherForecast = new(notification.Nation, notification.City, notification.Date, notification.TemperatureC)
        {
            Summary = notification.Summary,
        };

        _weatherForecastRepository.AddWeatherForecast(weatherForecast);

        await _unitOfWork.SaveChangesAsync(cancellationToken);
    }
}
  1. 第二個 CommandHandler 實作 INotificationHandler<WeatherForecastCommand>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class SendColdCurrentCommandHandler : INotificationHandler<WeatherForecastCommand>
{
    public Task Handle(WeatherForecastCommand notification, CancellationToken cancellationToken)
    {
        if (request.TemperatureC <= 0)
        {
            string message = $"{notification.Nation}{notification.City}於{notification.Date}," +
                             $"預計溫度為{notification.TemperatureC},請民眾注意保暖";

            // Send Notify..

            throw new NotImplementedException();
        }
    }
}
  1. Controller使用Publish調用命令
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
[HttpPost]
public async Task<IActionResult> CreateWeatherForecast(
    [FromBody] CreateWeatherForecastRequest request,
    [FromServices] IValidator<WeatherForecastCommand> validator)
{
    var command = new WeatherForecastCommand
    {
        Nation = request.Nation,
        City = request.City,
        Date = request.Date,
        TemperatureC = request.TemperatureC,
        Summary = request.Summary,
    };

    var validate = await validator.ValidateAsync(command);
    if (validate.IsValid is false) return BadRequest(validate.ToDictionary());

    await _mediator.Publish(command);

    return Ok();
}

方法三:在原有的CommandHandler,引用IPublisher服務

當程式已經上線後,有時候基於考量,我們會盡量不變動原有的程式,此時我們可以利用 MediatR IPublisher服務,來觸發後續事件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class CreateWeatherForecastCommandHandler : ICommandHandler<CreateWeatherForecastCommand, bool>
{
    private readonly IWeatherForecastRepository _weatherForecastRepository;

    // 1. 引入IPublisher
    private readonly IPublisher _publisher;

    private readonly IUnitOfWork _unitOfWork;

    public CreateWeatherForecastCommandHandler(IWeatherForecastRepository weatherForecastRepository, IPublisher publisher, IUnitOfWork unitOfWork)
    {
        _weatherForecastRepository = weatherForecastRepository ?? throw new ArgumentNullException(nameof(weatherForecastRepository));
        _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
        _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
    }

    public async Task<bool> Handle(CreateWeatherForecastCommand request, CancellationToken cancellationToken)
    {
        WeatherForecast weatherForecast = new(request.Nation, request.City, request.Date, request.TemperatureC)
        {
            Summary = request.Summary,
        };

        _weatherForecastRepository.AddWeatherForecast(weatherForecast);

        await _unitOfWork.SaveChangesAsync(cancellationToken);

        if (request.TemperatureC <= 0)
        {
            // 2.使用Publish觸發通知事件
            await _publisher.Publish(new ColdCurrent
            {
                Date = weatherForecast.Date,
                City = weatherForecast.City,
                Nation = weatherForecast.Nation,
                TemperatureC = weatherForecast.TemperatureC,
            }, cancellationToken);
        }

        return true;
    }
}

public class CreateWeatherForecastCommand : ICommand<bool>
{
    public string Nation { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public DateTime Date { get; set; }
    public int TemperatureC { get; set; }
    public string? Summary { get; set; }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class ColdCurrentEvent : INotificationHandler<ColdCurrent>
{
    public Task Handle(ColdCurrent notification, CancellationToken cancellationToken)
    {
        string message = $"{notification.Nation}{notification.City}於{notification.Date}," +
                         $"預計溫度為{notification.TemperatureC},請民眾注意保暖";

        // Send Notify..

        throw new NotImplementedException();
    }
}

public class ColdCurrent : INotification
{
    public string Nation { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public DateTime Date { get; set; }
    public int TemperatureC { get; set; }
}

小結

Notifications 在專案初期成效或許並沒有那麼明顯,因為在需求明確的情況下,通常是使用一個CommandHandler呼叫其他的服務,而Notifications則是將多個CommandHandler以類似Decorator模式運行,所以在想要新增功能,卻不修改到原本類別時,特別適用。

參考

Calling commands from within another command Handle() method