実践 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

より良いデザインにするために大切にしたいと思っていること

こんにちは。デザイナーの遠藤です。 私は今クックパッドiOS/Androidアプリのデザインを担当しています。

みなさんは、既存の機能を別のプラットフォームに追加する際に、あまり考えずにそのまま追加してしまい、後で後悔したことはないでしょうか?今回は、web版にある機能「料理の基本」をクックパッドアプリへ追加した時のことを交えて、より良いデザインにするために大切にしたいと思っていることをご紹介します。

料理の基本について

レシピをみて料理を作っている際に、「あれ、半月切りって何だっけ?」「水にさらすってどれだけやればいいんだろう」のような、基本的なことさえわからず手が止まってしまったことはありませんか?クックパッドでは、そんな方のために料理の基本を提供しています。今回私は、この機能をiOS/Androidアプリのレシピページに追加する際、デザインを担当しました。

この機能の使い方は、レシピ中の手順欄にある言葉から利用できます。 例えば、スマートフォンwebの場合、レシピ中にところどころ薄く下線が引いてあります。 その下線の部分をクリックすると、詳しく内容が見れるようになっています。 (↓下記の例では、下線「みじん切り」をクリックした場合)

デザイン検討のプロセス

上記のスマートフォンwebの機能をアプリに追加する際に、下記のようにデザインを検討しました。

(1)現状把握(ユーザーのシーンを理解、今まで出来ていないことの洗い出し)

PCやスマートフォンweb版では、料理の基本のキーワードをタップした際、レシピページから料理の基本ページへ遷移します。ユーザーは料理中にレシピを見ていることが多いため、料理中に画面の行き来が発生することで、本来の目的から少し脱線し料理の進行を妨げてしまっているのではないか、ということを課題にしていました。 それを踏まえて、アプリではなるべく本来の目的の「料理をする」ということを妨げずに、知りたい情報を簡単に知れることを重視することになりました。

(2)そのシーンでは、「ユーザーはどんな情報が必要で、何をしたいのか?」アイデアを考える

料理中にわからない言葉に直面して困ってしまったというシーンでは、どういった情報が必要で、何ができると嬉しいのか?というアイデアを出しました。以下は、そのアイデアの一部です。

  • 3Dtouchの機能を使えばすぐに内容が知れて嬉しいのではないか?
  • 画面遷移して見に行くのは料理を妨げることにならないか?
  • 「料理の基本」を全文見せる必要はないのでは?

こういったアイデアをプロジェクトの担当者同士で意見し合い、ブラッシュアップしていきます。 また、アイデアだけでは実際に操作感がわからないため、すぐにUIを具体化していきます。(3)

(3)アイデアをプロトタイプしてデザインの方向性を決める

アイデアが浮かんだ段階で、どんどんプロトタイプを作っていきます。 また、今回iOSとAndroid両方での実装が必要だったため、両方でどういったデザインが必要かも都度検討していきます。 以下、プロトタイプの一例です。

プロトタイプを作っていくとたくさんの気づきがあり、方針がとても固めやすいです。 例えば、上記の一番左のプロトタイプでは、モーダルを表示している時に背景を暗くしてモーダルを目立たせていましたが、「暗くすると、本来の料理をすることを妨げることになるのでは?」という意見のもと、背景を暗くするのをやめました。

(4)デザインが決定したら、周りのデザイナーやエンジニアにレビューしてもらう

プロトタイプである程度方針を固めたら、次に細部のデザインをつめ、決定したら周りのデザイナーやエンジニアに触ってもらいレビューをもらいます。 ここでは、

  • 他の機能の挙動との齟齬がないか
  • 情報が適切か、見せ方が適切か
  • 実装が現実的にできるものかどうか

の観点でアドバイスをもらいます。

決定したデザイン

プロトタイプ作成を重ね、周りの人にレビューしてもらった結果、このようになりました。

iOS / Android

リリース後の評判はどうか?

リリース後、一部のブログなどで「嬉しいアップデートだった」というご意見をいただいているのを発見し、とても嬉しく感じています。とはいえ、まだまだ課題もたくさんありますので、引き続き今後も改善していく予定です。

