[Design Patterns #5] Facade Pattern

Facade Pattern là một design pattern khá giống với Adapter Pattern. Cả 2 design patterns này đều dùng để chuyển đổi interface của các class nhưng mục đích của chúng thì khác nhau. Trong khi Adapter Pattern dùng để làm cho code của một class hay module nào đó có thể thích ứng và làm việc được với class / module khác thì mục đích của Facade Pattern là tạo ra một lớp wrapper cho một subsystem (có thể là một library hoặc một module bao gồm nhiều classes khác nhau), giúp đơn giản hóa interface của subsystem đó. Nhờ đó, các module khác có thể làm việc với subsystem đó dễ dàng hơn thông qua wrapper, sự phức tạp trong bản thân của subsystem sẽ không ảnh hưởng đến thế giới bên ngoài.

Ví dụ về bài toán cần giải quyết

Có một câu chuyện vui như sau: giả sử bạn có một anh bạn thiết kế ra software library để điều khiển một cái máy in  và anh ta đang giới thiệu nó cho bạn một cách đầy say mê và tự hào.

– Làm thế dell nào để in một tài liệu bằng cái library của ông vậy hả ông bạn ? – Bạn hỏi. – Dễ vl ông ạ. Đầu tiên là call hàm initialize – anh ta nói – Ok, thế là nó in được luôn à ? – Chưa được, ông phải call hàm turnFanOn nữa. – Ok, chịch luôn được rồi nhỉ ? – Chưa, phải call tiếp hàm warmUp nữa. – Loằng ngoằng vãi, thế giờ nó đã in được chưa ? – Từ từ đã, ông phải call hàm getData để lấy data từ PC về rồi mới in được chứ. – Ok, getData. Xong, next là gì ? – Hàm formatData – Và tiếp theo là … – Hàm checkToner, checkPaperSupply, InternalDiagnostics, checkPaperPath … – Thôi thôi thôi thôi. Nín cmm đi. Cái lib của ông phức tạp vãi đái.

Sử dụng Facade Pattern để đơn giản hóa interface

– Ông nên viết một cái facade cho cái đống rối rắm này, cái facade đó sẽ làm đơn giản hóa interface, và nó sẽ call đến cái đống rối rắm của ông một cách âm thầm và lặng lẽ. Ví dụ: ông hãy viết một hàm gọi là print(), người dùng cái software của ông chỉ cần call 1 hàm duy nhất là print để in. Tất cả các xử lý loằng ngoằng như initialize, turnFanOn, warmUp, getData, … sẽ được call trong hàm print – bạn nói.

– Ờ, hợp lý đấy. Làm như thế sau maintain cũng dễ nữa có sửa tên hàm hay thay đổi thứ tự call thì update hàm print là được.

Thực ra nếu các subsystem (libraries / modules) được thiết kế OOP chuẩn, đóng gói tốt thì cũng không cần dùng đến Facade Pattern. Tuy nhiên trên thực tế, khi làm việc trong các hệ thống lớn, sử dụng nhiều subsystem của bên thứ 3 bạn sẽ gặp phải một số subsystem thiết kế lởm khởm, rối rắm và khó sử dụng giống như phần mềm điều khiển máy in của a bạn trong ví dụ trên. Trong những tình huống như vậy, Facade Pattern nên được áp dụng để làm đơn giản hóa interface của subsystem thiết kế tồi, giúp cho code dễ maintain và gọn gàng, sạch sẽ hơn.

Sample code

Ở đây mình sẽ lấy một ví dụ khác để minh họa bằng sample code. Giả sử có một subsystem gọi là compiler dùng để biên dịch chương trình. Subsystem này bao gồm các class như Scanner, Parser, ProgramNode, BytecodeStream, ProgramNodeBuilder. Một số ứng dụng đặc biệt có thể có thể cần truy cập và sử dụng trực tiếp các class này nhưng hầu hết các ứng dụng thông thường không quan tâm cụ thể và chi tiết đến việc phân tích cú pháp như thế nào, sinh mã byte code ra sao mà chỉ đơn giản là muốn biên dịch code. Đối với những ứng dụng kiểu này thì các low-level interface bên trong compiler chỉ làm mọi thứ phức tạp và rắc rối thêm mà thôi.

Để cung cấp một high-level interface và tách biệt client code với các class bên trong thì compiler subsystem sẽ cung cấp một class tên là Compiler. Class này định nghĩa một interface thống nhất cho tất cả các chức năng của compiler. Class Compiler đóng vai trò là facade (dịch nôm na là “mặt tiền”), nó cung cấp cho client code một interface đơn giản, thống nhất để có thể làm việc với compiler subsystem, nó kết nối các class thực hiện các chức năng của compiler lại với nhau dưới một interface chung nhưng không che dấu hoàn toàn các class này. Các ứng dụng đặc biệt cần sử dụng trực tiếp các class bên trong của compiler vẫn có thể sử dụng các class đó bình thường.

compiler subsystem

Bây giờ chúng ta đi vào cụ thể hơn về code ↓

compiler subsystem có một class là BytecodeStreamimplements viêc xử lý các mã bytecode. Subsystem cũng định nghĩa một class Token để lưu và xử lý các token trong ngôn ngữ lập trình (hiểu đơn giản các token trong một ngôn ngữ lập trình là các các ký tự, hoặc từ nhỏ nhất có ý nghĩa trong ngôn ngữ đó). Class Scanner sẽ xử lý tập hợp các ký tự đầu vào (chính là code) và output ra các token.

Class Parser sử dụng a ProgramNodeBuilder để tạo ra một cây phân tích (parse tree) các token ( được output ra bởi Scanner) →

Parse tree được tạo thành từ các instances của các class là class con (lớp dẫn xuất) của ProgramNode như StatementNode, ExpressionNode, …

Method Traverse() của ProgramNode sẽ nhận input là một object của CodeGenerator, các class con của ProgramNode sử dụng object này để sinh ra mã máy (machine code) → 

CodeGenerator lại có các class con như StackMachineCodeGeneratorRISCCodeGenerator, chúng generate ra machine code cho các kiến trúc phần cứng khác nhau. 

Mỗi class con của ProgramNode phải implement method Traverse() để thực hiện traverse trên các đối tượng ProgramNode là node con của nó, và cứ như vậy một cách đệ quy → 

Các class mà mình vừa đề cập ở trên đây đã tạo nên một compiler subsystem phức tạp vcđ đúng không. Và đây là lúc chúng ta cần một vị cứu tinh mang tên Compiler, một class kết nối tất cả các class này lại với nhau. Compiler cung cấp một interface đơn giản và thống nhất để có thể biên dịch source code cho một machine cụ thể →

Client code lúc này chỉ cần call method Compile() của class Compiler để biên dịch source code mà không cần quan tâm cái đống loằng ngoằng ẩn sau lời gọi đó là gì.

Note: Trong code ví dụ trên thì đang hard-code kiểu của code generator là RISCCodeGenerator, nó là hợp lý nếu ứng dụng chỉ làm việc với kiến trúc phần cứng là RISC. Nếu muốn linh hoạt hơn, có thể lựa chọn code generator dynamic lúc runtime thì cần sửa lại chương trình một chút để có thể pass instance CodeGenerator từ bên ngoài vào cho Compiler.

— Phạm Minh Tuấn (Shun) —