Improving Performance and Scalability

This chapter talks about the different optimizations that we can apply to ASP.NET Core applications so that they perform faster and are able to handle more simultaneous connections. The two concepts that we will be looking at—performance and scalability—are different and, in fact, to some degree, they conflict with each other. You must apply the right level of optimization to find the sweet spot.

After reading this chapter, you should be able to apply techniques, first to understand what is going wrong or what can be improved in your application, and second, how you can improve it. We will look at some of the available techniques in the forthcoming sections.

We will cover the following topics in this chapter:

  • Profiling—how to gain insights into what your application is doing
  • Hosting choices and tweaking your host for the best performance
  • Bundling and minimization
  • Using asynchronous actions
  • Caching
  • Compressing responses

Technical requirements

In order to implement the examples introduced in this chapter, you will need the .NET Core 3 SDK and a text editor. Of course, Visual Studio 2019 (any edition) meets all the requirements, but you can also use Visual Studio Code, for example.

The source code can be retrieved from GitHub at https://github.com/PacktPublishing/Modern-Web-Development-with-ASP.NET-Core-3-Second-Edition.

Getting started

As Lord Kelvin once famously said, If you cannot measure it, you cannot improve it. With that in mind, we need to measure our application to see where its problems are. There are some applications, known as profilers, that can give us the means to do this. Let's have a look at some of the choices that we have.

MiniProfiler

One open source profiler is MiniProfiler, available from http://miniprofiler.com/dotnet/AspDotNetCore and from NuGet as MiniProfiler.AspNetCore.Mvc. There are also other packages, such as Microsoft.EntityFrameworkCore.SqlServer, for the Entity Framework Core SQL Server provider, andMicrosoft.EntityFrameworkCore.Sqlite for SQLite, which you should add as well.

The following screenshot shows the console, with details regarding the request and the database calls:

This screen shows some metrics after a page was loaded, including the response time, the time it took the DOM to load, and how long the action method took to execute. To use MiniProfiler, you need to register its services (ConfigureServices):

services
.AddMiniProfiler()
.AddEntityFramework();

Add the middleware component (Configure):

app.UseMiniProfiler()

Then, add the client-side JavaScript code:

<mini-profilerposition="@RenderPosition.Right"max-traces="5"color-scheme="ColorScheme.Auto"/>

As this is a tag helper, you will need to register it first (_ViewImports.cshtml):

@addTagHelper*, MiniProfiler.AspNetCore.Mvc

There are other options, such as formatting SQL queries and colorization, and so on, so I suggest you have a look at the sample application available on GitHub.

Stackify Prefix

