Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Adding Assemblies/Types to be made available to Razor Page at Runtime

I'm trying to build a dynamic Web interface where I can dynamically point at a folder and serve Web content out of that folder with ASP.NET Core. This works fairly easily by using FileProviders in ASP.NET Core to re-route the Web root folder. This works both for StaticFiles and For RazorPages.

However, for RazorPages the problem is that once you do this you can't dynamically add references for additional types. I'd like to be able to optionally add a folder (PrivateBin) which on startup I can loop through, load the assemblies and then have those assemblies visible in Razor.

Unfortunately it doesn't work as Razor does not appear to see the loaded assemblies even when using runtime compilation.

I use the following during startup to load assemblies. Note the folder that these are loaded from are not in the default ContentRoot or WebRoot but in the new redirected WebRoot.

// WebRoot is a user chosen Path here specified via command line --WebRoot c:\temp\web
private void LoadPrivateBinAssemblies()
{
    var binPath = Path.Combine(WebRoot, "PrivateBin");
    if (Directory.Exists(binPath))
    {
        var files = Directory.GetFiles(binPath);
        foreach (var file in files)
        {
            if (!file.EndsWith(".dll", StringComparison.CurrentCultureIgnoreCase) &&
               !file.EndsWith(".exe", StringComparison.InvariantCultureIgnoreCase))
                continue;

            try
            {
                var asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(file);
                Console.WriteLine("Additional Assembly: " + file);
            }
            catch (Exception ex)
            {
                Console.WriteLine("Failed to load private assembly: " + file);
            }
        }
    }
}

The assembly loads into the AssemblyLoadContext() and I can - using Reflection and Type.GetType("namespace.class,assembly") - access the type.

However, when I try to access the type in RazorPages - even with Runtime Compilation enabled - the types are not available. I get the following error:

enter image description here

To make sure that the type is indeed available, I checked that I can do the following inside of Razor:

@{
 var md = Type.GetType("Westwind.AspNetCore.Markdown.Markdown,Westwind.AspNetCore.Markdown");
 var mdText = md.InvokeMember("Parse", BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Static, null,
                    null, new object[] { "**asdasd**", false, false, false });
}
@mdText

and that works fine. So the assembly is loaded and the type is accessible, but Razor doesn't appear to be aware of it.

So the question is:

Is it possible to load assemblies at runtime and make them available to Razor with Runtime Compilation, and use it like you normally would use a type via direct declarative access?

like image 556
Rick Strahl Avatar asked Nov 04 '19 00:11

Rick Strahl


People also ask

How do I add Razor runtime compilation?

Enable runtime compilation at project creationSelect either the Web Application or the Web Application (Model-View-Controller) project template. Select the Enable Razor runtime compilation checkbox.

In which folder does the runtime look for Razor pages by default?

Notes: The runtime looks for Razor Pages files in the Pages folder by default. Index is the default page when a URL doesn't include a page.

How do I add an API to my Razor page?

Adding the Web API In order to add a Web API Controller you will need to Right Click the Project in the Solution Explorer and click on Add and then New Item. Now from the Add New Item window, choose the API Controller – Empty option as shown below. Then give it a suitable name and click Add.


2 Answers

It turns out the solution to this is via the Razor Runtime Compilation Options which allow adding of extra 'ReferencePaths', and then explicitly loading assemblies.

In ConfigureServices():

services.AddRazorPages(opt => { opt.RootDirectory = "/"; })
    .AddRazorRuntimeCompilation(
        opt =>
        {

            opt.FileProviders.Add(new PhysicalFileProvider(WebRoot));
            LoadPrivateBinAssemblies(opt);
        });

then:

private void LoadPrivateBinAssemblies(MvcRazorRuntimeCompilationOptions opt)
{
    var binPath = Path.Combine(WebRoot, "PrivateBin");
    if (Directory.Exists(binPath))
    {
        var files = Directory.GetFiles(binPath);
        foreach (var file in files)
        {
            if (!file.EndsWith(".dll", StringComparison.CurrentCultureIgnoreCase) &&
               !file.EndsWith(".exe", StringComparison.InvariantCultureIgnoreCase))
                continue;

            try
            {
                var asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(file);
                opt.AdditionalReferencePaths.Add(file);           
            }
            catch (Exception ex)
            {
                ...
            }

        }
    }

}

The key is:

opt.AdditionalReferencePaths.Add(file);  

which makes the assembly visible to Razor, but doesn't actually load it. To load it you then have to explicitly load it with:

AssemblyLoadContext.Default.LoadFromAssemblyPath(file);

which loads the assembly from a path. Note that any dependencies that this assembly has have to be available either in the application's startup path or in the same folder you're loading from.

Note: Load order for dependencies may be important here or a not previously added assembly may not be found as a dependency (untested).

