Đến nội dung chính
sang.id.vn
Quay lại

Behavioral Design Pattern — Giải thích dễ hiểu với TypeScript

English

Behavioral pattern xử lý cách các object giao tiếp với nhau. Nếu bạn đã từng viết đống callback loạn xạ, danh sách observer, hay state machine dính băng keo — thì đây là lúc cho mớ hỗn loạn đó một cái tên và cấu trúc rõ ràng. Đây là 9 pattern mình hay dùng nhất.

1. Chain of Responsibility

Cho phép chúng ta xử lý sự kiện bằng một hoặc nhiều Handler

Mình hay hình dung Chain of Responsibility giống như trò chuyền bóng cho request vậy. Mình chuyển đối tượng tiếp nhận event thành các handler độc lập, rồi nối chúng thành một chuỗi. Khi request đến, nó nhảy qua từng handler cho đến khi gặp thằng xử lý được. Mỗi handler tự quyết định: “mình xử lý” hoặc “chuyển tiếp đi.”

Cái hay ở đây là tách nhỏ một request cồng kềnh ra thành nhiều mảnh nhỏ gọn. Nếu bạn đã từng ngồi nhìn đống if..else lồng nhau mà muốn phát điên thì pattern này dành cho bạn. Mỗi handler lo đúng một logic, với một điều kiện cụ thể. Hết rối. Và bên gửi request chẳng cần biết handler nào cuối cùng sẽ xử lý.

1.1. Chain of Responsibility được sử dụng khi nào?

1.2. Cấu trúc

Các thành phần tham gia vào Chain of Responsibility Pattern:

1.3. Thực hành

interface Handler {
    setNext(handler: Handler): void;
    handle(request: Request): any;
}
class BaseHandler implements Handler {
    protected next?: Handler;
    init(next?: Handler) {
        this.next = next
    }
    setNext(handler?: Handler) : void {
        this.next = handler
    }
    handle(request: Request) : any {
        if(this.next!=null)
            return this.next.handle(request);
    }
}
class ConcreteHandler extends BaseHandler {
    protected next?: Handler;
    init(next?: Handler) {
        this.next = next
    }
    setNext(handler?: Handler) : void {
        this.next = handler
    }
    handle(request: Request) : any {
        if(this.canHandle(request)) {
            // Code here
        } else {
            if(this.next!=null)
                return this.next.handle(request);
        }
    }
    canHandle(request: any) : boolean {
        // return True or False
    }
}
class Client {
  sendRequest() {
    let thirdHandler = new ConcreteHandler(null);
    let secondHandler = new ConcreteHandler(thirdHandler);
    let firstHandler = new ConcreteHandler(secondHandler);
    let request = new Request();
    firstHandler.handle(request);
  }
}

2. Command

Command Pattern (còn gọi là Action Pattern hoặc Transaction Pattern) tạo ra một đối tượng có thể triển khai một phương thức xác định nào đó từ một đối tượng đầu vào khác mà không cần biết đối tượng đầu vào khác có tính chất gì

2.1. Vấn đề

Giả sử bạn đang code một website bán hàng. Mỗi tuần site gửi tin nhắn về sản phẩm bán chạy nhất qua email hoặc SMS, tùy theo setting của người dùng. Chọn email thì không SMS, chọn SMS thì không email. Vậy làm sao xây dựng một đối tượng gửi được tin nhắn qua 2 kênh khác nhau mà logic hoạt động hoàn toàn khác nhau?

Hoặc hình dung thế này: bạn đang xây dựng SlickUI, một framework GUI. Button đẹp, dialog xịn, icon bắt mắt — xong hết rồi. Nhưng rồi bạn đặt câu hỏi: “Giờ người ta dùng mấy cái này để làm gì?” Bạn mong SlickUI sẽ phổ biến, hàng nghìn dev dùng, hàng triệu SlickButton instance được tạo ra. Giải pháp quen thuộc là inheritance — yêu cầu dev tạo subclass cho mỗi loại button. Nhưng một app GUI phức tạp có thể có hàng trăm button. Hàng trăm subclass? Rồi còn menu item, radio button nữa. Hướng này không ổn.

