CA.ktに登壇してServer Side Kotlinについて話してきた。

CA.kt #3で登壇しました。テーマは「FRESH!プロジェクト/Server Side Kotlin 活用事例」。スライドはこちら。

speakerdeck.com

約1年前にFRESH!チームにジョインしてKotlinに出会いServer Side Kotlinと向き合ってきた経験を話してきた。

Server Side Kotlinの導入事例やIdeomatic Kotlin Codeをテーマにしたコード事例を紹介したり、Spring Boot 2.0とgRPCのチャレンジ、DBアクセスには何を使ってるの?、テストは?、Apiドキュメンテーションは?など、Server Side Kotlinを俯瞰して横断的な内容をまとめられと思う。

興味ある方は是非スライドを参照ください👀

そして今回のエントリは、これまでアウトプットしてきたServer Side Kotlin、Spring Boot 2.0、gRPC方面のエントリを総集編的にまとめていきたい。会場で出会えた方にもブログの存在をアピールできたし、「で、そのブログにはどんなエントリあるの?」って少なからず感じてもらえたと思うので応えたい。

Sever Side Kotlin

Kotlinを始めてまず取り掛かったのがテストコード。JavaではJMockitoにお世話になっていたがKotlinで動かないことに絶望を感じたのが記憶にある。JMockitoが使えないならKotlinでどのようにコードの品質を担保するのか!!と差し迫った時期のエントリ。

blog.soushi.me


DelegateとKotlinコルーチンについてまとめたエントリ。ここらへんの話も発表に入れたかった。

blog.soushi.me

blog.soushi.me


純正KotlinのJSONパーサーのKlaxonについてのエントリ。この記事はアクセス数が結構ある。JSONパースって必須と感じる。

blog.soushi.me

Spring Boot 2.0.0

Spring Boot 2.0.0+ Kotlin動きそう? HTTPサーバのInterceptorってどうなってるの? を疑問に思ってまとめたエントリ。

blog.soushi.me


Spring Framework 5から提供が始まったRouter Function DSLのテスト方法についてまとめたエントリ。

blog.soushi.me


Spring Boot 2.0.0 Milestone versionではSpringFox(Swagger)やSpring Rest DocsなどのAPIドキュメンテーションツールのサポートが不完全なので、その時期にどうやって解決したかアウトプットしたエントリ。

blog.soushi.me

gRPC

protobuf typeについてまとめたエントリ。まだ試していない型がたくさんあるしgRPC楽しい。

blog.soushi.me

blog.soushi.me

protoの運用について考えたエントリ。protodep便利。

blog.soushi.me


gRPC Contextについてまとめたエントリ。gRPCはInterceptorを使ってContextに値を入れたり取り出したりすることがある。AOPには必須な知識。

blog.soushi.me


gRPC Client/Serverのテスト方法をまとめたエントリ。

blog.soushi.me

blog.soushi.me

blog.soushi.me


エンドクライアント(iOSやAndroid)のgRPC ClientとgRPC Serverの通信にTLSを有効にするためにはどうすればよいのか?を調べてアウトプットしたエントリ。

blog.soushi.me

blog.soushi.me


TLSについてまとめたけどALPN対応できなくて焦って問題解決できた経緯をまとめたエントリ。

blog.soushi.me

まとめ

  • スライドのテーマに似ているような過去エントリをまとめてみた。
  • まとめた順番とエントリの時期が前後したり、過去の情報で最新ではない可能性もあるので内容は保証できないことをご了承いただきたい🙏
  • こういう機会にエントリをまとめてみるのは頭の整理が進むので良いと感じた。

次回はgrpc-gatewayのアウトプットをしようと計画中。

grpc-javaのClientでHeaderをセットしてリクエストする方法をまとめる

gRPCのClientで認証ヘッダーなどリクエストHeaderに値をセットしてgRPC Serverへリクエストするにはどうすればよいか?今回のエントリではgrpc-javaのClientでHeaderをセットしてリクエストする方法をまとめていく。

