Spring MVCにおけるHandlerMethodArgumentResolverによるパラメータ解析の仕組み

Webアプリケーション開発において、さまざまなデータ形式のリクエストを処理する必要があり、バックエンドでは対応するパラメータ処理メカニズムが求められます。Spring MVCは、Cookie値、ヘッダー値、JSONデータ、URIパラメータなど、多様なデータソースからの値取得に対応したパラメータリゾルバー群を提供しています。以下で、Spring MVCが提供する主要なパラメータ解析コンポーネントについて詳しく検討します。

Spring MVCでは、パラメータ解析機能の基底インターフェースとしてHandlerMethodArgumentResolverが定義されています。このインターフェースには多数の実装クラスが存在し、それぞれ異なる用途に対応しています。

これらのリゾルバーはHandlerMethodArgumentResolverCompositeというコンテナによって管理され、RequestMappingHandlerAdapter内でパラメータ処理に利用されます。

単純パラメータの解析ケース

以下のリクエストを例に考えてみましょう:

http://localhost:8080/testendpoint?userId=5&displayName=%E3%83%86%E3%82%B9%E3%83%88

対応するコントローラーメソッド:


    @GetMapping("testendpoint")
    public String processTest(Long userId, String displayName) {
        System.out.println(String.format("ユーザーID:%d, 表示名:%s", userId, displayName));
        return "/view/main";
    }

このパターンで使用されるのはRequestParamMethodArgumentResolverです。supportsParameterメソッドの実装を見てみましょう:


    @Override
    public boolean supportsParameter(MethodParameter methodParam) {
        if (methodParam.hasParameterAnnotation(Param.class)) {
            if (Map.class.isAssignableFrom(methodParam.nestedIfOptional().getNestedParameterType())) {
                String paramName = methodParam.getParameterAnnotation(Param.class).value();
                return StringUtils.hasText(paramName);
            } else {
                return true;
            }
        } else {
            if (methodParam.hasParameterAnnotation(Part.class)) {
                return false;
            }
            methodParam = methodParam.nestedIfOptional();
            
            if (MultipartResolutionDelegate.isMultipartArgument(methodParam)) {
                return true;
            } else if (this.enableDefaultResolution) {
                return BeanUtils.isSimpleProperty(methodParam.getNestedParameterType());
            } else {
                return false;
            }
        }
    }

このロジックでは、@RequestParamアノテーション(ここでは@Paramとして表現)が付与されたパラメータや、Map型以外の単純型パラメータを対象とします。enableDefaultResolutionがtrueの場合、BeanUtils.isSimplePropertyにより単純型かどうかが判定されます。

単純型の判定ロジック

isSimplePropertyメソッドの実装:


    public static boolean isSimpleProperty(Class<?> targetClass) {
        Assert.notNull(targetClass, "Class must not be null");
        return isSimpleValueType(targetClass) || 
               (targetClass.isArray() && isSimpleValueType(targetClass.getComponentType()));
    }

isSimpleValueTypeメソッドによる詳細な型チェック:


    public static boolean isSimpleValueType(Class<?> targetClass) {
        return (ClassUtils.isPrimitiveOrWrapper(targetClass) || targetClass.isEnum() ||
                CharSequence.class.isAssignableFrom(targetClass) ||
                Number.class.isAssignableFrom(targetClass) ||
                Date.class.isAssignableFrom(targetClass) ||
                URI.class == targetClass || URL.class == targetClass ||
                Locale.class == targetClass || Class.class == targetClass);
    }

この判定により、列挙型、文字列型、数値型、日付型、URI/URL型、ロケール型、クラス型などの単純型およびそれらの配列型がサポートされます。

パラメータ値の解決処理

