Trong bài viết này chúng ta sẽ cùng thảo luận về design pattern CQRS, đây là một Microservice Design Pattern để chia việc đọc và ghi dữ liệu trong ứng dụng một cách độc lập và lược đồ hóa dữ liệu một cách tối ưu hóa.
Models trong hoạt động đọc (Read) và ghi (Write) dữ liệu
Với hầu hết các bạn có kinh nghiệm, chúng ta sẽ nhận thấy rằng có một thực tế là hầu hết các ứng dụng đều là CRUD, có chăng là khác nhau cái nghiệp vụ trước khi ghi hay trước khi đọc dữ liệu. Khi chúng ta thiết kế các ứng dụng này, chúng ta tạo các lớp thực thể (class entity) và các DAO (data access object) tương ứng cho các hoạt động CRUD. Chúng ta sử dụng các class model giống nhau cho tất cả các hoạt động CRUD. Tuy nhiên, các ứng dụng này có thể có các yêu cầu đọc và ghi hoàn toàn khác nhau. Ví dụ: Hãy xem xét một ứng dụng trong đó chúng ta có 3 bảng như được hiển thị ở đây.
- user
- product
- purchase_order
Tất cả các bảng này đã được chuẩn hóa. Khi tạo người dùng mới, một sản phẩm mới hoặc một đơn đặt hàng dữ liệu sẽ được lưu trực tiếp vào các bảng tương ứng. Nhưng hãy xem xét các yêu cầu đọc dữ liệu ra, chúng ta sẽ không chỉ muốn xem thông tin của người dùng, hoặc của sản phẩm, hoặc đơn đặt hàng. Thay vào đó, chúng ta sẽ quan tâm đến việc biết tất cả thông tin chi tiết đơn đặt hàng của một người dùng, tổng doanh số bán hàng, mức bán hàng.... Rất nhiều thông tin tổng hợp liên quan đến việc join nhiều bảng. Tất cả các bảng tham gia vào hành động đọc dữ liệu này cũng có thể yêu cầu ánh xạ DTO tương ứng khi trả về dữ liệu.
Khi chuẩn hóa các bảng, việc ghi sẽ dễ dàng hơn nhưng cũng gặp phải một khó khẳn là khó đọc dữ liệu hơn vì phải tổng hợp join dữ liệu từ nhiều bảng, do đó nó cũng ảnh hưởng đến hiệu suất tổng thể.
Một ứng dụng có thể có các yêu cầu đọc và ghi dữ liệu hoàn toàn khác nhau. Vì vậy, một model được tạo cho việc đọc có thể không hoạt động cho việc ghi. Để giải quyết vấn đề này, chúng ta có thể có các mô hình riêng biệt cho việc đọc và ghi.
Lưu lượng (Traffic) đọc (Read) so với ghi (Write) dữ liệu
Hầu hết các ứng dụng web việc đọc dữ liệu thường nặng hơn ghi. Ví dụ như Facebook, Twitter. Hay như ứng dụng đặt vé máy bay. Có lẽ ít hơn 5% người dùng sẽ đặt vé trong khi phần lớn người dùng ứng dụng sẽ tiếp tục tìm kiếm chuyến bay tốt nhất đáp ứng nhu cầu của họ.
Các ứng dụng có nhiều yêu cầu đối với các hoạt động đọc hơn so với các hoạt động ghi dữ liệu. Để giải quyết vấn đề này, chúng ta thậm chí có thể thiết kế các Microservices riêng biệt cho việc đọc và ghi. Vì vậy, chúng có thể được dễ dàng scale in scale out một cách độc lập tùy thuộc vào nhu cầu.
Đây là mẫu thiết kế CQRS Pattern (Command Query Responsibility Segregation Pattern - Mẫu thiết kế hân tách trách nhiệm truy vấn).
- Command: sửa đổi dữ liệu (insert, update,..) và không trả về bất cứ thứ gì (WRITE)
- Query: không sửa đổi nhưng trả về dữ liệu (READ)
Đó là cách tách Command (ghi) và Query (đọc) model của một ứng dụng để chia tỷ lệ các hoạt động đọc và ghi của một ứng dụng một cách độc lập . Chúng ta có thể giải quyết 2 vấn đề trên bằng cách sử dụng CQRS Pattern. Hãy xem nó có thể được thực hiện như thế nào.
Ứng dụng demo
Chúng ta hãy xem xét một ứng dụng đơn giản, trong đó chúng ta có 3 dịch vụ như hình dưới đây. (Tốt nhất là tất cả các dịch vụ này nên có cơ sở dữ liệu khác nhau. Trong phạm vi bài viết này, chúng ta đang sử dụng cùng một db). Bây giờ chúng ta chỉ quan tâm đến các chức năng liên quan đến dịch vụ đặt hàng cho bài viết này.
Chúng ta có 3 bảng là : user, product, purchase_order
CREATE TABLE users( id serial PRIMARY KEY, firstname VARCHAR (50), lastname VARCHAR (50), state VARCHAR(10)
); CREATE TABLE product( id serial PRIMARY KEY, description VARCHAR (500), price numeric (10,2) NOT NULL
); CREATE TABLE purchase_order( id serial PRIMARY KEY, user_id integer references users (id), product_id integer references product (id), order_date date
);
Giả sử rằng chúng ta có một interface cho order-service cho các thao tác đọc và ghi như hình dưới đây.
public interface OrderService { void placeOrder(int userIndex, int productIndex); void cancelOrder(long orderId); List<PurchaseOrderSummaryDto> getSaleSummaryGroupByState(); PurchaseOrderSummaryDto getSaleSummaryByState(String state); double getTotalSale();
}
- Nó có nhiều trách nhiệm như đặt hàng, hủy đơn hàng và truy vấn bảng tạo ra các loại kết quả khác nhau.
- Việc hủy đơn đặt hàng có thể liên quan đến nghiệp vụ bổ sung như ngày đặt hàng phải trong vòng 30 ngày và tính toán hoàn lại một phần, v.v.
Đây là cách thông thường mà chúng ta vẫn thường làm. Khi áp dụng CQRS Pattern thay vì có 1 interface duy nhất chịu trách nhiệm cho tất cả các hoạt động đọc và ghi. Chúng ta chia thành 2 interface khác nhau, một chuyên sử dụng cho việc đọc, một chuyên sử dụng cho việc ghi dữ liệu như sau:
Service layer
Order Query Service Interface
public interface OrderQueryService { List<PurchaseOrderSummaryDto> getSaleSummaryGroupByState(); PurchaseOrderSummaryDto getSaleSummaryByState(String state); double getTotalSale();
}
Order Command Service Interface
public interface OrderCommandService { void createOrder(int userIndex, int productIndex); void cancelOrder(long orderId);
}
Order Query Service Implementation
@Service
public class OrderQueryServiceImpl implements OrderQueryService { @Autowired private PurchaseOrderSummaryRepository purchaseOrderSummaryRepository; @Override public List<PurchaseOrderSummaryDto> getSaleSummaryGroupByState() { return this.purchaseOrderSummaryRepository.findAll() .stream() .map(this::entityToDto) .collect(Collectors.toList()); } @Override public PurchaseOrderSummaryDto getSaleSummaryByState(String state) { return this.purchaseOrderSummaryRepository.findByState(state) .map(this::entityToDto) .orElseGet(() -> new PurchaseOrderSummaryDto(state, 0)); } @Override public double getTotalSale() { return this.purchaseOrderSummaryRepository.findAll() .stream() .mapToDouble(PurchaseOrderSummary::getTotalSale) .sum(); } private PurchaseOrderSummaryDto entityToDto(PurchaseOrderSummary purchaseOrderSummary){ PurchaseOrderSummaryDto dto = new PurchaseOrderSummaryDto(); dto.setState(purchaseOrderSummary.getState()); dto.setTotalSale(purchaseOrderSummary.getTotalSale()); return dto; }
}
Order Command Service Implementation
@Service
public class OrderCommandServiceImpl implements OrderCommandService { private static final long ORDER_CANCELLATION_WINDOW = 30; @Autowired private UserRepository userRepository; @Autowired private ProductRepository productRepository; @Autowired private PurchaseOrderRepository purchaseOrderRepository; private List<User> users; private List<Product> products; @PostConstruct private void init(){ this.users = this.userRepository.findAll(); this.products = this.productRepository.findAll(); } @Override public void createOrder(int userIndex, int productIndex) { PurchaseOrder purchaseOrder = new PurchaseOrder(); purchaseOrder.setProductId(this.products.get(productIndex).getId()); purchaseOrder.setUserId(this.users.get(userIndex).getId()); this.purchaseOrderRepository.save(purchaseOrder); } @Override public void cancelOrder(long orderId) { this.purchaseOrderRepository.findById(orderId) .ifPresent(purchaseOrder -> { LocalDate orderDate = LocalDate.ofInstant(purchaseOrder.getOrderDate().toInstant(), ZoneId.systemDefault()); if(Duration.between(orderDate, LocalDate.now()).toDays() <= ORDER_CANCELLATION_WINDOW){ this.purchaseOrderRepository.deleteById(orderId); // TODO... thêm logic bổ sung hoàn tiền,... } }); }
}
Controller layer
Khi đó chúng ta cũng có các controller tương ứng dành riêng cho Query (đọc) và Command (ghi).
Chúng ta thậm chí có thể kiểm soát xem ứng dụng sẽ hoạt động ở chế độ đọc hay chế độ ghi dựa trên giá trị thuộc tính trong cấu hình.
Order Query Controller Query Controller chỉ có các yêu cầu GET. Nó không làm bất cứ điều gì sửa đổi dữ liệu.
@RestController
@RequestMapping("po")
@ConditionalOnProperty(name = "app.write.enabled", havingValue = "false")
public class OrderQueryController { @Autowired private OrderQueryService orderQueryService; @GetMapping("/summary") public List<PurchaseOrderSummaryDto> getSummary(){ return this.orderQueryService.getSaleSummaryGroupByState(); } @GetMapping("/summary/{state}") public PurchaseOrderSummaryDto getStateSummary(@PathVariable String state){ return this.orderQueryService.getSaleSummaryByState(state); } @GetMapping("/total-sale") public Double getTotalSale(){ return this.orderQueryService.getTotalSale(); }
}
Order Command Controller
@RestController
@RequestMapping("po")
@ConditionalOnProperty(name = "app.write.enabled", havingValue = "true")
public class OrderCommandController { @Autowired private OrderCommandService orderCommandService; @PostMapping("/sale") public void placeOrder(@RequestBody OrderCommandDto dto){ this.orderCommandService.createOrder(dto.getUserIndex(), dto.getProductIndex()); } @PutMapping("/cancel-order/{orderId}") public void cancelOrder(@PathVariable long orderId){ this.orderCommandService.cancelOrder(orderId); }
}
Application properties
spring: datasource: url: jdbc:postgresql://localhost:5432/productdb username: ... password: ...
app: write: enabled: false
Chúng ta có thể thay đổi thuộc tính dưới đây để chạy ứng dụng ở chế độ ghi (WRITE mode)
app.write.enabled=true
CQRS Pattern – Scaling
Chúng ta đã tách thành công mô hình đọc và ghi dữ liệu. Bây giờ chúng ta cần khả năng mở rộng quy mô hệ thống của mình một cách độc lập. Hãy xem làm thế nào chúng ta có thể đạt được điều đó.
Trên OrderCommandController
và OrderQueryController
, chúng ta đã thêm một điều kiện cho Spring Boot là có tạo controller này hay không. Nghĩa là, annotaion bên dưới sẽ là dấu hiệu để Spring Boot tạo Bean chỉ khi app.write.enabled
được đặt thành true
. Nếu không, nó sẽ không tạo Bean Controller này.
//for WRITE controller
@ConditionalOnProperty(name = "app.write.enabled", havingValue = "true")
//for READ controller
@ConditionalOnProperty(name = "app.write.enabled", havingValue = "false")
Vì vậy, dựa trên một thuộc tính, chúng ta thay đổi nếu ứng dụng sẽ hoạt động như một node chỉ đọc (read only) hoặc node chỉ ghi (write only). Nó cung cấp cho chúng ta khả năng chạy nhiều phiên bản của một ứng dụng với các chế độ khác nhau. Chúng ta có thể có 1 phiên bản ứng dụng của mình để ghi dữ liệu trong khi có thể có nhiều phiên bản ứng dụng chỉ để phục vụ các yêu cầu ghi dữ liệu. Chúng có thể được scale in - scale out một cách độc lập. Chúng ta có thể đặt chúng đằng sau bộ cân bằng tải / proxy như nginx - để các yêu cầu đọc/ghi có thể được chuyển tiếp đến các phiên bản thích hợp bằng cách sử dụng định tuyến dựa trên URI hoặc một số cơ chế khác.
Command vs Query DB
Trong ví dụ này chúng ta đã sử dụng cùng một DB. Chúng ta thậm chí có thể tiến thêm một cấp nữa bằng cách tách cơ sở dữ liệu cho việc đọc và ghi. Có nghĩa là, bất kỳ hoạt động ghi nào sẽ đẩy các thay đổi đến cơ sở dữ liệu sử dụng cho việc đọc thông qua luồng xử lý sự kiện - event sourcing.
CQRS Pattern mang lại nhiều lợi ích như sử dụng các DB khác nhau cho cùng một ứng dụng với lược đồ dữ liệu được tối ưu hóa tốt. Tuy nhiên, cần lưu ý rằng dữ liệu cuối cùng sẽ phải được nhất quán trong cách tiếp cận này. Vì chúng ta có thể chạy nhiều phiên bản của các node đọc với DB của riêng nó, chúng ta thậm chí có thể đặt chúng ở các nơi khác nhau để cung cấp trải nghiệm người dùng với độ trễ tối thiểu.
Tổng kết
Ngoài các lợi ích như dễ dàng bảo trì và mở rộng quy mô, CQRS Pattern cung cấp một số lợi ích khác như các team phát triển song song. Có nghĩa là, 2 nhóm phát triển khác nhau có thể làm việc cùng nhau trên một Microservice. Khi một nhóm người có thể làm việc với Query trong khi nhóm người khác có thể làm việc ở phía Command.
Nguồn: https://thenewstack.wordpress.com/2021/11/25/msdp-cqrs-pattern/
Follow me: thenewstack.wordpress.com