これまでのエントリではgRPC ServerのほうでClientからリクエストされたHeaderを参照する方法に ServerInterceptorを用いることをまとめてきた。

blog.soushi.me

Clientでも同様にInterceptorを用いてリクエストHeaderに値をセットすることになる。ClientInterceptorを継承したAuthInterceptorを用意する。

ClientInterceptorを準備する

class AuthInterceptor : ClientInterceptor {

    private val CUSTOM_HEADER_KEY = Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER)
    private val AUTH_TOKEN = "your-auth-token"

    override fun <ReqT : Any?, RespT : Any?> interceptCall(method: MethodDescriptor<ReqT, RespT>?, callOptions: CallOptions?, next: Channel?): ClientCall<ReqT, RespT> {
        return object : ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(next?.newCall(method, callOptions)) {

            override fun start(responseListener: ClientCall.Listener<RespT>, headers: Metadata) {
                /* put custom header */
                headers.put(CUSTOM_HEADER_KEY, AUTH_TOKEN)
                super.start(object : ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT>(responseListener) {
                    override fun onHeaders(headers: Metadata) {
                        super.onHeaders(headers)
                    }
                }, headers)
            }
        }
    }
}
  • CUSTOM_HEADER_KEY変数にHeaderのKeyをauthorizationに定義したMetadataインスタンスをセットしている
  • start関数をoverrideしてheaderにセットすればよい

これでauthorizationをキーにした認証ヘッダーをセットするAuthInterceptorができた。

ManagedChannelのビルド工程にinterceptorをセットする

gRPC ClientではManagedChannelをビルドすることになるが、ビルドアップのパラメータにinterceptがある。これに先ほど作ったAuthInterceptorをセットすれば良い。

private fun getNettyChannel(): ManagedChannel {
    return NettyChannelBuilder
            .forAddress(DOMAIN, PORT)
            .sslContext(
                    GrpcSslContexts.forClient()
                            .trustManager(File(CERT_PATH)).build())
            .intercept(AuthInterceptor())
            .build()
}

まとめ

  • grpc-javaのClientでHeaderをセットしてリクエストする方法をまとめた
  • ClientもServerと似ていてClientInterceptorを継承したinterceptorを実装しChannelBuilderのinterceptにセットすればよい

コード

コードはgithubに置いています。

github.com

参考になるコードはこちら

grpc-javaのServerでTLSを有効にした場合はALPN対応しているか確認すべし

開発中のプロジェクトでTLSが有効なGo製 gRPC ServerにAndorid8.0(Oreo)からは接続ができて、Kotlin製(grpc-java) gRPC Serverには接続ができない状況に直面した。

原因はOpenSSLでTLSを有効にしていたがALPN対応ができていなかった。Android8.0(Oreo)ではプロトコルのネゴシエーションにALPNが用いられる。Android8.0以下(Lolipopなど)では接続ができてAndroid8.0(Oreo)では接続できない、そんな状況であった。そして次のgrpc-javaのissue報告と同じような経験を踏んでいた。

github.com

このissueによるとServer側がALPN対応していないことが原因でALPN対応することで解決した、ということで参考にしながら解決まで進めた。対応内容をまとめていきたい。

ALPN対応ができているか確認する方法

issueを参考に次の方法でALPN対応ができているか確認したところ「No ALPN negotiated」と出力されALPN対応できていないことが発覚した。

$ openssl s_client -alpn h2 -connect <domain>:<port>

---
・・・省略
No ALPN negotiated ← ALPN対応していないと、`No ALPN negotiated`と出力される
SSL-Session:
    Protocol  : TLSv1.2
    Cipher    : XXXX
・・・省略
---

プロジェクトではGo製のTLSが有効なgRPC Serverがあり同じ方法で確認したところ「ALPN protocol: h2」の出力が確認できた。そしてAndroid8.0(Oreo)からも接続ができていた。SSL Clientを用いた違い(No ALPN negotiatedALPN protocol: h2)を頼りにKotlin製(grpc-java)のgRPC Serverでも「ALPN protocol: h2」が出力されることを目指した。

