|
Ghi chú
|
Đây là bài 3 trong series microservices e-commerce. Code của bài ở tag |
Trang chi tiết sản phẩm của một shop cần ba thứ: thông tin sản phẩm, đánh giá của người mua, và các sản phẩm gợi ý. Ba thứ đó do ba service khác nhau sở hữu. Client không nên phải gọi ba lần rồi tự ghép, nên ta đặt một service đứng trước làm việc đó: product-composite-service.
Cuối bài bạn sẽ có:
-
review-service(cổng 7003) vàrecommendation-service(cổng 7002). -
product-composite-service(cổng 7000) gọi ba service song song và gộp kết quả. -
Trang sản phẩm vẫn trả về được khi review hoặc recommendation sập.
GET /product-composite/1
|
product-composite :7000
+----------------+----------------+
| | |
GET /product/1 GET /review?... GET /recommendation?...
product :7001 review :7003 recommendation :7002
Hai service lõi còn lại
review-service và recommendation-service có cấu trúc giống hệt product-service, nên mình chỉ nêu chỗ khác.
Hợp đồng trong module api
package com.ecommerce.api.core.review;
public record Review(
int productId,
int reviewId,
String author,
String subject,
String content,
String serviceAddress) {
}
package com.ecommerce.api.core.review;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import reactor.core.publisher.Flux;
public interface ReviewService {
@GetMapping(value = "/review", produces = "application/json")
Flux<Review> getReviews(@RequestParam(value = "productId", required = true) int productId);
}
Lần này kiểu trả về là Flux<Review> vì một sản phẩm có nhiều đánh giá. productId đi qua query string (/review?productId=1) chứ không nằm trong path, vì ta đang lọc một tập review, không phải lấy một review cụ thể.
Recommendation và RecommendationService tương tự, với trường rate thay cho subject:
public record Recommendation(
int productId,
int recommendationId,
String author,
int rate,
String content,
String serviceAddress) {
}
Implementation giả lập
@RestController
public class ReviewServiceImpl implements ReviewService {
private static final Logger LOG = LoggerFactory.getLogger(ReviewServiceImpl.class);
private final ServiceUtil serviceUtil;
public ReviewServiceImpl(ServiceUtil serviceUtil) {
this.serviceUtil = serviceUtil;
}
@Override
public Flux<Review> getReviews(int productId) {
if (productId < 1) {
throw new InvalidInputException("Invalid productId: " + productId);
}
// Giả lập: sản phẩm 213 chưa có đánh giá nào.
if (productId == 213) {
LOG.debug("No reviews found for productId: {}", productId);
return Flux.empty();
}
String address = serviceUtil.getServiceAddress();
List<Review> list = List.of(
new Review(productId, 1, "Author 1", "Subject 1", "Content 1", address),
new Review(productId, 2, "Author 2", "Subject 2", "Content 2", address),
new Review(productId, 3, "Author 3", "Subject 3", "Content 3", address));
LOG.debug("/reviews response size: {}", list.size());
return Flux.fromIterable(list);
}
}
RecommendationServiceImpl giống hệt, chỉ khác là sản phẩm 113 không có gợi ý. Thêm hai quy ước giả lập này vào danh sách từ bài trước:
| productId | Ý nghĩa |
|---|---|
|
Sản phẩm không tồn tại (404) |
|
Không có gợi ý |
|
Không có đánh giá |
số âm |
Đầu vào sai (422) |
Mỗi service có build.gradle, class main (vẫn @ComponentScan("com.ecommerce")) và application.yml giống product-service, chỉ đổi server.port thành 7003 cho review và 7002 cho recommendation. Test cũng theo khuôn WebTestClient của bài 2, thêm một ca thiếu tham số:
@Test
void getReviewsMissingParameter() {
client.get()
.uri("/review")
.accept(APPLICATION_JSON)
.exchange()
.expectStatus().isBadRequest()
.expectBody()
.jsonPath("$.path").isEqualTo("/review");
}
Nhớ thêm ba module mới vào settings.gradle:
include ':microservices:review-service'
include ':microservices:recommendation-service'
include ':microservices:product-composite-service'
Hợp đồng của composite
Composite trả về một đối tượng gộp. Mình tách các bản summary riêng vì trang sản phẩm không cần productId lặp lại trong từng review:
package com.ecommerce.api.composite.product;
public record ProductAggregate(
int productId,
String name,
BigDecimal price,
List<RecommendationSummary> recommendations,
List<ReviewSummary> reviews,
ServiceAddresses serviceAddresses) {
}
public record ReviewSummary(int reviewId, String author, String subject, String content) {
}
public record RecommendationSummary(int recommendationId, String author, int rate, String content) {
}
/** Địa chỉ instance đã xử lý từng phần: composite, product, review, recommendation. */
public record ServiceAddresses(String cmp, String pro, String rev, String rec) {
}
Mỗi record nằm trong file riêng của nó trong package com.ecommerce.api.composite.product. Interface:
public interface ProductCompositeService {
@GetMapping(value = "/product-composite/{productId}", produces = "application/json")
Mono<ProductAggregate> getProduct(@PathVariable int productId);
}
Lớp tích hợp: nói chuyện với các service khác
Sách tách phần gọi HTTP ra một class riêng, ProductCompositeIntegration. Điểm hay: class này implement chính các interface trong api. Với phần còn lại của composite, gọi integration.getProduct(1) trông y như gọi một service cục bộ; chuyện đi qua mạng bị giấu kín ở đây.
@Component
public class ProductCompositeIntegration implements ProductService, RecommendationService, ReviewService {
private static final Logger LOG = LoggerFactory.getLogger(ProductCompositeIntegration.class);
private final WebClient webClient;
private final ObjectMapper mapper;
private final String productServiceUrl;
private final String recommendationServiceUrl;
private final String reviewServiceUrl;
public ProductCompositeIntegration(
WebClient.Builder webClientBuilder,
ObjectMapper mapper,
@Value("${app.product-service.host}") String productServiceHost,
@Value("${app.product-service.port}") int productServicePort,
@Value("${app.recommendation-service.host}") String recommendationServiceHost,
@Value("${app.recommendation-service.port}") int recommendationServicePort,
@Value("${app.review-service.host}") String reviewServiceHost,
@Value("${app.review-service.port}") int reviewServicePort) {
this.webClient = webClientBuilder.build();
this.mapper = mapper;
productServiceUrl = "http://" + productServiceHost + ":" + productServicePort + "/product/";
recommendationServiceUrl = "http://" + recommendationServiceHost + ":" + recommendationServicePort
+ "/recommendation?productId=";
reviewServiceUrl = "http://" + reviewServiceHost + ":" + reviewServicePort + "/review?productId=";
}
@Override
public Mono<Product> getProduct(int productId) {
String url = productServiceUrl + productId;
LOG.debug("Will call getProduct API on URL: {}", url);
return webClient.get().uri(url).retrieve()
.bodyToMono(Product.class)
.onErrorMap(WebClientResponseException.class, this::handleException); // (1)
}
@Override
public Flux<Review> getReviews(int productId) {
String url = reviewServiceUrl + productId;
LOG.debug("Will call getReviews API on URL: {}", url);
return webClient.get().uri(url).retrieve()
.bodyToFlux(Review.class)
.onErrorResume(ex -> { // (2)
LOG.warn("Got an exception while requesting reviews, return zero reviews: {}", ex.getMessage());
return Flux.empty();
});
}
// getRecommendations(...) giống hệt getReviews(...)
private Throwable handleException(WebClientResponseException ex) {
switch (ex.getStatusCode().value()) {
case 404:
return new NotFoundException(getErrorMessage(ex));
case 422:
return new InvalidInputException(getErrorMessage(ex));
default:
LOG.warn("Got an unexpected HTTP error: {}, will rethrow it", ex.getStatusCode());
LOG.warn("Error body: {}", ex.getResponseBodyAsString());
return ex;
}
}
private String getErrorMessage(WebClientResponseException ex) {
try {
return mapper.readValue(ex.getResponseBodyAsString(), HttpErrorInfo.class).message(); // (3)
} catch (IOException ioex) {
return ex.getMessage();
}
}
}
-
product-service trả 404 thì WebClient ném
WebClientResponseException. Ta đổi nó lại thànhNotFoundException, để bộ xử lý lỗi chung ở composite trả 404 cho client. Không có bước này, mọi lỗi từ service con đều thành 500. -
Review và gợi ý là phần phụ của trang sản phẩm. Lỗi ở đây thì trả về danh sách rỗng thay vì làm hỏng cả trang. Đây là phiên bản thô sơ của fallback; bài về Resilience4j sẽ làm việc này bài bản hơn.
-
Đọc lại
HttpErrorInfotừ body lỗi để giữ nguyên message gốc, ví dụNo product found for productId: 13.
|
Ghi chú
|
Chỗ khác sách: ở chương 3, sách dùng |
Gộp kết quả: Mono.zip
@RestController
public class ProductCompositeServiceImpl implements ProductCompositeService {
private final ServiceUtil serviceUtil;
private final ProductCompositeIntegration integration;
public ProductCompositeServiceImpl(ServiceUtil serviceUtil, ProductCompositeIntegration integration) {
this.serviceUtil = serviceUtil;
this.integration = integration;
}
@Override
public Mono<ProductAggregate> getProduct(int productId) {
// Gọi song song cả 3 service, chờ đủ kết quả rồi mới gộp.
return Mono.zip(
integration.getProduct(productId),
integration.getRecommendations(productId).collectList(),
integration.getReviews(productId).collectList())
.map(t -> createProductAggregate(t.getT1(), t.getT2(), t.getT3(), serviceUtil.getServiceAddress()));
}
private ProductAggregate createProductAggregate(Product product, List<Recommendation> recommendations,
List<Review> reviews, String serviceAddress) {
List<RecommendationSummary> recommendationSummaries = recommendations.stream()
.map(r -> new RecommendationSummary(r.recommendationId(), r.author(), r.rate(), r.content()))
.toList();
List<ReviewSummary> reviewSummaries = reviews.stream()
.map(r -> new ReviewSummary(r.reviewId(), r.author(), r.subject(), r.content()))
.toList();
String productAddress = product.serviceAddress();
String reviewAddress = reviews.isEmpty() ? "" : reviews.get(0).serviceAddress();
String recommendationAddress = recommendations.isEmpty() ? "" : recommendations.get(0).serviceAddress();
ServiceAddresses serviceAddresses =
new ServiceAddresses(serviceAddress, productAddress, reviewAddress, recommendationAddress);
return new ProductAggregate(product.productId(), product.name(), product.price(),
recommendationSummaries, reviewSummaries, serviceAddresses);
}
}
Mono.zip subscribe cả ba nguồn cùng lúc, nên ba request HTTP bay đi song song. Thời gian phản hồi của trang là thời gian của service chậm nhất, không phải tổng ba service. Nếu getProduct lỗi thì cả zip lỗi theo, đúng ý ta: không có sản phẩm thì không có trang.
Cấu hình địa chỉ các service
server.port: 7000
server.error.include-message: always
spring.application.name: product-composite
app:
product-service:
host: localhost
port: 7001
recommendation-service:
host: localhost
port: 7002
review-service:
host: localhost
port: 7003
logging:
level:
root: INFO
com.ecommerce: DEBUG
Host và cổng đang ghi cứng. Bài 4 sẽ đổi chúng khi chạy trong Docker, và đến bài về Eureka thì bỏ hẳn, thay bằng tên service.
Chạy cả bốn service
./gradlew build
java -jar microservices/product-service/build/libs/*.jar &
java -jar microservices/review-service/build/libs/*.jar &
java -jar microservices/recommendation-service/build/libs/*.jar &
java -jar microservices/product-composite-service/build/libs/*.jar &
curl -s localhost:7000/product-composite/1 | jq .
{
"productId": 1,
"name": "name-1",
"price": 199000,
"recommendations": [
{ "recommendationId": 1, "author": "Author 1", "rate": 1, "content": "Content 1" },
{ "recommendationId": 2, "author": "Author 2", "rate": 2, "content": "Content 2" },
{ "recommendationId": 3, "author": "Author 3", "rate": 3, "content": "Content 3" }
],
"reviews": [
{ "reviewId": 1, "author": "Author 1", "subject": "Subject 1", "content": "Content 1" },
{ "reviewId": 2, "author": "Author 2", "subject": "Subject 2", "content": "Content 2" },
{ "reviewId": 3, "author": "Author 3", "subject": "Subject 3", "content": "Content 3" }
],
"serviceAddresses": {
"cmp": "vm/127.0.0.1:7000",
"pro": "vm/127.0.0.1:7001",
"rev": "vm/127.0.0.1:7003",
"rec": "vm/127.0.0.1:7002"
}
}
serviceAddresses cho thấy bốn phần do bốn tiến trình khác nhau xử lý. Lỗi từ product-service đi xuyên qua composite với đúng status và message:
$ curl -s localhost:7000/product-composite/13
{"timestamp":"2026-10-02T05:57:56.405471652Z","path":"/product-composite/13","httpStatus":"NOT_FOUND","message":"No product found for productId: 13"}
Thử tắt review-service
Đây là thí nghiệm mình thích nhất ở bài này. Tắt review-service (kill tiến trình đó) rồi gọi lại:
$ curl -s localhost:7000/product-composite/1 | jq -c '{reviews: (.reviews|length), recommendations: (.recommendations|length)}'
{"reviews":0,"recommendations":3}
Trang sản phẩm vẫn trả về 200, chỉ thiếu phần đánh giá, và log của composite ghi lại một dòng WARN … return zero reviews. Đây chính là lợi ích "lỗi không lan ra cả hệ thống" đã nói ở bài 1, dù mới ở dạng đơn giản nhất.
Dừng tất cả khi xong:
kill $(jobs -p)
Test composite mà không cần ba service kia
Test của composite không nên phụ thuộc vào việc ba service khác có đang chạy hay không. Ta thay ProductCompositeIntegration bằng một mock:
@SpringBootTest(webEnvironment = RANDOM_PORT)
class ProductCompositeServiceApplicationTests {
private static final int PRODUCT_ID_OK = 1;
private static final int PRODUCT_ID_NOT_FOUND = 2;
private static final int PRODUCT_ID_INVALID = 3;
@Autowired
private WebTestClient client;
@MockitoBean
private ProductCompositeIntegration compositeIntegration;
@BeforeEach
void setUp() {
when(compositeIntegration.getProduct(PRODUCT_ID_OK))
.thenReturn(Mono.just(new Product(PRODUCT_ID_OK, "name", new BigDecimal("199000"), "mock-address")));
when(compositeIntegration.getRecommendations(PRODUCT_ID_OK))
.thenReturn(Flux.just(new Recommendation(PRODUCT_ID_OK, 1, "author", 1, "content", "mock address")));
when(compositeIntegration.getReviews(PRODUCT_ID_OK))
.thenReturn(Flux.just(new Review(PRODUCT_ID_OK, 1, "author", "subject", "content", "mock address")));
when(compositeIntegration.getProduct(PRODUCT_ID_NOT_FOUND))
.thenReturn(Mono.error(new NotFoundException("NOT FOUND: " + PRODUCT_ID_NOT_FOUND)));
when(compositeIntegration.getRecommendations(PRODUCT_ID_NOT_FOUND)).thenReturn(Flux.empty());
when(compositeIntegration.getReviews(PRODUCT_ID_NOT_FOUND)).thenReturn(Flux.empty());
// PRODUCT_ID_INVALID trả về InvalidInputException theo cùng cách
}
@Test
void getProductById() {
client.get()
.uri("/product-composite/" + PRODUCT_ID_OK)
.accept(APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.productId").isEqualTo(PRODUCT_ID_OK)
.jsonPath("$.recommendations.length()").isEqualTo(1)
.jsonPath("$.reviews.length()").isEqualTo(1);
}
@Test
void getProductNotFound() {
client.get()
.uri("/product-composite/" + PRODUCT_ID_NOT_FOUND)
.accept(APPLICATION_JSON)
.exchange()
.expectStatus().isEqualTo(NOT_FOUND)
.expectBody()
.jsonPath("$.path").isEqualTo("/product-composite/" + PRODUCT_ID_NOT_FOUND)
.jsonPath("$.message").isEqualTo("NOT FOUND: " + PRODUCT_ID_NOT_FOUND);
}
}
|
Ghi chú
|
Chỗ khác sách: sách dùng |
./gradlew build
Toàn bộ test của bốn service phải pass. Commit:
git add .
git commit -m "Bài 3: review, recommendation và product-composite"
git tag blog-03
Tổng kết
-
Composite gọi ba service song song bằng
WebClientvàMono.zip, nên trang chỉ chậm bằng service chậm nhất. -
Lớp tích hợp giấu chuyện gọi mạng sau chính các interface trong
api, và dịch lỗi HTTP về exception của ta. -
Lỗi ở phần phụ (review, gợi ý) không làm hỏng trang sản phẩm.
-
Test composite với
@MockitoBean, không cần ba service kia chạy.
Bốn tiến trình Java chạy tay bằng bốn lệnh thì chưa ổn lắm. Ở bài 4, mình đóng gói từng service thành Docker image và khởi động cả hệ thống bằng một lệnh docker compose up.