Building a basic REST AspNet Core Example: Caching - Part 7

If you need to catch up on the previous posts then see part 1 & 2, 3, 4, 5, 6. The source code for this post is at github.

This post will take a look at RFC 7234 which is about HTTP 1.1 caching. It is mainly about two headers Expires and Cache-Control. The first one is for server responses. The second one can be used both on requests and responses. Cache-Control has the highest precedence. If your API returns a GET (safe method) with a 200 response then it can be subject to caching. If you don’t specify anything in your GET response the cache may assign a heuristic expiration time. Other status codes where this can happen is

Responses with status codes that are defined as cacheable by default (e.g., 200, 203, 204, 206, 300, 301, 404, 405, 410, 414, and 501 in this specification) can be reused by a cache with heuristic expiration unless otherwise indicated by the method definition or explicit cache controls [RFC7234]; all other status codes are not cacheable by default.

If you specify Last-Modified or ETag these are taken into account.

Expires Header

You can read about the Expires header here. Basically it allows you to specify a date after which the response is considered stale. Like this

Expires: Fri, 16 Dec 2016 16:00:00 GMT

Cache-Control Header

You can read about the Cache-Control header here. The directives you can use depends on if we are talking about the request or the response. So let’s shortly look at some of these

Request Cache-Control Directives

  • no-cache - Specify this directive if the client is not willing to get a cached response.

  • max-age - Specifying Cache-Control: max-age=5, means that the client is willing to accept a response that is up to 5 seconds old.

  • max-stale - Here the client is willing to accept a stale result, eg Cache-Control: max-stale=5.

  • min-fresh - Here you say that you want a result that will be fresh for at least the time specified.

  • no-transform - Says no intermediary may transform the payload.

  • only-if-cached - Can be used to explicitly go for a cached response. If not in the cache you will get a 504 (Gateway timeout).

Response Cache-Control Directives

Some of the response directives are

  • must-revalidate - Says that when the response is stale the cache must not use the response without successfull validation on the origin server. If it cannot reach the origin server it will return 504 (Gateway Timeout).

  • no-cache - Says that the response may not be stored without validating the subsequent requests.

  • no-store - Says that the response must not be stored in the cache.

  • public - Says that the response may be stored in either a shared or private (local) cache.

  • private - Says that the response may be stored in the user’s cache

  • max-age - Says that the response is stale after the specified number of seconds.

  • s-maxage - Says that the response is stale in a shared cache after the specified number of seconds.

Using Caching

You should use caching to provide scalability and decrease the load on your server. In the previous posts we made a FileController that can return files. This could potentially put a load on server we may want to minimize. You can read more about caching with aspnet core here. Basically we just have to use the ResponseCacheAttribute. Let’s try it out with curl and Nginx (or whatever your preferred tool might be). Here I’ve put nginx in front of the aspnet core app running on port 5000. So when hitting 8080 nginx will be the cache intermediate.

