仕様変更が想定される場合の Fizz Buzz のドメインモデリングについて
前の記事 「Fizz Buzz と税率とタイムゾーンの話 (ドメインレイヤとアプリケーションレイヤの話、あるいは時間変化する値をモデリングする話)」 でもちょっと言及した下記のついーと。
これはプロダクトの文脈(FizzBuzzをどんな用途で使うか)によるかと。
— フェイ=サン@Y!の人 (@fei_kome) 2019年2月15日
その文脈上で不変性が高いならばEntityやValueObjに、低いならばUseCaseかなと。
例えば今後「4の倍数ならhuzzが返る」的な仕様追加が容易に想像できるならばUseCaseにしますねー。
変化しやすい箇所を依存関係の外側に定義するというのはクリーンアーキテクチャの教えとして正しいのだけど、ドメインやユースケースを捉えるという意味では「仕様変更されやすそうならユースケース」 というのは本質的ではなく、その点については賛成できない。
一方で「仕様変更されやすそうなときはどうするの?」 というのは良い問題提起だなーと感じた。 というわけで 『今後 「4 の倍数なら huzz が返る」 的な仕様追加が容易に想像できる』 場合に自分だったらどうするか考えてみる。
自分だったらどうするか?
想定される変化に今のドメインモデルでは対応できない、ということは、ドメインモデル上で本来変更されやすくなっているべき箇所 *1 が変更されやすくなっていないということだ。 おそらくドメインモデルに改善の余地があると思われるので、ドメインを捉えなおしてドメインモデリングをやり直したい。
「4 の倍数なら huzz が返る」 的な仕様追加が容易に想像できる、という感じであれば、Fizz Buzz 問題を下記のように捉えなおすことができそうである。
条件と文字列の組 (「設定項目」 と呼ぶ) が複数与えられる。 数字を数え上げていき、その数字がいずれかの設定項目の条件に合致する場合はその設定項目の文字列を表示すること。 合致するものがない場合はその数字をそのまま表示すること。 ただし、合致する条件を持つ設定項目が複数ある場合は、それらの文字列をすべて連結して表示すること。
ここで、設定項目は次のとおりである。
- 条件 : 数値が 3 の倍数; 文字列 : Fizz
- 条件 : 数値が 5 の倍数; 文字列 : Buzz
問題を上のように捉えなおすことでドメインモデルも変化する。 以下のような感じである。
class FizzBuzzConfigItem( /** この条件が真になる場合に [expression] が出力に含まれる。 */ val predicate: (Int) -> Boolean, /** 条件が真の場合に出力される文字列。 */ val expression: String ) class FizzBuzzCalculator(private val config: List<FizzBuzzConfigItem>) { fun calculate(inputNumber: Int): String = run { val expressions = config.filter { it.predicate(inputNumber) } .map(FizzBuzzConfigItem::expression) if (expressions.isEmpty()) { inputNumber.toString() } else { expressions.joinToString("") } } }
これで通常の Fizz Buzz に対応することも 「4 の倍数なら Huzz」 という仕様を追加することも容易になった。
// 3 の倍数なら Fizz、5 の倍数なら Buzz を返す。 val fizzBuzzCalculator = FizzBuzzCalculator(listOf( FizzBuzzConfigItem({ it % 3 == 0 }, "Fizz"), FizzBuzzConfigItem({ it % 5 == 0 }, "Buzz") )) // 3 の倍数なら Fizz、4 の倍数なら Huzz、5 の倍数なら Buzz を返す。 val fizzHuzzBuzzCalculator = FizzBuzzCalculator(listOf( FizzBuzzConfigItem({ it % 3 == 0 }, "Fizz"), FizzBuzzConfigItem({ it % 4 == 0 }, "Huzz"), FizzBuzzConfigItem({ it % 5 == 0 }, "Buzz") ))
「3 の倍数なら Fizz」 というような設定項目をどこに定義するかは難しいところだが、カスタマイズ性がそこまで高くなくて良いなら (仕様変更がたまにであれば) ドメイン層の中にべた書きしてしまって良いだろう。 もしユースケースごとに異なるのであればユースケース層に定義すると良いし、ユーザーごとに変更する必要があるならそもそもコード上に定義するのではなくてストレージに保存することになるだろう。
コード全体は fizzbuzz_configurable.kt においてある。
ここまで書いたけど
ドメインレイヤの API が変わらないなら別に実装が変わっても困らないので、必ずしも上のようにカスタマイズ性を高める必要はない気もする。 それよりも、Fizz Buzz をさらに抽象的にして 「数字を数え上げて、文字列を出力するゲーム」 みたいに捉えて、ドメインモデルとしては数字から文字列の変換のインターフェイスと実装を別に提供して切り替えやすくする、みたいな感じにしても良さそう。
(追記) ここでは事前に仕様変更について書いていたが、「2019-02-16 UseCase とは何か | wada811.com」 に書かれている通り、問題領域に変更が入ってからドメインモデリングしなおすというのでも全然問題ないと思う。
実際の開発におけるドメインモデルの変更
実際の開発においてもドメインモデルの再構築が必要になることはままある。 ドメインモデルの再構築が必要だと気付くのは大体開発の途中なので、再構築するのはちょっと大変だったりする。 だけど場当たり的な対応をするとどんどん崩壊していく (繰り返すうちにどんどん変更が難しくなっていく) ので、必要になったらしっかりドメインモデルの再構築をしていく方が良いと思う。 きれいなドメインモデルを保っていれば変更もそこまで苦ではないはず。
ちなみに前の記事で時刻によって状態が変化する場合のドメインモデリングについて書いたが、これは今開発している製品でもともと時刻に応じた状態を持っていなかったところに時刻に応じた状態を持たせるというドメインモデルの変更を行う必要があって、そのときにいろいろ考えたときのことをベースに書いた。 (具体的に言うと、人と組織の所属関係としてもともと 「所属していない」 「所属している」 「所属していた」 という状態しかもっていなかったところに、本来は 「所属期間」 という概念が必要だったということがわかってドメインモデルを変更した。) そのときはもともとのドメインレイヤの設計がめちゃくちゃだったのですごく苦労したのだけど、きれいなドメインモデルを作って実装に落とし込めていれば多分そこまで苦労せずにドメインモデルを変更できたはず。
Fizz Buzz と税率とタイムゾーンの話 (ドメインレイヤとアプリケーションレイヤの話、あるいは時間変化する値をモデリングする話)
Twitter で見かけて面白そうだったのでちょっと考えてみた。
関連記事
Fizz Buzz のロジックをドメインレイヤ (Entities 層) とアプリケーションレイヤ (Use Case 層) のどちらに書くか
UseCase がわからない...
— Yuki Anzai (@yanzm) 2019年2月15日
FizzBuzz で
「3の倍数のときは fizz が返る」
「5の倍数のときは buzz が返る」
「3の倍数かつ5の倍数のときは fizzbuzz が返る」
「3の倍数でも5の倍数でもないときはそのままの数字が返る」
これは
(実際に作るアプリケーションにおいてはいろいろ考慮して設計することになると思うけど) Fizz Buzz の問題を素直に捉えると 「3 の倍数のときは fizz が返る」 というのは問題領域に存在するロジックなので (例えば、ソフトウェアを実装せずに人が手で Fizz Buzz を書きだす場合でもこのロジックは使われる)、自分だったらドメイン層に書くなーと思った。
(文字列を返すドメインサービスにするのか、バリューオブジェクトを定義するのか、みたいなところは場合によって分かれそう。)
アプリケーション層 (Use Case 層) にはアプリケーションとしてどう使われるかに応じたロジックを書く。 例えば 「1 から指定された値までの Fizz Buzz をコンソールに表示する」 だったらコンソールにアウトプットするための処理を書く *1 し、例えば 「1 秒ごとに Epoch 秒に応じた Fizz Buzz の値をどこかに送る」 みたいなアプリケーションだったら Epoch 秒を取得してどこかに送るみたいな処理を書くことになる。 (個人の見解。)
- 書いてみた : fizzbuzz.kt · GitHub
『Clean Architecture』 によると
Clean Architecture 達人に学ぶソフトウェアの構造と設計 (アスキードワンゴ)
- 作者: RobertC.Martin,角征典,高木正弘
- 出版社/メーカー: ドワンゴ
- 発売日: 2018/08/01
- メディア: Kindle版
- この商品を含むブログを見る
Uncle Bob によって書かれた書籍にはエンティティについて下記のように書かれている。
エンティティは、企業全体の最重要ビジネスルールをカプセル化したものだ。エンティティは、メソッドを持ったオブジェクトでも、データ構造と関数でも構わない。企業にあるさまざまなアプリケーションから使用できるなら、エンティティは何であっても問題はない。
企業が存在せず、単一のアプリケーションを作成しているだけなら、エンティティはアプリケーションのビジネスオブジェクトになるだろう。それは、最も一般的で、最上位レベルのルールをカプセル化したものである。
ユースケースについては下記。
ユースケースのレイヤーのソフトウェアには、アプリケーション固有のビジネスルールが含まれている。ここには、システムのすべてのユースケースがカプセル化・実装されている。ユースケースは、エンティティに入出力するデータの流れを調整し、ユースケースの目標を達成できるように、エンティティに最重要ビジネスルールを使用するように指示を出す。
これに照らしてみても、Fizz Buzz の数値から文字列を生成するロジックは問題の最上位レベルのルールなのでエンティティで、入出力回りなどをユースケースに記述する、って感じになりそう。
税率
という Fizz Buzz だけの話だったらブログに書くほどのものでもないのだけど、税率の話 (というか時間変化する状態のモデリング) については最近考えていたこともあったので吐き出しておく。
税込価格を計算する処理ってどっちだと思いますか?来年変更されそうですけど...
— Yuki Anzai (@yanzm) 2019年2月15日
(税率とは関係ないけど、*2 上のついーとのリプ先に 『仕様追加が容易に想像できるならばUseCaseにします』 って書かれてるのは本質的ではない話で、ちゃんと問題領域をとらえてドメイン層を定義すべきでしょ、って思った。)
この話って、抽象的には 「時間変化する状態をアプリケーション上でどう扱うか」 という話 (ではなかった)*3 で、時刻が変化したときに状態がどう変化するか (例えば 2019 年 10 月までは税率 8 % で、2019 年 10 月からは税率 10 % になる) という部分も含めてドメインモデリングするのがうまい方法なんだと思う *4。
つまり、ドメイン層には 「指定の商品について、指定された時刻における税込み価格を計算する」 みたいなロジックを定義して、アプリケーション層の方でそのロジックに現在時刻を渡してやることで、その時点での税込み価格を計算する、みたいな感じになる。
時間変化のモデリングの例 : Date and Time API におけるオフセット時間の時間変化
時刻によって変化する値のモデリングの例として Java の Date and Time API (JSR 310) がある。 Date and Time API は 「時刻やタイムゾーン」 を問題領域とする API であり、時刻についてのドメインモデルとして捉えて良いだろう。
このドメインにおける 「時間変化する値」 としてはタイムゾーンごとのオフセット時間がある。 タイムゾーンごとのオフセット時間は時刻によって変化するが、それがうまくモデリングされて、きれいな API が提供されている。
最近あった身近なオフセット時間の変化としては、平壌時間における 2015 年と 2018 年のオフセット時間の変化がある。
協定世界時 (UTC) より9時間進んでおり (UTC+9)、大韓民国(韓国)で使用されている韓国標準時 (KST) や、日本で使用されている日本標準時 (JST) と同じである。2015年8月15日から2018年5月4日まではUTC+8:30と定められていた。
平壌時間 - Wikipedia
こういうオフセット時間の時間変化も Date and Time API の実装が面倒を見てくれるので、API を使う側はオフセット時間の時間変化について知らずとも任意の時刻の指定のタイムゾーンでの現地時刻を取得することができる。
/** 渡された時刻を平壌時間での [LocalDateTime] にして返す。 */ fun calculatePyongyangLocalDateTime(instant: Instant) = instant.atZone(ZoneId.of("Asia/Pyongyang")).toLocalDateTime() fun main() { val pyongyang2015 = calculatePyongyangLocalDateTime(Instant.parse("2015-01-01T12:00:00Z")) val pyongyang2017 = calculatePyongyangLocalDateTime(Instant.parse("2017-01-01T12:00:00Z")) val pyongyang2019 = calculatePyongyangLocalDateTime(Instant.parse("2019-01-01T12:00:00Z")) println(pyongyang2015) //=> 2015-01-01T21:00 println(pyongyang2017) //=> 2017-01-01T20:30 println(pyongyang2019) //=> 2019-01-01T21:00 }
(問題領域に税率を含み、時間の概念も持つアプリケーションを開発するのであれば) Date and Time API におけるタイムゾーンと同様に、税率を時間変化する値としてドメインモデル上で定義すると、ドメインモデルを使う側からはいい感じに税率の時間変化が隠蔽されて良い感じになるのではなかろうか。
ドメインとかユースケースとか
最近 「ドメイン」 とか 「ユースケース」 って言葉を聞くことが多いのだけど、人によって解釈が全然違ったりするなー、ということをよく感じる。 そもそも明確な定義みたいなのもないと思うのだけど、個人的には下記の書籍を読んだことで理解が進んだので、「ドメイン」 とか 「ユースケース」 についてよくわかんないけどなんか書籍を読みたい、って人は下記みたいな書籍を読むと良さそう。 (私は DDD に関する本はいろいろ読んだけどユースケースについて言及している書籍は下記の 1 冊しか読んだことがないので、他におすすめがあれば教えてください!)
- 作者: ダグ・ローゼンバーグ,マット・ステファン
- 出版社/メーカー: 翔泳社
- 発売日: 2016/01/28
- メディア: Kindle版
- この商品を含むブログ (1件) を見る
- 読書感想文も書きました : 読んだ : ユースケース駆動開発実践ガイド - ひだまりソケットは壊れない
この書籍では、要件定義からアプリケーションの実装までの開発のライフサイクルを扱っている。 まずアプリケーションが扱う問題領域から重要な言葉を抜き出してドメイン辞書を作る。 それからユースケース (アクター *5 とシステムの相互作用についての記述) を記述する。 ドメイン辞書は静的な設計の出発点で、ユースケースは動的な設計の出発点。 ここから初めて実装に落とし込むまでの流れを説明する、という感じ。
で、この書籍で説明されているユースケースと Clean Architecture でいうユースケース層の関係としては、ユースケース記述に書かれている文章と同じぐらいの粒度でユースケース層のメソッドを実装する、って感じになる。
個人的な解釈ですが、これはEntityのロジック(ドメイン知識で)、UseCaseは「FizzBuzzの入力値に対して出力を得る」みたいな感じで理解してます。
— 松岡@DDDブログ書いてます (@little_hand_s) 2019年2月16日
ユースケース図の吹き出しで出てくるぐらいの粒度のものがその名の通り1UseCaseというイメージです。 https://t.co/a8AB9z5gap
なので、単純なユースケースについては 『ユースケース図の吹き出しで出てくるぐらいの粒度のものがその名の通り1UseCaseというイメージ』 となるはず *6。 実際のところは、画面をまたがるユースケースがあったり、イベント駆動な設計になっている場合もあったりするので、1 ユースケースがユースケース層の 1 メソッドや 1 クラスに相当するということはなくて、複数メソッドに分散したりするはず。
*1:実際のコンソールへのアウトプットを行う処理自体はさらに上のレイヤから渡すべきで、アプリケーション層では抽象化された出力操作を行う。
*2:下の注釈に書いたとおり、関係あるということに気づいた。
*3:Fizz Buzz の話の流れで書かれていたので、この文を書いていた時には 「Fizz Buzz の入力数字を時刻、出力の文字列を税率と見立てて、税率の時間変化をどう扱うか」 という問題提起なのかなーと思ったんだけど、今読み返すと 「仕様追加が容易に想像できるならばUseCaseにします」 への応答だったんだな、と思った。 文脈が読めてなかった……><
*4:もちろんアプリケーションに求められるものによって税率などをどこで扱うのがいいかは違ってくるとは思っていて、例えば 「表示時にシュッと税込み価格を計算して表示すればいいだけで、ドメインロジックとして税率を扱う必要はない」 みたいなアプリケーションだったらプレゼンテーション層に税率計算の処理を入れちゃってもいい話だし。
*5:システムを使う人やモノ。 ユーザー。
GraphQL サーバー on Kotlin ことはじめ (DroidKaigi 2019 に参加して GraphQL について学んだ)
DroidKaigi 2019 にスポンサー枠で参加しました。 弊社では Android アプリエンジニアをはじめとして各分野のソフトウェアエンジニアを募集しております。 人生を豊かにするプロダクトの開発や、大きな企業でのソフトウェア開発をいかに改善していくかといったところに興味がある方はぜひお声がけください! いっしょにやっていきましょう!
それはともかく 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 さんたちから知見を吸わせてもらえました。 神か! (ありがとうございました。)
知見
- GraphQL をするならまずは gfx さんのこの記事を見ろ! : 「GraphQL」徹底入門 ─ RESTとの比較、API・フロント双方の実装から学ぶ - エンジニアHub|若手Webエンジニアのキャリアを考える!
- GraphiQL というブラウザ上で動く GraphQL IDE が存在する : graphiql - npm
- JVM 系言語における GraphQL の実装は graphql-java が一番良さそう。
- Spring Boot で graphql-java を使った実装のサンプルは Ubie 社 (shiraji さん) が公開しているぞ! : GitHub - ubie-inc/kotlin-graphql-sample: Sample implementation of Kotlin+Spring+GraphQL
- graphql-java を Spring Boot で使うときの便利ライブラリの依存関係は taka さんがまとめてくれている : コードで見る graphql-java 関連ライブラリの関係性 - Qiita
自分で動かして試してみる
上記のようなページを見ることでなんとなく Kotlin での GraphQL サーバーサイド実装をどうすれば良いのかが見えてきましたが、Spring Boot を使った上記の例では便利ライブラリが詳細を隠ぺいしてくれているので細かな理解ができませんでした。 というわけで自分でも手を動かして試してみます。
graphql-java を試す
graphql-java は GraphQL のスキーマやクエリのパース、バリデーション、実行といった機能を提供するものです。 HTTP のインターフェイスは提供しません。 Kotlin で GraphQL の挙動を試したい場合は、まずは graphql-java を生で触るのが良いでしょう。 下記のドキュメントを見ながら進めると良いと思います。
外部 IO 無しで簡単に試せるので、最初のとっかかりとしては非常にやりやすいと思います。 私は下記のようなコードを書いて試していました。
- Hello world 的なやつ : https://github.com/nobuoka/kotlin-graphql-playground/blob/10d3f14c13b9d0e6d95fbbe82d1df85314227ba6/playground/src/main/kotlin/1-HelloWorld.kt
- 条件を指定してデータをフェッチするやつ : kotlin-graphql-playground/2-Schema.kt at 10d3f14c13b9d0e6d95fbbe82d1df85314227ba6 · nobuoka/kotlin-graphql-playground · GitHub
まだリソース間に関連がある場合などは試せていません。
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 を使えるようになります。
Ktor で GraphiQL を表示させてみるところまでできた。 GraphiQL すごい♡ pic.twitter.com/jjV3Hb8ZYk
— Nobuoka Yu (@nobuoka) 2019年2月10日
おわり
というわけで初心者が GraphQL を学んでいる軌跡でした。 ここに書かれていない知見等ありましたら是非教えてくださいませ〜〜。
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" }