2.2. Giải quyết

Đóng gói luôn cái action — việc cần làm khi button được ấn hay menu item được chọn — vào một object riêng. Tách code xử lý ra khỏi UI component. Những action đó chính là các command trong Command Pattern.

2.3. Khi nào nên sử dụng Command Pattern?

2.4. Cấu trúc

Các thành phần tham gia vào Command Pattern:

2.5. Thực hành

class Light {
    public light: string = "light";
}
interface Command {
    execute(): any;
}
class CommandOn implements Command {
    private object?: Light;
    constructor(object?: Light) {
        this.object = object;
    }
    execute() {
        console.log(this.object?.light + ' on')
    }
}
class CommandOff implements Command {
    private object?: Light;
    constructor(object?: Light) {
        this.object = object;
    }
    execute() {
        console.log(this.object?.light + ' off')
    }
}
class RemoteControl {
    private command?: Command;
    setCommand(command: Command) {
        this.command = command
    }
    run() {
        this.command?.execute()
    }
}
let remote = new RemoteControl();
remote.setCommand(new CommandOn(new Light()));
remote.run(); // light on

3. Mediator

Cung cấp một lớp trung gian có nhiệm vụ xử lý thông tin liên lạc giữa các lớp

Pattern này dùng mối quan hệ many-to-many giữa các đối tượng tương đồng để đạt trạng thái “full object”. Mình thấy Mediator đặc biệt hữu ích khi các component liên tục “giẫm chân” lẫn nhau.

3.1. Vấn đề

Bạn muốn thiết kế component tái sử dụng được. Tốt. Nhưng khi các component phụ thuộc lẫn nhau, bạn sẽ có spaghetti code.

“Spaghetti code” — ai code đủ lâu cũng gặp rồi. Luồng điều khiển phức tạp, rối rắm, đầy GOTO, exception, thread, hay các nhánh không có cấu trúc. Gọi là spaghetti vì luồng chương trình nhìn y hệt tô mì — xoắn và rối.

Thường xảy ra khi nhiều người với phong cách code khác nhau cùng sửa một codebase trong thời gian dài. Lập trình có cấu trúc giúp giảm đáng kể, nhưng đôi khi bạn cần thêm pattern để giữ trật tự.

3.2. Giải quyết

Mediator Pattern là câu trả lời. Thay vì để các Component nói chuyện trực tiếp với nhau, mình đẩy tất cả qua một đối tượng Mediator. Mediator nhận event từ các Component khác nhau và xử lý chúng. Coi nó như một tổng đài — một chỗ duy nhất điều phối mọi giao tiếp chéo giữa các component.

3.3. Mediator được sử dụng khi nào?

3.4. Ví dụ

Tháp điều khiển ở sân bay là ví dụ kinh điển, và mình thấy nó giải thích Mediator cực kỳ tốt. Phi công không nói chuyện trực tiếp với nhau — họ nói với tháp. Tháp quyết định ai cất cánh, ai hạ cánh, theo thứ tự nào. Nhưng tháp không điều khiển toàn bộ chuyến bay. Nó chỉ tồn tại để đảm bảo an toàn khi cất và hạ cánh. Đó chính xác là việc Mediator làm: điều phối, không phải kiểm soát.

3.5. Cấu trúc

Các thành phần tham gia vào Mediator Pattern:

3.6. Thực hành

abstract class BaseComponent {
    protected mediator?: Mediator
    constructor(mediator?: Mediator) {
        this.mediator = mediator
    }
    update(mediator?: Mediator) {
        this.mediator = mediator
    }
}

class Component1 extends BaseComponent {
    constructor(mediator?: Mediator) {
        super(mediator)
    }
    update(mediator?: Mediator) {
        super.update(mediator)
    }
    doA() {
        console.log("Component 1 does A.")
        this.mediator?.notify("A")
    }
    doB() {
        console.log("Component 1 does B.\n")
        this.mediator?.notify("B")
    }
}