改めてgrpc-java/SECURITY.mdを参照する

https://github.com/grpc/grpc-java/blob/master/SECURITY.md

Security.mdによるとTLSを有効にするためには2つの方法がありOpenSSLを用いるかjar起動のオプションのjavaagentにjetty-alpn-agentを加える方法がある。 以前のエントリでもまとめていた。

blog.soushi.me

プロジェクトではOpenSSLをつかっていたのだがALPN対応ができていなかった。最初はコンテナのubutsuが14.04/OpenSSLが1.0.1のためALPNがサポートされていないことが判明したので、OpenSSLを1.0.2にあげてみたが「ALPN protocol: h2」が出力されることはなかった。

そのため2つ目の方法のjar起動のオプションのjavaagentにjetty-alpn-agentを加える方法を試したところ「ALPN protocol: h2」の出力が確認できた。これでServer側の対応は準備ができた。そして実機からも無事接続が確認できた。解決である。

まとめ

  • grpc-javaのServerでTLSを有効にした場合はALPN対応しているかOpenSSLのSSLクライアントで確認できるので忘れずに実施する
    • openssl s_client -alpn h2 -connect <domain>:<port>
    • 出力にALPN protocol: h2があるか確認する
  • grpc-javaをつかっているならクライアント接続に用いられるChannelでテストを実施してクロスチェックを強化する
    • AndroidはOkHttpChannelBuilderを用いるのでServerのテストにもOkHttpChannelBuilderを想定したクライアントのテストを加える
  • コンテナのOpenSSLをALPNサポートしているVersionにしてもALPN対応できていなかった件については引き続き調査を進める。

ということで、タイトルのとおり「grpc-javaのServerでTLSを有効にした場合はALPN対応しているか確認すべし」を教訓として学んだ。

コード

TLSが有効なgRPC Serverに接続するgrpc-javaのクライアントテストをgithubに追加しています。

github.com

テストコードはこちら

SpringBoot 2.0とapiDocを連携させてみた

SpringFrameworkでAPIドキュメントを準備する手段としてSpringFoxやSpring REST Docsなどが候補にあがる。

SpringFox by springfox

Spring REST Docs

両者とも導入敷居が低くSpringFoxはコントローラクラスやレスポンスモデルにアノテーションをつけていきSpringを起動すればドキュメントエンドポイントが生まれる。APIの開発序盤からDocsを整理していけるスピード感がある。対してSpring REST DocsはテストコードにDocs生成手段を仕込みHttpStatus:200 OKHttpStatus:404 NotFoundなどレスポンス毎にDocsを整理できるので網羅的なドキュメントを整理することができる。

Spring Boot 2.0でも同様にSpringFoxやSpring REST Docsを使いAPIドキュメントを整理したかったが現時点では未対応な状況である。(2017/09/29)

github.com

github.com


何か別の手段でと思いを馳せて居たところで同僚にapiDocを教えてもらった。

apiDoc - Inline Documentation for RESTful web APIs

apiDocはJavadoc-Styleやコメントコードを駆使してAPIドキュメントをまとめapidocのコマンドを叩けばドキュメント一式のHTMLが生成される。詳細は公式を確認いただきたい。

http://apidocjs.com/#run

コメントコードが生成できればドキュメントHTMLが生成される仕組みなのでSpring REST DocsのようにテストコードにapiDoc-Params(コメントコード)を生成する仕組みを取り入れてみた。

ここからは開発したテストコードを元にドキュメントHTMLが生成されるまでをまとめていきたい。

apiDocのコメントコードを吐くテストコード

/**
 * @api {GET} /api/task/:taskId タスクの取得
 * @apiName GetTask
 * @apiGroup Task
 * @apiVersion 1.0.0
 *
 * @apiUse GetTaskOk
 * @apiUse GetTaskNotFound
 *
 */
