Home » Advertising » Self-Contained UI: Running One ASP.NET Core MVC Site Inside Another

Self-Contained UI: Running One ASP.NET Core MVC Site Inside Another

If you have ever used any 3rd party packages like Hangfire or Elmah you will have seen a self-contained UI before. These packages when added to a web project will have their own user interfaces which can be accessed from an admin route like /hangfire or /elmah. These packages don’t bring in any HTML, CSS or JavaScript resources, they just work, and they work well.

If you have ever tried to implement a similar self-contained UI in one of your projects you may have found it quite difficult to set up. The development workflow can also be a bit of a pain. You may have had to make trade-offs on development features like editing your HTML and seeing it refresh without having to recompile the app.

With ASP.NET Core, this type of setup has never been easier to implement. You can now simply reference one ASP.NET Core project from another and if you have precompiled your views, it just works!

Before we start, this sample project is hosted on GitHub here or you can download it here.

Let’s take a look…

Project Setup

What we have here is an ASP.NET Core MVC project called ParentSite. This project has a reference to a second project called DashboardExample, this project will contain our self-contained UI which we may later package into a NuGet package. This project also uses MVC and contains a Home controller and an Index view. Running this project directly will give the developer the rich development experience they are used to with no changes.

The DashboardExample exposes two extension methods, one for setting up the services it requires in the dependency injection system, and a second for injecting the middleware it uses into the ASP.NET Core pipeline. We use these extension methods in the ParentSite Startup configuration methods like this…

ParentSite/Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddDashboard();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    ...
    app.UseDashboard();
    ...
}

Those two extension methods are defined as follows in the DashboardMiddleware class of our DashboardExample project.

DashboardMiddleware.cs

using DashboardExample;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Builder
{
    public static class DashboardMiddleware
    {
        public static IServiceCollection AddDashboard(this IServiceCollection services)
        {
            services.AddMvc();

            services.ConfigureRazorViewEngineOptions(opt =
            {
                opt.ViewLocationExpanders.Add(new DashboardExampleLocationRemapper());
            });

            return services;
        }

        public static IApplicationBuilder UseDashboard(this IApplicationBuilder app, DashboardOptions dashboardOptions = null)
        {
            var pathMatch = (dashboardOptions?.Path ?? "dashboard").Trim('/');

            /*
            * Add a route with a dataToken that we can use to try to ensure we only match to classes in the DashboardExample project.
            * 
            * This attempts to prevent clashes if the user is also using MVC. If they also have a HomeController with an Index action
            * then the NamespaceConstraint attribute on our controller along with the data token below will ensure this route only
            * matches to our HomeController Index action.
            */
            app.UseMvc(routes =
            {
                routes.MapRoute(
                        name: "DashboardExample",
                        template: pathMatch + "/{controller=Home}/{action=Index}/{id?}", 
                        defaults: null,
                        constraints: null,
                        dataTokens: new { Namespace = "DashboardExample.DashboardExample.Controllers" });
            });

            return app;
        }
    }
}

Note that the namespace the class is defined in is Microsoft.AspNetCore.Builder by convention so that any projects which reference DashboardExample will have these extension methods available automatically to their Startup class without having to include additional custom namespaces.

If we try and run the ParentSite site now, we will get an error when the HomeController of the DashboardExample project attempts to find the Index view. This is because the view doesn’t exist in the ParentSite project and that is where the view engine will be looking by default. Fortunately for us, we can precompile the views into a DLL and those views will get picked up without us having to do anything!

In order to precompile the views, we will need to edit our DashboardExample project file and add an MVCRazorCompileOnPublish element.

Project Sdk="Microsoft.NET.Sdk.Web"

  PropertyGroup
    TargetFrameworknetcoreapp1.1/TargetFramework
    MvcRazorCompileOnPublishtrue/MvcRazorCompileOnPublish
  /PropertyGroup

Now, as you may have guessed, this will compile the views on publish, not on build. This means that we still can’t run our parent site and have the view load correctly. Yet!

This issue has been brought up before and there is already a community solution provided by Martin Andreas Ullrich which you can find here. All you need to do is add this to your project file:

Target Name="SetMVCRazorOutputPath"
    PropertyGroup
    MvcRazorOutputPath$(OutputPath)/MvcRazorOutputPath
    /PropertyGroup
/Target

Target Name="_MvcRazorPrecompileOnBuild"
        DependsOnTargets="SetMvcRazorOutputPath;MvcRazorPrecompile"
        AfterTargets="Build" 
        Condition=" '$(IsCrossTargetingBuild)' != 'true' " /

    Target Name="IncludePrecompiledViewsInPublishOutput"
        DependsOnTargets="_MvcRazorPrecompileOnBuild"
        BeforeTargets="PrepareForPublish"
        Condition=" '$(IsCrossTargetingBuild)' != 'true' "
    ItemGroup
    _PrecompiledViewsOutput Include="$(MvcRazorOutputPath)$(MSBuildProjectName).PrecompiledViews.DLL" /
    _PrecompiledViewsOutput Include="$(MvcRazorOutputPath)$(MSBuildProjectName).PrecompiledViews.pdb" /
    ContentWithTargetPath Include="@(_PrecompiledViewsOutput-'%(FullPath)')"
        RelativePath="%(_PrecompiledViewsOutput.Identity)" 
        TargetPath="%(_PrecompiledViewsOutput.Filename)%(_PrecompiledViewsOutput.Extension)" 
        CopyToPublishDirectory="PreserveNewest" /
    /ItemGroup
