viewengineoverridingrazorgenerator

Using a custom RazorViewEngine AND RazorGenerator precompiled views


I am trying to use a custom (derived) RazorViewEngine AND precompiled views using RazorGenerator.

Some context:

We have a base product that we use for multiple client implementations. With that we have a core set of base views. Most of the views work most of the time. Right now we end up copying existing views for each new solution and modifying as needed. This ends up with 95% of the views being the same between clients and 5% changed.

What I want to do take a base set of views, compile them into a DLL and re-use it across clients. So far I have that working well using RazorGenerator.

Now the next step is to allow for customization (overrides) of views. There is a caveat though. Our application has two "modes" that a user is in. The mode they are in could require a different view.

I have created a derived class from the RazorGeneratorView. This view basically inspects the "OrderingMode" from a UserProfile object that Autofac resolves. Based on the mode - the Path Locator is replaced for the view resolution.

The idea being individual client applications will attempt to resolve the view first in the traditional Views folder. Only I am adding in a sub-directory of Views/{OrderingMode}/{Controller}/{View}.cshtml.

If the view is not found - then it will look in the compiled library (the core views).

This allows me to override individual views / partials as need be for clients.

    public PosViewEngine() : base()
    {
        //{0} = View Name
        //{1} = ControllerName
        //{2} = Area Name
        AreaViewLocationFormats = new[]
        {
            //First look in the hosting application area folder / Views / ordering type
            //Areas/{AreaName}/{OrderType}/{ControllerName}/{ViewName}.cshtml
            "Areas/{2}/Views/%1/{1}/{0}.cshtml",

            //Next look in the hosting application area folder / Views / ordering type / Shared
            //Areas/{AreaName}/{OrderType}/{ControllerName}/{ViewName}.cshtml
            "Areas/{2}/Views/%1/Shared/(0}.cshtml",

            //Finally look in the IMS.POS.Web.Views.Core assembly
            "Areas/{2}/Views/{1}/{0}.cshtml"
        };

        //Same format logic
        AreaMasterLocationFormats = AreaViewLocationFormats;

        AreaPartialViewLocationFormats = new[]
        {
             //First look in the hosting application area folder / Views / ordering type
            //Areas/{AreaName}/{OrderType}/{ControllerName}/Partials/{PartialViewName}.cshtml
            "Areas/{2}/Views/%1/{1}/Paritals/{0}.cshtml",

            //Next look in the hosting application area folder / Views / ordering type / Shared
            //Areas/{AreaName}/{OrderType}/{ControllerName}/{ViewName}.cshtml
            "Areas/{2}/Views/%1/Shared/(0}.cshtml",

            //Finally look in the IMS.POS.Web.Views.Core
            "Areas/{2}/Views/{1}/{0}.cshtml"
        };

        ViewLocationFormats = new[]
        {
            "Views/%1/{1}/{0}.cshtml",
            "Views/%1/Shared/{0}.cshtml",
            "Views/{1}/{0}.cshtml",
            "Views/Shared/{0}.cshtml"
        };

        MasterLocationFormats = ViewLocationFormats;

        PartialViewLocationFormats = new[]
        {
            "Views/%1/{1}/Partials/{0}.cshtml",
            "Views/%1/Shared/{0}.cshtml",
            "Views/{1}/Partials/{0}.cshtml",
            "Views/Shared/{0}.cshtml"
        };




    }

    protected override IView CreatePartialView(ControllerContext controllerContext, string partialPath)
    {
        return base.CreatePartialView(controllerContext, partialPath.ReplaceOrderType(CurrentOrderingMode()));
    }

    protected override IView CreateView(ControllerContext controllerContext, string viewPath, string masterPath)
    {
        OrderType orderType = CurrentOrderingMode();
        return base.CreateView(controllerContext, viewPath.ReplaceOrderType(orderType), masterPath.ReplaceOrderType(orderType));
    }

    protected override bool FileExists(ControllerContext controllerContext, string virtualPath)
    {
        return base.FileExists(controllerContext, virtualPath.Replace("%1/",string.Empty));
    }


    private OrderType CurrentOrderingMode()
    {
        OrderType result;
        _profileService = DependencyResolver.Current.GetService<IUserProfileService>();

        if (_profileService == null || _profileService.OrderingType == 0)
        {
            IApplicationSettingService settingService =
                DependencyResolver.Current.GetService<IApplicationSettingService>();

            result =
                settingService.GetApplicationSetting(ApplicationSettings.DefaultOrderingMode)
                    .ToEnumTypeOf<OrderType>();
        }
        else
        {
            result = _profileService.OrderingType;
        }

        return result;
    } 



}

Here is the StartUp class RazorGenerator uses to Register the ViewEngine.

public static class RazorGeneratorMvcStart
{
    public static void Start()
    {
        var engine = new PrecompiledMvcEngine(typeof(RazorGeneratorMvcStart).Assembly)
        {
            UsePhysicalViewsIfNewer = HttpContext.Current.Request.IsLocal
        };

        ViewEngines.Engines.Insert(0, engine);

        // StartPage lookups are done by WebPages.
        VirtualPathFactoryManager.RegisterVirtualPathFactory(engine);
    }
}

The problem is:

  1. This code is executed last (after I register the PosViewEngine) and it inserts the engine at the first position (meaning this is the engine that gets resolved 1st when serving up responses). This ends up finding a view - it is the core view.
  2. If I change the code in the StartUp to Register my custom view engine first first and then the RazorGenerator engine

     public static void Start()
    {
        var engine = new PrecompiledMvcEngine(typeof(RazorGeneratorMvcStart).Assembly)
        {
            UsePhysicalViewsIfNewer = HttpContext.Current.Request.IsLocal
        };
    
        ViewEngines.Engines.Clear();
        ViewEngines.Engines.Insert(0, new PosViewEngine());
        ViewEngines.Engines.Insert(1, engine);
    
        // StartPage lookups are done by WebPages.
        VirtualPathFactoryManager.RegisterVirtualPathFactory(engine);
    }
    

I end up with an exception on the FileExists(ControllerContext controllerContext, string virtualPath) method - "The relative virtual path 'Views/Account/LogOn.cshtml' is not allowed here."

It obviously has something to do with both physical and virtual paths being mixed together.

It looks like someone else was trying to do the same thing here but I didn't see an answer on this.


