Spring5.0 + KotlinのRouterFunctionのテストはどうすればよいか? 試してみた。

引き続きSpring5.0(Spring Boot 2.0) + KotlinのWebアプリケーションを試している。今回はRouterFunctionをつかってHTTPルーティングを定義したときにテストコードはどう書くのか?気になったのでまとめてみた。

Router Function

Router FunctionはSpring5.0から導入されるものでアノテーションベースのHTTPサービスの定義ではなく RouterFunctionDslクラスを実装していくことになる。エンドポイントのURLとHTTP Handlerをマッピングを行うことでルーティングが定義できる。

@Configuration
class TaskRoutes(private val taskHandler: TaskHandler, private val exceptionFilter: ExceptionFilter) {

    @Bean
    fun taskRouter() = router {
        (accept(APPLICATION_JSON) and "/api").nest {
            "/task".nest {
                POST("/", taskHandler::create)
                GET("/{id}", taskHandler::fetchByTaskId)
                PUT("/{id}", taskHandler::updateByTaskId)
                DELETE("/{id}", taskHandler::deleteByTaskId)
                PUT("/{id}/finish", taskHandler::finishByTaskId)
            }
            "/tasks".nest {
                GET("/", taskHandler::fetchAll)
            }
        }
    }.filter(exceptionFilter())
}

次からはRouter Functionで定義されたHTTPサービスのテストコードを書いていきたい。

MockMvcではなくWebTestClient をつかう

SpringのコントローラテストコードはMockMvcを用いて書くことが多かったがRouter Functionでは WebTestClientをつかう。
上記の taskRouterはタスクエンティティを扱うコントローラでありルーティングも固有のBeanで定義している。また TaskHandlerはDBアクセスやgRPC サーバなどロジックが絡むのでモック化していきたい。そのような場合にはWebTestClientに用意されている bindToRouterFunctionを定義する。

@RunWith(SpringRunner::class)
class TaskRoutesTest {
    lateinit var client : WebTestClient
    lateinit var taskHandler: TaskHandler
    lateinit var exceptionFilter: ExceptionFilter

    val mapper = ObjectMapper().registerModule(KotlinModule())

    @Before
    fun before() {
        taskHandler = mock(TaskHandler::class)
        exceptionFilter = ExceptionFilter()

        val taskRoutes = TaskRoutes(taskHandler, exceptionFilter)

        client = WebTestClient.bindToRouterFunction(taskRoutes.taskRouter()).build()
    }

・・・

}
  • TaskHandlerをモック化する(taskHandler変数を定義)
  • taskHandler変数とexceptionFilter変数から TaskRoutesのコンストラクタを呼び出しRoutesオブジェクトを生成する(taskRoutes変数を定義)
  • taskRoutes変数を bindToRouterFunctionに渡しWebTestClientオブジェクトを生成する

以上をbeforeに定義してテスト実行の準備を整える

@Testを定義する

タスクを1件取得するエンドポイントURLのテストコードは次のようになった。

@Test
fun `GET Task`() {

    // mock
    `when`(taskHandler.fetchByTaskId(any())).thenReturn(ok().json().body(Mono.just(mockModel)))

    client.get().uri("/api/task/1")
            .accept(MediaType.APPLICATION_JSON_UTF8)
            .exchange()
            .expectStatus().isOk
            .expectBody()
            .consumeAsStringWith {
                val actual = mapper.readValue<TaskModel>(it, TaskModel::class)
                actual.id shouldBe 1L
                actual.title shouldBe "task title"
                actual.createdAt shouldBe "2017-06-13T16:22:52Z"
                actual.updatedAt shouldBe "2017-06-13T16:22:52Z"
            }
}
  • taskHandler.fetchByTaskIdをモック化して返すオブジェクトを定義している
  • exchange()ResponseSpecオブジェクトが返ってくるのでHTTPステータスやレスポンスボディを取得しアサーションする

Spring REST Docsは使えるの?

Test DrivenにAPIドキュメントを生成するSpring Rest DocsはMockMVCベースなので今のところは対応していない。

github.com

対応はしていないが対応はする予定である。issueから確認できるがREST Docs 2.0で対応予定のようなので期待したい。

Add support for using WebTestClient to document an API · Issue #384 · spring-projects/spring-restdocs · GitHub

同じくAPIドキュメントをアノテーションベースで生成できる便利なSpring Foxもサポート予定であることをissueから観測した。

