From 1f9ef236bcab34b8122a3c01508f407784b4853a Mon Sep 17 00:00:00 2001 From: Allan Ritchie Date: Sun, 9 Jun 2024 12:58:16 -0400 Subject: [PATCH] Better attributes and discovery - point to docs --- readme.md | 191 +++--------------- src/Shiny.Mediator.Maui/Attributes.cs | 48 +++++ .../Middleware/CacheRequestMiddleware.cs | 71 ++----- .../Middleware/MainTheadEventMiddleware.cs | 3 - .../TimedLoggingRequestMiddleware.cs | 7 - ...rNotificationExceptionRequestMiddleware.cs | 8 - .../Shiny.Mediator.Maui.csproj | 1 - src/Shiny.Mediator/Shiny.Mediator.csproj | 11 +- ...Package.targets => Shiny.Mediator.targets} | 2 +- .../CacheRequestMiddlewareTests.cs | 10 +- 10 files changed, 101 insertions(+), 251 deletions(-) create mode 100644 src/Shiny.Mediator.Maui/Attributes.cs rename src/Shiny.Mediator/{build/Package.targets => Shiny.Mediator.targets} (93%) diff --git a/readme.md b/readme.md index 9180b9b..81b7f8f 100644 --- a/readme.md +++ b/readme.md @@ -1,179 +1,42 @@ # Shiny Mediator (Preview) -## PLEASE NOTE - THIS IS AN ALPHA. THINGS ARE LIKELY TO CHANGE - -A mediator pattern, but for apps. Apps have pages with lifecycles that don't necessarily participate in the standard -dependency injection lifecycle. .NET MAUI generally tends to favor the Messenger pattern. We hate this pattern for many reasons +Mediator is a behavioral design pattern that lets you reduce chaotic dependencies between objects. The pattern restricts direct communications between the objects and forces them to collaborate only via a mediator object. + +Shiny Mediator is a mediator pattern implementation, but for apps. Apps have pages with lifecycles that don't necessarily participate in the standard +dependency injection lifecycle. .NET MAUI generally tends to favor the Messenger pattern. We hate this pattern for many reasons which we won't get into. That being said, we do offer a messenger subscription in our Mediator for where interfaces and dependency injection can't reach. This project is heavily inspired by [MediatR](https://github.com/jbogard/mediatr) with some lesser features that we feel were aimed more at server scenarios, while also adding some features we feel benefit apps +## Links +- Docs + - [Main](https://shinylib.net/client/mediator/) + - [Quick Start](https://shinylib.net/client/mediator/quick-start/) + - [Middleware](http://localhost:4321/client/mediator/middleware) + - [Advanced](http://localhost:4321/client/mediator/advanced/) +- [Sample](https://github.com/shinyorg/mediator/tree/main/Sample) + ## Features -* A Mediator for your .NET Apps (MAUI & Blazor are the main targets for us) -* Request & event middleware with some great "out of the box" scenarios for your app -* Think of "weak" message subscriptions without the fuss or mess to cleanup -* Our MAUI & Blazor integrations allow your viewmodels or pages to implement an IEventHandler interface(s) without them having to participate in the dependency injection provider -* We still have a "messagingcenter" type subscribe off IMediator for cases where you can't have your current type implement an interface -* Instead of Assembly Scanning, we have source generators to automatically wireup the necessary registrations for you! (WIP) -* Lightweight, No external dependencies, tiny bit of reflection -* Help remove service overrun and reduce your constructor fat -* Easy to Unit Test +- A Mediator for your .NET Apps (MAUI & Blazor are the main targets for us) +- Request/Response "Command" Handling +- Event Publication +- Request & event middleware with some great "out of the box" scenarios for your app +- Think of "weak" message subscriptions without the fuss or mess to cleanup +- Our MAUI & Blazor integrations allow your viewmodels or pages to implement an IEventHandler interface(s) without them having to participate in the dependency injection provider +- We still have a "messagingcenter" type subscribe off IMediator for cases where you can't have your current type implement an interface +- Instead of Assembly Scanning, we have source generators to automatically wireup the necessary registrations for you! (WIP) +- Lightweight, No external dependencies, tiny bit of reflection +- Help remove service overrun and reduce your constructor fat +- Easy to Unit Test ## Works With -* .NET MAUI - all platforms -* MVVM Frameworks like Prism, ReactiveUI, & .NET MAUI Shell -* Blazor - Work In Progress -* Any other .NET platform - but you'll have to come up with your own "event collector" for the out-of-state stuff - -## Getting Started - -Install [Shiny.Mediator](https://www.nuget.org/packages/Shiny.Mediator) from NuGet - -First, let's create our request & event handlers - -``` -using Shiny.Mediator; - -public record TestRequest(string AnyArgs, int YouWant) : IRequest; -public record TestEvent(MyObject AnyArgs) : IEvent; - -// and for request/response requests - we'll come back to this -public record TestResponseRequest : IRequest {} -public record TestResponse {} -``` - -Next - let's wire up a RequestHandler. You can have ONLY 1 request handler per request type. -This is where you would do the main business logic or data requests. - -Let's create our RequestHandler - -```csharp -using Shiny.Mediator; - -// NOTE: Request handlers are registered as singletons -public class TestRequestHandler : IRequestHandler -{ - // you can do all dependency injection here - public async Task Handle(TestRequest request, CancellationToken ct) - { - // do something async here - } -} - -public class TestResponseRequestHandler : IRequestHandler -{ - public async Task Handle(TestResponseRequest request, CancellationToken ct) - { - var response = await GetResponseThing(ct); - return response; - } -} - -public class TestEventHandler : IEventHandler -{ - // Dependency injection works here - public async Task Handle(TestEvent @event, CancellationToken ct) - { - // Do something async here - } -} -``` - -Now, let's register all of our stuff with our .NET MAUI MauiProgram.cs - -```csharp -public static class MauiProgram -{ - public static MauiApp CreateMauiApp() - { - var builder = MauiApp - .CreateBuilder() - .UseMauiApp(); - - builder.Services.AddShinyMediator(); - builder.Services.AddSingletonAsImplementedInterfaces(); - builder.Services.AddSingletonAsImplementedInterfaces(); - builder.Services.AddSingltonAsImplementedInterfaces(); - // OR if you're using our attribute for source generation - } -} -``` - -Lastly, any model model/viewmodel/etc participating in dependency injection can now inject the mediator - -``` -public class MyViewModel(Shiny.Mediator.IMediator mediator) -{ - public async Task Execute() - { - await mediator.Send(new TestRequest()); // this will execute TestRequestHandler - var response = await mediator.Request(new TestResponseRequest()); // this will execute TestResponseRequestHandler and return a value - - // this will publish to any service registered that implement IEventHandler - // there are additional args here to allow you to execute values in sequentially or wait for all events to complete - await mediator.Publish(new TestEvent()); - } -} -``` - -### What about my ViewModels? - -For .NET MAUI, your viewmodels have the ability to participate in the event publishing chain without being part of dependency injection - -With this setup, you don't need to worry about deallocating any events, unsubscribing from some service, or hooking to anything. - -Lastly, if your page/viewmodel is navigated away from (popped), it will no longer participate in the event broadcast - -First, let's install [Shiny.Mediator.Maui](https://www.nuget.org/packages/Shiny.Mediator.Maui) -Now...let's go back to our MauiProgram.cs and alter the AddShinyMediator - -```csharp -builder.Services.AddShinyMediator(cfg => cfg.UseMaui()); -``` - -Now your viewmodel (or page) can simply implement the IEventHandler interface to participate - -NOTE: Further example to below - you can implement multiple event handlers (or request handlers) - -```csharp -public class MyViewModel : BaseViewModel, - Shiny.Mediator.IEventHandler, - Shiny.Mediator.IEventHandler -{ - public async Task Handle(TestEvent @event, CancellationToken ct) - { - } - - public async Task Handle(TestEvent2 @event, CancellationToken ct) - { - } -} -``` - -## Sample -There is a sample in this repo. You do not need any other part of Shiny, Prism, ReactiveUI, etc - those are included as I write things faster with it. -Focus on the interfaces from the mediator & the mediator calls itself - -## Ideas for Workflows Request & Events -* Use Prism with Modules - want strongly typed navigation parameters, navigations, and have them available to other modules - we're the guy to help! - * Example TBD -* Using a Shiny Foreground Job - want to push data an event that new data came in to anyone listening? -* Have a Shiny Push Delegate that is executing on the delegate but want to push it to the UI, Mediator has a plan! - - -## TODO -* Document - * Event Collectors - * Request Middleware - * Covariance Event Handlers & Request Middleware - Dependency Injection Support - nothing to do with the library - * OOBE Middleware - easy, not feature rich - use as a template once you outgrow them -* Event Collectors for MAUI execute on main thread? -* Streams - IAsyncEnumerable or IObservable -* Source Generator Registration - * Need to use a different method or not use extension methods - maybe AddHandlersFromAssemblyName or allow it to be custom named +- .NET MAUI - all platforms +- MVVM Frameworks like Prism, ReactiveUI, & .NET MAUI Shell +- Blazor - Work In Progress +- Any other .NET platform - but you'll have to come up with your own "event collector" for the out-of-state stuff \ No newline at end of file diff --git a/src/Shiny.Mediator.Maui/Attributes.cs b/src/Shiny.Mediator.Maui/Attributes.cs new file mode 100644 index 0000000..19a5d01 --- /dev/null +++ b/src/Shiny.Mediator.Maui/Attributes.cs @@ -0,0 +1,48 @@ +namespace Shiny.Mediator; + +public enum StoreType +{ + File, + Memory +} + +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public class CacheAttribute : Attribute +{ + /// + /// Setting this to true will tell the middleware to always passthrough to the server if the app detects online connectivity + /// False will follow the standard expiration + /// + public bool OnlyForOffline { get; set; } + + /// + /// Max age of a cache item + /// + public int MaxAgeSeconds { get; set; } + + /// + /// You can store in-memory which means the data is not available in subsequent starts OR file where it available across + /// application sessions + /// + public StoreType Storage { get; set; } +} + + +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public class MainThreadAttribute : Attribute {} + + +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public class TimedLoggingAttribute : Attribute +{ + public double ErrorThresholdMillis { get; set; } = 0; +} + + +public class UserExceptionRequestMiddlewareConfig +{ + public bool ShowFullException { get; set; } + public string ErrorMessage { get; set; } = "We're sorry. An error has occurred"; + public string ErrorTitle { get; set; } = "Error"; + public string ErrorConfirm { get; set; } = "OK"; +} \ No newline at end of file diff --git a/src/Shiny.Mediator.Maui/Middleware/CacheRequestMiddleware.cs b/src/Shiny.Mediator.Maui/Middleware/CacheRequestMiddleware.cs index 92a8dd5..a0d1e51 100644 --- a/src/Shiny.Mediator.Maui/Middleware/CacheRequestMiddleware.cs +++ b/src/Shiny.Mediator.Maui/Middleware/CacheRequestMiddleware.cs @@ -1,25 +1,26 @@ +using System.Reflection; using System.Text.Json; -using Microsoft.Extensions.Configuration; namespace Shiny.Mediator.Middleware; public class CacheRequestMiddleware( - IConfiguration configuration, IConnectivity connectivity, IFileSystem fileSystem ) : IRequestMiddleware where TRequest : IRequest // IRequestHandler, // IRequestHandler { + readonly Dictionary> memCache = new(); + + public async Task Process(TRequest request, RequestHandlerDelegate next, IRequestHandler requestHandler, CancellationToken cancellationToken) { // no caching for void requests if (typeof(TResult) == typeof(Unit)) return await next().ConfigureAwait(false); - // no config no cache - TODO: could consider ICacheItem taking default values - var cfg = GetConfiguration(request); + var cfg = requestHandler.GetType().GetCustomAttribute(); if (cfg == null) return await next().ConfigureAwait(false); @@ -50,18 +51,18 @@ public async Task Process(TRequest request, RequestHandlerDelegate item, CacheConfiguration cfg) + protected virtual bool IsExpired(CachedItem item, CacheAttribute cfg) { - if (cfg.MaxAge == null) + if (cfg.MaxAgeSeconds <= 0) return false; - var expiry = item.CreatedOn.Add(cfg.MaxAge.Value); + var expiry = item.CreatedOn.Add(TimeSpan.FromSeconds(cfg.MaxAgeSeconds)); var expired = expiry < DateTimeOffset.UtcNow; return expired; } - protected virtual async Task GetAndStore(TRequest request, RequestHandlerDelegate next, CacheConfiguration cfg) + protected virtual async Task GetAndStore(TRequest request, RequestHandlerDelegate next, CacheAttribute cfg) { var result = await next().ConfigureAwait(false); this.Store(request, result, cfg); @@ -86,7 +87,7 @@ protected virtual string GetCacheFilePath(TRequest request) } - protected virtual void Store(TRequest request, TResult result, CacheConfiguration cfg) + protected virtual void Store(TRequest request, TResult result, CacheAttribute cfg) { switch (cfg.Storage) { @@ -110,7 +111,7 @@ protected virtual void Store(TRequest request, TResult result, CacheConfiguratio } - protected virtual CachedItem? GetFromStore(TRequest request, CacheConfiguration cfg) + protected virtual CachedItem? GetFromStore(TRequest request, CacheAttribute cfg) { CachedItem? returnValue = null; @@ -143,44 +144,15 @@ protected virtual void Store(TRequest request, TResult result, CacheConfiguratio return returnValue; } - - - readonly Dictionary> memCache = new(); - readonly object syncLock = new(); - CacheConfiguration[]? configurations; - - - protected virtual CacheConfiguration? GetConfiguration(TRequest request) - { - var type = request.GetType(); - var key = $"{type.Namespace}.{type.Name}"; - - if (this.configurations == null) - { - lock (this.syncLock) - { - this.configurations = configuration - .GetSection("Cache") - .Get(); - } - } - - var cfg = this.configurations? - .FirstOrDefault(x => x - .RequestType - .Equals( - key, - StringComparison.InvariantCultureIgnoreCase - ) - ); - - return cfg; - } } // public record FlushCacheItemRequest(Type RequestType) : IRequest; // public record FlushAllCacheRequest : IRequest; +/// +/// Implementing this interface will allow you to create your own cache key, otherwise the cache key is based on the name +/// of the request model +/// public interface ICacheItem { string CacheKey { get; } @@ -191,16 +163,3 @@ public record CachedItem( T Value ); -public enum StoreType -{ - File, - Memory -} - -public class CacheConfiguration -{ - public string RequestType { get; set; } - public bool OnlyForOffline { get; set; } - public TimeSpan? MaxAge { get; set; } - public StoreType Storage { get; set; } -} \ No newline at end of file diff --git a/src/Shiny.Mediator.Maui/Middleware/MainTheadEventMiddleware.cs b/src/Shiny.Mediator.Maui/Middleware/MainTheadEventMiddleware.cs index d0c1697..4374a62 100644 --- a/src/Shiny.Mediator.Maui/Middleware/MainTheadEventMiddleware.cs +++ b/src/Shiny.Mediator.Maui/Middleware/MainTheadEventMiddleware.cs @@ -4,9 +4,6 @@ namespace Shiny.Mediator.Middleware; -[AttributeUsage(AttributeTargets.Method, Inherited = false)] -public class MainThreadAttribute : Attribute {} - public class MainTheadEventMiddleware : IEventMiddleware where TEvent : IEvent { public async Task Process(IEvent @event, EventHandlerDelegate next, IEventHandler eventHandler, CancellationToken cancellationToken) diff --git a/src/Shiny.Mediator.Maui/Middleware/TimedLoggingRequestMiddleware.cs b/src/Shiny.Mediator.Maui/Middleware/TimedLoggingRequestMiddleware.cs index 98b963c..8ce678f 100644 --- a/src/Shiny.Mediator.Maui/Middleware/TimedLoggingRequestMiddleware.cs +++ b/src/Shiny.Mediator.Maui/Middleware/TimedLoggingRequestMiddleware.cs @@ -5,13 +5,6 @@ namespace Shiny.Mediator.Middleware; -[AttributeUsage(AttributeTargets.Class, Inherited = false)] -public class TimedLoggingAttribute : Attribute -{ - public double ErrorThresholdMillis { get; set; } = 0; -} - - public class TimedLoggingRequestMiddleware(ILogger logger) : IRequestMiddleware where TRequest : IRequest { public async Task Process(TRequest request, RequestHandlerDelegate next, IRequestHandler requestHandler, CancellationToken cancellationToken) diff --git a/src/Shiny.Mediator.Maui/Middleware/UserNotificationExceptionRequestMiddleware.cs b/src/Shiny.Mediator.Maui/Middleware/UserNotificationExceptionRequestMiddleware.cs index 80e4e8e..09db4ad 100644 --- a/src/Shiny.Mediator.Maui/Middleware/UserNotificationExceptionRequestMiddleware.cs +++ b/src/Shiny.Mediator.Maui/Middleware/UserNotificationExceptionRequestMiddleware.cs @@ -3,14 +3,6 @@ namespace Shiny.Mediator.Middleware; -public class UserExceptionRequestMiddlewareConfig -{ - public bool ShowFullException { get; set; } - public string ErrorMessage { get; set; } = "We're sorry. An error has occurred"; - public string ErrorTitle { get; set; } = "Error"; - public string ErrorConfirm { get; set; } = "OK"; -} - public class UserExceptionRequestMiddleware(ILogger logger, UserExceptionRequestMiddlewareConfig config) : IRequestMiddleware where TRequest : IRequest { public async Task Process(TRequest request, RequestHandlerDelegate next, IRequestHandler requestHandler, CancellationToken cancellationToken) diff --git a/src/Shiny.Mediator.Maui/Shiny.Mediator.Maui.csproj b/src/Shiny.Mediator.Maui/Shiny.Mediator.Maui.csproj index c30d876..215dbce 100644 --- a/src/Shiny.Mediator.Maui/Shiny.Mediator.Maui.csproj +++ b/src/Shiny.Mediator.Maui/Shiny.Mediator.Maui.csproj @@ -7,7 +7,6 @@ - diff --git a/src/Shiny.Mediator/Shiny.Mediator.csproj b/src/Shiny.Mediator/Shiny.Mediator.csproj index 9ac4e88..7037b1d 100644 --- a/src/Shiny.Mediator/Shiny.Mediator.csproj +++ b/src/Shiny.Mediator/Shiny.Mediator.csproj @@ -8,9 +8,10 @@ - - - + + @@ -23,4 +24,8 @@ PackagePath="analyzers/dotnet/cs" Visible="false" /> + + + + diff --git a/src/Shiny.Mediator/build/Package.targets b/src/Shiny.Mediator/Shiny.Mediator.targets similarity index 93% rename from src/Shiny.Mediator/build/Package.targets rename to src/Shiny.Mediator/Shiny.Mediator.targets index 0378f00..a8c55c2 100644 --- a/src/Shiny.Mediator/build/Package.targets +++ b/src/Shiny.Mediator/Shiny.Mediator.targets @@ -2,4 +2,4 @@ - \ No newline at end of file + diff --git a/tests/Shiny.Mediator.Tests/CacheRequestMiddlewareTests.cs b/tests/Shiny.Mediator.Tests/CacheRequestMiddlewareTests.cs index 0266991..0b8f689 100644 --- a/tests/Shiny.Mediator.Tests/CacheRequestMiddlewareTests.cs +++ b/tests/Shiny.Mediator.Tests/CacheRequestMiddlewareTests.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.Configuration; using Shiny.Mediator.Middleware; namespace Shiny.Mediator.Tests; @@ -10,15 +9,9 @@ public async Task EndToEnd() { var conn = new MockConnectivity(); var fs = new MockFileSystem(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Cache:Shiny.Mediator.Tests.CacheRequest"] = "" - }) - .Build(); var handler = new CacheRequestHandler(); - var middleware = new CacheRequestMiddleware(configuration, conn, fs); + var middleware = new CacheRequestMiddleware(conn, fs); // TODO: test with ICacheItem var request = new CacheRequest(); @@ -50,6 +43,7 @@ public async Task EndToEnd() public record CacheRequest : IRequest; public record CacheResult(long Ticks); +[Cache(MaxAgeSeconds = 5, Storage = StoreType.Memory, OnlyForOffline = false)] public class CacheRequestHandler : IRequestHandler { public Task Handle(CacheRequest request, CancellationToken cancellationToken)