Перейти к содержимому

Road to precompiled web application based on Umbraco CMS

Start of it 
Some time ago, my colleague Jeroen asked me to the local development process for one of our web applications, which has more than 450 C# MVC views, which leads to a problem – extremely long local startup times (can be more than 5 minutes on decent developer workstation). Also, we’ve experienced the same problems on production, which is delivered by means of MsDeploy (though they are not so big, cause delivery to production is much rarer event than local compilation). So, I set sails on investigation and optimizing this

First try: MvcBuildViews 
In each C# MVC project there is an option to compile MVC views, by turning parameter MvcBuildViews to True. I supposed that this will solve our issue by compiling views to IL (Intermediate Language), but this was not the case. As I understand, the main target of this parameter is to check views for errors, so we can avoid them during runtime compilation in future – it starts aspnet_compiler.exe to compile all views to IL (this is what seems to be the goal), but, it keeps views itself unmodified and compiles to a temporary folder. So, on the next startup of web application, when a view is requested, it will be loaded unmodified from the Views folder and undergo the whole runtime compilation again, outputting IL to another temporary folder. Additional catch is: MvcBuildViews is not compatible with MsDeploy, so, if you are compiling and deploying in one run (e.g. parameter DeployOnBuild is set to True) – the build will fail, as ASPCONFIG is failing on parsing application web.config. which fails a build. However, if you do wish to have your views to be checked for compilation sanity during deployment builds, this can be fixed by modifying build process (for example, in csproj file of your MVC application) by adding the following at the end (before closing tag):

<PropertyGroup>
<!-- MvcBuildViews is not compatible with DeployOnBuild - so we need to change order of build events -->
<MvcBuildViews Condition="$(MvcBuildViews) == ''">False</MvcBuildViews>
<MSDeployPublishDependsOn Condition="$(DeployOnBuild) And '$(MSDeployPublishDependsOn)'!=''">MvcBuildViews;$(MSDeployPublishDependsOn);</MSDeployPublishDependsOn>
</PropertyGroup>
<Target Name="MvcBuildViews" Condition="$(MvcBuildViews)">
<AspNetCompiler VirtualPath="temp" PhysicalPath="$(WebProjectOutputDir)" />
</Target>

his will modify the build process to ensure that MvcBuildViews is performed before publishing (by default, it is performed after publish step) and will allow to drop an error from compiler about web.config. However, it will still not produce precompiled IL for views and the application must build it on runtime.

Second try: Built-in views precompilation 
It turns out that the Visual Studio team already thought about those, who wish to use precompiled views and embedded set of parameters to be set to achieve this goal, which is invoked on publish of web application. So, it pushes us to using out-of-webroot development, which is beneficial for all (allows to tackle edge cases, encountered during publishing to production; makes your solution smaller and isolates delta of your custom work on top of the vanilla install), because view precompilation alters *.cshtml files (they are left there as a placeholders, with placeholder text).

So, I used the publishing wizard and configured it as follows:

If one will set ‘Allow precompiled site to be updateable’ – precompilation will not fire up for Views (at least, it does not produce AppCode.dll in bin folder of published app). This ended up in the following publish profile (all parameters there can be copied directly in csproj file of your MVC web app):

<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer//2003">
<PropertyGroup>
<WebPublishMethod>FileSystem</WebPublishMethod>
<LastUsedBuildConfiguration>Debug</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<SiteUrlToLaunchAfterPublish />
<LaunchSiteAfterPublish>False</LaunchSiteAfterPublish>
<ExcludeApp_Data>False</ExcludeApp_Data>
<publishUrl>../../Published</publishUrl>
<DeleteExistingFiles>False</DeleteExistingFiles>
<PrecompileBeforePublish>True</PrecompileBeforePublish>
<EnableUpdateable>False</EnableUpdateable>
<DebugSymbols>True</DebugSymbols>
<WDPMergeOption>MergeAllOutputsToASingleAssembly</WDPMergeOption>
<UseMerge>True</UseMerge>
<SingleAssemblyName>AppCode</SingleAssemblyName>
<DeleteAppCodeCompiledFiles>False</DeleteAppCodeCompiledFiles>
</PropertyGroup>
</Project>

