Microservices e-commerce #3: review, recommendation và service tổng hợp trang sản phẩm

· 9 phút đọc Java Spring Boot WebFlux Microservices Testing
Ghi chú

Đây là bài 3 trong series microservices e-commerce. Code của bài ở tag blog-03. Bài trước: service đầu tiên với Spring WebFlux.

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

ReviewServiceImpl.java
@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

13

Sản phẩm không tồn tại (404)

113

Không có gợi ý

213

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.

services/ProductCompositeIntegration.java
@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();
        }
    }
}
  1. product-service trả 404 thì WebClient ném WebClientResponseException. Ta đổi nó lại thành NotFoundException, để 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.

  2. 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.

  3. Đọc lại HttpErrorInfo từ 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 RestTemplate (blocking) và đến chương 7 mới chuyển sang WebClient. Mình dùng WebClient luôn từ bây giờ. Lý do: service đang chạy trên WebFlux, gọi blocking trong handler là chặn đúng mấy thread ít ỏi của Netty. RestTemplate cũng đang ở chế độ bảo trì, Spring khuyên dùng WebClient hoặc RestClient cho code mới.

Gộp kết quả: Mono.zip

services/ProductCompositeServiceImpl.java
@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

product-composite-service/src/main/resources/application.yml
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 @MockBean. Từ Spring Boot 3.4, @MockBean bị đánh dấu deprecated và được thay bằng @MockitoBean của Spring Framework (package org.springframework.test.context.bean.override.mockito). Cách dùng giống hệt.

./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 WebClient và 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.

Nhận bài viết mới qua email

Mỗi khi có bài viết mới về Spring Boot, kiến trúc hệ thống hay ghi chép kỹ thuật, mình sẽ gửi thẳng vào hộp thư của bạn.

Không gửi spam, không chia sẻ email cho bên thứ ba. Huỷ đăng ký bất cứ lúc nào.