三連休を半分ぐらいこれに費やした。(残り全部は 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>
)
NullPointException
とMethodArgumentNotValidException
が発生し 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 で行います。
全体的な流れ#
mavContainer.containsAttribute#
ここで「ペイロードのパラメーター名とクラスのプロパティ名が違うとき」の判定が行われているような気がします。
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#
コンストラクタを初期化する準備をします。
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#
コンストラクタで初期化します
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 されます。
対処法#
- application/x-www-form-urlencoded を使うのをやめる
- data class を使うのをやめる
- Java を使う
- ModelAttributeMethodProcessor を改造する
- 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
を自力で実装する。