MySQL with InnoDB のインデックスの基礎知識とありがちな間違い

こんにちは、サービス開発部の荒引 (@a_bicky) です。

突然ですが、RDBMS の既存のテーブルを見てみたら「何でこんなにインデックスだらけなの?」みたいな経験はありませんか?不要なインデックスは容量を圧迫したり、挿入が遅くなったりと良いことがありません。

そんなわけで、今回はレコードを検索するために必要なインデックスの基礎知識と、よく見かける不適切なインデックスについて解説します。クックパッドでは Rails のデータベースとして主に MySQL 5.6、MySQL のストレージエンジンとして主に InnoDB を使っているので、MySQL 5.6 の InnoDB について解説します。

InnoDB のインデックスに関する基礎知識

インデックスの構造 (B+ 木)

InnoDB では B+ 木が使われています。B+ 木は次のような特徴を持った木構造です。

  • 次数を b とすると、各内部ノード(葉ノード以外のノード)は最大 b - 1 個のキーと最大 b 個の子ノードを持つ*1
  • 内部ノードは値を持たない
  • 葉ノードの各キーは値(または値へのポインタ)を持つ
  • 葉ノードは次の葉ノードへのポインタを持つ
  • O(\log_bn) で検索できる

次の図は次数 3 の例です。

f:id:a_bicky:20170417093420p:plain:w552

例えば、key が 4 の value を取り出すには次のように木を辿れば良いです。

f:id:a_bicky:20170417093427p:plain:w440

key が 2 〜 9 の value を全て取り出すには次のように木を辿ることができます。葉ノード間が繋がっていることによって範囲検索を効率的に行うことができます。

f:id:a_bicky:20170417093430p:plain:w440

InnoDB の B+ 木では、インデックスに使われているカラムの値と主キーの値が value に格納されています。

ここで (c1, c2) のような複合インデックスを考えてみます。イメージとしては次のような構造になります。例えば、key の上位 4 bytes を c1 に割り当て、下位 4 bytes を c2 に割り当てるイメージです。

f:id:a_bicky:20170418083840p:plain:w440

図からわかるように、c2 = 2 に対応するインデックスレコードを探そうとしても、c1, c2 の順番で並んでいるので、木を辿ることで探すことはできません。c1 = 4 AND c2 = 2 のように c1 の条件が指定されることで木を辿ることができるようになります。

また、c1 >= 2 AND c2 <= 4 のような条件の場合、c1 >= 2 の条件を満たすインデックスレコードは木を辿ることで探せますが、それらのインデックスレコードを更に c2 <= 4 の条件で絞り込むために木を利用することはできません。具体的には、図の例だと [7,5] が条件を満たさないインデックスレコードですが、その隣の [9,3] が条件を満たすかどうかはわからないですよね。結局のところ、c1 >= 2 のインデックスレコード全てを走査しないといけません。

よって、インデックスレコードの走査の数を減らす観点では c1 = 2 AND c2 <= 4 のように c1 の条件が等価比較になる場合に限って、複合インデックスとしての意味を持つことになります。*2

このことについては MySQL のドキュメントの 8.2.1.2 Range Optimization にも次のように言及されています。

The optimizer attempts to use additional key parts to determine the interval as long as the comparison operator is =, <=>, or IS NULL. If the operator is >, <, >=, <=, !=, <>, BETWEEN, or LIKE, the optimizer uses it but considers no more key parts.

InnoDB における B+ 木の実装について知りたい方は次の記事と言及されている関連記事を読むとかなり理解が深まると思います。

B+Tree index structures in InnoDB – Jeremy Cole

クラスタ化インデックスとセカンダリインデックス

InnoDB の主キーはクラスタ化インデックス (clustered indexes) になっており、B+ 木の葉ノードにレコードの全データが格納されています。 特定のカラムに張るインデックスはセカンダリインデックスと呼ばれ、セカンダリインデックスの葉ノードには主キーが必ず含まれています。

セカンダリインデックスを使った検索は、まずセカンダリインデックスの B+ 木を辿って主キーを取得し、次にクラスタ化インデックスの B+ 木を辿ってレコードを取得することになります。

f:id:a_bicky:20170417093437p:plain:w440

セカンダリインデックスに必要な情報が全て格納されていれば、クラスタ化インデックスを辿る手順をスキップすることができるので高速です。このようにレコードを取得する際にセカンダリインデックスで完結する場合のことをカバリングインデックスと呼びます。

