2023-10-09の記事を追加

This commit is contained in:
usbharu 2023-10-09 17:31:04 +09:00
parent 3588ffd64a
commit 09cbce1092
Signed by: usbharu
GPG Key ID: 6556747BF94EEBC8
1 changed files with 330 additions and 0 deletions

View File

@ -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<String> 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<String>
)
```
`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.<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 で作成したプロジェクト](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<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 を使うのをやめる
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`を自力で実装する。