In the previous part of the tutorial, we reviewed internal vs external caching, and different caching techniques. In this part, we’ll try external caching with redis, learn how to use it in the code, run redis server in a development environment with docker, and mock it for unit tests.
If you don’t follow the tutorial, you can get the sources and use them as a start point:
$ git clone https://github.com/losikov/api-example.git
$ cd api-example
$ git checkout tags/v7.0.0
$ yarn install
Run Redis Server with Docker
The easiest way to run redis on local machine is to use docker. We already have ./scripts/run_dev_dbs.sh script to run MongoDB with docker, let’s update and add redis:
You can run it and check now:
Project Initial Setup for Redis
$ yarn add redis @types/redis
$ yarn add -D redis-mock @types/redis-mock
To establish a connection to a redis server we need an url. For unit tests, we should use redis-mock instead of redis. Let’s add REDIS_URL to the config/.env.schema:
In config/.env.dev and in config/.env.prod, set the value:
and in config/.env.test set the redis-mock value:
Update src/config/index.ts file. Add redisUrl to Config interface, and to config constant:
You might use multiple external caches for different purposes. Let’s create the one, a singleton, for our example. Create file src/utils/cache_external.ts with the following content:
In line 6, based on config.redisUrl value, we use real redis implementation, or just redis-mock to test environment, unit tests.
Open method (line 26) returns Promise which we need to resolve. To resolve it only once _initialConnection property is used (line 13, 16, 34–37). But the server won’t start listening until it establishes the connection with the redis server. Change the logic if needed.
Finally, update src/app.ts to establish a connection with a redis server (line 6):
Run unit tests to make sure they still work, and run the app in dev environment:
Redis has multiple interesting options you might need. Make sure to review the documentation.
For our example, let’s store JSON Web Tokens in redis. In the part covering JWT, I mentioned, that a single Node.js app can handle only ~16K auth requests per second due to a CPU intensive jwt.verify() call. Let’s see if the caching can help to improve it.
The implementation is trivial. Pay attention to expireAfter (line 1 and 3) — the token will be evicted from the cache at the same time when JWT expires.
In all previous parts, I added and described unit tests along with the code. It is time to add them. I won’t do it here now, but I’ll add them and commit to the git. Please work on unit tests on your own if you’d like, and compare with mine. Redis get/set functions can return with error or call a callback function passing an error. To cover these cases, I implemented a custom redis mock.
In our caching example, to store JWT/userId pairs, a database is not used; as a result, the caching technique described in the previous chapter can not be applied. redis is just our primary database to store JWT/userId pairs. In src/api/services/user.ts, update createAuthToken function to store the token, and auth to do both get the token and store it (think over reasons why it can be not in the cache, when it had been pushed to the cache by createAuthToken):
In auth function, we check if a token is in a cache (lines 8–10). If not, or if redis is unavailable, we decode the token with jwt.verify (line 17). Finally, if the token is valid, we store it to the cache (line 21), and return the result (line 23, 26), even if redis is unavailable. When we store to the cache, we set a key/value expiration time, which we got from the decoded token (line 20–21).
In createAuthToken function, we calculate an expiration time manually (39–41), and set it when we store the token to cache (line 43). The successful result is returned (line 45, 48), even if a redis server is unavailable.
With such implementation, when we don’t rely on redis availability for JWT verification, all unit tests should continue working without any modifications. You can try to run existing unit tests.
In case a connection to redis is required for the code logic to work properly, we should open and close) the connection in each test file. cache_external.ts file already has the code to use inmemory redis-mock. Update src/api/controllers/__tests__/greeting.ts, src/api/controllers/__tests__/user.ts, src/api/services/__tests__/user.ts, to import cacheExternal, open connection in beforeAll, and close it in afterAll:
‘auth perfromance test’ unit test which processed 16K auth requests per second, handles ~320K now. I got the same number replacing cacheExternal with localCache for storing JWT. It is expected, as we eliminated a CPU heavy jwt.verify call. But this number is only for inmemory redis-mock. As soon as I changed inmemory redis-mock to a real redis server (using config/.env.test file), the performance unit test, single-threaded and with sequential requests, failed, and dropped much below 16K requests per second. Keep it in mind during system design and verify your expectations. Keep it in mind when you choose internal and external caching for different purposes.
You can get the project sources from git https://github.com/losikov/api-example. Git commit history and tags are organized based on the parts.
In the next part, I’ll explain all essentials of containerization. We’ll make the first step towards a deployment, create a docker image for our Node.js REST API Server and run it in a cluster together with mongo and redis behind nginx reverse-proxy.