MySQL がレコードを取得する大まかな手順

MySQL がレコードを取得する際の主要な登場人物として、executor*3 と storage engine (e.g. InnoDB) がいます。

storage engine が InnoDB の場合は次のようにレコードを取得します。

  1. executor が storage engine にレコードを要求する
  2. storage engine (InnoDB) はセカンダリインデックスの木を辿ることで、取得すべきレコードのインデックスに含まれているカラム値と主キーの値を得る
    • インデックスが使えない場合はクラスタ化インデックスに含まれている全レコード情報を executor に返す
  3. storage engine は 2 で得たデータのうち、インデックスに含まれているカラム値の情報を使って取得すべきレコードをフィルタリングする (Using index condition)
    • インデックスに含まれている情報が使えない場合はスキップ
  4. storage engine は 3 で得たデータの主キー情報を使ってクラスタ化インデックスからレコードを取得する
    • SELECT で指定されているカラムや WHERE で指定されているカラムの情報が全てインデックスに含まれている場合はスキップ (Using index)
  5. storage engine は取得したレコード情報を executor に返す
  6. executor は storage engine がフィルタリングできなかったレコードをフィルタリングする (Using where)
    • storage engine 側で全てフィルタリングされている場合はスキップ

「雑なMySQLパフォーマンスチューニング」はこの辺の内容をわかりやすく説明しているので、ピンとこなければ読むことをお勧めします。

以上の説明で、Explain で extra に Using index, Using index condition, Using where が出る場合にどのような処理が行われているかイメージが付くと思います。 Using index condition が出る場合は ICP (Index Condition Pushdown) 最適化と呼ばれ、MySQL 5.6 から導入されました。これによって、クラスタ化インデックスから取得するレコードが減るのでその分高速になります。c1 >= 2 AND c2 <= 4 のような条件のために (c1, c2) の複合インデックスを張っても意味がないと前述しましたが、ICP の恩恵を受けてパフォーマンスが改善する場合もあります。

インデックスを張る上でのポイント

インデックスを張る上では次のような内容がポイントになると思います。

  • インデックスで絞り込めるレコード数が大きいか?(選択性が高いか?)
    • WHERE c1 = 1 AND c2 = 2 AND c3 = 3 AND c4 = 4 みたいな条件があるからといって、(c1, c2, c3, c4) の複合インデックスが必要とは限らない
    • c1 で十分絞りこめるのに他のカラムもインデックスに含めるとインデックスが肥大化する
  • 複合インデックスを張る場合
    • 絞り込めるレコード数の大きいカラムを先にしているか?
    • 等価比較するカラムが先になっているか?
  • カバリングインデックスの恩恵を十分に受けられるか?
  • ICP の恩恵を十分に受けられるか?
  • ソートに使うか?*4

不適切なインデックスの例

これまでに説明した内容がわかっていると、以下に挙げる内容が不適切なインデックスだとわかるはずです。

  • インデックスの最初のカラムに範囲指定をする複合インデックス
  • 選択性の悪いインデックス
  • 他のインデックスで代替できるインデックス

具体例を挙げるために、次のようなテーブルを扱うことにします。商品情報を管理するテーブルで、現在以降に掲載される商品の情報が日々追加されていく想定です。

CREATE TABLE `products` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `shop_id` int(10) unsigned NOT NULL,  -- 商品を掲載している店舗の ID
  `name` varchar(255) NOT NULL,         -- 商品名
  `price` int(10) unsigned NOT NULL,    -- 商品の価格
  `starts_at` datetime NOT NULL,        -- 商品の掲載開始日時
  `ends_at` datetime NOT NULL,          -- 商品の掲載終了日時
  PRIMARY KEY (`id`)
) ENGINE=InnoDB

それでは具体例を見ていきます。

インデックスの最初のカラムに範囲指定をする複合インデックス

次のインデックスが定義されているとします。

ALTER TABLE products ADD INDEX ix_ends_at_starts_at (ends_at, starts_at);

これは、次のようなクエリに対処するために定義されたインデックスですが、ends_at 単一のインデックスを張るのとあまり変わりません。*5

-- 現在掲載されている商品を抽出する
SELECT * FROM products WHERE starts_at <= NOW() AND ends_at >= NOW();

この理由は次のような B+ 木をイメージするとわかりやすいです。この木は (c1, c2) に対して構築された B+ 木ですが、c1 >= 2 AND c2 <= 5 のような条件でレコードを引こうと思った場合にインデックスレコードを走査する数が減りません。例えば、[2,8] のインデックスレコードが c2 の条件を満たしませんが、その隣の [3,1]c2 の条件を満たすかどうかは木の構造から判断できず、c1 >= 2 のインデックスレコードを全て走査することになります。

f:id:a_bicky:20170418084900p:plain:w440

MySQL 5.6 では前述した ICP 最適化という仕組みがあるので、starts_at >= NOW() での絞り込みで劇的にレコードが減るようであれば、ICP の恩恵を受けられるかもしれません。

ICP の効果は session status の Handler_read_next の値がどれだけ変わるかを見てみるのが良いでしょう。この値が少ないほどインデックスレコードの走査が少ないことを意味します。

-- ICP 有効
FLUSH STATUS;
SET @@optimizer_switch = "index_condition_pushdown=on";
SELECT * FROM products WHERE starts_at <= NOW() AND ends_at >= NOW();
SHOW SESSION STATUS LIKE 'Handler%';

-- ICP 無効
FLUSH STATUS;
SET @@optimizer_switch = "index_condition_pushdown=off";
SELECT * FROM products WHERE starts_at <= NOW() AND ends_at >= NOW();
SHOW SESSION STATUS LIKE 'Handler%';

ICP の効果が低いと判断したら、ix_ends_at_starts_at は削除して単一のインデックスを張りましょう。

ALTER TABLE products DROP INDEX ix_ends_at_starts_at,
  ADD INDEX ix_ends_at (ends_at);

似たような例として、次のようなインデックスを考えます。

ALTER TABLE products ADD INDEX ix_ends_at_shop_id (ends_at, shop_id);

これは、次のようなクエリに対処するために定義されたインデックスですが、同様に ends_at 単一のインデックスを張るのとあまり変わりません。

-- shop_id = 1234 の店舗の現在掲載される商品を抽出する
SELECT * FROM products WHERE shop_id = 1234 AND starts_at <= NOW() AND ends_at >= NOW();

shop_id は等価比較で使う前提なので、(shop_id, ends_at) の順番でインデックスを張ることで複合インデックスとしての恩恵が得られます。

ALTER TABLE products DROP INDEX ix_ends_at_shop_id,
  ADD INDEX ix_shop_id_ends_at (shop_id, ends_at);

選択性の悪いインデックス

次のインデックスが定義されているとします。

ALTER TABLE products ADD INDEX ix_shop_id_starts_at (shop_id, starts_at);

これは、次のようなクエリに対処するために定義されたインデックスですが、選択性の観点で良くありません。

-- shop_id = 1234 の店舗の現在掲載される商品を抽出する
SELECT * FROM products WHERE shop_id = 1234 AND starts_at <= NOW() AND ends_at >= NOW();

ix_shop_id_starts_at の選択性が良いかどうかはテーブルとクエリの特性次第ですが、products テーブルが現在以降に掲載される商品しか追加されず、過去に掲載されていた商品が残り続けるのであれば、starts_at <= NOW() という条件は該当レコードが日々増えていきます。一方で、ends_at >= NOW() は商品の増え方が一定であれば該当レコードの量は一定とみなせます。

よって、(shop_id, ends_at) にインデックスを張るべきです。

ALTER TABLE products DROP INDEX ix_shop_id_starts_at,
  ADD INDEX ix_shop_id_ends_at (shop_id, ends_at);

他のインデックスで代替できるインデックス

次のインデックスが定義されているとします。

ALTER TABLE products ADD INDEX ix_shop_id (shop_id),
  ADD INDEX ix_shop_id_ends_at (shop_id, ends_at);

これは次のような 2 種類のクエリに対処するために定義されたインデックスですが、ix_shop_id は冗長です。

-- shop_id = 1234 の店舗に掲載されたことがある、または掲載される予定の商品を全て抽出する
SELECT * FROM products WHERE shop_id = 1234;
-- shop_id = 1234 の店舗の現在掲載される商品を抽出する
SELECT * FROM products WHERE shop_id = 1234 AND starts_at <= NOW() AND ends_at >= NOW();

shop_id での絞り込みに特化したインデックスとして ix_shop_id を導入したと思われますが、次の 2 つの木を見れば ix_shop_id_ends_atix_shop_id の役割を包含していることは一目瞭然です。

