Like! — Từ thiết kế Monolithic đến Scalable — Phần II

Nguyễn Thanh Tùng
13 min readApr 22, 2019

Đây là phần 2 trong loạt bài viết về quá trình chuyển đổi hệ thống của chúng tôi, trong trường hợp bạn chưa biết thì phần 1 nằm tại đây.

Disclaimer: This post is a “Wall of text”. Keep calm, plz.

Phần II: Biến mọi thứ trở nên rõ ràng

Như đã giới thiệu từ trước, kiến trúc khởi dựng của chúng tôi là một hệ thống MVC đơn khối (Monolithic). Tôi cho rằng lựa chọn này của những kỹ sư phát triển dự án khi đó là một quyết định đúng đắn. Với một ý tưởng kinh doanh, việc đưa sản phẩm tinh gọn (Minimum Viable Product) ra thị trường sớm nhất có thể chính là ưu tiên hàng đầu, sử dụng LEMP Stack đi kèm một framework phổ biến và đã trưởng thành như CakePHP là việc rất hợp lý.

Kiến trúc MVC của Like!

Tuy nhiên ở đây chúng ta phải thừa nhận một sự thật, entropy trong bất cứ dự án phần mềm nào cũng luôn tăng khiến nó ngày càng trở nên thoái hoá. Một sản phẩm phát triển càng nhanh, tích hợp càng nhiều các chức năng, sự phức tạp theo đó sẽ càng tăng cao. Và với sự phức tạp ấy, đến một lúc nào đó sẽ quá tốn công sức để sửa chữa hay quản lý. Phạm vi dự án chúng tôi hiện tại đã thay đổi, chuyển sang một nền tảng khác là cần thiết khiến hệ thống thích nghi được với phạm vi mới này cũng như các mở rộng trong tương lai.

Kiến trúc Monolithic chính là một bottleneck khi phát triển quy mô sản phẩm. Mục tiêu của team khi refactor là phải tháo gỡ nút thắt này. Nhưng để chia tách các thành phần không phải chuyện đơn giản. Các Module cần nằm trong một tập hợp thống nhất nhưng phải giữ độc lập và liên kết đủ lỏng lẻo (Low Coupling & High Cohesion) để giảm chi phí maintain cũng như dễ dàng mở rộng sau này.

Các khái niệm

Vậy cụ thể nó có ý nghĩa như thế nào? — Đây là một nguyên tắc, hay nói cách khác là một mục tiêu cần đạt được trong thiết kế. Tăng Cohesion có nghĩa là tổ chức các thành phần tính năng trong một component nhất định và làm chúng kết dính với nhau hơn. Mỗi component này cũng chỉ nên hoàn thành duy nhất một nhiệm vụ, không hơn không kém, theo nguyên lý Single Responsibility. Chính vì vậy, ta nên cố gắng tổ chức các thành phần xử lý một nghiệp vụ vào chung với nhau.

Còn về Coupling, nó là khái niệm chỉ mối quan hệ giữa các component với nhau. Một thiết kế Tight/High Coupling sẽ gây ra sự phụ thuộc lớn. Nghĩa là khi ta thay đổi một thành phần thì đồng thời sẽ phải sửa chữa những thành phần khác có liên quan. Điều này không chỉ mất công sức mà quan trọng hơn, chính là gây ra sự bất ổn định cũng như khó mở rộng sau này. Chúng ta thường gặp khái niệm này trong thiết kế OOP, nguyên tắc Dependency Inversion chỉ ra các class nên giao tiếp với nhau qua Interface thay vì dựa trên Implementation của chúng. Nó cũng có thể mở rộng ra cho việc thiết kế ở mức toàn bộ hệ thống. Các thành phần có một kết nối lỏng lẻo (Low Coupling) chính là mục tiêu cần phải đạt tới.

Phân tích và thiết kế tổng quan

Việc đầu tiên khi chúng ta bắt tay vào thiết kế mới đó là cố gắng phân tách các thành phần.

Nghiệp vụ chính (Domain) của Like! là tạo ra các Landing Page quảng cáo cho khách hàng là doanh nghiệp (Client). Sau đó các doanh nghiệp này lại dùng chính những landing page ấy để tiếp cận các khách hàng của mình, chúng tôi gọi đối tượng này là End-user. Đây là 2 nhóm khách hàng khác nhau, có những yêu cầu về mặt chức năng cũng như trải nghiệm khác nhau.

