動作確認環境

  • Java 11
  • Retrofit 2
  • OkHttp 3
  • Spring Boot 2.5.5
  • JUnit 5.7.2

JavaでRetrofit + OkHttpを使う

JavaとRetrofit/OkHttpと検査例外とエラーハンドリング

JavaでHTTPクライアントライブラリのRetrofit + OkHttpを使う際、以下が使いづらいと感じた。

  1. 検査例外IOExceptionが投げられる
  2. HTTPステータス 200 系以外のエラーハンドリング
  3. レスポンスボディのnullチェック

これらを毎回扱わなくても済むように工夫したい。

実装

まずはSpring Bootで/sampleにアクセスしたら外部サーバーのAPIに問い合わせをしてその結果をそのままクライアントに返す最低限の実装をする。

外部サーバーはJSON OKIBAを利用させていただき、以下のような結果を得られるようにする。

$ curl -v -w'\n' localhost:8080/sample/TKLmF211005070454
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /sample/TKLmF211005070454 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200
< Content-Type: application/json
< Content-Length: 16
< Date: Tue, 05 Oct 2021 10:27:51 GMT
<
* Connection #0 to host localhost left intact
{"key": "value"}
* Closing connection 0

Retrofit/OkHttpの設定

ApiConfigクラスをSpringのJavaConfigとし、各外部サーバーごとにクライアントをBeanとして生成する(今回はSampleApiClientだけ)。

package com.example.retrofit.api;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ApiConfig {

    @Bean
    SampleApiClient sampleApiClient(@Value("${sample-api-base-url}") String baseUrl, ObjectMapper objectMapper) {
        return SampleApiClientBuilder.build(baseUrl, objectMapper);
    }

}

SampleApiClient

package com.example.retrofit.api;

import com.example.retrofit.controller.json.SampleResponse;
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Headers;
import retrofit2.http.Path;

public interface SampleApiClient {

    @Headers({"Accept: application/json"})
    @GET("v1/json/{id}")
    Call<ResponseBody> sample(@Path("id") String id);

    @Headers({"Accept: application/json"})
    @GET("v1/json/{id}")
    Call<SampleResponse> sampleUseClass(@Path("id") String id);

}

SampleApiClientBuilder

package com.example.retrofit.api;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor;
import okhttp3.logging.HttpLoggingInterceptor.Level;
import retrofit2.Retrofit;
import retrofit2.converter.jackson.JacksonConverterFactory;

@Slf4j
public class SampleApiClientBuilder {

    public static SampleApiClient build(String baseUrl, ObjectMapper objectMapper) {
        return new SampleApiClientBuilder().sampleApiClient(baseUrl, objectMapper);
    }

    SampleApiClient sampleApiClient(String baseUrl, ObjectMapper objectMapper) {
        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl(baseUrl)
                .client(okHttpClient())
                .addConverterFactory(JacksonConverterFactory.create(objectMapper))
                .build();
        return retrofit.create(SampleApiClient.class);
    }

    private OkHttpClient okHttpClient() {
        return new OkHttpClient.Builder()
                .addInterceptor(new HttpLoggingInterceptor(log::info).setLevel(Level.BODY))
                .build();
    }

}

APIのメソッドは二つ用意したが、どちらも同じURLである。二つ用意したのは、レスポンスをJavaで定義しないでざっくり受け取る方法とあらかじめ定義してクラスにマッピングして受け取る方法の二パターンを試したかったから。

クラスにマッピングするためにSampleApiClientBuilder.addConverterFactory(JacksonConverterFactory.create(objectMapper))をしているのと、後ほど記載するがbuild.gradleimplementation group: 'com.squareup.retrofit2', name: 'converter-jackson', version: '2.9.0'を設定している。

あらかじめ定義したレスポンス用のクラスは以下の通り。

package com.example.retrofit.api.json;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
@Data
public class SampleResponse {
    private String key;
}

※クラスにマッピングせずともJSON文字列をJacksonのJsonNodeにしてatpathでJSONの要素を取得するような処理を書きたい場合は、addConverterFactoryを同じように設定してAPIの戻り値をCall<JsonNode>にすると良い。objectMapper.readTree(response.body().string())ResponseBodyJsonNodeに変換する手間が省ける。

Retrofit呼び出し元

Retrofitを呼び出す側として、毎回以下を実施する必要がある。

  1. 検査例外IOExceptionの制御
  2. HTTPステータス 200 系以外のエラーハンドリング
  3. レスポンスボディのnullチェック

具体的にはこのようなコードになる。

@RequiredArgsConstructor
@RestController
public class CallSampleApiController {

    private final SampleApiClient sampleApiClient;

    @GetMapping(value = "/sample/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
    String sample(@PathVariable("id") String id) {
        try {
            Response<ResponseBody> response = sampleApiClient.sample(id).execute();
            if (response.isSuccessful() && response.body() != null) {
                return response.body().string();
            }
            ResponseBody err = response.errorBody();
            String errBodyString = err != null ? err.string() : "";
            throw new ApiException("code: " + response.code() + ", body: " + errBodyString);
        } catch (IOException e) {
            throw new ApiException(e);
        }
    }

}

不便なので改善していく。

Retrofitの処理を共通化する

先ほど見た制御構造ほぼそのままに、Call<T>を受け取って実行するラッパーメソッドを用意した。

package com.example.retrofit.api;

import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.Response;

import java.io.IOException;

public class DefaultApiExecutor {

