orangain flavor

じっくりコトコト煮込んだみかん2。知らないことを知りたい。

Cloud Run(フルマネージド)でリクエスト外に処理をすると200倍遅くなる

はじめに

Cloud Runはサーバーレスなコンテナ実行基盤です。この記事ではフルマネージド版のCloud Runのみを対象とし、フルマネージド版のCloud Runを指して、単にCloud Runと表記します。

Cloud Runの料金プランの特徴として、リクエストの実行中のみ課金対象になるという点が挙げられます。しかし、リクエストのたびにコンテナの起動と終了を繰り返すわけではなく、起動したコンテナはある程度使い回されます。リクエストが無い間は、コンテナが起動していても課金されないというわけです。

f:id:mi_kattun:20200517165916p:plain
課金対象の時間 (https://cloud.google.com/run/pricing?hl=ja より引用)

だからと言って無料で使い放題というわけではなく、コンテナランタイムの契約として、リクエスト中しかCPUが使えないと明記されています

You should only expect to be able to do computation within the scope of a request: a container instance does not have any CPU available if it is not processing a request.

https://cloud.google.com/run/docs/reference/container-contract?hl=ja より引用

ですが、実際にデプロイしたアプリケーションの挙動を見る限り、まったくCPUが使えないわけではなく、リクエスト外でも処理は動いています。例えばコネクションプールライブラリのHikariCPを使っていると、30秒に1回コネクションプールがメンテナンスされ、必要に応じて再接続するログが出力されます。

あるCloud Runアプリケーションで、レスポンスを返した直後に追加で処理を行っていました。リクエスト中に処理した方が良いことは知っていましたが、動いてるならまあいいかと放置していたら、運用中にエラーが発生してしまいました。そこで、Cloud Runでリクエスト外に処理を行った場合の挙動を改めて実験してみました。実験結果は2020-05-17時点のもので、今後変わる可能性があることに留意してください。

実験方法

リクエストを受けるたびに、同じ処理を以下のタイミングで3回行うアプリケーションを作成し、それぞれの実行時間を計測しました。

  • (A) リクエスト中(レスポンスを返す前)
  • (B) レスポンスを返した直後
  • (C) Bの完了後、1秒経過してから

以下の3種類のワークロードで実験しました。

  1. 乱数生成
  2. Cloud Tasksのタスク作成
  3. Cloud SQLへのINSERT

実験用のコードを抜粋するとこんな感じになります。KotlinとKtorで書きました。詳しくはリポジトリを参照してください。

    routing {
        post("/calc") {
            calc() // (A) リクエスト中
            call.respondText("OK", status = HttpStatusCode.Created)
        }
    }

    intercept(ApplicationCallPipeline.Call) {
        proceed()
        if (call.request.httpMethod == HttpMethod.Post) {
            when (call.request.uri) {
                "/calc" -> {
                    calc() // (B) レスポンスを返した直後
                    delay(1000)
                    calc() // (C) Bの完了後、1秒経過してから
                }
            }
        }
    }

実験結果

初回はキャッシュなどの影響で (A) リクエスト中の実行時間が相対的に長くなるので無視して、5回計測した結果を掲載します。実行時間の単位はmsです。

1. 乱数生成

ランダムなUUID v4を10万個生成するのにかかる時間を計測しました。アプリケーションは -Djava.security.egd=file:/dev/./urandom をつけて実行しています。

試行 (A) リクエスト内の
実行時間
(B) リクエスト直後の
実行時間
(C) リクエスト外の
実行時間
何倍遅いか (C/A)
1回目 123 5379 33600 273.2
2回目 124 5431 36899 297.6
3回目 128 8431 30907 241.5
4回目 123 4804 35000 284.6
5回目 124 5246 35699 287.9

2. Cloud Tasksのタスク作成

Cloud Tasksのタスクを作成するのにかかる時間を計測しました。

試行 (A) リクエスト内の
実行時間
(B) リクエスト直後の
実行時間
(C) リクエスト外の
実行時間
何倍遅いか (C/A)
1回目 250 4014 9801 39.2
2回目 175 2378 27700※ 158.3
3回目 313 2072 10300 32.9
4回目 222 1913 13001 58.6
5回目 239 3510 11899 49.8

DEADLINE_EXCEEDED: deadline exceeded after 19.700177855s. と例外が発生したので、例外が発生するまでの時間を実行時間としました。

3. Cloud SQLへのINSERT

Cloud SQLのテーブルにINSERTするSQLを実行するのにかかる時間を計測しました。

試行 (A) リクエスト内の
実行時間
(B) リクエスト直後の
実行時間
(C) リクエスト外の
実行時間
何倍遅いか (C/A)
1回目 12 9 2610 217.5
2回目 11 9 1900 172.7
3回目 9 8 1500 166.7
4回目 10 11 2400 240.0
5回目 13 9 1492 114.8

考察

ワークロードによって異なりますが、(C) リクエスト外の実行時間は、(A) リクエスト内の実行時間に比べて30倍〜300倍程度遅くなることがわかりました。やはりCPUの実行が制限されているようです。

(B) リクエスト直後の実行時間が (C) より短くなるのは、アプリケーションがレスポンスを返した直後はまだリクエスト内だとみなされる時間が少しだけあり、その間に処理が進むためだと考えられます。

なお、リクエスト外に処理を行っていても、そのタイミングでちょうどアプリケーションにリクエストが来ると、リクエスト内とみなされて、CPU割り当てが元に戻って処理が進むようになります*1。このため、アプリケーションへのリクエストが多いときには、リクエスト外で処理をしていても問題が顕在化しないことがあります。

まとめ

実験から、Cloud Runでリクエスト外に処理を行うと、30倍〜300倍程度遅くなることがわかりました。 コンテナランタイムの契約にある通り、Cloud Runで実行するアプリケーションは、リクエストの外で処理を行うことがないようにしましょう。

*1:実験では毎回 (C) の処理が完了してから次のリクエストを送信しました。

monorepoのCI/CDで変更された部分だけをビルド/デプロイする

はじめに

今年のゴールデンウィークは暇があり、勤務先で複数のリポジトリを使っているのが辛く感じてきていたため、monorepoについて調べてみました。monorepoについての説明やメリットについては他の記事に譲ります。

www.graat.co.jp

この参考記事でmonorepoの本当の課題として挙げられている以下の4点のうち、3点目に相当する「CIで変更によって影響を受けた部分だけをビルドする方法」を調査・検討しました。

  • トランクベース開発は、より一段と重要になります
  • すべてのサービスがモノレポで上手く動くわけではありません
  • より精巧なCIセットアップが必要です
  • あなたは大規模な変更について考える必要があります

この参考記事ではnxが挙げられていますが、nxは主にJavaScriptのプロジェクトを対象としたツールのようなので、言語に依存しない方法を検討しました。

前提

以下の前提で話を進めます。

  • monorepoのローカル開発環境でのビルドは既に構成されている。
    • 実装例としてGradleのマルチプロジェクトビルドを使いますが、特定の言語やビルドツールに限定した内容ではありません。
  • ソースコードのバージョン管理にはGitを使っている。
  • masterブランチとfeatureブランチによるPull Requestベースの開発を行っている。
    • developブランチを使っていてはダメというものではありません。

どうやって変更された部分を特定するか

まず、変更された部分を特定する方法を考えます。

案1: ビルドツールの差分ビルドを活用する(不採用)

GradleやMake、Bazelなどの依存関係を考慮するビルドツールでは、変更された部分だけをビルドする機能が備わっていますが、CI環境でこれに依存するのは得策とは言えません*1。CI環境に前回のビルド結果がキャッシュとして残っていたとしても、そのキャッシュとの差分が実際の変更の差分と一致する保証はないためです。

CIの場合、仮に変更の差分と一致しなくても最終的な成果物をビルドできれば大きな問題はないかもしれません。一方CDの場合、本当は変更がないのにキャッシュの都合でビルドが実行され、デプロイまでされてしまうのは困るというケースがあるかもしれません。

案2: Gitの差分から特定する(採用)

そこで、Gitの差分から変更された部分を特定するのが良さそうです。 git diff --name-only <commit> を使えば現在のHEADと <commit> の間で変更されたファイル名一覧を取得できるので、それで判断するイメージです。

ただ、比較対象とする <commit> の選び方については一考の余地があります。単純な方法としては、featureブランチではmasterブランチとの差分を、masterブランチでは直前のコミット HEAD^ との差分を取る方法があります。masterブランチは絶対にマージでしか更新しない運用であれば大丈夫かもしれませんが、masterブランチに複数のコミットをプッシュしてしまった場合に差分から漏れてしまうので、ちょっと心許ないです。

理想的には、ビルドのトリガーとなったプッシュ(マージ含む)に含まれる差分から判断したいです。この差分を取得できるかどうかはCIツールに依存します。

CIツールでGitの差分から変更された部分を特定する

Travis CIでは、環境変数 TRAVIS_COMMIT_RANGE *2を見れば、そのビルドが対象としているコミットの範囲がわかります。 TRAVIS_COMMIT_RANGE は新しいブランチを新しいコミットなしでプッシュした場合には空になりますが、この場合には差分がないはずなので、特に影響ないと思います。

GitHub Actionsの場合、環境変数などではわからないものの、ワークフローのトリガーで on.push.paths *3を指定すれば、指定したパスが変更された場合のみ実行されるので、ビルド内で git diff する手間なく簡単に実現できます。

CircleCIでは、パイプライン変数 pipeline.git.revisionpipeline.git.base_revision *4を比較すれば近いことができそうですが、新しいブランチをプッシュしたときには pipeline.git.base_revision が空になってしまうので、もう一工夫必要です。

個人的によく使っているCloud Buildだと、変数一覧を見る限り単体では実現できなさそうでした。残念。

以下のリポジトリに、GitHub Actions, Travis CI, CircleCIの挙動を試した結果を残してあります。参考にしてください。

https://github.com/orangain/understand-github-actions-on-paths

具体的な実装例

というわけで、GitHub Actionsを使ってGradleマルチプロジェクトビルドのプロジェクトで変更があった部分だけビルドするサンプルを作りました。

github.com

次のような2つのアプリが2つのライブラリに依存する構成です。

f:id:mi_kattun:20200506130951p:plain:w400

それぞれのアプリに対応するワークフローの設定を含めており、 apps/account-app の設定は次のとおりです。on.push.paths にはアプリのディレクトリに加えて、アプリが依存するライブラリや、Gradleの設定ファイル *.gradle.kts も含めています。これによって、アプリのコードが変更された場合だけでなく、依存関係が変更された場合にも再ビルドできます。

name: Build account-app
on:
  push:
    paths:
      - "apps/account-app/**"
      - "libs/greeter/**"
      - "libs/profile/**"
      - "*.gradle.kts"
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-java@v1
        with:
          java-version: '11'

      - name: Run gradle build
        run: ./gradlew :apps:account-app:build

      - uses: actions/upload-artifact@v2
        with:
          path: apps/account-app/build/libs/*.jar

最後に

上記のサンプルプロジェクトは実際に運用したものではありませんが、変更があった部分だけCI/CDの対象とすることができそうな感触が得られました。

最初は単純に差分にファイルが含まれているかどうかで判断するのでは不十分で、依存関係を考慮しないといけないと考えていたため、ちょっとしたツールを実装していましたが、実装してる途中でそのような考慮は不要であることに気付きました。

結果的にこのゴールデンウィーク車輪の再発明をしただけでしたが、GradleのマルチプロジェクトビルドやGitHub Actionsを試したり、トポロジカルソートを実装したりと、いろいろ学べたので良しとします。この記事にも大した新規性はありませんが、少しでも検索に引っかかって再発明する人が減れば幸いです。

動的な値を含むJSON文字列をテストするための json-fuzzy-match を作った

APIなどのテストで、JSONが意図した形式・値になっているかチェックしたいことがあります。

文字列として比較してチェックする場合、JSONに含まれる空白がちょっと変わっただけでエラーになりますし、チェック対象のJSON文字列が整形されていないとテストの可読性が落ちます。さらに自動生成されるIDや更新日時など、動的な値が含まれていると使えません*1

JSON文字列をパースしてから比較する場合、Jacksonの ObjectMapper#readValue() のような気の利いたデシリアライズ機能を使うと、本当にJSON文字列として意図した形になっているか自信が持てません。一方で、 ObjectMapper#readTree() のように木構造としてパースする機能を使うと、記述が冗長になりがちで面倒です。テストコードも読みやすくありません。

json-fuzzy-match

そこで、JVM系言語においてJSON文字列をいい感じに比較できるアサーションライブラリ「json-fuzzy-match」を作りました。スペースや改行などのフォーマットの違いを無視して比較できるだけでなく、 #string #number #uuid のようなマーカーを使って、動的な値を含むJSONをそのまま比較できます。

例えばテスト対象とする response.content が次のようなJSON文字列だとします。

{
    "id": "2c0a9fd7-be2c-4bc2-b134-acc3fa13d400", // 自動生成されたUUID
    "title": "Example Book",
    "price": "9.99",
    "currency": "USD",
    "amount": 10,
    "timestamp": "2019-09-25T13:34:17Z" // 動的なタイムスタンプ
}

json-fuzzy-matchを使うと、次のように比較できます*2。UUIDやタイムスタンプのような動的な項目にはマーカーを指定しているので、形式が合っていればテストが通ります。

JsonStringAssert.assertThat(response.content).jsonMatches("""
    {
      "id": "#uuid",
      "title": "Example Book",
      "price": "9.99",
      "currency": "USD",
      "amount": 10,
      "timestamp": "#string"
    }
""".trimIndent())

インストール方法やマーカーのリファレンスなど、詳しくはGitHubリポジトリを参照してください。

github.com

謝辞

json-fuzzy-matchは Karate というテスト自動化ツールの機能の一つである Fuzzy MatchingJUnitやAssertJから使いやすいようにラップしただけのライブラリです。使えるマーカーもKarateのそのままです。

JSONのテストを書くのは面倒で、日頃から良い方法がないか考えていた中でKarateのFuzzy Matchingに出会いました。このような便利なツールがOSSとして公開されていたことで、JSONのテストを簡単に書けるようになったので、Karateの開発者には非常に感謝しています。この場を借りてお礼申し上げます。

最後に

しばらく勤務先で使っていますが、それなりに便利だと思うので、良ければぜひ使ってみてください。改善点などあれば、IssueやPull Requestなどいただけると嬉しいです。

あるいは、JSONをテストするためのもっと良い方法をご存知であれば、ぜひ教えていただけるとありがたいです。

*1:正規表現で頑張る手もありますが、あまりやりたくありません。

*2:この例はAssertJスタイルのアサーションを使っていますが、古き良きJUnitスタイルの assertJsonMatches() も提供しています。

2019年を振り返って

2019年を振り返って

2019年は勤務先での仕事が楽しい1年でした。 長く開発・運用されてきた大規模なアプリケーションへの追加開発を通して、コードの保守しやすさについて考えることが多く、とても良い経験になりました。 年の後半はGCP (Google Cloud Platform), Auth0, Kotlin, React, TypeScriptなどを本格的に使ったり、DDD (Domain-Driven Design) を実践したりと、学びの多い1年でした。

プライベートでは、ご好評いただいたPythonクローリング&スクレイピングの改訂版も出版できました。

2020年に向けて

しばらくは仕事が楽しそうですが、もう一段ステップアップするためにできることを考えていきたいです。

また、年齢のせいか少し体重が気になるので、何かしら運動する習慣を作りたいと思います。

来年もよろしくお願いします。

JVM系言語のSQLマッパーについてPostgreSQLの型への対応状況を比較検証

KotlinでWebアプリケーションを作るにあたり、SQLを直接記述できるタイプのO/Rマッパー(本稿ではSQLマッパーと呼びます)を探し求めました。

SQLマッパーに求める機能

SQLマッパーに求める機能はBindとMapです。この記事ではBindとMapを次のように定義します。

  • Bind
    • SQLに埋め込んだプレースホルダー(名前付きが望ましい)に対応するパラメーター群を渡す機能。
    • 例: SELECT ... WHERE price BETWEEN :minPrice AND :maxPrice のようなSQLに、minPricemaxPriceプロパティを持つオブジェクトを渡す。
  • Map
    • 実行結果として得られた行をオブジェクトにマッピングする機能。
    • 例: SELECT id, name, price FROM ... のようなSQLの実行結果を、id, name, price のプロパティを持つオブジェクト(のリスト)として返す。

次のような機能は(あれば嬉しいかもしれませんが)特に求めていません。

  • 動的にSQLを組み立てる機能*1
  • snake_caseのカラムをcamelCaseのプロパティにMapする機能*2

最初はなんとなくsql2oを使っていましたが、プロパティの型によっては思い通りにBind/Mapされないことがあり、各種のSQLマッパーがどのような型に対応しているのか真面目に検証してみようと思いました。

検証対象とバージョン

上記の求める機能を持つSQLマッパーとして、次の5つのライブラリを検証しました。

対象とするデータベースはPostgreSQLです。

基本的な情報

基本的な情報は次の通りです。最新については2019-09-29時点の情報です。

名前 Apache Commons DbUtils Spring JDBC Template MyBatis JDBI sql2o
リンク ドキュメント
リポジトリ
ドキュメント
リポジトリ
ドキュメント
リポジトリ
ドキュメント
リポジトリ
ドキュメント
リポジトリ
開発者 Apache Software Foundation Pivotal MyBatisチーム JDBI Project Members Lars Aaberg氏
ライセンス Apache License 2.0 Apache License 2.0 Apache License 2.0 Apache License 2.0 MIT License
最新バージョン 1.7 5.1.10 3.5.2 3.10.1 1.6.0
最新版公開日 2017-07-02 2019-09-28 2019-07-15 2019-09-07 2018-10-20
最終コミット日 2019-08-16 2019-09-27 2019-09-28 2019-09-29 2019-05-16
SQLを書く場所 引数 引数 別ファイル or アノテーション 引数 or アノテーション 引数

検証方法

検証対象とする型のカラムを含むテーブルsql_mapper_testを作成し、そこにテストデータを1行INSERTしておきます。

create table sql_mapper_test (
    -- simple
    c_boolean boolean,
    c_integer integer,
    c_decimal decimal,
    c_double double precision,
    c_varchar varchar(100),
    c_text text,
    c_bytea bytea,
    c_uuid uuid,
    -- datetime
    c_date date,
    c_time time,
    c_timetz timetz,
    c_timestamp timestamp,
    c_timestamptz timestamptz,
    -- complex
    c_inet_ipv4 inet,
    c_inet_ipv6 inet,
    c_url text,
    c_integer_array integer[],
    c_varchar_array varchar(10)[]
);

BindとMapについて、それぞれ次のように対応可否を検証します。

  • Bind
    • SELECT COUNT(*) FROM sql_mapper_test WHERE カラム名 = :value のようなSQLでテストデータと同じ値をBindして、1が返ってくれば対応していると判断。
  • Map
    • SELECT カラム名 FROM sql_mapper_test というSQLで、テストデータと同じ値が返ってくれば対応していると判断。

検証に使用したソースコードは次のリポジトリにあります。

https://github.com/orangain/compare-sql-mappers

検証結果

結果は次のスプレッドシートに記載しています。 それぞれのセルは、1列目のカラムを2列目の型でBind/Mapした結果を表します。

  • Success(緑): 正しくBind/Mapできた
  • Wrong Value(黄): 期待とは異なる値がBind/Mapされた(配列が文字列としてBindされてしまう、期待とは異なる型にMapされてしまう、型は同じだけど値が異なるなど)
  • 〜Exception(赤): 発生した例外の名前

https://docs.google.com/spreadsheets/d/1Tf3uB5xFIZnP1ieSu9JQdKaGDY_XfJyGJYleAXI3328/edit?usp=sharing

検証結果のサムネイル

解説

まず最初に、timetz⇔OffsetTime、url⇔URLの変換は試した全ライブラリでできませんでした。

Commons DbUtilsSpring JDBC Templateは同じような結果となりました。 基本的にはJDBCドライバー任せで、Bind時はJDBCドライバーが対応していればBindできます。 Map時はJDBCドライバーが返す値をそのままキャストして返すので、期待している型とは異なる型が返ってくる場合があります。 例えば、日時に関する型はjava.sql.Dateなどで返ってくるので、LocalDateなどのDate and Time APIの型で受け取ることはできません。 また、integer型のカラムをBigDecimalで受け取りたいと思っても、返ってくるのはIntegerです。

Spring JDBC Templateの追加の特徴として、List<T>をBindした時に、個数分のカンマ区切りのプレースホルダーに展開してくれるので、 IN (:list) のような記述が可能です。

MyBatisはMap時に期待する型に変換してくれるので、インターフェイスの戻り値の型に応じてDate and Time APIの型で受け取ることもできます。 PostgreSQLの配列型に関しても、 Integer[]String[] などの配列型であれば受け取れました。 ただし、Integer[]をパラメーターとしてBindすることはできなかったので、注意が必要です。 MyBatisの場合、(本稿の狙いとはズレますが)動的なSQL組み立て機能が強みなので、<foreach>を使えばINなどのパラメーターを展開できます。

JDBIはPostgresプラグインを有効にするとほぼ全ての型をBind/Mapでき、一番良い結果となりました。 他の全ライブラリが対応しているjava.sql.Datejava.sql.TimeをMapできないのは意外でしたが、LocalDateLocalTimeを使えば問題ありません。 普通のプレースホルダーは :name のような形式ですが、<ids>という形式のプレースホルダーを使ってbindList()List<T>をBindすると、個数分のカンマ区切りのプレースホルダーに展開してくれるので、IN (<list>) のような記述が可能です。

Sql2oはMap時に期待する型に変換してくれますが、Date and Time APIには対応していません。 細かいですが、integerBigDecimalで受け取る際は、一度doubleに変換されて、桁数がずれてしまいました。 List<T>や配列をBindした時に、個数分のカンマ区切りのプレースホルダーに展開してくれるので、 IN (:list) のような記述が可能です。

大まかにまとめると次のようになります。

Apache Commons DbUtils Spring JDBC Template MyBatis JDBI sql2o
基本的な型への対応
Date and Time APIへの対応 △Mapのみ △Mapのみ △Mapのみ
ArrayやListへの対応 × × △数値の配列はダメ ×
Bind時のコレクションの展開 × △配列はダメ <foreach> で対応 △配列はダメ*3

結論

比較したSQLマッパーのPostgreSQLの型への対応度合いは、次の順で良い結果となりました。

  1. JDBI
  2. MyBatis
  3. sql2o
  4. Spring JDBC Template
  5. Commons DbUtils

JDBIは、ドキュメントに対応している型が明確に記述されていたのも好感が持てました。試しに使ってみようと思います。

なお、各種ライブラリの検証結果で対応していない型についても、Bind時にSQLの書き方を工夫したり、コンバーターを自作したりすれば対応できるので、ライブラリの評価はこれだけで決まるものではありません。 むしろ、対応している型を把握した上でライブラリを使うことが大切だと思うので、その助けになれば幸いです。 また、この結果はJDBCドライバーの実装に大きく依存するので、他のDBやJDBCドライバーを使う場合は、この結果をそのまま使えないことに注意してください。

なるべく注意を払いましたが、すべてのライブラリにおいてドキュメントやソースコードを完全に読んだわけではないので、検証方法がおかしいところなどありましたら、ぜひお知らせください。

参考文献

*1:とは言いながら、INのパラメーターを展開する機能はあると嬉しいので、追加で検証してます。

*2:チームメンバーに聞いたら、気持ち悪いからasを書きたいという声が多数でした。

*3:正確には配列も bindList("name", ...array) とすれば展開できるが、 List<T> の場合と同じ bindList("name", array) という記法を使うと動作しないので、他のライブラリとの公平性を考慮してダメ扱いとした。

Pythonクローリング&スクレイピングの初版の詰まりどころ

Pythonクローリング&スクレイピングの初版(表紙の文字が青い本)は2017年に出版され、現在では動かなくなってしまったコードもあります。

f:id:mi_kattun:20190921161334j:plain

そこで初版を手にしてくださった方のために、主な詰まりどころと対策をまとめておきます。初版のサポートページには細かな変更点も記載されているので、合わせてご確認ください。

2章(2.4以降) urllibで403 Forbiddenになる

問題

Webサイト側の変更により、2.4以降のコードを実行すると403 Forbiddenとなってしまいます。

対策

注釈やサポートページに記載があるように、 https://gihyo.jp/dphttp://sample.scraping-book.com/dp に置き換えて実行してください。

5.3 Excelファイルなどのデータ構造が変わっている&新しいpandasだと動かない

問題

公開されているExcelファイルなどの構造が当時から変更されており、そのままのコードでは意図した通りの結果が得られないところがあります。また、pandas 0.24以降では一部挙動が変わっており、途中で動かない部分があります。

対策

増補改訂版のサンプルコード5-3 を参照してください。

5.5.2 Amazon.co.jpへのログインができなくなっている

問題

Webサイト側の仕様が変わり、RoboBrowserでログインしようとするとCAPTCHAが表示されてしまいます。

対策

次の記事で、SeleniumとHeadless Chromeを使ってAmazon.co.jpの注文履歴を取得する方法を解説しているので、参考にしてください。

orangain.hatenablog.com

Headless Chromeの使い方については、次項目の対策を参照してください。

5.6 PhantomJSの開発が終了した

問題

PhantomJSは開発が終了したため、現在ではHeadless Chromeなどを使うことが推奨されています。macOSでは書籍に記載通りのコマンドでインストールできなくなっています。

対策

次の記事でPhantomJSの代わりにHeadless Chromeを使う方法を解説しているので、参考にしてください。

qiita.com

また、増補改訂版のサンプルコード5-6 も参照してください。

6章全般 Spiderで意図した通りにデータを抜き出せない

問題

Webサイト側の変更で、動かなくなっているコードがちらほらあります。

対策

増補改訂版のサンプルコード6-* を参照してください。

最後に

増補改訂版ではこれらの問題に対応済みで、解説も新しくなっています。初版にはなかった新しいトピックも追加されているので、よろしければ是非お買い求めください!

scraping-book.com

SeleniumとHeadless ChromeでAmazonの注文履歴を取得する

Pythonクローリング&スクレイピングの初版ではAmazonの注文履歴を取得するサンプルコードを掲載していました。増補改訂版の執筆の際、RoboBrowserのようなJavaScriptを扱えないクローラーではログインが難しくなっていたので、対象のサイトを変更しました。

せっかくなので、SeleniumとHeadless Chromeで書き直したコードを紹介します。

import os
import logging

from selenium.webdriver import Chrome, ChromeOptions, Remote
from selenium.webdriver.common.keys import Keys
from selenium.common.exceptions import NoSuchElementException

logging.basicConfig(level=logging.INFO)

# 認証の情報は環境変数から取得する。
AMAZON_EMAIL = os.environ['AMAZON_EMAIL']
AMAZON_PASSWORD = os.environ['AMAZON_PASSWORD']

options = ChromeOptions()
# ヘッドレスモードを有効にするには、次の行のコメントアウトを解除する。
# options.headless = True
driver = Chrome(options=options)  # ChromeのWebDriverオブジェクトを作成する。
# Windows上の仮想マシンの場合は、前の行をコメントアウトして、
# 次の行のコメントアウトを解除する。Remote()の第1引数はゲストOSから見たホストOSのChromeDriverのURL。
# driver = Remote('http://10.0.2.2:4444', options=options)


def main():
    # 注文履歴のページを開く。
    logging.info('Navigating...')
    driver.get('https://www.amazon.co.jp/gp/css/order-history')

    # サインインページにリダイレクトされていることを確認する。
    assert 'Amazonログイン' in driver.title

    # name="signIn" というサインインフォームを埋める。
    # フォームのname属性の値はブラウザーの開発者ツールで確認できる。
    email_input = driver.find_element_by_name('email')
    email_input.send_keys(AMAZON_EMAIL)  # name="email" という入力ボックスを埋める。
    email_input.send_keys(Keys.RETURN)

    password_input = driver.find_element_by_name('password')
    password_input.send_keys(AMAZON_PASSWORD)  # name="password" という入力ボックスを埋める。

    # フォームを送信する。
    logging.info('Signing in...')
    password_input.send_keys(Keys.RETURN)

    # ページャーをたどる。
    while True:
        assert '注文履歴' in driver.title  # 注文履歴画面が表示されていることを確認する。

        print_order_history()  # 注文履歴を表示する。

        try:
            link_to_next = driver.find_element_by_link_text('次へ')  # 「次へ」というテキストを持つリンクを取得する。
        except NoSuchElementException:
            break  # 「次へ」のリンクがない場合はループを抜けて終了する。

        logging.info('Following link to next page...')
        link_to_next.click()  # 「次へ」というリンクをたどる。

    driver.quit()  # ブラウザーを終了する。


def print_order_history():
    """
    現在のページのすべての注文履歴を表示する。
    """
    # ページ内のすべての注文履歴について反復する。ブラウザーの開発者ツールでclass属性の値を確認できる。
    for line_item in driver.find_elements_by_css_selector('.order-info'):
        order = {}  # 注文の情報を格納するためのdict。
        # 注文の情報のすべての列について反復する。
        for column in line_item.find_elements_by_css_selector('.a-column'):
            try:
                label_element = column.find_element_by_css_selector('.label')
                value_element = column.find_element_by_css_selector('.value')
                label = label_element.text
                value = value_element.text
                order[label] = value  # 注文の情報を格納する。
            except NoSuchElementException:
                pass  # ラベルと値がない列は無視する。
        print(order['注文日'], order['合計'])  # 注文の情報を表示する。

if __name__ == '__main__':
    main()

.envファイルに次のようにログイン情報を保存します。ファイルの取り扱いには気をつけてください。

AMAZON_EMAIL=<Amazon.co.jpのメールアドレス>
AMAZON_PASSWORD=<Amazon.co.jpのパスワード>

次のように実行すると、日付と価格が表示されます。

$ forego run python selenium_amazon_order_history.py
INFO:root:Navigating...
INFO:root:Signing in...
2019年9月8日 ¥ 0
2019年8月12日 ¥ 2,236
2019年8月4日 ¥ 30,000
2019年7月29日 ¥ 648
2019年7月29日 ¥ 2,592
2019年7月29日 ¥ 648
2019年7月29日 ¥ 648
2019年7月29日 ¥ 648
2019年7月29日 ¥ 648
2019年7月28日 ¥ 648
INFO:root:Following link to next page...
2019年7月28日 ¥ 648
2019年7月28日 ¥ 648
2019年7月26日 ¥ 20,807
2019年7月25日 ¥ 0
2019年6月19日 ¥ 767
2019年6月3日 ¥ 10,092
2019年5月31日 ¥ 5,054
2019年4月15日 ¥ 2,500
2019年4月7日 ¥ 1,800

増補改訂版は好評発売中です。よろしくお願いします!