Stackify Prefix is not an open source product, but rather one that is maintained by the well-known Stackify (https://stackify.com). It can be downloaded from https://stackify.com/prefix, and at this time, it is not available with NuGet. It offers more features than the other two, so it might be worth taking a look at:

This screenshot shows the result of an invocation of an action method—a POST to order—and it shows a SQL that was executed inside it. We can see how long the .NET code, the database connection, and SQL SELECT took to execute.

Let's now look at the hosting options available in ASP.NET Core.

Hosting ASP.NET Core

Hosting is the process that is used to run your ASP.NET Core application. In ASP.NET Core, you have two out-of-the-box hosting choices:

  • Kestrel: The cross-platform host, which is set by default
  • HTTP.sys (WebListener in ASP.NET Core pre-2.x): A Windows-only host

If you want your application to run on different platforms, not just on Windows, then Kestrel should be your choice, but if you need to target only Windows, then WebListener/HTTP.sys may offer better performance, as it utilizes native Windows system calls. You have to make this choice. By default, the Visual Studio template (or the ones used by the dotnet command) uses Kestrel, which is appropriate for most common scenarios. Let's learn about how we can choose what's best for our purposes.

Choosing the best host

You should compare the two hosts to see how well they behave in stressful situations. Kestrel is the default one and is included in the Microsoft.AspNetCore.Server.Kestrel NuGet package. If you want to try HTTP.sys, you need to add a reference to the Microsoft.AspNetCore.Server.HttpSys package.

Kestrel is the default host, but if you wish to be explicit about it, it looks like this:

public static IHostBuilder CreateWebHostBuilder(string[] args) =>
Host
.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(builder =>
{
builder
.ConfigureKestrel((KestrelServerOptions options) =>
{
//options go here
})
.UseStartup<Startup>();
});

In order to use HTTP.sys in ASP.NET Core 3.x, then you should use the following:

public static IHostBuilder CreateWebHostBuilder(string[] args) =>
Host
.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(builder =>
{
builder
.UseHttpSys((HttpSysOptions options) =>
{
//options go here
})
.UseStartup<Startup>();
});

This example shows how to enable the HTTP.sys host and where some of the performance-related settings can be defined.

Configuration tuning

Both hosts, Kestrel and HTTP.sys, support tuning of some of their parameters. Let's look at some of them.

Maximum number of simultaneous connections

For Kestrel, it looks like this:

.ConfigureKestrel(options =>
{
options.Limits.MaxConcurrentConnections = null;
options.Limits.MaxConcurrentUpgradedConnections = null;
})

MaxConcurrentConnections specifies the maximum number of connections that can be accepted. If set to null, there will be no limit, except, of course, system resource exhaustion. MaxConcurrentUpgradedConnections is the maximum number of connections that can be migrated from HTTP or HTTPS to WebSockets (for example). null is the default value, meaning that there is no limit.

An explanation of this code is in order:

  • MaxAccepts: This is equivalent to MaxConcurrentConnections. The default is 0, meaning that there is no limit.
  • RequestQueueLimit: With this, it is also possible to specify the maximum queued requests in HTTP.sys.

For HTTP.sys, WebListener's replacement in ASP.NET Core 3.x, it is similar:

.UseHttpSys(options =>
{
options.MaxAccepts = 40;
options.MaxConnections = null;
options.RequestQueueLimit = 1000;
})

This code sets some common performance-related options for the HTTP.sys host, as shown in the following list:

  • MaxAccepts specifies the maximum number of concurrent accepts.
  • MaxConnections is the maximum number of concurrent accepts (the default is null) to use the machine-global settings from the registry. -1 means that there are an infinite number of connections.
  • RequestQueueLimit is the maximum number of requests that can be queued by HTTP.sys. Let's now see how limits work.

Limits

Similar to HTTP.sys, Kestrel also allows the setting of some limits, even a few more than HTTP.sys:

.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 30 * 1000 * 1000;
options.Limits.MaxRequestBufferSize = 1024 * 1024;
options.Limits.MaxRequestHeaderCount = 100;
options.Limits.MaxRequestHeadersTotalSize = 32 * 1024;
options.Limits.MaxRequestLineSize = 8 * 1024;
options.Limits.MaxResponseBufferSize = 64 * 1024;
options.Limits.MinRequestBodyDataRate.BytesPerSecond = 240;
options.Limits.MaxResponseDataRate.BytesPerSecond = 240
})

Explaining this code is simple:

  • MaxRequestBodySize: The maximum allowed size for a request body
  • MaxRequestBufferSize: The size of the request buffer
  • MaxRequestHeaderCount: The maximum number of request headers
  • MaxRequestHeadersTotalSize: The total acceptable size of the request headers
  • MaxRequestLineSize: The maximum number of lines in the request
  • MaxResponseBufferSize: The size of the response buffer
  • MinRequestBodyDataRate.BytesPerSecond: The maximum request throughput
  • MaxResponseDataRate.BytesPerSecond: The maximum response throughput

Timeouts

Whenever an application is waiting for an external event—waiting for a request to arrive in its entirety, for a form to be submitted, a connection to be established, and so on—it can only wait for a certain period of time; this is so that it does not affect the global functioning of the application. When it elapses, we have a timeout, after which the application either gives up and fails or starts again. Kestrel allows the specification of a number of timeouts:

.ConfigureKestrel(options =>
{
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
})

As for the two properties being set, here is some information:

  • KeepAliveTimeout is the client connection timeout in keep-alive connections; 0, the default, means an indefinite time period.
  • RequestHeadersTimeout is the time to wait for headers to be received; the default is also 0.

For HTTP.sys, the properties are as follows:

  • DrainEntityBody is the time allowed in keep-alive connections to read all the request bodies.
  • EntityBody is the maximum time for each individual body to arrive.
  • HeaderWait is the maximum time to parse all request headers.
  • IdleConnection is the time before an idle connection is shut down.
  • MinSendBytesPerSecond is the minimum send rate in bytes per second.
  • RequestQueue is the time allowed for queued requests to remain in the queue.

