ひだまりソケットは壊れない

ソフトウェア開発に関する話を書きます。 最近は主に Android アプリ、Windows アプリ (UWP アプリ)、Java 関係です。

まじめなことを書くつもりでやっています。 適当なことは 「一角獣は夜に啼く」 に書いています。

GraphQL サーバー on Kotlin ことはじめ (DroidKaigi 2019 に参加して GraphQL について学んだ)

DroidKaigi 2019 にスポンサー枠で参加しました。 弊社では Android アプリエンジニアをはじめとして各分野のソフトウェアエンジニアを募集しております。 人生を豊かにするプロダクトの開発や、大きな企業でのソフトウェア開発をいかに改善していくかといったところに興味がある方はぜひお声がけください! いっしょにやっていきましょう!

f:id:nobuoka:20190210163903j:plain
DroidKaigi 2019

それはともかく DroidKaigi、様々なセッションがあって素晴らしかったですね。 運営、スピーカー、スポンサー、そして参加者の皆様、ありがとうございました。

私個人としては、GraphQL についての知見を得られたのが大きな収穫でした。

本記事について

本記事は、GraphQL サーバーを Kotlin で立てるにあたって、仕組みを学んだ軌跡を残すものです。 GraphQL 自体の初心者が、Kotlin で GraphQL サーバーを実装する際には参考になると思います。 本番環境で使える技術とかを紹介しているわけではないのでご注意ください。

DroidKaigi 2019 と GraphQL と私

GraphQL についてはこれまでも雰囲気では知っていたのですが、はてなid:takuji31 さんと id:funnelbit さんの発表で改めて GraphQL の良さを感じました。

ところで、上記の発表では主にクライアントサイド視点での GraphQL の話が主で、サーバーサイドの実装がどうなるのかが非常に気になるところです。 クエリのパースから実際のデータの取得処理まで、素朴に実装しようとすると難しそうに感じます。 懇親会のときに id:takuji31 さんと id:funnelbit さんに聞いてみたところ、「サーバーサイドの実装者ではないのでそこまで詳しくはないが、言語ごとにいい感じのライブラリがあるから、割と何とかなるっぽい」 とのことでした。

ということで GraphQL の Kotlin サーバーに詳しい人に教えを請いたい〜、と思っていたら shiraji さんが導いてくれて gfx さんや taka さんたちから知見を吸わせてもらえました。 神か! (ありがとうございました。)

知見

自分で動かして試してみる

上記のようなページを見ることでなんとなく Kotlin での GraphQL サーバーサイド実装をどうすれば良いのかが見えてきましたが、Spring Boot を使った上記の例では便利ライブラリが詳細を隠ぺいしてくれているので細かな理解ができませんでした。 というわけで自分でも手を動かして試してみます。

graphql-java を試す

graphql-java は GraphQL のスキーマやクエリのパース、バリデーション、実行といった機能を提供するものです。 HTTP のインターフェイスは提供しません。 Kotlin で GraphQL の挙動を試したい場合は、まずは graphql-java を生で触るのが良いでしょう。 下記のドキュメントを見ながら進めると良いと思います。

外部 IO 無しで簡単に試せるので、最初のとっかかりとしては非常にやりやすいと思います。 私は下記のようなコードを書いて試していました。

まだリソース間に関連がある場合などは試せていません。

HTTP インターフェイスとつなぎこむ

GraphQL のサーバー・クライアント間の通信プロトコルについては、(おそらく) 仕様上は何も定められていなさそうです。 公式サイトに、HTTP での通信についてベストプラクティスとして書かれています。

これに従ってサーバーサイドの HTTP のエンドポイントを実装し、graphql-java とつなぎこむ、という感じにすれば良さそうです。 もともとの GraphQL のクライアント・サーバー間のやり取りがシンプルなので、HTTP のエンドポイントとしての実装もシンプルになります。

細かなエラーハンドリングなどはできていませんが、Ktor で実装すると以下のような感じになりました。

GraphiQL を使ってみる

ここまでくると自分のサーバーで GraphiQL も提供したいですよね?

GraphiQL の JS ライブラリは、npm パッケージとして配信されています。

