Is there a way to process an MVC view (aspx file) from a non-web application?
Asked Answered
K

5

8

I have a background service running which sends out emails to users of my website. I would like to write the email templates as MVC views, to keep things consistent (so that the same model can be used to send out an email as to display a web page).

Unfortunately, when I try to do a LoadControl (which simply patches through to BuildManager.CreateInstanceFromVirtualPath), I get the following:

System.NullReferenceException at
  System.Web.dll!System.Web.VirtualPath.GetCacheKey() + 0x26 bytes  
  System.Web.dll!System.Web.Compilation.BuildManager.GetCacheKeyFromVirtualPath + 0x2a bytes
  System.Web.dll!System.Web.Compilation.BuildManager.GetVPathBuildResultFromCacheInternal  + 0x30 bytes

It seems that if I were to set MvcBuildViews to true, that there should be some easy way to use the compiled views to build an email template, but I can't figure out how.

I found the following blog from Rick Strahl, which may do the trick: http://www.west-wind.com/presentations/aspnetruntime/aspnetruntime.asp

However, it seems to start up a whole ASP.NET server to process requests.

Is there a simple way to load an MVC view & render it? Or is the only way to load up the ASP.NET runtime as suggested by Rick Strahl?

Kabuki answered 13/9, 2010 at 16:44 Comment(6)
I've been working on a similar problem. I spent a good 3 hours messing around with things and although i got things working I had a number of issues. Now doing as eglasius recommend and using the the spark view engine for all views that are emails.Locomotion
I've used the Spark view engine to do this and it works very well.Inapprehensive
Trying to get a central repository of all data related to this here, so adding a link to another question that is also related: #1662084Kabuki
Thanks everyone for the "other view engine" suggestion, but I really dislike these new engines which are being created. I'm the first one to admit that there's a lot about ASPX files that suck, but I think the core of it is the best there is. It seems a stigma has formed around them because of the really bad parts (cough cough ViewState), but I prefer an XML-compliant file where custom controls are specified the same way as HTML (instead of with some funky @ or #).Kabuki
What I've discovered: LoadControl fails because HostingEnvironment.VirtualPathProvider is null. Getting LoadControl to work correctly seems to be quite difficult, and yet to be figured out.Kabuki
Got it figured out. Thanks to everyone who contributed. See my own answer which I marked as the correct answer below.Kabuki
K
7

Ended up answering my own question :)

public class AspHost : MarshalByRefObject
{
    public string _VirtualDir;
    public string _PhysicalDir;

    public string ViewToString<T>(string aspx, Dictionary<string, object> viewData, T model)
    {
        StringBuilder sb = new StringBuilder();
        using (StringWriter sw = new StringWriter(sb))
        {
            using (HtmlTextWriter tw = new HtmlTextWriter(sw))
            {
                var workerRequest = new SimpleWorkerRequest(aspx, "", tw);
                HttpContext.Current = new HttpContext(workerRequest);

                ViewDataDictionary<T> viewDataDictionary = new ViewDataDictionary<T>(model);
                foreach (KeyValuePair<string, object> pair in viewData)
                {
                    viewDataDictionary.Add(pair.Key, pair.Value);
                }

                object view = BuildManager.CreateInstanceFromVirtualPath(aspx, typeof(object));

                ViewPage viewPage = view as ViewPage;
                if (viewPage != null)
                {
                    viewPage.ViewData = viewDataDictionary;
                }
                else
                {
                    ViewUserControl viewUserControl = view as ViewUserControl;
                    if (viewUserControl != null)
                    {
                        viewPage = new ViewPage();
                        viewPage.Controls.Add(viewUserControl);
                    }
                }

                if (viewPage != null)
                {
                    HttpContext.Current.Server.Execute(viewPage, tw, true);

                    return sb.ToString();
                }

                throw new InvalidOperationException();
            }
        }
    }

    public static AspHost SetupFakeHttpContext(string physicalDir, string virtualDir)
    {
        return (AspHost)ApplicationHost.CreateApplicationHost(
            typeof(AspHost), virtualDir, physicalDir);
    }
}

Then, to render a file:

var host = AspHost.SetupFakeHttpContext("Path/To/Your/MvcApplication", "/");
var viewData = new ViewDataDictionary<SomeModelType>(){ Model = myModel };
String rendered = host.ViewToString("~/Views/MyView.aspx", new Dictionary<string, object>(viewData), viewData.Model);
Kabuki answered 22/9, 2010 at 2:25 Comment(0)
C
11

