Caching content in memory for immediate delivery
Directly accessing storage on each client request is not ideal. For this example, we will explore how to enhance server efficiency by accessing the disk on only the first request, caching the data from file for that first request, and serving all further requests out of the process memory.
Getting ready
We are going to improve upon the code from the previous task, so we'll be working with server.js
, and in the content
directory with index.html, styles.css
, and script.js
.
How to do it...
Let's begin by looking at our script from the previous recipe Serving static files:
var http = require('http'); var path = require('path'); var fs = require('fs'); var mimeTypes = { '.js' : 'text/javascript', '.html': 'text/html', '.css' : 'text/css' } ; http.createServer(function (request, response) { var lookup = path.basename(decodeURI(request.url)) || 'index.html'; var f = 'content/'+lookup; fs.exists(f, function (exists) { if (exists) { fs.readFile(f, function(err,data) { if (err) { response.writeHead(500); response.end('Server Error!'); return; } var headers = {'Content-type': mimeTypes[path.extname(lookup)]}; response.writeHead(200, headers); response.end(data); }); return; } response.writeHead(404); //no such file found! response.end('Page Not Found!'); });
We need to modify this code to only read the file once, load its contents into memory, and afterwards respond to all requests for that file from memory. To keep things simple and preserve maintainability, we'll extract our cache handling and content delivery into a separate function. So above http.createServer
, and below mimeTypes
, we'll add the following:
var cache = {}; function cacheAndDeliver(f, cb) { if (!cache[f]) { fs.readFile(f, function(err, data) { if (!err) { cache[f] = {content: data} ; } cb(err, data); }); return; } console.log('loading ' + f + ' from cache'); cb(null, cache[f].content); } //http.createServer …..
A new cache
object has been added to store our files in memory as well as a new function called cacheAndDeliver
. Our function takes the same parameters as fs.readFile
, so we can replace fs.readFile
in the http.createServer
callback while leaving the rest of the code intact:
//...inside http.createServer: fs.exists(f, function (exists) { if (exists) { cacheAndDeliver(f, function(err, data) { if (err) { response.writeHead(500); response.end('Server Error!'); return; } var headers = {'Content-type': mimeTypes[path.extname(f)]}; response.writeHead(200, headers); response.end(data); }); return; } //rest of fs.exists code (404 handling)...
When we execute our server.js
file and access localhost:8080
twice consecutively, the second request causes the console to output the following:
loading content/index.html from cache
loading content/styles.css from cache
loading content/script.js from cache
How it works...
We defined a function called cacheAndDeliver
, which like fs.readFile
, takes a filename and callback as parameters. This is great because we can pass the exact same callback of fs.readFile
to cacheAndDeliver
, padding the server out with caching logic without adding any extra complexity visually to the inside of the http.createServer
callback. As it stands, the worth of abstracting our caching logic into an external function is arguable, but the more we build on the server's caching abilities the more feasible and useful this abstraction becomes. Our cacheAndDeliver
function checks to see if the requested content is already cached, if not, we call fs.readFile
and load the data from disk. Once we have this data we may as well hold onto it, so it's placed into the cache
object referenced by its file path (the f
variable). The next time anyone requests the file, cacheAndDeliver
will see that we have the file stored in the cache
object and will issue an alternative callback containing the cached data. Notice that we fill the cache[f]
property with another new object containing a content
property. This makes it easier to extend the caching functionality in the future since we would just need to place extra properties into our cache[f]
object and supply logic that interfaces with these properties accordingly.
There's more...
If we were to modify the files we are serving, any changes wouldn't be reflected until we restart the server. We can do something about that.
Reflecting content changes
To detect whether a requested file has changed since we last cached it, we must know when the file was cached and when it was last modified. To record when the file was last cached, let's extend the cache[f]
object:
cache[f] = {content: data, timestamp: Date.now() //store a Unix time stamp };
That was easy. Now we need to find out when the file was updated last. The fs.stat
method returns an object as the second parameter of its callback. This object contains the same useful information as the command-line GNU coreutils stat.fs.stat
supplies three time-related properties: last accessed (atime
), last modified (mtime
), and last changed (ctime
). The difference between mtime
and ctime
is that ctime
will reflect any alterations to the file, whereas mtime
will only reflect alterations to the content of the file. Consequently, if we changed the permissions of a file, ctime
would update but mtime
would stay the same. We want to pay attention to permission changes as they happen, so let's use the ctime
property:
//requires and mimeType object.... var cache = {}; function cacheAndDeliver(f, cb) { fs.stat(f, function (err, stats) { var lastChanged = Date.parse(stats.ctime), isUpdated = (cache[f]) && lastChanged > cache[f].timestamp; if (!cache[f] || isUpdated) { fs.readFile(f, function (err, data) { console.log('loading ' + f + ' from file'); //rest of cacheAndDeliver }); //end of fs.stat } // end of cacheAndDeliver
The contents of cacheAndDeliver
have been wrapped in an fs.stat
callback. Two variables have been added and the if(!cache[f])
statement has been modified. We parse the ctime
property of the second parameter, dub stats
using Date.parse
to convert it to milliseconds since midnight, January 1, 1970 (the Unix epoch), and assign it to our lastChanged
variable. Then we check whether the requested file's last changed time is greater than when we cached the file (provided the file is indeed cached) and assign the result to our isUpdated
variable. After that, it's merely a case of adding the isUpdated
Boolean to the conditional if(!cache[f])
statement via the ||
(or) operator. If the file is newer than our cached version (or if it isn't yet cached), we load the file from the disk into the cache object.
See also
Optimizing performance with streaming discussed in this chapter
Browser-server transmission via AJAX discussed in Chapter 3, Working with Data Serialization