【Java】今から10分ではじめるサーバープッシュ　～TomcatやJettyでSSE(Server-Sent Events)する方法～

# 概要
- サーバーから情報をブラウザ側にプッシュする方法の紹介です
- プッシュする側のサーバーには Java Servletを使います
- いくつか手段がありますが、今回はおそらく一番手軽なSSE(Server-Sent Events)という方法を使います
 
# やりかた

## 依存関係設定

以下ライブラリ(※)を追加する。

**Maven**

```xml:POM.xmlへの依存関係
<dependency>
	<groupId>org.riversun</groupId>
	<artifactId>jetty-sse-helper</artifactId>
	<version>1.0.0</version>
</dependency>
```

<small>※ [jeasse](https://github.com/mariomac/jeasse)のミニマル版。[SSEのプロトコル](https://developer.mozilla.org/ja/docs/Server-sent_events/Using_server-sent_events)を実装。ソースは→https://github.com/riversun/jetty-sse-helper</small>

## コード

### PUSHを送信するサーブレットのソースコード

```java:SSEServlet.java
@SuppressWarnings("serial")
public class SSEServlet extends HttpServlet {

    private final SSEHelper mPushHelper = new SSEHelper();

    /**
     * (JSから)GETリクエストがあったら、リクエスト元をターゲットに追加する
     */
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        System.out.println("Added " + req + " to target to broadcast");

        final EventTarget tgt = new EventTarget(req);
        mPushHelper.addTarget(tgt);
    }

    /**
     * POSTリクエストがあったら、登録されたターゲットにメッセージをブロードキャストする
     */
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String msgToSend = req.getParameter("message");
        if (msgToSend == null) {
            msgToSend = "";
        }
        mPushHelper.broadcast("message", msgToSend);

        resp.setStatus(HttpServletResponse.SC_OK);
        final PrintWriter out = resp.getWriter();
        out.close();
    }

}
```

- doGet側では、PUSHを受け取りたいクライアント（ブラウザ）の登録をしている。ブラウザ側のコードは下部に掲載。
- doPost側では、POSTを受け取ったら、登録されたすべてのクライアントにメッセージを送信している。
- もしすべてのクライアントに送りたくない場合は、`EventTarget#send` を呼び出せば指定されたターゲットのみにメッセージを送信できる

### メインクラス＝サーブレットコンテナを起動するコード

ここではJettyを使用。コードだけでサーブレットコンテナ、Webサーバーを起動することができる。

```java:App.java
public class App {

    public static void main(String[] args) {
        new App().startWebServer();
    }

    public void startWebServer() {

        final int port = 8080;

        // サーブレットホスト用ハンドラ
        ServletContextHandler sch = new ServletContextHandler(ServletContextHandler.SESSIONS);
        ServletHolder sh = sch.addServlet(SSEServlet.class, "/sse");
        sh.setAsyncSupported(true);

        // 静的ページのホスト用ハンドラ
        final ResourceHandler rh = new ResourceHandler();
        rh.setResourceBase(System.getProperty("user.dir") + "/htdocs");
        rh.setWelcomeFiles(new String[] { "index.html" });
        rh.setCacheControl("no-store,no-cache,must-revalidate");// キャッシュオフ

        final HandlerList hnList = new HandlerList();
        hnList.addHandler(rh);
        hnList.addHandler(sch);

        final Server server = new Server(port);
        server.setHandler(hnList);

        try {
            server.start();
            System.out.println("Server started on port:" + port);
            server.join();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}
```

Jettyを使うので、依存関係に以下も追加する。

```xml:POM.xmlに追加（Jetty用）
<dependency>
	<groupId>org.eclipse.jetty</groupId>
	<artifactId>jetty-webapp</artifactId>
	<version>9.4.18.v20190429</version>
</dependency>
```

### サーバーPUSHを受け取るJavaScript

SSEのサーバーPUSHを受け取るには **new EventSource(url)**でEventSourceオブジェクトを作ってそこに、コールバックを設定すれば良い。コードは以下のようになる。

```js
    const eventSource = new EventSource('sse');//http://localhost:8080/sse
    eventSource.addEventListener('message', (event) => {
        console.info('SSE: ' + event.data);
    });
```

これをHTMLに内包した全ソースは以下

```html:index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>SSEのサンプル</title>
</head>
<body>

<h1>Server Sent Event(SSE) Example</h1>
・PUSH通知をうけとってメッセージを表示します<br>
・PUSH送信は↓のフォームから実行できます<br>
・別のブラウザでこのページを開いても、BroadcastするのにPUSH通知を受け取れます
<hr>
<h3>PUSH送信するメッセージ</h3>
<input id="text_message" value="テストメッセージ">
<button id="send_message">PUSH送信</button>
（エンターでもOK)
<br>
<br>
<small>（他のブラウザを開いても↓のメッセージが受信される）</small>
<hr>
<h3>Server PUSHを受信したメッセージ</h3>
<div id="messages"></div>
<script>
    const eventSource = new EventSource('sse');//http://localhost:8080/sse
    const msgContent = document.querySelector('#messages');
    eventSource.addEventListener('message', (event) => {
        console.info('SSE: ' + event.data);
        msgContent.innerHTML += event.data + '<br>';
    });
    const msgText = document.querySelector('#text_message');
    const msgPostBtn = document.querySelector('#send_message');

    const funcSendData = (event) => {
        sendData('sse', {message: msgText.value});
        msgText.value = '';
    };
    msgText.addEventListener('keypress', function (event) {

        if (event.key === 'Enter') {
            event.preventDefault();
            funcSendData(event);
        }
    });

    msgPostBtn.addEventListener('click', funcSendData);

    //https://developer.mozilla.org/ja/docs/Learn/HTML/Forms/Sending_forms_through_JavaScript
    function sendData(url, data) {
        const XHR = new XMLHttpRequest();
        let urlEncodedData = '';
        const urlEncodedDataPairs = [];
        let name;
        for (name in data) {
            urlEncodedDataPairs.push(encodeURIComponent(name) + '=' + encodeURIComponent(data[name]));
        }
        urlEncodedData = urlEncodedDataPairs.join('&').replace(/%20/g, '+');
        XHR.addEventListener('load', function (event) {
        });
        XHR.addEventListener('error', function (event) {
        });
        XHR.open('POST', url);
        XHR.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
        XHR.send(urlEncodedData);
    }
</script>
</body>
</html>
```

## サーバーPUSHを実行する

- App.java を起動すると、Webサーバーとサーブレットコンテナが立ち上がる
- http://localhost:8080 をブラウザで開く
- ブラウザを複数枚開いておくと良い  
(１つのブラウザで入力した文字が他のブラウザにもサーバーＰＵＳＨによって即座に反映されるので）
- テキストボックスに文字列を入力してエンターすると、その情報が他のブラウザにも即座に反映されるのがわかる

## デモ、2枚のブラウザを開いた例。右側ブラウザで入力している。
![push.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/170905/907c5565-ba84-1fbb-f38c-7b2c57296a5c.gif)

# 補足：SSEの注意点
- SSEは片方向(半二重：HALF DUPLEX)のメッセージングとなる。
    - サーバーからクライアントへの通信しかケアされていないので、クライアントからサーバーへの通信を含む双方向（全二重：FULL DUPLEX）を実現するにはWebSocketのほうが適している場合がある。
    - 既存のGET、POSTとセッションなどをつかって頑張る方法ももちろんある。

- SSEでクライアントとサーバーのコネクションを張り続けた場合、クライアント＝ブラウザの同時接続数制限にひっかかる。
    - Chromeの同時接続数は6コネクションまで。（同一のサーバーへの接続数）

- MSのブラウザであるIEやEdgeはSSEにデフォルト対応していないが、[Polyfill](https://github.com/remy/polyfills/blob/master/EventSource.js)が出ているので、そちらを活用すれば動作可能（Polyfill対応したサンプルコードは[こちら](https://github.com/riversun/jetty-sse-example/tree/master/htdocs))

# まとめ

- Java Servletでサーバープッシュを行う方法を紹介しました
- サーバーで発生したイベントの通知や、サーバーからのストリーミングなどの用途ならWebSocketより手軽に実現できます
- 紹介した全ソースコードは https://github.com/riversun/jetty-sse-example にあります

**サンプルコードの実行方法**
mavenがあれば、すぐに実行して試せます

```shell:コマンドラインでSSEのサンプルコード実行
$ git clone https://github.com/riversun/jetty-sse-example.git
$ jetty-sse-example

$ mvn exec:java
または
$ mvn clean install exec:java

Server started on port:8080
```

サーバーが起動したら、http://localhost:8080 を開く