Introduce To Clean Architecture

- 26 mins

Trong bài viết này chúng ta sẻ cùng thảo luận về một loạt các patterns, principles, paradigms nhằm tạo ra một kiến trúc tốt hơn nhằm thay thế cho các approaches không thực sự còn hợp thời nữa, đặc biệt là các concepts trong Clean Architecture. Bài viết này mình sẻ thảo luận về một số mục chính sau:

I. Software Architecture là gì? Tại sao chúng ta cần tìm hiểu chúng?

II. Những gì cấu nên Software Architecture

  1. Các promgramming paradigms
  2. Các design principles

III. Một architecture tốt sẻ trông như thế nào?

IV. Clean Architecture

Trước khi bắt đầu thảo luận về các mục chính, mình xin chia sẻ một chút về quá trình tiếp xúc với các mẫu kiến trúc của mình.

Điểm xuất phát của mình dường như nó không đi theo con đường “chính đạo” như các bạn các anh chị đi trước. Đa số các tiền bối đi trước thường xuất phát với Java, C#, Scala,…. Việc xuất phát với các ngôn ngữ framework dựa trên các ngôn ngữ đó phần nào mang lại lợi ích nhất đích định dựa vào sự trưởng thành của ecosystem ứng với ngôn ngữ đó, dựa vào cộng đồng lớn cùng với các kiến thức cực kì cần thiết trong OOP cũng như Design parterns, do đó mang lại cái nhìn sớm hơn, đúng đắn và đầy đủ hơn về architecture cũng như system design.

Mình thì ngược lại, mình bắt đầu sự nghiệp coder với một dự án startup với công nghệ chính là Node.js, code không theo một paradigm cụ thể nào cả, OOP cũng không hẳn là OOP, Functional cũng không hẳn là Functional, Functional Reactive cũng không ra Functional Reactive, qua một thời gian thì mọi thứ gần như out of control và không có cơ hội cứu chữa trừ khi đập đi làm lại. Và thực tế là dự án đó đã đập đi làm lại rất nhiều lần tuy nhiên không có một kiến trúc vững trãi thì sau một thời gian lại đâu vào đấy, out of control.

Sau một thời gian dài code quán tính thì không những không xây dựng được tư duy kiến trúc, tư duy tổ chức và làm mình khó nhồi nhét tư duy design sau này. Thật may lúc đó mình có cơ hội cho công việc mới dù kết thúc sớm vì một số lí do nhưng nhờ chính cơ hội đó đã mở ra cho mình một số kiến thức hay ho, mang lại lợi ích to lớn sau này.

Yêu cầu công việc của mình thời điểm đó là tìm hiểu và áp dụng Clean Architecture vào dự án với Golang với một tài liệu duy nhất là bài viết này The Clean Architecture. Một task với description cực kì ngắn gọn nhưng không hề đơn giản tí nào cho một con gà tại thời điểm đó. Với tư duy tổ chức kém, hỏng kiến thức về OOP lẫn design partern thì quả thực Clean Architecture là một thứ gì đó quá xa vời.

Clean Architecture yêu cầu phãi xâu chuổi tất cả kiến thức tổ chức code, từ sự hiểu biết và kinh nghiệ về các Programming paradigms, các Design Parterns ở OOP cũng như ở các paradigms khác, tiếp đến mới là các Design Principles như SOLID Principles, Component Principles rồi mới đến các nguyên tắc, các mẫu kiến trúc cho phần mềm. Thực tế thì không chỉ là Clean Architecture mà tất cả các kiến trúc khác đều dựa trên những nguyên tắc chung đó mà xây dựng nên.

Sau một thời gian với nhiều may mắn được làm việc với các dự án từ Golang đến Typescript mình tóm tắt lại quá trình mình tiếp cận với Clean Architecture nói riêng và các quy tắc lập trình, tổ chức code nói chung cũng và cũng là dịp để mình ôn lại kiến thức, kĩ năng lẫn thói quen nhằm ghi nhớ sâu hơn hoặc ai đó đang tìm hiểu hoặc đang implement Clean Architecture vô tình đọc bài này có thể cảm nhận và phản biện giúp mình cũng cố kiến thức hơn.

