mirror of https://github.com/Squidex/squidex.git
16 changed files with 389 additions and 22 deletions
@ -0,0 +1,120 @@ |
|||||
|
// ==========================================================================
|
||||
|
// EventStore.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using EventStore.ClientAPI; |
||||
|
using Squidex.Infrastructure.CQRS.Events; |
||||
|
using EventData = Squidex.Infrastructure.CQRS.Events.EventData; |
||||
|
|
||||
|
// ReSharper disable ConvertIfStatementToSwitchStatement
|
||||
|
// ReSharper disable InvertIf
|
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventStore |
||||
|
{ |
||||
|
public sealed class EventStore : IEventStore, IExternalSystem |
||||
|
{ |
||||
|
private const int WritePageSize = 500; |
||||
|
private const int ReadPageSize = 500; |
||||
|
private readonly IEventStoreConnection connection; |
||||
|
private readonly string projectionHost; |
||||
|
private readonly string prefix; |
||||
|
|
||||
|
public EventStore(IEventStoreConnection connection, string prefix, string projectionHost) |
||||
|
{ |
||||
|
Guard.NotNull(connection, nameof(connection)); |
||||
|
|
||||
|
this.connection = connection; |
||||
|
this.projectionHost = projectionHost; |
||||
|
|
||||
|
this.prefix = prefix?.Trim(' ', '-').WithFallback("squidex"); |
||||
|
} |
||||
|
|
||||
|
public void Connect() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
connection.ConnectAsync().Wait(); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
throw new ConfigurationException("Cannot connect to event store.", ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public IEventSubscription CreateSubscription(string streamFilter = null, string position = null) |
||||
|
{ |
||||
|
return new EventStoreSubscription(connection, streamFilter, position, prefix, projectionHost); |
||||
|
} |
||||
|
|
||||
|
public async Task<IReadOnlyList<StoredEvent>> GetEventsAsync(string streamName) |
||||
|
{ |
||||
|
var result = new List<StoredEvent>(); |
||||
|
|
||||
|
var sliceStart = 0L; |
||||
|
|
||||
|
StreamEventsSlice currentSlice; |
||||
|
do |
||||
|
{ |
||||
|
currentSlice = await connection.ReadStreamEventsForwardAsync(GetStreamName(streamName), sliceStart, ReadPageSize, false); |
||||
|
|
||||
|
if (currentSlice.Status == SliceReadStatus.Success) |
||||
|
{ |
||||
|
sliceStart = currentSlice.NextEventNumber; |
||||
|
|
||||
|
foreach (var resolved in currentSlice.Events) |
||||
|
{ |
||||
|
var eventData = Formatter.Read(resolved.Event); |
||||
|
|
||||
|
result.Add(new StoredEvent(resolved.OriginalPosition.ToString(), resolved.Event.EventNumber, eventData)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
while (!currentSlice.IsEndOfStream); |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
public async Task AppendEventsAsync(Guid commitId, string streamName, int expectedVersion, ICollection<EventData> events) |
||||
|
{ |
||||
|
Guard.NotNull(events, nameof(events)); |
||||
|
Guard.NotNullOrEmpty(streamName, nameof(streamName)); |
||||
|
|
||||
|
if (events.Count == 0) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var eventsToSave = events.Select(Formatter.Write).ToList(); |
||||
|
|
||||
|
if (eventsToSave.Count < WritePageSize) |
||||
|
{ |
||||
|
await connection.AppendToStreamAsync(GetStreamName(streamName), expectedVersion, eventsToSave); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
using (var transaction = await connection.StartTransactionAsync(GetStreamName(streamName), expectedVersion)) |
||||
|
{ |
||||
|
for (var p = 0; p < eventsToSave.Count; p += WritePageSize) |
||||
|
{ |
||||
|
await transaction.WriteAsync(eventsToSave.Skip(p).Take(WritePageSize)); |
||||
|
} |
||||
|
|
||||
|
await transaction.CommitAsync(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private string GetStreamName(string streamName) |
||||
|
{ |
||||
|
return $"{prefix}-{streamName}"; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,145 @@ |
|||||
|
// ==========================================================================
|
||||
|
// EventStoreSubscription.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Concurrent; |
||||
|
using System.Linq; |
||||
|
using System.Net; |
||||
|
using System.Net.Sockets; |
||||
|
using System.Text; |
||||
|
using System.Threading.Tasks; |
||||
|
using EventStore.ClientAPI; |
||||
|
using EventStore.ClientAPI.Exceptions; |
||||
|
using EventStore.ClientAPI.Projections; |
||||
|
using Squidex.Infrastructure.CQRS.Events; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventStore |
||||
|
{ |
||||
|
internal sealed class EventStoreSubscription : IEventSubscription |
||||
|
{ |
||||
|
private static readonly ConcurrentDictionary<string, bool> subscriptionsCreated = new ConcurrentDictionary<string, bool>(); |
||||
|
private readonly IEventStoreConnection connection; |
||||
|
private readonly string position; |
||||
|
private readonly string streamFilter; |
||||
|
private readonly string streamName; |
||||
|
private readonly string prefix; |
||||
|
private readonly string projectionHost; |
||||
|
private EventStoreCatchUpSubscription internalSubscription; |
||||
|
|
||||
|
public EventStoreSubscription(IEventStoreConnection connection, string streamFilter, string position, string prefix, string projectionHost) |
||||
|
{ |
||||
|
this.prefix = prefix; |
||||
|
this.position = position; |
||||
|
this.connection = connection; |
||||
|
this.streamFilter = streamFilter; |
||||
|
this.projectionHost = projectionHost; |
||||
|
|
||||
|
streamName = CreateStreamName(streamFilter, prefix); |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
internalSubscription?.Stop(); |
||||
|
} |
||||
|
|
||||
|
public async Task SubscribeAsync(Func<StoredEvent, Task> handler) |
||||
|
{ |
||||
|
Guard.NotNull(handler, nameof(handler)); |
||||
|
|
||||
|
if (internalSubscription != null) |
||||
|
{ |
||||
|
throw new InvalidOperationException("An handler has already been registered."); |
||||
|
} |
||||
|
|
||||
|
if (subscriptionsCreated.TryAdd(streamName, true)) |
||||
|
{ |
||||
|
var projectsManager = await ConnectToProjections(); |
||||
|
|
||||
|
var projectionConfig = |
||||
|
$@"fromAll()
|
||||
|
.when({{ |
||||
|
$any: function (s, e) {{ |
||||
|
if (e.streamId.indexOf('{prefix}') === 0 && /{streamFilter}/.test(e.streamId.substring({prefix.Length + 1}))) {{ |
||||
|
linkTo('{streamName}', e); |
||||
|
}} |
||||
|
}} |
||||
|
}});";
|
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
await projectsManager.CreateContinuousAsync($"${streamName}", projectionConfig, connection.Settings.DefaultUserCredentials); |
||||
|
} |
||||
|
catch (ProjectionCommandConflictException) |
||||
|
{ |
||||
|
// Projection already exists.
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
long? eventStorePosition = null; |
||||
|
|
||||
|
if (long.TryParse(position, out var parsedPosition)) |
||||
|
{ |
||||
|
eventStorePosition = parsedPosition; |
||||
|
} |
||||
|
|
||||
|
internalSubscription = connection.SubscribeToStreamFrom(streamName, eventStorePosition, CatchUpSubscriptionSettings.Default, (subscription, resolved) => |
||||
|
{ |
||||
|
var eventData = Formatter.Read(resolved.Event); |
||||
|
|
||||
|
handler(new StoredEvent(resolved.OriginalEventNumber.ToString(), resolved.Event.EventNumber, eventData)).Wait(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private async Task<ProjectionsManager> ConnectToProjections() |
||||
|
{ |
||||
|
var addressParts = projectionHost.Split(':'); |
||||
|
|
||||
|
if (addressParts.Length < 2 || !int.TryParse(addressParts[1], out int port)) |
||||
|
{ |
||||
|
port = 2113; |
||||
|
} |
||||
|
|
||||
|
var endpoints = await Dns.GetHostAddressesAsync(addressParts[0]); |
||||
|
var endpoint = new IPEndPoint(endpoints.First(x => x.AddressFamily == AddressFamily.InterNetwork), port); |
||||
|
|
||||
|
var projectsManager = |
||||
|
new ProjectionsManager( |
||||
|
connection.Settings.Log, endpoint, |
||||
|
connection.Settings.OperationTimeout); |
||||
|
return projectsManager; |
||||
|
} |
||||
|
|
||||
|
private static string CreateStreamName(string streamFilter, string prefix) |
||||
|
{ |
||||
|
var sb = new StringBuilder(); |
||||
|
|
||||
|
sb.Append(prefix.Trim(' ', '-')); |
||||
|
sb.Append("-"); |
||||
|
|
||||
|
var prevIsLetterOrDigit = false; |
||||
|
|
||||
|
foreach (var c in streamFilter) |
||||
|
{ |
||||
|
if (char.IsLetterOrDigit(c)) |
||||
|
{ |
||||
|
sb.Append(char.ToLowerInvariant(c)); |
||||
|
|
||||
|
prevIsLetterOrDigit = true; |
||||
|
} |
||||
|
else if (prevIsLetterOrDigit) |
||||
|
{ |
||||
|
sb.Append("-"); |
||||
|
|
||||
|
prevIsLetterOrDigit = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return sb.ToString().Trim(' ', '-'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Formatter.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Text; |
||||
|
using EventStore.ClientAPI; |
||||
|
using EventData = Squidex.Infrastructure.CQRS.Events.EventData; |
||||
|
using EventStoreData = EventStore.ClientAPI.EventData; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventStore |
||||
|
{ |
||||
|
public static class Formatter |
||||
|
{ |
||||
|
public static EventData Read(RecordedEvent eventData) |
||||
|
{ |
||||
|
var body = Encoding.UTF8.GetString(eventData.Data); |
||||
|
var meta = Encoding.UTF8.GetString(eventData.Metadata); |
||||
|
|
||||
|
return new EventData { Type = eventData.EventType, EventId = eventData.EventId, Payload = body, Metadata = meta }; |
||||
|
} |
||||
|
|
||||
|
public static EventStoreData Write(EventData eventData) |
||||
|
{ |
||||
|
var body = Encoding.UTF8.GetBytes(eventData.Payload); |
||||
|
var meta = Encoding.UTF8.GetBytes(eventData.Metadata); |
||||
|
|
||||
|
return new EventStoreData( |
||||
|
eventData.EventId, |
||||
|
eventData.Type, |
||||
|
true, body, meta); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
|
||||
|
<PropertyGroup> |
||||
|
<TargetFramework>netcoreapp1.1</TargetFramework> |
||||
|
</PropertyGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<PackageReference Include="EventStore.ClientAPI.NetCore" Version="4.0.0-alpha-1" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<ProjectReference Include="..\src\Squidex.Infrastructure\Squidex.Infrastructure.csproj" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
</Project> |
||||
Loading…
Reference in new issue