DEV Community

loading...
Cover image for Making a Legacy ASP.Net Project (WebForms, MVC or Web API) More DevOps Friendly

Making a Legacy ASP.Net Project (WebForms, MVC or Web API) More DevOps Friendly

sharpninja profile image The Sharp Ninja ・9 min read

Introduction

Finding developers with a lot of experience with WebForms is not easy. Most of us old enough to have the knowledge are not able to deal with it daily or have direct influence on those that do. Most devs still working with WebForms would rather be working on newer, shinier frameworks. But there is a massive amount of mission critical code running in WebForms, and right now there's a lot of pressure to automate more processes.

WebForms comes from an era where dev teams were responsible for everything, up to and including debugging problems in production. Then Worldcom happened and that ushered in Sarb Ox, which led to the enforcement of Separation of Duties. Instead of the lead developer pushing builds to each environment from Visual Studio, a network admin pushed builds to each environment from Visual Studio as the lead developer watched in horror as the mouse hovered over any icon other than the one they were instructing the Network Admin to click.

Everybody knew this was impossible to continue doing and so DevOps got its start to fix this gaping hole in the SDLC. One of the most difficult parts of WebForms development had always been testing. Products like Rational Robot were hugely expensive to license and learn, so Microsoft embraced unit testing and reimagined ASP.Net around easily testable layers of code and introduced the MVC framework, which is still the primary way that ASP.Net and ASP.Net Core is used for server-side HTML rendering.

WebForms and MVC are built on the same bones and it's possible to mix the two paradigms in a single app. This provided a migration path for many projects over the last ten years or so, but not all projects have even nudged from WebForms. As recent technologies have rolled out, WebForms has adapted to accept them and even excel with them. I currently help maintain a WebForms codebase that mixes traditional WebForms, Vue.js and raw Ajax calls. It's an amazingly effective combination, but there are challenges with it.

First, unit testing is a pain, and getting good coverage is hard because of all the unusual ways that the various layers can interact. Next, there's the biggest pain with .NET Framework's ASP.Net: Accessing the HttpContext from an async method can be challenging. I wound up writing a WebFormsTaskScheduler to solve the problem, but it does mean that anywhere you need to call an async method from an ASP.Net thread must use the appropriate Task Scheduler. Library code won't know to do this, so calling library code that might call an async method requires wrapping it in a Task that is dispatched from the WebFormsTaskSchedular (descendent tasks will use this task schedular unless they define their own).

But once you learn about these pain points and figure out their solutions, the motivation to abandon WebForms diminishes. That means you are going to eventually want to manage these applications with modern CI/CD pipelines and host them as managed services and not eat up the cost of a VM just for one app.

Most .NET shops are quite familiar with Team City. For a long time, it has been the most reasonable way to use CI/CD with .NET Framework. Team City suffers from a common malady: Admin Anxiety. Everywhere I've been, the setup of pipelines in Team City has been a closed process with little input from developers. So, devs being devs, we create things like Cake Build, Nuke Build and Pester which gives the Team City Admin an easy way to execute a build without writing code. There are no simple YAML templates for setting up Team City.

Using Cake or Nuke, you write C# code to perform complex builds. Cake is a scripting language (Based on the Roslyn C# Script from Microsoft) and Nuke is precompiled Dotnet Core. Both allow you to generalize scripts to templates that can be reused on multiple projects. In my experience, Nuke is far easier to implement and debug. Pester is a PowerShell library to do anything. Literally. There are modules for creating unit tests, build scripts and WinForms apps with Pester, it's a topic that is too broad for me to cover today, but if you like writing scripts to do your dirty work, PowerShell + Pester will change your life.