I. SOFTWARE ARCHITECTURE LÀ GÌ? TẠI SAO CHÚNG TA PHÃI TÌM HIỂU CHÚNG?

Như chúng ta đều biết thì với yêu cầu của việc phát triển phần mềm thì việc phát triển đội ngủ về số lượng luôn là điều hiển nhiên. Tuy nhiên sự phát triển của số lượng chưa hẳn sẻ kéo theo sự phát triển chất lượng và tốc độ phát triển phần mềm.

Một sự thật đớn đau là theo các con số thống kê, đa số phần mềm khi phát đến một mức nào đó thì cho dù tăng thêm số lượng engineer đến bao nhiêu cũng không thể kéo tốc độ cũng như chất lượng lên được nữa, cũng có thể là tốc độ và chất lượng lại đi xuống. Tức nhiên là chẳng ai muốn điều đó xãy ra, ai cũng luôn luôn cố gắn phát triển cả. Nhưng với việc tổ chức kém code base ban đầu, việc out of control chất lượng code, độ phình to của software mà không có một chiến lược tái cấu trúc hợp lí….tất cả các nguyên nhân đó tạo ra một đống hổn độn mà không một cá nhân nào trong đó không muốn “đập đi làm lại cmn đi”.

Các Software Architecture sinh ra để giải quyết bài toán đầu tiên về tổ chức cũng như độ dễ của việc duy trì một code base sạch sẻ gọn gàn, nhằm giảm tối đa resource để xây dựng phát triển cũng như maintain đồng thời tăng khả năng mở rộng về mặt hệ thống lẫn số lượng engineer.

II. CÁC PROGRAMMING PARADIGMS:

Như chúng ta đều biết thì các Architectures đều bắt đầu từ code, do đó các Paradigms sẻ cho chúng ta biết cách tổ chức nào sẻ được sử dụng. Đến nay thì mình chỉ mới tiếp cận được 3 Programming Paradigms nên có thể nêu lên quan điểm cá nhân của mình như sau:

1.Object Oriented Programming(OOP):

Đây dường như đây là Paradigm thịnh hành nhất hiện tại với các ưu điểm đáng kể.

Việc hiểu rõ các tính chất cũng như các nguyên tắc design trong OOP giúp chúng ta dễ tổ chức, phát triển và maintain, dễ module hoá, tính reusable cao và cũng như cho phép chúng ta dễ dàng phát triển các module một cách song song.

Tuy nhiên OOP cũng có nhiều hạn chế. Về coding style, OOP nhìn chung là quá imperative, tập trung trả lời câu hỏi “How?”(làm sao để làm điều đó) hơn là câu hỏi “What?”(chúng ta muốn gì ở đây), khi đọc code chúng ta khó nhanh chóng hiểu được người viết muốn gì.

Nhược điểm thứ hai đó là về vấn đề race condition, trong concurrent programming đây là vấn đề thường gặp và rất tốn công giải quyết, việc chia sẻ trạng thái trong lập trình hướng đối tượng là một trong các nguyên nhân chủ yếu dẫn đến race condition và khiến ta khó debug cũng như fix bug.

2.Functional Programming(FP):

Những năm gần đây FP nổi lên như một xu hướng mà các lập trình viên đang theo đuổi và dần chuyển đổi, không chỉ mang lại những trải nghiệm mới mà FP xử lí một số vấn đề hạn chế của OOP.

Về coding style nhìn chung FP theo trường phái declarative, tập trung trả lời câu hỏi “What?”(chúng ta muốn gì), giúp chúng ta nhanh chóng hiểu code trong lúc maintain, fix bug.

Việc sử dụng các pure functions mang lại cho chúng ta các functions đáng tin cậy hơn, hạn chế tối đa side effect có thể xãy ra do đó các hàm luôn luôn chỉ trả đúng kết quả mà chúng ta mong đợi.

