Spring Cloudエコシステムにおいて、マイクロサービス間の通信はシステム設計の重要な側面です。サービスを連携させるためのアプローチはいくつか存在しますが、本記事では特にOpenFeignとRestTemplateに焦点を当て、その利用方法と認証メカニズムの統合について解説します。
マイクロサービス間通信の選択肢
Spring Cloud環境下でのサービス間呼び出しには、主に以下の方法があります。
1. OpenFeignクライアント
- 利点: インターフェースベースの宣言的なAPIクライアントを生成でき、コードの記述量を削減できます。認証情報の付与や共通処理は、Feignインターセプターを通じて一元的に管理可能です。
- 欠点: リクエストヘッダーやボディを動的に、かつきめ細かく制御したい場合には、インターセプターの実装が必要となり、実行時の柔軟性に制約がある場合があります。
- 推奨: 定型的なAPI呼び出しや、コードの可読性・保守性を重視する場合に最適です。
2. ロードバランシングされたRestTemplate
- 利点: リクエスト/レスポンス処理に対する高い柔軟性を提供します。ヘッダー、ボディ、クエリパラメータなどを細かく制御できるため、複雑なリクエストを構築する際に適しています。サービス名を指定することで、クライアントサイドロードバランシング(例: Eurekaとの連携)の恩恵も受けられます。
- 欠点: 各呼び出しごとにHTTPリクエストを構築する必要があるため、類似する多数のAPIを呼び出す場合、コード量が増加する傾向があります。
- 推奨: 高い柔軟性が求められるケースや、OpenFeignの定義が難しい特定の要件がある場合に有効です。
IPアドレスを直接指定してRestTemplateを使用する方法もありますが、これはサービスディスカバリやロードバランシングといったSpring Cloudの主要な利点を損なうため、通常は推奨されません。
システム構成例
本記事では、以下の3つの仮想サービスを用いて解説します。
authentication-service: ユーザー認証を行い、JWTトークンを発行します。product-service: 商品情報を提供するリソースサービスです。認証トークンが必要です。api-gateway-service: クライアントからのリクエストを受け付け、authentication-serviceやproduct-serviceを呼び出します。
以下に、各サービスの主要なコードスニペットと、api-gateway-serviceにおけるOpenFeignおよびRestTemplateの設定例を示します。
認証サービス (authentication-service)
ユーザー認証を行い、成功時にはトークンを含むレスポンスを返します。
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.HashMap;
import java.util.List;
import java.util.Arrays;
// JWTトークン生成の簡易ユーティリティ (実際はより堅牢な実装が必要)
class JwtTokenProvider {
public String generateToken(String username) {
// 簡易実装: 本番環境ではJWTライブラリを使用
return "mock-jwt-token-for-" + username + "-" + System.currentTimeMillis();
}
}
@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
private final JwtTokenProvider tokenProvider = new JwtTokenProvider(); // JWT生成のユーティリティ
@PostMapping("/login")
public ServiceResponse login(@RequestBody LoginCredentials credentials) {
// 実際の認証ロジックをここに実装
if ("user".equals(credentials.getUsername()) && "password".equals(credentials.getPassword())) {
String token = tokenProvider.generateToken(credentials.getUsername());
return ServiceResponse.success("認証成功", Map.of("accessToken", token));
}
return ServiceResponse.failure("認証失敗: 無効なユーザー名またはパスワード");
}
}
// 認証リクエスト用のDTO
class LoginCredentials {
private String username;
private String password;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
// 共通レスポンスDTO
class ServiceResponse {
private boolean success;
private String message;
private Map<String, Object> data;
public ServiceResponse(boolean success, String message, Map<String, Object> data) {
this.success = success;
this.message = message;
this.data = data;
}
public static ServiceResponse success(String message, Map<String, Object> data) {
return new ServiceResponse(true, message, data);
}
public static ServiceResponse failure(String message) {
return new ServiceResponse(false, message, new HashMap<>());
}
public boolean isSuccess() { return success; }
public String getMessage() { return message; }
public Map<String, Object> getData() { return data; }
}
商品サービス (product-service)
商品リストを返すシンプルなリソースエンドポイントです。
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.HashMap;
import java.util.List;
import java.util.Arrays;
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping("/list")
public ServiceResponse getProductList() {
List<Map<String, Object>> products = Arrays.asList(
Map.of("id", 1, "name", "Apple", "price", 100),
Map.of("id", 2, "name", "Banana", "price", 50)
);
return ServiceResponse.success("商品リストを取得しました", Map.of("products", products));
}
}
APIゲートウェイサービス (api-gateway-service)
ここでは、OpenFeignとRestTemplateを用いた他のサービスへの呼び出しを実装します。
OpenFeignクライアントと設定
認証サービスと商品サービス用のFeignクライアントを定義します。認証トークンを自動で付与するために、リクエストインターセプターも実装します。
認証トークン管理用ヘルパークラス
認証トークンをスレッドローカルに保持し、Feignインターセプターから参照できるようにします。
public class AuthTokenContext {
private static final ThreadLocal<String> currentToken = new ThreadLocal<>();
public static void setToken(String token) {
currentToken.set(token);
}
public static String getToken() {
return currentToken.get();
}
public static void clear() {
currentToken.remove();
}
}
OpenFeignインターセプター
authentication-serviceとの通信にはインターセプター不要ですが、product-serviceへの呼び出しには認証トークンが必要です。
import feign.RequestInterceptor;
import feign.RequestTemplate;
import feign.codec.ErrorDecoder;
import feign.Response;
import feign.codec.ErrorDecoder.Default;
import org.springframework.context.annotation.Bean;
// authentication-service用 (認証にはトークン不要)
public class AuthServiceClientConfig {
@Bean
public RequestInterceptor noAuthInterceptor() {
return template -> {
// 特にヘッダーを追加する必要がない場合
// System.out.println("AuthService呼び出し中...");
};
}
}
// product-service用 (認証トークンを付与)
public class ProductServiceClientConfig {
@Bean
public RequestInterceptor authorizationInterceptor() {
return template -> {
String token = AuthTokenContext.getToken();
if (token != null) {
template.header("Authorization", "Bearer " + token);
System.out.println("Authorizationヘッダーを追加: Bearer " + token.substring(0, Math.min(token.length(), 10)) + "...");
}
};
}
@Bean
public ErrorDecoder feignErrorDecoder() {
return new CustomFeignErrorDecoder();
}
}
// カスタムエラーデコーダーの例
class CustomFeignErrorDecoder implements ErrorDecoder {
private final ErrorDecoder defaultDecoder = new Default();
@Override
public Exception decode(String methodKey, Response response) {
if (response.status() >= 400 && response.status() < 500) {
// クライアントエラーの場合
return new RuntimeException("Feign Client Error: " + response.status() + " " + response.reason());
}
return defaultDecoder.decode(methodKey, response);
}
}
OpenFeignクライアントインターフェース
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(name = "authentication-service", configuration = AuthServiceClientConfig.class)
public interface AuthFeignClient {
@PostMapping("/api/auth/login")
ServiceResponse login(@RequestBody LoginCredentials credentials);
}
@FeignClient(name = "product-service", configuration = ProductServiceClientConfig.class)
public interface ProductFeignClient {
@GetMapping("/api/products/list")
ServiceResponse getProducts();
}
RestTemplateの設定
@LoadBalancedアノテーションを付与することで、サービス名による呼び出しが可能になります。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
@Configuration
public class ApiGatewayConfig {
@Bean
@LoadBalanced
public RestTemplate loadBalancedRestTemplate() {
RestTemplate restTemplate = new RestTemplate();
// 必要に応じてインターセプターを追加することも可能
// restTemplate.getInterceptors().add(new LoggingInterceptor());
return restTemplate;
}
}
テスト用コントローラー
OpenFeignとRestTemplateの両方を使ったサービス呼び出しの例です。
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
@RestController
@RequestMapping("/api/client-test")
public class ApiClientController {
private final AuthFeignClient authClient;
private final ProductFeignClient productClient;
private final RestTemplate restTemplate;
public ApiClientController(AuthFeignClient authClient, ProductFeignClient productClient, RestTemplate restTemplate) {
this.authClient = authClient;
this.productClient = productClient;
this.restTemplate = restTemplate;
}
@PostMapping("/login-and-fetch-products")
public ServiceResponse loginAndFetchProducts(@RequestBody LoginCredentials credentials) {
// 1. 認証サービスにログイン (OpenFeign利用)
ServiceResponse loginResponse = authClient.login(credentials);
if (loginResponse.isSuccess() && loginResponse.getData().containsKey("accessToken")) {
String token = loginResponse.getData().get("accessToken").toString();
AuthTokenContext.setToken(token); // トークンをスレッドローカルに保存
try {
// 2. 商品サービスからデータを取得 (OpenFeign利用、インターセプターがトークンを付与)
ServiceResponse productsResponse = productClient.getProducts();
return productsResponse;
} finally {
AuthTokenContext.clear(); // トークンをクリア
}
}
return ServiceResponse.failure("ログイン失敗");
}
@PostMapping("/login-and-fetch-products-via-resttemplate")
public ServiceResponse loginAndFetchProductsRestTemplate(@RequestBody LoginCredentials credentials) {
// 1. 認証サービスにログイン (OpenFeign利用)
ServiceResponse loginResponse = authClient.login(credentials);
if (loginResponse.isSuccess() && loginResponse.getData().containsKey("accessToken")) {
String token = loginResponse.getData().get("accessToken").toString();
// 2. 商品サービスからデータを取得 (RestTemplate利用、手動でヘッダー設定)
String url = "http://product-service/api/products/list";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token); // Authorization: Bearer <token>
HttpEntity<Void> requestEntity = new HttpEntity<>(headers);
try {
ResponseEntity<ServiceResponse> responseEntity = restTemplate.exchange(
url, HttpMethod.GET, requestEntity, ServiceResponse.class);
return responseEntity.getBody();
} catch (Exception e) {
return ServiceResponse.failure("商品情報取得エラー: " + e.getMessage());
}
}
return ServiceResponse.failure("ログイン失敗");
}
}
タイムアウト設定
OpenFeignクライアントのデフォルトのタイムアウト設定は、以下のようにapplication.ymlで調整可能です。これにより、全てのFeignクライアントに共通のタイムアウトが適用されます。
feign:
client:
config:
default: # 全てのFeignクライアントに適用
connectTimeout: 5000 # 接続タイムアウト (ミリ秒)
readTimeout: 10000 # 読み取りタイムアウト (ミリ秒)
loggerLevel: basic # ロガーレベル
特定のFeignクライアントのみに適用する場合は、defaultの代わりにそのクライアントのname属性を指定します。
feign:
client:
config:
product-service: # product-serviceクライアントのみに適用
connectTimeout: 3000
readTimeout: 6000