So, what are the challenges in bringing WebForms to other CI/CD pipelines, specifically Azure DevOps and GitHub Actions? The first challenge is with Assembly resolution and versioning. Often, Team City servers have years of legacy experience built into them. Layers of dependencies have been installed and are known to always be present and their version is always known. Azure DevOps and GitHub Actions run in containers. Each time the container is used; it is started from a known, clean state. You must build up the dependencies each time you run the pipeline. This can be slow and tedious, especially if migrating from Team City. Whereas Team City servers tend to service multiple repositories and projects simultaneously, each Azure DevOps Pipeline serves exactly one instance of one repository. Each repository provides the details of the execution of the Pipelines, so multiple repos with the same CI/CD configuration will duplicate pipeline resources, which is where Templates come in. As you can see, the concepts of creating and maintaining CI/CD pipelines in Azure or GitHub is completely opposite of doing so in Team City.

Back to assembly resolution. Microsoft is obviously done with .NET Framework. So much so that .NET Framework 4.8 ships reference assemblies for System.Runtime that do not yet have implementations available on nuget.org. And there are other assemblies in this conundrum. If you are targeting .NET Framework 4.8 in your projects then this is no big deal, because the actual implementations are included in .NET Framework 4.8. But if you cannot move to .NET Framework 4.8, you now have a problem.

That problem comes from the default assembly resolution logic in MSBuild 16.x. In a classic style project file, Assembly references from a NuGet package or a specific location on disk are not absolutely assured to use the hint for the assembly. Let that sink in for a moment. Let's assume you have this reference:

<Reference Include="System.Runtime, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
 <HintPath>C:\Windows\Microsoft.NET\Framework\v4.0.30319\System.Runtime.dll</HintPath>
</Reference>
Enter fullscreen mode Exit fullscreen mode

One would assume that this reference will always resolve System.Runtime.dll from C:\Windows\Microsoft.NET\Framework\v4.0.30319\System.Runtime.dll. Unfortunately, if .NET Framework 4.8 is installed, it will instead resolve C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\Facades\System.Runtime.dll. All the assemblies in the Facade folder are just type-safe reference shells that are intended to be used for type resolution by the compiler. If you distribute or try to instantiate anything from these assemblies your application will crash with a helpful message that explains you are using a non-implementation assembly. This will then lead you to NuGet to find the correct package to include.

Alt Text

Download and break open the newest version there and you'll find the newest targeted Framework is 4.6.2 and the assembly included has a strong name of System.Runtime, Version=4.1.1.1, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a.

The strong name on the reference assembly is System.Runtime, Version=4.1.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a.

Houston, we have a problem.

But then you remember you can use Binding Redirection at runtime! And if you have the correct assembly in the resolution path then it will work. But I'm writing this for a reason: the assembly resolution during the build process always picks the .NET 4.8 Reference Assembly, ignoring any hints or binding redirection.

The Solution

In your project file, add this target:

<Target Name="_HandlepackageFileConflicts" />
Enter fullscreen mode Exit fullscreen mode

Yes, that's it. As you can see from the name, this target is buried deep in MSBuild and its job is to resolve naming collisions on assemblies. Unfortunately, it's broken and always chooses the .NET 4.8 Reference Assemblies, without fail. By including this target in your project file, it overrides the MSBuild target, preventing it from running and allowing any Hint you supply to be used to resolve assemblies from NuGet packages.

Other Helpful Hints

A CI/CD pipeline on Azure or GitHub will always start with an empty packages folder. All references will have to be downloaded each time. With MSBuild and NuGet you need to select a location ahead of time for packages to be downloaded to.

Some repositories may have the packages folder included (during the early days of using NuGet people were paranoid of mismatched nupkg files and often included their dependencies in TFS), and if so, you can ignore this part.

To avoid having the packages folder included in the repository, I have seen many orgs choose to place it in the parent folder to the repository folder, thus giving a relationship of ../packages from the repository root. This is perfectly reasonable and won't affect either TeamCity or Azure DevOps. Placing a nuget.config file in the root of the repository is the best way to enforce this location, just remember to always reference this config file on every NuGet operation.

