HTTP/2 and ASP.NET Core MVC - Protocol based content delivery

Two weeks ago I've given a talk at DevConf conference about HTTP/2. During that talk I've mentioned protocol based content delivery as potential way to optimize an application for HTTP/2 users, without degradation in performance for HTTP/1 users at the same time. After the talk I was asked for some code examples. I've decided that it's a great opportunity to spin up ASP.NET Core 2.2 preview (which brings HTTP/2 to ASP.NET Core) and play with it.

The idea behind protocol based content delivery is to branch application logic (usually rendering) based on protocol of current request (in ASP.NET Core this information is available through HttpRequest.Protocol property) in order to employ different optimization strategy. In ASP.NET Core MVC there are multiple levels on which we can branch, depending on how precise we want to be and how far we want to separate logic for different protocols. I'll go through those levels, starting from the bottom.

Conditional rendering

The simplest thing that can be done, is putting an if into a view. This will allow for rendering different blocks of HTML for different protocols. In order to avoid reaching to HttpRequest.Protocol property directly from view, protocol check can be exposed through HtmlHelper.

public static class HtmlHelperHttp2Extensions
{
    public static bool IsHttp2(this IHtmlHelper htmlHelper)
    {
        if (htmlHelper == null)
        {
            throw new ArgumentNullException(nameof(htmlHelper));
        }

        return (htmlHelper.ViewContext.HttpContext.Request.Protocol == "HTTP/2");
    }
}

This provides an easy solution for having different bundling strategies for HTTP/2 (in order to make better use of multiplexing).

@if (Html.IsHttp2())
{
    <script src="~/js/core-bundle.min.js" asp-append-version="true"></script>
    <script src="~/js/page-bundle.min.js" asp-append-version="true"></script>
}
else
{
    <script src="~/js/site-bundle.min.js" asp-append-version="true"></script>
}

This works, but in many cases doesn't give the right development time experience. It requires context switching between C# and markup. It breaks standard HTML parsers. This is especially if we start mixing if statements with TagHelpers which have similar purpose.

@if (Html.IsHttp2())
{
    <environment include="Development">
        <script src="~/js/core-bundle.js" asp-append-version="true"></script>
        <script src="~/js/page-bundle.js" asp-append-version="true"></script>
    </environment>
    <environment exclude="Development">
        <script src="~/js/core-bundle.min.js" asp-append-version="true"></script>
        <script src="~/js/page-bundle.min.js" asp-append-version="true"></script>
    </environment>
}
else
{
    <environment include="Development">
        <script src="~/js/site-bundle.js" asp-append-version="true"></script>
    </environment>
    <environment exclude="Development">
        <script src="~/js/site-bundle.min.js" asp-append-version="true"></script>
    </environment>
}

Do you see EnvironmentTagHelper in the code above? It also serves as an if statement, but much cleaner. Wouldn't it be nice to have one for protocol as well? It's quite easy to create it, just couple checks and call to TagHelperOutput.SuppressOutput().

public class ProtocolTagHelper : TagHelper
{
    [HtmlAttributeNotBound]
    [ViewContext]
    public ViewContext ViewContext { get; set; }

    public override int Order => -1000;

    public string Include { get; set; }

    public string Exclude { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        ...

        output.TagName = null;

        string currentProtocol = ViewContext.HttpContext.Request.Protocol;

        bool shouldSuppressOutput = false;

        if (!String.IsNullOrWhiteSpace(Exclude))
        {
            shouldSuppressOutput = Exclude.Trim().Equals(currentProtocol, StringComparison.OrdinalIgnoreCase);
        }

        if (!shouldSuppressOutput && !String.IsNullOrWhiteSpace(Include))
        {
            shouldSuppressOutput = !Include.Trim().Equals(currentProtocol, StringComparison.OrdinalIgnoreCase);
        }

        if (shouldSuppressOutput)
        {
            output.SuppressOutput();
        }
    }
}

This is a very simple version of ProtocolTagHelper, more powerful one can be found here. But even with this simplest version it is possible to write following markup.

<protocol include="HTTP/2">
    <environment include="Development">
        <script src="~/js/core-bundle.js" asp-append-version="true"></script>
        <script src="~/js/page-bundle.js" asp-append-version="true"></script>
    </environment>
    <environment exclude="Development">
        <script src="~/js/core-bundle.min.js" asp-append-version="true"></script>
        <script src="~/js/page-bundle.min.js" asp-append-version="true"></script>
    </environment>
</protocol>
<protocol exclude="HTTP/2">
    <environment include="Development">
        <script src="~/js/site-bundle.js" asp-append-version="true"></script>
    </environment>
    <environment exclude="Development">
        <script src="~/js/site-bundle.min.js" asp-append-version="true"></script>
    </environment>