Then I tried to publish my web app and … it fails.

Catch 1: and Umbraco_client folders 
Due to some historical reasons, we’ve included both and umbraco_client in the solution, which lead to a precompilation failure. There are several ascx files, which are referring to backend code which is not present anymore. However, if Umbraco is installed via a NuGet package – it is not needed to include umbraco and umbraco_client folders in the solution, because nuget will import a set of tasks, which is responsible for copying umbraco and umbraco_client content to web deploy package and solution root during build. So, I excluded those folders again, and … it failed again. This time it failed because we have had some plugins installed and included in our solution, which was using masters from umbraco folders. I Included those masters files – and this time, precompilation was OK.

Catch 2: App_Code folder 
I expected that after publishing my app will start up fast and will not spend any time on runtime precompilation, but – I received YSOD, stating that App_Code folder is not allowed in precompiled applications. I checked filesystem and found out that App_code with some cs files is present there. It turned out that we have had some cs files, marked as content in this folder and used by nuPicker. I changed their attribute to ‘Compile’ and published once again (clearing target folder manually, as my publish profile says to keep existing files by setting DeleteExistingFiles to False – this is needed as we have a separate frontend build, which uses Gulp for generating it). After that, application shown up and startup time improved greatly (though, initial index build requires some time, but subsequent publishes result in fast application start).

Catch 3: Long publish times 
Views precompilation is good, but there is one immediately observed tradeoff – time to build increases greatly (on HDD can be up to 5 minutes – the same as we spent on startup before precompilation). So, in order to benefit from fast startup, my colleague Jeroen, who initially came with the idea of this feature, added a simple postbuild event in our MVC application csproj file:

<PropertyGroup>
<PostBuildEvent>XCOPY "$(ProjectDir)bin\*.dll" "$(ProjectDir)..\..\Published\bin\" /S /Y /i</PostBuildEvent>
</PropertyGroup>

As you can see – after build it will copy all dll’s from project bin to published folder, where precompiled app resides. So, if one has not changed anything in views – he does not need to launch full publish, but just build the solution.

Catch 4: Could not login to Umbraco 
After playing enough with frontend, we’ve found out that trying to login to results in YSOD. Quick check revealed its source – since our application is precompiled and not updateable (see publish profile example) it is expecting that all parts of our application are precompiled in the same manner. But, as noted before, we could not precompile Umbraco and umbraco_client folders, as this leads to failures in precompilation stage. Looks like a real problem, but we’ve found 2 possible solutions:

1. We can trick runtime: when app is precompiled, compiler will drop PrecompiledApp.config in webroot and add a definition there, if app is updateable or not: So, to allow using non-precompiled views with precompiled web app views – just set updateable to true This can be done by adding the following to MVC app csproj file

<Target Name="__localPublishAllowUpdateable" AfterTargets="CopyAllFilesToSingleFolderForPackage" Condition="$(PrecompileBeforePublish) And !$(EnableUpdateable)">
<!-- This target is used with local publish -->
< Projects="$(MSBuildProjectFile)" Targets="__AllowUpdateable" />
</Target>
<Target Name="__MsDeployPublishAllowUpdateable" AfterTargets="CopyAllFilesToSingleFolderForMsdeploy" Condition="$(PrecompileBeforePublish) And !$(EnableUpdateable)">
<!-- This target is used with msdeploy publish -->
<MSBuild Projects="$(MSBuildProjectFile)" Targets="__AllowUpdateable" />
</Target>
<Target Name="__AllowUpdateable" Condition="$(PrecompileBeforePublish) And !$(EnableUpdateable)">
<!-- This target is required to mark precompiled app, based on  CMS as updateable -->
<PropertyGroup>
<___IntermediateOutputPath Condition="'$([System.IO.Path]::IsPathRooted($(IntermediateOutputPath)))' == 'False'">$(MSBuildProjectDirectory)\$(IntermediateOutputPath)</___IntermediateOutputPath>
<___IntermediateOutputPath Condition="'$([System.IO.Path]::IsPathRooted($(IntermediateOutputPath)))' == 'True'">$(IntermediateOutputPath)</___IntermediateOutputPath>
</PropertyGroup>
<Exec Command='copy "$(ProjectDir)Properties\BuildTargets\PrecompiledApp.source" "$(___IntermediateOutputPath)Package\PackageTmp\PrecompiledApp.config" /y' />
<RemoveDir Directories="$(___IntermediateOutputPath)Package\PackageTmp\App_Code" />
</Target>

