Dependency Injection trong Ruby - Không đáng sợ như bạn nghĩ
Đây là một bài viết khá lâu rồi (từ năm 2009) của Sandi Metz, người viết cuốn sách Practical Object-Oriented Design in Ruby. Tuy nhiên, những tư tưởng trong bài viết này rất hay và tôi muốn chia sẻ lại nó cho các bạn. (Nếu bạn đọc được cuốn sách trên thì lại càng tuyệt vời hơn nữa.) Nếu các bạn ...
Đây là một bài viết khá lâu rồi (từ năm 2009) của Sandi Metz, người viết cuốn sách Practical Object-Oriented Design in Ruby. Tuy nhiên, những tư tưởng trong bài viết này rất hay và tôi muốn chia sẻ lại nó cho các bạn. (Nếu bạn đọc được cuốn sách trên thì lại càng tuyệt vời hơn nữa.)
Nếu các bạn hay viết code theo phong cách này:
class SomeService def perform a = ObjectA.new.method_of_a b = ObjectB.class_method_of_b .... end end
thì có thể bạn sẽ muốn thay đổi sau khi đọc bài viết này.
Không có gì hài lòng hơn đối với lập trình viên chúng ta khi thấy code đẹp. Tuy nhiên, không có thứ gì khiến ta khó chịu hơn là việc những dòng code đẹp mà ta tạo ra lại bị phá hủy bởi những yêu cầu mới hoặc yêu cầu chỉnh sửa khác.
Gần đây, tôi bắt đầu chú ý hơn với việc các nguyên tắc SOLID của OOP và sự tương tác với TDD. Tôi phát hiện ra rằng TDD mặc dù là bước đi đầu tiên để hướng tới việc thiết kế code tốt hơn, nò vẫn là chưa đủ để thực hiện điều này. Nếu muốn code của mình có thể sống sót trước những yêu cầu thay đổi và có thể hữu dụng sau này, tôi cần các nguyên tắc SOLID để làm cứng nó.
Giữa TDD và OOP có một sự cân xứng thú vị khi mà chỉ với TDD là không đủ để bạn thiết kế được code một cách hoàn hảo, nhưng nếu bạn làm theo những nguyên lý của OOP khi viết code và kèm theo test, bạn sẽ thấy là TDD không còn khó nữa và test của bạn cũng sẽ tốt dần lên.
Mặc dù vậy nếu như ứng dụng của bạn đáp ứng được những điều kiện này:
- ứng dụng cực kỳ đơn giản
- có spec hoàn chỉnh 100%
- và spec sẽ không bao giờ thay đổi
thì bạn không cần phải quan tâm lắm đến SOLID hay TDD, cứ viết thế nào bạn muốn (có thể áp dụng nếu muốn code viết dễ đọc hơn hoặc chỉ là để thử nghiệm).
Tuy nhiên, thực tế không bao giờ đơn giản như vậy. Trong cuốn sách Design Principles and Design Patterns, Uncle Bob mô tả một phần mềm tốt là trong sạch (clean), thanh lịch (elegant) và hấp dẫn (compelling) với một vẻ đẹp đơn giản mà làm cho các nhà thiết kế và phát triển ngứa ngáy muốn xem nó làm việc thế nào.
Mặc dù vậy, ông ấy cũng nói là:
Điều gì xấu có thể xảy ra với phần mềm ? Đó là phần mềm bắt đầu bị mục nát. Lúc bắt đầu thì nó cũng không tệ lắm. Sau đó thì một vài chỗ thay đổi bắt đầu mọc ra làm xấu một vài đoạn code, tuy nhiên vẫn không ảnh hưởng tới thiết kế tổng thể. Tuy nhiên theo thời gian, khi mà sự mục nát vẫn tiếp tục, nó làm thay đổi toàn bộ cấu trúc thiết kế ban đầu của ứng dụng. Chương trình giờ đây trở thành một đống code mà những lập trình viên sẽ thấy nó cực kì khó để duy trì
Nếu bạn có test và nó thực sự tốt thì bạn có thể đảm bảo sự tin cậy của bất kỳ phần mềm nào, cho dù nó có thể khiến bạn khóc khi phải vào sửa code.
Nếu bạn vừa có test và thiết kế tốt, phần mềm của bạn sẽ vừa đáng tin cậy và những sự thay đổi là niềm vui. Bạn sẽ muốn thêm những tính năng mới vào code của mình và có thể đảm đương được việc tái cấu trúc code hiện có và có thể bắt code thực hiện được nhiều việc hơn nữa.
Dependency Injection
Ta sẽ bắt đầu với một đoạn code ví dụ sau:
class Job def run @retriever = FileRetriever.new strm = @retriever.get_file("theirs") @cleaner = FileCleaner.new cleaned = @cleaner.clean(strm) local = "mine" File.open(local, "w") {|f| f.write(cleaned) } local end end
Class Job có nhiệm vụ là lấy về file từ ngoài, làm sạch file này và sau đó là lưu nó lại trên đĩa. Nó sử dụng 2 class có sẵn là FileRetriever và FileCleaner.
Class này cực kỳ đơn giản, Nếu bạn viết test trước, bạn sẽ có một đoạn test tương tự như sau:
it "should retrieve 'theirs' and store it locally" do @job = Job.new local_fn = @job.run local_fn.should have_the_correct_contents end
Đoạn test trên test những gì ? Job hay Job và FileRetriever và FileCleaner ? Tất nhiên là cả 3. Đoạn test trên sẽ kiểm tra toàn bộ tập đối tượng: Job và các đối tượng mà nó phụ thuộc. Đoạn test trên dễ vỡ do nó phụ thuộc vào các object khác ngoài object cần test và nó chạy lâu vì nó phải chạy cả những đoạn code mà nó không cần quan tâm (code từ FileRetriever và FileCleaner)
Ta có thể dùng Mock/Stub để giải quyết vấn đề này. Stub FileRetriever.get_file và FileCleaner.clean có thể giải quyết 2 vấn đề nêu ra ở trên. Tuy nhiên kể cả khi stub những method kia thì đoạn code trên vẫn có vấn đề. Stub có thể cải thiện test nhưng không thế sửa được những lỗ hổng trong những đoạn code trên.
Với phong cách code trong Job, sẽ rất khó có thể tái cấu trúc và sử dụng lại nó trong tương lai do nó chữa những object phụ thuộc khác trong đấy. Giờ ta sẽ di chuyển các đoạn code ra chỗ khác:
class Job attr_reader :retriever, :cleaner, :remote, :local def initialize(retriever=FileRetriever.new, cleaner=FileCleaner.new, remote="theirs", local="mine") @retriever = retriever @cleaner = cleaner @remote = remote @local = local end def run strm = retriever.get_file(remote) cleaned = cleaner.clean(strm) File.open(local, ‘w’) {|f| f.write(cleaned) } local end end
Ở đây, tôi tiêm những đối tượng phụ thuộc và trong Job. Giờ đây, Job đã bớt cụ thể hơn và có khả năng tái sử dụng tốt hơn. Ở trong test, tôi có thể tạo các object mock và gán chúng vào Job. Tôi không cần phải stub method của những class có sẵn khác nữa.
Đoạn code trên còn có thể cải thiện hơn nữa. Thay vì việc phải viết hàm khởi tạo với các tham số dài dòng và theo thứ tự, ta có thể thay thế như sau:
class Job attr_reader :retriever, :cleaner, :remote, :local def initialize(opts) @retriever = opts[:retriever] ||= FileRetriever.new @cleaner = opts[:cleaner] ||= FileCleaner.new @remote = opts[:remote] ||= "theirs" @local = opts[:local] ||= "mine" end def run File.open(local, ‘w’) {|f| f.write(cleaner.clean(retriever.get_file(remote)))} local end end
Đoạn code này cho cảm giác khác hơn hẳn so với ban đầu. Một sự thay đổi nhỏ trong phong cách code đã làm cho Job có khả năng mở rộng dễ hơn, dễ tái sử dụng và cũng dễ test hơn. Bạn có thể viết code theo phong cách này mà không mất thêm gì cả mà nó còn có thể giúp cho bạn và người khác sau này.
Thêm một ví dụ khác đó là đoạn code để tạo xml cho đối tượng ActiveRecord:
module ActiveRecord #:nodoc: module Serialization def to_xml(options = {}, &block) serializer = XmlSerializer.new(self, options) block_given? ? serializer.to_s(&block) : serializer.to_s end #… end end
Nếu tôi muốn sử dụng to_xml với một Serializer khác thì phải làm thế nào ? Sẽ rất đơn giản nếu như XmlSerializer được tiêm vào to_xml. Thay vào đó, ta sẽ phải ghi đè toàn bộ method chỉ để thay thế một class serializer khác.
Đoạn code trong to_xml thực hiện đúng những gì nó cần làm. Người viết đoạn code này không hẳn là kém mà họ chỉ không bao giờ tưởng tượng rằng tôi muốn tái sử dụng nó theo cách này.
Hãy để tôi nhắc lại: Họ không bao giờ nghĩ rằng tôi sử dụng lại code của họ
Câu truyện ở đây là gì ? Điều tương tự có thể xảy ra với bất cứ đoạn code nào mà bạn viết. Tương lai là không xác định và cách duy nhất là ta cần phải hiểu được sự bất định này. Bạn không biết điều gì có thể xảy ra, do đó hãy luôn tiêm những sự phụ thuộc của bạn vào trong code.
TDD có thể giúp bạn thực hiện những thay đổi không lường trước được trong code với sự tự tin là code của bạn sẽ hoạt động sau đó. Còn các nguyên tắc OOP giúp bạn viết code sạch, đẹp và hấp dẫn hơn.