Here is a sample code that illustrates these options:

.UseHttpSys(options =>
{
options.Timeouts.DrainEntityBody = TimeSpan.FromSeconds(0);
options.EntityBody = TimeSpan.FromSeconds(0);
options.HeaderWait = TimeSpan.FromSeconds(0);
options.IdleConnection = TimeSpan.FromSeconds(0);
options.MinSendBytesPerSecond = 0;
options.RequestQueue = TimeSpan.FromSeconds(0);
})

In this section, we explored some of the tweaks available in the ASP.NET Core hosts that can lead to better resource utilization and ultimately lead to better performance and scalability. In the next section, we will look at techniques for improving static resource transmission.

Understanding bundling and minification

Bundling means that several JavaScript or CSS files can be combined in order to minimize the number of requests that the browser sends to the server. Minification is a technique that removes unnecessary blanks from CSS and JavaScript files and changes the function and variable names so that they are smaller. When combined, these two techniques can result in much less data to transmit, which will result in faster load times.

A default project created by Visual Studio performs bundling automatically when the application is run or deployed. The actual process is configured by the bundleConfig.json file, which has a structure similar to the following:

[
{
"outputFileName": "wwwroot/css/site.min.css",
"inputFiles": [
"wwwroot/css/site.css"
]
},
{
"outputFileName": "wwwroot/js/site.min.js",
"inputFiles": [
"wwwroot/js/site.js"
],
"minify": {
"enabled": true,
"renameLocals": true
},
"sourceMap": false
}
]

We can see two different groups, one for CSS and the other for JavaScript, each resulting in a file (outputFileName). Each takes a set of files, which can include wildcards (inputFiles), and it is possible to specify whether the result is to be minified (enabled), and functions and variables renamed so that they are smaller (renameLocals). For JavaScript files, it is possible to automatically generate a source map file (sourceMap). You can read about source maps at https://developer.mozilla.org/en-US/docs/Tools/Debugger/How_to/Use_a_source_map. Mind you, this behavior is actually not intrinsic to Visual Studio, but rather it is produced by the Bundler & Minifier extension by Mads Kristensen, available from the Visual Studio gallery at https://marketplace.visualstudio.com/items?itemName=MadsKristensen.BundlerMinifier.

Other options exist, such as adding the BuildBundlerMinifier NuGet package, also from Mads Kristensen, which adds a command-line option to dotnet, allowing us to perform bundling and minification from the command line at build time. Yet another option is to use Gulp, Grunt, or WebPack, but since these are JavaScript solutions rather than ASP.NET Core ones, I won't discuss them here. For WebPack, Gulp, and Grunt, please refer to Chapter 14, Client-SideDevelopment.

Next, we will move on to learn how asynchronous actions aid applications.

Using asynchronous actions

Asynchronous calls are a way to increase the scalability of your application. Normally, the thread that handles the request is blocked while it is being processed, meaning that this thread will be unavailable to accept other requests. By using asynchronous actions, another thread from a different pool is assigned the request, and the listening thread is returned to the pool, waiting to receive other requests. Controllers, Razor pages, tag helpers, view components, and middleware classes can perform asynchronously. Whenever you have operations that perform input/output (IO), always use asynchronous calls, as this can result in much better scalability.

For controllers, just change the signature of the action method to be like the following (note the async keyword and the Task<IActionResult> return type):

public async Task<IActionResult> Index() { ... }

In Razor Pages, it is similar (note the Async suffix, the Task<IActionResult> return type, and the async keyword):

public async Task<IActionResult> OnGetAsync() { ... }

For tag helpers and tag helper components, override the ProcessAsync method instead of Process:

public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { ... }

In view components, implement an InvokeAsync method, like this one:

public async Task<IViewComponentResult> InvokeAsync(/* any parameters */) { ... }

Also make sure that you invoke it asynchronously in your views:

@await Component.InvokeAsync("MyComponent", /* any parameters */)

Finally, in a middleware class, do the following:

publicasync Task Invoke(HttpContext httpContext) { ... }

Or, in lambdas, execute the following code:

app.Use(async (ctx, next) =>
{
//async work
await next();
});

Better still, for controller actions, include a CancellationToken parameter and pass it along any asynchronous methods that are called inside it. This will make sure that, should the request be canceled by the client (by closing the browser or terminating the call in any other way), all calls will be closed as well:

public async Task<IActionResult> Index(CancellationToken token) { ... }

This parameter is the same as the one you'd get from HttpContext.RequestAborted, mind you.

That is not all; you should also prefer asynchronous API methods instead of blocking ones, especially those that do I/O, database, or network calls. For example, if you need to issue HTTP calls, always look for asynchronous versions of its methods:

var client = new HttpClient();
var response = await client.GetStreamAsync("http://<url>");

If you want to pass along the cancellation token, it's slightly more complex, but not much more:

var client = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Get, "<url>");
var response = await client.SendAsync(request, token);

Or, should you need to upload potentially large files, always use code like the following (install the Microsoft.AspNetCore.WebUtilitiesNuGet package):

app.Use(async (ctx, next) =>
{
using (var streamReader = new HttpRequestStreamReader
(ctx.Request.Body, Encoding.UTF8))
{
var jsonReader = new JsonTextReader(streamReader);
var json = await JObject.LoadAsync(jsonReader);
}
});

This has the benefit that you don't block the post while all the payload contents are being read, and in this example, it builds the JSON object asynchronously, too.

With ASP.NET 3, the hosts are now asynchronous all the way, which means that synchronous APIs are disabled by default, and calling them results in exceptions. Should you wish otherwise, you need to change this behavior by turning on a flag on a feature, using a middleware component:

var synchronousIOFeature = HttpContext.Features.Get<IHttpBodyControlFeature>(); 
synchronousIOFeature.AllowSynchronousIO = true;

Or, individually for Kestrel and HTTP.sys, you can do this on the services configuration:

//Kestrel
services.Configure<KestrelServerOptions>(options =>
{
options.AllowSynchronousIO = true;
});

//HTTP.sys
services.Configure<HttpSysOptions>(options =>
{
options.AllowSynchronousIO = true;
});

//if using IIS
services.Configure<IISServerOptions>(options =>
{
options.AllowSynchronousIO = true;
});

Here, we've seen how to use asynchronous actions to improve the scalability of our solution. In the next section, we will be looking at a solution to improve performance: caching.

Keep in mind, however, that asynchronicity is not a panacea for all your problems; it is simply a way to make your application more responsive.

Improving performance with caching

Caching is one of the optimizations that can have a greater impact on the performance of a site. By caching responses and data, you do not have to fetch them again, process them, and send them to the client. Let's look at a couple of ways by which we can achieve this.

Caching data

By caching your data, you do not need to go and retrieve it again and again whenever it is needed. You need to consider a number of aspects:

  • How long will it be kept in the cache?
  • How can you invalidate the cache if you need to do so?
  • Do you need it to be distributed across different machines?
  • How much memory will it take? Will it grow forever?

There are usually three ways to specify the cache duration:

  • Absolute: The cache will expire at a predefined point in time.
  • Relative: The cache will expire some time after it is created.
  • Sliding: The cache will expire some time after it is created, but, if accessed, this time will be extended by the same amount.

In-memory cache

The easiest way to achieve caching is by using the built-in implementation of IMemoryCache, available in the Microsoft.Extensions.Caching.MemoryNuGet package (it also comes in the Microsoft.AspNetCore.All metapackage). As you can guess, it is a memory-only cache, suitable for single-server apps. In order to use it, you need to register its implementation in ConfigureServices:

services.AddMemoryCache();

After that, you can inject the IMemoryCache implementation into any of your classes—controllers, middleware, tag helpers, view components, and more. You have essentially three operations:

  • Add an entry to the cache (CreateEntry or Set).
  • Get an entry from the cache (Get, GetOrCreate, or TryGetValue).
  • Remove an entry from the cache (Remove).

Adding an entry requires you to give it a name, a priority, and a duration. The name can be any object and the duration can either be specified as a relative, sliding expiration, or absolute time. Here's an example:

//relative expiration in 30 minutes
cache.Set("key", new MyClass(), TimeSpan.FromMinutes(30));

//absolute expiration for next day
cache.Set("key", new MyClass(), DateTimeOffset.Now.AddDays(1));

//sliding expiration
var entry = cache.CreateEntry("key");
entry.SlidingExpiration = TimeSpan.FromMinutes(30);
entry.Value = new MyClass();

You can also combine the two strategies:

