ThreeTenABP と ThreeTenBP の関係について (Android における JSR-310 バックポート)
「ThreeTenABP って Android フレームワークに依存するけど、ドメインレイヤとかで Android 依存を排除したい場合どうするのがいいんですかね」 という相談を受けて、ThreeTenABP 周りを調べたのでメモ。
ThreeTenABP って何?
- JSR 310 Date and Time API の Android 向け Backport。 Jake 氏作。
- GitHub リポジトリ : GitHub - JakeWharton/ThreeTenABP: An adaptation of the JSR-310 backport for Android.
- 『An adaptation of the JSR-310 backport for Android.』 とのこと。
JSR 310 Date and Time API 周りの話
- ThreeTen - Home page and Documentation
- Java SE 8 で Java API に導入された時刻周りの API。
- Java SE 7 以前の環境向けのバックポートも存在する : ThreeTen Backport
ThreeTenABP が存在する意味は?
ThreeTenABP の具体的な実装
というわけで
ThreeTenABP を使いたいけど Android フレームワークに依存させたくないというモジュールでは、ThreeTen Backport に依存させて、テストを書く際にタイムゾーン情報の扱いだけなんとかする、みたいなことをすれば良さそう。
タイムゾーン情報周りを厳密にテストしなくていいなら、compileOnly "org.threeten:threetenbp:${threetenbp_version}"
と testCompile "org.threeten:threetenbp:${threetenbp_version}"
を依存に追加しちゃうのが手軽でいい気がする。 (バージョン周りはよしなに。 これでうまくいくだろうと思って書いてるけど特に何も確認はしてない。)
関連ページ
- ThreeTen-Backport – Changes : ThreeTen Backport の 1.3 でタイムゾーン情報を持たない JAR ファイルもリリースされるようになった。 Android 用。
- GitHub - dlew/joda-time-android: Joda-Time library with Android specialization : Joda-Time でも同じような問題があって、Android 用のものが作られてる。
- Joda Time's Memory Issue in Android : タイムゾーン情報を JAR から読み込む方式だと Android でメモリ使用量が大きくなる話。 (Joda-Time の話だけど ThreeTen Backport も同じらしい。)
- Android の Java で時刻を扱う (Date、Calendar、DateFormat クラス) - ひだまりソケットは壊れない : 本筋ではないけど冒頭で ThreeTen ABP の紹介をした。
読んだ : .NET のエンタープライズアプリケーションアーキテクチャ 第 2 版 / Dino Esposito、Andrea Saltarello 著
ドメイン駆動開発 (DDD) 関係の読書会に参加していて、最近読んでいるのがこれです。
.NETのエンタープライズアプリケーションアーキテクチャ 第2版 (マイクロソフト公式解説書)
- 作者:Dino Esposito,Andrea Saltarello
- 発売日: 2015/06/04
- メディア: 単行本
本書は、『再利用可能で、入手が容易な、ソフトウェアアーキテクチャの確かな知識ベースを提供』 することを考えて執筆されています。 すなわち、DDD の書籍というよりは、開発者とアーキテクトのための、ソフトウェアアーキテクチャ (その中でも特にエンタープライズアプリケーションや web アプリケーションのアーキテクチャ) の虎の巻のようなものです。 その中にドメイン駆動設計の考え方も含まれています。
内容
本書の内容を紹介します。
1 部 「基礎」 では、ソフトウェアアーキテクチャの基礎ということで、アーキテクトの役割であったり、プロジェクトを成功させるためにソフトウェアディザスタ (技術的負債だらけであること) を何故避ける必要があるのかといったこと、ソフトウェアの設計原則やテストが重要であることなどが書かれています。 ここら辺は割と他の書籍でも書かれていることなので、わりとサラッと読んでしまって良さそうです。 (ここら辺の内容をちゃんと吸収するなら 『CODE COMPLETE』 などを読むのが良いと思います。)
第 2 部 「アーキテクチャの考案」 には、プレゼンテーション層とビジネス層についてが書かれています。 目玉は 5 章の 「ドメインアーキテクチャの発見」 で、要約すると 『ドメインを深く理解することだけが適切なアーキテクチャの発見につながる』 ということと 『サブドメインの存在に気づいた場合は、それらをサブアプリケーションとしてモデリングし、最適なアーキテクチャをそれぞれに定義でき』 るということです。 ユビキタス言語と境界づけられたコンテキストが重要である、ということですね。
DDD 関係の本を読むとドメインモデルを構築するための技術的な部分に着目しがちですが、本書ではドメインモデルはあくまでサポートアーキテクチャの 1 つにすぎないと明確に述べていて、ユビキタス言語と境界づけられたコンテキストの重要性を前面に押し出しているのが特徴的です。 また、システムを設計するための手法として、ユーザーエクスペリエンスファースト (UX ファースト) と呼ばれるアプローチをとっているのも特徴ですね。 これは、システム設計の作業をプレゼンテーション層から開始して、予備的な分析を並行して行うという設計理念です。 つまり、最初にビジネスドメインと UX に関するデータを集めてユーザーとの対話モデルを設計して、その後でデータのワークフローやロジック、サービス、ストレージの定義に取り掛かるというものです。
第 3 部 「サポートアーキテクチャ」 では、ドメインモデル、CQRS、イベントソーシングの 3 つのサポートアーキテクチャについての説明がなされます。 ドメインモデルを用いた場合は、ドメインのすべての側面に適合する単一のモデルを設計する必要があり、モデルが複雑になってしまうという問題があるということが述べられ、本書ではやや CQRS 推しな感じです。 (もちろんドメインモデルが適している場面ではドメインモデルを使えば良いのですが。) 個人的にもドメインモデルをがっつり設計するのが常にいいとは思っていなかったので、「DDD の分析部分 (ユビキタス言語や境界づけられたコンテキストの発見) は常に重要だけど、戦略部分 (各境界づけられたコンテキストに最適なアーキテクチャを割り当てる) ではドメインモデルを使う以外の方法もある。 トランザクションスクリプトでもいいし、CQRS という選択肢もある」 というのを明確に示している本書は参考になりました。
第 4 部は 「インフラストラクチャ」 で、永続化レイヤーについて説明されます。
感想
DDD の 「分析部分」 と 「戦略部分」 を分けて、「戦略部分に置いてどういうアーキテクチャを選択するのが最適なのかは場合による」 というのを明確に示しているのが良いなーと思いました。 上でも書いたように、DDD というと 「ドメインモデルをいかに設計するのか」 に着目しがちなのですが、分析部分がまず重要であるというのが読んでいて伝わってきました。 (ユビキタス言語と境界づけられたコンテキストが重要であるということは DroidKaigi 2017 でのあんざいゆきさんの発表でもなされてましたね。)
戦略部分については、サポートアーキテクチャとして DDD において従来から使われているドメインモデルだけでなく、CQRS とイベントソーシングについても述べられているのが良い点だと思います。 どういうものかの紹介や思想の説明もありますし、実装の例も紹介されています。 実際に設計する際にどういう形にすればよいかがイメージしやすいようになっていて参考になります。
翻訳については、1 部の日本語が難しくなっていて読みづらいという問題はありました。 2 部以降はそれほど気にならなかったです。
全体としては、エンタープライズアプリケーションや web サービスの開発者やアーキテクトがアプリケーションのアーキテクチャについて検討する際に役立つ知識がまとまっていて、良い本だと思います。 『.NET の』 とタイトルにありますが、.NET に詳しくなくても問題なく読み進めていくことができますし、アプリケーションの全体のアーキテクチャについて考えている人は読んでみて損はないと思います。
Android のアカウントマネージャ (AccountManager) の概説
最近 Android のアカウントマネージャを仕事で触ったので、調べた内容としてアカウントマネージャの概要をまとめておきます。
Web 上を調べると AccountManager
を使う処理の実装方法はいろいろ見つかるのですが、アカウントマネージャの概要を説明しているページはあんまりなくて全体像を掴みにくいと思っています。 そういう情報を探している人の役に立てば幸いです。
公式ドキュメント
アカウントマネージャに関する公式ドキュメントは以下のものぐらいしかなさそうです。 AccountManager
クラスのドキュメントを全て (各メソッドの説明も含めて) 読むと大体理解できると思います。 (下記ドキュメントを見て理解したら本ページの内容を読む必要はありません。)
- AccountManager | Android Developers
- AbstractAccountAuthenticator | Android Developers
- Remembering Users | Android Developers
AccountManager
を使って認証情報を取得する方法 (利用側) : Remembering Your User と Authenticating to OAuth2 ServicesAccountManager
経由でのアカウント追加や認証情報取得のリクエストを処理する方法 (提供側) : Creating a Custom Account Type
アカウントマネージャの概要
アカウントマネージャは、Android のシステムがユーザーのオンラインアカウントを中央管理するための機能です。 Android 端末の 「設定」 にずらずらとアカウントが並ぶと思いますが、これもアカウントマネージャの仕組みで実現されています。
各種オンラインサービスはそれぞれ異なったアカウントの扱い方や認証方式を使うため、アカウントマネージャはアカウント種別ごとに authenticator モジュールを使い分けるようになっています。
使いどころ
複数アプリで認証情報を共有するときに便利です。 (認証情報の提供側、および利用側両方とも。)
また、単一アプリでのみ認証情報を使う場合でも便利に使えると思います。 なぜなら、アカウントマネージャを使うことで認証周りの処理を分離しやすくなり、また、統一された方法で認証トークンを扱えるようになるためです。
用語
- Account Type : Authenticator を特定するための文字列。
- Auth token (認証トークン) : オンラインサービスのユーザー認証に使われるトークン。
- Auth token type : Authenticator 固有の、Auth token 種別。 1 つのアカウントが auth token を複数持つことができる。
- Feature : Authenticator 固有の、アカウントプロパティを特定するトークン。
- Client (クライアント) / Application : アカウントマネージャを使って認証情報を取得するアプリを指す。
- Authenticator : トークンの払い出しなどを行うやつ。 実体は
AbstractAccountAuthenticator
を継承したクラス。- システム上で有効な authenticator (
AbstractAccountAuthenticator
) を持っているアプリを指して Authenticator と言ったりもしてる? (明示的にアプリを表す場合は 「Authenticator アプリ」 と呼ぶのが良さそう。) - クライアントであり、かつ Authenticator であるというようなアプリも存在しうる。
- システム上で有効な authenticator (
Authenticator とクライアントの関係
アカウントマネージャを扱う処理を大別すると、authenticator とクライアントに分かれます。
- Authenticator (や、それに関連する処理) : オンラインサービスのアカウント取得や認証処理を行います。 典型的には、ユーザーに対してログイン画面を表示してユーザー名とパスワードを入力させ、それらのアカウント情報を使ってオンラインサービスから認証情報を取得して、アカウントマネージャに渡します。 普通はそのオンラインサービスの提供者がアプリの中に実装します。
- Authenticator のメソッドはアカウントマネージャから呼ばれます。
- クライアント : アカウントマネージャを通じて、オンラインサービスの認証情報 (アクセストークンなど) を取得し、それを使ってオンラインサービスにリクエストを投げる、というような処理です。
クライアントが AccountManager
のクライアント向けのメソッドを呼ぶと、(一部のメソッドは) 対応する Authenticator のメソッドを呼びます。 そして、典型的には Authenticator のメソッドは AccountManager
の Authenticator 用のメソッドを呼びます。 クライアントも Authenticator も AccountManager
のメソッドを呼ぶという点で混乱しやすいのですが、AccountManager
のメソッドをクライアント用メソッド群と Authenticator 用メソッド群に分けると、理解しやすいと思います。
自社サービスクライアントと他社サービスクライアントについて公式ドキュメント上での区別はありませんが、こういう分け方で考えると理解しやすいと思うので、本ページではこのように表記します。 ちなみに厳密な区別としては、「扱うアカウント種別の authenticator アプリと同じシグネチャを持つアプリ」 が自社サービスクライアント、「異なるシグネチャを持つアプリ」 が他社サービスクライアントです。
AccountManager
のメソッド一覧
アカウントマネージャについて理解するには AccountManager
クラスに生えている全てのメソッドを把握すればいいのですが、ドキュメントそのままだと理解しづらいので、整理して一覧できるようにしておきます。 これらのメソッド一覧に目を通すと、アカウントマネージャについての理解が深まるはずです。
API level 25 のドキュメントを参照しています。
クライアント向け
他社サービスのクライアントが使えるメソッド
Authenticator 一覧を取得したり、アカウント一覧を取得したり (パーミッションがあれば他社サービスのアカウントも全て)、指定のアカウントの認証トークンを取得したり、指定の認証トークンが無効になっていることをアカウントマネージャに伝えたり、ユーザーが指定のアカウントのパスワードを知っていることを確認するよう依頼したり、といったことができます。
getAuthenticatorTypes()
- システムに登録されている Authenticator 一覧を返す。
- パーミッション不要。
addOnAccountsUpdatedListener
- アカウントマネージャが管理するアカウントの変化 (追加や削除など) を検知するリスナ (
OnAccountsUpdateListener
) を追加する。 getAccounts
メソッドで取得可能なアカウントの変化のみ検知できる。 (典型的には、任意のアカウントの変化を検知するにはGET_ACCOUNTS
パーミッションが必要。)
- アカウントマネージャが管理するアカウントの変化 (追加や削除など) を検知するリスナ (
removeOnAccountsUpdatedListener(OnAccountsUpdateListener)
- リスナの削除。
addAccount
- アカウント取得系
confirmCredentials
updateCredentials(Account, String, Bundle, Activity, AccountManagerCallback<Bundle>, Handler)
getPreviousName
- 指定のアカウントの前の名前を返す。
LOGIN_ACCOUNTS_CHANGED_ACTION
ブロードキャストを受け取ったときに、アカウント名が変更されているかどうか検知できるようにするためのメソッドらしい。
- Auth token 取得系 : AccountManager がキャッシュしていたらそれを返し、なければ対応する Authenticator が認証トークンを取得する。
getAuthToken(Account, String, Bundle, boolean, AccountManagerCallback<Bundle>, Handler)
- バックグラウンドの処理向け。
- 指定のアカウントの指定のトークン種別の認証トークンを取得する。 アカウントマネージャがキャッシュしている場合はそれが返される。 キャッシュされていない場合は authenticator がリクエストを処理する。
- Authenticator は、パスワードが使えるのであればそれでサーバーに問い合わせを行う。 それが無理ならユーザーにログイン画面を表示して、ユーザー入力によって認証トークンを取得する。
notifyAuthFailure
に真を渡したならば、そのインテントを開始するステータスバーの通知も表示される。- その場合 (ログインする必要がある場合? それともステータスバーの通知が表示される場合?) には、ユーザーが対応するまで何時間も、何日も、あるいは永遠に待つことになる可能性がある。
notifyAuthFailure
に偽を渡した場合は、アプリケーションがIntent
を開始する責任がある。- API level 22 以下では
USE_CREDENTIALS
パーミッションが必要。
getAuthToken(Account, String, boolean, AccountManagerCallback<Bundle>, Handler)
- API level 14 で Deprecated になってるので
Bundle
パラメータ有り版を呼ぶこと。
- API level 14 で Deprecated になってるので
getAuthToken(Account, String, Bundle, Activity, AccountManagerCallback<Bundle>, Handler)
blockingGetAuthToken
getAuthTokenByFeatures(String, String, String[], Activity, Bundle, Bundle, AccountManagerCallback<Bundle>, Handler)
invalidateAuthToken
hasFeatures
自社アプリ (同じシグネチャを持つアプリ) のみ
removeAccount(Account, Activity, AccountManagerCallback<Bundle>, Handler)
removeAccount(Account, AccountManagerCallback<Boolean>, Handler)
- 上に同じ。 Deprecated。
renameAccount(Account, String, AccountManagerCallback<Account>, Handler)
clearPassword
- アカウントマネージャが保持しているパスワードを削除する。 「ログアウト」 ボタンの処理などで使われることを想定しているらしい。
setPassword
メソッドでも同じことができるけどこっちの方が必要な権限は少ないっぽい。- アカウントの Authenticator と同じシグネチャが必要。
editProperties
Authenticator (または、それに関連する処理) 向け
addAccountExplicitly(Account, String, Bundle)
- アカウントをアカウントマネージャに追加する。
- Authenticator に関連するサインアップ処理向け。
addAccount
メソッドなどのシステムがアカウント追加を検知できるメソッド経由で呼ばれたわけじゃない場合は、自前でnotifyAccountAuthenticated
メソッドを呼ぶべき。
removeAccountExplicitly(Account)
notifyAccountAuthenticated(Account)
- アカウントが認証されたことをシステムに伝える。
- このイベント情報が他のアプリで使われたりするっぽい? (未調査。)
- 呼び出し側はアカウントの Authenticator と同じシグネチャを持つ必要がある。
getPassword
setPassword(Account, String)
getUserData
setUserData(Account, String, String)
peekAuthToken(Account, String)
setAuthToken(Account, String, String)
その他
get(Context)
: インスタンス取得。getAccountsByTypeForPackage
: システム用。newChooseAccountIntent(Account, List<Account>, String[], String, String, String[], Bundle)
- アカウント選択画面を表示するための
Intent
を生成する。
- アカウント選択画面を表示するための
newChooseAccountIntent(Account, ArrayList<Account>, String[], boolean, String, String, String[], Bundle)
- 上の Deprecated 版。
関連ページ
- 『Android アプリのセキュア設計・セキュアコーディングガイド』2016年9月1日版 (JSSEC) : セキュリティ周りのことはこれが一番良いはず。 説明もわかりやすい。
- AccountManagerを利用する : 利用側についてわかりやすくまとまっている。
- AccountManagerでアカウントを管理する : 提供側の実装方法がわかりやすくまとまっている。
- Android におけるアカウント管理 : 提供側の実装方法がわかりやすくまとまってる。
- authenticator.xml の accountType の文字列を strings.xml に入れてはいけない : 厳しい。
- Differences of AccountManager from API 22 to 24 : Kyobashi.dex #4 における shogo さんの発表資料。 最近の Android のバージョンごとの差異の説明。 複数アプリに同じ account type の authenticator を持たせるとどうなるのか、といった話も出てきて非常に参考になる。
Hyper-V 上の Debian VM の vNIC の IP アドレスを管理 OS 側から取得できるように LIS を有効にする
表題通り。 Hyper-V 上の Debian 8.7.1 で Linux 統合サービス (Linux Integration Services; LIS) を有効にした方法のメモ。
私が試したのは、Windows 10 Pro (Insider Preview Build 14986) の Hyper-V 上の VM に Debian 8.7.1 Jessie をインストールする Packer のビルドを実行しようとした、というもの。
前提
- Hyper-V の管理 OS 側から、VM の仮想ネットワークアダプタ (vNIC) の IP アドレスを取得できるようになっている。
- Hyper-V マネージャの GUI で確認できる。
- PowerShell の Hyper-V モジュールのコマンドを使う場合は、
Get-VM
コマンドで返ってくるMicrosoft.HyperV.PowerShell.VirtualMachine
オブジェクトのNetworkAdapters
プロパティで確認できる。- PowerShell で
Get-VM | %{$_.NetworkAdapters}
と入力。 - Packer のソースコード上でも hyperv.go の中で使われている。
- PowerShell で
直面した状況
Packer でビルドして Hyper-V VM に Debian 8.7.1 Jessie をインストールしようとしたところ、Packer がゲストマシンに SSH 接続しようとしてずっと待ち続けるという状況になった。 (下記表示のまま進まない。)
==> hyperv-iso: Waiting for SSH to become available...
Packer のログ ($env:PACKER_LOG=1
でログ出力されるようにした) を確認したところ、以下のようなメッセージが表示されていた。
2017/02/18 19:48:48 packer.exe: 2017/02/18 19:48:48 [DEBUG] Error getting SSH address: No ip address.
すなわち、管理 OS 側から仮想ネットワークアダプタの IP アドレスを取得できなかったぽい。
管理 OS 側から仮想ネットワークアダプタの IP アドレスを取得できなかった原因
「Error getting SSH address: No ip address.」 の原因を探したところ、以下の issue が引っかかった。 ここでゲスト OS 側が管理 OS に IP アドレスを伝える必要があるということを知った。
で、Linux ゲストの場合は、Linux 統合サービスが必要とのこと。
Debian 8.7.1 Jessie で LIS を有効にする
Debian 8 系では、LIS がディストリビューションに含まれている。
有効にするための手順がどこに書かれているのかわからなかったので困ったのだけれど、やってみたところ Debian 8.7.1 では、APT の hyperv-daemons パッケージをインストールして再起動するだけで LIS が有効になるっぽかった。 (実際に試して、管理 OS 側から IP アドレスを取得できることを確認した。)
apt-get install hyperv-daemons
今回の場合、Packer でビルドするために Preseed を使っていたので、preseed.cfg のパッケージ選択に hyperv-daemons を含めるようにした。
d-i pkgsel/include string openssh-server build-essential hyperv-daemons # これは一例です。 hyperv-daemons パッケージ以外のパッケージは必ずしも必要ではありません。
参考
- Debian Jessie Hyper-V : Debian 8 で LIS を有効にする方法。 KVP、VSS、FCOPY 統合のために hyperv-daemons と 4.2 カーネルが必要とのこと。 KVP やら VSS やら FCOPY まで追いかけられていないが、参考にした。
- Install Hyper-V (LIS) On Debian 8 : Debian 8 で LIS を有効にする方法。 こちらも参考にはしたが、ここに書かれている方法を全て実行する必要はなさそうだった。
読んだ : RESTful Web Services with Dropwizard / Alexandros Dallas 著
Dropwizard に関わる仕事をしているので読んでみました。
RESTful Web Services with Dropwizard
- 作者: Alexandros Dallas
- 出版社/メーカー: Packt Publishing
- 発売日: 2014/02/19
- メディア: Kindle版
- この商品を含むブログを見る
Dropwizard について
Dropwizard は Java の web アプリケーションフレームワークです。 基本的には既存ライブラリの組み合わせで web アプリケーションを構築するというもので、Dropwizard 固有の仕組みはさほど多くありません。 (例えば Web リクエストを受け取るのは Jersey で、DB アクセスには Hibernate か jDBI が使われる。)
特に Java EE 系の知識を持っている人であれば、とっつきやすい感じです。
本書について
本書は、Dropwizard を使って web アプリケーションを構築するための方法を説明するものです。 プロジェクトの準備や、HTTP リクエストを受けるエンドポイントの記述、DB アクセス、ユーザー認証、HTML を返す View テンプレートについてなど、基本的な要素について、サンプルコードを交えながら仕組みが解説されます。
Java EE などについてある程度わかっている人が読むと Dropwizard の公式ドキュメントを読むのと大差ないと思いますが、初心者の人が読むと結構わかりやすいんじゃないかなと思います。 (ある程度わかってる人にとっても、Dropwizard についてざっと知ることができて良いかもしれませんが、そういう使い方だとちょっと値段は高めに感じる気がします。)
本書での学び
参考のリンクとしては現時点での最新バージョンのドキュメントへのリンクです。 閲覧時の最新バージョンのドキュメントは各自探してください。
- Dropwizard では、maven-shade プラグイン を使って、単体で web アプリケーションとして実行可能な JAR ファイル (uber-jar) を作る。
- Hibernate Validator によるアプリケーション設定のバリデーションが可能。
- jDBI で DB から取得した結果をオブジェクトにマップする方法として、
@MapResultAsBean
アノテーションを使うという方法もある。- 参考 : MapResultAsBean (jDBI 2.48.2 API)
- とはいえ公式的なドキュメントは何もなく、ドキュメント化されていない挙動に依存することになるので不安。 (JDBI の機能の多くがドキュメント化されてないのでまあこれに限った話ではないのだけど。)
- HTTP リクエストパラメータのバリデーション周り。
- JAX-RS のリソースメソッドのパラメータのバリデーションを明示的に実行することも可能。 (
@Valid
アノテーションでのバリデーション実行しか知らなかった。) - 複数フィールドにまたがるバリデーション。
- 参考 : Dropwizard Validation | Dropwizard
- JAX-RS のリソースメソッドのパラメータのバリデーションを明示的に実行することも可能。 (
- Dropwizard には HTTP クライアント用モジュールも含まれている。
- 認証周り。
- Basic 認証用のクラスが用意されている。
- オプションの認証も可能。 (認証されたユーザーの場合はそのユーザー専用のコンテンツを表示し、さもなければ一般ユーザー向けのコンテンツを表示する、みたいな。)
CachingAuthenticator
によるキャッシング。- 参考 : Dropwizard Authentication | Dropwizard
- Fixtures for Easy Software Testing (FEST) というプロジェクトがある。 TestNG や JUnit と一緒に使える。 ソフトウェアテストを書きやすくするライブラリ。
感想
Dropwizard をそこそこ使ってて公式ドキュメントも (全部ではないけど) 読んでいたので、本書での学びはあんまりなかったです。 とはいえ上に書いたように新たに知れたこともいくつかあったので読んでよかったです。 (そんなに時間もかけずにざっと読めましたし。)
とはいえ紙の本だと 30 ドル以上するので、ちょっと高い感じはしますね。