resolveArgumentメソッドはパラメータ値の取得と変換を担当します:


    @Override
    public final Object resolveArgument(MethodParameter methodParam, 
            ModelAndViewContainer container, NativeWebRequest nativeRequest, 
            WebDataBinderFactory dataBinderFactory) throws Exception {
        
        NamedValueInfo valueInfo = retrieveNamedValueInfo(methodParam);
        MethodParameter nestedParam = methodParam.nestedIfOptional();
        Object resolvedKey = resolveStringValue(valueInfo.key);
        
        if (resolvedKey == null) {
            throw new IllegalArgumentException(
                    "Specified key must not resolve to null: [" + valueInfo.key + "]");
        }
        
        Object extractedValue = extractValue(resolvedKey.toString(), nestedParam, nativeRequest);
        
        if (extractedValue == null) {
            if (valueInfo.defaultValue != null) {
                extractedValue = resolveStringValue(valueInfo.defaultValue);
            } else if (valueInfo.required && !nestedParam.isOptional()) {
                handleMissingParameter(valueInfo.key, nestedParam, nativeRequest);
            }
            extractedValue = handleNullResult(valueInfo.key, extractedValue, 
                                            nestedParam.getNestedParameterType());
        } else if ("".equals(extractedValue) && valueInfo.defaultValue != null) {
            extractedValue = resolveStringValue(valueInfo.defaultValue);
        }
        
        if (dataBinderFactory != null) {
            WebDataBinder binder = dataBinderFactory.createBinder(nativeRequest, null, valueInfo.key);
            try {
                extractedValue = binder.convertIfNecessary(extractedValue, 
                                                         methodParam.getParameterType(), methodParam);
            } catch (ConversionNotSupportedException conversionEx) {
                throw new MethodArgumentConversionNotSupportedException(extractedValue, 
                        conversionEx.getRequiredType(), valueInfo.key, methodParam, conversionEx.getCause());
            } catch (TypeMismatchException mismatchEx) {
                throw new MethodArgumentTypeMismatchException(extractedValue, 
                        mismatchEx.getRequiredType(), valueInfo.key, methodParam, mismatchEx.getCause());
            }
        }
        
        handleResolvedResult(extractedValue, valueInfo.key, methodParam, container, nativeRequest);
        return extractedValue;
    }

名前付き値情報の取得

getNamedValueInfoメソッドはキャッシュ機構を使用してパラメータ情報を効率的に取得します:


    private NamedValueInfo retrieveNamedValueInfo(MethodParameter methodParam) {
        NamedValueInfo cachedInfo = this.namedValueInfoCache.get(methodParam);
        if (cachedInfo == null) {
            cachedInfo = createNamedValueInfo(methodParam);
            cachedInfo = updateNamedValueInfo(methodParam, cachedInfo);
            this.namedValueInfoCache.put(methodParam, cachedInfo);
        }
        return cachedInfo;
    }

createNamedValueInfoメソッドではアノテーションの有無に基づいて適切なNamedValueInfoインスタンスを作成します:


    @Override
    protected NamedValueInfo createNamedValueInfo(MethodParameter methodParam) {
        Param annotation = methodParam.getParameterAnnotation(Param.class);
        return (annotation != null ? new CustomNamedValueInfo(annotation) : new CustomNamedValueInfo());
    }

CustomNamedValueInfoクラスの実装:


    private static class CustomNamedValueInfo extends NamedValueInfo {

        public CustomNamedValueInfo() {
            super("", false, ValueConstants.DEFAULT_NONE);
        }

        public CustomNamedValueInfo(Param annotation) {
            super(annotation.value(), annotation.required(), annotation.defaultValue());
        }
    }

パラメータ名の解決