//keep item in cache as long as it is requested at least once every 5 
//minutes
// but refresh it every hour
var options = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromMinutes(5))
.SetAbsoluteExpiration(TimeSpan.FromHours(1));

var entry = cache.CreateEntry("key");
entry.SetOptions(options);

When using the sliding expiration option, it will be renewed whenever the cache item is accessed. Using Set will create a new item or replace any existing item with the same key. You can also use GetOrCreate to either add one if no item with the given key exists, or to return the existing one as follows:

var value = cache.GetOrCreate("key", (entry) =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
return new MyClass();
});

The priority controls when an item is evicted from the cache. There are only two ways by which an item can be removed from the cache: manually or when running out of memory. The term priority refers to the behavior applied to the item when the machine runs out of memory. The possible values are as follows:

  • High: Try to keep the item in memory for as long as possible.
  • Low: It's OK to evict the item from memory when it is necessary.
  • NeverRemove: Never evict the item from memory unless its duration is reached.
  • Normal: Use the default algorithm.

It is possible to pass a collection of expiration tokens; this is essentially a way to have cache dependencies. You create a cache dependency in a number of ways, such as from a cancellation token:

var cts = new CancellationTokenSource();
var entry = cache.CreateEntry("key");
entry.ExpirationTokens.Add(new CancellationChangeToken(cts.Token));

You can also create one from a configuration change:

var ccts = new ConfigurationChangeTokenSource<MyOptions>(this.Configuration);
var entry = cache.CreateEntry("key");
entry.ExpirationTokens.Add(ccts.GetChangeToken());

You can even create one from a change in a file (or directory):

var fileInfo = new FileInfo(@"C:SomeFile.txt");
var fileProvider = new PhysicalFileProvider(fileInfo.DirectoryName);
var entry = cache.CreateEntry("key");
entry.ExpirationTokens.Add(fileProvider.Watch(fileInfo.Name));

And if you want to combine many, so that the cache item expires when any of the change tokens do, you can use CompositeChangeToken:

var entry = cache.CreateEntry("key");
entry.ExpirationTokens.Add(new CompositeChangeToken(new List<IChangeToken> {
/* one */,
/* two */,
/* three */
}));

You can also register a callback that will be called automatically when an item is evicted from the cache, as follows:

var entry = cache.CreateEntry("key");
entry.RegisterPostEvictionCallback((object key, object value, EvictionReason reason, object state) =>
{
/* do something */
}, "/* some optional state object */");

This can be used as a simple scheduling mechanism: you can add another item with the same callback so that when the item expires, it will add the item again and again. The key and value parameters are obvious; the reason parameter will tell you why the item was evicted, and this can be for one of the following reasons:

  • None: No reason is known.
  • Removed: The item was explicitly removed.
  • Replaced: The item was replaced.
  • Expired: The expiration time was reached.
  • TokenExpired: An expiration token was fired.
  • Capacity: The maximum capacity was reached.

The state parameter will contain any arbitrary object, including null, that you pass to RegisterPostEvictionCallback.

In order to get an item from the cache, two options exist:

//return null if it doesn't exist
var value = cache.Get<MyClass>("key");

//return false if the item doesn't exist
var exists = cache.TryGetValue<MyClass>("key", out MyClass value);

As for removing, it couldn't be simpler:

cache.Remove("key");

This removes the named cache item from the cache permanently.

A side note: it is not possible to iterate through the items in the cache from the IMemoryCache instance, but you can count them by downcasting to MemoryCache and using its Count property.

Distributed cache

ASP.NET Core ships with two distributed cache providers:

  • Redis: Available as a NuGet package at Microsoft.Extensions.Caching.Redis
  • SQL Server: Available from Microsoft.Extensions.Caching.SqlServer

The core functionality is made available through the IDistributedCache interface. You need to register one of these implementations in ConfigureServices. For Redis, use the following command:

services.AddDistributedRedisCache(options =>
{
options.Configuration = "serverName";
options.InstanceName = "InstanceName";
});

For SQL Server, use the following command:

services.AddDistributedSqlServerCache(options =>
{
options.ConnectionString = @"<Connection String>";
options.SchemaName = "dbo";
options.TableName = "CacheTable";
});

Once you have done that, you will be able to inject an IDistributedCache instance, which offers four operations:

  • Add or remove an item (Set, SetAsync)
  • Retrieve an item (Get, GetAsync)
  • Refresh an item (Refresh, RefreshAsync)
  • Remove an item (Remove, RemoveAsync)