まとめ

今回、下記の3点を行ったことにより、ただの機能追加にとどまらず、より良いデザインは何か?ということを追求することが出来たと思っています。

★現状把握をする

既存の機能の追加であれば、現在できていることと、出来ていないことをきちんと把握することが大事です。もし何も考えずにそのまま追加した場合、ユーザーが現状で感じている疑問や不安を無視してしまう可能性があります。現状把握をして振り返ることで、デザインする際に大事にしたいことが浮き彫りになってくると思います。

★「ユーザーがしたいことに対してどうアプローチするのか」という方針をある程度定める

今回で言えば、「ユーザーが料理中にわからない言葉に出会った時に、その時していることをなるべく妨げずに、知りたい情報を簡単に知れるようにする」という方針があったので、それを軸にデザインがスムーズに出来たと思います。

★アイデアを出した段階でなるべく早くプロトタイプを作る

アイデアを出しているときに、頭のなかで想像するものでは議論が進まず、悩みのポイントもどんどんずれてきてしまいます。実際に触れるものになって気づく点や、想像と違ったものになってしまったときどうするかという方向転換も早い段階でできたのがとても良かったと思います。

これらは、今後もデザインをする際に大切にしたいと思っていることです。 既存の機能を他のプラットフォームへ追加する際にどう見せるべきか迷っている方は、ぜひ試してみてください。


クックパッドでは、より良いユーザー体験を届けていきたい!というデザイナーやエンジニアを募集しています。

クックパッド株式会社 採用情報

Core Text と遊んでみましょう

こんにちは、技術部モバイル基盤グループのヴァンサン(@vincentisambart)です。

この間、クックパッドの iOS アプリの開発で Core Text と色々遊んだので、今日は Core Text の話をしましょう。

課題は表示する文字の一部の裏に角丸長方形を表示することです。例えばクックパッド iOS アプリに表示されているリンクを長く押すと表示されている角丸長方形です。以下の画像は「落し蓋」に表示されるタッチフィードバックが見えます。区域を計算したら、その後タップ区域のためにも使えますしね。

f:id:vincentisambart:20160617074525p:plain

以下に説明するやり方はクックパッド iOS アプリのやり方を簡略化したものです。(クックパッド iOS アプリは実装時にまだ Swift を使い始めていなかったので Objective-C ですけども)

Swift Playground (Swift 2.2) で開発しましょう。コードは iOS 用ですが、少しいじれば OS X でも使えるはずです。スターティングポイントは下記の新しい画像に文字列を表示するコードです。

import UIKit
import CoreText

let availableWidth: CGFloat = 200 // 文字表示に使える幅
let text = "français 日本語 हिन्दी English\n\n言語混ぜるのっておもしろくない?"
let font = UIFont.systemFontOfSize(20)
// kCTFontAttributeNameの代わりにNSFontAttributeNameも使えるけど、CocoaとCore Textのattributesは違いあるので、間違いを防ぐためにCore Textのを使いましょう。
let attributes = [
    kCTFontAttributeName as String: font,
    // ユーザーの言語が何であろうと、漢字は日本語フォントを優先に使ってほしいですね。
    kCTLanguageAttributeName as String: "ja",
]
// Core Textの説明書がCFAttributedStringを使うけど、NSAttributedStringとCFAttributedStringがtoll-free bridgedなので、もっと使いやすいNSAttributedStringを使います。
let attributedString = NSAttributedString(string: text, attributes: attributes)
// Core Textのframesetterというのは文字列を指定される形の中にレイアウトするツールです。
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
// 表示に必要な高さを計算します。
let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(), nil, CGSize(width: availableWidth, height: CGFloat.max), nil)
let bounds = CGRect(origin: CGPoint.zero, size: frameSize)
// 文字列を長方形の中に表示したいだけですね。
let path = CGPathCreateWithRect(bounds, nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)