Về mặt Architecture, tính IMMUTABILITY là tính chất rất quan trọng mà chúng ta cần xem xét. Đứng ở vị trí là một Backend Engineer ngoài các vấn đề thuật toán, design hay architectures thì các engineer thường coi trọng nhất các vấn đề về concurency như race condition, deadlock conditions và concurrent update. Tất cả chúng sinh ra do đâu? chỉ có thể là do tính mutable của biến.

Chúng ta sẻ không dính bất cứ race condition hoặc concurrent update nào khi không có một biến/shared memory nào được cập nhật. Chúng ta sẻ không dính bất cứ deadlocks nào nếu không có mutable locks. Hay nói cách khác tất cả vấn đề concurrency, tất cả vấn đề chúng ta đối mặt trong hệ thống multiple threads/multiple processors sẻ không bao giờ xãy ra nếu không có bất cứ mutable variables nào.

Ở vị trí Backend Enginner chúng ta luôn muốn thiết kế một hệ thống mạnh mẻ với sự có mặt của multi threads multi processors sau đó câu hỏi mà chúng ta tự hỏi chính mình đó là tính immutability có luôn được thực hiện được hay không? Câu trả lời tức nhiên sẻ là không rồi, tuy nhiên chúng ta sẻ có những kĩ thuật nhằm phân tích các thành phần mutable và các thành phần immutable sau đó tách biệt chúng ra. Các thành phần immutable bắt buộc chúng ta phãi implement một cách purely functional và các thành phần mutable bắt buộc chúng ta phãi cân nhắc sử dụng một số loại transaction memory để tránh khỏi vấn đề concurrent updates và race conditions.

Tuy nhiên nó vẫn chưa được bảo vệ hoàn toàn khỏi vấn đề concurrent updates và deadlocks khi có quá nhiều biến phụ thuộc xuất hiện, khi đó chúng ta lại phãi cân nhắc tách và chuyển các thành phần mutable thành các thành phần immutable nhiều nhất có thể và tập trung đẩy càng nhiều tài nguyên nhất có thể vào các thành phần immutable. Ngoài ra việc thay đổi, tận dụng sức mạnh xử lí của hệ thống máy tính hiện đại kết hợp cùng các kĩ thuật xử lí theo event, cron job,…. có thể giúp chúng ta giải quyết phần nào hiệu quả các vấn đề trên.

3. Reactive & Functional Reactive Programming(FRP):

Đối với hầu hết các lập trình viên Javascript thì đây là một Paradigm không quá xa lạ, là sự hợp thành của 2 Paradigms Functional và Reactive(Events/Data stream Driven, Push, Asynchronous).

Đây là một Paradigm giúp chúng ta code Declarative hơn, dễ hiểu hơn theo hướng event, giúp đơn giản hoá việc xử lí bất đồng bộ đồng thời tránh đi việc gọi quá nhiều callback gây callback hell hoặc dùng promise/async-await mà quên đi tính event driven khi code. Ngoài ra đây cũng là một Paradigm giúp engineer xử lí concurent với một mô hình khác(event driven) ở mức low-level thông qua các khái niệm như observables, observers, operators, scheduler….

III.CÁC DESIGN PRINCIPLES:

Hệ thống phần mềm tốt phãi bắt đầu bằng việc clean code, nó cũng giống như việc xây nhà, khi bắt đầu điều tiên quyết chúng ta cần có đó là những viên gạch tốt. Những viên gạch đã không tốt thì cho dù kiến trúc tổ chức tốt đến nhừng nào cũng chỉ là trên bản vẻ mà thôi, khi xây nên hoàn toàn dễ dàng sụp đổ. Mặt khác khi bạn đã có các viên gạch đủ tốt rồi nhưng đôi lúc từ chúng bạn vẫn tạo ra một mớ hổn độn.

Để cấu nên một toà nhà lớn chúng ta nên chia nhỏ phát triển từng components bằng cách abstract hoá lên rồi lại separate ra một cách thích hợp để tạo thành các components nhỏ dễ cho việc quản lí, tái sử dụng cũng như gỡ bỏ khi không cần nữa.