class Component2 extends BaseComponent {
    constructor(mediator?: Mediator) {
        super(mediator)
    }
    update(mediator?: Mediator) {
        super.update(mediator)
    }
    doC() {
        console.log("Component 1 does C")
        this.mediator?.notify("C")
    }
    doD() {
        console.log("Component 1 does D")
        this.mediator?.notify("D")
    }
}
interface Mediator {
    notify(event: String): void
}

class ConcreteMediator implements Mediator {
    constructor(...components: BaseComponent[]) {
        for(let component of components) {
            component.update(this)
        }
    }
    updateMediator(component: BaseComponent) {
        component.update(this)
    }
    notify(event: String) {
        console.log(`Mediator reacts on ${event}`)
    }
}
let component1 = new Component1();
let component2 = new Component2();
let mediator = new ConcreteMediator(component1, component2);

component1.doA();
console.log("\n");
component2.doC();
Component 1 does A.
Mediator reacts on A

Component 1 does C
Mediator reacts on C

4. Memento

Cho phép chúng ta lưu trữ và khôi phục trạng thái của một đối tượng mà không tiết lộ chi tiết bên trong của nó

4.1. Memento Pattern được sử dụng khi nào?

Mình dùng Memento khi cần chức năng save/restore. Nghĩ đến game đi — bạn save progress, tắt game, hôm sau mở lại và chơi tiếp từ chỗ cũ. Hoặc bất kỳ app nào cần Undo/Redo: chụp lại trạng thái đối tượng, cất đi, rồi rollback khi cần. Cũng rất hợp với các ứng dụng cần quản lý transaction.

4.2. Cách thức hoạt động

Memento cấu trúc dữ liệu cần lưu của Object thành một State, rồi lưu State đó lại. Các State sau khi lưu gọi là Memento. CareTaker chịu trách nhiệm lưu trữ State dạng Memento và trả Memento về dạng State khi cần dùng. Vì trạng thái Object nằm trong State, truyền State qua các Object khác nhau sẽ không lộ implementation chi tiết bên trong. Phân tách sạch sẽ.

4.3. Ưu điểm

4.4. Nhược điểm

4.5. Cấu trúc

Các thành phần tham gia vào Memento Pattern:

4.6. Ví dụ

class Memento {
    constructor(private readonly state: string) { }
    getSavedState(): string {
        return this.state
    }
}
class Originator {
    private state!: string
    set(state: string): void {
        console.log("Originator: Setting state to " + state)
        this.state = state
    }
    saveToMemento(): Memento {
        console.log("Originator: Saving to Memento.")
        return new Memento(this.state)
    }
    restoreFromMemento(memento: Memento): void {
        this.state = memento.getSavedState()
        console.log("Originator: State after restoring from Memento: " + this.state)
    }
}
 // CareTaker
let savedStates : Array<Memento> = new Array<Memento>()
originator.set("State #1");
originator.set("State #2");
savedStates.push(originator.saveToMemento());
originator.set("State #3");
savedStates.push(originator.saveToMemento());
originator.set("State #4");

// Restore lại state cũ nhất đã lưu
originator.restoreFromMemento(savedStates[0]);
Originator: Setting state to State #1
Setting state to State #2
Saving to Memento.
Setting state to State #3
Saving to Memento.
Setting state to State #4
State after restoring from Memento: State #2

5. Observer

Một đối tượng, gọi là subject, duy trì một danh sách các thành phần phụ thuộc nó, gọi là observer, và thông báo tới chúng một cách tự động về bất cứ thay đổi nào, thường thì bằng cách gọi một hàm của chúng

5.1. Vấn đề

Hình dung bảng tính Excel với nhiều trang tính cung cấp dữ liệu cho biểu đồ. Bạn có thể tạo bao nhiêu biểu đồ tùy thích từ các trang tính đó. Giờ thay đổi một giá trị trong trang tính — mọi biểu đồ dùng dữ liệu đó đều phải cập nhật. Và số lượng biểu đồ phụ thuộc vào một trang tính là không giới hạn.

