Tối ưu hóa CQRS trong ứng dụng kế thừa
Khi chúng ta nghĩ về phân tách trách nhiệm lệnh và truy vấn (CQRS), có lẽ hình ảnh trong đầu của chúng ta là một ứng dụng được xây dựng xung quanh việc gửi sự kiện trong mô hình ghi, xử lý chúng để cập nhật mô hình đọc, lưu trữ sự kiện, bus tin nhắn, các dự đoán,...
Trong bài viết này, chúng ta sẽ khám phá cách tận dụng lợi ích từ CQRS mà không cần đến tất cả những công cụ phức tạp, điều mà có thể tưởng chừng như không thể thực hiện trong các hệ thống kế thừa. Để làm điều đó, chúng ta sẽ tìm hiểu về CQRS Pattern như một cách tiếp cận cấp thấp để phân biệt các hoạt động lệnh và truy vấn.
Tôi sẽ đề cập đến các hoạt động hoặc trường hợp sử dụng thay đổi trạng thái là lệnh hoặc ghi. Sau một trong những hoạt động này, hệ thống sẽ khác so với trước đó. Ví dụ: kích hoạt một người dùng mới.
Đối với các hoạt động hoặc trường hợp sử dụng đọc dữ liệu, tôi sẽ sử dụng truy vấn hoặc đọc. Không quan trọng bạn chạy chúng bao nhiêu lần, hệ thống vẫn giữ nguyên trạng thái vì hoạt động này không có tác dụng phụ. Ví dụ: liệt kê tất cả người dùng.
CQRS Kiến trúc vs CQRS Pattern
CQRS bản chất là về việc tách biệt mã chịu trách nhiệm thay đổi trạng thái khỏi mã chịu trách nhiệm đọc dữ liệu. Điều này chính xác là những gì tên của mẫu này có nghĩa.
Sự tách biệt này có thể ảnh hưởng đến toàn bộ kiến trúc ứng dụng hoặc áp dụng ở mức độ chi tiết hơn. Cả hai tùy chọn đều có ưu điểm và nhược điểm riêng.
Lưu ý CQRS Kiến trúc và CQRS Pattern chỉ là những tên tôi tự tạo ra cho bài viết này. Đây không phải là thuật ngữ tiêu chuẩn.
CQRS Kiến trúc ngụ ý rằng kiến trúc được xây dựng xung quanh nó và tối đa hóa lợi ích của mẫu. Đổi lại, nó yêu cầu công cụ cụ thể (như đã đề cập trước đó: sự kiện, bus, v.v.). Vì việc thay đổi kiến trúc của một ứng dụng thường là một quá trình tốn kém, ngay cả khi thực hiện theo từng giai đoạn, việc áp dụng cách tiếp cận này cho các cơ sở mã hiện có có thể đồng nghĩa với một nỗ lực đáng kể.
CQRS Pattern là một khía cạnh cấp thấp hơn, và do đó dễ áp dụng hơn theo từng trường hợp. Vì vậy, có thể dễ dàng hơn để tái cấu trúc một ứng dụng hiện có với cách tiếp cận này, ngay cả khi lợi ích đến từ những phần nhỏ hơn.
Có một bài viết tuyệt vời từ Alberte Mozo, giải thích về việc hình thành mẫu ban đầu và cách mà không có cách tiếp cận kiến trúc hay mẫu nào (và mọi thứ ở giữa) là tốt hơn hay thuần khiết hơn, nếu việc thuần khiết là điều liên quan.
Ở đây, chúng ta quan tâm đến CQRS Pattern trong một dự án kế thừa, và chúng ta sẽ sử dụng một ví dụ đơn giản để minh họa.
Một kịch bản quen thuộc
Giả sử một cơ sở mã hiện có với các đặc điểm sau:
- Ứng dụng được tổ chức theo các trường hợp sử dụng.
- Có một số trừu tượng để lấy các thực thể từ mô hình miền của chúng ta, như các repository.
- Các thực thể được mô hình hóa để bao gồm cả các hoạt động lệnh và truy vấn.
- Một số hoạt động đọc liên quan đến nhiều thực thể.
Ví dụ, chúng ta có một trường hợp sử dụng để kích hoạt người dùng và một trường hợp khác để liệt kê tất cả người dùng. Thực thể User
được sử dụng trong cả hai, nhưng việc liệt kê người dùng yêu cầu hiển thị kế hoạch đăng ký mà người dùng có, do đó chúng ta cũng cần liên quan đến thực thể Subscription
để liệt kê.
php
class User {
public function __construct(
//Nhiều thuộc tính khác
public string $email,
private Status $status,
)
public function activate(): void {
if ($this->status->canBeActivated()) {
$this->status = $this->status->toActive();
}
}
public function getStatus(): string {
return $this->status->toString();
}
}
class Subscription {
public function __construct(
//nhiều thuộc tính không liên quan đến việc liệt kê người dùng
private Type $type,
)
public function typeAsString(): string {
return $this->type->asString();
}
}
Trường hợp sử dụng kích hoạt là một lệnh và sử dụng User
để thay đổi trạng thái:
php
class ActivateUser {
public function run(
UserRepository $userRepository,
UserId $userId
): void {
$user = $userRepository->getWithId($userId);
$user->activate();
}
}
Trong khi việc liệt kê người dùng sử dụng cả hai thực thể vì dữ liệu cần thiết được phân tán giữa chúng:
php
class ListUsers {
public function run(
UserRepository $userRepository,
SubscriptionRepository $subscriptionRepository,
): ListUserCollection {
$users = $userRepository->findAll();
$response = new ListUserCollection();
foreach ($users as $user) {
$userSubscription = $subscriptionRepository->forUser($user->id());
$response = $response->add(
[
'email' => $user->email,
'subscription_type' => $userSubscription->typeAsString(),
//nhiều trường khác
]
);
}
return $response;
}
}
Có một số vấn đề tiềm tàng ở đây.
Các thực thể bị rối loạn
Chúng bao gồm cả hai loại hoạt động và có khả năng một số thuộc tính của chúng chỉ để hiển thị dữ liệu, trong khi một số khác cũng được sử dụng để thực hiện logic kinh doanh. Các thuộc tính được sử dụng cho các trường hợp sử dụng đọc dữ liệu có thể được truy cập thông qua các getter hoặc thuộc tính công khai để trả về một số phản hồi. Điều này phá vỡ nguyên tắc đóng gói trong OOP và gây hại cho khả năng tái cấu trúc và phát triển mô hình miền của chúng ta. Chưa kể rằng các thực thể bị phình to với các thuộc tính không nhằm vào logic kinh doanh.
Ví dụ, User
có thể có quá nhiều thuộc tính. Subscription
cũng vậy, và điều này đặc biệt đau đầu ở đây vì chúng ta chỉ quan tâm đến chuỗi đại diện của loại từ nó. Chúng ta cần bao gồm các getter cho một đại diện chuỗi của trạng thái người dùng và loại đăng ký, điều này khiến việc thay đổi chúng trở nên phức tạp hơn là một mối quan tâm nội bộ của các thực thể.
Một vấn đề tiềm tàng khác là hiệu suất và lãng phí tài nguyên. Nói chung, các trường hợp sử dụng đọc được kỳ vọng phục vụ nhanh hơn vì có ai đó đang chờ phản hồi ở phía bên kia. Vì chúng ta sử dụng các thực thể để truy cập dữ liệu, một trường hợp sử dụng đọc có thể yêu cầu nhiều thực thể để lấy chỉ một số dữ liệu nhỏ từ mỗi thực thể. Các thực thể sẽ bao gồm nhiều thuộc tính khác không liên quan đến nhu cầu hiện tại, nhưng tài nguyên và thời gian vẫn được đầu tư để lấy chúng.
Trong ví dụ của chúng ta, nếu việc xây dựng một Subscription
yêu cầu nhiều phép nối ở cấp cơ sở dữ liệu, chúng ta đang gánh chịu chi phí đó chỉ để lấy loại.
Lợi ích của CQRS
Bằng cách áp dụng CQRS, chúng ta ngăn chặn các vấn đề trên bằng cách tạo ra các mô hình khác nhau cho các hoạt động ghi và đọc. Các thực thể được giải phóng khỏi dữ liệu chỉ đọc và chúng ta giới thiệu một mô hình đọc riêng biệt chỉ là một cấu trúc dữ liệu không có logic kinh doanh liên quan.
Mô hình ghi của chúng ta có thể phát triển một cách linh hoạt hơn vì không có thuộc tính bổ sung và lượng giao diện công khai được phơi bày đã giảm.
Mô hình đọc là tùy chỉnh cho các trường hợp sử dụng truy vấn và các hoạt động truy cập dữ liệu có thể được tối ưu hóa để có phản hồi nhanh hơn.
Cả CQRS Kiến trúc và CQRS Pattern đều hỗ trợ việc mô hình hóa thực thể tốt hơn, trong khi các đọc tối ưu hóa mạnh mẽ hơn nhiều trong CQRS Kiến trúc so với phiên bản Pattern, mà có thể vẫn yêu cầu một số hình thức truy vấn để truy cập tất cả dữ liệu.
Triển khai CQRS Pattern
Trong CQRS Kiến trúc, mô hình ghi (các thực thể) công bố các sự kiện khi có sự thay đổi trạng thái. Các sự kiện này được xử lý để cập nhật sự bền vững dành riêng cho mô hình đọc và cho các hoạt động đọc, chúng ta sử dụng các truy vấn SELECT
đơn giản. Chúng ta thậm chí có thể sử dụng một công nghệ lưu trữ khác để ưu tiên tốc độ đọc. Như đã đề cập, nó ảnh hưởng đến toàn bộ kiến trúc và cần các công cụ cụ thể để thích nghi.
Trong CQRS Pattern, chúng ta từ bỏ một số lợi ích của phiên bản Kiến trúc, nhưng việc triển khai nó đơn giản hơn. Việc giới thiệu nó vào các ứng dụng hiện có nhìn chung dễ hơn và mang lại lợi ích ngay lập tức.
- Tạo một mô hình mới cho các trường hợp sử dụng đọc chỉ bao gồm dữ liệu mà chúng yêu cầu.
- Tạo một trừu tượng truy cập dữ liệu mới để lấy mô hình này.
- Thay thế việc sử dụng các repository mô hình ghi bằng trừu tượng mới và sử dụng mô hình đọc để tạo ra phản hồi.
- Lợi nhuận.
Áp dụng vào ví dụ của chúng ta, trường hợp sử dụng kích hoạt vẫn giữ nguyên nhưng việc đọc được đơn giản hóa thành:
php
class ListUsers {
public function run(
ListUsersQuery $query,
): ListUserCollection {
return $query->findAll();
}
}
Vì ListUsersQuery
sẽ lo liệu việc truy vấn để lấy dữ liệu cần thiết.
Thực thể User
không còn cần dữ liệu dành cho các hoạt động đọc và có một giao diện công khai nhỏ hơn:
php
class User {
public function __construct(
//Các thuộc tính khác, nhưng không có cho các hoạt động đọc
private Status $status,
)
public function activate(): void {
//giống như trước
}
}
Thay đổi cấu trúc nội bộ của nó liên quan đến ít thay đổi hơn vì nó không còn được phơi bày nữa.
Ưu điểm
Như chúng ta thấy trong ví dụ đơn giản của mình, chúng ta không cần phải thay đổi các trường hợp sử dụng lệnh, và đối với trường hợp sử dụng truy vấn, chúng ta giới thiệu một mô hình mới và trừu tượng để lấy nó từ lưu trữ. Điều này có thể được thực hiện theo cách lặp đi lặp lại và với một lượng thay đổi giảm mỗi lần.
Khi chúng ta di chuyển các trường hợp sử dụng đọc sang CQRS Pattern, chúng ta có thể lần lượt loại bỏ các thuộc tính không sử dụng trong các thực thể của mình. Chúng trở nên ít chi tiết hơn và chỉ bao gồm dữ liệu được sử dụng cho các hoạt động lệnh. Một giao diện công khai nhỏ hơn có nghĩa là đóng gói tốt hơn và làm cho việc tái cấu trúc và phát triển chúng dễ dàng hơn so với một bề mặt công khai lớn hơn.
Thực tế, càng nhiều chúng ta gắn kết các thực thể với các hoạt động đọc, càng có khả năng chúng ta bắt chước cơ sở dữ liệu khi thiết kế chúng, điều này làm tổn hại đến sự linh hoạt trong thiết kế miền. CQRS giúp giảm thiểu cám dỗ đó.
Hiệu suất cũng có thể được cải thiện tiềm năng, giảm thiểu các truy vấn để chỉ lấy những gì chúng ta cần.
Cuối cùng, bằng cách áp dụng CQRS Pattern, chúng ta kết thúc với một thiết kế trường hợp sử dụng sẵn sàng để chuyển sang CQRS Kiến trúc dễ dàng vì các trường hợp sử dụng đọc sẽ về cơ bản giống nhau (nhiều thay đổi khác là cần thiết, nhưng ở các nơi khác; việc chuyển đổi dễ hơn nhưng không phải là điều đơn giản).
Nhược điểm
Luôn có một sự đánh đổi và CQRS Pattern cũng không ngoại lệ. Chúng ta đã sử dụng một ví dụ đơn giản trong bài viết này, nhưng đôi khi (luôn luôn) thực tế không đơn giản như vậy.
Nếu trường hợp sử dụng đọc của chúng ta yêu cầu dữ liệu được tính toán thông qua logic kinh doanh, chúng ta sẽ cần phải lấy các thực thể để thực hiện các phép tính (hoặc để sao chép logic kinh doanh trong các truy vấn, điều này là một ý tưởng rất tồi tệ).
Ví dụ, nếu liệt kê các đơn hàng hiển thị tổng đơn hàng và giá trị này không được lưu trữ mà được tính toán trong thực thể Order
(bằng cách cộng tổng giá mỗi dòng đơn hàng, áp dụng thuế, giảm giá, v.v.), chúng ta sẽ cần phải lấy và sử dụng Order
để xây dựng phản hồi của mình.
Chúng ta nên đánh giá xem việc áp dụng CQRS có xứng đáng với nỗ lực hay không, vì nó có thể giảm tính rõ ràng và thậm chí làm giảm hiệu suất do việc lấy các thực thể trên mô hình đọc.
Từ góc độ bảo trì, bằng cách áp dụng CQRS Pattern, chúng ta đang giới thiệu một mô hình mới mà không hoàn toàn phá vỡ sự kết nối bền vững giữa các mô hình ghi và đọc. Chúng ta nên kỳ vọng một số chi phí bảo trì nhất định. Các miền đơn giản mà không có logic kinh doanh quan trọng có thể hoàn toàn ổn khi trộn lẫn dữ liệu ghi và đọc vì chúng có xu hướng không thay đổi nhiều, và chúng ta có thể đang đầu tư tài nguyên vào một sự tách biệt mà có thể không mang lại lợi ích nào.
Kết luận
Chúng ta đã thấy cách mà chúng ta có thể hưởng lợi từ CQRS bằng cách giới thiệu nó vào các ứng dụng hiện có mà không cần thay đổi kiến trúc lớn. Lợi ích của điều này cần được phân tích trong từng trường hợp, vì nó không phải là một viên đạn bạc, và đôi khi có thể gây hại hơn là có lợi.
Nếu bạn, như tôi, cảm thấy khó chịu với những thực thể không thể đọc được bị lộn xộn với các getter và gắn liền với thiết kế cơ sở dữ liệu đến mức khó khăn khi phát triển chúng, hãy cân nhắc thử nghiệm với CQRS Pattern. Một lần tái cấu trúc trường hợp sử dụng được chọn kỹ lưỡng có thể ngay lập tức thể hiện tiềm năng của nó và thuyết phục những người hoài nghi và những người e ngại về việc thiết kế quá phức tạp.