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