// 新しい画像を描き始めます。
UIGraphicsBeginImageContextWithOptions(frameSize, true, 2.0)
if let context = UIGraphicsGetCurrentContext() {
    // 背景を白で塗ります。
    CGContextSetRGBFillColor(context, 1.0, 1.0, 1.0, 1.0)
    CGContextFillRect(context, bounds)

    // 何もしないとCore Textの表示が逆さまなので正しい方向に表示されるようにします。
    CGContextTranslateCTM(context, 0, frameSize.height)
    CGContextScaleCTM(context, 1.0, -1.0)

    // 文字の表示です。
    CTFrameDraw(frame, context)
}
// 描いた画像を出します。
let image = UIGraphicsGetImageFromCurrentImageContext()
// 描き終えます。
UIGraphicsEndImageContext()

Playground上で画像の表示をインラインに表示できて便利です。コードが実行されたら画像が定義されている場所の右側に画像のサイズが表示されていて、マウスカーソルをその上に乗せたら右の一番端に表示されるボタンをクリックしたら画像がその下に表示されます。

f:id:vincentisambart:20160617074532p:plain

framesetter には色々な設定がありますが、それでも足りない人は CTTypesetterCreateWithAttributedStringCTTypesetterSuggestLineBreakCTTypesetterCreateLine で自分で行の列を作れます。

Core Foundation

開発を続けるには Core Text が返している Core Foundation の型(CFArrayCFRange)を扱わなければいけないけど、Swift だと少し扱いづらいですね。もっと扱いやすくしましょう。

まず、 CFRange はシンプルな struct なので、そんなに扱いづらいわけではないけど、便利関数が少ないですね。 NSRange はそういう便利機能が色々あるので、簡単に変換できるようにしましょう。

extension NSRange {
    // CFRangeからNSRangeを作れるようにします。
    init(_ range: CFRange) {
        self.init(location: range.location, length: range.length)
    }
}

extension CFRange {
    // NSMaxRangeのCFRange版です。
    var max: CFIndex {
        return location + length
    }
}

折角 NSRange をいじっているなら、あとで使えそうな便利機能を1個追加しておきましょう。

// 渡されるrangeが重なるかどうか。
func rangesOverlap(range1: NSRange, _ range2: NSRange) -> Bool {
    return NSIntersectionRange(range1, range2).length != 0
}

CFRange は簡単でしたが、 CFArray を Swift の配列に変換するのが少しややこしいです。一番自然なやり方でやってみましょう。

let lines = CTFrameGetLines(frame) as! [CTLine]

上記のコードを実行するとクラッシュします。実は Swift の配列に変換する前に NSArray に変換すると動きます。

let lines = (CTFrameGetLines(frame) as NSArray) as! [CTLine]

じゃあそれを関数にしてみましょう。

func toArray<T>(sourceArray: CFArray) -> [T] {
    return (sourceArray as NSArray) as! [T]
}

上記のコードは動きますが、Xcode 7.3では、間違っている警告「Cast from 'NSArray' to unrelated type '[T]' always fails」が出ます。そしてなぜか関数の中だと as NSArray がなくても動きます(警告まだ出ますけど)。

警告や謎キャストが好きじゃないので。代わりに CFArray の中身を新しい Swift 配列に入れるようにしましょう。

func toArray<T>(sourceArray: CFArray) -> [T] {
    var destinationArray = [T]()
    let count = CFArrayGetCount(sourceArray)
    destinationArray.reserveCapacity(count)
    for index in 0..<count {
        let untypedValue = CFArrayGetValueAtIndex(sourceArray, index)
        let value = unsafeBitCast(untypedValue, T.self) // 😅
        destinationArray.append(value)
    }
    return destinationArray
}

結局また乱暴なキャストが必要になりましたね…でも仕方ない気がします。

各文字がどこに表示されるのか

