Node.js: Patterns & best practices

Node.js: Patterns & best practices
Photo by Caspar Camille Rubin from Unsplash

Node.js là một môi trường lập trình phổ biến, được dùng để xây dựng các ứng dụng cần xử lý lượng request đồng thời lớn. Sự đơn giản, linh hoạt của nó là ưu điểm rất lớn so với các môi trường lập trình khác. Thế nhưng, chính sự linh hoạt của nó lại là con dao hai lưỡi. Nếu đi chệch hướng, ứng dụng sẽ trở thành một đống hỗ độn. Vì vậy, lập trình Node.js cũng phải tuân theo những pattern và best practice nhất định.

Coding style

Sử dụng const, let

Có hai phong cách định nghĩa biến trong JavaScript: var kiểu cũ mà let, const kiểu mới.

Định nghĩa bằng var sẽ cho chúng ta một biến cục bộ trong một hàm, hoặc biến toàn cục nếu định nghĩa var ngoài hàm.

Định nghĩa let, const sẽ cho chúng ta các biến cục bộ trong một block. let định nghĩa một biến, biến này có thể thay đổi bằng cách gán giá trị khác.

let foo = 3;
foo = 6;
console.log(foo); // 6

Từ khóa const có thể gây lú một chút. Theo ý nghĩa, nó định nghĩa một “hằng số”. Tuy nhiên, thực tế nó chỉ định nghĩa một hằng số trỏ đến một vùng nhớ mà thôi. Không thể gán “hằng số” này một giá trị khác, nhưng không có nghĩa là giá trị của hằng số đó không bao giờ thay đổi:

const foo = 3;
foo = 6; // TypeError: Assignment to constant variable.

const bar = {foo: 3};
bar.foo = 6;
console.log(bar); // { foo: 6 }

Như vậy, nếu const định nghĩa một dữ liệu nguyên thủy, nó sẽ là một hằng số, không thể gán giá trị khác. Nhưng nếu nó là một object, giá trị của object đó hoàn toàn có thể thay đổi.

Trong lập trình, chúng ta nên tránh việc sử dụng var, thay vào đó, hãy sử dụng let, const.

Tái định nghĩa một biến bằng var không bị lỗi

Nếu sử dụng var, có thể định nghĩa một biến cùng tên biến cũ trong cùng scope. Biến mới sẽ ghi đè giá trị của biến cũ. Điều này có thể dẫn đến những lỗi không lường trước, đồng thời việc debug cũng khó khăn. Với một hàm dài, một số biến hay được dùng cho vòng lặp như i, j có thể bị khai báo lại.

function aFunction() {
    var i = 1;
    var i = 2;
    console.log(i);
}

aFunction(); // 2

Cả constlet đều không cho phép tái định nghĩa một biến có sẵn. Nhờ đó những tai nạn không mong muốn sẽ khó xảy ra hơn.

function thisFunction() {
    let x = 1;

    // tái định nghĩa biến sẽ gặp lỗi
    // SyntaxError: Identifier 'x' has already been declared
    let x = 2;

    console.log(x);
}

thisFunction();

var sẽ có hiện tượng hoist, cho phép gọi biến trước định nghĩa

Nếu truy cập một biến được định nghĩa bởi var trước dòng code định nghĩa biến đó, JavaScript vẫn cho phép làm điều đó (giá trị của biến sẽ là undefined). Hiện tượng này gọi là hoist (một số ngôn ngữ lập trình khác cũng có hiện tượng này). Hiện tượng này có thể đẫn đến một số lỗi cũng như việc debug sẽ gặp khó khăn vì code sẽ trở nên khó hiểu.

console.log(bar);
// undefined
var bar = 1;

Với letconst, hiện tượng này sẽ không xảy ra.

console.log(foo);
// ReferenceError: Cannot access 'foo' before initialization
let foo = 2;

let, const dễ đọc, dễ kiểm soát

Bởi vì phạm vi của let hay const là trong block, chúng dễ đọc dễ hiểu và dễ kiểm soát phạm vi hơn. Lập trình viên chỉ cần nhìn vào block sâu nhất, nơi biến được định nghĩa là đủ.

let x = 5;

function thisFunction() {
    let x = 1;
    if (true) {
        let x = 2;
    }
    console.log(x);
    // 1
}

thisFunction();
console.log(x);
// 5

Nhưng nếu vẫn đoạn code như trên, sử dụng var sẽ phức tạp hơn rất nhiều:

var x = 5;

function thisFunction() {
    var x = 1;
    if (true) {
        var x = 2;
    }
    console.log(x); // 2
}

thisFunction();
console.log(x); // 5

Với var, lập trình viên sẽ phải cẩn thận hơn rất nhiều khi sử dụng biến, nhất là những biến với tên ngắn gọn.

Naming conventions

Việc đặt tên biến, hàm, v.v… phải tuân theo những nguyên tắc nhất định. Điều này giúp thống nhất cách viết code, giúp làm việc nhóm dễ dàng hơn.

Tên biến và hàm thường dùng lowerCamelCase. Ngay cả khi định nghĩa một hằng số bằng const, nhưng hằng số đó chỉ có giá trị cục bộ thì cũng nên dùng lowerCamelCase.

Chỉ có một vài trường hợp nhất định, định nghĩa biến const dùng kiểu khác. Nếu một hằng số phạm vị toàn bộ ứng dụng mà giá trị của nó (giá trị của các trường, nếu là object) không thay đổi, hằng số đó có thể sử dụng UPPER_SNAKE_CASE.

const ANOTHER_VAR = 3;

Một class nên dùng UpperCamelCase:

class MyClass() {
    // ...
}

Việc đặt tên theo quy tắc giúp duy trì code thống nhất trong toàn bộ dự án, giúp nó dễ đọc, dễ sửa hơn.