Spring 5 support · Issue #1773 · springfox/springfox · GitHub

個人的にはエンドポイント設計時からアノテーションを付与すればAPI仕様書がつくれるSpring Foxのほうが好みである。だがアノテーションベースで定義する手法がHTTPルーティングをアノテーションベースで定義しないRouter Functionにどのようにマッピングされるのだろうか。サポート結果が楽しみである。

まとめ

  • RouterFunctionをつかった場合でもテストコードはモックも交えることができるし問題無さそうで導入に前向きになれた。
  • APIドキュメントのライブラリのサポート体制状況も観測できたので、Spring5.0のリリースが待ち遠しい。

コード

コードを書きながら調べたのでgithubと合わせて確認いただけます。

github.com

テストコードはこちらです。 spring5-kotlin-application/TaskRoutesTest.kt at master · nsoushi/spring5-kotlin-application · GitHub

参考にしたエントリ

Spring5.0 + KotlinではDoma、Request Interceptorあたりはどうなっているのか調べてみた

Spring5.0のリリースが迫るなか、プロジェクトへ導入に向けて色々と調べている。インタセプターなどのSpring Frameworkにおける作法はどうなっているか、便利に使えていたライブラリとの相性はどうなのか、などをアウトプットしていく。

次の2つのはてな?を調べてみた。

  • DBアクセスにはDomaを使いたい。Spring Transactionalの@Transactionalも合わせて使える?
  • HandlerInterceptorAdapter@ControllerAdviceなどAOPRouterFunctionで使いたいけど、どうすれば?

それでは1つずつ整理していく。

Domaとの相性

Spring Boot 1.5系とDomaの相性は良くSpring Transactionalの@Transactionalも問題なく使えている。
@Transactionalの使い勝手は良くアノテーションを付けたメソッド無いでRuntimeExceptionが発生すればロールバックしてくれる。

@Transactional
override fun invoke(command: CreateTaskCommand): Task {
    return taskRepository.create(command.title).fold({
        task -> task
    }, {
        error -> throw handle(error)
    })
}

com.mysql.jdbc.ReplicationDriverのドライバを使い @Transactional(readOnly = true)をつければスレーブに対してDBアクセスが行われる。

DomaのKotlinサポートの対応方法をベースにSpringBoot 2.0で試してみたところ、問題なく使えた!

Domaとの相性は問題ないと結果が得られたのでDBアクセスには引き続きDomaを使っていく。

動いているコードはgithubにあるので合わせて参照ください。

RouterFunctionでリクエストやExceptionを良しなに処理したい

HTTPリクエストをハンドリングするアプリケーションの関心事として次のようなものがある。

  • HTTPリクエストをインターセプトして共通の処理を入れたい
  • エラーレスポンスの扱いを共通化したい

これらの関心事をSpring5.0から導入されるRouterFunctionでは、どのように解決すれば良いか。
Spring4系であればHTTPリクエストのインターセプトにはHandlerInterceptorAdapterを実装したクラスを用意すれば良かった。サービス層で発生したエクセプションに応じてエラーレスポンスを定義するクラスを@ControllerAdviceアノテーションをつけたクラスを用意すれば良かった。

RouterFunctionでは、FilterFunctionをつかって2つの関心事を解決できる。
コードとしては次のようになる。

@Configuration
class TaskRoutes(private val taskHandler: TaskHandler, private val exceptionFilter: ExceptionFilter) {

    @Bean
    fun taskRouter() = router {
        (accept(APPLICATION_JSON) and "/api").nest {
            "/task".nest {
                POST("/", taskHandler::create)
                GET("/{id}", taskHandler::fetchByTaskId)
                PUT("/{id}", taskHandler::updateByTaskId)
                DELETE("/{id}", taskHandler::deleteByTaskId)
                PUT("/{id}/finish", taskHandler::finishByTaskId)
            }
            "/tasks".nest {
                GET("/", taskHandler::fetchAll)
            }
        }
    }.filter(exceptionFilter())
}

@Component
class ExceptionFilter {

    private val logger = KotlinLogging.logger {}

