動作確認環境
- 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を使う際、以下が使いづらいと感じた。
- 検査例外
IOException
が投げられる - HTTPステータス 200 系以外のエラーハンドリング
- レスポンスボディの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.gradle
でimplementation 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
にしてat
やpath
でJSONの要素を取得するような処理を書きたい場合は、addConverterFactory
を同じように設定してAPIの戻り値をCall<JsonNode>
にすると良い。objectMapper.readTree(response.body().string())
でResponseBody
をJsonNode
に変換する手間が省ける。
Retrofit呼び出し元
Retrofitを呼び出す側として、毎回以下を実施する必要がある。
- 検査例外
IOException
の制御 - HTTPステータス 200 系以外のエラーハンドリング
- レスポンスボディの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.mockwebserver
のMockWebServer
やMockResponse
で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: {}");
}
}