diff --git a/src/EdjCase.JsonRpc.Router/BuilderExtensions.cs b/src/EdjCase.JsonRpc.Router/BuilderExtensions.cs index ecea850..0a5edef 100644 --- a/src/EdjCase.JsonRpc.Router/BuilderExtensions.cs +++ b/src/EdjCase.JsonRpc.Router/BuilderExtensions.cs @@ -37,7 +37,10 @@ public static IServiceCollection AddJsonRpc(this IServiceCollection serviceColle /// IoC serivce container to register JsonRpc dependencies /// (Optional) Server wide rpc configuration /// IoC service container - public static IServiceCollection AddJsonRpc(this IServiceCollection serviceCollection, RpcServerConfiguration? configuration = null) + public static IServiceCollection AddJsonRpc( + this IServiceCollection serviceCollection, + RpcServerConfiguration? configuration = null + ) { if (serviceCollection == null) { @@ -62,7 +65,11 @@ public static IServiceCollection AddJsonRpc(this IServiceCollection serviceColle serviceCollection .TryAddScoped(); serviceCollection - .TryAddScoped(); + .TryAddScoped(); + serviceCollection + .TryAddSingleton(); + serviceCollection + .AddOptions(); serviceCollection .TryAddScoped(); serviceCollection diff --git a/src/EdjCase.JsonRpc.Router/Defaults/DefaultRequestMatcher.cs b/src/EdjCase.JsonRpc.Router/Defaults/DefaultRequestMatcher.cs index 80f724e..ed89ec2 100644 --- a/src/EdjCase.JsonRpc.Router/Defaults/DefaultRequestMatcher.cs +++ b/src/EdjCase.JsonRpc.Router/Defaults/DefaultRequestMatcher.cs @@ -1,10 +1,8 @@ using System; using System.Buffers; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; -using EdjCase.JsonRpc.Router; using EdjCase.JsonRpc.Common; using EdjCase.JsonRpc.Router.Abstractions; using EdjCase.JsonRpc.Router.Utilities; @@ -13,92 +11,74 @@ using Microsoft.Extensions.Caching.Memory; namespace EdjCase.JsonRpc.Router.Defaults -{ +{ + internal class DefaultRequestMatcher : IRpcRequestMatcher { - private static ConcurrentDictionary> requestToMethodCache { get; } - = new ConcurrentDictionary>(); - private ILogger logger { get; } private IRpcMethodProvider methodProvider { get; } private IRpcContextAccessor contextAccessor { get; } - private IRpcParameterConverter rpcParameterConverter { get; } - - public DefaultRequestMatcher(ILogger logger, - IRpcMethodProvider methodProvider, - IRpcContextAccessor contextAccessor, - IRpcParameterConverter rpcParameterConverter) - { - this.contextAccessor = contextAccessor; - this.logger = logger; - this.methodProvider = methodProvider; - this.contextAccessor = contextAccessor; - this.rpcParameterConverter = rpcParameterConverter; - } - - public IRpcMethodInfo GetMatchingMethod(RpcRequestSignature requestSignature) - { - this.logger.AttemptingToMatchMethod(new string(requestSignature.GetMethodName().Span)); - - RpcContext context = this.contextAccessor.Get(); - IReadOnlyList? methods = this.methodProvider.GetByPath(context.Path); - if (methods == null || !methods.Any()) - { - throw new RpcException(RpcErrorCode.MethodNotFound, $"No methods found for route"); - } - - Span matches = this.FilterAndBuildMethodInfoByRequest(context, methods, requestSignature); - if (matches.Length == 1) - { - this.logger.RequestMatchedMethod(); - return matches[0]; - } - - string errorMessage; - if (matches.Length > 1) - { - var methodInfoList = new List(); - foreach (IRpcMethodInfo matchedMethod in matches) - { - var parameterTypeList = new List(); - foreach (IRpcParameterInfo parameterInfo in matchedMethod.Parameters) - { - RpcParameterType type = this.rpcParameterConverter.GetRpcParameterType(parameterInfo.RawType); - string parameterType = parameterInfo.Name + ": " + type; - if (parameterInfo.IsOptional) - { - parameterType += "(Optional)"; - } - parameterTypeList.Add(parameterType); - } - string parameterString = string.Join(", ", parameterTypeList); - methodInfoList.Add($"{{Name: '{matchedMethod.Name}', Parameters: [{parameterString}]}}"); - } - errorMessage = "More than one method matched the rpc request. Unable to invoke due to ambiguity. Methods that matched the same name: " + string.Join(", ", methodInfoList); - } - else - { - //Log diagnostics - this.logger.MethodsInRoute(methods); - errorMessage = "No methods matched request."; - } - throw new RpcException(RpcErrorCode.MethodNotFound, errorMessage); + private IRpcParameterConverter rpcParameterConverter { get; } + private RequestMatcherCache requestMatcherCache { get; } + public DefaultRequestMatcher( + ILogger logger, + IRpcMethodProvider methodProvider, + IRpcContextAccessor contextAccessor, + IRpcParameterConverter rpcParameterConverter, + RequestMatcherCache requestMatcherCache + ) + { + this.contextAccessor = contextAccessor; + this.logger = logger; + this.methodProvider = methodProvider; + this.rpcParameterConverter = rpcParameterConverter; + this.requestMatcherCache = requestMatcherCache; + } + + + public IRpcMethodInfo GetMatchingMethod(RpcRequestSignature requestSignature) + { + this.logger.AttemptingToMatchMethod(new string(requestSignature.GetMethodName().Span)); + + // Create efficient cache key from path and signature + string path = this.contextAccessor.Get().Path?.ToString() ?? string.Empty; + + IRpcMethodInfo[] matchingMethods = this.requestMatcherCache.GetOrAdd( + path, + requestSignature, + () => + { + RpcContext context = this.contextAccessor.Get(); + IReadOnlyList? methods = this.methodProvider.GetByPath(context.Path); + if (methods == null || !methods.Any()) + { + return Array.Empty(); + } + + return this.GetMatchingMethods(requestSignature, methods); + }); + + + return this.HandleMatchResult(matchingMethods); + } + + private IRpcMethodInfo HandleMatchResult(IRpcMethodInfo[] matches) + { + if (matches.Length == 1) + { + this.logger.RequestMatchedMethod(); + return matches[0]; + } + if (matches.Length > 1) + { + // Format the methods for error message + string errorMessage = "More than one method matched the rpc request. Unable to invoke due to ambiguity."; + throw new RpcException(RpcErrorCode.MethodNotFound, errorMessage); + } + + throw new RpcException(RpcErrorCode.MethodNotFound, "No methods matched request."); } - private IRpcMethodInfo[] FilterAndBuildMethodInfoByRequest(RpcContext context, IReadOnlyList methods, RpcRequestSignature requestSignature) - { - //If the request signature is found, it means we have the methods cached already - - string rpcPath = context.Path?.ToString() ?? string.Empty; - var rpcPathMethodsCache = DefaultRequestMatcher.requestToMethodCache.GetOrAdd(rpcPath, path => new ConcurrentDictionary()); - return rpcPathMethodsCache.GetOrAdd(requestSignature, BuildMethodCache); - IRpcMethodInfo[] BuildMethodCache(RpcRequestSignature s) - { - return this.GetMatchingMethods(s, methods); - } - } - - private IRpcMethodInfo[] GetMatchingMethods(RpcRequestSignature requestSignature, IReadOnlyList methods) { IRpcMethodInfo[] methodsWithSameName = ArrayPool.Shared.Rent(methods.Count); @@ -260,7 +240,83 @@ private bool ParametersMatch(RpcRequestSignature requestSignature, IReadOnlyList } - } + } + + + public class RequestCacheOptions + { + public long SizeLimit { get; set; } = 100; + public TimeSpan? SlidingExpiration { get; set; } = null; + public TimeSpan? AbsoluteExpiration { get; set; } = null; + } + + internal class RequestMatcherCache + { + private RequestCacheOptions options { get; } + private MemoryCache memoryCache { get; } + + public RequestMatcherCache(IOptions options) + { + this.options = options.Value; + this.memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions + { + SizeLimit = this.options.SizeLimit + })); + } + + public IRpcMethodInfo[] GetOrAdd( + string path, + RpcRequestSignature requestSignature, + Func resolveFunc + ) + { + var cacheKey = new RequestCacheKey(path, requestSignature); + + if (this.memoryCache.TryGetValue(cacheKey, out IRpcMethodInfo[]? result)) + { + return result!; + } + + IRpcMethodInfo[] matchingMethods = resolveFunc(); + + if (matchingMethods.Length != 1) + { + return matchingMethods; // Don't cache bad matches + } + + // Cache with configurable options + var cacheEntryOptions = new MemoryCacheEntryOptions() + .SetSize(1); + if (this.options.SlidingExpiration != null) + { + cacheEntryOptions = cacheEntryOptions.SetSlidingExpiration(this.options.SlidingExpiration.Value); + } + if (this.options.AbsoluteExpiration != null) + { + cacheEntryOptions = cacheEntryOptions.SetAbsoluteExpiration(this.options.AbsoluteExpiration.Value); + } + return this.memoryCache.Set(cacheKey, matchingMethods, cacheEntryOptions); + } + } + + internal readonly struct RequestCacheKey : IEquatable + { + private readonly string path; + private readonly string signatureKey; + + public RequestCacheKey(string path, RpcRequestSignature signature) + { + this.path = path; + this.signatureKey = signature.AsString(); + } + + public bool Equals(RequestCacheKey other) => this.path == other.path && this.signatureKey == other.signatureKey; + + public override bool Equals(object? obj) => obj is RequestCacheKey other && this.Equals(other); + + public override int GetHashCode() => HashCode.Combine(this.path, this.signatureKey); + } + internal class RpcEndpointInfo { diff --git a/test/Benchmarks/Program.cs b/test/Benchmarks/Program.cs index cf12e42..f6663c4 100644 --- a/test/Benchmarks/Program.cs +++ b/test/Benchmarks/Program.cs @@ -57,7 +57,13 @@ public void GlobalSetup() JsonSerializerSettings = null }); var rpcParameterConverter = new DefaultRpcParameterConverter(options, new FakeLogger()); - this.requestMatcher = new DefaultRequestMatcher(logger, methodProvider, fakeRpcContextAccessor, rpcParameterConverter); + this.requestMatcher = new DefaultRequestMatcher( + logger, + methodProvider, + fakeRpcContextAccessor, + rpcParameterConverter, + new RequestMatcherCache(Options.Create(new RequestCacheOptions())) + ); } private RpcRequestSignature? requestsignature; diff --git a/test/EdjCase.JsonRpc.Router.Sample/Startup.cs b/test/EdjCase.JsonRpc.Router.Sample/Startup.cs index d613197..117faea 100644 --- a/test/EdjCase.JsonRpc.Router.Sample/Startup.cs +++ b/test/EdjCase.JsonRpc.Router.Sample/Startup.cs @@ -64,7 +64,13 @@ void ConfigureRpc(RpcServerConfiguration config) services .AddControllers() .Services - .AddJsonRpcWithSwagger(ConfigureRpc, globalJsonSerializerOptions); + .AddJsonRpcWithSwagger(ConfigureRpc, globalJsonSerializerOptions) + .Configure(options => + { + options.SizeLimit = 10; + options.SlidingExpiration = TimeSpan.FromMinutes(5); + options.AbsoluteExpiration = TimeSpan.FromMinutes(90); + }); } diff --git a/test/EdjCase.JsonRpc.Router.Tests/MethodMatcherTests.cs b/test/EdjCase.JsonRpc.Router.Tests/MethodMatcherTests.cs index a2f1eb7..63455dd 100644 --- a/test/EdjCase.JsonRpc.Router.Tests/MethodMatcherTests.cs +++ b/test/EdjCase.JsonRpc.Router.Tests/MethodMatcherTests.cs @@ -22,23 +22,23 @@ public class MethodMatcherTests private readonly StaticRpcMethodDataAccessor methodDataAccessor; public MethodMatcherTests() - { + { var baserouteData = typeof(MethodMatcherThreeController) .GetMethods(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) .Select(DefaultRpcMethodInfo.FromMethodInfo) - .ToList(); - - var routeData = new Dictionary> - { + .ToList(); + + var routeData = new Dictionary> + { [nameof(MethodMatcherController)] = typeof(MethodMatcherController) .GetMethods(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) .Select(DefaultRpcMethodInfo.FromMethodInfo) - .ToList(), + .ToList(), [nameof(MethodMatcherDuplicatesController)] = typeof(MethodMatcherDuplicatesController) .GetMethods(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) .Select(DefaultRpcMethodInfo.FromMethodInfo) - .ToList() - }; + .ToList() + }; this.methodDataAccessor = new StaticRpcMethodDataAccessor() { Value = new RpcRouteMetaData(baserouteData, routeData)}; } @@ -64,7 +64,13 @@ private DefaultRequestMatcher GetMatcher(RpcPath? path = null) var methodProvider = new StaticRpcMethodProvider(this.methodDataAccessor); - return new DefaultRequestMatcher(logger.Object, methodProvider, rpcContextAccessor.Object, rpcParameterConverter); + return new DefaultRequestMatcher( + logger.Object, + methodProvider, + rpcContextAccessor.Object, + rpcParameterConverter, + new RequestMatcherCache(Options.Create(new RequestCacheOptions())) + ); } [Fact] @@ -81,8 +87,8 @@ public void GetMatchingMethod_WithRpcRoute() DefaultRequestMatcher path2Matcher = this.GetMatcher(path: typeof(MethodMatcherDuplicatesController).GetTypeInfo().Name); IRpcMethodInfo path2Match = path2Matcher.GetMatchingMethod(requestSignature); Assert.NotNull(path2Match); - Assert.NotSame(path1Match, path2Match); - + Assert.NotSame(path1Match, path2Match); + DefaultRequestMatcher path3Matcher = this.GetMatcher(path: null); IRpcMethodInfo path3Match = path3Matcher.GetMatchingMethod(requestSignature); Assert.NotNull(path2Match); @@ -352,15 +358,15 @@ public void GetMatchingMethod__Dictionary_Request_With_Optional_Parameters__Matc Assert.NotNull(methodInfo); Assert.Equal(methodName, methodInfo.Name); - } - - + } + + [Fact] public void GetMatchingMethod_WithoutRpcRoute() { string methodName = nameof(MethodMatcherController.GuidTypeMethod); RpcRequestSignature requestSignature = RpcRequestSignature.Create(methodName, new[] { RpcParameterType.String }); - + DefaultRequestMatcher path3Matcher = this.GetMatcher(path: null); IRpcMethodInfo path3Match = path3Matcher.GetMatchingMethod(requestSignature); Assert.NotNull(path3Match);