    public static <T> T executeAndGetResponse(Call<T> call) {
        try {
            Response<T> response = call.execute();
            if (response.isSuccessful()) {
                return response.body();
            }
            ResponseBody err = response.errorBody();
            String errBodyString = err != null ? err.string() : "";
            throw new ApiException("code: " + response.code() + ", body: " + errBodyString);
        } catch (IOException e) {
            throw new ApiException(e);
        }
    }

    public static String executeAndGetResponseAsString(Call<ResponseBody> call) {
        try {
            return executeAndGetResponse(call).string();
        } catch (IOException e) {
            throw new ApiException(e);
        }
    }


    public static void execute(Call<ResponseBody> call) {
        executeAndGetResponse(call);
    }

    public static class ApiException extends RuntimeException {
        public ApiException(String message) {
            super(message);
        }

        public ApiException(Throwable cause) {
            super(cause);
        }
    }

}

これで先ほどの呼び出し元はreturn DefaultApiExecutor.executeAndGetResponseAsString(sampleApiClient.sample(id));だけで良くなる。

変更後

package com.example.retrofit.controller;

import com.example.retrofit.api.DefaultApiExecutor;
import com.example.retrofit.api.SampleApiClient;
import com.example.retrofit.api.json.SampleResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class CallSampleApiController {

    private final SampleApiClient sampleApiClient;

    @GetMapping(value = "/sample/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
    String sample(@PathVariable("id") String id) {
        return DefaultApiExecutor.executeAndGetResponseAsString(sampleApiClient.sample(id));
    }

    @GetMapping("/sampleUseClass/{id}")
    SampleResponse sampleUseClass(@PathVariable("id") String id) {
        return DefaultApiExecutor.executeAndGetResponse(sampleApiClient.sampleUseClass(id));
    }
}

Spring Bootで動かすための最低限の設定

起動クラス

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

application.properties

sample-api-base-url=https://jsondata.okiba.me/

build.gradle抜粋

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation group: 'com.squareup.retrofit2', name: 'retrofit', version: '2.9.0'
    implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.9.2'
    implementation group: 'com.squareup.okhttp3', name: 'logging-interceptor', version: '4.9.2'
    implementation group: 'com.squareup.retrofit2', name: 'converter-jackson', version: '2.9.0'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

JUnitとMockitoでRetrofit/OkHttpをテストする

okhttp3.mockwebserverMockWebServerMockResponseでAPI通信をモック化してテストできるようだが、今回はMockitoを使ったユニットテストを書いてみたい。

以下にコードは記載するが、ポイントはCall<ResponseBody>@Mockでモックにし、executeメソッドの戻り値を自由にカスタマイズしている点である。

@ExtendWith(MockitoExtension.class)
public class SampleApiClientTest {

    @Mock
    SampleApiClient sampleApiClient;

    @Mock
    Call<ResponseBody> sampleCall;

    @Mock
    Call<SampleResponse> sampleCallUseClass;

    @Test
    public void sample() throws IOException {
        doReturn(sampleCall).when(sampleApiClient).sample(any());
        Supplier<ResponseBody> responseBody = () -> ResponseBody.create("{}", MediaType.get("application/json"));

        // API成功時
        doReturn(Response.success(responseBody.get())).when(sampleCall).execute();
        String actual = DefaultApiExecutor.executeAndGetResponseAsString(sampleApiClient.sample("1"));
        assertThat(actual).isEqualTo("{}");

        // API失敗時
        doReturn(Response.error(400, responseBody.get())).when(sampleCall).execute();
        assertThatThrownBy(() -> DefaultApiExecutor.executeAndGetResponseAsString(sampleApiClient.sample("2")))
                .isExactlyInstanceOf(ApiException.class)
                .hasMessage("code: 400, body: {}");
    }

    @Test
    public void sampleUseClass() throws IOException {
        doReturn(sampleCallUseClass).when(sampleApiClient).sampleUseClass(any());
        Supplier<ResponseBody> responseBody = () -> ResponseBody.create("{}", MediaType.get("application/json"));

        // API成功時
        doReturn(Response.success(new SampleResponse("value"))).when(sampleCallUseClass).execute();
        SampleResponse actual = DefaultApiExecutor.executeAndGetResponse(sampleApiClient.sampleUseClass("1"));
        assertThat(actual.getKey()).isEqualTo("value");

        // API失敗時
        doReturn(Response.error(400, responseBody.get())).when(sampleCallUseClass).execute();
        assertThatThrownBy(() -> DefaultApiExecutor.executeAndGetResponse(sampleApiClient.sampleUseClass("2")))
                .isExactlyInstanceOf(ApiException.class)
                .hasMessage("code: 400, body: {}");
    }
}