As you can see, it is similar to IMemoryCache, but it is not quite the same—for one thing, it offers asynchronous and synchronous versions for all operations. In addition, it does not feature all of the options that exist for an in-memory cache, such as priorities, expiration callbacks, and expiration tokens. But the most important difference is that all items need to be stored as byte arrays, meaning that you have to serialize any objects that you want to store in the cache beforehand. A special case is strings, where there are extension methods that work directly with strings.

So, in order to add an item, you need to do the following:

using (var stream = new MemoryStream())
{
var formatter = new BinaryFormatter();
formatter.Serialize(stream, new MyClass());

cache.Set("key", formatter.ToArray(), new DistributedCacheEntryOptions
{
//pick only one of these
//absolute expiration
AbsoluteExpiration = DateTimeOffset.Now.AddDays(1),
//relative expiration
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(60),
//sliding expiration
SlidingExpiration = TimeSpan.FromMinutes(60)
});
}

As you can see, it does support absolute, relative, and sliding expiration. If you want to use strings, it's simpler:

cache.SetString("key", str, options);

To retrieve an item, you also need to deserialize it afterward:

var bytes = cache.Get("key");
using (var stream = new MemoryStream(bytes))
{
var formatter = new BinaryFormatter();
var data = formatter.Deserialize(stream) as MyClass;
}

And for strings, you use the following code:

var data = cache.GetString("key");

Refreshing is easy; if the item uses sliding expiration, then it is renewed:

cache.Refresh("key");

The same goes for removing:

cache.Remove("key");

The asynchronous versions are identical, except that they end with the Async suffix and return a Task object, which you can then await.

As you may know, BinaryFormatter is only available from .NET Core 2.0 onward, so, for versions of .NET Core prior to that, you need to come up with your own serialization mechanism. A good one might be MessagePack, available from NuGet.

Both distributed caches and in-memory caches have their pros and cons. A distributed cache is obviously better when we have a cluster of machines, but it also has a higher latency—the time it takes to get results from the server to the client. In-memory caches are much faster, but they take up memory on the machine on which it is running.

In this section, we've discussed the alternatives to caching data, whether in memory or in a remote server. The next section explains how to cache the result of the execution of action methods.

Caching action results

