We are writing a microservice on KoaJS 2 in ES2017 style. Part I: Such Different Asynchrony

Have you ever had the desire to rewrite everything from scratch, “score” for compatibility and do everything “wisely”? Most likely KoaJS was created that way. This framework has been developed by the Express team for several years. The experts about these 2 frameworks write like this: Philosophically, Koa aims to “fix and replace node”, whereas Express “augments node” [From a philosophical point of view, Koa aims to “fix and replace the node” while Express “expands the node”].
Koa is not burdened with legacy code support, from the first line you are immersed in the world of modern ES6 (ES2015), and in version 2 there are already designs from the future ES2017 standard. In my company, this framework has been in production for 2 years, one of the projects ( AUTO.RIA) works at a load of half a million visitors per day. Despite its bias towards modern / experimental standards, the framework is more stable than Express and many other frameworks with CallBack-style approach. This is not due to the framework itself, but to the modern JS designs that are used in it.
In this article I want to share my development experience on koa. In the first part, the framework itself and a little theory on organizing the code on it will be described, in the second we will create a small rest service on koa2 and bypass all the rakes that I have already stepped on.
Bit of theory
Let's take a simple example, write a function that reads data into an object from a JSON file. For clarity, we will do without "reqiure ('my.json')":
const fs = require('fs');
function readJSONSync(filename) {
return JSON.parse(fs.readFileSync(filename, 'utf8'))
}
//...
try {
console.log(readJSONSync('my.json'));
} catch (e) {
console.log(e);
}
Whatever problem happens when calling readJSONSync , we will handle this exception. Everything is fine here, but there is a big obvious minus: this function is executed synchronously and will block the thread for the entire duration of the read.
Let's try to solve this problem in nodejs style using callback functions:
const fs = require('fs');
function readJSON(filename, callback) {
fs.readFile(filename, 'utf8', function (err, res) {
if (err) return callback(err);
try {
res = JSON.parse(res);
callback(null, res);
} catch (ex) {
callback(ex);
}
})
}
//...
readJSON('my.json', function (err, res) {
if (err) {
console.log(err);
} else {
console.log(res);
}
})
Everything is good with asynchrony, but the usability of the code has suffered. There is still the possibility that we will forget to check for the error 'if (err) return callback (err)' and if an exception occurs when reading the file everything will fall out, the second inconvenience is that we have already plunged one step into, so called, callback hell. If there will be many asynchronous functions, then nesting will grow and the code will be very difficult to read.
Well, let's try to solve this problem in a more modern way, let's make the readJSON function a promise :
const fs = require('fs');
function readJSON(filename) {
return new Promise(function(resolve,reject) {
fs.readFile(filename,'utf8', function (err, res) {
if (err) reject(err);
try {
res = JSON.parse(res);
resolve(res);
} catch (e) {
reject(e);
}
})
})
}
//...
readJSON('my.json').then(function (res) {
console.log(res);
}, function(err) {
console.log(err);
});
This approach is a bit more progressive, as we can “expand” the large complex nesting into the chain then ... then ... then, it looks something like this:
readJSON('my.json')
.then(function (res) {
console.log(res);
return readJSON('my2.json')
}).then(function (res) {
console.log(res);
}).catch(function (err) {
console.log(err);
}
);
This situation, so far, does not significantly change, there is a cosmetic improvement in the beauty of the code, it may have become clearer what is being done. The emergence of generators and the co library , which became the basis of the koa v1 engine, radically changed the situation .
Example:
const fs = require('fs'),
co = require('co');
function readJSON(filename) {
return function(fn) {
fs.readFile(filename,'utf8', function (err, res) {
if (err) fn(err);
try {
res = JSON.parse(res);
fn(null,res);
} catch (e) {
fn(e);
}
})
}
}
//...
co(function *(){
console.log(yield readJSON('my.json'));
}).catch(function(err) {
console.log(err);
});
In the place where the yield directive is used , an asynchronous readJSON is pending . readJSON needs to be redone a bit. This code design is called the thunk function. There is a special library that makes a function written in nodejs-style into a thunkify thunk function .
What does this give us? The most important thing is that the code in the part where we call yield is executed sequentially, we can write
console.log(yield readJSON('my.json'));
console.log(yield readJSON('my2.json'));
and get sequential execution of first reading 'my.json' then 'my2.json'. But this is a "callback goodbye." Here the “ugliness” is that we use the peculiarity of the work of generators not for their intended purpose, the thunk function is something non-standard and to rewrite everything for koa in such a “not ice” format. It turned out that not everything is so bad, yield can be done not only for the thunk function, but also a promise or even an array of promises or an object with promises.
Example:
console.log(
yield {
'myObj': readJSON('my.json'),
'my2Obj': readJSON('my2.json')
}
);
It seemed that you couldn’t imagine a better one, but they did. They made it so that everything was “for the direct” purpose. Meet Async Funtions :
import fs from 'fs'
function readJSON(filename) {
return new Promise(function (resolve, reject) {
fs.readFile(filename, 'utf8', function (err, res) {
if (err) reject(err);
try {
res = JSON.parse(res);
resolve(res)
} catch (e) {
reject(e)
}
})
})
}
//...
(async() => {
try {
console.log(await readJSON('my.json'))
} catch (e) {
console.log(e)
}
})();
Do not rush to run, without babel this syntax your node will not understand. Koa 2 works in this style. You have not run away yet?
Let's see how this "callback killer" works:
import fs from 'fs'
similarly
var fs = require('fs')
I’m already familiar with promises.
() => {} - this is what the "arrow function" is denoted, similar to the notation function () {} . The arrow function has a slight difference - the context: this refers to the object in which the arrow function is initialized.
async before the function indicates that it is asynchronous, the result of such a function will also be a promise. Since, in our case, after executing this function, nothing needs to be done there, we omitted the then or catch call. It could be as shown below, and this will work too:
(async() => {
console.log(await readJSON('my.json'))
})().catch (function(e) {
console.log(e)
})
await is the place where you need to wait for the execution of the asynchronous function (promise) and then work with the result that it returned or handle the exception. To some extent, it resembles the yield of generators.
The theory is over - we can start the first launch of KoaJS.
Meet koa
"Hello world" for koa:
const Koa = require('koa');
const app = new Koa();
// response
app.use(ctx => {
ctx.body = 'Hello Koa';
});
app.listen(3000);
a function that is passed as an argument in app.use is called middleware. Minimalist, isn't it? In this example, we see a shortened version of the recording of this function. Koa middleware terminology can be of three types:
- common function
- async function
- generatorFunction
Also, in terms of the code execution phase, middleware is divided into two phases: before (upstream) processing the request and after (downstream). These phases are separated by the next function, which is passed to middleware.
common function
// Middleware обычно получает 2 параметра (ctx, next), ctx это контекст запроса,
// next это функция которая будет выполнена в фазе 'downstream' этого middleware. Она возвращает промис, который можно зарезолвить с помощью фукции then и выполнить часть кода после того как запрос уже обработан.
app.use((ctx, next) => {
const start = new Date();
return next().then(() => {
const ms = new Date() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
});
async function (works with babel transporter)
app.use(async (ctx, next) => {
const start = new Date();
await next();
const ms = new Date() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
generatorFunction
In the case of this approach, you need to connect the co library , which, starting with version 2.0, is no longer part of the framework:
app.use(co.wrap(function *(ctx, next) {
const start = new Date();
yield next();
const ms = new Date() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
}));
Legacy middleware from koa v1 is also supported. I hope the above examples understand where upstream / downstream is. (If not, write a comment)
In the context of a ctx request, there are 2 important request and response objects for us . In the process of writing middleware, we will analyze some properties of these objects, using the indicated links you can get a complete list of properties and methods that you can use in your application.
It's time to move on to practice until I have quoted all of the ECMAScript documentation
Writing your first middleware
In the first example, we will expand the functionality of our “Hello world” and add an additional header in the response, which will indicate the processing time of the request, another middleware will write all requests to our application to the log. Go:
const Koa = require('koa');
const app = new Koa();
// x-response-time
app.use(async function (ctx, next) {
const start = new Date();
await next();
const ms = new Date() - start;
ctx.set('X-Response-Time', `${ms}ms`);
});
// logger
app.use(async function (ctx, next) {
const start = new Date();
await next();
const ms = new Date() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}`);
});
// response
app.use(ctx => {
ctx.body = 'Hello World';
});
app.listen(3000);
The first middleware saves the current date and at the downstream stage writes a header in response.
The second does the same thing, it just doesn’t write to the header, but displays it on the console.
It is worth noting that if the next method is not called in middleware, then all middleware that are connected after the current one will not participate in request processing.
When testing the example, do not forget to connect babel
Error handler
This job koa copes with chic. For example, we want in case of any error to respond to the user in json-format 500 with an error and the message property with information about the error.
The very first middleware we write the following:
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
// will only respond with JSON
ctx.status = err.statusCode || err.status || 500;
ctx.body = {
message: err.message
};
}
})
That's all, you can try to throw an exception in any middleware with the help of 'throw new Error ("My error") or provoke an error in another way, it will "pop up" along the chain to our handler and the application will respond correctly.
I think that this knowledge should be enough for us to create a small REST service. We will certainly deal with this in the second part of the article, unless, of course, it is of interest to anyone except me.