These targets will copy PrecompiledApp.source file from Properties\BuildTargets of MVC web app project to output folder of published app. Content of PrecompiledApp.source is:

<precompiledApp version="2" updatable="true"/>

Actually, this can be achieved by importing this nuget - http://www.nuget.org/packages/Colours.Ci.Umbraco/

2. Another possible solution, which also addresses catch number 5 and can be used to test load balanced solution is to add 2 IIS sites: one is pointing to published folder, and we will use it to check web app performance, check changes in code and views to be displayed correctly, second – pointing to folder with MVC project, which we will use to access Umbraco. In such approach, besides all other benefits, we can easily test load balanced approach (changing content in shall be reflected in our published precompiled web app, events shall be called on both web apps and etc).

Catch 5: Could not debug views 
Despite the fact, that we are publishing in Debug mode and are adding DebugSymbols (see publishing profile) – views could not be debugged when debugger is attached to published web app process. Hence, Jeroen came up with a solution, described as point 2 in Catch 4. At this moment, I could not figure out how to debug views in precompiled application – maybe someone can come up with suggestions?

Catch 6: VPP is not working 
Our based web app has the following setup: it is storing images at Azure blob storage via UmbracoFileSystemProviders.Azure, processes them via ImageProcessor and serves them via Azure CDN with the help of Our.Umbraco.AzureCDNToolkit. Final problem we faced was – our web app was not able to serve media directly from our virtual path provider (VPP), without processing it via ImageProcessor. So, all request like http://mydevhost/media/1000/1.jpg was serving ASP.NET 404, while requests like http://mydevhost/media/1000/1.jpg?1=1 will be happily picked up by ImageProcessor and processed further. Not a big flaw, and, in some setups, it can be even treated like an additional source of protection for original images (imagine that you do not want to serve original media items for your end-users in any circumstances). Actually, this is by design: http://msdn2.microsoft.com/en-us/library/system.web.hosting.virtualpathprovider.aspx notes that VPP will not work in precompiled web application.
However, in this forum thread I came up with an answer how we can hack it and allow usage of particular VPP in precompiled application.

Teamcity configuration 
Since we are using direct publish from Teamcity (system.DeployOnBuild is set to True) via MsDeployPublish – we have 2 options to set this up:

1) Define all parameters in additional publish profile and pass its name to Teamcity build
2) Add following parameters as system to the build configuration itself:
system.EnableUpdateable = False
system.PrecompileBeforePublish = True
system.SingleAssemblyName = nameOfTheAssembly
system.UseMerge = True

However, in my situation, despite of the fact that I have had Visual Studio 2015 and SDK’s for .NET versions installed – build was failing, because Teamcity was not able to find aspnet_merge.exe, which is part of the SDK. So, I defined 2 additional parameters:
system.AspnetMergePath = “C:\Program Files (x86)\Microsoft SDKs\Windows\v8.1A\bin\NETFX 4.5.1 Tools\”
system.GetAspNetMergePath = False

After this, my builds were green again and I got a precompiled application also on our production environment, which is boasting faster startup times after IIS recycle or VM restart for updates installation.