feat(eventbus): add platform runtimes and benchmarks

Add the Entities NativeQueue adapter, standalone .NET runtime and source generator, reproducible smoke coverage, and Unity benchmark assets for EventBus 2.0.
This commit is contained in:
2026-08-26 01:17:39 +08:00
parent ad5a7b68a3
commit 724e0bc8d8
32 changed files with 1813 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# ShrinkEventBus for .NET
- `ShrinkEventBus.Core``netstandard2.1` ValueTask 运行时,不引用 UnityEngine 或 UniTask。
- `ShrinkEventBus.Generator`Roslyn incremental generator,为 `partial` 普通 C# subscriber 生成强类型绑定。
- `ShrinkEventBus.Smoke``net8.0` 烟测,覆盖多 Bus 隔离、Inline 和 DedicatedThread。
```bash
dotnet build DotNet/ShrinkEventBus.Core/ShrinkEventBus.Core.csproj -c Release
dotnet build DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj -c Release
dotnet run --project DotNet/ShrinkEventBus.Smoke/ShrinkEventBus.Smoke.csproj -c Release
```
+205
View File
@@ -0,0 +1,205 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace ShrinkEventBus
{
internal interface IDotNetScheduler : IDisposable
{
bool IsOnSchedulerThread { get; }
bool TryPost(Action action);
ValueTask PostAsync(Func<ValueTask> action, CancellationToken cancellationToken);
}
internal sealed class QueueFullException : InvalidOperationException { }
internal static class DotNetSchedulerFactory
{
public static IDotNetScheduler Create(string name, ShrinkBusOptions options)
{
return options.Scheduler switch
{
ShrinkBusSchedulerKind.MainThread => new SynchronizationContextScheduler(options),
ShrinkBusSchedulerKind.DedicatedThread => new DedicatedScheduler(name, options),
ShrinkBusSchedulerKind.TaskPool => new TaskPoolScheduler(options),
_ => new InlineScheduler()
};
}
}
internal sealed class InlineScheduler : IDotNetScheduler
{
public bool IsOnSchedulerThread => true;
public bool TryPost(Action action) { action(); return true; }
public ValueTask PostAsync(Func<ValueTask> action, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return action();
}
public void Dispose() { }
}
internal sealed class SynchronizationContextScheduler : IDotNetScheduler
{
private readonly SynchronizationContext _context;
private readonly int _threadId;
private int _disposed;
public SynchronizationContextScheduler(ShrinkBusOptions options)
{
_context = SynchronizationContext.Current ?? throw new InvalidOperationException(
"MainThread scheduler requires a current SynchronizationContext in a pure .NET host.");
_threadId = Thread.CurrentThread.ManagedThreadId;
}
public bool IsOnSchedulerThread => Thread.CurrentThread.ManagedThreadId == _threadId;
public bool TryPost(Action action)
{
if (Volatile.Read(ref _disposed) != 0)
return false;
if (IsOnSchedulerThread)
action();
else
_context.Post(_ => action(), null);
return true;
}
public ValueTask PostAsync(Func<ValueTask> action, CancellationToken cancellationToken)
{
if (IsOnSchedulerThread)
return action();
return new ValueTask(PostCoreAsync(action, cancellationToken));
}
private Task PostCoreAsync(Func<ValueTask> action, CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_context.Post(async _ =>
{
try { cancellationToken.ThrowIfCancellationRequested(); await action(); completion.SetResult(true); }
catch (OperationCanceledException) { completion.SetCanceled(); }
catch (Exception ex) { completion.SetException(ex); }
}, null);
return completion.Task;
}
public void Dispose() => Interlocked.Exchange(ref _disposed, 1);
}
internal sealed class TaskPoolScheduler : IDotNetScheduler
{
private readonly SemaphoreSlim _concurrency;
private readonly int _capacity;
private int _pending;
private int _disposed;
public TaskPoolScheduler(ShrinkBusOptions options)
{
var concurrency = options.DispatchMode == ShrinkDispatchMode.Ordered ? 1 : options.MaxConcurrency;
_concurrency = new SemaphoreSlim(concurrency, concurrency);
_capacity = options.QueueCapacity;
}
public bool IsOnSchedulerThread => false;
public bool TryPost(Action action)
{
if (Volatile.Read(ref _disposed) != 0 || Interlocked.Increment(ref _pending) > _capacity)
{
Interlocked.Decrement(ref _pending);
return false;
}
_ = Task.Run(async () =>
{
await _concurrency.WaitAsync().ConfigureAwait(false);
try { action(); }
finally { _concurrency.Release(); Interlocked.Decrement(ref _pending); }
});
return true;
}
public async ValueTask PostAsync(Func<ValueTask> action, CancellationToken cancellationToken)
{
if (Volatile.Read(ref _disposed) != 0 || Interlocked.Increment(ref _pending) > _capacity)
{
Interlocked.Decrement(ref _pending);
throw new QueueFullException();
}
await _concurrency.WaitAsync(cancellationToken).ConfigureAwait(false);
try { await action().ConfigureAwait(false); }
finally { _concurrency.Release(); Interlocked.Decrement(ref _pending); }
}
public void Dispose()
{
Interlocked.Exchange(ref _disposed, 1);
_concurrency.Dispose();
}
}
internal sealed class DedicatedScheduler : IDotNetScheduler
{
private sealed class Work
{
public Func<ValueTask> Action = null!;
public TaskCompletionSource<bool>? Completion;
}
private readonly BlockingCollection<Work> _queue;
private readonly Thread _thread;
private int _threadId;
private int _disposed;
public DedicatedScheduler(string name, ShrinkBusOptions options)
{
_queue = new BlockingCollection<Work>(options.QueueCapacity);
_thread = new Thread(Run) { IsBackground = true, Name = $"ShrinkBus:{name}" };
_thread.Start();
}
public bool IsOnSchedulerThread => Thread.CurrentThread.ManagedThreadId == Volatile.Read(ref _threadId);
public bool TryPost(Action action)
{
if (IsOnSchedulerThread) { action(); return true; }
return Volatile.Read(ref _disposed) == 0 && _queue.TryAdd(new Work
{
Action = () => { action(); return default; }
});
}
public ValueTask PostAsync(Func<ValueTask> action, CancellationToken cancellationToken)
{
if (IsOnSchedulerThread)
return action();
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
if (Volatile.Read(ref _disposed) != 0 || !_queue.TryAdd(new Work
{ Action = action, Completion = completion }))
throw new QueueFullException();
return new ValueTask(completion.Task);
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
_queue.CompleteAdding();
}
private void Run()
{
Volatile.Write(ref _threadId, Thread.CurrentThread.ManagedThreadId);
foreach (var work in _queue.GetConsumingEnumerable())
{
try { work.Action().AsTask().GetAwaiter().GetResult(); work.Completion?.SetResult(true); }
catch (OperationCanceledException) { work.Completion?.SetCanceled(); }
catch (Exception ex) { work.Completion?.SetException(ex); }
}
_queue.Dispose();
}
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>ShrinkEventBus.Core</AssemblyName>
<RootNamespace>ShrinkEventBus</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\Assets\Modules\ShrinkEventBus\Runtime\ShrinkEventContracts.cs"
Link="Contracts\ShrinkEventContracts.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkEventBus\Runtime\ShrinkEventAttributes.cs"
Link="Contracts\ShrinkEventAttributes.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,494 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ShrinkEventBus
{
public delegate ValueTask ShrinkAsyncEventHandler<in TEvent>(TEvent eventData,
CancellationToken cancellationToken) where TEvent : IShrinkEvent;
public interface IShrinkEventBus : IDisposable
{
ShrinkBusKey Key { get; }
ShrinkBusOptions Options { get; }
ShrinkPostResult Post<TEvent>(in TEvent eventData) where TEvent : IShrinkEvent;
ValueTask<ShrinkPostResult> PostAsync<TEvent>(TEvent eventData,
CancellationToken cancellationToken = default) where TEvent : IShrinkEvent;
IDisposable Attach(object target);
}
public interface IShrinkBusResolver
{
IShrinkEventBus GetBus(ShrinkBusKey key);
bool TryGetBus(ShrinkBusKey key, out IShrinkEventBus bus);
}
public interface IShrinkGeneratedSubscriber
{
IDisposable AttachGenerated(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus = null);
}
public sealed class ShrinkEventBinding : IDisposable
{
private readonly List<IDisposable> _items = new List<IDisposable>();
private bool _disposed;
public void Add(IDisposable item)
{
if (item == null)
throw new ArgumentNullException(nameof(item));
if (_disposed)
{
item.Dispose();
throw new ObjectDisposedException(nameof(ShrinkEventBinding));
}
_items.Add(item);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
for (var i = _items.Count - 1; i >= 0; i--)
_items[i].Dispose();
_items.Clear();
}
}
public static class ShrinkStaticBindingRegistry
{
internal sealed class Entry
{
public long Id;
public ShrinkBusKey Key;
public Func<IShrinkBusResolver, IDisposable> Factory = null!;
}
private static readonly object Gate = new object();
private static readonly List<Entry> Entries = new List<Entry>();
private static readonly List<WeakReference<ShrinkEventBusHost>> Hosts =
new List<WeakReference<ShrinkEventBusHost>>();
private static long _nextId;
public static void Register(ShrinkBusKey key,
Func<IShrinkBusResolver, IDisposable> factory)
{
if (factory == null)
throw new ArgumentNullException(nameof(factory));
ShrinkEventBusHost[] hosts;
Entry entry;
lock (Gate)
{
entry = new Entry
{
Id = Interlocked.Increment(ref _nextId),
Key = key,
Factory = factory
};
Entries.Add(entry);
hosts = LiveHostsLocked();
}
for (var i = 0; i < hosts.Length; i++)
hosts[i].TryAttachStatic(entry);
}
internal static void TrackHost(ShrinkEventBusHost host)
{
lock (Gate)
{
LiveHostsLocked();
Hosts.Add(new WeakReference<ShrinkEventBusHost>(host));
}
}
internal static Entry[] GetEntries(ShrinkBusKey key)
{
lock (Gate)
return Entries.FindAll(item => item.Key == key).ToArray();
}
private static ShrinkEventBusHost[] LiveHostsLocked()
{
var result = new List<ShrinkEventBusHost>(Hosts.Count);
for (var i = Hosts.Count - 1; i >= 0; i--)
{
if (Hosts[i].TryGetTarget(out var host))
result.Add(host);
else
Hosts.RemoveAt(i);
}
return result.ToArray();
}
}
public sealed class ShrinkEventBusHost : IShrinkBusResolver, IDisposable
{
private readonly ConcurrentDictionary<ShrinkBusKey, IShrinkEventBus> _buses =
new ConcurrentDictionary<ShrinkBusKey, IShrinkEventBus>();
private readonly object _staticGate = new object();
private readonly Dictionary<long, IDisposable> _staticBindings =
new Dictionary<long, IDisposable>();
private bool _disposed;
public ShrinkEventBusHost()
{
ShrinkStaticBindingRegistry.TrackHost(this);
}
public IShrinkEventBus CreateBus(ShrinkBusKey key, ShrinkBusOptions options)
{
if (_disposed)
throw new ObjectDisposedException(nameof(ShrinkEventBusHost));
var bus = new DotNetShrinkEventBus(key, options);
if (!_buses.TryAdd(key, bus))
{
bus.Dispose();
throw new InvalidOperationException($"Bus '{key}' is already registered.");
}
var entries = ShrinkStaticBindingRegistry.GetEntries(key);
for (var i = 0; i < entries.Length; i++)
TryAttachStatic(entries[i]);
return bus;
}
public IShrinkEventBus GetBus(ShrinkBusKey key)
{
if (_buses.TryGetValue(key, out var bus))
return bus;
throw new KeyNotFoundException($"Bus '{key}' is not registered.");
}
public bool TryGetBus(ShrinkBusKey key, out IShrinkEventBus bus) =>
_buses.TryGetValue(key, out bus!);
public IDisposable Attach(object target, ShrinkBusKey? defaultBus = null)
{
if (target is not IShrinkGeneratedSubscriber generated)
throw new InvalidOperationException(
$"Type {target?.GetType().FullName ?? "<null>"} has no generated event binding.");
return generated.AttachGenerated(this, defaultBus);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
lock (_staticGate)
{
foreach (var binding in _staticBindings.Values)
binding.Dispose();
_staticBindings.Clear();
}
foreach (var bus in _buses.Values)
bus.Dispose();
_buses.Clear();
}
internal void TryAttachStatic(ShrinkStaticBindingRegistry.Entry entry)
{
lock (_staticGate)
{
if (_disposed || _staticBindings.ContainsKey(entry.Id) || !_buses.ContainsKey(entry.Key))
return;
_staticBindings.Add(entry.Id, entry.Factory(this));
}
}
}
public static class ShrinkGeneratedBinding
{
public static IDisposable Subscribe<TEvent>(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus,
string? configuredBus, object? owner, Action<TEvent> handler, ShrinkEventPriority priority,
int numericPriority, bool receiveCanceled) where TEvent : IShrinkEvent
{
return Resolve(resolver, defaultBus, configuredBus).Subscribe(
owner, handler, priority, numericPriority, receiveCanceled);
}
public static IDisposable SubscribeAsync<TEvent>(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus,
string? configuredBus, object? owner, ShrinkAsyncEventHandler<TEvent> handler,
ShrinkEventPriority priority, int numericPriority, bool receiveCanceled) where TEvent : IShrinkEvent
{
return Resolve(resolver, defaultBus, configuredBus).SubscribeAsync(
owner, handler, priority, numericPriority, receiveCanceled);
}
private static DotNetShrinkEventBus Resolve(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus,
string? configuredBus)
{
var key = string.IsNullOrWhiteSpace(configuredBus)
? defaultBus ?? ShrinkBusKey.Game
: ShrinkBusKey.Parse(configuredBus);
return resolver.GetBus(key) as DotNetShrinkEventBus
?? throw new InvalidOperationException("Generated bindings require the built-in bus implementation.");
}
}
internal sealed class DotNetShrinkEventBus : IShrinkEventBus
{
private readonly ConcurrentDictionary<Type, IEventChannel> _channels =
new ConcurrentDictionary<Type, IEventChannel>();
private readonly IDotNetScheduler _scheduler;
private long _nextSubscriptionId;
private bool _disposed;
public DotNetShrinkEventBus(ShrinkBusKey key, ShrinkBusOptions options)
{
Key = key;
Options = (options ?? throw new ArgumentNullException(nameof(options))).CloneValidated();
_scheduler = DotNetSchedulerFactory.Create(key.Id, Options);
}
public ShrinkBusKey Key { get; }
public ShrinkBusOptions Options { get; }
public ShrinkPostResult Post<TEvent>(in TEvent eventData) where TEvent : IShrinkEvent
{
if (_disposed)
return ShrinkPostResult.Rejected(ShrinkPostFailure.BusStopped);
var copied = eventData;
if (_scheduler.IsOnSchedulerThread)
return Channel<TEvent>().Post(copied);
return _scheduler.TryPost(() => Channel<TEvent>().Post(copied))
? new ShrinkPostResult(true, false, false)
: ShrinkPostResult.Rejected(ShrinkPostFailure.QueueFull);
}
public async ValueTask<ShrinkPostResult> PostAsync<TEvent>(TEvent eventData,
CancellationToken cancellationToken = default) where TEvent : IShrinkEvent
{
if (_disposed)
return ShrinkPostResult.Rejected(ShrinkPostFailure.BusStopped);
ShrinkPostResult result = default;
try
{
await _scheduler.PostAsync(async () =>
{
result = await Channel<TEvent>().PostAsync(eventData, Options.DispatchMode, cancellationToken)
.ConfigureAwait(false);
}, cancellationToken).ConfigureAwait(false);
return result;
}
catch (QueueFullException)
{
return ShrinkPostResult.Rejected(ShrinkPostFailure.QueueFull);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return new ShrinkPostResult(true, false, false, ShrinkPostFailure.Canceled);
}
}
public IDisposable Attach(object target)
{
if (target is not IShrinkGeneratedSubscriber generated)
throw new InvalidOperationException(
$"Type {target?.GetType().FullName ?? "<null>"} has no generated event binding.");
return generated.AttachGenerated(new SingleBusResolver(this), Key);
}
public IDisposable Subscribe<TEvent>(object? owner, Action<TEvent> handler,
ShrinkEventPriority priority, int numericPriority, bool receiveCanceled) where TEvent : IShrinkEvent
{
return Channel<TEvent>().Add(Interlocked.Increment(ref _nextSubscriptionId), owner,
handler, null, priority, numericPriority, receiveCanceled);
}
public IDisposable SubscribeAsync<TEvent>(object? owner, ShrinkAsyncEventHandler<TEvent> handler,
ShrinkEventPriority priority, int numericPriority, bool receiveCanceled) where TEvent : IShrinkEvent
{
return Channel<TEvent>().Add(Interlocked.Increment(ref _nextSubscriptionId), owner,
null, handler, priority, numericPriority, receiveCanceled);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_scheduler.Dispose();
foreach (var channel in _channels.Values)
channel.Clear();
_channels.Clear();
}
private EventChannel<TEvent> Channel<TEvent>() where TEvent : IShrinkEvent =>
(EventChannel<TEvent>)_channels.GetOrAdd(typeof(TEvent), _ => new EventChannel<TEvent>());
private sealed class SingleBusResolver : IShrinkBusResolver
{
private readonly IShrinkEventBus _bus;
public SingleBusResolver(IShrinkEventBus bus) => _bus = bus;
public IShrinkEventBus GetBus(ShrinkBusKey key) => key == _bus.Key
? _bus
: throw new KeyNotFoundException($"Bus '{key}' is not available.");
public bool TryGetBus(ShrinkBusKey key, out IShrinkEventBus bus)
{
bus = _bus;
return key == _bus.Key;
}
}
}
internal interface IEventChannel
{
void Clear();
}
internal sealed class EventChannel<TEvent> : IEventChannel where TEvent : IShrinkEvent
{
private sealed class Entry
{
public long Id = 0;
public object? Owner;
public Action<TEvent>? Sync;
public ShrinkAsyncEventHandler<TEvent>? Async;
public ShrinkEventPriority Priority;
public int NumericPriority;
public bool ReceiveCanceled;
}
private readonly object _gate = new object();
private readonly List<Entry> _entries = new List<Entry>();
private Entry[] _snapshot = Array.Empty<Entry>();
public IDisposable Add(long id, object? owner, Action<TEvent>? sync,
ShrinkAsyncEventHandler<TEvent>? asyncHandler, ShrinkEventPriority priority,
int numericPriority, bool receiveCanceled)
{
lock (_gate)
{
_entries.Add(new Entry
{
Id = id,
Owner = owner,
Sync = sync,
Async = asyncHandler,
Priority = priority,
NumericPriority = numericPriority,
ReceiveCanceled = receiveCanceled
});
Rebuild();
}
return new Subscription(this, id);
}
public ShrinkPostResult Post(TEvent eventData)
{
var handled = false;
var snapshot = _snapshot;
for (var i = 0; i < snapshot.Length; i++)
{
var entry = snapshot[i];
if (IsCanceled(eventData) && !entry.ReceiveCanceled)
continue;
if (entry.Sync != null)
entry.Sync(eventData);
else if (entry.Async != null)
_ = entry.Async(eventData, CancellationToken.None).AsTask();
else
continue;
handled = true;
}
return ShrinkPostResult.Completed(handled, IsCanceled(eventData));
}
public async ValueTask<ShrinkPostResult> PostAsync(TEvent eventData, ShrinkDispatchMode mode,
CancellationToken cancellationToken)
{
var handled = false;
var snapshot = _snapshot;
if (mode == ShrinkDispatchMode.Parallel)
{
var tasks = new List<Task>();
for (var i = 0; i < snapshot.Length; i++)
{
var entry = snapshot[i];
if (IsCanceled(eventData) && !entry.ReceiveCanceled)
continue;
if (entry.Sync != null)
entry.Sync(eventData);
else if (entry.Async != null)
tasks.Add(entry.Async(eventData, cancellationToken).AsTask());
else
continue;
handled = true;
}
if (tasks.Count > 0)
await Task.WhenAll(tasks).ConfigureAwait(false);
}
else
{
for (var i = 0; i < snapshot.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var entry = snapshot[i];
if (IsCanceled(eventData) && !entry.ReceiveCanceled)
continue;
if (entry.Sync != null)
entry.Sync(eventData);
else if (entry.Async != null)
await entry.Async(eventData, cancellationToken).ConfigureAwait(false);
else
continue;
handled = true;
}
}
return ShrinkPostResult.Completed(handled, IsCanceled(eventData));
}
public void Clear()
{
lock (_gate)
{
_entries.Clear();
_snapshot = Array.Empty<Entry>();
}
}
private void Remove(long id)
{
lock (_gate)
{
_entries.RemoveAll(item => item.Id == id);
Rebuild();
}
}
private void Rebuild()
{
_entries.Sort((left, right) =>
{
var leftValue = left.NumericPriority == 0 ? (int)left.Priority * 1000 : -left.NumericPriority;
var rightValue = right.NumericPriority == 0 ? (int)right.Priority * 1000 : -right.NumericPriority;
var result = leftValue.CompareTo(rightValue);
return result != 0 ? result : left.Id.CompareTo(right.Id);
});
_snapshot = _entries.ToArray();
}
private static bool IsCanceled(TEvent value) =>
value is IShrinkCancelableEvent cancelable && cancelable.IsCanceled;
private sealed class Subscription : IDisposable
{
private EventChannel<TEvent>? _owner;
private readonly long _id;
public Subscription(EventChannel<TEvent> owner, long id)
{
_owner = owner;
_id = id;
}
public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Remove(_id);
}
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>ShrinkEventBus.Generator</AssemblyName>
<RootNamespace>ShrinkEventBus.Generator</RootNamespace>
<IncludeBuildOutput>false</IncludeBuildOutput>
<NoWarn>$(NoWarn);RS2008</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.1" PrivateAssets="all" />
</ItemGroup>
</Project>
@@ -0,0 +1,309 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace ShrinkEventBus.Generator
{
[Generator]
public sealed class ShrinkEventBusGenerator : IIncrementalGenerator
{
private const string SubscriberAttribute = "ShrinkEventBus.ShrinkEventSubscriberAttribute";
private const string SubscribeAttribute = "ShrinkEventBus.ShrinkSubscribeAttribute";
private static readonly DiagnosticDescriptor PartialRequired = new DiagnosticDescriptor(
"SHRINKEVENT001", "Subscriber must be partial",
"Subscriber type '{0}' must be partial so ShrinkEventBus can generate a reflection-free binding",
"ShrinkEventBus", DiagnosticSeverity.Error, true);
private static readonly DiagnosticDescriptor UnsupportedHandler = new DiagnosticDescriptor(
"SHRINKEVENT002", "Unsupported handler signature",
"Handler '{0}' must be void(TEvent), UniTask(TEvent), UniTask(TEvent, CancellationToken), or ValueTask(TEvent, CancellationToken)",
"ShrinkEventBus", DiagnosticSeverity.Error, true);
private static readonly DiagnosticDescriptor TopLevelRequired = new DiagnosticDescriptor(
"SHRINKEVENT003", "Top-level subscriber required",
"Subscriber type '{0}' must be top-level in the current generator version",
"ShrinkEventBus", DiagnosticSeverity.Error, true);
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterSourceOutput(context.CompilationProvider,
static (sourceContext, compilation) =>
{
if (compilation.GetTypeByMetadataName(
"System.Runtime.CompilerServices.ModuleInitializerAttribute") == null)
{
sourceContext.AddSource("ShrinkEventBus.ModuleInitializerAttribute.g.cs",
SourceText.From(
"namespace System.Runtime.CompilerServices { [global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)] internal sealed class ModuleInitializerAttribute : global::System.Attribute { } }",
Encoding.UTF8));
}
});
var candidates = context.SyntaxProvider.ForAttributeWithMetadataName(
SubscriberAttribute,
static (node, _) => node is ClassDeclarationSyntax,
static (syntaxContext, _) => (INamedTypeSymbol)syntaxContext.TargetSymbol);
context.RegisterSourceOutput(candidates.Collect(), Generate);
}
private static void Generate(SourceProductionContext context,
ImmutableArray<INamedTypeSymbol> candidates)
{
foreach (var type in candidates)
GenerateType(context, type);
}
private static void GenerateType(SourceProductionContext context, INamedTypeSymbol type)
{
if (type.ContainingType != null)
{
context.ReportDiagnostic(Diagnostic.Create(TopLevelRequired,
type.Locations.FirstOrDefault(), type.ToDisplayString()));
return;
}
var isPartial = type.DeclaringSyntaxReferences
.Select(reference => reference.GetSyntax())
.OfType<ClassDeclarationSyntax>()
.Any(declaration => declaration.Modifiers.Any(SyntaxKind.PartialKeyword));
if (!isPartial)
{
context.ReportDiagnostic(Diagnostic.Create(PartialRequired,
type.Locations.FirstOrDefault(), type.ToDisplayString()));
return;
}
var handlers = new List<HandlerModel>();
foreach (var method in type.GetMembers().OfType<IMethodSymbol>())
{
var attribute = method.GetAttributes().FirstOrDefault(item =>
item.AttributeClass?.ToDisplayString() == SubscribeAttribute);
if (attribute == null)
continue;
if (!TryCreateHandler(method, attribute, type.IsStatic, out var handler))
{
context.ReportDiagnostic(Diagnostic.Create(UnsupportedHandler,
method.Locations.FirstOrDefault(), method.ToDisplayString()));
continue;
}
handlers.Add(handler);
}
var subscriber = type.GetAttributes().First(item =>
item.AttributeClass?.ToDisplayString() == SubscriberAttribute);
var defaultBus = ReadString(subscriber, "DefaultBus");
var source = type.IsStatic
? BuildStaticSource(type, handlers, defaultBus)
: BuildSource(type, handlers, defaultBus);
var hint = type.ToDisplayString().Replace('.', '_').Replace('+', '_') + ".ShrinkEvents.g.cs";
context.AddSource(hint, SourceText.From(source, Encoding.UTF8));
}
private static bool TryCreateHandler(IMethodSymbol method, AttributeData attribute,
bool staticSubscriber,
out HandlerModel model)
{
model = default;
if (method.IsStatic != staticSubscriber || method.Parameters.Length == 0 || method.Parameters.Length > 2)
return false;
var eventType = method.Parameters[0].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var returnType = method.ReturnType.ToDisplayString();
var isSync = method.ReturnsVoid && method.Parameters.Length == 1;
var isValueTask = returnType == "System.Threading.Tasks.ValueTask" &&
method.Parameters.Length == 2 &&
method.Parameters[1].Type.ToDisplayString() == "System.Threading.CancellationToken";
var isUniTask = returnType == "Cysharp.Threading.Tasks.UniTask";
var isUniTaskLegacy = isUniTask && method.Parameters.Length == 1;
var isUniTaskCancelable = isUniTask && method.Parameters.Length == 2 &&
method.Parameters[1].Type.ToDisplayString() == "System.Threading.CancellationToken";
if (!isSync && !isValueTask && !isUniTaskLegacy && !isUniTaskCancelable)
return false;
var bindingMethod = isSync
? "Subscribe"
: isUniTaskLegacy ? "SubscribeAsyncLegacy" : "SubscribeAsync";
model = new HandlerModel(
method.Name,
eventType,
bindingMethod,
ReadString(attribute, "Bus"),
ReadInt(attribute, "Priority", 2),
ReadInt(attribute, "NumericPriority", 0),
ReadBool(attribute, "ReceiveCanceled", false));
return true;
}
private static string BuildSource(INamedTypeSymbol type, IReadOnlyList<HandlerModel> handlers,
string defaultBus)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
if (!type.ContainingNamespace.IsGlobalNamespace)
{
builder.Append("namespace ").Append(type.ContainingNamespace.ToDisplayString()).AppendLine();
builder.AppendLine("{");
}
var accessibility = type.DeclaredAccessibility switch
{
Accessibility.Public => "public ",
Accessibility.Internal => "internal ",
_ => string.Empty
};
builder.Append(" ").Append(accessibility).Append("partial class ")
.Append(type.Name).AppendLine(" : global::ShrinkEventBus.IShrinkGeneratedSubscriber");
builder.AppendLine(" {");
builder.AppendLine(" global::System.IDisposable global::ShrinkEventBus.IShrinkGeneratedSubscriber.AttachGenerated(");
builder.AppendLine(" global::ShrinkEventBus.IShrinkBusResolver resolver,");
builder.AppendLine(" global::ShrinkEventBus.ShrinkBusKey? defaultBus)");
builder.AppendLine(" {");
builder.AppendLine(" var binding = new global::ShrinkEventBus.ShrinkEventBinding();");
foreach (var handler in handlers)
{
var bus = string.IsNullOrWhiteSpace(handler.Bus) ? defaultBus : handler.Bus;
builder.Append(" binding.Add(global::ShrinkEventBus.ShrinkGeneratedBinding.")
.Append(handler.BindingMethod).Append('<').Append(handler.EventType).AppendLine(">(");
builder.Append(" resolver, defaultBus, ")
.Append(ToLiteral(bus)).AppendLine(", this,");
builder.Append(" this.").Append(handler.MethodName)
.Append(", (global::ShrinkEventBus.ShrinkEventPriority)")
.Append(handler.Priority).Append(", ").Append(handler.NumericPriority).Append(", ")
.Append(handler.ReceiveCanceled ? "true" : "false").AppendLine("));");
}
builder.AppendLine(" return binding;");
builder.AppendLine(" }");
builder.AppendLine(" }");
if (!type.ContainingNamespace.IsGlobalNamespace)
builder.AppendLine("}");
return builder.ToString();
}
private static string BuildStaticSource(INamedTypeSymbol type,
IReadOnlyList<HandlerModel> handlers, string defaultBus)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
if (!type.ContainingNamespace.IsGlobalNamespace)
{
builder.Append("namespace ").Append(type.ContainingNamespace.ToDisplayString()).AppendLine();
builder.AppendLine("{");
}
var accessibility = type.DeclaredAccessibility switch
{
Accessibility.Public => "public ",
Accessibility.Internal => "internal ",
_ => string.Empty
};
builder.Append(" ").Append(accessibility).Append("static partial class ")
.Append(type.Name).AppendLine();
builder.AppendLine(" {");
builder.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]");
builder.AppendLine(" internal static void ShrinkEventBus_RegisterStaticBindings()");
builder.AppendLine(" {");
for (var i = 0; i < handlers.Count; i++)
{
var bus = string.IsNullOrWhiteSpace(handlers[i].Bus) ? defaultBus : handlers[i].Bus;
builder.Append(" global::ShrinkEventBus.ShrinkStaticBindingRegistry.Register(")
.Append("global::ShrinkEventBus.ShrinkBusKey.Parse(")
.Append(ToLiteral(bus)).Append("), ShrinkEventBus_Bind_").Append(i).AppendLine(");");
}
builder.AppendLine(" }");
for (var i = 0; i < handlers.Count; i++)
{
var handler = handlers[i];
var bus = string.IsNullOrWhiteSpace(handler.Bus) ? defaultBus : handler.Bus;
builder.Append(" private static global::System.IDisposable ShrinkEventBus_Bind_")
.Append(i).AppendLine("(global::ShrinkEventBus.IShrinkBusResolver resolver)");
builder.AppendLine(" {");
builder.Append(" return global::ShrinkEventBus.ShrinkGeneratedBinding.")
.Append(handler.BindingMethod).Append('<').Append(handler.EventType).AppendLine(">(");
builder.Append(" resolver, null, ").Append(ToLiteral(bus))
.AppendLine(", null,");
builder.Append(" ").Append(handler.MethodName)
.Append(", (global::ShrinkEventBus.ShrinkEventPriority)")
.Append(handler.Priority).Append(", ").Append(handler.NumericPriority).Append(", ")
.Append(handler.ReceiveCanceled ? "true" : "false").AppendLine(");");
builder.AppendLine(" }");
}
builder.AppendLine(" }");
if (!type.ContainingNamespace.IsGlobalNamespace)
builder.AppendLine("}");
return builder.ToString();
}
private static string ReadString(AttributeData attribute, string name)
{
foreach (var pair in attribute.NamedArguments)
{
if (pair.Key == name)
return pair.Value.Value as string ?? string.Empty;
}
return string.Empty;
}
private static int ReadInt(AttributeData attribute, string name, int defaultValue)
{
foreach (var pair in attribute.NamedArguments)
{
if (pair.Key == name && pair.Value.Value != null)
return Convert.ToInt32(pair.Value.Value);
}
return defaultValue;
}
private static bool ReadBool(AttributeData attribute, string name, bool defaultValue)
{
foreach (var pair in attribute.NamedArguments)
{
if (pair.Key == name && pair.Value.Value is bool value)
return value;
}
return defaultValue;
}
private static string ToLiteral(string value) => SymbolDisplay.FormatLiteral(value ?? string.Empty, true);
private readonly struct HandlerModel
{
public HandlerModel(string methodName, string eventType, string bindingMethod, string bus,
int priority, int numericPriority, bool receiveCanceled)
{
MethodName = methodName;
EventType = eventType;
BindingMethod = bindingMethod;
Bus = bus;
Priority = priority;
NumericPriority = numericPriority;
ReceiveCanceled = receiveCanceled;
}
public string MethodName { get; }
public string EventType { get; }
public string BindingMethod { get; }
public string Bus { get; }
public int Priority { get; }
public int NumericPriority { get; }
public bool ReceiveCanceled { get; }
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using ShrinkEventBus;
internal readonly struct PingEvent : IShrinkEvent
{
public PingEvent(int value) => Value = value;
public int Value { get; }
}
[ShrinkEventSubscriber]
internal sealed partial class SmokeSubscriber
{
public int Sum { get; private set; }
public int ThreadId { get; private set; }
[ShrinkSubscribe]
private void OnPing(PingEvent evt)
{
Sum += evt.Value;
ThreadId = Thread.CurrentThread.ManagedThreadId;
}
}
[ShrinkEventSubscriber(DefaultBus = "game")]
internal static partial class StaticSmokeSubscriber
{
public static int Sum { get; set; }
[ShrinkSubscribe]
private static void OnPing(PingEvent evt) => Sum += evt.Value;
}
internal static class Program
{
private static async Task<int> Main()
{
using var host = new ShrinkEventBusHost();
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
var workerKey = new ShrinkBusKey("worker", "smoke");
var worker = host.CreateBus(workerKey, ShrinkBusOptions.DedicatedThread());
var gameSubscriber = new SmokeSubscriber();
var workerSubscriber = new SmokeSubscriber();
var callerThread = Thread.CurrentThread.ManagedThreadId;
using var gameBinding = host.Attach(gameSubscriber, ShrinkBusKey.Game);
using var workerBinding = host.Attach(workerSubscriber, workerKey);
game.Post(new PingEvent(2));
await worker.PostAsync(new PingEvent(3));
if (gameSubscriber.Sum != 2 || workerSubscriber.Sum != 3 || StaticSmokeSubscriber.Sum != 2 ||
workerSubscriber.ThreadId == callerThread)
{
Console.Error.WriteLine("FAIL");
return 1;
}
Console.WriteLine("PASS");
return 0;
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ShrinkEventBus.Core\ShrinkEventBus.Core.csproj" />
<ProjectReference Include="..\ShrinkEventBus.Generator\ShrinkEventBus.Generator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>