Jersey + Spring Boot の環境で multipart/form-data のリクエストを受け付ける方法


概要

JAX-RS にて REST API を構築しているアプリケーションの DI コンテナを CDI (Weld) から Spring Boot に変更したところ、 multipart/form-data のリクエストを受け付けなくなってしまいました。

このような実装をしています。


Board.java

import org.jboss.resteasy.annotations.providers.multipart.MultipartForm;

import javax.ws.rs.Consumes;
import javax.ws.rs.FormParam;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("/v1/board")
public interface Board {
// 実装クラスは別に存在する
@Path("/talk/")
@POST
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
TalkResponse addTalk(@MultipartForm TalkRequest request);
}



TalkRequest.java

import javax.ws.rs.FormParam;

import lombok.Data;
import org.jboss.resteasy.annotations.providers.multipart.PartType;

@Data
public class TalkRequest implements Serializable {

@FormParam("name")
private String name;

@FormParam("content")
private String content;

@FormParam("tempFile")
@PartType(MediaType.APPLICATION_OCTET_STREAM)
private byte[] tempFile;

@FormParam("tempFileName")
private String tempFileName;
}


この実装において、エンドポイントへリクエストするとこんなエラーが出力されました。

javax.ws.rs.NotSupportedException: HTTP 415 Unsupported Media Type

….
Caused by: org.glassfish.jersey.message.internal.MessageBodyProviderNotFoundException: MessageBodyReader not found for media type=multipart/form-data;boundary=----WebKitFormBoundaryBxPm2eAVMMAM29Dr, type=class

これを解決するために、1 日程度かかってしまったのでメモとしてこの記事を残しておきます。


解決策

本要件を実現するためには、上記エラーは解決すべきことの一つにすぎません。下記を実施します。


415 エラーの回避

Jersey + Spring Boot 環境において、規定の状態だとリクエストの内容をパースできるクラスが存在していないようでした。そのため、 multipart/form-data 用のライブラリの依存関係を追加します。


pom.xml

<dependency>

<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-multipart</artifactId>
<version>${glassfish.version}</version>
</dependency>

そして、このライブラリに含まれる MultipartFeature クラスを Jersey のリソースとして登録します。


JerseyConfig.java

@Component

@ApplicationPath("/resources")
public class JerseyConfig extends ResourceConfig {

public JerseyConfig() {
register(Board.class);
register(MultiPartFeature.class); // MultiPartFeature を登録する。アプリケーション起動時に、このクラスがパーサーを登録する
}
}


ResourceConfig で登録 (register) したクラスは、ResourceConfig -> CommonConfig が保持している ComponentBag クラスに登録されます。ここに登録されたクラスがアプリケーション (Jersey) が知っているクラスになるので、何かあるとここからインスタンスを取り出そうとします。multipart/form-data のリクエストをパースするクラスを登録したことですし、これでリクエストをパースできると思いました。


ClassNotFoundException の回避

上記の変更を実施すると、415 エラーは回避できますが、今度は違うエラーが発生します。

org.glassfish.jersey.internal.l10n.LocalizableMessageFactory$ResourceBundleSupplier

at java.net.URLClassLoader.findClass(URLClassLoader.java:382)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:349)

これは依存関係を追加して解決します。


pom.xml

<dependency>

<groupId>org.glassfish.jersey.core</groupId>
<artifactId>jersey-client</artifactId>
<version>${glassfish.version}</version>
</dependency>


POJO に MultiPart クラスを継承させてみる

後述 の通り不要な手順ですが、話の流れのため記述します。

しかしこれでも 415 (Unsupported Media Type) エラーが発生してしまいます。Jersey のソースコードを眺めたところ、MediaType を multipart/form-data でリクエストした場合、リクエストの内容をパースするためのパーサーを見つけられないため 415 エラー発生しているようでした。

クライアントからリクエストが行われると HttpServlet から Jersey へとパイプされ、Jersey が様々なことをしているようです。その中でリクエストされた内容を Java のオブジェクトにマップする処理があります。この処理の中で、複数あるパーサーの候補の中から MessageBodyFactory クラスの isCompatible メソッドを使って、リクエストに含まれた内容をパースできるパーサーを判断しているようです。この判断で、MediaType が multipart/form-data のリクエストである場合、MultiPart クラスを継承しているクラスか、MultiPart クラスを継承しているパーサーが、リクエストの内容をパースできると判断される動作がありました。


MessageBodyFactory.java

    private <T> boolean isCompatible(final AbstractEntityProviderModel<T> model, final Class c, final MediaType mediaType) {

if (model.providedType().equals(Object.class)
|| // looks weird. Could/(should?) be separated to Writer/Reader check
model.providedType().isAssignableFrom(c)
|| c.isAssignableFrom(model.providedType())
) {

isCompatible メソッドの引数は、


  • AbstractEntityProviderModel : アプリケーションが知っているパーサー


    • Jersey に登録されているパーサーの数だけループが周り都度このメソッドで判定しています。引数にくるのはループから渡されるパーサーの候補です。



  • Class : Resource (@Path をつけた) クラスのメソッドの引数の Class インスタンス。


    • Board.addTalk の例だと引数である、TalkRequest。 TalkRequest.class が渡ってきます。



  • MediaType : multipart/form-data


    • Consumes で指定した MediaType



であり、TalkRequest は、MultiPart を継承していませんし、TalkRequest を継承しているパーサーも存在しません。そのため、Multipart でリクエストされた内容を TalkRequest にマップさせることができないために、415 エラーが発生しているようなのでした。

そこで、isCompatible メソッドで true と判定されるよう TalkRequest に MultiPart を継承させてみました。


MIMEParsingException の回避

再度リクエストを投げると、TalkRequest の MultiPart の継承により、415 エラーは回避することができましたが、また異なるエラーが発生しました。

Caused by: org.jvnet.mimepull.MIMEParsingException: Missing start boundary

at org.jvnet.mimepull.MIMEParser.skipPreamble(MIMEParser.java:318) ~[mimepull-1.9.10.jar:1.9.10]
at org.jvnet.mimepull.MIMEParser.access$300(MIMEParser.java:68) ~[mimepull-1.9.10.jar:1.9.10]
at org.jvnet.mimepull.MIMEParser$MIMEEventIterator.next(MIMEParser.java:154) ~[mimepull-1.9.10.jar:1.9.10]
at org.jvnet.mimepull.MIMEParser$MIMEEventIterator.next(MIMEParser.java:132) ~[mimepull-1.9.10.jar:1.9.10]
at org.jvnet.mimepull.MIMEMessage.makeProgress(MIMEMessage.java:228) ~[mimepull-1.9.10.jar:1.9.10]
at org.jvnet.mimepull.MIMEMessage.parseAll(MIMEMessage.java:189) ~[mimepull-1.9.10.jar:1.9.10]
at org.jvnet.mimepull.MIMEMessage.getAttachments(MIMEMessage.java:115) ~[mimepull-1.9.10.jar:1.9.10]

このエラーについては全く意味がわからなかったのですが、どうやら、バイナリを読もうとしたところ、読み込むべきバイナリが存在していないような状態になっているようでした。デバッグしていたら、リクエスト時のストリームと思われる内容が存在していないことが伺えました。


MultiPartReaderClientSide.java

    protected MultiPart readMultiPart(final Class<MultiPart> type,

final Type genericType,
final Annotation[] annotations,
MediaType mediaType,
final MultivaluedMap<String, String> headers,
final InputStream stream) throws IOException, MIMEParsingException {
mediaType = unquoteMediaTypeParameters(mediaType, "boundary");

final MIMEMessage mimeMessage = new MIMEMessage(stream,
mediaType.getParameters().get("boundary"),
mimeConfig);



リクエストの内容であると思われる stream の available が 0 になっている

スクリーンショット 2019-03-07 16.54.08.png


これについては、StackOverFlowの回答 に答えがありました。


After some research and finding related issues1, it seems that Spring's HiddenHttpMethodFilter reads the input stream, which leaves it empty for any other filters further down the filter chain. This is why we are getting a Bad Request in the Jersey filter; because the entity stream is empty. Here is the note from the Javadoc


なんとリクエスト時に Spring Boot のフィルターが一度、ストリームを空の状態にしているということなのです。

それを回避するためにここに書かれているように、まずは、Jersey のフィルターを動作させますが、その直前に、RequestContextFilter を動かすように変更したところ、正常に動作しました。


AppConfiguration.java

import org.springframework.boot.web.servlet.FilterRegistrationBean;

import org.springframework.boot.web.servlet.filter.OrderedRequestContextFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.RequestContextFilter;

@Configuration
public class AppConfiguration {
@Bean
public RequestContextFilter requestContextFilter() {
OrderedRequestContextFilter filter = new OrderedRequestContextFilter();
filter.setOrder(-100001);
return filter;
}
}



application.properties

spring.jersey.filter.order=-100000


この親切かつ詳細なアドバイスを適用すると無事リクエストの内容が存在する状態となりました。

スクリーンショット 2019-03-07 17.13.41.png


@MultiPartForm を諦める

ここまで実施しましたが、今度はリクエスト時にこのようなエラーが発生してしまいました。

java.lang.IllegalArgumentException: argument type mismatch

at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_202]
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_202]
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_202]
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_202]
at org.glassfish.jersey.server.model.internal.ResourceMethodInvocationHandlerFactory.lambda$static$0(ResourceMethodInvocationHandlerFactory.java:76) ~[jersey-server-2.27.jar:na]
at org.glassfish.jersey.server.model.internal.AbstractJavaResourceMethodDispatcher$1.run(AbstractJavaResourceMethodDispatcher.java:148) ~[jersey-server-2.27.jar:na]
at org.glassfish.jersey.server.model.internal.AbstractJavaResourceMethodDispatcher.invoke(AbstractJavaResourceMethodDispatcher.java:191) ~[jersey-server-2.27.jar:na]
at org.glassfish.jersey.server.model.internal.JavaResourceMethodDispatcherProvider$TypeOutInvoker.doDispatch(JavaResourceMethodDispatcherProvider.java:243) ~[jersey-server-2.27.jar:na]

上述した POJO に MultiPart クラスを継承させてみる で確かに multipart/form-data のためのパーサーが選択されるようになったのですが、今度はそのようなクラスをパースできないというようなエラー内容なのではないかと思います。さらなるトラブルシュートを続けようと思いましたが、よくよく考えると @MultiPartForm は JAX-RS の実装である RESTEasy の持ち物です。

RESTEasyのマニュアル を見ると、@MultiPartForm で容易に POJO でリクエストをマップさせることができるとあります。そして、Spring Boot を導入するまでは容易に POJO にリクエストをマップできていました。

そうです、よくよく考えると、Spring Boot のプロジェクトの雛形を生成するための Spring Initializr には RESTEasy への依存関係が存在しません。JAX-RS を選択する場合は、現段階において、Jersey 一択です。そのためでしょう、Spring Boot を導入していない状態だと、上記の Jersey の MessageBodyFactory#isCompatible メソッドが動かないですし、その他めぼしい箇所にかけたブレークポイントも、RESTEasy で動かしている場合はとおらないのです。

JAX-RS の実装に Jersey ではなく RESTEasy を採用することも少し考えましたが、Spring Boot がサポートしていない RESTEasy と組み合わせるのはリスクが高すぎます。@MultiPartForm のためだけに取る手段ではありません。

そのため、もしかしたら方法はあるのかもしれませんが、公式ページのマニュアル に RESTEasy の @MultiPartForm と同等の機能に関する言及がなく、糸口が皆目見当もつかないので、POJO でのリクエストのマップを諦める方針としました。それまで動作していたものと同じ動作を実現するため、POJO に MultiPart クラスを継承させていましたが、それをやめました。代わりに、String 型、byte 型、それぞれに適切なパーサーがないかと期待して、POJO にあったメンバー変数を個別にインターフェースのメソッドの引数に定義しました。


Board.java

