--- author: usbharu draft: true categories: - 技術 date: 2023-10-09T15:00:35+09:00 tags: - SpringBoot - SpringWeb - Kotlin keywords: - SpringBoot - SpringWeb - Kotlin title: Spring WebとKotlinのdata classはapplication/x-www-form-urlencodedと相性が悪い relpermalink: posts/2023-10-09/ url: posts/2023-10-09/ decription: Spring WebとKotlinのdata classは相性が悪い --- 三連休を半分ぐらいこれに費やした。(残り全部は WarThunder) # 何故相性が悪いのか 「コンストラクタのパラメーターが 0 個ではないとき」 かつ 「ペイロードのパラメーター名とクラスのプロパティ名が違うとき」 にリクエストを正常に処理することができなくなってしまいます。 ペイロードのパラメーター名とクラスのプロパティ名が違うときとはどういうことか 例えばこういうリクエストがあったとしましょう。 ```HTTP Request 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 の場合 [詳細な確認用コード](https://git.usbharu.dev/usbharu/springboot-x-www-form-urlencoded) ```java public class Hoge{ private String id; private String text; private List ids; //setter getterは省略 } ``` 全てのパラメーターが正常にバインドされ、コントローラーに渡されます。 ## Kotlin の Data class の場合 [詳細な再現用コード](https://git.usbharu.dev/usbharu/springboot-x-www-form-urlencoded-kotlin) ```kotlin data class Hoge( private val id:String, private val text:String, private val ids:List ) ``` `NullPointException`と`MethodArgumentNotValidException`が発生し 400 BadRequest が返されます。 ### 発生するエラー スタックトレース部分は省略 ```Java 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., parameter fuga] with root cause java.lang.NullPointerException: Parameter specified as non-null is null: method dev.usbharu.springbootxwwwformurlencodedkotlin.Hoge., parameter fuga at dev.usbharu.springbootxwwwformurlencodedkotlin.Hoge.(Hoge.kt) ~[main/:na] ``` ### 詳細な原因 Spring Boot 3 で作成したプロジェクトをいじっていたら詳細な原因が判明したため、説明は SpringBoot3 で行います。 [SpringBoot3 で作成したプロジェクト](https://git.usbharu.dev/usbharu/springboot3-x-www-form-urlencoded) #### 全体的な流れ {{< mermaid >}} flowchart TD Start --> resolveArgument resolveArgument --> containsAttribute{mavContainer.containsAttribute} containsAttribute --> |true|binding containsAttribute --> |false|createAttribute createAttribute --> constructAttribute constructAttribute --> binding binding --> End {{< /mermaid >}} #### 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 ```java 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 ```java 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 ```java 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 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, 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 を使うのをやめる 1. data class を使うのをやめる 1. Java を使う 1. ModelAttributeMethodProcessor を改造する 1. 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`を自力で実装する。