読者です 読者をやめる 読者になる 読者になる

FCMでWeb Push。Firebase Javascript SDKを使ったプッシュ通知とトピック送信を試した。

FirebaseのFirebase Cloud Messaging(FCM)を試している。今回のエントリではFCMのJavaScriptライブラリを使ってブラウザにプッシュ通知やトピックにメッセージを送信する方法をまとめていく。

FCMではトピック端末グループへのメッセージングなどの機能が利用できる。これらの機能をPush APIをサポートしているブラウザにも同様に利用することができる。

developers-jp.googleblog.com

ここからはFCMのJavaScriptライブラリの使い方とサーバからメッセージを送信する方法などクライアントとサーバに分けてまとめていく。

クライアント

クライアントではJasvaScript SDKを通してトークンを取得する。そのトークンをサーバに送りストレージに保持させる。
サーバではトークンを用いてFCMと連携を行い通知リクエストを送信することになる。

必要なSDK

必要なJavaScript SDKは次の2つである。

<script src="https://www.gstatic.com/firebasejs/4.0.0/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/4.0.0/firebase-messaging.js"></script>

またfirebaseアプリを定義するために次のコードで初期設定を完了させる。

var config = {
    messagingSenderId: fcmSenderId // Firebaseのコンソールで確認できる送信者IDを設定する
};
firebase.initializeApp(config);

manifest.json

manifest.jsongcm_sender_idを定義する。このIDは固定で103953800507とする。

{
  "gcm_sender_id": "103953800507"
}

serviceworker.js

ブラウザに通知を表示させるためにserviceworker.jsで表示処理を行うがSDKを読み込んだ場合はserviceworker.jsのファイル名が固定となる。
ファイル名はfirebase-messaging-sw.jsとする。firebase-messaging-sw.jsに通知表示処理のコードを記述する。

トークンの取得

SDKを使いプッシュ通知に必要なトークンを取得する。
取得したトークンを用いて特定の端末へのメッセージ送信やトピック送信を行う。

let messaging = firebase.messaging();

messaging.getToken()
    .then(function(currentToken) {
        // 取得したトークンをサーバへ送る。サーバ側でユーザIDとトークンを連携させDBなどのストレージに保持する。
    })
    .catch(function(err) {
        console.log('An error occurred while retrieving token. ', err);
    });

トークンの取得の前にブラウザで通知を購読させたい場合は次のように処理をする。

messaging.requestPermission()
    .then(function() {
        // 通知購読が成功した場合、`messaging.getToken()` でトークンを取得しサーバAPIと連携させる。
    })
    .catch(function(err) {
        console.log('Unable to get permission to notify.', err);
    });

サーバ

サーバではクライアントから送信されたトークンをストレージに保持したりFCMと連携して通知リクエストを送信する。

特定の端末へのメッセージ送信

fun send() {
    val to = "token"
    val payload = Payload(title, body, tag, icon, clickaction)
    val content = objectMapper().writeValueAsString(FcmRequest(to, payload, CustomPayload("custom")))
    val body = okhttp3.RequestBody.create(MediaType.parse("application/json"), content)

    val request = okhttp3.Request.Builder()
            .url("https://fcm.googleapis.com/fcm/send")
            .header("Authorization", "key=%s".format(appProperties.serverKey))
            .header("Content-Type", "application/json")
            .post(body)
            .build()
    val client = OkHttpClient.Builder().build()
    val response = client.newCall(request).execute()

    response.body().use { body ->
        val responseBody = body.string()
        log.info("body:%s".format(content))
        log.info("response:%s".format(responseBody))
    }
}
 
 data class FcmRequest(
            val to: String,
            val notification: Payload,
            val data: CustomPayload)

data class CustomPayload(
            val topic: String?)

curlで表すと次のようになる

curl -X POST \
-H "Authorization: key={Firebaseのコンソールで確認できるサーバーキー}" \
-H "Content-Type: application/json" \
-d '{
  "notification": {
    "title": "Portugal vs. Denmark",
    "body": "5 to 1",
    "icon": "/image/ic_alarm_black_48dp_2x.png",
    "click_action": "http://localhost"
  },
  "data": {
    "score": "3x1"
  },
  "to": "{クライアントで取得したトークン}"
}'  \
"https://fcm.googleapis.com/fcm/send"

HTTP メッセージ(JSON)の詳細

FCMへ通知をリクエストするHTTP メッセージ(JSON)の詳細は次のページから参照できる。

トピックにメッセージを送信する

トピックにメッセージを送信するには取得したトークンをトピックに登録する必要がある。

トークンをトピックに登録する

fun postTopic() {
    val body = okhttp3.RequestBody.create(MediaType.parse("application/json"), "{}")
    val request = okhttp3.Request.Builder()
            .url("%s/%s/rel%s".format("https://iid.googleapis.com/iid/v1", "token", "/topics/movies"))
            .header("Authorization", "key=%s".format(appProperties.serverKey))
            .header("Content-Type", "application/json")
            .header("Content-Length", "0")
            .post(body)
            .build()
        val client = OkHttpClient.Builder().build()
        client.newCall(request).execute()
}

curlで表すと次のようになる