f:id:a_bicky:20170417093440p:plain:w440

よって、ix_shop_id は削除すべきです。

ALTER TABLE products DROP INDEX ix_shop_id;

最後に

以上、MySQL (InnoDB) のインデックスについて簡単に解説しました。InnoDB について完璧に理解しようと思うと膨大な知識が必要ですが、よくある単純な用途でインデックスを張るだけであれば、必要とされる知識はほんの少しであることがわかると思います。 本エントリーによって、世の中から不適切なインデックスだらけのテーブルが少しでもなくなれば幸いです。

*1:次数の解釈は文献によって異なるので、2017 年 4 月 17 日時点の Wikipedia に合わせています

*2:後述する ICP が効果を発揮する場合はその限りではありません

*3:executor を mysql server と表現している記事を見かけることがありますが、sql_executor.cc に実装されているので executor という表現の方が適切だと思います

*4:本エントリではソートには触れないので、興味のある方は「漢(オトコ)のコンピュータ道: Using filesort」を参照すると良いと思います

*5:Explain の key_len 的には starts_at も使われるように見えるので、ソースコードを読んでその理由を調べようと前々から思ってますが、未だに調査できてません…

ハードな案件のやわらかいプロジェクト管理

研究開発部 兼 クックパッド料理教室の伊尾木です。 暖かくなったり、寒くなったりと気温差が激しいですが、皆さんお体は大丈夫でしょうか。

ところで、最近クックパッド料理教室で、ビジネスモデル変更に伴うリニューアルプロジェクトを実施しました。

f:id:woochanx:20170417111032p:plain:w300
(ビジネスモデル変更に伴う全面リニューアル)

私はPMと開発リーダーを担当したのですが、そこで実施した「やわらかいプロジェクト管理」についてご紹介したいと思います。

炎上しそうな予感がいっぱい!

ビジネスモデル変更に伴うリニューアルって聞いただけで炎上の予感で胸が膨らみますね。 ビジネスモデルの変更だけでも大きな話なのに、システムの全面刷新まで同時に実施したので、プロジェクトとして不確定要素が多く、管理が難しいものになっていました。

20名弱(エンジニアが8名、他には営業チーム、ユーザサポートチームなどがありました)で8ヶ月程度のプロジェクトでした。一般的には非常に大規模というわけではないのですが、システムの全面刷新でしたので開発量が膨大で、クックパッド内の普段の開発規模からすると非常に大きなプロジェクトです。さらにはこの規模の管理を経験したメンバーも少なく、私がJOINした段階ではどのようにプロジェクトを管理するかもあまり明確に決まっていませんでした。

つまりは、大い燃え上がることが当初から想定できていたわけです。

じゃあガチガチのプロジェクト管理をやるのも以下の理由から難しいと考えました。

  • 開発量が多く、不確定要素が多い
  • 期間が短い
  • そんなに管理工数を割けない(PMの私もバリバリに実装していました…)
  • 文化に合わない

多くの場合、不確定要素が多いとガチガチのプロジェクト管理に走ってしまうのですが、管理のための管理が発生しやすく、結果的にプロジェクトの進行が遅くなると思っています。また、社内の普段の開発方法から大きく変わると、文化的な摩擦や、コミュニケーションミスが起きてしまうリスクも高いなと考えました。

そこで変更が多発すること前提とした「やわらかいプロジェクト管理」を実施することにしました。

やわらかいプロジェクト管理

ここでいう「やわらかいプロジェクト管理」とは、

  • 開発対象をやや大きな粒度でタスク化し、各タスク間にあえて「遊び」を持たせることで、調整や変更を行いやすくする
  • エンジニアの生産性を低下する管理を排除する

を実施することです。

厳密な計画を立ててその通りにやることを目標にするのではなく、変更が多発することを前提として、状況に応じて臨機応変に変更できるように管理することを目標としました。 こういうと「あーアジャイル開発ね」っと思われるかもしれませんが、ここで言っているのは開発方法論のような大きな話ではなく、それよりはもう少し細かく、タスクの管理のテクニックなどの話になります。

具体的には以下のことを実施しました。

  • プロジェクト全体は月単位のタイムボックス管理
  • 進捗管理は週単位のタイムボックス
  • 開発メンバーとの進捗確認MTGを実施しない
  • 心理的安全性を保証する

プロジェクト全体は月単位のタイムボックス管理

