CloudFront のプライベートコンテンツ配信を試した (Kotlin で signed cookie を生成する)
- AWS の CDN である CloudFront でプライベートコンテンツを配信する方法として、signed URL を用いる方法と、signed cookie を用いる方法がある。
- 本記事では、signed cookie の生成処理を Kotlin (Java) で行う方法を説明する。
事前準備 (CloudFront 側の準備)
- アクセスする側の挙動を確認するために、試験的に CloudFront 側も準備したのでその手順を書いておく。 (AWS コンソール上で操作を行った。)
- 内容は次の 2 つ。
- CloudFront のディストリビューションを用意。
- CloudFront でコンテンツへのアクセスを制限する。
CloudFront のディストリビューションを用意
- 参考 : ディストリビューションを作成するためのステップ (概要) - Amazon CloudFront
- Delivery メソッドとして Web を選択。 (RTMP というのもあって、これはリアルタイムのストリーミングとかに使われるっぽい。 HTTP(S) での CDN として使うなら Web。)
- Origin としては適当なドメインの HTTP サーバーを指定した。
- → この段階では、新たに作成されたディストリビューションに割り当てられているドメインにアクセスすると普通にオリジンの内容が配信されることが確認できる。
コンテンツへのアクセスを制限する
キーペアの作成と 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 Java に CloudFrontCookieSigner
というクラスがあり、これを使うことで signed cookie を生成することも可能。 通常はこれを使うのが良さそう。
JAX-RS のリソースから送出された例外の扱い
JAX-RS のリソースのメソッドから例外が送出された場合の挙動についてちゃんと把握していなかったので調べた。
前提知識 : JAX-RS について
JAX-RS は、RESTful Web API を提供するための Java の API。 もともとは Java EE の一部。 現在は Eclipse Foundation に移管されて EE4J プロジェクトの一部 (Jakarta EE の一部) となっている。
JAX-RS のリソースから送出された例外の扱いについて
今回は JAX-RS 2.1 の仕様を参照する。
3.3.4 節に書かれている。
3.3.4 Exceptions
A resource method, sub-resource method or sub-resource locator may throw any checked or unchecked exception. An implementation MUST catch all exceptions and process them in the following order:
- 1. Instances of
WebApplicationException
and its subclasses MUST be mapped to a response as follows. If theresponse
property of the exception does not contain an entity and an exception mapping provider (see Section 4.4) is available forWebApplicationException
or the corresponding subclass, an implementation MUST use the provider to create a newResponse
instance, otherwise theresponse
property is used directly. The resultingResponse
instance is then processed according to Section 3.3.3.- 2. If an exception mapping provider (see Section 4.4) is available for the exception or one of its superclasses, an implementation MUST use the provider whose generic type is the nearest superclass of the exception to create a
Response
instance that is then processed according to Section 3.3.3. If the exception mapping provider throws an exception while creating aResponse
then return a server error (status code 500) response to the client.- 3. Unchecked exceptions and errors that have not been mapped MUST be re-thrown and allowed to propagate to the underlying container.
- 4. Checked exceptions and throwables that have not been mapped and cannot be thrown directly MUST be wrapped in a container-specific exception that is then thrown and allowed to propagate to the underlying container. Servlet-based implementations MUST use
ServletException
as the wrapper. JAX-WSProvider
-based implementations MUST useWebServiceException
as the wrapper.Note: Items 3 and 4 allow existing container facilities (e.g. a Servlet filter or error pages) to be used to handle the error if desired.
Exception mapping provider による変換
上記の説明の中に、「exception mapping provider」 というものが出てくる。 詳細は 4.4 節に書かれている。
4.4 Exception Mapping Providers
Exception mapping providers map a checked or runtime exception to an instance of
Response
. An exception mapping provider implements theExceptionMapper<T>
interface and may be annotated with@Provider
for automatic discovery.When a resource class or provider method throws an exception for which there is an exception mapping provider, the matching provider is used to obtain a
Response
instance. The resultingResponse
is processed as if a web resource method had returned theResponse
, see Section 3.3.3. In particular, a mappedResponse
MUST be processed using the ContainerResponse filter chain defined in Chapter 6.When choosing an exception mapping provider to map an exception, an implementation MUST use the provider whose generic type is the nearest superclass of the exception. If two or more exception providers are applicable, the one with the highest priority MUST be chosen as described in Section 4.1.3.
To avoid a potentially infinite loop, a single exception mapper must be used during the processing of a request and its corresponding response. JAX-RS implementations MUST NOT attempt to map exceptions thrown while processing a response previously mapped from an exception. Instead, this exception MUST be processed as described in steps 3 and 4 in Section 3.3.4.
すなわち、リソースメソッドなどから送出された例外を変換して Response
オブジェクトを生成する機能を提供するものである。
Exception mapping provider は複数登録することができる。 例外が送出されると、対応する exception mapping provider が選択され、Response
が生成されて、それがレスポンスとして使用される。 対応する exception mapping provider がない場合、非検査例外だとそのまま再送出されるし、検査例外だとコンテナに応じた非検査例外にラップされて送出されるとのこと。
WebApplicationException
について
基本的には上に書いた通りの例外ハンドリングがなされるが、リソースメソッドなどから送出された例外が WebApplicationException
の場合は、exception mapping provider を通らないことがある。 3.3.4 節の箇条書きの 1 つめがそれである。
WebApplicationException
の response
プロパティがエンティティを持っていない場合で、対応する exception mapping provider が存在する場合には exception mapping provider による Response
生成がなされる。 それ以外の場合は WebApplicationException
の response
プロパティの値がそのままレスポンスとして使われる。
サンプルコード
JAX-RS の参照実装である Jersey を使った Kotlin のサンプルコード。
import org.glassfish.jersey.jdkhttp.JdkHttpServerFactory import org.glassfish.jersey.server.ResourceConfig import javax.ws.rs.GET import javax.ws.rs.Path import javax.ws.rs.WebApplicationException import javax.ws.rs.core.Response import javax.ws.rs.core.UriBuilder import javax.ws.rs.ext.ExceptionMapper @Path("exceptions") class MyResource { @GET @Path("re") fun re() { // RuntimeException なので対応する MyExceptionMapper で変換される。 throw RuntimeException("RuntimeException") } @GET @Path("wae-without-entity") fun waeWithoutEntity() { // エンティティのない WebApplicationException なので MyExceptionMapper で変換される。 throw WebApplicationException("WebApplicationException with response not containing entity", Response.status(Response.Status.BAD_REQUEST).build()) } @GET @Path("wae-with-entity") fun waeWithEntity() { // エンティティを持つ WebApplicationException なので MyExceptionMapper で変換されない。 throw WebApplicationException("WebApplicationException with response containing entity", Response.status(Response.Status.BAD_REQUEST).entity("Entity").build()) } } class MyExceptionMapper : ExceptionMapper<Throwable> { override fun toResponse(exception: Throwable): Response { println(exception) return Response.status(Response.Status.BAD_REQUEST).entity("$exception").build() } } fun main() { val baseUri = UriBuilder.fromUri("http://localhost/").port(9998).build() val config = ResourceConfig(MyResource::class.java, MyExceptionMapper::class.java) JdkHttpServerFactory.createHttpServer(baseUri, config) }
ビルドコードは以下のような感じで動くはず。 (Gradle。)
plugins { id 'org.jetbrains.kotlin.jvm' version '1.3.11' } repositories { mavenCentral() } dependencies { compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8" compileOnly "javax.ws.rs:javax.ws.rs-api:2.1" compile "org.glassfish.jersey.containers:jersey-container-jdk-http:2.27" compile "org.glassfish.jersey.inject:jersey-hk2:2.27" // JDK 9 以降は下記が必要。 // See : https://stackoverflow.com/questions/43574426/how-to-resolve-java-lang-noclassdeffounderror-javax-xml-bind-jaxbexception-in-j compile "javax.xml.bind:jaxb-api:2.2.11" compile "javax.activation:activation:1.1.1" } compileKotlin { kotlinOptions.jvmTarget = "1.8" } compileTestKotlin { kotlinOptions.jvmTarget = "1.8" }
読んだ : Clean Architecture 達人に学ぶソフトウェアの構造と設計 (Robert C. Martin 著)
Robert Cecil Martin 氏、いわゆるアンクル・ボブ (ボブおじさん; Uncle Bob) による Clean Architecture についての書籍。
Clean Architecture 達人に学ぶソフトウェアの構造と設計
- 作者: Robert C.Martin,角征典,高木正弘
- 出版社/メーカー: KADOKAWA
- 発売日: 2018/07/27
- メディア: 単行本
- この商品を含むブログを見る
アンクル・ボブの Clean Architecture といえば、下記記事を読んだことがある人も多いだろう。
ここ数年の Android アプリ開発界隈でも Clean Architecture はよく話題に上がっているように思う。 私自身も web 上で Clean Architecture について調べて学んだりしてきた。
しかし、web 上の情報を追いかけるだけだと、レイヤ分けや依存関係のルールはわかっても、より本質的な思想の部分まではなかなか理解できなかった。 例えば、「クリーンアーキテクチャにおいてサーバーとクライアントの構成にする場合にどういう扱いにするのが妥当なのか?」 というのは長年考えてきてなかなか自分の中で納得した答えは出せなかったもののひとつである。
本書を読むことで、Clean Architecture の本質的な思想についても深く理解できた。 また、そもそもの 「アーキテクチャとは何か?」 といった部分まで考えることができた。 ソフトウェアアーキテクチャについてより深い洞察を得たい人におすすめの一冊である。
本書の流れ
本書は、「設計とアーキテクチャは地続きのものであり、本質的な違いはない」 というところから始まる。 そして、構造化プログラミングや関数型プログラミングといったコーディングのパラダイムが説明される。 その後に設計の原則 (SOLID 原則)、コンポーネントの原則 (REP、CCP、CRP) についての説明がある。 ここまでが本書の前半である。
本書の後半では、前半の話を踏まえて、アーキテクチャの説明に入っていく。 アーキテクチャの話だけ読みたい人もいるだろうが、個人的には SOLID 原則や REP、CCP、CRP といった部分についても改めて学ぶことができて非常に良かった。
学びや感想
設計とアーキテクチャ、その目的について
1 章で、「設計とアーキテクチャについて、本質的には違いはない」 ということが言われる。 通常、アーキテクチャは下位レベルの詳細とは切り離された文脈で使用され、設計は下位レベルの構造や意思決定を意味しているが、実際のところ下位レベルの詳細と上位レベルの構造が全体の設計の一部になる。 そうした連続した構造がシステムの形状を定義するので、設計とアーキテクチャを明確に区別することはできない。 上位レベルから下位レベルまで決定の連続であり、その目的は下記の通りである。
ソフトウェアアーキテクチャの目的は、求められるシステムを構築・保守するために必要な人材を最小限に抑えることである。
ソフトウェアの振る舞いが正しいものであることを重視し、アーキテクチャを軽視すると、開発を続けていくうちに生産性がどんどん低くなっていく。 「振る舞いが正しいこと」 と 「変更しやすいこと (良いアーキテクチャ・設計であること)」 のどちらが重要であるかという質問に対して、ビジネスマネージャは、「振る舞いが正しいこと」 であると答えることが多く、開発者もそれに賛同することが多い。 しかし、下記のように考えると、より重要なものは 「アーキテクチャ」 である。
- 振る舞いが正しく、変更しづらい (アーキテクチャ・設計が良くない) プログラムは、今は正しくとも将来的な要件の変更に追従できなくなり、やがて役に立たなくなる。
- 振る舞いが誤っており、変更しやすい (アーキテクチャ・設計が良い) プログラムは、今の正しくない振る舞いを正しく修正することも容易であり、将来的な要件の変更にも追従しやすく、長く価値を保つ。
開発者以外に対してアーキテクチャ・設計の重要性を説明することは非常に難しいと思っていたが、上記の観点で説明すると結構説明しやすい気がした。
優れたソフトウェア開発チームは、真正面から闘争に立ち向かう。 ステークホルダーたちと対等に、ひるむことなく口論する。 ソフトウェア開発者もステークホルダーであることは忘れてはいけない。 保護すべきソフトウェアに対する責任がある。 それが、あなたの役割であり、義務である。 それが、あなたが雇われている大きな理由だ。
『開発者はスクラッチからシステム全体を再設計することが答えだと考えているかもしれないが、それもまたウサギのやり方だ。 崩壊をもたらした自信過剰が、今度は競走をやり直せばもっとうまく構築できるという話に変わっている。 現実はそれほどうまくはいかない。』 せやぞ#書籍 #CleanArchitecture
— Nobuoka Yu (@nobuoka) 2018年10月15日
ソフトウェアの 1 つ目の価値は 『ふるまい』 で、2 つ目の価値は 『アーキテクチャ』。 ふるまいは緊急ではあるが重要ではない。 アーキテクチャは緊急ではないが重要。 後者の方が優先度は高いが、前者の方が優先度が高いと勘違いしてしまうことが多い。 なるほど。#書籍 #CleanArchitecture
— Nobuoka Yu (@nobuoka) 2018年10月15日
テストと反証可能性について
Dijkstra は 「テストはバグが存在しないことではなく、バグが存在することを示すものである」 と述べた。 つまり、テストによってプログラムが正しくないことは証明できるが、プログラムが正しいことは証明できないのである。 テストに十分な労力をかけていれば、そのプログラムは目的のために十分に真であるとみなせる。
この事実は、驚くべきことを示している。ソフトウェア開発は、数学的な構成要素を操作しているかのように思えるかもしれないが、実際には数学的な試みではない。 むしろソフトウェア開発は科学のようなものである。 どれだけ最善を尽くしても正しくないことを証明できないことによって、その正しさを明らかにしているのである。
数学的なアプローチか科学的なアプローチか、という観点は持っていなかったので、「なるほどー」 と思った。
最小の機能から最大のコンポーネントまで、あらゆるレベルにおいて、ソフトウェアは科学のように、反証可能性によって動かされている。ソフトウェアアーキテクトは、簡単に反証できる(テスト可能な)モジュール、コンポーネント、サービスを定義しようとする。そのために、さらに上位のレベルにおいて、構造化プログラミングのような制限を課している。
だからこそ、小さな粒度から大きな粒度まで、あらゆるレベルで反証可能な形で部品化することが重要。 TDD の利点って、この 「反証可能な形で部品化する」 ということをサポートしてくれるからなんだろうな。 『TDD で綺麗な設計に近づける』 と漠然と思っていたけど、すごく納得した
— Nobuoka Yu (@nobuoka) 2018年10月15日
単一責任の原則
SOLID 原則の一つである単一責任の原則 (SRP) について、「たった一つのことだけ行うべき」 だと思っていたけど、正しくは 「モジュールが責務を負うべきアクター (ソフトウェアの変更を望む人たちのグループ) がひとつだけになるようにすべき」 ということだった。 アクターをどう定義するかが難しい気もするけど、アクターの定義をどうするかを含めてしっかり考えていく必要があるんだろうなぁと思う。
複数のアクターが使用するコードが存在すると、あるアクターのための変更が他のアクターに想定外の影響を及ぼしてしまう、ということ。 SRP を完全に理解した。
— Nobuoka Yu (@nobuoka) 2018年10月15日
コンポーネントの原則
再利用・リリース等価の原則 (REP)、閉鎖性共通の原則 (CCP)、全再利用の原則 (CRP)、初めて聞いたな。 コンポーネントの凝集性に関する原則。 再利用性、保守性、それからコンポーネントの変更による他コンポーネントへの影響を小さく保つことの 3 つのバランスをどうとるか#書籍 #CleanArchitecture
— Nobuoka Yu (@nobuoka) 2018年10月16日
『安定度・抽象度等価の原則 (SAP) : コンポーネントの抽象度は、その安定度と同程度でなければいけない。』
— Nobuoka Yu (@nobuoka) 2018年10月16日
抽象的で安定しているか、具体的で不安定かのどちらかがよい。 コンポーネント版の DIP のためにこれが必要。#書籍 #CleanArchitecture
アーキテクチャについて
アーキテクチャについては、下記の内容が非常に刺さったり共感するものだった。
- アーキテクチャはユースケースを叫ぶものでなければならない。 どういう技術を採用するかということに意識がいきがちだけど、重要なのはユースケースであって、どういう技術を採用するかではない。
- アーキテクトとしては詳細の決定はできるだけ遅らせるようにすること。 DB に何を採用するのかとか、通信プロトコルに何を採用するのかなど、そういったものは技術的な実装詳細であり、優れたアーキテクチャはそれらの決定を遅らせられるものである。
- 入出力はテストしづらい部分なので、依存関係の一番外側に持ってくること。
- フレームワークへの依存は避けるべき。 フレームワークは実装詳細であり、アーキテクチャとして重要なものではない。
つまり、冒頭で述べた私のこれまでの疑問の話で言うと、(クライアントとサーバーの両方で一つのソフトウェアなのであれば) クライアント・サーバー間の通信なども詳細であり、アーキテクチャとしてはクライアントもサーバーも含めてユースケースを考える、というのが優れたアーキテクトとしての姿勢なのだと感じた。
ツイートメモ
『アーキテクチャの主な目的は (中略) システムのライフタイムコストを最小限に抑え、プログラマの生産性を最大にすることである。』
— Nobuoka Yu (@nobuoka) 2018年10月16日
アーキテクチャと技術的負債は対応関係にありそう。 『エンジニアリング組織論への招待』 でもそう書かれてた気がする#書籍 #CleanArchitecture
『レイヤーやユースケースを切り離す方法はいくつもある。 ソースコード (ソース) レベル、バイナリコード (デプロイ) レベル、実行単位 (サービス) レベルで切り離すことができる。』 『システムの切り離し方式は時間とともに変化する可能性』 選択肢を残すことが大事#書籍 #CleanArchitecture
— Nobuoka Yu (@nobuoka) 2018年10月16日
ここで言われる保守性を高めることこそがアーキテクチャでありアーキテクトの仕事であろうなー。 その仕事には基本設計も含まれるんだけど、詳細設計やコーディングをできない人がアーキテクチャを考えることは難しい。
— Nobuoka Yu (@nobuoka) 2018年10月18日
https://t.co/of3FgtGEV8
もっと戦略的に事業の展開を予測しながらアーキテクチャを考えていくということをしていく必要性を感じているけどその水準に全然達していない
— Nobuoka Yu (@nobuoka) 2018年10月18日
『優れたソフトウェアアーキテクチャがあれば、フレームワーク、データベース、ウェブサーバー、その他の環境の問題やツールの意思決定を延期・留保できる。 フレームワークの選択肢は残されたままだ。』
— Nobuoka Yu (@nobuoka) 2018年10月22日
いいこと言ってる。 アーキテクチャはユースケースをサポートする。 #書籍 #CleanArchitecture
生まれてから一度もフレームワークに依存したいと思ったことはないんだけど、わりとフレームワークに依存してアプリケーションを書きたがる人が多いように思う。
— Nobuoka Yu (@nobuoka) 2018年10月22日
『システムアーキテクチャがユースケースをサポートするものであり、フレームワークから少し距離を置いたものになっていれば (中略) テストを実行するためにウェブサーバーを起動する必要はない。 テストを実行するためにデータベースに接続する必要はない』
— Nobuoka Yu (@nobuoka) 2018年10月22日
早くここにたどり着きたい
Humble Object パターンって初めて聞いた。 (みんなやってることではあるけれど。)
— Nobuoka Yu (@nobuoka) 2018年10月23日
描画処理などのテストしづらい処理をテストしやすい処理を担うクラスとテストしづらい処理を担うクラスに分けて、後者を控えめ (humble) にしておく、というもの。 Presenter と View とか #書籍 #CleanArchitecture
『ORM システムはどこに属するのだろうか? もちろんデータベースのレイヤーだ。実際、ORM はゲートウェイインターフェイスとデータベースの間に Humble Object の境界を作るものである』
— Nobuoka Yu (@nobuoka) 2018年10月23日
JPA とかはこの思想にそぐわなくてあんま好きになれないんだよなー。 #書籍 #CleanArchitecture
『作者にとって、フレームワークとの結合には何のリスクもない。』 『さらに作者は、あなたに対してもフレームワークとの結合を望んでいる。 (中略) 作者にとって、自分の作った基底クラスを大勢のユーザーが継承することほど、自尊心が満たされることはない』
— Nobuoka Yu (@nobuoka) 2018年10月26日
激しい#書籍 #CleanArchitecture
フレームワークなんかと結婚するな!
— Nobuoka Yu (@nobuoka) 2018年10月26日
#書籍 #CleanArchitecture
クラス・インターフェイスごとには同じような依存関係を持たせるにしてもどこでコンポーネントを切るかでいろんなパターンになるという話。 わかりやすい図だ。 #書籍 #CleanArchitecture pic.twitter.com/M9veKX5eZM
— Nobuoka Yu (@nobuoka) 2018年11月3日
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 でコルーチンを構造化しやすくなった
コルーチンの構造化をやりやすくするために、launch
や async
といったコルーチンビルダーが 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
を紐づけることで、そのライフサイクルの中で管理したいコルーチンを扱いやすくなります。
コルーチン、いい感じに使っていきましょう。
Maven の Surefire プラグインと JMockit と JaCoCo プラグイン
ビルドツールとして Maven を使っている Java プロジェクトで JMockit と JaCoCo を使いたいときの話。 ユニットテストの実行には Maven Surefire プラグインを使用しているものとする。 また、JaCoCo の適用には JaCoCo Maven プラグインを使用するものとする。
JMockit と JaCoCo の -javaagent
指定を共存させる
JMockit も JaCoCo も、Java プログラミング言語エージェントの仕組みを利用している *1。 なので、どちらも基本的には -javaagent
の指定が必要である。
JMockit の -javaagent
指定
JMockit の方は、Surefire プラグインの argLine
パラメータで -javaagent
を指定する。
<plugins> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>2.21.0</version> <!-- or some other version --> <configuration> <argLine> -javaagent:${settings.localRepository}/org/jmockit/jmockit/${jmockit.version}/jmockit-${jmockit.version}.jar </argLine> <!-- ... --> </configuration> <plugin> <!-- ... --> <plugins>JMockit - Tutorial - Introduction
JaCoCo の -javaagent
指定
JaCoCo については jacoco:prepare-agent
ゴールにて argLine
プロパティ *2 に -javaagent
が追加される仕組みになっている。
Surefire プラグインに argLine
パラメータが指定されていないときには argLine
プロパティの値が使われるので、Surefire プラグインに argLine
パラメータが指定されていない場合にはそれ以上の設定は必要ない。
一方で、Surefire プラグインに argLine
パラメータの指定がされている場合は (そのままでは) JaCoCo プラグインが設定した argLine
プロパティの値が使われないため、別途設定が必要となる。 具体的には、Surefire プラグインの late property evaluation (late replacement) を使用する。 これは、プロパティ参照の ${...}
の代わりに @{...}
を使うというもの。 ${...}
を使った場合はプラグインの実行前に評価されるため JaCoCo プラグインが argLine
プロパティを変更してもそれが反映されないが、@{...}
を使うとプラグインの実行後の値が反映される。
One of the ways to do this in case of maven-surefire-plugin - is to use syntax for late property evaluation:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <configuration> <argLine>@{argLine} -your -extra -arguments</argLine> </configuration> </plugin>
IntelliJ IDEA での問題
さて、Maven でビルドを実行する際に JMockit と JaCoCo を共存させるだけならここまでで解決するが、さらにややこしいのが IntelliJ IDEA の存在である。
IntelliJ IDEA で Maven プロジェクトを扱う場合、デフォルトでは JUnit 実行時に Surefire プラグインの argLine
パラメータを読み取るのだが、IntelliJ IDEA は late replacement (@{...}
) に対応していない。 そのため、下記のようなエラーが発生してしまう。
エラー: メイン・クラス@{argLine}が見つからなかったかロードできませんでした
- IntelliJ IDEA が
@{...}
を解釈しない件 : https://youtrack.jetbrains.com/issue/IDEA-143187
回避策の一つは、IntelliJ IDEA でのユニットテスト実行時に Surefire プラグインの argLine
パラメータを参照する設定を無効にすること。 設定の中の 「Maven」 の 「Running Tests」 で Surefire プラグインの argLine
を参照するかどうか指定できる。
私が採った回避策は、argLine
パラメータには空文字列として宣言したプロパティを含めておき、JaCoCo を有効にしたいときだけコマンドライン引数で @{argLine}
を埋め込むというもの。
<properties> <!-- Surefire プラグインの `argLine` に含める文字列。 JaCoCo の Java Agent を有効にするためには `@{argLine}` を指定すること。 IntelliJ IDEA が `@{...}` による late replacement に対応しておらず、直接 `@{argLine}` を指定すると IntelliJ IDEA でのテスト実行時にエラーが発生してしまうので、`@{argLine}` をコマンドラインオプションで指定できるように このようなプロパティを用意した。 See : http://maven.apache.org/surefire/maven-surefire-plugin/test-mojo.html (Surefire Plugin の `argLine` の説明) See : https://www.eclemma.org/jacoco/trunk/doc/prepare-agent-mojo.html (JaCoCo の Java Agent の指定について) See : https://youtrack.jetbrains.com/issue/IDEA-143187 (IntelliJ IDEA が argLine の `@{...}` を解釈しない件) --> <surefireArgLine/> </properties> <!-- (中略) --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>2.19.1</version> <configuration> <argLine> ${surefireArgLine} <!-- See : http://jmockit.github.io/tutorial/Introduction.html#runningTests --> -javaagent:${settings.localRepository}/org/jmockit/jmockit/${jmockit.version}/jmockit-${jmockit.version}.jar </argLine>
上のようにしておけば IntelliJ IDEA でのテスト実行にエラーが出ることはない。 そして Maven をコマンドラインから動かすときには、下記のように指定すれば JaCoCo が有効になる。
mvn package -D surefireArgLine="@{argLine}"
もっといい方法があれば知りたい……。
参考
- Java プログラミング言語エージェントについて : java.lang.instrument (Java SE 10 & JDK 10 )