ActiveRecordを使ってRedshiftから大量のデータを効率的に読み出す

こんにちは、トレンド調査ラボの井上寛之(@inohiro)です。 普段は、クックパッドの検索ログを基にした法人向けデータサービス「たべみる」の開発や、 広告事業周辺のデータ分析などを担当しています。

Amazon Redshiftなどのデータベースに蓄積されたログなどの大量のデータに対して、 日次や週次などの単位でバッチ処理を行っている方は多くいらっしゃると思います。 ログなどを扱うバッチ処理では、処理対象が膨大であるとアプリケーションが使うメモリが増大し、 枯渇してしまう恐れもあるため、データの扱いに気をつける必要があります。 データベース内で完結するバッチ処理ならばそこまで気にする必要は無いかもしれませんが、 外部のプログラムからデータを読み出して処理する場合は特に注意が必要です。

そこで考えられる一つの工夫として、処理対象を分割して、繰り返して処理を行う方法が挙げられます。 一般的なRDBMSが備えるカーソルと呼ばれる機能を利用することで、処理対象を分割して少しずつ処理することができます。

本稿では、特に Rails(ActiveRecord)を使って書かれたAmazon Redshiftを利用するようなバッチ処理において、 カーソル機能を簡単に利用できる "redshift_cursor" gem を紹介します。

まずカーソルについて、もう少し詳しく説明したいと思います。

そもそもカーソルって?

カーソルはデータベースからデータを得るする際に、一度にすべてのデータを読み出すのではなく、 ある程度の単位(行数)に分けて読み出すための仕組みです。イテレータのように動作することで、 アプリケーション側のメモリの枯渇を防ぐことができます。

PostgreSQLやMySQLなどの、一般的なRDBMSにはカーソル機能が備わっていて、すぐに使うことができます。 もちろんRedshiftにもあります。それぞれのRDBMSのカーソルについては以下を参照してください。

以下は、PostgreSQLで、カーソルを使って大きな結果から10行ずつ読む例です。

begin ; -- カーソルはトランザクションの中で使う
declare sample_cursor cursor for -- カーソルを宣言
    select title
    from recipes
    where title like '%トマト%' ;

fetch 10 from sample_cursor ; -- 最初の10件を得る
fetch 10 from sample_cursor ; -- 次の10件を得る
-- 必要なだけ繰り返す

close sample_cursor ; -- カーソルを閉じる
commit ;

Railsにおけるカーソル的な処理

大量のクエリ結果を少しずつ取り出して処理を行う場合、 Railsだと ActiveRecord::Batches.find_each.find_in_batches を利用する方も多いのではないでしょうか。 .find_each および .find_in_batches の詳しい説明は割愛しますが、 これらのメソッドを使う際は、以下の点で注意が必要です。

  • ソートカラムが指定できない(プライマリキー(大抵 id カラム)でソートされる)
    • 特にログ系のテーブルだと id カラムが付いてなかったり、そもそもプライマリーキーが設定されていないこともある
    • また、日付カラムがソートキーとなっている可能性が高く、意図しないキーで大量の行をソートしてしまうおそれがある
  • プライマリキーが必ず数値型である必要がある
  • チャンク毎にクエリが何度も再実行される
    • カーソルはクエリを一度だけ実行し、結果をチャンクに分けて返す

ソートカラムが明示的に指定されていない状態で、チャンク毎にクエリが再実行されると、 得られた結果が正しくない可能性も考えられます。 またチャンク数分、同じようなクエリが発行されるので非効率とも言えます。 以上のことから、カーソルを利用する方がパフォーマンスや信頼性の面で良いと言えます。

redshift_cursor

さて、カーソルについて簡単に説明しましたが、ここからが本題です。 今回紹介するredshift_cursor gemは、 Rails(ActiveRecord)でRedshiftに接続して大量の行を得るような場合に、 カーソルの構文を覚えなくても、カーソルを透過的に利用できるようにするgemです。 redshift_cursorは実際にクックパッドの一部のバッチジョブで、ログの集計やユーザーの抽出に利用されています。

以下、この gem の使い方を簡単に説明します。

まず Gemfile に記述して bundle install します。

# Gemfile
gem 'redshift_cursor'

すると、各モデルで.each_row, .each_instance, .each_row_by_sql, .each_instance_by_sql などのメソッドが使えるようになります。 .each_row, .each_row_by_sql は結果をハッシュの配列で、.each_instance, .each_instance_by_sql は結果をレシーバークラスのインスタンスの配列で返します。