ESLint

Thay vì phải suy nghĩ về coding style và chăm chăm đánh giá chúng khi review code, sử dụng những công cụ linting như ESLint là một giải pháp tốt hơn nhiều. ESLint đã được tin tưởng sử dụng để kiểm tra, sửa lỗi về coding style cho JavaScript trong nhiều năm qua. Kết hợp ESLint với Prettier sẽ cho kết quả tốt nhất, code luôn được định dạng theo đúng tiêu chuẩn.

ESLint vốn được phát triển để kiểm tra JavaScript thuần. Để làm việc với từng thư viện, framework cụ thể, người dùng cần cài thêm một số plugin. Với Node.js, một số plugin nổi bật là eslint-plugin-nodeeslint-plugin-node-security.

Ngoài ra, ESLint có thể áp dụng coding style của những công ty lớn như Google hay Airbnb. Đây là những phong cách code được đánh giá cao bởi cộng đồng và được sử dụng rộng rãi trong thời gian qua.

Xử lý lỗi Node.js

Sử dụng Error object

Trong một vài trường hợp, lập trình viên có thể throw một exception khi gặp lỗi. Để dễ dàng hơn cho việc xử lý lỗi sau đó, exception đó nên sử dụng Error object của Node.js. Điều đó giúp việc điều tra lỗi sẽ dễ dàng hơn.

Ví dụ, throw một string như sau:

if (!data) {
    throw "There is no data";
}

Về mặt kỹ thuật, code này không có gì sai cả. Thế nhưng, exception này thiếu những thông tin quan trọng để debug (ví dụ stack trace). Đây là một anti-pattern. Thay vào đó, nếu dùng Error object như dưới đây sẽ tốt hơn rất nhiều:

if (!data) {
    throw new Error("There is no data");
}

Dùng async/await

Ban đầu, Node.js dùng callback để xử lý bất đồng bộ. Tuy nhiên, điều này có thể sẽ dẫn đến “callback hell” nếu dùng nhiều callback lồng nhau. Ví dụ:

function getData(err, function(err, res) {
    if(err !== null) {
        function(valueA, function(err, res) {
            if(err !== null) {
                function(valueB, function(err, res) {
                    // ...
                }
            }
        })
    }
})

Việc sử dụng callback lồng nhau như vậy sẽ là một anti-pattern. Code sẽ trở nên rất khó đọc và sửa chữa sau này. Hiện tại, Node.js đã hỗ trợ cú pháp async/await để code bất đồng bộ dễ dàng hơn. Với cú pháp này, lập trình viên có thể sử dụng cơ chế kinh điển try/catch để xử lý lỗi. Ví dụ, vẫn là logic phía trên, nhưng viết lại bằng async/await như sau:

async function getData(err, res) {
    try {
        let resA = await functionA(res);
        let resB = await functionB(resA);

        return resB;
    } catch (err) {
        logger.error(err);
    }
}

Ghi log cho ứng dụng

Tôi đã viết riêng một bài về vấn đề này. Nói chung, ghi log là một việc rất quan trọng với tất cả các ứng dụng nói chung và Node.js nói riêng.

Viết test

Viết test quan trọng với mọi ứng dụng, với những ứng dụng lớn và phức tạp, nó càng quan trọng hơn. Test có thể đảm bảo ứng dụng hoạt động ổn định mỗi khi có thay đổi code. Nó giúp phòng tránh việc thay đổi chỗ này gây lỗi ở chỗ khác. Ngoài ra, test cũng giúp đảm bảo code hoạt động theo đúng nhu cầu.

API test

Với các ứng dụng Node.js, viết test cho API có lẽ là phần quan trọng nhất. Lập trình viên có thể sử dụng một số thư viện, ví dụ Supertest, Jest, hoặc bất cứ thư viện nào tương tự. Không chỉ là unit test, những thư viện này cho phép viết test ở nhiều mức độ khác nhau.

Ví dụ với một ứng dụng Express đơn giản như sau:

const express = require("express");
const bodyParser = require("body-parser");

const app = express();

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

// middleware...

app.get("/", (req, res, next) => {
    res.json({ hello: "Hello World" });
});

module.exports = app;

Test với supertest:

const request = require("supertest");
const app = require("./index");

describe("hello test", () => {
    it("/ should return a response", async () => {
    const res = await request(app).get("/");
    expect(res.statusCode).toEqual(200);
    expect(res.body).toEqual({ hello: "Hello World" });
    });
});

Mô tả test có ý nghĩa

Test nên được mô tả đầy đủ ý nghĩa về những gì được test. Điều đó giúp người khác đọc code sẽ dễ hiểu hơn.

Cập nhật package

Kiểm tra package đã lỗi thời

Kiểm tra các package đã lỗi thời bằng lệnh npm outdated hoặc yarn outdated. Những package đã quá cũ nên được cập nhật thường xuyên để ứng dụng hoạt động tốt.

Kiểm tra lỗ hổng bảo mật

Một số công cụ như yarn audit hoặc npm-audit có thể giúp tự động kiểm tra các lỗ hổng bảo mật của các package. Ngoài ra, nên thường xuyên kiểm tra thông tin trên mạng để cập nhật tin tức mới nhất.

Kết luận

Trong bài viết này, tôi đã trình bày một số pattern và best practice trong lập trình Node.js. Hy vọng bài viết sẽ giúp ích trong việc viết những ứng dụng tốt hơn.

Happy coding!

Tôi xin lỗi nếu bài viết có bất kỳ typo nào. Nếu bạn nhận thấy điều gì bất thường, xin hãy cho tôi biết.

Nếu có bất điều gì muốn nói, bạn có thể liên hệ với tôi qua các mạng xã hội, tạo discussion hoặc report issue trên Github.