Giờ đang tới giai đoạn xem xét các tình huống trả dữ liệu về cho client thông qua các truy vấn. Nếu như Query Object Pattern (QOP) rất phù hợp khi sử dụng với Repository thì với những truy vấn đòi hỏi phối hợp giữa nhiều Aggregate hay thậm chí là giữa nhiều dịch vụ đòi hỏi ta cần những cách làm phù hợp hơn. Bài viết trước đã có phần đề cập ngắn gọn về API Composition Pattern và BFF. Nếu như API Composition là một mẫu thiết kế thường gặp trong kiến trúc Microservices để tổng hợp dữ liệu từ nhiều dịch vụ thì BFF lại là một mẫu thiết kế kiến trúc bởi nó hình thành nên một lớp chuyên biệt đứng giữa các dịch vụ backend và lớp giao diện đặc thù. Giờ ta sẽ cùng đi vào chi tiết hơn.
Yêu cầu cần thực thi: Tìm kiếm danh sách Product theo Brand Name.
API Composition
API Composition là cách đơn giản nhất để triển khai các truy vấn trong kiến trúc phân tán nói chung và Microservices nói riêng, bằng cách truy vấn các dịch vụ cung cấp dữ liệu và tổng hợp lại kết quả cuối cùng rồi trả về cho client.
Mẫu này phổ biến đến nỗi hầu như các lập trình viên vẫn đang dùng và nghĩ tới đầu tiên mà có thể vẫn không biết cách làm đó có một cái tên đặt cho nó.
public ReturnData queryData(Param param) { <1> DataService1 dataService1 = service1.query(param.param1); <2> Param2 param2 = composeParam2(param, dataService1); <3> DataService2 dataService2 service2.query(param2); <4>return aggregateData(dataService1, dataService2);}
Hãy đi từ những thứ cơ bản nhất mà chúng ta thường làm. Đầu tiên là các bảng trong cơ sơ dữ liệu quan hệ (RD) với data model cơ bản nhất Option 1 trong bài trước:
Hình 1 cho thấy mối quan hệ chặt chẽ giữa hai bảng BRANDS và PRODUCTS, dù chúng có những nhược điểm trong việc mở rộng nhưng hãy bỏ qua ở đây. Dữ liệu nằm tại hai bảng, vậy để đáp ứng yêu cầu trên, ta vẫn thường làm đó là JOIN chúng lại với nhau:
SELECT PRODUCT_ID, PRODUCT_NAME, BRAND_ID, BRAND_NAME FROM BRANDS INNER JOIN PRODUCTS ON BRANDS.ID = PRODUCTS.BRAND_ID WHERE BRANDS.NAME = 'Apple'
Đây là kỹ thuật đã "ăn vào máu" của lập trình viên rồi và nó là đúng. Giờ ta áp dụng với cấp độ dịch vụ:
Ngoài cấp độ bảng là cấp độ dịch vụ. Giờ đây, giả sử rằng nghiệp vụ đã đủ phong phú và khối lượng dữ liệu cũng như mức độ sử dụng đã đủ đi tới quyết định tách biệt Product ra khỏi Brand để đưa chúng vào các dịch vụ micro. Lúc này, Product Service và Brand Service cũng tương tự như các bảng PRODUCTS và BRANDS vậy: Chúng chứa các dữ liệu đặc thù, có tham chiếu thông tin (trong Product Service có BrandId) nhưng chỉ là tham chiếu đó mang tính logic chứ không phải là các cơ chế chặt chẽ như trong database. Vậy thì, cũng là một cách tự nhiên để ta "JOIN" chúng lại với nhau để thực hiện nhiệm vụ:
- Lấy dữ liệu từ Brand Service với điều kiện BrandName rồi lấy ra danh sách BrandId để đưa vào:
- Lấy dữ liệu Product Service, để rồi cuối cùng:
- Tổng hợp kết quả và trả về cho client.
Sự khác nhau nằm ở phạm vi thực hiện và "ngôn ngữ" sử dụng:
- SQL thực thi trong một cơ sở dữ liệu nơi xử lý trong cùng một máy chủ còn API Composition đòi hỏi dữ liệu di chuyển qua tầng network rồi kết hợp ở tầng ứng dụng.
- Ngôn ngữ truy vấn SQL là rất cụ thể và đã trở thành một tiêu chuẩn thì việc kết hợp dữ liệu trong API Composition lại không có ngôn ngữ chung. Sau này ta sẽ sử dụng GraphQL để bù đắp cho API Composition.
Các API thành phần
Đầu tiên, các dịch vụ cơ bản có các API cung cấp dữ liệu của chúng như sau:
Brand Service GET /api/brands?name=
Kết quả trả về có cấu trúc: { brandId, brandName } cơ bản.
Product Service GET /api/products?brandIds=
Kết quả trả về: { productId, productName, brandId }
Các API này là tốt vì chúng thể hiện đúng vai trò của từng dịch vụ chuyên trách và độc lập.
Triển khai API Composition
Giờ là lúc kết hợp dữ liệu của hai API trên lại với nhau để mang tới kết quả cuối cùng. Thủ tục sẽ là:
- Gọi Brand Service với brandName, ví dụ, "Adidas".
- Nhận về danh sách Brand, trích xuất danh sách BrandId.
- Gọi Product Service với danh sách BrandId.
- Nhận được danh sách Product.
- "JOIN" trong bộ nhớ: Lặp qua danh sách Product, dùng BrandId để kết hợp với danh sách Brand để cho ra kết quả cuối cùng của từng Product.
- Trả về danh sách kết quả đã tổng hợp.
Triển khai API tại từng dịch vụ
Hình 3 cho thấy hai public API mà hai dịch vụ công bố ra bên ngoài. Bên trong chúng có cấu trúc như sau:
Hình 4 thể hiện cấu trúc chung của dịch vụ theo kiến trúc Hexagonal (hay Clean Architecture) mà Brand Service và Product Service tuân theo. Thông qua method tại Controller, một RESTful API chìa ra.
- Lời gọi từ bên ngoài vào API này dẫn tới luồng xử lý bắt đầu từ Controller. Controller không trực tiếp gọi tới Application layer mà thông qua một lớp Facade mỏng trung gian.
- Facade layer đóng vai trò như một adapter giữa Controller và Application layer: Chuyển đổi dữ liệu giữa hai lớp, ngoài ra, nó còn kiểm soát transaction cũng như cả "thông dịch" các ngoại lệ mà tầng Application phát ra để trả về dạng Exception phù hợp với Adapter.
- Facade gọi tới Application layer qua Input Port mà Application layer công khai. Bên trong tầng này, các Use Case được thực thi thông qua các lời gọi tới Domain layer.
- Luồng xử lý này, hay data flow được thể hiện qua mũi tên màu tím trong hình 4.
Chi tiết hơn vào nửa sau của luồng data: Trả kết quả về từ Application layer tới Facade và cuối cùng là Controller:
Luồng dữ liệu trong hình 5 cho thấy dữ liệu đi ra: Domain -> Application -> Facade -> Controller -> Client. Bởi trong kiến trúc này có 3 ranh giới tương ứng với 3 đường tròn nên một cách chặt chẽ, ta có 3 DTO, nhưng để đơn giản hóa trong ví dụ này, ta sẽ đề cập sơ bộ về DTO giữa Application và Adapter (cụ thể là Facade).
Code tại tầng facade có dạng:
public class BrandFacade { private final SearchBrand searchBrand; public List< BrandDTO > searchBrand(String brandName) { List< brands > searchBrand.execute(brandName); return convert(brands); }}- Facade có sử dụng một use case tại tầng Application là SearchBrand.
- Facade tiếp nhận param từ Controller gửi vào, thực hiện convert sang tham số phù hợp với giao diện mà Application đề ra (trường hợp này không cần vì kiểu tham số "vô tình" trùng nhau.
- Facade gọi use case SearchBrand và nhận kết quả trả về.
- Facade cần convert tiếp kết quả đó để trả về cho Controller.
Bước 3 cho thấy rằng Application layer không sử dụng DTO mà gửi trả về các Aggregate thuần túy. Bước 4 convert Brand thành BrandDTO. Với sự xuất hiện lớp Facade mỏng này, Controller không cần convert sang DTO để trả về nữa mà nó sử dụng ngay BrandDTO trên để trả về cho Client (đây là một lựa chọn).
Bước 3, Application không định nghĩa DTO riêng có vấn đề gì không? Giao diện mà Application công khai là:
public interface SearchBrand { List< Brand > execute(String brandName);}Đây là một sự "cám dỗ", thậm chí, đôi khi ta còn bị sự cám dỗ này đưa đi xa hơn nữa bằng cách trả về thẳng cho Client chính List< Brand > này vì sự thuận tiện của nó. Hãy phân tích điều này:
- Client có trong tay Brand Aggregate nguyên thủy có sao không? Có đảm bảo bảo mật gì không? Thật ra thì... không vấn đề gì đáng kể cả. Client có trong tay Brand là tốt vì họ có đươc thông tin họ cần. Đứng trên góc nhìn của tầng Domain, ta định nghĩa ra Repository cho Client sử dụng (bài này) và đó là trách nhiệm của chúng ta. Nếu ta muốn không trả về Brand nguyên thủy thì nên có ngay một lớp DTO tại ranh giới Domain:
List< BrandDTO > findAll(BrandQuery ..)tại BrandRepository. - Vấn đề đến từ sự "cám dỗ" vì tính thuận tiện: Sử dụng ngay chính Brand để trả về cho Client khiến ta không cần tạo ra các DTO và các converter trung gian. Như Uncle Bob đã đề cập, nếu chặt chẽ, giao diện tại các tầng nên là độc lập, tự chủ để tăng tính... độc lập của chúng:
List< brands > searchBrand.execute(brandName);
Giao diện mà Application đưa ra ở đây cho thấy:
- Khả năng độc lập ở tham số truyền vào:
brandName: Stringbởi String là một kiểu dữ liệu phổ biến và hơn hết là nó tĩnh. - Sự phụ thuộc vào Brand ở kết quả trả về: Đây chính là sự cám dỗ mà ta đang bàn. Nhìn kỹ hơn vào hình sau:
Hình 6 cho thấy một số điều ẩn sâu nhưng thú vị mà ít khi ta để ý. Sơ đồ đó được mô tả như sau:
- Tầng Domain đang có Brand - một đối tượng nghiệp vụ quan trọng.
- Tầng Application có Input Port là SearchBrand - một interface mà ta thực hiện lời gọi execute bên trên. SearchBrand phụ thuộc vào Brand qua giao diện hàm. SearchBrandUseCase là class triển khai giao diện InputPort, được ẩn bên trong mà Client không biết được. Client ở đây là tầng Adapter chứa Facade.
- Tầng Adapter chứa Facade sử dụng SearchBrand mà không cần quan tâm triển khai cụ thể vì ở đây áp dụng DIP.
Mọi thứ dường như hoàn hảo cho đến khi bị giảm bớt do Facade phụ thuộc gián tiếp vào Brand. Điều này thể hiện ở mũi tên phụ thuộc màu đỏ. Sự phụ thuộc này trông qua thấy rất tự nhiên nhưng nó vô tình tạo ra sự phụ thuộc chặt chẽ từ tầng Adapter tới tầng Domain. Căn nguyên tới từ giao diện mà Input Port tạo ra: List< brands > searchBrand.execute(brandName); Mỗi khi Brand thay đổi, tầng Adapter có thể không cần quan tâm cũng cần biên dịch lại bởi giao diện tại tầng Application cũng cần biên dịch lại. Lý do thay đổi của Brand hay tổng quát hơn là tầng Domain lại rất khác lý do của tầng Application: đó có thể là một Invariant cài đặt bên trong, có thể là một thuộc tính dữ liệu bổ sung mà Client không cần quan tâm. Nếu Application tạo ra giao diện của chính nó, Adapter thay đổi theo tạo thành một sự tự nhiên cao hơn. Tóm lại, một API tốt nên do chính lớp (layer) sở hữu trực tiếp định nghĩa chứ không nên lấy từ lớp khác, nhất là khi lớp đó không ổn định.
Đi tiếp tới Controller, lúc này, nó lấy kết quả của Facade và trả về ngay cho Client:
@RestController@RequestMapping("/api/brands")public class BrandController {
private final BrandFacade brandFacade;
@Autowired public BrandController(BrandFacade brandFacade) { this.brandFacade = brandFacade; }
@GetMapping public List findBrandsByName(@RequestParam("name") String brandName) { return brandFacade.searchBrand(brandName); }}
Cách triển khai các API tại Brand Service và Product Service cùng theo dạng như vậy:
@RestController@RequestMapping("/api/products")public class ProductController { ... @GetMapping public ResponseEntity> findProductsByBrandIdsV2( @RequestParam("brandId") List brandIdsList) { if (brandIdsList == null || brandIdsList.isEmpty()) { return ResponseEntity.badRequest().body(Collections.emptyList()); } // Ủy quyền cho Facade List products = productFacade.findProductsByBrandIds(brandIdsList); return ResponseEntity.ok(products); }}Giờ hãy đi tới triển khai API Composition.
Chú ý nhỏ: Code tại các tầng Controller và Facade đều không có các khối try-catch để chuyển đổi các ngoại lệ có thể phát sinh thành các response phù hợp cho end-user. Đó là vì ở đây, tôi đã triển khai một lớp Global Advice chuyên xử lý các ngoại lệ nghiệp vụ được định nghĩa sẵn của ứng dụng. Cách làm này sẽ giúp code tại tầng Adapter thêm "sạch", tập trung hoàn toàn vào nghiệp vụ. Nội dung này sẽ được bàn tới chi tiết hơn ở một bài viết phía sau.
Triển khai API Composition
Giờ là lúc triển khai logic 6 bước của API Composition bên trên. Logic này chắc chắn sẽ cần Controller để làm điểm đầu vào cho Client. Vậy logic 6 bước đặt tại đâu sẽ là phù hợp nhất?
Tại Domain layer
Tại tầng này, sẽ phù hợp nhất khi đặt trong một Domain Service bởi đây là logic điều phối giữa các đối tượng Domain. Các đối tượng Domain trường hợp này là Brand và Product, được lấy về thông qua các Repository. Khá hợp lý. Đi sâu hơn vào lựa chọn này, ta thấy rằng các đối tượng Brand và Product lại là những đối tượng thuộc về dịch vụ khác, liệu điều này có không hợp lý không?
Có mấy khía cạnh cần đánh giá.
Thứ nhất, logic này có đủ để cần triển khai trong Domain Service không? Nghĩa là nếu triển khai tại Application bên ngoài, logic này đang bị rò rỉ? Thông thường, một Domain Service sẽ triển khai một "hành động nghiệp vụ" làm thay đổi trạng thái của Aggregate. Do đó, với trường hợp này, ta nên triển khai tại Application.
Thứ hai, mặc dù Brand và Product là các đối tượng chính trong những Bounded Context khác nhưng thông qua các Repository (những Output Port) được triển khai dưới dạng Anti-Corruption Layer (ACL), những rò rỉ miền được cô lập vào trong miền của chúng ta, thì những đối tượng này vẫn thực sự thuộc về Domain đang xử lý, chỉ có điều, chúng là read-only.
Tại Application layer
Từ phân tích tại Domain layer, dẫn tới Application là nơi phù hợp nhất để triển khai API Composition. Cụ thể:
Các Domain Object nằm tại tầng Domain: Brand, Product nhưng có thể với những thuộc tính khác với những đối tượng cùng tên tại hai dịch vụ nguồn.
public class Brand { private String id; private String name;
public Brand(String id, String name) { this.id = id; this.name = name; } // Getters ...}
public class Product { private String id; private String name; private String brandId;
public Product(String id, String name, String brandId) { this.id = id; this.name = name; this.brandId = brandId; }
// Getters ...}
Kết quả trả về:
public class ComposedProduct { private String productId; private String productName; private String brandName;
public ComposedProduct(String productId, String productName, String brandName) { this.productId = productId; this.productName = productName; this.brandName = brandName; } // Getters...}
Các Repository là những Output Port nằm tại Application, mà không cần nằm tại Domain.
public interface BrandRepository { List< Brand > findByName(String name);}
public interface ProductRepository { List< Product > findByBrandIds(List< String > brandIds);}
Một Input Port interface và class Use Case để triển khai logic 6 bước.
public interface SearchProductsInputPort { List< ComposedProduct > execute(String brandName);}
Triển khai Use case:
public class SearchProductsUseCase implements SearchProductsInputPort { private final BrandRepository brandRepository; private final ProductRepository productRepository; ... @Override public List execute(String brandName) { // Bước 1 & 2: Gọi Port để lấy Brand và trích xuất ID --- List brands = brandRepository.findByName(brandName); List brandIds = extractBrandIds(brands); // Bước 3 & 4: Gọi Port để lấy Product --- List products = productRepository.findByBrandIds(brandIds); // Bước 5: "JOIN" trong bộ nhớ --- List finalResult = join(brands, products); return finalResult; }}
Controller:
@RestController@RequestMapping("/api/bff/search")public class ProductSearchController { private final SearchProductsInputPort searchProductsInputPort;... @GetMapping("/products") public List searchProductsByBrandName( @RequestParam("brandName") String brandName) { // 1. Gọi Use Case (Input Port) List domainResult = searchProductsInputPort.execute(brandName); // 2. Map từ Domain Model sang DTO để trả về cho client return mapper.toDtoList(domainResult); }}
Tại Adapter layer
Sẽ là một sự "tối giản" đến quá mức nếu triển khai tại đây, bởi khi đó, ta đã bỏ qua mọi lợi thế của Hexagonal cũng như DIP.
Vị trí triển khai
Câu hỏi mang tính kiến trúc: Đặt API Composition ở đâu?
Lựa chọn 1: Đặt tại Product Service hoặc Brand Service
Có thể nói, đây là lựa chọn thường gặp nhất vì nó mang lại một số ưu điểm sau:
- "Tính tự nhiên" của dữ liệu. Theo đó, thông thường ta sẽ đặt tại Product Service vì truy vấn này là lấy về dữ liệu Product có kết hợp Brand, nhưng Brand chỉ là thông tin bổ sung.
- Tối ưu hóa hiệu năng vì ít nhất dữ liệu thành phần (Product hoặc Brand) đã nằm tại dịch vụ đó.
- Một trong hai đối tượng Domain (là Product/Brand) được dùng lại.
Tuy nhiên, nhược điểm là: hai dịch vụ bị phụ thuộc chặt chẽ vào nhau bởi lời gọi synchronous. Ví dụ, triển khai API Composition tại Product Service:
Lựa chọn 2: Đặt tại một dịch vụ trung gian
Thay vì đặt tại một trong hai dịch vụ, ta đặt nó ở một dịch vụ chuyên trách, gọi là: Product Search Service.
Product Search Service giúp cho sự độc lập của Brand Service và Product Service được giữ vững, logic kết hợp dữ liệu được tập trung hóa ở nơi hợp lý giúp tách rời các mối quan tâm. Tuy nhiên, mô hình này gặp phải vấn đề khi kết quả trả về cần hiển thị trên nhiều loại thiết bị khác nhau: Mobile, Web, Console, v.v. Mobile chỉ cần trả về một tập thuộc tính trong khi Web thì có thể hiển thị nhiều hơn nên cần tập thuộc tính rộng hơn. Lúc này, ta cần một mẫu thiết kế phù hợp hơn, gọi là Backend For Frontend (BFF).
