メインコンテンツへスキップ

Spring WebとKotlinのdata classはapplication/x-www-form-urlencodedと相性が悪い

·732 文字·4 分 下書き
技術 SpringBoot SpringWeb Kotlin

三連休を半分ぐらいこれに費やした。(残り全部は WarThunder)

何故相性が悪いのか
#

「コンストラクタのパラメーターが 0 個ではないとき」 かつ 「ペイロードのパラメーター名とクラスのプロパティ名が違うとき」 にリクエストを正常に処理することができなくなってしまいます。

ペイロードのパラメーター名とクラスのプロパティ名が違うときとはどういうことか

例えばこういうリクエストがあったとしましょう。

POST /api/v1/statuses HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
Accept: */*
Content-Length: 29

id=aa&text=test&ids%5B%5D=aaa

id=aa&text=test&ids%5B%5D=aaaの部分はid=aa&text=test&ids[]=aaaをパーセントエンコーディングしたものです。

これをそれぞれ下記のクラスに格納させたいとします。

クラスのプロパティ名はidsですが、ペイロードのパラメーター名はids[]になります。 このとき正常に処理できずにエラがー発生します。

Java の場合
#

詳細な確認用コード

public class Hoge{
  private String id;
  private String text;
  private List<String> ids;

  //setter getterは省略
}

全てのパラメーターが正常にバインドされ、コントローラーに渡されます。

Kotlin の Data class の場合
#

詳細な再現用コード

data class Hoge(
  private val id:String,
  private val text:String,
  private val ids:List<String>
)

NullPointExceptionMethodArgumentNotValidExceptionが発生し 400 BadRequest が返されます。

発生するエラー
#

スタックトレース部分は省略

2023-10-09 15:19:37.387 ERROR 7648 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [dev.usbharu.springbootxwwwformurlencodedkotlin.Hoge]: Constructor threw exception; nested exception is java.lang.NullPointerException: Parameter specified as non-null is null: method dev.usbharu.springbootxwwwformurlencodedkotlin.Hoge.<init>, parameter fuga] with root cause

java.lang.NullPointerException: Parameter specified as non-null is null: method dev.usbharu.springbootxwwwformurlencodedkotlin.Hoge.<init>, parameter fuga
	at dev.usbharu.springbootxwwwformurlencodedkotlin.Hoge.<init>(Hoge.kt) ~[main/:na]

詳細な原因
#

Spring Boot 3 で作成したプロジェクトをいじっていたら詳細な原因が判明したため、説明は SpringBoot3 で行います。

SpringBoot3 で作成したプロジェクト

全体的な流れ
#

flowchart TD Start --> resolveArgument resolveArgument --> containsAttribute{mavContainer.containsAttribute} containsAttribute --> |true|binding containsAttribute --> |false|createAttribute createAttribute --> constructAttribute constructAttribute --> binding binding --> End

mavContainer.containsAttribute
#

ここで「ペイロードのパラメーター名とクラスのプロパティ名が違うとき」の判定が行われているような気がします。

https://github.com/spring-projects/spring-framework/blob/9df4bce043773bde74a0b0f8ca6e40463147a10c/spring-web/src/main/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessor.java#L143-L165

        if (mavContainer.containsAttribute(name)) {
            attribute = mavContainer.getModel().get(name);
        }
        else {
            // Create attribute instance
            try {
                attribute = createAttribute(name, parameter, binderFactory, webRequest);
            }
            catch (MethodArgumentNotValidException ex) {
                if (isBindExceptionRequired(parameter)) {
                    // No BindingResult parameter -> fail with BindException
                    throw ex;
                }
                // Otherwise, expose null/empty value and associated BindingResult
                if (parameter.getParameterType() == Optional.class) {
                    attribute = Optional.empty();
                }
                else {
                    attribute = ex.getTarget();
                }
                bindingResult = ex.getBindingResult();
            }
        }

createAttribute
#

コンストラクタを初期化する準備をします。

https://github.com/spring-projects/spring-framework/blob/9df4bce043773bde74a0b0f8ca6e40463147a10c/spring-web/src/main/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessor.java#L215-L227

    protected Object createAttribute(String attributeName, MethodParameter parameter,
            WebDataBinderFactory binderFactory, NativeWebRequest webRequest) throws Exception {


        MethodParameter nestedParameter = parameter.nestedIfOptional();
        Class<?> clazz = nestedParameter.getNestedParameterType();


        Constructor<?> ctor = BeanUtils.getResolvableConstructor(clazz);
        Object attribute = constructAttribute(ctor, attributeName, parameter, binderFactory, webRequest);
        if (parameter != nestedParameter) {
            attribute = Optional.of(attribute);
        }
        return attribute;
    }

constructAttribute
#

コンストラクタで初期化します

https://github.com/spring-projects/spring-framework/blob/9df4bce043773bde74a0b0f8ca6e40463147a10c/spring-web/src/main/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessor.java#L245-L350

    protected Object constructAttribute(Constructor<?> ctor, String attributeName, MethodParameter parameter,
            WebDataBinderFactory binderFactory, NativeWebRequest webRequest) throws Exception {


        if (ctor.getParameterCount() == 0) {
            // A single default constructor -> clearly a standard JavaBeans arrangement.
            return BeanUtils.instantiateClass(ctor);
        }


        // A single data class constructor -> resolve constructor arguments from request parameters.
        String[] paramNames = BeanUtils.getParameterNames(ctor);
        Class<?>[] paramTypes = ctor.getParameterTypes();
        Object[] args = new Object[paramTypes.length];
        WebDataBinder binder = binderFactory.createBinder(webRequest, null, attributeName);
        String fieldDefaultPrefix = binder.getFieldDefaultPrefix();
        String fieldMarkerPrefix = binder.getFieldMarkerPrefix();
        boolean bindingFailure = false;
        Set<String> failedParams = new HashSet<>(4);


        for (int i = 0; i < paramNames.length; i++) {
            String paramName = paramNames[i];
            Class<?> paramType = paramTypes[i];
            Object value = webRequest.getParameterValues(paramName);


            // Since WebRequest#getParameter exposes a single-value parameter as an array
            // with a single element, we unwrap the single value in such cases, analogous
            // to WebExchangeDataBinder.addBindValue(Map<String, Object>, String, List<?>).
            if (ObjectUtils.isArray(value) && Array.getLength(value) == 1) {
                value = Array.get(value, 0);
            }


            if (value == null) {
                if (fieldDefaultPrefix != null) {
                    value = webRequest.getParameter(fieldDefaultPrefix + paramName);
                }
                if (value == null) {
                    if (fieldMarkerPrefix != null && webRequest.getParameter(fieldMarkerPrefix + paramName) != null) {
                        value = binder.getEmptyValue(paramType);
                    }
                    else {
                        value = resolveConstructorArgument(paramName, paramType, webRequest);
                    }
                }
            }


            try {
                MethodParameter methodParam = new FieldAwareConstructorParameter(ctor, i, paramName);
                if (value == null && methodParam.isOptional()) {
                    args[i] = (methodParam.getParameterType() == Optional.class ? Optional.empty() : null);
                }
                else {
                    args[i] = binder.convertIfNecessary(value, paramType, methodParam);
                }
            }
            catch (TypeMismatchException ex) {
                ex.initPropertyName(paramName);
                args[i] = null;
                failedParams.add(paramName);
                binder.getBindingResult().recordFieldValue(paramName, paramType, value);
                binder.getBindingErrorProcessor().processPropertyAccessException(ex, binder.getBindingResult());
                bindingFailure = true;
            }
        }


        if (bindingFailure) {
            BindingResult result = binder.getBindingResult();
            for (int i = 0; i < paramNames.length; i++) {
                String paramName = paramNames[i];
                if (!failedParams.contains(paramName)) {
                    Object value = args[i];
                    result.recordFieldValue(paramName, paramTypes[i], value);
                    validateValueIfApplicable(binder, parameter, ctor.getDeclaringClass(), paramName, value);
                }
            }
            if (!parameter.isOptional()) {
                try {
                    Object target = BeanUtils.instantiateClass(ctor, args);
                    throw new MethodArgumentNotValidException(parameter, result) {
                        @Override
                        public Object getTarget() {
                            return target;
                        }
                    };
                }
                catch (BeanInstantiationException ex) {
                    // swallow and proceed without target instance
                }
            }
            throw new MethodArgumentNotValidException(parameter, result);
        }


        try {
            return BeanUtils.instantiateClass(ctor, args);
        }
        catch (BeanInstantiationException ex) {
            if (KotlinDetector.isKotlinType(ctor.getDeclaringClass()) &&
                    ex.getCause() instanceof NullPointerException cause) {
                BindingResult result = binder.getBindingResult();
                ObjectError error = new ObjectError(ctor.getName(), cause.getMessage());
                result.addError(error);
                throw new MethodArgumentNotValidException(parameter, result);
            }
            else {
                throw ex;
            }
        }
    }

このときwebRequest.getParameterでパラメーターを取得しますが、binder などを使用しないためids[]などの配列を取得することが出来ず、null になります。

return BeanUtils.instantiateClass(ctor, args);
#

BeanUtils.instantiateClass(ctor, args); ここでコンストラクタでの初期化が行われます。

Kotlin のコンストラクタでnullチェックが行われids[]などで取得できなかったパラメーターにnullで初期化しようとするため、NullPointerExceptionが発生し、BeanInstantiationExceptionが throw、catch され、さらに MethodArgumentNotValidExceptionが throw されます。

対処法
#

  1. application/x-www-form-urlencoded を使うのをやめる
  2. data class を使うのをやめる
  3. Java を使う
  4. ModelAttributeMethodProcessor を改造する
  5. HandlerMethodArgumentResolver を実装する

application/x-www-form-urlencoded を使うのをやめる
#

一番オススメです。 大人しく Json 使いましょう。こういうときは

data class を使うのをやめる
#

Kotlin で普通のクラスを作成します。 Java の Bean と同じものを作るようにしましょう。

このときコンストラクタでプロパティを宣言すると意味ないので注意

Java を使う
#

安定の Java です。Java は全てを解決します。

ModelAttributeMethodProcessor を改造する
#

実際に使用されているのはこれをさらに継承した org.springframework.web.servlet.mvc.method.annotation.ServletModelAttributeMethodProcessorが使用されているようですが、基本的な処理はorg.springframework.web.method.annotation.ModelAttributeMethodProcessorに委譲されているため、こちらを改造します。

HandlerMethodArgumentResolver を実装する
#

org.springframework.web.method.annotation.ModelAttributeMethodProcessorが実装しているインターフェースのorg.springframework.web.method.support.HandlerMethodArgumentResolverを自力で実装する。