Solution

  • For anyone else wanting to try this approach I'll post the answer. Basically you need to implement a custom view engine that derives from the PrecompiledMvcEngine found in the RazorGenerator assembly.

    public class PosPrecompileEngine : PrecompiledMvcEngine
    {
        private IUserProfileService _profileService;
    
    
    
        public PosPrecompileEngine(Assembly assembly) : base(assembly)
        {
            LocatorConfig();
        }
    
        public PosPrecompileEngine(Assembly assembly, string baseVirtualPath) : base(assembly, baseVirtualPath)
        {
            LocatorConfig();
        }
    
        public PosPrecompileEngine(Assembly assembly, string baseVirtualPath, IViewPageActivator viewPageActivator) : base(assembly, baseVirtualPath, viewPageActivator)
        {
            LocatorConfig();
        }
    
        protected override IView CreatePartialView(ControllerContext controllerContext, string partialPath)
        {
            return base.CreatePartialView(controllerContext, partialPath.ReplaceOrderType(CurrentOrderingMode()));
        }
    
        protected override IView CreateView(ControllerContext controllerContext, string viewPath, string masterPath)
        {
            OrderType orderType = CurrentOrderingMode();
            return base.CreateView(controllerContext, viewPath.ReplaceOrderType(orderType), masterPath.ReplaceOrderType(orderType));
        }
    
        protected override bool FileExists(ControllerContext controllerContext, string virtualPath)
        {
            return base.FileExists(controllerContext, virtualPath.ReplaceOrderType(CurrentOrderingMode()));
        }
    }
    

    In this class - I override the Locator Paths. Because I have the "base" compiled views in another assembly from the web application - we implemented a convention where the view engine will first look in a PosViews/{ordering mode}/{controller}/{view} path in the web application. If a view is not located -then it will look in the traditional /Views/controller/view. The trick here is the later is a virtual path located in another class library.

    This allowed us to "override" an existing view for the application.

        private void LocatorConfig()
        {
            //{0} = View Name
            //{1} = ControllerName
            //{2} = Area Name
            AreaViewLocationFormats = new[]
            {
                //First look in the hosting application area folder / Views / ordering type
                //Areas/{AreaName}/{OrderType}/{ControllerName}/{ViewName}.cshtml
                "PosAreas/{2}/Views/%1/{1}/{0}.cshtml",
    
                //Next look in the hosting application area folder / Views / ordering type / Shared
                //Areas/{AreaName}/{OrderType}/{ControllerName}/{ViewName}.cshtml
                "PosAreas/{2}/Views/%1/Shared/(0}.cshtml",
    
                //Next look in the POS Areas Shared
                "PosAreas/{2}/Views/Shared/(0}.cshtml",
    
                //Finally look in the IMS.POS.Web.Views.Core assembly
                "Areas/{2}/Views/{1}/{0}.cshtml"
            };
    
            //Same format logic
            AreaMasterLocationFormats = AreaViewLocationFormats;
    
            AreaPartialViewLocationFormats = new[]
            {
                 //First look in the hosting application area folder / Views / ordering type
                //Areas/{AreaName}/{OrderType}/{ControllerName}/Partials/{PartialViewName}.cshtml
                "PosAreas/{2}/Views/%1/{1}/Partials/{0}.cshtml",
    
                //Next look in the hosting application area folder / Views / ordering type / Shared
                //Areas/{AreaName}/{OrderType}/{ControllerName}/{ViewName}.cshtml
                "PosAreas/{2}/Views/%1/Shared/(0}.cshtml",
    
                //Next look in the hosting application shared folder
                "PosAreas/{2}/Views/Shared/(0}.cshtml",
    
                //Finally look in the IMS.POS.Web.Views.Core
                "Areas/{2}/Views/{1}/{0}.cshtml"
            };
    
            ViewLocationFormats = new[]
            {
                "~/PosViews/%1/{1}/{0}.cshtml",
                "~/PosViews/%1/Shared/{0}.cshtml",
                "~/PosViews/Shared/{0}.cshtml",
                "~/Views/{1}/{0}.cshtml",
                "~/Views/Shared/{0}.cshtml"
            };
    
            MasterLocationFormats = ViewLocationFormats;
    
            PartialViewLocationFormats = new[]
            {
                "~/PosViews/%1/{1}/{0}.cshtml",
                "~/PosViews/%1/Shared/{0}.cshtml",
                 "~/PosViews/Shared/{0}.cshtml",
                "~/Views/{1}/{0}.cshtml",
                "~/Views/Shared/{0}.cshtml"
            };
        }
    

    Register this engine in your application start up events.

       public static void Configure()
        {
            var engine = new PosPrecompileEngine(typeof(ViewEngineConfig).Assembly)
            {
                UsePhysicalViewsIfNewer = true,
                PreemptPhysicalFiles = true
            };
            ViewEngines.Engines.Add(engine);
    
            // StartPage lookups are done by WebPages.
            VirtualPathFactoryManager.RegisterVirtualPathFactory(engine);
        }
    

    Here is the final key. When RazorGenerator gets installed view NuGet - you end up with this start-up class that will run on startup

    [assembly: WebActivatorEx.PostApplicationStartMethod(typeof(Views.Core.RazorGeneratorMvcStart), "Start")]
    
    
    public static class RazorGeneratorMvcStart
    {
        public static void Start()
        {
            var engine = new PrecompiledMvcEngine(typeof(RazorGeneratorMvcStart).Assembly)
            {
                UsePhysicalViewsIfNewer = true,
                PreemptPhysicalFiles = true
            };
            ViewEngines.Engines.Add(engine);
    
            // StartPage lookups are done by WebPages.
            VirtualPathFactoryManager.RegisterVirtualPathFactory(engine);
        }
    } 
    

    By default - RazorGenerator adds ViewEngine to the first in the collection

    ViewEngines.Engines.Insert(0,engine);
    

    You need to change that to an add

    ViewEngines.Engines.Add(engine); 
    

    So it is added to engines last - this way your custom ViewEngine is used FIRST in locating views.

    This approach allows you to reuse views in multiple applications while allowing a means to override that view.

    This may be overkill for most applications - bust as I mentioned in the question - this is base product that we use to develop multiple client applications. Trying achieve reuse while maintaining a level of flexibility on a per client basis is something we were trying to achieve.