5.2. Giải quyết

Observer Pattern xử lý gọn gàng. Trang tính là subject, các biểu đồ là observer. Khi dữ liệu trang tính thay đổi, nó tự động thông báo đến tất cả biểu đồ phụ thuộc. Xong.

5.3. Khi nào nên dùng Observer Pattern?

5.4. Cấu trúc

Các thành phần tham gia vào Observer Pattern:

5.5. Thực hành

interface Observer {
    update(mesage: string): void
}

class ConcreteObserver implements Observer {
    constructor(private beforeMessage: string) {}
    update(message: string) {
        console.log(this.beforeMessage + " " + message)
    }
}
interface Subject {
    observers : Array<Observer>
    attach(observer: Observer) : void
    detach(observer: Observer): void
    notifyChange(message: string): void
}
class ConcreteSubject implements Subject {
    observers: Array<Observer> = new Array<Observer>()
    attach(observer: Observer): void {
        this.observers.push(observer)
    }
    detach(observer: Observer): void {
        this.observers.splice(this.observers.indexOf(observer), 1)
    }
    notifyChange(message: string): void {
        for (let observer of this.observers) {
            observer.update(message)
        }
    }
}
let subject : Subject = new ConcreteSubject()
let observer1 : Observer = new ConcreteObserver("Message 1 updated:")
let observer2 : Observer = new ConcreteObserver("Message 2 updated:")
subject.attach(observer1)
subject.attach(observer2)

subject.notifyChange('Subject notify!')
subject.detach(observer1)
console.log("Removed Observer 1\n")
subject.notifyChange('Subject notify!')
Message 1 updated: Subject notify!
Message 2 updated: Subject notify!
Removed Observer 1
Message 2 updated: Subject notify!

6. Strategy

Cho phép chúng ta định nghĩa các business logic thành các đối tượng khác nhau và các đối tượng này có thể thay thế cho nhau trong quá trình runtime

6.1. Strategy Pattern được sử dụng khi nào?

Đây là một trong những pattern mình dùng nhiều nhất. Dùng khi bạn có hai hoặc nhiều hành vi cần hoán đổi cho nhau lúc runtime. Thay vì hard-code thuật toán nào chạy, bạn để caller quyết định bằng cách inject strategy phù hợp.

6.2. Cấu trúc

Các thành phần tham gia vào Strategy Pattern:

6.3. Thực hành

interface Strategy {
    doOperation(num1: number, num2: number): number
}
class OperationAdd implements Strategy {
    doOperation(num1: number, num2: number): number {
        return num1 + num2
    }
}
class OperationSubstract implements Strategy {
    doOperation(num1: number, num2: number) : number {
        return num1 - num2
    }
}
class OperationMultiply implements Strategy {
    doOperation(num1: number, num2: number) : number {
        return num1 * num2
    }
}
class Context {
    constructor(private strategy: Strategy) { }
    executeStrategy(num1: number, num2: number) : number {
        return this.strategy.doOperation(num1, num2)
    }
}
let context : Context = new Context(new OperationAdd())
console.log("10 + 5 = " + context.executeStrategy(10, 5))

context = new Context(new OperationSubstract())
console.log("10 - 5 = " + context.executeStrategy(10, 5))

context = new Context(new OperationMultiply())
console.log("10 * 5 = " + context.executeStrategy(10, 5))
10 + 5 = 15
10 - 5 = 5
10 * 5 = 50

7. Visitor

Cho phép thay đổi, mở rộng các thao tác cho đối tượng mà không thay đổi cấu trúc, nội dung bên trong đối tượng

Ý tưởng là thế này: các đối tượng (Element) tách các thao tác ra phương thức riêng, định nghĩa trên các lớp Visitor tách biệt. Nhờ vậy các thao tác độc lập với cấu trúc đối tượng, thay đổi linh hoạt hơn nhiều.

Mỗi thao tác mới cho đối tượng, tạo thêm một visitor class. Vậy thôi — đối tượng gốc không đụng vào.