like image 170
Rick Strahl Avatar answered Oct 07 '22 17:10

Rick Strahl


A Quick look into the ASP.NET Core source code reveals:

All Razor view Compilations start at:

RuntimeViewCompiler.CreateCompilation(..)

which uses: CSharpCompiler.Create(.., .., references: ..)

which uses: RazorReferenceManager.CompilationReferences

which uses: see code on github

// simplyfied
var referencePaths = ApplicationPartManager.ApplicationParts
    .OfType<ICompilationReferencesProvider>()
    .SelectMany(_ => _.GetReferencePaths())

which uses: ApplicationPartManager.ApplicationParts

So we need somehow register our own ICompilationReferencesProvider and this is how..

ApplicationPartManager

While it's search for Application parts does the ApplicationPartManager a few things:

  1. it searchs for hidden Assemblies reading attributes like:
[assembly: ApplicationPartAttribute(assemblyName:"..")] // Specifies an assembly to be added as an ApplicationPart
[assembly: RelatedAssemblyAttribute(assemblyFileName:"..")] // Specifies a assembly to load as part of MVC's assembly discovery mechanism.
// plus `Assembly.GetEntryAssembly()` gets added automaticly behind the scenes.
  1. Then it loops throuth all found Assemblies and uses ApplicationPartFactory.GetApplicationPartFactory(assembly) (as seen in line 69) to find types which extend ApplicationPartFactory.

  2. Then it invokes the method GetApplicationParts(assembly) on all found ApplicationPartFactorys.

All Assemblies without ApplicationPartFactory get the DefaultApplicationPartFactory which returns new AssemblyPart(assembly) in GetApplicationParts.

public abstract IEnumerable<ApplicationPart> GetApplicationParts(Assembly assembly);

GetApplicationPartFactory

GetApplicationPartFactory searches for [assembly: ProvideApplicationPartFactory(typeof(SomeType))] then it uses SomeType as factory.

public abstract class ApplicationPartFactory {

    public abstract IEnumerable<ApplicationPart> GetApplicationParts(Assembly assembly);

    public static ApplicationPartFactory GetApplicationPartFactory(Assembly assembly)
    {
        // ...

        var provideAttribute = assembly.GetCustomAttribute<ProvideApplicationPartFactoryAttribute>();
        if (provideAttribute == null)
        {
            return DefaultApplicationPartFactory.Instance; // this registers `assembly` as `new AssemblyPart(assembly)`
        }

        var type = provideAttribute.GetFactoryType();

        // ...

        return (ApplicationPartFactory)Activator.CreateInstance(type);
    }
}

One Solution

This means we can create and register (using ProvideApplicationPartFactoryAttribute) our own ApplicationPartFactory which returns a custom ApplicationPart implementation which implements ICompilationReferencesProvider and then returns our references in GetReferencePaths.

[assembly: ProvideApplicationPartFactory(typeof(MyApplicationPartFactory))]

namespace WebApplication1 {
    public class MyApplicationPartFactory : ApplicationPartFactory {
        public override IEnumerable<ApplicationPart> GetApplicationParts(Assembly assembly)
        {
            yield return new CompilationReferencesProviderAssemblyPart(assembly);
        }
    }

    public class CompilationReferencesProviderAssemblyPart : AssemblyPart, ICompilationReferencesProvider {
        private readonly Assembly _assembly;

        public CompilationReferencesProviderAssemblyPart(Assembly assembly) : base(assembly)
        {
            _assembly = assembly;
        }

        public IEnumerable<string> GetReferencePaths()
        {
            // your `LoadPrivateBinAssemblies()` method needs to be called before the next line executes!
            // So you should load all private bin's before the first RazorPage gets requested.

            return AssemblyLoadContext.GetLoadContext(_assembly).Assemblies
                .Where(_ => !_.IsDynamic)
                .Select(_ => new Uri(_.CodeBase).LocalPath);
        }
    }
}

My Working Test Setup:

  • ASP.NET Core 3 WebApplication
  • ASP.NET Core 3 ClassLibrary
  • Both Projects have no reference to each other.
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Content Remove="Pages\**" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="3.0.0" />
  </ItemGroup>

</Project>
services
   .AddRazorPages()
   .AddRazorRuntimeCompilation();
AssemblyLoadContext.Default.LoadFromAssemblyPath(@"C:\path\to\ClassLibrary1.dll");
// plus the MyApplicationPartFactory and attribute from above.

~/Pages/Index.cshtml

@page

<pre>
    output: [
        @(
            new ClassLibrary1.Class1().Method1()
        )
    ]
</pre>

And it shows the expected output:

    output: [ 
        Hallo, World!
    ]

Have a nice day.

like image 2
r-Larch Avatar answered Oct 07 '22 15:10

r-Larch