Java
spring

行儀の悪いAPIのレスポンスを自前のPOJOにバインドする(2)

More than 3 years have passed since last update.

前回の投稿、行儀の悪いAPIのレスポンスを自前のPOJOにバインドする(1)でイケてないHttpMessageConverterを書いてしまったので、もう少し汎用的な作りに変えてみます。

実は前回の記事のAPI、ユーザーが見つからなければこんなレスポンスが返ってきます。

Status Code: 200 OK

Response-Body: ErrorCode=A012&ErrorMessage=指定のユーザーが存在しません。

あら、前回の投稿で書いたHttpMessageConverterでヌルポ発生決定ですね。

しかもエラー情報拾えない。

そこで、このサービスが提供するAPIのレスポンスの共通的なインターフェースを作ることにしました。


レスポンスの共通実装

インターフェースはこんな感じかな。

public interface ApiResponse<R> {

ErrorInfo getErrorInfo();
R getResource();
}

エラー情報の宣言を全リソースクラスで宣言したくないからベースクラスも作っておく。

public abstract class ApiResponseBase<R> implements ApiResponse<R> {

private ErrorInfo errorInfo;

public boolean hasError() {
return errorInfo != null;
}

// getter and setter
}

準備OK。じゃあ、前回投稿したクソコードを直していこうか。


ユーザー取得APIのレスポンスクラスを実装

Userクラスをそのまま使うとエラー情報が取れないから、ちゃんとレスポンスクラスを作ろう。

public class GetUserResponse extends ApiResponseBase<User> {

private User user;

@Override
public User getResource() {
return user;
}

// getter and setter
}

よしよし。ここまで実装したらHttpMessageConverterの中でUserを意識しなくて済むだろう。

・・・

Σ( ̄o ̄;)

いやいや、user.setXXX(hogehoge);していくのは変わらない。。

ここを共通化したいんだよ!


ConversionServiceによる変換処理の実装

Springにはこういうときのために便利なSPIを用意してくれているんですよね。


ConversionService

決まった型から型に変換するConverterを複数保持し、変換指定時に適切なConverterで変換してくれる。

なので、MaltiValueMapからGetUserResponseに変換してくれるConverterを作ります。

で、それをConversionServiceに登録します。


UserConverterの実装

public enum MaltiValueMapToUserConverter implements<MultiValueMap<String, String>, GetUserResponse> {

// singleton
INSTANCE;

public GetUserResponse convert(MultValueMap<String, String> source) {
GetUserResponse response = new GetUserResponse();

if(source.containsKey("ErrorCode")) {
response.setErrorInfo(new ErrorInfo(source.get("ErrorCode"), source.get("ErrorMessage")));
return response;
}

User user = new User();
// user.setXXX(hogehoge);

response.setUser(user);

return response;
}
}


  • スレッドセーフでイミュータブルなのでシングルトンパターンで実装


ConversionServiceへの登録

ConversionService自体はインターフェースなので実装します。

って、結構実装しなきゃいけないメソッドがあるな。。

しかもGetUserResponse以外のConverterどうやって用意すりゃいいんだ?

って、困らなくても実装クラスを検索してみるといい感じのクラスがありました。

DefaultFormattingConversionServiceです。

こいつに色々デフォルトで用意されているConverterを登録させて使うにはFormattingConversionServiceFactoryBean経由でインスタンスを作ると良いみたいです。

こいつはFactoryBeanInitializingBeanなので、こんな感じでConversionServiceを生成します。

FormattingConversionServiceFactoryBean factoryBean = new FormattingConversionServiceFactoryBean();

Set<?> converters = new HashSet<?>();
converters.add(MaltiValueMapToUserConverter.INSTANCE);
factoryBean.setConverters(converters);

factoryBean.afterPropertiesSet();

FormattingConversionService conversionSerivce = factoryBean.getObject();


HttpMessageConverterの処理を修正

前回の投稿で書いたUserFormHttpMessageConverterApiResponse用に修正して、ConversionServiceを使うように修正していみます。