Ngoài ra đây cũng là kỹ thuật giúp phục hồi lại kiểu dữ liệu bị mất của đối số truyền vào. Nó gọi phương thức tương ứng dựa trên kiểu dữ liệu của cả đối tượng gọi và đối số truyền vào. Gọi là Double Dispatch.

7.1. Double Dispatch và Single Dispatch là gì?

class TestClass {
    testMethod(param: string) {
        console.log(param)
    }
}
new TestClass().testMethod("Hello World")
class Visitor {
    visit(element: Element) {
        console.log(element.hello())
    }
}
class Element {
    hello() {
        return "Xin chào"
    }
    accept(Visitor: visitor) {
        visitor.visit(this)
    }
}
new Element().accept(new Visitor())

7.2. Ưu điểm

7.3. Khi nào nên dùng Visitor Pattern?

7.4. Cấu trúc

Visitor Pattern

Các thành phần tham gia vào Visitor Pattern:

7.5. Ví dụ

Mình thích ví dụ này vì nó hơi ngớ ngẩn nhưng giải thích rất dễ hiểu. Bạn là một ladykiller, muốn tỏ tình với một cô gái nhưng không biết quốc tịch của cô ấy. Rõ ràng không thể nói “anh yêu em” với cô gái Nhật Bản được — cô ấy chẳng hiểu gì. Phải nói “Aishite imasu” mới đúng. Nên mình viết một hàm chung sayLove() và truyền vào lời yêu tùy theo quốc tịch của mỗi nàng.

interface Lady {
    sayLove(): void;
}

class AmericanLady implements Lady {
    sayLove(): void {
        console.log("I love you");
    }
}

class JapanLady implements Lady {
    sayLove(): void {
        console.log("Aishite imasu");
    }
}

let lady : Lady = new JapanLady();
lady.sayLove(); // Kết quả: Aishite imasu

Vấn đề xuất hiện khi bạn muốn thêm sayGoodBye(). Phải thêm vào interface Lady, rồi implement cho tất cả class đã triển khai. Tốn thời gian và thêm rủi ro. Đây chính là lúc Visitor Pattern phát huy.

interface Lady {
    accept(visitor: Visitor): void
}

class AmericanLady implements Lady {
    accept(visitor: Visitor): void {
        visitor.visit(this)
    }
}

class JapanLady implements Lady {
    accept(visitor: Visitor): void {
        visitor.visit(this)
    }
}
interface Visitor {
    visit(lady: Lady): void
}

class SayLoveVisitor implements Visitor {
    visit(lady: Lady): void {
        if (lady instanceof AmericanLady)
            console.log('I love you')
        if (lady instanceof JapanLady)
            console.log('Aishite imasu')
    }
}
let lady: Lady = new AmericaLady()
lady.accept(new SayLoveVisitor()) // Kết quả: I love you
class SayGoodByeVisitor implements Visitor {
    visit(lady: Lady): void {
        if (lady instanceof AmericanLady)
            console.log('Good bye!')
        if (lady instanceof JapanLady)
            console.log('Sayounara!')
    }
}
let lady: Lady = new JapanLady()
lady.accept(new SayGoodByeVisitor()) // Kết quả: Sayounara!

7.6. Kết luận

Khi muốn mở rộng thao tác của ConcreteElement, chỉ cần cập nhật Visitor — không đụng vào ConcreteElement. Open/Closed Principle thỏa mãn.

Hạn chế lớn nhất? Visitor không hỗ trợ mở rộng Element. Thêm Element mới nghĩa là phải cập nhật toàn bộ interface và class của Visitor. Tuy nhiên có thể khắc phục bằng cách tinh chỉnh pattern kết hợp một chút khéo léo trong cấu trúc dữ liệu.

8. State

Cho phép một đối tượng có thể thay đổi hành vi của nó khi có sự thay đổi trạng thái nội bộ trong lúc run-time

8.1. Ưu điểm

8.2. State Pattern được sử dụng khi nào?