CTFrame には、表示されるグリフの位置が決まっています。ある座標がどの文字に一番近いのか教えてくれる関数(CTLineGetStringIndexForPosition)、または文字列のあるインデックスのy座標を教えてくれる関数(CTLineGetOffsetForStringIndex)があります。けれど、文字列の各文字がとっているスペースを直接教えてくれる関数がありません。英語、日本語、だけを対象にするなら CTLineGetOffsetForStringIndex で簡単にできそうですが、もっと複雑な言語にも対応したければそんなに簡単にいきません。簡単じゃないとはいえ、そこまで複雑でもありません。

詳しい説明は公式Core Text紹介でご覧になれますが、軽く説明しましょう。CTFrame に何が入っているのかといいますと、単に CTLine(行) のリストです。「行」というのは "\n" で句切らているパラグラフではなく、表示の1行1行です。

そして各行が CTRun で作られています。"run" がフォント、フォントサイズ、方向、が同じなグリフのリストです。気をつけるべきなのは、元々指定されたフォントが同じでも、文字がフォントに存在していなければ、代わりに別のフォントが使われるので、別の"run"になるかもしれません。例えば、文字列が"Vincentと申します"とフォントがシステムフォント(San Francisco)の場合、2つのrunになります:

  • 「Vincent」のグリフが入ったrun (フォント:San Francisco)
  • 「と申します」のグリフが入ったrun (フォント:Hiragino Kaku)

求める長方形を探すには、各グリフを見て、そのグリフは選択されている文字から来ているのかを確認しましょう。

しかし、グリフがどの文字から来ているのか教えてくれる CTRunGetStringIndices という関数が少し使いづらいです。グリフごとに文字列の中の開始インデックスだけを返します。1つのグリフが複数の文字を使うことがあります。例えば合字(リガチャー)や結合文字列(combining character sequence)ですね。なので開始インデックスだけではなく、文字列の区域が必要ですね。

次のグリフの開始インデックスに1を引けば…と思った方いるかもしれませんが、残念ながらそんなにうまく行きません。アラビア語やヘブライ語だと 3, 2, 1, 0 になりますし、次のヒンディー語の文字列「हिन्दी」のCTRunGetStringIndicesの結果が:1, 0, 2, 5。並び順が予測できません。

では、各文字で開始インデックスに一番近いインデックスを探すコードを書きましょう。

func stringRangesPerGlyph(run: CTRun) -> [NSRange] {
    let runEndIndex = CTRunGetStringRange(run).max
    let glyphCount = CTRunGetGlyphCount(run)

    var stringIndices = Array<CFIndex>(count: glyphCount, repeatedValue: 0)
    CTRunGetStringIndices(run, CFRange(location: 0, length: glyphCount), &stringIndices)

    return stringIndices.map { glyphStartIndex in
        // glyphStartIndexより大きいけど一番近いインデックスを探します。
        var glyphEndIndex = runEndIndex
        for comparedIndex in stringIndices {
            if comparedIndex > glyphStartIndex && comparedIndex < glyphEndIndex {
                glyphEndIndex = comparedIndex
            }
        }
        // 因みに let glyphEndIndex = stringIndices.filter({ $0 > glyphStartIndex }).sort().first ?? runEndIndex でもいけます。

        return NSRange(location: glyphStartIndex, length: glyphEndIndex - glyphStartIndex)
    }
}

実装

必要な物が揃ったので実装してみましょう。最初に書いたコードの上に上記のextensionや関数を入れて、 let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil) の下に以下のコードを入れましょう。(全部が入ったコードはこの記事の一番下にも入れておきます。)

// 2つのCGRectが水平にすぐ隣なのかどうか。
func rectAdjacentHorizontally(rect1: CGRect, _ rect2: CGRect) -> Bool {
    if rect1.intersects(rect2) {
        return true
    }
    if abs(rect1.maxX - rect2.minX) <= 1.0 {
        return true
    }
    if abs(rect1.minX - rect2.maxX) <= 1.0 {
        return true
    }
    return false
}

let roundedRectangleRanges = [
    NSRange(location: 10, length: 5),
    NSRange(location: 31, length: 3),
]

let lines: [CTLine] = toArray(CTFrameGetLines(frame))
var lineOrigins = Array<CGPoint>(count: lines.count, repeatedValue: CGPoint.zero)
CTFrameGetLineOrigins(frame, CFRange(location: 0, length: lines.count), &lineOrigins)

