bash - staycrafting.dev

~$ cat chu-i-trong-solid-tuyen-nguoi-trong-xe-dung-doi-biet-pha-latte.md

Chữ I trong SOLID: tuyển người trông xe, đừng đòi biết pha latte

Chữ I trong SOLID (Interface Segregation): đừng bắt class hứa những việc nó không làm được hay không ai cần. Bài thứ tư series SOLID quán trà đá.

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, Store và 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 đề:

  • ThermalReceiptPrinter bị bắt hứa scan và fax, nên nó phải raise. Vi phạm L ngay từ lúc sinh ra. Ai đó cắm nhầm máy in bill vào InvoiceArchive là nổ.
  • Checkout chỉ in, nhưng nhận vào cả một OfficeMachine. Người đọc nhìn tham số tưởng nó cần cả scan lẫn fax.
  • Test Checkout phả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ả user chỉ để lấy user.id, và test service đó phải tạo User thật trong database. Truyền user_id thô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 Publishable bắt model include phải có published_at, draft?, author, slug, seo_title, og_image... trong khi nửa số model chỉ cần published_at. Tách thành vài concern nhỏ theo vai trò.
  • Service object kiểu PostService với 15 method public, controller nào cũng nhận cả service: đây là OfficeMachine phiê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?".

  1. Class cha Repository bắt mọi repository cài find, all, create, update, delete, search, export_csv. ReportRepository (chỉ đọc số liệu báo cáo) phải raise ở create, update, delete.
  2. Một class chỉ cài đúng method each, include Enumerable, và được dùng map, select, sort_by...
  3. Class cha Notifier bắt cài send_email, send_sms, send_push. SlackNotifier chỉ gửi được Slack, phải raise ở cả ba, rồi tự thêm method send_slack.
  4. ReceiptPrinter.new(gateway) nhận vào cả PaymentGateway (12 method public: charge, refund, capture, void, list_transactions...) chỉ để gọi gateway.last_transaction_id in lên bill.
  5. WelcomeMailer.welcome(user) nhận cả user nhưng chỉ dùng user.email và 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 NotImplementedError nà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.

Đăng nhập để xem đáp án.

~$ cd ..