By the means of caching action results, you instruct the browser, after the first execution, to keep the supplied result for a period of time. This can result in dramatic performance improvement; as no code needs to be run, the response comes directly from the browser's cache. The process is specified in an RFC at https://tools.ietf.org/html/rfc7234#section-5.2. We can apply caching to action methods by applying the [ResponseCache] attribute to either the controller or the action method. It can take some of the following parameters:

  • Duration (int): The cache duration in seconds; it is mapped to the max-age value in the Cache-control header
  • Location (ResponseCacheLocation): Where to store the cache (one of Any, Client, or None)
  • NoStore (bool): Do not cache the response
  • VaryByHeader (string): A header that will make the cache vary—for example, Accept-Language causes a response to be cached for each requested language (see https://www.w3.org/International/questions/qa-accept-lang-locales)
  • VaryByQueryKeys (string[]): Any number of query string keys that will make the cache vary
  • CacheProfileName (string): The name of a cache profile; more on this in a moment

The cache locations have the following meanings:

  • Any: Cached on the client and in any proxies; sets the Cache-control header to public
  • Client: Cached on the client only; Cache-control is set to private
  • None: Nothing is cached; Cache-control and Pragma are both set to no-cache

But before we can use it, we need to register the required services in ConfigureServices:

services.AddResponseCaching();

It is possible to configure some options by passing a delegate:

services.AddResponseCaching(options =>
{
options.MaximumBodySize = 64 * 1024 * 1024;
options.SizeLimit = 100 * 1024 * 1024;
options.UseCaseInsensitivePaths = false;
});

The available options are as follows:

  • MaximumBodySize (int): The maximum cacheable response; the default is 64 KB
  • SizeLimit (int): Maximum size of all the cached responses; the default is 100 MB
  • UseCaseInsensitivePaths (bool): Whether or not paths should be taken as case sensitive; the default is false

To make this work, as well as registering the services, we need to add the response-caching middleware (the Configuremethod):

app.UseResponseCaching();

Rather than passing duration, location, and other parameters, it is better to use cache profiles. Cache profiles are defined when we register the MVC services by adding entries such as this:

services
.AddMvc(options =>
{
options.CacheProfiles.Add("Public5MinutesVaryByLanguage",
new CacheProfile
{
Duration = 5 * 60,
Location = ResponseCacheLocation.Any,
VaryByHeader = "Accept-Language"
});
});

Here, we are registering some options for a cache profile named Public5MinutesVaryByLanguage, which are as follows:

  • Duration (int): The duration, in seconds, of the cached item
  • Location (ResponseCacheLocation): Where to store the cached item; it can either be on the server or on the client (the browser)
  • VaryByHeader (string): An optional request header to have the cache vary upon; in this example, we are changing the cache by the browser's language

If you wish, you could load the configuration from a configuration file. Say you have this structure:

          {
"CacheProfiles" : {
"Public5MinutesVaryByLanguage" : {
"Duration" : 300 ,
"Location" : "Any",
"VaryByHeader" : "Accept-Language"
}
}
}

You could load it using the configuration API, in ConfigureServices:

            services
.Configure < Dictionary < string , CacheProfile >> (this.C onfiguration .
GetSection ( "CacheProfiles" ) )
. AddMvc ( options = >
{
var cacheProfiles = this .C onfiguration . GetSection < Dictionary
< string , CacheProfile > ( ) ;
foreach ( var keyValuePair in cacheProfiles )
{
options
. CacheProfiles . Add ( keyValuePair ) ;
}
} );

Using cache profiles allows us to have a centralized location where we can change the profile settings that will be used across all the applications. It's as simple as the following:

[ResponseCache(CacheProfileName = "Public5MinutesVaryByLanguage")]
public IActionResult Index() { ... }

Response caching also depends on an HTTP.sys setting, which is enabled by default. It is called EnableResponseCaching:

.UseHttpSys(options =>
{
options.EnableResponseCaching = true;
})

This enables response caching for the HTTP.sys host. Bear in mind that without this, the [ResponseCache] attribute won't work. This is required for sending the appropriate caching response headers.

In this section, we've seen how to cache responses from action methods. Let's now see how we can cache view markup.

Caching views

By using the included tag helpers, <cache> and <distributed-cache>, you will be able to cache parts of your views. As you can infer from their names, <cache> requires a registered instance of IMemoryCache, and <distributed-cache> requires IDistributedCache. I have already talked about these two tag helpers in Chapter 9, Reusable Components, so I won't go over them again. We will only look at two examples. This one is for in-memory caching:

<cache expires-sliding="TimeSpan.FromMinutes(30)">
...
</cache>

This one is for distributed caching:

<distributed-cache name="redis" expires-sliding="TimeSpan.FromMinutes(30)">
...
</distributed-cache>

Anything placed inside <distributed-cache> will be stored in the named distributed cache (in this example, redis) for a period of time (30 minutes) from the first time the view is rendered, and on subsequent occasions, it will directly come from there, without any additional processing.

Do not forget that you need to register an instance of either IMemoryCache or IDistributedCache. These tag helpers, unfortunately, cannot take cache profiles.

Caching is a must-have for any real-life web application, but it must be considered carefully because it may put memory pressure on your system. In the next two sections, we will learn how to optimize responses.

Compressing responses

Response compression is available from the Microsoft.AspNetCore.ResponseCompression package. Essentially, for browsers that support it, it can compress the response before sending it through the wire, thereby minimizing the amount of data that will be sent, at the expense of consuming some time compressing it.

If a browser supports response compression, it should send an Accept-Encoding: gzip, deflate header. Let's see how:

  1. We first need to register the response compression services in ConfigureServices:
services.AddResponseCompression();
  1. A more elaborate version allows you to specify the actual compression provider (GzipCompressionProvider is the one included) and the compressible file types:
services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.
MimeTypes.Concat(new[] {"image/svg+xml"});
});

services.Configure<GzipCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Fastest;
});

The only option for GzipCompressionProviderOptions is the compression level, of which there are three options:

    • NoCompression: No compression—this is the default
    • Fastest: The fastest compression method, which may result in bigger responses
    • Optimal: The compression method that offers the best compression, but potentially takes more time