@Test
fun `GET Task`() {

    val define = DefineBuilder({
        version { "1.0.0" }
        name { "GetTaskOk" }
    })

    // @apiParamのコメントコードを吐く
    define.param { ApiParam("taskId", "タスクID", "23445", false, "7") }

    // 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()
            .consumeWith {
                val actual: TaskModel = mapper.readValue(it.responseBody)
                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"

                // @apiSuccessのコメントコードを吐く
                define.success { actual }
                // @apiSuccessExampleのコメントコードを吐く
                define.successExample { ApiSuccessExample("Success", HttpStatus.OK, actual) }
            }

    define.genDoc()
}

タスクを1件取得するエンドポイントのテストコードに仕込んだ。最初のコメントコードでapiDocの仕様に基づきAPIの概要をまとめている。@apiUseについては後述する。
テストコードのブロック内ではDefineBuilderのインスタンスからDocをビルドアップしている。このDefineBuilderをお手製でつくってみた。

define.success { actual }

このsuccessに渡るインスタンスクラスのプロパティに次のような@ApiDocPropertyがついていれば@apiSuccess {String} firstname Firstname of the User.のようなコメントコードが吐かれるようにした。

data class TaskModel(
        @ApiDocProperty(value = "タスクID", example = "23445")
        val id: Long,
        @ApiDocProperty(value = "タイトル", example = "牛乳を買う")
        val title: String,
        @ApiDocProperty(value = "完了日時", example = "yyyy-MM-dd'T'HH:mm:ss'Z'")
        val finishedAt: String?,
        @ApiDocProperty(value = "作成日時", example = "yyyy-MM-dd'T'HH:mm:ss'Z'", nullable = true)
        val createdAt: String,
        @ApiDocProperty(value = "更新日時", example = "yyyy-MM-dd'T'HH:mm:ss'Z'")
        val updatedAt: String)

上記のインスタンスは次のようなコメントコードを吐く。JSONレスポンスの各項目にも名称やサンプル値などが整理できる。

@apiSuccess {String} createdAt 作成日時, Nullable:TRUE, Example:yyyy-MM-dd'T'HH:mm:ss'Z'
@apiSuccess {String} finishedAt 完了日時, Nullable:FALSE, Example:yyyy-MM-dd'T'HH:mm:ss'Z'
@apiSuccess {long} id タスクID, Nullable:FALSE, Example:23445
@apiSuccess {String} title タイトル, Nullable:FALSE, Example:牛乳を買う
@apiSuccess {String} updatedAt 更新日時, Nullable:FALSE, Example:yyyy-MM-dd'T'HH:mm:ss'Z'

そして最後に@apiUseである。冒頭のコメントコードは次のようになっていた。

@apiUse GetTaskOk
@apiUse GetTaskNotFound

apiDocの特徴的な機能で@apiUseに宣言された変数は@apiDefineに宣言された変数と紐づくことになる。GetTaskOkは正常系のテストコードに紐づき、GetTaskNotFoundは404エラーになるテストコードに紐付いている。404エラーのテストコードは次のようになっている。

fun `GET Task NotFound`() {

    val define = DefineBuilder({
        version { "1.0.0" }
        name { "GetTaskNotFound" } // `@apiDefine GetTaskNotFound`を吐く
    })

    // mock
    `when`(taskHandler.fetchByTaskId(any())).thenThrow(WebAppException.NotFoundException("task notfound."))

    client.get().uri("/api/task/1")
            .accept(MediaType.APPLICATION_JSON_UTF8)
            .exchange().expectStatus().isNotFound
            .expectBody()
            .consumeWith {
                val actual: ErrorItem = mapper.readValue(it.responseBody)

                define.error { actual }
                define.errorExample { ApiErrorExample("BadRequest", HttpStatus.BAD_REQUEST, actual) }
            }

    define.genDoc()
}

@apiUse@apiDefineの仕組みをつかえば複数のAPIレスポンスをドキュメント化することができる。
エンドポイントのあらゆる異常系を書き@apiUse@apiDefineで紐付けることでドキュメントが整備されていく仕掛けとなる。Spring REST Docsのエッセンスを上手く取り入れられた。

