Series:
Bài 1: Giới thiệu hệ thống demo và quy trình phát triển.
Bài 2: EventStorming: Lý thuyết
Bài 3: EventStorming: Áp dụng Big Picture (1)
Bài 4: EventStorming: Áp dụng Big Picture (2)
Bài 5: Bài này.
Bài 6: Thiết kế kiến trúc (2) - Sơ đồ tĩnh, động, và triển khai
--------------
Đưa ra quyết định
Sau buổi EventStorming, người điều phối tổng hợp kết quả và gửi lại cho các bên liên quan, trong đó có SA – người phụ trách lên thiết kế kiến trúc hệ thống.
Dựa vào kết quả này, cùng với những thông tin từ các stakeholder khác, SA có thể biết được cơ bản một số thông tin sau:
- Nghiệp vụ: Các bộ { Read models – Actors – Commands – Aggregates – Events } mang lại những thông tin quan trọng cho thiết kế.
- Các bounded context: Đây là thông tin cực kỳ quan trọng giúp SA có thể đưa ra quyết định thiết kế mức cao và liệu xem họ có nên phát triển các microservices riêng biệt.
- Các điểm khó xử lý (pain point): Những cảnh báo mà SA cần biết để có giải pháp.
- Với phân loại sơ bộ các bounded context về các nhóm: core subdomain, supporting subdomain hay generic subdomain giúp SA đưa ra quyết định cách thiết kế cũng như đầu tư nguồn lực vào từng bounded context cụ thể.
Dựa vào những thông tin này, SA cuối cùng sẽ đưa ra một bản thiết kế kiến trúc hệ thống. Ở đây, cần nhấn mạnh từ “kiến trúc hệ thống” là dạng kiến trúc không cho biết thông tin triển khai cụ thể như ngôn ngữ lập trình, framework, các công nghệ cụ thể khác như database, messaging, v.v.
Hãy cùng đi qua một số quyết định mà SA đã tạo ra.
Quyết định
Khi làm việc trong một tổ chức, điều "mong muốn" của lập trình viên là được làm trong những dự án greenfield - dự án mới hoàn toàn hơn là những dự án brownfield - dụ án maintain. Tại sao lại vậy? Thật xấu hổ khi thổ lộ điều này. Với bản thân tôi, đó là sự "ngại" xen lẫn "sợ hãi" khi maintain code của người khác trong những dự án brownfield. Thông thường, lập trình viên có thể mất tới 50% thời gian của mình cho việc đọc code của người khác. Thế nên tôi và bạn đang học cách code - thực ra là học phương pháp thiết kế để sao cho code sáng sủa hơn, để người khác thầm cảm ơn mình chứ không phải với những câu thậm tệ như "...".
Những mấy ai có được may mắn mãi như vậy. Ta thường tiếp cần dự án brownfield nhiều hơn. Tại đó, ta hiếm khi thấy tài liệu thiết kế kiến trúc (hay là do những công ty tôi làm như vậy nhỉ?!). Do đó, những quyết định kiến trúc, những thông tin về kết cấu hệ thống, những lý do đằng sau các quyết định và thiết kế có phần không hợp lý như thế (điều khiến ta thấy bức xúc ở thời điểm hiện tại) bắt nguồn từ đâu.
Có những quan niệm cho rằng tài liệu thiết kế nằm trong code triển khai và đó là nơi phản ánh trung thực nhất thiết kế hệ thống. Bạn thấy có đúng không?
Bản thiết kế, nhất là thiết kế cấp cao, cần thể hiện được tính trừu tượng (abstraction) để người đọc có thể nhanh chóng nắm bắt được tư tưởng, những thông tin quan trọng nhất. Nếu để nắm được những điều này bằng cách đọc code, thì liệu timeline có cho phép và thậm chí liệu bạn có làm được? Bản thiết kế thể hiện linh hồn, triết lý mà hệ thống tuân theo. Không hẳn sprint nào ta cũng cần cập nhật nó, trừ khi có sự thay đổi dẫn tới triết lý đó bị ảnh hưởng. Hãy nhớ câu này để thấy được giá trị của các sơ đồ thiết kế:
A picture is worth a thousand words.
Giờ hãy cùng xem, với bài toán demo này cùng output của Big Picture EventStorming, ta có thể có những quyết định nào mang tầm cỡ kiến trúc?
Quyết định kiến trúc là những quyết định về việc tạo ra một thanh phần và mối quan hệ của nó trong kiến trúc tổng thể. Đó phải là quyết định quan trọng đối với hệ thống. Nếu chỉ để lưu dữ liệu trong cơ sở dữ liệu quan hệ, việc lựa chọn MySQL hay PostgreSQL đâu có quan trọng và mang tầm kiến trúc. Việc giao tiếp qua một cơ chế messaging với quyết định Kafka hay RabitMQ cũng không phải quyết định kiến trúc.
Qua các quyết định dưới đây, ta thấy rằng các bounded context của buổi Big Picture EventStorming chỉ là các bounded context thuần nghiệp vụ, và với vai trò SA, bạn có thể cần xác định ra những bounded context mang tính kỹ thuật hơn, và chúng có vai trò quan trọng trong kiến trúc hệ thống.
Quyết định architectural style
Hiện tượng “microservice envy” đang nổi lên như một cảnh báo đáng chú ý. Một số công ty đã thực hiện refactor hệ thống trở thành microservices để rồi quay lại monolith như một sự phù hợp hơn. Các yếu tố về độ phức tạp cao hơn, đòi hỏi kỹ năng chuyên môn cao hơn, sự phân chia ranh giới nghiệp vụ cần đầu tư hơn, v.v. đã khiến nhiều hệ thống microservices thất bại. “Thất bại” không có nghĩa là hệ thống chạy lỗi hay thậm chí không chạy được mà đó là sự tiêu tốn nguồn lực vốn có giới hạn cao hơn nhiều khiến tổ chức vận hành bị ảnh hưởng đáng kể.
Vậy với các bounded context đang có trong tay, ta chỉ có thể triển khai microservices – tức là ta không có đường lùi?
Không hẳn! Ta vẫn có thể xác định các bounded context nhưng triển khai chúng dưới dạng một hoặc một vài monolith. Khi đó, ta có những ứng dụng “modular monolith”. Khi làm vậy, hãy đảm bảo giữ vững các quy tắc (không quá cứng nhắc) của DDD như một transaction chỉ tác động một aggregate, eventual consistency ngay chính trong một ứng dụng, v.v. Có được những thứ này, ta có thể bóc tách các module ra thành các microservice khi nhu cầu xuất hiện một cách dễ dàng hơn.
Đó là những thông tin khi thiết kế một ứng dụng mà ta cần quan tâm. Nhưng trong khuôn khổ demo này, ta hãy xây dựng microservices đúng nghĩa nhất có thể.
Quyết định 1: Architectural style: Event-driven microservices.
Quyết định 2: Domain-driven Design. Quyết định 2 có vẻ thừa thãi vì việc tổ chức EventStorming trước đó đã cho thấy điều này. Nhưng ta nhắc lại để nhấn mạnh rằng ta sẽ sử dụng nó ở cả giai đoạn tactical design cho hầu hết bounded context, ít nhất là các bounded context thuộc core subdomain.
Quyết định 1 sử dụng event-driven microservices dẫn tới nhu cầu xuất hiện một số thành phần chuyên trách khác trong kiến trúc.
API Gateway
Quyết định sử dụng API Gateway không chỉ tới từ kiến trúc microservices mà tổng quát hơn, nó tới từ nhu cầu tập trung và che giấu.
Trong một hệ thống monolithic, nhu cầu này bị giảm bớt do backend chỉ bao gồm một hoặc một vài monolithic nhưng trong hệ thống microservices đáp ứng nhu cầu này mang tới nhiều giá trị.
Một trong số đó là che giấu – hay kỹ thuật hơn là tính đóng gói, giúp cho client không cần biết cấu trúc phức tạp của các microservices bên dưới, giúp họ không phải lắp ráp dữ liệu từ nhiều nguồn, cuối cùng, họ dễ sử dụng ứng dụng nghiệp vụ hơn. Lợi ích khác có thể gặp sau này là backend sử dụng nhiều cơ chế giao tiếp khiến client khó sử dụng hoặc triển khai khi ở bên ngoài firewall.
Một nhóm lợi ích khác là nếu không có một API Gateway, những công việc dạng “cross-cutting concerns” thường sẽ triển khai tại nhiều dịch vụ. Hình dung công việc authentication/authorization đòi hỏi tại mỗi service sự thực thi, sau đó, phát sinh một yêu cầu chỉnh sửa sẽ cần nguồn lực và kế hoạch lớn như thể nào? Ngay cả khi bạn nghĩ về một thư viện dùng chung thì việc áp dụng một sự thay đổi trên thư viện cho toàn bộ dịch vụ cũng cần nguồn lực đáng kể để đánh giá, kiểm thử.
Vì thế, API Gateway trong hệ thống microservices trở thành một dịch vụ độc lập, hoạt động như một bộ lọc và định tuyến cho tất cả các lời gọi tới service đằng sau. Tại đây, ta có thể thực hiện những hành động cần xuyên suốt hệ thống (hay còn gọi là điểm thực thi chính sách).
Service Discovery
Khi lựa chọn microservices, Service Discovery không thể thiếu. Bởi vì cách hoạt động của microservices là các instance có thể được hủy và tạo mới khi có bất cứ sự có nào xảy ra nên ta không thể cấu hình tĩnh như cách truyền thống được: Một load balancer đứng trước các microservice instances, sử dụng Routing Tables (là nơi cấu hình sẵn cho các dịch vụ) để thực hiện chức năng. Ngoài ra, mô hình đảm bảo tính sẵn sàng của load balancer là active/passive với một số load balancer chờ sẵn nhưng không tham gia vào hoạt động.
Service Discovery mang tới mô hình peer-to-peer để các instances tự động chia sẻ thông tin routing tables với nhau. Các microservice instances khi sinh ra tự biết nơi cần đăng ký thông tin, chính là cấu hình endpoint của Service Discovery, tải về một routing table rồi đặt vào local cache, nhờ đó chúng có khả năng tự thực hiện load balancing mà không cần dịch vụ chuyên trách.
Service Discovery giúp kiến trúc microservices tăng khả năng mở rộng dễ dàng.
Messaging
Messaging là thành phần không thể thiếu trong kiến trúc event-driven microservices.
Integration
Đây là thành phần tùy chọn. Mục đích là giúp mạng lưới giao tiếp giữa các microservices, đặc biệt là theo cơ chế messaging, được dễ dàng, tập trung, dễ theo dõi hơn, và đảm bảo được đa dạng các pattern tích hợp (theo enterpriseintegrationpatterns.com). Điều này đạt được bằng cách:
- Mỗi microservice thường chỉ có hai message queue duy nhất: input-queue và output-queue.
- Việc định tuyến message từ output-queue này tới input-queue khác được ủy quyền cho hệ thống ở giữa: Integration System.
Với scope hiện tại, thành phần này có thể chưa được triển khai sớm.
Logging and Tracing
Bản chất của microservices là phân tán chức năng, mặc dù, thiết kế của chúng ta có thể đảm bảo tính tự trị cao giữa mỗi dịch vụ, nhưng một quy trình nghiệp vụ dài vẫn cần được phân tách ra ở nhiều nơi.
Một số khó khăn xuất hiện với sự phân tán này như việc làm thế nào để biết một giao dịch bị lỗi ở khâu nào, do đó, cách móc nối chúng lại để tạo nên một bức tranh thống nhất, đầy đủ là một nhu cầu cấp bách.
Monitoring and Scaling
Trong một hệ thống phức tạp như microservices, việc giám sát (monitoring) không chỉ tập trung vào trạng thái của các microservices mà còn phải theo dõi luồng sự kiện, hiệu suất tích hợp, và trải nghiệm người dùng.
Yếu tố quan trọng tiếp theo là khả năng tự động mở rộng đảm bảo hệ thống có thể xử lý tải thay đổi mà không bị quá tải hoặc lãng phí tài nguyên khi thấp tải.
Quyết định phân loại subdomain
Hiện tại, với bốn bounded context Catalog, Inventory, Order Management, và Payment có vẻ là đủ với những gì nghiệp vụ cần. SA sẽ bổ sung hoặc thay đổi chúng khi các yếu tố kỹ thuật được làm rõ dần.
Hệ thống cần thông báo tới người dùng, ví dụ như trạng thái đơn hàng, số tồn kho sản phẩm tới ngưỡng, v.v. Sẽ không hợp lý nếu mỗi bounded context tự làm điều này. Ta sẽ cần bổ sung một bounded context chuyên dụng: Notification. Notification tiếp nhận các message từ các bounded context khác kèm theo nhưng thông tin cơ bản như đối tượng nhận là ai, có cần lập lịch hay không, rồi sử dụng những gì nội tại của nó để xử lý. Và để đảm bảo sự kết nối lỏng lẻo, Notification có một message queue đầu vào riêng để tiếp nhận.
Để giữ demo đơn giản, tập trung vào nghiệp vụ yêu cầu, ta tạm thời bỏ qua bounded context(s) quản lý thông tin merchant và customer. Nếu có, chúng có thể được gộp vào một hoặc tách ra thành các bounded context riêng biệt tùy theo độ phức tạp, tên của bounded context này có thể là User Management, Identity, v.v.
Vậy lúc này, ta có cơ bản năm bounded context: Catalog, Inventory, Order Management, Payment, và Notification. Hãy phân loại chúng về ba nhóm subdomain: Core, Supporting, và Generic.
Phát triển theo DDD cho biết rằng các bounded context cần được chia thành ba nhóm như vậy để đảm bảo mục tiêu chiến lược của tổ chức và tối ưu nguồn lực đầu tư. Những bounded context thuộc core, nên được đầu tư mạnh nhất về con người, công nghệ, và các nguồn lực khác, và cần ưu tiên in-house. Những bounded context thuộc supporting đóng vai trò hỗ trợ cho vùng core, thường dễ xử lý nên nguồn lực con người sẽ ở mức hạn chế hơn. Những bounded context thuộc nhóm generic thường là khó và cũng không phải mục tiêu chiến lược của tổ chức, tốt nhất nên được mua hoặc thuê ngoài.
Dựa vào những thông tin trên, ta thấy rằng
- Order Management thuộc nhóm core subdomain vì yêu cầu đầu vào tập trung chủ yếu vào quy trình mà bounded context này phụ trách, và họ - những người đưa ra yêu cầu, cho rằng đây là nhóm quy trình lõi. Ngoài ra, trong tương lai gần, những trải nghiệm mua hàng tiên tiến hơn sẽ được xây dựng nên việc đặt nó vào vùng core là dự phòng phù hợp.
- Catalog có thể thuộc nhóm supporting subdomain vì bounded context này chứa các nhóm quy trình nhỏ hỗ trợ cho trải nghiệm mua hàng bên trên và giải pháp kỹ thuật của nó không có gì quá khó khăn. Mọi người có thể thắc mắc rằng Catalog đang chứa các chức năng duyệt qua sản phẩm, và sản phẩm có khá nhiều thông tin như thuộc tính cơ bản của sản phẩm, trạng thái tồn kho, điểm đánh giá, v.v. thì sao không nên coi nó là core? Những thông tin này cơ bản là không khó xây dựng. Nếu sau này xuất hiện thành phần recommendation (khuyến nghị sản phẩm) thì thành phần này cũng được dựng ở bounded context khác. Catalog đang được xây dựng như một Read model service thuần túy.
- Inventory và Payment hỗ trợ cho Order Management trong quy trình đặt hàng nên chúng thuộc vùng supporting. Nếu việc tích hợp nhiều cổng thanh toán hoặc có yêu cầu phức tạp như changeback, fraud detection, Payment có thể trở thành core. Tương tự với Inventory.
- Notification cũng là một bounded context thường gặp và ta không phát triển nó trở thành sản phẩm chiến lược. Nó thuộc supporting.
Quyết định 3:
Bounded context |
Subdomain type |
Application Architecture |
Order Management |
Core |
Hexagonal |
Catalog |
Supporting |
Hexagonal |
Inventory |
Supporting |
Hexagonal |
Payment |
Supporting |
Hexagonal |
Notification |
Supporting |
Hexagonal |
Quyết định 3 đúc rút từ phân tích phân loại từng bounded context và bổ sung thêm kiến trúc ứng dụng sử dụng. Thông thường, những ứng dụng thuộc vùng supporting có thể áp dụng kiến trúc Layered để đơn giản hóa. Tuy nhiên, nếu tổ chức có nguồn lực chuyên môn sẵn sàng và cũng để dự phòng nếu một bounded context di chuyển sang vùng core, thì có thể áp dụng đồng loạt Hexagonal. Chú ý rằng, các dự án thuộc vùng supporting là nơi lý tưởng để đào tạo chuyên môn nhân sự.
Quyết định cơ chế giao tiếp giữa các bounded context
Do kiến trúc sử dụng event-driven microservices kết hợp phương pháp DDD, các bounded context hướng tới sự tự chủ lớn hơn, do đó, chúng có xu hướng giao tiếp qua event nhiều hơn.
Vậy cơ chế giao tiếp chính bao gồm:
- Synchronous: Chủ yếu sử dụng RESTful.
- Asynchronous: Chủ yếu qua message queue với cơ chế pub/sub.
Các domain event được đóng gói vào các message với nội dung chính bao gồm:
- ID: Định danh của message.
- EventType: Loại event, thông thường là giá trị chuỗi.
- CorrelationID: ID móc nối chuỗi nghiệp vụ.
- OccurredAt: Thời gian publish lên message queue.
- DomainEvent: Chứa nội dung domain event.
Nhu cầu giao tiếp qua event dẫn tới sự xuất hiện của dịch vụ mới: EventSchema Service. Dịch vụ này đảm bảo các bounded context giao tiếp với nhau thông qua các sự kiện có cấu trúc thống nhất. Trước mắt, dịch vụ này chưa cần thiết nhưng tương lai sẽ cần, có thể phát triển vào phase 2.
Quyết định cấu hình tập trung
Với một hệ thống hàng trăm microservices, việc lưu cấu hình ứng dụng tại các file thuộc tính (YAML, JSON, XML) có thể gây rối vì mỗi microservice lại có những cấu hình khác nhau cho những môi trường khác nhau. Mỗi khi một cấu hình cần thay đổi, nó cần được thay đổi tại chính file đó và rồi qua quy trình CI/CD khởi tạo lại ứng dụng thì mới có thể sử dụng. Đây là lý do mà Config Server tồn tại và mang lại lợi ích.