hopefully not a horribly foolish refactoring
This commit is contained in:
parent
59d660165f
commit
1ecaf64dea
82 changed files with 782 additions and 398 deletions
|
@ -1,3 +1,7 @@
|
|||
namespace Femto.Common.Domain;
|
||||
|
||||
public class DomainException(string message) : Exception(message);
|
||||
public class DomainException : Exception
|
||||
{
|
||||
public DomainException(string message, Exception innerException) : base(message, innerException) {}
|
||||
public DomainException(string message) : base(message) {}
|
||||
}
|
7
Femto.Common/Domain/ICommand.cs
Normal file
7
Femto.Common/Domain/ICommand.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
using MediatR;
|
||||
|
||||
namespace Femto.Common.Domain;
|
||||
|
||||
public interface ICommand : IRequest;
|
||||
|
||||
public interface ICommand<out T> : IRequest<T>;
|
5
Femto.Common/Domain/IQuery.cs
Normal file
5
Femto.Common/Domain/IQuery.cs
Normal file
|
@ -0,0 +1,5 @@
|
|||
using MediatR;
|
||||
|
||||
namespace Femto.Common.Domain;
|
||||
|
||||
public interface IQuery<out T> : IRequest<T>;
|
|
@ -8,9 +8,10 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Infrastructure\" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
using System.Data;
|
||||
using Npgsql;
|
||||
|
||||
namespace Femto.Common.Infrastructure.DbConnection;
|
||||
|
||||
public class DbConnectionFactory(string connectionString) : IDbConnectionFactory
|
||||
{
|
||||
public IDbConnection GetConnection()
|
||||
{
|
||||
return new NpgsqlConnection(connectionString);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
using System.Data;
|
||||
|
||||
namespace Femto.Common.Infrastructure.DbConnection;
|
||||
|
||||
public interface IDbConnectionFactory
|
||||
{
|
||||
IDbConnection GetConnection();
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
namespace Femto.Common.Infrastructure.Outbox;
|
||||
|
||||
/// <summary>
|
||||
/// A mapping based on the CLR type name
|
||||
/// Brittle in the case of types being moved as they will then not be able to be handled by the outbox. but simple in implementation
|
||||
/// </summary>
|
||||
public class ClrTypenameMessageMapping : IOutboxMessageMapping
|
||||
{
|
||||
public Type? GetTypeOfEvent(string eventName)
|
||||
{
|
||||
return Type.GetType(eventName);
|
||||
}
|
||||
|
||||
public string? GetEventName(Type eventType)
|
||||
{
|
||||
return eventType.AssemblyQualifiedName;
|
||||
}
|
||||
}
|
9
Femto.Common/Infrastructure/Outbox/IOutboxContext.cs
Normal file
9
Femto.Common/Infrastructure/Outbox/IOutboxContext.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Femto.Common.Infrastructure.Outbox;
|
||||
|
||||
public interface IOutboxContext
|
||||
{
|
||||
DbSet<OutboxEntry> Outbox { get; }
|
||||
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
using MediatR;
|
||||
|
||||
namespace Femto.Common.Infrastructure.Outbox;
|
||||
|
||||
public interface IOutboxMessageHandler
|
||||
{
|
||||
Task Publish<TNotification>(TNotification notification, CancellationToken executionContextCancellationToken);
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
namespace Femto.Common.Infrastructure.Outbox;
|
||||
|
||||
public interface IOutboxMessageMapping
|
||||
{
|
||||
Type? GetTypeOfEvent(string eventName);
|
||||
string? GetEventName(Type eventType);
|
||||
}
|
29
Femto.Common/Infrastructure/Outbox/Outbox.cs
Normal file
29
Femto.Common/Infrastructure/Outbox/Outbox.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Femto.Common.Attributes;
|
||||
using Femto.Common.Integration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Femto.Common.Infrastructure.Outbox;
|
||||
|
||||
public class Outbox<TContext>(TContext context, IOutboxMessageMapping mapping) where TContext : IOutboxContext
|
||||
{
|
||||
public async Task AddMessage<TMessage>(
|
||||
Guid aggregateId,
|
||||
TMessage message,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
where TMessage : IIntegrationEvent
|
||||
{
|
||||
var eventName = mapping.GetEventName(typeof(TMessage));
|
||||
if (eventName is null)
|
||||
throw new InvalidOperationException(
|
||||
$"{typeof(TMessage).Name} does not have EventType attribute"
|
||||
);
|
||||
|
||||
await context.Outbox.AddAsync(
|
||||
new(message.EventId, aggregateId, eventName, JsonSerializer.Serialize(message)),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
59
Femto.Common/Infrastructure/Outbox/OutboxEntry.cs
Normal file
59
Femto.Common/Infrastructure/Outbox/OutboxEntry.cs
Normal file
|
@ -0,0 +1,59 @@
|
|||
namespace Femto.Common.Infrastructure.Outbox;
|
||||
|
||||
public class OutboxEntry
|
||||
{
|
||||
private const int MaxRetries = 5;
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
|
||||
public string EventType { get; private set; } = null!;
|
||||
public Guid AggregateId { get; private set; }
|
||||
|
||||
public string Payload { get; private set; } = null!;
|
||||
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
|
||||
public DateTime? ProcessedAt { get; private set; }
|
||||
public DateTime? NextRetryAt { get; private set; }
|
||||
public int RetryCount { get; private set; } = 0;
|
||||
public string? LastError { get; private set; }
|
||||
public OutboxEntryStatus Status { get; private set; }
|
||||
|
||||
private OutboxEntry() { }
|
||||
|
||||
public OutboxEntry(Guid eventId, Guid aggregateId, string eventType, string payload)
|
||||
{
|
||||
this.Id = eventId;
|
||||
this.EventType = eventType;
|
||||
this.AggregateId = aggregateId;
|
||||
this.Payload = payload;
|
||||
this.CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void Succeed()
|
||||
{
|
||||
this.ProcessedAt = DateTime.UtcNow;
|
||||
this.Status = OutboxEntryStatus.Completed;
|
||||
}
|
||||
|
||||
public void Fail(string error)
|
||||
{
|
||||
if (this.RetryCount >= MaxRetries)
|
||||
{
|
||||
this.Status = OutboxEntryStatus.Failed;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.LastError = error;
|
||||
this.NextRetryAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, this.RetryCount));
|
||||
this.RetryCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum OutboxEntryStatus
|
||||
{
|
||||
Pending,
|
||||
Completed,
|
||||
Failed
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
namespace Femto.Common.Infrastructure.Outbox;
|
||||
|
||||
public static class OutboxEventNameRegistry
|
||||
{
|
||||
|
||||
}
|
84
Femto.Common/Infrastructure/Outbox/OutboxProcessor.cs
Normal file
84
Femto.Common/Infrastructure/Outbox/OutboxProcessor.cs
Normal file
|
@ -0,0 +1,84 @@
|
|||
using System.Text.Json;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Quartz;
|
||||
|
||||
namespace Femto.Common.Infrastructure.Outbox;
|
||||
|
||||
[DisallowConcurrentExecution]
|
||||
public class OutboxProcessor<TContext>(
|
||||
TContext context,
|
||||
ILogger<OutboxProcessor<TContext>> logger,
|
||||
IOutboxMessageMapping mapping,
|
||||
IOutboxMessageHandler handler
|
||||
) : IJob
|
||||
where TContext : DbContext, IOutboxContext
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext executionContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var messages = await context
|
||||
.Outbox.Where(message => message.Status == OutboxEntryStatus.Pending)
|
||||
.Where(message => message.NextRetryAt == null || message.NextRetryAt <= now)
|
||||
.OrderBy(message => message.CreatedAt)
|
||||
.ToListAsync(executionContext.CancellationToken);
|
||||
|
||||
logger.LogTrace("loaded {Count} outbox messages to process", messages.Count);
|
||||
foreach (var message in messages)
|
||||
{
|
||||
try
|
||||
{
|
||||
var notificationType = mapping.GetTypeOfEvent(message.EventType);
|
||||
|
||||
if (notificationType is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"unmapped event type {Type}. skipping.",
|
||||
message.EventType
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
var notification =
|
||||
JsonSerializer.Deserialize(message.Payload, notificationType)
|
||||
as INotification;
|
||||
|
||||
if (notification is null)
|
||||
throw new Exception("notification is null");
|
||||
|
||||
logger.LogTrace(
|
||||
"publishing outbox message {EventType}. Id: {Id}, AggregateId: {AggregateId}",
|
||||
message.EventType,
|
||||
message.Id,
|
||||
message.AggregateId
|
||||
);
|
||||
|
||||
await handler.Publish(notification, executionContext.CancellationToken);
|
||||
|
||||
message.Succeed();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(
|
||||
e,
|
||||
"Error processing event {EventId} for aggregate {AggregateId}",
|
||||
message.Id,
|
||||
message.AggregateId
|
||||
);
|
||||
|
||||
message.Fail(e.ToString());
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(executionContext.CancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "Error while processing outbox");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
41
Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs
Normal file
41
Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs
Normal file
|
@ -0,0 +1,41 @@
|
|||
using System.Reflection;
|
||||
using Femto.Common.Attributes;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Quartz;
|
||||
|
||||
namespace Femto.Common.Infrastructure.Outbox;
|
||||
|
||||
public static class OutboxServiceExtension
|
||||
{
|
||||
public static void AddOutbox<TContext>(
|
||||
this IServiceCollection services,
|
||||
Func<IServiceProvider, TContext>? contextFactory = null
|
||||
)
|
||||
where TContext : DbContext, IOutboxContext
|
||||
{
|
||||
|
||||
services.AddSingleton<IOutboxMessageMapping, ClrTypenameMessageMapping>();
|
||||
|
||||
services.AddScoped<IOutboxContext>(c =>
|
||||
contextFactory?.Invoke(c) ?? c.GetRequiredService<TContext>()
|
||||
);
|
||||
|
||||
services.AddScoped<Outbox<TContext>>();
|
||||
|
||||
services.AddQuartz(q =>
|
||||
{
|
||||
var jobKey = JobKey.Create(nameof(OutboxProcessor<TContext>));
|
||||
|
||||
q.AddJob<OutboxProcessor<TContext>>(jobKey)
|
||||
.AddTrigger(trigger =>
|
||||
trigger
|
||||
.ForJob(jobKey)
|
||||
.WithSimpleSchedule(schedule =>
|
||||
schedule.WithIntervalInSeconds(1).RepeatForever()
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
57
Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs
Normal file
57
Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs
Normal file
|
@ -0,0 +1,57 @@
|
|||
using Femto.Common.Domain;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Femto.Common.Infrastructure;
|
||||
|
||||
public class SaveChangesPipelineBehaviour<TRequest, TResponse>(
|
||||
DbContext context,
|
||||
IPublisher publisher,
|
||||
ILogger<SaveChangesPipelineBehaviour<TRequest, TResponse>> logger
|
||||
) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
{
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var response = await next(cancellationToken);
|
||||
|
||||
if (context.ChangeTracker.HasChanges())
|
||||
{
|
||||
await this.EmitDomainEvents(cancellationToken);
|
||||
|
||||
logger.LogDebug("saving changes");
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task EmitDomainEvents(CancellationToken cancellationToken)
|
||||
{
|
||||
var domainEvents = context
|
||||
.ChangeTracker.Entries<Entity>()
|
||||
.SelectMany(e =>
|
||||
{
|
||||
var events = e.Entity.DomainEvents;
|
||||
e.Entity.ClearDomainEvents();
|
||||
return events;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
logger.LogTrace("loaded {Count} domain events", domainEvents.Count);
|
||||
|
||||
foreach (var domainEvent in domainEvents)
|
||||
{
|
||||
logger.LogTrace(
|
||||
"publishing {Type} domain event {Id}",
|
||||
domainEvent.GetType().Name,
|
||||
domainEvent.EventId
|
||||
);
|
||||
await publisher.Publish(domainEvent, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
37
Femto.Common/Util/EventTypeMapping.cs
Normal file
37
Femto.Common/Util/EventTypeMapping.cs
Normal file
|
@ -0,0 +1,37 @@
|
|||
using System.Reflection;
|
||||
using Femto.Common.Attributes;
|
||||
using MediatR;
|
||||
|
||||
namespace Femto.Common.Util;
|
||||
|
||||
public static class EventTypeMapping
|
||||
{
|
||||
public static IDictionary<string, Type> GetEventTypeMapping(Assembly assembly)
|
||||
{
|
||||
var mapping = new Dictionary<string, Type>();
|
||||
|
||||
var types = assembly.GetTypes();
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (
|
||||
!typeof(INotification).IsAssignableFrom(type)
|
||||
|| type.IsAbstract
|
||||
|| type.IsInterface
|
||||
)
|
||||
continue;
|
||||
|
||||
var attribute = type.GetCustomAttribute<EventTypeAttribute>();
|
||||
if (attribute == null)
|
||||
continue;
|
||||
|
||||
var eventName = attribute.Name;
|
||||
if (!string.IsNullOrWhiteSpace(eventName))
|
||||
{
|
||||
mapping.TryAdd(eventName, type);
|
||||
}
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue