Servlet標準の非同期処理に触れてみる

  • 43
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

今回は、Servlet 3.0でサポートされた非同期処理について説明します。これは、後日投稿予定の「Spring MVC(+Spring Boot)上でのServlet標準の非同期処理を理解する」の前提となる基礎知識になります。私自身Servlet標準の非同期処理は使ったことはほぼないので、間違っている箇所があるかもしれません+細かい動作仕様の説明は本投稿では扱いません・・・ (あしからず :sweat_smile:

動作確認環境

  • Java SE 8
  • Tomcat 8.0 (Servlet 3.1)

まずは普通のServletを作ってみる

非同期うんぬんの前に、まず普通のServletを作ってみましょう。
waitSecで指定した秒数だけ待ってから "/complete.jsp"に遷移するというシンプルなサーブレットです。

src/main/java/com/example/component/StandardServlet.java
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などのロガーを入れてもいいけど、なんとなく標準出力にログを出力する簡易クラスを作ることにしてみた。(ロガー使いたい人はロガーにしてください!!)

src/main/java/com/example/component/Console.java
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はこんな感じ。

src/main/webapp/complete.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レスポンス
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を作成します。

src/main/java/com/example/component/AsyncServlet.java
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 だと・・・

src/main/webapp/WEB-INF/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レスポンス
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つのスレッドにわかれて処理が行われていることがわかります。たぶんテキストだけだと伝わりずらいと思うので、動きを絵であわらしてみましょう。

servlet-async-example.png

だいだいこんな感じで動いています。
へ〜という感じではありますが、これだと非同期にする意味あるの?同期型と何が違うの?単に複雑になるだけじゃん・・・と思っちゃいますよね!?
そうなんです・・・非同期処理(主に重たい処理、長い間クライアントとの接続を維持したい処理など)を行うスレッド(上の絵だと、「http-nio-8080-exec-9」のスレッド)をアプリケーションサーバーがリクエスト処理を行うために用意しているスレッドを使ってしまうと、非同期にする意味がないのです・・・。
私の足りていないかもしれない認識では・・・
非同期処理は「アプリケーションサーバーがリクエスト処理を行うために用意しているスレッド」ではなく「アプリケーションが用意するスレッド(スレッドプールから取得したスレッド)」を使うことで、非同期処理を実行している最中でもアプリケーションサーバーのリクエスト受付をブロックさせないための機能だと思っています。(ここは私の認識不足なだけの可能性あり :sweat_smile:

私の認識を絵に反映すると、以下のようになります。アプリケーションサーバー管理のスレッドは、リクエストの受付とレスポンスデータの生成という比較的軽い処理に専念することができます。

servlet-async-example-using-app-managed-thread.png

アプリ管理のスレッドを使用した非同期実装にしてみる

非同期処理の実行をExecutorService経由で実行するように変更しましょう。
まず、initdestoryメソッドをオーバーライドし、指定した数のスレッドプールをもつExecutorServiceの生成・破棄を行います。

src/main/java/com/example/component/AsyncServlet.java
@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レスポンス
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: タイムアウト後の非同期処理内でのエラーの回避方法について
いまいち何がベストプラクティスなのかわかっていません・・・ :sweat_smile: IBMのページには、AtomicBooleanを使って回避する方法が紹介されていましたが、これで本当に解決されるのか疑問・・・。誰かヘルプミー〜。Springがこのあたりをどう扱っているのか調べてみます。

【2016/5/22】
Spring MVCでも、リクエストスコープでAtomicBooleanな変数を共有して多重dispatchを防いでいました。なお、Spring MVCを使用した非同期処理については、「Spring MVC(+Spring Boot)上での非同期リクエストを理解する -前編-」をご覧ください。

タイムアウトエラー画面へ遷移させる

タイムアウトエラーが発生した場合は、500(Internal Server Error)ではなく専用のエラー画面などに遷移させたいのではないでしょうか。ここでは、タイムアウトエラー用のJSPにディスパッチしてみます。

まず、タイムアウトエラー画面用のJSPを作ります。

src/main/webapp/timeout.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レスポンス
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を実現する際にも使用できます。

分割レスポンス方式の利用イメージは以下の通りです。

servlet-async-chunked.png

非同期リクエスト用のイベントリスナー

タイムアウトのハンドリングのところで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 だと・・・

src/main/webapp/WEB-INF/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 だと・・・

src/main/webapp/WEB-INF/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標準の非同期処理を理解する」の前提となる知識を事前に紹介しておきました。私もほぼ初めて使うので、間違ったことを書いているかもしれません。(有識者の方は是非コメントを :wink:

参考サイト