Recipe.where(id: 3199605).each_row.fitst
=> {"id"=>"3199605", "title"=> "簡単 生地なし!キヌアキッシュ", ... }

Recipe.where(id: 3199605).each_instance.first
=> #<Recipe:0x007fe5260eeaa8 id: 3199605, title: "簡単 生地なし!キヌアキッシュ", ...>

これらのメソッドは Enumerable を返すので、結果に対して .map.each など使うこともできます。 以下、利用例です。

# タイトルが「ほうれん草」にマッチするようなレシピ
Recipe.where('title like ?', '%ほうれん草%').each_insntace.map {|recipe| recipe.title }

# 必要なカラムがタイトルだけなら
Recipe.where('title like ?', '%トマト%').select(:title).each_row.map {|recipe| recipe['title'] ... }

# 条件や順序をSQLで書く
Recipe.each_instance_by_sql('select * from recipe where ... order ...').map {|recipe| recipe.created_at }

# ヒアドキュメントでSQLを書く
SearchLog.each_row_by_sql(<<~SQL
    select
        title
        , count(*) as pv
    from
        search_logs
    where
        keyword like '%ズッキーニ%'
        and log_time between ...
    group by
        title
SQL
).each {|log| log['pv'] ... }

.each_row_by_sql, .each_instance_by_sql では Array Condition が使えないことに注意)

上記のコードは、それぞれカーソルを使ったクエリに書き換えられ、 利用者はカーソルの構文や仕組みを新たに覚えなくても、大量のデータを効率的に扱うことができます。

実装

実はredshift_cursorの大部分は、postgresql_cursor gemを利用しています。 redshift_cursorは、activeRecord4-redshift-adapterを使ってRailsからRedshiftに接続している時に、postgresql_cursorが正しく使えるように互換性を追加しています。

まとめ

本稿では、バッチ処理等で大量のデータを読み込む際にアプリケーション側の負荷やパフォーマンスを改善する カーソルについて説明しました。

またRails(ActiveRecord)を使って書かれたAmazon Redshiftを利用するようなバッチ処理において、 カーソル機能を簡単に利用できる "redshift_cursor" gem を紹介しました。 「postgresql_cursor」というよく出来たPostgreSQL向けのgemをRedshiftでも使えるようにしたgemです。

Gemfileに追加して、ActiveRecordのメソッドとよく似たメソッドで、 カーソルを使ったクエリを簡単に発行することができます。 ぜひログなどの大量のデータをバッチ処理するときにご活用ください。

追記(2016/07/12)

(ブコメでもご指摘いただいておりますが、)10万行を超えるような結果を読み出すならば、 カーソルではなく UNLOAD コマンドを使いましょう。

社内でもこのポリシーで運用しています。

そのメールアドレス、現在も使っていますか?

こんにちは。ユーザーファースト推進室ディレクターの大黒です。

ありがたいことにクックパッドは今年で20年目をむかえ、数多くのユーザーに利用されるまでに成長しました。それ故に発生する課題もあり、今回はその中でもユーザー登録に使われているメールアドレスの課題と対策をご紹介したいと思います。

ユーザー登録の仕組み

クックパッドのユーザー登録では、下記の項目が必要となります。

  • メールアドレス
  • パスワード
  • 郵便番号
  • 生年月日

※iOSアプリでは郵便番号と生年月日は任意入力となります

f:id:kotsuru0812:20160628234150p:plain

SNSアカウント認証や認証コードでのアクティベートを採用するサービスが今では主流ですが、20年続くサービスであれば一般的なユーザー登録フローではないでしょうか。しかしながら最近のスマートフォンユーザーの多くはメールを使わないという実態も分かっているため、ユーザー登録にメールアドレスを使い続けるかどうかは、別途議論を進めているところです。

メールアドレスの課題

一度取得したメールアドレスを生涯にわたって使い続けるという人は多くありません。SNSやメッセージアプリが普及した今では、メールでやり取りをすることが少なくなり、ナンバーポータビリティが可能になったことも相まって、キャリアのメールアドレスが変わることへの抵抗感はほぼありません。また、登録した時のメールアドレスとパスワードさえ覚えていれば、実際にそのメールアドレスを使っていなくてもログインできるので、メールアドレスを変更せずに使い続けるという人も出てきます。

例えば私たちが把握しているメールアドレスの課題としては、次のようなものを挙げることができます。