/Target

Now when you build your DashboardExampleProject you should see a PrecompiledViews DLL alongside the project DLL.

Project Bin

This DLL contains the Index view which will be used at runtime instead of the one on disk. If you want to continue to be able to edit your view and refresh the page without having to rebuild each time, disable the IncludePrecompiledViewsInPublishOutput step that you just added to your project file.

Great, now we just need to run the ParentSite project, right? Err, almost. At the moment, the ParentSite project references the DashboardExample project and knows that DashboardExample.DLL exists, but has no idea that PrecompiledViews.DLL exists so won’t copy it to its output directory. If we were packaging DashboardExample into a NuGet package we could include it in the package and it wouldn’t be an issue. However, as we’re referencing the DashboardExample project directly we just need to manually add a reference from our ParentSite project to the PrecompiledViews.DLL that’s been build to the DashboardExample project’s bin directory.

AssemblyReference

Finally, we can run our ParentSite project and we will see this…

ParentSite

And when we go to /dashboard, we will see our view which has been loaded from the pre-compiled DLL.

DashboardExample

Great! This works if our project doesn’t also use MVC, but what if we are adding this dashboard to an existing MVC project? Well, I’m glad you asked! You will quickly run into issues with views clashing if you have a Home controller with an Index view as we now have these compiled into that DLL and these will take precedence over any view files on disk. To get around this we need a way of telling MVC to only use the pre-compiled views from this DLL for requests processed by our DashboardExample project.

Enter IViewLocationExpander. You may have seen DashboardExampleLocationRemapper referenced in the code snippet above when we register our services. This class is used to tell MVC where to load the views from on a per request basis.

DashboardExampleLocationRemapper.cs

using System.Collections.Generic;
using Microsoft.ASP.NET Core.Mvc.Razor;

namespace DashboardExample
{
    internal class DashboardExampleLocationRemapper : IViewLocationExpander
    {
        private readonly IEnumerablestring preCompiledViewLocations;

        public DashboardExampleLocationRemapper()
        {
            // custom view locations for the pre-compiled views
            this.preCompiledViewLocations = new[]
            {
                "/DashboardExample/Views/{1}/{0}.cshtml",
                "/DashboardExample/Views/Shared/{1}/{0}.cshtml",
            };
        }

        /// inheritdoc /
        public void PopulateValues(ViewLocationExpanderContext context)
        {
            // check if we're trying to execute an action from a DashboardExample route
            if (context.ActionContext.ActionDescriptor.MatchesNamespaceInRoute(context))
            {
                /*
                 * by adding a value it identifies the view location is different from any similar locations in the user's project
                 * e.g. if the user also has a file at /Views/Home/Index.cshtml this will make sure that's not matched the same
                 * as ours at /DashboardExample/Views/Home/Index.cshtml even though they're both /Home/Index.cshtml views
                 */
                context.Values.Add("DashboardExample", bool.TrueString);
            }
        }

        /// inheritdoc /
        public IEnumerablestring ExpandViewLocations(ViewLocationExpanderContext context, IEnumerablestring viewLocations)
        {
            string isDashboardExample;
            if (context.Values.TryGetValue("DashboardExample", out isDashboardExample)  isDashboardExample == bool.TrueString)
            {
                return this.preCompiledViewLocations;
            }
            else
            {
                return viewLocations;
            }
        }
    }
}

You may have noticed in the above code sample that the Views folder is now in its own sub folder which matches the project name. The reason for this is we need the view resolution not to clash. For example, we don’t want a view for the Index action of the Home controller of the ParentSite project to return the precompiled view from the DashboardExample project. This parent folder convention pretty much guarantees that we won’t get view path clashes.

The last issue we have is that we have controller clashes. We have two Home controllers available to MVC. So when it tries to resolve which class to use when someone tries to go to /home/index, it has two possible choices. In previous versions of MVC, you could specify a namespace to limit which controllers could be used on a per route basis. In the latest version, this feature no longer exists. We can, however, achieve the same result, we just have to create our own namespace constraint. Stas Boyarincev shows us how to do that with this great StackOverflow answer.

And that’s it. What we now have is a dashboard which we can add to any ASP.NET Core project, whether that project uses MVC or not!

Check out the GitHub repository here or download the example project source here. Enjoy!

Leave a Reply

Your email address will not be published. Required fields are marked *

*
*

cover letter