</protocol>

Doesn't this look much cleaner than if statement? This is not the only thing that Tag Helper can help with. Another thing one might want to do is applying different CSS class to an element depending on protocol. Different CSS classes may result in loading different sprite files (again to better utilize multiplexing in case of HTTP/2). Here it would be ugly to copy entire element just to have different value in class attribute. Luckily Tag Helpers can target attributes and change values of other attributes.

[HtmlTargetElement(Attributes = "asp-http2-class")]
public class Http2ClassTagHelper : TagHelper
{
    [HtmlAttributeNotBound]
    [ViewContext]
    public ViewContext ViewContext { get; set; }

    [HtmlAttributeName("asp-http2-class")]
    public string Http2Class { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (!String.IsNullOrWhiteSpace(Http2Class) && (ViewContext.HttpContext.Request.Protocol == "HTTP/2"))
        {
            output.Attributes.SetAttribute("class", Http2Class);
        }

        output.Attributes.RemoveAll("asp-http2-class");
    }
}

The above Tag Helper will target any element with asp-http2-class attribute and if protocol of current request is HTTP/2 it will use asp-http2-class attribute value for class attribute value. Below code will render different markup for different protocols.

<h1 class="http1" asp-http2-class="http2">Conditional Rendering</h1>

Thanks to those two Tag Helpers a lot can be achieved, but if there is a lot of differences the code may become unreadable. In such cases cleaner separation is required. In order to achieve that, branching needs to be done at higher level.

View discovery

If views for HTTP/2 and HTTP/1 are significantly different, it would be nice if ASP.NET Core MVC could simply use different view based on protocol. ASP.NET Core MVC determines which view should be used through view discovery process, which can be customized by using a custom IViewLocationExpander. As the name implies, an implementation of IViewLocationExpander can expand list of discovered view locations, for example by appending "-h2" suffix to the ones discovered by default convention.

public class Http2ViewLocationExpander : IViewLocationExpander
{
    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context,
        IEnumerable<string> viewLocations)
    {
        context.Values.TryGetValue("PROTOCOL_SUFFIX", out string protocolSuffix);

        if (String.IsNullOrWhiteSpace(protocolSuffix))
        {
            return viewLocations;
        }

        return ExpandViewLocationsCore(viewLocations, protocolSuffix);
    }

    private IEnumerable<string> ExpandViewLocationsCore(IEnumerable<string> viewLocations,
        string protocolSuffix)
    {
        foreach (var location in viewLocations)
        {
            yield return location.Insert(location.LastIndexOf('.'), protocolSuffix);
            yield return location;
        }
    }

    public void PopulateValues(ViewLocationExpanderContext context)
    {
        context.Values["PROTOCOL_SUFFIX"] =
            (context.ActionContext.HttpContext.Request.Protocol == "HTTP/2") ? "-h2" : null;
    }
}

An instance of IViewLocationExpander implementation needs to be added to RazorViewEngineOptions.

services.Configure<RazorViewEngineOptions>(options =>
    options.ViewLocationExpanders.Add(new Http2ViewLocationExpander())
);

After that, for requests over HTTP/2, the view locations list might look like below.

/Views/Demo/ViewDiscovery-h2.cshtml
/Views/Demo/ViewDiscovery.cshtml
/Views/Shared/ViewDiscovery-h2.cshtml
/Views/Shared/ViewDiscovery.cshtml
/Pages/Shared/ViewDiscovery-h2.cshtml
/Pages/Shared/ViewDiscovery.cshtml

If a view dedicated for HTTP/2 (with "-h2" suffix) exists, it will be chosen instead of "regular" one. Of course this is only one of possible conventions. There are other options, like for example subfolders.

Action Selection

There is one more level on which one may want to branch - business logic level. If the business logic is supposed to be different depending on protocol, it might be the best to have separated actions. For this purpose ASP.NET Core MVC provides IActionConstraint. All that needs to be implemented is an attribute with an Accept() method, which will return true or false based on current protocol.

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class Http2OnlyAttribute : Attribute, IActionConstraint
{
    public int Order { get; set; }

    public bool Accept(ActionConstraintContext context)
    {
        return (context.RouteContext.HttpContext.Request.Protocol == "HTTP/2");
    }
}

This attribute can be applied to an action in order to make it HTTP/2 only (also a second attribute to make actions HTTP/1 only can be created).

All mentioned mechanism create a comprehensive set of tools to deal with protocol based content delivery in various scenarios. I've gathered them all in a single demo project, which you can find here.