Mình gặp State Pattern nhiều trong các hệ thống có nhiều trạng thái khác nhau và thay đổi liên tục trong quá trình hoạt động. Số lượng state có thể có giới hạn hoặc không. Ví dụ kinh điển là đèn giao thông: “đỏ”, “vàng”, “xanh” — luân phiên liên tục. Ngoài ra, State Pattern cũng là liều thuốc tốt cho đống if-else lồng nhau quản lý state transition thủ công.

8.3. Cấu trúc

Các thần phần tham gia vào State Pattern:

8.4. Cách thức hoạt động

8.5. Luồng hoạt động

8.6. Ví dụ

Mình định nghĩa interface State với 2 trạng thái: LowerCaseStateMultipleUpperCaseState. Một cái in chữ thường, cái kia in chữ hoa. Chúng tự động hoán đổi qua lại.

interface State {
    writeName(context: StateContext, name: string): void;
}

class LowerCaseState implements State {
    writeName(context: StateContext, name: string) {
        console.log(name.toLowerCase());
        context.setState(new MultipleUpperCaseState());
    }
}

class MultipleUpperCaseState implements State {
    private count: number = 0;
    writeName(context: StateContext, name: string): void {
        console.log(name.toUpperCase());
        /* Change state after StateMultipleUpperCase's writeName() gets invoked twice */
        if (++this.count > 1) {
            context.setState(new LowerCaseState());
        }
    }
}

Lớp Context chứa biến state — trạng thái hiện tại. Khi khởi tạo, nó được gán state mặc định. Có thêm setter để đổi state mỗi khi thực hiện hành vi. Hành vi ở đây là writeName().

class StateContext {
    private state!: State;
    constructor() {
        this.state = new LowerCaseState();
    }
    setState(newState: State): void {
        this.state = newState;
    }
    writeName(name: string): void {
        this.state.writeName(this, name);
    }
}

Sử dụng

monday
TUESDAY
WEDNESDAY
thursday
FRIDAY
SATURDAY
sunday

8.7. Design Pattern liên quan

9. Repository

Repository Pattern là lớp trung gian giữa việc truy cập dữ liệu và xử lý logic, giúp cho việc truy cập dữ liệu chặt chẽ và bảo mật hơn

9.1. Cấu trúc

Repository Pattern nằm giữa tầng Data Access và Business Logic. Nó làm cho việc truy cập dữ liệu gọn gàng và dễ bảo trì hơn.

Không có nó, bạn viết Controller query thẳng database. Với Repository Pattern, bạn đặt Repository giữa ControllerModel. Luồng đơn giản: request đến Controller, Controller gọi Repository, Repository gọi Model lấy và xử lý data. Controller không bao giờ đụng trực tiếp vào database.

9.2. Ưu điểm

9.3. Ví dụ

getPost() {
    let posts = Post.orderBy('id', 'desc').get();
    return posts;
}

Đơn giản. Nhưng khách hàng muốn sắp xếp post theo bảng chữ cái thì sao? Bạn phải mò vào hàm sửa lại. Cấu trúc bảng thay đổi? Phải sửa getPost() giữa hàng đống method khác. Trường hợp xấu nhất, quản lý muốn đổi database và bạn phải viết lại hàm get từ đầu. Repository Pattern giải quyết hết mấy cái đó.

Tạo thêm class PostRepository, có thể đặt trong thư mục Repositories riêng. Logic lấy dữ liệu nằm hết ở đây. Khi yêu cầu thay đổi, sửa một file duy nhất.

class PostRepository {
  getPostById() {
    return Post.orderBy("id", "desc").get();
  }
}

Giờ PostController chỉ cần gọi repository:

import { PostRepository } from './../Repositories';
class PostController extends Controller {
    constructor(private postRepository: PostRepository) { }
    getPost() {
        let posts = this.postRepository.getPostById();
        return posts;
    }
}

Share this post on:

Bài trước
Chuyển tiếp request từ path sang port cụ thể bằng .htaccess
Bài tiếp
Creational Design Pattern — Giải thích dễ hiểu với TypeScript