Ep.11: Vanilla Node (Basic app for Node Backend Frameworks Comparison)
Teolin Codreanu
Software Developer | Microservices / APIs in Node.js, JavaScript, TypeScript, Nest.js / Koa / Express etc
This article is part of an in-depth comparison series of the top Node frameworks. We'll cover all the important aspects: market share, learning curve, ecosystem, security and more. To compare them properly, we will build the exact same app in all these frameworks (plus Vanilla Node), observe all the steps along the way and then benchmark them as we progressively add more functionalities.
Table of contents
Some of you probably wonder why should we bother using a framework at all. So did I, to be honest, as I never actually felt the pain of the callback hell, it was before I got started with Node. Also, if we're serious about benchmarking the performance of the top dogs, we need a baseline, and that baseline is the vanilla version itself (i.e., no framework). We should build a version of the app with nothing on.
My gut tells me that's gonna be harder than I think, but let's dive in. We will want the in-memory storage and synchronous version, and we should start with the single file variant. Let's do this!
'http' to the rescue
The way frameworks work under the bonnet is by wrapping the http node module and building a server and a router around it. So, how do we build a server? Luckily, http has a method that does just that: http.createServer().
It takes a callback handler function as its argument, and that function has two arguments: req & res, the standard request and response objects.
Essentially, a server is just a giant switch which basically says: when the req.url equals 'https://somesite.com/api/someEntity/someAction/someId/etc', call the correct action method for that entity, with the provided id and/or query parameters. For example, for '..../api/users/register', call the user registration function or method.
Since I have 13 nested routes, I went with a nested switch - only one branch is expanded, and the whole code is 3 times longer than you can see here:
You could implement this as if-else statements, or even better, a map, or smarter solutions. In fact, I'm going to refactor this as a map, it's so much easier on the eye.
NOTE on speed:
Update: map is not the bright idea I thought it would be, I switched back to switch.
The Server
In order to understand how controllers work, we need to have a look at the parameters they receive from the server: (req, res) - the Request and Response objects. Let's have a look at the server:
The http.createServer function is used to create an HTTP server. This function takes a request handler callback function as an argument, which receives the two objects as arguments. The request body is collected in chunks and concatenated once the data stream ends via req.on('data'...).on('end'...).
领英推荐
After the Request stream completes and we have our req object, the routing switch springs into action. Based on the path components extracted earlier with:
const [_, api, entity, action, id, operation] = url.pathname.split('/')
it can now decide which branch to switch to. If the pathname was '/api/users/register', our extracted components would be:
_ = ''
api = 'api'
entity = 'users'
action = 'register'
id = ''
operation = ''
Therefore it would pick this branch:
The req object UserController.createUser receives is an object containing { body }.
Controllers
Ok, now let's have a look at the controllers. Continuing with the example above, this is what our UserController.createUser looks like:
Pretty straightforward, right? It parses the body (it receives a serialised JSON, not an object), then it calls the service with the same body - as an object this time.
If the UserService.createUser(body) plays nice and works as expected (i.e. does not throw an error), it proceeds to the next line and sets the status code as a happy 201, then it sends the result of the createUser method as a JSON string back to the server with res.end(...). Voila! We have our first route!
Parameters
What if we need to send multiple types of parameters (path, query, body)? Well, let's take a PUT route. For example PUT/users/user/:id, which should update a user's details:
If we do have an id, we call the service method with two keys in the request object, params and body:
UserController.updateUser({ params: { id }, body }, res)
That's it. From here on, the controller takes over in a similar manner as the other example.
Well, that's about it. The services are identical, no point dwelling on that.
It wasn't anywhere near as difficult as I thought it would be. Since ours is a simple example, there was no callback hell to speak of, but I can indeed imagine this becoming hard to manage as an application increases in complexity.