From 09cbce1092634a6f811177c2775ad96aab49dff0 Mon Sep 17 00:00:00 2001 From: usbharu <64310155+usbharu@users.noreply.github.com> Date: Mon, 9 Oct 2023 17:31:04 +0900 Subject: [PATCH] =?UTF-8?q?2023-10-09=E3=81=AE=E8=A8=98=E4=BA=8B=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/posts/2023-10-09/index.md | 330 ++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 content/posts/2023-10-09/index.md diff --git a/content/posts/2023-10-09/index.md b/content/posts/2023-10-09/index.md new file mode 100644 index 0000000..1f1c456 --- /dev/null +++ b/content/posts/2023-10-09/index.md @@ -0,0 +1,330 @@ +--- +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`を自力で実装する。