http4kをベースにサーバーサイド Kotlinの関連ライブラリをつかってみた
Google I/O 2017でKotlinがAndroidアプリ開発言語に選定された。Androidに限らずサーバーサイドでもメインの言語としてKotlinは選択できて、いくつかのサービス開発の経験を経てきた。これまでSpring BootをメインのFrameworkに置いて開発をしてきたけど、この機会にKotlinで開発されたライブラリをつかってみて実戦投入が検討できそうなライブラリを探っていきたい欲がでてきた。
モチベーション
先述したとおりSpring Bootを中心に置いた開発は快適でKotlinを言語選択しても生産性が落ちることはない。ただKotlin主体で開発されたHTTPサービスを提供するライブラリも多く存在している状況にあり、一度Spring Bootから離れてKotlin主体のFreameworkを試してみたい、というのが今回のエントリのモチベーション。 HTTPサービス周りのライブラリだけを試してみるのではなく、サーバーサイドアプリケーションの関心事にあるORMやDIなどもKotlin主体のライブラリを選択していきたい。
HTTPサービス
まずはHTTPサービス。
これには http4k
を選択。
コミット状況も最近の履歴が含まれているのが好印象。
ルーティングを設計してHTTPハンドラを実装していきながら、リクエストインタセプターも実装できるのでHTTPサービスにおいては最低限のものが揃っている。
HTTP ServerはJetty, Netty, Undertowから選択できる。
routes( GET to "/hello/{name:*}" by { request: Request -> Response(OK).body("Hello, ${request.path("name")}!") }, POST to "/fail" by { request: Request -> Response(INTERNAL_SERVER_ERROR) } ).asServer(Jetty(8000)).start()
HTTPクライアントも提供されていて簡単に実装できる。
val uri = Uri.of("${getUrlBase()}${getPath()}?Authorization=%s".format(param)) val request = MemoryRequest(Method.GET, uri, uri.queries()) val response = OkHttp()(request)
APIドキュメンテーションにはSwaggerをつかえる。
RouteModuleにSwaggerを有効にすれば /api/api-doc
のエンドポインにjson形式のApiドキュメンテーションがルーティングされるでSwagger-UIに読み込ませればよい。
RouteModule(Root / "api", Swagger(ApiInfo("http4k test API", "v1.0"), Jackson)) .withDescriptionPath { it / "api-docs" }
http4k
をつかってtodo-listのバックエンドAPIを作ってみたのでエントリ最後にあるgithubから詳細なプログラムコードが参照できるので機会があれば参考にしてほしい。
データベース
次にデータベース。
これには requery
を選択。
Java/Kotlin/Androidの言語をサポートしたORM。
interface
またはdata class
でエンティティを作る。
@OneToMany
や@PostLoad
などのアノテーションが提供されていて、それぞれ1:多の関連づけや更新したときのコールバックを指定できる機能が用意されている。
@Entity(model = "kt") @Table(name = "task") interface Task { @get:Key @get:Generated @get:Column(name = "task_id") var id: Long @get:Column(name = "title") var title: String @get:Column(name = "finished_at") var finishedAt: LocalDateTime? @get:Column(name = "created_at") var createdAt: LocalDateTime @get:Column(name = "updated_at") var updatedAt: LocalDateTime }
fun findOneById(id: Long): Result<Task, TaskException> { return data.invoke { // ココ val query = select(Task::class) where (Task::id eq id) if (query.get().firstOrNull() == null) Result.Failure(TaskException.TaskNotFoundException("task not found. taskId:%d".format(id))) else Result.Success(query.get().first()) } }
Data Transfer Object
ユーティリティ的なところであるがResult
というモデルをつかってみた。
データベース
のサンプルコードのところで出てきたが findOneById(id: Long)
のメソッドの返り値にResult<Task, TaskException>
をつかっている。
Resultを使えば処理結果にSuccess
とFailure
を含めて返すことができる。
if (query.get().firstOrNull() == null) Result.Failure(TaskException.TaskNotFoundException("task not found. taskId:%d".format(id))) else Result.Success(query.get().first())
Result
の戻り値を受け取った側は次のように処理できる。(.fold({ task -> TaskModel(task) }, { error -> throw handle(error) })
のところ)
class GetTaskService(private val taskRepository: TaskRepository) : ApplicationService<GetTaskCommand, TaskModel> { override fun invoke(command: GetTaskCommand): TaskModel { return taskRepository.findOneById(command.id).fold({ task -> TaskModel(task) }, { error -> throw handle(error) }) } }
データがない場合に null
またはlistOf()
を返すかexceptionをthrowする
か迷いがちな印象がある。
今回はレポジトリ層の全ての返り値の型にResult
を指定してみたところ開発効率が良かった。
Dependency injection
Spring Bootを使わない縛りを入れたのでDIもkotlin純製のものを選択。
DIにはKodein
をつかった。
val kodein = Kodein { // filter bind<AuthFilter>("authFilter") with singleton { AuthFilter(instance("authClient")) } bind<ExceptionFilter>("exceptionFilter") with singleton { ExceptionFilter() } ・・・ } val exceptionFilter = kodein.instance<ExceptionFilter>("exceptionFilter") val authFilter = kodein.instance<AuthFilter>("authFilter")
ドキュメントに好印象。
ドキュメントは熟読できていないが必要最低限のDIはできた。
サンプルアプリケーション
これまで紹介したライブラリを用いてTodoリストのサンプルアプリケーションを開発してみたので合わせて参照していただきたい。
今回はこちらのエントリに触発されてDDDを意識して作ってみたが、まだまだ勉強しなくてはと感じた。
ScalaでウェブAPIを書いている人が設計や実装やその他について話そうか // Speaker Deck
まとめ
- Spring Bootを使わない縛りルールのなかでKotlinライブラリを揃えてアプリケーションを構築してみた。(できた)
- あらためてSpringの良さに気づいたのは正直なところ。HTTPリクエストのインバウンド・アウトバウンドの簡単さは偉大。(DIも偉大)
- HTTPルーティングに振り切っているサービスなどは
http4k
を使ってサクッと作るもあり。 - Springの偉大さに気づき、
requery
やResult
などの新しい発見もあったのでチームに持ち帰り導入を検討していきたい。
FCMでWeb Push。Firebase Javascript SDKを使ったプッシュ通知とトピック送信を試した。
FirebaseのFirebase Cloud Messaging(FCM)を試している。今回のエントリではFCMのJavaScriptライブラリを使ってブラウザにプッシュ通知やトピックにメッセージを送信する方法をまとめていく。
FCMではトピックや端末グループへのメッセージングなどの機能が利用できる。これらの機能をPush APIをサポートしているブラウザにも同様に利用することができる。
ここからは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.jsonにgcm_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プロジェクトから取得した送信者ID
はmanifest.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
をつかった。
暗号化はライブラリがほとんど処理してくれるためサーバ側のコードはシンプルである。
@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で取得したサーバキー
を指定している。
暗号化の詳細については次のエントリが参考になるのでオススメする。
プッシュサーバへ送信時のヘッダーとエンドポイント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に公開しているので参照してほしい。
VAPIDのWeb Push
つぎにVAPIDのWeb Pushをまとめていこう。VAPIDの全体の流れは次のエントリが参考になるのでオススメする。(同じ作者である。一貫してまとめていただいているので大変助かりました。)
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" \
Authorization
とCrypto-Key
にそれぞれ変更と追加がある。
エンドポイントURL: https://fcm.googleapis.com/fcm/send/{registration_id}
エンドポイントURLのOriginはFirebase Cloud Messagingに変更されている。
サンプルコード
同様にVAPIDのほうもgithubにコード一式を公開したので参照してほしい。
まとめ
- FCMとVAPIDのWeb Pushのプッシュ通知方法が理解できた。エンドポイントなどをサービス側で保持するタイミングなど実運用に向けて考えなくてはいけないことがある。
- Chromeのみ動作確認を行っていたが各種ブラウザの挙動が異なりそうなので導入時にクリアしていきたい。