public class CustomApiFormHttpMessageConverter implements HttpMessageConverter<ApiResponse<?>> {

private FormHttpMessageConverter formHttpMessageConverter;
private ConversionService conversionService;

public UserFormHttpMessageConverter(FormHttpMessageConverter formHttpMessageConverter, ConversionService conversionService) {
this.formHttpMessageConverter = formHttpMessageConverter;
this.conversionService = conversionService;
}

@Override
public boolean canRead(Class<?> clazz, MediaType mediaType) {
// ApiResponseの派生クラスだったらOK。MediaTypeの判定はめんどくさいから
// FormHttpMessageConverterにやらせる。(clazzはMultiValueMapで来たと嘘をつく。)
return ApiResponse.class.isAssignableFrom(clazz) && formHttpMessageConverter.canRead(MultiValueMap.class, mediaType);
}

@Override
public ApiResponse<?> read(Class<? exntends ApiResponse<?>> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
@SuppressWarnings("unchecked")
final MultiValueMap<String, String> response = formHttpMessageConverter.read((Class<? extends MultiValueMap<String, ?>>) MultiValueMap.class, inputMessage);

ApiResponse<?> apiResponse = conversionService.convert(response, clazz);
return apiResponse;
}

おぉ。Userクラスに関する記述が消えた!

うーん、でもまだConverterの中がsetXXX(hogehge)地獄でヤだ。


BeanWrapperによるデータバインド処理の共通化


BeanWrapper

POJOのフィールドへ値を設定するためのユーティリティ。

その名の通り対象オブジェクトをラップして使う。


GetUserResponseに付加情報追加

BeanWrapperで処理しやすいようにバインドルールを定義するMixinを作っておく。

@Mixin(GetUserResponse.class)

public abstract class GetUserResponseMixin {
@Property(accessKey = "user.id", paramName = "User_ID")
private Long userId;
@Property(accessKey = "user.name", paramName = "User_Name")
private String userName;
@Property(accessKey = "user.birthday", paramName = "User_Birthdaty")
@DateTimeFormat(pattern = "yyyyMMdd")
private LocalDate userBirthday;

@Property(accessKey = "errorInfo.code", paramName = "ErrorCode")
private errorCode;
@Property(accessKey = "errorInfo.message", maraName = "ErrorMessage")
private errorMessage;
}

まぁ、フィード名は何でも良いです。

必要なのは@Propertyと付与されているフィールドの型が重要になってきます。

ちなみに使用したアノテーションは自前で用意しました。

@MixinアノテーションはMixinクラスを特定するためのアノテーションです。

特定する際の処理は割愛するので悪しからず。


ConverterFactory

変換できる対象をUser決め打ちにするのではなく、ApiResponseの実装クラス全部に適応させたい。

そんなときに動的にCoverterを生成してくれるのがこれ。CoverterFactory

これでApiResponseの各実装クラスのCoverterを生成してくれるCoverterFactoryを実装する。

@AllArgsConstructor // lombok

public class ApiResponseConverterFactory implements ConverterFactory<MultiValueMap<String, String>, ApiResponse<?>> {

private ConversionService conversionService;

@Override
public <T extends ApiResponse<?>> Converter<MultiValueMap<String, String>, T> getConverter(Class<T> targetType) {
return new ApiResponseConverter<>(conversionService, targetType);
}

@AllArgsConstructor // lombok
private static class ApiResponseConverter<T> implements Converter<MultiValueMap<String, String>, T> {

private static final TypeDescriptor STRING_TYPE_DESC = TypeDescriptor.valueOf(String.class);

private ConversionService conversionService;
private Class<T> targetType;

@Override
public T convert(MultiValueMap<String, String> source) {
T targetNewInstance = BeanUtils.instantiate(targetType);
BeanWrapper targetWrapper = PropertyAccessorFactory.forBeanPropertyAccess(targetNewInstance);

// "user.id"でアクセスした際にUserインスタンスを自動で生成させる
targetWrapper.setAutoGrowNestedPaths(true);

Class<?> mixinType = getMixinType(targetType); // 実装は割愛しします

for(Field mixinField : mixinType.getDeclaredFields()) {
Property property = mixinField.getAnnotation(Property.class);

if(property == null) {
continue;
}

if(targetWrapper.isWritableProperty(property.accessKey()) == false) {
continue;
}

String value = source.getFirst(property.paramName());

if(value == null) {
continue;
}

TypeDescriptor targetFieldDesc = new TypeDescriptor(mixinField);

Object convertedValue = conversionService.convert(value, STRING_TYPE_DESC, targetFieldDesc);

targetWrapper.setPropertyValue(property.accessKey(), convertedValue);
}

return (T) targetWrapper.getWrappedInstance();
}
}

BeanWrapperを使うことでフィールドへの値のセットが楽に書けます。

そしてミソがTypeDescriptor。こいつは型、総称型、フィールドに付与されたアノテーション情報全て持っています。

こいつを使ってコンバートすることで@DateTimeFormatなんかも見てくれてMixinで定義したとおりに変換してくれます。

これでMultiValueMapからApiResponseの実装クラスへの変換クラスを動的に生成できるようになりました。これをConversionServiceに登録しておくだけでOKです。

conversionService.setConverters(Arrays.asList(new ApiResponseConverterFactory(conversionService)));


結果

user.setXXX(hogehoge);書かなくて良くなりました。

他にもGetItemResponseとか実装した場合もMixinを用意して、getMixinType(targetType)で取得できるようにしておけば、あとはこんな風に書けます。

GetItemResponse response = restTemplate.getForObject(apiUrl, GetItemResponse.class);

if(response.hasError()) {
throw new ApiException(response.getErrorInfo());
}

return response.getResource(); // returned Item instance.


CustomApiFormHttpMessageConverterを更に修正

#write(Object obj)とか有効にして、リクエストパラメータにも対応したら完璧ですね。

その場合はHttpMessageConverterの総称型をObjectにして実装すれば良いと思います。

サンプルコード