細かいスケジュールは立てませんでしたが、全体の流れとチームやタスクの依存関係を把握するために月単位のタイムボックス管理を行いました。

プロジェクト全体を1ヶ月程度のタイムボックスに分割して、各タイムボックスに大きな粒度のタスクのみを配置します。 そして、そのタスク間の順序や依存関係を整理して、どのタイムボックス中に何ができるかを把握しました。

各タイムボックスに作業の目安となるような目的を決めていたので(e.g. 「ビジネスモデルの詳細をつめる」「管理画面の主要な機能を開発する」等)いわゆるフェーズ管理とほぼ同じものになっていたと思います。ただ、フェーズ管理のように「今が何フェーズか」というのはあまり重要ではなく、各タイムボックスに積み残しがあっても次のタイムボックスに進むようにしました。

ちなみにここでは粒度の細かいタスクを配置しないように注意しました。このレベルで細かいタスク管理などをやってしまうと、管理工数が跳ね上がってしまいますし、柔軟な変更が難しくなってしまうからです。

進捗管理は週単位のタイムボックス

タスクの具体的な進捗管理は週単位のタイムボックスで管理を行いました。 月単位のタイムボックスに配置されたタスクを少しだけ分割して、週のタイムボックスに配置し、それらの進捗管理を週次で行うようにしました。

ちなみに、日次での進捗は追わないようにしました。よくある「タスクAは、X月Y日に終了します」というのを避けて、「今週中にAとBが終わればいい」という感じです。

1週間の中でどのように時間配分するかや、どのような開発順序にするかは、各エンジニアに任せるようにしました。 どのように働くのが効率がいいのか、いつが集中できるのかなどはエンジニアによって大きく異なるので、なるべくその人が良いと思うやり方を実施しました。

また、タスク自体もあまり細かい分割は避けました。タスクを細かくして管理してしまうと、管理のための管理が発生しやすいと考えたからです。 といっても粒度が大きすぎても管理できないので、大体1週間のタイムボックスに収まるように分割しました。

開発メンバーとの進捗確認MTGを実施しない

私はPMと開発チームのリーダーを兼務していましたが、開発メンバーとは進捗MTGを実施しませんでした(リーダー間の進捗MTGは週次で実施していました)。 進捗確認MTGは、どうしても余計な作業が発生してしまうので(進捗遅れの言い訳作りや、進捗を示すためのデータ集めなど)、そんなムダなことはしたくなかったですし、 進捗を報告させるのは一定のストレスを与えてしまうので、そういう余計なストレスも与えたくなかったためです。

そもそも開発の進捗は、GithubのPullRequestやIssueを見れば大体は把握できますし、細かい部分は現場でのメンバーの様子を見ていれば把握できるので、廃止しました。

心理的安全性を保証する

チームの心理的安全性が低いとどうしてもストレスから生産性が落ちてしまいます。また、タスクの柔軟な変更や、調整を行うには心理的安全性が高くないと、防衛的になってしまって、上手く調整できなくなってしまいます。そこで、心理的安全性を保証するように色々と配慮しました。

例えば、進捗の遅れを追求しないようにしていました。 進捗が遅れている人も、サボって遅れているわけではなくその人なりに最大限努力しているので、無理に急かしたところでメリットはありません。

何か問題があって遅れているのか、見積もりが間違っていたかだけなので、問題を取り除くか、スコープを調整する必要があります。いずれにしろ問題はメンバーではないということを意識して伝えました。

また、どんなに進捗が遅れていても、メンバーのプライベートを優先することを徹底しました。

どんなに忙しくてもライブやサッカーの観戦などの邪魔は絶対にしないようにしましたし、何時に来て何時に帰ろうが自由だという空気を作るようにしました。そもそもフルフレックスなので、当然の権利なわけですが、例えばお昼から出社する場合、みんなどうしても「すいません、お昼から出社します」というように謝ってしまいます。このような発言がある場合「謝る必要ないですし、そもそも報告も不要ですよ」と伝えるようにしました。

やわらかいプロジェクト管理のデメリット

上で述べたように、やわらかいプロジェクト管理では変更を吸収しながらプロジェクトを進めることがやりやすくなります。とはいえ、プロジェクト管理に銀の弾丸はなく、当然デメリットも存在します。