3〜6ヶ月でメールアドレスは再利用される

キャリアやプロバイダーにもよりますが、使われなくなったメールアドレスは3〜6ヶ月程度の保留期間を経て、再利用が可能になります。メールが普及してからの年月を考えると、多くのメールアドレスが再利用されていると予想されます。

登録できない人がでてくる

クックパッドでは、1つのメールアドレスで作れるアカウントの数は1つだけです。もしユーザーAさんが取得したメールアドレスが、過去にクックパッドのユーザー登録に使われていて、誰かがメールアドレスを変更せずに使い続けているとしたら、その人がメールアドレスを変更するか、ユーザーAさんが別のメールアドレスを取得しないとユーザー登録ができないことになります。

以上のことをふまえると、フィーチャーフォン全盛期からサービスを続けているクックパッドとしては、ユーザー登録、もしくはメールアドレス変更から一定期間経過しているユーザーに対して、登録しているメールアドレスを現在も使っているかどうかを確認することが大切だと考えました。

最小の実装で確認施策を行う

ユーザーにメールアドレスが最新であるかどうかを確認するような施策は、他社ではあまり事例がなく、社内でも知見が全くない状態から始まりました。このような場合はなるべく既存の仕組みを使い、対象者をできるだけ絞り込んでコンパクトなサイズでまず試してみることが大切です。

重要なお知らせという仕組み

クックパッドには返金などが必要になったユーザーに対して、案内を通知する仕組みを持っています。これはアプリで使われている仕組みですが、PUSH通知とアプリのトップスクリーンに導線が表示されるため、ユーザーに内容を確認してもらえる確率が非常に高くなります。今回はこの仕組みの一部を使い、何度かテストしてみることにしました。

f:id:kotsuru0812:20160628234542p:plain

対象者を定義する

メールアドレスの課題を考慮すると、キャリアのメールアドレスで登録、またはキャリアのメールアドレスに変更してから一定期間経過しているユーザーの場合、機種変更などによりメールアドレスが変わっている可能性が高いという仮説がたてられます。あとは一定期間をどうするかですが、メールアドレスの保留期間が3〜6ヶ月程度なのを考慮すると、6ヶ月以上経過している人たちを対象とした方がよさそうということになりました。

施策の実施

f:id:kotsuru0812:20160628234637p:plain

施策の評価は下記の数値を元に行いました。

  • 変更率
    メールアドレスをいまは使っていないと判断し、新しいメールアドレスの登録が完了したユーザーの割合

  • 変更手続き中
    メールアドレスをいまは使っていないと判断し、新しいメールアドレスの登録が完了していないユーザーの割合

  • アクション率
    上記の変更率と変更手続き中に加え、「使っているメールアドレスです」と明示的に回答してくれたユーザーの割合

第1回テスト

PUSH通知を大量に配信すると、サイトに思わぬ負荷がかかる場合があるので、インフラ面でも問題がないことを確認する必要があります。なので最初のテストでは、無事にPUSH通知が配信できるか、ユーザーがPUSH通知を開いてくれるかという点を中心に検証しました。

配信数 変更率 変更手続き中 アクション率
300 1% 1% 35%

結果を見てみると、多くのユーザーがPUSH通知に反応してくれたことが分かります。インフラ面でも問題がないことが分かったため、今度はメールアドレスを変更したい人たちが無事に変更することができるかを検証することにしました。

第2回テスト

このテストではある程度配信数を増やし、メールアドレスを変更するユーザーが多くなるよう抽出条件を変更し、メールアドレスの更新が古いユーザーを対象としました。

配信数 変更率 変更手続き中 アクション率
1,000 5.1% 1.8% 40.3%

結果としては変更手続き中で止まっているユーザーが、前回と同じくらいの割合でいることが分かります。このことからメールアドレス変更フローを見直してみたところ、ユーザーが躓くポイントがあることが判明したため、既存のメールアドレス変更フローを改善することにしました。

メールアドレスを変更するには、パスワードの入力が必要になります。登録しているメールアドレスを現在使っておらず、パスワードを忘れてしまうと、パスワードの再設定を行うことができないため、メールアドレスの変更ができずフローの途中で止まってしまいます。

改善前 f:id:kotsuru0812:20160628234726p:plain