    operator fun invoke(): (request: ServerRequest, next: HandlerFunction<ServerResponse>) -> Mono<ServerResponse> = { request, next ->
        try {
            next.handle(request)
        } catch (e: Exception) {
            when (e) {
                is SystemException -> status(e.status).json().body(Mono.just(ErrorItem(e.message ?: "web application error", e.status.value().toString(), null)))
                else -> {
                    logger.error(e) { "unknown exception: %s".format(e.message ?: "unknown error") }
                    status(HttpStatus.INTERNAL_SERVER_ERROR).json().body(Mono.just(ErrorItem(e.message ?: "internal server error", null, null)))
                }
            }
        }
    }
}

.filter(exceptionFilter())の箇所でFilter Functionを定義している。
コードの例ではExceptionFilterクラスでは各APIで発生したExceptionをフィルタリングしている。Exceptionが発生した場合はエラーレスポンスを返すようにしている。これで例外処理をハンドリングできるインタセプターをRouterFunctionに追加できる。

Spring4系で HandlerInterceptorAdapter@ControllerAdviceで分かれていたところがFilterFunctionに集約された格好である。

コード

コードを書きながら調べたのでgithubと合わせて確認いただけます。

github.com

まとめ

  • Domaとの相性が問題ないことやHTTPハンドリングの関心事もRouterFunctionでも解決できることが分かった。
  • 並行してSpringFoxとの相性も調べている。APIドキュメントの快適さもSpring4系を使う恩恵があったのでSpring5.0からも問題なく使えることを期待している。

Spring5.0 + Kotlinで1つのjarにHTTPサーバーとgRPCサーバーを相乗りさせてみた

Spring Boot 2.0.0 M1がリリースされました。以前のエントリで試した当時は 2.0.0.BUILD-SNAPSHOTでありHTTPサーバーが起動している状態でgRPCクライアントを動かすとエラーになっていた。

2.0.0 M1のリリースに伴いHTTPサーバーとgRPCサーバーが1つのjarに相乗りできるようになっているか確認するのが今回のモチベーション。

次のようなアプリケーション構成を実現したい。

f:id:n_soushi:20170608125908p:plain

  • API ServerHTTP1.1のリクエストのルーティングgRPCサーバーのエンドポイントを提供する
  • API Serverに届いたリクエストはBackend Server向けgRPCクライアントからBackend Serverにリクエストする
  • API ServerとBackend Server間の通信はgRPCで行う
  • エンドポイントにはHTTP1.1のリクエストのルーティングgRPCサーバーの2つの通信方式を用意する
  • すべてのアプリケーションはSpring Boot 2.0.0 M1の上で動く
  • Introducing Kotlin support in Spring Framework 5.0のエントリにあるようなSpring5.0で提供される新しい機能をつかう

ここからは試した過程での気づきなどをアウトプットしていく。

CommandLineRunnerでgRPCサーバーを起動する

HTTPルーティングが動いている状態で CommandLineRunnerをつかいgRPCサーバーを次のように起動させた。

@Configuration
class GrpcServerRunner(private val appProperties: AppProperties,
                       private val echoServer: EchoServer,
                       private val taskServer: TaskServer) : CommandLineRunner, DisposableBean {

    private val logger = KotlinLogging.logger {}

    lateinit var server: Server

    override fun run(args: Array<String>) {

        val port = appProperties.grpc.server.port

        logger.info { "Starting gRPC Server ..." }
        val serverBuilder = NettyServerBuilder.forPort(port)
        serverBuilder.addService(echoServer)
        serverBuilder.addService(taskServer)
        server = serverBuilder.build().start()
        logger.info {"gRPC Server started, listening on port $port."}

        startDaemonAwaitThread()
    }
}

Spring Boot 2.0.0 M1で試したところ問題なく起動した!2.0.0.BUILD-SNAPSHOTではエラーになっていたところ)
2.0.0 M1では300以上のissueやプルリクエストがマージされたので、その中のどれかで解消されたということだろう。

grpc-java1.3.0はエラーになる

Spring Bootには直接関係はないところであるが最新のgrpc-javaの1.3.0を使うとエラーになった。

