Skip to content
This repository was archived by the owner on Nov 16, 2019. It is now read-only.

Feature/support generics #6

Closed
wants to merge 12 commits into from
104 changes: 23 additions & 81 deletions src/Microsoft.JSInterop/DotNetDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using Microsoft.JSInterop.Internal;
using Microsoft.JSInterop.MethodInfoCaching;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
Expand All @@ -16,8 +17,14 @@ namespace Microsoft.JSInterop
/// </summary>
public static class DotNetDispatcher
{
private static ConcurrentDictionary<string, IReadOnlyDictionary<string, (MethodInfo, Type[])>> _cachedMethodsByAssembly
= new ConcurrentDictionary<string, IReadOnlyDictionary<string, (MethodInfo, Type[])>>();
private static readonly IMethodInfoCache _methodInfoCache;

static DotNetDispatcher()
{
_methodInfoCache = new MethodInfoCache(
instanceMethodInfoCache: new InstanceMethodInfoCache(),
staticMethodInfoCache: new StaticMethodInfoCache());
}

/// <summary>
/// Receives a call from JS to .NET, locating and invoking the specified method.
Expand Down Expand Up @@ -135,7 +142,17 @@ private static object InvokeSynchronously(string assemblyName, string methodIden
assemblyName = targetInstance.GetType().Assembly.GetName().Name;
}

var (methodInfo, parameterTypes) = GetCachedMethodInfo(assemblyName, methodIdentifier);
MethodInfoCacheEntry cachedMethodInfo;
if (targetInstance == null)
{
cachedMethodInfo = _methodInfoCache.GetStaticMethodInfo(assemblyName, methodIdentifier);
}
else
{
cachedMethodInfo = _methodInfoCache.GetInstanceMethodInfo(targetInstance.GetType(), methodIdentifier);
}
MethodInfo methodInfo = cachedMethodInfo.MethodInfo;
IReadOnlyList<Type> parameterTypes = cachedMethodInfo.ParameterTypes;

// There's no direct way to say we want to deserialize as an array with heterogenous
// entry types (e.g., [string, int, bool]), so we need to deserialize in two phases.
Expand All @@ -148,9 +165,10 @@ private static object InvokeSynchronously(string assemblyName, string methodIden
suppliedArgs = Json.Deserialize<SimpleJson.JsonArray>(argsJson).ToArray<object>();
suppliedArgsLength = suppliedArgs.Length;
}
if (suppliedArgsLength != parameterTypes.Length)
if (suppliedArgsLength != parameterTypes.Count)
{
throw new ArgumentException($"In call to '{methodIdentifier}', expected {parameterTypes.Length} parameters but received {suppliedArgsLength}.");
throw new ArgumentException($"In call to '{methodIdentifier}', " +
$"expected {parameterTypes.Count} parameters but received {suppliedArgsLength}.");
}

// Second, convert each supplied value to the type expected by the method
Expand Down Expand Up @@ -210,82 +228,6 @@ public static void ReleaseDotNetObject(long dotNetObjectId)
jsRuntime.ArgSerializerStrategy.ReleaseDotNetObject(dotNetObjectId);
}

private static (MethodInfo, Type[]) GetCachedMethodInfo(string assemblyName, string methodIdentifier)
{
if (string.IsNullOrWhiteSpace(assemblyName))
{
throw new ArgumentException("Cannot be null, empty, or whitespace.", nameof(assemblyName));
}

if (string.IsNullOrWhiteSpace(methodIdentifier))
{
throw new ArgumentException("Cannot be null, empty, or whitespace.", nameof(methodIdentifier));
}

var assemblyMethods = _cachedMethodsByAssembly.GetOrAdd(assemblyName, ScanAssemblyForCallableMethods);
if (assemblyMethods.TryGetValue(methodIdentifier, out var result))
{
return result;
}
else
{
throw new ArgumentException($"The assembly '{assemblyName}' does not contain a public method with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")].");
}
}

