Bài viết được sự cho phép của tác giả Sơn Dương
Với những bạn lập trình Java thì có lẽ biết rất rõ nguyên lý SOLID. Với Java thì SOLID gần như là quy tắc bất di bất dịch mà mọi lập trình viên phải nắm vững. Mình cũng đã có một bài viết về clean code với nguyên lý SOLID. Các bạn có thể đọc lại nhé.
Tuy nhiên, với Node.js hay Javascript nói chúng thì lại rất dễ dãi. Bạn viết code kiểu gì cũng được, bạ đâu viết đấy cũng được và tùy thuộc style code của mỗi người. Chính vì điều này mà Node.js/Javascript cực dễ học.
Nhưng vì viết code thoải mái, không có quy tắc sẽ dẫn đến dự án khó maintain, code sẽ rất rối, khó debug… Chính vì vậy, nếu có thể áp dụng được nguyên tắc SOLID cho dự án Node.js thì thật tuyệt.
Bài viết này mình sẽ chia sẻ cách thực hiện nguyên lý SOLID trong Node.Js với sự hỗ trợ của TypeScript.
1. SOLID là gì? Nguyên lý SOLID trong Node.js
Trước khi chúng ta bắt tay vào code thì phải hiểu SOLID là gì đã. Cần phải nắm vững lý thuyết trước khi thực hành là quan điểm của mình.
Về cơ bản thì các nguyên lý thiết kế phần mềm hay kiến trúc phần mềm sinh ra là để ứng dụng dễ maintain, dễ mở rộng về sau. Muốn đạt được điều đó thì người ta thực hiện nguyên tắc “Chia để trị“.
Tức là người ta chia ứng dụng thành các khía cạnh khác nhau, càng độc lập với nhau càng tốt.
Mình lấy ví dụ: Ứng dụng “quản lý siêu thị” chẳng hạn. Logic của nghiệp vụ quản lý siêu thị: quản lý thu nhân như nào, quản lý hàng nhập kho ra sao… được coi là một khía cạnh. Giao diện của ứng dụng là một khía cạnh.
Việc lập trình giao diện và logic ứng dụng nên độc lập với nhau. Để sau này khi đổi giao diện không cần phải đổi cả business logic của ứng dụng.
Và nguyên lý SOLID cũng kiểu như vậy.
Thực hiện nguyên lý SOLID trong Node.JS
SOLID là tên viết tắt của 5 nguyên tắc sau:
Về định nghĩa cụ thể từng nguyên tắc của SOLID đã được mình trình bày ở bài viết trước. Bài này mình chỉ tập trung vào cách thực hiện nguyên lý SOLID trong Node.js như thế nào mà thôi.
#Single responsibility principle
Được hiểu như sau:
Một class chịu trách một việc mà thôi
Ví dụ sau mình tạo một class Person bằng TypeScript. Trong đó mình định nghĩa các thuộc tính của một người như Tên, đệm, thông tin liên hệ( email), và các hành động chào hỏi – greet()
, xác thực email – validateEmail()
.
class Person { public name : string; public surname : string; public email : string; constructor(name : string, surname : string, email : string){ this.surname = surname; this.name = name; if(this.validateEmail(email)) { this.email = email; } else { throw new Error("Invalid email!"); } } validateEmail(email : string) { var re = /^([w-]+(?:.[w-]+)*)@((?:[w-]+.)*w[w-]{0,66}).([a-z]{2,6}(?:.[a-z]{2})?)$/i; return re.test(email); } greet() { alert("Hi!"); } }
Nhìn qua class trên bạn thấy ngay rằng có chi tiết thừa. Đó chính là hàm validateEmail(), vì hành động này không phải là hành vi của một người. Không nên đặt hàm này trong class Person.
Chúng ta có cải tiến đoạn code như sau:
class Email { public email : string; constructor(email : string){ if(this.validateEmail(email)) { this.email = email; } else { throw new Error("Invalid email!"); } } validateEmail(email : string) { var re = /^([w-]+(?:.[w-]+)*)@((?:[w-]+.)*w[w-]{0,66}).([a-z]{2,6}(?:.[a-z]{2})?)$/i; return re.test(email); } } class Person { public name : string; public surname : string; public email : Email; constructor(name : string, surname : string, email : Email){ this.email = email; this.name = name; this.surname = surname; } greet() { alert("Hi!"); } }
#Open/close principle
Có thể hiểu nguyên tắc này như sau:
Chỉ nên mở rộng một class bằng cách kế thừa. Tuyệt đối không mở rộng class bằng cách sửa đổi nó.
Điểm mấu chốt của nguyên tắc này là: Chỉ được THÊM mà không được SỬA.
Đoạn code dưới đây là một ví dụ cho vi phạm nguyên tắc này:
class Rectangle { public width: number; public height: number; } class Circle { public radius: number; } function getArea(shapes: (Rectangle|Circle)[]) { return shapes.reduce( (previous, current) => { if (current instanceof Rectangle) { return current.width * current.height; } else if (current instanceof Circle) { return current.radius * current.radius * Math.PI; } else { throw new Error("Unknown shape!") } }, 0 ); }
Đoạn code cho phép chúng ta tính diện tích của hình chữ nhật và hình tròn. Nếu giờ mình muốn mở rộng chương trình, muốn hỗ trợ thêm hình tam giác nữa. Vậy phải làm sao?
Mình sẽ phải SỬA code của hàm getArea()
. Mà làm như vậy là vi phạm nguyên tắc rồi.
Để cải thiện, chúng ta sửa lại thành như sau:
interface Shape { area(): number; } class Rectangle implements Shape { public width: number; public height: number; public area() { return this.width * this.height; } } class Circle implements Shape { public radius: number; public area() { return this.radius * this.radius * Math.PI; } } function getArea(shapes: Shape[]) { return shapes.reduce( (previous, current) => previous + current.area(), 0 ); }
Với code mới này, để hỗ trợ thêm một hình dạng mới, bạn chỉ cần tạo THÊM một class và implement interface Shape là được, không cần phải sửa code đã có.
#Liskov substitution principle
Trong một chương trình, các object của class con có thể thay thế class cha mà không làm thay đổi tính đúng đắn của chương trình
Với nguyên tắc này khuyến khích chúng ta sử dụng tính đa hình trong lập trình hướng đối tượng.
Quay lại đoạn code của ví dụ trước:
function getArea(shapes: Shape[]) { return shapes.reduce( (previous, current) => previous + current.area(), 0 ); }
Chúng ta đã sử dụng interface Shape để đảm bảo chương trình có thể mở rộng mà không cần phải sửa code đã có.
Nguyên tắc thay thế Liskov cho chúng ta biết rằng chúng ta có thể chuyển bất cứ kiểu con nào của Shape( Ví dụ: Rectangle, Cyrcle…) sang hàm getArea() mà không làm thay đổi tính chính xác của chương trình.
Trong các ngôn ngữ kiểu như TypeScript/Java, thì trình biên dịch sẽ kiểm tra đã implement chính xác interface đó chưa( Nếu implement mà không override đủ method là bị lỗi biên dịch ngay). Nên bạn không phải làm thủ công để đảm bảo chương trình tuân thủ nguyên tắc Liskov.
# Interface segregation principle
Nên tách Interface thành nhiều interface nhỏ với những mục đích riêng biệt
Vẫn lấy đoạn code tính diện tích hình chữ nhật và hình tròn. Bây giờ có một vấn đề là: Nếu bạn sử dụng đoạn code cho nhiều mục đích khác nhau thì sao?
Nếu mình muốn tính diện tích xong rồi thì mã hóa thành JSON và trả về cho client. Nếu mình code như sau thì sẽ vi phạm nguyên tắc thứ 4 này.
interface Shape { area(): number; serialize(): string; } class Rectangle implements Shape { public width: number; public height: number; public area() { return this.width * this.height; } public serialize() { return JSON.stringify(this); } } class Circle implements Shape { public radius: number; public area() { return this.radius * this.radius * Math.PI; } public serialize() { return JSON.stringify(this); } }
Vi phạm bởi vì: Có lúc mình dùng không cần phải mã hóa thành JSON để trả cho client, có lúc mình lại cần JSON. Đoạn code đã mix cả hai mục đích ấy vào cùng một interface.
Để tốt hơn thì nên tác interface ra.
interface RectangleInterface { width: number; height: number; } interface CircleInterface { radius: number; } interface Shape { area(): number; } interface Serializable { serialize(): string; }
Khi không cần mã hóa JSON.
class Rectangle implements RectangleInterface, Shape { public width: number; public height: number; public area() { return this.width * this.height; } } class Circle implements CircleInterface, Shape { public radius: number; public area() { return this.radius * this.radius * Math.PI; } } function getArea(shapes: Shape[]) { return shapes.reduce( (previous, current) => previous + current.area(), 0 ); }
Và khi cần mã hóa thành JSON.
class RectangleDTO implements RectangleInterface, Serializable { public width: number; public height: number; public serialize() { return JSON.stringify(this); } } class CircleDTO implements CircleInterface, Serializable { public radius: number; public serialize() { return JSON.stringify(this); } }
Các bạn thấy code “ngon” hơn chưa?
#Dependency inversion principle
Đây là nguyên tắc quan trọng nhất trong nguyên lý SOLID. Nhưng vì nó là chữ D trong cụm từ SOLID nên luôn được giải thích sau cùng.
Nếu chúng ta xem lại các ví dụ ở các nguyên tắc trên, chúng ta thấy rằng interface là một yếu tố cơ bản nhất của mọi nguyên tắc. Và nguyên tắc dependency inversion chính là tổng hợp của 4 nguyên tắc trước.
Việc thực hiện nguyên lý SOLID trong các ngôn ngữ lập trình không hỗ trợ Interface như Javascript ES5, thậm chí ES6 thật là khiêm cưỡng. Nhưng với hỗ trợ của TypeScript thì lại thật tuyệt.
Mình định trình bày tiếp phần kiến trúc Onion (kiến trúc củ hành) kết hợp với nguyên lý SOLID trong Node.js để có được một mã nguồn clean “vô đối”. Nhưng do bài dài quá nên thôi, các bạn đợi bài viết sau nhé!
Bài viết gốc được đăng tải tại vntalking.com
Xem thêm:
- Code ví dụ TypeScript, hướng dẫn tạo project TypeScript
- Cách tạo một Docker đơn giản cho Node.JS
- Hướng dẫn chi tiết xây dựng web app với Reactjs
Xem thêm Việc làm IT hấp dẫn trên TopDev