While researching the problem above, one of the things I ran across was guidance from the MSBuild team to use absolute references for Hint paths. This means that your project file will have to calculate the exact location of the packages folder.

<PropertyGroup>
    <MSBuildSolutionDirectory Condition=" '$(MSBuildProjectDirectory)' == '' ">$([System.IO.Path]::GetFullPath('..\..\'))</MSBuildSolutionDirectory>
    <MSBuildSolutionDirectory Condition=" '$(MSBuildProjectDirectory)' != '' ">$(MSBuildProjectDirectory.Replace('src\WebApp', ''))</MSBuildSolutionDirectory>
    <MSBuildSolutionDirectory Condition="!HasTrailingSlash('$(MSBuildSolutionDirectory)')">'$(MSBuildSolutionDirectory)\'</MSBuildSolutionDirectory> 

 <PackagesDir>$([System.IO.Path]::GetFullPath('$(MSBuildSolutionDirectory)..\packages'))</PackagesDir>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

MSBuild provides the path containing the current project file in $(MSBuildProjectDirectory). Since we know the relationship of the project path to the root of the repository, we just work backwards to get the repository root, which I've called $(MSBuildSolutionDirectory). From there we combine the repository root with the relative path to the packages folder and then get the full path (absolute path) of the result, storing it in $(PackagesDir). Now, we do a search for ..\..\..\packages and replace it with $(PackagesDir) in the project file. This will ensure that every reference to a NuGet package is absolute when MSBuild is doing dependency resolution. If you have multiple projects in your solution, you will need to repeat this process accordingly.

Mixing Technologies

One of the oddities I ran into while migrating our Hybrid WebForms-Vue.js Application had to do with the behavior of Microsoft's Web Publishing tool when publishing to an Azure App Service. This tool is used by both TeamCity and Azure Pipelines as it's the de-facto method for publishing to IIS from MSBuild.

Our app generates many JavaScript Bundles during the build process and places them in the dist folder at the root of the WebForms project. In the past, including this snippet sufficed to get the contents of the dist folder published.

  <ItemGroup>
    <Content Include="dist/**" />
  </ItemGroup>
Enter fullscreen mode Exit fullscreen mode

About a week ago this quite working. I haven't isolated what change broke it, but suddenly the contents of the dist folder were no longer being included in the zip file generated by the Web Publishing task. Using the Build Logging window in Visual Studio, I was able to see that the Content files were being calculated before the typescript compiler was called and before WebPack put the bundles together. First, I tried ensuring the dist folder was created before building, but that didn't help, the empty dist folder was excluded from the zip file. I placed a README.md file in the dist folder and included it in source control. The README and dist folder were present in the zip file, but not the bundles. Out of sheer desperation I replaced the above node with this:

    <Content Include="dist\README.md">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
    <Content Include="dist\*.js">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
    <Content Include="dist\*.js.map">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>

Enter fullscreen mode Exit fullscreen mode

Success! The bundles and maps are once again included in the zip file.

WARNING: Leaving out the README.md causes the early content resolution to ignore the dist folder.

Conclusion

Like any other technology, ASP.Net is a moving target. Microsoft has dedicated to security fixes for .NET Framework 4.8 for many years to come, which means there's always a chance for something weird to come up. Minimizing the gotchas starts with migrating all .NET Framework Projects to .NET 4.8. Insisting on older versions just ensures that you will have a problem sooner rather than later. All the assembly binding nonsense at the beginning of this article is solved by migrating to .NET 4.8. If you are an admin, moving to new versions of anything scares you, and I totally get that, but staying in the past only means you are further from reality every day. Get your project teams to commit to a target of reaching .NET Framework 4.8 sooner rather than later. Once there, everyone can relax a little more and know that the absurd flexibility of ASP.Net will continue to serve you well for many years as a good citizen in your CI/CD environment.

Discussion (0)

pic
Editor guide