Bà chủ quán trà đá đầu ngõ (sau bài trước, bà đã thuê thêm người trông xe) muốn bán thêm trà chanh. Bà gọi thợ đến sơn lại cái biển trên tường. Thợ sơn xong chữ "TRÀ CHANH 10K" rất đẹp, tiện tay sơn đè luôn lên số điện thoại gọi giữ xe. Món mới lên biển, dịch vụ cũ ra đi.
Anh em nào từng thêm một tính năng nhỏ rồi làm hỏng một tính năng chẳng liên quan, xin mời ngồi xuống đây, mình gọi thêm ly trà.
Lời dẫn
Đây là bài thứ hai 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 | |
| I | Interface Segregation Principle | |
| D | Dependency Inversion Principle |
Bài trước về chữ S nói chuyện chia việc: mỗi class chỉ nên có một lý do để thay đổi. Hôm nay chữ O nói chuyện tiếp theo: khi phải thay đổi, thay đổi bằng cách nào để không làm hỏng cái đang chạy.
Chưa đọc bài S cũng không sao, bài này đọc riêng vẫn hiểu. Nhưng đọc rồi thì sẽ thấy hai chữ này đi với nhau như trà đá với hạt hướng dương.
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. O là gì?
Open/Closed Principle (OCP), nguyên tắc đóng/mở. Ý gốc của Bertrand Meyer, trong sách Object-Oriented Software Construction năm 1988 (nguyên văn của ông là "Modules should be both open and closed"). Cách phát biểu quen thuộc nhất hiện nay đến từ bài viết năm 1996 của bác Bob (Robert C. Martin):
Một module nên mở cho việc mở rộng, nhưng đóng cho việc sửa đổi.
Nghe như câu đố. Vừa mở vừa đóng là sao? Quán đóng cửa nhưng vẫn bán qua khe cửa à?
Hồi đó Meyer tính chuyện mở rộng bằng kế thừa. Về sau bác Bob phổ biến cách hiểu hiện nay: mở rộng bằng cách cắm thêm các class cùng chung một "phích cắm". Bài này đi theo cách hiểu sau.
Dịch cabin: khi có yêu cầu mới, anh em thêm tính năng bằng cách viết code mới, chứ không phải mổ code cũ ra sửa.
- Mở cho mở rộng: muốn thêm một loại mới (món mới, khuyến mãi mới, kênh thanh toán mới...) thì thêm được.
- Đóng cho sửa đổi: phần code đang chạy ngon, đã test, đã lên production thì để yên, không phải đụng vào.
Tại sao phải khổ vậy? Vì code cũ là code đã được kiểm chứng. Nó đã sống sót qua test, qua review, qua cả trăm ngàn request thật. Mỗi lần mổ ra sửa là một lần có thể sơn đè lên số điện thoại giữ xe.
Hiểu nhầm phổ biến
"Đóng cho sửa đổi" nghĩa là cấm sửa code cũ. Không phải. Có bug thì cứ sửa, đổi một luật đang có thì cứ sửa. O chỉ nói về một tình huống cụ thể: khi thêm một biến thể mới của thứ đã có (thêm loại, thêm kênh, thêm một luật cùng kiểu với các luật đã có), thì không nên phải sửa những chỗ đang xử lý các biến thể cũ.
"Phải thiết kế để mở rộng được mọi thứ ngay từ đầu." Cũng không phải. Không ai đoán được tương lai, kể cả sếp. Mở rộng mọi thứ thì code thành một cái mê cung toàn interface, factory, plugin mà chẳng ai cắm gì vào. Chỉ mở ở chỗ thực sự hay thay đổi. Mục 6 sẽ nói kỹ.
Kết luận phần này: thêm biến thể mới thì viết thêm, không sửa lại.
2. Chuyện đời thường: biển sơn và bảng gài thẻ
Quay lại quán trà đá. Có hai kiểu làm menu:
| Biển sơn lên tường | Bảng gài thẻ | |
|---|---|---|
| Thêm món mới | Gọi thợ sơn lại, cả biển | Viết một tấm thẻ, gài vào |
| Bỏ món hết hàng | Sơn đè, để lại vết loang | Rút thẻ ra |
| Rủi ro | Sơn nhầm, sơn lem, sơn đè số điện thoại | Gài nhầm chỗ thì gài lại |
| Món cũ có bị ảnh hưởng không? | Có, vì cả biển là một khối | Không, mỗi món một thẻ |
Cái bảng gài thẻ chính là chữ O. Cái khung bảng thì đóng: không ai phải sửa nó. Cái khe gài thì mở: thẻ nào đúng kích thước thì gài vào được.
Một ví dụ nữa trong nhà anh em: ổ cắm điện. Mua thêm cái nồi chiên không dầu, anh em không phải gọi thợ đục tường đi dây lại. Chỉ cần nồi có phích cắm đúng chuẩn là cắm vào chạy. Tường đóng, ổ cắm mở. Cái phích cắm chuẩn, trong code, gọi là interface (hay trong Ruby là "biết trả lời đúng method").
Mẹo nhỏ: Mỗi lần thêm tính năng, đếm xem phải sửa bao nhiêu file cũ và thêm bao nhiêu file mới. Nếu thêm một loại mới mà phải sửa 5 file cũ, chỗ đó đang là biển sơn.
3. Vi phạm O thì đã sao?
Vẫn chạy được, như mọi lần. Cái giá nằm ở lần thêm tiếp theo:
- Thêm cái mới, hỏng cái cũ. Anh em chen thêm một khối
ifcho khuyến mãi mới, lỡ tay viếtdiscount = ...thay vìdiscount += ..., thế là mấy khuyến mãi phía trên bị xóa sạch. Phát hiện ra khi khách quen chửi ở quầy. - Test lại từ đầu. Sửa vào một hàm dùng chung thì mọi tính năng đi qua hàm đó đều phải test lại. QA nhìn anh em bằng ánh mắt của người sắp phải tăng ca.
- Sửa một chỗ, quên ba chỗ. Cùng một
casetheo loại xuất hiện ở 4 file. Thêm loại mới, sửa được 3 file, quên file thứ 4. Lên production mới biết. Người ta gọi kiểu này là shotgun surgery: bắn một phát, mảnh văng khắp nơi. - Conflict liên miên. Hai người hai tính năng khác nhau nhưng cùng thêm nhánh vào một
case. Merge xong là ngồi gỡ.
Dấu hiệu nhận biết
- Chuỗi
if/elsifhoặccasetheo "loại" (type, kind, status, provider...) ngày càng dài. - Cùng một
casenhư vậy lặp ở nhiều nơi. - Mỗi lần thêm loại mới, commit đụng vào hàng loạt file cũ.
- Có comment kiểu
# TODO: nhớ thêm vào đây khi có loại mới. Comment này nói thật đấy, nhưng là lời trăn trối của người viết trước.
4. Bài refactor: khuyến mãi quán trà đá
Code bằng Ruby như bài trước. Anh em ngôn ngữ khác cứ đọc như mã giả, ý tưởng y hệt.
Bối cảnh: quán trà đá giờ có phần mềm tính tiền. Bộ phận marketing (là cô con gái bà chủ, mới học xong khóa digital marketing) tuần nào cũng nghĩ ra khuyến mãi mới.
4.1. Code ban đầu: một hàm, một chuỗi if
class CheckoutCalculator
def total(items, customer:, time:)
subtotal = items.sum { |i| i[:price] * i[:qty] }
discount = 0
# Giờ vàng 14h-16h: giảm 20%
if (14...16).cover?(time.hour)
discount += subtotal * 20 / 100
end
# Khách quen: giảm thẳng 2k
if customer[:regular]
discount += 2_000
end
# Thứ Hai, mua từ 3 ly: tặng 1 ly trà đá (giảm 3k)
if time.monday? && items.sum { |i| i[:qty] } >= 3
discount += 3_000
end
[subtotal - discount, 0].max
end
end
Để ý: soi theo chữ S thì class này không tệ. Cách tính tổng tiền và luật "không trả tiền âm" gần như không bao giờ đổi, phần duy nhất hay bị đòi sửa là khuyến mãi, và chỉ bộ phận marketing đòi. Nhưng nó vẫn có vấn đề. Ổn ở S mà sai ở O, chuyện thường ngày ở huyện.
Vấn đề là gì? Thứ Hai tới, cô con gái nghĩ ra "trời mưa giảm 10%". Thứ Hai tiếp, "sinh nhật quán giảm 50%". Tháng sau, "giờ vàng" hết hạn, phải xóa. Mỗi lần như vậy, anh em lại mổ cái hàm total ra:
- Thêm một khối
ifnữa, hy vọng không đụng vào khối cũ. - Test lại tất cả khuyến mãi, vì tất cả nằm chung một hàm.
- Hàm dài dần, dài dần, như hóa đơn tiền điện tháng Sáu.
4.2. Bước 1: tìm chỗ hay thay đổi
Trước khi refactor, hỏi: cái gì trong đoạn code này hay thay đổi nhất?
| Phần | Bao lâu đổi một lần |
|---|---|
| Cách tính tổng tiền hàng (giá × số lượng) | Gần như không bao giờ |
| Luật "tiền phải trả không được âm" | Không bao giờ |
| Luật cộng dồn khuyến mãi (cộng hết, chỉ lấy cái lớn nhất, hay có trần) | Hiếm |
| Danh sách khuyến mãi | Hằng tuần |
Chỗ hay đổi là danh sách khuyến mãi. Đó là chỗ cần mở. Phần còn lại cần đóng.
4.3. Bước 2: định nghĩa "phích cắm" chung
Mọi khuyến mãi, dù trời mưa hay sinh nhật, đều trả lời được đúng một câu hỏi: "Với đơn hàng này, giảm bao nhiêu tiền?"
Vậy quy ước: khuyến mãi nào cũng có method discount_for(order), trả về số tiền giảm (không được giảm thì trả về 0). Đây là cái phích cắm chuẩn.
Ruby không có từ khóa interface như Java hay TypeScript. Không sao. Ruby theo kiểu duck typing: con gì đi như vịt, kêu như vịt thì là vịt. Class nào có discount_for thì là khuyến mãi.
Trước tiên, gom dữ liệu đơn hàng lại cho gọn:
Item = Struct.new(:name, :price, :qty, keyword_init: true)
Order = Struct.new(:items, :customer, :time, keyword_init: true) do
def subtotal
items.sum { |i| i.price * i.qty }
end
def cups
items.sum(&:qty)
end
end
4.4. Bước 3: mỗi khuyến mãi một tấm thẻ
class HappyHourPromotion
def discount_for(order)
(14...16).cover?(order.time.hour) ? order.subtotal * 20 / 100 : 0
end
end
class RegularCustomerPromotion
def discount_for(order)
order.customer[:regular] ? 2_000 : 0
end
end
class MondayFreeTeaPromotion
TEA_PRICE = 3_000
def discount_for(order)
(order.time.monday? && order.cups >= 3) ? TEA_PRICE : 0
end
end
Mỗi class nhỏ xíu, đọc 5 giây là hiểu, và không biết gì về nhau. Giờ vàng không biết khách quen là ai. Khách quen không biết hôm nay thứ mấy. Hàng xóm tốt là hàng xóm không hỏi han.
4.5. Bước 4: cái khung bảng
class CheckoutCalculator
def initialize(promotions)
@promotions = promotions
end
def total(order)
discount = @promotions.sum { |p| p.discount_for(order) }
[order.subtotal - discount, 0].max
end
end
CheckoutCalculator giờ không biết có những khuyến mãi nào. Nó chỉ biết: đưa tao một danh sách thứ gì đó biết discount_for, tao cộng lại rồi trừ đi. Đây là cái khung bảng: đóng, thêm hay bỏ khuyến mãi thì không ai phải sửa nó nữa.
Lắp ráp:
PROMOTIONS = [
HappyHourPromotion.new,
RegularCustomerPromotion.new,
MondayFreeTeaPromotion.new
]
calculator = CheckoutCalculator.new(PROMOTIONS)
order = Order.new(
items: [
Item.new(name: "Trà đá", price: 3_000, qty: 2),
Item.new(name: "Trà chanh", price: 10_000, qty: 1)
],
customer: { regular: true },
time: Time.new(2026, 10, 5, 15, 0) # thứ Hai, 15h
)
calculator.total(order) # => 7800
Đơn 16k, giờ vàng giảm 3.200đ, khách quen giảm 2k, thứ Hai mua 3 ly giảm 3k. Còn 7.800đ. Khách quen đến đúng giờ vàng thứ Hai được giảm hơn nửa đơn. Bà chủ xót.
Bà chủ xót thì sẽ có yêu cầu kiểu "giảm tối đa 30% thôi" hoặc "không cộng dồn nữa, chỉ lấy khuyến mãi lớn nhất". Lần này phải sửa CheckoutCalculator:
def total(order)
discount = @promotions.sum { |p| p.discount_for(order) }
discount = [discount, order.subtotal * 30 / 100].min # luật mới: giảm tối đa 30%
[order.subtotal - discount, 0].max
end
Và sửa là đúng. Đây là đổi một luật đang có (cách cộng khuyến mãi), không phải thêm một biến thể, đúng như phần "Hiểu nhầm" ở mục 1. Bảng ở bước 1 cũng đã ghi: luật cộng dồn thì hiếm đổi, nên không mở chỗ đó ra từ đầu.
Thành thật khai báo: chữ O chỉ bảo vệ anh em trước đúng kiểu thay đổi mình đã dự đoán, ở đây là thêm và bỏ khuyến mãi. Kiểu thay đổi khác thì vẫn phải sửa. Nếu một ngày luật cộng dồn cũng đổi hằng tuần, lúc đó mới tính cho nó một "phích cắm" riêng.
4.6. Thử thách: thứ Hai tới có khuyến mãi mới
Cô con gái: "Anh ơi, từ mai trời mưa giảm 10% nhé."
class RainyDayPromotion
def initialize(weather)
@weather = weather
end
def discount_for(order)
@weather.raining? ? order.subtotal * 10 / 100 : 0
end
end
Rồi thêm một dòng vào danh sách:
# Sửa lại danh sách cũ, thêm đúng một dòng
PROMOTIONS = [
HappyHourPromotion.new,
RegularCustomerPromotion.new,
MondayFreeTeaPromotion.new,
RainyDayPromotion.new(WeatherService.new) # WeatherService: class giả định, biết trả lời raining?
]
CheckoutCalculator không sửa dòng nào. Ba khuyến mãi cũ không sửa dòng nào. Giờ vàng hết hạn? Xóa một dòng trong danh sách, xong.
Thành thật khai báo: danh sách PROMOTIONS thì vẫn phải sửa. Không tránh được, và cũng không cần tránh. Ổ cắm điện mở đến đâu thì cái phích vẫn phải có người cắm vào, nó không tự bay vào được. Cái quan trọng là chỗ phải sửa chỉ là chỗ lắp ráp, một dòng, không có logic gì để làm hỏng.
4.7. Test
Mỗi khuyến mãi test riêng, không cần dựng cả hệ thống. Còn CheckoutCalculator thì test bằng khuyến mãi giả, không cần quan tâm khuyến mãi thật nào đang chạy:
require "minitest/autorun"
require_relative "checkout" # file chứa Item, Order, các khuyến mãi và CheckoutCalculator
class HappyHourPromotionTest < Minitest::Test
def order_at(hour)
Order.new(
items: [Item.new(name: "Trà chanh", price: 10_000, qty: 1)],
customer: {},
time: Time.new(2026, 10, 6, hour, 0) # thứ Ba, để khuyến mãi thứ Hai không chen vào
)
end
def test_giam_20_phan_tram_trong_gio_vang
assert_equal 2_000, HappyHourPromotion.new.discount_for(order_at(15))
assert_equal 0, HappyHourPromotion.new.discount_for(order_at(17))
end
end
class CheckoutCalculatorTest < Minitest::Test
FixedPromotion = Struct.new(:amount) do
def discount_for(_order)
amount
end
end
def order
Order.new(items: [Item.new(name: "Trà chanh", price: 10_000, qty: 1)], customer: {}, time: Time.now)
end
def test_cong_don_moi_khuyen_mai
calculator = CheckoutCalculator.new([FixedPromotion.new(1_000), FixedPromotion.new(500)])
assert_equal 8_500, calculator.total(order)
end
def test_khong_bao_gio_tra_tien_am
calculator = CheckoutCalculator.new([FixedPromotion.new(99_000)])
assert_equal 0, calculator.total(order)
end
end
Test cái khung một lần là xong. Từ giờ thêm bao nhiêu khuyến mãi, cái khung vẫn đúng.
Mẹo nhỏ: Để ý giờ vàng tính
subtotal * 20 / 100chứ không phảisubtotal * 0.2. Tiền thì tính bằng số nguyên (hoặcBigDecimal), đừng để số thực chen vào, không thì có ngày hóa đơn ra 7799.999999đ. Chia nguyên thì phần lẻ bị bỏ đi; tiền Việt toàn số tròn trăm nên không sao, còn tính tiền có số lẻ thì dùngBigDecimalkèm quy tắc làm tròn rõ ràng.
4.8. So sánh trước và sau
| Trước | Sau | |
|---|---|---|
| Thêm khuyến mãi mới | Sửa hàm total |
Viết class mới, thêm 1 dòng vào danh sách |
| Bỏ khuyến mãi hết hạn | Xóa khối if giữa hàm, rồi test lại cả hàm |
Xóa 1 dòng trong danh sách |
| Test khi thêm khuyến mãi | Test lại tất cả | Test mỗi khuyến mãi mới |
| Hai người cùng thêm khuyến mãi | Conflict trong cùng một hàm | Mỗi người một file, cùng lắm đụng nhau ở danh sách lắp ráp, gỡ trong một nốt nhạc |
| Số class | 1 | 1 khung + mỗi khuyến mãi 1 class |
Cái giá vẫn như bài trước: nhiều class hơn. Đổi lại, tuần nào cô con gái bà chủ nghĩ ra khuyến mãi mới, anh em cũng bình thản như người đã gài thẻ cả đời.
Bài trước anh em đã làm O mà không biết
Nhớ bài S không? Sau khi tách, muốn gửi báo cáo qua Slack thay vì email, anh em chỉ cần viết thêm SlackBillSender và đổi một dòng ở chỗ khởi tạo, không class cũ nào phải sửa. Đó chính là chữ O.
Nhớ câu Invoice ở bài tập bài S không? discount do marketing quản và đổi liên tục, nên tách ra. Thứ được tách ra đó chính là cái danh sách khuyến mãi trong bài này.
S và O hay đi cùng nhau: S chia code thành từng mảnh theo lý do thay đổi, O làm cho từng mảnh đó thay thế, cắm thêm được. Chia đúng chỗ thì cắm mới dễ.
5. O không chỉ là chuyện xóa if
Đọc tới đây, dễ tưởng chữ O chỉ có nghĩa là "thay chuỗi if bằng nhiều class". Đó là cách hay gặp nhất, nhưng chỉ là một cách. Chữ O rộng hơn: thêm hành vi mới mà không phải mổ code cũ. Kiểu thay đổi khác nhau thì cách mở cũng khác nhau.
| Kiểu thay đổi | Cách mở | Ở quán trà đá |
|---|---|---|
| Thêm một loại mới của thứ đã có | Cắm biến thể (mục 4) | Thêm khuyến mãi mới |
| Thêm việc bao quanh một việc cũ | Bọc thêm lớp ngoài | Ghi log mỗi lần tính tiền |
| Thêm việc xảy ra sau một việc cũ | Phát sự kiện | Tích điểm sau mỗi đơn đã trả |
| Thêm một khâu cho mọi lượt xử lý | Chuỗi xử lý (middleware) | Kiểm tra giờ mở cửa trước mọi đơn |
| Luật đổi liên tục, người không biết code muốn tự đổi | Đưa luật ra dữ liệu | Cô con gái tự thêm khuyến mãi trên trang admin |
Hai cách hay dùng nhất sau "cắm biến thể" là bọc thêm lớp ngoài và phát sự kiện. Xem nhanh từng cách.
Bọc thêm lớp ngoài
Cuối ngày bà chủ muốn đối soát, cần ghi log mỗi lần tính tiền. Không cần đụng vào CheckoutCalculator, chỉ cần bọc nó lại:
class LoggedCheckout
def initialize(calculator, logger)
@calculator = calculator
@logger = logger
end
def total(order)
result = @calculator.total(order)
@logger.info("Đơn #{order.subtotal}đ, khách trả #{result}đ")
result
end
end
require "logger"
calculator = LoggedCheckout.new(CheckoutCalculator.new(PROMOTIONS), Logger.new($stdout))
Người gọi vẫn gọi calculator.total(order) như cũ, không biết bên trong có thêm một lớp. Mai muốn thêm cache, thêm đo thời gian, thì bọc thêm lớp nữa. Giống ly trà mang đi: ly vẫn là ly cũ, chỉ bọc thêm cái túi nilon. Người ta gọi kiểu này là decorator. Class được bọc có nhiều method thì Ruby có sẵn SimpleDelegator để khỏi phải viết lại từng method.
Phát sự kiện
Tuần này bà chủ muốn tích điểm cho khách quen sau mỗi đơn. Tuần sau muốn trừ tồn kho đá. Tháng sau muốn in phiếu cho bếp. Nếu mỗi lần lại chen thêm một dòng vào code thanh toán, code thanh toán lại thành biển sơn.
Thay vào đó, code thanh toán chỉ thông báo "đơn này đã trả tiền", ai quan tâm thì tự đăng ký nghe:
class OrderEvents
def initialize
@listeners = []
end
def subscribe(listener)
@listeners << listener
end
def order_paid(order)
@listeners.each { |listener| listener.order_paid(order) }
end
end
class LoyaltyPoints
def order_paid(order)
# cộng điểm cho khách quen
end
end
events = OrderEvents.new
events.subscribe(LoyaltyPoints.new)
events.subscribe(IceStockTracker.new) # class giả định, tuần sau thêm, code thanh toán không đổi
# Trong code thanh toán, chỉ có đúng một dòng:
events.order_paid(order)
Thêm tính năng mới là thêm một class biết order_paid và đăng ký nó. Rails có sẵn ActiveSupport::Notifications dùng được cho việc này, dù nó sinh ra chủ yếu để đo đạc (instrumentation). Muốn một thư viện chuyên cho sự kiện nghiệp vụ thì có gem wisper.
Thành thật khai báo: sự kiện nhiều quá thì khó lần theo. Đọc code thanh toán không thấy ai đang nghe, có lỗi thì phải đi tìm từng người nghe. Dùng cho những việc "phụ" xảy ra sau việc chính, đừng dùng cho luồng chính.
Ba cách còn lại, nói nhanh
- Cắm biến thể: chính là mục 4 ở trên, mỗi khuyến mãi một class.
- Chuỗi xử lý: trong Rails, mỗi request đi qua một dây chuyền Rack middleware. Muốn thêm một khâu cho mọi request (ghi log, chặn bot) thì thêm một middleware, không sửa controller nào.
- Đưa luật ra dữ liệu: khuyến mãi kiểu "giảm X% từ giờ A đến giờ B" có thể lưu trong database. Cô con gái bà chủ tự thêm trên trang admin, không cần dev, không cần deploy. Cái giá: luật nào phức tạp quá thì dữ liệu không diễn tả nổi, lúc đó vẫn phải quay về code.
Kết luận phần này: if không có tội. Có tội là chỗ cứ thêm tính năng là phải mổ code cũ. Chữa thì có nhiều cách, chọn cách theo kiểu thay đổi đang gặp.
6. Đừng mở quá tay
Học xong O, anh em sẽ có cảm giác muốn biến mọi if thành plugin. Kìm lại.
- Cái gì chưa từng thay đổi thì đừng mở.
if user.admin?hai nhánh, mấy năm nay vẫn hai nhánh, thì để nguyên. Biến nó thànhRolePolicyStrategyRegistrylà tự làm khổ mình và đồng nghiệp. - Quy tắc ba lần (rule of three, Martin Fowler nhắc trong sách Refactoring). Lần đầu thêm một loại: viết
ifcũng được. Lần thứ hai: bắt đầu để ý. Lần thứ ba: đây là chỗ hay thay đổi thật, mở nó ra. - Mở sai chỗ còn tệ hơn không mở. Sandi Metz có câu nổi tiếng (bài blog The Wrong Abstraction, 2016): duplication is far cheaper than the wrong abstraction, code lặp còn rẻ hơn nhiều so với một abstraction sai. Đoán sai chỗ mở, đến lúc yêu cầu thật đến thì phải phá cái khung ra làm lại, đau hơn cả sơn lại biển.
O là để thêm tính năng yên tâm hơn, không phải để code trông kiến trúc hơn. Khung plugin cho một thứ chỉ có một loại thì giống lắp ổ cắm 10 lỗ cho một cái quạt.
Nói riêng với anh em Rails
Rails dùng chữ O ở khắp nơi, chỉ là anh em không để ý:
- Active Storage: ảnh blog này nằm trên GCS. Đổi từ lưu trên ổ đĩa (Disk) sang GCS, code của blog không phải sửa dòng nào: Active Storage có sẵn các service Disk, S3, GCS cùng chung một phích cắm, khai báo trong
config/storage.ymllà đổi. - Active Job: đổi từ Sidekiq sang Solid Queue thì đổi adapter trong config, code job không đổi (miễn là job viết theo API của Active Job, không dùng tính năng riêng của Sidekiq).
- Mailer delivery method: SMTP, chế độ test, hay adapter của dịch vụ gửi mail... cũng là cắm adapter.
Mấy ông viết Rails đã đóng cái khung, mở cái khe. Anh em chỉ việc gài thẻ.
7. 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 nên mở, chỗ nào chưa cần. 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 nên áp dụng O (mở chỗ đó ra cho mở rộng), trường hợp nào chưa cần? Giải thích bằng câu hỏi "chỗ này có hay thêm biến thể mới không?".
- Class
ShippingFeecócase carriervới 3 nhánh:"ghn","ghtk","viettel_post". Sếp vừa báo tháng sau ký thêm J&T và Ahamove. - Quán chỉ bán đồ uống, chịu đúng một mức thuế. Class
TaxCalculatortính VAT bằng hằng sốVAT_PERCENT = 10, lâu lâu mới phải đổi con số này. - Ba hàm
export_data(format),content_type(format),file_extension(format), hàm nào cũng cócase formatvới"csv","json","xlsx". Khách vừa xin thêm định dạng PDF. - Class
AppLoggernhận vào một danh sách output, mỗi output có methodwrite(message). Hiện cóConsoleOutputvàFileOutput, giờ muốn log thêm ra Slack. - View có
if current_user.admin?để hiện nút xóa bài. Hệ thống chỉ có admin và người thường, ba năm nay chưa thêm vai trò nào.
Túm cái váy lại
| Ghi nhớ | |
|---|---|
| Định nghĩa | Mở cho mở rộng, đóng cho sửa đổi |
| Dịch cabin | Thêm biến thể mới bằng cách viết thêm, không sửa lại |
| Câu hỏi thần chú | "Thêm một loại mới thì phải sửa mấy file cũ?" |
| Dấu hiệu | case/if theo loại ngày càng dài, lặp ở nhiều nơi |
| Cách làm | Tìm chỗ hay đổi, định nghĩa "phích cắm" chung, mỗi biến thể một class, cái khung chỉ làm việc với phích cắm |
| Nhiều cách mở | Cắm biến thể, bọc lớp ngoài, phát sự kiện, chuỗi xử lý, đưa luật ra dữ liệu. Chọn theo kiểu thay đổi |
| Hiểu nhầm | Cấm sửa code cũ. Sai, sửa bug và đổi luật thì cứ sửa |
| Đừng | Mở mọi thứ "phòng khi sau này". Chỉ mở chỗ đã thay đổi thật |
Sau bài này, anh em đã sở hữu:
- Một câu hỏi để soi code: thêm loại mới thì phải sửa mấy file cũ?
- Một quy trình 4 bước: tìm chỗ hay đổi, định nghĩa phích cắm, tách từng biến thể, để cái khung chỉ biết phích cắm.
- Và hy vọng là một chuỗi
elsifdài hơn cả menu quán lẩu nào đó sắp được nghỉ hưu.
Chúc anh em thêm tính năng không hỏng tính năng, deploy không giật mình, và trà luôn đủ đá. Hẹn anh em ở chữ L.
À mà, nếu anh em đang nghĩ "dự án em toàn case mà vẫn chạy ngon", thì mình tin. Biển sơn cũng treo được hai chục năm. Chỉ là đừng ai hỏi số điện thoại giữ xe.