自分で HTML ファイルや CSS ファイル、JS ファイルをビルドすることもできますが、既にビルドされている JS ファイルを使って簡単に GraphiQL のエンドポイントを生成することもできます。 使い方の例は GraphiQL のリポジトリに含まれています。

上記 HTML ファイルを自分のサーバーから配信すれば、GraphiQL が提供されます。 ただし、依存するリソースである graphiql.css と graphiql.js をどうにかする必要があります。 (上記の HTML そのままの場合は、これらのファイルも同じサーバーから配信する必要があります。)

今回は手軽に試したかったので CDN で配信されているものを使うようにしました。 下記 CDN で graphiql.css と graphiql.js が配信されています。

これを使うように書き換えた HTML をサーバーから配信すると、GraphiQL を使えるようになります。


おわり

というわけで初心者が GraphQL を学んでいる軌跡でした。 ここに書かれていない知見等ありましたら是非教えてくださいませ〜〜。

CloudFront のプライベートコンテンツ配信を試した (Kotlin で signed cookie を生成する)

  • AWSCDN である CloudFront でプライベートコンテンツを配信する方法として、signed URL を用いる方法と、signed cookie を用いる方法がある。
  • 本記事では、signed cookie の生成処理を Kotlin (Java) で行う方法を説明する。

事前準備 (CloudFront 側の準備)

  • アクセスする側の挙動を確認するために、試験的に CloudFront 側も準備したのでその手順を書いておく。 (AWS コンソール上で操作を行った。)
  • 内容は次の 2 つ。

CloudFront のディストリビューションを用意

コンテンツへのアクセスを制限する

キーペアの作成と CloudFront への登録

CloudFront にキーペアを登録する方法としては、ローカルマシン上で作成して公開鍵をアップロードするか、AWS コンソール上で作成して秘密鍵をダウンロードするかのどちらかとのこと。 今回はローカルマシン上で作成した。 コマンドは上記参考ページに載っている。

# 秘密鍵の作成
openssl genrsa -out private_key.pem 4096

# 公開鍵の作成
openssl rsa -pubout -in private_key.pem -out public_key.pem

# Java で使う用に秘密鍵を DER 形式に変換
openssl pkcs8 -topk8 -nocrypt -in private_key.pem -inform PEM -out cloud_front_key.der -outform DER

アカウントメニューから 「セキュリティ認証情報」 画面に行くと 「CloudFront のキーペア」 という項目があるので、ここで公開鍵をアップロードする。 登録された鍵のアクセスキー ID と、上のコマンドで作成された DER 形式の鍵が後で必要になる。

信頼された署名者をディストリビューションに追加する

上で登録した鍵を使って signed cookie を生成してプライベートコンテンツにアクセスできるように、AWS アカウントを信頼された署名者として CloudFront のディストリビューションに登録する。

(ちなみにディストリビューションを作成した AWS アカウント以外のアカウントも登録できるらしい。 今回は自分自身を登録する。)

この段階で、signed cookie や signed url を使わないアクセスはエラーになるようになる。

Signed cookie を生成してプライベートコンテンツにアクセスする (Kotlin)

上で生成された DER 形式の秘密鍵とアクセスキーを使うことで、下記のようなコードでプライベートコンテンツにアクセスできる。 (下記では Java SE 11 から新たに追加された API (java.net.http モジュール) を使用しているので、Java SE 11 でなければ動かない。)

import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.Paths
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.time.Instant
import java.time.Period
import java.util.*

// CloudFront のドメイン名
private val cloudFrontDomainName = "xxxxx.cloudfront.net"
// AWS コンソール上に表示されている CloudFront のキーペアのアクセス ID
private const val cloudFrontKeyPairId = "xxxx"
// 自分で生成した DER 形式の秘密鍵
private const val cloudFrontKeyPairFile = "cloud_front_key.der"

