Skip to content

Latest commit

 

History

History
646 lines (473 loc) · 21.2 KB

ch06.02-the-router-class.md

File metadata and controls

646 lines (473 loc) · 21.2 KB

Read Prev

The Router class

To begin with, let's create a basic version of the Router class for better understanding. We can gradually add more functionality to it as we move forward. The first implementation of the Router class will allow us to add routes and handle basic HTTP requests.

// file: index.js

class Router {
  constructor() {
    // Stores our routes
    this.routes = {};
  }
}

The routes member variable in our Router class serves as an internal data structure for managing the mapping between URL paths and corresponding handlers. This approach provides the benefits of encapsulation and efficient route lookups. However, this approach is not suitable for dynamic routes such as /api/:version or /api/account/:account_id/transactions, where there are infinite possible URLs that can match. This issue will be addressed later. For now, we will stick to static routes such as /api/users or /api/account/signup.

Let's introduce a new helper method called addRoute in the Router class. This method will help us bind callback functions to execute whenever a request for a particular endpoint is made.

// file: index.js

class Router {
  constructor() {
    this.routes = {};
  }

  addRoute(method, path, handler) {
    this.routes[`${method} ${path}`] = handler;
  }
}

The first argument, method is the HTTP method - GET, POST, PUT etc. The second argument is the URL or the path of the request, and the third argument is the callback function that will be called for that particular method and path combination. Let's see an example on how will be it called.

// file: index.js

class Router { ... }

const router = new Router();

router.addRoute('GET', '/', () => console.log('Hello from GET /'));

Let us add a method inside our Router class which will print all the routes, for debugging purposes.

// file: index.js

class Router {
    constructor() { ... }

    addRoute(method, path, handler) { ... }

    printRoutes() {
        console.log(Object.entries(this.routes));
    }
}

const router = new Router();
router.addRoute('GET', '/', () => console.log('Hello from GET /'));
router.addRoute('POST', '/', () => console.log('Hello from POST /'));

router.printRoutes()

Now, let us give it a try:

$ node index.js
// Outputs
[
  [ 'GET /', [Function (anonymous)] ],
  [ 'POST /', [Function (anonymous)] ]
]

The Object.entries method converts an object, with key-value pairs, into a tuple array with only two elements. The first element is the key, and the second element is the corresponding value.

The function (anonymous) signature isn't helpful at all. Let's change it:

// file: index.js

...

const router = new Router();
router.addRoute('GET', '/', function handleGetBasePath() { console.log(...) });
router.addRoute('POST', '/',function handlePostBasePath() { console.log(...) });

router.printRoutes()

Outputs:

[
  [ 'GET /', [Function: handleGetBasePath] ],
  [ 'POST /', [Function: handlePostBasePath] ]
]

Much better. I generally tend to avoid anonymous functions as much as I can, and give them a proper name instead. The above can also be written as:

// file: index.js

function handleGetBasePath() { ... }
function handlePostBasePath() { ... }

router.addRoute("GET", "/", handleGetBasePath)
router.addRoute("POST", "/", handlePostBasePath)

Let us try to hook our router with a real HTTP server. We'll have to import the node:http module, as well as add an utility method inside the Router class that redirects the incoming request to their appropriate handlers.

Using Router with an HTTP server

// file: index.js

class Router {
    ...
    handleRequest(request, response) {
        const { url, method } = request;
        this.routes[`${method} ${url}`](request, response);
    }
}

Since we only care about the url and method of an HTTP request, we're destructuring it out of the request object. There's a weird looking syntax, that might be foreign to you if you're new to javascript.

this.routes[`${method} ${url}`](request, response);

The above is a short way to write:

let functionToExecute = this.routes[`${method} ${url}`];
functionToExecute(request, response);

You need to be careful with this syntax though. If path and method combination isn't registered, it will return undefined. And the above syntax would resolve to undefined(), that in fact does not make any sense. Javascript will throw a beautiful error message -

undefined is not a function

To take care of it, let's add a simple check:

class Router {
    ...
    handleRequest(request, response) {
        const { url, method } = request;
        const handler = this.routes[`${method} ${url}`];

        if (!handler) {
            return console.log('404 Not found')
        }

        handler(request, response)
    }
}

Now let's forward every request to this method.

// file: index.js

const http = require('node:http')
const PORT = 5255;

class Router { ... }

const router = new Router();
router.addRoute('GET', '/', function handleGetBasePath() { console.log(...) });
router.addRoute('POST', '/', function handlePostBasePath() { console.log(...) });

