Chuyển đến nội dung chính

レッスン 13: REST クライアント — サービス間の同期通信

MicroProfile REST クライアント、タイプセーフな REST 呼び出し、例外処理、タイムアウト構成、応答キャッシュ、および再試行ポリシー。

💻 プログラミング — レッスン 12 レッスン 13: REST クライアント — 同期通信 サービス間

Quarkus マイクロサービス: 基本から運用まで

パート 4: サービス間通信

xdev.asia

はじめに

マイクロサービスでは、サービスは相互に通信する必要があります。 同期通信 (REST クライアント) は、サービス A がサービス B からのデータをすぐに必要とする場合のリクエスト/レスポンス パターンに適しています。Quarkus は、ローカル メソッドを呼び出すのと同じように、REST API をタイプセーフに呼び出すのに役立つ MicroProfile REST クライアント を提供します。

MicroProfile REST クライアント

依存関係

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-rest-client-jackson</artifactId>
</dependency>

REST クライアント インターフェイスを宣言する

import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import java.util.List;

@RegisterRestClient(configKey = "product-service")
@Path("/api/v1/products")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public interface ProductServiceClient {

    @GET
    List<ProductInfo> list(
        @QueryParam("page") int page,
        @QueryParam("size") int size);

    @GET @Path("/{id}")
    ProductInfo getById(@PathParam("id") Long id);

    @POST @Path("/{id}/reserve-stock")
    StockResult reserveStock(
        @PathParam("id") Long id,
        @QueryParam("quantity") int quantity);

    @POST @Path("/{id}/release-stock")
    void releaseStock(
        @PathParam("id") Long id,
        @QueryParam("quantity") int quantity);

    @GET @Path("/{id}/check-stock")
    StockInfo checkStock(
        @PathParam("id") Long id,
        @QueryParam("quantity") int quantity);
}

構成

# application.properties
quarkus.rest-client.product-service.url=http://localhost:8081
quarkus.rest-client.product-service.scope=jakarta.inject.Singleton

# Timeouts
quarkus.rest-client.product-service.connect-timeout=5000
quarkus.rest-client.product-service.read-timeout=10000

# Override URL cho mỗi environment
%dev.quarkus.rest-client.product-service.url=http://localhost:8081
%prod.quarkus.rest-client.product-service.url=http://product-service:8080

サービスでの使用

@ApplicationScoped
public class OrderService {

    @Inject
    @RestClient
    ProductServiceClient productClient;

    @Transactional
    public OrderDTO createOrder(String customerId,
                                CreateOrderRequest request) {
        Order order = new Order();
        order.customerId = customerId;

        for (var item : request.items()) {
            // Gọi Product Service
            ProductInfo product =
                productClient.getById(item.productId());

            if (product == null) {
                throw new BusinessException(400,
                    "Product " + item.productId() + " not found");
            }

            order.addItem(product.id(), product.name(),
                Money.vnd(product.price()), item.quantity());
        }

        order.persist();
        return OrderDTO.from(order);
    }
}

REST クライアントの例外処理

応答例外マッパー

import org.eclipse.microprofile.rest.client.ext.ResponseExceptionMapper;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.WebApplicationException;

@Provider
public class ProductServiceExceptionMapper
        implements ResponseExceptionMapper<RuntimeException> {

    @Override
    public RuntimeException toThrowable(Response response) {
        int status = response.getStatus();

        return switch (status) {
            case 404 -> new ResourceNotFoundException(
                "Product", "unknown");
            case 400 -> {
                String body = response.readEntity(String.class);
                yield new BusinessException(400, body);
            }
            case 503 -> new ServiceUnavailableException(
                "Product Service is unavailable");
            default -> new WebApplicationException(
                "Product Service error: " + status, status);
        };
    }

    @Override
    public boolean handles(int status, jakarta.ws.rs.core.MultivaluedMap headers) {
        return status >= 400;
    }
}

クライアントに登録する

@RegisterRestClient(configKey = "product-service")
@RegisterProvider(ProductServiceExceptionMapper.class)
@Path("/api/v1/products")
public interface ProductServiceClient {
    // ...
}

リクエスト/レスポンスのロギング

import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.client.ClientResponseContext;
import jakarta.ws.rs.client.ClientResponseFilter;
import io.quarkus.logging.Log;

@Provider
public class RestClientLoggingFilter
        implements ClientRequestFilter, ClientResponseFilter {

    @Override
    public void filter(ClientRequestContext request) {
        Log.infof("→ REST Client: %s %s",
            request.getMethod(), request.getUri());
    }

    @Override
    public void filter(ClientRequestContext request,
                       ClientResponseContext response) {
        Log.infof("← REST Client: %s %s → %d",
            request.getMethod(), request.getUri(),
            response.getStatus());
    }
}