var rectsFound = [CGRect]()

for (lineIndex, line) in lines.enumerate() {
    let lineRange = NSRange(CTLineGetStringRange(line))
    let runs: [CTRun] = toArray(CTLineGetGlyphRuns(line))

    var ascent: CGFloat = 0
    var descent: CGFloat = 0
    CTLineGetTypographicBounds(line, &ascent, &descent, nil)
    let lineOrigin = lineOrigins[lineIndex]
    let lineMinY = lineOrigin.y - descent
    let lineMaxY = lineOrigin.y + ascent

    for roundedRectangleRange in roundedRectangleRanges {
        if !rangesOverlap(roundedRectangleRange, lineRange) {
            continue // この長方形がこの行に入りません。
        }

        var rectsForRangeOnCurrentLine = [CGRect]()

        for run in runs {
            let runRange = NSRange(CTRunGetStringRange(run))
            if !rangesOverlap(roundedRectangleRange, runRange) {
                continue // この長方形がこのrunに入りません。
            }

            let glyphCount = CTRunGetGlyphCount(run)
            let allGlyphs = CFRange(location: 0, length: glyphCount)
            let stringRanges = stringRangesPerGlyph(run)
            var positions = Array<CGPoint>(count: glyphCount, repeatedValue: CGPoint.zero)
            CTRunGetPositions(run, allGlyphs, &positions)
            var advances = Array<CGSize>(count: glyphCount, repeatedValue: CGSize.zero)
            CTRunGetAdvances(run, allGlyphs, &advances)

            // 以前に説明したとおり、グリフが並び順通りじゃないから、まずはグリフ1つずつ長方形を作ります。
            for (glyphIndex, stringRange) in stringRanges.enumerate() {
                if !rangesOverlap(roundedRectangleRange, stringRange) {
                    continue
                }
                let x1 = positions[glyphIndex].x
                let x2 = positions[glyphIndex].x + advances[glyphIndex].width
                let minX = min(x1, x2)
                let maxX = max(x1, x2)
                let rect = CGRect(x: minX, y: lineMinY, width: maxX - minX, height: lineMaxY - lineMinY)
                rectsForRangeOnCurrentLine.append(rect)
            }
        }

        // このrangeのすぐ隣の長方形をくっつけます。
        rectsForRangeOnCurrentLine.sortInPlace { $0.minX < $1.minX }
        var mergedRects = [CGRect]()
        for rect in rectsForRangeOnCurrentLine {
            var rectToAppend = rect
            if let previousRect = mergedRects.last {
                if rectAdjacentHorizontally(previousRect, rect) {
                    mergedRects.removeLast()
                    let minX = min(previousRect.minX, rect.minX)
                    let maxX = max(previousRect.maxX, rect.maxX)
                    rectToAppend.origin.x = minX
                    rectToAppend.size.width = maxX - minX
                }
            }
            mergedRects.append(rectToAppend)
        }
        rectsFound.appendContentsOf(mergedRects)
    }
}

表示は CTFrameDrawの上に以下のコードを入れましょう。

UIColor.cyanColor().setFill()
for zone in zonesFound {
    let roundedRectanglePath = UIBezierPath(roundedRect: zone, cornerRadius: 4)
    roundedRectanglePath.fill()
}

f:id:vincentisambart:20160617074536p:plain

最後に

一番重要な処理はやってありますけど、改善の余地はありますね。

まず、制限が1つあります:roundedRectangleRanges が合字や結合文字列の真ん中で始まる/終わる場合、グリフ全体の後ろに角丸長方形を描くことになります。それが問題になるかどうかは使う場所次第ですね。

あと、一番上のスクリーンショットみたいに、複数行に渡る時は行末や行頭では角丸にしない方がいいかもしれません。クックパッド iOS アプリでやっていて、そこまで難しくないけど記事が既に十分長いです(笑)。

全コードまとめ

import UIKit
import CoreText