fun main() {
    // ポリシーステートメントを用意。
    val endingDate = Instant.now().plus(Period.ofDays(1))
    val cloudFrontPolicyStatement = """
        {
           "Statement": [
              {
                 "Resource": "http://$cloudFrontDomainName/",
                 "Condition": {
                    "DateLessThan": { "AWS:EpochTime": ${endingDate.epochSecond} }
                 }
              }
           ]
        }
    """.trimIndent()
    // CloudFront に登録された鍵の情報を用意。
    val keyFactory = KeyFactory.getInstance("RSA")
        // すべての Java 実装で RSA がサポートされる。 Java SE 8 のドキュメントで確認。
    val cloudFrontKeySpec = PKCS8EncodedKeySpec(Files.readAllBytes(Paths.get(cloudFrontKeyPairFile)))
    val cloudFrontPrivateKey = keyFactory.generatePrivate(cloudFrontKeySpec)
    val cloudFrontKey = CloudFrontKey(cloudFrontKeyPairId, cloudFrontPrivateKey)
    // Signed cookie の値を生成。
    val cloudFrontCookieValue = CloudFrontSignedCookieValue.create(cloudFrontPolicyStatement, cloudFrontKey)

    // Signed cookie を使用してプライベートコンテンツにアクセス。
    val targetUri = URI.create("http://$cloudFrontDomainName/")
    val response = request(targetUri, HttpResponse.BodyHandlers.ofString(), cloudFrontCookieValue)
    println(response.body())
}

fun <T> request(
    uri: URI,
    responseBodyHandler: HttpResponse.BodyHandler<T>,
    cloudFrontCookieValue: CloudFrontSignedCookieValue
): HttpResponse<T> {
    val client = HttpClient.newBuilder()
        .build()
    val request = HttpRequest.newBuilder()
        .uri(uri)
        .headers(
            "Cookie", "CloudFront-Policy=${cloudFrontCookieValue.policy}",
            "Cookie", "CloudFront-Signature=${cloudFrontCookieValue.signature}",
            "Cookie", "CloudFront-Key-Pair-Id=${cloudFrontCookieValue.keyPairId}"
        )
        .build()
    return client.send(request, responseBodyHandler)
}

data class CloudFrontSignedCookieValue(
    val policy: String,
    val signature: String,
    val keyPairId: String
) {
    companion object {
        fun create(policyStatement: String, key: CloudFrontKey): CloudFrontSignedCookieValue {
            val canonicalPolicyStatement = Regex("\\s").replace(policyStatement, "")
            val encodedPolicyStatement = encodeForCookieValue(canonicalPolicyStatement.toByteArray())
            val encodedSignature = sign(canonicalPolicyStatement.toByteArray(), key.privateKey)
            return CloudFrontSignedCookieValue(encodedPolicyStatement, encodedSignature, key.keyPairId)
        }

        private fun sign(base: ByteArray, privateKey: PrivateKey): String {
            val signatureInstance = Signature.getInstance("SHA1withRSA")
                // すべての Java 実装で SHA1withRSA がサポートされる。 Java SE 8 のドキュメントで確認。
            signatureInstance.initSign(privateKey)
            signatureInstance.update(base)
            val signature = signatureInstance.sign()
            return encodeForCookieValue(signature)
        }

        private fun encodeForCookieValue(value: ByteArray) =
            Base64.getEncoder().encodeToString(value)
                .replace('+', '-')
                .replace('=', '_')
                .replace('/', '~')
    }
}

data class CloudFrontKey(
    val keyPairId: String,
    val privateKey: PrivateKey
)

AWS のドキュメントには 『エンコーダが正常に機能するように、Bouncy Castle の Java 用暗号 API の jar をプロジェクトに追加してから Bouncy Castle プロバイダを追加します。』 と書かれているが、Java SE に含まれるエンコーダだけで問題なく署名できるはず。

AWS SDK for Java

AWS SDK for JavaCloudFrontCookieSigner というクラスがあり、これを使うことで signed cookie を生成することも可能。 通常はこれを使うのが良さそう。

kotlinx.coroutines 0.26.0 でコルーチンの構造化 (structured concurrency) がやりやすくなった

この記事は Recruit Engineers Advent Calendar 2018 の 10 日目の記事です。 今日は Kotlin におけるコルーチンの構造化された並行性 (structured concurrency) やコルーチンスコープ (coroutine scope) についてです。

昨日は古川陽介さんの 『R-ISUCON Winter 2018 解説記事 | リクルートテクノロジーズ メンバーズブログ』 でした。 明日は masahiro331 さんの予定です。

前書き : コルーチンについて

Kotlin のコルーチンについては下記記事に書きましたので、参照ください。 (Kotlin 1.1 のころの情報なので少し古くなっています。)