ヘッダーとカスタム インターセプター

カスタムヘッダーを追加する

import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;

@Provider
public class CorrelationIdFilter
        implements ClientRequestFilter {

    @Inject
    io.vertx.core.http.HttpServerRequest serverRequest;

    @Override
    public void filter(ClientRequestContext ctx) {
        // Propagate correlation ID
        String correlationId = serverRequest
            .getHeader("X-Correlation-ID");
        if (correlationId == null) {
            correlationId = UUID.randomUUID().toString();
        }
        ctx.getHeaders().putSingle(
            "X-Correlation-ID", correlationId);

        // Thêm service identifier
        ctx.getHeaders().putSingle(
            "X-Source-Service", "order-service");
    }
}

プログラムによる REST クライアント

クライアントを動的に作成する必要がある場合 (URL の変更):

import org.eclipse.microprofile.rest.client.RestClientBuilder;
import java.net.URI;

@ApplicationScoped
public class DynamicServiceCaller {

    public ProductInfo getProduct(String serviceUrl,
                                  Long productId) {
        ProductServiceClient client = RestClientBuilder
            .newBuilder()
            .baseUri(URI.create(serviceUrl))
            .connectTimeout(5, TimeUnit.SECONDS)
            .readTimeout(10, TimeUnit.SECONDS)
            .register(ProductServiceExceptionMapper.class)
            .build(ProductServiceClient.class);

        return client.getById(productId);
    }
}

マルチパートとファイルのアップロード

import org.jboss.resteasy.reactive.PartType;
import org.jboss.resteasy.reactive.RestForm;
import jakarta.ws.rs.core.MediaType;

// Client interface
@RegisterRestClient(configKey = "storage-service")
@Path("/api/v1/files")
public interface StorageServiceClient {

    @POST
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    FileUploadResult upload(
        @RestForm("file") java.io.File file,
        @RestForm("folder") String folder);
}

非同期/リアクティブ REST クライアント (Mutiny)

Quarkus は、Mutiny を備えた Reactive REST クライアントをサポートしています。これは、高同時実行に適した非ブロッキング呼び出しです (応答の待機中にスレッドをブロックしません**)。

リアクティブインターフェイス

@RegisterRestClient(configKey = "product-service")
@Path("/api/v1/products")
@Produces(MediaType.APPLICATION_JSON)
public interface ProductServiceReactiveClient {

    // Trả về Uni<T> — single async result
    @GET @Path("/{id}")
    Uni<ProductInfo> getById(@PathParam("id") Long id);

    // Trả về Multi<T> — stream of results
    @GET
    Multi<ProductInfo> listAll();

    // Uni<Response> — khi cần check status code
    @POST @Path("/{id}/reserve-stock")
    Uni<Response> reserveStock(
        @PathParam("id") Long id,
        @QueryParam("quantity") int quantity);
}

リアクティブクライアントを使用する

@ApplicationScoped
public class OrderService {

    @Inject @RestClient
    ProductServiceReactiveClient productClient;

    // Non-blocking: gọi nhiều services song song
    public Uni<OrderDTO> createOrderReactive(
            String customerId, CreateOrderRequest request) {

        // Gọi song song: check stock cho tất cả items
        List<Uni<ProductInfo>> productCalls = request.items()
            .stream()
            .map(item -> productClient.getById(item.productId()))
            .toList();

        return Uni.combine().all().unis(productCalls)
            .with(results -> {
                // Tất cả products đã load xong
                @SuppressWarnings("unchecked")
                List<ProductInfo> products =
                    (List<ProductInfo>) (List<?>) results;

                Order order = new Order();
                order.customerId = customerId;

                for (int i = 0; i < products.size(); i++) {
                    ProductInfo product = products.get(i);
                    int qty = request.items().get(i).quantity();
                    order.addItem(product.id(), product.name(),
                        Money.vnd(product.price()), qty);
                }

                order.persist();
                return OrderDTO.from(order);
            });
    }

    // Chain reactive calls
    public Uni<OrderDTO> processOrder(Long orderId) {
        return Order.<Order>findById(orderId)
            .onItem().ifNull()
                .failWith(new ResourceNotFoundException(
                    "Order", orderId))
            .flatMap(order ->
                // Reserve stock cho từng item
                reserveAllStock(order)
                    .replaceWith(order))
            .map(OrderDTO::from);
    }

    private Uni<Void> reserveAllStock(Order order) {
        List<Uni<Response>> reservations = order.items.stream()
            .map(item -> productClient.reserveStock(
                item.productId, item.quantity))
            .toList();

        return Uni.combine().all().unis(reservations)
            .discardItems();
    }
}

Reactive を使用するのはどのような場合ですか? サービスが複数のダウンストリーム サービスを並行して呼び出す必要がある場合、または高いスループットが必要な場合。単純なリクエストとレスポンスの場合は、クライアントをブロックするだけで十分です。