curl -X POST \
-H "Authorization: key={Firebaseのコンソールで確認できるサーバーキー}" \
-H "Content-Type: application/json" \
-H "Content-Length: 0" \
"https://iid.googleapis.com/iid/v1/{クライアントで取得したトークン}/rel/topics/movies"

トピックにメッセージを送信

fun send() {
    val to = "/topics/movies"
    val payload = Payload(title, body, tag, icon, clickaction)
    val content = objectMapper().writeValueAsString(FcmRequest(to, payload, CustomPayload("custom")))
    val body = okhttp3.RequestBody.create(MediaType.parse("application/json"), content)

    val request = okhttp3.Request.Builder()
            .url("https://fcm.googleapis.com/fcm/send")
            .header("Authorization", "key=%s".format(appProperties.serverKey))
            .header("Content-Type", "application/json")
            .post(body)
            .build()
    val client = OkHttpClient.Builder().build()
    val response = client.newCall(request).execute()

    response.body().use { body ->
        val responseBody = body.string()
        log.info("body:%s".format(content))
        log.info("response:%s".format(responseBody))
    }
}

特定の端末へのメッセージ送信のコードのうちtoの変数が/topics/moviesに変更されている。それ以外は変更なしである。

ペイロードの暗号化は?

ペイロードの暗号化はFCM APIを使えばサポートしてくれる。クライアントでトークン取得時にブラウザの公開鍵(p256dh)乱数(auth)SDKを通してfcm.googleapis.comにPostされている。トークンはkeyとauthを用いている生成されているためクライアント側もサーバ側も暗号化を意識することなくWeb Pushを利用することができる。

まとめ

  • FCMのJavascript SDKを使ったプッシュ通知とトピック送信を試してみた。トピック送信は非常に強力な機能である。この機能をWeb Pushでも使うことで通知運用の幅も広がる。
  • FirebaseのFCMを使っているのでネイティブの通知も合わせて運用できる。通知運用がネイティブとウェブを一元管理できることは良いことである。

コード

紹介したコードは断片のためgithubを参照してほしい。動作確認ができるようにまとめてある。 github.com

Web PushをFCMとVAPIDで認証してブラウザにプッシュ通知を送る

Web Pushを試している。調べていく過程で2つの認証方式を用いてプッシュ通知を送信できることが分かった。1つはFirebase Cloud Messaging(FCM)を使い取得したサーバーキーを認証に使い送信する方法とVoluntary Application Server Identification for Web Push (VAPID)で認証をする方法である。

2つの方法としたがWeb Pushが標準化する過程で整理された認証方法であり、VAPIDのほうが後発となりFirebaseのサーバーキーを必要としない認証方式である。 VAPIDはFirebaseのプロジェクト登録が不要となるだけでプッシュサーバはFirebase Cloud Messagingが担っている。

今回のエントリではFCMとVAPIDそれぞれのWeb Pushのプッシュ通知方法をまとめていく。 また試したブラウザはChromeのみである。

Firebase Cloud Messaging(FCM)のWeb Push

事前にFirebaseでプロジェクトを作成しサーバキー送信者IDが必要となる。
※プロジェクトを作成済みであればFirebaseのコンソールから「Overview」→「プロジェクトの設定」→「クラウドメッセージング」からそれぞれ参照できる。

ここからはServiceWorkerなどフロントエンド(クライアント)とサーバに分けてプッシュ通知方法をまとめていく。

クライアント

クライアントはServiceWorkerの登録とWeb Push購読時に取得できる各種変数をサーバ側へ送信する。実際にプッシュサーバへプッシュ通知をリクエストするのはサーバである。

manifest.json

Firebaseプロジェクトから取得した送信者IDmanifest.jsonで利用するので登録しておく。

{
  "name": "FCM Web-Push",
・・・省略
  "gcm_sender_id": "Firebaseプロジェクトから取得した送信者ID"
}

ServiceWorker登録イベントとWeb Push購読イベント

クライアント側ではServiceWorkerを登録して、ブラウザでWeb Pushの購読が完了するとエンドポイントとPayload を暗号化するためのブラウザの公開鍵と鍵生成の複雑生成を増すための乱数が取得できる。これらの変数をサーバへ送信する。サーバはその鍵を利用することで通知メッセージを暗号化してPayloadに乗せることができる。

let subscription = null;

// ServiceWorkerが登録されると`serviceWorkerReady(registration)`がコールされるようにしている
function serviceWorkerReady(registration) {
    if('pushManager' in registration) {
        registration.pushManager.getSubscription().then(getSubscription);
    }
}

// Web Pushの購読イベント時に`requestPushSubscription(registration)`がコールされるようにしている
function requestPushSubscription(registration) {
    let opt = {
        userVisibleOnly: true
    };
    return registration.pushManager.subscribe(opt).then(getSubscription);
}

function getSubscription(sub) {
    subscription = sub;
}

subscriptionエンドポイントブラウザの公開鍵(p256dh)乱数(auth)が格納されている。

プッシュ通知送信時にサーバにエンドポイントブラウザの公開鍵(p256dh)乱数(auth)を送信する

function requestPushNotification() {

    if (subscription) {
        fetch(appServerURL, {
            credentials: 'include',
            method: 'POST',
            headers: {'Content-Type': 'application/json; charset=UTF-8'},
            body: JSON.stringify({
                endpoint: subscription.endpoint,
                key: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('p256dh'))))
                    .replace(/\+/g, '-').replace(/\//g, '_'),
                auth: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('auth'))))
                    .replace(/\+/g, '-').replace(/\//g, '_'),
                message: _('message').value || '(empty)'
            })
        });
    }
}

プッシュ通知受信イベント時の処理

プッシュ通知を受け取ったイベントはServiceWorkerで処理する

function showNotification(data) {
    return self.registration.showNotification('FCM/GCM WebPush Test', {
        icon: data.icon,
        body: data.body || '(with empty payload)',
        data: data.url,
        vibrate: [400,100,400]
    });
}

function receivePush(event) {
    var data = '';
    if(event.data) {
        data = event.data.json();
    }

    if('showNotification' in self.registration) {
        event.waitUntil(showNotification(data));
    }
}

function notificationClick(event) {
    event.notification.close();

    event.waitUntil(
        clients.openWindow(event.notification.data)
    );
}

self.addEventListener('push', receivePush, false);
self.addEventListener('notificationclick', notificationClick, false);
  • event.dataにサーバ側から暗号化されたPayloadが格納されている。json()をコールすることでJSONフォーマットで整形される。
  • notificationClick(event)では通知がクリックされた時のイベントをまとめてある。showNotification(data)dataにURLなどを指定しておけばnotificationClick(event)で参照できる。

サーバ

サーバ側ではクライアントから送信されたエンドポイントブラウザの公開鍵(p256dh)乱数(auth)をもとに通知メッセージを暗号化する。 暗号化のライブラリはMartijnDwars/web-pushをつかった。

github.com

暗号化はライブラリがほとんど処理してくれるためサーバ側のコードはシンプルである。

@PostMapping
fun post(@RequestBody req: Request): ResponseEntity<Boolean> {

    val payload  = objectMapper().writeValueAsString(Payload(req.message, req.tag, req.icon, req.url))

    Security.addProvider(BouncyCastleProvider())
    val push = app.push.PushService()
    push.setGcmApiKey(appProperties.serverKey)

    push.send(Notification(req.endpoint, req.key, req.auth, payload))

    return ok().json().body(true)
}

push.setGcmApiKey(appProperties.serverKey) でFirebaseで取得したサーバキーを指定している。

暗号化の詳細については次のエントリが参考になるのでオススメする。

qiita.com

プッシュサーバへ送信時のヘッダーとエンドポイントURL

次のVAPIDのWeb Pushと比較したいためプッシュサーバ送信時のヘッダーエンドポイントURL をまとめていきたい。

-H "Authorization: key={Firebaseのサーバキー}" \
-H "Encryption: keyid=p256dh;salt={乱数、salt}" \
-H "Crypto-Key: keyid=p256dh;dh={共有鍵}" \
-H "Ttl: 2419200"

AuthorizationにはFirebaseのサーバキーを指定している。クライアントから送信された公開鍵とauthからEncryptionとCrypto-Keyを生成している。 ブラウザでは暗号化されたPayloadを復号する。

エンドポイントURL: https://android.googleapis.com/gcm/send/{registration_id}

エンドポイントURLのOriginはGoogle Cloud Messaging(GCM)である。

サンプルコード

これまでFCMのWeb Pushをまとめてきたがコードの断片のみで参考にならない。 動作確認ができるコード一式をgithubに公開しているので参照してほしい。

github.com

VAPIDのWeb Push

つぎにVAPIDのWeb Pushをまとめていこう。VAPIDの全体の流れは次のエントリが参考になるのでオススメする。(同じ作者である。一貫してまとめていただいているので大変助かりました。)

qiita.com

FCMのほうではFirebaseのプロジェクト登録が必要であったがVAPIDでは必要としない。 クライアント側ではsubscription取得時にサーバ側から取得した公開鍵を用いる。

ここからはクライアントとサーバに分けてFCMとの違いについてまとめていく。

クライアント

クライアントはWeb Push購読時の処理に変更が入っている。

function requestPushSubscription(registration) {
    return fetch(appServerPublicKeyURL).then(function(response) {
        return response.text();
    }).then(function(key) {
        let opt = {
            userVisibleOnly: true,
            applicationServerKey: decodeBase64URL(key)
        };

        return registration.pushManager.subscribe(opt).then(getSubscription, errorSubscription);
    });
}

サーバ側から公開鍵を取得し購読リクエストに含めている。 そのほかの変更点はmanifest.jsonからgcm_sender_idが取り除かれたのみである。

サーバ

サーバ側では公開鍵をクライアントに提供するためのAPIを追加している。

@GetMapping("public-key")
fun get(): String {
    return publicKey
}

暗号化のライブラリはFCMと同じくMartijnDwars/web-pushをつかっている。VAPIDもサポートしてくれている。 通知処理のAPIには公開鍵と秘密鍵を含めてPushServiceオブジェクトを生成している。

@PostMapping
fun post(@RequestBody req: Request): ResponseEntity<Boolean> {

    val payload  = objectMapper().writeValueAsString(Payload(req.message, req.tag, req.icon, req.url))

    Security.addProvider(BouncyCastleProvider())
    val push = app.push.PushService(publicKey, privateKey, "http://localhost")
    push.send(Notification(req.endpoint, req.key, req.auth, payload))

    return ok().json().body(true)
}

プッシュサーバへ送信時のヘッダーとエンドポイントURL

VAPIDに変わると次のようにヘッダーとエンドポイントが変わっている。

-H "Authorization: WebPush {JWT形式の署名トークン}" \
-H "Encryption: keyid=p256dh;salt={乱数、salt ※ここは変わらない}" \
-H "Crypto-Key: keyid=p256dh;dh={共有鍵};p256ecdsa={サーバの公開鍵}" \
-H "Content-Type: application/octet-stream" \
-H "Ttl: 2419200" \

AuthorizationCrypto-Keyにそれぞれ変更と追加がある。

エンドポイントURL: https://fcm.googleapis.com/fcm/send/{registration_id}

エンドポイントURLのOriginはFirebase Cloud Messagingに変更されている。

サンプルコード

同様にVAPIDのほうもgithubにコード一式を公開したので参照してほしい。

github.com

まとめ

  • FCMとVAPIDのWeb Pushのプッシュ通知方法が理解できた。エンドポイントなどをサービス側で保持するタイミングなど実運用に向けて考えなくてはいけないことがある。
  • Chromeのみ動作確認を行っていたが各種ブラウザの挙動が異なりそうなので導入時にクリアしていきたい。

KotlinでgRPC。grpc-javaのTLS with JDKとTLS with OpenSSLの使い方をまとめた。

grpc-java/SECURITY.mdを読み進めるとTLSを有効にしたgRPCサーバの起動には2つのプロトコルプロバイダーを選択できる。 JDKOpenSSLがその2つである。それぞれを有効にする方法は異なりドキュメントも豊富ではない。この機会にまとめていきたいというのが今回のモチベーション。

github.com

TLS with JDK

まずはJDKからみていきたい。grpc-java/SECURITY.mdにも記載があるとおり、この方式は推奨されていない。 JavaTLS実装にALPNがサポートされるのはJava9からでありJava8(Java < 8)を利用している場合は、JVMオプションにjavaagentを加える。加えたjavaagentjetty-alpn-agentを指定する。 起動までの流れとしては次ようになる。

mkdir -p /var/lib
wget -q -O /var/lib/jetty-alpn-agent.jar https://repo.maven.apache.org/maven2/org/mortbay/jetty/alpn/jetty-alpn-agent/2.0.6/jetty-alpn-agent-2.0.6.jar

java $JAVA_OPTS -javaagent:/var/lib/jetty-alpn-agent.jar -jar /usr/local/your-project/lib/app-name.jar

これでTLS with JDKで起動できる。

TLS with OpenSSL

次にOpenSSLである。grpc-java/SECURITY.mdにも記載があるとおりstaticなnetty-tcnativeを使わない場合はOpenSSL version >= 1.0.2Apache APR library (libapr-1) version >= 1.5.2.が必要になる。 DockerコンテナでJVMを起動する場合は事前にインストールをしておく。

apt-get install -y ca-certificates openssl libc6 libapr1

libc6はprotoBufferを生成する際に必要となる。コンテナ起動時にビルドのタスクに.protoからprotoBufferを生成している。

OpenSSLのハマりポイントがありalpineをコンテナイメージのベースにしていたが上手く起動できなかった。ubuntuベースにするとサクッと起動した。 alpineで起動すると次のようなエラーに遭遇する。netty-tcnativeを使うためにalpineで何かが足りていないのだろう。今後深追いしていきたい。

Caused by: java.lang.UnsatisfiedLinkError: /tmp/libnetty-tcnative3270913869898400045.so: Error relocating /tmp/libnetty-tcnative3270913869898400045.so: SSL_add0_chain_cert: symbol not found
        at java.lang.ClassLoader$NativeLibrary.load(Native Method)
        at java.lang.ClassLoader.loadLibrary0(ClassLoader.java:1941)
        at java.lang.ClassLoader.loadLibrary(ClassLoader.java:1824)
        at java.lang.Runtime.load0(Runtime.java:809)
        at java.lang.System.load(System.java:1086)
        at io.netty.util.internal.NativeLibraryUtil.loadLibrary(NativeLibraryUtil.java:36)
        ... 21 more

ubutuを許容できるのであればubuntuベースでOpenSSLを利用するのがよさそう。

最後に必要なnetty-tcnativeはドキュメントのGetting netty-tcnative from Gradleが参考になる。

これでTLS with OpenSSLが起動できる。

JDKとOpenSSLを有効にしたDockerfileをまとめた

ここまでJDKOpenSSLについてまとめてきたがコードの断片しかなく参考にならないため、それぞれのDockerfileと動作確認環境をシュッと起動できるdocker-composeをまとめたので詳細はgithubを参照してほしい。

github.com

READMEに動作確認手順をまとめているが、docker-composeでJDKとOpenSSLが有効になったgRPCサーバを起ち上げ、gRPCクライアントを含むアプリの起動時にサーバの環境変数を切り替えている。

ここまでgrpc-javaについて触れていたけどgithubに置いているコードはkotlinで書いている。適宜javaに置き換えて参照いただきたい。

grpc-javaはそこまでドキュメントが豊富とは言いづらい。積極的にアウトプットしていきたい。

PagerDutyとAsanaをzapierをつかって連携させてみた

zapierをつかってPagerDutyとAsanaを連携させる方法をまとめます。

zapier.com

モチベーション

PagerDutyのIncidentをSlackに通知をして、その対応をAsanaでタスク化して運用をするうえで Asanaのタスク化を自動化させたい。そんなときzaiperは zap という単位でAサービスで発生したイベントをトリガーにBサービスにアクションを行うという一連の運用フローを自動化することができる。

PagerDutyでIncidentが新しく作られたら Asanaでタスクを作成する、このような運用フローがzaiperのダッシュボードからポチポチと設定するだけで自動化できる。

連携させてみた

f:id:n_soushi:20170426093701p:plain

zaiperのダッシュボードは直感的で設定フローの理解もすぐにできた。
PagerDutyをトリガーにAsanaでタスクを作るというフローだけど、他にもステップを追加することができる。例えばGoogleCalendarにイベントを追加したりgithubでissue化したり認証できるサービスであれば多様なステップを作ることができる。

ここからはzaiperの設定フローで補足したいところをまとめていく。

設定フローの補足

PagerDutyの設定

  • Incidentの発生条件はオプションで設定できる。
    • Incidentが誰にアサインされたかを指定したり、どのイベントタイプをトリガーにするか指定できる。
    • 今回は誰にアサインされたかは指定せず、イベントタイプはTriggerとした。つまり認知していない新規のIncidentの発生がトリガーとなる。
  • zaiperはPagerDutyのイベントを検知するためにEndpoinURLを生成するのでPagerDuty側でそのURLをWebhookに追加する必要がある。

Asanaの設定

  • 今回はAsanaのタスクを作成するアクションにしたが、StoryProjectなど他のアクションも設定できる。
  • タスクを生成するうえでタスクのタイトルやメモなどをテンプレートとして設定できる。
    • PagerDutyのIncidentから要素を取得することができるので、タイトルにIncidentのServiceNameやメモにIncidentのURLStatusなどを変数としてテンプレート化できる。
    • 今回は次のように設定してみた。 f:id:n_soushi:20170426100129p:plain
  • その他にもタスクを誰にアサインするかなどタスクの要素を細かく設定できる。 f:id:n_soushi:20170426100510p:plain

動かしてみる

あらかじめPagerDutyとAsanaはSlackと連携させているのでIncidentとタスクのイベントに応じてSlackに通知されることが確認できた。

f:id:n_soushi:20170426100917p:plain

実際にPagerDutyでIncidentを発生させてみて、Incidentが発生した通知の後にAsanaのタスクが作成されていることが分かる。

f:id:n_soushi:20170426101532p:plain

AsanaのタスクにはIncidentの情報が参照されていてzaiperで設定したAsanaのテンプレートが適応されていることが確認できる。

まとめ

zaiperをつかって運用フローの自動化を試してみた。zaiperのダッシュボードは使いやすい印象でつくってる方たちも好印象だった。
zaiperのプランはzapとタスクの数でグレードが分かれている。あとはzapierが稼働するのがBasicだと15分毎でBusiness以降は5分毎なので運用要件に合わせたプラン選択が必要だと感じた。

他のzapの組み合わせとしてはGoogleカレンダーにイベントが登録されたのをトリガーにSlackにイベントを通知する連携方法なども試しみたりした。

f:id:n_soushi:20170426161411p:plain

GoogleカレンダーのイベントをトリガーにしてSlackで告知するような運用はよくあるケースなので、まずはこういったところから導入を始めて、運用に合わせてzapを増やしていくとよさそうである。

KotlinでgRPC。SSL/TLSを有効にする方法をまとめた。

前回のエントリではgrpc/grpc-javaをベースにkotlinでgRPCを試しました。今回はSSL/TLSを有効にする方法をまとめていきます。grpc/grpc-java/SECURITY.mdを参照しながら進めました。

証明書を準備する

手元に適当な証明書がなかったのでgrpc-go/testdataにある証明書を利用しました。

Subject Alternative Name を確認するとマルチドメイン*.test.google.fr, waterzooi.test.google.be, *.test.youtube.comが定義されていますので、hostsにwaterzooi.test.google.beを追加しました。

openssl x509 -text -in ./server1.pem
127.0.0.1 waterzooi.test.google.be

OpenSSLを有効にする

netty-tcnativeをプロジェクトに追加します。

buildscript {
  repositories {
    mavenCentral()
  }
}

dependencies {
    compile 'io.netty:netty-tcnative-boringssl-static:1.1.33.Fork26'
}

netty-tcnativeはDynamicStaticがあります。このエントリではStaticを利用しました。grpc/grpc-java/SECURITY.mdを参照するとStaticの利用を推奨していますがOpenSSLのセキュリティパッチが提供された場合、Staticではパッチ反映が即座には行われないためプロジェクト方針によってはDynamicの利用を検討する必要があります。

GrpcSslContextsでSslContextを生成する

サーバ側とクライアント側ではGrpcSslContextsに定義されているforServerforClientのメソッドをつかってSslContextを生成します。

サーバ側