Có một thực tế là không có giải pháp nào hoàn hảo (there is no one-size fits all solution) để có thể thoả mãn hết các yêu cầu của từng đối tượng khách hàng. Nếu giữ kiến trúc MVC đơn khối, rất khó để gia tăng trải nghiệm của họ đối với sản phẩm. Do đó ta cần đến nguyên lý Chia-để-trị. Lúc này, nghiệp vụ chính sẽ được chia thành các nghiệp vụ nhỏ hơn (sub-domain) dựa trên những đặc điểm mà hệ thống cần phục vụ mỗi đối tượng. Việc phân tách này cũng giúp lựa chọn những công nghệ phù hợp nhất để giải quyết bài toán của riêng chúng.

Về mặt kĩ thuật, ta có đồng thời 2 Bounded Context ở đây. Cụ thể hơn, đó là 2 bounded context nghiệp vụ quản lý trang landing page dành cho đối tượng Creator (người tạo trang) và xem trang landing page dành cho End-user (người dùng cuối, đối tượng mà trang quảng cáo hướng tới). Nếu để ý kỹ, ta sẽ thấy phía ở nghiệp vụ tạo bài của Creator sẽ chủ yếu phát sinh nhu cầu write (create/edit bài viết). Ngược lại, phía End user sẽ chỉ phát sinh nhu cầu read (view bài viết). Đây là điểm mấu chốt, từ những nhu cầu đặc thù ấy, ta bóc tách được luồng nghiệp vụ của cả hệ thống thành phần ghi và phần đọc. Tư tưởng này khớp với nền tảng kiến trúc CQRS (Command Query Responsibility Segregation). Vì lẽ đó, cách triển khai của team sẽ đi theo hướng tiếp cận này.

Với CQRS, thao tác thay đổi trạng thái của dữ liệu được định nghĩa là Command, còn lại là Query — vốn chỉ truy vấn dữ liệu. Kiến trúc này thường được biết đến khi đi kèm với Event Sourcing (CQRS/ES) cũng như Eventual Consistency và Event Bus. Nhưng liệu chúng có bắt buộc phải đi cùng nhau hay không? — Tôi không cho rằng là như vậy. CQRS là một pattern đơn giản, phát triển của nguyên tắc Single ResponsibilityCQS trong lập trình, nó hoàn toàn có thể đứng độc lập. Thực tế rằng, dù CQRS đi kèm Event Sourcing có mang lại một số lợi ích, nhưng cũng đánh đổi là sự phức tạp khi cài đặt tăng lên đáng kể, một team size nhỏ đang phải phát triển hệ thống mới song song với maintain nền tảng cũ như chúng tôi thì đó là một bài toán bất khả thi. Vì vậy có lẽ quyết định hợp lý hơn sẽ là phát triển một sản phẩm đơn giản và dễ dàng scale trong tương lai khi có yêu cầu. Dưới đây là mô hình giống như chúng tôi đã chọn lựa:

Kiến trúc CQRS cơ bản

Hệ thống Backend

Sau khi hoàn thành sơ bộ thiết kế của kiến trúc tổng quan, ta tiếp tục đi sâu vào từng thành phần đơn lẻ, bắt đầu là hệ thống quản lý Landing Page (Write side). Đây là thành phần nghiệp vụ chứa core bussiness của Like!, mọi hoạt động như tạo/sửa/phân tích/theo dõi landing page đều nằm tại hệ thống này. Bởi lẽ đó tính chính xác và ổn định được đặt lên hàng đầu.

Lúc này một câu hỏi được đặt ra với chúng tôi: Với việc nghiệp vụ thay đổi liên tục, làm sao để giữ được tính chính xác của hệ thống? — Để giải quyết được vấn đề này, chúng tôi quyết định chọn phương pháp thiết kế theo DDD (Domain Driven Design) đi kèm kiến trúc Hexagon.

Nguồn: https://fideloper.com

Với DDD, các stakeholders trong dự án có tiếng nói chung với nhau (Ubiquitous Language), qua đó Developer có thể hiểu sâu sắc nghiệp vụ, việc này giúp họ thiết kế model trong chương trình thích ứng với sự thay đổi tốt hơn (DDD cung cấp 1 loạt design pattern để làm việc này). Ngoài ra, DDD cũng là phương pháp thiết kế nằm trong chương trình training bắt buộc đầu vào tại công ty chúng tôi. Do đó, các kĩ sư của team đã có chung nền tảng nhận thức giúp tăng tốc độ triển khai của dự án.

Còn về phía kiến trúc Hexagon, nó cải tiến kiến trúc Layer vốn có trong DDD thành nhiều lớp với hạt nhân là Domain Model, các layer giao tiếp với nhau qua từng Abstraction theo nguyên lý Dependencies Inversion của OOP. Thêm vào đó, nó cũng chia chức năng trong hệ thống thành những vùng độc lập, giao tiếp bên ngoài bằng Port, được hiểu như một Interface, và implement của chúng được gọi là Adapter. Điều này đặc biệt có ý nghĩa với chúng tôi. Like! là 1 dự án giao tiếp với nhiều bên thứ 3 (3rd-party tracking ad như Adplan, Facebook, Yahoo… cùng các service của Amazon, Google…), việc khoanh vùng được phạm vi của chúng sẽ có thể giúp giảm thiểu đáng kể chi phí maintain.