updateNamedValueInfoメソッドはパラメータ名の確定処理を行います:


    private NamedValueInfo updateNamedValueInfo(MethodParameter methodParam, NamedValueInfo baseInfo) {
        String paramName = baseInfo.key;
        if (baseInfo.key.isEmpty()) {
            paramName = methodParam.getParameterName();
            if (paramName == null) {
                throw new IllegalArgumentException(
                        "Name for argument type [" + methodParam.getNestedParameterType().getName() +
                        "] not available, and parameter name information not found in class file either.");
            }
        }
        String actualDefaultValue = (ValueConstants.DEFAULT_NONE.equals(baseInfo.defaultValue) ? 
                                   null : baseInfo.defaultValue);
        return new NamedValueInfo(paramName, baseInfo.required, actualDefaultValue);
    }

パラメータ名の取得にはASMベースのメタデータ解析が使用されるため、コンパイル時のデバッグ情報保持が重要になります。

実際の値抽出処理

resolveNameメソッドはマルチパートリクエストと通常リクエストの両方に対応します:


    @Override
    protected Object extractValue(String paramName, MethodParameter methodParam, 
                                 NativeWebRequest nativeRequest) throws Exception {
        HttpServletRequest httpRequest = nativeRequest.getNativeRequest(HttpServletRequest.class);
        MultipartHttpServletRequest multiRequest =
                WebUtils.getNativeRequest(httpRequest, MultipartHttpServletRequest.class);
        
        Object multipartValue = MultipartResolutionDelegate.resolveMultipartArgument(
                paramName, methodParam, httpRequest);
        if (multipartValue != MultipartResolutionDelegate.UNRESOLVABLE) {
            return multipartValue;
        }

        Object result = null;
        if (multiRequest != null) {
            List<MultipartFile> fileList = multiRequest.getFiles(paramName);
            if (!fileList.isEmpty()) {
                result = (fileList.size() == 1 ? fileList.get(0) : fileList);
            }
        }
        
        if (result == null) {
            String[] values = nativeRequest.getParameterValues(paramName);
            if (values != null) {
                result = (values.length == 1 ? values[0] : values);
            }
        }
        return result;
    }

必須パラメータの欠損処理

handleMissingValueメソッドは必須パラメータが見つからない場合の例外処理を行います:


    @Override
    protected void handleMissingParameter(String paramName, MethodParameter methodParam, 
                                        NativeWebRequest nativeRequest) throws Exception {

        HttpServletRequest httpRequest = nativeRequest.getNativeRequest(HttpServletRequest.class);
        if (MultipartResolutionDelegate.isMultipartArgument(methodParam)) {
            if (!MultipartResolutionDelegate.isMultipartRequest(httpRequest)) {
                throw new MultipartException("Current request is not a multipart request");
            } else {
                throw new MissingServletRequestPartException(paramName);
            }
        } else {
            throw new MissingServletRequestParameterException(paramName,
                    methodParam.getNestedParameterType().getSimpleName());
        }
    }

null値の処理

handleNullValueメソッドはnull値に対する特別な処理を実施します:


    private Object handleNullResult(String paramName, Object currentValue, Class<?> paramClass) {
        if (currentValue == null) {
            if (Boolean.TYPE.equals(paramClass)) {
                return Boolean.FALSE;
            } else if (paramClass.isPrimitive()) {
                throw new IllegalStateException("Optional " + paramClass.getSimpleName() + 
                        " parameter '" + paramName + "' is present but cannot be translated into a null value " +
                        "due to being declared as a primitive type. Consider declaring it as object wrapper " +
                        "for the corresponding primitive type.");
            }
        }
        return currentValue;
    }

基本データ型(int、longなど)に対してnull値が渡された場合、例外が発生します。

アノテーションを使用した明示的なパラメータ名指定:


    @GetMapping("annotatedRequest")
    public String processAnnotatedRequest(@Param("userId") Long userId) {
        System.out.println("ユーザ識別子:" + userId);
        return "アノテーション付きリクエストの処理結果";
    }

RequestParamアノテーションを使用することで、リクエストパラメータ名の指定、デフォルト値の設定、必須/任意の指定などが可能になります。

タグ: spring-mvc handlermethodargumentresolver requestparam parameter-resolver web-development

7月17日 21:03 投稿