orangain flavor

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

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

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

Pythonクローリング&スクレイピングの増補改訂版が出版されます

Pythonクローリング&スクレイピングはおかげさまでご好評いただき、この度、増補改訂版を出版する運びとなりました。紙版は本日8/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がメンテナンスされなくなり、ChromeFirefoxがヘッドレスモードをサポートしました。増補改訂版でもHeadless Chromeを使うよう変更し、PuppeteerをPythonにポートしたpyppeteerも解説しました。

運用をアップデート

7章で扱う運用もいろいろとアップデートしました。

  • Linux OSをUbuntu 14.04 → 18.04に変更し、systemdを前提とした解説に変更
  • Scrapy Cloudの解説を追加
  • クローリングとスクレイピングの分離ではキューとしてAmazon SQSを使用
  • サーバーレスなクローラーの運用についても軽く解説

さいごに

今回、改訂の機会を得られたのは、初版を読んでくださった読者の皆様と、初版の出版に関わってくださった皆様のおかげです。改めてお礼申し上げます。また、増補改訂版のレビュアーの皆様には数多くの指摘をいただき、よりわかりやすく正確な内容へと洗練させることができました。本当にありがとうございました。

増補改訂版をよろしくお願いいたします。

Pythonクローリング&スクレイピングの韓国語版が出版されました

表紙のクモは光沢処理されてて綺麗です。

f:id:mi_kattun:20190321181120j:plain

https://wikibook.co.kr/python-for-web-scraping/

原書では日本のWebサイトを多く扱っているので、翻訳して大丈夫なのかと勝手に心配していましたが、韓国のサイトに置き換えられていました。他にも自然言語処理MeCabを使っている箇所がKoNLPyという韓国語のライブラリに置き換えられているなど、韓国語の読者にとってわかりやすい仕上がりになっているようです。

執筆していた頃は翻訳されるなどとは思ってもみませんでしたが、訳書を通してさらに多くの読者に役立ててもらえたら嬉しいです。

2018年を振り返って

主に子育てで時間の取れない生活が続いており、1つ前の記事が2017年の振り返りなのは非常に良くないですが、少なくとも1年に1回は書けてよかったです。

2018年を振り返って

今年は転職したのが大きなイベントでした。まあ楽しくやってますということで、勤務先のブログに記事を書きました。

buildersbox.corp-sansan.com

個人開発では、eBook-1IT技術書の横断検索サイトにリニューアルしたり、僕の考えた最強のランキングを簡単に作れるBOKURANを開発したりしてました。

昨年の振り返りで書いてたSpotlight Englishは、転職後にAirPodsを買ったこともあり、よく聴けてます。

2019年に向けて

とりあえず現在仕掛かり中のお仕事が終われば落ち着くと思うので、楽しく過ごせればと思います。

加齢とともに夜遅くまで起きているのが難しくなってきているので、時間を有効活用できるよういろいろ改善したいです。

勤務先では、チームには馴染めているので、来年はチームを超えてインパクトを与えていきたいです。

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