別件でメールアドレス変更フローの改善を行った際に、パスワードを忘れてしまった場合の考慮が漏れていました。この場合、サポートにお問い合わせをして本人確認を行うことで、パスワードの再設定をすることができるので、パスワード入力画面にお問い合わせへのリンクを設置することにしました。

改善後 f:id:kotsuru0812:20160628234814p:plain

この改善を行った結果、この導線から1日平均約3件のお問い合わせがありました。言い換えると、メールアドレスを変更しようと思ったがパスワードが分からず、どうしていいか困っていた人が1日平均約3人ほどいたということになります。

第2回のテスト結果としてはアクション率は前回と同じくらい高く、PUSH通知に対してのネガティブなご意見もなかったため、メールアドレス変更フローの改善を行い、施策としてはこのまま進めるという判断になりました。

まとめ

実際に登録しているメールアドレスを現在も使っているかどうか確認してみると、「使ってるメールアドレスです」というアクションをしてくれたユーザーもたくさんいて、ユーザーにとっては非常に関心が高いことだと分かりました。提供するサービスにもよりますが、サービス側から定期的に「登録してるメールアドレス使ってる?」と聞いてあげることは、とても大事なことです。しかしながら冒頭にも書いた通り、いつまでユーザー登録にメールアドレスを必須にするかについては引き続き議論を重ね、今後の改善に繋げていきたいと思っています。

実践 Pact:マイクロサービス時代のテストツール

技術部の taiki45 です。

以前「サービス分割時の複雑性に対処する: テスト戦略の話」という記事で、サービス間のインテグレーションテストにおける問題について紹介しました。現在のクックパッドではこの問題の解決のために Pact というツールを導入して運用しています。この記事では、その運用の知見を紹介できればと思います。

Pact

Pact は Consumer-Driven Contract testing (CDC testing) を実現するためのツールです。"Consumer"、"Provider" という見慣れない単語が出てきますが、この記事ではだいたい「Consumer = Web API クライアント」、「Provider = Web API サーバー」と対応ができます。この記事では具体的な Pact の利用例を通じて CDC testing がどういうものなのかについても紹介します。

必要になった背景

クックパッドでは、今までサービス間連携(他サービスの呼び出し結果を使うこと)の部分のテストには主に WebMock を使って書いていました。しかし、WebMock だと Provider が更新されても Consumer のスタブデータは更新されないので Provider 側の変更に Consumer のテストは追随できません。定期的にスタブデータを更新したり、RackVCR を使って Provider 側が変更されたら Consumer のスタブデータを更新することもできますが、それだとあくまで Consumer でのテストであるため、Provider が意図しない破壊的変更をリリースすることは防げません。それを防ぐには Provider のテストで Consumer に対する破壊的変更を検知できる必要があります。

そこで、Consumer の期待する振る舞いをプログラムから扱えるデータとして表現して、Provider に渡し、Provider 側でその「Consumer が期待する振る舞い」を満たしているか検証する手法で、Provider の変更がサービス間の連携を壊さないことを確かめることができます。この「Consumer が期待する振る舞い」を "Consumer-Driven Contract (CDC)" と呼んでいます。そして、この手法を実装に落とし込んだツールが Pact です。

Pact の仕組み

Pact では CDC は "pact file" と呼ばれるファイルへと JSON フォーマットで出力されて、Consumer の CI 時に生成されます。Consumer は CI 時に毎回 pact_broker と呼ばれるアプリケーションに pact file を "publish" し、pact_broker がバージョンや publish 日時といったメタデータと共に保管します。Provider は CI 時に pact_broker から自身が関連する Consumer が publish した pact file をチェックアウトして、新しい変更が Contract を壊していないか検証します。このテスト自体は pact file から RSpec の example を生成して実行することで実現されています。それぞれの example をパスさせるためには Provider のデータのセットアップが必要になることが多く、それは "Provider State" と呼ばれる機能で実現されています。Provider State は、Consumer が状態に一意な名前を付けて、Provider はその状態を再現するためのコールバックを登録できる仕組みです。

実際のフローは以下のようになります:

  1. Consumer プロジェクトがテスト内で Contract を記述する。Contract はリクエストと、そのリクエストに対応するレスポンスと期待する副作用が含まれる。
  2. Consumer のテスト実行時にモックサーバーを起動させる。Consumer は HTTP クライアントの向き先を起動したモックサーバーに向けてリクエストを発行する。モックサーバーは登録された Contract を元にレスポンスを返す。
  3. テスト成功後に Consumer は定義した Contract を JSON 形式でファイルに書き出し、pact_broker と呼ばれるサーバーにアップロードする。
  4. Provider は CI で pact_broker から関係する Consumer(s) がアップロードした Contract をダウンロードして、その Contract 通りに自身が振る舞うかテストを実行する。