let server = http.createServer(function serveRequest(request, response) {
    router.handleRequest(request, response)
})

server.listen(PORT)

We've imported the node:http module, to create an HTTP server, and used the http.createServer method, providing it a callback that takes 2 arguments - first one is the request, and the second one is the response object.

We can still make our code a little better. Instead of passing a callback that has only one job, i.e. calls another method; we can directly pass the target method as an argument:

// file: index.js

class Router {...}

/** __ Add routes __ **/

let server = http.createServer(router.handleRequest);
server.listen(PORT)

Or even shorter, in case you do not wish to access any methods of the http.Server object returned by http.createServer.

http.createServer(router.handleRequest).listen(PORT);

Let us test it using cURL, after starting the server using node index on a different terminal:

$ curl http://localhost:5255 -v
*   Trying 127.0.0.1:5255...
* Connected to localhost (127.0.0.1) port 5255 (#0)
> GET / HTTP/1.1
> Host: localhost:5255
> User-Agent: curl/7.87.0
> Accept: */*
>
* Empty reply from server
* Closing connection 0
curl: (52) Empty reply from server

An empty reply from server? Let's head over to the node program's console. Oops, a crash!

TypeError: Cannot read properties of undefined (reading 'GET /')
        at Server.handleRequest (/Users/ishtmeet/Code/velocy/index.js:16:36)

It's saying we tried to access the key GET / of an undefined value. It's pointing at this line:

const handler = this.routes[`${method} ${url}`];

It is saying that this.routes is undefined. Weird, isn't it? No. If you have written some code with javascript previously, you would've already figured out the issue. The culprit is this line:

http.createServer(router.handleRequest);

Let us try to print what the value of this is, in the handleRequest method:

class Router {
    ...
    handleRequest(request, response) {
        console.log(this.constructor.name);
        ...
    }
}

Send another cURL request:

# Prints
Server

How?

this is not good

Let's take a moment to understand a huge part of Javascript programming in general: the this keyword. We won't go deep into the nitty gritty of this but we'll understand enough to not fall into this weird semantic bug in our programs.

When we tried to log this.constructor.name, it printed Server which has nothing to do with our code. We don't have a class or function named Server in our code. It means that the this context inside handleRequest is an instance of Node's native HTTP Server class, not our Router class.

The reason is how this works in JavaScript when passing a method as a callback. When we originally had:

// we're passing the router.handleRequest method here as an argument
let server = http.createServer(router.handleRequest);

// The `handleRequest` method is defined as a normal function, not an arrow function
class Router {
    handleRequest(request, response) {
        this.routes; // Looks good but not good.
        ...
    }
}

The this value in the handleRequest method will not be the original object (router in this case), but will be determined by how it's called — which, in the case of http.createServer, won't be the router object. That's why this.routes is undefined.

It turns out that there is nothing wrong with the method definition inside the Router class. However, there is an issue with the way it is being invoked.

The method handleRequest is passed as a callback to http.createServer(). When this callback gets invoked by the Node.js HTTP Server, the context (this) inside handleRequest is bound to that Server instance, not to the Router instance.

We're passing a reference to the handleRequest method, but it loses its context (this), i.e. it gets dissociated from the router instance of the Router class. When the handleRequest method is invoked by the HTTP server, this is set to the HTTP Server object, not the router instance.

How do we fix this? There are two ways: an old way and a modern one. Let's see the old way first:

Using .bind()

let server = http.createServer(router.handleRequest.bind(router));

The .bind method returns a new function that is a "bound" version of the handleRequest method, such that the this context within that method is set to the router instance.

So, .bind() ensures that when handleRequest is called, the value of this inside of it will be our router object. Before ES6 or EcmaScript 2015, this was the standard way of solving issues with the this keyword.

Let's take a look at a more convenient way, i.e use an Arrow function:

Using Arrow function

let server = http.createServer((req, res) => router.handleRequest(req, res));

// or if you prefer named functions
const handleRequest = (req, res) => router.handleRequest(req, res);
const server = http.createServer(handleRequest);

Unlike normal functions, arrow functions don't have their own this. Instead, they inherit the this value from the surrounding lexical context where they were defined. This lexical scoping for this is one of the most useful features of arrow functions.

I'll explain what I mean by the lexical context.

Lexical Context

Lexical context (or lexical scope) is the area where a certain variable is accessible or has meaning. When a variable is defined, it's confined to a particular scope, and it can't be accessed from outside of that scope.

Global Scope
|
|-- const global = "I'm global";
|
|-- function outerFunction() {
|     |
|     |-- const outer = "I'm in the outer function";
|     |
|     |-- function innerFunction() {
|     |     |
|     |     |-- const inner = "I'm in the inner function";
|     |     |
|     |     |-- // Can access inner, outer, and global
|     |}
|     |
|     |-- // Can access outer and global, but NOT inner
|}
|
|-- // Can access global, but NOT outer or inner

Let's look at another example using classes:

class Person {
  constructor() {
    this.name = "Ishtmeet";
  }

  regularFunction() {
    setTimeout(function () {
      // `this` here is not the Person instance, it's either the window object
      // in browsers or `global` in Node.js
      console.log(this.name); // Undefined or error
    }, 1000);
  }

  arrowFunction() {
    setTimeout(() => {
      // `this` here is lexically bound, it's the Person instance
      console.log(this.name); // Outputs: "Ishtmeet"
    }, 1000);
  }
}

One more example using classes:

class Player {
  constructor() {
    this.health = 52;
  }

  regularFunction() {
    // Will output 52 if called as an instance method
    console.log(this.health);
  }

  arrowFunction = () => {
    // Will also output 52 if called as an instance method
    console.log(this.health);
  };
}

For example:

const mainCharacter = new Player();

mainCharacter.regularFunction(); // Outputs 52
mainCharacter.arrowFunction(); // Outputs 52

But, let's see what happens if we extract these methods and call them independently of the class instance:

const extractedRegularFn = mainCharacter.regularFunction;
const extractedArrowFn = mainCharacter.arrowFunction;

extractedRegularFn(); // Outputs undefined or throws an error
extractedArrowFn(); // Outputs 52

In the code above, extractedRegularFn() outputs undefined or throws a TypeError depending on strict mode because it loses its original this context.

On the other hand, extractedArrowFn() still outputs 52, because the arrow function doesn't have its own this; it uses the this from the lexical scope where it was defined (inside the Player constructor, because it is bound to mainCharacter as we specifically called it on mainCharacter using mainCharacter.arrowFunction).

Arrow functions are not free

Keep in mind, arrow functions come with a slight performance penalty. It is usually negligible for most applications, but can be heavy on memory if we're creating a lot of objects of a certain class. This won't be an issue with our Router, but it's worth knowing this.

When arrow functions are defined as class methods/properties, a new function object is created for each instance of the class, rather than each time the function is invoked.

For example, let's look at this example:

class Monster {
  regularMethod() { ...  }
  arrowMethod = () => { ... };
}

const boss = new Monster();
const creep = new Monster()

In this case, both boss and creep will have their own copy of arrowMethod, because it's defined as an instance property using arrow syntax. Each time a new Monster object is created, new memory is allocated for arrowMethod.

On the other hand, regularMethod is defined on Monster.prototype, meaning that all instances of Monster share the same regularMethod function object. This is generally more memory-efficient.

In a game, one can spawn thousands, if not millions of monsters. Or imagine another hypothetical example of a photo editing application, that stores every pixel on the screen as an object of a Pixel class. There are going to be millions of pixels on the screen, and every extra allocation for the function body may be slight overwhelming for the memory constraints.

Why should we care about memory?

We are focusing on building a high-performance backend framework, it is important to consider the impact of memory allocations. While the Router class may only have 10-15 instances, we may introduce our custom Response or Request class in the future. If we create every function as an arrow function, our framework will allocate unnecessary memory for applications receiving high load, such as those receiving thousands of requests per second. An easy way to picture arrow functions in mind is as follows:

class Response {
  constructor() {
    // If these arrow functions are created new for every instance of Response,
    // and the site is currently in a heavy load situation, receiving 5k requests per second
    // we'd be creating 5,000 * num_of_arrow_functions new function instances per second
    this.someMethod = () => {
      /*... */
    };
    this.anotherMethod = () => {
      /* ... */
    };
    // ... more arrow functions
  }
}