import org.glassfish.jersey.media.multipart.FormDataParam;

import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("/v1/board")
public interface Board {
// 実装クラスは別に存在する
@Path("/talk/")
@POST
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
TalkResponse addTalk(
@FormDataParam("name") String name,
@FormDataParam("content") String content,
@FormDataParam("tempFile") byte[] tempFile,
@FormDataParam("tempFileName") String tempFileName
);
}


これで正常に動作しました。上記については、以下 2 つの注意点を反映させました。


@FormDataParam にする

最初は、メンバー変数に @FormParam アノテーションを指定しましたが、@FormDataParam にしました。そもそも、@FormParam は、application/x-www-form-urlencoded 用のアノテーションです。 multipart/form-data の場合は @FormDataParam です。@FormParam アノテーションを指定するとアプリケーション起動時にこんなエラーが出ます。

[ExceptionMapper.toResponse:21] - The @FormParam is utilized when the content type of the request entity is not application/x-www-form-urlencoded - POST - user-agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36 - url:http://localhost:8080/resources/v1/board/talk

RESTEasy はこのアノテーションを気にしていなかったのか、それとも以前の @FormParam は JAX-RS のアノテーションなので RESTEasy がそれを利用することを想定してそもそも正しいのか、あるいは他の理由か、なぜ以前のバージョンで正常に動いていたかはわかりませんが、少なくとも私の環境ではこのような動作になりました。

参考

リクエストからの情報の抽出

https://docs.oracle.com/cd/E28613_01/web.1211/b65960/develop.htm#BABFAACJ

Annotation Type FormDataParam

http://javadox.com/org.glassfish.jersey.media/jersey-media-multipart/2.17/org/glassfish/jersey/media/multipart/FormDataParam.html


@PartType アノテーションを外す

RESTEasy の持ち物なので @PartType(MediaType.APPLICATION_OCTET_STREAM) を外しました。@PartType アノテーションがあっても動作しますが、



  • @FormDataParam

  • @PartType

の順番で記述すると、アプリケーション起動時にこんなエラーが出ました。

[[FATAL] No injection source found for a parameter of type public abstract TalkRequest Board.addTalk(java.lang.String,java.lang.String,byte[],java.lang.String) at index 1.; source='ResourceMethod{httpMethod=POST, consumedTypes=[multipart/form-data], producedTypes=[application/json], suspended=false, suspendTimeout=0, 

理屈はわかりませんが、とにかくアノテーションの順序を変更したらエラーがなくなりました。


  • @PartType


  • @FormDataParam

の順番でアノテーションを指定するとエラーがなくなります。


その他

POJO を諦めて引数の型をシンプルな String, byte にしたので、MediaType が multipart/form-data であってもよしなに適切なパーサーが選択されるかと思い、pom.xml に追記した jersey-media-multipart などの依存関係をはずしましたが、エラーが出てしまいました。内容は、上述した、@PartType@FormDataParamのアノテーションを順番通りに記述しないときのエラーと同じです。アプリケーション起動時に出力されます。

[[FATAL] No injection source found for a parameter of type public abstract TalkRequest Board.addTalk(java.lang.String,java.lang.String,byte[],java.lang.String) at index 1.; source='ResourceMethod{httpMethod=POST, consumedTypes=[multipart/form-data], producedTypes=[application/json], suspended=false, suspendTimeout=0, 

jersey-media-multipart への依存関係は確実に必要みたいです。Jersey + Spring Boot の環境ではこのライブラリが MediaType が multipart/form-data をパースする役割を担うのだと思います。


所感

私の実施した内容が果たして最適な手順であるのかについては、甚だ自信がありません。

ただ、Spring Boot + Jersey での multipart/form-data でのリクエストは非常にややこしいのは間違いないと思います。

そのため、よほどの理由がなければ multipart/form-data でのリクエストは避けて、バイナリをリクエストしたい場合は Base64 形式にて JSON でリクエストする方法を採用した方が良いかと思いました。

また、新規でアプリケーションを開発するのであれば、Spring Boot + Spring MVC の構成が素直なのではないかと思いました。