Goalist Developers Blog

VAPIDを用いたデスクトップ通知のAngular6とJavaによる実装

こんにちは。
新卒の増田です。

今回、クライアント(フロントエンド)にAngular6、サーバー(バックエンド)にPlayFramework(Java)を使ってデスクトップ通知を実装しようとした際、あまり参考になる資料がなかったのでここに残しておきます。

VAPIDやPush通知に関する細かい仕組みについては他のサイト(ここなど)を参照していただいて、で、実際に何を書けば良いのかにだけ焦点を当てて紹介していきます。

僕はエンジニア新生児なので、初めから書いていきます。
何もないところからデスクトップ通知ができるように。
もう本当に初めの方から。
ただ、sbtやAngular CLIなどのツールはこっそり入っているという前提です。

使っているもの

  • PlayFramework 2.6
  • Java 1.8
  • Eclipse 4.7
  • Angular6
  • TypeScript
  • WebStorm

ディレクトリとプロジェクトの作成

まず、適当なところにフォルダを作ります。名前も適当に。

$ mkdir notification
$ cd notification

そしてそこにクラアイアント(Angular)とサーバー(PlayFramework)、それぞれのプロジェクトを作成します。コマンドで。
まずはサーバー側。

notification$ sbt new playframework/play-java-seed.g8

name [play-java-seed]: backend
organization [com.example]: 
scala_version [2.12.6]: 
play_version [2.6.16]: 
sbt_version [1.1.6]: 

色々聞かれますが、とりあえず名前だけbackendと付けてあげてあとはEnterキーです。
もちろん名前もなくて良いです。お好みで。

次はクライアント側。

notification$ ng new frontend

こちらも名前はお好みで。
さて、これでとりあえずはディレクトリができました。いえい。

サーバー側の準備

サーバー側から触っていくことにします。
というわけで何も考えず

notification$ cd backend

そして今回はEclipseを使っていくので、project/plugins.sbtに

addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.2.4")

を書き加え、ついでに今回必要なものは全てありがたいライブラリを使わせていただくので、build.sbtにあれこれ追記します。

libraryDependencies ++= Seq(
  filters,
  "org.bouncycastle" % "bcprov-jdk15on" % "1.54",
  "nl.martijndwars" % "web-push" % "3.1.0",
  "commons-io" % "commons-io" % "2.4"
)

はい。
ここで、plugins.sbtやbuild.sbtに追加したものをEclipseに適用するために

backend$ sbt eclipse

をしておきます。
次は、サーバーとクライアントで通信をするのですが、このままだとクライアントからサーバーにリクエストを送ってもCORSで弾かれてしまいます。CORSについては割愛します。僕もよく知りません。

とりあえず、application.confに

play.filters {
  ## CORS filter configuration
  # https://www.playframework.com/documentation/latest/CorsFilter
  # ~~~~~
  # CORS is a protocol that allows web applications to make requests from the browser
  # across different domains.
  # NOTE: You MUST apply the CORS configuration before the CSRF filter, as CSRF has
  # dependencies on CORS settings.
  enabled += "play.filters.cors.CORSFilter"
  cors {
    # Filter paths by a whitelist of path prefixes
    pathPrefixes = ["/"]

    # The allowed origins. If null, all origins are allowed.
    allowedOrigins = null

    # The allowed HTTP methods. If null, all methods are allowed
    allowedHttpMethods = null
    allowedHttpHeaders = null

    #preflightMaxAge = 3 days
  }
}

と書き加えた上で(コメント部分はもちろんなくても)、appの下にFilters.javaというクラスを作成します。Eclipseで普通にクラスを作成するとデフォルトパッケージの下に来ますが気にしません。
で、Filters.javaに

import javax.inject.Inject;

import play.filters.cors.CORSFilter;
import play.http.DefaultHttpFilters;
import play.mvc.EssentialFilter;

public class Filters extends DefaultHttpFilters {

    CORSFilter corsFilter;

    @Inject
    public Filters(CORSFilter corsFilter) {
        super(corsFilter);
        this.corsFilter = corsFilter;
       }

    public EssentialFilter[] filters() {
        return new EssentialFilter[] { corsFilter.asJava() };
    }
}

というのを貼り付けましょう。
PlayFrameworkのHPからサンプルをダウンロードすると初めからあるような気がするので、
そこからコピペしても良いと思います。これでCORSで弾かれなくなりました。幸せ。

ここまででサーバー側の設定はおしまいです。

クライアント側の準備

次はクライアント側の準備に移ります。
とりあえずfrontendディレクトリに移って、まずはこのプロジェクトをPWA化しましょう。
デスクトップ通知にはサービスワーカーが欠かせないらしいので、そのためです。
サービスワーカーが何であるのかについては世に遍在している説明を発見していただければ。

frontend$ ng add @angular/pwa —project frontend
frontend$ npm i @angular/service-worker --save -D

クライアント側の設定はこれでおしまいです。Angular CLIさんが必要な記述も全部してくれていますので。ちなみに ここなどを参考にしています。 参考ページではindex.htmlとmanifest.jsonを修正していますが、必要ありませんでした。

というわけで、これでサービスワーカーを使えるようになりました。

ここまでで必要な準備が終わったのでやっと本題に入れます。
デスクトップ通知の実装へ。

サーバー側でECDSAによる鍵ペアを生成

鍵ペアの生成には初めに準備しておいたライブラリを使わせていただきます。
ECDSAというのがどういうものなのかよく知りません。
知りませんが、それでも使えるというのが大事ということで。

鍵ペアですが、実は毎回作成する必要はなく、アルゴリズムに従って生成された鍵ペアさえあれば良いので、一度作ったものをそのままペタッ。

とりあえず一度生成すべく、Eclipseでapp下に何か適当なメインクラスを作成します。
本当はパッケージなど分けた方が良いのでしょうが、そういう細かいことは必要に迫られたときに。
例えば表示するだけならこのように、VapidKey.javaのようなクラスを作成します。

import java.security.Security;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import nl.martijndwars.webpush.cli.commands.GenerateKeyCommand;
import nl.martijndwars.webpush.cli.handlers.GenerateKeyHandler;

public class VapidKey {

    public static void main(String[] args) {
        try {
            new VapidKey().generateKeys();
        } catch (Throwable th) {
            th.printStackTrace();
        }

    }

    public void generateKeys() throws Throwable {
        Security.addProvider(new BouncyCastleProvider());
        GenerateKeyHandler gh = new GenerateKeyHandler(new GenerateKeyCommand());
        gh.run();
    }
}

全部mainに入れれば良いじゃんと思いつつ。
ただ、何もせずにこれを実行すると、たぶん「メインクラスが見つかりません」などと言われます。
なので、

backend$ sbt compile

をして、プロジェクトをインポートし直し、ここにあるように、ビルドパスを設定します。

  • Eclipse上でプロジェクトを右クリック > Build Path > Configure Build Path...
  • LibraryタブからAdd Class Folderを選択
  • 開いたダイアログでtarget/scala-2.12/classesにチェックを入れてOK

そしたら実行できるようになるはずです。
実行できました。できたらコンソールに

PublicKey:
BP198Ghlpaac41UMKrRvYyNt56tt7JCcgusX1JSh1e3W-kApskF2BLqCNi0c2GBjPx5BflPezwvi0OwVsaGTclI=
PrivateKey:
Kzh4c_SbGLXoybns7pCiRdvhx9b0m_H5AEyD8DD9Bvw=

のように出力されるのではないかと。

繰り返しになりますが、アルゴリズムに従って生成された鍵ペアさえあれば良いので、上記のものをそのまま利用してもらっても大丈夫なはずです(なのでこのクラスを作らずに上の鍵を使うのでも良いような)。セキュリティ的にどうなのかというのはひとまずおいといて。

生成したサーバー鍵をクライアント側に送信

クライアントからリクエストを送って、そのレスポンスでサーバー鍵を渡します。
HomeController.javaに

import play.libs.Json;