(serverBuilder as NettyServerBuilder).sslContext(
        GrpcSslContexts.forServer(
                File(classLoader.getResource("server1.pem").file),
                File(classLoader.getResource("server1.key").file))
                .clientAuth(ClientAuth.OPTIONAL)
                .build())

クライアント側

NettyChannelBuilder.forAddress("waterzooi.test.google.be", 50051)
        .sslContext(
                GrpcSslContexts.forClient()
                        .trustManager(File(classLoader.getResource("ca.pem").file))
                        .build())
        .build()

上記の実装を行えばSSL/TLSが有効になります。

コードを公開しています

このエントリのコードはgithubに公開しています。
SSL/TLSを有効にするにあたり情報が少ない印象をもちました。java or kotlinで実装をする方に少しでも参考になると嬉しいです。

github.com

関連エントリ

naruto-io.hatenablog.com

KotlinでgRPC。実運用にも活かせるWEBアプリケーション構成で試してみた。

KotlinでgRPCを試していきます。protocol buffersがkotlinに対応していないのでjavaに生成したものを使います。次のようなアプリケーション構成でKotlinを使ったgRPC通信を試してみました。

アプリケーション構成

f:id:n_soushi:20170413103848p:plain

  • エンドクライアントからのアクセスはGateway Serverが窓口となりHTTP/1.1で通信を行います。
  • Gateway ServerのバックエンドにいるgRPC ServerとはgRPC(HTTP/2)で通信を行います。
  • monitoring toolGateway ServergRPC Serverの監視を行いHTTP/1.1で通信を行います。

モチベーション

何度かgRPCについてのエントリをまとめてきました。kotlinでgRPCを試してみたいと感じていたのとSpring Framework 5.0のリリースを控えた状況でSpring Framework 5 on Kotlinを試してみたい欲求がありました。 そのためアプリケーション構成図にあるとおりGateway ServerにはRouter機能を試したいので spring-webfluxでアプリケーションを作りました。

次のエントリではSpring Framework 5.0でReactive Programmingを活用しながらkotlinらしいコードの紹介がされています。

spring.io

またgRPC ServerではHTTP/1.1gRPC(HTTP2)の2つの通信方式を有効にしたいです。Spring Bootでどのように実現するのか?この課題についても理解を深める必要がありました。

そして実戦に向けて実運用をイメージしたアプリケーション構成を構築する必要がありました。


ここからは構築にいたるまでの勘所や課題などについてまとめていきます。

gRPC Server

まずはgRPC Serverからです。
ここでの課題はHTTP/1.1gRPC(HTTP2)の2つの通信方式を有効にすることです。

  • monitoring toolからはヘルスチェックなどの監視リクエストに応えるためにHTTP/1.1で通信を行いたい
  • Gateway Serverとの通信にはgRPC(HTTP2)で通信を行いたい

この課題を解決するために次のspring-boot-starterを使いました。

github.com

こちらを使えばgRPC Serverを実装したクラスに@GRpcServiceをつけるだけでgRPC SeverをSpring Boot上に起動できます。またSpring Boot(spring-boot-starter-web)で起動していますのでHTTP/1.1の通信も有効です。

@GRpcService
class EchoServer : EchoServiceGrpc.EchoServiceImplBase() {

    override fun echoService(request: EchoMessage?, responseObserver: StreamObserver<EchoMessage>?) {
        val msg = EchoMessage.newBuilder().setMessage("echo \\${request?.message}/").build()
        responseObserver?.onNext(msg)
        responseObserver?.onCompleted()
    }
}

レポジトリのREADMEにあるとおりinterceptorの提供(ログ差し込んだり)やServerビルド定義もカスタマイズできます。

$ ./gradlew clean generateProto bootRun
・・・
2017-04-13 14:48:45.479  INFO 30602 --- [           main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
2017-04-13 14:48:45.482  INFO 30602 --- [           main] o.l.springboot.grpc.GRpcServerRunner     : Starting gRPC Server ...
2017-04-13 14:48:45.528  INFO 30602 --- [           main] o.l.springboot.grpc.GRpcServerRunner     : 'app.grpc.server.EchoServer' service has been registered.
2017-04-13 14:48:45.531  INFO 30602 --- [           main] o.l.springboot.grpc.GRpcServerRunner     : 'app.grpc.server.GreetServer' service has been registered.
2017-04-13 14:48:45.765  INFO 30602 --- [           main] o.l.springboot.grpc.GRpcServerRunner     : gRPC Server started, listening on port 50051.
・・・

8080ポート(HTTP/1.1)と50051ポート(gRPC/HTTP2)の両方が起動ログで確認できます。これでgRPC Serverの課題は解決です。

Gateway Server

次にGateway Serverです。
ここではspring-web-fluxを使いreactor coreベースでアプリケーションが動いています。そしてエンドクライアントからリクエストをgRPC Serverへ渡すgatewayの役割を担います(gRPCクライアントの役割)。
spring-web-fluxを使うとnon-blockingなservletが起動するためgRPCクライントには okhttpを使います。gRPCのクライアントは標準でnettyが使われるためokhttpを指定します。これをしないとreactor coreのアプリケーションとgRPC Serverのnon-blokingなところがバッティングしてしまうようです。

private fun getChannel() = OkHttpChannelBuilder.forAddress(appProperties.grpc.server.hostname, appProperties.grpc.server.port!!)
            // for testing
            .usePlaintext(true)
            .build()

もしGateway Serverエンドクライアントとの通信にgRPCを使いたい場合はGateway ServerにgRPC Serverを置く必要がありバッティング問題を解消しなくてはなりません。これに関してはSpring Framework5.0の正式リリースやマイルストーンの動きを見て試していく必要があり課題として残りました。

protobuf-gradle-plugin

protoclo bufferの生成には次のgradleプラグインを使っています。

github.com

起動時に.protoからprotocol bufferを生成するようにgradleに次ような設定をしています。

protobuf {
    protoc {
        artifact = 'com.google.protobuf:protoc:3.2.0'
    }
    plugins {
        grpc {
            artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
        }
    }
    generateProtoTasks {
        ofSourceSet('main').each { task ->
            task.builtins {
                java {
                    outputSubDir = 'protoGen'
                }
            }
            task.plugins {
                grpc {
                    outputSubDir = 'protoGen'
                }
            }
        }
    }
    generatedFilesBaseDir = "$projectDir/src/"
}

task cleanProtoGen {
    doFirst{
        delete("$projectDir/src/main/protoGen")
    }
}
clean.dependsOn cleanProtoGen

上記の定義により./gradlew clean generateProtoを実行することでprotocol bufferを再生成できます。起動時に最新の.protoからprotocol bufferを使われるように起動コマンドは./gradlew clean generateProto bootRunを使っています。


まとめ

  • 現バージョンのSpring Boot 1.5.2ではLogNet/grpc-spring-boot-starterを使うことでHTTP/1.1gRPC(HTTP2)の共存課題は解決できました。
  • Spring Framework 5ではreactor coreの採用からgRPC Serverとの共存には課題が残りました。今後のjavaのエコシステムなどを使いながら課題解決に取り組みます。

コードを公開しています

今回のKotlinでgRPCを試したコードのすべてはgithubに公開しています。 起動して確認するにはgithubのコードからできますのでREADMEを参照してください。

github.com

supersetをシュッと起動できるDockerfile(認証方式をGoogle API OAuth2に変更)をつくってみた、あと触ってみた所感など

ダッシュボードツールのsupersetをシュッと起動できるDockerfileを作りました。といってもsupersetのDockerfileはgithub.comに見かけるので認証方式をGoogle API OAuth2.0に変更したDockerfileを作りました。あとsupersetを触ってみての感想など導入に向けての所感をまとめたエントリです。

github.com

認証方式をOAuthに変更する方法

supersetは認証方式を変更できます。チームに最適な認証方式を選択できます。標準はDBにID/パスワードを登録する方式になっています。これをOAuthに変更する方法をまとめます。

コンテナ内の環境変数 SUPERSET_HOMEにセットしたディレクトリ配下にsuperset_config.pyを置いてsupersetの環境変数を上書きします。次のように認証方式をAUTH_OAUTHに設定し認証プロバイダの詳細設定を記述します。

import os
from flask_appbuilder.security.manager import AUTH_OAUTH
basedir = os.path.abspath(os.path.dirname(__file__))
AUTH_TYPE = AUTH_OAUTH
OAUTH_PROVIDERS = [
    {'name':'google', 'icon':'fa-google', 'token_key':'access_token',
        'remote_app': {
            'consumer_key':'{GOOGLE_AUTH_CLIENT_ID}',
            'consumer_secret':'{GOOGLE_AUTH_SECRET_KEY}',
            'base_url':'https://www.googleapis.com/plus/v1/',
            'request_token_params':{
              'scope': 'https://www.googleapis.com/auth/userinfo.email'
            },
            'request_token_url':None,
            'access_token_url':'https://accounts.google.com/o/oauth2/token',
            'authorize_url':'https://accounts.google.com/o/oauth2/auth'}
    }
]

認証方式をAUTH_OAUTHに設定した状態でsupersetを起動するとログイン画面で認証するサービスにGoogleが表示されます。

次に認証情報を作成したGoogle Developer ConsoleでリダイレクトURLhttp://localhost:8088/oauth-authorized/googleに設定するとGoogle Accountで認証ができます。またGoogle+ Apiからアカウント情報を取得しますのでDeveloper ConsoleでGoogle+ Apiを有効にします。

最後に認証させたいアカウントをfabmanegerを使って作成します。

docker exec -it superset \
  fabmanager create-admin --app superset \
  --username 'Google+ アカウントの表示名(displayName)' \
  --firstname '任意の名' \
  --lastname '任意の姓' \
  --email 'Google アカウントのメールアドレス' \
  --password '任意のパスワード'

Google以外にもTwitterFacebookなどの認証サービスを追加することができます。

詳しくはgithubのレポジトリに公開していますので合わせて確認できます。

GitHub - nsoushi/superset-demo: This repository contains demo using Superset. After begging containers, you can try Superset right now.

supersetが参照するDBを標準のsqliteからmysqlに変更する

標準ではsupersetが参照するDBはsqliteでOS内の$DATA_DIRにデータが格納されます。
これだとコンテナを削除するとダッシュボードの登録設定が消えてしまうのでsupersetのコンテナではない外のmysqlコンテナを起動させて参照させました。
Compose化して次のようにSQLALCHEMY_DATABASE_URI環境変数を変更しています。

SQLALCHEMY_DATABASE_URI = 'mysql://root@mysql:3306/app?charset=utf8mb4'

mysqlのコンテナではマルチバイト文字列も扱えるようにutf8mb4文字コードを有効にしています。 supersetアプリが参照するデータベースURLの末尾に?charset=utf8mb4をつければダッシュボードの名前にマルチバイト文字列が使えるようになります。

Dockerfileの使い方

次のレポジトリのDockerfileでコンテナを起動させるとsupersetが使えるようになります。
superset-demo/superset at master · nsoushi/superset-demo · GitHub

supersetだけを起動したい場合はsuperset-init.sh内の次の行をコメントアウトしてください。

SQLALCHEMY_DATABASE_URI = '${SUPERSET_DB_URI}'

mysqlのコンテナとセットで動かしたい場合はレポジトリのREADMEを参考にdocker-copomseでsupersetとmysqlのコンテナを起動してください。
GitHub - nsoushi/superset-demo: This repository contains demo using Superset. After begging containers, you can try Superset right now.

supersetを触ってみた感想など

最後にsupersetを触ってみた感想をまとめます。
初めてsupersetを触りましたが次のようなダッシュボードを作成することができました。

f:id:n_soushi:20170407141457p:plain

mysqlが提供するworldデータベースをデータソースにして人口やGNPの数値をグラフ化しました。

  • 人口の総数(VisualizationType: BigNumber)
  • 大陸ごとの人口総数(VisualizationType: Distribution - Bar Chart)
  • 大陸ごとの人口総数(VisualizationType: Distribution - Pie Chart)
  • 言語ごとのGNP(VisualizationType: Word Cloud)
  • 大陸ごとのGNP(VisualizationType: Treemap)

worldデータベースには時系列のデータがありませんが、時系列のデータがあれば集計条件に取得範囲時間を設定してダッシュボードを定期的に更新して定点観測することもできます。

グラフの作成手順

特にヘルプなどを見なくても直感的にグラフ作成まで進めます。
グラフ作成手順は次のような流れです。

  • データベースを登録する
  • 登録したデータベースからテーブルを登録する
  • テーブルからグラフ化するカラムを登録する
  • グラフ化に必要なメトリクスを登録する
    • データの総数が必要な場合はCount(*)、カラム値の総数が必要な場合(人口の総数)はSum(Population)などを登録する
  • 登録したテーブルからスライスを登録する
  • グラフを選択する
  • メトリクスとGroup Byするカラムを組み合わせる
  • 例)'Asia',‘Europe'などの大陸ごとに人口総数を出す場合は、メトリクスにSum(Population) を登録してContinentカラムをGroup Byする
  • 登録したグラフをダッシュボードへ登録する