まとめ

apiDocが生成したドキュメントの画面キャプチャは次のようになった。

正常系

f:id:n_soushi:20170929214250p:plain

エラー

f:id:n_soushi:20170929214321p:plain


SpringFoxやSpring REST DocのSpring Boot 2.0.0(Spring5)のサポートを待つ間の暫定対応としてapiDocを連携させてみたが、思いの外ドキュメントにする手間もなくドキュメント化が実現できた。

サポートが完了したSpringFoxやSpring REST Docの新しい機能に期待をしながら、今回つくったDefineBuilderに対応していないapiDoc-Paramを追加していきライブラリとして成熟させていきたい。

コード

今回紹介したコードはgithubに置いていますので良ければ参照してください。

github.com

テストコードはこちらです。

gRPC serverのmetadataをテストする方法をまとめる

gRPC ServerのレスポンスにはMetadataを含めることができる。gRPCのレスポンス・ステータス(io.grpc.Status.OKio.grpc.Status.INVALID_ARGUMENT)やDescription(エラーメッセージ)に加え、例えば400系のエラーでも異なるエラー内容をクライアントに伝えるのに役立つ。HTTP/1.1ではHTTPステータスに加えレスポンス・ボディにJSON形式でエラーメッセージとエラーコードを含めるようなことで実現していたが、gRPCではMetadataを活用すればDescription(エラーメッセージ)をJSON形式にする必要はない。

今回のエントリではMetadataを含むレスポンスを返すgRPC Serverのテスト方法をまとめていく。

Metadataを含むレスポンスを返すgRPC Serverを準備する

gRPC Serverで起きたエラーをインターセプトしてレスポンスを返すInterceptorを以前のエントリで紹介した。

blog.soushi.me

このインターセプターで返すレスポンスにcustom_statusというKeyのMetadataを含めるようにした。コードとしては次のようにしている。

private fun <ReqT, RespT> handleException(call: ServerCall<ReqT, RespT>?, headers: Metadata?, ex: Exception) {

    when (ex) {
// ・・・省略
        is WebAppException.NotFoundException -> {
            Metadata.Key.of("custom_status", Metadata.ASCII_STRING_MARSHALLER).let {
                headers?.put(it, HttpStatus.NOT_FOUND.value().toString())
            }
            call?.close(Status.fromCode(Status.NOT_FOUND.code).withDescription(ex.message), headers)
        }
// ・・・省略
    }
}

WebAppException.NotFoundExceptionのExceptionクラスであればcustom_statusのMetadataには404の文字列が含まれるようにした。

metadataをテストする方法

metadataをテストするためにはテストコードで起動しているgRPC Serverのインターセプターに新しいインターセプター・クラスを加える。そのインターセプター・クラスのmetadataをキャプチャすることでmetadataが含まれているか検証する。

コードとしては次のようになる。

private val metadataCaptor = ArgumentCaptor.forClass(io.grpc.Metadata::class.java)
private val mockServerInterceptor = Mockito.spy(TestInterceptor())
private class TestInterceptor : ServerInterceptor {
    override fun <ReqT : Any?, RespT : Any?> interceptCall(call: ServerCall<ReqT, RespT>?, headers: io.grpc.Metadata?,
                                                           next: ServerCallHandler<ReqT, RespT>?): ServerCall.Listener<ReqT> {
        return next!!.startCall(call, headers)
    }
}

@Before
fun setUp() {
    delegateTaskService = mock(DelegateTaskService::class)

    target = TaskBackendServer(delegateTaskService)
    inProcessServer = InProcessServerBuilder
            .forName(UNIQUE_SERVER_NAME)
            .addService(target)
            .intercept(exceptionInterceptor)
            .intercept(mockServerInterceptor)  // このインターセプター・クラスのmetadataをキャプチャする
            .directExecutor()
            .build()
    inProcessChannel = InProcessChannelBuilder.forName(UNIQUE_SERVER_NAME).directExecutor().build()

    inProcessServer.start()
}

