動的な値を含む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のリポジトリを参照してください。
謝辞
json-fuzzy-matchは Karate というテスト自動化ツールの機能の一つである Fuzzy Matching をJUnitやAssertJから使いやすいようにラップしただけのライブラリです。使えるマーカーもKarateのそのままです。
JSONのテストを書くのは面倒で、日頃から良い方法がないか考えていた中でKarateのFuzzy Matchingに出会いました。このような便利なツールがOSSとして公開されていたことで、JSONのテストを簡単に書けるようになったので、Karateの開発者には非常に感謝しています。この場を借りてお礼申し上げます。
最後に
しばらく勤務先で使っていますが、それなりに便利だと思うので、良ければぜひ使ってみてください。改善点などあれば、IssueやPull Requestなどいただけると嬉しいです。
あるいは、JSONをテストするためのもっと良い方法をご存知であれば、ぜひ教えていただけるとありがたいです。
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
- Map
次のような機能は(あれば嬉しいかもしれませんが)特に求めていません。
最初はなんとなくsql2oを使っていましたが、プロパティの型によっては思い通りにBind/Mapされないことがあり、各種のSQLマッパーがどのような型に対応しているのか真面目に検証してみようと思いました。
検証対象とバージョン
上記の求める機能を持つSQLマッパーとして、次の5つのライブラリを検証しました。
- Apache Commons DbUtils: 1.7
- Spring JDBC Template: 5.1.9.RELEASE
- MyBatis: 3.5.2
- JDBI (with jdbi3-postgres): 3.10.1
- sql2o (with sql2o-postgres): 1.6.0
対象とするデータベースはPostgreSQLです。
- PostgreSQL: 11.5
- PostgreSQL JDBC Driver: 42.2.8
基本的な情報
基本的な情報は次の通りです。最新については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 DbUtilsとSpring 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.Date
とjava.sql.Time
をMapできないのは意外でしたが、LocalDate
やLocalTime
を使えば問題ありません。
普通のプレースホルダーは :name
のような形式ですが、<ids>
という形式のプレースホルダーを使ってbindList()
でList<T>
をBindすると、個数分のカンマ区切りのプレースホルダーに展開してくれるので、IN (<list>)
のような記述が可能です。
Sql2oはMap時に期待する型に変換してくれますが、Date and Time APIには対応していません。
細かいですが、integer
をBigDecimal
で受け取る際は、一度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の型への対応度合いは、次の順で良い結果となりました。
JDBIは、ドキュメントに対応している型が明確に記述されていたのも好感が持てました。試しに使ってみようと思います。
なお、各種ライブラリの検証結果で対応していない型についても、Bind時にSQLの書き方を工夫したり、コンバーターを自作したりすれば対応できるので、ライブラリの評価はこれだけで決まるものではありません。 むしろ、対応している型を把握した上でライブラリを使うことが大切だと思うので、その助けになれば幸いです。 また、この結果はJDBCドライバーの実装に大きく依存するので、他のDBやJDBCドライバーを使う場合は、この結果をそのまま使えないことに注意してください。
なるべく注意を払いましたが、すべてのライブラリにおいてドキュメントやソースコードを完全に読んだわけではないので、検証方法がおかしいところなどありましたら、ぜひお知らせください。
参考文献
Pythonクローリング&スクレイピングの初版の詰まりどころ
Pythonクローリング&スクレイピングの初版(表紙の文字が青い本)は2017年に出版され、現在では動かなくなってしまったコードもあります。
そこで初版を手にしてくださった方のために、主な詰まりどころと対策をまとめておきます。初版のサポートページには細かな変更点も記載されているので、合わせてご確認ください。
2章(2.4以降) urllibで403 Forbiddenになる
問題
Webサイト側の変更により、2.4以降のコードを実行すると403 Forbiddenとなってしまいます。
対策
注釈やサポートページに記載があるように、 https://gihyo.jp/dp
を http://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の注文履歴を取得する方法を解説しているので、参考にしてください。
Headless Chromeの使い方については、次項目の対策を参照してください。
5.6 PhantomJSの開発が終了した
問題
PhantomJSは開発が終了したため、現在ではHeadless Chromeなどを使うことが推奨されています。macOSでは書籍に記載通りのコマンドでインストールできなくなっています。
対策
次の記事でPhantomJSの代わりにHeadless Chromeを使う方法を解説しているので、参考にしてください。
また、増補改訂版のサンプルコード の 5-6
も参照してください。
6章全般 Spiderで意図した通りにデータを抜き出せない
問題
Webサイト側の変更で、動かなくなっているコードがちらほらあります。
対策
増補改訂版のサンプルコード の 6-*
を参照してください。
最後に
増補改訂版ではこれらの問題に対応済みで、解説も新しくなっています。初版にはなかった新しいトピックも追加されているので、よろしければ是非お買い求めください!
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
増補改訂版は好評発売中です。よろしくお願いします!
Pythonクローリング&スクレイピング[増補改訂版] -データ収集・解析のための実践開発ガイド
- 作者: 加藤耕太
- 出版社/メーカー: 技術評論社
- 発売日: 2019/08/10
- メディア: 単行本(ソフトカバー)
- この商品を含むブログを見る
Pythonクローリング&スクレイピングの増補改訂版が出版されます
Pythonクローリング&スクレイピングはおかげさまでご好評いただき、この度、増補改訂版を出版する運びとなりました。紙版は本日8/10発売で、電子書籍版は既に発売中です。
Pythonクローリング&スクレイピング[増補改訂版] -データ収集・解析のための実践開発ガイド
- 作者: 加藤耕太
- 出版社/メーカー: 技術評論社
- 発売日: 2019/08/10
- メディア: 単行本(ソフトカバー)
- この商品を含むブログを見る
クローリングやスクレイピングを扱う書籍は、対象として利用しているWebサイトの変化によって、サンプルコードが動かなくなってしまう宿命があります。初版を執筆した際は、なるべく考え方を伝えるようにすることで、たとえサンプルが動かなくなったとしても役立つ内容にすることを心がけました。
ですが、書いてあるコードがそのまま動くに越したことはありません。今回改訂の機会をいただいたことで、読者の皆様に学びやすい形でお届けできるのを嬉しく思います。
増補改訂版の主な変更点
初版で評価いただいた良い点は残しつつ、全体的に内容をアップデートしています。主な変更点は次のとおりです。
早めにサードパーティライブラリを使う構成に変更
初版では、2章で標準ライブラリのみを使い、3章からサードパーティライブラリを使う構成でした。増補改訂版では、主要なサードパーティライブラリであるRequestsとlxmlを2章から使うように変更しました。これによって、実践的な手法を素早く学べるようになりました。
最新のPython 3.7に対応
初版ではPython 3.4/3.5を使っていましたが、増補改訂版ではPython 3.7を使います。これによって、f-stringsやdictの並び順保持、asyncio.runなどの便利な機能を使えるようになりました。
また、サンプルコードでは積極的に型ヒントを使うようにしました。型ヒントは好みが分かれるかもしれませんが、関数の入出力がわかりやすくなることを重視してつけました。好みでない場合、型ヒントは省略しても問題なく動きます。
Headless Chromeやpyppeteerに対応
初版から2年ほどの間に、ヘッドレスブラウザー周りは大きな変化がありました。PhantomJSがメンテナンスされなくなり、ChromeやFirefoxがヘッドレスモードをサポートしました。増補改訂版でもHeadless Chromeを使うよう変更し、PuppeteerをPythonにポートしたpyppeteerも解説しました。
運用をアップデート
7章で扱う運用もいろいろとアップデートしました。
- Linux OSをUbuntu 14.04 → 18.04に変更し、systemdを前提とした解説に変更
- Scrapy Cloudの解説を追加
- クローリングとスクレイピングの分離ではキューとしてAmazon SQSを使用
- サーバーレスなクローラーの運用についても軽く解説
さいごに
今回、改訂の機会を得られたのは、初版を読んでくださった読者の皆様と、初版の出版に関わってくださった皆様のおかげです。改めてお礼申し上げます。また、増補改訂版のレビュアーの皆様には数多くの指摘をいただき、よりわかりやすく正確な内容へと洗練させることができました。本当にありがとうございました。
増補改訂版をよろしくお願いいたします。
Pythonクローリング&スクレイピング[増補改訂版] -データ収集・解析のための実践開発ガイド
- 作者: 加藤耕太
- 出版社/メーカー: 技術評論社
- 発売日: 2019/08/10
- メディア: 単行本(ソフトカバー)
- この商品を含むブログを見る
Pythonクローリング&スクレイピングの韓国語版が出版されました
表紙のクモは光沢処理されてて綺麗です。
https://wikibook.co.kr/python-for-web-scraping/
原書では日本のWebサイトを多く扱っているので、翻訳して大丈夫なのかと勝手に心配していましたが、韓国のサイトに置き換えられていました。他にも自然言語処理でMeCabを使っている箇所がKoNLPyという韓国語のライブラリに置き換えられているなど、韓国語の読者にとってわかりやすい仕上がりになっているようです。
執筆していた頃は翻訳されるなどとは思ってもみませんでしたが、訳書を通してさらに多くの読者に役立ててもらえたら嬉しいです。