Creating separate function objects for each instance is usually not a concern for most applications. However, in cases where you have a very large number of instances or if instances are frequently created and destroyed, this could lead to increased memory usage and garbage collection activity.

It's also worth noting that if we're building a library or a base class that other developers will extend, using prototype methods (regular methods) allows for easier method overriding and usage of the super keyword.

Note: Unless we're in a very performance-critical scenario or creating a vast number of instances, the difference is likely negligible. Most of the time, the decision between using arrow functions or regular methods in classes comes down to semantics and specific requirements around this binding.

Testing the updated code

Our code in the index.js file should look something like this:

// file: index.js

const http = require("node:http");

const PORT = 5255;

class Router {
  constructor() {
    this.routes = {};
  }

  addRoute(method, path, handler) {
    this.routes[`${method} ${path}`] = handler;
  }

  handleRequest(request, response) {
    const { url, method } = request;
    const handler = this.routes[`${method} ${url}`];

    if (!handler) {
      return console.log("404 Not found");
    }

    handler(request, response);
  }

  printRoutes() {
    console.log(Object.entries(this.routes));
  }
}

const router = new Router();
router.addRoute("GET", "/", function handleGetBasePath() {
  console.log("Hello from GET /");
});

router.addRoute("POST", "/", function handlePostBasePath() {
  console.log("Hello from POST /");
});

