实现事务性发件箱模式

This commit is contained in:
hello 2024-10-09 20:56:30 +08:00
parent d47ff517ad
commit 16386a8ea5
21 changed files with 241 additions and 54 deletions

View File

@ -9,6 +9,6 @@
<ProjectReference Include="..\HelloShop.ServiceDefaults\HelloShop.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="8.2.1" />
</ItemGroup>
</Project>

View File

@ -15,7 +15,7 @@
<ProjectReference Include="..\HelloShop.WebApp\HelloShop.WebApp.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="8.0.2" />
<PackageReference Include="Aspire.Hosting.Redis" Version="8.0.2" />
<PackageReference Include="Aspire.Hosting.AppHost" Version="8.2.1" />
<PackageReference Include="Aspire.Hosting.Redis" Version="8.2.1" />
</ItemGroup>
</Project>

View File

@ -13,12 +13,12 @@
<Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.StackExchange.Redis.DistributedCaching" Version="8.0.2" />
<PackageReference Include="Aspire.StackExchange.Redis.DistributedCaching" Version="8.2.1" />
<PackageReference Include="Calzolari.Grpc.AspNetCore.Validation" Version="8.1.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.63.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Grpc.Swagger" Version="0.8.7" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.7" />
<PackageReference Include="Grpc.AspNetCore" Version="2.66.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Grpc.Swagger" Version="0.8.10" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HelloShop.ServiceDefaults\HelloShop.ServiceDefaults.csproj" />

View File

@ -58,10 +58,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="8.0.70" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="8.0.70" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="8.0.70" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
<PackageReference Include="Microsoft.Maui.Controls" Version="8.0.91" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="8.0.91" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="8.0.91" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.1" />
</ItemGroup>
</Project>

View File

@ -8,12 +8,12 @@
<ProjectReference Include="..\HelloShop.ServiceDefaults\HelloShop.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
</ItemGroup>
</Project>

View File

@ -3,13 +3,14 @@
using HelloShop.OrderingService.Extensions;
using HelloShop.OrderingService.Infrastructure;
using HelloShop.OrderingService.Services;
using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Data;
namespace HelloShop.OrderingService.Behaviors
{
public class TransactionBehavior<TRequest, TResponse>(OrderingServiceDbContext dbContext, ILoggerFactory loggerFactory) : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
public class TransactionBehavior<TRequest, TResponse>(OrderingServiceDbContext dbContext, ILoggerFactory loggerFactory, IDistributedEventService eventService) : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger = loggerFactory.CreateLogger<TransactionBehavior<TRequest, TResponse>>();
@ -42,6 +43,8 @@ namespace HelloShop.OrderingService.Behaviors
}
await transaction.CommitAsync();
await eventService.PublishEventsThroughEventBusAsync(transaction.TransactionId, cancellationToken);
});
return response ?? throw new ApplicationException($"Command {typeName} returned null response");

View File

@ -14,10 +14,14 @@ using Microsoft.EntityFrameworkCore;
namespace HelloShop.OrderingService.Commands.Orders
{
public class CreateOrderCommandHandler(IMediator mediator, OrderingServiceDbContext dbContext, IMapper mapper, IDistributedEventBus distributedEventBus) : IRequestHandler<CreateOrderCommand, bool>
public class CreateOrderCommandHandler(IMediator mediator, IDistributedEventService distributedEventService, OrderingServiceDbContext dbContext, IMapper mapper) : IRequestHandler<CreateOrderCommand, bool>
{
public async Task<bool> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
var orderStartedIntegrationEvent = new OrderStartedDistributedEvent(request.UserId);
await distributedEventService.AddAndSaveEventAsync(orderStartedIntegrationEvent, cancellationToken);
Address address = mapper.Map<Address>(request);
IEnumerable<OrderItem> orderItems = mapper.Map<IEnumerable<OrderItem>>(request.OrderItems);
@ -47,8 +51,6 @@ namespace HelloShop.OrderingService.Commands.Orders
await mediator.Publish(new OrderStartedLocalEvent(order), cancellationToken);
await distributedEventBus.PublishAsync(new OrderStartedDistributedEvent(request.UserId), cancellationToken);
return await Task.FromResult(true);
}
}

View File