work_flow_of_pact

https://github.com/realestate-com-au/pact/blob/v1.9.2/README.md より

実際のコードを見たほうがより理解しやすいと思うので、ここで示します。

次のような Consumer の期待が存在するケースを例にします:

「レシピが2つ存在している前提で、Consumer が Provider の /v1/recipes に対して GET リクエストを送信した時に、Provider は [recipe_a, recipe_b] となる構造や値のデータを Content-Type: application/json のようなヘッダー値とともにレスポンスする」

このケースでは Consumer 側のテストで以下のように Pact を使って HTTP リクエストをモックするとともに、Contract を記述できます:

RSpec.describe Recipe, pact: true do
  before do
    allow(described_class.client).to receive(:endpoint).and_return(provider_app.mock_service_base_url)
  end

  describe 'get_all' do
    let(:recipe_a) { { id: Pact.like(1), name: Pact.like('Curry') } }
    let(:recipe_b) { { id: Pact.like(2), name: Pact.like('Salada') } }

    before do
      provider_app.given('there are 2 recipes').
        upon_receiving('a request for recipes').
        with(method: :get, path: '/v1/recipes').
        will_respond_with(
          status: 200,
          headers: {
            'Content-Type' => Pact.term(generate: 'application/json', matcher: %r{application/json}),
          },
          body: [recipe_a, recipe_b]
        )
    end

    it 'returns recipes' do
      recipes = described_class.get_all
      expect(recipes.size).to eq(2)
      expect(recipes.first.name).to eq('Curry')
    end
  end
end

上記コードでは、まず、HTTP クライアントの API call 先をモックサーバーに向けています。その後、Pact のモックサーバーに対して Contract の登録をしています。テストケース内の described_class.get_all が評価されると、実際に HTTP リクエストがモックサーバーに送信され、セットアップしたレスポンスが返却されます。以上の流れでこのテストは成功します。

先ほどのテストを実行すると pact file が生成されます。pact file には Consumer/Provider の情報、Consumer が期待するリクエスト/レスポンスが含まれています:

{
  "consumer": {
    "name": "ConsumerApp"
  },
  "provider": {
    "name": "ProviderApp"
  },
  "interactions": [
    {
      "description": "a request for recipes",
      "provider_state": "there are 2 recipes",
      "request": {
        "method": "get",
        "path": "/v1/recipes",
        "query": "fields=media%5Bcustom%5D&image_size[recipe]=280"
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": {
            "json_class": "Pact::Term",
            "data": {
              "generate": "application/json",
              "matcher": {
                "json_class": "Regexp",
                "o": 0,
                "s": "application/json"
              }
            }
          }
        },
        "body": [
          {
            "id": {
              "json_class": "Pact::SomethingLike",
              "contents": 1
            },
            "name": {
              "json_class": "Pact::SomethingLike",
              "contents": "Curry"
            }
          },
          {
            "id": {
              "json_class": "Pact::SomethingLike",
              "contents": 2
            },
            "name": {
              "json_class": "Pact::SomethingLike",
              "contents": "Salada"
            }
          }
        ]
      }
    }
  ],
  "metadata": {
    "pactSpecificationVersion": "1.0.0"
  }
}

Provider 側では、この pact file を使って自身が振る舞うかテストを実行します。 Pact を使った CDC testing では、単体テストと同じように、Provider の検証テスト実行毎に環境を初期化します。そのため、Pact が "Provider States" という前提条件を記述する仕組みを提供していて、Provider の検証時にはその仕組みを使ってテスト時にデータのセットアップをします。上記の例では、there are 2 recipes という文字列が Consumer から指定され、Provider はその前提条件をセットアップするためのロジックを記述します:

Pact.provider_states_for 'ConsumerApp' do
  provider_state "there are 2 recipes" do
    set_up do
      %w[Curry Salada].each {|name| Recipe.create!(name: name) }
    end
  end
end

データのセットアップロジックが揃うと、後は Pact が提供する Rake タスクを使って Provider の振る舞いを検証するテストを実行します。テストが成功することを確認して一連のワークフローが完了します。

ここでは一部の Pact を使った CDC testing の一部のコード例を示しましたが、より詳細なコード例は Pact の README にあります。

