Structural pattern nói về composition — cách bạn ghép các object thành cấu trúc lớn hơn mà vẫn giữ được sự linh hoạt. Nếu bạn đã từng wrap một library để nó fit với interface của mình, hay xây cây component mà tất cả đều respond cùng một method, thì bạn đã dùng mấy ý tưởng này rồi. Đây là 9 pattern hệ thống hóa chúng.
1. Adapter/ Wrapper
Adapter Pattern là pattern giữ vai trò trung gian giữa hai lớp, chuyển đổi giao diện của một hay nhiều lớp có sẵn thành một giao diện khác, thích hợp cho lớp đang viết. Điều này cho phép các lớp có các giao diện khác nhau có thể dễ dàng giao tiếp tốt với nhau thông qua giao diện trung gian, không cần thay đổi code của lớp có sẵn cũng như lớp đang viết. Adapter Pattern còn gọi là Wrapper Pattern do cung cấp một giao diện “bọc ngoài” tương thích cho một hệ thống có sẵn, có dữ liệu và hành vi phù hợp nhưng có giao diện không tương thích với lớp đang viết
Adapter thì ai cũng biết rồi. Power adapter (cục chuyển đổi điện áp), laptop adapter (cục sạc), memory card adapter — tất cả đều làm một việc: nối hai thứ không tương thích lại với nhau. Adapter Pattern trong code cũng y hệt vậy.
1.1. Tại sao cần sử dụng Adapter Pattern
Mình từng rất chán khi phải viết đi viết lại cùng một đoạn code từ dự án này sang dự án khác. Đến lúc quyết định tự viết library để tái sử dụng thì lại gặp tình huống: interface phù hợp với dự án cũ nhưng sang dự án mới lại dùng không được. Lại hì hục ngồi sửa. Adapter Pattern sinh ra để giải quyết đúng chuyện này. Dùng khi:
- Bạn muốn dùng một class có sẵn (hoặc library bên thứ ba) mà interface của nó không khớp với cái bạn cần.
- Bạn muốn tạo class có khả năng tái sử dụng cao, có thể làm việc với các interface không tương thích trong tương lai.
Để hiểu Adapter Pattern, bạn cần nắm ba khái niệm:
- Client: Lớp sẽ sử dụng đối tượng của bạn (đối tượng mà bạn muốn chuyển đổi giao diện)
- Adaptee: Lớp bạn muốn Client dùng, nhưng giao diện hiện tại không phù hợp
- Adapter: Lớp trung gian, chuyển đổi giao diện của Adaptee và kết nối nó với Client
1.2. Phân loại adapter
- Composition: Lớp B trở thành một field trong lớp A. Lớp A không kế thừa giao diện của B, nhưng có được mọi khả năng mà B có.
- Inheritance: Lớp Derived kế thừa từ lớp Base và thừa hưởng tất cả. Kế thừa giúp tái sử dụng code, dễ bảo trì, dễ nâng cấp — là khái niệm trọng tâm trong OOP. Nhưng lạm dụng nó thì chương trình sẽ phức tạp không cần thiết. Dân lập trình game biết rõ điều này, nên họ thường ưu tiên composition hơn.
Hai khái niệm này cho ta hai cách cài đặt adapter: Object Adapter và Class Adapter.
1.3. Class Adapter
Trong mô hình này, lớp Adapter kế thừa từ lớp có giao diện không tương thích (Adaptee), đồng thời implement giao diện mà người dùng mong muốn (Target). Khi gọi các phương thức của Target, Adapter sẽ gọi các phương thức nó kế thừa từ Adaptee bên trong.
1.3.1. Cấu trúc
1.3.2. Ví dụ
- Tạo giao diện Target phù hợp cho người sử dụng
interface Target {
request(): string;
}
- Tạo lớp Adaptee chứa giao diện không phù hợp
class Adaptee {
specificRequest() : string {
return ".eetpadA eht fo roivaheb laicepS";
}
}
- Tạo lớp Adapter chuyển đổi giao diện sao cho phù hợp với lớp Target
class Adapter extends Adaptee implements Target {
constructor() {
super();
}
request(){
return "Adapter: (TRANSLATED) "+this.specificRequest().split("").reverse().join("");
}
}
- Client sử dụng
function clientCode(target: Target) {
console.log(target.request());
}
let adaptee = new Adaptee();
console.log("Client: The Adaptee class has a weird interface. See, I don't understand it");
console.log("Adaptee: "+adaptee.specificRequest());
console.log("Client: But I can work with it via the Adapter");
let adapter = new Adapter();
clientCode(adapter);
- Kết quả
Client: The Adaptee class has a weird interface. See, I don't understand it
Adaptee: .eetpadA eht fo roivaheb laicepS
Client: But I can work with it via the Adapter
Adapter: (TRANSLATED) Special behavior of the Adaptee.
1.4. Object Adapter
Cách này dùng composition thay vì kế thừa. Adapter giữ một tham chiếu đến đối tượng Adaptee và implement giao diện Target. Khi gọi phương thức Target, Adapter ủy quyền cho Adaptee thông qua tham chiếu đó. Cách này tránh được vấn đề đa kế thừa — mà Java, C# không hỗ trợ. Mình khuyến khích dùng cách này.
1.4.1. Cấu trúc Object Adapter
1.4.2. Ví dụ
- Tạo lớp Target (lớp ban đầu mà client sử dụng)
class Target {
request() {
return "Target: The default target's behavior.";
}
}
- Tạo lớp Adaptee chứa giao diện không phù hợp
class Adaptee {
specificRequest() {
return ".eetpadA eht fo roivaheb laicepS";
}
}
- Tạo lớp Adapter chuyển đổi giao diện sao cho phù hợp với lớp Target
class Adapter implements Target {
constructor(private obj: Adaptee) { }
request(){
return "Adapter: (TRANSLATED) "+this.obj.specificRequest().split("").reverse().join("");
}
}
- Client sử dụng
function clientCode(target: Target) {
console.log(target.request());
}
let adaptee = new Adaptee();
console.log("Client: The Adaptee class has a weird interface. See, I don't understand it");
console.log("Adaptee: "+adaptee.specificRequest());
console.log("Client: But I can work with it via the Adapter");
let adapter = new Adapter(adaptee);
clientCode(adapter);
- Kết quả
Client: The Adaptee class has a weird interface. See, I don't understand it
Adaptee: .eetpadA eht fo roivaheb laicepS
Client: But I can work with it via the Adapter
Adapter: (TRANSLATED) Special behavior of the Adaptee.
1.5. Lời kết
Mình thấy Adapter Pattern cực kỳ hữu ích khi làm ứng dụng lớn có dùng nhiều API bên ngoài. Nó là lớp đệm — khi nhà cung cấp API thay đổi gì đó, bạn chỉ sửa adapter chứ không phải đào bới cả codebase. Đúng, bạn phải tạo thêm class và interface. Nhưng với hệ thống lớn, chi phí ban đầu đó hoàn toàn xứng đáng.
2. Bridge Pattern
Bridge pattern được sử dụng khi chúng ta muốn tách một lớp lớn với các tính năng phức tạp thành một lớp chính (Abstraction) và nhiều giao diện của các tính năng khác nhau (Implementation) để phát triển độc lập với nhau. Chúng được nối với nhau bởi việc lớp chính trỏ đến giao diện của các tính năng gọi là bridge (cầu nối). Nhờ đó việc chỉnh sửa Abstraction sẽ không tác động đến Implementation và ngược lại.
2.1. Vấn đề
Giả sử bạn có lớp Shape với hai lớp con: Circle và Square. Giờ bạn muốn thêm màu sắc. Vì đã có hai lớp con, bạn sẽ cần bốn tổ hợp: BlueCircle, RedSquare, v.v.
Số lượng class tăng theo cấp số nhân. Thêm Triangle? Hai class mới (mỗi màu một cái). Thêm một màu? Ba class mới (mỗi hình một cái). Mỗi chiều mở rộng thêm sẽ nhân lên tổng số class.
Đây chính là lúc cần đến Bridge Pattern.
2.2. Khi nào nên dùng Bridge pattern
- Khi bạn muốn tách và tổ chức một lớp có nhiều biến thể của chức năng (ví dụ: muốn lớp hoạt động với nhiều loại database server khác nhau)
- Khi cần mở rộng class theo nhiều chiều độc lập
- Khi cần chuyển đổi Implementation lúc runtime
2.3. Cấu trúc
- Abstraction: Logic điều khiển mức cao. Ủy quyền công việc cơ bản cho các đối tượng Implementation.
- Implementation: Khai báo interface cho tính năng.
- Concrete Implementations: Các biến thể của Implementation.
- Refined Abstractions: Các biến thể của Abstraction.
- Client: Người dùng — nối Abstraction với Implementation.
2.4. Các bước thực hiện
- Xác định các chiều độc lập trong class của bạn. Có thể là abstraction/platform, domain/infrastructure, front-end/back-end, hay interface/implementation.
- Liệt kê các operation mà client cần và khai báo chúng trong lớp Abstraction.
- Xác định các operation cần thiết cho các biến thể Implementation và khai báo trong interface Implementation.
- Tạo các concrete implementation class cho tất cả biến thể, đảm bảo chúng tuân theo interface Implementation.
- Thêm trường tham chiếu kiểu Implementation vào lớp Abstraction. Abstraction ủy quyền hầu hết công việc cho đối tượng Implementation được tham chiếu.
- Nếu có nhiều biến thể Abstraction, tạo các Refined Abstraction bằng cách extend lớp base Abstraction.
- Client truyền đối tượng Implementation vào constructor của Abstraction. Sau đó Client chỉ làm việc với Abstraction thôi.
2.5. Ví dụ
- Khai báo lớp Abstraction
class Abstraction {
constructor(protected iA : ImplementationA) { }
operation() : string {
return `Abstraction - Base operation with: \n${this.iA.operation_implementation()}`;
}
}
- Tạo giao diện cho tính năng ImplementationA (một tính năng có nhiều biến thể)
interface ImplementationA {
operation_implementation() : string;
}
- Khai báo các biến thể của tính năng ImplementationA (Mở rộng theo chiều ngang)
class ConcreteAImplementationA implements ImplementationA {
operation_implementation() : string {
return `Concrete A of Implementation A`;
}
}
class ConcreteBImplementationA implements ImplementationA {
operation_implementation() : string {
return `Concrete B of ImplementationA`;
}
}
- Client sử dụng
function client_code(abstraction: Abstraction) {
console.log(abstraction.operation());
}
let concreteAimplementationA = new ConcreteAImplementationA();
let abstraction = new Abstraction(concreteAimplementationA);
client_code(abstraction);
- Kết quả
Abstraction - Base operation with:
Concrete A of Implementation A
- Ta có thể mở rộng class Abstraction (Mở rộng theo chiều dọc)
class ExtendedAbstraction extends Abstraction {
constructor(protected iA: ImplementationA) {
super(iA);
}
operation() : string {
return `ExtendedAbstraction - Extended operation with: \n${this.iA.operation_implementation()}`;
}
}
let concreteBimplementationA = new ConcreteBImplementationA();
let extendedAbstraction = new ExtendedAbstraction(concreteBimplementationA);
client_code(extendedAbstraction);
- Kết quả
ExtendedAbstraction - Extended operation with:
Concrete B of ImplementationA
3. Composite
Composite pattern cho phép bạn làm việc với các đối tượng tương tự nhau với cấu trúc dạng cây
3.1. Khi nào nên dùng Composite pattern
- Khi bạn xây dựng thứ gì đó có cấu trúc cây tự nhiên — sơ đồ nhân viên, file system, danh sách bài hát, menu lồng nhau.
- Khi bạn muốn client xử lý đồng nhất cả phần tử đơn lẻ và nhóm phần tử qua cùng một đoạn code.
3.2. Cấu trúc
Ba loại class:
- Component: Base class định nghĩa interface chung cho tất cả các thành phần trong cây.
- Leaf: Phần tử độc lập, không có con. Không chia nhỏ được nữa.
- Composite: Phần tử cấp cao hơn — vừa là component, vừa là tập hợp các component.
3.3. Cách cài đặt
- Đảm bảo mô hình của bạn biểu diễn được dưới dạng cây. Chia thành phần tử đơn giản và container. Container phải chứa được cả phần tử đơn giản lẫn container khác.
- Khai báo
Component interfacevới các phương thức có ý nghĩa cho cả thành phần đơn giản và phức tạp. - Tạo
Leafclass cho các phần tử đơn giản. Có thể có nhiều loại leaf khác nhau. - Tạo
Containerclass cho các phần tử phức tạp. Cung cấp một mảng để lưu tham chiếu đến các thành phần con. Kiểu mảng là Component interface để chứa được cả leaf lẫn container. - Viết phương thức thêm và xóa phần tử con trong container.
3.4. Ví dụ
- Tạo interface cho Component
class Employee {
private subordinates: Array<Employee>;
constructor(
private name: string,
private dept: string,
private salary: number
) {
this.subordinates = new Array<Employee>();
}
add(e: Employee): void {
this.subordinates.push(e);
}
remove(e: Employee): void {
this.subordinates.splice(this.subordinates.indexOf(e), 1);
}
getChildren(): Array<Employee> {
return this.subordinates;
}
toString(): string {
return (`Employee :[ Name : ${this.name}, dept : ${this.dept}, salary : ${this.salary} ]`);
}
}
- Triển khai Component và thêm các node cho cây (các nhân viên), với người đứng đầu là CEO
let CEO: Employee = new Employee("John", "CEO", 30000);
let headSales : Employee = new Employee("Robert", "Head Sales", 20000);
let headMarketing : Employee = new Employee("Michel", "Head Marketing", 20000);
let clerk1 : Employee = new Employee("Laura", "Marketing", 10000);
let clerk2 : Employee = new Employee("Bob", "Marketing", 10000);
let salesExecutive1 : Employee = new Employee("Richard", "Sales", 10000);
let salesExecutive2 : Employee = new Employee("Rob", "Sales", 10000);
CEO.add(headSales);
CEO.add(headMarketing);
headSales.add(salesExecutive1);
headSales.add(salesExecutive2);
headMarketing.add(clerk1);
headMarketing.add(clerk2);
//print all employees of the organization
console.log(CEO);
for (let headEmployee of CEO.getChildren()) {
console.log(headEmployee);
for (let employee of headEmployee.getChildren())
console.log(employee);
}
- Kết quả
Employee :[ Name : John, dept : CEO, salary : 30000 ]
Employee :[ Name : Robert, dept : Head Sales, salary : 20000 ]
Employee :[ Name : Richard, dept : Sales, salary : 10000 ]
Employee :[ Name : Rob, dept : Sales, salary : 10000 ]
Employee :[ Name : Michel, dept : Head Marketing, salary : 20000 ]
Employee :[ Name : Laura, dept : Marketing, salary : 10000 ]
Employee :[ Name : Bob, dept : Marketing, salary : 10000 ]
4. Decorator
Decorator thường được dùng khi ta muốn thêm chức năng cho một đối tượng đã tồn tại trước đó, mà không muốn ảnh hưởng đến các đối tượng khác
4.1. Vấn đề
Đôi khi mình cần mở rộng một phương thức trong đối tượng. Cách thông thường là kế thừa. Nhưng có những lúc kế thừa làm code phức tạp hơn mức cần thiết. Decorator Pattern cho bạn cách mở rộng linh hoạt hơn.
4.2. Khác biệt giữa mở rộng phương thức theo cách linh động với mở rộng theo cách tĩnh
- Mở rộng tĩnh: Kế thừa class rồi mở rộng cứng nhắc. Muốn thêm tính năng? Thêm subclass. Muốn bỏ tính năng? Cũng phải thêm subclass. Cứ thế nhân lên.
- Mở rộng động: Cung cấp cơ chế thay đổi một đối tượng đã tồn tại mà không ảnh hưởng đến các đối tượng khác cùng class.
4.3. Ví dụ
Tưởng tượng bạn làm ở cửa hàng Pizza. Cửa hàng làm pizza cà chua và pizza phô mai. Khách hàng có thể thêm topping: gà hoặc tiêu. Vậy bạn có các combo: pizza gà cà chua, pizza cà chua hạt tiêu, pizza gà phô mai, pizza phô mai hồ tiêu, pizza cà chua gà hồ tiêu và phô mai gà hồ tiêu. Dùng mở rộng tĩnh thì phải tạo một class cho mỗi combo: TomatoChickenPizza, TomatoPepperPizza… Số lượng class bùng nổ. Phải dùng cách động.
4.4. Cấu trúc
Các thành phần:
- Component (IPizza): Interface chung để tạo các đối tượng.
- ConcreteComponent (TomatoPizza, ChickenPizza): Các đối tượng implement Component.
- Decorator (PizzaDecorator): Lớp trừu tượng, giữ tham chiếu đến đối tượng gốc và cài đặt phần cơ bản cho Decorator.
- ConcreteDecorator (PepperDecorator, CheeseDecorator): Decorator cụ thể, gắn thêm chức năng mở rộng cho đối tượng được wrap.
4.5. Thực hành
- Tạo giao diện Component (IPizza):
interface IPizza {
doPizza() : string;
}
- Tạo ra 2 concrete của Component:
class TomatoPizza implements IPizza {
doPizza(): string {
return "I am a Tomato Pizza";
}
}
class ChickenPizza implements IPizza {
doPizza(): string {
return "I am a Chicken Pizza";
}
}
- Tạo ra một lớp trừu tượng Decorator làm xương sống cho các concrete Decorator khác
abstract class PizzaDecorator implements IPizza {
constructor(protected mPizza: IPizza) { }
getPizza(): IPizza {
return this.mPizza;
}
setPizza(mPizza: IPizza): void {
this.mPizza = mPizza;
}
abstract doPizza(): string;
}
- Tạo 2 concrete Decorator: PepperDecorator và CheeseDecorator. Mỗi cái thêm một topping. Logic mở rộng nằm trong các phương thức như addPepper().
class CheeseDecorator extends PizzaDecorator {
constructor(pizza: IPizza) {
super(pizza);
}
doPizza(): string {
let type: string = this.mPizza.doPizza();
return type + this.addCheese();
}
// This is additional functionality
// It adds cheese to an existing pizza
addCheese(): string {
return " + Cheese";
}
}
class PepperDecorator extends PizzaDecorator {
constructor(pizza: IPizza) {
super(pizza);
}
doPizza(): string {
let type: string = this.mPizza.doPizza();
return type + this.addPepper();
}
// This is additional functionality
// It adds cheese to an existing pizza
addPepper(): string {
return " + Pepper";
}
}
- Chồng Decorator lên nhau để thêm topping cho Pizza
let tomato : IPizza = new TomatoPizza();
let chicken : IPizza = new ChickenPizza();
console.log(tomato.doPizza());
console.log(chicken.doPizza());
// Use Decorator pattern to extend existing pizza dynamically
// Add pepper to tomato-pizza
let pepperDecorator : PepperDecorator = new PepperDecorator(tomato);
console.log(pepperDecorator.doPizza());
// Add cheese to tomato-pizza
let cheeseDecorator : CheeseDecorator = new CheeseDecorator(tomato);
console.log(cheeseDecorator.doPizza());
// Add cheese and pepper to tomato-pizza
// We combine functionalities together easily.
let cheeseDecorator2 : CheeseDecorator = new CheeseDecorator(pepperDecorator);
console.log(cheeseDecorator2.doPizza());
- Kết quả
I am a Tomato Pizza
I am a Chicken Pizza
I am a Tomato Pizza + Pepper
I am a Tomato Pizza + Cheese
I am a Tomato Pizza + Pepper + Cheese
5. Facade
Bao bọc một hệ thống con phức tạp với một giao diện đơn giản
5.1. Ưu điểm của Facade Pattern?
- Giúp library dễ dùng và dễ hiểu hơn.
- Giảm sự phụ thuộc giữa code bên ngoài và code bên trong library — hầu hết code đi qua Facade, nên bạn có thể thoải mái refactor phía sau.
- Gói nhiều API thiết kế tệ lại thành một API sạch sẽ hơn.
5.2. Vấn đề
Giả sử bạn có chuỗi hành động thực hiện theo thứ tự, và chuỗi này cần dùng ở nhiều nơi trong ứng dụng. Bạn copy-paste nó khắp nơi. Nhanh gọn. Rồi bạn nhận ra cần thay đổi chuỗi xử lý đó. Giờ phải lục lại tất cả các chỗ, sửa từng cái một, và cầu nguyện không bỏ sót chỗ nào. Tốn thời gian. Mất kiểm soát. Dễ sinh bug.
5.3. Giải pháp
Tạo một Facade với phương thức bao bọc chuỗi code lặp đi lặp lại. Mọi nơi chỉ cần gọi Facade. Cần thay đổi logic? Sửa một chỗ. Xong.
5.4. Cấu trúc
Các subsystem bên trong Facade cũng có thể sử dụng Facade.
5.5. Ví dụ
Xét quy trình checkout khi người dùng mua hàng online trên trang web của bạn:
- Thêm sản phẩm vào giỏ hàng
- Tính toán chi phí vận chuyển
- Tính toán tiền chiết khấu
- Tạo đơn đặt hàng
// Xử lý checkout
let productId = "123456";
let product = Product.find(productId);
if(product.length > 0) {
// Thêm vào giỏ hàng
let cart = new Cart();
cart.addItem(product);
// Tính phí ship hàng
let shipping = new ShippingCharge(product);
shipping.calculateCharge();
// Tính mã giảm giá
let discount = new Discount(product);
discount.applyDiscount();
// Tạo mã đơn hàng
let order = new Order();
order.generateOrder();
...
}
- Rất nhiều object để hoàn thành chuỗi xử lý order. Bê nguyên đoạn code này đi khắp nơi là điều không nên.
- Áp dụng Facade:
class OrderFacade {
private product: any;
constructor(productId: string) {
this.product = Product.find(productId);
}
generateOrder() {
// Xử lý toàn bộ logic
if(this.checkQuantity()) {
this.addToCart();
this.calulateShipping();
this.applyDiscount();
this.placeOrder();
}
}
private addToCart () {
let cart : Cart = new Cart();
cart.addItem(this.product);
}
private checkQuantity() {
return this.product.length != 0 ? true : false;
}
private function calulateShipping() {
let shipping : ShippingCharge = new ShippingCharge(this.product);
shipping.calculateCharge();
}
private applyDiscount() {
let discount : Discount = new Discount();
discount.applyDiscount(this.product);
}
private placeOrder() {
let order : Order = new Order();
order.generateOrder();
}
}
- Sử dụng
let productId = "123456";
let order = new OrderFacade(productId);
order.generateOrder();
Toàn bộ logic phức tạp giờ nằm gọn trong generateOrder. Cần thay đổi? Sửa Facade. Cần dùng ở chỗ khác? Gọi generateOrder(). Không cần bê cả đống code đi nữa.
5.6. Kết
Mình hay nghĩ Facade Pattern giống như có thư ký. Bạn nói cần làm gì, cô ấy lo phần còn lại. Facade object cũng vậy — nó điều phối nhiều nhiệm vụ phía sau một lời gọi duy nhất.
6. Proxy
Proxy cung cấp một class ảo đứng trước class thực sự mà chúng ta muốn làm việc để xử lí, tăng thêm tính bảo mật và cải thiện performance cho hệ thống khi xử lí dữ liệu liên quan đến class mà chúng ta làm việc
6.1. Khi nào nên sử dụng Proxy Pattern?
- Khi muốn thêm lớp bảo mật, kiểm soát truy cập dữ liệu của đối tượng
- Khi cần linh hoạt cách truy xuất dữ liệu (Lazy Loading,…)
6.2. Các loại ứng dụng của Proxy Pattern
- Remote Proxy: Đại diện cho object nằm ở địa chỉ khác
- Virtual Proxy: Dành cho dữ liệu tốn chi phí khởi tạo. Thay vì load hết từ đầu, nó trì hoãn đến khi người dùng thực sự yêu cầu. Lazy Loading là ứng dụng kinh điển.
- Protective Proxy: Kiểm soát quyền truy cập vào object gốc bằng các bước kiểm tra bảo mật
- Smart Proxy: Thêm hành vi bổ sung khi truy cập hoặc truy xuất dữ liệu từ đối tượng
6.3. Cấu trúc
Ba thành phần:
- Subject: Interface chung cho RealSubject và Proxy
- RealSubject: Đối tượng thực sự mà người dùng làm việc
- Proxy: Implement interface Subject, đứng trước RealSubject để xử lý tiền/hậu xử lý và bảo mật. Giữ tham chiếu đến RealSubject để truy cập dữ liệu.
6.4. Ví dụ
- Tạo interface IItem và class Item triển khai interface đó
- Item tạo nhanh, nhưng lấy giá trị content thì tốn chi phí (tưởng tượng mỗi lần lấy content phải request lên server — xem IItem như index/id của giá trị content)
interface IItem {
delaytoResponseContent(): Promise<number>;
content: number;
}
class Item implements IItem {
// Giá trị của content nằm trên server
protected _content: number = Math.round(Math.random() * 10);
get content() {
return this._content;
}
set content(n: number) {
this._content = n;
}
// Trả về giá trị của content mất nhiều chi phí
async delaytoResponseContent() : Promise<number> {
await this.sleep(1000);
return this.content;
}
// Tạo chi phí kết nối
sleep(ms: number) {
return new Promise((resolve) =>
setTimeout(resolve, ms)
)
}
}
- Tạo interface Subject và class RealSubject, Proxy
interface Subject {
data: Array<IItem>;
Request(index: number): any;
}
class RealSubject {
constructor(private _data: Array<IItem>) { }
get data() : Array<IItem> {
return this._data;
}
Request(index: number) : Promise<number> {
return this.data[index].delaytoResponseContent();
}
}
class SubjectProxy implements Subject {
// Khai báo cho đúng cấu trúc nhưng không sử dụng
data!: Array<IItem>;
// Duy trì một kết nối đến RealSubject để xử lí dữ liệu
private realSubject!: RealSubject;
// Kết nối đến host thông qua RealSubject
constructor(data: Array<IItem>) {
this.realSubject = new RealSubject(data);
}
// - Ghi đè lại phương thức Request
// - Thay vì lấy về giá trị content mất nhiều chi phí
// thì ta chỉ lấy về index/id của giá trị đó
// - Khi nào người dùng cần giá trị content thì mới
// sử dụng phương thức delaytoResponseContent để
// lấy giá trị content về
Request(index: number) : IItem {
return this.realSubject.data[index];
}
}
- Tạo một host giả có chứa và index Item
// Tạo một host giả có chứa content và index Item
let data : Array<IItem> = [
new Item(),
new Item(),
new Item(),
new Item(),
new Item(),
new Item(),
];
- Khi không dùng Proxy: đường truyền yếu, chỉ tải được 1 content 1 lần, phải tải tuần tự hết — trong khi người dùng muốn xem item thứ 4 trước.
let realSubject = new RealSubject(data);
(async () => {
let wanted : number = 3;
for(let i = 0; i < 5; i++) {
await realSubject.Request(i).then(
res => {
if(i!=wanted)
console.log("Result: "+res)
else
console.log("Wanted result: "+res)
}
);
}
})();
- Kết quả khi không dùng Proxy
Result: 4
Result: 5
Result: 5
Wanted result: 4
Result: 9
- Khi dùng Proxy với Lazy Loading (Virtual Proxy): dữ liệu cần thiết tải trước, phần còn lại tải sau.
let proxy = new SubjectProxy(data);
(async () => {
let wanted : number = 3;
let results : Array<IItem> = new Array<IItem>();
for(let i = 0; i < 5; i++) {
results.push(proxy.Request(i));
if(i==wanted)
results[i].delaytoResponseContent()
.then(res => console.log("Wanted result via proxy: "+res));
}
for(let i = 0; i < 5; i++)
if(i!=wanted)
results[i].delaytoResponseContent()
.then(res => console.log("Result via proxy: "+res))
})();
- Kết quả khi sử dụng Proxy
Wanted result via proxy: 1
Result via proxy: 0
Result via proxy: 7
Result via proxy: 2
Result via proxy: 1
- Ngoài ra có thể thêm kiểm tra bảo mật và xử lý lỗi vào proxy để tăng tính bảo mật (Smart Proxy).
6.5. So sánh với pattern cùng loại (Structural Pattern)
Proxy trông khá giống Adapter và Decorator, nên cần phân biệt:
- Khác với Adapter: Adapter cung cấp interface khác với đối tượng gốc. Proxy cung cấp cùng interface.
- Khác với Decorator: Cài đặt có thể tương tự, nhưng mục đích khác. Decorator bổ sung thêm nhiệm vụ. Proxy kiểm soát truy cập. Tùy loại, Proxy có thể trông giống Decorator:
- Protection Proxy, Smart Proxy: Có thể cài đặt giống Decorator
- Remote Proxy: Không tham chiếu trực tiếp đến đối tượng thực — dùng tham chiếu gián tiếp như ID, địa chỉ host
- Virtual Proxy: Tham chiếu gián tiếp (tên file, index), chỉ tham chiếu trực tiếp khi cần
7. Flyweight
Được dùng như một object chia sẻ, có thể được sử dụng đồng thời ở nhiều ngữ cảnh khác nhau, hoạt động như một đối tượng độc lập tại mỗi ngữ cảnh. Mỗi đối tượng cụ thể sẽ tham chiếu đến cùng một instance được chia sẻ ở trong pool của Flyweight object
7.1. Vấn đề
Mình thấy ví dụ này khá hay. Bạn xây dựng game bắn súng sinh tồn kiểu PUBG. Hoàn thành, push code, build, gửi bạn chơi thử. Game crash sau vài phút. Sau mấy tiếng đào error log, phát hiện ra game hết RAM vì hệ thống particle. Mỗi viên đạn, tên lửa, mảnh đạn là một object riêng chứa đầy dữ liệu. Lúc chiến đấu căng, particle mới spawn liên tục cho đến khi RAM tràn. Game die.
7.2. Giải quyết
Nhìn kỹ lớp Particle, bạn thấy trường color và sprite ngốn bộ nhớ nhiều nhất. Tệ hơn, hai trường này lưu dữ liệu gần giống hệt nhau trên tất cả các object — đó là Intrinsic state. Tất cả đạn có cùng color và sprite. Chỉ khác coord, vector, speed — đó là Extrinsic state.
- Intrinsic state (trạng thái nội tại): Dữ liệu không đổi của một đối tượng
- Extrinsic state (trạng thái bên ngoài): Trạng thái thay đổi theo ngữ cảnh
Cách giải: ngừng lưu extrinsic state bên trong object. Chuyển chúng sang tham số phương thức. Object giờ chỉ giữ intrinsic state. Tái sử dụng các object đã “gọt” này kết hợp với extrinsic state từng ngữ cảnh — tiết kiệm bộ nhớ mà vẫn đủ dữ liệu. Object sau khi loại bỏ extrinsic state gọi là flyweight.
Lưu ý: Vì flyweight được chia sẻ giữa nhiều ngữ cảnh, tuyệt đối không sửa đổi thuộc tính của nó sau khi khởi tạo. Set một lần, xong rồi thôi.
Quay lại game. Giả sử có ba loại particle: đạn, tên lửa, mảnh đạn. Spawn rất nhiều, cần tối ưu. Sau khi tách extrinsic state khỏi các class (biến chúng thành flyweight), extrinsic state đi đâu? Nó vẫn riêng của từng object nên phải lưu đâu đó. Thường thì đưa vào Container — nơi quản lý cả extrinsic state lẫn flyweight. Container ở đây là lớp Game. Extrinsic state vào Container qua đối tượng Context, bên trong chứa:
- Extrinsic state
- Một tham chiếu đến flyweight phù hợp
Để quản lý flyweight tốt hơn, dùng FlyweightFactory thay vì mảng thường.
- FlyweightFactory: Nhận intrinsic state làm đầu vào, tìm flyweight phù hợp, trả về nếu có, tạo mới nếu chưa có.
7.3. Khi nào nên sử dụng Flyweight Pattern?
Chỉ khi chương trình phải chứa số lượng lớn object gần giống nhau và bạn cần tối ưu bộ nhớ. Nếu không, thêm complexity không đáng.
7.4. Cấu trúc
Các thành phần:
- Flyweight: Chứa intrinsic state
- FlyweightFactory: Quản lý các Flyweight
- Context: Chứa extrinsic state và tham chiếu đến flyweight phù hợp
- Client: Đóng vai Container. Quản lý FlyweightFactory và Context.
7.5. Cài đặt Flyweight Pattern
- Chia các trường của class thành hai phần: intrinsic state và extrinsic state
- Giữ intrinsic state trong class, đảm bảo chỉ set lúc khởi tạo và không thay đổi
- Loại bỏ extrinsic state. Thay bằng phương thức nhận giá trị extrinsic state qua tham số.
- (Tùy chọn) Tạo Factory class để quản lý và truy cập flyweight
- Tạo Client class để lưu FlyweightFactory, extrinsic state và tham chiếu đến flyweight phù hợp qua Context
7.6. Thực hành
- Đầu tiên tạo lớp Flyweight (class này được tham chiếu nhiều nhất nên bắt đầu từ đây)
class Flyweight {
constructor(
public model: string,
public processor: string
) { }
operation(memory: number, tag: string) {
return `Computer: Model = ${this.model}, Proccessor = ${this.processor}, Memory = ${memory}, Tag = ${tag}`;
}
}
- Tiếp đến là FlyweightFactory để quản lí các Flyweight
class FlyWeightFactory {
constructor() { }
// Quản lí các flyweight
public cache: any = {};
getFlyweight(
model: string,
processor: string
) {
if (!this.cache[model + processor]) {
this.cache[model + processor] =
new Flyweight(model, processor);
}
return this.cache[model + processor];
}
count() {
var count = 0;
for (var f in this.cache) count++;
return count;
}
}
- Tạo lớp Context để ghép intrinsic state (trong flyweight) với extrinsic state (bên ngoài) qua tham chiếu đến flyweight phù hợp
class Context {
constructor(
public flyweight: Flyweight,
public memory: number, // Unique state
public tag: string // Unique state
) { }
operation() {
return this.flyweight.operation(this.memory, this.tag);
}
}
- Cuối cùng là lớp Client đóng vai trò Container
class Client {
constructor() {}
public flyweightFactory : FlyWeightFactory = new FlyWeightFactory();
public contexts : Array<Context> = new Array<Context>();
public countContext : number = 0;
add(
model: string,
processor: string,
memory: number,
tag: string
) {
this.contexts.push(
new Context(
this.flyweightFactory.getFlyweight(
model, processor
),
memory, tag
)
);
this.countContext++;
}
getContext(
model: string,
processor: string,
memory: number,
tag: string
) {
return this.flyweightFactory.getFlyweight(
model, processor
).operation(memory, tag);
}
getCountContext() {
return this.countContext;
}
}
- Thêm vào các máy tính có model và processor tương tự nhau, xem kết quả.
var computers = new Client();
computers.add("Studio XPS", "Intel", 5, "Y755P");
computers.add("Studio XPS", "Intel", 6, "X997T");
computers.add("Studio XPS", "Intel", 2, "U8U80");
computers.add("Studio XPS", "Intel", 2, "NT777");
computers.add("Studio XPS", "Intel", 2, "0J88A");
computers.add("Envy", "Intel", 4, "CNU883701");
computers.add("Envy", "Intel", 2, "TXU003283");
console.log("Computer counting: " + computers.getCountContext());
console.log("Flyweight counting: " + computers.flyweightFactory.count());
- Kết quả
Computer counting: 7
Flyweight counting: 2
- Bảy máy tính, nhưng chỉ hai flyweight. Đó chính là điểm mấu chốt. Nhưng cẩn thận — vì flyweight chia sẻ qua tham chiếu, sửa intrinsic state sẽ ảnh hưởng toàn bộ context dùng flyweight đó. Ví dụ mình đổi processor của “Studio XPS Intel” thành “AMD” thông qua FlyweightFactory:
console.log("Before changes: ");
for (let [k, v] of computers.contexts.entries()) {
console.log(`(${k + 1}) => ` + v.operation());
}
computers.flyweightFactory.getFlyweight("Studio XPS", "Intel").processor =
"AMD";
console.log("After changes: ");
for (let [k, v] of computers.contexts.entries()) {
console.log(`(${k + 1}) => ` + v.operation());
}
- Kết quả
Before changes:
(1) => Computer: Model = Studio XPS, Proccessor = Intel, Memory = 5, Tag = Y755P
(2) => Computer: Model = Studio XPS, Proccessor = Intel, Memory = 6, Tag = X997T
(3) => Computer: Model = Studio XPS, Proccessor = Intel, Memory = 2, Tag = U8U80
(4) => Computer: Model = Studio XPS, Proccessor = Intel, Memory = 2, Tag = NT777
(5) => Computer: Model = Studio XPS, Proccessor = Intel, Memory = 2, Tag = 0J88A
(6) => Computer: Model = Envy, Proccessor = Intel, Memory = 4, Tag = CNU883701
(7) => Computer: Model = Envy, Proccessor = Intel, Memory = 2, Tag = TXU003283
After changes:
(1) => Computer: Model = Studio XPS, Proccessor = AMD, Memory = 5, Tag = Y755P
(2) => Computer: Model = Studio XPS, Proccessor = AMD, Memory = 6, Tag = X997T
(3) => Computer: Model = Studio XPS, Proccessor = AMD, Memory = 2, Tag = U8U80
(4) => Computer: Model = Studio XPS, Proccessor = AMD, Memory = 2, Tag = NT777
(5) => Computer: Model = Studio XPS, Proccessor = AMD, Memory = 2, Tag = 0J88A
(6) => Computer: Model = Envy, Proccessor = Intel, Memory = 4, Tag = CNU883701
(7) => Computer: Model = Envy, Proccessor = Intel, Memory = 2, Tag = TXU003283
8. Delegate
Loại bỏ các chức năng phức tạp từ class chính bằng cách đưa chúng vào class khác xử lí
8.1. Vấn đề
Ví dụ thực tế: vận chuyển hàng bằng xe khách, tàu hỏa, hoặc máy bay. Hàng nhẹ cần gấp thì đi máy bay. Hàng cồng kềnh cần nhanh thì đi xe khách. Hàng chuyển chậm được thì đi tàu. Bạn có ba class: RailShipper, BusShipper, PlaneShipper, đều có phương thức delivery. Thông thường bạn viết logic điều kiện để chọn đúng loại. Thêm nhiều loại vận chuyển nữa thì class phình to và rối.
8.2. Giải quyết
Tách logic chọn phương thức vận chuyển ra lớp ShipperHandler. Lớp này chọn shipper phù hợp và gọi delivery cho bạn.
8.3. Cấu trúc
Ba thành phần:
- Shipper: Interface cho các loại hình vận chuyển
- RailShipper, BusShipper, PlaneShipper: Các implementation cụ thể
- ShipperHandler: Implement interface Shipper và đóng gói logic chọn/ủy quyền
8.4. Thực hành
interface Shipper {
delivery(): string;
}
class RailShipper implements Shipper {
delivery() {
return `Package is delivering by train`;
}
}
class BusShipper implements Shipper {
delivery() {
return `Package is delivering by bus`;
}
}
class PlaneShipper implements Shipper {
delivery() {
return `Package is delivering by plane`;
}
}
class ShipperHandler implements Shipper {
private shipper!: Shipper;
constructor(type: string) {
this.shipper = eval(`new ${type}Shipper();`);
}
delivery() {
return this.shipper.delivery()
}
}
let shipper: Shipper = new ShipperHandler('Bus');
console.log(shipper.delivery());
- Kết quả
Package is delivering by bus
8.5. Lời kết
Delegate Pattern có điểm giống kế thừa trong OOP nhưng linh hoạt hơn. Cũng có nét tương đồng với Proxy Pattern, tuy mỗi pattern hữu ích trong tình huống khác nhau. Mình nghĩ điều quan trọng nhất với design pattern là: dùng khi phù hợp. Đừng ép pattern vào bài toán chỉ vì bạn biết nó tồn tại.
9. Entity-Attribute-Value (EAV)
Entity-Attribute-Value Pattern viết tắt là EAV Pattern, là 1 mô hình dữ liệu, làm việc với các thực thể (entity) có số lượng các thuộc tính (attribute) có thể mở rộng
EAV là kỹ thuật thiết kế CSDL cho hệ thống cần tùy biến cao. Nếu bạn từng làm việc với Magento thì đã thấy nó rồi — đó là xương sống của hệ thống product attribute trong Magento. Ở đây mình trình bày phiên bản đơn giản: Product Attributes (Level 2 trong hệ thống quản lý sản phẩm của Magento).
9.1. Cấu trúc
Ba bảng:
- Entity (entities): Thông tin cơ bản của đối tượng
- Attribute (attributes): Các thuộc tính có thể gắn vào entity
- Value (attribute_values): Liên kết Entity và Attribute cùng giá trị, qua 2 khóa ngoại
Mối quan hệ giữa các bảng:
- Value - Attribute: 1-n (một Attribute có nhiều Value)
- Value - Entity: n-n (một Entity có nhiều Value, một Value thuộc nhiều Entity)
- Entity - Attribute: n-n (một sản phẩm có nhiều Attribute, một Attribute thuộc nhiều sản phẩm)
Ví dụ về mối quan hệ giữa Entity-Attribute-Value:
| value_id | entity_id | value | attribute_id |
|---|---|---|---|
| 1 | 1 | ”S” | 1 |
| 2 | 1 | ”Trắng” | 2 |
| 3 | 1 | 30 | 3 |
| 4 | 1 | 100 | 4 |
| 5 | 2 | ”S” | 1 |
| 6 | 2 | ”Đen” | 2 |
| 7 | 2 | 20 | 3 |
| 8 | 2 | 200 | 4 |
9.2. Ví dụ
Bạn đang xây dựng trang bán hàng (quần áo chẳng hạn). Database đơn giản thôi:
- products: Lưu thông tin sản phẩm
- categories: Lưu loại sản phẩm để phân loại
Đây là cách thiết kế đơn giản nhất, dễ tiếp cận. Nhưng cũng dễ vỡ khi hệ thống lớn lên (Hệ thống quản lý sản phẩm Level 1).
Khi công ty mở rộng — bán điện thoại, máy tính, đồ gia dụng — cần thêm nhiều trường: color, size, weight, chip, ram… Với bảng phẳng, bạn thêm hàng chục cột nhưng mỗi query chỉ dùng vài cột. Lãng phí. Đây là lúc EAV phát huy tác dụng (Hệ thống quản lý sản phẩm Level 2).
Với EAV, cấu trúc bảng trở thành:
Giờ bạn có thể thêm thuộc tính từ trang quản trị mà không cần đụng đến schema database.
- Thêm record vào bảng attributes cho thuộc tính
color:
id=1, name="color"
- Thêm giá trị thuộc tính và product_id tương ứng vào bảng attribute_value:
id = 1, attribute_id = 1, value = "Đỏ", product_id = 1
id = 2, attribute_id = 1, value = "Xanh Lam", product_id = 2
id = 3, attribute_id = 1, value = "Vàng", product_id = 7
id = 4, attribute_id = 1, value = "Trắng", product_id = 5
Linh hoạt hơn nhiều. Nếu muốn nâng cao kỹ năng thiết kế hệ thống — đặc biệt e-commerce — mình khuyên nên nghiên cứu các mã nguồn mở như Magento. Cách họ thiết kế database dạy mình rất nhiều về tư duy thiết kế ở quy mô lớn.