private static IReadOnlyDictionary<string, (MethodInfo, Type[])> ScanAssemblyForCallableMethods(string assemblyName)
{
// TODO: Consider looking first for assembly-level attributes (i.e., if there are any,
// only use those) to avoid scanning, especially for framework assemblies.
var result = new Dictionary<string, (MethodInfo, Type[])>();
var invokableMethods = GetRequiredLoadedAssembly(assemblyName)
.GetExportedTypes()
.SelectMany(type => type.GetMethods(
BindingFlags.Public |
BindingFlags.DeclaredOnly |
BindingFlags.Instance |
BindingFlags.Static))
.Where(method => method.IsDefined(typeof(JSInvokableAttribute), inherit: false));
foreach (var method in invokableMethods)
{
var identifier = method.GetCustomAttribute<JSInvokableAttribute>(false).Identifier ?? method.Name;
var parameterTypes = method.GetParameters().Select(p => p.ParameterType).ToArray();

try
{
result.Add(identifier, (method, parameterTypes));
}
catch (ArgumentException)
{
if (result.ContainsKey(identifier))
{
throw new InvalidOperationException($"The assembly '{assemblyName}' contains more than one " +
$"[JSInvokable] method with identifier '{identifier}'. All [JSInvokable] methods within the same " +
$"assembly must have different identifiers. You can pass a custom identifier as a parameter to " +
$"the [JSInvokable] attribute.");
}
else
{
throw;
}
}
}

return result;
}

private static Assembly GetRequiredLoadedAssembly(string assemblyName)
{
// We don't want to load assemblies on demand here, because we don't necessarily trust
// "assemblyName" to be something the developer intended to load. So only pick from the
// set of already-loaded assemblies.
// In some edge cases this might force developers to explicitly call something on the
// target assembly (from .NET) before they can invoke its allowed methods from JS.
var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
return loadedAssemblies.FirstOrDefault(a => a.GetName().Name.Equals(assemblyName, StringComparison.Ordinal))
?? throw new ArgumentException($"There is no loaded assembly with the name '{assemblyName}'.");
}