Thậm chí lúc này, những thành phần phổ biến trong monolithic (View layer, DAO layer…) cũng được tách ra để giao tiếp với core domain qua những interface của riêng chúng. Ví dụ cụ thể: View layer được chuyển đổi thành một Single-Page Application (SPA) sử dụng VueJS, giao tiếp với Backend qua Port HTTP (trong định nghĩa của Hexagon, xin đừng nhầm sang port mạng vật lý) sử dụng chuẩn RESTful. Có một điều khá thú vị, vì chỉ coi Http là một port/adapter trong hệ thống backend, vậy nên để giữ nó đơn giản nhất có thể, team đã chọn thư viện Akka HTTP thay vì một framework như Play — đầy mạnh mẽ nhưng cũng rất cồng kềnh.

Cuối cùng, ngôn ngữ chúng tôi chọn sử dụng là Scala. Lý do vì tính linh hoạt, mạnh mẽ và Type-safe (bắt lỗi từ khi compile thay vì runtime) của nó giúp đảm bảo tính đúng đắn của các dữ liệu mà khách hàng doanh nghiệp yêu cầu. Và quan trọng không kém, Scala là ngôn ngữ chính thức được sử dụng toàn Septeni giúp team tận dụng được tri thức và nguồn lực sẵn có xung quanh.

Hệ thống frontend

Trong định nghĩa của chúng tôi, frontend chính là phía Read-side của CQRS, có trách nhiệm hiển thị và tương tác với End-user.

Với hệ thống này, nghiệp vụ tương đối đơn giản, công việc chỉ là load các dữ liệu liên quan tới trang landing page, chức năng cũng không thay đổi nhiều theo thời gian. Vì vậy mô hình MVC truyền thống vẫn sẽ đáp ứng tốt những yêu cầu ấy. Tuy nhiên, thách thức ở đây là nó sẽ phải chịu được lượng lớn truy cập đồng thời, luôn luôn khả dụng (high availability) và phải scale dễ dàng theo mỗi nhu cầu thực tế (horizontal scalability). Nếu vẫn chỉ là một ứng dụng MVC đơn thuần chạy trên nền máy chủ (VD: Amazon EC2, Google Compute Engine,…) thì rất có thể nó sẽ trở thành một tử huyệt (Single point of failure). Để giải quyết những vấn đề đó, chúng tôi sử dụng công nghệ Container cùng với Kubernetes (K8s), triển khai trên nền tảng điện toán đám mây của Google (GCP — Google Cloud Platform).

Vậy cụ thể vai trò của những công nghệ trên là như thế nào? — Tư duy theo hướng Container hoá (containerization) gián tiếp giúp ta xây dựng code dễ dàng scale hơn. Cụ thể, để có thể triển khai hiệu quả một ứng dụng container thì ta phải đóng gói tất cả các dependencies/ libraries của ứng dụng để chúng có thể hoạt động độc lập (encapsulation), đồng thời phải chia nhỏ ứng dụng thành những thành phần stateless nhỏ hơn chạy trên nền tảng immutable code (liệu bạn có liên tưởng đến Functional Programming không?). Điều này dẫn đến hệ quả là một khi tất cả các thành phần đã đều immutable và stateless thì ta có thể ráp chúng lại dễ dàng mà không cần lo lắng đến việc sự phụ thuộc lẫn nhau của chúng. Và tất nhiên rằng, điều đó cũng sẽ giúp hệ thống dễ dàng scale hơn (đây là lý do tại sao công nghệ container lại thường được triển khai cùng kiến trúc Microservice).

Nhưng khi đã chia nhỏ nhiều container thì ta cũng cần quản lý chúng một cách hiệu quả nếu không muốn rơi vào một mớ bòng bong khác. K8s là một opensource container-orchestration được phát triển bởi Google đang nổi lên gần đây, vì tính ưu việt của nó mà team đã lựa chọn nó và triển khai cùng GCP (bạn có thể đọc chia sẻ chi tiết của một trong những kĩ sư hệ thống của chúng tôi tại đây).

Về mặt ngôn ngữ, NodeJs (Express framework) là lựa chọn cuối cùng sau nhiều cân nhắc. Nó có những ưu điểm gì? — Đó là sự đơn giản của Javascript, xử lý tác vụ I/O bound tuyệt vời cùng server-side render. Hãy cùng điểm qua những ưu điểm này.

