前回の投稿、行儀の悪い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
経由でインスタンスを作ると良いみたいです。
こいつはFactoryBean
でInitializingBean
なので、こんな感じでConversionService
を生成します。
FormattingConversionServiceFactoryBean factoryBean = new FormattingConversionServiceFactoryBean();
Set<?> converters = new HashSet<?>();
converters.add(MaltiValueMapToUserConverter.INSTANCE);
factoryBean.setConverters(converters);
factoryBean.afterPropertiesSet();
FormattingConversionService conversionSerivce = factoryBean.getObject();
HttpMessageConverterの処理を修正
前回の投稿で書いたUserFormHttpMessageConverter
をApiResponse
用に修正して、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
にして実装すれば良いと思います。