Các Design Principles sinh ra nhằm giúp chúng ta thực hiện việc đó ở mức độ hight-level/mid-level/low-level như việc tổ chức tốt các thành phần từ đơn vị nhỏ như ghép nối các viên gạch thành cấu trúc từng bức tường.

Các design parterns cho chúng ta biết cách xắp xếp các hàm, các cấu trúc dữ liệu vào các class, struct và kết nối chúng lại với nhau để xây dựng nên các module hoàn chỉnh dễ dàng thay đổi, dễ hiểu cho người mới và đồng thời dễ tái sử dụng code, tiết kiệm effort hơn.

Các Principles chúng ta nên tham khảo và áp dụng như Component Principles, SOLID Principles,… Các bạn có thể xem lại SOLID Principles .

III. MỘT ARCHITECTURE TỐT SẺ TRÔNG NHƯ THẾ NÀO?

Markdowm Image

Architecture có thể được xem như cấp độ cao hơn code mà chúng ta đã viết. Nó là structure của ứng dụng, là cách mà các thành phần được tổ chức, sắp xếp và liên kết với nhau. Architecture thường bao gồm các lớp được triển khai theo trục dọc và ngang của hệ thống đồng thời phãi kết nối các thành đó lại với nhau để chúng work một cách gọn ghẽ.

Software architecture phãi được design với mục đích chính là phục vụ developer, cải thiện hiệu năng làm việc của họ chứ không phãi thoả mãn các architect hay optimize hiệu suất làm việc cho phần cứng.

Người thiết kế kiến trúc phần mềm phãi đặt mục tiêu thấu hiểu những vấn đề của dev, hiểu được gì họ cần để làm việc một cách vui vẻ hạnh phúc, hiểu được những khó khăn của họ trong quá trình phát triển cũng như duy trì hệ thống từ đó đưa ra những gì tốt nhất, tiện lợi nhất cho developer.

Khi nói rằng software architecture không sinh ra mục đích optimize hiệu suất làm việc cho phần cứng không có nghĩa là chúng ta không optimize hiệu suất phần cứng, ở đây chúng ta sẻ chỉ optimize cho phần cứng khi chi phí cho vấn đề performance lớn hơn lợi ích của một kiến trúc sạch cho developer mà thôi. Nói tóm lại chúng ta nên tránh optimize quá sớm, nên đặt trọng tâm vào design một architecture phục vụ developer trước tiên.

Đứng về phía developer chúng ta có thể thấy rằng một architecture tốt sẻ thoả mãn các tiêu chí sau đây:

V. CLEAN ARCHITECTURE:

Trãi qua một quá trình dài tiến hoá và phát triển với các mẫu Architecture khác nhau. Cụ Bob giới thiệu cho chúng ta một tập hợp các Principles, Design Parterns nhằm separate các components, các dependencies, các concerns, chia phần mềm thành nhiều lớp và tổ chức các lớp đó sao cho thoả mãn các yêu cầu chung của một kiến trúc có thể gọi là tốt, nó được cụ Bob giới thiệu đầy đủ trong cuốn Clean Architecture, một cuốn sách mình thực sự nghiền ngẫm. Trong đó mô tả một kiến trúc Clean là một kiến trúc đáp ứng những tiêu chí sau:

Để đạt được những điều trên, tác giả Uncle Bob đề ra các ý tưởng, quy tắc được mô tả trong hình dưới:

Markdowm Image

Và để dễ dàng tiếp cận chúng ta sẻ cùng phân tích các thành phần của một ứng dụng đơn giản để làm rõ những mục tiêu nguyên tắc của Clean Architect. Giả sử chúng ta có một ứng dụng đơn giản với một mô hình data-flow đơn giản như sau:

Markdowm Image

Khi đó chúng ta sẻ đặt các thành phần lên các vòng tròn cơ bản như sau:

Markdowm Image

Nhìn theo mức độ tổng quát chúng ta sẻ có hai tầng abstract như sau:

Tầng Interface:

Hai vòng tròn ngoài cùng mình tạm đặt nó vào tầng Interface. Đây là tầng chứa các cổng giao tiếp:

Domain Layer:

Domain Layer là layer chứa hai vòng tròn trong cùng, hai vòng tròn này về cơ bản sẻ gồm các Entities, các Use cases và các Infrastructure Interfaces.

Về cách tổ chức code chúng ta cũng sẻ dễ dàng tuỳ chỉnh dựa trên tư tưởng đó:

Với Golang mình thường tổ chức theo mô hình sau:

├── domain
│   ├── models
│   │   ├── ..
│   ├── repositories
│   │   ├── ..
│   ├── usecases
│   │   ├── ..
├── endpoints
├── interfaces
│   ├── httpsv
│   ├── grpcsv
│   ├── elasticsearch
│   ├── mysql
├── cmd
├── vendor
└── ...

Với Typescript trên nền tảng Nodejs mình thưởng tổ chức như sau:

├── src
│   ├── domains
│   │   ├── entities
│   │   ├── interfaces
│   │   ├── use_cases
│   │   ├── ...
│   ├── delivery
│   │   ├── http
│   │   ├── grpc
│   ├── infrastructure
│   │   ├── data_access_layer
│   │   │   ├── cassandra_implementation
│   │   │...
│   └── ...
├── node_modules
├── package.json
└── ...

Quy tắc về Dependency(The Dependency Rule)

Chính sự phân cấp về độ quan trọng của các thành phần của hệ thống, chúng ta phãi có một quy tắc để tổ chức chúng quy tắc về Dependency(The Dependency Rule)

Quy tắc này yêu cầu chúng ta phãi tổ chức code làm sao mà chúng chỉ có thể hướng vào bên trong, tức là những thứ ở bên trong không cần biết về những thứ ở bên ngoài, những thứ ở bên ngoài sinh ra nhằm phục vụ việc hiện thực business logic bên trong.

Cụ thể, tên của một cái gì đó khai báo trong một vòng tròn bên ngoài không được tham chiếu đến hoặc sử dụng bởi mã nguồn trong một vòng tròn bên trong bao gồm, các hàm, các class, biến, hoặc bất kỳ thực thể phần mềm khác. Cũng giống như vậy, các định dạng dữ liệu(Class/Struct/….) được sử dụng trong vòng ngoài không nên được sử dụng bởi những thứ ở vòng tròn bên trong, đặc biệt nếu các định dạng đó được tạo ra bởi một framework trong vòng tròn bên ngoài. Chúng ta không muốn bất cứ điều gì trong một vòng tròn bên ngoài tác động vào vòng tròn bên trong.

OK! Vậy phãi làm sao khi mà các thứ nằm trong vòng tròn bên trong không hề biết việc tồn tại các thứ ở bên ngoài lại có thể sử dụng được những thứ được implement bên ngoài. Ví dụ như các Use Cases nằm ở tầng Domain cần database implementation ở tầng bên ngoài cho việc hiện thực Use Case. Lúc này thay vì chúng ta sử dụng trực tiếp các Repository implementation thì chúng ta sẻ sử dụng Repository Interface/Abstraction ở tầng domain. Đây cũng là một ví dụ về nguyên tắc Dependency Inversion trong SOLID về việc phân tách các layers, các modules. Các high-level modules sẻ không phụ thuộc vào các low-level modules mà cả hai nên phụ thuộc vào các Abstractions nằm tại các hight-level modules.

Interface Adapters

Một thành phần không kém phần quan trọng trong hệ thống phần mềm của chúng ta đó là Interface Adapters. Đây là một bộ các adapters có nhiệm vụ chuyển dữ liệu từ đầng này sang tầng khác. Ví dụ như adapter chuyển đổi dữ liệu từ các use cases và Entities/Domain Model thành dữ liệu cho Database(Database Model) và ngược lại(Data Mapper). Hoặc đối với các bạn quen thuộc với Gokit sẻ thấy đó là các Endponts, chuyển đổi các cấu trúc dữ liệu từ định dạng của request sang cấu trúc dữ liệu đầu vào cho các Use Case Logics hoặc domain model. Và các Presenters sẻ encode response với định dạng phù hợp cho client.