まず、リーダー(あるいはPM)の負荷が非常に高いです。言ってしまえば、メンバーに対してタスク管理をあえて甘くしているため、具体的な進捗や、リスクなどの把握をリーダーが一挙に把握し切る必要があります。 つまりは、リーダーが全体を常にオーガナイズして、各メンバーの生産性を高める手法だとも言えます。

今回の場合でいうと、私が開発の全てをコントロールしていました。いわゆる、トラックナンバー1の状態で、非常に危うい状態だっと思います。

また、心理的安全性を高めるための様々な配慮も必要になります。プロジェクトが佳境に入っても、リーダーは常にポジティブな空気を作る必要があり、一般的には簡単なことではないと思います。

このため、プロジェクトの規模がある程度大きくなってしまうと、この手法は適用しづらいと思われます。

さらには、ある程度メンバーを信用して管理を行うため、自走できるメンバーがいない場合も上手く回らない可能性があります。

終わりに

クックパッド料理教室のリニューアルプロジェクトで用いた「やわらかいプロジェクト管理」についてご紹介しました。何かの参考になれば幸いです。

iTunes Connect から発行したプロモコードをカメラで読み取る

 こんにちは、iOS エンジニアの中村です。iOS クックパッドアプリの開発では、iTunes Connect から発行したプロモコードを使いリリース前に動作確認をしています。(2013年頃から審査通過後の動作確認がこの方法で出来るようになり、リリース時の緊張感が少し軽減されました)ここまではいいのですが、このコードは文字列で発行されるためキーボードで入力しなければなりません。毎回ランダムな文字列を手入力するのは避けたいです。そこで、このコードを iTunes Card に記載されているコードのようにカメラで読み取れる画像を生成するツール icccig を作りました。この記事では、このツールの製作過程をご紹介します。

  • App Store.app > おすすめ > コードを使う の画面(カメラで読み取るという選択肢がある)

f:id:nkmrh:20170406151004p:plain:w300

フォント

 テキストエディタに Helvetica Nune でコードを書き、その周りに枠線を付けたものが認識できるか試しましたが、枠線は認識してもコードを認識することはできませんでした。そこで、コードに使われているフォントを WhatTheFont で探しましたが、これはというものは見つかりませんでした。探している過程で「光学式文字認識のための字形(英数字)」というものを知り OCR-B がとてもよく似ていたのですが、これも認識することはできませんでした。

  • OCR-B で書いたコード

f:id:nkmrh:20170406151015j:plain:w300

  • iTunes Card のコード

f:id:nkmrh:20170323132333j:plain:w300

 フォント探しは諦め、Google の画像検索で iTunes Card のコードが沢山写っている画像を見つけることができたので、この画像からフォントを作るアプローチに変更しました。しかしよく見るとこの画像には足りない文字がありました。例えば 1 2 B Z 等々。ここで iTunes Connect からコードを100個発行して出現文字を確認したところ、そこで使われていたのは以下の20文字でした。

iTunes Connect から得られたコードの文字の種類
数字 3, 4, 6, 7, 9
英字 A, E, F, H, J, K, L, M, N, P, R, T, W, X, Y

 お店で売られている iTunes Card に使われているコードは16桁なのに対して、iTunes Connect から得られるのは12桁です。出現文字が少ないのは恐らくこのためですが、今回は iTunes Connect から得られるものに対応できればいいので足りない文字は無視して進めました。Sketch で文字をスライスした画像を並べて試したところ認識できました。これでフォントは完成です。

スクリプト

 スクリプトは2つ用意しました。1つは文字列からカメラで読み取れる画像を生成するスクリプトです。これは上記で作成したフォントでコードを描き、その周りに枠線を付けた画像を生成します。枠線の太さと文字からの距離も重要な点です。枠線をうまく認識できるように微調整してこのスクリプトを完成させました。

  • icccig で生成した画像

f:id:nkmrh:20170406151023j:plain:w300

 2つ目は生成した画像を社内画像共有サーバにアップロードし、そのリンクのリストを生成するスクリプトを用意しました。issue にこのリストをコピペしておけば、開発関係者がコードを手入力せずに済みます。

最後に

 上記のフォントとコード画像を生成するスクリプトは こちら のリポジトリにありますのでよかったら試してみてください。実際の運用で試したところ、確認項目の多いディレクターやテストエンジニアに好評でした。このツールで一旦解決できましたが、一方で Xcode プラグインとして作った方が良かったかもしれませんし、他にもっといいツールや方法があるかもしれません。何かいいアイデアをお持ちの方は是非教えていただきたいです。