Stork によるサービス検出

URL をハードコーディングする代わりに、Stork を使用してサービス検出とクライアント側の負荷分散を行います。

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-smallrye-stork</artifactId>
</dependency>
<!-- Consul backend -->
<dependency>
    <groupId>io.smallrye.stork</groupId>
    <artifactId>stork-service-discovery-consul</artifactId>
</dependency>
# application.properties
# Stork service discovery
quarkus.stork.product-service.service-discovery.type=consul
quarkus.stork.product-service.service-discovery.consul-host=localhost
quarkus.stork.product-service.service-discovery.consul-port=8500

# Load balancing strategy
quarkus.stork.product-service.load-balancer.type=round-robin

# REST Client dùng stork:// scheme
quarkus.rest-client.product-service.url=stork://product-service
// Client interface — không thay đổi gì!
@RegisterRestClient(configKey = "product-service")
@Path("/api/v1/products")
public interface ProductServiceClient {
    @GET @Path("/{id}")
    ProductInfo getById(@PathParam("id") Long id);
}
// Stork tự động resolve URL từ Consul

Kubernetes DNS (Consul は必要ありません)

# Kubernetes service discovery
quarkus.stork.product-service.service-discovery.type=kubernetes
quarkus.stork.product-service.service-discovery.k8s-namespace=ecommerce

WireMock を使用した REST クライアントのテスト

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-junit5-mockito</artifactId>
    <scope>test</scope>
</dependency>

@InjectMock を使用して REST クライアントをモックする

@QuarkusTest
class OrderServiceTest {

    @InjectMock
    @RestClient
    ProductServiceClient productClient;

    @Inject
    OrderService orderService;

    @Test
    void testCreateOrder() {
        // Mock product response
        when(productClient.getById(1L))
            .thenReturn(new ProductInfo(
                1L, "Laptop", 25_000_000L, "VND",
                "ACTIVE", new StockInfo(50, 0)));

        when(productClient.getById(2L))
            .thenReturn(new ProductInfo(
                2L, "Mouse", 500_000L, "VND",
                "ACTIVE", new StockInfo(100, 0)));

        CreateOrderRequest request = new CreateOrderRequest(
            List.of(
                new OrderItemRequest(1L, 1),
                new OrderItemRequest(2L, 2)));

        OrderDTO result = orderService.createOrder(
            "user-123", request);

        assertEquals(26_000_000L, result.totalAmount());
        assertEquals(2, result.items().size());

        verify(productClient).getById(1L);
        verify(productClient).getById(2L);
    }

    @Test
    void testProductNotFound() {
        when(productClient.getById(999L))
            .thenThrow(new ResourceNotFoundException(
                "Product", 999L));

        assertThrows(BusinessException.class,
            () -> orderService.createOrder("user-123",
                new CreateOrderRequest(List.of(
                    new OrderItemRequest(999L, 1)))));
    }

    @Test
    void testProductServiceDown() {
        when(productClient.getById(anyLong()))
            .thenThrow(new ProcessingException(
                "Connection refused"));

        assertThrows(ProcessingException.class,
            () -> orderService.createOrder("user-123",
                new CreateOrderRequest(List.of(
                    new OrderItemRequest(1L, 1)))));
    }
}

## Bài tập

1. Tạo `製品サービスクライアント` interface với REST Client annotations
2. Thêm `応答例外マッパー` xử lý 404, 400, 503
3. Implement logging filter cho REST Client calls
4. Thêm `X相関ID` header propagation
5. Gọi Product Service từ Order Service khi tạo order
6. Tạo Reactive REST Client — gọi song song check stock cho nhiều products
7. Tạo WireMock test cho: success, product not found, service down
8. Implement Programmatic Client (`RestClientBuilder`) cho dynamic URL
9. (Nâng cao) Tích hợp Stork service discovery với Consul/Kubernetes

## Tổng kết

- **MicroProfile REST Client** — type-safe, declarative HTTP calls giữa services
- **`@RegisterRestClient`** + `@RestClient` inject — giống CDI injection
- **`応答例外マッパー`** chuyển HTTP errors thành meaningful exceptions
- **Client Filters** cho logging, header propagation, authentication
- **Timeout configuration** trong `アプリケーションのプロパティ`
- **Async/Reactive** (`ユニ<T>`, `マルチ<T>`) — non-blocking, gọi nhiều services song song
- **Stork** — service discovery + client-side load balancing (Consul, Kubernetes DNS)
- **Programmatic client** (`RestClientBuilder`) cho dynamic URLs
- **Testing** — `単体テスト用の Mockito を使用した @InjectMock @RestClient`

次の記事: gRPC — 高性能通信。