はじめに
マイクロサービスでは、サービスは相互に通信する必要があります。 同期通信 (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 — 高性能通信。