From 8ac4513b61a33750306859ca66c700c3a10d6b91 Mon Sep 17 00:00:00 2001 From: Allan Ritchie Date: Tue, 1 Oct 2024 22:57:45 -0400 Subject: [PATCH] Vastly improved and simplified offline support services --- .../AppSupportExtensions.cs | 4 + .../Infrastructure/IOfflineService.cs | 137 ++++++++++++++++++ .../Infrastructure/IStorageService.cs | 8 +- .../Infrastructure/SerializerService.cs | 0 .../OfflineAvailableRequestMiddleware.cs | 37 ++--- .../Middleware/ReplayStreamMiddleware.cs | 9 +- .../Infrastructure/StorageService.cs | 29 +--- .../Infrastructure/StorageService.cs | 101 ++----------- .../OfflineAvailableMiddlewareTests.cs | 27 +++- 9 files changed, 210 insertions(+), 142 deletions(-) create mode 100644 src/Shiny.Mediator.AppSupport/Infrastructure/IOfflineService.cs rename src/{Shiny.Mediator.Maui => Shiny.Mediator.AppSupport}/Infrastructure/SerializerService.cs (100%) diff --git a/src/Shiny.Mediator.AppSupport/AppSupportExtensions.cs b/src/Shiny.Mediator.AppSupport/AppSupportExtensions.cs index bf132f9..c27b5a0 100644 --- a/src/Shiny.Mediator.AppSupport/AppSupportExtensions.cs +++ b/src/Shiny.Mediator.AppSupport/AppSupportExtensions.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using Shiny.Mediator.Infrastructure; using Shiny.Mediator.Middleware; namespace Shiny.Mediator; @@ -39,6 +41,8 @@ public static ShinyConfigurator AddUserErrorNotificationsRequestMiddleware(this /// public static ShinyConfigurator AddOfflineAvailabilityMiddleware(this ShinyConfigurator cfg) { + cfg.Services.TryAddSingleton(); + cfg.Services.TryAddSingleton(); cfg.Services.AddSingletonAsImplementedInterfaces(); cfg.AddOpenRequestMiddleware(typeof(OfflineAvailableRequestMiddleware<,>)); diff --git a/src/Shiny.Mediator.AppSupport/Infrastructure/IOfflineService.cs b/src/Shiny.Mediator.AppSupport/Infrastructure/IOfflineService.cs new file mode 100644 index 0000000..d996c45 --- /dev/null +++ b/src/Shiny.Mediator.AppSupport/Infrastructure/IOfflineService.cs @@ -0,0 +1,137 @@ +namespace Shiny.Mediator.Infrastructure; + + +public interface IOfflineService +{ + Task Set(object request, object result); + Task?> Get(object request); + Task ClearByType(Type requestType); + Task ClearByRequest(object request); + Task Clear(); +} + +public record OfflineResult( + string RequestKey, + DateTimeOffset Timestamp, + TResult Value +); + +public class OfflineService(IStorageService storage, ISerializerService serializer) : IOfflineService +{ + public async Task Set(object request, object result) + { + var requestKey = this.GetRequestKey(request); + await this.DoTransaction(dict => + { + dict[requestKey] = new OfflineStore( + this.GetTypeKey(request.GetType()), + requestKey, + DateTimeOffset.UtcNow, + serializer.Serialize(result) + ); + return true; + }); + return requestKey; + } + + + public async Task?> Get(object request) + { + OfflineResult? result = null; + await this + .DoTransaction(dict => + { + var requestKey = this.GetRequestKey(request); + if (dict.TryGetValue(requestKey, out var store)) + { + var jsonObj = serializer.Deserialize(store.Json); + result = new OfflineResult(store.RequestKey, store.Timestamp, jsonObj); + } + return false; + }) + .ConfigureAwait(false); + + return result; + } + + + public Task ClearByType(Type requestType) => this.DoTransaction(dict => + { + var typeKey = this.GetTypeKey(requestType); + var keys = dict + .Where(x => x.Value.TypeName == typeKey) + .Select(x => x.Key) + .ToList(); + + if (keys.Count == 0) + return false; + + foreach (var key in keys) + dict.Remove(key); + + return false; + }); + + + public Task ClearByRequest(object request) => this.DoTransaction(dict => + { + var requestKey = this.GetRequestKey(request); + if (dict.ContainsKey(requestKey)) + { + dict.Remove(requestKey); + return true; + } + return false; + }); + + + public Task Clear() => this.DoTransaction(dict => + { + dict.Clear(); + return true; + }); + + + SemaphoreSlim semaphore = new(1, 1); + Dictionary cache = null!; + + Task DoTransaction(Func, bool> action) => Task.Run(async () => + { + await this.semaphore.WaitAsync(); + if (this.cache == null) + { + var dict = await storage + .Get>(nameof(IStorageService)) + .ConfigureAwait(false); + + this.cache = dict ?? new(); + } + var result = action(this.cache); + if (result) + await storage.Set(nameof(IStorageService), this.cache).ConfigureAwait(false); + + this.semaphore.Release(); + }); + + + protected virtual string GetRequestKey(object request) + { + if (request is IRequestKey keyProvider) + return keyProvider.GetKey(); + + var t = request.GetType(); + var key = $"{t.Namespace}_{t.Name}"; + + return key; + } + + + protected string GetTypeKey(Type type) => $"{type.Namespace}.{type.Name}"; +} + +record OfflineStore( + string TypeName, + string RequestKey, + DateTimeOffset Timestamp, + string Json +); \ No newline at end of file diff --git a/src/Shiny.Mediator.AppSupport/Infrastructure/IStorageService.cs b/src/Shiny.Mediator.AppSupport/Infrastructure/IStorageService.cs index ebed6c2..d25a74b 100644 --- a/src/Shiny.Mediator.AppSupport/Infrastructure/IStorageService.cs +++ b/src/Shiny.Mediator.AppSupport/Infrastructure/IStorageService.cs @@ -3,9 +3,7 @@ namespace Shiny.Mediator.Infrastructure; public interface IStorageService { - Task Store(object request, object result); - Task Get(object request); - // Task ClearByRequestKey(IRequestKey requestKey) - // Task ClearByType(Type requestType); - Task Clear(); + Task Set(string key, T value); + Task Get(string key); + Task Remove(string key); } \ No newline at end of file diff --git a/src/Shiny.Mediator.Maui/Infrastructure/SerializerService.cs b/src/Shiny.Mediator.AppSupport/Infrastructure/SerializerService.cs similarity index 100% rename from src/Shiny.Mediator.Maui/Infrastructure/SerializerService.cs rename to src/Shiny.Mediator.AppSupport/Infrastructure/SerializerService.cs diff --git a/src/Shiny.Mediator.AppSupport/Middleware/OfflineAvailableRequestMiddleware.cs b/src/Shiny.Mediator.AppSupport/Middleware/OfflineAvailableRequestMiddleware.cs index b450bab..c99ce08 100644 --- a/src/Shiny.Mediator.AppSupport/Middleware/OfflineAvailableRequestMiddleware.cs +++ b/src/Shiny.Mediator.AppSupport/Middleware/OfflineAvailableRequestMiddleware.cs @@ -8,7 +8,7 @@ namespace Shiny.Mediator.Middleware; public class OfflineAvailableRequestMiddleware( ILogger> logger, IInternetService connectivity, - IStorageService storage, + IOfflineService offline, IConfiguration configuration ) : IRequestMiddleware { @@ -17,32 +17,35 @@ public async Task Process( RequestHandlerDelegate next ) { - // TODO: request key if (typeof(TResult) == typeof(Unit)) return await next().ConfigureAwait(false); + if (!this.IsEnabled(context.RequestHandler, context.Request)) + return await next().ConfigureAwait(false); + var result = default(TResult); if (connectivity.IsAvailable) { result = await next().ConfigureAwait(false); - if (this.IsEnabled(context.RequestHandler, context.Request)) - { - var timestampedResult = new TimestampedResult(DateTimeOffset.UtcNow, result); - await storage.Store(context.Request!, timestampedResult); - logger.LogDebug("Offline: {Request} - Key: {RequestKey}", context.Request, "TODO"); - } + var requestKey = await offline.Set(context.Request!, result!); + logger.LogDebug("Offline: {Request} - Key: {RequestKey}", context.Request, requestKey); } else { - var timestampedResult = await storage.Get>(context.Request!); - context.Offline(new OfflineAvailableContext("TODO", timestampedResult!.Timestamp)); - result = timestampedResult!.Value; - logger.LogDebug( - "Offline Hit: {Request} - Timestamp: {Timestamp} - Key: {RequestKey}", - context.Request, - timestampedResult.Value, - "TODO" - ); + var offlineResult = await offline.Get(context.Request!); + + // TODO: offline miss to context + if (offlineResult != null) + { + context.Offline(new OfflineAvailableContext(offlineResult.RequestKey, offlineResult.Timestamp)); + result = offlineResult.Value; + logger.LogDebug( + "Offline Hit: {Request} - Timestamp: {Timestamp} - Key: {RequestKey}", + context.Request, + offlineResult.Timestamp, + offlineResult.RequestKey + ); + } } return result; } diff --git a/src/Shiny.Mediator.AppSupport/Middleware/ReplayStreamMiddleware.cs b/src/Shiny.Mediator.AppSupport/Middleware/ReplayStreamMiddleware.cs index 1c230e6..42019f8 100644 --- a/src/Shiny.Mediator.AppSupport/Middleware/ReplayStreamMiddleware.cs +++ b/src/Shiny.Mediator.AppSupport/Middleware/ReplayStreamMiddleware.cs @@ -5,6 +5,7 @@ namespace Shiny.Mediator.Middleware; +// TODO: pump when connectivity is restored /// /// Replays the last result before requesting a new one @@ -13,7 +14,7 @@ namespace Shiny.Mediator.Middleware; /// public class ReplayStreamMiddleware( ILogger> logger, - IStorageService storage, + IOfflineService offline, IConfiguration configuration ) : IStreamRequestMiddleware where TRequest : IStreamRequest { @@ -57,15 +58,15 @@ protected virtual async IAsyncEnumerable Iterate( [EnumeratorCancellation] CancellationToken ct ) { - var store = await storage.Get(request); + var store = await offline.Get(request); if (store != null) - yield return store; + yield return store.Value; var nxt = next().GetAsyncEnumerator(ct); while (await nxt.MoveNextAsync() && !ct.IsCancellationRequested) { // TODO: if current is null, remove? - await storage.Store(request, nxt.Current!); + await offline.Set(request, nxt.Current!); yield return nxt.Current; } } diff --git a/src/Shiny.Mediator.Blazor/Infrastructure/StorageService.cs b/src/Shiny.Mediator.Blazor/Infrastructure/StorageService.cs index 77d1bca..ff0b881 100644 --- a/src/Shiny.Mediator.Blazor/Infrastructure/StorageService.cs +++ b/src/Shiny.Mediator.Blazor/Infrastructure/StorageService.cs @@ -7,43 +7,30 @@ namespace Shiny.Mediator.Blazor.Infrastructure; public class StorageService(IJSRuntime jsruntime) : IStorageService { - public Task Store(object request, object result) + public Task Set(string key, T value) { - var key = this.GetStoreKeyFromRequest(request); - var json = JsonSerializer.Serialize(result); + var json = JsonSerializer.Serialize(value); ((IJSInProcessRuntime)jsruntime).Invoke("localStorage.setItem", key, json); - return Task.CompletedTask; + return Task.FromResult(key); } - public Task Get(object request) + public Task Get(string key) { - var key = this.GetStoreKeyFromRequest(request); var stringValue = ((IJSInProcessRuntime)jsruntime).Invoke("localStorage.getItem", key); if (String.IsNullOrWhiteSpace(stringValue)) return null!; - var final = JsonSerializer.Deserialize(stringValue); + var final = JsonSerializer.Deserialize(stringValue); return Task.FromResult(final); } - public Task Clear() + + public Task Remove(string key) { var inproc = (IJSInProcessRuntime)jsruntime; - inproc.InvokeVoid("localStorage.clear"); + inproc.InvokeVoid("localStorage.removeItem", key); return Task.CompletedTask; } - - - protected virtual string GetStoreKeyFromRequest(object request) - { - if (request is IRequestKey keyProvider) - return keyProvider.GetKey(); - - var t = request.GetType(); - var key = $"{t.Namespace}_{t.Name}"; - - return key; - } } \ No newline at end of file diff --git a/src/Shiny.Mediator.Maui/Infrastructure/StorageService.cs b/src/Shiny.Mediator.Maui/Infrastructure/StorageService.cs index 58ee734..cba896c 100644 --- a/src/Shiny.Mediator.Maui/Infrastructure/StorageService.cs +++ b/src/Shiny.Mediator.Maui/Infrastructure/StorageService.cs @@ -3,113 +3,38 @@ namespace Shiny.Mediator.Infrastructure; public class StorageService(IFileSystem fileSystem, ISerializerService serializer) : IStorageService { - Dictionary keys = null!; - - - public virtual Task Store(object request, object result) + public Task Set(string key, T value) { - var path = this.GetFilePath(request, true); - var json = serializer.Serialize(result); + var path = this.GetFilePath(key); + var json = serializer.Serialize(value); File.WriteAllText(path, json); return Task.CompletedTask; } - public virtual Task Get(object request) + public Task Get(string key) { - TResult? returnValue = default; - var path = this.GetFilePath(request, false); + T? returnValue = default; + var path = this.GetFilePath(key); if (File.Exists(path)) { var json = File.ReadAllText(path); - var obj = serializer.Deserialize(json)!; - returnValue = obj; + returnValue = serializer.Deserialize(json)!; } return Task.FromResult(returnValue); } - - - public Task Clear() - { - lock (this.keys) - this.keys.Clear(); - - Directory.GetFiles(fileSystem.CacheDirectory, "*.mediator").ToList().ForEach(File.Delete); - Directory.GetFiles(fileSystem.AppDataDirectory, "*.mediator").ToList().ForEach(File.Delete); - - return Task.CompletedTask; - } - - protected virtual string GetFilePath(object request, bool createIfNotExists) - { - var key = this.GetPersistentStoreKey(request, createIfNotExists); - var path = Path.Combine(fileSystem.CacheDirectory, $"{key}.mediator"); - return path; - } + string GetFilePath(string key) => Path.Combine(fileSystem.CacheDirectory, $"{key}.mediator"); - protected virtual string GetStoreKeyFromRequest(object request) + public Task Remove(string key) { - if (request is IRequestKey keyProvider) - return keyProvider.GetKey(); - - var t = request.GetType(); - var key = $"{t.Namespace}_{t.Name}"; + var fn = this.GetFilePath(key); + if (File.Exists(fn)) + File.Delete(fn); - return key; - } - - - protected virtual string GetPersistentStoreKey(object request, bool createIfNotExists) - { - var key = this.GetStoreKeyFromRequest(request); - this.EnsureKeyLoad(); - if (this.keys.ContainsKey(key)) - { - key = this.keys[key]; - } - else if (createIfNotExists) - { - var newKey = Guid.NewGuid().ToString(); - this.keys.Add(key, newKey); - key = newKey; - - this.PersistKeyStore(); - } - - return key; - } - - - bool initialized = false; - protected void EnsureKeyLoad() - { - if (this.initialized) - return; - - var storePath = this.KeyStorePath; - if (File.Exists(storePath)) - { - var json = File.ReadAllText(storePath); - this.keys = serializer.Deserialize>(json)!; - } - else - { - this.keys = new(); - } - this.initialized = true; - } - - - protected void PersistKeyStore() - { - var json = serializer.Serialize(this.keys); - File.WriteAllText(this.KeyStorePath, json); + return Task.CompletedTask; } - - - protected string KeyStorePath => Path.Combine(fileSystem.AppDataDirectory, "keys.mediator"); } \ No newline at end of file diff --git a/tests/Shiny.Mediator.Tests/OfflineAvailableMiddlewareTests.cs b/tests/Shiny.Mediator.Tests/OfflineAvailableMiddlewareTests.cs index 4000509..9ee5305 100644 --- a/tests/Shiny.Mediator.Tests/OfflineAvailableMiddlewareTests.cs +++ b/tests/Shiny.Mediator.Tests/OfflineAvailableMiddlewareTests.cs @@ -10,7 +10,7 @@ namespace Shiny.Mediator.Tests; public class OfflineAvailableRequestMiddlewareTests { readonly MockInternetService connectivity; - readonly MockStorageService storeMgr; + readonly MockOfflineService offline; readonly OfflineAvailableRequestMiddleware middleware; readonly OfflineRequestHandler handler; readonly ConfigurationManager config; @@ -19,7 +19,7 @@ public OfflineAvailableRequestMiddlewareTests() { this.handler = new(); this.connectivity = new(); - this.storeMgr = new(); + this.offline = new(); this.config = new ConfigurationManager(); // this.config.AddConfiguration(new MemoryConfigurationProvider(new MemoryConfigurationSource().InitialData)) @@ -27,7 +27,7 @@ public OfflineAvailableRequestMiddlewareTests() this.middleware = new OfflineAvailableRequestMiddleware( null, this.connectivity, - this.storeMgr, + this.offline, null ); } @@ -77,19 +77,32 @@ public Task Handle(OfflineRequest request, CancellationToken cancellationT } } -public class MockStorageService : IStorageService +public class MockOfflineService : IOfflineService { - public Task Store(object request, object result) + public Task Set(object request, object result) { throw new NotImplementedException(); } - public Task Get(object request) + public Task?> Get(object request) { throw new NotImplementedException(); } - public Task Clear() => Task.CompletedTask; + public Task ClearByType(Type requestType) + { + throw new NotImplementedException(); + } + + public Task ClearByRequest(object request) + { + throw new NotImplementedException(); + } + + public Task Clear() + { + throw new NotImplementedException(); + } } public class MockInternetService : IInternetService