// Note: We're using an arrow function instead of a regular function now
let server = http.createServer((req, res) => router.handleRequest(req, res));
server.listen(PORT);

Let us try to execute this code, and send a request through cURL:

We see the output Hello from GET / on the server console. But, there's still something wrong on the client (cURL):

$ curl http://localhost:5255/ -v
*   Trying 127.0.0.1:5255...
* Connected to localhost (127.0.0.1) port 5255 (#0)
> GET / HTTP/1.1
> Host: localhost:5255
> User-Agent: curl/7.87.0
> Accept: */*
>

The request headers are shown, means the request was made successfully, although server did not send any response back. Why is it so?

We're observing this behavior because the server has not indicated to the client (in this case, cURL) that the request has been fully processed and the response has been completely sent. Well, how do we indicate that?

We do that using .end() method on the response object. But how can we get access to that inside our callback functions handlePostBasePath() and handleGetBasePath()? Turns out, they're already supplied to these functions when we did this:

// pass the `request` as the first argument, and `response` as the second.
let server = http.createServer((req, res) => router.handleRequest(req, res));

The http.createServer method requires a callback function, and provides request object as the first argument, and the response object as the second.

On updating the code:

// file: index.js

...

router.addRoute("GET", "/", function handleGetBasePath(req, res) {
    console.log("Hello from GET /");
    res.end();
});

router.addRoute("POST", "/", function handlePostBasePath(req, res) {
    console.log("Hello from POST /");
    res.end()
});

...

Now if you try to make a request to any endpoint, the server will respond back with appropriate response body:

$ curl http://localhost:5255/ -v
*   Trying 127.0.0.1:5255...
* Connected to localhost (127.0.0.1) port 5255 (#0)
→ GET / HTTP/1.1
→ Host: localhost:5255
→ User-Agent: curl/7.87.0
→ Accept: */** Mark bundle as not supporting multiuse
← HTTP/1.1 200 OK
← Date: Thu, 07 Sep 2023 13:04:39 GMT
← Connection: keep-alive
← Keep-Alive: timeout=5
← Content-Length: 0
←
* Connection #0 to host localhost left intact

We also set up a 404 handler, in case a route is not configured. We're also going to add a response.end() to indicate the client that the request has been processed.

class Router {
    ...
    handleRequest(request, response) {
        ...
        if (!handler) {
            console.log("404 Not found");
            response.writeHead(404, { 'Content-Type': 'text/plain' })
            return response.end('Not found');
        }
        ...
    }
}

Let's check whether it returns 404, if the route is not registered?

$ curl http://localhost:5255/not/found -v
*   Trying 127.0.0.1:5255...
* Connected to localhost (127.0.0.1) port 5255 (#0)
→ GET /not/found HTTP/1.1
... request body trimmed ...
→
* Mark bundle as not supporting multiuse
← HTTP/1.1 404 Not Found
← Content-Type: text/plain
... response body trimmed ...
* Connection #0 to host localhost left intact
Not found

Great! In the next section, we will explore how to make our router API even more elegant by eliminating the need to specify the name of the HTTP method every time we define a new endpoint handler. So, instead of writing:

router.addRoute("POST", "/", function handlePostBasePath(req, res) {
  console.log("Hello from POST /");
  res.end();
});

We could do something like:

router.get("/", function handlePostBasePath(req, res) {
    console.log("Hello from POST /");
    res.end();
});

This way, we'll provide a clean and clear interface for our clients.

Read Next