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:
+13
@@ -7,6 +7,9 @@
|
||||
/[Ll]ibrary/
|
||||
/[Tt]emp/
|
||||
/[Oo]bj/
|
||||
/DotNet/**/[Bb]in/
|
||||
/DotNet/**/[Oo]bj/
|
||||
/TestResults/
|
||||
/[Bb]uild/
|
||||
/[Bb]uilds/
|
||||
/[Ll]ogs/
|
||||
@@ -56,6 +59,16 @@ ExportedObj/
|
||||
*.opendb
|
||||
*.VC.db
|
||||
|
||||
# Tracked source project for rebuilding ShrinkModFramework external DLL fixtures.
|
||||
!Tools/ShrinkModFixtureBuilder/ShrinkModFixtureBuilder.csproj
|
||||
Tools/ShrinkModFixtureBuilder/bin/
|
||||
Tools/ShrinkModFixtureBuilder/obj/
|
||||
|
||||
# Tracked standalone .NET projects for ShrinkEventBus runtime and generator validation.
|
||||
!DotNet/ShrinkEventBus.Core/ShrinkEventBus.Core.csproj
|
||||
!DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj
|
||||
!DotNet/ShrinkEventBus.Smoke/ShrinkEventBus.Smoke.csproj
|
||||
|
||||
# Unity3D generated meta files
|
||||
*.pidb.meta
|
||||
*.pdb.meta
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2c9ad00f02a38c749b89748791709246
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,14 @@
|
||||
# ShrinkEventBus Entities
|
||||
|
||||
`com.cneicy.shrink-eventbus-entities` 是可选 ECS/Burst 适配包。它不创建第二套事件总线,而是让 Burst Job 把 `unmanaged IShrinkEvent` 写入 `NativeQueue<T>`,随后由主线程或 ECS playback system 发布到指定的 `IShrinkEventBus`。
|
||||
|
||||
```csharp
|
||||
using var queue = new ShrinkEcsEventQueue<MyEcsEvent>();
|
||||
var writer = queue.Writer;
|
||||
|
||||
// Burst Job 中:writer.Post(in eventData);
|
||||
// Playback 阶段:
|
||||
queue.Playback(worldBus);
|
||||
```
|
||||
|
||||
限制:Burst 端只负责事实事件生产,不能直接执行托管 handler、UniTask、取消或结果裁决。
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 02173172caac46a6bb3e586a95c6f8e1
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c58d5bbaaf44a3dba31fe63ab32fd22
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,53 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using ShrinkEventBus;
|
||||
using Unity.Collections;
|
||||
|
||||
namespace ShrinkEventBus.Entities
|
||||
{
|
||||
/// <summary>Burst-safe producer queue; playback enters the regular Bus instance.</summary>
|
||||
public sealed class ShrinkEcsEventQueue<TEvent> : IDisposable where TEvent : unmanaged, IShrinkEvent
|
||||
{
|
||||
private NativeQueue<TEvent> _queue;
|
||||
|
||||
public ShrinkEcsEventQueue(Allocator allocator = Allocator.Persistent)
|
||||
{
|
||||
_queue = new NativeQueue<TEvent>(allocator);
|
||||
Writer = new ShrinkEcsEventWriter<TEvent>(_queue.AsParallelWriter());
|
||||
}
|
||||
|
||||
public ShrinkEcsEventWriter<TEvent> Writer { get; }
|
||||
|
||||
public int Playback(IShrinkEventBus bus)
|
||||
{
|
||||
if (bus == null)
|
||||
throw new ArgumentNullException(nameof(bus));
|
||||
var count = 0;
|
||||
while (_queue.TryDequeue(out var eventData))
|
||||
{
|
||||
bus.Post(in eventData);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_queue.IsCreated)
|
||||
_queue.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct ShrinkEcsEventWriter<TEvent> where TEvent : unmanaged, IShrinkEvent
|
||||
{
|
||||
private readonly NativeQueue<TEvent>.ParallelWriter _writer;
|
||||
|
||||
internal ShrinkEcsEventWriter(NativeQueue<TEvent>.ParallelWriter writer)
|
||||
{
|
||||
_writer = writer;
|
||||
}
|
||||
|
||||
public void Post(in TEvent eventData) => _writer.Enqueue(eventData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 05a2db7ffefb4841bdcd63d8fd3ac6fe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "ShrinkEventBus.Entities.Runtime",
|
||||
"rootNamespace": "ShrinkEventBus.Entities",
|
||||
"references": [
|
||||
"ShrinkEventBus.Runtime",
|
||||
"Unity.Collections",
|
||||
"Unity.Entities"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": true,
|
||||
"autoReferenced": false
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e31f017584a4a04992bb6fa7538cb2f
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28ac62d48f4f4ba296c67b9b5985d39c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,76 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using NUnit.Framework;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Jobs;
|
||||
|
||||
namespace ShrinkEventBus.Entities.Tests
|
||||
{
|
||||
internal struct EcsPingEvent : IShrinkEvent
|
||||
{
|
||||
public int Value;
|
||||
}
|
||||
|
||||
internal sealed class EcsPingSubscriber : IShrinkGeneratedSubscriber
|
||||
{
|
||||
public int Sum { get; private set; }
|
||||
|
||||
public IDisposable AttachGenerated(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus = null)
|
||||
{
|
||||
return ShrinkGeneratedBinding.Subscribe<EcsPingEvent>(resolver, defaultBus,
|
||||
string.Empty, this, OnPing, ShrinkEventPriority.Normal, 0, false);
|
||||
}
|
||||
|
||||
private void OnPing(EcsPingEvent value) => Sum += value.Value;
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
internal struct PostPingJob : IJob
|
||||
{
|
||||
public ShrinkEcsEventWriter<EcsPingEvent> Writer;
|
||||
|
||||
public void Execute()
|
||||
{
|
||||
Writer.Post(new EcsPingEvent { Value = 11 });
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ShrinkEcsEventQueueTests
|
||||
{
|
||||
[Test]
|
||||
public void NativeWriterPlaybackUsesSameBusChannel()
|
||||
{
|
||||
using var host = new ShrinkEventBusHost();
|
||||
var bus = host.CreateBus(ShrinkBusKey.World("tests"), ShrinkBusOptions.Inline());
|
||||
var subscriber = new EcsPingSubscriber();
|
||||
using var binding = host.Attach(subscriber, ShrinkBusKey.World("tests"));
|
||||
using var queue = new ShrinkEcsEventQueue<EcsPingEvent>(Allocator.TempJob);
|
||||
|
||||
queue.Writer.Post(new EcsPingEvent { Value = 2 });
|
||||
queue.Writer.Post(new EcsPingEvent { Value = 5 });
|
||||
var played = queue.Playback(bus);
|
||||
|
||||
Assert.AreEqual(2, played);
|
||||
Assert.AreEqual(7, subscriber.Sum);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BurstJobWritesThenPlaybackUsesManagedBus()
|
||||
{
|
||||
using var host = new ShrinkEventBusHost();
|
||||
var bus = host.CreateBus(ShrinkBusKey.World("burst-tests"), ShrinkBusOptions.Inline());
|
||||
var subscriber = new EcsPingSubscriber();
|
||||
using var binding = host.Attach(subscriber, ShrinkBusKey.World("burst-tests"));
|
||||
using var queue = new ShrinkEcsEventQueue<EcsPingEvent>(Allocator.TempJob);
|
||||
var job = new PostPingJob { Writer = queue.Writer };
|
||||
|
||||
job.Schedule().Complete();
|
||||
var played = queue.Playback(bus);
|
||||
|
||||
Assert.AreEqual(1, played);
|
||||
Assert.AreEqual(11, subscriber.Sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d3be15e44ff9405e947ecaaac7905527
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "ShrinkEventBus.Entities.Tests",
|
||||
"rootNamespace": "ShrinkEventBus.Entities.Tests",
|
||||
"references": [
|
||||
"ShrinkEventBus.Entities.Runtime",
|
||||
"ShrinkEventBus.Runtime",
|
||||
"Unity.Burst",
|
||||
"Unity.Collections",
|
||||
"Unity.Jobs",
|
||||
"UniTask",
|
||||
"UnityEngine.TestRunner",
|
||||
"UnityEditor.TestRunner"
|
||||
],
|
||||
"includePlatforms": ["Editor"],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"autoReferenced": false,
|
||||
"defineConstraints": ["UNITY_INCLUDE_TESTS"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ef47a51870047fda3659747535d685a
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "com.cneicy.shrink-eventbus-entities",
|
||||
"version": "0.1.0",
|
||||
"displayName": "ShrinkEventBus Entities",
|
||||
"description": "Burst-safe NativeQueue writer and playback adapter for ShrinkEventBus.",
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.cneicy.shrink-eventbus": "2.0.0",
|
||||
"com.unity.entities": "1.0.11"
|
||||
},
|
||||
"keywords": ["eventbus", "entities", "burst", "ecs"],
|
||||
"author": {
|
||||
"name": "cneicy",
|
||||
"url": "https://github.com/cneicy"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 908d154d9ac34f55817ba92f073b3aa8
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,72 @@
|
||||
ShrinkEventBus 2.0 Benchmark Report
|
||||
===================================
|
||||
|
||||
Date: 2026-08-24
|
||||
Unity: 2022.3.62f3
|
||||
Platform: WindowsEditor, Play Mode
|
||||
Host: independent ShrinkEventBusHost, Inline scheduler
|
||||
Diagnostics: independent Host; detailed global capture does not observe it
|
||||
Final run: iter=10,000,000 mass=1,000,000 reg=5,000, three rounds
|
||||
|
||||
Median results
|
||||
--------------
|
||||
|
||||
Post / 0 handlers
|
||||
129,282,649 ops/s
|
||||
|
||||
Post / 1 handler
|
||||
67,249,586 ops/s
|
||||
|
||||
Post / 8 handlers, struct payload
|
||||
35,024,798 ops/s
|
||||
|
||||
Post / 8 handlers, class payload
|
||||
25,321,199 ops/s
|
||||
|
||||
Post / 30 handlers
|
||||
10,823,612 ops/s
|
||||
|
||||
Post / canceled skips 10
|
||||
9,029,517 ops/s
|
||||
|
||||
PostAsync / 1 async handler
|
||||
5,909,446 ops/s
|
||||
|
||||
Generated Attach + Dispose
|
||||
674,291 ops/s
|
||||
|
||||
Struct hot path allocation
|
||||
0 B / 10,000,000 Post operations
|
||||
|
||||
Comparison
|
||||
----------
|
||||
|
||||
Supplied MessagePipe chart, class payload, 8 handlers:
|
||||
25,639,260 publish/s
|
||||
|
||||
ShrinkEventBus 2.0, class payload, 8 handlers:
|
||||
25,321,199 publish/s median
|
||||
|
||||
ShrinkEventBus 2.0, struct payload, 8 handlers:
|
||||
35,024,798 publish/s median
|
||||
|
||||
Supplied ShrinkEventBus 1.3.0 sample:
|
||||
0 handlers: 8,971,378 -> 129,282,649 ops/s
|
||||
1 handler: 6,132,242 -> 67,249,586 ops/s
|
||||
30 handlers: 258,805 -> 10,823,612 ops/s
|
||||
canceled skips 10: 1,894,776 -> 9,029,517 ops/s
|
||||
1 async handler: 1,168,634 -> 5,909,446 ops/s
|
||||
instance scan/register: 76,353 -> 674,291 generated Attach/s
|
||||
|
||||
Implementation notes
|
||||
--------------------
|
||||
|
||||
- Inline buses use a dedicated implementation so the JIT can inline the channel dispatch path.
|
||||
- Non-cancelable, all-sync channels build a priority-ordered multicast dispatcher when subscriptions change.
|
||||
- ShrinkPostResult stores Accepted/Handled/Canceled/Failure in one 32-bit value.
|
||||
- Generic bus slots and handler snapshots are published with release semantics and read without a publish lock.
|
||||
- Mixed sync/async channels, cancelable events, queued schedulers and PostAsync retain the full semantic path.
|
||||
|
||||
Results are local editor measurements, not cross-device guarantees. The supplied MessagePipe chart was
|
||||
produced by a different runtime and machine; the class-payload comparison only establishes the same
|
||||
order of throughput. Recheck on the target Player and hardware before release.
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e379caaacb945699a297286798bc4e2
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2cc73e57d6ec4457a425843f734b6a70
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "ShrinkEventBus.Benchmark",
|
||||
"rootNamespace": "ShrinkEventBus.Benchmark",
|
||||
"references": [
|
||||
"ShrinkEventBus.Runtime",
|
||||
"UniTask"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"autoReferenced": true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4488cfe64b374f30b8081ce2f10b1ed5
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,282 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkEventBus.Benchmark
|
||||
{
|
||||
public static class ShrinkEventBusBenchmark
|
||||
{
|
||||
private readonly struct BenchmarkEvent : IShrinkEvent
|
||||
{
|
||||
public BenchmarkEvent(int value) => Value = value;
|
||||
public int Value { get; }
|
||||
}
|
||||
|
||||
private sealed class BenchmarkClassEvent : IShrinkEvent
|
||||
{
|
||||
public int Value { get; set; }
|
||||
}
|
||||
|
||||
private sealed class CanceledBenchmarkEvent : IShrinkCancelableEvent
|
||||
{
|
||||
public bool IsCanceled { get; private set; } = true;
|
||||
public void SetCanceled(bool value) => IsCanceled = value;
|
||||
}
|
||||
|
||||
[ShrinkEventSubscriber]
|
||||
private sealed class OneHandler
|
||||
{
|
||||
[ShrinkSubscribe(Bus = "benchmark:one")]
|
||||
private void H01(BenchmarkEvent value) => Consume(value);
|
||||
}
|
||||
|
||||
[ShrinkEventSubscriber]
|
||||
private sealed class EightHandlers
|
||||
{
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight")] private void H01(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight")] private void H02(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight")] private void H03(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight")] private void H04(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight")] private void H05(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight")] private void H06(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight")] private void H07(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight")] private void H08(BenchmarkEvent value) => Consume(value);
|
||||
}
|
||||
|
||||
[ShrinkEventSubscriber]
|
||||
private sealed class EightClassHandlers
|
||||
{
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H01(BenchmarkClassEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H02(BenchmarkClassEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H03(BenchmarkClassEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H04(BenchmarkClassEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H05(BenchmarkClassEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H06(BenchmarkClassEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H07(BenchmarkClassEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H08(BenchmarkClassEvent value) => Consume(value);
|
||||
}
|
||||
|
||||
[ShrinkEventSubscriber]
|
||||
private sealed class ThirtyHandlers
|
||||
{
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H01(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H02(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H03(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H04(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H05(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H06(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H07(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H08(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H09(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H10(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H11(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H12(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H13(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H14(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H15(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H16(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H17(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H18(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H19(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H20(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H21(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H22(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H23(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H24(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H25(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H26(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H27(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H28(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H29(BenchmarkEvent value) => Consume(value);
|
||||
[ShrinkSubscribe(Bus = "benchmark:thirty")] private void H30(BenchmarkEvent value) => Consume(value);
|
||||
}
|
||||
|
||||
[ShrinkEventSubscriber]
|
||||
private sealed class CanceledHandlers
|
||||
{
|
||||
[ShrinkSubscribe(Bus = "benchmark:canceled")] private void H01(CanceledBenchmarkEvent value) => _sink++;
|
||||
[ShrinkSubscribe(Bus = "benchmark:canceled")] private void H02(CanceledBenchmarkEvent value) => _sink++;
|
||||
[ShrinkSubscribe(Bus = "benchmark:canceled")] private void H03(CanceledBenchmarkEvent value) => _sink++;
|
||||
[ShrinkSubscribe(Bus = "benchmark:canceled")] private void H04(CanceledBenchmarkEvent value) => _sink++;
|
||||
[ShrinkSubscribe(Bus = "benchmark:canceled")] private void H05(CanceledBenchmarkEvent value) => _sink++;
|
||||
[ShrinkSubscribe(Bus = "benchmark:canceled")] private void H06(CanceledBenchmarkEvent value) => _sink++;
|
||||
[ShrinkSubscribe(Bus = "benchmark:canceled")] private void H07(CanceledBenchmarkEvent value) => _sink++;
|
||||
[ShrinkSubscribe(Bus = "benchmark:canceled")] private void H08(CanceledBenchmarkEvent value) => _sink++;
|
||||
[ShrinkSubscribe(Bus = "benchmark:canceled")] private void H09(CanceledBenchmarkEvent value) => _sink++;
|
||||
[ShrinkSubscribe(Bus = "benchmark:canceled")] private void H10(CanceledBenchmarkEvent value) => _sink++;
|
||||
}
|
||||
|
||||
[ShrinkEventSubscriber]
|
||||
private sealed class AsyncHandler
|
||||
{
|
||||
[ShrinkSubscribe(Bus = "benchmark:async")]
|
||||
private UniTask Handle(BenchmarkEvent value, CancellationToken cancellationToken)
|
||||
{
|
||||
Consume(value);
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[ShrinkEventSubscriber]
|
||||
private sealed class RegistrationHandler
|
||||
{
|
||||
[ShrinkSubscribe(Bus = "benchmark:registration")]
|
||||
private void Handle(BenchmarkEvent value) => Consume(value);
|
||||
}
|
||||
|
||||
private static int _sink;
|
||||
|
||||
public static string Run(int iterations = 1_000_000, int massIterations = 10_000,
|
||||
int registrationIterations = 5_000)
|
||||
{
|
||||
var keys = new[]
|
||||
{
|
||||
new ShrinkBusKey("benchmark", "empty"),
|
||||
new ShrinkBusKey("benchmark", "one"),
|
||||
new ShrinkBusKey("benchmark", "eight"),
|
||||
new ShrinkBusKey("benchmark", "eight-class"),
|
||||
new ShrinkBusKey("benchmark", "thirty"),
|
||||
new ShrinkBusKey("benchmark", "canceled"),
|
||||
new ShrinkBusKey("benchmark", "async"),
|
||||
new ShrinkBusKey("benchmark", "registration")
|
||||
};
|
||||
using var host = new ShrinkEventBusHost();
|
||||
var empty = host.CreateBus(keys[0], ShrinkBusOptions.Inline());
|
||||
var one = host.CreateBus(keys[1], ShrinkBusOptions.Inline());
|
||||
var eight = host.CreateBus(keys[2], ShrinkBusOptions.Inline());
|
||||
var eightClass = host.CreateBus(keys[3], ShrinkBusOptions.Inline());
|
||||
var thirty = host.CreateBus(keys[4], ShrinkBusOptions.Inline());
|
||||
var canceled = host.CreateBus(keys[5], ShrinkBusOptions.Inline());
|
||||
var asyncBus = host.CreateBus(keys[6], ShrinkBusOptions.Inline());
|
||||
host.CreateBus(keys[7], ShrinkBusOptions.Inline());
|
||||
|
||||
using var oneBinding = host.Attach(new OneHandler());
|
||||
using var eightBinding = host.Attach(new EightHandlers());
|
||||
using var eightClassBinding = host.Attach(new EightClassHandlers());
|
||||
using var thirtyBinding = host.Attach(new ThirtyHandlers());
|
||||
using var canceledBinding = host.Attach(new CanceledHandlers());
|
||||
using var asyncBinding = host.Attach(new AsyncHandler());
|
||||
|
||||
var report = new StringBuilder(2048);
|
||||
report.AppendLine("ShrinkEventBus 2.0 Benchmark Report");
|
||||
report.AppendLine($"iter={iterations} mass={massIterations} reg={registrationIterations}");
|
||||
report.AppendLine($"Unity {Application.unityVersion}, {Application.platform}, PlayMode={Application.isPlaying}");
|
||||
report.AppendLine();
|
||||
|
||||
var value = new BenchmarkEvent(1);
|
||||
var classValue = new BenchmarkClassEvent { Value = 1 };
|
||||
var canceledValue = new CanceledBenchmarkEvent();
|
||||
Warmup(empty, one, eight, eightClass, thirty, canceled,
|
||||
value, classValue, canceledValue);
|
||||
|
||||
MeasurePost(report, "Post / 0 handlers", iterations, empty, in value);
|
||||
MeasurePost(report, "Post / 1 handler", iterations, one, in value);
|
||||
MeasurePost(report, "Post / 8 handlers", massIterations, eight, in value);
|
||||
MeasurePost(report, "Post / 8 handlers class", massIterations, eightClass, in classValue);
|
||||
MeasurePost(report, "Post / 30 handlers", massIterations, thirty, in value);
|
||||
MeasurePost(report, "Post / canceled skips 10", massIterations,
|
||||
canceled, in canceledValue);
|
||||
MeasureAsync(report, "PostAsync / 1 async handler", massIterations,
|
||||
() => asyncBus.PostAsync(value));
|
||||
Measure(report, "Generated Attach + Dispose", registrationIterations, () =>
|
||||
{
|
||||
host.Attach(new RegistrationHandler()).Dispose();
|
||||
});
|
||||
|
||||
var before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
one.Post(in value);
|
||||
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
report.AppendLine($"Post / struct hot path allocation : {allocated} B / {iterations} ops");
|
||||
report.AppendLine($"sink={_sink}");
|
||||
|
||||
var result = report.ToString();
|
||||
UnityEngine.Debug.Log(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void Warmup(IShrinkEventBus empty, IShrinkEventBus one,
|
||||
IShrinkEventBus eight, IShrinkEventBus eightClass, IShrinkEventBus thirty,
|
||||
IShrinkEventBus canceled, BenchmarkEvent value, BenchmarkClassEvent classValue,
|
||||
CanceledBenchmarkEvent canceledValue)
|
||||
{
|
||||
for (var i = 0; i < 10_000; i++)
|
||||
{
|
||||
empty.Post(in value);
|
||||
one.Post(in value);
|
||||
eight.Post(in value);
|
||||
eightClass.Post(classValue);
|
||||
thirty.Post(in value);
|
||||
canceled.Post(canceledValue);
|
||||
}
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
}
|
||||
|
||||
private static void Measure(StringBuilder report, string name, int iterations, Action action)
|
||||
{
|
||||
var gen0 = GC.CollectionCount(0);
|
||||
var beforeBytes = GC.GetAllocatedBytesForCurrentThread();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
action();
|
||||
stopwatch.Stop();
|
||||
AppendResult(report, name, iterations, stopwatch.Elapsed.TotalMilliseconds,
|
||||
GC.GetAllocatedBytesForCurrentThread() - beforeBytes,
|
||||
GC.CollectionCount(0) - gen0);
|
||||
}
|
||||
|
||||
private static void MeasurePost<TEvent>(StringBuilder report, string name, int iterations,
|
||||
IShrinkEventBus bus, in TEvent eventData) where TEvent : IShrinkEvent
|
||||
{
|
||||
var gen0 = GC.CollectionCount(0);
|
||||
var beforeBytes = GC.GetAllocatedBytesForCurrentThread();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
bus.Post(in eventData);
|
||||
stopwatch.Stop();
|
||||
AppendResult(report, name, iterations, stopwatch.Elapsed.TotalMilliseconds,
|
||||
GC.GetAllocatedBytesForCurrentThread() - beforeBytes,
|
||||
GC.CollectionCount(0) - gen0);
|
||||
}
|
||||
|
||||
private static void MeasureAsync(StringBuilder report, string name, int iterations,
|
||||
Func<UniTask<ShrinkPostResult>> action)
|
||||
{
|
||||
var gen0 = GC.CollectionCount(0);
|
||||
var beforeBytes = GC.GetAllocatedBytesForCurrentThread();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
action().GetAwaiter().GetResult();
|
||||
stopwatch.Stop();
|
||||
AppendResult(report, name, iterations, stopwatch.Elapsed.TotalMilliseconds,
|
||||
GC.GetAllocatedBytesForCurrentThread() - beforeBytes,
|
||||
GC.CollectionCount(0) - gen0);
|
||||
}
|
||||
|
||||
private static void AppendResult(StringBuilder report, string name, int iterations,
|
||||
double milliseconds, long allocatedBytes, int gen0Collections)
|
||||
{
|
||||
var microseconds = milliseconds * 1000d / iterations;
|
||||
var throughput = iterations / (milliseconds / 1000d);
|
||||
report.AppendLine($"{name,-34} {milliseconds,10:F3} ms {microseconds,9:F4} us/op " +
|
||||
$"{throughput,12:F0} ops/s alloc={allocatedBytes} B gen0={gen0Collections}");
|
||||
}
|
||||
|
||||
private static void Consume(BenchmarkEvent value)
|
||||
{
|
||||
_sink = unchecked(_sink + value.Value);
|
||||
}
|
||||
|
||||
private static void Consume(BenchmarkClassEvent value)
|
||||
{
|
||||
_sink = unchecked(_sink + value.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76b9239259544977bd27221d3607aed1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a41c52d2d40c42479d1f1dfd2d35ae3c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
```
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user