1
2
3
4
5
6
7
8
9
10
11
12
13
http {
proxy_cache_path /home/mikael/nginx-cache levels=1:2 keys_zone=my_cache:10m max_size=10g
inactive=60m use_temp_path=off;
server {
listen 8080;
location / {
proxy_cache my_cache;
proxy_pass http://127.0.0.1:5000/;
add_header X-Cache-Status $upstream_cache_status;
}
}

Let’s try to get a file without any cache response header using

1
2
3
4
5
6
7
8
9
10
11
curl -I -X GET http://localhost:8080/files/1
HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Sat, 17 Dec 2016 15:18:11 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: keep-alive
Last-Modified: Sat, 10 Dec 2016 00:00:00 GMT
X-Cache-Status: MISS
```

Notice the X-Cache-Status saying it is a cache MISS. Repeating the command will give the same result. In principle it could have stored it according to the RFC. Let’s try to add the attribute as shown here

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Route("files")]
public sealed class FilesController : Controller
{
private readonly IRepository<File> fileRepository;
public FilesController(IRepository<File> fileRepository)
{
this.fileRepository = fileRepository;
}
[HttpGet("{id}", Name = "GetFile")]
[ResponseCache(Duration = 60)]
public IActionResult Get(string id)
{

We get the following response.

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Sat, 17 Dec 2016 15:24:02 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: public,max-age=60
Last-Modified: Sat, 10 Dec 2016 00:00:00 GMT
X-Cache-Status: MISS

Saying that the response can be cached for 60 seconds in private + shared caches. Retrying it within the time limit and we get X-Cache-Status: HIT. After the 60 seconds we can see nginx makes a new request and returns X-Cache-Status: EXPIRED (make one more request and it will give a HIT).

Now let’s specify that only the client may cache the result.

1
2
3
[HttpGet("{id}", Name = "GetFile")]
[ResponseCache(Duration = 60, Location=ResponseCacheLocation.Client)]
public IActionResult Get(string id)

Now we will always get

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Sat, 17 Dec 2016 15:33:44 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: private,max-age=60
Last-Modified: Sat, 10 Dec 2016 00:00:00 GMT
X-Cache-Status: EXPIRED

or rather MISS as X-Cache-Status but the cache already has the entry from before. If we specify ResponseCacheLocation.None like below we must also specify the Duration

1
2
3
[HttpGet("{id}", Name = "GetFile")]
[ResponseCache(Location = ResponseCacheLocation.None, Duration = 60)]
public IActionResult Get(string id)
1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Sat, 17 Dec 2016 15:42:59 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache,max-age=60
Pragma: no-cache
Last-Modified: Sat, 10 Dec 2016 00:00:00 GMT
X-Cache-Status: EXPIRED

If we do not want the result cached at all we can do it like this

1
2
3
[HttpGet("{id}", Name = "GetFile")]
[ResponseCache(NoStore = true)]
public IActionResult Get(string id)

and then we get

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Sat, 17 Dec 2016 15:46:43 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-store
Last-Modified: Sat, 10 Dec 2016 00:00:00 GMT
X-Cache-Status: EXPIRED

The VARY header is also supported

[ResponseCache(Location = ResponseCacheLocation.Any, Duration = 60, VaryByHeader = “Accept-Encoding”)]

1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Sat, 17 Dec 2016 15:59:08 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: public,max-age=60
Last-Modified: Sat, 10 Dec 2016 00:00:00 GMT
Vary: Accept-Encoding
X-Cache-Status: HIT

Using Cache Profiles

You probably don’t want to duplicate this attribute settings on many controllers. So instead you can define a profile in ConfigureServices in Startup.cs like shown here

1
2
3
4
5
6
7
8
9
10
11
12
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options =>
{
options.CacheProfiles.Add("Default", new CacheProfile()
{
Duration = 60,
Location = ResponseCacheLocation.Client
});
LimitFormattersToThisApplication(options);
});
...

in the FilesController we can then change the attribute to

1
2
3
[HttpGet("{id}", Name = "GetFile")]
[ResponseCache(CacheProfileName = "Default")]
public IActionResult Get(string id)

If you don’t want attributes or profiles

In this case then you can do everything using the ResponseHeaders, it has a CacheControl property and Expires.

Without an External Server

If you do not want to use an external server like nginx then AspNet itself has response caching middelware. The default you get is a memory cache. This may work for you depending on your use case.

To enable it you basically have to add the following to your Startup.cs file

1
2
3
4
5
6
7
8
9
10
public void ConfigureServices(IServiceCollection services)
{
services.AddResponseCaching();
}
public void Configure(IApplicationBuilder app)
{
app.UseResponseCaching();
...
}

Running the basic sample in the AspNet github repository and making two requests,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HTTP/1.1 200 OK
Date: Sat, 17 Dec 2016 16:16:32 GMT
Server: Kestrel
Cache-Control: public, max-age=10
Transfer-Encoding: chunked
Vary: Accept-Encoding
Vary: Non-Existent
HTTP/1.1 200 OK
Date: Sat, 17 Dec 2016 16:16:32 GMT
Content-Length: 32
Server: Kestrel
Cache-Control: public, max-age=10
Age: 5
Vary: Accept-Encoding
Vary: Non-Existent

It’s the Age header that tell us we got caching in place.

Wrap-up

That’s all I wanted to mention about caching. I will continue this REST example in future posts.