extension NSRange {
    // CFRangeからNSRangeを作れるようにします。
    init(_ range: CFRange) {
        self.init(location: range.location, length: range.length)
    }
}

extension CFRange {
    // NSMaxRangeのCFRange版です。
    var max: CFIndex {
        return location + length
    }
}

// 渡されるrangeが重なるかどうか。
func rangesOverlap(range1: NSRange, _ range2: NSRange) -> Bool {
    return NSIntersectionRange(range1, range2).length != 0
}

func toArray<T>(sourceArray: CFArray) -> [T] {
    var destinationArray = [T]()
    let count = CFArrayGetCount(sourceArray)
    destinationArray.reserveCapacity(count)
    for index in 0..<count {
        let untypedValue = CFArrayGetValueAtIndex(sourceArray, index)
        let value = unsafeBitCast(untypedValue, T.self) // 😅
        destinationArray.append(value)
    }
    return destinationArray
}

func stringRangesPerGlyph(run: CTRun) -> [NSRange] {
    let runEndIndex = CTRunGetStringRange(run).max
    let glyphCount = CTRunGetGlyphCount(run)

    var stringIndices = Array<CFIndex>(count: glyphCount, repeatedValue: 0)
    CTRunGetStringIndices(run, CFRange(location: 0, length: glyphCount), &stringIndices)

    return stringIndices.map { glyphStartIndex in
        // glyphStartIndexより大きいけど一番近いインデックスを探します。
        var glyphEndIndex = runEndIndex
        for comparedIndex in stringIndices {
            if comparedIndex > glyphStartIndex && comparedIndex < glyphEndIndex {
                glyphEndIndex = comparedIndex
            }
        }
        // 因みに let glyphEndIndex = stringIndices.filter({ $0 > glyphStartIndex }).sort().first ?? runEndIndex でもいけます。

        return NSRange(location: glyphStartIndex, length: glyphEndIndex - glyphStartIndex)
    }
}

// 2つのCGRectが水平にすぐ隣なのかどうか。
func rectAdjacentHorizontally(rect1: CGRect, _ rect2: CGRect) -> Bool {
    if rect1.intersects(rect2) {
        return true
    }
    if abs(rect1.maxX - rect2.minX) <= 1.0 {
        return true
    }
    if abs(rect1.minX - rect2.maxX) <= 1.0 {
        return true
    }
    return false
}


let availableWidth: CGFloat = 200 // 文字表示に使える幅
let text = "français 日本語 हिन्दी English\n\n言語混ぜるのっておもしろくない?"
let font = UIFont.systemFontOfSize(20)
// kCTFontAttributeNameの代わりにNSFontAttributeNameも使えるけど、CocoaとCore Textのattributesは違いあるので、間違いを防ぐためにCore Textのを使いましょう。
let attributes = [
    kCTFontAttributeName as String: font,
    // ユーザーの言語が何であろうと、漢字は日本語フォントを優先に使ってほしいですね。
    kCTLanguageAttributeName as String: "ja",
]
// Core Textの説明書がCFAttributedStringを使うけど、NSAttributedStringとCFAttributedStringがtoll-free bridgedなので、もっと使いやすいNSAttributedStringを使います。
let attributedString = NSAttributedString(string: text, attributes: attributes)
// Core Textのframesetterというのは文字列を指定される形の中にレイアウトするツールです。
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
// 表示に必要な高さを計算します。
let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(), nil, CGSize(width: availableWidth, height: CGFloat.max), nil)
let bounds = CGRect(origin: CGPoint.zero, size: frameSize)
// 文字列を長方形の中に表示したいだけですね。
let path = CGPathCreateWithRect(bounds, nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)

let roundedRectangleRanges = [
    NSRange(location: 10, length: 5),
    NSRange(location: 31, length: 3),
]

let lines: [CTLine] = toArray(CTFrameGetLines(frame))
var lineOrigins = Array<CGPoint>(count: lines.count, repeatedValue: CGPoint.zero)
CTFrameGetLineOrigins(frame, CFRange(location: 0, length: lines.count), &lineOrigins)

