Quán trà đá cần tuyển người trông xe. Bà chủ nhờ cô con gái đăng tin. Cô con gái copy nguyên mẫu tuyển dụng của một chuỗi cà phê: "Yêu cầu: pha chế thành thạo, sử dụng máy POS, tiếng Anh giao tiếp, có kinh nghiệm latte art, trông xe."
Ông chú trông xe giỏi nhất phường, nhớ mặt từng chiếc Wave trong bán kính 2 cây số, đọc xong lặng lẽ đi về. Quán tuyển được một bạn pha latte rất đẹp, và mất ba cái xe trong tuần đầu.
Lời dẫn
Đây là bài thứ tư của series SOLID, mỗi bài một chữ:
| Chữ | Tên đầy đủ | Nói kiểu quán trà đá |
|---|---|---|
| S | Single Responsibility Principle | Mỗi người nghe một sếp |
| O | Open/Closed Principle | Thêm món thì gài thêm thẻ, đừng sơn lại biển |
| L | Liskov Substitution Principle | Người thay ca phải bán đúng như người cũ |
| I | Interface Segregation Principle | Tuyển trông xe thì đừng đòi biết pha latte |
| D | Dependency Inversion Principle | (bài sau) |
Bài L có một lời khuyên: đứa con nào không thể giữ lời hứa của cha thì đừng cho làm con, hoặc chia lại cây (Bird không hứa bay, FlyingBird mới hứa). Chữ I đi tiếp từ đó: có khi lỗi không ở đứa con, mà ở chỗ lời hứa của cha quá dài.
Bài này đọc riêng vẫn hiểu. Đọc bài L trước thì sẽ thấy I là cách phòng bệnh, còn L là cách phát hiện bệnh.
Trong bài có bài refactor mẫu đi từng bước, và bài tập nhanh ở cuối để anh em tự kiểm tra.
1. I là gì?
Interface Segregation Principle (ISP), nguyên tắc phân tách interface. Lại là bác Bob (Robert C. Martin), viết thành bài năm 1996, đúc kết từ một lần đi tư vấn cho Xerox, nơi một class "Job" khổng lồ bị mọi loại tác vụ máy in dùng chung, sửa một chỗ nhỏ là cả hệ thống phải build lại, mỗi lần mất cả tiếng. Phát biểu:
Clients should not be forced to depend upon interfaces that they do not use.
Không nên bắt bên sử dụng phải phụ thuộc vào những phần interface mà họ không hề dùng tới.
— Robert C. Martin, The Interface Segregation Principle, C++ Report, 1996
Dịch cabin: mỗi bên chỉ nên thấy đúng phần mình cần. Người trông xe chỉ cần ký cam kết trông xe. Người pha chế chỉ cần cam kết pha chế. Đừng bắt ai ký một bản cam kết dài ba trang mà họ chỉ làm được một dòng.
Câu gốc nói về bên sử dụng, nhưng trong thực tế nguyên tắc này áp cho cả hai phía:
- Phía cài đặt (class phải hiện thực một interface): đừng bắt nó viết những method nó không làm được. Bắt viết thì nó sẽ viết
raise NotImplementedError, và đó chính là vi phạm L ở bài trước. - Phía sử dụng (code gọi đến interface): đừng bắt nó nhận một thứ khổng lồ trong khi nó chỉ dùng một góc nhỏ. Nhận thứ khổng lồ thì test phải giả lập cả thứ khổng lồ đó.
Hiểu nhầm phổ biến
"I với S là một." Gần, nhưng khác góc nhìn. S nhìn bên trong một class: nó có mấy lý do để thay đổi. I nhìn từ bên ngoài: người dùng nó thấy gì, phải phụ thuộc vào cái gì. Một class có thể đúng S (chỉ một lý do thay đổi) nhưng vẫn phơi ra cho mọi người một bộ mặt quá to.
"Ruby không có interface, chữ I không áp dụng." Ruby không có từ khóa interface, nhưng có interface. Interface trong Ruby nằm ở ba chỗ: class cha bắt con cài method (raise NotImplementedError), module bắt class include phải có method (ví dụ Enumerable đòi each), và những gì một object được truyền vào thật sự bị gọi. Cả ba chỗ đều có thể béo phì.
"Tách càng nhỏ càng tốt, mỗi method một interface." Không. Mục 5 sẽ nói kỹ.
Kết luận phần này: lời hứa nên vừa với người hứa, và vừa với người nghe.
2. Chuyện đời thường: điều khiển TV của bà nội
Nhà ai có điều khiển TV 50 nút thì hiểu. Bà nội chỉ cần ba việc: bật tắt, đổi kênh, to nhỏ. Nhưng cái điều khiển có đủ: input source, picture mode, sleep timer, 3D, Netflix, và một nút đỏ không ai biết để làm gì. Hậu quả:
- Bà bấm nhầm nút input, màn hình hiện "No signal", cả nhà mất buổi tối để tìm cách quay lại.
- Hãng làm TV rẻ tiền (không 3D, không Netflix) vẫn phải kèm cái điều khiển đủ 50 nút, 30 nút trong đó bấm vào không có tác dụng.
Giải pháp người ta đã nghĩ ra từ lâu: điều khiển đơn giản cho người cần đơn giản, điều khiển đầy đủ cho người cần đầy đủ. Cùng một cái TV, hai bộ mặt.
Quán trà đá cũng vậy. Một bản "nội quy nhân viên" dài ba trang, mọi người cùng ký, sẽ có lúc người trông xe bị trách vì "không lau bàn", còn người pha trà bị trách vì "để mất xe". Tách nội quy theo vị trí, mỗi người một tờ, thì ai cũng biết mình chịu trách nhiệm gì.
Mẹo nhỏ: Đếm số method mà một đoạn code thật sự gọi trên object được truyền vào, so với số method object đó có. Gọi 1 trong 20 thì chỗ đó đang cầm điều khiển 50 nút để bật tắt TV.
3. Vi phạm I thì đã sao?
- Đứa cài đặt phải nói dối. Bị bắt cài method không làm được, nó sẽ
raise, hoặc rỗng, hoặc trả bừa một giá trị. Đó là vi phạm L, và bài trước đã nói hậu quả. - Test khổ vì giả lập thừa. Muốn test một đoạn code chỉ gọi một method, phải tạo object giả có đủ 10 method cho "đúng chuẩn". Code test dài gấp ba code thật.
- Sửa một chỗ, ảnh hưởng người không liên quan. Thêm một method vào interface chung là mọi class cài đặt bị kéo theo, kể cả những class chẳng làm được method đó. Ở Java hay C++ thì phải sửa và build lại cả loạt, như câu chuyện của Xerox: sửa một chỗ nhỏ cho một loại tác vụ, cả hệ thống phải build lại. Ở Ruby thì im lặng hơn mà cũng nguy hơn: class con tự thừa hưởng thêm một method nổ, chẳng ai hay cho đến lúc nó được gọi.
- Người đọc hiểu nhầm. Thấy tham số kiểu
OfficeMachine, người đọc sẽ nghĩ đoạn code này cần scan, fax, đủ cả. Thực ra nó chỉ in. Tên kiểu dữ liệu quá rộng cũng là một kiểu nói dối: nó hứa nhiều hơn những gì đoạn code thật sự cần.
Dấu hiệu nhận biết
- Class cha hoặc module có nhiều method trừu tượng, và có class con
raise NotImplementedErrorở một vài cái. - Object giả trong test có nhiều method mà test không bao giờ gọi.
- Truyền cả một object to (cả
user, cảorder, cảconfig) vào một hàm chỉ dùng một hai trường. - Interface có tên chung chung kiểu
Manager,Service,Machine,Storevà ngày càng dài.
4. Bài refactor: máy in của quán trà đá
Code bằng Ruby như các bài trước. Anh em ngôn ngữ khác cứ đọc như mã giả.
Bối cảnh: quán trà đá đã lên đời, có máy in. Văn phòng nhỏ phía sau có một máy in đa năng (in, scan, fax) để làm sổ sách. Quầy thu ngân thì mua một máy in bill nhiệt giá rẻ, chỉ biết in bill.
(Method in đặt tên print_document chứ không phải print, vì print trùng với method có sẵn của Ruby.)
4.1. Code ban đầu: một bản cam kết cho mọi máy
class OfficeMachine
def print_document(document)
raise NotImplementedError
end
def scan(paper)
raise NotImplementedError
end
def fax(document, number)
raise NotImplementedError
end
end
class AllInOnePrinter < OfficeMachine
def print_document(document)
"In A4: #{document}"
end
def scan(paper)
"Bản scan của #{paper}"
end
def fax(document, number)
"Fax #{document} tới #{number}"
end
end
class ThermalReceiptPrinter < OfficeMachine
def print_document(document)
"In bill: #{document}"
end
# Class cha đã raise sẵn, viết lại chỉ để thông báo lỗi rõ hơn
def scan(paper)
raise NotImplementedError, "Máy in bill không scan được"
end
def fax(document, number)
raise NotImplementedError, "Máy in bill không fax được"
end
end
Và hai chỗ dùng:
class Checkout
def initialize(machine)
@machine = machine
end
def finish(order)
@machine.print_document("Hóa đơn #{order}")
end
end
class InvoiceArchive
def initialize(machine)
@machine = machine
end
def archive(paper)
@machine.scan(paper)
end
end
Vấn đề:
ThermalReceiptPrinterbị bắt hứa scan và fax, nên nó phảiraise. Vi phạm L ngay từ lúc sinh ra. Ai đó cắm nhầm máy in bill vàoInvoiceArchivelà nổ.Checkoutchỉ in, nhưng nhận vào cả mộtOfficeMachine. Người đọc nhìn tham số tưởng nó cần cả scan lẫn fax.- Test
Checkoutphải giả lập cả máy văn phòng, hoặc ít nhất phải nghĩ xem có cần giả lập scan, fax không. - Tháng sau thêm tính năng "scan gửi email" vào
OfficeMachine, máy in bill tự động thừa hưởng thêm một method nổ mà không ai hay. Nó có liên quan gì đâu. (Ở Java hay TypeScript thì còn bị bắt viết thêm method đó cho đủ mới chịu biên dịch.)
4.2. Bước 1: ai dùng cái gì
Liệt kê người dùng và những gì họ thật sự gọi:
| Người dùng | Gọi print_document |
Gọi scan |
Gọi fax |
|---|---|---|---|
Checkout |
Có | ||
InvoiceArchive |
Có | ||
| Kế toán gửi fax cho thuế (thỉnh thoảng) | Có |
Mỗi người dùng chỉ cần một vai trò. Không ai cần cả ba cùng lúc. Vậy là có ba vai trò: máy in, máy scan, máy fax.
4.3. Bước 2: mỗi vai trò một lời hứa nhỏ
Trong Java hay TypeScript, anh em sẽ viết ba interface nhỏ:
interface Printer { printDocument(document: string): string }
interface Scanner { scan(paper: string): string }
interface Fax { fax(document: string, number: string): string }
class AllInOnePrinter implements Printer, Scanner, Fax { /* ... */ }
class ThermalReceiptPrinter implements Printer { /* ... */ }
Trong Ruby không có từ khóa đó, nên vai trò được thể hiện bằng tên tham số, comment, và test vai trò (mục 4.6). Mỗi class chỉ cài đúng những gì nó làm được, không có class cha bắt cài thừa:
# Vai trò "printer": có print_document(document)
class ThermalReceiptPrinter
def print_document(document)
"In bill: #{document}"
end
end
# Đóng cả ba vai trò: printer, scanner, fax
class AllInOnePrinter
def print_document(document)
"In A4: #{document}"
end
def scan(paper)
"Bản scan của #{paper}"
end
def fax(document, number)
"Fax #{document} tới #{number}"
end
end
Máy in bill giờ chỉ hứa đúng một điều nó làm được. Không còn dòng raise nào. Vi phạm L biến mất mà chẳng cần sửa gì ở L.
4.4. Bước 3: người dùng nói rõ mình cần vai trò nào
class Checkout
def initialize(printer:)
@printer = printer
end
def finish(order)
@printer.print_document("Hóa đơn #{order}")
end
end
class InvoiceArchive
def initialize(scanner:)
@scanner = scanner
end
def archive(paper)
@scanner.scan(paper)
end
end
Đọc Checkout.new(printer:) là biết ngay nó chỉ cần một thứ biết in. Lắp ráp:
office = AllInOnePrinter.new
Checkout.new(printer: ThermalReceiptPrinter.new).finish("#42")
# => "In bill: Hóa đơn #42"
Checkout.new(printer: office).finish("#43") # máy đa năng cũng in được
InvoiceArchive.new(scanner: office).archive("hóa đơn đá tháng 9")
Cùng một máy đa năng, đóng vai máy in ở chỗ này, máy scan ở chỗ kia. Giống cái TV có hai cái điều khiển.
4.5. Test: object giả nhỏ xíu
require "minitest/autorun"
class CheckoutTest < Minitest::Test
FakePrinter = Struct.new(:printed) do
def print_document(document)
printed << document
end
end
def test_in_hoa_don_khi_thanh_toan_xong
printer = FakePrinter.new([])
Checkout.new(printer: printer).finish("#1")
assert_equal ["Hóa đơn #1"], printer.printed
end
end
Một method. Và nếu Checkout có lén gọi scan hay fax, FakePrinter sẽ nổ NoMethodError, test đỏ ngay. Object giả nhỏ không chỉ dễ viết mà còn là hàng rào: tên tham số nói Checkout chỉ cần printer, còn test chứng minh điều đó.
4.6. Test vai trò: thay cho từ khóa interface
Còn một vấn đề ở mục 4.1 chưa giải: cắm nhầm máy in bill vào InvoiceArchive. Sau refactor nó vẫn nổ, chỉ đổi từ NotImplementedError sang NoMethodError. Ruby không có trình biên dịch đứng gác như Java, nên hàng rào phải dựng bằng test: mỗi vai trò một module test, ai đóng vai nào thì include module đó.
module PrinterRoleTest
def test_dong_vai_printer
assert_respond_to @printer, :print_document
end
end
module ScannerRoleTest
def test_dong_vai_scanner
assert_respond_to @scanner, :scan
end
end
class ThermalReceiptPrinterTest < Minitest::Test
include PrinterRoleTest
def setup
@printer = ThermalReceiptPrinter.new
end
end
class AllInOnePrinterTest < Minitest::Test
include PrinterRoleTest
include ScannerRoleTest
def setup
@printer = @scanner = AllInOnePrinter.new
end
end
Object giả trong test cũng nên include test vai trò, để nó không lệch khỏi máy thật:
class FakePrinterTest < Minitest::Test
include PrinterRoleTest
def setup
@printer = CheckoutTest::FakePrinter.new([])
end
end
Mai này vai trò "printer" đổi tên method, chỉ cần sửa PrinterRoleTest: máy thật lẫn object giả nào chưa theo kịp đều đỏ, thay vì test Checkout vẫn xanh trong khi chạy thật thì nổ.
Ruby vẫn không chặn được ai đó cố tình truyền máy in bill vào scanner:. Nhưng tên tham số làm lỗi đó lộ ra ngay khi đọc code, còn test vai trò ghi rõ máy nào đóng được vai nào. Hai thứ cộng lại chính là interface của Ruby.
4.7. So sánh trước và sau
| Trước | Sau | |
|---|---|---|
| Máy in bill | Bị bắt cài scan, fax, phải raise |
Chỉ cài print_document |
| Vi phạm L | Có sẵn từ lúc sinh | Không còn |
Checkout nhận vào |
Cả OfficeMachine |
Chỉ một printer |
| Object giả trong test | Phải tính cả scan, fax | Một method |
| Cắm nhầm máy | Nổ lúc chạy, không ai báo trước | Tên tham số lộ ra, test vai trò ghi rõ máy nào đóng vai nào |
| Thêm tính năng cho máy văn phòng | Máy in bill phải sửa theo | Máy in bill không biết gì |
Bốn chữ cái gặp nhau
Để ý: bài này sửa một vi phạm I, và tiện tay xóa luôn một vi phạm L. Bài L sửa vi phạm L, và tiện tay xóa luôn một chuỗi if vi phạm O. Bài O thì xây trên những mảnh đã chia theo S.
SOLID không phải năm điều luật rời rạc, mà là năm góc nhìn vào cùng một câu hỏi: làm sao để sửa code mà không sợ.
5. Đừng tách quá tay
Học xong I, sẽ có anh em muốn mỗi method một interface. Kìm lại.
- Tách theo vai trò, không tách theo method.
print_document,print_duplex,print_colorđều là chuyện in, thường đi cùng nhau. Gom vào một vai trò "printer". Tách thành ba thì mỗi chỗ dùng phải nhận ba tham số, rối hơn cả cái điều khiển 50 nút. Ở ví dụ máy in, mỗi vai trò tình cờ chỉ có một method, vì in, scan, fax là ba việc khác nhau, chứ không phải vì ta tách theo method. - Tách theo người dùng thật, không theo tưởng tượng. Bước 1 ở mục 4 là liệt kê người dùng có thật và những gì họ thật sự gọi. Chưa có ai chỉ cần fax mà không cần in thì chưa cần tách vai trò fax.
- Interface to mà ai cũng dùng hết thì không sao. Vấn đề không phải độ dài, mà là có ai bị bắt phụ thuộc vào thứ họ không dùng hay không.
Ruby có hai ví dụ đẹp về interface "vừa đủ": module Enumerable chỉ đòi anh em cài một method each, đổi lại cho vài chục method như map, select, sort_by. Module Comparable chỉ đòi <=>. Lời hứa ngắn cho người cài đặt, quà nhiều cho người dùng. Thiết kế interface mà làm được vậy thì hết ý.
Nói riêng với anh em Rails
- Truyền model to vào chỗ chỉ cần một trường. Một service nhận cả
userchỉ để lấyuser.id, và test service đó phải tạoUserthật trong database. Truyềnuser_idthôi là test nhẹ hẳn. Nhưng đừng thấy model là tách: chỉ sửa khi chỗ dùng hoặc test đang thật sự đau. - Concern béo. Một concern
Publishablebắt model include phải cópublished_at,draft?,author,slug,seo_title,og_image... trong khi nửa số model chỉ cầnpublished_at. Tách thành vài concern nhỏ theo vai trò. - Service object kiểu
PostServicevới 15 method public, controller nào cũng nhận cả service: đây làOfficeMachinephiên bản Rails.
6. Bài tập nhanh
Đến lượt anh em. Một bài khởi động nhẹ, không cần mở editor, chỉ cần một ly trà đá: đọc từng tình huống và đoán xem chỗ nào vi phạm, chỗ nào không. Bài tập sửa code dài hơn sẽ gom vào một bài tổng ôn riêng của series.
Cố tự làm trước rồi hãy xem lời giải. Xem lời giải trước khi làm thì giống chép đáp án đề thi thử: cảm giác rất giỏi, cho đến hôm thi thật.
Trường hợp nào vi phạm I? Giải thích bằng câu hỏi "có ai bị bắt phụ thuộc vào thứ họ không dùng không?".
- Class cha
Repositorybắt mọi repository càifind,all,create,update,delete,search,export_csv.ReportRepository(chỉ đọc số liệu báo cáo) phảiraiseởcreate,update,delete. - Một class chỉ cài đúng method
each, includeEnumerable, và được dùngmap,select,sort_by... - Class cha
Notifierbắt càisend_email,send_sms,send_push.SlackNotifierchỉ gửi được Slack, phảiraiseở cả ba, rồi tự thêm methodsend_slack. ReceiptPrinter.new(gateway)nhận vào cảPaymentGateway(12 method public: charge, refund, capture, void, list_transactions...) chỉ để gọigateway.last_transaction_idin lên bill.WelcomeMailer.welcome(user)nhận cảusernhưng chỉ dùnguser.emailvàuser.name. App nhỏ, test mailer tạo user bằng factory mất vài mili giây.
Túm cái váy lại
| Ghi nhớ | |
|---|---|
| Định nghĩa | Không bắt ai phụ thuộc vào method họ không dùng |
| Dịch cabin | Mỗi bên chỉ thấy đúng phần mình cần |
| Câu hỏi thần chú | "Đoạn code này thật sự gọi mấy method?" |
| Hai phía | Người cài đặt không bị bắt cài thừa; người dùng không bị bắt nhận thừa |
| Dấu hiệu | Class con raise NotImplementedError; object giả trong test thừa method; tham số kiểu quá rộng |
| Cách làm | Liệt kê ai gọi gì, gom thành vai trò, mỗi class cài vai trò nó làm được, người dùng nói rõ cần vai trò nào |
| Hiểu nhầm | Ruby không có interface nên không cần. Sai, interface nằm ở cái được gọi |
| Đừng | Tách mỗi method một interface. Tách theo vai trò và người dùng thật |
Sau bài này, anh em đã sở hữu:
- Một câu hỏi để soi mọi tham số: đoạn code này thật sự gọi mấy method trên nó?
- Một quy trình 3 bước: liệt kê ai dùng gì, gom thành vai trò, để người dùng nói rõ mình cần vai trò nào.
- Và hy vọng là một dòng
raise NotImplementedErrornào đó trong dự án sắp được nghỉ hưu.
Chúc anh em interface vừa vặn, test giả lập nhẹ tênh, và trà luôn đủ đá. Hẹn anh em ở chữ D, chữ cuối cùng.
À mà, nếu anh em đang nghĩ "interface của em 30 method nhưng mỗi class con chỉ raise có 5 cái thôi", thì mình tin. Ông chú trông xe cũng chỉ không biết có 4 trong 5 yêu cầu tuyển dụng thôi.