引言
环境: NodeJS
设计模式 Design Parttern
Design patterns are a way for you to structure your solution’s code in a way that allows you to gain some kind of benefit, such as faster development speed, code reusability, etc.
All patterns lend themselves quite easily to the OOP (object-oriented programming) paradigm. Although, given JavaScript’s flexibility, you can implement these concepts in non-OOP projects as well.
目录
- 立即调用函数表达式 Immediately Invoked Function Expressions (IIFE)
- 工厂模式 The Factory method pattern
- 单例模式 The singleton pattern
- 建造者模式 The builder pattern
- 原型模式 The prototype pattern
- 观察者模式 The observer pattern
- 依赖注入 Dependency injection
- 责任链模式 The chain of responsibility pattern
- 中间件模式 Middleware
- 流模式 Streams
立即调用函数表达式 Immediately Invoked Function Expressions (IIFE)
Definition
The first pattern we’ll explore is one that allows you to define and call a function at the same time. Due to the way JavaScript scopes works, using IIFEs can be great to simulate things like private properties in classes.
(function () {
var x = 20;
var y = 20;
var answer = x + y;
console.log(answer);
})();
Simulating static variables
As you may know, ES6 classes treat every member as public, meaning there are no private properties or methods. That’s out of the question, but thanks to IIFEs you could potentially simulate that:
const autoIncrementer = (function() {
let value = 0;
return {
incr() {
value++
},
get value() {
return value
}
};
})();
> autoIncrementer.incr()
undefined
> autoIncrementer.incr()
undefined
> autoIncrementer.value
2
> autoIncrementer.value = 3
3
> autoIncrementer.value
2
工厂模式 The Factory method pattern
The factory method allows you to centralize the logic of creating objects (which object to create and why) in a single place. This allows you to focus on simply requesting the object you need and then using it.
What does the factory method pattern look like?
class Employee {
speak() {
return "Hi, I'm a " + this.type + " employee";
}
}
class FullTimeEmployee extends Employee {
constructor(data) {
super();
this.type = "full time";
//....
}
}
class PartTimeEmployee extends Employee {
constructor(data) {
super();
this.type = "part time";
//....
}
}
class ContractorEmployee extends Employee {
constructor(data) {
super();
this.type = "contractor";
//....
}
}
class MyEmployeeFactory {
createEmployee(data) {
if (data.type == "fulltime") return new FullTimeEmployee(data);
if (data.type == "parttime") return new PartTimeEmployee(data);
if (data.type == "contractor") return new ContractorEmployee(data);
}
}
Case of handling errors
if(err) {
res.json({error: true, message: “Error message here”})
}
如果你有很多种错误,并且在程序中的 30 多个地方都有使用的话,如果要更换的错误逻辑或者增加错误种类,都会非常麻烦。所以我们可以使用工厂模式将所有错误的处理逻辑集中到同一个 class 中:
if (err) {
res.json(ErrorFactory.getError(err));
}
单例模式 The singleton pattern
The singleton pattern is another oldie but goodie. It’s a simple pattern but it helps you keep track of how many instances of a class you’re instantiating. The pattern helps you keep that number to just one, all of the time.
What does the singleton pattern look like?
let instance = null;
class SingletonClass {
constructor() {
this.value = Math.random(100);
}
printValue() {
console.log(this.value);
}
static getInstance() {
if (!instance) {
instance = new SingletonClass();
}
return instance;
}
}
module.exports = SingletonClass;
const Singleton = require("./singleton");
const obj = Singleton.getInstance();
const obj2 = Singleton.getInstance();
obj.printValue();
obj2.printValue();
console.log("Equals:: ", obj === obj2);
Connect with database
const driver = require("...");
let instance = null;
class DBClass {
constructor(props) {
this.properties = props;
this._conn = null;
}
connect() {
this._conn = driver.connect(this.props);
}
get conn() {
return this._conn;
}
static getInstance() {
if (!instance) {
instance = new DBClass();
}
return instance;
}
}
module.exports = DBClass;
建造者模式 The builder pattern
In this design pattern, the focus is to separate the construction of complex objects from their representation. In Node.js builder, the pattern is a way to create complex objects in a step-by-step manner.
What does the builder pattern look like?
Most of the time while developing a solution one has to handle too many properties. One approach is to pass all the properties in the constructor.
If developed properly, the code will run but passing so many arguments inside the constructor will look ugly and if it’s a large-scale application, it might become unreadable over time.
To avoid this, developers use builder design patterns. Let’s understand this by looking at an example:
class House {
constructor(builder) {
this.bedrooms = builder.bedrooms;
this.bathrooms = builder.bathrooms;
this.kitchens = builder.kitchens;
this.garages = builder.garages;
}
}
class HouseBuilder {
constructor() {
this.bedrooms = 0;
this.bathrooms = 0;
this.kitchens = 0;
this.garages = 0;
}
setBedrooms(bedrooms) {
this.bedrooms = bedrooms;
return this;
}
setBathrooms(bathrooms) {
this.bathrooms = bathrooms;
return this;
}
setKitchens(kitchens) {
this.kitchens = kitchens;
return this;
}
setGarages(garages) {
this.garages = garages;
return this;
}
build() {
return new House(this);
}
}
const house1 = new HouseBuilder()
.setBedrooms(3)
.setBathrooms(2)
.setKitchens(1)
.setGarages(2)
.build();
console.log(house1); // Output: House { bedrooms: 3, bathrooms: 2, kitchens: 1, garages: 2 }
Get flexibility in the creating of objects
Here, the developer wants flexibility in the creation of objects. Instantiating a person’s object is a complex process as it can have multiple properties that need to be catered to. In this case, opting for a builder design pattern is a good idea:
class Person {
constructor(name, age, email, phoneNumber) {
this.name = name;
this.age = age;
this.email = email;
this.phoneNumber = phoneNumber;
}
}
class PersonBuilder {
constructor() {
this.name = "";
this.age = 0;
this.email = "";
this.phoneNumber = "";
}
withName(name) {
this.name = name;
return this;
}
withAge(age) {
this.age = age;
return this;
}
withEmail(email) {
this.email = email;
return this;
}
withPhoneNumber(phoneNumber) {
this.phoneNumber = phoneNumber;
return this;
}
build() {
return new Person(this.name, this.age, this.email, this.phoneNumber);
}
}
// Example usage
const person1 = new PersonBuilder()
.withName("Alice")
.withAge(30)
.withEmail("[email protected]")
.build();
const person2 = new PersonBuilder()
.withName("Bob")
.withPhoneNumber("555-1234")
.build();
原型模式 The prototype pattern
In the context of the Node, a prototype design pattern is classified as a creational design pattern and allows us to create new objects based on a pre-existing object. The gist of this design pattern is to create an object as a prototype and then instantiate a new object by cloning the prototype.
What does the prototype pattern look like?
// Define a prototype object
const prototype = {
greeting: "Hello",
sayHello: function () {
console.log(this.greeting + " World!");
},
clone: function () {
return Object.create(this);
},
};
// Create a new object by cloning the prototype
const newObj = prototype.clone();
// Modify the new object's properties
newObj.greeting = "Hola";
// Call the sayHello method of the new object
newObj.sayHello(); // Output: Hola World!
Use cases
- Creating new objects with similar properties: If developers need to create new objects that share similar properties, opting for prototype design pattern is the best decision
- Optimizing object creation: If creating new object in the application is a costly decision, opting for prototype pattern to reduce the overhead is the best decision.
- Caching: If the developer needs to cache data in the application, he can opt for the Prototype pattern to create a cache of objects that are initialized with default values. When a new object is needed, developers can clone one of the objects in the cache and modify its properties as needed
To better understand this, here is an example of how the prototype design pattern can be used to cache data in a Node.js application:
// Define a prototype object for caching data
const cachePrototype = {
data: {},
getData: function (key) {
return this.data[key];
},
setData: function (key, value) {
this.data[key] = value;
},
clone: function () {
const cache = Object.create(this);
cache.data = Object.create(this.data);
return cache;
},
};
// Create a cache object by cloning the prototype
const cache = cachePrototype.clone();
// Populate the cache with some data
cache.setData("key1", "value1");
cache.setData("key2", "value2");
cache.setData("key3", "value3");
// Clone the cache to create a new cache with the same data
const newCache = cache.clone();
// Retrieve data from the new cache
console.log(newCache.getData("key1")); // Output: value1
// Modify data in the new cache
newCache.setData("key2", "new value");
// Retrieve modified data from the new cache
console.log(newCache.getData("key2")); // Output: new value
// Retrieve original data from the original cache
console.log(cache.getData("key2")); // Output: value2
观察者模式 The observer pattern
The observer pattern allows you to respond to a certain input by being reactive to it instead of proactively checking if the input is provided. In other words, with this pattern, you can specify what kind of input you’re waiting for and passively wait until that input is provided in order to execute your code. It’s a set and forget kind of deal.
What does the observer pattern look like?
const http = require('http');
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Your own server here');
});
server.on('error', err => {
console.log(“Error:: “, err)
})
server.listen(3000, '127.0.0.1', () => {
console.log('Server up and running');
});
Observable
class 的实现
class Observable {
constructor() {
this.observers = {};
}
on(input, observer) {
if (!this.observers[input]) this.observers[input] = [];
this.observers[input].push(observer);
}
off(input, observer) {
if (!this.observers[input]) return;
const index = this.observers[input].indexOf(observer);
if (index > -1) {
this.observers[input].splice(index, 1);
}
}
triggerInput(input, params) {
if (this.observers[input]) {
this.observers[input].forEach((o) => {
o.apply(null, params);
});
}
}
}
class Server extends Observable {
constructor() {
super();
}
triggerError() {
let errorObj = {
errorCode: 500,
message: "Port already in use",
};
this.triggerInput("error", [errorObj]);
}
}
Register error input
server.on('error', err => {
console.log(“Error:: “, err)
})
triggerError
Error::{ errorCode: 500, message: "Port already in use" }
Use cases
- 处理应用程序中大量的异步代码
- 任意触发特定事件 (如错误或状态更新) 的模块
- HTTP 模块
- 任何数据库驱动程序
- Socket.IO
依赖注入 Dependency injection
In the context of Node.js, dependency injection is a design pattern that is used to decouple application components and make them more testable and maintainable.
The basic idea behind dependency injection is to remove the responsibility of creation and management of an object’s dependencies (i.e., the other objects it depends on to function) from the object itself and delegate this responsibility to an external component. Rather than creating dependencies inside an object, the object receives them from an external source at runtime.
By using dependency injection, we can:
- Avoid hardcoding dependencies inside an object, which makes it difficult to modify or replace them
- Simplify the testing process by injecting mock dependencies during the testing phase
- Promote code reusability and modularity by separating concerns
What does the dependency injection look like?
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
async getUsers() {
const users = await this.userRepository.getUsers();
return users;
}
async addUser(user) {
await this.userRepository.addUser(user);
}
}
class UserRepository {
async getUsers() {
// get users from database
}
async addUser(user) {
// add user to database
}
}
// Creating instances of the classes
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
// Using the userService object to get and add users
userService.getUsers();
userService.addUser({ name: "John", age: 25 });
Use cases
- Testing: One of the main benefits of DI is that it makes it easier to test code. By injecting dependencies into classes, it becomes easier to replace them with mock during testing, which allows us to isolate the code under test
- Modularization: DI can also help to make code more modular by minimizing the coupling between different components of code
- Scalability: DI can also help to make applications more scalable. It does this by making it easier to manage dependencies between different components
Email case for test phase
EmailSender
class sends emails to users, and it depends on an EmailService
class to actually send the email. We want to test EmailSender
without actually sending emails to real users, so we can create a mock EmailService
class that logs the email content instead of sending the email:
// emailSender.js
class EmailSender {
constructor(emailService) {
this.emailService = emailService;
}
async sendEmail(userEmail, emailContent) {
const success = await this.emailService.sendEmail(userEmail, emailContent);
return success;
}
}
// emailService.js
class EmailService {
async sendEmail(userEmail, emailContent) {
// actually send the email to the user
// return true if successful, false if failed
}
}
// emailSender.test.js
const assert = require("assert");
const EmailSender = require("./emailSender");
const EmailService = require("./emailService");
describe("EmailSender", () => {
it("should send email to user", async () => {
// Create a mock EmailService that logs email content instead of sending the email
const mockEmailService = {
sendEmail: (userEmail, emailContent) => {
console.log(`Email to ${userEmail}: ${emailContent}`);
return true;
},
};
const emailSender = new EmailSender(mockEmailService);
const userEmail = "[email protected]";
const emailContent = "Hello, this is a test email!";
const success = await emailSender.sendEmail(userEmail, emailContent);
assert.strictEqual(success, true);
});
});
责任链模式 The chain of responsibility pattern
The chain of responsibility pattern is one that many Node.js developers have used without even realizing it.
What does the chain of responsibility look like?
function processRequest(r, chain) {
let lastResult = null
let i = 0
do {
lastResult = chain\[i\](r)
i++
} while(lastResult != null && i < chain.length)
if(lastResult === null) {
console.log("Error: request could not be fulfilled")
}
}
let chain = [
function (r) {
if(typeof r == 'number') {
console.log("It's a number: ", r)
return null
}
return r
},
function (r) {
if(typeof r == 'string') {
console.log("It's a string: ", r)
return null
}
return r
},
function (r) {
if(Array.isArray(r)) {
console.log("It's an array of length: ", r.length)
return null
}
return r
}
]
processRequest(1, chain)
processRequest([1,2,3], chain)
processRequest('[1,2,3]', chain)
processRequest({}, chain)
Output
It's a number: 1
It's an array of length: 3
It's a string: [1,2,3]
Error: request could not be fulfilled
Use cases
中间件模式 Middleware for ExpressJS
// Middleware function to log incoming requests
app.use((req, res, next) => {
console.log(`Incoming request: ${req.method} ${req.url}`);
next();
});
// Route handler for the home page
app.get("/", (req, res) => {
res.send("Hello World!");
});
// Start the server
app.listen(3000, () => {
console.log("Server listening on port 3000");
});
流模式 Streams
const { Readable, Transform, Writable } = require("stream");
// Define a Readable stream that emits an array of numbers
class NumberGenerator extends Readable {
constructor(options) {
super(options);
this.numbers = [1, 2, 3, 4, 5];
}
_read(size) {
const number = this.numbers.shift();
if (!number) return this.push(null);
this.push(number.toString());
}
}
// Define a Transform stream that doubles the input number
class Doubler extends Transform {
_transform(chunk, encoding, callback) {
const number = parseInt(chunk, 10);
const doubledNumber = number * 2;
this.push(doubledNumber.toString());
callback();
}
}
// Define a Writable stream that logs the output
class Logger extends Writable {
_write(chunk, encoding, callback) {
console.log(`Output: ${chunk}`);
callback();
}
}
// Create instances of the streams
const numberGenerator = new NumberGenerator();
const doubler = new Doubler();
const logger = new Logger();
// Chain the streams together
numberGenerator.pipe(doubler).pipe(logger);
Resource
- A guide to Node.js design patterns: https://blog.logrocket.com/guide-node-js-design-patterns/