java.lang.NoClassDefFoundError: io/netty/handler/codec/http2/internal/hpack/Decoder
        at io.grpc.netty.GrpcHttp2HeadersDecoder.<init>(GrpcHttp2HeadersDecoder.java:85) ~[grpc-netty-1.2.0.jar:1.2.0]
        at io.grpc.netty.GrpcHttp2HeadersDecoder$GrpcHttp2ServerHeadersDecoder.<init>(GrpcHttp2HeadersDecoder.java:135) ~[grpc-netty-1.2.0.jar:1.2.0]
        at io.grpc.netty.NettyServerHandler.newHandler(NettyServerHandler.java:109) ~[grpc-netty-1.2.0.jar:1.2.0]
        at io.grpc.netty.NettyServerTransport.createHandler(NettyServerTransport.java:132) ~[grpc-netty-1.2.0.jar:1.2.0]
        at io.grpc.netty.NettyServerTransport.start(NettyServerTransport.java:77) ~[grpc-netty-1.2.0.jar:1.2.0]
        at io.grpc.netty.NettyServer$1.initChannel(NettyServer.java:141) ~[grpc-netty-1.2.0.jar:1.2.0]
・・・

Netty 4.1.9ではio.netty.handler.codec.http2.internal.hpack.Decoderが削除されているのが原因。

github.com

gradleは次のようにgrpc-nettyのみ1.4.0-SNAPSHOTのバージョンを指定することで解消できる。

buildscript {
    ext.grpc_version = "1.3.0"
    ext.grpc_version_snapshot = "1.4.0-SNAPSHOT"

    repositories {
        mavenCentral()
        maven { url "https://repo.spring.io/milestone" }
        maven { url 'http://repo.spring.io/plugins-release' }
        maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
    }
}

・・・

dependencies {
・・・
    // grpc
    compile "io.grpc:grpc-netty:${grpc_version_snapshot}"
    compile "io.grpc:grpc-protobuf:${grpc_version}"
    compile "io.grpc:grpc-stub:${grpc_version}"
    compile "io.grpc:grpc-okhttp:${grpc_version}"
    compile "com.google.api.grpc:googleapis-common-protos:0.0.3"
・・・
}

BackendサーバーにはHTTPサーバーがいらない

BackendサーバーにはHTTPサーバーがいらないので次のように@SpringBootApplicationがついたメインクラスの起動でWebアプリケーションを動かさないようにした。

@SpringBootApplication
@EnableConfigurationProperties(AppProperties::class)
class Application {

    companion object {
        @JvmStatic
        fun main(args: Array<String>) {
            SpringApplicationBuilder(Application::class).web(WebApplicationType.NONE).run(*args)
        }
    }
}

コード

今回試したコードはgithubに置いてあるので参考になれば嬉しい。

github.com

まとめ

  • 理想の構成どおりにアプリケーションが組めた
  • Spring5.0のkotlinサポートは魅力的であり実戦投入を検討したかったがgRPCとの相性が良くない印象であったが検証を経て問題ないことが確認できた
  • いよいよSpring5.0の正式リリースに向けて整ってきている印象を受けた
  • その他で利用しているSpringエコシステムの各種ライブラリとのSpring5.0/Spring Boot 2.0.0の相性が問題ないことを引き続き確認していき導入を検討していきたい

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を選択。

github.com

コミット状況も最近の履歴が含まれているのが好印象。
ルーティングを設計して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を選択。

github.com

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
}

SQLDSLでタイプセーフに組み立てられる。

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というモデルをつかってみた。

github.com

データベースのサンプルコードのところで出てきたが findOneById(id: Long)のメソッドの返り値にResult<Task, TaskException>をつかっている。
Resultを使えば処理結果にSuccessFailureを含めて返すことができる。

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をつかった。

github.com

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リストのサンプルアプリケーションを開発してみたので合わせて参照していただきたい。

github.com

今回はこちらのエントリに触発されてDDDを意識して作ってみたが、まだまだ勉強しなくてはと感じた。

ScalaでウェブAPIを書いている人が設計や実装やその他について話そうか // Speaker Deck

まとめ

  • Spring Bootを使わない縛りルールのなかでKotlinライブラリを揃えてアプリケーションを構築してみた。(できた)
  • あらためてSpringの良さに気づいたのは正直なところ。HTTPリクエストのインバウンド・アウトバウンドの簡単さは偉大。(DIも偉大)
  • HTTPルーティングに振り切っているサービスなどはhttp4kを使ってサクッと作るもあり。
  • Springの偉大さに気づき、requeryResultなどの新しい発見もあったのでチームに持ち帰り導入を検討していきたい。

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のみ動作確認を行っていたが各種ブラウザの挙動が異なりそうなので導入時にクリアしていきたい。

関連エントリ

naruto-io.hatenablog.com

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はそこまでドキュメントが豊富とは言いづらい。積極的にアウトプットしていきたい。