Consumer のテストで WebMock などを使って HTTP リクエストを単にスタブするのに似ていて、Pact はさらにそこに Consumer が暗黙に期待していた内容をやりとりできるように pact file に落としこむ点が加わっています。

Pact というツールは、コアとなる機能を複数の言語実装から利用できるように、いくつかのコンポーネントに分割されています。ここでは主に Ruby 実装である pact gem を中心に、その周辺のコンポーネントと合わせて紹介します。

pact gem

https://github.com/realestate-com-au/pact

Ruby 用の Pact ライブラリです。各種設定機能や、Consumer のテストで Contract を定義するための DSL が実装されています。また、Contract を読み込んで、それをもとに RSpec のテストを自動で行うことによって Provider を検証する機能が実装されています。

pact file の生成やモックの登録や検証といった主要な機能は後述の pact-mock_service gem で実装されています。

pact-mock_service gem

https://github.com/bethesque/pact-mock_service

上述のように Pact のコアとなるような機能が実装されています。

  • Consumer が期待するリクエスト、レスポンスの組の登録を受け付ける
  • Web サーバーとして動作し、リクエストを受け付けて登録されたレスポンスを返却する
  • 登録されたリクエストが期待するパラメータで呼ばれたどうかを検証する
  • pact file を生成する

pact-mock_service はコマンドライン経由で起動できます。また、Ruby のプロセス内から実行することもできます。

pact-mock_service の役割

典型的なリクエスト/レスポンスを見てみると pact-mock_service の役割と Pact の仕組みについてわかりが得やすいのでここで試します。

まず、モックサーバーを起動します:

$ pact-mock-service service --pact-dir=. --port=3000

とりあえずヘルスチェックをします:

$ curl -H 'X-PACT-MOCK_SERVICE: 1' -v http://localhost:3000
* Rebuilt URL to: http://localhost:3000/
*   Trying ::1...
* Connected to localhost (::1) port 3000 (#0)
> GET / HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.43.0
> Accept: */*
> X-PACT-MOCK_SERVICE: 1
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Access-Control-Allow-Origin: *
< X-Pact-Mock-Service-Location: http://localhost:3000
< Server: WEBrick/1.3.1 (Ruby/2.3.0/2015-12-25) OpenSSL/1.0.2e
< Date: Tue, 21 Jun 2016 15:20:02 GMT
< Content-Length: 20
< Connection: Keep-Alive
<
* Connection #0 to host localhost left intact
Mock service running

Pact では Provider/Consumer 間で期待されるリクエスト/レスポンスの組を Interaction と呼びます。Pact の Contract は、Provider や Consumer に関する情報の他に、複数の Interaction から構成されています。

ここでその Interaction を1つ登録します:

$ curl -X POST -H 'X-PACT-MOCK_SERVICE: 1' -H 'Content-Type: application/json' \
  http://localhost:3000/interactions \
  -d '{"description": "a request for recipes", "request": {"method": "get", "path": "/recipes"}, "response": {"status": 200, "headers": {"Content-Type": "application/json"}, "body": "[]"} }'

Set interactions

登録した Interaction が呼び出されたかどうか検証します。ここではまだ呼び出してないのでエラーが返ります:

$ curl -H 'X-PACT-MOCK_SERVICE: 1' -v 'http://localhost:3000/interactions/verification?example_description=a%20request%20for%20recipes'
*   Trying ::1...
* Connected to localhost (::1) port 3000 (#0)
> GET /interactions/verification?example_description=a%20request%20for%20recipes HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.43.0
> Accept: */*
> X-PACT-MOCK_SERVICE: 1
>
< HTTP/1.1 500 Internal Server Error
< Content-Type: text/plain
< Access-Control-Allow-Origin: *
< X-Pact-Mock-Service-Location: http://localhost:3000
< Server: WEBrick/1.3.1 (Ruby/2.3.0/2015-12-25) OpenSSL/1.0.2e
< Date: Tue, 21 Jun 2016 15:36:26 GMT
< Content-Length: 144
< Connection: Keep-Alive
<
Actual interactions do not match expected interactions for mock MockService.

Missing requests:
    GET /recipes

* Connection #0 to host localhost left intact
See standard out/err for details.

登録した Interaction を呼び出します:

curl http://localhost:3000/recipes

[]

もう一度 Interaction が呼び出されたか検証します。すでに呼び出しているので今回は成功レスポンスになります:

curl -H 'X-PACT-MOCK_SERVICE: 1' -v 'http://localhost:3000/interactions/verification?example_description=a%20request%20for%20recipes'
*   Trying ::1...
* Connected to localhost (::1) port 3000 (#0)
> GET /interactions/verification?example_description=a%20request%20for%20recipes HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.43.0
> Accept: */*
> X-PACT-MOCK_SERVICE: 1
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Access-Control-Allow-Origin: *
< X-Pact-Mock-Service-Location: http://localhost:3000
< Server: WEBrick/1.3.1 (Ruby/2.3.0/2015-12-25) OpenSSL/1.0.2e
< Date: Tue, 21 Jun 2016 15:36:54 GMT
< Content-Length: 20
< Connection: Keep-Alive
<
* Connection #0 to host localhost left intact
Interactions matched

pact file の生成リクエストを送ります:

$ curl -X POST -H 'X-PACT-MOCK_SERVICE: 1' -H 'Content-Type: application/json' http://localhost:3000/pact -d '{"consumer": {"name": "A Consumer"}, "provider": {"name": "A Provider"}}'

{
  "consumer": {
    "name": "A Consumer"
  },
  "provider": {
    "name": "A Provider"
  },
  "interactions": [
    {
      "description": "a request for recipes",
      "request": {
        "method": "get",
        "path": "/recipes"
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/json"
        },
        "body": "[]"
      }
    }
  ],
  "metadata": {
    "pactSpecificationVersion": "1.0.0"
  }
* Connection #0 to host localhost left intact
}

pact file が ${consumer_name}-${provider_name}.json という命名規則で書き出されています:

$ cat a_consumer-a_provider.json
{
  "consumer": {
    "name": "A Consumer"
  },
  "provider": {
    "name": "A Provider"
  },
  "interactions": [
    {
      "description": "a request for recipes",
      "request": {
        "method": "get",
        "path": "/recipes"
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/json"
        },
        "body": "[]"
      }
    }
  ],
  "metadata": {
    "pactSpecificationVersion": "1.0.0"
  }
}

pact gem は Consumer のテスト実行時に DSL で登録された内容を元に、このようなリクエストをモックサーバーに対して実行することで Pact の機能を実現しています。

pact-mock_service gem は pact 以外からも利用できるように設計されているので、Ruby 以外の言語で Pact の Consumer を実装する時に pact-mock_service が利用できます。例として pact-consumer-swift という Swift 向けの Pact Consumer ライブラリがあります。 クックパッドでも pact-mock_service を利用した Swift 向けの Pact Consumer ライブラリを作成しました。そのうち公開されると思います。

また pact-mock_service 相当の機能を独自に実装しているような Pact ライブラリも存在します。例として pact-jvm があります。

pact_broker

https://github.com/bethesque/pact_broker

Consumer と Provider が pact file をやりとりするための仲介サーバーです。 Pact を使って CDC testing を実行するのに pact_broker は必須でありません。例えば Consumer と Provider を同じチームが開発している場合、手元での開発中は pact file をローカルのファイルシステムのどこかに置き、pact verification 時にそのファイルパスを指定する、ということもできます。しかし、複数の CI サーバーで各 Consumer/Provider の CI が実行されるような環境では、ストレージは独立していたほうが使い勝手が良く、かつ pact file のバージョンや更新日時や差分などのメタデータも合わせて管理できたほうが便利です。なので pact_broker という仲介サーバーが提供されています。

上述した機能の他に、各 Consumer/Provider 間の関係を可視化する機能が用意されていて、意外と便利です:

network_diagram

https://github.com/bethesque/pact_broker/blob/v1.9.2/README.md より

pact_broker は Rack アプリケーションとして実装され gem として提供されています。公式に Docker イメージも提供されています。

また Ruby 向けクライアントライブラリとして pact_broker-client が提供されています。

後述しますが、クックパッドでも pact_broker サーバーを用意して Provider/Consumer 間のやりとりに利用しています。

pact-specification

https://github.com/pact-foundation/pact-specification

Pact における Contract のデータ構造とマッチングの仕様に関するテストケース群が集まっているリポジトリです。 Ruby 実装の pact gem 以外に、pact-jvm など、他言語による実装がありますが、仕様に沿って実装されている限り Consumer と Provider の言語が異なっても互換性があるようになっています。

クックパッドでの Pact の運用

基本的には Pact が想定する使い方やワークフローを採用しています。Consumer の CI で pact file を生成し pact_broker へ publish、Provider の CI で pact verification を実行しています。

手元ではデフォルトで pact file を生成しない

開発者の手元でテストを実行する時には pact file を生成する必要がないので、pact file を生成しないようにしています。CI ジョブでテストを実行する時、もしくは明示的に指定された時のみ生成するようにしています。

pact file を生成しないオプションを追加するパッチを送り、環境変数でそのオプションを制御しています。

pact verification の結果を見やすくする

規模が大きいアプリケーションでは、pact verification が失敗した時に出力が多く、Jenkins のコンソール画面だと内容が把握しにくい問題がありました。そこで JUnit format で検証結果を出力する gem を作り verification 失敗時に素早く内容を把握できるようにしています。

pact file のバージョニング

Pact では pact file を pact_broker にアップロードする際にバージョンをつけることができます。pact_broker では x.y.z のようなよくあるバージョニング以外に、独自にバージョニングロジックを定義することができます。 クックパッドでは頻繁にリリースを行うため、バージョンを表すのに単に git revision hash が良く使われています。git revision hash 単体ではバージョン番号として順序を表すのに不適当なのと、日時を得るまでに1ステップ必要になってしまうので、日時と git revision hash 値を連結した値を pact_broker におけるバージョンとして採用しています。

pact_broker の運用

公式に Docker イメージが公開されているのですが、上記のバージョニングロジックや、ヘルスチェックエンドポイントの追加など、いくつか変更する箇所があったのと、社内にすでに Web アプリケーション用のベースイメージが存在しているので、独自にイメージを作成してそれを利用しています。

pact_broker には内部からのみアクセスできるようにアクセス制御をかけています。データベースには RDS 上の PostgreSQL インスタンスを利用しています。また、CI 環境から publish する本番環境と、チュートリアルやテスト用途に利用できるサンドボックス環境を用意しています。

動的なポート確保

Pact のチュートリアルでは Consumer がモックサービスを設定する際に固定なポート番号を指定しています。ポート番号を手で管理するのは面倒なので、動的なポート確保機能を使うようにしています。

以前の pact-mock_service でも find_a_port gem を使用して動的に空いてるポートを確保する実装が存在していたのですが、社内にある RRRSpec を利用して分散テスト実行しているアプリケーションのテストで webkit-server が起動しないなどの不具合があったため、モックサーバーを起動する時に port 0 に bind する実装に修正した上で pact gem からも使えるようにしました。

Remote Facade パターンと pact-expectations

サービス間通信を利用するようなアプリケーションでは設計のガイドラインを設けていて、Remote Facade パターンを推奨しています。特にテストで Pact を使う際は Contract の数や種類を極力減らしたいので、様々な層のテストで個別に Pact を使って HTTP リクエストをスタブするのではなく、Remote Facade のテストでのみ Pact を使い、その他の層では Remote Facade をモックするようにしています。

Remote Facade パターンを単純に適用するだけだと、Pact を使わずにモックしている箇所でその Expectation が検証されない問題がありました。Pact で記述する Expectation をそのまま Remote Facade をモックする際にレスポンスとして利用することでその問題を解決し、人事部長氏 の手によって pact-expectations gem という実装が作られ、利用しています。

集約的ライブラリの作成

上記のようなバージョニングロジックや、Pact を利用するのに必要なヘルパーの初期生成や、Railtie を使った Zero-Configuration 化などをするために、集約的ライブラリを作成しています。主に社内向けの設定を出力するので、このライブラリは公開していません。

まとめ

Consumer-Driven Contract testing を実現する Pact というツールについてと、クックパッドにおける Pact の運用について紹介しました。

今回 Pact を導入するにあたり、遭遇した不具合を修正したり機能を追加しました。例えば、UTF-8 への対応や、Expectation 内の query をネストできるようにすることや、pact verification の際に pact file の取得をリトライするようにするなど。みなさんが Pact を導入する際に、この記事及び Pact への貢献がお役に立てると幸いです。

クックパッドでは自由に OSS に貢献することのできる環境や文化があります。共にワイワイ OSS に貢献していく仲間を募集してます。応募をお待ちしています。

http://recruit.cookpad.com/jobs/career_recruitment


One more thing: 8月の iOS Developers Conference Japan で、トークが採択されれば Pact の話をするようなので、興味のある方は是非。

https://iosdc.jp/2016/c/node/62