Friday, March 28, 2014

Mixing C# and VB User Controls in the Same Web Application

Authored with Kusuma Kora

At my employer, DealerOn, we have a VB web application and want to move completely to C#. Unfortunately, we do not have the luxury of doing a mass-conversion. For standard class files it is straight forward to place them in another project and reference the project, but web controls are not so simple. We were able to come up with a seamless way to make this possible and we wanted to share details about the solution here to help others with this situation.

First, a bit about our situation:

  • We have a web application (as opposed to a web site)
  • We pre-compile our web application before deploying it to our various servers
If you don't pre-compile, you'll be able to get by without the last step.

Step 1: Create a C# Web Application & User Control

You'll need a web application to place your C#-based user controls in. Add this to the solution, and then add a project reference to this project in your VB web application.

Now, you'll want to create a C# user control so we have something to work with to test later.

Step 2: Embed the User Controls in the assembly

The way that this works is that we embed the ascx files in the C# assembly, and then we adjust the VB web application to use a custom virtual path provider that is capable of finding the ascx files via the assembly instead of via a physical path to the control.

First, we create a class (in C# of course) called EmbeddedResourcePathProvider:
public class EmbeddedResourcePathProvider : VirtualPathProvider
{
  private bool AssemblyPathExists(string path)
  {
    var relativePath = VirtualPathUtility.ToAppRelative(path);
    return relativePath.StartsWith("~/Assembly/", StringComparison.OrdinalIgnoreCase);
  }

  public override bool FileExists(string virtualPath)
  {
    return AssemblyPathExists(virtualPath) || base.FileExists(virtualPath);
  }

  public override VirtualFile GetFile(string virtualPath)
  {
    if (AssemblyPathExists(virtualPath))
    {
      return new EmbeddedResourceFile(virtualPath);
    }

    return base.GetFile(virtualPath);
  }

  public override System.Web.Caching.CacheDependency GetCacheDependency(string virtualPath, System.Collections.IEnumerable virtualPathDependencies, DateTime utcStart)
  {
    if (AssemblyPathExists(virtualPath))
    {
      return null;
    }

    return base.GetCacheDependency(virtualPath, virtualPathDependencies, utcStart);
  }
}


The inspiration for this code is this MSDN article: http://code.msdn.microsoft.com/CSASPNETAccessResourceInAss-6725d61a

Second, we need to change the Application_Start method to use the custom path provider. Have it call this static function in C# to do this:

public static void ConfigureResourcePathProviderBasedOnRuntimeEnvironment()
{
  if (!IsAppPrecompiled())
  {
    var embeddedResourcePathProvider = new EmbeddedResourcePathProvider();

    // we get the current instance of HostingEnvironment class. We can't create a new one
    // because it is not allowed to do so. An AppDomain can only have one HostingEnvironment instance.
    var hostingEnvironmentInstance = (HostingEnvironment)typeof(HostingEnvironment).InvokeMember("_theHostingEnvironment", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.GetField, null, null, null);

    if ((hostingEnvironmentInstance != null))
    {
      // we get the MethodInfo for RegisterVirtualPathProviderInternal method which is internal and also static.
      var mi = typeof(HostingEnvironment).GetMethod("RegisterVirtualPathProviderInternal", BindingFlags.NonPublic | BindingFlags.Static);

      if ((mi != null))
      {
        mi.Invoke(hostingEnvironmentInstance, new object[] { embeddedResourcePathProvider });
      }
      else
      {
        throw new ApplicationException("RegisterVirtualPathProviderInternal MethodInfo is NULL");
      }
    }
    else
    {
      throw new ApplicationException("HostingEnvironment is NULL");
    }
  }
}

If you don't plan on pre-compiling your application then you can change this code to remove that check. If you are going to pre-compile your application then create a function that returns false for now and we'll revisit this later in the article.

Step 3: Wire it up and Test

Now that we have the user control and the web application, its time to put it together.

To begin, you need to adjust the user control to be an embedded resource. For your user control, open the properties window and change the build action to embedded resource as illustrated in this screenshot:


Now, back in VB-land you need to load the user control from your C# project. Because the control is now an embedded resource the path changes to include the embedded path. Whereas typically your call to load the control looks like this:

LoadControl(~/x/y/mycontrol.ascx)

This will now change to:

LoadControl(Assembly.MyC#ProjectDllName.dl.MyC#ProjectDefaultNamespace.x.y.mycontrol.ascx)

It looks a little ugly, but wait until you get to the part where we pre-compile. :-) More on that in a minute, but now is a good time to get your web application running locally with a VB aspx page loading a C# control. Embedded resources are always a little tricky to work with as it is very easy to get the path wrong.

If you are not pre-compiling your web application you can stop here. 

Step 4: Precompilation

Pre-compilation adds another layer of complexity, and this causes the embedded resource system that we just implemented to not work. We'll touch on this later, but for now it comes down to this: IIS has a completely different way of locating things when the application is pre-compiled. 

What to do? It ends up being pretty straight forward. We use our custom path provider only when the application is not pre-compiled. We also need to do some structuring on the build side of the house to combine two pre-compiled web applications together.

First, change your implementation to flag if the application is currently pre-compiled our not. We use an application setting: 

return bool.Parse(ConfigurationManager.AppSettings["PRECOMPILED"]);

Second, we need to handle the situation where our path to LoadControl is sometimes going to use the embedded resource format and sometimes it is going to use the standard path lookup. What we decided to is introduce an extension method to the TemplateControl class that takes in the standard path format (e.g. ~/x/y/mycontrol.ascx) and call it instead of LoadControl():

public static Control LoadControlExternalToCurrentProject(this TemplateControl container, string virtualPath)
{
  if (string.IsNullOrWhiteSpace(virtualPath) || !virtualPath.StartsWith("~/"))
  {
    throw new ArgumentException("Argument is not a valid virtual path that starts with ~/", "virtualPath");
  }

  if (IsAppPrecompiled())
  {
    return container.LoadControl(virtualPath);
  }
  else
  {
    return container.LoadControl(~/Assembly/FileNameOfDLL.dll/AssemblyDefaultNamespace. + virtualPath.Replace("~/", "").Replace("/", "."));
  }
}

Note that this requires matching the folder structure to the namespace perfectly to work (probably a good idea anyway). In our VB code, this is what the call to load controls now looks like:

Me.LoadControlExternalToCurrentProject("~/a/b/mycontrol.ascx")

Third, we need to merge the two pre-compiled web applications. Your way of doing it may vary (we use PowerShell) but what you need to do is pre-compile both applications, and copy certain files out of the C# one into the VB one. The file patterns are:
  • ~\bin\App_Web*.dll
  • ~\bin\.*compiled
In PowerShell, this command will do it:

Copy-Item $pathToCSharpPrecompiledApp\bin\App_Web*.dll $pathToVBPrecompiledApp\bin -ErrorAction stop
Copy-Item $pathToCSharpPrecompiledApp\bin\*.compiled $pathToVBPrecompiledApp\bin -ErrorAction stop

Don't forget to flip your web.config setting to indicate that the application is pre-compiled.

Interestingly enough, we like that the pre-compiled application does not use our custom path provider. The application runs "pure" on the actual server; our "hack" really is to run things locally. 

Conclusion

One thing worth mentioning is that we benefited greatly by spending the time to dig into how IIS loads "stuff". We used the wonderful, free dotPeek decompiler tool from JetBrains to understand at at least a high-level what happens when LoadControl() is called. People may pan ASP.NET for its quirks but it is pretty obvious that there is a method to the madness. The hour or so of digging definitely lead us down the path of getting it all working pretty quickly. Don't be afraid to dig around; up-voting that stale Microsoft Connect case to improve the design of LoadControl() will get you nowhere!

Before you know it, your ratio of C# to VB will be greatly increased -- a happier outcome for code and developers alike. 

Hopefully you find this article helpful and are able to employ it in your situation.

No comments: