今回は、Servlet 3.0でサポートされた非同期処理について説明します。これは、後日投稿予定の「Spring MVC(+Spring Boot)上でのServlet標準の非同期処理を理解する」の前提となる基礎知識になります。私自身Servlet標準の非同期処理は使ったことはほぼないので、間違っている箇所があるかもしれません+細かい動作仕様の説明は本投稿では扱いません・・・ (あしからず )
動作確認環境
- Java SE 8
- Tomcat 8.0 (Servlet 3.1)
まずは普通のServletを作ってみる
非同期うんぬんの前に、まず普通のServletを作ってみましょう。
waitSec
で指定した秒数だけ待ってから "/complete.jsp"
に遷移するというシンプルなサーブレットです。
package com.example.component;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@WebServlet(urlPatterns = "/standard")
public class StandardServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
Console.println("Start doGet.");
long waitSec = Optional.ofNullable(request.getParameter("waitSec"))
.map(Long::valueOf).orElse(0L);
request.setAttribute("acceptedTime", LocalDateTime.now());
String dispatchPath;
try {
heavyProcessing(waitSec, request);
dispatchPath = "/complete.jsp";
} catch (Exception e) {
dispatchPath = "/error.jsp";
}
request.getRequestDispatcher(dispatchPath).forward(request, response);
Console.println("End doGet.");
}
private void heavyProcessing(long waitSec, HttpServletRequest request) {
if (waitSec == 999) {
throw new IllegalStateException("Special parameter for confirm error.");
}
try {
TimeUnit.SECONDS.sleep(waitSec);
} catch (InterruptedException e) {
Thread.interrupted();
}
request.setAttribute("completedTime", LocalDateTime.now());
}
}
Logbackなどのロガーを入れてもいいけど、なんとなく標準出力にログを出力する簡易クラスを作ることにしてみた。(ロガー使いたい人はロガーにしてください!!)
package com.example.component;
import java.time.LocalDateTime;
public class Console {
public static void println(Object target) {
System.out.println(LocalDateTime.now() + " " + Thread.currentThread() + ": " + target);
}
}
JSPはこんな感じ。
<%@ page import="com.example.component.Console" %>
<% Console.println("Called complete.jsp"); %>
<% Console.println(request.getDispatcherType()); %>
<html>
<body>
<h2>Processing is complete !</h2>
<p>Accept timestamp is ${acceptedTime}</p>
<p>Complete timestamp is ${completedTime}</p>
<p><a href="${pageContext.request.contextPath}/">Go to Top</a></p>
</body>
</html>
cURLやブラウザを使ってアクセスすると、complete.jsp
で生成されたHTMLが返却されます。
$ curl -D - http://localhost:8080/standard?waitSec=1
HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=B19F3B445442E16E9CF1EA4E855BFE24; Path=/; HttpOnly
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 201
Date: Sun, 15 May 2016 12:40:55 GMT
<html>
<body>
<h2>Processing is complete !</h2>
<p>Accept timestamp is 2016-05-15T21:40:54.902</p>
<p>Complete timestamp is 2016-05-15T21:40:55.903</p>
<p><a href="/">Go to Top</a></p>
</body>
</html>
アプリケーションサーバー上のコンソールには以下のようなログが出力されます。
2016-05-15T21:40:54.902 Thread[http-nio-8080-exec-6,5,main]: Start doGet.
2016-05-15T21:40:55.904 Thread[http-nio-8080-exec-6,5,main]: Called complete.jsp
2016-05-15T21:40:55.904 Thread[http-nio-8080-exec-6,5,main]: FORWARD
2016-05-15T21:40:55.905 Thread[http-nio-8080-exec-6,5,main]: End doGet.
あたり前ですが、全て同じスレッドで実行されていることがわかります。
Servlet標準の仕組みを利用して非同期実装にしてみる
StandardServlet
の実装を、Servlet標準の仕組みを使って非同期実装にしてみます。ここでは、非同期用に別のServletを作成します。
package com.example.component;
import javax.servlet.AsyncContext;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@WebServlet(urlPatterns = "/async", asyncSupported = true) // asyncSupported属性をtrueにし、非同期処理を扱えるようにする
public class AsyncServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
Console.println("Start doGet.");
long wait = Optional.ofNullable(request.getParameter("waitSec"))
.map(Long::valueOf).orElse(0L);
request.setAttribute("acceptedTime", LocalDateTime.now());
// 非同期リクエストを開始し、コンテキストオブジェクトを取得する。
AsyncContext asyncContext = request.startAsync(request, response);
// 非同期で実行したい処理をリクエストを受け付けたスレッドとは別のスレッドで開始する。
// AsyncContext#startメソッドを使用すると、アプリケーションサーバー(Tomcat)が管理しているスレッドが使われる。
asyncContext.start(() -> {
Console.println("Start Async processing.");
String dispatchPath;
try {
heavyProcessing(wait, (HttpServletRequest) asyncContext.getRequest());
dispatchPath = "/complete.jsp";
} catch (Exception e) {
dispatchPath = "/error.jsp";
}
// 指定したパス(complete.jsp)へのディスパッチを行う
// 指定したパスに対する処理は、アプリケーションサーバー(Tomcat)が管理しているスレッドプールのスレッドが使われる。
asyncContext.dispatch(dispatchPath);
Console.println("End Async processing.");
});
// 非同期処理を起動した後に、リクエストを受け取ったスレッドの処理を終了し、スレッドプールに戻す。
// 同期リクエストだとHTTPレスポンスが完了してしまうが、非同期リクエストだとメソッドを抜けてもHTTPレスポンスは行われない(正確には完了しない)。
Console.println("End doGet.");
}
private void heavyProcessing(long waitSec, HttpServletRequest request) {
if (waitSec == 999) {
throw new IllegalStateException("Special parameter for confirm error.");
}
try {
TimeUnit.SECONDS.sleep(waitSec);
} catch (InterruptedException e) {
Thread.interrupted();
}
request.setAttribute("completedTime", LocalDateTime.now());
}
}
web.xml
だと・・・
<servlet>
<servlet-name>AsyncServlet</servlet-name>
<servlet-class>com.example.component.AsyncServlet</servlet-class>
<async-supported>true</async-supported> <!-- trueを指定して非同期をサポートする -->
</servlet>
<!-- ... -->
cURLやブラウザを使ってアクセスしてみましょう。
$ curl -D - http://localhost:8080/async?waitSec=1
HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=62614119E151341D5A7179699731E8DF; Path=/; HttpOnly
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 201
Date: Sun, 15 May 2016 13:26:54 GMT
<html>
<body>
<h2>Processing is complete !</h2>
<p>Accept timestamp is 2016-05-15T22:26:53.205</p>
<p>Complete timestamp is 2016-05-15T22:26:54.208</p>
<p><a href="/">Go to Top</a></p>
</body>
</html>
あれあれ・・・HTTPレスポンスを見るだけだと、同期型と一緒ですね・・・・。サーバー側のログも見てみましょう!!
2016-05-15T22:26:53.204 Thread[http-nio-8080-exec-8,5,main]: Start doGet.
2016-05-15T22:26:53.205 Thread[http-nio-8080-exec-8,5,main]: End doGet.
2016-05-15T22:26:53.205 Thread[http-nio-8080-exec-9,5,main]: Start Async processing.
2016-05-15T22:26:54.209 Thread[http-nio-8080-exec-9,5,main]: End Async processing.
2016-05-15T22:26:54.211 Thread[http-nio-8080-exec-10,5,main]: Called complete.jsp
2016-05-15T22:26:54.211 Thread[http-nio-8080-exec-10,5,main]: ASYNC
HTTPレスポンスの結果は一緒ですが、サーバー側の処理は3つのスレッドにわかれて処理が行われていることがわかります。たぶんテキストだけだと伝わりずらいと思うので、動きを絵であわらしてみましょう。
だいだいこんな感じで動いています。
へ〜という感じではありますが、これだと非同期にする意味あるの?同期型と何が違うの?単に複雑になるだけじゃん・・・と思っちゃいますよね!?
そうなんです・・・非同期処理(主に重たい処理、長い間クライアントとの接続を維持したい処理など)を行うスレッド(上の絵だと、「http-nio-8080-exec-9」のスレッド)をアプリケーションサーバーがリクエスト処理を行うために用意しているスレッドを使ってしまうと、非同期にする意味がないのです・・・。
私の足りていないかもしれない認識では・・・
非同期処理は「アプリケーションサーバーがリクエスト処理を行うために用意しているスレッド」ではなく「アプリケーションが用意するスレッド(スレッドプールから取得したスレッド)」を使うことで、非同期処理を実行している最中でもアプリケーションサーバーのリクエスト受付をブロックさせないための機能だと思っています。(ここは私の認識不足なだけの可能性あり )
私の認識を絵に反映すると、以下のようになります。アプリケーションサーバー管理のスレッドは、リクエストの受付とレスポンスデータの生成という比較的軽い処理に専念することができます。
アプリ管理のスレッドを使用した非同期実装にしてみる
非同期処理の実行をExecutorService
経由で実行するように変更しましょう。
まず、init
とdestory
メソッドをオーバーライドし、指定した数のスレッドプールをもつExecutorService
の生成・破棄を行います。
@WebServlet(urlPatterns = "/async", asyncSupported = true)
public class AsyncServlet extends HttpServlet {
private ExecutorService executorService;
// ExecutorServiceを生成する
@Override
public void init() throws ServletException {
this.executorService = Executors.newFixedThreadPool(10);
}
// ExecutorServiceを停止する
@Override
public void destroy() {
try {
executorService.shutdown();
executorService.awaitTermination(60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.interrupted();
}
}
...
}
次に、AsyncContext#start()
としていた部分をExecutorService
のメソッドを使用するようにします。
executorService.execute(() -> { // ExecutorServiceのメソッドを使って別スレッドで処理を行う
// ...
});
サーブレットにアクセスすると、ログが以下のようになり、非同期処理がアプリ管理のスレッド上で動いているのがわかります。
2016-05-16T00:07:47.021 Thread[http-nio-8080-exec-5,5,main]: End doGet.
2016-05-16T00:07:47.021 Thread[pool-1-thread-1,5,main]: Start Async processing.
2016-05-16T00:07:48.026 Thread[pool-1-thread-1,5,main]: End Async processing.
2016-05-16T00:07:48.079 Thread[http-nio-8080-exec-6,5,main]: Called complete.jsp
2016-05-16T00:07:48.079 Thread[http-nio-8080-exec-6,5,main]: ASYNC
タイムアウトの指定
非同期処理にはタイムアウトを設けることができます。デフォルトはアプリケーションサーバーの設定に依存しますが、アプリケーション側で明示的にしているすることもできます。
...
AsyncContext asyncContext = request.startAsync(request, response);
asyncContext.setTimeout(3000); // タイムアウト時間(ミリ秒単位)を指定する。0を指定すると無制限。
...
タイムアウトのハンドリング
タイムアウト時間を超えても非同期処理が終わらない場合は、デフォルトだと500(Internal Server Error)になります。
$ curl -D - http://localhost:8080/async?waitSec=4
HTTP/1.1 500 Internal Server Error
Server: Apache-Coyote/1.1
Content-Type: text/html;charset=utf-8
Content-Language: ja
Content-Length: 1065
Date: Sun, 15 May 2016 15:44:23 GMT
Connection: close
...
また、サーバー側のコンソールログは以下のようになります。
2016-05-16T00:54:26.771 Thread[http-nio-8080-exec-8,5,main]: Start doGet.
2016-05-16T00:54:26.771 Thread[http-nio-8080-exec-8,5,main]: End doGet.
2016-05-16T00:54:26.771 Thread[pool-1-thread-3,5,main]: Start Async processing.
Exception in thread "pool-1-thread-3" java.lang.IllegalStateException: The request associated with the AsyncContext has already completed processing.
at org.apache.catalina.core.AsyncContextImpl.check(AsyncContextImpl.java:536)
at org.apache.catalina.core.AsyncContextImpl.dispatch(AsyncContextImpl.java:186)
at com.example.component.AsyncServlet.lambda$doGet$0(AsyncServlet.java:64)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:745)
ここで意識してほしいのは、タイムアウト後も非同期処理自体は実行され続けるという点です。今回のケースだと、タイムアウト後にAsyncContext#dispatch
メソッドが呼び出され、結果としてエラーになっています。
Note: タイムアウト後の非同期処理内でのエラーの回避方法について
いまいち何がベストプラクティスなのかわかっていません・・・ IBMのページには、AtomicBoolean
を使って回避する方法が紹介されていましたが、これで本当に解決されるのか疑問・・・。誰かヘルプミー〜。Springがこのあたりをどう扱っているのか調べてみます。【2016/5/22】
Spring MVCでも、リクエストスコープでAtomicBoolean
な変数を共有して多重dispatchを防いでいました。なお、Spring MVCを使用した非同期処理については、「Spring MVC(+Spring Boot)上での非同期リクエストを理解する -前編-」をご覧ください。
タイムアウトエラー画面へ遷移させる
タイムアウトエラーが発生した場合は、500(Internal Server Error)ではなく専用のエラー画面などに遷移させたいのではないでしょうか。ここでは、タイムアウトエラー用のJSPにディスパッチしてみます。
まず、タイムアウトエラー画面用のJSPを作ります。
<%@ page import="com.example.component.Console" trimDirectiveWhitespaces="true" %>
<% Console.println("Called timeout.jsp"); %>
<% Console.println(request.getDispatcherType()); %>
<html>
<body>
<h2>Timeout!</h2>
<p><a href="${pageContext.request.contextPath}/">Go to Top</a></p>
</body>
</html>
次に、javax.servlet.AsyncListener
インターフェースを実装し、onTimeout
メソッドにエラー処理(timeout.jsp
へのディスパッチ処理)を実装します。
@WebServlet(urlPatterns = "/async", asyncSupported = true)
public class AsyncServlet extends HttpServlet implements AsyncListener {
// ...
@Override
public void onComplete(AsyncEvent event) throws IOException {
Console.println("onComplete:" + event);
}
@Override
public void onTimeout(AsyncEvent event) throws IOException {
Console.println("onTimeout:" + event);
event.getAsyncContext().dispatch("/timeout.jsp"); // timeout.jspへディスパッチする
}
@Override
public void onError(AsyncEvent event) throws IOException {
Console.println("onError:" + event);
}
@Override
public void onStartAsync(AsyncEvent event) throws IOException {
Console.println("onStartAsync:" + event);
}
}
$ curl -D - http://localhost:8080/async?waitSec=4
HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=6696B7A54D07D3300658115D8ED093D8; Path=/; HttpOnly
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 81
Date: Sun, 15 May 2016 16:22:47 GMT
<html>
<body>
<h2>Timeout!</h2>
<p><a href="/">Go to Top</a></p>
</body>
</html>
サーバー側のコンソールログは以下のようになります。
2016-05-16T01:23:23.488 Thread[http-nio-8080-exec-2,5,main]: Start doGet.
2016-05-16T01:23:23.488 Thread[http-nio-8080-exec-2,5,main]: End doGet.
2016-05-16T01:23:23.488 Thread[pool-1-thread-5,5,main]: Start Async processing.
2016-05-16T01:23:26.496 Thread[http-nio-8080-exec-3,5,main]: onTimeout:javax.servlet.AsyncEvent@339e5a11
2016-05-16T01:23:26.498 Thread[http-nio-8080-exec-3,5,main]: Called timeout.jsp
2016-05-16T01:23:26.498 Thread[http-nio-8080-exec-3,5,main]: ASYNC
2016-05-16T01:23:26.498 Thread[http-nio-8080-exec-3,5,main]: onComplete:javax.servlet.AsyncEvent@339e5a11
...
非同期処理終了後の振る舞い
本投稿で扱ったサンプルでは、非同期処理が終了した後に指定したパスにディスパッチする方式になっていますが、ディスパッチせずにすぐに完了させることもできます。
メソッド | 説明 |
---|---|
dispatch |
このメソッドを呼び出すと、サーブレットコンテナは指定されたパスへディスパッチします。(サンプルではこちらの方法を使っています) |
complete |
このメソッドを呼び出すと、サーブレットコンテナはHTTPレスポンスを完了します。このメソッドは、この後紹介する「分割レスポンス」時と一緒に使うことが多いと思われます。 |
レスポンス方法
本投稿で扱ったサンプルでは、ディスパッチ後にまとめてレスポンスを返却していますが、非同期処理中に分割してレスポンスすることもできます。
方法 | 説明 |
---|---|
一括 | ディスパッチ後の処理で一括でレスポンスします。本投稿のサンプル実装は、この方式での実装になっています。 |
分割 | 非同期処理を開始する前にレスポンスをいったんフラッシュすることで、分割レスポンス(Transfer-Encoding: chunked)となることをクライアントへ通知し、以降は任意のタイミングでデータをクライアントへ送信(Push)します。 この方法は、HTTP Streamingを実現する際にも使用できます。 |
分割レスポンス方式の利用イメージは以下の通りです。
非同期リクエスト用のイベントリスナー
タイムアウトのハンドリングのところでonTimeout
メソッドを実装しましたが、onTimeout
メソッド以外にもいくつかのメソッドが提供されています。(説明は間違っているかもしれません・・・)
メソッド | 説明 |
---|---|
onComplete |
非同期リクエスト(dispatch後の処理)が完了した時にコールバックされる。 |
onTimeout |
サーブレットコンテナが非同期処理のタイムアウトを検知した時にコールバックされる。 |
onError |
非同期リクエスト(dispatch後の処理)でサーブレットコンテナに通知されるエラーが発生した時にコールバックされる。(たぶん・・・) |
onStartAsync |
非同期リクエスト(dispatch後の処理)中に、新たに別の非同期リクエストが開始された時にコールバックされる。(たぶん・・・) |
サーブレットフィルターの振る舞い
Servlet標準の非同期処理を利用する場合は、Servletに加えてFilterも非同期処理にサポートさせる必要があります。ひとつでも非同期に対応していないFilterがあるとエラーになってしまいます。
@WebFilter(urlPatterns = "/*", asyncSupported = true) // asyncSupported属性をtrueにし、非同期処理を扱えるようにする
public class CustomFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
chain.doFilter(request, response);
}
@Override
public void destroy() {
}
}
web.xml
だと・・・
<filter>
<filter-name>CustomFilter</filter-name>
<filter-class>com.example.component.CustomFilter</filter-class>
<async-supported>true</async-supported> <!-- trueを指定して非同期をサポートする -->
</filter>
<!-- ... -->
また、非同期処理終了後のディスパッチにサーブレットフィルターを適用したい場合は、DispatcherType
としてASYNC
を指定してください。
@WebFilter(urlPatterns = "/*", asyncSupported = true, dispatcherTypes = DispatcherType.ASYNC) // dispatcherTypes属性にASYNCを指定し、非同期処理を適用対象にする
public class CustomFilter implements Filter {
// ...
}
web.xml
だと・・・
<!-- ... -->
<filter-mapping>
<filter-name>CustomFilter</filter-name>
<url-pattern>/*</url-pattern>
<dispatcher>ASYNC</dispatcher> <!-- dispatcherタグにASYNCを指定 -->
</filter-mapping>
まとめ
今回は、後日投稿予定の「Spring MVC(+Spring Boot)上でのServlet標準の非同期処理を理解する」の前提となる知識を事前に紹介しておきました。私もほぼ初めて使うので、間違ったことを書いているかもしれません。(有識者の方は是非コメントを )