Spring Cloudマイクロサービス連携: OpenFeignとRestTemplateによる呼び出しと認証

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-serviceproduct-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

タグ: SpringCloud OpenFeign RestTemplate Microservices authentication

5月18日 10:20 投稿