@ -4,12 +4,13 @@
using HelloShop.OrderingService.DistributedEvents.Events;
using HelloShop.OrderingService.Entities.Orders;
using HelloShop.OrderingService.Infrastructure;
using HelloShop.OrderingService.Services;
using HelloShop.ServiceDefaults.DistributedEvents.Abstractions;
using Microsoft.EntityFrameworkCore;
namespace HelloShop.OrderingService.DistributedEvents.EventHandling
{
public class OrderPaymentSucceededDistributedEventHandler(OrderingServiceDbContext dbContext, IDistributedEventBus distributedEventBus) : IDistributedEventHandler<OrderPaymentSucceededDistributedEvent>
public class OrderPaymentSucceededDistributedEventHandler(OrderingServiceDbContext dbContext, IDistributedEventService distributedEventService) : IDistributedEventHandler<OrderPaymentSucceededDistributedEvent>
{
public async Task HandleAsync(OrderPaymentSucceededDistributedEvent @event)
{
@ -29,13 +30,15 @@ namespace HelloShop.OrderingService.DistributedEvents.EventHandling
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
var orderStockList = order.OrderItems.Select(orderItem => new OrderStockItem(orderItem.ProductId, orderItem.Units));
var integrationEvent = new OrderPaidDistributedEvent(order.Id, orderStockList);
await distributedEventBus.PublishAsync(integrationEvent);
await distributedEventService.AddAndSaveEventAsync(integrationEvent);
await distributedEventService.PublishEventsThroughEventBusAsync(transaction.TransactionId);
await transaction.CommitAsync();
});
}
}

View File

@ -0,0 +1,42 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using HelloShop.ServiceDefaults.DistributedEvents.Abstractions;
using System.Diagnostics.CodeAnalysis;
namespace HelloShop.OrderingService.Entities.EventLogs
{
public enum DistributedEventStatus { NotPublished, InProgress, Published, PublishedFailed }
public class DistributedEventLog
{
public Guid EventId { get; set; }
public required string EventTypeName { get; set; }
public required DistributedEvent DistributedEvent { get; set; }
public DistributedEventStatus Status { get; set; } = DistributedEventStatus.NotPublished;
public int TimesSent { get; set; }
public DateTimeOffset CreationTime { get; set; } = DateTimeOffset.UtcNow;
public required Guid TransactionId { get; set; }
/// <summary>
/// EF Core cannot set navigation properties using a constructor.
/// The constructor can be public, private, or have any other accessibility.
/// </summary>
private DistributedEventLog() { }
[SetsRequiredMembers]
public DistributedEventLog(DistributedEvent @event, Guid transactionId)
{
EventId = @event.Id;
EventTypeName = @event.GetType().Name;
DistributedEvent = @event;
TransactionId = transactionId;
}
}
}

View File

@ -48,6 +48,8 @@ namespace HelloShop.OrderingService.Extensions
builder.Services.AddHostedService<GracePeriodWorker>();
builder.Services.AddHostedService<PaymentWorker>();
builder.Services.AddTransient<IDistributedEventService, DistributedEventService<OrderingServiceDbContext>>();
}
public static WebApplication MapApplicationEndpoints(this WebApplication app)

View File

@ -8,12 +8,12 @@
<ProjectReference Include="..\HelloShop.ServiceDefaults\HelloShop.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,27 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using HelloShop.OrderingService.Entities.EventLogs;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using HelloShop.ServiceDefaults.DistributedEvents.Abstractions;
namespace HelloShop.OrderingService.Infrastructure.EntityConfigurations.EventLogs
{
public class DistributedEventLogEntityTypeConfiguration : IEntityTypeConfiguration<DistributedEventLog>
{
private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web);
public void Configure(EntityTypeBuilder<DistributedEventLog> builder)
{
builder.ToTable("DistributedEventLogs");
builder.HasKey(x => x.EventId);
builder.Property(x => x.EventTypeName).HasMaxLength(32);
builder.Property(x => x.Status).HasConversion<string>();
builder.Property(x => x.DistributedEvent).HasConversion(v => JsonSerializer.Serialize(v, v.GetType(), s_jsonSerializerOptions), v => JsonSerializer.Deserialize<DistributedEvent>(v, s_jsonSerializerOptions)!);
}
}
}

View File

