SHIBUYA 3%

(元在宅が)渋谷で働くエンジニアの備忘録的memo & 雑記 - ココロはいつもSHIBUYAに在り

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 省略 */
}

@ParameterNameannotationを定義して、マッピングするプロパティに別名を定義してうまい具合に動くようにする(つもりでした)

アノテーションの設定

@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()を呼ぶとかすればうまく行きそうな気がしないでもない。

他に良い方法あれば教えてください。