テストコードは次のようになった。

@Test
fun getProducts_NOT_FOUND() {

    val taskId = 1L
    val request = TaskInbound.newBuilder().setTaskId(taskId.toInt()).build()

    val command = GetTaskCommand(taskId)

    // mock
    mockStatic(GRpcLogContextHandler::class)
    Mockito.`when`(GRpcLogContextHandler.getLog()).thenReturn(GRpcLogBuilder())
    Mockito.`when`(delegateTaskService.getTask(command)).thenThrow(WebAppException.NotFoundException("not found"))

    try {
        // request server
        val blockingStub = TaskServiceGrpc.newBlockingStub(inProcessChannel)
        blockingStub.getTaskService(request)
    } catch (e: StatusRuntimeException) {
        // assertion
        e.status.code shouldBe Status.NOT_FOUND.code
        e.message shouldBe "NOT_FOUND: not found"

        // metadataの`custom_status`が"404" であること
        Mockito.verify(mockServerInterceptor).interceptCall(
                MockHelper.any<ServerCall<TaskInbound, TaskOutbound>>(),
                metadataCaptor.capture(),
                MockHelper.any<ServerCallHandler<TaskInbound, TaskOutbound>>())
        metadataCaptor.value.get(
                Metadata.Key.of("custom_status", Metadata.ASCII_STRING_MARSHALLER)) shouldBe "404"
    }
}

MockitoのArgumentCaptorを活用すればmetadataの検証も手軽に行うことができた。

コード

githubにコードがありますので合わせて参照ください。

github.com

テストコードはこちらです。

jackson.datatypeをつかってiOSのレシートの日付文字列をデシアライズする

iOSアプリで購入したアイテムのレシート検証を実装する過程でレシートに含まれる日付文字列をdata classにデシアライズしてみた。

レシートに含まれる日付は次のような形となっている。

"expires_date": "2016-06-17 01:32:28 Etc/GMT"

見慣れないタイムゾーンを表すEtc/GMTの文字列があった。

DateTimeFormatter (Java Platform SE 8 )

上記を参照すると次のように定義されている。

V       time-zone ID                zone-id           America/Los_Angeles; Z; -08:30

この定義からDateTimeFormatter は次のように定義する必要がある。※ Vは2つVV

"yyyy-MM-dd HH:mm:ss VV"

このフォーマットをもとにJson文字列から定義したdata classへデシアライズしてみよう。

デシアライズするdata classを定義する

デシアライズするdata classは次のようなに定義した。

data class MyState(
        @JsonProperty("expiration_date")
        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss VV")
        val expirationDate: ZonedDateTime
)

JSONにあるexpiration_dateはdata classでは文字列ではなく日付系の型に変換したい。
ここで重要な点がある。
日付文字列にはEtc/GMTのタイムゾーンが含まれるためLocalDateTimeには変換できない。LocalDateTimeはタイムゾーンを持たないからである。
そのためZonedDateTimeに変換し expirationDate.toLocalDateTime()とすることでLocalDateTimeが取得することができる。

data classの準備はこれで整った。

jackson.datatypeをつかう

ここでエントリのタイトルにもあるjackson.datatypeが登場する。
Json <-> DataClassのシリアライズ/デシアライズにはkotlinモジュールも整っているのでjacksonを採用したい。

日付型に変換が必要であればjackson.datatypeが必要である。gradleに依存を追加する。

compile 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.9.1'

そしてObjectMapperの初期化にJavaTimeModuleを追加する。

val objectMapper = ObjectMapper()
        .registerModule(KotlinModule())
        .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
        .registerModule(JavaTimeModule())

これでiOSのレシートでつかわれる日付文字列を日付型に定義したdata classにデシアライズできる。

class SandboxTest {

    @Test
    fun deserialize() {
        Sandbox.deserialize<MyState>(json).let {
            format(formatter)(it.expirationDate.toLocalDateTime()) shouldBe "2016-06-17 01:27:28"
        }
    }

