I’m doing a session on hosting the ASP.NET runtime in desktop applications at Portland CodeCamp this weekend, so I had to dust off my old samples and check through the code again. I had written an extensive article on this subject a few years back, that covers this topic in some detail. The article was originally published in Code Magazine in 2002 some time.
Since then I’d been updating some of the classes presented, fixing a few issues that were reported and added some new features that proved useful in my own work.
A few weeks ago I got a message from on user who mentioned that the class doesn’t work in .NET 2.0 and sure enough as I was getting ready to review for the session I found that the class as described in the article doesn’t work with ASP.NET 2.0.
ASP.NET 2.0 breaks my original code
I’m not surprised that the code breaks in 2.0 because I used some custom AppDomain creation that tries to desperately duplicate all the things that ASP.NET requires to efficiently host the ASP.NET runtime in an AppDomain and pass back an Application Host reference.
In the original version of the class I present I implement a custom ASP.NET Runtime Loader method, that replaces the default functionality that ApplicationHost.CreateApplicationHost provides. The problem with CreateApplicationHost is that it is not very configurable. The worst of the problems arises from the fact that if you use a custom hosting class (which you pretty much have to do if you want to do anything useful with the runtime) the assembly that the hosting class resides in must exist in the Web application’s bin directory. This means you have to copy the DLL into the bin directory. Along the same lines if you share any types between the main application and the ASP.NET host environment, you have to make sure that the assemblies exist in both the root application directory and in the ASP.NET host application’s bin directory. Needless to say this is a bit of a hassle as you have to make sure you keep the assemblies in sync.
So when I built my wrapper class, I created a custom method that created an AppDomain and configured it appropriately to be usable as an ASP.NET host AppDomain. Most of the information for this I found by using Reflector to dig into the innards of System.Web.Hosting and picking out the undocument AppDomain configuration settings.
It worked well. The end result was that my CreateApplicationHost method had optional parameters that allowed me to pass in a PrivateBin path that could be assigned to the AppDomain, to have it look in the host application’s execution path for assemblies, which did away with the whole issue of having to copy the assemblies to the Web applications.
public static wwAspRuntimeProxy CreateApplicationHostX(Type hostType,
string virtualDir, string physicalDir,
string ApplicationBase, string ConfigurationFile)
{
if (!(physicalDir.EndsWith("\\")))
physicalDir = physicalDir + "\\";
string aspDir = HttpRuntime.AspInstallDirectory;
string domainId = "ASPHOST_" + DateTime.Now.ToString().GetHashCode().ToString("x");
string appName = (virtualDir + physicalDir).GetHashCode().ToString("x");
AppDomainSetup setup = new AppDomainSetup();
// setup.ApplicationBase = physicalDir;
// setup.PrivateBinPath = Directory.GetCurrentDirectory();
setup.ApplicationName = appName;
setup.ConfigurationFile = ConfigurationFile;
/// Assign the application base where this class' assembly is hosted
/// Otherwise the ApplicationBase is inherited from the current process
if (ApplicationBase != null && ApplicationBase != "")
setup.PrivateBinPath = ApplicationBase;
AppDomain Domain = AppDomain.CreateDomain(domainId, GetDefaultDomainIdentity(), setup);
Domain.SetData(".appDomain", "*");
Domain.SetData(".appPath", physicalDir);
Domain.SetData(".appVPath", virtualDir);
Domain.SetData(".domainId", domainId);
Domain.SetData(".hostingVirtualPath", virtualDir);
Domain.SetData(".hostingInstallDir", aspDir);
ObjectHandle oh = Domain.CreateInstance(hostType.Module.Assembly.FullName,
hostType.FullName);
wwAspRuntimeProxy Host = (wwAspRuntimeProxy) oh.Unwrap();
// *** Save virtual and physical path so we can tell where app runs later
Host.VirtualPath = virtualDir;
Host.PhysicalDirectory = physicalDir;
// *** Save Domain so we can unload later
Host.AppDomain = Domain;
return Host;
}
The key feature is the PrivateBinPath which adds the application’s base path into the locations that ASP.NET will check for assemblies (ASP.NET adds the bin folder to that list).
Unfortunately, this functionality no longer works with ASP.NET 2.0. It looks like the AppDomain startup and request routing mechanisms still work, but I get a permissions exception that indicates that certain hosting permissions have not been applied. In other words there’s more happening in 2.0 to configure the ASP.NET AppDomain. Looking through System.Hosting.Web I can see that the Hosting setup has become much more sophisticated with a number of new objects (HostingEnvironment, HostingManager), none of which appear to be exposed for direct public consumption.
So, I decided it’s probably not a good idea to continue down this path of creating my own domain. Which brings me back to using the stock ASP.NET CreateApplicationHost() method to create the AppDomain and Host reference.
The new solution
To work around the assembly copy issue I added a property to my wwASPRuntimeHost called ShadowCopyAssemblies. This property is a semi-colon delimited list of assemblies that need to be ‘shadow copied’ to the ASP.NET application’s bin directory. The class does this transparently when the application host is started.
There are two things that need to be copied actually – the assembly that holds the Host type (the class that gets instantiated in the ASP.NET AppDomain and becomes the host as well as any other assemblies that are shared by both the local app and the ASP.NET app (such as when objects are passed to the executing ASP.NET page via the wwASPRuntimeHost.Context object).
Copying of the host assembly is accomplished right inside of the new CreateApplicationHost() method:
public static wwAspRuntimeProxy CreateApplicationHost(Type hostType,
string virtualDir, string physicalDir)
{
if (!(physicalDir.EndsWith("\\")))
physicalDir = physicalDir + "\\";
// *** Copy this hosting DLL into the /bin directory of the application
string FileName = Assembly.GetExecutingAssembly().Location;
try
{
if (!Directory.Exists(physicalDir + "bin\\"))
Directory.CreateDirectory(physicalDir + "bin\\");
string JustFname = Path.GetFileName(FileName);
File.Copy(FileName, physicalDir + "bin\\" + JustFname, true);
}
catch { ;}
wwAspRuntimeProxy Proxy = ApplicationHost.CreateApplicationHost(
hostType,
virtualDir,
physicalDir)
as wwAspRuntimeProxy;
if (Proxy != null)
// *** Grab the AppDomain reference and add the ApplicatioÿBase
// *** Must call into the Proxy to do this
Proxy.CaptureAppDomain();
return Proxy;
}
This eliminates any need to copy anything in most situations and makes sure the ASP.NET host ‘just runs’ without any configuration. This also means you can dynamically create a directory, copy files into it and start executing ASP.NET pages out of it, which can be very powerful!
For the ShadowCopyAssemblies, I use a method in the top level wwASPRuntimeHost class, which first copies the files specified in the property, then starts the runtime host.
/// <summary>
/// Starts ASP.Net runtime hosting by creating new appdomain and loading runtime into it.
/// </summary>
/// <returns>true or false</returns>
public bool Start()
{
if (this.Proxy == null)
{
// *** Make sure ASP.Net registry keys exist
// *** if IIS was never registered, required aspnet_isapi.dll
// *** cannot be found otherwise
this.GetInstallPathAndConfigureAspNetIfNeeded();
if (this.VirtualPath.Length == 0 || this.PhysicalDirectory.Length == 0)
{
this.ErrorMessage = "Virtual or Physical Path not set.";
this.Error = true;
return false;
}
// *** Force any assemblies assemblies to be copied
this.MakeShadowCopies(this.ShadowCopyAssemblies);
try
{
this.Proxy = wwAspRuntimeProxy.Start(this.PhysicalDirectory ,
this.VirtualPath);
// *** Assign these so the proxy knows the paths
this.Proxy.PhysicalDirectory = this.PhysicalDirectory;
this.Proxy.VirtualPath = this.VirtualPath;
}
catch(Exception ex)
{
this.ErrorMessage = ex.Message;
this.Error = true;
this.Proxy = null;
return false;
}
this.Cookies.Clear();
}
return true;
}
/// <summary>
/// Copies any assemblies marked for ShadowCopying into the BIN directory
/// of the Web physical director. Copies only
/// if the assemblies in the source dir is newer
/// </summary>
private void MakeShadowCopies(string ShadowCopyAssemblies)
{
if (ShadowCopyAssemblies == null ||
ShadowCopyAssemblies == string.Empty)
return;
string[] Assemblies = ShadowCopyAssemblies.Split(';', ',');
foreach (string Assembly in Assemblies)
{
try
{
string TargetFile = PhysicalDirectory + "bin\\" + Path.GetFileName(Assembly);
if (File.Exists(TargetFile))
{
// *** Compare Timestamps
DateTime SourceTime = File.GetLastWriteTime(Assembly);
DateTime TargetTime = File.GetLastWriteTime(TargetFile);
if (SourceTime == TargetTime)
continue;
}
File.Copy(Assembly, TargetFile, true);
}
catch { ; } // nothing we can do on failure
}
}
Assemblies are copied only if they don’t exist or if the timestamp of the application assembly is not the same.
For application setup to start the runtime and call a page (in this case generically), the whole process now looks like this:
private void LoadRuntime()
{
// *** Use a form reference to keep the runtime alive here!
this.Host = new wwAspRuntimeHost();
this.Host.PhysicalDirectory = Directory.GetCurrentDirectory() + "\\WebDir\\";
this.Host.VirtualPath = "/LocalScript";
// *** ADD
this.Host.ShadowCopyAssemblies = "AspNetHosting.exe;wwutils.dll";
this.Host.OutputFile = Directory.GetCurrentDirectory() + "\\WebDir\\__PREVIEW.HTM";
wwAspRuntimeProxy.IdleTimeoutMinutes = 1;
if (!this.Host.Start())
MessageBox.Show("ASP.Net Runtime couldn't start. Error: " + this.Host.ErrorMessage);
}
private void btnExecute_Click(object sender, System.EventArgs e)
{
this.Cursor = Cursors.WaitCursor;
// *** this code only fires if the runtime was unloaded.
if (this.Host == null)
this.LoadRuntime();
// *** If you use ANY context items on a page and reuse the Host
// *** you HAVE TO clear the Context items!
this.Host.Context.Clear();
// *** Pass object via the Context collection
cCustomer Cust = new cCustomer();
Cust.cCompany = "West Wind Technologies from Frankfurt";
this.Host.Context.Add("Customer",Cust);
// *** Optionally pass a POST buffer to ASP.NET page
this.Host.AddPostBuffer("Company=West+Wind+Technologies GMBH&Name=Rick+Strahl");
try
{
File.Delete(this.Host.OutputFile);
this.Host.ProcessRequest(this.txtFilename.Text.Trim(),this.txtQueryString.Text);
}
catch(Exception ex)
{
MessageBox.Show(ex.Message);
}
if (this.Host.Error)
MessageBox.Show( this.Host.ErrorMessage );
else
this.Navigate("file://" + this.Host.OutputFile);
this.oPages.SelectedIndex = 0;
this.Cursor = Cursors.Default;
}
private void btnUnload_Click(object sender, System.EventArgs e)
{
this.Cursor = Cursors.WaitCursor;
if (this.Host != null)
{
this.Host.Stop();
}
this.Cursor = Cursors.Default;
}
And voila, this code works reliably under .NET 1.1 and 2.0 and presumably in future versions.
It’s too bad that AppDomain configuration or some sort of configuration object is not available for CreateApplicationHost() – it would certainly make life easier, but for the time being this funky shadow copy mechanism actually works well.
A few other notes about ASP.NET 2.0
One other issue I haven’t been able to verify is whether ASP.NET 2.0 installs its registry keys by default. I know Version 1.1 didn’t install the ASP.NET registry keys unless IIS is installed. So the code in the wwASPRuntimeProxy class checks for the keys and if they don’t exist goes ahead and creates them. I’m not sure whether this is still required for 2.0 or not. If it is, the class currently won’t work as it’s not writing the 2.0 specific keys. I would think with the built-in Cassini Web Server and the ability to much more easily host a Web Server in your own applications ASP.NET 2.0 might be writing these keys on install now, but the only way to check this is to install on a machine or VM that doesn’t have IIS installed in the first place.
Another very cool feature of .NET 2.0 is the new HttpListener class, which is basically a Web Server class that you can host in your own applications with a few lines of code. Using a familiar Request and Response object model you can simply fire off a listener and wait for incoming events or fire delegate callbacks in response to incoming requests. I haven’t had much time to look at this, but the quick sample I threw together was really easy to set up and worked well. With my wwASPRuntimeHost class to route requests to the ASP.NET runtime it becomes very simple to set up a self contained Web Server that can handle ASP.NET requests with very little code.
This is very useful for a lot of direct communication applications that can potentially act as client Web servers for callbacks. The only caveat with this new feature is that it requires Windows 2003 Server or XP SP2 because it relies on the new Http.sys kernel mode HTTP driver to feed the Http requests. However, this also means that the HttpListener is using the same high performance Web engine that is used internally by the framework. Reportedly Indigo is using the HttpListener for its Http listener so the same functionality is exposed up to the developer level…
Lots of opportunity in this space to say the very least!