Framework và Driver

Đây là một số thành phần có thể nằm trong các vòng tròn ngoài cùng, nói chung bạn không viết nhiều code trong layer này ngoại trừ code để connect với các vòng tròn ở bên trong. Layer này là nơi tập trung của các chi tiết. Chúng ta sẻ giữ những thứ này ở bên ngoài, nơi chúng khó có thể gây ảnh hưởng đến các phần ở vòng tròn bên trong.

Tuân theo các quy tắc đơn giản trên không phải là một việc quá khó khăn nhưng nó sẻ giúp chúng ta tiết kiệm được nhiều thời gian trong tương lai. Bằng việc tách hệ thống thành các layer, đồng thời tuân theo Dependency Rule, chúng ta sẽ xây dựng được một hệ thống dễ test, cùng với những lợi ích kèm theo như đã đề cập ở trên. Khi bất kỳ bộ phận bên ngoài của hệ thống trở nên lỗi thời, chẳng hạn như database, hoặc web framework, bạn hoàn toàn có thể thay thế chúng với một effort tối thiểu.

Chỉ chừng đó là đủ ư?

Tất nhiên đó chỉ là những thứ cơ bản, chỉ là một mẫu đơn giản, nhiều lúc bạn cần nhiều hơn 4 vòng tròn đó hoặc mỗi vòng tròn chúng ta phãi chia nhỏ thành các vòng tròn khác nữa để đảm bảo sự rõ ràng và “CLEAN” tuỳ thuộc vào sự tiến hoá của code base, hơn nữa không có quy tắc nào nói rằng bạn luôn phải có chỉ 4 vòng tròn, có thể có chừng đó layer, có thể nhiều hơn và cũng có thể ít hơn, nhiều hơn khi kiến trúc monolith của bạn ngày càng phình to và trở nên phức tạp, ít hơn khi bạn đã chia chúng được thành các microservices một cách mượt mà, chỉ tập trung là logic và loại bỏ đi những thứ rườm rà không đáng quan trọng, và có thể chia thêm layer cho phù hợp với độ tiến hoá. Cũng có thể bạn tách ra thành miroservices nhưng vẫn giữ nguyên kiến trúc với đầy đủ ban bệ của mẫu monolith củ, tuỳ vào việc bạn muốn quản lí một củ hành bự hoặc một rỗ hành hoặc một rỗ các tép hành mà thôi. Nhưng một điều mà chúng ta phãi ghi nhớ nếu không muốn bị ăn hành đó là Dependency Rule luôn phãi được áp dụng, sự phụ thuộc luôn phãi được hướng vào những thứ quan trọng nhất. Khi chia các layer theo các vòng tròn chúng ta nhìn sâu vào tâm vòng tròn thì mức độ abstract phãi tăng lên, vòng tròn trong cùng chỉ chứa những gì chung nhất, khó có thể chia nhỏ được nữa như các interface chẳng hạn. Các vòng tròn ngoài cùng phãi là các chi tiết được implement cụ thể ở mức thấp nhất và không ngại phãi thay đổi.

Clean Architecture hay các Architectures mục tiêu lớn nhất vẫn là hướng Engineer vào thứ quan trọng nhất của phần mềm là các Domain, Business Rules và là tập hợp các quy tắc, để giữ cho source code hệ thống phần mềm của chúng ta luôn được “Clean”.

Implement Clean Architecture:

Đây là hai ví dụ nhỏ mình implement Clean Architecture với Golang và Typescript các bạn có thể tham khảo:

Golang Clean Microservice

Typescript Clean Microservice

comments powered by Disqus
rss facebook twitter github gitlab youtube mail spotify lastfm instagram linkedin google google-plus pinterest medium vimeo stackoverflow reddit quora quora