    private fun format(f: DateTimeFormatter): (LocalDateTime) -> String {
        return { date -> f.format(date) }
    }

    private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

    private val json = """
{
  "expiration_date": "2016-06-17 01:27:28 Etc/GMT"
}
"""
}

まとめ

  • 2016-06-17 01:32:28 Etc/GMTのようなフォーマットをjacksonでデシアライズする方法をまとめた。
  • タイトルのナレッジの日本語エントリが少なかったのとレシート検証している人も多いだろうし備忘録として溜めておく。

コード

コードはgithubにあります。

github.com

soushin.sandbox.kt.jackson を参照してください。

KotlinでConstructor Injectionが増えてきたからDelegateをつかってリファクタリングしてみた

今回のエントリはDelegateの使い方をまとめる。
次のようなConstructor Injectionに複数のサービスクラスが並んだTaskBackendServerクラスがある。

@GRpcService
class TaskBackendServer(private val getTaskService: GetTaskService,
                        private val findTaskService: FindTaskService,
                        private val createTaskService: CreateTaskService,
                        private val updateTaskService: UpdateTaskService,
                        private val deleteTaskService: DeleteTaskService,
                        private val finishTaskService: FinishTaskService) : TaskServiceGrpc.TaskServiceImplBase() {

TaskBackendServerは複数のTaskServiceに依存していることが分かる。これくらいの数になると多くの依存が明確でり煩雑な印象を受ける。またテスト時にも依存クラスのインスタンスをつくりTaskBackendServerをつくりあげるのは骨が折れる。

そんなときには Delegate(委譲)をつかって処理を委譲させるとよい。
DelegateをつかえばAクラスにBクラスのpublicな関数を委譲させることができる。委譲されたAクラスはBクラスの関数を使えるようになる。AクラスはCクラス、Dクラスなど委譲させる関数名にコンフリクトがなければ複数のクラスを委譲することができる。

上記のコードではTaskにまつわる複数のサービスクラスが TaskBackendServerに依存しているので DelegateTaskServiceクラスをつくり委譲をしてみる。

委譲させるクラスはインターフェースを実装したクラスであること

委譲のコードの前にGetTaskServiceFindTaskServiceなどそれぞれの委譲させるクラスは次のようにインターフェースを実装したクラスであることを整理しておく。

interface CreateTaskService {
    fun createTask(command: CreateTaskCommand): Task
}

@Service("createTaskService")
class CreateTaskServiceImpl(private val taskRepository: TaskRepository) : CreateTaskService {

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

DelegateTaskServiceをつくる

@Service
class DelegateTaskService(private val getTaskService: GetTaskService,
                          private val findTaskService: FindTaskService,
                          private val createTaskService: CreateTaskService,
                          private val updateTaskService: UpdateTaskService,
                          private val deleteTaskService: DeleteTaskService,
                          private val finishTaskService: FinishTaskService) :
        GetTaskService by getTaskService,
        FindTaskService by findTaskService,
        CreateTaskService by createTaskService,
        UpdateTaskService by updateTaskService,
        DeleteTaskService by deleteTaskService,
        FinishTaskService by finishTaskService

委譲させるにはクラス名の宣言のあとに、 byキーワードをつかいDelegateを明示する。

TaskBackendServerのConstructor Injectionは次のようにスッキリした。

@GRpcService
class TaskBackendServer(private val delegateTaskService: DelegateTaskService) : TaskServiceGrpc.TaskServiceImplBase() {

また、delegateTaskServiceGetTaskServiceの関数を呼び出せるようになった。

val task = delegateTaskService.getTask(GetTaskCommand(taskId.toLong()))

まとめ

  • Delegate(委譲)についてまとめた。
  • 複数のインターフェースを実装する場合、委譲を使えばクラスの肥大化を抑えることができる。
  • 今回のようにConstructor Injectionが増えてきて依存度が増しと感じれば委譲を使い整理することができる。

コード

githubにコードがありますので合わせて確認できます。

github.com