https://raw.githubusercontent.com/nsoushi/superset-demo/master/docs/capture.gif

柔軟にテーブルを定義できる

柔軟にグラフ化したデータテーブルを定義できます。

  • DBにあるテーブルをつかう
  • 複数のテーブルをJoinさせた結果をテーブルとしてつかう
  • SQL LabSQLクエリを実行できる)で実行した結果からダイレクトにグラフ化に進む

ただ、SQL Labからダイレクトにグラフ化に進む方法は手元のバージョン(0.17.3)ではエラーとなりIssueとしても登録されていました。

github.com

SQL Lab

SQL Labでは作成したクエリを実行できます。
実行したクエリは履歴として残ります。後から再度実行できたり、実行結果から直接グラフを作ることもできます。
クエリを書ける人であればSQL Labでグラフ化したいデータの条件でSQLを作りメトリクスとグラフ作成に進むほうが効率が良さそうです。

f:id:n_soushi:20170407145848p:plain

機能権限とセキュリティ

Admin, Alpha, Gamma, sql_labなどのロールが用意されていてテーブルの登録権限、スライスの登録権限、SQL Labだけを使える権限などがあります。

http://airbnb.io/superset/security.html

機能権限に加えてユーザの操作ログや各種メニューへのアクセス権限などを設定することも可能です。
ここらへんはBIツールに必要そうな機能をサポートする姿勢が伺えます。

まとめ

  • RedashはSQLクエリの作成を起点としてダッシュボードを整える流れに比べて、supersetは予め準備されたデータソースを選択します。次に総数や平均など、どんなメトリクスでグラフを作るかを考えダッシュボードを整えます。データソースの選択からダッシュボード登録まで全て画面UIとして提供されているのでクエリを理解していなくても簡単にグラフを作成できます。
  • 必要なデータソースを予め準備するエンジニアと解析する人で役割を分ける運用ができます。解析者はエンジニアが準備してくれたデータソースをもとにメトリクスを作成して長所を活かした役割分担ができます。
  • ロールとセキュリティも担保されているので情報の公開範囲に注意しながら運用できます。
  • グラフの種類が豊富で定期的にグラフが更新されるダッシュボードが作れるので実行したSQL結果をエクセルに持っていきプレゼンしているような状況であれば利用の検討ができそうです。

コードを公開しています

コード全体はgitbubで確認できます。

github.com