雑に言うと 「コルーチンとは軽量のスレッド (のようなもの) である」 という理解で良いと思います。

kotlinx.coroutines 0.26.0 での並行性モデルの変更

少し前の話ですが、Kotlin 1.3 がリリースされ、コルーチンが fully stable になりました *1。 さらにその少し前、kotlinx.coroutines 0.26.0 にて並行性モデルが大きく変更されました。

私は Kotlin 1.2 のころからコルーチンを使っていたのですが、Kotlin 1.3 にマイグレーションするにあたってこの並行性モデルの変更に少しはまってしまったので、調べたことを書き残しておきます。

kotlinx.coroutines 0.26.0 よりも前

kotlinx.coroutines 0.26.0 より前は、下記のようなコードを実行すると、標準出力には 「Bar」 しか表示されず 「Foo」 は表示されませんでした。

import kotlinx.coroutines.experimental.delay
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.runBlocking

fun main(args: Array<String>) = runBlocking {
    launch {
        delay(1000)
        println("Foo")
    }
    println("Bar")
}

なぜなら、launch で起動されたコルーチンはグローバルスコープのコルーチンであり、その終了を待たずに処理が runBlocking を抜けてしまい、(アクティブなコルーチンが存在しても) プロセスが殺されてしまうからです。 ガイドでは、「グローバルスコープのコルーチンはデーモンスレッドのようなもの」 と表現されています。

Active coroutines that were launched in GlobalScope do not keep the process alive. They are like daemon threads.

kotlinx.coroutines/coroutines-guide.md at 0.26.0 · Kotlin/kotlinx.coroutines · GitHub

これを避けて、launch の処理を最後まで終わらせるためには、val job = launch { ... }; job.join() という感じで明示的にコルーチンの終了を待ったり、下記のように明示的にコルーチンコンテキストを指定して、launch で起動されるコルーチンが runBlocking のコルーチンの子になるようにするなどの必要がありました。

fun main(args: Array<String>) = runBlocking {
    launch(kotlin.coroutines.experimental.coroutineContext) {
        delay(1000)
        println("Foo")
    }
    println("Bar")
}

すなわち、明示的に記述しなければコルーチンは構造化されませんでした。 この挙動での問題として、issue では下記の例が書かれています。

suspend fun loadAndCombineImage(name1: String, name2: String): Image {
    val image1 = async { loadImage(name1) }
    val image2 = async { loadImage(name2) }
    return combineImages(image1.await(), image2.await())
}

上記のコードでは、image1.await() が例外を送出した場合に、image2 のコルーチンがキャンセルされずに放置されてしまいます。 仮に async(coroutineContext) を使ったとしても、親コルーチンが広すぎて適切ではありません。 本来は、loadAndCombineImage で一つのスコープを形成し、画像読み込み処理をそのスコープの子にする、という風にコルーチンを構造化する必要があります。

kotlinx.coroutines 0.26.0 でコルーチンを構造化しやすくなった

コルーチンの構造化をやりやすくするために、launchasync といったコルーチンビルダーが CoroutineScope の拡張関数に変更されたり、coroutineScope という関数が導入されたりしました。

例えば、下記のように書くと、launch で起動されるコルーチンは自動的に runBlocking のコルーチンの子になるので、処理は launch のコルーチンの終了を待ってから runBlocking を抜けるようになります。

fun main(args: Array<String>) = runBlocking {
    launch {
        delay(1000)
        println("Foo")
    }
    println("Bar")
}

loadAndCombineImage の例については、下記のように書くことで loadAndCombineImage の処理に対応するコルーチンスコープが形成され、例外送出時には自動的に子の画像読み込み処理がキャンセルされるようになります。

suspend fun loadAndCombineImage(name1: String, name2: String): Image = coroutineScope {
    val image1 = async { loadImage(name1) }
    val image2 = async { loadImage(name2) }
    combineImages(image1.await(), image2.await())
}

便利ですね。

グローバルスコープのコルーチンを起動したい場合には GlobalScope オブジェクトを使用します。

Kotlin 1.3 へのマイグレーション時にはまったところ