private static Exception UnwrapException(Exception ex)
{
while ((ex is AggregateException || ex is TargetInvocationException) && ex.InnerException != null)
Expand Down
14 changes: 5 additions & 9 deletions src/Microsoft.JSInterop/JSInvokableAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,15 @@ public class JSInvokableAttribute : Attribute
/// </summary>
public string Identifier { get; }

/// <summary>
/// Constructs an instance of <see cref="JSInvokableAttribute"/> without setting
/// an identifier for the method.
/// </summary>
public JSInvokableAttribute()
{
}

/// <summary>
/// Constructs an instance of <see cref="JSInvokableAttribute"/> using the specified
/// identifier.
/// </summary>
/// <param name="identifier">An identifier for the method, which must be unique within the scope of the assembly.</param>
/// <param name="identifier">
/// An identifier for the method.
/// For static methods the identifier must be unique within the scope of the assembly.
/// For instance methods the identifier must be unique within the scope of the instance's class hierarchy.
/// </param>
public JSInvokableAttribute(string identifier)
{
if (string.IsNullOrEmpty(identifier))
Expand Down
2 changes: 1 addition & 1 deletion src/Microsoft.JSInterop/JSRuntimeBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public Task<T> InvokeAsync<T>(string identifier, params object[] args)

try
{
var argsJson = args?.Length > 0
string argsJson = args?.Length > 0
? Json.Serialize(args, ArgSerializerStrategy)
: null;
BeginInvokeJS(taskId, identifier, argsJson);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;

namespace Microsoft.JSInterop.MethodInfoCaching
{
/// <summary>
/// Cached information about a <see cref="MethodInfo"/> class, to avoid reflection lookups.
/// </summary>
internal interface IInstanceMethodInfoCache
{
/// <summary>
/// Gets the cached MethodInfo for a specific class and method identifier.
/// </summary>
/// <param name="classType">The class type to search for the method.</param>
/// <param name="methodIdentifier">The identifier of the method to retrieve information about.</param>
/// <returns>Cached information about the method.</returns>
MethodInfoCacheEntry Get(Type classType, string methodIdentifier);
/// <summary>
/// Gets the cached MethodInfo for a specific class and method identifier.
/// </summary>
/// <param name="classType">The class type to search for the method identifier.</param>
/// <param name="methodIdentifier">The identifier of the method to retrieve information about.</param>
/// <param name="result">Cached information about the method.</param>
/// <returns>True if the method identifier was found on the class, otherwise false</returns>
bool TryGet(Type classType, string methodIdentifier, out MethodInfoCacheEntry result);
}
}
23 changes: 23 additions & 0 deletions src/Microsoft.JSInterop/MethodInfoCaching/IMethodInfoCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;

namespace Microsoft.JSInterop.MethodInfoCaching
{
/// <summary>
/// A cache for quickly looking up invokable methods.
/// </summary>
internal interface IMethodInfoCache
{
/// <summary>
/// <see cref="IInstanceMethodInfoCache.Get(Type, string)"/>
/// </summary>
MethodInfoCacheEntry GetInstanceMethodInfo(Type classType, string methodIdentifier);
/// <summary>
/// <see cref="IStaticMethodInfoCache.Get(string, string)"/>
/// </summary>
MethodInfoCacheEntry GetStaticMethodInfo(string assemblyName, string methodIdentifier);
/// <summary>
/// <see cref="IInstanceMethodInfoCache.TryGet(Type, string, out MethodInfoCacheEntry)"/>
/// </summary>
bool TryGetInstanceMethodInfo(Type classType, string methodIdentifier, out MethodInfoCacheEntry result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Microsoft.JSInterop.MethodInfoCaching
{
/// <summary>
/// A cache for quickly looking up invokable static methods within an assembly.
/// </summary>
internal interface IStaticMethodInfoCache
{
/// <summary>
/// Gets the cache MethodInfo for a static method within an assembly.
/// </summary>
/// <param name="assemblyName">The name of the assembly in which the method is declared.</param>
/// <param name="methodIdentifier">The assembly-unique identifier of the method to retrieve information about.</param>
/// <returns>The <see cref="MethodInfoCacheEntry"/> for the method identifier within the named assembly.</returns>
MethodInfoCacheEntry Get(string assemblyName, string methodIdentifier);
}
}
105 changes: 105 additions & 0 deletions src/Microsoft.JSInterop/MethodInfoCaching/InstanceMethodInfoCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;

namespace Microsoft.JSInterop.MethodInfoCaching
{
/// <summary>
/// A cache for quickly looking up invokable instance methods on a class.
/// </summary>
internal class InstanceMethodInfoCache : IInstanceMethodInfoCache
{
private readonly ConcurrentDictionary<Type, IReadOnlyDictionary<string, MethodInfoCacheEntry>> _lookup
= new ConcurrentDictionary<Type, IReadOnlyDictionary<string, MethodInfoCacheEntry>>();

/// <summary>
/// <see cref="IInstanceMethodInfoCache.Get(Type, string)"/>
/// </summary>
public MethodInfoCacheEntry Get(Type classType, string methodIdentifier)
{
MethodInfoCacheEntry result;
if (!TryGet(classType, methodIdentifier, out result))
{
throw new ArgumentException($"The class '{classType.Name}' does not contain a public method" +
$" with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")].");
}
return result;
}

/// <summary>
/// <see cref="IInstanceMethodInfoCache.TryGet(Type, string, out MethodInfoCacheEntry)"/>
/// </summary>
public bool TryGet(Type classType, string methodIdentifier, out MethodInfoCacheEntry result)
{
IReadOnlyDictionary<string, MethodInfoCacheEntry> cacheItemByMethodIdentifier =
_lookup.GetOrAdd(classType, ScanClassForInvokableMethods);
return cacheItemByMethodIdentifier.TryGetValue(methodIdentifier, out result);
}

/// <summary>
/// Scans a class for all methods decorated with [<see cref="JSInvokableAttribute"/>]
/// </summary>
/// <param name="classType">The class type to search for the method identifier.</param>
/// <returns>A collection of cached method info indexed by method identifier string.</returns>
private IReadOnlyDictionary<string, MethodInfoCacheEntry> ScanClassForInvokableMethods(Type classType)
{
var result = new Dictionary<string, MethodInfoCacheEntry>();
// For instance invokable methods we need public instance methods decorated with [JSInvokable]
// including any that have been inherited
IEnumerable<MethodInfo> invokableMethods = classType
.GetMethods(
BindingFlags.Public |
BindingFlags.Instance)
.Where(method => method.IsDefined(typeof(JSInvokableAttribute), inherit: true));
foreach (MethodInfo methodInfo in invokableMethods)
{
EnsureMethodIsInvokable(classType, methodInfo);

string methodIdentifier = methodInfo.GetCustomAttribute<JSInvokableAttribute>(true).Identifier?? methodInfo.Name;
var cacheItem = new MethodInfoCacheEntry(methodInfo);
try
{
result.Add(methodIdentifier, cacheItem);
}
catch (ArgumentException)
{
if (result.ContainsKey(methodIdentifier))
{
throw new ArgumentException($"The class '{classType.Name}' contains more than one " +
$"[{nameof(JSInvokableAttribute)}] method with identifier '{methodIdentifier}'. " +
$"All instance methods within the same class must have different identifiers.");
}
else
{
throw;
}
}
}
return result;
}

/// <summary>
/// Throws an exception if the class has open generic types or the method is generic.
/// </summary>
/// <param name="classType">The class type to which the method info belongs.</param>
/// <param name="methodInfo">The method info to ensure is invokable.</param>
private void EnsureMethodIsInvokable(Type classType, MethodInfo methodInfo)
{
// Prohibit the calling of methods on classes with undefined generic arguments.
// As this is ultimately called from an instance this shouldn't be possible.
if (classType.ContainsGenericParameters)
{
throw new ArgumentException($"Cannot determine generic argument types for class '{classType.Name}'");
}
// Prohibit the calling of methods with generic parameters
if (methodInfo.GetGenericArguments().Length > 0)
{
throw new ArgumentException($"Cannot determine generic argument types " +
$"for method '{classType.Name}.{methodInfo.Name}'");
}
}
}

}
43 changes: 43 additions & 0 deletions src/Microsoft.JSInterop/MethodInfoCaching/MethodInfoCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;

namespace Microsoft.JSInterop.MethodInfoCaching
{
/// <summary>
/// <see cref="IMethodInfoCache"/>
/// </summary>
internal class MethodInfoCache : IMethodInfoCache
{
private readonly IInstanceMethodInfoCache InstanceMethodInfoCache;
private readonly IStaticMethodInfoCache StaticMethodInfoCache;

/// <summary>
/// Constructs an instance of <see cref="MethodInfoCache"/>.
/// </summary>
/// <param name="instanceMethodInfoCache">Cache for instance methods.</param>
/// <param name="staticMethodInfoCache">Cache for static methods.</param>
public MethodInfoCache(IInstanceMethodInfoCache instanceMethodInfoCache, IStaticMethodInfoCache staticMethodInfoCache)
{
InstanceMethodInfoCache = instanceMethodInfoCache ?? throw new ArgumentNullException(nameof(instanceMethodInfoCache));
StaticMethodInfoCache = staticMethodInfoCache ?? throw new ArgumentNullException(nameof(staticMethodInfoCache));
}

/// <summary>
/// <see cref="IMethodInfoCache.GetInstanceMethodInfo(Type, string)"/>
/// </summary>
public MethodInfoCacheEntry GetInstanceMethodInfo(Type classType, string methodIdentifier)
=> InstanceMethodInfoCache.Get(classType, methodIdentifier);

/// <summary>
/// <see cref="IMethodInfoCache.TryGetInstanceMethodInfo(Type, string, out MethodInfoCacheEntry)"/>
/// </summary>
public bool TryGetInstanceMethodInfo(Type classType, string methodIdentifier, out MethodInfoCacheEntry result)
=> InstanceMethodInfoCache.TryGet(classType, methodIdentifier, out result);

/// <summary>
/// <see cref="IMethodInfoCache.GetStaticMethodInfo(string, string)"/>
/// </summary>
public MethodInfoCacheEntry GetStaticMethodInfo(string assemblyName, string methodIdentifier)
=> StaticMethodInfoCache.Get(assemblyName, methodIdentifier);
}

}
Loading