@ -0,0 +1,89 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using HelloShop.OrderingService.Entities.EventLogs;
using HelloShop.ServiceDefaults.DistributedEvents.Abstractions;
using Microsoft.EntityFrameworkCore;
namespace HelloShop.OrderingService.Services
{
public class DistributedEventService<TContext>(TContext dbContext, IDistributedEventBus distributedEventBus, ILogger<DistributedEventService<TContext>> logger) : IDistributedEventService, IDisposable where TContext : DbContext
{
private volatile bool _disposedValue;
public async Task UpdateEventStatusAsync(Guid eventId, DistributedEventStatus status, CancellationToken cancellationToken = default)
{
var eventLogEntry = dbContext.Set<DistributedEventLog>().Single(ie => ie.EventId == eventId);
eventLogEntry.Status = status;
if (status == DistributedEventStatus.InProgress)
{
eventLogEntry.TimesSent++;
}
await dbContext.SaveChangesAsync(cancellationToken);
}
public async Task<IEnumerable<DistributedEventLog>> RetrieveEventLogsPendingToPublishAsync(Guid transactionId, CancellationToken cancellationToken = default)
{
var result = await dbContext.Set<DistributedEventLog>().Where(e => e.TransactionId == transactionId && e.Status == DistributedEventStatus.NotPublished).ToListAsync(cancellationToken: cancellationToken);
return result.Count != 0 ? result.OrderBy(o => o.CreationTime) : [];
}
public async Task AddAndSaveEventAsync(DistributedEvent @event, CancellationToken cancellationToken = default)
{
var transaction = dbContext.Database.CurrentTransaction ?? throw new InvalidOperationException("This method must be called within a transaction scope.");
var eventLog = new DistributedEventLog(@event, transaction.TransactionId);
await dbContext.AddAsync(eventLog, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
}
public async Task PublishEventsThroughEventBusAsync(Guid transactionId, CancellationToken cancellationToken = default)
{
var pendingEventLogs = await RetrieveEventLogsPendingToPublishAsync(transactionId, cancellationToken);
foreach (var eventLog in pendingEventLogs)
{
logger.LogInformation("Publishing integration event {EventId} {DistributedEvent}", eventLog.EventId, eventLog.DistributedEvent);
try
{
await UpdateEventStatusAsync(eventLog.EventId, DistributedEventStatus.InProgress, cancellationToken);
await distributedEventBus.PublishAsync(eventLog.DistributedEvent, cancellationToken);
await UpdateEventStatusAsync(eventLog.EventId, DistributedEventStatus.Published, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Error publishing distributed event {EventId}", eventLog.EventId);
await UpdateEventStatusAsync(eventLog.EventId, DistributedEventStatus.PublishedFailed, cancellationToken);
}
}
}
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
dbContext.Dispose();
}
_disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,19 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using HelloShop.OrderingService.Entities.EventLogs;
using HelloShop.ServiceDefaults.DistributedEvents.Abstractions;
namespace HelloShop.OrderingService.Services
{
public interface IDistributedEventService
{
Task<IEnumerable<DistributedEventLog>> RetrieveEventLogsPendingToPublishAsync(Guid transactionId, CancellationToken cancellationToken = default);
Task AddAndSaveEventAsync(DistributedEvent @event, CancellationToken cancellationToken = default);
Task UpdateEventStatusAsync(Guid eventId, DistributedEventStatus status, CancellationToken cancellationToken = default);
Task PublishEventsThroughEventBusAsync(Guid transactionId, CancellationToken cancellationToken = default);
}
}

View File

@ -8,11 +8,11 @@
<ProjectReference Include="..\HelloShop.ServiceDefaults\HelloShop.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
</ItemGroup>
</Project>

View File

@ -8,14 +8,14 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Dapr.AspNetCore" Version="1.14.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="8.7.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="8.10.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="8.2.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.1" />
<PackageReference Include="AutoMapper" Version="13.0.1" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
</ItemGroup>

View File

@ -15,15 +15,15 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Google.Protobuf" Version="3.27.2" />
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.63.0" />
<PackageReference Include="Grpc.Tools" Version="2.64.0">
<PackageReference Include="Google.Protobuf" Version="3.28.2" />
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.66.0" />
<PackageReference Include="Grpc.Tools" Version="2.67.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Grpc.JsonTranscoding" Version="8.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Grpc.JsonTranscoding" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -14,9 +14,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -9,13 +9,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" Version="8.0.2" />
<PackageReference Include="Aspire.Hosting.Testing" Version="8.2.1" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -14,10 +14,10 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -14,10 +14,10 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>