The default asp.net view engine is tied to the asp.net engine. Its tied to the context, I think you can work around it but its definitely not simple.

The issue is with the default view engine + asp.net engine combination, other view engines shouldn't have that issue. At the very least the spark view engine doesn't.


Edit: OP solved with the last hints, but fwiw my version that uses the controller home index action of the default asp.net mvc project template:

public class MyAppHost : MarshalByRefObject
{
    public string RenderHomeIndexAction()
    {
        var controller = new HomeController();
        using (var writer = new StringWriter())
        {
            var httpContext = new HttpContext(new HttpRequest("", "http://example.com", ""), new HttpResponse(writer));
            if (HttpContext.Current != null) throw new NotSupportedException("httpcontext was already set");
            HttpContext.Current = httpContext;
            var controllerName = controller.GetType().Name;
            var routeData = new RouteData();
            routeData.Values.Add("controller", controllerName.Remove(controllerName.LastIndexOf("Controller")));
            routeData.Values.Add("action", "index");
            var controllerContext = new ControllerContext(new HttpContextWrapper(httpContext), routeData, controller);
            var res = controller.Index();
            res.ExecuteResult(controllerContext);
            HttpContext.Current = null;
            return writer.ToString();
        }
    }
}

... from a separate project:

    [TestMethod]
    public void TestIndexAction()
    {
        var myAppHost = (MyAppHost)ApplicationHost.CreateApplicationHost(
            typeof(MyAppHost), "/", @"c:\full\physical\path\to\the\mvc\project");
        var view = myAppHost.RenderHomeIndexAction();
        Assert.IsTrue(view.Contains("learn more about"));

    }

Some extra notes:

  • url in new HttpRequest doesn't matter, but needs to be a valid url
  • it isn't meant to be used from an asp.net app that already has a context / that said, I'm not sure if it'd actually spawn the new AppDomain and work
  • Controller type's constructor and specific instance is explicit in the code, could be replaced with something to be passed in the parameters, but need to deal with the restrictions of MarshalByRef / worst case some simple reflection could be used for it
Cowell answered 13/9, 2010 at 17:13 Comment(7)
Well, I'm sure I'll regret asking this, but... how does one work around it?Kabuki
@Kabuki one way is to host the asp.net runtime like in the link in your question / which starts an AppDomain. Another one is to replace HttpContext.Current, and when doing so hook an output stream you control to the response --- so you can actually get to the rendered view. One of the issue is that even if there are bits in place that make it look like it would could write to a writer you provide, the asp.net engine always go directly to .Response to write ...Cowell
... . The same doesn't happen with partial views, but you still have to build a somewhat populated RequestContext or ControllerContext to be able to retrieve the view, and then do something like in this answer #3700505Cowell
Unfortunately the link I included to Rick Strahl's page seems to simply process an ASP.Net request, which is not quite what I want - waht I'd like to be able to do is pass the model, as is done in the link you provided.Kabuki
Too bad newlines aren't accepted in comments - continuing on, the CreateApplicationHost/SimpleWorkerRequest approach simply allows me to make an HTTP request, and does not allow me to create the view passing in a model, nor does it allow me to use any of the Controller methods directly (as they all rely on LoadControl)Kabuki
Very nice! Yup both your answer and mine are perfect, hopefully will be useful to anyone else who wants to do this madness. And now they have 2 different ways of doing it. Thanks again Eglasius! Upvoted yours.Kabuki
Just for the record - this works with razor too. Not that there's any reason it wouldn't, but thought I'd mention it. The major caveat is you have to copy all the binaries into the "bin" directory under the full path to the project, where normally they would not be for an unpublished project. Usually they're in bin/debug or bin/release.Unbalanced
K
7

Ended up answering my own question :)

public class AspHost : MarshalByRefObject
{
    public string _VirtualDir;
    public string _PhysicalDir;

