blog/content/posts/2023-10-09/index.md

13 KiB

author draft categories date tags keywords title relpermalink url decription
usbharu true
技術
2023-10-09T15:00:35+09:00
SpringBoot
SpringWeb
Kotlin
SpringBoot
SpringWeb
Kotlin
Spring WebとKotlinのdata classはapplication/x-www-form-urlencodedと相性が悪い posts/2023-10-09/ posts/2023-10-09/ Spring WebとKotlinのdata classは相性が悪い

三連休を半分ぐらいこれに費やした。(残り全部は 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 で作成したプロジェクト

全体的な流れ

{{< mermaid >}} flowchart TD

Start --> resolveArgument resolveArgument --> containsAttribute{mavContainer.containsAttribute} containsAttribute --> |true|binding containsAttribute --> |false|createAttribute createAttribute --> constructAttribute constructAttribute --> binding binding --> End {{< /mermaid >}}

mavContainer.containsAttribute

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

9df4bce043/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

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

9df4bce043/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

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

9df4bce043/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を自力で実装する。