私が Kotlin 1.3 にマイグレーションするときにはまったのは、もともとグローバルスコープのコルーチンを起動していた箇所が、意図せずに別のコルーチンの子になるように変更されてしまった箇所でした。 雑な例ですが、もともと kotlinx.coroutines 0.25.0 で下記のようなコードを書いていたとします。

launch {
    val v = async { ... }
}

kotlinx.coroutines 0.26.0 以降にマイグレーションする際には、

GlobalScope.launch {
    val v = GlobalScope.async { ... }
}

という風に書き換えないといけません。 上記の例の場合、launch メソッドの方は他のコルーチンスコープに属していないためマイグレーション時にコンパイルエラーが発生するので GlobalScope のつけ忘れをすることはないのですが、上記の例の async メソッドのようにもともと他のコルーチンスコープの中で使われているコルーチンビルダーについては GlobalScope を付けなくてもコンパイルエラーにならないため、GlobalScope のつけ忘れをしてしまって意図せぬ挙動になる可能性があります。 私はこれにはまりました。

皆様もお気を付けください。

ガイド

本記事では kotlinx.coroutines 0.26.0 での変更点を主に説明しましたが、新たにコルーチンを学ぶ場合は下記のようなガイドを読むと良いと思います。

また、CoroutineScope のドキュメントも参考になります。 Android の Activity のようにライフサイクルが明確に定まっているものに CoroutineScope を紐づけることで、そのライフサイクルの中で管理したいコルーチンを扱いやすくなります。

コルーチン、いい感じに使っていきましょう。

Gradle 4.9 の新しいタスク定義 API と Gradle Kotlin DSL 1.0 RC での対応

Gradle 4.9 の新しいタスク定義 API

Gradle 4.9 で、新しいタスク定義 API が導入されました。 まだ incubating です。

blog.gradle.org

パフォーマンス向上のため、タスクの設定を遅延実行する、というのがこの新しい API の導入の目的のようです。 古い API では Task インスタンスをすぐに生成していたのを、新 API ではまず Provider<Task> を生成して、必要になった段階で Task インスタンスを生成する、という形になります。

API から新 API へのマイグレーション

API から新 API への置き換えは

  • tasks.create(...)tasks.register(...)
  • tasks.withType(SomeType) { }tasks.withType(SomeType).configureEach { }
  • tasks.all { }tasks.configureEach { }
  • tasks.getByName(...)tasks.named(...)

という感じです。

Gradle Kotlin DSL 1.0 RC での対応

ちょうど昨日 Gradle 4.10 がリリースされましたね! めでたい!

Gradle 4.10 には Gradle Kotlin DSL 1.0-RC3 が載っており、Kotlin DSL の方でも新しいタスク定義 API がサポートされています。

  • introducing the new existing and registering delegated properties designed specifically with configuration avoidance in mind which expose NamedDomainObjectProvider<T> instead of the container element type.
Release 1.0-RC3 · gradle/kotlin-dsl · GitHub

下記のように使用できます。

// 既存タスクの設定。
val test by tasks.existing(Test::class) { ... }
// 新規タスクの宣言と設定。
val jacocoMerge by tasks.registering(JacocoMerge::class) { ... }

使ってみた

早速使ってみました。

Update Gradle (version 4.10) by nobuoka · Pull Request #13 · nobuoka/wd-image-processor · GitHub

小さいプロジェクトなので特にパフォーマンスの向上などは感じられませんが、移行自体は苦労なくできます。

Gradle のマルチモジュールプロジェクトで JaCoCo の結果を集計する

Java / Kotlin のコードカバレッジツールとして JaCoCo を使いたい。 Gradle のマルチモジュールプロジェクトでの JaCoCo の導入について記す。

f:id:nobuoka:20180814004742p:plain

(この図は JaCoCo によるコードカバレッジの集計結果の履歴を Codecov で表示した例。)

JaCoCo について

JaCoCo は、JVM 言語のコードカバレッジツールである。 Java Agent によるオンザフライ方式のバイトコード instrumentation によるカバレッジ計測が可能である。

以前、Kotlin のコードカバレッジツールについて書いたので、こちらも参考にどうぞ。

Gradle プロジェクトで JaCoCo を使ったカバレッジ計測を行う