    public string ViewToString<T>(string aspx, Dictionary<string, object> viewData, T model)
    {
        StringBuilder sb = new StringBuilder();
        using (StringWriter sw = new StringWriter(sb))
        {
            using (HtmlTextWriter tw = new HtmlTextWriter(sw))
            {
                var workerRequest = new SimpleWorkerRequest(aspx, "", tw);
                HttpContext.Current = new HttpContext(workerRequest);

                ViewDataDictionary<T> viewDataDictionary = new ViewDataDictionary<T>(model);
                foreach (KeyValuePair<string, object> pair in viewData)
                {
                    viewDataDictionary.Add(pair.Key, pair.Value);
                }

                object view = BuildManager.CreateInstanceFromVirtualPath(aspx, typeof(object));

                ViewPage viewPage = view as ViewPage;
                if (viewPage != null)
                {
                    viewPage.ViewData = viewDataDictionary;
                }
                else
                {
                    ViewUserControl viewUserControl = view as ViewUserControl;
                    if (viewUserControl != null)
                    {
                        viewPage = new ViewPage();
                        viewPage.Controls.Add(viewUserControl);
                    }
                }

                if (viewPage != null)
                {
                    HttpContext.Current.Server.Execute(viewPage, tw, true);

                    return sb.ToString();
                }

                throw new InvalidOperationException();
            }
        }
    }

    public static AspHost SetupFakeHttpContext(string physicalDir, string virtualDir)
    {
        return (AspHost)ApplicationHost.CreateApplicationHost(
            typeof(AspHost), virtualDir, physicalDir);
    }
}

Then, to render a file:

var host = AspHost.SetupFakeHttpContext("Path/To/Your/MvcApplication", "/");
var viewData = new ViewDataDictionary<SomeModelType>(){ Model = myModel };
String rendered = host.ViewToString("~/Views/MyView.aspx", new Dictionary<string, object>(viewData), viewData.Model);
Kabuki answered 22/9, 2010 at 2:25 Comment(0)
D
0

We used Cassini web server for our Web application while it was offline. May be this approach will work for you too? Take a look here Cassini

Dyarchy answered 13/9, 2010 at 17:23 Comment(0)
H
0

In a word, no -- ASP.NET view rendering is married to the web response cycle. Probably was quite necessary to get reasonable performance in the old days.

Now, some other options exist, including the new razor view engine from Microsoft or the open-source Spark View Engine.

Herson answered 13/9, 2010 at 17:27 Comment(0)
K
0

This was my first attempt, and it failed. See above for the correct and working answer

This is as close as I was able to get, but it still didn't work. Now it complains about get_Server causing a NullreferenceException.

Just thought I'd post on here what I did and how far I got, in case anyone wants to continue the research.

I modified my csproj file to generate an assembly with the precompiled ASPX files, as such:

<PropertyGroup>
...
    <MvcBuildViews>true</MvcBuildViews>
    <AspNetMergePath>C:\Program Files\Microsoft SDKs\Windows\v7.0A\bin\NETFX 4.0 Tools\aspnet_merge.exe</AspNetMergePath>
...
</PropertyGroup>
<Target Name="AfterBuild" Condition="'$(MvcBuildViews)'=='true'">
    <AspNetCompiler PhysicalPath="$(ProjectDir)" TargetPath="$(ProjectDir)..\$(ProjectName)_CompiledAspx" Updateable="false" VirtualPath="$(ProjectName)" Force="true" />
    <Exec Command="%22$(AspNetMergePath)%22 %22$(ProjectDir)..\$(ProjectName)_CompiledAspx%22 -o %22$(ProjectName)_views%22" />
    <Copy SourceFiles="$(ProjectDir)..\$(ProjectName)_CompiledAspx\bin\$(ProjectName)_views.dll" DestinationFolder="$(TargetDir)CompiledAspx\" />
</Target>

This created a "MyProject_CompiledAspx.dll", which I then referenced from my application. This, however, caused a new NullReferenceException.

It's a pitty that ASPX files, being as powerful as they are, are so tightly integrated with the ASP.NET server.

Kabuki answered 17/9, 2010 at 15:4 Comment(4)
Woops... I accidentally pasted twice. Ignore the 2nd set of <PropertyGroup> and <Target>Kabuki
I decided I'd give it a further try to my findings from when I tried addressing it a good time ago, and definitely got it working, I'll be posting my solution soon. You still need the AppDomain, but you'll be able to work directly with the View and Controller objects as you wanted.Cowell
Thanks for all the tips eglasius, but ended up solving it myselfKabuki
glad to hear, that's what I meant (AspHost.SetupFakeHttpContext is actually AspHost.SetupAspnetAppDomain). Although I used a more view engine neutral solution, mocking a view context and calling view.RenderView(viewContext). Posted an edit in my answer, with a version that mocks the controllercontext and uses the controller action instead of the view directly ... ps. just upvoted your answer.Cowell

© 2022 - 2024 — McMap. All rights reserved.