public Result vapidKey() {
     // さっきの鍵をそのままペタッ
     String serverKey = "BP198Ghlpaac41UMKrRvYyNt56tt7JCcgusX1JSh1e3W-kApskF2BLqCNi0c2GBjPx5BflPezwvi0OwVsaGTclI=";
     // サーバーキーを返すだけ
     return ok(Json.toJson(serverKey);
}

というように追記した上で、routesに

GET /vapidKey        controllers.HomeController.vapidKey

としておくことで、この/vapidKeyにリクエストが送られたときにサーバーの公開鍵を返すようになります。
普通です。

プッシュサーバーにサーバー鍵を登録

subscriptionからエンドポイントやクライアントの公開鍵等を取得

取得した諸々の情報をサーバー側へ送信

クライアントでPush通知を受け取った際にデスクトップ通知として表示

4つまとめてやります。

app.component.tsを

import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { SwPush } from '@angular/service-worker';

@Componetn(中略)

export class AppComponent implements OnInit {

    constructor(private swPush: SwPush, private http: HttpClient) {

        // Push通知が送られて来たときに受け取るやつ
        this.swPush.messages.subscribe(
            (message) => {

                const options = {
                    body:  message['message'],
                    // trueにするとユーザーから消されるるまで表示し続けられます。
                    requireInteraction: true,
                    // tag名が異なると異なる通知として出され(下にずらっと並ぶ)、
                    // tag名が同じものはそれ以前からある通知に上書きされるようです。
                    tag: 'notification_tag',
                    // これが何かは忘れてしまいました
                    renotify: false,
                };

                // どうしてかよく分かりませんが、Notificationをnewすると表示されるみたいです。
                const n = new Notification(message['title'], options);

            });
    }

    ngOnInit() {
        this.setDesktopNotification();
    }

    setDesktopNotification(): void {
        // サーバー公開鍵を受け取るべくリクエストを送信
        this.http.get('http://localhost:9000/vapidKey')
            .subscribe(
                // レスポンスに鍵が入っているはずなので、それをserverPublicKeyにセット
                (res) => {
                    this.swPush.requestSubscription({
                        serverPublicKey: res.toString()
                    }).then(
                        (pushSubscription: PushSubscription) => {

                            // 必要なものは全てpushSubscriptionに入っているので、アクセスすると取れます。
                            // endpointとp256dhとauthを取得
                            const endpoint = pushSubscription.endpoint;
                            const p256dh: string = btoa(String.fromCharCode.apply(null, new Uint8Array(pushSubscription.getKey('p256dh'))));
                            const auth: string = btoa(String.fromCharCode.apply(null, new Uint8Array(pushSubscription.getKey('auth'))));

                            const body = {
                                       endpoint: endpoint,
                                       p256dh: p256dh,
                                       auth: auth
                            };

                            // 送り先は/webPushという場所にしました。
                            this.http.post('http://localhost:9000/webPush', body).subscribe(
                                (res) => {
                                    // console.log('[App] Add subscriber request answer', res);
                                 },
                                 (err) => {
                                     console.log(err);
                                 });
                        }).catch(
                            err => console.log(err)
                        );
                },
                (err) => {
                    console.log(err);
                });
    }

}

として、
あとComponentでHttpClientを使うために、app.module.tsにちろっと追記しておきます。

import { HttpClientModule } from '@angular/common/http';

@NgModule({
    (中略)
    imports: [
        ...
        HttpClientModule,
        ...
    ],
    ()
})

サーバー側からメッセージをエンドポイントへと送ると、
プッシュサーバーがクライアント側にPush通知を送ってくれます。
あとはそれを受け取ってあれこれするとデスクトップ通知ができるのです。きっと。ここでそのプッシュサーバーからの通知を受け取る役目を負っているのが、constructorの中のthis.swPush.messages.subscribe()です。

ちなみに、Notificationのオプションはここに載っているものが使えるのではないかと勝手に思っています。「Instance properties」のところですね。

一つ引っかかったところがありまして、pushSubscriptionに入っているならそれをそのまま返せば良いじゃないかと初めはなったのですが、そのまま返すとなぜか文字が欠けてしまったりします(この理由がよく分かってません)。
なので、文字列に変換してから必要な分だけを渡しています。

というわけで、送信できるようになりました。

クライアント側から送られてくるリクエストを受け付ける窓口作成

当然のことながら、上記でクライアントから送った情報を受け取る窓口が必要になります。
さっきと同じようにして、HomeController.javaに

import play.mvc.Http.Request;

public Result webPush() {

        Request req = request();

        // ここの受け取り方はクライアントからどう送るかによって色々です。たぶん。
        JsonNode jn = req.body().asJson();
        String endpoint = jn.get("endpoint").asText();
        String p256dh = jn.get("p256dh").asText();
        String auth = jn.get("auth").asText();

        System.out.println("endpoint : " + endpoint);
        System.out.println("p256dh : " + p256dh);
        System.out.println("auth : " + auth);

        return ok(Json.toJson("OK!"));
}

と追記し、そしてroutesに、

POST /webPush        controllers.HomeController.webPush

というような感じで追記しておくと、クライアントから送られて来た情報がコンソールに出力されると思います。

ここまで来たら次はサーバーとクライアントで通信させる必要があるので、サーバーとクライアントをそれぞれbuildします。
まずはサーバー側を

backend$ sbt run

おしまい。

次はクライアント側

frontend$ ng build --prod
frontend$ cd dist/frontend
frontend$ http-server

突然出て来たhttp-serverというのは、ありがたい記事によると

このhttp-serverをインストールしておくと、任意のディレクトリでhttp-serverというコマンドを実行するだけでそのディレクトリをドキュメントルートにしたウェブサーバーが起動します。

というものらしいです。とりあえず

$ npm install -g http-server

をしておけば良いのではないでしょうか。あって困るものではないでしょうから。
そしてあとはlocalhost:8080をブラウザで開くと、通信が行われて、サーバーのコンソールに

endpoint : https://fcm.googleapis.com/fcm/send/eit1VHpri4g:APA91bFWD6MZw-ulAbKRlIfSyqUvRYGqcPfUFmxqkj4bWmUzUaBB-6OZyUmvy9Cob6WYHJH_JpWeU-C_nPsDO7yiMEqPXg6DXD3KXKiS85BgbKgQxcuD15_LK3s09yxeiqKcCrx6IUasFfBq0oQ6nt5b0SxiPXX4ug
p256dh : BOeKRtnhXWm+dSVAHP3ws7EE6cYUi83Lb6ny7+IweHniUSywQAc9BzZmVtVEyY5SKGxR4QGG6PAWNzlr5LhadA8=
auth : 4rIMQM5HTe04kclcRU2Rhw==

というように出力されるのではないかと(上手くいかなかったらリロードなどを)。
ちなみに、サーバーの鍵ペアはここに載せてあるものでも使えますが、エンドポイント等はここにあるものをコピペしてもたぶん送信できませんのでお気をつけて。

大事なことを一つ。あとでPush通知を飛ばしたときに受け取らないといけないので、フロント側は動かしたままに。

エンドポイントへとメッセージを送信

これも先のライブラリを使わせていただくと、 とっても簡単に送信できてしまいます。便利。
適当にapp下にWebPush.java(名前は適当に)をこんな感じで作成し、

import java.security.Security;

import org.apache.http.HttpResponse;
import org.bouncycastle.jce.provider.BouncyCastleProvider;

import com.google.gson.JsonObject;

import nl.martijndwars.webpush.Notification;
import nl.martijndwars.webpush.PushService;
import nl.martijndwars.webpush.Utils;

public class WebPush {

    public static void main(String[] args) {
            try {
                new WebPush().exec();
            } catch (Throwable th) {
                th.printStackTrace();
            }
    }

    public void exec() throws Throwable {

            Security.addProvider(new BouncyCastleProvider());

            // jsonから取得と書いていますが、出力されたものをコピペです
            String endpoint = /* webPushに送られてくるjsonから取得(上記のendpointに相当) */;
            String userPublicKey = /* webPushに送られてくるjsonから取得(上記のp256dhに相当) */;
            String userAuth = /* webPushに送られてくるjsonから取得(上記のauthに相当) */;

            // Base64 string server public/private key
            String vapidPublicKey = /* 初めに作ったサーバーの公開鍵 */;
            String vapidPrivateKey = /* 初めに作ったサーバーの秘密鍵 */;

            // Construct notification
            Notification notification = new Notification(endpoint, userPublicKey, userAuth, getPayload());

            // Construct push service
            PushService pushService = new PushService();
            pushService.setSubject("mailto:admin@martijndwars.nl");
            pushService.setPublicKey(Utils.loadPublicKey(vapidPublicKey));
            pushService.setPrivateKey(Utils.loadPrivateKey(vapidPrivateKey));

            // Send notification!
            HttpResponse httpResponse = pushService.send(notification);

    }

    private byte[] getPayload() {
            JsonObject jsonObject = new JsonObject();
            jsonObject.addProperty("title", "おっはー");
            jsonObject.addProperty("message", "World");

            return jsonObject.toString().getBytes();
    }
}

これを実行すると、クライアントへとPush通知が飛ぶようになります。
別のメインクラスが実行されたりそもそも実行されなかったりするときは(たぶん普通には実行できないように思うのですが)、sbt compileをしてEclipseでインポートし直すなどしてみてください。それで実行できるのではないかと思います。もっと良い方法があるように思いつつ。

ここまでで必要な準備は終わったので、あとはさっき作ったWebPush.javaを実行してメッセージを送ってあげればデスクトップ通知ができるのではないでしょうか。たぶん。
というわけで実行しましょう!

f:id:s-masuda:20180719203906p:plain でけた!!!!

めでたしめでたし。

というわけでデスクトップ通知の大枠はできたのではないかと思いますので、あとは煮るなり焼くなり炙るなりすればきっと素晴らしいデスクトップ通知が。

こんな感じで、ほぼ入門程度の技術研修(それもJavaだけ!!)を終えたばかりの新卒なのに「AngularとPlay使ってデスクトップ通知実装してね!」などと突然言われてしまうような会社ではありますが、僕みたいな初心者でもこうしてちゃんと実装しきるところまで助けてもらえますので、もしよければどうぞ。そしてぜひ大阪に。

fresh-recruiting.goalist.co.jp

お待ちしております。