You can see that you can also configure the file types to compress. As a note, the following content types are automatically compressed:

    • text/plain
    • text/css
    • application/javascript
    • text/html
    • application/xml
    • text/xml
    • application/json
    • text/json
  1. Finally, you need to add the response compression middleware to the Configure method:
app.UseResponseCompression();

Now, whenever a response is one of the configured mime types, it will be automatically compressed and the response headers will include a Content-Encoding: gzip header.

Note that you can roll out your own compression implementation by implementing the ICompressionProvider interface and registering it in the AddResponseCompression method overload that takes a lambda. Besides GZip, Microsoft also has a Brotli-based implementation (BrotliCompressionProvider and BrotliCompressionProviderOptions classes). Brotli is an open source compression algorithm that is supported by several browsers and provides better compression than GZip.

The Deflate compression method is not supported in ASP.NET Core 2.x,—only GZip. Read about Deflate at its RFC (https://tools.ietf.org/html/rfc1951) and about GZip at https://tools.ietf.org/html/rfc1952. Read about Brotli in RFC 7932 (https://tools.ietf.org/html/rfc7932) and see the list of supported browsers at https://www.caniuse.com/#feat=brotli.

Compression can greatly improve the latency of the response at the cost of some extra processing on the server, and now that we've looked at it, let's see how we can improve the response time by using buffering.

Buffering responses

The final technique we will be covering here is response buffering. Normally, a web server streams a response, meaning that it sends a response as soon as it has its chunks. Another option is to get all these chunks, combine them, and send them at once: this is called buffering.

Buffering offers some advantages: it can result in better performance and offer the ability to change the contents (including headers) before they are sent to the client.

Microsoft offers buffering capabilities through the Microsoft.AspNetCore.Buffering NuGet package. Using it is simple—for example, you can use it in a middleware lambda:

app.UseResponseBuffering();

app.Run(async (ctx) =>
{
ctx.Response.ContentType = "text/html";
await ctx.Response.WriteAsync("Hello, World!);

ctx.Response.Headers.Clear();
ctx.Response.Body.SetLength(0);

ctx.Response.ContentType = "text/plain";
await ctx.Response.WriteAsync("Hello, buffered World!");
});

In this example, we are first registering the response buffering middleware (essentially wrapping the response stream), and then, on the middleware lambda, you can see that we can write to the client, clear the response by setting its length to 0, and then write again. This wouldn't be possible without response buffering.

If you want to disable it, you can do so through its feature, IHttpBufferingFeature:

var feature = ctx.Features.Get<IHttpBufferingFeature>();
feature.DisableResponseBuffering();

In this section, we learned about buffering, its advantages, and how to enable it, and with this, we conclude the chapter.

Summary

In this chapter, we learned that using response caching in action methods and views is essential, but it must be used judiciously because you do not want your content to become outdated. Cache profiles are preferred for action methods, as they provide a centralized location, which makes it easier to make changes. You can have as many profiles as you need.

Distributed caching can help if you need to share data among a cluster of servers, but be warned that transmitting data over the wire can take some time, even if, trivially, it is faster than retrieving it from a database, for example. It can also take a lot of memory, and so can cause other unforeseeable problems.

Then, we saw that bundling and minification are also quite handy because they can greatly reduce the amount of data to be transmitted, which can be even more important for mobile browsers.

Asynchronous operations should also be your first choice; some modern APIs don't even allow you to have any other choices. This can greatly improve the scalability of your app.

Lastly, we saw that we need to use a profiler to identify the bottlenecks. Stackify Prefix is a very good choice.

The choice of host greatly depends on deployment needs—if it is non-Windows, then we have no choice other than Kestrel. On both Kestrel and HTTP.sys, there are a great number of parameters that you can tweak to your needs, but be warned that playing with these can result in poor performance.

In this chapter, we looked at some ways by which we can improve the performance and scalability of an application. This is not an exhaustive list, and there is a lot that can be done in the code, especially when it comes to fetching data. Use your best judgment and experiment with things before applying them in production.

In the next chapter, we will be covering real-time communication.

Questions

SO, by the end of the chapter, you should know the answers to the following questions:

  1. What are the two hosts available to ASP.NET Core 3?
  2. What are the two kinds of cache that are available?
  3. What is the benefit of compressing a response?
  4. What is the purpose of caching a response?
  5. Do asynchronous actions improve performance?
  6. What is bundling?
  7. What are profilers good for?
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.131.110.169