Một trong những điều giúp NodeJS trở nên rất phổ biến ngày nay đó là tính dễ tiếp cận của cú pháp Javascript. Tính linh hoạt của ngôn ngữ này cũng là một điều không phải bàn cãi. Một nhược điểm lớn của Javascript chính là không hỗ trợ type safe, tuy nhiên như đã nói ở phía trên, nghiệp vụ đơn giản của hệ thống Read-side giúp chúng tôi vượt qua rào cản ấy để có thể áp dụng nó.

Ưu điểm tiếp theo của NodeJS chính là xử lý tác vụ I/O vô cùng hiệu quả, đó là nhờ vào cơ chế Event Loop và Single Thread (giúp loại bỏ context switching) nên nó có thể nhận một lượng lớn request mà không cần đợi thao tác đọc/ghi IO hoàn tất (non-blocking). Điểm yếu cần lưu ý của NodeJS là xử lý các tác vụ CPU bound, nhưng một lần nữa, phía Read-side của chúng tôi không phải dạng ứng dụng này cho nên nhược điểm ấy được bỏ qua.

Cuối cùng, đó là Server-side Render. Like! là một sản phẩm giúp doanh nghiệp dễ dàng viral sản phẩm của mình, vì vậy việc hỗ trợ SEO (Search Egnine Optimization) và chia sẻ trên các SNS (Social networking service) là rất quan trọng. Ban đầu, để đồng nhất Techstack, VueJS được ưu tiên sử dụng cho hệ thống này. Với VueJS bạn hoàn toàn có thể hỗ trợ SEO và SNS sharing, tuy nhiên phải thực hiện một số tip/trick nhất định, không quá phức tạp nhưng cũng không đơn giản. Hơn thế nữa, chúng tôi sẽ phải cài đặt thêm một hệ thống backend nữa để phục vụ giao tiếp với ứng dụng VueJS frontend. Đây là điểm mấu chốt, nó khiến complexity của dự án tăng lên một cách không cần thiết. Chúng tôi tự hỏi, tại sao phải chọn VueJS và làm một loạt các task liên quan thay vì dùng NodeJS là đáp ứng tốt tất cả các yêu cầu? Vậy nên NodeJS là lựa chọn cuối cùng như một lẽ dĩ nhiên.

Database luôn là một phần quan trọng của mọi hệ thống. Đôi khi, nếu không cẩn thận đây sẽ là một bottleneck gây tốn công khắc phục nhất của chúng ta.

Các Developer thường bị ám ảnh bởi tốc độ mà quên đi những thứ khác xung quanh. Khi được hỏi làm sao để tăng tốc database, câu trả lời phổ biến nhất có lẽ là hãy sử dụng một NoSQL DB. Các NoSQL thường loại bỏ join trên Relational DB để tối ưu tốc độ. Thế nhưng chúng cũng có những hạn chế riêng, đó là do thiếu chuẩn hoá (normalized) mà hoàn toàn có thể dẫn tới trường hợp dư thừa và dị thường dữ liệu. Đây là một điều tối kị với khách hàng doanh nghiệp. Ai lại muốn kết quả báo cáo cuối tháng của mình số liệu mỗi nơi một khác cơ chứ?

Để giải quyết vấn đề ấy, chúng ta cần đặt từng loại DB vào ngữ cảnh mà nó có khả năng phát huy tác dụng nhất. Với kiến trúc CQRS, điều đó càng trở nên dễ dàng. Chúng ta đã có luồng ghi và đọc độc lập, vậy với luồng ghi (ở đây là hệ thống CMS backend) dữ liệu cần nhất quán, lựa chọn một RDBMS như MySQL là hợp lý. Còn lại phía Read, hệ thống frontend phục vụ end-user, một Key-Value NoSQL như Aerospike sẽ rất thích hợp để scale cũng như có tốc độ phản hồi tốt. Việc của chúng ta lúc này là đảm bảo cơ chế sync giữa chúng.

Thử thách và bài học

Đến lúc này thiết kế cũng như techstack của chúng tôi đã được định hình. Tuy nhiên vẫn còn chặng đường hiện thực những kiến trúc và công nghệ ấy. Đây cũng là một thử thách thật sự. Không gì đảm bảo rằng những dự định của chúng tôi sẽ đi đúng hướng. Và quả thật, đã có những sai lầm từ chúng tôi. Tuy nhiên, sai lầm cũng chính là những bài học mà chúng ta cần tận dụng. Vậy chúng là những gì? Phần 3 sẽ cùng chỉ ra điều đó.

(còn tiếp)

--

--