SpringBootでパラメータ名がスネークケースだった時に一律でキャメルに変換して@ModelAttributeオブジェクトにマッピングさせようと試みた時の挫折メモ
結果として難しそうなので諦めましたが、いろいろ調べたところまでと、妥協案のメモです。
前提として、データをひも付けたオブジェクトに対してBean Validationによるチェックもする予定です。
(レスポンスは適当でOK
と返るだけです。)
流れとしては、
- リクエストパラメータをオブジェクトにbind
- Bean Validationによるチェック(@Validatedで自動であれるので今回は特に設定せず)
- 差し込まれたオブジェクトがControllerのメソッドの引数に正しく渡ってくること
以下、今回用意したリクエストパラメータにデータをマッピングさせるオブジェクトのSampleEntity
と、リクエストを受けるSampleController
になります。
public class SampleEntity { @NotNull private String userId; @NotNull private String nickname; /* getter&setter 省略 */ }
@Controller @RequestMapping("/sample") public class SampleController { @RequestMapping(path = "/entity", method = RequestMethod.GET) public ResponseEntity entitySample( @Validated SampleEntity sampleEntity, BindingResult result) { System.out.println(ToStringBuilder.reflectionToString(sampleEntity)); System.out.println(ToStringBuilder.reflectionToString(result.getAllErrors(), ToStringStyle.JSON_STYLE)); return new ResponseEntity<>("OK", HttpStatus.OK); } }
リクエスト例
$ curl "http:://localhost:8080/sample/entity?user_id=test&nickname=testsan"
上記のURLの場合、SampleEntityオブジェクトにnicknameは値が格納されますが、userIdはnullになります。
BindingResultには、Bean Validationによるエラーが入ることになります。
(ちなみに、?user_id=test
を?userId=test
にすると正常に動きます。)
考えた対応方法(個人的な理想)
- EntityのuserIdのプロパティに独自annotationを定義して、そこに自動でマッピングされる別名を定義する
イメージ
public class SampleEntity { @NotNull @ParameterName("user_id") private String userId; @NotNull private String nickname; /* getter&setter 省略 */ }
@ParameterName
annotationを定義して、マッピングするプロパティに別名を定義してうまい具合に動くようにする(つもりでした)
アノテーションの設定
@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ParameterName { String value(); }
HandlerMethodArgumentResolver
の独自実装クラスの作成
このインターフェースは、Controllerメソッドで引数としてデータを受け取るための処理になります。
@ModelAttribute
の付いたオブジェクトや、その他のいろんなオブジェクトを自動でバインドするのはHandlerMethodArgumentResolver
の実装クラスであるModelAttributeMethodProcessor
と、ServletModelAttributeMethodProcessor
で定義されてます。
ModelAttributeMethodProcessor
を継承したクラスじゃないと@Validated
が効かなそうだったので、以下の様にServletModelAttributeMethodProcessor
を継承して作ってみました。(中身なデバック中だったのでとりあえず空)
public final class RenamingParameterNameProcessor extends ServletModelAttributeMethodProcessor { public RenamingParameterNameProcessor() { super(false); } @Override protected void bindRequestParameters(WebDataBinder binder, NativeWebRequest request) { Object target = binder.getTarget(); Class<?> targetClass = target.getClass(); /* ここで@ParameterNameのvalueを読み込んで、マッピング情報を書き換えるとかするイメージ */ super.bindRequestParameters(binder, request); } }
RenamingParameterNameProcessorの設定
@Configuration public class WebMvcConfig extends WebMvcConfigurerAdapter { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) { argumentResolvers.add(new RenamingParameterNameProcessor()); } }
RenamingParameterNameProcessorが呼ばれることはありませんでした
実際の処理がどうなっていたのか。
デフォルトで幾つかArgumentResolversがすでセットされてるんですが、(ServletModelAttributeMethodProcessor
含む)
てっきり、argumentResolvers.add(...)
で追加してやれば呼ばれるものと思っていました。
RequestMappingHandlerAdapter
/** * Return the list of argument resolvers to use including built-in resolvers * and custom resolvers provided via {@link #setCustomArgumentResolvers}. */ private List<HandlerMethodArgumentResolver> getDefaultArgumentResolvers() { List<HandlerMethodArgumentResolver> resolvers = new ArrayList<HandlerMethodArgumentResolver>(); // Annotation-based argument resolution resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), false)); resolvers.add(new RequestParamMapMethodArgumentResolver()); resolvers.add(new PathVariableMethodArgumentResolver()); resolvers.add(new PathVariableMapMethodArgumentResolver()); resolvers.add(new MatrixVariableMethodArgumentResolver()); resolvers.add(new MatrixVariableMapMethodArgumentResolver()); resolvers.add(new ServletModelAttributeMethodProcessor(false)); resolvers.add(new RequestResponseBodyMethodProcessor(getMessageConverters(), this.requestResponseBodyAdvice)); resolvers.add(new RequestPartMethodArgumentResolver(getMessageConverters(), this.requestResponseBodyAdvice)); resolvers.add(new RequestHeaderMethodArgumentResolver(getBeanFactory())); resolvers.add(new RequestHeaderMapMethodArgumentResolver()); resolvers.add(new ServletCookieValueMethodArgumentResolver(getBeanFactory())); resolvers.add(new ExpressionValueMethodArgumentResolver(getBeanFactory())); resolvers.add(new SessionAttributeMethodArgumentResolver()); resolvers.add(new RequestAttributeMethodArgumentResolver()); // Type-based argument resolution resolvers.add(new ServletRequestMethodArgumentResolver()); resolvers.add(new ServletResponseMethodArgumentResolver()); resolvers.add(new HttpEntityMethodProcessor(getMessageConverters(), this.requestResponseBodyAdvice)); resolvers.add(new RedirectAttributesMethodArgumentResolver()); resolvers.add(new ModelMethodProcessor()); resolvers.add(new MapMethodProcessor()); resolvers.add(new ErrorsMethodArgumentResolver()); resolvers.add(new SessionStatusMethodArgumentResolver()); resolvers.add(new UriComponentsBuilderMethodArgumentResolver()); // Custom arguments if (getCustomArgumentResolvers() != null) { resolvers.addAll(getCustomArgumentResolvers()); } // Catch-all resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), true)); resolvers.add(new ServletModelAttributeMethodProcessor(true)); return resolvers; }
実際に上記のresolversから何を使うかを選ぶ処理はHandlerMethodArgumentResolverComposite
にあります。
/** * Find a registered {@link HandlerMethodArgumentResolver} that supports the given method parameter. */ private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) { HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter); if (result == null) { for (HandlerMethodArgumentResolver methodArgumentResolver : this.argumentResolvers) { if (logger.isTraceEnabled()) { logger.trace("Testing if argument resolver [" + methodArgumentResolver + "] supports [" + parameter.getGenericParameterType() + "]"); } if (methodArgumentResolver.supportsParameter(parameter)) { result = methodArgumentResolver; this.argumentResolverCache.put(parameter, result); break; } } } return result; }
見てわかるようにマッチするresolverが見つかったらfor文をbreakしてます。
ServletModelAttributeMethodProcessor
を追加しても呼ばれないわけです。
諦めた妥協案(ネットで見つけたやつ)
@Controller @RequestMapping("/sample") public class SampleController { @InitBinder("sampleEntity") public void initSampleBinder(WebDataBinder binder, HttpServletRequest request) { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("userId", request.getParameter("user_id")); binder.bind(propertyValues); } @RequestMapping(path = "/entity", method = RequestMethod.GET) public ResponseEntity entitySample( @Validated SampleEntity sampleEntity, BindingResult result) { System.out.println(ToStringBuilder.reflectionToString(sampleEntity)); System.out.println(ToStringBuilder.reflectionToString(result.getAllErrors(), ToStringStyle.JSON_STYLE)); return new ResponseEntity<>("OK", HttpStatus.OK); } }
jsonのjacksonさん(com.fasterxml.jackson.databind.PropertyNamingStrategy
)がそこらへん自動でやってくれるから、てっきり今回のも何も考えずにできるかと思いましたが、いろいろ面倒そうです。
ClassPoolから強引に書き換えたりとか、ConfigurableWebBindingInitializer
を作って頑張るとかも考えましたが、結果として楽な方法に落ち着きました。
もう少し・・
WebBindingInitializer
の実装クラスを書くか、
Beanに登録されているRequestMappingHandlerAdapter
を取り出して、getWebBindingInitializer().initBinder()
を呼ぶとかすればうまく行きそうな気がしないでもない。
他に良い方法あれば教えてください。