これは、なにをしたくて書いたもの?
前に、WildFly(RESTEasy)を使ってJakarta RESTful Web Services(以降JAX-RS)でServer-Sent Events(SSE)が
扱えることを試してみました。
WildFly 35(RESTEasy)でServer-Sent Events(SSE)を試す - CLOVER🍀
この時は基本的な動作を確認しましたが、イベント送信時にパイプライン(MessageBodyWriter)が使われるということ
だったのでこのあたりを確認しようかなと思いまして。
Jakarta RESTful Web ServicesでServer-Sent Eventsのイベント送信時にメディアタイプを指定する
今回、なんの話をしているかというと、この部分です。
The initial SSE response, which may only include the HTTP headers, is processed using the standard JAX-RS pipeline as described in Appendix Processing Pipeline. Each subsequent SSE event may include a different payload and thus require the use of a specific message body writer. Note that since this use case differs slightly from the normal JAX-RS pipeline, implementations SHOULD NOT call entity interceptors on each individual event.
Jakarta RESTful Web Services / Server-Sent Events / Processing Pipeline
HTTPヘッダーを含む可能性のある最初のレスポンスのみ、標準のJAX-RSのパイプラインが使われるとされています。
The initial SSE response, which may only include the HTTP headers, is processed using the standard JAX-RS pipeline as described in Appendix Processing Pipeline.
後続のSSEの各イベントでは、ペイロードに合わせたMessageBodyWriterを使われることが求められています。
Each subsequent SSE event may include a different payload and thus require the use of a specific message body writer.
またこのケースは通常のJAX-RSのパイプラインと少し異なるので、JAX-RS実装は個々のイベントごとにエンティティ
インターセプターを呼び出すべきではないとされています。
Note that since this use case differs slightly from the normal JAX-RS pipeline, implementations SHOULD NOT call entity interceptors on each individual event.
パイプラインというのはこちらですね。
Jakarta RESTful Web Services / Appendix C: Processing Pipeline
ここでOutboundSseEvent.Builderを見ると、イベントデータのメディアタイプを指定することができます。
これとServer-Sent EventsにおけるMessageBodyWriterが関係していそうですね。
OutboundSseEvent.Builder (Jakarta RESTful WS API 3.1.0 API)
ところで、Server-Sent Eventsの仕様自体を見ると、特にtext/event-stream以外のメディアタイプには触れられていません。
text/event-streamは最初のレスポンスで使うメディアタイプですね。
HTML Living Standard / Server-sent events
イベントにおけるメディアタイプの指定が、送信されるデータにどのように反映されるかが気になるところです。
というわけで、今回はこのあたりを確認してみたいと思います。
- イベントの構築時にメディアタイプを指定する/しない時の挙動の変化
- JAX-RSのフィルターやインターセプターを適用してみて、イベント送信時に動作するかどうか
あとはRESTEasyの実装面でも確認できればという感じですね。
環境
今回の環境はこちら。
$ java --version openjdk 21.0.6 2025-01-21 OpenJDK Runtime Environment (build 21.0.6+7-Ubuntu-124.04.1) OpenJDK 64-Bit Server VM (build 21.0.6+7-Ubuntu-124.04.1, mixed mode, sharing) $ mvn --version Apache Maven 3.9.9 (8e8579a9e76f7d015ee5ec7bfcdc97d260186937) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 21.0.6, vendor: Ubuntu, runtime: /usr/lib/jvm/java-21-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "6.8.0-54-generic", arch: "amd64", family: "unix"
WildFlyは35.0.1.Finalを使います。
準備
Maven依存関係など。
pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>org.littlewings</groupId> <artifactId>jaxrs-sse-mediatype-example</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>war</packaging> <properties> <maven.compiler.release>21</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.wildfly.bom</groupId> <artifactId>wildfly-ee-with-tools</artifactId> <version>35.0.1.Final</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>jakarta.servlet</groupId> <artifactId>jakarta.servlet-api</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>jakarta.ws.rs</groupId> <artifactId>jakarta.ws.rs-api</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>jakarta.enterprise</groupId> <artifactId>jakarta.enterprise.cdi-api</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>jakarta.inject</groupId> <artifactId>jakarta.inject-api</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>jakarta.enterprise.concurrent</groupId> <artifactId>jakarta.enterprise.concurrent-api</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>org.jboss.logging</groupId> <artifactId>jboss-logging</artifactId> <scope>provided</scope> </dependency> </dependencies> <build> <finalName>ROOT</finalName> <plugins> <plugin> <groupId>org.wildfly.plugins</groupId> <artifactId>wildfly-maven-plugin</artifactId> <version>5.1.2.Final</version> <executions> <execution> <id>package</id> <goals> <goal>package</goal> </goals> </execution> </executions> <configuration> <overwrite-provisioned-server>true</overwrite-provisioned-server> <discover-provisioning-info> <version>35.0.1.Final</version> </discover-provisioning-info> </configuration> </plugin> </plugins> </build> </project>
Server-Sent Eventsを使ったJAX-RSのソースコードを作成する
それでは、Server-Sent Eventsを使ったJAX-RSのソースコードを作成していきます。
JAX-RSの有効化。
src/main/java/org/littlewings/jaxrs/sse/RestApplication.java
package org.littlewings.jaxrs.sse; import jakarta.ws.rs.ApplicationPath; import jakarta.ws.rs.core.Application; @ApplicationPath("/") public class RestApplication extends Application { }
メディアタイプごとに指定するクラス。JSON用とXML用の2種類を作りましょう。
JSON向けはRecordにします。
src/main/java/org/littlewings/jaxrs/sse/Book.java
package org.littlewings.jaxrs.sse; public record Book(String isbn, String title, Integer price) { }
XML用。JAXBを使います。
src/main/java/org/littlewings/jaxrs/sse/XmlBook.java
package org.littlewings.jaxrs.sse; import jakarta.xml.bind.annotation.XmlRootElement; @XmlRootElement public class XmlBook { private String isbn; private String title; private Integer price; public XmlBook() { } public static XmlBook create(String isbn, String title, Integer price) { XmlBook book = new XmlBook(); book.setIsbn(isbn); book.setTitle(title); book.setPrice(price); return book; } // getter/setterは省略 }
Server-Sent Eventsを扱うリソースクラス。
src/main/java/org/littlewings/jaxrs/sse/SseResource.java
package org.littlewings.jaxrs.sse; import jakarta.annotation.Resource; import jakarta.enterprise.concurrent.ManagedExecutorService; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.sse.OutboundSseEvent; import jakarta.ws.rs.sse.Sse; import jakarta.ws.rs.sse.SseEventSink; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.IntStream; @Path("/sse") @ApplicationScoped public class SseResource { @Inject private Sse sse; @Resource private ManagedExecutorService executorService; @GET @Path("/text") @Produces(MediaType.SERVER_SENT_EVENTS) public void text(@Context SseEventSink sseEventSink) { executorService.submit(() -> { try (SseEventSink sink = sseEventSink) { String id = "last-id"; IntStream.rangeClosed(1, 10).forEach(i -> { OutboundSseEvent event = sse.newEventBuilder() .id(id) .name("name-" + i) .data("data-" + i) .build(); sink.send(event); sleep(); }); } }); } @GET @Path("/entity") @Produces(MediaType.APPLICATION_JSON) public Book entity() { return new Book("978-4621303252", "Effective Java 第3版", 4400); } @GET @Path("/json") @Produces(MediaType.SERVER_SENT_EVENTS) public void json(@Context SseEventSink sseEventSink) { List<Book> books = List.of( new Book("978-4621303252", "Effective Java 第3版", 4400), new Book("978-4798180946", "独習Java 第6版", 3278), new Book("978-4297144357", "Javaエンジニアのための ソフトウェアテスト実践入門 ~自動化と生成AIによるモダンなテスト技法~", 3520), new Book("978-4297146801", "改訂3版 パーフェクトJava (Perfect series 02)", 3740), new Book("978-4774189093", "Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278) ); executorService.submit(() -> { try (SseEventSink sink = sseEventSink) { String id = "last-id"; books.forEach(book -> { OutboundSseEvent event = sse.newEventBuilder() .id(id) .name("name-" + books.indexOf(book)) .mediaType(MediaType.APPLICATION_JSON_TYPE) .data(Book.class, book) .build(); sink.send(event); sleep(); }); } }); } @GET @Path("/entity-xml") @Produces(MediaType.APPLICATION_XML) public XmlBook entityXml() { return XmlBook.create("978-4621303252", "Effective Java 第3版", 4400); } @GET @Path("/xml") @Produces(MediaType.SERVER_SENT_EVENTS) public void xml(@Context SseEventSink sseEventSink) { List<XmlBook> books = List.of( XmlBook.create("978-4621303252", "Effective Java 第3版", 4400), XmlBook.create("978-4798180946", "独習Java 第6版", 3278), XmlBook.create("978-4297144357", "Javaエンジニアのための ソフトウェアテスト実践入門 ~自動化と生成AIによるモダンなテスト技法~", 3520), XmlBook.create("978-4297146801", "改訂3版 パーフェクトJava (Perfect series 02)", 3740), XmlBook.create("978-4774189093", "Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278) ); executorService.submit(() -> { try (SseEventSink sink = sseEventSink) { String id = "last-id"; books.forEach(book -> { OutboundSseEvent event = sse.newEventBuilder() .id(id) .name("name-" + books.indexOf(book)) .mediaType(MediaType.APPLICATION_XML_TYPE) .data(XmlBook.class, book) .build(); sink.send(event); sleep(); }); } }); } private void sleep() { try { TimeUnit.SECONDS.sleep(1L); } catch (InterruptedException e) { // ignore } } }
比較のために5つのリソースメソッドを用意しました。
- Server-Sent Eventsでデフォルトのメディアタイプでイベントを送信するもの
- JSONを単一のレスポンスで返すもの
- Server-Sent EventsでメディアタイプにJSONを指定してイベントを送信するもの
- XMLを単一のレスポンスで返すもの
- Server-Sent EventsでメディアタイプにXMLを指定してイベントを送信するもの
単一のレスポンスを返すものがあるのは、フィルターやインターセプターのかかり方の違いを見るためです。
また結果がわかりやすくなるように、スリープを入れています。
というわけで、フィルターを用意。
src/main/java/org/littlewings/jaxrs/sse/LoggingFilter.java
package org.littlewings.jaxrs.sse; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.container.ContainerResponseContext; import jakarta.ws.rs.container.ContainerResponseFilter; import jakarta.ws.rs.ext.Provider; import java.io.IOException; import org.jboss.logging.Logger; @Provider public class LoggingFilter implements ContainerRequestFilter, ContainerResponseFilter { private Logger logger = Logger.getLogger(LoggingFilter.class); @Override public void filter(ContainerRequestContext requestContext) throws IOException { logger.info("request logging"); } @Override public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { logger.info("response logging"); } }
インターセプター。
src/main/java/org/littlewings/jaxrs/sse/LoggingInterceptor.java
package org.littlewings.jaxrs.sse; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.ReaderInterceptor; import jakarta.ws.rs.ext.ReaderInterceptorContext; import jakarta.ws.rs.ext.WriterInterceptor; import jakarta.ws.rs.ext.WriterInterceptorContext; import java.io.IOException; import org.jboss.logging.Logger; @Provider public class LoggingInterceptor implements ReaderInterceptor, WriterInterceptor { private Logger logger = Logger.getLogger(LoggingInterceptor.class); @Override public Object aroundReadFrom(ReaderInterceptorContext context) throws IOException, WebApplicationException { logger.info("read intercept logging"); return context.proceed(); } @Override public void aroundWriteTo(WriterInterceptorContext context) throws IOException, WebApplicationException { logger.info("write intercept logging"); context.proceed(); } }
これらを使って動作確認していきます。
動作確認してみる
それでは、動作確認していきます。
Server-Sent Eventsでデフォルトのメディアタイプでイベントを送信する
まずは、デフォルトのメディアタイプでイベントを送信してみます。
$ curl -i localhost:8080/sse/text HTTP/1.1 200 OK Connection: keep-alive Transfer-Encoding: chunked Content-Type: text/event-stream Date: Fri, 07 Mar 2025 14:50:50 GMT event: name-1 id: last-id data: data-1 event: name-2 id: last-id data: data-2 event: name-3 id: last-id data: data-3 event: name-4 id: last-id data: data-4 event: name-5 id: last-id data: data-5 event: name-6 id: last-id data: data-6 event: name-7 id: last-id data: data-7 event: name-8 id: last-id data: data-8 event: name-9 id: last-id data: data-9 event: name-10 id: last-id data: data-10
この時のログ。フィルターのログが表示されます。
23:50:50,552 INFO [org.littlewings.jaxrs.sse.LoggingFilter] (default task-1) request logging 23:50:50,578 INFO [org.littlewings.jaxrs.sse.LoggingFilter] (default task-1) response logging
ログが表示されるのは、最初のレスポンスを返す時だけですね。
JSONを単一のレスポンスで返す
次はJSONを単一のレスポンスで返してみます。
$ curl -i localhost:8080/sse/entity
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: application/json
Content-Length: 71
Date: Fri, 07 Mar 2025 14:52:03 GMT
{"isbn":"978-4621303252","price":4400,"title":"Effective Java 第3版"}
この時のログは、フィルターに加えてインターセプターのものも表示されます。
23:52:03,422 INFO [org.littlewings.jaxrs.sse.LoggingFilter] (default task-1) request logging 23:52:03,425 INFO [org.littlewings.jaxrs.sse.LoggingFilter] (default task-1) response logging 23:52:03,429 INFO [org.littlewings.jaxrs.sse.LoggingInterceptor] (default task-1) write intercept logging
Server-Sent EventsでメディアタイプにJSONを指定してイベントを送信する
イベント送信時にメディアタイプにJSONを指定してみます。
$ curl -i localhost:8080/sse/json
HTTP/1.1 200 OK
Connection: keep-alive
Transfer-Encoding: chunked
Content-Type: text/event-stream
Date: Fri, 07 Mar 2025 14:53:19 GMT
event: name-0
id: last-id
data: {"isbn":"978-4621303252","price":4400,"title":"Effective Java 第3版"}
event: name-1
id: last-id
data: {"isbn":"978-4798180946","price":3278,"title":"独習Java 第6版"}
event: name-2
id: last-id
data: {"isbn":"978-4297144357","price":3520,"title":"Javaエンジニアのための ソフトウェアテスト実践入門 ~自動化と生成AIによるモダンなテスト技法~"}
event: name-3
id: last-id
data: {"isbn":"978-4297146801","price":3740,"title":"改訂3版 パーフェクトJava (Perfect series 02)"}
event: name-4
id: last-id
data: {"isbn":"978-4774189093","price":3278,"title":"Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで"}
データ自体はJSONで返ってきますが、指定したメディアタイプがレスポンスに現れることはありません。
この時のログ。
23:53:19,235 INFO [org.littlewings.jaxrs.sse.LoggingFilter] (default task-1) request logging 23:53:19,238 INFO [org.littlewings.jaxrs.sse.LoggingFilter] (default task-1) response logging
最初のレスポンスを返した時のフィルターのログのみで、ここはメディアタイプを指定しない時と同じですね。
またインターセプターのログもありません。
XMLを単一のレスポンスで返す
XMLを単一のレスポンスで返してみます。
$ curl -i localhost:8080/sse/entity-xml HTTP/1.1 200 OK Connection: keep-alive Content-Type: application/xml;charset=UTF-8 Content-Length: 157 Date: Fri, 07 Mar 2025 14:55:54 GMT <?xml version="1.0" encoding="UTF-8" standalone="yes"?><xmlBook><isbn>978-4621303252</isbn><price>4400</price><title>Effective Java 第3版</title></xmlBook>
ログ。
23:55:54,118 INFO [org.littlewings.jaxrs.sse.LoggingFilter] (default task-1) request logging 23:55:54,121 INFO [org.littlewings.jaxrs.sse.LoggingFilter] (default task-1) response logging 23:55:54,127 INFO [org.littlewings.jaxrs.sse.LoggingInterceptor] (default task-1) write intercept logging
JSONの時と同じですね。
Server-Sent EventsでメディアタイプにXMLを指定してイベントを送信する
最後は、イベントのメディアタイプにXMLを指定します。
$ curl -i localhost:8080/sse/xml HTTP/1.1 200 OK Connection: keep-alive Transfer-Encoding: chunked Content-Type: text/event-stream Date: Fri, 07 Mar 2025 14:56:52 GMT event: name-0 id: last-id data: <?xml version="1.0" encoding="UTF-8" standalone="yes"?><xmlBook><isbn>978-4621303252</isbn><price>4400</price><title>Effective Java 第3版</title></xmlBook> event: name-1 id: last-id data: <?xml version="1.0" encoding="UTF-8" standalone="yes"?><xmlBook><isbn>978-4798180946</isbn><price>3278</price><title>独習Java 第6版</title></xmlBook> event: name-2 id: last-id data: <?xml version="1.0" encoding="UTF-8" standalone="yes"?><xmlBook><isbn>978-4297144357</isbn><price>3520</price><title>Javaエンジニアのための ソフトウェアテスト実践入門 ~自動化と生成AIによるモダンなテスト技法~</title></xmlBook> event: name-3 id: last-id data: <?xml version="1.0" encoding="UTF-8" standalone="yes"?><xmlBook><isbn>978-4297146801</isbn><price>3740</price><title>改訂3版 パーフェクトJava (Perfect series 02)</title></xmlBook> event: name-4 id: last-id data: <?xml version="1.0" encoding="UTF-8" standalone="yes"?><xmlBook><isbn>978-4774189093</isbn><price>3278</price><title>Java本格入門 ~モダンスタイルによる基礎からオブジ ェクト指向・実用ライブラリまで</title></xmlBook>
ログ。こちらもJSONと同じですね。
23:56:52,800 INFO [org.littlewings.jaxrs.sse.LoggingFilter] (default task-1) request logging 23:56:52,802 INFO [org.littlewings.jaxrs.sse.LoggingFilter] (default task-1) response logging
というわけで、Server-Sent Eventsで送信するイベントにメディアタイプを指定した場合は、MessageBodyWriterだけが
動いていそうなことがわかりました。
実装を見てみる
最後に、RESTEasyでの実装を見てみましょう。
SseEventSink#sendの実装箇所はこちらです。
この処理はCompletableFutureを使うので非同期処理なのですが、その時の状態に応じてそのまま書き込みを行うか
1度キューに入れるかに分岐します。
書き込みが行われるのはここですね。
ここでのwriterは、MessageBodyWriterのことです。
writer.writeTo(event, event.getClass(), null, new Annotation[] {}, mediaType, null, bout);
ただ、このMessageBodyWriterはServer-Sent Eventsの最初のレスポンスのメディアタイプを扱うようになっていて、
実際にはこのメソッドの中でイベントのメディアタイプに対応するMessageBodyWriterを取り出して書き込みを行うように
なっています。
ちなみに、メディアタイプが未指定の場合はtext/plainとして扱われます。
というわけで、確かにJAX-RSのパイプラインにあるフィルターやインターセプターは登場せず、ペイロードを
扱う時にMessageBodyWriterが使われる、という感じみたいですね。
ちなみに今回使ったMessageBodyWriterは、JsonBindingProviderとJAXBXmlRootElementProviderの2つです。
おわりに
WildFly 35.0.0.1.Finalに含まれるRESTEasyを使って、Server-Sent Eventsのイベント送信時にメディアタイプを指定すると
どうなるかということを確認してみました。
それから、フィルターやインターセプターはServer-Sent Eventsを使う時にどのような扱いになるかも確認できたと
思います。
あとは実際に使うとなると、Jakarta Concurrentyまわりも押さえておいた方がよさそうですね。