Deconstruct
Chuyện về một chiếc source code đã cũ.
Vừa rồi, mình có một buổi tech talk ở công ty, với chủ đề không phải về React, React Native, hay thậm chí JavaScript. Một chủ đề mới nhưng không cũ, về những điều mà nếu mình có một cỗ máy thời gian, mình sẽ quay trở lại để nói với chính mình của quá khứ, những điều mà mình muốn tin rằng, có thể tạo nên một sự khác biệt.
I. The DRY Codebase
Đó là một dự án mình cùng team thực hiện nhiều năm về trước. Lúc này, có hai module nằm ở hai file khác nhau. Và nhóm đang phát triển một tính năng mới ở một trong hai file. Mọi người phát hiện rằng tính năng này thật ra rất giống với một cái đã có sẵn ở file kia. Vậy nên tất cả quyết định sao chép đoạn code này bởi vì chúng gần như giống nhau.
Mình nhận nhiệm vụ review qua đoạn code, sau khi đã đọc hết những sách về “best pratices”. Well Groomed Code, Pragmatic Programmer, Clean Coder… Và mình biết rằng không được copy-paste code vì việc này sẽ hình thành một ”maintenance burden” và sẽ trở nên rất khó để tiếp tục phát triển. Mình cũng học được khái niệm DRY, viết tắt cho Don’t Repeat Yourself. Một quan niệm rất phổ biến từ khi vừa học lập trình: Không được để lặp code.
Vậy là mình cùng team đi đến quyết định tách đoạn mã ấy ra một module riêng để hai file này cùng phụ thuộc vào. Một Abstraction (lớp trừu tượng) được hình thành.
Định nghĩa Abstraction ở đây, không quan trọng việc sử dụng ngôn ngữ nào. Nó có thể là một function, một class, hoặc một module, một package, miễn sao nó có thể reusable (tái sử dụng) ở bất kỳ đâu trong codebase.
Mọi người đều cảm thấy hài lòng, và đã cùng nhau có một cuộc sống hạnh phúc…
Nhiều tháng sau, dự án nhận thêm một phase mới khi team đã không đụng vào source code này một khoảng thời gian. Trong đó có một feature cần một thứ gần tương tự như chiếc abstraction ngày xưa chúng tôi cùng theo đuổi. Lấy ví dụ như, abstraction ban đầu là một function chạy asynchronous (bất đồng bộ), và chúng ta cần một function có cấu trúc gần như tương tự, ngoại trừ việc nó có thể chạy synchronous.
Vậy là dù ta không thể trực tiếp sử dụng đoạn code đó nữa, nhưng cũng sẽ không hay nếu mình copy-paste nó vì nó chẳng khác cái cũ là bao. Team quyết định hợp nhất hai phần và chỉnh sửa abstraction để nó có thể giải quyết cho tất cả trường hợp.
Việc chỉnh sửa này làm cho abstraction có một chút kỳ cục, nhưng ít ra ta cũng không phải lặp lại đoạn code ấy?
Sau một thời gian ngắn chạy production, team phát hiện một bug trong feature mới này. Lỗi xuất phát từ việc ban đầu tất cả nghĩ rằng cả hai tính năng có vẻ giống nhau, nhưng thật ra chúng có một chút xíu khác biệt. Dĩ nhiên là mình có thể fix bug này, bằng cách thêm vào abstraction một case đặc biệt, kiểu như một câu if-else chẳng hạn. Nếu nó là case mới này, mình sẽ làm khác một chút, và mọi thứ trở lại hoạt động.
Dễ thấy đây cũng là chuyện bình thường của một abstraction trong quá trình phát triển.
Tiếp tục làm việc với feature này, nhóm phát hiện một lỗi mới trong một module cũ. Sự cố này xảy ra do hai trường hợp cũ mà chúng tôi nghĩ là giống nhau này thực ra cũng có chút khác biệt mà chỉ là team không nhận ra vào lúc đấy. Vậy nên chúng tôi thêm những đoạn patch khác cho các module vào abstraction.
Lúc này, abstraction bắt đầu trở nên hơi hơi kỳ lạ và đáng sợ. Vậy nên hãy làm cho nó trở nên thân thiện hơn. Sao ta không đem hết những case đặc biệt này ra khỏi abstraction?
Đúng vậy, ta kéo tất cả những patch case về lại nơi chúng thuộc về. Abstraction trở lại xinh đẹp và không còn dựa theo một trường hợp cụ thể nào, dù chẳng còn ai thực sự hiểu được nó đại diện cho cái gì nữa. Những module khi sử dụng abstraction đều phải được tham số hoá.
Sự tiến triển này diễn ra thực sự hoàn toàn bình thường, mỗi bước mỗi bước đều có vẻ make sense với những người viết và review code, nên chúng tôi cứ để cho nó như vậy.
Rồi thời gian trôi qua, một số thành viên rời team, một số mới join team. Trải qua nhiều bản update & sửa lỗi. Ai đó sửa một lỗi nhỏ ở đây. Không cần biết cái này để làm gì nhưng tôi cần sửa nó một xíu, thêm tính năng mới kia, cải thiện vài chỉ số hiệu năng... Và ta kết thúc bằng một thứ giống như này:
Nhắc lại, mỗi giai đoạn phát triển độc lập có vẻ đều có lý cả. Nhưng chỉ cần quên đi mục đích ban đầu của toàn bộ quá trình, ta sẽ không nhận ra có những cyclical dependency đang hình thành, hay mấy dòng code kỳ lạ ở đâu đó, vì ta không còn nhìn thấy được bức tranh tổng thể nữa. Và dĩ nhiên, trong thực tế, đây cũng chính là nơi mà câu chuyện dừng lại. Không còn ai muốn đụng vô source code đó và nó bị vứt ở một xó, cho tới khi ai đó viết lại hết, rồi nhiều khi còn được tăng lương nữa, mình nghĩ vậy (haha).
Nhưng nếu ta có một cỗ máy thời gian, sau khi tìm hiểu về vật lý lượng tử, các thứ nhiệt động lực học và đảo ngược entropy của vật chất 🤦 Mình quay lại quá khứ để sửa chữa những lỗi lầm xưa. Và mình muốn chọn thời điểm mà abstraction vẫn còn xinh tươi.
Chúng tôi có trường hợp thứ ba này và không muốn phải duplicate đoạn code dù nó hơi khác một chút. Vậy nên chúng tôi đang chuẩn bị chỉnh sửa abstraction để đáp ứng với trường hợp mới. Nhưng nếu mình của ngày hôm nay có mặt ở đấy, mình sẽ nói với chính bản thân, hãy inline abstraction này!
Nghĩa inline ở đây của mình là hãy copy-paste phần code về nơi cần dùng nó. Điều này gây ra một số sự trùng lặp nhưng sẽ tiêu diệt được con quái vật sắp được hình thành. Và dĩ nhiên sự duplicate là không hoàn hảo, nhưng wrong abstraction lại càng không tốt. Ta cần cân bằng được cả hai vấn đề.
Cách mà hướng đi này giúp ta là bây giờ nếu tìm thấy bug và mình nhận ra chỗ này phải khác một chút, ta chỉ việc thay đổi nó mà không sợ làm ảnh hưởng đến những phần khác.
Tên gọi cho cách làm này là WET solution, viết tắt cho "write every time", "write everything twice", hay "waste everyone's time", như một cách chơi chữ với DRY solution.
Tất nhiên mình không khuyến khích ta luôn copy paste mọi thứ. Trong tương lai, có thể ta nhận ra tất cả những mảnh ghép này hoàn toàn vừa khít cho một abstraction, hoặc ta ghép những mảnh khác lại thành một thứ mà ban đầu ta không nghĩ đó là một abstraction tốt. Có khi là một cái gì đó hoàn toàn khác biệt. Và tất cả những điều này mình đã chỉ có thể nhận ra trong quá trình thực tế, còn như khi mình được học, copy pasting là một phương pháp thực sự tiêu cực.
Lập trình viên học những best practices từ thế hệ trước và cố gắng làm theo chúng. Bởi vì có những vấn đề cụ thể và những giải pháp cụ thể được đưa ra bằng kinh nghiệm, nên các thế hệ tiếp theo cố gắng truyền đạt lại. Nhưng thật khó để có thể giải thích toàn bộ câu chuyện, vậy là chúng dần bị làm phẳng và hầu như ta chỉ còn tập trung vào những ý tưởng chung chung. Những mặt hạn chế, những lý do đưa đến giải pháp đấy, những trường hợp để sử dụng, hay có thể áp dụng đến đâu,... Tất cả dần mai một theo từng thế hệ, và ta thì vẫn cố gắng khuôn đúc vấn đề riêng của mình theo những best practices và anti-patterns này.
Một cách để phá vỡ lối mòn này là khi ta truyền đạt lại cho thế hệ sau bài học nào đó, ta không nên phiến diện khẳng định đây là best practices hay đây là anti-patterns mà cần giải thích những điều gì cần phải đánh đổi, cả mặt lợi lẫn mặt hại.
II. Benefits of Abstraction
Dĩ nhiên là abstraction có ích. Cỗ máy tính của chúng ta thực chất cấu thành từ tầng tầng lớp lớp các abstraction lồng vào nhau. Những khái niệm cơ bản nhất của lập trình, function, procedure, cũng từ nó mà ra.
- Focusing on intent
- Code reuse
- Avoiding some bugs
Focusing on intent
Abstraction giúp ta tập trung vào một mục tiêu cụ thể. Lấy ví dụ một ứng dụng cần tính năng gửi email, và bạn không muốn phải biết một trăm năm mươi ba bước để gửi một cái email, mình cũng không biết mấy email đó được gửi thế nào. Nó vẫn còn là một bí ẩn tại sao chúng có thể thậm chí… tới được người nhận. Nhưng mình có thể dùng một thư viện nào đó để gọi hàm sendEmail và gần như chẳng cần phải quan tâm gì thêm.
Code reuse
Và một lợi ích phổ biến nữa của abstraction, tái sử dụng những đoạn code của bạn hay người khác mà không cần phải nhớ cách mà chúng hoạt động.
Avoiding some bugs
Những lớp abstraction này còn giúp ta tránh được một số bug. Ví dụ ta copy paste một đoạn code, sau đó phát hiện bug trong một phiên bản và fix nó, nhưng phiên bản còn lại vẫn bị lỗi và nhiều khi không được chú ý. Đây cũng là một luận điểm tại sao nên hạn chế để lặp code.
III. Costs of Abstraction
Nhưng khi nói đến lợi ích ta cũng nên nói về những hạn chế của abstraction:
- Accidental coupling
- Extra indirection
- Inertia
Accidental coupling
Khi ta có hai module sử dụng chung abstraction, rồi sau đó phát hiện một module xảy ra lỗi, ta sẽ phải sửa lớp abstraction bởi đây mới là nơi của đoạn code. Nhưng bây giờ ta sẽ phải chịu trách nhiệm cân nhắc tất cả các module khác phụ thuộc vào lớp abstraction đấy để không làm xuất hiện những lỗi khác. Đây là một bất lợi thường thấy khi dùng abstraction, và chúng ta thường chịu sống chung với điều này, nhưng nó vẫn là một điểm bất lợi thực sự.
Extra indirection
Một hạn chế khác thậm chí còn tai hại hơn: những thứ dư thừa ta phải bận tâm mà abstraction có thể gây ra. Nghe có vẻ nghịch lý với một lợi ích vừa nãy, nhưng lời hứa hẹn “focusing on intent” để giúp ta không phải quan tâm đến những thứ không cần thiết liệu có hoàn toàn đúng? Mình chắc rằng hầu hết mọi người đều từng có một lỗi xuất hiện trong một feature, sau đó tìm ra nó nằm ở function khác, mà thực ra không phải, nó xuất hiện từ một module khác, nhưng rồi nhận ra nó phụ thuộc vào một lớp khác nữa.
Bộ não con người có những ngăn xếp (Stack) để xử lý một số lượng cực kỳ hạn chế các công việc, và nó sẽ tràn (StackOverflow) nếu không có được sự quản lý tốt. Chắc đó cũng là lý do cho cái tên của trang web mà các lập trình viên đều biết là trang nào đó.
Ta cố gắng để hạn chế những đoạn mã lặp mà ta nghĩ rằng xấu xí bằng cơ số tầng tầng lớp lớp cho đến khi ta không còn hiểu được chuyện gì đang xảy ra nữa.
Inertia
Ngoài vấn đề kỹ thuật, áp dụng sai abstraction còn gây ra ảnh hưởng xấu tới văn hoá làm việc của nhóm, sự chậm chạp và lười biếng.
Ta bắt đầu một abstraction có vẻ đầy hứa hẹn, rồi theo thời gian nó càng trở nên phức tạp, nhưng không một ai có thời gian để gỡ rối hay viết lại nó, đặc biệt nếu như bạn mới tham gia vào dự án. Việc copy-paste có thể đơn giản, nhưng nếu bạn không quen thuộc với project từ đầu nó có thể trở nên cực kỳ khó khăn.
Một điều nữa là chẳng ai trong chúng ta muốn trở thành người chỉ đưa ra những “worse practices”. Ai lại muốn nói, “hãy copy paste cái này?”, nhắm xem bạn có pass được probation ko? Vậy nên bạn học cách chấp nhận hiện tại rồi cứ tiếp tục và hy vọng đến một ngày không phải chịu trách nhiệm tới đống code ấy nữa.
Và vấn đề là thậm chí cả nhóm đã đồng ý inline abstraction ấy, có khi đã quá muộn. Có thể bạn biết được mục đích và cách dùng của abstraction, và làm sao để test nó. Để khi tái cấu trúc hay phân tách chúng, bạn hiểu được cách xác thực những thay đổi mà không làm hư hỏng các phần khác. Nhưng có khi abstraction lúc này đã được phụ trách bởi các team khác, hay trải qua một thời gian không ai maintain, đến khi không còn ai biết cách test chỗ code này nữa.
IV. Abstract Responsibly
Tất nhiên, sẽ thật phiến diện nếu nói rằng ta không nên tạo abstraction. Vấn đề ở đây là ta sẽ mắc những sai lầm, nhưng làm sao ta có thể giảm thiểu những hậu quả mà chúng gây ra:
- Test concrete code
- Delay adding layers
- Be ready to inline it
Test concrete code
Một bài học mình học được từ các dự án mã nguồn mở là hãy test những đoạn code có giá trị cụ thể. Ví như ta có một abstraction, và ta lượm được chút thời gian để viết một số đoạn test. Vậy là ta hì hục viết test cho abstraction nơi chứa những đoạn code phức tạp.
Và theo mình thực ra đây là một ý tưởng tồi, bởi sau đó nếu bạn nhận ra đó là abstraction xấu và muốn inline chúng, thử nghĩ điều gì sẽ xảy đến với những test case này? Tất cả chúng đều fail. Và bạn sẽ phải: 1. revert lại vì bạn không muốn phải viết lại tất cả đoạn test; 2. chịu giảm code coverage, việc mà chắc chẳng ai muốn cả.
Nhưng nếu ta lại có được cỗ máy thời gian để quay lại và viết test cho những chỗ thực sự cần quan tâm đến: Những test case cho chính feature phải hoàn thành và không liên quan đến abstraction. Việc này sẽ giúp ta tái cấu trúc abstraction dễ dàng, những test case này sẽ chỉ ra nếu đoạn code được refactor có hoạt động đúng.
Delay adding layers
Một yếu tố nữa, có thể bạn nhận được một pull request với vài đoạn code bị lặp và chỉ muốn bay vô xử đẹp hết? Hãy bình tĩnh!
Nghĩ như thế này, bạn bước vào thư viện và nhìn thấy cô gái bạn crush đọc quyển sách bạn yêu thích, cũng không đồng nghĩa với việc bạn và cô ấy có thể có nhiều điểm chung, và bạn google tìm một trường mẫu giáo cho tương lai… Chỉ vì cấu trúc của hai đoạn mã trông giống nhau, nhiều khi do bạn chưa thực sự hiểu hết vấn đề ấy. Hãy dành thêm thời gian để chứng minh được rằng chúng là cùng một vấn đề chứ không chỉ vô tình giống nhau.
Be ready to inline it
Và cuối cùng, cũng rất quan trọng, rằng nếu bạn mắc sai lầm, cái mà team cần học được là sự chấp nhận, rằng abstraction này đang gây ra tác động xấu và phải bị loại bỏ. Chúng ta không nên chỉ thêm abstraction, mà cũng phải xoá chúng khi cần thiết, nếu muốn xây dựng một quy trình phát triển lành mạnh. Điều đó có nghĩa là bạn hoàn toàn có thể để lại một bình luận như thế này và nói, ây, cái này đang vượt quá tầm kiểm soát:
Hãy dành một chút thời gian để copy-paste và sau đó ta sẽ tìm ra những gì cần làm tiếp theo.
Plus
Ngoài ra ta còn có một giải pháp về kỹ thuật cho vấn đề này. Nếu cấu trúc ứng dụng của bạn như thế này, cũng sẽ rất khó khăn để inline dù ta có muốn làm. Ví dụ như bạn copy và paste chỗ này, dẫn đến một số dữ liệu hay state dùng chung cũng bị duplicate, hoặc ta phải gỡ rối cho hết đống dependency rồi nối chúng lại với nhau. Và đây là một nhiệm vụ bất khả thi trong nhiều trường hợp.
Cũng từ đấy theo mình, một điểm rất thông minh của React là giúp ta viết theo cấu trúc hình cây. Ta có một input component nằm trong một form component, được bao trong một screen component và tất cả được gộp lại trong app. Tất cả những luồng dữ liệu chỉ theo một hướng đi duy nhất, ta không phải lo lắng nếu mọi thứ phụ thuộc chéo lẫn nhau trong một vòng lặp vô tận…
Còn bởi vì nhờ các components và một số giải pháp như state management, ta có thể dễ dàng refactor các abstraction nếu muốn trước khi quá trễ. Đây là thứ ta có thể cân nhắc về cả hai mặt xã hội và công nghệ.
V. Deconstruct
verb ˌdiː.kənˈstrʌkt
to break something down into its separate parts in order to understand its meaning, especially when this is different from how it was previously understood:
We should deconstruct the Western myth of human rights.
(theo Cambridge Dictionary)
“Don’t Repeat Yourself”, cũng như là một trong số những nguyên tắc mà có lẽ cũng là những ý tưởng hay mà ta luôn chịu ảnh hưởng hằng ngày. Chuyện này cũng hoàn toàn bình thường, cái quan trọng là khi ta muốn giải thích tại sao chúng là ý tưởng tốt, ta cũng nên quan tâm đến mặt hạn chế và điều gì dẫn ta đến giải pháp ấy. Và có thể sẽ rất khó để nắm bắt được tất cả chúng. Nhưng ta có thể bắt đầu từ những thứ nhỏ hơn, quen thuộc hơn.
Hãy chọn một vài best practices và anti-patterns mà bạn đã luôn tin tưởng là đúng, chúng có thể đến từ trải nghiệm của bạn hoặc từ bất kỳ đâu, và thực sự cố gắng phân tích, giải mã để tìm ra lý do đã khiến bạn tin, những điều đã bị che lấp mất, và những thứ ta đã phải đánh đổi.
Đó cũng là tất cả nội dung mà mình đã chuẩn bị. Bạn cũng có thể tìm hiểu thêm một số bài talks mà mình đã học và lấy ý tưởng:
So All the Little Things bởi Sandi Metz, một bài talk rất hay, đi sâu vào chi tiết và hiện thực các quan điểm trên.
Minimal API Surface Area bởi Sebastian Markbage. Mình học được rất nhiều từ anh này, một thành viên của React Core Team. “No Abstraction” > “Wrong Abstraction”.
On the Spectrum of Abstractions bởi Cheng Lou, tìm hiểu nhiều khía cạnh hơn của Abstraction, và dùng Abstract level để giải thích nhiều vấn đề nhạy cảm: React vs Template, CSS-in-JS vs Plain CSS, mutable vs immutable…