Gradle の JaCoCo プラグインを使うことで、Gradle のプロジェクトで JaCoCo を使ったコードカバレッジ計測を手軽に行うことができる。 ちなみに Gradle 4.9 の段階ではこのプラグインは incubating である。

Junit 5 のテスト実行時にカバレッジを計測する

各モジュールのテスト実行時にコードカバレッジを計測するだけなら非常に簡単で、単に JaCoCo プラグインを適用するだけで良い。

While all tasks of type Test are automatically enhanced to provide coverage information when the java plugin has been applied, any task that implements JavaForkOptions can be enhanced by the JaCoCo plugin. That is, any task that forks Java processes can be used to generate coverage information.

The JaCoCo Plugin - Gradle User Manual

バージョンを表す変数の定義や repositories の定義などは省略しているが、おおよそ以下のようなビルドスクリプトJUnit 5 でのテスト実行時に JaCoCo によるコードカバレッジの計測がなされる。

apply plugin: 'kotlin'
apply plugin: 'jacoco'

test {
    useJUnitPlatform()
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"

    testCompileOnly("org.junit.jupiter:junit-jupiter-api:$junit_version")
    testRuntime("org.junit.jupiter:junit-jupiter-engine:$junit_version")
}

デフォルトでは、カバレッジの計測結果は build/jacoco/test.exec というファイルに出力されるようである。

複数モジュールのコードカバレッジの集計

難しいのはここからで、複数モジュールでのコードカバレッジをどう集計するかで私は結構悩んだ。 JaCoCo の結果集計のためのタスクとしては JacocoMerge というのがあるのだが、これをどう定義するのかが難しい。 (どのサブモジュールのカバレッジを集計するのかを明示的に指定するのであればそれほど大変ではないが、自動で全サブモジュールのカバレッジを集計するためのタスク定義をするのが難しい。)

結論としては、ルートプロジェクトに以下のようなタスク定義を行うことで対応した。 (ルートプロジェクトにも jacoco プラグインを適用している。)

task jacocoMerge(type: JacocoMerge) {
    // ルートプロジェクトの評価時にはまだサブプロジェクトは存在しないので、
    // 各プロジェクトの評価完了時に中の処理が走るように。
    gradle.afterProject { p, state ->
        // ルートプロジェクトと `jacoco` プラグインが適用されていないプロジェクトは除く。
        if (p.rootProject != p && p.plugins.hasPlugin('jacoco')) {
            executionData p.tasks.test.jacoco.destinationFile
            dependsOn(p.tasks.test)
        }
    }
}

大雑把に説明すると、jacocoMerge タスクの executionData に各サブモジュールのテスト実行時のカバレッジ計測結果を渡していき、かつ、jacocoMerge タスクの依存に各サブモジュールの test タスクを追加する、ということをしている。

gradle.afterProject を使っている理由や if 文を使っている理由はコメントに書いてあるとおりである。

Gradle のプロジェクトの評価順については下記ページを参照されたい。

集計結果のコードカバレッジのレポーティング

さらにレポーティングのタスクは、ルートプロジェクトに下記のように定義することで行える。 java プラグインが適用されている全サブモジュールのソースファイルをコードカバレッジのレポート対象に含める場合の例である。

task jacocoMergedReport(type: JacocoReport, dependsOn: [tasks.jacocoMerge]) {
    executionData jacocoMerge.destinationFile

    sourceDirectories = files()
    classDirectories = files()
    gradle.afterProject { p, state ->
        if (p.rootProject != p && p.plugins.hasPlugin('java')) {
            sourceDirectories = sourceDirectories + files([p.sourceSets.main.allJava.srcDirs])
            classDirectories = classDirectories + p.sourceSets.main.output
        }
    }

    reports {
        xml.enabled = true
        xml.destination file("${buildDir}/reports/jacoco/report.xml")
        html.destination file("${buildDir}/reports/jacoco/html")
    }
}

本当は JacocoReport#sourceSets を使いたかったが、内部で gradle.afterProject を使われていて期待する挙動にならなかったので諦めた。

ちなみに上の xml.destination の値は Codecov が期待するレポートファイルの位置である。

サンプル

下記プロジェクトで今回書いたコードカバレッジ取得の方法を行っている。

ちなみに CircleCI でのビルド時にコードカバレッジを取得して Codecov で可視化してる。

参考