var rectsFound = [CGRect]()

for (lineIndex, line) in lines.enumerate() {
    let lineRange = NSRange(CTLineGetStringRange(line))
    let runs: [CTRun] = toArray(CTLineGetGlyphRuns(line))

    var ascent: CGFloat = 0
    var descent: CGFloat = 0
    CTLineGetTypographicBounds(line, &ascent, &descent, nil)
    let lineOrigin = lineOrigins[lineIndex]
    let lineMinY = lineOrigin.y - descent
    let lineMaxY = lineOrigin.y + ascent

    for roundedRectangleRange in roundedRectangleRanges {
        if !rangesOverlap(roundedRectangleRange, lineRange) {
            continue // この長方形がこの行に入りません。
        }

        var rectsForRangeOnCurrentLine = [CGRect]()

        for run in runs {
            let runRange = NSRange(CTRunGetStringRange(run))
            if !rangesOverlap(roundedRectangleRange, runRange) {
                continue // この長方形がこのrunに入りません。
            }

            let glyphCount = CTRunGetGlyphCount(run)
            let allGlyphs = CFRange(location: 0, length: glyphCount)
            let stringRanges = stringRangesPerGlyph(run)
            var positions = Array<CGPoint>(count: glyphCount, repeatedValue: CGPoint.zero)
            CTRunGetPositions(run, allGlyphs, &positions)
            var advances = Array<CGSize>(count: glyphCount, repeatedValue: CGSize.zero)
            CTRunGetAdvances(run, allGlyphs, &advances)

            // 以前に説明したとおり、グリフが並び順通りじゃないから、まずはグリフ1つずつ長方形を作ります。
            for (glyphIndex, stringRange) in stringRanges.enumerate() {
                if !rangesOverlap(roundedRectangleRange, stringRange) {
                    continue
                }
                let x1 = positions[glyphIndex].x
                let x2 = positions[glyphIndex].x + advances[glyphIndex].width
                let minX = min(x1, x2)
                let maxX = max(x1, x2)
                let rect = CGRect(x: minX, y: lineMinY, width: maxX - minX, height: lineMaxY - lineMinY)
                rectsForRangeOnCurrentLine.append(rect)
            }
        }

        // このrangeのすぐ隣の長方形をくっつけます。
        rectsForRangeOnCurrentLine.sortInPlace { $0.minX < $1.minX }
        var mergedRects = [CGRect]()
        for rect in rectsForRangeOnCurrentLine {
            var rectToAppend = rect
            if let previousRect = mergedRects.last {
                if rectAdjacentHorizontally(previousRect, rect) {
                    mergedRects.removeLast()
                    let minX = min(previousRect.minX, rect.minX)
                    let maxX = max(previousRect.maxX, rect.maxX)
                    rectToAppend.origin.x = minX
                    rectToAppend.size.width = maxX - minX
                }
            }
            mergedRects.append(rectToAppend)
        }
        rectsFound.appendContentsOf(mergedRects)
    }
}


// 新しい画像を描き始めます。
UIGraphicsBeginImageContextWithOptions(frameSize, true, 2.0)
if let context = UIGraphicsGetCurrentContext() {
    // 背景を白で塗ります。
    CGContextSetRGBFillColor(context, 1.0, 1.0, 1.0, 1.0)
    CGContextFillRect(context, bounds)

    // 何もしないとCore Textの表示が逆さまなので正しい方向に表示されるようにします。
    CGContextTranslateCTM(context, 0, frameSize.height)
    CGContextScaleCTM(context, 1.0, -1.0)

    UIColor.cyanColor().setFill()
    for rect in rectsFound {
        let roundedRectanglePath = UIBezierPath(roundedRect: rect, cornerRadius: 4)
        roundedRectanglePath.fill()
    }

    // 文字の表示です。
    CTFrameDraw(frame, context)
}
// 描いた画像を出します。
let image = UIGraphicsGetImageFromCurrentImageContext()
// 描き終えます。
UIGraphicsEndImageContext()