Quantcast
Channel: クックパッド開発者ブログ
Viewing all 726 articles
Browse latest View live

Android版クックパッドアプリで採用している技術の現状確認 2018年版

$
0
0

目次

  • はじめに
  • 技術選択の各論
    • 開発環境
    • HTTP Client
    • Dependency Injection
    • Image Loader
    • Debugging
    • Android Emulator on Jenkins
    • コードレビューbot
    • リリースエンジニアリング
  • おわりに

はじめに

技術部の門田( @_litmon_ )です。

Android版クックパッドアプリで採用している技術の現状確認 2015年版から3年、Androidアプリ開発を取り巻く環境も大きく変わってきました。 本エントリでは、以前のエントリからこれまでにAndroid版クックパッドアプリにあった技術選択の推移や、現在の状況を記していきます。

技術選択に関する基本的な方針などは変わっていないので、前回のエントリ( Android版クックパッドアプリで採用している技術の現状確認 2015年版 )を参照ください。

技術選択の各論

開発環境

現在のクックパッドアプリの開発環境は以下のようになっています。

  • Android Gradle Plugin 3.2.0
  • Gradle 4.10
  • targetSdkVersion 28
  • compileSdkVersion 28
  • minSdkVersion 21
  • support library version 28.0.0 (AndroidX未対応)
  • 使用言語: Java, Kotlin 1.2.60

ここ一年で大きく変化したことといえば、やはり targetSdkVersion と minSdkVersion 、そしてKotlinの採用でしょう。 それぞれに関して1つずつ振り返っていきたいと思います。

targetSdkVersion

targetSdkVersionに関しては、Googleが昨年12月頃にtargetSdkVersion 26以下のアプリは今後リリースもしくはアップデートできなくなるという発表をしたことで、無理矢理にでも上げざるを得ない状況が各アプリにあったと思います。 クックパッドアプリも例に漏れず、今年の頭はまだtargetSdkVersion 23だったアプリが、今年の9月にようやく26を満たすことが出来るようになりました。

targetSdkVersionをこれだけ長い時間かけて上げていったのには理由がありました。

まず1つが、targetSdkVersion 24で入ったIntent, Bundleに含められるデータサイズ制限です。 クックパッドアプリは大量のデータをActivityのIntentに載せて画面遷移を行っていたり、savedInstanceStateに保持していたため、targetSdkVersionを24に上げた状態でAPI 24以上の端末でアプリを使用すると、画面遷移を行った瞬間にクラッシュするという事態が発生していました。 ここを直すために色々と手を尽くして、最終的に以下の対応をすることで大きな問題を起こすことなくアップデートすることが出来ました。(実際にはいくつか作業ミスもありバグ報告も来ていたが、すぐに収束した)

  • Activity遷移の際のデータ保持は可能な限り小さくなるよう、大きな箇所を洗い出して修正
  • savedInstanceStateのデータ保持はAndroid Architecture Component ViewModelを使うように変更

そしてもう1つが、targetSdkVersion 26で入ったバックグラウンド制限です。 クックパッドアプリでは、料理きろくという機能の中でJobSchedulerを使ってバックグラウンド処理を行っており、その他には特にバックグラウンド制限に引っかかるようなものはない、と作業当初は思っていたのですが、実はGoogle Cloud Messagingの実装が古く、BroadcastReceiver内でServiceを起動しており、見事にバックグラウンド制限に引っかかっていました。 また、Google Cloud Messagingも2019年4月までにはFirebase Cloud Messagingに置き換える必要があったため、これを機にFirebase Cloud Messagingへの移行を行いました。

ここでもまた、アプリ上で使っているFirebaseプロジェクトが現在GCMで使っているプロジェクトと一致していない、という問題もあったのですが、GCMは2019年4月以降プッシュ通知を送れない状況であることと、そのプロジェクトはGCM以外の用途では使っていなかったこと、またプッシュ通知を送信する基盤実装への修正も容易であったことを踏まえて、廃止されるまでは両方のプロジェクトにプッシュ通知を送信する、という対応で事なきを得ました。

さらに、ローカルプッシュ通知の実装も、AlarmManagerからBroadcastReceiverを経由してServiceを起動する流れになっており、こちらもバックグラウンド制限に引っかかっていました。こちらはJobSchedulerを使った実装に変更することで対応を行い、これらの作業によって無事にtargetSdkVersion 26に上げることが出来ました。

targetSdkVersion 27, 28に関しては、上で挙げたような大きな障害はなく、すんなりと上がって今は最新の環境で開発が行えている状況です。

minSdkVersion

minSdkVersionに関しては、以前ブログでも紹介があった通り、現在は21となっています。詳細は以下の記事を参照ください。 Androidアプリ の minSdkVersion を21にした話

これによって、開発環境のアップデートに対する作業がぐっとやりやすくなりました。 正直、この1年でここまでのアップデートを行えたのはこの取り組みがあったおかげだと思っています。

Kotlinの導入

Kotlinの導入に関しては、2017年のGoogle I/Oで公式にサポートすると発表した直後から導入したいというのは話していたのですが、クックパッドアプリに関しては導入するのにだいぶ時間がかかってしまいました。

その頃のクックパッドアプリの最大の問題として「どこになにを書けばいいかが分からない」というものがあり、長い間続いているプロジェクトでアーキテクチャなどもうまく導入できておらず、無法地帯なコードベースがあったため、この状態のままKotlinを導入しても問題が増えるだけだと感じていました。 そのため、どこからKotlinを導入するか、どういう風に進めるかを基盤チームで議論した結果、VIPERアーキテクチャを導入し各画面の実装をアーキテクチャに対応させながらKotlinを導入していくことが決まりました。

その間も、他のプロジェクトや社内で使うライブラリにはKotlinを使うことに特に制限はしていなかったため、Kotlin 100%のプロジェクトも中には存在します。 また、導入の際にはStyleGuideを定めたり、社内でKotlin勉強会を開いたりして知見を共有し合ったりしていました。

方針が決まってからしばらく別の施策によって手が止まっていたのですが、クックパッドアプリでも今年の3月頃から導入を開始していて、今ではプロジェクトの約20%のコードがKotlinに置き換わっています。

f:id:litmon:20181115172928p:plainf:id:litmon:20181115172946p:plain

Swiftに比べてまだまだ手を付けられていない状況ですが、手を付けられるところから少しずつKotlinコードへの変換とアーキテクチャの適用を行っています。

HTTP Client

以前の記事時点では、Cookpad APIとの通信にはVolleyを使用していました。 ですが、以前の記事でも述べていたとおり、Volley内部で使用されているApache HTTP ClientがDeprecatedになっていたため、昨年メインとなるCookpad APIとの通信層に使用するコアライブラリをOkHttp3に置き換えました。

Retrofitなどのラッパーライブラリを使うかは検討しましたが、Cookpad APIはGarageで作られているため、Garageのリクエスト方式に沿ったものがあったほうが良いだろうという判断をして、社内ライブラリとしてgarage-client-androidを作成して使っています。

以降の展望としては、API側の変化を伴うものになっていくのではないかなと予想しています。 現在の問題点として、Kotlin, SwiftなどのNull安全な型制約を持った言語とRESTFullなAPIは相性が悪く、返ってくる値が Nullable であるかどうかが分かりにくく、開発効率も下がるしバグが発生しやすいというものがあります。 また、モバイルアプリでは、一画面で複数のリソースが必要になることが多く、RESTFullなAPIだと複数のAPI通信を行う必要がある場合が発生し、扱いが難しいところがあります。

これを解決するために、社内の一部新規サービスではGraphQLを使用したAPI通信を始めていたり、gRPCやSwaggerなどの型制約を解決できるような仕組みを使えないか検討し始めていたりします。

Dependency Injection

以前の記事時点ではRoboGuiceを使用していました。 ですが、RoboGuiceは2016年8月の時点でサポートが終了し、別のDIライブラリへの移行を余儀なくされる形となっていました。 前回の記事でも言及していたとおり、社内ではDIライブラリを使うべきかどうかという議論がずっと繰り返されており、Kotlinを導入した際に同時に導入したVIPERアーキテクチャではDIライブラリを使用しない形で進めていこうという話もしていました。

しかし、現段階で使っている箇所に関しては簡単に剥がすことも出来ないため、2018年4月からは実装も薄くてRoboGuiceからの移行が比較的簡単だったToothpickというDIライブラリを導入しています。 導入しようとしていた4月時点ではまだproguardへの対応が行われている最中だったため、少し時期尚早だったかなと思っていたのですが、いまのところ大きな問題もなく運用出来ています。

ちなみに、クックパッドで最近リリースされたcookpadTVアプリに関しては、DIライブラリにはDagger2を使用しています。このあたりの技術選定は基本的に各サービスごとのエンジニアに任せるようにしています。

cookpadTV -クッキングLIVEアプリ-

Image Loader

画像読み込みには、以前はPicassoを使用していましたが、こちらも長らく開発が滞っていたため、昨年Glideに移行しました。 導入段階ではFacebook製のfrescoも検討していましたが、こちらは画像読み込みの際のインターフェースがPicassoと大きく離れていたり、画像のサイズを予め知っておく必要があったりと、移行作業に難ありだったため見送りました。

PicassoからGlideへの移行に関しては、インターフェースも非常に似通っているためほとんど技術的な問題は起きませんでしたが、リリース後「画像が読み込めない」というお問い合わせが多数寄せられ、とても頭を悩ませたことは記憶に新しいです。(結果的に、通信レイヤーで使用していたOkHttp3のバグだったことが判明し、OkHttp3のアップデートを行うことで解決しました)

Debugging

デバッグツールには、StethoHyperion-Androidを導入しています。

Sthethoは、通信層のコアライブラリをOkHttp3に置き換えたことにより、プロキシを介さずにアプリの通信履歴を見ることが出来て、非常に重宝しています。

また、Hyperionは、SharedPreferencesに保存されているデータの中身を見たり、View構造を探索したりと、非常に多機能で優秀な上に、拡張してデバッグ用のメニューを追加することも出来ます。 今までは、クックパッドアプリのサイドメニュー下にデバッグ版のみ表示されるツールメニューを用意していたのですが、Hyperion導入後はHyperionのメニューとしてデバッグメニューをまとめることが出来るようになり、デバッグ用の機能を管理するのがとても楽になりました。

Android Emulator on Jenkins

Jenkins CI上でのAndroidエミュレータの扱いに関してもここ数年で大きく動きがありました。 弊社のCI環境は、Amazon EC2インスタンス上に構築されたJenkinsを使用しており、以前はその上にARMエミュレータを起動してCI上でのInstrumentation Testの実行に使用していました。 しかし、やはり起動時間や実行時間がネックとなり、それらの改善を行うためにこれまで様々な取り組みをしてきました。各取り組みに関してはそれぞれブログがまとまっているので、そちらを御覧ください。

現在では、AndroidエミュレータはGenymotion Cloud(旧Genymotion On Demand)を使用していますが、上記記事内でも言及している通りGenymotion CloudではGoogle Playの機能を使うことが出来ません。 これに対して、クックパッドアプリでは新たにいくつかの方法を検討中です。例えば、Firebase TestLabを使ったInstrumentation Testの実行や、上記記事でも挙げたようなグローバルチームでいち早く導入されているAWS Bare Metalインスタンスを使用したAndroidエミュレータの構築、またはFirebase, AWSなどのデバイスファームの使用などが挙げられます。

CI環境に関してもその時々で要求されるものは移り変わっていくので、色々な選択肢を試しながら技術選択を行っています。

コードレビューbot

社内では、GitHub Enterprise上でのPullRequest駆動での開発が盛んですが、その中でもコードレビューを行う際にLintやfindbugsなどの静的解析ツールによる指摘を自動化しています。

以前は、社内で開発していたdokumiというツールを使っていたのですが、 dangerというオープンソースのツールに乗り換えました。 dokumiの設定はdokumi本体に含める必要があり、各プロジェクトの設定がツールに含まれる形になってしまい、ツールとプロジェクトの関係が密になりすぎるという問題がありました。

その点、dangerは各プロジェクトごとにRubyで設定ファイルを記述することで動作するし、プラグインを作成するのも容易だったため、複数のプロジェクトに簡単に導入できるという点が乗り換えたポイントでした。また個人的に、dokumiと違ってdangerのコメントは上書きされていくので、PullRequest上に指摘コメントが積み重なっていき読みづらくなることが減るところが気に入っています。

danger導入時に関する話は以下のブログ記事で詳細に書かれているため、よろしければ読んでみてください。 Android開発のコードレビューbotを乗り換えた話

リリースエンジニアリング

以前から、リリースに伴う作業を手作業ではなく機械によって自動化するために、fastlane/supplyを使ってリリース関連の作業を自動化しています。

弊社のCI環境はJenkinsなので、Jenkinsのgoogle-play-android-publisher-pluginを使用する選択や、Gradleプラグインのgradle-play-publisherなどを使用する選択も取れたのですが、その中でもfastlaneを使用している理由は以下の点が大きいかなと思っています。

  • fastlaneはiOSアプリでも積極的に使用されていて、内製のプラグインも開発されているなど、iOSとの仕組み共通化のためにも使える
  • Gradleプラグインとして採用するとビルドに影響が出てしまうので、ビルドとリリースフローは分離したい
  • Ruby製のツールなので、社内の開発リソースと一致しやすい(※個人的な意見です)
  • Groovy書きたくない(※あくまでも個人的な意見です)

最近では、fastlane/supplyを利用してアプリの自動リリースを行い、リリースフローの機械化・自動化も進めています。こちらに関しては、iOSでの取り組みが先行しているため、以下のブログ記事を参照ください。

クックパッドアプリはみんなが寝ている間にサブミットされる

また、来たる2019年2月のDroidKaigi 2019にて、「Google Play Consoleのリリーストラックを有効活用してリリースフローの最適化を行った話」というセッションが採択されたため、そこで詳細に話す予定です。ご興味のある方は足を運んでいただけると幸いです。

おわりに

いかがでしたでしょうか。思いつく限りの最近のクックパッドアプリの開発事情について書き記してみました。

近年のクックパッドアプリは、目新しいものを導入したというよりは今まで使っていたものがどんどんと使えなくなっていったためアップデートしていったという話が主になっていることがわかります。 とはいえ、新しい技術を試していないわけではなく、使えるものは積極的に取り入れていくし、色々な技術を試すための環境は充分に用意されています。

また、今回はクックパッドアプリの開発事情について書きましたが、最近は新規サービスもどんどんと生まれてきており、複数のアプリに対する仕組みの共通化なども積極的に行っています。

これからもいろんな技術を駆使してユーザーさんにすばやく価値を届けられるように改善を続けていくので、一緒に高まっていきたい人はぜひぜひお声がけください!


最新のログもすぐクエリできる速くて容量無限の最強ログ基盤をRedshift Spectrumで作る

$
0
0

こんにちは。去年の今頃は Rust を書いていました。 インフラストラクチャー部データ基盤グループの id:koba789です。

背景

クックパッドではデータ基盤の DBMS として Amazon Redshift を利用しています。 既存のデータ基盤について詳しいことは クックパッドのデータ活用基盤 - クックパッド開発者ブログを参照してください。

今まで、ログは数時間に1度、定期実行ジョブで Redshift 内のテーブルにロードしていました。 ロードジョブの実行間隔が "数時間"と長めなのは、Redshift のトランザクションのコミットが遅いためです。 クックパッドでは数百ものログテーブルがあるため、仮に1分おきにすべてを取り込もうとすると秒間数回以上のコミットを行わなければなりません。 このような頻繁なコミットは Redshift 全体のパフォーマンスを悪化させてしまいます。

サービスの開発者はリリースした新機能の様子をすぐに確認したいものです。 にもかかわらず、ログがクエリできるようになるまで、最大で数時間も待たなければなりませんでした。 最悪の場合、その日のうちに確認することは叶わず、翌朝まで待たされることもありました。 これではサービスの改善を鈍化させてしまいます。

また、問題はもう一つありました。 当然ですが、ログは日々単調増加し、ストレージの容量を消費し続けます。 一方で、Redshift のストレージサイズはノード数に対して固定です。 ノードを追加すればストレージを増やせますが、ノードには CPU やメモリも付いているため、ストレージが欲しいだけの場合は割高です。 すなわち、ログにとって、Redshift のストレージは高価だったのです。

求められているもの

絶え間なく流れ続けるログを遅延なくロードし続けられるログ基盤が必要でした。 もちろん、ロードされたデータはなるべくすぐにクエリ可能になるべきです。

その上で、クエリの実行速度を犠牲にすることはできません。 クエリが遅くなれば夜間のバッチジョブの実行時間に影響が出ます。 そのため、従来の Redshift 内のテーブルへのクエリと同等かそれ以上のクエリ速度が求められていました。

そして最後に、願わくばストレージが安くて無限にスケールすることを。

この要件だけ見ると BigQuery を使えばいいのではないかと思われるかもしれません。 確かに BigQuery なら上記の要件は満たせるかもしれません。 しかし、Redshift にある PostgreSQL 互換の接続インターフェイスや AWS IAM との連携などの機能は BigQuery にはないため、Redshift を使うことで既に達成できているその他の要件が満たせなくなってしまいます。 また、大量のデータをクラウドプロバイダ間で日々転送しつづけることによって発生する追加の転送料も問題になります。

以上のような理由から、BigQuery に乗り換えるだけでは我々の理想は達成されないと判断しました。

Redshift Spectrum

まずはじめに、最強のログ基盤の一翼を担っている大切なコンポーネントである Redshift Spectrum を紹介します。

Redshift Spectrum は Redshift の機能のひとつです。 Redshift Spectrum を用いると、Redshift 内から S3 に置かれたデータを直接クエリすることができます。 にもかかわらず、Redshift 内のテーブルと JOIN することもできます。 つまり、S3 に置かれたデータを通常のテーブルと同様に扱うことができるのです。

Redshift Spectrum で読めるテーブル(以下、外部テーブル)の内容は Redshift へではなく、S3 に直接書き込みます。 そのため、トランザクションによる保護はなく、同一トランザクション内であっても、複数回の読み取りはそれぞれ別の結果を返す可能性があります。

しかし、今回はこの特性を逆手にとり、頻繁なロードを可能にしました。 Redshift の内部テーブルへの書き込みと違い、S3 への書き込みならコミットのパフォーマンスに悩まされることはない、という算段です。

そしてみなさんご存知のとおり、S3 は容量が事実上無限にスケールして容量単価も安価なストレージですので、最後の願いが叶えられることは言うまでもありません。

高速なクエリのために

Redshift Spectrum を活用するにあたって、高速なクエリを実現するために実践せねばならないプラクティスがいくつかあります。 これらのプラクティスは AWS の開発者ガイドを参考にしています。

Amazon Redshift Spectrum クエリパフォーマンスの向上 - Amazon Redshift

まず1つ目はパーティション化です。

Redshift Spectrum ではパーティション化をしないとクエリのたびにテーブルの全データをスキャンすることになってしまいます。 今回のケースでは、ログレコードの発生時刻の日付でパーティションを切ることにしました。 パーティション化によって、データオブジェクトの key はスキーマ名・テーブル名のあとに、さらに日付で分割され、以下のようなレイアウトになります。

  • hoge_schema.nanika_table/
    • dt=2018-08-09/
      • 001.parquet
      • 002.parquet
      • ...
    • dt=2018-08-10/
      • 008.parquet
      • 009.parquet
      • ....

2つ目は列指向フォーマットの利用です。

Redshift Spectrum では、CSV や改行区切りの JSON などの一般的な行指向のテキストファイルもクエリすることができますが、クエリをより効率的かつ高速にするには、列指向フォーマットの利用が有効です。 列指向フォーマットにも様々な種類がありますが、今回のケースでは Parquet を採用しました。

3つ目は、各データオブジェクトのサイズを64MB以上の均等なサイズに揃えることです。

あまりに小さなデータオブジェクトは I/O オーバーヘッドの割合を増加させたり、Parquet の圧縮率を低下させたりします。 また、データオブジェクトのサイズの偏りは分散処理の効率性に悪影響を及ぼします。

上記の記事には、以上の3つの他に、効率的なクエリの書き方についてのプラクティスも紹介されていますが、ログのロードではそれらは関係ないため、ファイルの配置に関する内容のみを取り上げています。

Overview

f:id:koba789:20181121111431p:plain

Prism は Redshift Spectrum にログをロードするために私が開発したソフトウェアです。 Prism は3つのコンポーネントからなります。

1つ目は Prism Stream です。 これは S3 に到着した JSON 形式のログオブジェクトを Parquet に変換するコンポーネントです。

2つ目は Prism Merge です。 これは Prism Stream が書き出した細切れのデータオブジェクトを適切なサイズに結合(マージ)するコンポーネントです。

3つ目は Prism Catalog です。 日付変更後に当日分のパーティションを作成したり、階層の切り替え(後述)を行ったりするコンポーネントです。

弊社のコンテナ基盤である Hako を用いてスポットインスタンス上にデプロイされています。 スポットインスタンスの急な停止に耐えるため、上記のコンポーネントのすべての処理は、途中で突然終了してもデータの欠落や不整合が発生しないように設計されています。 Prism は RDB の読み書きのみならず、S3 への書き込みなども行います。 そのため、データの整合性については単に DBMS のトランザクションに委ねるというわけにはいかず、ケースバイケースでのケアが必要になります。*1

このようにすべての処理を冪等ないしはアトミックに実装したことの嬉しい副作用として、ネットワークや S3 の不調などによって偶発的にエラーが起きても、リトライするだけで回復できるという点があります。 もっとも、勝手にリトライするので人間がそれを気にすることは稀ですが。

また、S3 に配置したログオブジェクトや各パーティションのメタデータの DB として PostgreSQL を用いていますが、ログオブジェクトの数に比例して行が増えてしまうテーブルはローテート可能な設計にするなど、スケーラビリティのための工夫をしています。

階層化されたパーティション

各パーティションは prefix を用いて S3 上で論理的に階層化されています。 階層は SMALL と MERGED に分かれており、Glue カタログにはパーティションごとに SMALL では prefix が、MERGED ではマニフェストファイルの key が登録されています。 つまり、Redshift Spectrum が読めるのはパーティションごとに SMALL 階層か MERGED 階層のどちらか一方のみです。 当日(最新)のパーティションは初期状態で SMALL 階層を参照しており、マージ処理後、Prism Catalog によって順次 MERGED 階層を参照するように切り替わっていきます。

SMALL 階層のデータは分単位で細切れになっているため、オブジェクトサイズが最適ではなかったり不揃いだったりしており、高速なクエリには向いていません。 一方 MERGED 階層のデータは適切なサイズに揃えられており、高速なクエリのためのプラクティスに沿っています。

これは、低レイテンシなロードが必要になる場面と、高速なクエリが必要になる場面の違いを踏まえた設計です。

  • 直近(1日程度)のデータについては低レイテンシである必要があるが、クエリは低速でよい
    • "直近のデータ"である時点で対象が小さいため、クエリの速度は問題にならない
  • より古いデータについてはクエリは高速でなければならないが、ロードのレイテンシは高くてよい
    • 「今さっき届いた昨日分のログ」をすぐに見たいということは稀である

SMALL 階層のように細切れのオブジェクトを並べるだけではクエリが低速になってしまいますが、適切なサイズに粒を揃えるために単にバッファリングしてしまってはレイテンシが大きくなってしまいます。 これは低レイテンシなロードと高速なクエリを両立するための設計です。

データの流れと階層切り替え

では当日のパーティションに書き込まれた当日分のデータの流れを追ってみましょう。

まず、到着したログオブジェクトは Prism Stream によって Parquet に変換され SMALL 階層に書き込まれます。 この時点で、Redshift から読むことが可能になります。

その裏で、SMALL 階層に書き込まれたログオブジェクトは定期的に Prism Merge によってマージされ、マージ後のデータオブジェクトは MERGED 階層へ書き込まれます。 ただしまだこの時点では Redshift から MERGED 階層のデータを読むことはできません。

日付が変わったあと、Prism Catalog は SMALL 階層にある分のデータがすべて MERGED 階層に揃ったことを確認します。 確認が取れると、Prism Catalog は MERGED 階層にあるオブジェクトの一覧をマニフェストファイルに書き出し、Glue カタログにその key を登録することで、階層の切り替えをします。 この処理が走ることによって初めて、パーティションの参照先が MERGED 階層に切り替わります。 もしも MERGED 階層に SMALL 階層と同じだけのデータが揃う前に切り替えてしまうと、それまで読めていたデータが一部減少することになってしまいます。

Out-of-Order Data

当日に到着した、当日分のデータの流れについて説明しましたが、現実にはログは大幅に遅れて届くこともあります。

例として、モバイルアプリの行動ログの場合を説明します。 ユーザーが通信状況の悪い環境でアプリを操作したとします。 すると、ログレコード自体はその場で生成されますが、ログを送出することに失敗します。 クックパッドのモバイルアプリではログの送出に Puree というライブラリを使っており*2、上記のように送出に失敗したログは一旦端末に保管され、次の送出のチャンスを待ちます。 ここでユーザーがアプリを終了させ、翌日になってから通信状況の良好な環境で再度起動したとします。 通信状況が回復したため、アプリは前日の行動ログを再送します。 するとログ基盤には到着時刻に対して発生時刻が1日前になっているレコードが到着します。 ほかにも様々な理由により、ログのレコードはバラバラの順序で到着します。

そのため、日付が変わろうと当該日の SMALL 階層への書き込みが止むことはありません。 つまり、Prism Catalog が確認をしているその最中にも Prism Stream が新たなログオブジェクトを SMALL 階層に書き込むかもしれないということです。 これでは Prism Catalog が "SMALL 階層にある分のデータがすべて MERGED 階層に揃ったことを確認"することができません。

この問題を解決するため、Prism では「締め」という概念を導入し、SMALL 階層を「締め」前に書き込む LIVE 階層と「締め」後に書き込む DELAYED 階層に分割しました。 そして、SMALL 階層のうち、Redshift Spectrum が読み取り可能な部分は LIVE 階層のみとしました。

  • hoge_schema.nanika_table/
    • live/ (LIVE 階層)
      • dt=2018-08-09/
        • 001.parquet
        • 002.parquet
        • ...
    • deleyed/ (DELAYED 階層)
      • dt=2018-08-09/
        • 010.parquet
        • 011.parquet
        • ...
    • merged/ (MERGED 階層)
      • dt=2018-08-09/
        • 001-007.parquet
        • 008-013.parquet
        • ...

「締め」の後では LIVE 階層のデータが増えないことが保証されるため、Prism Catalog が階層切り替えの判断をする際には、LIVE 階層のデータがすべて MERGED 階層に揃っているかどうかを安心して確認することができます。 LIVE 階層のデータが MERGED 階層に揃った後では、どのタイミングで切り替えてもデータの減少は起きませんので、LIVE 階層と MERGED 階層の比較だけもって階層切り替えをすることができます。

まとめ

最強のログ基盤を手に入れるために開発したソフトウェアと、その設計についてご紹介しました。 Redshift Spectrum のようなクラウドサービスはとても大きくて複雑なコンポーネントですが、それ自体の理解はもちろんのこと、自分たちの課題をじっと見つめ、ひとつひとつ丁寧にトレードオフを選択していくことで強力な武器となります。

Prism の各処理をいかにリトライ可能にしたかなど、まだまだ 自慢解説したい内容は尽きないのですが、それについて書き始めるといつまで経っても本記事を公開できそうになかったため、ここで筆を置かせていただきました。

クックパッドでは絶対ジョブをリトライ可能にしたいエンジニアやデカいデータなんとかしたいエンジニアを募集しています。 Prism の設計や実装について興味があるという方はぜひともご応募ください。

*1:基本方針としては、なんらかアトミックな値の書き換えによってコミットとすることで Atomicity を作り込むというパターンですが、ここに記すには余白が狭すぎる

*2:良い感じにログを収集するライブラリ、Puree-Swiftをリリースしました - クックパッド開発者ブログ

クックパッド機械学習チームのメンバが働く環境と役割

$
0
0

研究開発部の takahi_iです。本稿ではクックパッド研究開発部の機械学習チームに所属するメンバがタスクに取り組む体制および、働く環境について解説します。

準備

機械学習はそれら単体が学ぶのにコストが掛かる分野で、高い専門性を獲得するためには多くの時間をかける必要があります。そのため機械学習の専門家はソフトウェア開発を十分に経験する 機会を得られにくい状況にあります。

このような前提において民間企業で機械学習を導入する場合、二つの可能性が考えられます。一つは完全に分業する方向で、機械学習のエキスパートはモデルだけを作り、ソフトウェアエンジニアが導入 を引き受けます。もう一つは機械学習の実験、モデル作成から導入までを一人がおこないます。

分業による利点と欠点

機械学習は日進月歩の分野です。機械学習エンジニアやデータサイエンティストといった職が単体で存在します。特に大きな組織では、機械学習のモデル作成を担当する機械学習 エキスパート(リサーチャー)とモデルの導入および運用を担当するソフトウェアエンジニアは区別されていることが多いです。

分業体制の利点

分析を担当する機械学習エキスパートと導入を担当するソフトウェアエンジニアが担当箇所を分けることで、以下のような利点があります。

  1. 各自が自分の専門性を追求できる
  2. 分業による効率化

各自が自身の専門性を磨けるので、新規性のある構成を追求したいメンバは満足できる環境です。また、うまくリソース配分ができれば、各自は自分の専門性に適合するタスクだけが与えられるために 高い生産性が期待できます。

分業体制の問題点

次に問題点を考えてみると、以下のようなものがあります。

コスト

機械学習エキスパートが機械学習のモデルだけをつくればよいという環境を作るにはコストがかかります。シンプルには社内に Jupyter サーバを立てて、そこで作業をしてもらうのが考えられますが、実際にサービスで利用するにはもう少し大掛かりな機構が必要になります。Jupyter Notebook で作ったモデルを自動でプロダクション環境にデプロイする機構や、自動デプロイされたモデルのバージョン管理、自動テストをシステムとして作り込む必要があります。

また一つのプロジェクトを遂行するにはマネージメントのコストも発生します。よくあるのがモデルは完成したが、それを組み込んでくれるソフトウェアエンジニアのリソースが足りないため数ヶ月待たされるというものです。

さらに将来システムに問題が起こった時にも、複数名をアサインする必要があるため十分な人員を確保しつづけるには頭のいたい問題です。

担当箇所が曖昧になりやすい

機械学習タスクの実装を細かく分業すればするだけ、だれが個々の箇所を担当するのかが曖昧になっていきます。たとえば多くの機械学習タスクは単純に学習器自体を適用するだけではなく、前処理を駆使して精度を上げてゆきます。この前処理部分は実験をしている時に作られ Jupyter Notebook にべた書きされています。

この前処理部分がプロダクション環境に移植されないと、入力データをモデルに入れても動作しません。ではこの前処理部分をプロダクション環境に持ってゆくのは誰でしょうか。タスクやアルゴリズムを理解しているという意味では、機械学習エキスパートですし、システムへの組み込みに慣れていることを優先するのであればソフトウェアエンジニアが適任と言えます。

通常この前処理部分の組み込みはソフトウェアエンジニアがモデルを作った機械学習エキスパート指示を受けつつ作成します。残念ながら、この共同作業は機械学習エキスパートもソフトウェアエンジニアも理解が中途半端な部分がありつつ仕上げるので、バグが混入しやすいです。さらに、精度向上が必要になった場合、前処理の書き換えが必要になる場合があります。前処理の書き換えが発生するすると、共同作業が必要になりコストは更に膨らんでゆきます。

クックパッド研究開発部の体制〜メンバがモデルの配備まで責任を持つ

現在クックパッドの研究開発部では、機械学習のモデル生成のみを担当するメンバはいません。メンバ全員が機械学習エンジニアです。ここで言う機械学習エンジニアはモデルの作成からモデルの結果を配備するところまでの責任を持ちます。

このような一人で一気通貫するシステムにも利点と欠点があります。

利点

タスクを一気通貫して受け持つ体制には以下のメリットがあります。

責任の所在が分かりやすい(責任の空白地帯が発生しにくい)

分離型ではタスクのなかの自分が興味のある部分だけ貢献して、別プロジェクトに移って行ってしまうタスクをつまみ食いするモラルハザード(タスクホッピング)が起こりがちです。

このような状況だと問題が起こった時に、貢献した人はすでに別プロジェクトをしているから問題解決は別の人がやってくれという話になります。しかし実装から数ヶ月、数年経った機械学習プロジェクトの問題解決は実装した当事者でも難しい問題です。別の人が解決にあたる場合には、さらに大きなコストが掛かってしまいます。

これに対して個人が責任を負うシステムでは、基本モデルを作ったエンジニアが責任を持つので、責任がはっきりし前処理やつなぎ込み部分において責任の空白地帯が発生しません。将来問題が起こっても、作った(もしくは正式に引き継いだ)メンバが問題の解決にあたってくれることが期待できます。

省コスト

大規模なシステムの作り込みを必要としません。機械学習エンジニアは Jupyter Notebook で実験し、自分でコードを整形、ライブラリ化し、それらをプロダクション環境にデプロイします。すくなくとも通常のサービスへのデプロイではモデル配備のために特別に自動化されたインフラは必要ありません。

問題点

とはいえ、このやり方にも問題点があります。具体的には以下の問題点があります。

知見が共有しにくい

各自が一気通貫して作業するので、各タスクの知識を一人だけが保持するという事態が起こりやすくなります。そのため、タスクの実装が適当になってしまいやすいという問題があります。

クックパッドの研究開発部ではプロジェクトの規約を提案してくれたメンバがいて、プロダクションで利用されるプロジェクトはその規約にしたがって作られています。規約には、実サービスに導入するレポジトリにはテストをつけ CI を導入する。ほかに関連するリソース(S3)の置き場、利用する Role などがあります。

専門性を磨く時間が削られる

機械学習の結果をサービスに繋ぎこむ部分にもコストがかかります。そのため各自が専門性を磨く時間は分業体制に比べて少なくなります。この状況に対応するため、研究開発部として学習をサポートする仕組みを導入しています。「5%ルール」呼ばれる仕組みで、二週間に一回(半日の間)、新しい技術をキャッチアップする時間を自由に取得できるようになっています。

さらに、この問題についてはクックパッド社の社員に提供されている作り込まれたインフラでかなり改善できていると感じています。以下の節で、機械学習エンジニアがインフラからどのような恩恵を受けているのかについて解説します。

インフラによるサポート

クックパッドで機械学習プロジェクトの作業をしていて、助かっていると感じる部分が二つあります。一つは DWH(データウェアハウス)、もう一つは各自が構築できるインフラです。

データウェアハウス(DWH)

クックパッドの社員は DWH を使って必要なデータをほぼ全て取得できます。社員がデータ取得するには分析用 SQL を入力するだけです。データ取得 SQL は機械学習用の前処理スクリプトからでも埋め込んで実行できます(詳しくはこちらを参照してください)。これによって、日々変化してゆくデータを取り込んだ状態の機械学習モデル及び出力結果をすくないコストで提供し続けられます。

作り込まれたインフラ

現在、クックパッドのインフラは ECS 上に構築されています(くわしくはこちらを参照してください)。提供される仕組みのおかげで機械学習エンジニアはプロダクション環境にインスタンスを自由に構築できます(もちろんレビューを受ける必要はあります)。

我々が機械学習に関するコンポーネントをプロダクション環境に構築するには、まずバッチや API サーバを Docker コンテナで動作するようにまとめたレポジトリを作ります。次に、Jsonnet で記述する設定ファイルに Docker イメージ、Role、環境変数などの設定を記述します。このような環境だとサーバ構築にコストがかからないですし、必要であればサーバの構成(CPU、メモリ)も設定ファイルの書き換えにより簡単に修正できます。チーム間の複雑なやり取りが必要ないので、機械学習エンジニアはすくないコストでプロダクション環境に機械学習周りの計算機リソースを構築できます。

研究開発部における省力化の取り組み

これまで述べてきたように社内環境に助けてもらっていますが、研究開発部でも実験や導入作業が効率化できるようにいろいろな取り組みをしています。

一つには研究開発部にはインフラに強いメンバがいます。彼らが高速な研究開発を支える GPU 計算機環境を作ってくれ、メンバが必要とする計算機リソースを常に確保できる状態になっています。

機械学習エンジニア自身も各自がツールをつくって自分の業務を効率化するのに役立てています。たとえば Kelnerという爆速で Keras のモデルから API サーバを構築するツールを作っているメンバもいますし、私も各プロジェクトごとに異なるポートフォーワードの設定を管理するツール、pfmを自作して自身の業務を効率化してます。

また、機械学習プロジェクトはタスクは異なっても、デプロイ方法は似ているものが多いです。たとえば、機械学習が出力する判別結果の多くはいくつかの方法で利用されます。DB や Redshift のテーブルに入れる。API サーバを立てる。検索エンジンのインデクスに登録するなどです。このあたりはタスクが変わってもやり方は変わらないため、過去の Issue やそれらを抽象化したドキュメントが役に立ちます。チームメンバが各自経験したタスクをもとにしたドキュメントを書いてくれているので、自分自身が 初めてやるタスクでも比較的低コストに実装できる環境になっています。

まとめ

このエントリではクックパッドの研究開発部における機械学習エンジニアの役割について解説しました。クックパッド研究開発部は今後も様々な取り組みに挑戦していきます。メンバを募集しているので、ご興味がある方は是非ご応募ください!

Catchpointを使ったWebページのパフォーマンス計測

$
0
0

技術部開発基盤グループの外村です。最近はクックパッドのレシピサービスのWebフロントエンドの改善に取り組んでいます。その一環でWebサイトのページロードのパフォーマンス計測をおこなっているので、今回はその取り組みについて紹介します。

Webページのパフォーマンスといっても、文脈によってそれが指すものは様々です。サーバーのレスポンスタイムのみを指すこともあれば、ブラウザがページをレンダリングするまでの時間を指すこともあります。また、レンダリング後にUIの操作やアニメーションがどのぐらいの速度で動くかというのもWebページのパフォーマンスの1つです。今回はブラウザでWebサイトを開いてからページが表示されるまでのパフォーマンス(ページロードのパフォーマンス)にフォーカスします。

継続的なパフォーマンスの計測

ページロードのパフォーマンスを計測する手法はいくつかあります。まず、簡単なのは Google Chrome の DevTools に付属している Lighthouse を使う方法です。DevTools の Audits タブを選択して計測をおこなうと、次のように詳細なパフォーマンスレポートを見ることができます。

f:id:hokaccha:20181129161257p:plain

DevTools を使った計測は便利なのですが、単発の計測では意図せずパフォーマンスが劣化したときに気づけませんし、マシンのスペックによって数値がばらけてしまうこともあります。パフォーマンス改善のための計測は、同一の環境から自動で定期的に計測することが重要です。

ページロードのパフォーマンスを自動で測定するツールはWebPagetestSpeedCurveNew Relic Syntheticsなど様々なものがあります。クックパッドでは、以前以下の記事でも紹介した、Catchpointというサービスを利用しています。

Synthetic Monitoring を活用したグローバルサービスのネットワークレイテンシの測定と改善 - クックパッド開発者ブログ

指標を決める

パフォーマンスの改善をおこなうために改善すべき目標の指標を決める必要がありますが、ページロードにおける指標は様々なものがあり、どの指標を改善すべきかはコンテンツの特性や目的によっても変わってきます。

例えば DOM のロード完了(DOMContentLoaded)や、サブリソースの取得まで含めたロードの完了(load)のイベントが発火するまでの時間はよく知られた指標の1つです。これらは簡単に取得できるというメリットがありますが、実際にページにコンテンツが表示されるまでの速度とは異なるため、改善の目安の1つとして使うのはよいですが、目標とする指標にするには不十分です。

DOM やリソースのロード時間と比べ、コンテンツが描画されたり、ユーザーが操作できるようなタイミングといった指標は、より実際のユーザー体験に近い指標を得ることができます。例えば以下のようなものがあります。

  • First Paint
    • 画面に何かしら(背景色などでもよい)描画されたタイミング
  • First Contentful Paint
    • テキストや画像などのコンテンツが最初に描画されたタイミング
  • First Meaningful Paint
    • ユーザーにとって意味のあるコンテンツが描画されたタイミング
  • Time To Interactive
    • ユーザーの入力に反応できるようになったタイミング

f:id:hokaccha:20181129161429p:plain
User-centric Performance Metrics | Web Fundamentals | Google Developers
Licensed under a Creative Commons Attribution 3.0 License.

First Paint と First Contentful Paint は簡単に機械的に判定できるものなので、ブラウザの API から取得できますし、標準化も進んでいます。一方、First Meaningful Paint や Time To Interactive は何を持って意味のあるコンテンツとするのか、ユーザーの入力に反応できる状態と判断するのか、という基準を決めるのが難しいので、曖昧さが残る指標ですが、実際のユーザーの体験に近い数値を取ることができます。

これらの指標が有効なケースも多いのですが、実際のブラウザによるページのロードはもう少し複雑で、ある特定のタイミングを測るだけでは不十分なケースも多くあります。そこで考えられたのが Speed Index という指標です。Speed Index は単純なある地点でのタイミングでなく、ファーストビューが表示されるまでの進捗を含んだ数値です。

f:id:hokaccha:20181129161758p:plain

上記の図において、A と B はファーストビューの表示速度は同じですが、A のほうは徐々にコンテンツが表示されており、B はファーストビューがでるまでほぼ真っ白な状態です。Speed Index は最終フレームと途中経過のフレームの差分を計算し、それを足し合わせることでスコアを算出します。そうすることで実際のユーザー体験により近い指標を得ることができます。上記のケースでは当然体験がよいのは A で、Speed Index のスコアも A のほうが低く(Speed Index は低い方がよい)なります。

もっと具体的な算出法方法については公式のドキュメントを参照してください。

Speed Index - WebPagetest Documentation

今回はこれらの指標を検討し、Speed Index を目標指標として採用することにしました。

Catchpointでの計測

Catchpoint では Speed Index の計測はデフォルトで有効になっていません。Advanced Settings から Filmstrip Capture を有効にすることで Speed Index を計測できるようになります。

f:id:hokaccha:20181129161843p:plain

Filmstrip Capture は特定の間隔で画面のキャプチャを取る機能で、このキャプチャを元に Speed Index が算出されます。Catchpoint ではこのキャプチャを取る間隔を 200ms 〜 2000ms で選ぶことができます。有効にして測定すると次のように、画面がレンダリングされていく様子がキャプチャで記録され、Speed Index のスコアが取得できます。

f:id:hokaccha:20181129161934p:plain

また、Speed Index 以外にも改善の助けとなる指標はたくさんあるので、それらをまとめてダッシュボードを作り継続して観測できるようにしています。

f:id:hokaccha:20181129162010p:plain

ページ遷移のパフォーマンス計測

ページロードのパフォーマンス改善は、初回アクセス時と別のページから遷移時、2度目のアクセス時などによって大きく性質がことなります。初回アクセス時には何もキャッシュを持っていない状態なのでクライアント側のキャッシュを使う方法は役に立ちません。一方、クックパッドのサイトで検索ページからレシピページに遷移するようなケースでは、検索ページにアクセスした時点で共通のアセットをキャッシュしたり、検索結果のレシピのページを先読みしてキャッシュしておくなどの対策が可能になります。

Catchpoint ではこのようなページ遷移時のパフォーマンスも測定することができます。

Transaction Test Type – Catchpoint Help

ページ遷移するためのスクリプトを設定に書くことができます。例えば検索画面にアクセスし、一番上のレシピをクリックしてレシピ画面に遷移するためには、以下のようなスクリプトを設定画面に書きます。

// Step - 1
open("https://cookpad.com/search/%E3%83%91%E3%82%B9%E3%82%BF")
setStepName("Step 1: Open Search Page")

// Step - 2
clickAndWait("//*[@id='recipe_0']/div[@class='recipe-text']/span[1]/a")
setStepName("Step 2: Transition To Recipe Page")

計測結果は各ステップごとに保存され、以下のように結果を見ることができます。

f:id:hokaccha:20181129162052p:plain

様々なキャッシュが効くので初期ロードのときよりも各種スコアがよくなっており、遷移時のほうが高速にページがロードされていることがわかります。

まとめ

Catchpoint を使ったWebページのパフォーマンス計測について紹介しました。パフォーマンスの改善についてはまだ着手し始めたばかりで、具体的な施策をおこなうのはこれからです。具体的な改善について成果がでたらまた別の機会に報告したいと思います。

1週間で仮説検証を繰り返す、サービス開発のための取り組み

$
0
0

こんにちは。投稿開発部 エンジニアの角田と申します。投稿開発部は、クックパッドの中でもレシピを投稿するユーザーに向けた機能の開発を行っている部署です。私達の部署では、エンジニアも仮説検証の段階からディレクターやデザイナーと一緒に取り組むことが多く、本稿では投稿開発部で行っている仮説検証についてご紹介します。

Webやアプリでサービス提供している方なら、新しいアイディアの価値は最速・最小限で確かめたいものですよね。 特に大規模な実装になりそうなアイディアであればあるほど、本当に価値があるのか、ユーザーに使われそうかを先に確かめたいものです。

私達は、本実装する前に「実際のユーザーの反応を見ながら、新しいアイディアの価値を短期間で確認する」取り組みを行っています。 私達のチームに合わせた手法になっているので、そのままは活用できないかもしれないですが、同様に仮説検証に取り組んでいる、もしくは取り組もうとしている方々の参考になればと思い、ご紹介します。

プロダクトの価値を短期間で確認するために

クックパッドでは、この本でも有名なGoogleのデザインスプリントを全社的に取り入れ始めています。こちらの記事にて取り組みの内容について取材していただいております。

ここでは、投稿開発部での最新の取り組みを紹介しますが、ところどころスプリントの手法を参考にしている部分があるので、気になった方はそちらも読んでいただけると、より理解が深まるかもしれません。

私達の場合は、仮説の設定を含め、概ね1週間以内でプロダクトや機能の発案とその評価を行っています。この検証では、定性調査(ユーザーインタビュー)のみを行いますが、検証の結果が良さそうであれば本実装を行い、そこで定量調査を実施します。 1サイクルの中で、以下のようなステップを踏んでいます。

1) 部orチームの目標から、仮説を設定する
2) 定性評価の精度を上げるための「問い」を設定する
3) ソリューションを考える
4) ソリューションの共有と決定を行う
5) 問いを見直し、ストーリーボードを作成する
6) プロトタイプを作成する
7) ユーザーインタビューと評価を行う

1)部orチームの目標から、仮説を設定する

ここでは各部署や各チームの長期的な目標から、それを成し遂げるために検証したい仮説を設定します。

投稿開発部では、「レシピを投稿し始める人を増やすこと」を長期的な目標のひとつとして設定しています。 例えば、その目標を実現するために、「料理の工程や工夫の話が出来て反応が嬉しい反応がもらえることで、レシピ投稿を始めるのではないか」といったような仮説を設定します。 複数の仮説を確かめたい場合もありますが、仮説をひとつに絞った方が評価もしやすく、インタビューでも深く聞くことができるため、個人的にはひとつに絞ることをおすすめします。

仮説のタネは、自分たちで日頃から料理やレシピ投稿の経験を積んだり、課題抽出のためのユーザーインタビューを行うことによって得ています。

この後、1週間でこの仮説にYes/No の結論を出すための検証を設計していきます。

2)定性評価の精度を上げるための「問い」を設定する

検証したい仮説にYes/Noを下すためには、仮説が成り立つ条件をすべてクリアしている必要があります。ここではその条件を問いにします。

例えば、上記で挙げた「料理の工程や工夫の話が出来て反応が嬉しい反応がもらえることで、レシピ投稿を始めるのではないか」という仮説は、以下の条件から成り立つとします。

  • そもそも料理の工夫や話したいネタがある
  • 料理の話をして反応が欲しいと思っている
  • レシピを投稿すれば、反応が得られる思える
  • レシピ投稿の方法が分かり、投稿できる

これを疑問系に置き換えて問いを定義します。そして、ユーザーインタビューの際にこれがクリアできているかを観察します。

3)ソリューションを考える

ここでは、1)で立てた仮説を、ユーザーに体験してもらうためのソリューションを考えます。 全チームメンバーが考えを持ち寄る事を大切にしていて、同日に制限時間を設けて案を出すこともありますが、難しければ1~2日後に個々人が考えた案を持ち寄ることもあります。 ソリューション案は、後ほど壁に貼って見比べるので、検討しやすいよう項目を以下のように共通化しています。

  • 仮説
    • 1)で立てた仮説をより具体的なシーンに落とし込んで、ソリューションと紐づけたもの
  • ターゲット
    • 仮説にあてはまる人の具体的な属性(ex: 主婦、仕事あり..etc)や、心理的な背景(ex: 日々の料理が作業になっていてモチベーションが上がらない)など
  • 体験
    • そのソリューションで実現したい具体的な体験
  • 方法
    • 定義した「体験」を実現する方法
    • 簡単な画面遷移図などを添えることが多い

4)ソリューションの共有と決定を行う

2)で考えたソリューションを、共有し、そのプロジェクトの意思決定者が採用する案を決めます。

こちらは、スプリント本で言及されている「ソリューション決定」を参考にして、おおまかに以下の流れで実施しています。詳細はスプリント本をご確認ください。

  1. 各ソリューションをB4の紙に印刷もしくは書き出して、ホワイトボードに貼り出します(考案者の名前は記述しません)
  2. 各メンバーは<無言で>気になった点に小さな丸シールを貼り、感想や質問を付箋紙に書いてはります(5-10分程度)
  3. 全員貼り終わったら、各考案者が付箋に対し回答し、他のメンバーと不明点の解消を行います
  4. 議論が完了したら、各メンバーが採用したいソリューションに大きめの丸シールを貼ります(2-3枚程度)
  5. 意思決定者は、各メンバーが選んだ理由を聞いて、最後にどの案を採択するか決めます

f:id:kaktaam:20181129154848j:plainf:id:kaktaam:20181129154653j:plain

5)問いを見直し、ストーリーボードを作成する

問いの見直し

ここでは、2)で設定した問いをソリューションに合わせて見直します。問いの本質を変えるわけではなく、問いをそのソリューションに合った表現に置き換えます。インタビューや評価する際に、問いに対して判断をしやすくするために行います。

ストーリーボード作成

こちらもスプリント本の「ストーリーを固める」を参考にしていますので、詳しくはそちらをご覧ください。 簡単に言うと、ユーザーに私達のソリューションを正しく体験してもらうために、プロトタイプの絵コンテを10~15コマ程度で作成します。 これをもとに、具体的なプロトタイプやインタビューの設計に落とし込みます。

f:id:kaktaam:20181129155405j:plain:w350

6)プロトタイプを作成する

このプロトタイプでは、設計した検証に必要な体験をしてもらうための、最低限・最小限の画面・機能を用意します。短期間での検証なので、不要な詳細は省きます。

プロトタイプを作る場合、大きく2パターンの方法を採用しています。 ひとつは、静的なプロトタイプを作成するパターンで、もうひとつは実装するパターンです。

静的なプロトタイプの場合

Marvelというプロトタイプツールを使います。この場合は、デザイナーが作ったプロトタイプ用の画像をこのツールに反映し、画面遷移をできるようにします。どのインタビューイーに対しても、同じ文言やUIを見せられれば良い場合などは、この方法を選択します。簡単に作ることができますし、Marvelの専用アプリを用いて操作することで本物のサービスのように見せることができるので、この方法で十分かと思います。

実装する場合

投稿開発部では、レシピ投稿についての仮説検証を行うことが多く、検証の中でユーザー自身が投稿したレシピや、それに関連する複数の情報をプロトタイプ上で表示したい、操作してもらいたい場合がよくあります。そうなると、静的なプロトタイプではなく実装をします。具体的には、React Native製の「クックパッド MYキッチン」アプリにプロトタイプを実装し、インタビューで使うことが多いです。 インタビューにおいても、ユーザー自身のデータを表示することにより、素直で本音に近い回答が得やすいと感じています。

クックパッド MYキッチンについては、こちらの記事に詳しく書かれています。 React Nativeの開発環境については、iOSAndroidともに記事を公開していますので、よろしければご覧ください。

7)ユーザーインタビューと評価を行う

最後にインタビューと評価についてです。

ユーザーインタビュー

インタビューは一人30分×5名を1日で行うことが多いです。事前に決めた問いを判断できるようなインタビュースクリプトを用意して臨みます(インタビューに関してはこちらの記事に詳しく書いています)。インタビューは数名が行い、その内容をインタビュー中継用の別室で他のメンバーが観察します。

中継室のメンバーは、各問いに対するユーザーの反応や発話内容を付箋に書いておきます(付箋の色は、ポジティブ・ネガティブ・どちらでもない に分けると良いです)。事前に、問いと各ユーザー概要を記した以下のような表をホワイトボードに書き出しておき、インタビューが終わるたびに付箋を貼り出しておきます。

f:id:kaktaam:20181129162208p:plain:w400f:id:kaktaam:20181129155421j:plain:w398

評価

インタビューと同日に評価を行います。まずは、各インタビューイーに共通して見られた反応や発話内容をホワイトボードに書き出し、チーム内でインタビューの結果について理解を深めます。次に、2)で出した問いに対して、○、△、✕ で結果を評価します。私達は以下のような基準で評価を行っています。

  • ○:仮説とソリューションがマッチしていて、このまま本実装して問題ない
  • △:この仮説や方針は悪くないが、ソリューションのピボットが必要
  • ✕:この仮説や方針自体、見直す必要がある

この結果をもって、次のアクションを決めます。 全て○になるようであれば本実装と定量調査に進み、それ以外であれば課題になった箇所についての検証を進める形になるでしょう。

さいごに

投稿開発部では、発案したプロダクトや機能の価値を事前に検証することで、根拠を持って新しい機能を実装できるよう取り組んでいます。こういった事前の検証サイクルをまわすことで、想定しなかった問題が明確になったり、意外なところでポジティブな発見を得ることができる等、利点は多くあるように感じます。

もちろん、週単位で仮説検証を回すのは正直楽ではないし、ディレクター・デザイナー・エンジニアで顔を突き合わせて議論する中で、途方に暮れる...ということもあります。 ただ、そうやって考えた仮説に対して早期にユーザーの反応が分かること、そして直接聞くことで具体的にその理由が分かることが非常に良いと感じています。そして、ユーザーインタビューを通し、改めて料理をする人の考え方やリアルな生活感を理解することで、次のアクションのヒントにも繋がっていると実感しています。

こういったチームでのサービス開発、興味ありませんか?クックパッドでは一緒に開発に取り組んでいただけるメンバーを募集中です!ぜひ興味を持っていただけた方は、採用サイトをご覧ください。

【開催レポ】Cookpad TechBar #9 〜秋の最高LT大会〜 &ライブ配信の裏側

$
0
0

こんにちは。新卒採用担当の小久保です。

2018年11月21日に、Cookpad TechBar #9 〜秋の最高LT大会〜を開催しました。

Cookpad TechBarとは

Cookpad TechBarは学生向けのイベントで、クックパッド社員とカジュアルな雰囲気で気軽に交流していただけるイベントです。 f:id:bonami:20181130142831j:plain

今回のテーマ

今回は、「最高のLT」をテーマに、3名の若手社員と1名の内定者がLTをしてくれました。

具体的な発表内容としては、クックパッドの業務にほとんど関係のないLTを実施したのですが、堅苦しい説明会とは違う、TechBarならではの雰囲気を楽しんでいただき、大好評でした。懇親会では、参加者から多くの質問をしていただき、有意義な時間を過ごすことができました。

f:id:bonami:20181130143510j:plain

最高だと感じてもらう工夫

ただ楽しいLTイベントではなく、最高なLTイベントだと感じてもらうために実施した2つの取り組みについてご紹介します。

最高ボタン

f:id:bonami:20181130143023j:plain:w300

この「最高ボタン」はスマートフォンなどからアクセスすることができ、押すと「最高!」の音声が流れます。参加者が発表を「最高!」だと感じたとき、ボタンを押し、音声を鳴り響かせることで、LTの会場がさらに盛り上がる、という仕掛けです。ボタンが押された回数をリアルタイムでスクリーンに表示するようにしたところ、結果的に20,000以上の最高が集まりました。これは、スタッフ担当の内定者が作ってくれました。

f:id:bonami:20181130142824j:plain

イベントのライブ配信

※ここは配信担当の id:koba789が書いています。

実は、今回のイベントは地方に住む学生にも楽しんでいただけるようライブ配信も実施しました。クックパッドのオフィスで行うイベントとしては初めての試みだったのですが、当日の来場者数と同じくらいの方々に視聴していただくことができました。

当初ライブ配信の予定はなかったのですが、地方にも興味を持ってくれる学生が多いということもあり、かねてから仕事でライブ配信をやってみたかった私が提案して実験的にやってみた、という経緯があります。 こうして「やりたいです」と手を挙げると、好きなこと・得意なことが仕事にできるというのは弊社のいいところだと思います。

配信にあたっては、参加者が学生だということもあって、現地の参加者の姿を映したくない、という強い要件がありました。 しかし、会場の大きなスクリーンと登壇者の姿をひとつのカメラで撮影することは、会場のレイアウト的にもカメラの画角的にとても困難でした。 そこで、スライドの映像と登壇者の姿を別で取り込み、PIP*1で合成することにしました。

映像の配信用 PC への取り込みや PIP などの配信技術については、私(id:koba789)が趣味で温めていた技術を使いました。

配信用のソフトウェアとして OBS Studio を利用し、PIP やシーン切り替えなどをしました。

OBS Studio に映像ソースとして、登壇者の姿はビデオカメラの HDMI 出力を、スライドは PC の HDMI 出力をそれぞれ取り込む必要がありましたが、これにはよくある USB 接続の HDMI キャプチャデバイスではなく、LKV373 というハードウェアと自作の OBS プラグインを用いました。 実はこの OBS プラグインは Rust で書かれていたりしますが、これらの詳細については私の個人ブログで紹介していますので、そちらをご覧ください。

https://diary.hatenablog.jp/entry/2018/11/23/010746

そして、これらの機材を組み合わせた配線図は以下のとおりです。 f:id:bonami:20181203161813p:plain

会場に備え付けの HDMI スイッチャーによってスライドの映像の分岐ができたため、構成が簡単になっています。

そして、実際の会場のレイアウトはこのようになっていました。 f:id:bonami:20181203161725p:plain会場端に立ち入り禁止エリアを設けることで、参加者が映り込んでしまわないよう配慮しています。 図には描いていませんが、不意にカメラに映り込むことを避けるために椅子を並べてバリケードを作っていました。

そして、登壇者の姿を撮影していたカメラはこのようになっていました。 f:id:bonami:20181130142839j:plainカメラの HDMI 出力は、三脚に固定されている LKV373(写真では見切れている) にすぐさま接続されており、配信機材のある演台の横までは LAN ケーブルで映像を伝送しています。 長さが 15m もある HDMI ケーブルは調達するのが大変であったり、ノイズ対策のためにシールドが固すぎて取り回しが大変であったりしますが、LAN ケーブルであれば安価で調達しやすく、しなやかで取り回しも容易です。

最後に、私が張り付いていた機材デスクを紹介します。

f:id:bonami:20181130142741j:plain
※これは同一構成でライブ配信をした別日の写真です

写真に写っている中で、配信用に追加した機材は、MacBook Pro・Rust のステッカーが貼ってある USB オーディオインターフェース*2・ヘッドフォン・USB NIC x2・LKV373 だけです。 その他の白い箱などは会場(オフィス)に備え付けのものです。

写真中央、短い LAN ケーブルが刺さっている黒い箱が LKV373 です。これは演台から来た HDMI を受けています。 こちらはカメラと違い、LAN ケーブルを長距離取り回す必要がないため、極めて短いケーブルで "SLIDE"と書かれた USB NIC と接続されています。 この USB NIC の裏にもう一つ、"ACTOR"と書かれた USB NIC がありますが、これはカメラの映像を受けているものです。

Rust のステッカーが貼ってある USB オーディオインターフェースは、マイクの音を取り込むために利用しました。 下に置いてあるマイクアンプのライン出力から音声を取り込んでいます。

以上、TechBar #9 のライブ配信の裏側をご紹介しました。 自作の OBS プラグインを使うなどのチャレンジもありながら、大きな事故もなく配信を終えることができ、配信担当者としてはホッとした気持ちです。

おわりに

クックパッドでは、2020年新卒採用を開始しております。職種は、ソフトウェアエンジニア、リサーチエンジニア、デザイナーの3職種です。

info.cookpad.com

クックパッドは、ユーザーの課題発見とその解決に真摯に向き合い続けることで、日々の生活を支えるサービスに成長してきました。また、その裏側を高度な技術が支えています。レシピサービスはもちろんのこと、新規サービス、グローバル展開など「毎日の料理を楽しみにする」ために多くの挑戦を行っていきます。興味を持っていただけた方は、ぜひご応募ください。お待ちしております。

・ソフトウェアエンジニア
・リサーチエンジニア
・デザイナー

*1:ピクチャ・イン・ピクチャ。ひとつの大きな映像の隅に、別の小さな映像を重ねること

*2:私が気まぐれで貼り付けただけで、Rust は特に関係ないです

BERT with SentencePiece で日本語専用の pre-trained モデルを学習し、それを基にタスクを解く

$
0
0

研究開発部の菊田(@yohei_kikuta)です。機械学習を活用した新規サービスの研究開発(主として画像分析系)に取り組んでいます。 最近は、社内の業務サポートを目的として、レシピを機械学習モデルで分類して Redshift に書き込む日次バッチを開発・デプロイしたりしてました。

ここ数ヶ月で読んだ論文で面白かったものを3つ挙げろと言われたら以下を挙げます。

本記事では、BERTというモデルをクックパッドのレシピから得られる日本語テキストで pre-train して、それを fine-tune して特定のタスクを解いた、という話を紹介します。 高いテンションで書いていたらなかなかの感動巨編になってしまいましたが、ぜひご一読ください。

まとめ

  • BERT の multilingual モデルは日本語の扱いには適さないので SentencePiece を使った tokenization に置き換えて学習
  • pre-training にはクックパッドの調理手順のテキスト(約1600万文)を使用
  • 学習は p3.2xlarge インスタンスで 3.5 日程度学習を回し、loss は以下の図のように推移(バッチサイズ32)
  • 学習済みの pre-trained モデルを基に、手順のテキストが料理の手順であるか否かを予測する問題を解き、以下の表のように良い結果
  • (ある程度ドメインを限定すれば)現実的なコストで有用な pre-trained モデルが作れる

pre-training における loss の推移は以下の図の通りです。

Masked Language Model の loss の推移

classification の結果の表は以下の通りです。
baseline は TF-IDF を特徴量として学習した Logistic Regression と、word2vec による embedding を用いた LSTM です。 multilingual は提供されている学習済みの多言語 BERT を基に fine-tune したモデルです。 out model はクックパッドのデータで学習した日本語用の BERT を基に fine-tune した我々のモデルです。 学習データサイズを変えながら、5477 件の検証データに対する正答率を記しています。 比較は色々と手を抜いていますが、自分たちで学習した BERT が有用であることがひと目で理解できます。 特に、fine-tuning なので少量のデータで良い結果を出せるところが強力で、解きたいタスクがあれば数百件から千件程度 annotation すればよいというのは応用上かなり有用です。

学習データサイズ baseline (LR) baseline (LSTM) multilingual our model
1000 87.4% 90.4% 88.6% 93.6%
5000 88.9% 92.2% 91.6% 94.1%
13000 89.5% 92.5% 92.6% 94.1%

以降の節では、この結果に辿り着くまでの要素を詳しく解説していきます。 BERT に関する基本的な説明が結構長いので、BERT を理解している人は SentencePiece による tokenization の置き換えの節まで飛ばしてください。

BERT とはどのようなモデルか

まず、BERT とはどのようなモデルかを簡単に説明します。 本記事で注目するモチベーションは、汎用的な pre-trained モデルを提供し、それに基づいて fine-tune したモデルで各種タスクを解いて良い結果を得るというものです。

モデルそのものは Transformerです。 本来は機械翻訳のモデルとして提案されていますが、BERT においては Encoder 部分のみが重要なので、そこのポイントだけを記して詳細は省きます(やや不適切ですが呼称としては Transformer をそのまま用います)。

  • sequential な構造を使わずに入力 token 列を一度に処理する(GPU とも相性が良い)
  • self-attention によって特徴量を学習する
  • 位置情報が落ちる分は position embedding という token 列の位置に応じて与えられる embedding を token embedding に足すことでカバーする

モデルの肝となる self-attention と positon encoding ですが、ウェブ上でも解説が色々落ちてるので説明は割愛します。 実装まで含めて理解したい方は tensor2tensor の実装よりも harvardnlp が提供している実装がオススメです。 notebook で提供されていてところどころに可視化も入っているので、具体的に何をしているかを理解するには良いと思います。 ただし、ここで表示されている position encoding の図は dim をいくつか fix して横軸を position にしてますが、embedding の気持ちを考えるなら position をいくつか fix して横軸を dim にした方が良いと思います(ここでやってるみたいに)。

Transformer の話はこれくらいにしておいて、これに基づいて pre-trained モデルを構築した OpenAI GPTの話に移ります。 この pre-training には教師なしで学習できる言語モデル、一言で言うと位置 t-1 までの token が与えられた状況で位置 t の token が正しく予測するように最尤法で学習、を用いています。 イメージ図は以下で、E_iが各 token の embedding、T_iが特徴量になっていて、特徴量はさらに一層 dense を重ねてその後 softmax につないで token を予測するようになっています。

OpenAI GPT の概念図(図は BERT の論文より引用)

注意すべきは各 layer による connection は前の位置から後ろの位置の方向にしか伸びていない点です。 上述の言語モデルの構造を考えれば、ある位置での token を予測するときにはそれ以前の位置の情報しか使えないので、後ろから前への connection は mask して落とすようにしています。

このモデルは良い結果を残しましたが、明白な改善点として、対象となる token の位置より後ろの位置にある情報も使いたいということが考えられます。 例として 今日は新しく買った〇〇〇でコーヒーを淹れます◯◯◯を予測をすることを考えてみましょう。 これは前の情報だけでは無理がありますが、後ろの情報があればコーヒー豆やコーヒードリッパーなどの可能性が高いことが予想できます。 この点を考慮してモデリングをしたものがELMoです。 left-to-right と right-to-left を別々に LSTM で学習して得られる特徴量をタスク特有のネットワークの単語 embedding に concat するという使い方をします。

ここで「別々に」というのがポイントで、上述の言語モデルは明らかに一方向でなければ学習が意味を成さないため、別々に学習する必要があります。 しかし、理想を言えば同じモデルの中で双方向の情報を同時に扱いたいです。 同じコンテキストにおいて、前の情報と後ろの情報をその関係性を考慮した上で合わせて使うことができるからです。

これを実現するための言語モデルとして Masked Language Model (MLM) を提案したというのが BERT の貢献です。 話としてはとてもシンプルで、入力 token 列の 15% を mask して、言語モデルは mask されたものを正しく予測するように学習するというものです。 本当はもう少し細かいことをしていますが、難しい話ではなく学習のためのテクニック的な要素でもあるのでここでは割愛します。 具体的な構造はここまでの話が分かっていれば簡単で、下図のように、後ろから前への connection を mask せずに使った Transformer が BERT になります。

BERT の概念図(図は BERT の論文より引用)

これまでは入力部分を何となく token の embedding として扱ってきましたが、BERT の入力の形は少し特殊なのでそれを詳しく見てみます。

BERT の入力の概念図(図は BERT の論文より引用)

まず、Input から。 最初の token は [CLS] という token ですが、これは MLM においては重要ではないので後で詳しく見ることにして一旦スキップします。 入力の文章は一般に二つ入り得て、間と最後に区切りを明示するためのの [SEP] という特別な token が入ります。 ここで文章と呼んでいるものは一つ一つが一般に複数の文から構成されるもので、例えば質問応答では一つの文章が passage(これは一般に複数の文から成る)でもう一つの文章が question になります。 また、recurrent 構造を持たないので入力 token 列の長さは fix する必要があり、512 token となっています。 入力が長すぎる場合は二つの文章が同じ長さになるように token を後ろから削っていって、余る場合は 0 padding します。

次に Token Embeddings ですが、これは入力 token 毎の embedding です。 embedding は lookup テーブルで実装されるので、入力 token は辞書に基づいてマッピングされた id になります。 pre-training によってこの embedding が学習されることになります。

次に Segment Embeddings ですが、これは token が文章1から来ているのか2から来ているかを区別するために必要なものです。 self-attention は前の layer の出力特徴量を同時に扱うので、位置情報などは知りえません。 位置情報は Position Embeddings で付与していますが、どの token が文章1でどの token が文章2かはまだ区別できていません。 そこで文章1と2を区別する embedding を追加しています。 これは次の Position Embeddings と同様に、文章の内容には依らずに純粋に1つ目の文章か2つ目の文章かだけで定まる embedding です。

Positon Embeddings は元の Transformer と同じ用途ですが、BERT では pre-training で学習するものとして提供しています。 具体的には この辺のコードを参照してください。

ここまでを理解すれば MLM がどうやって実現されるかがかなり具体的にイメージできるようになると思います。 Input の token をランダムに [MASK] という token に置き換え、この [MASK] token に対応する出力特徴量を dense と softmax につないで置き換え前の token の id を当てるように学習します。 これによって文章中のどの位置にどのような token が現れるかを学習できます。

BERT ではさらに文章1と2が意味的に連続している文章かどうかを分類する IsNext prediction によって、文章間の関係性も考慮できるようにします。 これもシンプルで、ある document から連続している2つの文章を抜き出した場合は IsNext で、文章2としてランダムに持ってきた文章の場合は NotNext として、分類問題を解きます。 この分類問題を解く際に、先程登場した文頭の [CLS] token の出力特徴量を dense と softmax につないで解くことになります。

MLM と IsNext prediction によって pre-traned モデルが得られれば、あとはそれを基に fine-tuning によって様々なタスクを解くことができます。 例えば、single sentence classification では、入力を一つの文章として、[CLS] token の出力特徴量に層を追加して分類問題を解くことになります。 その他にも質問応答など色々なタスクを解くことができますが、本記事で扱う fine-tuning タスクはこの single sentence classification のみです。 BERT 論文ではこの fine-tuning で GLUEの各種タスクを解いて軒並み優秀な結果を叩き出しています。

モデルの具体的なパラメタや学習の非効率性の議論を除けば、本質的なポイントはこのくらいです。 これで 3.3 billion word corpus のデータで 256 TPU chip days(ちなみに GCP の cloud TPU では一つのデバイスで 4 chips なのでそれだと 64 日)で学習したものが公開されている pre-trained となっています。

Google Research から 公式実装が公開されています。 このレポジトリから学習済みモデルもダウンロードできます。 英語、中国語、multilingual のモデルが公開されています。 英語に関してはモデルパラメタ数が三倍程度の Large モデルも提供されていますが、これは試してないので、本記事で BERT と言えば Base の方を想定しています。 以降ではこのレポジトリの内容に基づいて、特に tokenization の手法と日本語で扱うために変更した点に注目して解説します。

BERT の tokenization はどうなっているか

具体的にどのような単位を token とするのか、という話をしていませんでしたが、ここまで述べてきた token は公式実装における sub-token に対応しています。 実装との比較をするため、この節に限り、token と sub-token という言葉を公式実装における変数名に合わせることにします。 繰り返しになりますが、この節以外で使っている token はこの節で言うところの sub-token に対応していることに注意してください。

sub-token とは sub-word のことですが、sub-word と言ってもどのように区切るかは様々な方法があります。 ということで BERT における tokenization を詳しく見てみましょう。

まず、実際に処理をしている部分のコードは tokenization.pyで定義されている FullTokenizerクラスの tokenizeメソッドです。

classFullTokenizer(object):
  """Runs end-to-end tokenziation."""def__init__(self, vocab_file, do_lower_case=True):
    self.vocab = load_vocab(vocab_file)
    self.inv_vocab = {v: k for k, v in self.vocab.items()}
    self.basic_tokenizer = BasicTokenizer(do_lower_case=do_lower_case)
    self.wordpiece_tokenizer = WordpieceTokenizer(vocab=self.vocab)

  deftokenize(self, text):
    split_tokens = []
    for token in self.basic_tokenizer.tokenize(text):
      for sub_token in self.wordpiece_tokenizer.tokenize(token):
        split_tokens.append(sub_token)

    return split_tokens
...

BasicTokenizerで token に分けてから各 token を WordpieceTokenizerで sub-token に分けていることが分かります。

BasicTokenizerの tokenize は以下のように実装されています。

deftokenize(self, text):
    """Tokenizes a piece of text."""
    text = convert_to_unicode(text)
    text = self._clean_text(text)

    # This was added on November 1st, 2018 for the multilingual and Chinese# models. This is also applied to the English models now, but it doesn't# matter since the English models were not trained on any Chinese data# and generally don't have any Chinese data in them (there are Chinese# characters in the vocabulary because Wikipedia does have some Chinese# words in the English Wikipedia.).
    text = self._tokenize_chinese_chars(text)

    orig_tokens = whitespace_tokenize(text)
    split_tokens = []
    for token in orig_tokens:
      if self.do_lower_case:
        token = token.lower()
        token = self._run_strip_accents(token)
      split_tokens.extend(self._run_split_on_punc(token))

    output_tokens = whitespace_tokenize(" ".join(split_tokens))
    return output_tokens

各々の処理の実装の詳細までは立ち入りませんが、ざっくりと以下のような処理をしています。

  • 入力テキストは utf-8 で取り扱う
  • unicode category の control のものは除くなどの処理
  • ord()で各ユニコード文字を int に変換し、範囲指定で漢字を見つけて前後にスペースを入れる
  • スペース区切りで tokenize して list を返す

このようにして tokenize された token に対して、更に WordpieceTokenizerで sub-token に tokenize します。 抜粋して実際に処理をしている箇所を載せてみます。

    output_tokens = []
    for token in whitespace_tokenize(text):
      chars = list(token)
      iflen(chars) > self.max_input_chars_per_word:
        output_tokens.append(self.unk_token)
        continue

      is_bad = False
      start = 0
      sub_tokens = []
      while start < len(chars):
        end = len(chars)
        cur_substr = Nonewhile start < end:
          substr = "".join(chars[start:end])
          if start > 0:
            substr = "##" + substr
          if substr in self.vocab:
            cur_substr = substr
            break
          end -= 1if cur_substr isNone:
          is_bad = Truebreak
        sub_tokens.append(cur_substr)
        start = end

      if is_bad:
        output_tokens.append(self.unk_token)
      else:
        output_tokens.extend(sub_tokens)

何をやっているか理解できるでしょうか? ここで最初の方で渡している text は BasicTokenizerで作られた token になります。 例外処理的な部分を除けば、本質的には以下のような処理をしています。

  • まずは与えられた token をそのまま扱う
  • token が辞書に存在すればそれを sub-token として登録
  • token が辞書になければ最後の一文字を削って sub-token を作って辞書マッチングをする、を繰り返す
  • sub-token がマッチしたら、残りの文字に ##ingのように ##をつけて同じ処理を繰り返していく

ここの処理は機械的な処理であるため、sub-token の粒度を司るのは辞書となります。 公式レポジトリからダウンロードできる BERT-Base, Uncasedの中の vocab.txt を眺めてみると、organizationalのような長めの単語が入っていたり、##ableのような典型的な接尾辞が入っていたりします。 また、英語以外にも様々な文字が入っていることも確認できます。 30000 程度辞書に登録されているのでかなりの数になっていて、##aなども登録されているので、英語を処理する場合に [UNK] に遭遇することはほぼないでしょう。

例えば The Higgs boson is an elementary particle in the Standard Model of particle physics.という文章を tokenize すると以下の結果が得られます。

['the', 'hi', '##ggs', 'bo', '##son', 'is', 'an', 'elementary', 'particle', 'in', 'the', 'standard', 'model', 'of', 'particle', 'physics', '.']

BERT の multilingual モデルによる日本語の取り扱い

BERT では multilingual のモデルも公開されているので、これを使えば日本語もバッチリ!と期待したいところですが残念ながらそうはいきません。 具体的に tokenization の方法を見てきたので何が起きそうかはある程度想像できると思います。

例えば、鶏肉は包丁を入れて均等に開き、両面にフォークで穴を開け塩コショウする。を tokenize すれば結果はどうなるでしょうか? 正解は以下のようになります。

['鶏', '肉', 'は', '包', '丁', 'を', '入', 'れて', '均', '等', 'に', '開', 'き', '、', '両', '面', 'に', '##フ', '##ォ', '##ーク', '##で', '穴', 'を', '開', 'け', '塩', 'コ', '##シ', '##ョ', '##ウ', '##する', '。']

ほとんど文字ベースみたいなものですね...
漢字もしくはスペースによってある程度の単位に区切られること前提としているので、日本語は相性が悪いです。 これは漢字がところどころに含まれているのでまだマシですが、例えば にフォークでの部分はこれがまとめて WordpieceTokenizerで処理されるのでこのようになってしまいます。 さらに vocab.txt を見てもまともな日本語の単語が登録されていないことが分かります。 使う気になれば文字ベースのモデルとして使えますが、##あを区別しているなどなかなか厳しいものになっています。

SentencePiece による tokenization の置き換え

日本語にマッチした BERT モデルを作るには、tokenization を日本語にマッチしたものに変える必要があります。 英語版と同じように sub-word 単位で取り扱いをすることを考え、今回は SentencePieceを使用することにしました。

SentencePiece は教師なしで学習可能で、そして学習が速いです。ほんと速い。 SentencePiece が提供する言語モデルベースの分割や detokenization などの詳細はここでは触れないので、本家のレポジトリや 論文をご覧ください。

ここでは tokenizer の置き換え部分を軽く紹介します。 まず、BERT の元のコードの FullTokenizerクラスを次のように変更します。

classFullTokenizer(object):
  """Runs end-to-end tokenziation."""def__init__(self, model_file, vocab_file, do_lower_case=True):
    self.tokenizer = SentencePieceTokenizer(model_file)
    self.vocab = load_vocab(vocab_file)
    self.inv_vocab = {v: k for k, v in self.vocab.items()}

  deftokenize(self, text):
    split_tokens = self.tokenizer.tokenize(text)
    return split_tokens
...

これは単純に tokenizeメソッドを SentencePieceTokenizerでの処理に変更しているだけですね。 SentencePieceTokenizerクラスは次のように定義します。

classSentencePieceTokenizer(object):
    """Runs SentencePiece tokenization (from raw text to tokens list)"""def__init__(self, model_file = None, do_lower_case=True):
        """Constructs a SentencePieceTokenizer."""
        self.tokenizer = spm.SentencePieceProcessor()
        if self.tokenizer.Load(model_file):
            print("Loaded a trained SentencePiece model.")
        else:
            print("You have to set the path to a trained SentencePiece model.")
            sys.exit(1)
        self.do_lower_case = do_lower_case

    deftokenize(self, text):
        """Tokenizes a piece of text."""
        text = convert_to_unicode(text)
        output_tokens = self.tokenizer.EncodeAsPieces(text)
        return output_tokens

これも難しいことは何もなくて SentencePiece の SentencePieceProcessorクラスの EncodeAsPiecesメソッドを使っているのみです。 学習済みモデルさえ別途準備できれば、この程度の変更で置き換えが可能です。

ここまでで必要な道具が出揃ったので、あとはデータを準備して実際に学習をしていきます。

使用するデータ

クックパッドには 300 万品を超すレシピが投稿されています。 レシピには調理手順毎に説明の文章が付与されているので、今回はこの手順のテキストを学習データにします。 手順のテキストは十分なデータ量があること、手順のテキストには材料や調理器具や調理法などレシピの分析に必要な要素が含まれていること、などが理由です。

一つの手順のテキストを一行に格納したデータを作成した結果、約 1600 万行のテキストデータとなりました。

まず、SentencePiece の学習データですが、これは扱いが簡単で、学習時に作成したテキストデータの path を与えるだけです。 この規模のデータでも MacBook Pro で一時間程度で学習が終わるので、かなり使いやすいです。 今回は unigramモデルを使い、vocab_sizeは 32000 としました。 vocab_sizeがかなり大きめですが、これは後の BERT の学習を考慮し、BERT の英語モデルにおける辞書の要素数と同じくらいにするという意図で設定しました*1。 学習したモデルで先ほどと同じ例を tokenize してみると、以下の結果が得られます。 vocab_sizeが大きいので token も大きめになっていますが、クックパッドっぽい token に分かれてますね〜。

['▁鶏肉は', '包丁を入れて', '均等に', '開き', '、', '両面に', 'フォークで穴を開け', '塩コショウする', '。']

次いで、BERT の pre-training の学習データです。 create_pretraining_data.pyによって、テキストデータに [MASK] や IsNext の情報などが付与された後に .tf_record形式に変換されます。 テキストデータは IsNext 情報の作成のために、一行一文でかつ document 間には空白行を挿入することが推奨されています。 先ほど使ったテキストデータを、一つのレシピが一つの document になるようにして間に空白行を挿入し、一行には一手順のテキストを入れるように作りました。 後はこのスクリプトを学習した SentencePiece の tokenization に基づいて実施すれば必要な学習データが作成できます。 ただし、このスクリプトは全データをメモリに読み込む仕様になっているので、大量のデータではメモリ不足になってしまいます。 後の pre-training では複数のファイルを学習データとできるので、ここでは 1/10 ずつ .tf_recordを作りました(一つのファイルが約 2 [GB])。 どれくらいデータを複製するかなどの各種パラメタは、公式レポジトリのものと揃えています。

ここで vocab.txtは少し注意が必要です。 SentencePiece が出力する .vocabファイルには存在しない [PAD], [CLS], [SEP], [MASK] などを編集して追加し、それに合わせて vocabulary を扱う関数を少し変更する必要があります。 こうすると SentencePiece のモデル内部が有する token:id のマッピングとズレが生じるので少しイケてないですが、モデルは純粋に tokenize をするためだけに使われるので問題は生じません。

最後に、fine-tuning タスク用のデータも準備しておきます。 今回は手順のテキストが料理に関するものか否かを二値分類するタスクを扱います。 手順のテキストには「つくれぽありがとうございます」というような、料理に関するものでないテキストが含まれる場合があります。 例えば音声による手順の読み上げを考えた場合、このような料理に関するものでないテキストまで読み上げられるのは望ましくないため、自動で分類することは価値があります。 過去に annotation された 18477 件のデータがあったため、13000 件を学習データ、残りの 5477 件を検証データとして使用します。

BERT の pre-training

学習は AWS EC2 p3.2xlarge に nvidia-docker 環境を構築して実施しています。 nvidia/cuda:9.0-cudnn7-devel-ubuntu16.04tensorflow-gpu==1.12.0を使っています。 この環境では計算機パワー不足では?と思われるかもしれませんが、料理というドメインに限定したデータを対象としており、予備実験をして様子を見てもいけそうだったので、これで学習を回しました。

モデルのパラメタは BERT の英語モデルのものと基本的に同じで、vocab_sizeを合わせて、learning_rate=5e-5train_batch_size=32max_seq_length=128で回しました。 以降の結果はステップ数が 1,000,000 回における結果です。 学習経過の確認として、loss の振る舞いを見る以外にも、適当なタイミングで学習を止めて fine-tuning タスクを解いてその性能が向上していることなども確認していました。

loss の推移は下図の通りです。

Masked Language Model の loss の推移

pre-train したモデルの性能は以下のようになりました。

INFO:tensorflow:***** Eval results *****
INFO:tensorflow:  global_step = 1000000
INFO:tensorflow:  loss = 2.1979418
INFO:tensorflow:  masked_lm_accuracy = 0.5468771
INFO:tensorflow:  masked_lm_loss = 2.1357276
INFO:tensorflow:  next_sentence_accuracy = 0.9775
INFO:tensorflow:  next_sentence_loss = 0.061067227

公式実装の pre-trained モデルと比べると masked_lm_lossの値が低くなっています(これは多クラス分類なので 54.7 % は全然当てられてないわけではないです)。 手順のテキストには記号も結構な割合で含まれていて、これは当てるのが困難というのも影響していると思います。 とはいえ masked_lm_lossがまだまだ下がりそうなのと token 列が長い場合の positon encoding の学習もしてないので、引き続き学習を継続して様子を見ていきたいと思います*2

次節の二値分類タスクは、この pre-trained モデルを基に fine-tune した結果となります。

二値分類タスクによるモデルの比較

準備した fine-tuning 用のデータを用いて、各種モデルを比較してみました。 使用したモデルは以下のものです。

  • baseline (LR): TF-IDF を入力とする Logistic Regression を fine-tuning 用の学習データのみで学習したもの
  • baseline (LSTM): word2vec の embedding を入力とする LSTM を fine-tuning 用の学習データのみで学習したもの
  • multilingual: 公式レポジトリで提供されている multilingual モデルを fine-tune したもの
  • our model: 今回作った pre-trained モデルを fine-tune したもの

fine-tuning の有用性を確認する上でも、学習データサイズを変えながら試した結果が以下の表です。

学習データサイズ baseline (LR) baseline (LSTM) multilingual our model
1000 87.4% 90.4% 88.6% 93.6%
5000 88.9% 92.2% 91.6% 94.1%
13000 89.5% 92.5% 92.6% 94.1%

有用性を確認するためだけの比較実験なので、色々と雑にやっていますが、自分たちで学習したモデルが良い結果を返していることが分かります。 特に、学習データサイズが小さくてもかなり良い結果を返していることが分かります。 それほど難しくないタスクなので baseline も悪くない結果を出していますが、学習データサイズが小さい方が差が大きくなっています。 これはドメインを限定したクックパッド用の pre-traiend モデルを使っているので期待通りの振る舞いですが、この事実は社内における機械学習を用いたサービス開発に有用です。 試したいタスクがあれば千件件程度の annotation されたデータがあれば十分であることを示唆しているので、社内の annotator の方々に依頼すれば数日で結果を出すところまで確認できます。 ちなみに fine-tuning に要する時間は数分とかその程度です。 pre-trained モデルと SentencePiece のモデルが準備できた現状ならば、色々なタスクにすぐに試すことができるので、trial and error を高速に回せるので嬉しいですね!

CPU では学習は厳しいですが、予測に関しては数千件であればバッチ処理で使うのも可能な程度の処理速度なので、その用途なら他の機械学習モデルと同様の運用コストで使っていけます。 処理件数が多かったりスピードが要求される場合はやはり GPU(もしくは TPU)が必要になります。

今後は、引き続き pre-training を回して性能をチェックしつつ、BERT の特性を活かして様々なタスクに適用していきたいと考えています。

本節における baseline の結果を出すのには @studio_graph3にも手伝ってもらいました。スペシャルサンクス。

再びまとめ

だいぶ長くなりましたが、BERT with SentencePiece で日本語専用のモデルを作って、そこから fine-tune してタスクを解く話をしました。 ドメインが限定されれば single GPU でも十分に有用なモデルが構築できるのではないかと思います。 pre-trained モデルが得られれば、少量の annotation さえあれば高い性能を発揮するので、サービスに導入するために様々な trial and error を実施しやすいというのは大きな利点です。

また、本記事では紹介しませんでしたが、Google Colaboratory を使えば無料で TPU を使って BERT を試すこともできます(結果ファイル出力に GCS が必要ですが)。

いかがでしたでしょうか。 このように弊社では機械学習の新しい発展を活用してサービスの研究開発がしたい、という方を募集しています。 興味がある方は @yohei_kikutaまでご連絡ください、クックパッドで美味しいご飯でも食べながら議論しましょう。 もしくは 応募フォームから直接ご応募ください。

*1:サイズを変えて 8000 でも試しましたが、tokenization としてはこれくらいでも十分な印象です。

*2:ちなみにこの後も学習を回していて、1,800,00 ステップでは loss が 1.971 まで下がっています。

得られた知見をフリーズドライ〜情報共有のための仕組み Report.md の紹介〜

$
0
0

こんにちは、会員事業部の新井(@SpicyCoffee66)です。今年はレシピサービスにおける体験改善を主な業務としていました。
サービス開発かラブライブ!の話をすると早口になります*1。今日はついにスマブラが発売されるのでおそらく早退します。

さて、本記事ではサービス開発において重要な要素である施策結果・知見のプールや共有について、社内でどのような取り組みが行われているのかを紹介したいと思います。

施策の結果から最大限に学びを得たい

私たちはサービス開発を進める中で日々多くの施策を実施することになります。
サービス開発のプロセスにおいて、施策は実施して終わりではなく、その結果からいかに多くの学びを得るのかということが重要になります。
施策の結果から学びを得るためには、その施策の意図や結果を可能な限り 正しく解釈し、それを(将来入ってくるメンバーを含めて)より多くの人に 共有することが必要です。
しかし、サービス開発の現場では以下のようなことが往々にして発生します。

  • 間違った知見が共有される
    • 結果の数字の読み取り方がおかしい
    • そもそもKPIの設定がおかしい
    • 検証の期間が短すぎて正確性に欠ける
    • 施策の目的と手法が噛み合っていなかった
    • といったようなことに気がつかず正しい知見として共有される
  • 知見がまるで共有されない
    • どこにプールされているか判然としない
    • 知見が属人的になる
    • ならまだいい方で時が経って本人もはっきり覚えていない感じになる
    • 最悪プールすらされてなくて闇に消える
    • 結果新規メンバーが似たような失敗を繰り返す

僕自身、入社して間もない時期には過去に実施された施策の詳細を探すも見つからず、歯がゆい思いをしたことが数多くありました。
クックパッドのように歴史の長いサービスであれば、自分が思いつくアイディアについて過去に誰かが似たような施策に取り組んでいたことも多く、その時の結果を参照できないのは非常にもったいないと感じました。

このような問題を解消するため、社内では昨年度途中から Report.md という仕組みが利用され始めました。

Report.md

Report.md を一言で言うと「施策の結果を Pull Request で管理する仕組み」となります。
具体的には

  1. 担当者は施策の終了後、Markdown形式でその内容をまとめた report を作成し、PRを送信する
  2. チームメンバーがその report をレビューする
  3. いい感じになったらマージし、report をプールする

という手順を踏んで施策の結果をレビューし、ストックしていきます。

f:id:spicycoffee:20181206192507p:plain
PR を出して
f:id:spicycoffee:20181206192525p:plain
レビューして
f:id:spicycoffee:20181206192536p:plain
マージする

こうすることで

  • 施策の結果を正しく解釈する → レビューを通すことによって精度が上がる
  • 多くのメンバーに共有する → report のプール箇所が明確になり参照しやすくなる

といった効果が上がり、前述した二つの目的を果たすことが可能になりました。
コードをレビューして(質を高めて)、マージする(永続化する)という普段エンジニアがやっているフローが、施策の結果に対しても有効だったわけです。

いつ書くか?

上記手順では、Report.md のイメージを持っていただくために施策終了後としましたが、施策の実施前に Report.md を作成することで、仮説や検証内容が明確になり、施策がブレにくくなるという効果があります*2
導入初期は施策後に作成して「結果の共有」のためにのみ利用するのもよいと思いますが、慣れてきたらぜひ施策の実施前に作成するフローで運用することをオススメします。
その場合の手順は

  1. 担当者は施策の実施前に、Markdown形式でその内容をまとめた report を作成し、PRを送信する
  2. チームメンバーがその report をレビューする
  3. いい感じになったらマージし、 report をプールする
  4. マージされた report をもとにして施策を実施する
  5. 施策終了後、その結果を report に追記する
  6. チームメンバーが report をレビューする
  7. いい感じになったらマージし、完成版の report としてプールする

といった形になります。

f:id:spicycoffee:20181207115725p:plain

何を書くか?

Report.md に何を書くかについては、運用しているチームによって様々です。
ここでは、私が見た事例の中で一番よくまとまっていると感じたテンプレートをもとにして内容を紹介していきます。
また、以下にあげる内容のうち、「仮説」「仮説分析」「検証方法」「結果の想定」については、施策の実施前に記載することでその指針とすることができる内容になります。

3 行まとめ

作成された report を読むのは自分だけではないことを考えると、まずは概要を読んでから詳細を読むべきか判断できる構造になっていた方が親切です。

仮説

report の中でも非常に重要な項目です。
仮説をしっかり定義できていないと検証設計がぶれ、結果を次に活かすことが難しくなります。
仮説をどのように表現するかはチームによりますが、社内独自のフレームワークである価値仮説シート*3に沿って表現することが多いです。
形式に迷う場合は
(ターゲット) は (ジョブ/インサイト) したいが (何らか要因でそれができていない状況) なので、 (体験) すると (目的) になる
といったフォーマットで表現してみるのがよいと思います。

仮説分析

そもそも上記の仮説はどこからきたのか、その妥当性はあるのかといったような仮説の価値自体やその詳細を示す項目です。
具体的には

  • ターゲットボリューム
    • 「仮説」で定義したターゲットの条件に当てはまる人がどのくらいいるのかを試算する
  • 事前検証・前回施策の結果
    • この施策に繋がる情報を持った施策 report へのリファレンスを貼っておくとわかりやすい
  • 落としてはいけないコア機能
    • 「仮説」の内容をより明確にするためにコアとなる要素を明記する
  • 諦めること
    • 検証のブレを無くすためにやらないことを明記する
  • 技術的に難しいこと
    • 期間やリソース都合を含め、実装側の都合で諦めたことを明記する

「仮説」を所与のものとしても report は成立しますし、あまり情報量が多くなっても report の骨子が読みづらくなるため、施策によって項目は柔軟に変更するのがよいと思います。
なるべく外部資料や別 report への参照を貼るよう工夫したり、<details>タグを利用して情報を畳んでおくのもおすすめです。

検証方法

「仮説」の項目で書いた「体験」を実現するための方法を示し、その内容を具体的に解説します。

  • 検証項目
    • 施策によって答えを出したい問い
    • 仮説そのものに対する問いになることもあれば、仮説をもとに考えた機能の有用性に対する問いになることもあります
  • 検証方法・提供機能
    • 仮説に対してどのような機能を提供してそれを検証するか
    • あるいはどのような資料をもとにユーザーにヒアリングを行うか
  • 検証資料・機能詳細
    • それらの機能や資料の詳細
  • 検証期間・人数
  • 評価方法

といったような内容を盛り込むとよいでしょう。

結果の想定

「検証方法」で定義した「評価方法」に対し、どのような結果が出たときに「仮説」をどう評価すればよいかについて可能な限り明記しておく。

  • 定量検証の場合
    • CVR が x % 以上向上した場合は仮説が正しかったと考える
  • 定性検証の場合
    • ユーザーインタビューで y というような反応が z 人以上見られた場合は仮説が正しかったと考える

根拠を持って詳細な数値を設定するのは難しいことが多いですが、過去の施策やチーム内での議論をもとに目安を設定してメンバーの目線を揃えておくことが重要です。

検証結果

検証の結果をまとめます。
主観的な考えや分析は後の「考察」に記載するとし、ここではそれに必要な結果のみを記載します。

考察・ギャップ分析

「結果の想定」と「検証結果」を比較し、自分たちの仮説や認識について合っていたことと間違っていたことを明らかにしていきます。
長文で書き綴るよりも検証項目ごとに箇条書きで簡潔にまとめる方が振り返りやすくてよいかと思います。

Next Action

「考察・ギャップ分析」の内容を受けて、この施策を次にどうするか、具体的なアクションを記載します。

Report.md 運用の所感

メリット

Report.md を社内で運用していくうちに、以下のようなメリットがあることが感じられるようになりました。

  • 施策結果の解釈の精度が上がる
  • 施策そのものがブレづらくなる
  • 施策の結果を気軽に共有できるようになる
    • Slack で「こういう感じのことやったことある人いないですか?」「お、それなら前にやりましたよ(report のリンクを貼る)」といったやり取りが数多く見られるようになりました
  • サービス開発者の成果が可視化されやすくなった*4
    • 期末の評価期間に自己評価を執筆する際に上長に成果を提示しやすくなりました

デメリット

個人的には非常に有用な仕組みだと感じてはいますが、デメリットも存在しているとは思います。
最も大きいのは「施策に関して目先の実行速度が遅くなる」ことです。
Report.md をしっかり作成しようとすると、それ相応のコストが掛かります。
その分施策に対する理解が深まったり、後々参照できる資産になったりという大きなメリットがあるとは思いますが、サービスのフェーズやチームの雰囲気によっては、メリットがコストに見合わないかもしれません。
そういった場合には、記載する項目を取捨選択したり施策と同時並行で作成するなどの工夫によって、report 作成のコストを抑えるのがいいでしょう。

今後の課題

今後の課題として、report を通した情報共有をより活発にしたいと思っています。
Report.md 自体はあくまでも「施策の結果がチームごとに一箇所に集まる」ものでしかなく、組織横断的に情報を提供してくれる仕組みではありません。
したがって、より容易に情報が社内に行き渡るような仕組みと組み合わせることでその効果をさらに高めることができるのではないかと考え、その方法を模索しています*5

まとめ

本記事では、サービス開発において実は失敗しがちな知見のプール・共有について社内でどのような取り組みがなされているかを紹介しました。
クックパッドでは、技術力を大切にしているのはもちろんのこと、サービス開発そのものについてもその手法を洗練させていくことで、よりすばやくユーザーに価値を届けようと日々努力しています。
そして、そういった想いのもと一緒にサービスを作り上げていってもらえるメンバーを募集中です!
このような姿勢や働き方に興味を持っていただけたなら、ぜひ一度採用サイトをチェックしてみてください。
興味はあるけどいきなり採用の話は……という方は、気軽に @SpciyCoffee66まで連絡してください。
クックパッド名物の(?)キッチンラウンジで美味しいご飯を食べながら社内の様子についてお話しましょう!

*1:早口になるほど好きなのでサービス開発者のコミュニティである s-dev talksを運営しています。

*2:施策の実施前にどのようなことを考えればいいかについては別の記事を投稿させていただいているので、興味のある方はご一読ください。

*3:具体的なフォーマットは TechConf 2018 での講演や、この夏に開催されたインターンシップの資料をご参照ください。

*4:Report.md の前身となった取り組みに関する記事でも少し触れられています。

*5:最近になってまずは専用のドメインを切った社内ブログに集約してみるという動きが始まりました。


iOSでの読みやすい幅

$
0
0

モバイル基盤グループのヴァンサン(@vincentisambart)です。

iOSの設定画面の右側は一定の幅を超えないように作られています。

  • iPadでは: iPadでの設定画面

  • 新iPad Pro 12.9"では: iPad Pro 12.9インチでの設定画面

iPadでTwitterのタイムラインのセルの中身も一定の幅を超えません。

iPad Twitter

このように、自分のアプリで広い画面でもコンテンツが広がりすぎないようにするためにはどうすればよいのでしょうか。AutoLayoutでいくつかの制約を使ってできるのですが、もっと簡単な方法はないのでしょうか。

iOS 9以上では、端末の種類を気にせず、複雑なAutoLayout制約を使わず、殆どのビューですぐ使える仕組みがあります。Appleのドキュメントで「readable content」や「readable width」と呼ばれているものです。以下日本語で「読みやすい幅」と呼ぶことにします。

注意点:下記の説明はAutoLayoutを使う前提で書かれています。AutoLayoutを使わない場合、親ビューのreadableContentGuidelayoutFrameで読みやすい幅の明確な数字が取れるので、それを元にレイアウトを計算することになるのではないでしょうか。

(Twitterは読みやすい幅機能が使える前からこの表示でしたので、実際独自で同じことを実装しているようです。)

読みやすい幅とは

文章の表示領域の幅が広がりすぎると、少し読みづらく感じてしまいませんか。iOS 9以上では、readableContentGuideを使って幅が広がりすぎないようにできます。ビューのレイアウトの制約を定義する時、親ビューのleadingAnchor/trailingAnchor/leftAnchor/rightAnchorlayoutMarginsGuideの代わりにreadableContentGuideを使います。(因みに読みやすい幅のためのガイドなので、readableContentGuideの垂直方向のアンカーtopAnchorbottomAnchorcenterYAnchorheightAnchorが実質layoutMarginsGuideのと同じです)

縦向きのiPhoneにしか表示されないコンテンツは読みやすい幅を使うメリットがあまりないのですが、iPadや横向きのiPhoneでも表示されるコンテンツは画面の全幅に渡ると少し読みづらくなるので読みやすい幅を適切に使えば良いではないでしょうか。

因みに「読みやすい」といっても、テキストの幅を合わせるだけでなく、セルに入っている画像やボタンの配置も読みやすい幅に合わせたほうが自然だと思います。

「読みやすい幅」は具体的にどういう幅なのでしょうか。全画面のroot view controllerのビューに子ビューを貼って、その子ビューの制約を親ビューのreadableContentGuideに合わせて、子ビューの幅を測ってみました。

iPadでの表示: f:id:vincentisambart:20181206092236p:plain

端末 向き 画面全幅③ 読みやすい
最大幅④
余白
(③-④)
左右マージン
((③-④)/2)
iPhone Xs Max
iPhone Xr
iPhone 8 Plus
414 pt 374 pt 40 pt 20 pt
iPhone Xs Max
iPhone Xr
896 pt 672 pt 224 pt 112 pt
iPhone 8 Plus 736 pt 696 pt 40 pt 20 pt
iPhone Xs
iPhone 8
375 pt 343 pt 32 pt 16 pt
iPhone 8 667 pt 627 pt 40 pt 20 pt
iPhone Xs 812 pt 772 pt 40 pt 20 pt
iPhone SE 320 pt 288 pt 32 pt 16 pt
iPhone SE 568 pt 528 pt 40 pt 20 pt
iPad 768 pt 672 pt 96 pt 48 pt
iPad 1024 pt 672 pt 352 pt 176 pt
iPad Pro 11" 834 pt 672 pt 162 pt 81 pt
iPad Pro 11" 1194 pt 672 pt 522 pt 261 pt

上記のサイズは画面の全幅のを使う場合の数字ですが、readableContentGuideが必ず自分のビューのlayoutMarginsGuideに収まるように設計されているので、上記の幅が最大値です。親ビューのマージンがもっと大きかったり、親ビューがもっと小さかったりすると、読みやすい幅が小さくなります。

また、注意すべき点として、readableContentGuidelayoutMarginsGuideの水平方向で中央になるように設計されているので、左右のマージンが違っていれば、readableContentGuideがビューの中央になりません。ただ様々な設定によって(マージンが例えばsafe areaを含むように)マージンが変わるので、指定した左右のマージンが違ってもreadableContentGuideがビューの中央になることもあります。

表の数字をまとめてみると、iPadでは、画面サイズや向きが何であろうと、幅が最大672 ptになるように計算されているようです(上記の表に載っていないiPad Pro 10.5"もiPad Pro 12.9"もそうです)。横向きのiPhone XsやiPhone 8 Plusでは、読みやすい幅がiPadより大きくなるのか少し気になりますが。

縦向きのiPhoneではreadableContentGuideを使うと左右の余白が最低でも16 ptになるように見えます(因みにiOSで殆どのビューの標準のマージンが8 ptです)。ただし、色んなケースを試してみると、親ビューが全画面でない場合、左右の余白が12 ptになったりしますし、layoutMarginsdirectionalLayoutMarginsで(またはInterface Builderで)親ビューのマージンを小さくすると、読みやすい幅の左右の余白がもっと小さくなることがあります。絶対なのはマージンに収まるように設計されているところだけのようですね。

上記の数字にはsafe areaが含まれていません。readableContentGuideをそのまま使うとsafe area外になる可能性があります。本当に画面全体でreadableContentGuideを使う場合、そのreadableContentGuideを持っているビューのinsetsLayoutMarginsFromSafeAreatrueにすれば良いかもしれません。insetsLayoutMarginsFromSafeAreatrueにすると、マージンにsafe areaが含まれるようになり、readableContentGuideがマージン内になるように設計されているので、readableContentGuideもsafe area内になります。因みに、safe area関連の機能は全部iOS 11以上でしか使えません。

Interface Builder

コードでは、readableContentGuideを使って制約を定義すれば良いのですが、ビューをInterface Builderで配置する場合、どうすれば良いのでしょうか。

やるべきことが2つあります:

  • 読みやすい幅に合わせたいビューの親ビューのレイアウトの設定に「Follow Readable Width」にチェックを入れます。

    f:id:vincentisambart:20181206090752p:plain:w295

  • 読みやすい幅に合わせたいビューの配置に使われるsuperviewに対する左右の制約がマージンに対する制約である必要があります。
    • 制約の詳細にある「First/Second Item」が「Superview.Leading」や「Superview.Trailing」ではなく、「Superview.Leading Margin」や「Superview.Trailing Margin」である必要があります。変えるには、その「First/Second Item」をクリックして、出てくるメニューに「Relative to margin」を選びましょう。

      f:id:vincentisambart:20181206091213p:plain:w295

    • 「First/Second Item」が「Safe Area」の場合、それをまず「Superview」に変えてから、「Relative to margin」を選びましょう。

      f:id:vincentisambart:20181206091038p:plain:w295

ビューが必ずsafe area内に配置されるようにしたかったら、親ビューのレイアウト設定に「Safe Area Relative Margins」にチェックを入れてください。コード上ではinsetsLayoutMarginsFromSafeAreaと同じです。

Dynamic Type

読みやすい幅は便利そうではありますが、使う前に注意すべき点が1つあります:Dynamic Typeです。

Dynamic Typeというのはユーザーが使われるフォントのサイズを変えられる機能です。iOS全体の設定に一般→アクセシビリティ→さらに大きな文字(英語だとSettings→General→Accessibility→Larger Text)で変えられます。対応しているアプリではフォントサイズがその設定に合わせられます。

readableContentGuideのドキュメントを見ると、従われるルールが3つあります:

  • The readable content guide never extends beyond the view’s layout margin guide.
  • The readable content guide is vertically centered inside the layout margin guide.
  • The readable content guide’s width is equal to or less than the readable width defined for the current dynamic text size.

1つめと2つめのルールは既に説明しましたが、3つめはまだでした。上記の表の数字はアクセシビリティ設定で標準のフォントサイズが選択されている場合の値です。もっと大きいフォントサイズが選ばれている場合、読みやすい幅が(マージン内で)広くなります。もっと小さいフォントサイズが選ばれている場合、読みやすい幅が狭くなります。

別の言い方をすると、iOSの「読みやすい幅」の制限が行のポイント数ではなく、その行に入れる文字数と言えるかもしれません。

Dynamic Type対応を既にしてあるアプリは幅が選ばれたフォントサイズに合わせられた方が綺麗に表示されますが、Dynamic Type対応をしていないアプリは大きいフォントサイズが選択されている場合表示が少し読みづらくなりそうです。

ユーザーの選んだフォントサイズカテゴリはUIApplication.shared.preferredContentSizeCategoryで取れます。カテゴリが多いので、どう変わるのかのスクリーンショットは標準サイズ(large)、一番小さいサイズ(extraSmall)、「さらに大きな文字」が有効になっていない場合の一番大きいサイズ(extraExtraExtraLarge)、「さらに大きな文字」が有効になっている場合の一番大きいサイズ(accessibilityExtraExtraExtraLarge)だけのにしました。

  • large (標準設定) f:id:vincentisambart:20181206092236p:plain
  • extraSmallf:id:vincentisambart:20181206092208p:plain
  • extraExtraExtraLargef:id:vincentisambart:20181206092011p:plain
  • accessibilityExtraExtraExtraLargef:id:vincentisambart:20181206091716p:plain

読みやすい幅のまとめ表

Dynamic Typeの設定によって読みやすい幅の数字をまとめてみました。上記のスクリーンショット同様4つのカテゴリだけに絞りました。

どう変わるのか見せるために明確な数字を載せていますが、その明確な数字をビューの配置のために使わないでおきましょう。今後iOS端末の画面サイズの種類がまた増えても不思議ではありませんし、今後iOSのメジャーアップデートで数字が少し変わる可能性もあります。ビューをpixel perfectで配置できていた時代はもう終わっています。

iPhone SE

preferred content size category 向き 画面全幅③ 読みやすい
最大幅④
余白
(③-④)
左右マージン
((③-④)/2)
large 320 pt 288 pt 32 pt 16 pt
large 568 pt 528 pt 40 pt 20 pt
extraSmall 320 pt 288 pt 32 pt 16 pt
extraSmall 568 pt 528 pt 40 pt 20 pt
extraExtraExtraLarge 320 pt 288 pt 32 pt 16 pt
extraExtraExtraLarge 568 pt 528 pt 40 pt 20 pt
accessibilityExtraExtraExtraLarge 320 pt 288 pt 32 pt 16 pt
accessibilityExtraExtraExtraLarge 568 pt 528 pt 40 pt 20 pt

フォントサイズが変わっても、読みやすい幅は変わらないようですね。

iPhone 8

preferred content size category 向き 画面全幅③ 読みやすい
最大幅④
余白
(③-④)
左右マージン
((③-④)/2)
large 375 pt 343 pt 32 pt 16 pt
large 667 pt 627 pt 40 pt 20 pt
extraSmall 375 pt 343 pt 32 pt 16 pt
extraSmall 667 pt 560 pt 107 pt 53 pt
extraExtraExtraLarge 375 pt 343 pt 32 pt 16 pt
extraExtraExtraLarge 667 pt 627 pt 40 pt 20 pt
accessibilityExtraExtraExtraLarge 375 pt 343 pt 32 pt 16 pt
accessibilityExtraExtraExtraLarge 667 pt 627 pt 40 pt 20 pt

縦向きの場合、フォントサイズが変わっても、読みやすい幅は変わらないようですが、横向きだと(少し余裕あるので)少し変わるようです。

iPhone 8 Plus

preferred content size category 向き 画面全幅③ 読みやすい
最大幅④
余白
(③-④)
左右マージン
((③-④)/2)
large 414 pt 374 pt 40 pt 20 pt
large 736 pt 696 pt 40 pt 20 pt
extraSmall 414 pt 374 pt 40 pt 20 pt
extraSmall 736 pt 560 pt 176 pt 88 pt
extraExtraExtraLarge 414 pt 374 pt 40 pt 20 pt
extraExtraExtraLarge 736 pt 696 pt 40 pt 20 pt
accessibilityExtraExtraExtraLarge 414 pt 374 pt 40 pt 20 pt
accessibilityExtraExtraExtraLarge 736 pt 696 pt 40 pt 20 pt

iPhone 8同様、縦向きの場合、フォントサイズが変わっても、読みやすい幅は変わらないようですが、横向きだと(少し余裕あるので)少し変わるようです。

iPhone Xs

preferred content size category 向き 画面全幅③ 読みやすい
最大幅④
余白
(③-④)
左右マージン
((③-④)/2)
large 375 pt 343 pt 32 pt 16 pt
large 812 pt 772 pt 40 pt 20 pt
extraSmall 375 pt 343 pt 32 pt 16 pt
extraSmall 812 pt 560 pt 252 pt 126 pt
extraExtraExtraLarge 375 pt 343 pt 32 pt 16 pt
extraExtraExtraLarge 812 pt 772 pt 40 pt 20 pt
accessibilityExtraExtraExtraLarge 375 pt 343 pt 32 pt 16 pt
accessibilityExtraExtraExtraLarge 812 pt 772 pt 40 pt 20 pt

iPhone 8同様、縦向きの場合、フォントサイズが変わっても、読みやすい幅は変わらないようですが、横向きだと(少し余裕あるので)少し変わるようです。

iPhone Xr、iPhone Xs Max

preferred content size category 向き 画面全幅③ 読みやすい
最大幅④
余白
(③-④)
左右マージン
((③-④)/2)
large 414 pt 374 pt 40 pt 20 pt
large 896 pt 672 pt 224 pt 112 pt
extraSmall 414 pt 374 pt 40 pt 20 pt
extraSmall 896 pt 560 pt 336 pt 168 pt
extraExtraExtraLarge 414 pt 374 pt 40 pt 20 pt
extraExtraExtraLarge 896 pt 856 pt 40 pt 20 pt
accessibilityExtraExtraExtraLarge 414 pt 374 pt 40 pt 20 pt
accessibilityExtraExtraExtraLarge 896 pt 856 pt 40 pt 20 pt

他のiPhone同様、縦向きの場合、フォントサイズが変わっても、読みやすい幅は変わらないようですが、横向きの場合、フォントサイズによって(もう少し余裕あるので)それなりに変わるようです。

iPad

preferred content size category 向き 画面全幅③ 読みやすい
最大幅④
余白
(③-④)
左右マージン
((③-④)/2)
large 768 pt 672 pt 96 pt 48 pt
large 1024 pt 672 pt 352 pt 176 pt
extraSmall 768 pt 560 pt 208 pt 104 pt
extraSmall 1024 pt 560 pt 464 pt 232 pt
extraExtraExtraLarge 768 pt 728 pt 40 pt 20 pt
extraExtraExtraLarge 1024 pt 896 pt 128 pt 64 pt
accessibilityExtraExtraExtraLarge 768 pt 728 pt 40 pt 20 pt
accessibilityExtraExtraExtraLarge 1024 pt 984 pt 40 pt 20 pt

小さめなフォントサイズの場合、縦向きと横向きでは幅が同じですが、大きめなフォントサイズの場合そういうわけでもありませんね。

iPad Pro 11"

preferred content size category 向き 画面全幅③ 読みやすい
最大幅④
余白
(③-④)
左右マージン
((③-④)/2)
large 834 pt 672 pt 162 pt 81 pt
large 1194 pt 672 pt 522 pt 261 pt
extraSmall 834 pt 560 pt 274 pt 137 pt
extraSmall 1194 pt 560 pt 634 pt 317 pt
extraExtraExtraLarge 834 pt 794 pt 40 pt 20 pt
extraExtraExtraLarge 1194 pt 896 pt 298 pt 149 pt
accessibilityExtraExtraExtraLarge 834 pt 794 pt 40 pt 20 pt
accessibilityExtraExtraExtraLarge 1194 pt 1154 pt 40 pt 20 pt

通常のiPad同様、小さめなフォントサイズの場合、縦と横では幅が同じですが、大きめなフォントサイズの場合そういうわけでもありませんね。

セル

一番上に載せたiOSの設定画面の例もTwitterの例もテーブルでした。Twitterの方は左右の余白でもセルがタッチに反応するので、読みやすい幅に合わせられたのはテーブル自体ではなく、セル内のコンテンツでしょう。

セルの場合、普通のビューと少し違うところあるので、それを見ましょう。

UITableView

UITableViewのセルの挙動は他のビューと同じです: - コードでビューを配置する場合、readableContentGuideを使えます。 - Interface Builderを使う場合、制約はマージンに対して定義し、親ビュー(おそらくcontent view)のレイアウト設定の「Follow Readable Width」にチェックを入れたら読みやすい幅に合わせられます。

でも、よりよい方法として、UITableViewcellLayoutMarginsFollowReadableWidthというプロパティを使う方法もあります。Interface Builderでは、UITableViewの「Follow Readable Width」にチェックを入れるのと同じです。

cellLayoutMarginsFollowReadableWidthは自分が定義したカスタムセルだけではなく、テーブルの区切り線にもUIKitが提供しているカスタムでないCellStyleにも影響しています。

いくつかのセルCellStyleの一例、cellLayoutMarginsFollowReadableWidthfalseの場合: f:id:vincentisambart:20181206092608p:plain

cellLayoutMarginsFollowReadableWidthtrueの場合: f:id:vincentisambart:20181206092711p:plain

因みにcellLayoutMarginsFollowReadableWidthtrueの場合、content viewのlayoutMarginsGuidereadableContentGuideのどっちを使っても同じことになります。

UICollectionView

UICollectionViewに全幅に渡るセルがあれば、読みやすい幅を使いたくなることあるかもしれません。ただ、UICollectionViewにはcellLayoutMarginsFollowReadableWidthのようなプロパティがありません。

コードではreadableContentGuideは使えますが、Interface Builderでセル(実質セルのcontent view)のレイアウト設定の「Follow Readable Width」にチェックを入れても効果がありません(バグ)。でもワークアラウンドとしてcontent viewの中にビューを入れ、そのビューの「Follow Readable Width」にチェックを入れ、他のビューをその中に入れ、制約をそのビューのマージンに対して定義すれば動くようです。

まとめ

iOSでは、対応すべき画面サイズが少しずつ増えています。先日発表された新しいiPad Proも以前のと画面サイズが少し変わりました。事前に分かっている数少ない画面サイズを元にアプリの配置を決められる時代が終わっています。

その中でできるだけ多くの画面サイズに合わせて綺麗に配置できるツールの1つとして、読みやすい幅、または読みやすいコンテンツ、というのがあります。

読みやすいコンテンツを使うときはドキュメントにも書いてある3つのルールを覚えておきましょう:

  • readableContentGuideが自分のビューのlayoutMarginsGuide外に出ることはありません。
  • readableContentGuideが水平方向でlayoutMarginsGuideの中央にあります。
  • readableContentGuideの幅が選ばれたDynamic Typeのフォントサイズ次第で決まる読みやすい幅以下です。

毎週リリースを実現するテスト活動

$
0
0

こんにちは。 品質向上グループの茂呂一子(@ichiko_revjune)です。

クックパッドアプリは、サブミット・リリース作業を自動化して、アプリを毎週サブミットするようになりました。これを実現するリリースフローについては、 クックパッドアプリはみんなが寝ている間にサブミットされるで紹介しました。

このリリースフローを実現していく過程では、「機械に人間があわせる」という方針で、サブミット・リリース作業が自動化されていきました。つまり、毎週サブミット・リリースをするためには、何をどのように自動化するべきかという視点で自動化する対象が決まっていきました。

アプリは開発が終わればすぐにリリースできるというものではありません。この記事では、リリース前のテスト作業をどのように調整して、毎週リリースを実現しているのかを説明していきます。自動サブミットの導入はiOSアプリが先行したため、ここではiOS版クックパッドアプリについて説明します。

自動サブミット導入以前のリリース前テスト

この記事にあるように、以前のリリースフローでは、開発期間、コードフリーズ、テスト期間を経てリリースをしていました。このテスト期間に行うテストをリリース前テストと呼びます。テスト期間は、3〜4営業日でした。

リリース前テストの内容は大きく3つです。

ひとつめは、UI操作を伴うシナリオテストを複数のテスト環境(iOSデバイスとiOSバージョンの組み合わせ)で実行することです。このシナリオテストは、Appiumを使って機械的に実行しています。

ふたつめは、自動化がむつかしいが重要な機能を手動でテストすることです。例えば、In App Purchase に関する機能や、写真撮影が必要な機能などです。

みっつめは、各リリースに含まれる変更に関連した機能を手動でテストすることです。変更内容から起こりそうな不具合を想像し、それを実際に確認するというような作業です。探索的テストと呼ばれるものに近いでしょう。

これらを実施して不具合が見つかれば修正し、リリース前にその修正がうまくいっているかを確認していました。

自動サブミット導入のために

自動サブミット導入後もリリース前テストはなくなりません。スケジュールされた自動サブミットが完了し、テスト対象が確定してからリリース前テストが始まります。

先に説明した、以前のリリースフローにあわせて設計したテストをそのまま実施すると3〜4営業日かかることになり、リリース前テスト以外の活動にあてられる時間がなくなってしまいます。わたしたちは自動化したシナリオテストを持っているため、テスト実施以外にも、これらをメンテナンスする時間は必要です。また、テストツールを改善したり、リリースフローを改善するなどの活動もリリースを続ける上では欠かせません。テストのやり方か内容を変更しなければ、毎週のサブミットに耐えられなくなってしまいます。

自動化できるところは自動化し、人間にしかできないことを人間が行うという方針で、テスト作業を効率化してきました。自動サブミット導入当初はサブミットからリリースまで、2〜3営業日かかっていました。しかし、最近は2営業日でリリースできるようになってきました。

幸いにして、大きな不具合を出すことなく、毎週のサブミットとリリースが実現できています。テスト作業をどのように変えていったのかを課題ごとに説明していきます。

一番時間がかかるのはシナリオテスト実行

既に自動化してあった200件弱のシナリオテストは、ひとつのテスト環境(iOS デバイスとiOSバージョンの組み合わせ)での実行に10時間以上かかっていました。加えて、以前はシナリオテストをCIマシン上で実行させる為の環境整備が追いついておらず、手元のPC上で実行していました。そのため、毎晩の就業時間外にひとつのテスト環境を選び、実行していました。

リリース前テストでは、iPhone/iPadいずれの環境でも動かすことと、サポートiOSバージョンを網羅することを満たすよう、複数のテスト環境で実行します。したがって、1リリースあたりのシナリオテストにかかる時間は、10時間かけるテストする組み合わせの数でした。

これらの問題はCIマシンでの実行と、シナリオテストの並列実行という方法で実行時間を短縮しました。

今年に入ってから、CIマシン上で実行することは試みていました。そして、自動サブミット導入の頃に準備が整い、本格的に実行をCIマシンに任せるようになりました。シナリオテストを実行しているCI環境はビルド用のCI環境とは別のものを使用しています。理由はふたつあり、ひとつめは、実行にかかる時間が長いため短時間で終了するビルドタスクと混ぜて実行するべきでないためです。ふたつめは、最新のOSでは動作が不安定になりやすく、ビルド環境と同じタイミングでOSを更新できないことが多いためです。

シナリオテストの並列実行は、今年チームに加わった加藤が、実行の高速化を目的に実現してくれました。並列実行と呼んでいるのは、同じデバイスとiOS を使用するiOS Simulatorを複数起動し、別のシナリオを同時に実行することです。UIテストのボトルネックのひとつにSimulatorを操作する時間があるため、Simulatorを増やすことに効果がありました。これによって、1つのテスト環境でのテスト実行にかかる時間が約3分の1になり、実行時間の総量も同じように少なくなりました。

実行時間の短縮という他にも、金曜日から日曜日にかけてシナリオテストを実行するようスケジュールして、実行開始が遅延してもシナリオテストが終わるのを待つ時間が発生しにくいように工夫しています。自動サブミットは金曜日の早朝に動きだすので、順調にいけば金曜日の午後にはシナリオテスト結果がそろい、レポートを見れるようになっています。

シナリオテスト実行の指示を毎回人間がしていた

シナリオテストは、1回のリリースのために複数のテスト環境で実行しています。テスト環境は固定しておらず、リリースごとに変え、ある程度の期間でみたときに、iOSデバイスサイズとiOSバージョンを網羅するように設計してきました。

自動サブミット導入以前は、このテスト環境の選定を人間が行い、実行のたびに指定していました。

自動サブミット導入後は、このテスト環境の選定をスクリプト化し、実行のたびに指定しなくて済むようにし、機械による無作為抽出を導入しました。

機械に任せる領域を増やして、リリース前テストの準備をひとつ減らしました。

お手本画像を人間が更新していた

シナリオテストツールには、表示くずれを見つけるために、シナリオテスト中で取得したスクリーンショット画像をお手本画像と比較して、差分を表示する機能があります。

このお手本画像の管理には2つの課題がありました。ひとつめは、テストツールの実行環境ごとにファイルを管理しており、すべての環境で同じファイルを使っている保証がなかったことです。ふたつめは、人間が差分画像を見ながら更新すべきファイルを探し、コピー&ペーストでお手本画像を更新していたことです。実行環境が複数存在したため、特定の環境への反映漏れということも起きていました。

この作業を改善するにあたって、まず、お手本画像はAWS S3に一元管理するようにしました。実行のたびに、S3から最新のお手本画像を取得し、どこでも同じお手本を使用するようにしました。

次に、更新作業の一部をスクリプト化し、更新するべきかの判断だけを人間が行うようにしました。人間は差分画像の一覧をみながら、更新する画像にマークをつけます。その選択結果をファイルに出力し、それをスクリプトに与えて実行すればS3上にファイルが送信されます。

単純作業を機械に任せることで、人間は不具合の探索に集中しやすくなりました。

シナリオテスト結果から不具合をみつける手助け

シナリオテストは、UI操作を伴うため、不具合がなくてもシナリオが失敗することがあります。本当に不具合があるのかは最終的に、人間が実機を操作して判断することになります。その失敗が不具合の可能性が高いのか、低いのかを知ることで、この再操作を減らすことができます。

失敗しやすいシナリオや不安定なシナリオを見つける目的で、シナリオごとの実行結果を蓄積、可視化するアプリケーションを構築しました。このアプリでは、同じバージョンのアプリに対する複数のテスト環境での実施結果の成否を一覧できます。「どのテスト環境でも失敗しているので不具合の可能性が高い」「ひとつの環境だけ失敗しているので不具合の可能性が低い」といった具合に参考情報を簡単に見つけることができるようになりました。

f:id:ichiko_revjune:20181212111114p:plain
シナリオごとの実行結果を蓄積、可視化

このアプリがない頃は、複数のレポートを開いて見比べていました。成功失敗というデータに絞って可視化することで、ずいぶんと容易に理解できるようになりました。

自動化できない手動テストの効率化

自動化することはできないけれど重要な機能、例えば In App Purchase に関する機能は毎回手動でテストをしています。リリースごとの追加機能を対象とするテストではないので、ほぼ内容は決まっています。テスト作業の見直しを始めた時点では、削ってしまえる機能もありませんでした。

これらの手動で実施するテストの項目は、厳密なテスト手順を定めておらず、この機能でなにができること、という満たすべき項目をリストにしています。探索的テストのチャーターのようなものでしょうか。

自動サブミット導入以前も、関連機能に変更がなければ省略してもよい項目はあり、省略することもありました。自動サブミット導入後は、これを厳格に適用して、関連機能に変更がなく不具合が発生しないであろう機能へのテストを省略するようにしました。合理的にテストしすぎない工夫としては、うまくいっている方ではないでしょうか。

これからの課題

小さな改善を重ねてリリース前テストに時間をかけ過ぎることなく、毎週のリリースを実現している方法を紹介しました。

サブミット自動化後のリリースフローは、リリース前テストで致命的な不具合が見つかった場合、リリースをあきらめるルールです。このフローがうまくまわっていくためには、不具合が入りにくいしくみや、機能変更をマージする前に不具合に気づきやすくするしくみが必要です。そのためには、開発チーム内のテスト技術を向上させることも必要でしょう。これらの点ではまだ効果的なアクションを取れていないので、急がず確実に小さな変化を積み重ねていきたいです。

本番/ステージング環境GPUぼくめつ大作戦

$
0
0

機械学習チームの林田(@chie8842)です。好きなスポーツはテニスとスノボです。

システムは、その当時の最新の技術で作ったとしても必ずレガシー化します。 機械学習システムも他システムと同様、一度デプロイしたら終わりではなく、継続的なメンテナンスが必要です。昨今機械学習は、特に技術の進歩が目覚ましいため、レガシー化するのも早い分野といえます。本稿ではレガシー化した機械学習アプリケーションのメンテナンスと、それに伴うGPU環境からCPU環境への移行によって、大幅にシステムの運用コストを削減した例をご紹介します。

機械学習アプリケーションにおけるコスト課題

クックパッドにおける最初の大きな機械学習プロジェクトである料理きろくがリリースされたのは、2年前のことです。それ以来、様々な機械学習アプリケーションがデプロイされ、現在では大小含めて30を超える機械学習アプリケーションが運用されています。そのうち、10 個のアプリケーションがGPUサーバ上で運用されており、常時数十台のg2.2xlargeインスタンス1が起動している状況でした。

ここで、これらのGPUインスタンスについて、コスト面における問題が2つありました。 1つは実行環境であるEC2インスタンス料金です。GPUインスタンスは他のGPU非搭載インスタンスと比べて単位時間あたりの使用料金が非常に高価です。たくさんのGPUインスタンスが稼働していることで、クックパッド全体のサーバコストを逼迫させているという問題がありました。 2つめはメンテナンスにかかる人的コストです。GPUサーバをアプリケーション環境として利用することで、通常のモニタリングメトリクスに加えてGPUのリソース監視が必要になります。さらに、コンテナからデバイスドライバを使用するための権限設定など、特別な対応が必要となり、将来的にも人的メンテナンスコストがかかるという問題がありました。

機械学習における処理パフォーマンス

機械学習には、データの特徴をよく表すモデルを作る「学習」と呼ばれる過程と、 作ったモデルを使って分類問題を解くなどの「推論」と呼ばれる過程に分けることができます。 例えば、犬と猫の画像を使って、犬と猫を分類するモデルを作るのが「学習」過程、そのモデルを使って犬と猫の画像を分類するのが「推論」の過程です。

クックパッドでは、GPU環境を利用して定期的にモデルを再学習しているアプリケーションはなく、GPU環境のアプリケーションは、全て「推論」に利用していました。

GPUには、並列処理を高速化できるという特徴があります。機械学習の中でも、特にディープラーニングなどのニューラルネットワークを用いる場合に、GPUが用いられることはよく知られていますが、顕著に処理パフォーマンス上効果的なのは、以下の場合です。

  1. ディープラーニングにおける「学習」処理2
  2. 非常に高いパフォーマンスが要求される「推論」処理

実はディープラーニングを使った機械学習アプリケーションでも、GPUが必要なケースというのは、意外と限られているのです。 例えば、Applied Machine Learning at Facebook: A Datacenter Infrastructure Perspectiveという論文では、以下のように、機械学習システムを大規模に利用しているFacebookのサービスでさえ、推論にはCPU環境を利用しているということが、語られています。

Facebook currently relies heavily on CPUs for inference, 
and both CPUs and GPUs for training, 

クックパッドでは、モデル学習にGPUを使っていますが、先に書いたとおり、アプリケーションとして実行しているものにおいて、1.に当てはまるものはありません。また、ユーザに対してレスポンシブに応答を返したり、大量のデータを処理したりするといったものはないため、2.についても回避できる可能性が高いということが分かっていました。 そこで、必要なものはアプリケーションのチューニングを行い、GPUアプリケーションを全てCPU環境に移行することにしました。

CPU環境への移し替えに伴うサービス影響確認とパフォーマンス・チューニング

GPUで動作しているアプリケーションをCPU環境に載せ替えるにあたり、まずはCPU環境への移行によるパフォーマンス影響の調査を行いました。 Webシステムにおけるパフォーマンスの重要な指標として、「スループット」と「レスポンスタイム」があります。 スループットとは、一定の時間内に処理できるトランザクション数、レスポンスタイムとは、クライアントからのアクセスに対しての応答速度です。 目標とするスループットやレスポンスタイムが決まっていれば、それらにあわせればよいですが、今回は目標が明確に決まっていないアプリケーションが多い状態でした。そこでまずはGPUを利用した場合とCPUを利用した場合のパフォーマンスの差を測定し、それをもってプロダクトオーナと協議するという方式をとりました。

以下の節では「料理きろく」3という写真に対して料理/非料理判定を行う機械学習アプリケーションの場合を例にとって、パフォーマンス計測とチューニングの進め方を紹介します。 料理きろくの詳細については以下をご覧ください。

techlife.cookpad.com

techlife.cookpad.com

チューニング前のパフォーマンス

料理きろくの機械学習部分は、TensorFlow v1.2.1で実装されていました。 GPUインスタンス上で、アプリケーションの推論部分のみをtensorflow-gpuパッケージとtensorflowパッケージ[^3]で動作させた結果、以下のとおり、tensorflow-gpuを利用したほうが約18倍速いことがわかりました。

GPUCPU
レスポンスタイム0.0723秒/枚1.2776秒/枚

なお、上記レスポンスタイムは、下記のように100枚の推論の平均値をとっています。

elapsed_times = []
for i in image_list:
    image = Image.open(i)
    start = time.time()
    model.infer(image) # 推論処理
    elapsed_time = time.time() - start # 実行時間
    elapsed_times.append(elapsed_time)

print(mean(elapsed_times)) # 実行時間の平均値の表示

TensorFlowのチューニング

まずはTensorFlowのレイヤーで、レスポンスタイムを縮めるようチューニングしました。 料理きろくのために行ったチューニングの中では以下の2つの項目が効果的でした。

  1. TensorFlowバージョンアップ
  2. tf.Sessionを使い回すようにする

順に説明します。 まず1.についてです。TensorFlowは、非常に開発が活発なソフトウェアです。バージョンが上がるごとにTensorFlow自体の高速化も行われているため、単純にTensorFlowのバージョンを上げることで、パフォーマンスが向上することが予想できました。このため、今回は思い切ってTensorFlowのバージョンを1.2.1から最新の1.12.0に上げました。バージョンアップに伴いリファクタリングは必要でしたが、モデル自体はv1.2.1で学習したものをそのまま使うことができました。

次に2.についてです。TensorFlowでは、定義した内容を実行するために、tf.Sessionというセッションが必要です。 このtf.Sessionの起動にはオーバヘッドがあるため、複数の処理ごとに張り直すと、それだけで処理が非常に重くなります。そのため、1つのセッションをできるだけ使い回すように修正しました。

元のコードの書き方

defhoge():
        with tf.Session as sess:
            sess.run()


    deffuga():
        with tf.Session as sess:
           sess.run()

セッションを使い回す場合の書き方

sess = tf.Session()

defhoge(sess):
    sess.run()

deffuga(sess):
    sess.run()

(省略)

sess.close()

その結果、元のCPU版と比べてレスポンスタイムが5分の1程度になりました。

GPUCPU(チューニング前)CPU(チューニング後)
レスポンスタイム0.0723秒/枚1.2776秒/枚0.25秒/枚

Dockerコンテナリソースのチューニング

TensorFlowレイヤーにおけるチューニングはここまでとして、次に実際本番環境と同等のCPU環境で動作させる場合のアプリケーションとDockerコンテナリソースをチューニングしました。 CPUで動作させる場合の本番環境は、c5.xlargeインスタンスからなるクラスタを利用します。4そのためc5.xlarge環境で、リソースをモニタリングしながら、アプリケーションを実行します。 今回の検証ではリソースモニタリングツールとしてsarを利用しました。sarを採用した理由は、非常に軽量で扱いやすく、時刻付きのテキスト形式でログを保存しやすいためです。

結果として、Dockerコンテナ上で、ホストのリソース利用制限を行わずにアプリケーションを実行した結果、レスポンスタイムとリソース使用率は以下のようになりました。

レスポンスタイムCPU使用率メモリ使用率
0.474秒/枚93%程度20%程度

TensorFlowは、処理が1プロセスであっても、デフォルトで使用可能なCPUすべてを使います。 1プロセスでCPUをほとんど使ってしまうので、1ホスト上で実行するアプリケーションのプロセス数やスレッド数を増やしても、レスポンスタイムが低下してしまい、スループットもそれほどあがらないだろうということがわかります。このため、1ホスト1コンテナ構成で、スループット確保のためには、スケールアウトを行うしかない、という結論となりました。

移行

上記の実験から、GPUからCPUへの移行のパフォーマンス影響としては、以下のとおりとなりました。

  • レスポンスタイムがGPUを利用した場合と比べて0.47秒程度増える
  • スループットはサーバのスケールアウト(オートスケール)により担保する

この内容でプロダクトオーナーに了解をとり、移行を行いました。 クックパッドでは、Hakoという、Kubernetesライクなコンテナオーケストレーション環境が導入されており、移行自体は、Dockerイメージの更新及びHakoの定義ファイルの更新のみを行えば、スケールアウトも自動で行われ、サーバ作業を行うことなく実行できます。 Hakoについては、詳しくは以下の記事を参照してください。

techlife.cookpad.com

最初はGPU環境と並行運用し、最終的にCPU環境のみを残すようにして、移行します。

コスト削減の結果

現在移行による並行運用中のアプリケーションもありますが、料理きろくも含めてすべての移行対象アプリケーションをCPU環境で動作しています。

これによるサーバコスト削減効果を算出したところ、EC2利用料金は元の6分の1程度となり、年間1,500万円以上の節約となることがわかりました。

その他の有効なチューニング

今回はチューニングの一例として、料理きろくをCPU環境に移行する内容を紹介しました。 最後に、料理きろくのチューニングではやらなかったけどその他の一般的に有効なチューニング手法を3つだけ紹介します。

モデルアルゴリズムの変更

料理きろくのモデルは、InceptionV3ベースです。モデル更新を行う必要があるため、今回はしませんでしたが、最近では、MobileNetなどの軽量モデルが発展しており、こうしたモデルに置き換えることで、パフォーマンスが向上する可能性が大きいです。

プロセス数/スレッド数の調整

料理きろくでは、TensorFlowの演算によるCPUがボトルネックとなったため実施しませんでしたが、1プロセスでCPU、メモリ、IOといったリソースを十分に使い切れていない場合、WSGI等を利用してプロセス数、スレッド数を増やすことで、1ホストあたりのスループットをあげることができます。

共有メモリの利用

上記において、例えばプロセス数を増やした場合、各プロセスがメモリ上にモデルをロードすることになります。現状クックパッドで運用しているモデルには、それほど巨大なモデルがありませんが、例えば今後以下ブログにおいて紹介したBERTなどの巨大なモデルを利用したいとなったときは、プロセス間で同じ共有メモリ上のモデルを利用するようにアプリケーションを記述することが有効になるかもしれません。

techlife.cookpad.com

さいごに

機械学習というと、新しい手法を試し、ハイパーパラメータチューニングを行ってモデルの精度を高めることに興味がある人が多いでしょう。しかしながら、機械学習をサービスに活かすためには、こうしたアーキテクチャ面における「チューニング」も重要な仕事であることを分かっていただけたなら嬉しいです!


  1. AWS上のGPUサーバ

  2. 最近はGPUが必要ない軽量なモデルもある

  3. tensorflow-gpuはGPU版、tensorflowはCPU版のPythonのTensorFlowパッケージ

  4. 厳密にはc5.xlarge以上のリソースを持つインスタンスタイプが混ざったスポットインスタンスからなるクラスタ

EnvoyCon 2018 Seattle で登壇しました

$
0
0

f:id:aladhi:20181217091426p:plain

こんにちは、Taikiです。先日、Envoy proxyのためのカンファレンスである EnvoyCon 2018がシアトルで開催され、参加・登壇してきたので、私の発表内容や他の登壇者の内容をいくつかこの記事では紹介できればと思います。また、EnvoyCon は KubeCon + CloudNativeCon North America 2018の co-located event として開催されたのもあり、KubeCon + CNCon にも参加してきたので、そちらのほうからも Envoy 関係の発表をいくつか紹介します。

クックパッドの発表

クックパッドではおよそ1年前から Envoy をサービスメッシュの data plane proxy として利用しています。クックパッドは Amazon ECS を使用しているのもあり、サービスメッシュの構築にあたり、自分たちの requirements や環境に合う製品が見つからなかったので、自社で control plane を開発するアプローチを採用しました。事前に生成した Envoy proxy 向けの xDS response を Amazon S3 を用いて配信する、という設計は Envoy ユーザーの中でもユニークなものであり、その設計の詳細や運用の側面について紹介しました。

EnvoyCon での発表

時系列順にいくつかご紹介します。

まずは Google の Harvey から、Envoy 開発の統計をもとに Envoy の開発状況について誰がどのように開発しているのか、という内容のトークがありました。OSS のユーザーとして、利用している OSS の開発状況やコミュニティは、その OSS プロダクトの将来性に関わることもあり関心があるので、興味深いセッションでした。クックパッドからも私がパッチを送っているのもあり、スライドに少し登場しています。

f:id:aladhi:20181217091530p:plain

次に eBay から Bala と Qiu が、 edge proxy としての Envoy 利用事例の発表をしました。eBay の PoP の中で動いていたハードウェアロードバランサーを、visibility の強化やより柔軟なトラフィックルーティングを実現するために、Envoy で置き換えるというチャレンジングな内容でとても楽しめました。

次に Yelp の Ben と John から、Yelp でのサービスメッシュの進化と data plane の Envoy への置き換えについてセッションがありました。Yelp では以前より HAProxy ベースのサービスメッシュを運用していて、そのサービスメッシュの改善・進化についての発表でしたが、”小さく始める” ポリシーやプロダクトの開発に集中するといった考え方がクックパッドでの考え方に近く、親近感を持ちながらセッションを聞きました。

次に、Google の Alyssa から、 Envoy のセキュリティ面での強化(hardening) について発表がありました。Google では全てのトラフィックを受け持つ frontend proxy での Envoy 利用が進んでいるようで、そのために Envoy のセキュリティ面、特に攻撃に対する耐性の強化についての発表でした。こちらは個人的に興味関心がある分野で興味深く聞きました。

他にも非常に興味深い発表が盛りだくさんでした。Twitter の #envoycon ハッシュタグでも様子を少しつかめるのでぜひご覧ください。

KubeCon + CNCon での発表

Lyft から Daniel が、 database traffic に Envoy を利用している事例について発表しました。主な目的はメトリクスの確保です。Envoy はすでに Redis, DynamoDB, MongoDB への通信に対応したフィルターを実装しており、それを利用してどのような利点があったか、という内容でした。クックパッドでは MySQL, PostgreSQL の利用が主であり、Envoy のフィルターの実装を待つか手伝う必要があるのですぐには実現は難しいのですが、今後導入を図りたいと個人的に考えています。

Keynote, Envoy Update: Lyft から Matt, Constance, Jose の3人が Envoy の近況についてキーノート発表をしました。今回の KubeCon + CNCon での Envoy の盛り上がりを感じられるセッションかなと思います。

KubeCon + CNCon はすでにある程度 YouTube にセッション動画がアップロードされています。EnvoyCon も録画をしている様子があったので、そのうちアップロードされるのではないかと思います。

終わりに

Envoy やサービスメッシュに関連する事例を共有しあったり、現在の課題や実現を目指していることなどをお互いに話し合える場は大変貴重で、とても楽しみ、また数々の学びがありました。

クックパッドという会社はこのような海外登壇であっても強力にバックアップしてくれます。また、先進的な領域で問題に取り組み成果を出しやすい環境があります。興味のある方はぜひ下記のリンクよりコンタクトください。

https://info.cookpad.com/careers

We’re hiring!

プロと読み解く Ruby 2.6 NEWS ファイル

$
0
0

技術部の笹田(ko1)と遠藤(mame)です。クックパッドで Ruby (MRI: Matz Ruby Interpreter、いわゆる ruby コマンド) の開発をしています。お金をもらって Ruby を開発しているのでプロの Ruby コミッタです。

もうすぐ Ruby 2.6 がリリースされますね! Ruby 2.6 の新機能は何だろう、と調べるためには、ソースコードの diff を見ればいいのですが、膨大な変更があるので、一つ一つ見ていくのは大変です。

$ git diff --compact-summary origin/ruby_2_5
...
 6404 files changed, 228441 insertions(+), 97984 deletions(-)

そこで、NEWSファイルという、主要な変更点をまとめたファイルが用意されています。これを見るだけで、Ruby 2.6 の変更点が把握できます。NEWSファイルは Ruby 2.6 の tarball などに入っています。

ただ、NEWS ファイルも、あまり詳細は書いていないため、読みづらいかも知れません。淡々と、「このメソッドが追加された」とかが並んでいるだけです。

そこで、本記事では Ruby 2.6 の NEWSファイルの内容を、プロ Ruby コミッタの笹田と遠藤で解説していきます。解説記事はすでにいくつかあり、さらに出てくるだろうと思うのですが、本稿では、なぜ「そのような変更がおこなわれたか」という背景事情をなるべく書くように心がけています。

なお、NEWSファイルに追記しているのは人間なので、当然追記忘れなどのミスがあります。そのため、これ以外にも変更があるかもしれませんが、ご容赦下さい。

See also:

(ko1) <- 以降、こんなふうに文責を明示します。

NEWS ファイルの読み方

まず、NEWSファイルの構成について解説しておきます。

見ての通り、Markdown ではなく、RD フォーマット(って知ってます?)で書いてあります。まぁ、読む分には見た目の雰囲気でわかるんじゃないかと思います。

章立ては次の通りです。

  • Language changes / 言語の変更
  • Core classes updates (outstanding ones only) / 組込クラスのアップデート(主要なもののみ)
  • Stdlib updates (outstanding ones only) / 添付ライブラリのアップデート(主要なもののみ)
  • Compatibility issues (excluding feature bug fixes) / 非互換(バグ修正を除く)
  • Stdlib compatibility issues (excluding feature bug fixes) / 添付ライブラリの非互換(バグ修正を除く)
  • C API updates / C API のアップデート
  • Implementation improvements / 性能向上
  • Miscellaneous changes / その他

読めばわかると思いますが、言語の変更が最初にあって、組込クラス、標準ライブラリの仕様変更とかの話になり、最後に互換性関係ない性能向上とかで話を締めています。やっぱり、これまでの Ruby アプリが動くかどうかと言う、仕様変更が気になりますよね。

文中に出てくる [Feature #12912] といった表記は、https://bugs.ruby-lang.org/projects/ruby-trunk/issuesに登録されたチケット番号になります。https://bugs.ruby-lang.org/issues/の後ろに番号を付ければ、この場合は https://bugs.ruby-lang.org/issues/12912とすれば、当該チケットを見ることができます。

NEWSは上記の順番で並んでいますが、本稿では、この順番は無視して、我々が重要だと思っている、もしくは興味がある変更を順に紹介していきます。でも、笹田が一番興味のある性能向上については、最後にまとめます(知らなくても使う分には問題ないですから)。

(ko1)

言語機能の改善

少し、文法などの拡張がありました。新しい文法などを使うと、古い Ruby で動かなくなるので、お気を付け下さい。

終端なしの Range が導入された

  • Endless ranges are introduced. You can use a Range that has no end, like (0..) (or similarly (0...)). [Feature #12912]

(1..)のように、終端を省略した Range が書けるようになりました。

遠藤が提案・実装しました。提案時に想定していたユースケースは、次の 3 つです。

ary[1..] #=> ary[1..-1] と同じ
    
(1..).each {|index| ... } # 1 から無限にループ# each_with_index を 0 でなく 1 から始める
ary.zip(1..) {|elem, index| ... }

どれも、意外とスッキリ書けなくてモヤモヤしていました。終端が省略できればいいのでは、と思って実装してみたら、意外にすんなり実装できて驚きました。

他の用途としては、下限の指定を DSL 的に表現するのにも使えそうです。(この用途としては beginless rangeも欲しくなりますが、こちらの提案は pending となってます)

users.where(id: 10..)

(1..)(1..nil)の構文糖です。終端を nilにするかどうかは少し議論がありました。従来から書けていた nil..nilが endless になってよいのかとか。Qundef(Ruby ユーザからは見えない未定義を表す値)を使うとか、他にも選択肢はありましたが、一番直観的なものということで nilになりました。 (1..)(1...)の違いも結構議論がありました。(1..)は無限大を含む Range のように見えて数学的にはちょっと不思議、とか。しかし ary[1..]ary[1...]と書かないと行けないのは面倒なので、数学的な直感はおいといて (1..)(1...)の両方が入ることになりました。なお、これらはオブジェクトの等価性としては別(exclude フラグの有無が違う)です(ary[1..]ary[1...]は同じ結果になります)。

なお、次のように書くと syntax error になるのでご注意ください。

case id
when10..
  puts "id >= 10"end

これは、10..で行が終わると、行継続になってしまうためです。when (10..)のようにカッコをつければ動きます。直すこともできたのですが、複数行に渡る Range リテラルの例が発見されたので、互換性重視で現在の挙動になりました。

(mame)

ローカル変数の shadowing 警告を削除

  • The "shadowing outer local variable" warning is removed. [Feature #12490]

ブロックの引数の名前が、外のスコープのローカル変数と衝突しているとき、警告モードで実行すると警告が出ていました。

x = "str"1.times {|x| ... }
#=> warning: shadowing outer local variable - x

この警告を取り除くことになりました。これにより、次のようなコードで警告を見なくてよくなりました。

user = users.find {|user| cond(user) }
#=> warning: shadowing outer local variable - user ← 2.6 で消えた

歴史的な話をすると、Ruby 1.8 ではこういう衝突があるとき、外のローカル変数を上書きしていたのですが、1.9 から現在の挙動に変わりました。

x = "str"
(0..10).each {|x| }
p x #=> 10    (in Ruby 1.8)#=> "str" (in Ruby 1.9+)

この警告はその非互換を伝えるために存在しましたが、さすがにもう要らなそうだし、妥当なコードでも警告されてしまうのが邪魔であるということで、消すことになりました。もしこの警告が欲しい人は、Rubocop の Lint/ShadowingOuterLocalVariableを使うといいんじゃないでしょうか。

(mame)

rescue のない else が禁止

  • else without rescue now causes a syntax error. [EXPERIMENTAL] [Feature #14606]

例外処理の構文 begin ... rescue ... endには、例外が投げられなかったときの処理を書く else節があります。

begin
  ...
rescue# 例外が投げられた場合else# 例外が投げられなかった場合end

rescue 節は何個書いてもよいのですが、0 個でもよかったのでした。

begin
  ...
else
  ...
end

が、このプログラムは意味がなく、理解もしにくいだろうということで、rescue 節 0 個のときは SyntaxError とすることになりました。同様に、メソッド内での rescue無し elseも禁止になりました。

deffoo
  ...
else
  ...
end

個人的には、変なコードが書けなくなったので少しだけ残念な気持ちです。

(mame)

定数名で非 ASCII の大文字も利用可能に

  • Constant names may start with a non-ASCII capital letter. [Feature #13770]

定数名は ASCII の大文字でないとダメでしたが、2.6 で Unicode の大文字も OK となりました。

classМир
  defприветствовать
    "Привет, Мир!"endend

Мはキリル文字の大文字です。

完全に余談ですが、Unicode の大文字・小文字の話題になると、「Dz」という字の話をするのがお作法です。これは D と z の 2 文字ではなく、D と z が合体した 1 つの文字です。こういう文字を、digraph、二重音字と言います。この文字には、大文字・小文字に加え、タイトルケース(先頭の文字だけが大文字)の 3 種類があります。

# 大文字
p "\u01F1"#=> DZ# 小文字
p "\u01F3"#=> dz# タイトルケース
p "\u01F2"#=> Dz

Ruby では、大文字とタイトルケースの両方を定数として使えます。

classDZ # OKendclassDz # OKendclassdz #=> class/module name must be CONSTANTend

ちなみに、Dz はスロバキア語、ハンガリー語などで使われるそうです(Wikipedia の記事)。digraph は Dz 以外にもいくつかあります(List of Unicode Characters of Category “Titlecase Letter”)。

(mame)

キーワード引数とオプション引数のコーナーケースを禁止

  • Non-Symbol keys in a keyword arguments hash cause an exception.

matz がみつけた、オプション引数とキーワード引数を両方受け取るときの微妙な挙動が禁止になりました *1

deffoo(h = {}, key: :default)
  p [h, key]
end

foo(:key => 1, "str" => 2)
  #=> [{"str"=>2}, 1] (2.5 まで)#=> non-symbol key in keyword arguments: "str" (ArgumentError) (2.6)

Ruby 2 のキーワード引数にはいろいろ変なところがあり、Ruby 3 での作り直しが検討されています(参考:大江戸 Ruby 会議 07 『Ruby 3のキーワード引数について考える』)。この変更は、それの伏線になっているとかいないとか。

(mame)

バックトレースで原因(cause)のバックトレースも出るようになった

  • Print cause of the exception if the exception is not caught and printed its backtraces and error message. [Feature #8257]

例外処理中(rescue文や ensure文実行中)に、新しい例外を意図して発生させたり(下記の例の NantokaError)、意図せず発生させちゃったり(rescue文に typo があったりとか。例外処理のテストは網羅するのが面倒なので、よくありそうな話ですね)することがあります。そのとき、プロセス異常終了時のバックトレースの表示には、最後に発生した例外の情報しかありませんでした。ランタイムとしては、最後に発生した例外オブジェクトで、 Exception#causeというメソッドで取れることは取れるんですが、プロセス終了時のエラー表示には含まれていませんでした。

Ruby 2.6 からは、プロセスが例外で終了したとき、あがってきた例外オブジェクトに causeの情報があれば、その情報も一緒に表示するようになりました。

classNantokaError< RuntimeErrorenddefmy_open
  open('non_existing_file')
endbegin
  my_open
rescueErrno::ENOENTraiseNantokaErrorend

この例では、openErrno::ENOENT例外を発生しますが、呼び出し元で NantokaErrorをさらに発生させています。

Ruby 2.5 では、

Traceback (most recent call last):
        1: from /home/ko1/src/ruby/trunk/test.rb:8:in `<main>'
/home/ko1/src/ruby/trunk/test.rb:11:in `rescue in <main>': NantokaError (NantokaError)

と、最後に発生させた NantokaErrorだけ表示しています。

Ruby 2.6 では、

Traceback (most recent call last):
        3: from /home/ko1/src/ruby/trunk/test.rb:9:in `<main>'
        2: from /home/ko1/src/ruby/trunk/test.rb:5:in `my_open'
        1: from /home/ko1/src/ruby/trunk/test.rb:5:in `open'
/home/ko1/src/ruby/trunk/test.rb:5:in `initialize': No such file or directory @ rb_sysopen - non_existing_file (Errno::ENOENT)
        1: from /home/ko1/src/ruby/trunk/test.rb:8:in `<main>'
/home/ko1/src/ruby/trunk/test.rb:11:in `rescue in <main>': NantokaError (NantokaError)

と、5行目の openが元々のエラーの原因であることを示すようになりました。

冗長になりますが、原因がわからないよりは便利だろう、ということで、導入されることになりました。Java とかで、すでにそのように表示されるようですね。

ちなみに、causeがたくさん連鎖していると、バックトレースはどんどん長くなります。また、上記例をちょっと変えて、少しメソッド呼び出しの深いところで実行してみると、

classNantokaError< RuntimeErrorenddefmy_open
  open('non_existing_file')
enddeffoo
  bar
enddefbarbegin
    my_open
  rescueErrno::ENOENTraiseNantokaErrorendend

foo

結果:

Traceback (most recent call last):
        5: from /home/ko1/src/ruby/trunk/test.rb:20:in `<main>'
        4: from /home/ko1/src/ruby/trunk/test.rb:9:in `foo'
        3: from /home/ko1/src/ruby/trunk/test.rb:14:in `bar'
        2: from /home/ko1/src/ruby/trunk/test.rb:5:in `my_open'
        1: from /home/ko1/src/ruby/trunk/test.rb:5:in `open'
/home/ko1/src/ruby/trunk/test.rb:5:in `initialize': No such file or directory @ rb_sysopen - non_existing_file (Errno::ENOENT)
        3: from /home/ko1/src/ruby/trunk/test.rb:20:in `<main>'
        2: from /home/ko1/src/ruby/trunk/test.rb:9:in `foo'
        1: from /home/ko1/src/ruby/trunk/test.rb:12:in `bar'
/home/ko1/src/ruby/trunk/test.rb:16:in `rescue in bar': NantokaError (NantokaError)

こんなエラー出るようになりました。これをよく見てみると、

        5: from /home/ko1/src/ruby/trunk/test.rb:20:in `<main>'
        4: from /home/ko1/src/ruby/trunk/test.rb:9:in `foo'
        ...
        3: from /home/ko1/src/ruby/trunk/test.rb:20:in `<main>'
        2: from /home/ko1/src/ruby/trunk/test.rb:9:in `foo'

共通するこれらの行が被っていますね。Java だと共通する行は出力しないように制御するようですので、冗長な表記をやめるように、今後変更されるかもしれません。

結構面白いハックネタだと思うので、Ruby インタプリタをいじってみたい人は、挑戦してみませんか?

(ko1)

フリップフロップ構文が非推奨に

  • The flip-flop syntax is deprecated. [Feature #5400]

Ruby には、フリップフロップと呼ばれる、知る人ぞ知る機能がありました。条件式に 開始条件 .. 終了条件と書くと、開始条件が成立してから終了条件が成立するまでずっと真になるという便利機能です。たぶん awk → Perl 経由で Ruby に入ったと思われます。

["a", "b", "c", "d", "e"].each do |str|
  if (str == "b") .. (str == "d")
    p str #=> "b", "c", "d" が順に表示されるendend

しかし、この機能は 3.0 で削除される方向になりました。2.6 では "warning: flip-flop is deprecated"という警告が出るようになりました。ちょっと残念ですね。

削除が提案された理由 [Feature #5400] が "Nobody knows them. Nobody uses them.(誰も知らない。誰も使ってない。)"という煽りだったので、若干荒れました。「誰がこんなの使うの」と思うような機能でも、誰かは使ってるんですよね。まあそれはともかく matz が消したいと言ったので、消える方向に。

遠藤が非推奨の警告を入れる作業を行ったのですが、思った以上に標準添付ライブラリやビルドスクリプトの中でフリップフロップは使われていて、消して回る対応が大変でした。実際にやってみるとわかるのですが、フリップフロップを使っているコードをフリップフロップ無しにするのは、思った以上にややこしいです。ということで、本当に消えて大丈夫なんでしょうか。

(mame)

Refinement の拡張

  • Refinements take place at block passing. [Feature #14223]
  • Refinements take place at Kernel#public_send. [Feature #15326]
  • Refinements take place at Kernel#respond_to?. [Feature #15327]

Refinement という、Ruby のメソッドを拡張する仕組みがあるのですが、拡張が効く部分が足りない、ということで拡張されることになりました。

Refinement 自体がすごく難しい機能であまり使うべきでは無いと思っているので(笹田個人の感想です)、ここではあんまり紹介しません。ただ、自分が Refinement を使っていて、「あれ、ここで拡張が効くはずなのになんで効かないんだろう?」ということがあれば、それはもしかしたらバグかもしれないので、ご報告頂ければ幸いです。

(ko1)

クラスやメソッドの追加・改善

いろんな変更がありました。

to_hがブロックを受け取るように

  • Array#to_h now accepts a block that maps elements to new key/value pairs. [Feature #15143]

to_hにブロックを渡すことで、キーと値を指定できるようになりました。

# 従来の to_h の使い方

["Foo", "Bar"].map {|x| [x.upcase, x.downcase] }.to_h
  #=> {"FOO"=>"foo", "BAR"=>"bar"}# 新しい用法

["Foo", "Bar"].to_h {|x| [x.upcase, x.downcase] }
  #=> {"FOO"=>"foo", "BAR"=>"bar"}

Array の他に Enumerable や Struct なども同じように拡張されました。

この提案は過去にもあった([Feature #10208] Passing block to Enumerable#to_h)のですが、そのときは matz がリジェクトしています。しかし今回はシュッとアクセプトされました。気が変わったそうです。何度も言ってみるものですね。

(mame)

Enumerable#chain

  • Enumerable#chain returns an enumerator object that iterates over the elements of the receiver and then those of each argument in sequence. [Feature #15144]
  • Enumerator#+ returns an enumerator object that iterates over the elements of the receiver and then those of the other operand. [Feature #15144]
  • Enumerator::Chain: This is a new class to represent a chain of enumerables that works as a single enumerator, generated by such methods as Enumerable#chain and Enumerator#+.

Ruby プログラミングをしていると、イテレータをよく使います。イテレータが複数あって、それらをいっぺんに辿りたいときはどうするといいでしょうか。

例えば、配列 a1, a2, a3があるとします。これらの要素すべてを表示する、という簡単な例を考えてみましょう。

こんな感じでしょうか。イテレータの配列を作っています。多重ループになっちゃうのがイマイチですね。

a1 = %w(1 2)
a2 = %w(3 4)
a3 = %w(5 6)
[a1, a2, a3].each{|ary| ary.each{|e| p e}}

では、配列を全部つなげてみるといいでしょうか。

(a1+a2+a3).each{|e| p e}

ただ、これはイテレータが配列の時にしかうまくいかず、また大きな配列を作ってしまうと性能悪化の懸念が生じます。

Ruby 2.6 からは、Enumerable#chainを使って、イテレータをつなげることができるようになりました。例えば、この例では、次のように書くことができます。

a1.chain(a2, a3).each{|e| p e}

Enumerator#+を使うことで、Enumeratorを同じようにつなげることができます。

(a1.each + a2.each + a3.each).each{|e| p e}

Enumerator#+eachを持つメソッドならなんでも受け取るので、例えばこんなふうに書けます。

(a1.each + a2 + a3).each{|e| p e}

eachを持ったオブジェクトならなんでも与えられるので、a2だけ逆順に表示したい、といったときは、Array#reverse_eachEnumeratorを返すことを利用して、こんなふうに書くことができます。

(a1.each + a2.reverse_each + a3.each).each{|e| p e}

# or
a1.chain(a2.reverse_each, a3).each{|e| p e}

Enumerator::Chainクラスは、この機能を実装するために導入されたので、まぁこのクラスの存在を気にする必要はないでしょう。

要望自体は以前からあったのですが、なんとなくペンディングになっていたのを、最近話題に取り上げたことで導入されることになりました。時々欲しくなりますよね。

最初は、Enumeratorだけを対象に、Enumerator#+だけでいいんじゃないかな、と思っていたんですが、開発者会議で議論するうちに、Enumeable#chainという、みんなが使う配列とかも影響がありそうなメソッドに発展していきました。

Enumerator#+を使って i1 + i2 + ...と沢山足していくと、内部的に深い木構造を作ることになるので、効率の心配がありました。まぁ、何十も重ねる人は居ないと思うのですが、心配なら、Enumerable#chain(i1, i2, ...)や、Enumerator::Chain.new(i1, i2, ...)を利用するといいと思います。まぁ、居ないと思うんだけど。*2

(ko1)

Enumerator::ArithmeticSequence の導入

  • This is a new class to represent a generator of an arithmetic sequence, that is a number sequence defined by a common difference. It can be used for representing what is similar to Python's slice. You can get an instance of this class from Numeric#step and Range#step.
  • Added Range#% instance method. [Feature #14697]
  • Range#step now returns an instance of the Enumerator::ArithmeticSequence class rather than one of the Enumerator class.

Arithmetic Sequence、つまり等差数列を扱うクラスが提案されました。なんじゃこの長い名前は、誰が使うんだ、と思うかも知れませんが、生成は簡単です。

as1 = 3.step(by: 2, to: 10)

また、Range#%を使っても作ることができます(Range#stepの別名として導入されました)。

as2 = (3..10)%2# 3.step(by: 2, to: 10) と同じ

どちらも、to_a[2, 4, 6, 8, 10]を取り出すことができます。

さて、なんでこのような等差数列が必要になるかというと、Python における スライスがあると、色々と便利だそうで(笹田は、どう便利かはよく知りません)、そのため、Ruby ではどのように導入するか、ということが議論になり、最終的に Enumerator::ArithmeticSequenceという形で導入されました。

Python では 3:10:2begin:end:step)のように書くそうです。(3..10)%2は、Ruby で許される表現の中で、短く書けるのでこれでいいか、といった議論を経て導入されました(新規文法の導入も検討しましたが、そこまですることはないか、となりました)。

実は Ruby では、これを役立てるための仕組みは、まだあまり組み込まれていないため、実際に便利に使えるには、いろいろと揃ってきてからかな、と思います。例えば、配列の要素を取り出すといったことはできません(あ、MRI のハックネタですね)。

p (1..20).to_a[(1..)%3]
#=> no implicit conversion of Enumerator::ArithmeticSequence into Integer (TypeError)

例えば Python だと、こんなふうに使えます。

>>> list(range(1, 21))[1::3]
[2, 5, 8, 11, 14, 17, 20]

ちなみに、Enumerator::ArithmeticSequenceには #begin#end#stepメソッドがあるので、自分のライブラリをこれに対応することが可能です。

なお、Range#stepは Enumerator クラスを返していましたが、この変更で Enumerator::ArithmeticSequenceが返るという非互換があります。が、まぁ誰もはまらないよね、多分。

(ko1)

Kernel#yield_self の別名に Kernel#then が導入された

  • Kernel#then is a new alias for Kernel#yield_self. [Feature #14594]

yield_self便利だけど名前がね... という議論に終止符を打つべく、我らがまつもとゆきひろさんが満を持してコミットした別名thenです。まつもとさんの久々のコミットでした。

thenという言葉自体は、Promise などで使われていて、それと被るから良くないんじゃないの、という批判があったんですが、Matz が、まぁいいんじゃないの、ということで導入されました。新たな名前論争の種になるかもしれません。

(ko1)

Proc に関数合成オペレータ Proc#>>Proc#<<が追加

  • Added Proc#<< and Proc#>> for Proc composition. [Feature #6284]

一部の方にとっては待望の、関数合成オペレータが追加されました。

Proc の f1 、f2 に対して f1 >> f2とすると、まず f1 を呼び出し、その返り値を f2 に渡して呼び出す、という Proc を新たに作ります。f1 << f2は逆で、f2 、f1 の順に呼び出します。次の例を見ると違いがわかると思います。

plus2  = -> x { x + 2 }
times3 = -> x { x * 3 }

times3plus2 = plus2 << times3
p times3plus2(3) #=> 3 * 3 + 2 => 11
p times3plus2(4) #=> 4 * 3 + 2 => 14

plus2times3 = times3 << plus2
p plus2times3(3) #=> (3 + 2) * 3 => 15
p plus2times3(5) #=> (5 + 2) * 3 => 21

提案自体は古くからありましたが、なかなか記号が決まらなくて pending になっていました。数学での関数合成の記号は小さい円(たとえばf ∘ g)なのですが、どちらが先に評価されるかわかりにくいことや、Unicode でないと書けないので *で代用せざるを得ないことなどで議論がまとまらないということが続いていました。

今回、Groovy が <<>>を使っている(6.3. Composition)ということが決め手となり、それにならうことになりました。上の例も Groovy のドキュメントのサンプルを翻訳したものです。

(mame)

exception オプションの導入

  • Kernel#Complex, Kernel#Float, Kernel#Integer, and Kernel#Rational take an :exception option to specify the way of error handling. [Feature #12732]
  • Kernel#system takes an :exception option to raise an exception on failure. [Feature #14386]

予想外の入力に対して、例外を起こすか、nilを返すかは、API デザインにとって難しい問題です。例えば、Array#[]は、範囲外アクセスを行うと nilを返します。Array#fetch(index)では、範囲外では例外を返します。Array#[]のほうが圧倒的に短いため、普通は Array#[]を使うと思いますが、その辺(なにをデフォルトに置くか)は言語デザインの妙なのかなと思います。

さて、Kernel#Integer(obj)は、何か objを与えると、良い感じに整数に変換してくれるメソッドです。ただ、変換できない場合、例外を発生します。

p Integer('hello')
#=> `Integer': invalid value for Integer(): "hello" (ArgumentError)

JSON などをパースするとき、整数としてパースできるかな、という検査をするとき、このメソッドが使えそうですが、失敗時にいちいち例外が発生してしまうと、ちょっと面倒です(プログラムを書くのも面倒だし、性能が落ちてしまうのもいや)。そこで、exception: falseというキーワード引数を指定することで、整数への変換に失敗すると、例外ではなく、単に nilを返すようにしました。

Integerだけでなく、Kernel#Complex, Kernel#Float, Kernel#Rationalにも同様についたようですね。

似た話で、system()で実行が失敗したときに例外を発生することができるようになりました。

p system("ruby -e raise") #=> false
p system("ruby -e raise", exception: true)
#=> `system': Command failed with exit 1: ruby -e raise (RuntimeError)

(ko1)

File.read('| ...') が出来なくなった

  • File.read, File.binread, File.write, File.binwrite, File.foreach, and File.readlines do not invoke external commands even if the path starts with the pipe character '|'. [Feature #14245]

File.read('| cmd')のように実行すると、cmdの実行結果を返す、みたいな機能があったんですが、Fileって言ってるのにコマンド実行しちゃうのは罠だろう、ということで、例外になることになりました。

もし必要なら、 IO.read('| cmd')などを使ってください。

(ko1)

String#cryptが非推奨に

  • String#crypt is now deprecated. [Feature #14915]

crypt(3) はなんかもう古くて脆弱なので消しましょう、ということで、2.6 では非推奨となりました。まあ、String クラスのメソッドにするのは現代から見たらやりすぎですよね。

互換レイヤとして string-crypt gemがリリースされています。

require"string-crypt"

とすれば String#cryptが利用可能になります。これを書きながら気づいたんですが、この gem は Linux でビルドできませんでした。PR を投げておいたのでお待ちください。(なお、Ruby 2.6 でも組み込みの String#cryptが消えたわけではないので、今すぐ困ることはないはずです)

(mame)

Timeオブジェクトのタイムゾーンを指定できるように

  • Time.new and Time#getlocal accept a timezone object as well as a UTC offset string. Time#+, Time#-, and Time#succ also preserve the timezone. [Feature #14850]

タイムゾーンを指定した Timeオブジェクトを作る方法が環境変数経由しか無かった(びっくり!)ということで、API が追加されました。

Time.new(2018, 12, 25, 0, 0, 0, tz)

tzとして渡すオブジェクトは、local_to_utcutc_to_localutc_offsetの 3 つのメソッドを実装している必要があるらしいです。

(mame)

Array#unionArray#difference

  • Added Array#union and Array#difference instance methods. [Feature #14097]

それぞれ、Array#|(和集合)と Array#-(差集合)の別名です。

p [1, 2, 3].union([2, 3, 4])      #=> [1, 2, 3, 4]
p [1, 2, 3].difference([2, 3, 4]) #=> [1]

となると Array#&の別名の Array#intersectionもありそうなものですが、こちらは導入されていません。なぜなら要望が来なかったので(貢献チャンスかも?)。

(mame)

Array#selectの別名として Array#filterが追加

  • Array#filter is a new alias for Array#select. [Feature #13784]
  • Array#filter! is a new alias for Array#select!. [Feature #13784]

selectの別名です。

[1, 2, 3, 4, 5].filter {|n| n.odd? } #=> [1, 3, 5]

filterというと、該当するものを残すのか(selectと同じ)、それとも消すのか(rejectと同じ)、曖昧だという声もありましたが、他の言語では残すのが多そうということで、selectと同じということになったようです。

#filter!#select!の別名として追加されています。Array 以外に Enumerable や Hash なども同様に追加されてます。

(mame)

Binding#source_locationの追加

  • Added Binding#source_location. [Feature #14230]

Binding が作られたファイル位置を返すメソッドが追加されました。[Feature #14230]

# test.rb
bndg = binding # ここは 2 行目

p bndg.source_location #=> ["test.rb", 2]

これには中々面倒くさい背景があります。

現在、eval内で例外が発生すると、binding引数由来のファイル名や行番号を表示してしまいます。

bndg = binding # ここは 1 行目eval(<<END, bndg)
  def foo  # bndg 基準では 1 行目    raise  # bndg 基準では 2 行目  endEND

foo #=> Traceback (most recent call last):#       1: from test.rb:9:in `<main>'#   test.rb:2:in `foo': unhandled exception

例外のスタックトレースを見てください。2 行目で例外が発生したと言われています。しかし、このファイルの 2 行目を見ると、空行です。びっくり。この問題を避けるために、evalbinding引数の生成元のファイル名や行番号を利用しないようにしよう、ということになりました。[Bug #4352]

しかしこの変更を実際に試したところ、eval("[__FILE__, __LINE__]", bndg)として Binding の生成元のファイル名や行番号を取り出すというイディオムが pry など一部のプログラムで利用されていることが発覚しました。このイディオムはあまり推奨されるものでもないので、この情報をより明示的に取り出す手段の Binding#source_locationを導入し、世の中のプログラムではこちらを使うように変えてもらう期間を置くことにしました。

なお、Ruby 2.6 でこのイディオムを警告ありモードで実行すると、警告が出るようになっています。

$ ./ruby -w
eval("[__FILE__, __LINE__]", binding)
-:1: warning: __FILE__ in eval may not return location in binding; use Binding#source_location instead
-:1: warning: __LINE__ in eval may not return location in binding; use Binding#source_location instead

(mame)

Dir#each_child

  • Added Dir#each_child and Dir#children instance methods. [Feature #13969]

Ruby 2.5 で導入された、ディレクトリの中を探る(ただし、., ..は列挙しない) Dir.childrenDir.each_childというクラスメソッドはあるけど、Dir.openで生成する Dirインスタンスで使える Dir#each_childDir#childrenはないから入れましょう、という提案で、「そうだね」とすんなり入りました。

こういう、「そうだね」という提案ばかりだと楽なんですが。

(ko1)

Exception#full_message に highlight, order キーワード引数がついた

  • Exception#full_message takes :highlight and :order options. [Bug #14324]

まぁ、書いてあるとおりなのですが、引数がつきました。そもそも、Exception#full_messageとは、って感じですが、ログとかに出力するため、文字列でバックトレース表記(+エラー原因)を出力するために Ruby 2.5 で導入されたものです。これに、いろいろカスタマイズするオプションがついた感じです。

(ko1)

Hash#merge#merge!が任意個の引数を受け取るようになった

  • Hash#merge, Hash#merge!, and Hash#update now accept multiple arguments. [Feature #15111]

Hash#mergeが任意個の引数を受け取るようになりました。h1.merge(h2).merge(h3)と書かなくて良くなります。

h1 = { 1 => 1 }
h2 = { 2 => 2 }
h3 = { 3 => 3 }
h1.merge(h2, h3) #=> { 1=>1, 2=>2, 3=>3 }

クックパッドが開催した、Ruby をハックしてみようというイベント Cookpad Ruby Hack Challenge #5の参加者の方が提案して、作成したパッチが取り込まれました。めでたいですね。Ruby Hack Challenge は、今後もちょくちょく開催すると思うので、貢献してみたい人はぜひご参加ください。

(mame)

openのモードに修飾子 "x"が追加

  • Added new mode character 'x' to open files for exclusive access. [Feature #11258]

Kernel#openなどのモードに "x"という修飾子が追加されました。"w"と組み合わせて使うと、ファイルをうっかり上書きしなくて済むようになります。

open("file", "w")  # file を作って開く(すでに file があったら上書き)
open("file", "wx") # file を作って開く(すでに file があったら例外)

(mame)

KerError 発生原因の receiverkeyを Ruby レベルで指定可能に

  • KeyError.new accepts :receiver and :key options to set receiver and key in Ruby code. [Feature #14313]

Ruby 2.5 から、KeyError が発生したときのレシーバとキーが KeyError インスタンスから参照できるようになっています。

begin
  { 1 => 2 }.fetch(:foo)
rescueKeyError => e
  p e.key      #=> :foo
  p e.receiver #=> {1=>2}end

このように、組み込みの Hash#fetchは内部的にこれらの情報を設定していたのですが、Ruby レベルで投げる例外にこの情報を設定することができませんでした。2.6 では、次のようにできるようになりました。

raiseKeyError.new(receiver: recv, key: key)

NameErrorNoMethodErrorでも、同様に receiverが指定できるようになりました。

(mame)

Module#method_defined? とかが inherited オプショナル引数を受けるようになった

  • Module#method_defined?, Module#private_method_defined?, and Module#protected_method_defined? now accept the second parameter as optional. If it is +true+ (the default value), it checks ancestor modules/classes, or checks only the class itself. [Feature #14944]

細かい話です。

Module#instance_methodsは、inherited オプショナル引数を受け取ることができます。デフォルトは trueですが、falseにすると、クラスの継承木を辿らないで、そのクラス・モジュールだけ調査します。

p String.instance_methods(true).size   #=> 183String.ancestors.each{|c|
  p [c, c.instance_methods(false).size]
}
#=># [String, 128]# [Comparable, 7]# [Object, 0]# [Kernel, 50]# [BasicObject, 8]# 合計 193 ... あれ、あわないよ?# というのは、10個ほど、重複するメソッドがあるからです(多分)。# ちなみに、String の 128 個というのはキリが良いですね。

このように、いくつかのメソッドには、inherited オプショナル引数を取りますが、似たようなメソッドで、それを取らないものがあったので、似たようなもの全部に入れてしまえばいいのでは、という提案があって、イイネイイネと入りました。

(ko1)

Object#=~が非推奨に

  • Object#=~ is deprecated. [Feature #15231]
  • NilClass#=~ is added for compatibility. [Feature #15231]

Object#=~が非推奨になりました。と言うとびっくりするかもしれませんが、String#=~Regexp#=~は残るので、gets =~ /regexp/みたいな普通のマッチングは引き続き可能なので、ほとんど影響はないはずです。

Object#=~は、引数にかかわらず常に nil を返すという、あまり用途のわからないメソッドでした(導入経緯も調べたのですが、古すぎてよくわかりませんでした)。

p(1 =~ 1) #=> nil

用途がわからないだけでなく、運が悪いとバグを隠すことがあった(次のような例)ので、非推奨ということになりました。

s = ["foo"] # 文字列のつもりだったのに、うっかり文字列の配列にしてしまった
if s =~ /foo/
  puts "マッチしない……なぜ……"
end

なお、nil はマッチング対象にしたいことがある(たとえば ENV['non_existing'] =~ /regexp/)ということで、NilClass#=~は新たに導入されました。

Object#=~と対になる Object#!~は、非推奨になっていません。!~=~を呼び出してその返り値の not をとって返すので、自分のクラスに =~だけを定義するというプログラムが存在します。そういうプログラムは完全に無実であること、また !~が残っていても =~がなければ結局 NoMethodError になるだけで実害はないことから、そのまま残されることになりました。

(mame)

Random.bytesが導入

  • Added Random.bytes. [Feature #4938]

ちょっとした便利メソッドです。

p Random.bytes(3) #=> "\xCF\xB5\xF4"

提案チケットは 2011 年に登録されていて、非常に長い間放置されていました。遠藤が古いチケットを整理しているときに見つけたので、開発者会議の議題にあげて無事採択されました。つまり遠藤がえらい。

(mame)

String#splitがブロックを受け取るように

  • String#split yields each substring to the block if given. [Feature #4780]

String#splitにブロックを渡すと、区切られた各断片が yield されてくるようになりました。

"foo/bar/baz".split("/") {|s| p s } #=> "foo", "bar", "baz"

これも 2011 年からほったらかしだったチケットを掘り起こした成果です。えらい。

(mame)

Unicode のバージョンが 10.0.0 から 11.0.0 に

  • Update Unicode version from 10.0.0 to 11.0.0. [Feature #14802] This includes a rewrite of the grapheme cluster (/\X/) algorithm and special-casing for Georgian MTAVRULI on String#downcase.
  • Update Emoji version from 5.0 to 11.0.0 [Feature #14802]

Unicode のバージョンが上がったようです。あと Emoji も。正直よくわからないのですが、たとえばジョージア語の大文字が導入されたそうです。

p "ლალი".upcase #=> "ლალი" in 2.5#=> "ᲚᲐᲚᲘ" in 2.6

(mame)

Ruby の抽象構文木を取り出す実験 API が導入

  • RubyVM::AbstractSyntaxTree class is added.
  • RubyVM::AbstractSyntaxTree.parse parses a given string and returns AST nodes. [experimental]
  • RubyVM::AbstractSyntaxTree.parse_file parses a given file and returns AST nodes. [experimental]

なぜか結構話題の、抽象構文木を取り出す API が実験的に導入されました。

ast = RubyVM::AbstractSyntaxTree.parse("1 + 2 * 3")

pp ast #=># (SCOPE@1:0-1:9#  tbl: []#  args: nil#  body:#    (OPCALL@1:0-1:9 (LIT@1:0-1:1 1) :+#       (ARRAY@1:4-1:9#          (OPCALL@1:4-1:9 (LIT@1:4-1:5 2) :*#             (ARRAY@1:8-1:9 (LIT@1:8-1:9 3) nil)) nil)))

RubyVM::AbstractSyntaxTree#childrenを使うと、サブツリーを取り出せます。

pp ast.children[2] #=># (OPCALL@1:0-1:9 (LIT@1:0-1:1 1) :+#    (ARRAY@1:4-1:9#       (OPCALL@1:4-1:9 (LIT@1:4-1:5 2) :* (ARRAY@1:8-1:9 (LIT@1:8-1:9 3) nil))#       nil))

RubyVM::AbstractSyntaxTree.parse_fileなんてのもあります。

さて、この API がどういうときに便利かと言うと、実際のところ、そんなに便利ではないと思います。というのも、この抽象構文木は、MRI の評価器の実装に結びついてて読み解くのは難しいし、もちろんドキュメントは無いし、今後の Ruby のバージョンアップで説明なく非互換な変更が入っていくし、微妙に最適化っぽい変換がされててソースとの対応が取りにくいし、という感じで、一般ユーザがカジュアルに使うものではないです(RubyVMという名前空間にあるものは、そういうプロユースのものです)。想定用途は、Ruby 本体のデバッグやテスト、Ruby のバージョンアップに食いついていく覚悟のあるプログラム(たとえば静的解析器とか)などです。普通に Ruby の抽象構文木で遊びたい人は、たぶん parser gemを使うのがよいと思います。

(mame)

RubyVM::AbstractSyntaxTree.of

  • RubyVM::AbstractSyntaxTree.of returns AST nodes of the given proc or method. [experimental]

メソッドオブジェクトや Proc オブジェクトから抽象構文木オブジェクトを取り出す API です。

deff1 + 2 + 3end

pp RubyVM::AbstractSyntaxTree.of(method(:f))
#=> (SCOPE@2:0-4:3#    tbl: []#    args:#      (ARGS@2:5-2:5#       pre_num: 0#       pre_init: nil#       opt: nil#       first_post: nil#       post_num: 0#       post_init: nil#       rest: nil#       kw: nil#       kwrest: nil#       block: nil)#    body:#      (OPCALL@3:2-3:11#         (OPCALL@3:2-3:7 (LIT@3:2-3:3 1) :+ (ARRAY@3:6-3:7 (LIT@3:6-3:7 2) nil))#         :+ (ARRAY@3:10-3:11 (LIT@3:10-3:11 3) nil)))

これはもう完全な闇 API です。というのも Ruby インタプリタは、読み込んだソースコードをバイトコードにコンパイルし終えた後は、ソースコード文字列も抽象構文木も捨ててしまうので、本来この抽象構文木は取り出しようがないはずのものです。どのようにしているかと言うと、

  • パース時に、すべてのノードに番号を振っておく
  • 抽象構文木のコンパイル時に、元ソースファイル名やルートノードのノード番号をバイトコードに書き加えておく
  • RubyVM::AbstractSyntaxTree.ofが呼ばれたら、メソッドオブジェクトなどのバイトコードが持つ元ソースファイル名とノード番号を引っ張り出す
  • ソースファイルをもう一度開いて、読み込み、パースし直して、対応するノード番号のサブツリーを特定し、抽象構文木オブジェクトとして返す

というハックになっています。なので、ソースファイルの中身が変わっていたり、ソースコードを標準入力などで流し込んだ場合は、RubyVM::AbstractSyntaxTree.ofは使えません。闇ですよね。覚悟なしに使わないでください。

(mame)

RubyVM.resolve_feature_path

  • RubyVM.resolve_feature_path identifies the file that will be loaded by require(feature). [experimental] [Feature #15230]

requireは、(1) 読み込むファイルのパスを特定する、(2) そのファイルを読み込んで実行する、の 2 段階を行いますが、RubyVM.resolve_feature_pathは (1) だけをやる API です。

静的解析を作るときにほしいなーという気分だったのでとりあえず作ってみました。RubyVMの名前空間にある通り、一般ユーザが使うことは想定されていません。もし何か用途があったら、ちゃんとした API として導入することも検討できると思うので、教えてください。

(mame)

TracePoint の拡張

  • script_compiled event is supported. [Feature #15287]
  • TracePoint#parameters [Feature #14694]
  • TracePoint#instruction_sequence [Feature #15287]
  • TracePoint#eval_script [Feature #15287]
  • TracePoint#enable accepts new keywords "target:" and "target_line:". [Feature #15289]

いろいろ便利だったり悪用できたりする TracePointですが、いくつか拡張がありました。そもそも、使う人が居なさそうなのに、さらに複雑な拡張が入ったので、普通の人は気にしないでいいと思います。普通じゃない人には待望の機能です。

まず、TracePoint#enableで、target:キーワード引数が導入され、フックを有効にする場所を指定することができるようになりました。

TracePoint(と、その前身となった set_trace_func)は、フックを登録すると、すべての場所でフックが呼ばれるようになります。例えば、あるファイルのある行を実行したときだけ、フックを実行したい、というケースを考えます。つまりブレイクポイントですね。これまでは、フックの中で場所(ファイル名と行番号)を確認する、ということを行っていました。ちょっと考えるだけでも非効率です。TracePoint#enable(target:)を指定することで、本当にフックが欲しいところだけでチェックできるようになりました。

次に、新イベントcompiled_scriptに対応しました。スクリプトをバイトコード(MRI用語では ISeq: InstructionSequence、命令列)にコンパイルしたタイミングで呼ばれます(ついでに便利メソッドがいくつか増えています)。

この新イベントと TracePoint#enable(target:)を組み合わせることで、(例えば)ブレイクポイントが便利に実装できることになります。

なお、TracePointの拡張については、後日改めてまとめます。

(ko1)

ライブラリの変更

あまり詳しくないので、わかるところだけピックアップしてご紹介します。

Bundler の同梱

  • Add Bundler to Standard Library. [Feature #12733]

Bundler がついに、Ruby と一緒にインストールされるようになりました。もう、gem i bundlerとしなくてもよくなります。

今後 rubygems と少しずつ統合が進んでいくそうです。

(ko1)

oneshot coverage の導入

  • A oneshot_lines mode is added. [Feature #15022] This mode checks "whether each line was executed at least once or not", instead of "how many times each line was executed". A hook for each line is fired at most once, and after it is fired the hook flag is removed, i.e., it runs with zero overhead.

コードカバレッジ測定機能に、oneshot coverage という新モードを追加しました。 これは、各行の実行回数ではなく、各行が1回でも実行されたかどうかを記録するものです。

oneshot coverageについては、明日詳説する記事を書く予定です。

(mame)

FileUtils#cp_lr

  • FileUtils#cp_lr. [Feature #4189]

ディレクトリの中の全ファイルを再帰的にハードリンクしていくメソッドです。cp -rと似ていますが、コピーの代わりにハードリンクをします。

2010 年に提案されていて放置されていたチケット [Feature #4189] をチケット整理で掘り起こした成果です。えらい。

(mame)

Matrix の拡張

Matrix が交代行列かどうかを判定する Matrix#antisymmetric?が追加

  • Matrix#antisymmetric?, Matrix#skew_symmetric?

交代行列とは、転置して符号反転させたら元の行列と一致する行列のことです。日本語で言うとややこしいですが、行列 mm.t == -mを満たすなら交代行列です。

これを判定するメソッド antisymmetric?が追加されました。skew_symmetric?という別名も入っています。

以下、どうでもよい話。このメソッドにはなかなかややこしい経緯がありました。このメソッドはそもそも、反対称関係(antisymmetric relation)の行列表現の判定として提案され、取り込まれました。しかし、反対称関係の行列表現とは別に、反対称行列(antisymmetric matrix、日本語では交代行列)という概念があります。Matrix#antisymmetric?という名前なので、「反対称行列の判定がなにかおかしい」というバグ報告が来て、現在の挙動に変わりました。しかし不幸なことに、この行列は数学の分野ではあまり反対称行列とは言わず、歪対称行列(skew-symmetric matrix)と言います(ただし、物理学の世界では antisymmetric matrix が普通らしい)。ということで Matrix#skew_symmetric?の別名も追加されました。なお、日本語では交代行列ということが多いですが、英語で alternating matrix とはどの分野でもあまり言わないようです(ゼロでもないみたいですが)。名前がこんがらがると不幸が起きるという例でした。

(mame)

Matrix の破壊的更新が可能に

  • Matrix#map!, Matrix#collect! [Feature #14151]
  • Matrix#[]=
  • Vector#map!, Vector#collect!
  • Vector#[]=

Matrix#[]=で要素の破壊的更新ができるようになってしまいました。[Feature #14151]

Matrix#map!#collect!Vector#map!#collect!Vector#[]=なども入っています。

個人的に、Matrixみたいな数の一種が破壊的に更新可能なのはとても違和感があるのですが。Matlab とかの方から来た人は、更新したくなるようです。

(mame)

性能向上

最後に、みんな大好き性能向上の話です。本章の執筆は全て笹田 (ko1) が担当します。

MJIT

  • Introduce an initial implementation of JIT (Just-in-time) compiler. [Feature #14235] [experimental]

MJIT という JIT コンパイラが導入されました。Ruby 2.6 の目玉ですね。

私が何か説明するよりも、国分さんの発表資料( https://speakerdeck.com/k0kubun)などを見て貰うのが正確で良いと思います。他の人も沢山解説記事を書いているようですし。

現状では、バイトコード実行することによるオーバヘッドを、MJIT によって削減する、といった程度の効果なので、すごく劇的に性能向上、というレベルではありません。いろいろな理由で、Rails での性能向上も、まだ難しいでしょう(他人の仕事には厳しい)。今後、どこまで速くなるか楽しみですね。

Proc まわりの性能向上

ブロックパラメータで渡された Proc#callで呼んでも速い

  • Speedup block.call where block is passed block parameter. [Feature #14330] Ruby 2.5 improves block passing performance. [Feature #14045] Additionally, Ruby 2.6 improves the performance of passed block calling.

Proc まわりでは、def foo(&b); ... baz(&b)のように、渡されたブロックを、別のメソッドに単に渡すだけなら、Procオブジェクトをわざわざ作らないので速くなる、というハックを Ruby 2.5 で導入しました(Ruby 2.5 の改善を自慢したい)。Ruby 2.6 では、その続きで、もう少しいろいろしています。

大抵、&bと渡ってきたブロックは、yieldじゃなくて b.callって呼びたくなると思うんですが、Ruby 2.5 では、結局ここで(bを参照した時点で)Procオブジェクトを生成してしまので、遅いという問題がありました。

Ruby 2.6 では、ちょっと工夫して(説明が面倒なので詳細割愛)、Procを生成しなくても済むようになりました。チケットにはどれくらい高速になったか書いていないのですが、下記のベンチマークで試してみると、

Benchmark.driver{|x|
  x.executable name: 'ruby 2.5', command: %w'/home/ko1/ruby/install/ruby_2_5/bin/ruby'
  x.executable name: 'ruby 2.6', command: %w'/home/ko1/ruby/install/trunk/bin/ruby'

  x.prelude %q{    def foo(&b); b.call; end    def bar(); yield; end}
  x.report 'b.call', %q{    foo{}}
  x.report 'yield', %q{    bar{}}
}
Warming up --------------------------------------
              b.call     2.719M i/s -      2.815M times in 1.035435s (367.78ns/i)
               yield    13.591M i/s -     13.646M times in 1.004094s (73.58ns/i, 272clocks/i)
Calculating -------------------------------------
                       ruby 2.5    ruby 2.6
              b.call     3.451M     12.293M i/s -      8.157M times in 2.363882s 0.663573s
               yield    15.725M     19.970M i/s -     40.772M times in 2.592803s 2.041643s

Comparison:
                           b.call
            ruby 2.6:  12292769.5 i/s
            ruby 2.5:   3450742.9 i/s - 3.56x  slower

                            yield
            ruby 2.6:  19970380.1 i/s
            ruby 2.5:  15725212.8 i/s - 1.27x  slower

こんな結果になり、この環境だと Ruby 2.5 と比べて 3.56 倍くらい速いようです。 ただ、やっぱりまだ yieldよりは遅いですね。もうちょと頑張って欲しい。しかし、なんで Ruby 2.6 で yieldこんなに速いんだろう。

$SAFEを一部諦めて Proc#callを高速化

  • Speedup Proc#call because we don't need to care about $SAFE any more. [Feature #14318] With +lc_fizzbuzz+ benchmark which uses Proc#call many times we can measure x1.4 improvements. [Bug #10212]

$SAFEという古の機能があるのですが(何の機能かはググってね)、この仕様の一部をこっそり削って、Proc#callが高速になりました。たくさん Proc#callを実行するベンチマークでは、1.4 倍の速度向上が得られたようです。やったね。

具体的に何をしたかというと、Proc#callを呼び出した時の $SAFEを保存しておいて、Proc#callが終了したとき、必ず保存しておいた状態に戻す、という仕様があったんですが、それを撤廃しました。戻すだけなら簡単じゃん、と思うかもしれませんが、「必ず戻す」というのがくせ者で、例外時などでも戻せるように、いろいろ準備が要るのでした。

ただ、そもそも $SAFEを誰も利用しないので、この値を戻す必要がないことが大半で、無駄な努力でした。と言う事情を開発者会議で説明すると、「じゃあやめよう」とすんなりやめることになりました。そのため、元に戻すための諸々のコードが不要になり、軽量になりました。

VM生成系の一新

  • VM generator script renewal; makes the generated VM more optimized. [GH-1779]

VM 生成系と言われてもよくわからないと思いますので、ちょっと解説します。

現在の MRI の仮想マシンは、直接ソースコードを全部書くわけではなく、insns.defというファイルに、命令定義が記述してあります。このファイルを、ある Ruby スクリプトを通すことで、C のソースコードに変換し、それを使って MRI バイナリが完成します。つまり、Ruby のビルドには Ruby が必要です。この「ある Ruby スクリプト」が VM 生成系です。

で、この VM 生成系なんですが、私が10年以上も前に、1ファイルに汚く書き散らしていたモノを Ruby 2.5 までは使っていました。それを今回卜部さんがモダンな感じにファイルを分割したりして整理してくれました。これで、さらに insns.def ファイルに情報を入れやすくなりました。

ということで、すでに最適化に必要な情報を入れたりして VM の実行がいくらか高速化されています。何もしなくても、Ruby 2.6 にバージョンアップするだけで数%速くなる、かもしれません。

スレッドキャッシュの有効化

  • Thread cache enabled for pthreads platforms (for Thread.new and Thread.start). [Feature #14757]

スレッドのキャッシュが有効化されました。ってだけだとわかんないですよね。

Ruby(MRI)のスレッドはネイティブスレッドと1対1対応なので、Thread.new{...}でスレッドを作成すると、OS などが提供するネイティブスレッドの生成が必要になります。例えば、POSIX Thread が利用できる環境では、Ruby は POSIX Thread を pthread_create()関数を用いて作るのですが、これが重い(ことが多い。実装方法によります)。例えば、Linux では重い。

そこで、このパッチでは、終了して使わなくなった POSIX Thread は少しの間キャッシュしておいて、また Thread.new{...}でスレッドを生成したら、そのキャッシュされたものを使う、というものになります。ベンチマークによっては 70 倍程度速くなったそうです。スレッドを作っては捨て、と繰り返すような、ちょっと特殊かも知れない処理ですね。

なお、「少しの間」というのは、5秒間のようです。

3rd party library が TLS (Thread Local Storage) を用いて、初期値に依存していたりすると、もしかしたらまずいことが起こるかも知れません(再利用されたときは、初期値は前のスレッドの値が残っているため)。もし、そういう例を知っていたら教えてください。

タイマースレッドを不用に

  • timer thread is eliminated for platforms with POSIX timers [Misc #14937]

スレッドを扱うために、インタプリタ内部で、タイマースレッドというものを利用していました。タイマースレッドのために、Ruby プロセスを起動すると、かならず(メイン処理用のスレッドとあわせて)2 つネイティブスレッドを作るようになっていました。

Ruby 2.6 では、POSIX timer API が使えるなら、それを用いることで、タイマースレッドを生成しなくても良くなりました。起動時間の削減と、メモリ等のリソース消費削減に、少し効果ありそうです。

しかし、昔タイマースレッドを導入したのは私(ko1)なんですが、今回の Eric Wong さんによるこの変更、難しすぎて理解できてないんですよね...。

Fiber の実装向上

  • Native implementations (arm32, arm64, ppc64le, win32, win64, x86, amd64) of coroutines to improve performance of Fiber significantly. [Feature #14739]

NEWS エントリ的には「コルーチンをネイティブで実装した」ってありますが、コルーチンを実装するための API を CPU ごとに書いたって話になります。だいたいアセンブラで書いてあります。

技術的には、Fiber の実装には(POSIXの場合)swapcontextといった(現在では非推奨の)API を使っているのですが、こいつらが Fiber の実装にとって、若干無駄な処理をしていました。多分、一番無駄だったのは signal 関連の処理です。今回は、そういう無駄な処理を除いたコンテキスト切り替えの API を独自に作った、というものです。

Fiber 切り替えを沢山行うマイクロベンチマークでは数倍、環境によっては20倍くらい速くなったそうです。また、聞くところによると、Fiber を利用するウェブアプリで数%の性能向上があったとか。

TransientHeap による効率的なメモリ確保

  • Transient Heap (theap) is supported. [Bug #14858] [Feature #14989] theap is managed heap for short-living memory objects. For example, making small and short-living Hash object is x2 faster. With rdoc benchmark, we measured 6-7% performance improvement.

Transient Heap (theap) という新しいメモリ管理のための仕組みを導入して、短寿命のオブジェクトの生成が2倍くらい速くなりました。ただし、対応しているのは、限られたオブジェクトだけで、一番効きそうな Stringについては未対応です。

これについては、後日改めてまとめます。

まとめ

Ruby 2.6 の NEWS ファイルの内容を駆け足でご紹介しました。ご紹介したとおり、変更いっぱいありますが、まぁ普段使ってる分にはあまり変らないと思うので、とりあえずバージョンアップしてみてはいかがでしょうか。

なお、本稿をまとめるにあたり、Ruby コミッタ各位にいろいろ「この説明で良い?」とか、「この変更ってなんでやったの?」とか、「なんでこんな仕様にしたの?」といったことを聞いて回りました。快く答えて頂きました各位に御礼申し上げます。

で、その調査の過程で、いくつかのバグを見つけたので、本稿は Ruby 2.6 のリリースに貢献しています。褒めて欲しい。

では、良い Ruby 2.6 ライフをお送りください。メリークリスマス。

(ko1)

*1:ko1: Matz が最近見つけたと言っても、私が気づいた時にはそういう仕様だったので、そういうもんだと思っていたよ...(Ruby 2.1 当時)。

*2:mame: 純粋関数型データ構造っていう、そんなことばかり考えてる本があります。

Ruby 2.6 新機能:本番環境での利用を目指したコードカバレッジ計測機能

$
0
0

技術部の遠藤(mame)です。1 ヶ月くらい風邪が直らず、苦しみながらこれを書いています。

昨日は Ruby 2.6 の NEWS を裏話付きで解説する記事を書きました(プロと読み解く Ruby 2.6 NEWS ファイル)。今日と明日は、その中でクックパッドのフルタイムRubyコミッタが主に担当したところを少し詳しく紹介します。

今日は、遠藤が作った "oneshot coverage"と言う 2.6 の新機能を紹介します。

背景:Ruby では不要コードの発見・削除が難しい

クックパッドのサービスの多くは、cookpad_all という 1 リポジトリからなる、巨大な Rails アプリケーションとして実現されていました。しかし、このやり方ではメンテナンスが限界になってきたので、「お台場プロジェクト」という大整理プロジェクトが行われてきました。この辺の詳細は次の 2 つの記事が詳しいです。

お台場プロジェクトの活動のひとつに、「不要になったコードを削除する」というものがあります。

クックパッドに入るまで考えたことがなかったのですが、この「不要コードを発見・削除する」が、なかなかむずかしいのでした。他人の書いたコードが不要になったかどうかの判定はむずかしいですし、不要と思って消したら予想外のところで使われていたということもあります。特に Ruby は、インタプリタ言語なのでコンパイルによるチェックがなく*1、その上リフレクションが多用される文化なので、検証がとてもむずかしいです。

そこでクックパッドでは現在、Ruby 本体にパッチを入れて、各コード断片が初めて実行されたときに記録をつけながら本番運用をしています。これにより、長期間運用していても全く使われていないコード断片を効率的に発見できます。また、実際に使われていないコードなので、わりと安心して削除できます。この詳細は次の記事をご覧ください。

これを同じようなことを、Ruby にパッチをあてずに実現するのが、oneshot coverage です。

コードカバレッジとは

コードカバレッジを知っている人はこの節をスキップしてください。

コードカバレッジとは、どのコード断片が実行されたかを記録したものです。

-: # test.rb1: deffoo(n)
2:   if n <= 102:     p "n < 10"
-:   else0:     p "n >= 10"# テストされていない
-:   end
-: end
-:
1: foo(1)
1: foo(2)

左端の数字がコードカバレッジです。各行が何回実行されたかを表しています。空行など意味のない行は -になっています。0 になっているところは、1 度も実行されなかったことを意味しています。通常、コードカバレッジはテスト時に使われ、テスト不足のコード(端的に言うと実行回数が 0 の行)を探すために使われます。

Ruby では、coverage ライブラリを使うことでコードカバレッジを計測できます。

require"coverage"# コードカバレッジ測定開始Coverage.start(lines: true)

# 測定対象のプログラムを読み込むload"test.rb"# 結果の取得
p Coverage.result
#=> {"test.rb"=>{:lines=>[nil, 1, 2, 2, nil, 0, nil, nil, nil, 1, 1]}}

配列の数字が各行の実行回数に対応しています。

Ruby のコードカバレッジ測定について詳しく知りたい方は RubyKaigi 2017 の遠藤の発表資料をご覧ください。

このコードカバレッジ測定を本番環境で使えば、いつまでたっても実行されない行を発見することができます。しかし、通常のコードカバレッジ測定では、各行を実行するたびにカウントアップのフックを実行することになり、オーバーヘッドが問題になります。また、Covearge.resultは、全ソースファイル行数の長さの配列を作るので、こちらもオーバーヘッドが気になるところです。

この問題を解決するのが oneshot coverage です。

oneshot coverage とは

oneshot coverage とは、各行の実行回数ではなく、各行が 1 回でも実行されたかどうかを計測するコードカバレッジです。コードカバレッジ測定ツールは伝統的に、行ごとの実行回数を数えるものが多いですが、実際の用途としては、未テストの行(実行回数が 0 の行)を探すというのがふつうです。なので、実行回数が取れなくなることにデメリットはほとんどないと思います。

oneshot coverage の計測モードでは、各行について最初の 1 回だけカウントアップのフックを実行します。1 度実行されたら、その行のフックのフラグを消し去るので、あとはカバレッジ計測のない状態と同じになります。*2

以下、具体的な使い方を説明していきます。

1. oneshot モードにする方法

oneshot coverage を有効にするには、カバレッジ測定開始メソッドを Coverage.start(oneshot_lines: true)というように呼び出します。

require"coverage"# カバレッジ測定開始 (oneshot_lines モードにする)Coverage.start(oneshot_lines: true)

# 測定対象のプログラムを読み込むload"test.rb"#  1: # test.rb#  2: def foo(n)#  3:   if n <= 10#  4:     p "n < 10"#  5:   else#  6:     p "n >= 10"#  7:   end#  8: end#  9:# 10: foo(1)# 11: foo(2)# 結果の取得
p Coverage.result
{"test.rb"=>{:oneshot_lines=>[2, 10, 3, 4, 11]}}

Coverage.resultの返り値が「実行された行番号」の列に変わりました。

2. インクリメンタルに計測する方法

oneshot coverage は運用環境で使うことを想定しているので、たとえば 100リクエストごとや 10 分ごとなど、定期的に Coverage.resultを呼んで測定結果を記録していきたくなると思われます。しかし Coverage.resultを無引数で呼ぶと、カバレッジの測定を停止してしまいます*3

現在までに実行した行番号は知りたいが、その後もカバレッジ測定は継続して欲しい、というときのために、Coverage.resultstopというキーワード引数を追加しました。次のように使えます。

require"coverage"# カバレッジ測定開始 (oneshot_lines モードにする)Coverage.start(oneshot_lines: true)

# 測定対象のプログラムを読み込むload"test.rb"# 結果の取得
p Coverage.result(stop: false)
{"test.rb"=>{:oneshot_lines=>[2, 10, 3, 4, 11]}}

# 実行されていなかった test.rb の 6 行目を実行する
foo(100)

# 新たな結果の取得:6 行目が追加された
p Coverage.result(stop: false)
{"test.rb"=>{:oneshot_lines=>[2, 10, 3, 4, 11, 6]}}

1 回目の Coverage.resultの呼び出し結果と 2 回目の結果を比べると、6 行目が追加で実行されたことがわかります。

また、resetキーワードを使うと、1 度見た行をクリアできます。

require"coverage"# カバレッジ測定開始 (oneshot_lines モードにする)Coverage.start(oneshot_lines: true)

# 測定対象のプログラムを読み込むload"test.rb"# 結果の取得
p Coverage.result(reset: true, stop: false)
{"test.rb"=>{:oneshot_lines=>[2, 10, 3, 4, 11]}}

# 実行されていなかった test.rb の 6 行目を実行する
foo(100)

# 新たな結果の取得
p Coverage.result(reset: true, stop: false)
{"test.rb"=>{:oneshot_lines=>[6]}}

# もう未到達の行は存在しない
foo(0)
foo(100)

# 新たな結果の取得(新たに実行された行はない)
p Coverage.result(reset: true, stop: false)
{"test.rb"=>{:oneshot_lines=>[]}}

このように、oneshot coverage では Coverage.result(reset: true, stop: false)を使うのが基本です。

oneshot coverage の結果は「実行された行番号の列」なので、Coverage.resultを呼ぶたびに全ソースファイル行数分の配列ができるのを避けることができます。

3. 実行されなかった行を調べる

「実行された行番号」の列が取れるようになりましたが、実際に興味があるのは「実行されなかった行番号」です。ただし、空行とかコメント行とかのように、実行という概念がない行は無視する必要があります。

そのために、Coverage.line_stubという補助関数を用意しました。この関数を使うと、行カバレッジの配列のスタブを作れます。

require"coverage"# 行カバレッジのスタブ配列を作る
ary = Coverage.line_stub("test.rb")
p ary #=> [nil, 0, 0, 0, nil, 0, nil, nil, nil, 0, 0]

nilになっているのは空行やコメント行など、0になっているのは行カバレッジの測定対象(実行という概念がある行)です。

「実行された行番号」番目を 1 にすることで、見慣れた行カバレッジの形式に変換できます。

require"coverage"# カバレッジ測定開始 (oneshot_lines モードにする)Coverage.start(oneshot_lines: true)

# 測定対象のプログラムを読み込むload"test.rb"# 行カバレッジのスタブ配列を作る
ary = Coverage.line_stub("test.rb")

# 実行された行番号の要素を 1 にしていくCoverage.result["test.rb"][:oneshot_lines].each do |i|
  ary[i - 1] = 1end# test.rb の行カバレッジ
p ary #=> [nil, 1, 1, 1, nil, 0, nil, nil, nil, 1, 1]

このように変換すれば、既存のカバレッジ可視化ツールに渡したり、自分で可視化したりがやりやすくなると思います。

評価実験

次の 3 つの条件で、プログラムの実行にかかる時間を測定してみました。

  • (1) コードカバレッジ測定なし
  • (2) 従来の行コードカバレッジ測定(Coverage.start(lines: true)
  • (3) oneshot coverage モード(Coverage.start(oneshot_lines: true)

マイクロベンチマーク

次のプログラムは、コードカバレッジの計測オーバーヘッドが最大になりそうな人工的な例です。

# bench.rbdefbench
  x = 1
  x = 2
  x = 3
  x = 4
  x = 5
  x = 6
  x = 7
  x = 8
  x = 9
  x = 10end10000000.times { bench }

このプログラムの実行時間を測定した結果はこちら。それぞれ 3 回測定した平均です。

条件 時間 (秒)
(1) カバレッジ測定なし 0.972
(2) 従来の行カバレッジ 4.53
(3) oneshot coverage 0.967

(2) がだんとつで遅く、(1) と (3) がほぼ同じです。oneshot coverage が実質ゼロオーバーヘッドであることがわかります。

optcarrot の例

もう少し現実的な例として、Ruby 3 のデファクトベンチマークである optcarrotも測定しました。

結果はこちら。やはり 3 回ずつ測定した平均です。単位は frame per second で、数字が大きいほうが速いです。

条件 fps
(1) カバレッジ測定なし 39.8
(2) 従来の行カバレッジ 10.6
(3) oneshot coverage 39.4

やはり、(2) が 4 倍くらい遅く、(1) と (3) はほぼ同じです。

先ほどのマイクロベンチマークでも optcarrot も CPU 律速のベンチマークなので、Rails のような IO 律速なアプリでは、オーバーヘッドはさらに小さくなっていくはずです。

余談:MJIT との相性

少しだけ補足です。Ruby 2.6 の一番の目玉機能である、JIT コンパイラ、MJIT との相性について。

coverage(というか TracePoint API)が有効だと MJIT は動かないようになっています。oneshot coverage はバイトコード(フックフラグ)を実行時に書き換えるので、JIT コンパイルしてもフラグが変わったらコンパイルのやりなおしになるためです。optcarrot を --jitオプション付きで測定し直した結果がこちら。

条件 --jitなし fps --jitあり fps
(1) カバレッジ測定なし + --jit 39.8 56.1
(2) 従来の行カバレッジ + --jit 10.6 10.3
(3) oneshot coverage + --jit 39.4 38.8

--jitをつけると、(1) カバレッジ測定なしなら 39.8 → 56.1 fps に大幅スピードアップしていますが、(3) oneshot coverage は 39.4 → 38.8 fps と、MJIT が無効になっていることが確認できます。

しかし、現時点では MJIT はまだ Rails アプリを高速化するには至っていない(k0kubun さんによる進捗報告記事)ので、気にしなくても大丈夫です(?)。MJIT のクオリティが上がっていったら、なんか改善できる(してくれる)のではないかと思います。

まとめ

Ruby 2.6 で入った、(最初の1回のフックの後は)ゼロオーバーヘッドでカバレッジを測定できる oneshot coverage という機能を紹介しました。これを本番環境で使うことで、使われていなさそうな行の情報が得られます。不要コードの発見・削除のお役に立てば幸いです。

なお cookpad_all はまだ Ruby 2.6 で運用されていないので(当たり前)、まだ oneshot coverage は適用できていませんが、使っていく方向で計画が進んでます。これについてはまたの機会に報告できればと思います。

*1:インタプリタ言語だからといって同様の検証ができないとも限りませんが、とりあえず Ruby にはそういう検証ツールがいまのところありません。

*2:もう少し言うと、Rails のような IO ボトルネックなプログラムでは、カバレッジ計測のオーバーヘッドは元々ほとんど問題にならないと思います。少なくとも、cookpad.com のテストをカバレッジ計測あり・なしで走らせても、実行時間に違いはみられませんでした。ただ、本番環境で従来のカバレッジ計測を有効にするのは、オーバーヘッドが問題になるリスクがあるかもしれません。

*3:これは Coverage.result の仕様を最初に決めたときの判断ミスで、とても後悔しています。

Ruby 2.6 の改善を自慢したい

$
0
0

技術部で Ruby インタプリタの開発をしている笹田です。娘のために、今年はじめて大きなクリスマスツリー(1.8 m)を買いました。

本稿では、私が Ruby 2.6 で取り組んだ中から、次の新しい機能と性能改善について紹介します。どちらのトピックも、普通に Ruby を使っているだけなら気にならない、玄人向きの記事になっていると思います。興味がある人にお読み頂ければ幸いです(居ればいいのですが)。

  • TracePoint の拡張
    • 新しいイベント script_compiled の導入
    • フックを有効にする場所を制限する機能の導入
    • デバッガの実装が、10~100倍くらい速くなる、かもしれない
    • ブレイクポイントの実装を例に解説
  • Transient Heap の導入
    • 短寿命メモリオブジェクトの高速化
    • 世代別コピーGCのアイディアを利用
    • Rails とかには効かないかも...。

そういえば、両方とも "Tra"で始まってますね。寅年はまだ先ですが。

本稿は、Cookpad techlife で三日連続で掲載する Ruby 2.6 紹介記事の最後になります。

タイトルは 昨年の記事 Ruby 2.5 の改善を自慢したいの二番煎じです。来年もやるのかな...。

目次

TracePoint の拡張

まず、簡単なほう、TracePoint の拡張の話から始めます。

TracePoint 基礎

TracePoint は、プログラム中のイベントで起動する Proc を登録するための仕組みです。イベントには、毎行ごとに実行する line イベントや、メソッドの呼び出しと、そのリターンごとに実行する call、return などがあります。

例えば、すべてのメソッド呼び出し、およびそのリターンをログに出力したい、というプログラムは次のように簡単に書くことができます。

1deffoo; bar; end2defbar; nil; end34TracePoint.trace(:call, :return){|tp| p tp}
     56  foo

このプログラムを実行すると、次のような出力が得られます。

#<TracePoint:call `foo'@t.rb:1>
#<TracePoint:call `bar'@t.rb:2>
#<TracePoint:return `bar'@t.rb:2>
#<TracePoint:return `foo'@t.rb:1>

foo が呼ばれ、bar が呼ばれ、bar から戻り、foo から戻る、ということが見て取れます。が、ちょっと出力が読みづらいですね。呼び出しレベルごとにインデントを付けるようにしてみましょう。

indent = 0TracePoint.trace(:call, :return){|tp|
  indent -= 2if tp.event == :return
  print '' * indent; p tp
  indent += 2if tp.event == :call
}

実行結果:

#<TracePoint:call `foo'@t.rb:1>
  #<TracePoint:call `bar'@t.rb:2>
  #<TracePoint:return `bar'@t.rb:2>
#<TracePoint:return `foo'@t.rb:1>

ちょっと読みやすくなりました。もうちょっと整形してみましょう。

indent = 0TracePoint.trace(:call){|tp|
  puts "#{'' * indent}-> #{tp.method_id}@#{tp.path}:#{tp.lineno}"
  indent += 2
}
TracePoint.trace(:return){|tp|
  indent -= 2
  puts "#{'' * indent}<- #{tp.method_id}@#{tp.path}:#{tp.lineno}"
}

TracePoint の設定を、call と return の2つに分けてみました。実行結果です。

-> foo@t.rb:1
  -> bar@t.rb:2
  <- bar@t.rb:2
<- foo@t.rb:1

どうでしょう。それっぽくなったでしょうか。

実は、TracePoint.trace(events){...}TracePoint.new(events){...}.enable(新しい TracePoint を作成し、それを有効にする、という意味)の略なので、今後は TracePoint#enableを使うようにしてみます。

TracePoint の基礎はだいたいこんなものですが、詳細はるりまのドキュメント "class TracePoint (Ruby 2.5.0)"をご覧下さい。

TracePoint の問題

便利に、悪巧みに、色々使えそうな TracePoint ですが、性能の問題があります。

性能が気になる一番顕著な例はブレイクポイントの実装です。あるファイルのある行で実行を止める、ということを考えてみましょう。breakpoint('t.rb', 10)とすると、t.rb:10 で irb が起動するなんてどうでしょうか。

TracePoint を使うと、こんな感じで簡単に作ることができます。

defbreakpoint file, line
  TracePoint.new(:line){|tp|
    if tp.path == file && tp.lineno == line
      tp.binding.irb
    end
  }.enable
end

各行で実行するフック(line イベントによるフック)中で、if tp.path == file && tp.lineno == lineという if 文で、指定された場所かどうかを判断し、もしそうであれば irb を実行します。Ruby 2.5 から binding.irbという便利なメソッドが追加されたので、もし指定された行を実行したら binding.irbを呼ぶようにしてみました。

では、早速使ってみましょう。

deffoo a, b
  p a, b # line 11end

breakpoint __FILE__, 11

foo 10, 20
foo 30, 40

foo メソッドの1行目の pが11行目だったと思ってください。

実行結果:

From: /home/ko1/src/ruby/trunk/test.rb @ line 11 :

     6:     end
     7:   }.enable
     8: end
     9:
    10: def foo a, b
 => 11:   p a, b # line 11
    12: end
    13:
    14: breakpoint __FILE__, 11
    15:
    16: foo(10, 20)

irb(main):001:0> p [a, b]
[10, 20]
=> [10, 20]
irb(main):002:0> exit
10
20

From: /home/ko1/src/ruby/trunk/test.rb @ line 11 :

     6:     end
     7:   }.enable
     8: end
     9:
    10: def foo a, b
 => 11:   p a, b # line 11
    12: end
    13:
    14: breakpoint __FILE__, 11
    15:
    16: foo(10, 20)

irb(main):001:0> p [a, b]
[30, 40]
=> [30, 40]
irb(main):002:0>
30
40

見事にブレイクポイントを持つデバッガっぽいものが出来ました! たった数行(7行)でデバッガっぽいものが作れる Ruby って凄いですね! ちなみに、byebug などどの Ruby 用デバッガも、基本的にこんな感じで実装されています。

ただ、性能に問題があります。この簡単なブレイクポイントの設置場所「とは関係ない」コードの実行時間を測ってみます。

deffib n
  case n
  when0, 1; n
  else fib(n-1) + fib(n-2)
  endendrequire'benchmark'Benchmark.bm{|x|
  x.report{
    fib(30)
  }
  x.report{
    breakpoint __FILE__, 11# enable breakpoint here
    fib(30)
  }
}
       user     system      total        real
   0.095480   0.000000   0.095480 (  0.095490)
   0.904503   0.000000   0.904503 (  0.904552)

上が breakpoint なし、下が breakpoint ありです。実に 9.5 倍くらい遅くなっているのがわかります。実行している全ての行で上記(無駄な)チェックを行っているので、しょうがないといえばしょうがないかもしれません。

ちなみに byebug を使って、byebug のブレイクポイント機能を有効にした状態で fib(30)を実行してみましょう。

$ byebug /home/ko1/src/ruby/trunk/test.rb
...
(byebug) b 11
Created breakpoint 1 at /home/ko1/src/ruby/trunk/test.rb:11
(byebug)
       user     system      total        real
   7.406062   0.000000   7.406062 (  7.406415)

さらに遅いです。何もしない場合に比べ、80倍程度遅いようです。きっと、他にもいろいろな処理をしているんでしょうね。

今回はブレイクポイントを例にしましたが、最初に紹介したメソッド呼び出し履歴のロギングでも、例えば gem は除く、といった要望は当然出てくると思います。

TracePoint#enable(target:, target_line:)拡張

さて、TracePoint では無駄なフックを呼んでしまうことで性能上問題が生じる、ということをご紹介しました。10倍とか100倍遅くなってしまいました。なんとかしたい。

そこで、Ruby 2.6 では、TracePoint を有効にする場所を制限するという拡張を TracePoint#enableメソッドに行うことにしました。

enable(target: code, target_line: line) と、target:target_line:キーワードを追加しました。これらのキーワードを利用することで、指定された code および行番号に、イベント発火を絞ることができます。

なお、target_line:lineイベントにだけ有効です。そのため、lineイベントと一緒に、他のイベント(callなど)が指定されていると、target_line:を指定していても、callなどは有効になります。わかりやすいように、target_line:指定は lineイベントのみと一緒に使うことをオススメします。

さて、この拡張を用いて、実際にちゃんと動くブレイクポイントを作ることができるか試してみましょう。まず、breakpoint メソッドの仕様を変更します。

defbreakpoint method, line
  TracePoint.new(:line){|tp|
    tp.binding.irb
  }.enable(target: method, targe_line: line)
end

breakpointメソッドの第一引数がファイルではなく、method とあるのに注意してください。ここでは、メソッドオブジェクトを渡します。

使ってみましょう。

deffoo a, b
  p a, b # line 11end

breakpoint method(:foo), 11
foo(10, 20)
foo(30, 40)

実行結果です。

From: /home/ko1/src/ruby/trunk/test.rb @ line 11 :

     6:     tp.binding.irb
     7:   }.enable(target: method, target_line: line)
     8: end
     9:
    10: def foo a, b
 => 11:   p a, b # line 11
    12: end
    13:
    14: breakpoint method(:foo), 11
    15: foo(10, 20)
    16: foo(30, 40)

irb(main):001:0> p [a, b]
[10, 20]
=> [10, 20]
irb(main):002:0> exit
10
20

From: /home/ko1/src/ruby/trunk/test.rb @ line 11 :

     6:     tp.binding.irb
     7:   }.enable(target: method, target_line: line)
     8: end
     9:
    10: def foo a, b
 => 11:   p a, b # line 11
    12: end
    13:
    14: breakpoint method(:foo), 11
    15: foo(10, 20)
    16: foo(30, 40)

irb(main):001:0> p [a, b]
[30, 40]
=> [30, 40]
irb(main):002:0> exit
30
40

先ほどと同様、ちゃんと動いていることが確認できました。

では、性能はどうなっているでしょうか。

       user     system      total        real
   0.096092   0.000000   0.096092 (  0.096180)
   0.095210   0.000000   0.095210 (  0.095306)

上が breakpoint なし、下が breakpoint ありです。ほぼ、実行時間に変わりが無いことがわかるかと思います。やった!

TracePoint#enable(target: code)codeって何?

いやいや、ちょっと待って。いちいちメソッドオブジェクトを渡すのって大変じゃないですか。breakpoint(path, line)を実装したかったらどうすればいいんでしょうか。

そもそも、target: codeで指定する code とは、なんでしょうか?

ここには、Ruby で記述したプログラムにおける MethodUnboundMethodProcおよび RubyVM::InstructionSequence(長いので、以降 ISeq)が指定できます。何やら難しそう...。そもそも、最後の ISeq って何。

Ruby で記述されたプログラムはバイトコードにコンパイルされます。そのバイトコードのことを Ruby (MRI) の文脈では命令列、Instruction Sequence、つまり ISeq と言ってます。

Ruby で記述したメソッドから取り出した Methodオブジェクトなどは、たどっていくと ISeq が取り出せます。実は、code で指定しているのは、ISeq なのです。

ちなみに、RubyVM::InstructionSequence.of(code)で、MethodProcオブジェクトなどから ISeq が取り出せます。C で実装されたメソッドの場合、nilが返ります。TracePoint#enable(target: code)では、内部でまさに ISeq.of(code)を呼んで、ISeq を取り出しています。もし、ISeq が取り出せない場合はエラーになります。

p RubyVM::InstructionSequence.of(method(:p)) #=> nilTracePoint.new{}.enable(target: method(:p))
#=> <internal:prelude>:137:in `__enable': specified target is not supported (ArgumentError)

今回の拡張は、ある ISeq(および、その ISeq から辿ることができるすべての ISeq)に TracePoint を限定する、というのが正しい理解です。

例えば、aliasを使うとメソッドを増やすことができますが、指し示す ISeq は同じものです。

deffoo; endaliasbarfooISeq = RubyVM::InstructionSequence
p ISeq.of(method(:foo)) == ISeq.of(method(:bar)) #=> true

そのため、enable(code: method(:bar))とすると、foobarで有効な TracePoint になります。

同じように、ある Module を includeしたクラス C1, C2 も、同じメソッドを共有することになります。

moduleM; deffoo; end; endclassC1; includeM; endclassC2; includeM; endISeq = RubyVM::InstructionSequence
p ISeq.of(C1.new.method(:foo)) == ISeq.of(C2.new.method(:foo)) #=> true

モジュールの例は、もしかしたらはまりやすいかもしれませんね。「そこからたどれるソースコードにイベントで発火する hook を登録する」という機能であることを抑えておくことが大事です。

メソッドを指定するブレイクポイント

メソッドを指定するブレイクポイントを実装する場合を考えます。あるメソッドが起動したとき、というタイミングにブレイクポイントを指定する場合ですね。

前節で実装した breakpointが、そのまま使えそうです。

defmethod_breakpoint method, line = nilTracePoint.new(:call){|tp|
    tp.binding.irb
  }.enable(target: method)
end

「メソッドが呼ばれるとき」なので、callイベントのみを利用しています。target_line:指定がないことに気を付けてください。lineイベントがないのに target_line:指定があると、target_line is specified, but line event is not specified (ArgumentError)と怒られます。

簡単ですね。

C で実装されたメソッドの場合は、この方法ではうまくいきません。その点がちょっと残念ですね(これまで通り、c_callイベントをフックするか、Ruby でラッパーメソッドを書く、という方法があります。今だと後者が速いかな)。

場所を指定するブレイクポイント

さて、ファイル(path)と行(line)で場所を指定したいブレイクポイントを実装する場合、対象となる ISeq をどうやって集めてくるか、という問題になります。

実は、今の MRI には、そのための方法がないので、今回は外部の gem を使います。iseq_collectorです。この gem が提供する ObjectSpace.each_iseqは、インタプリタ中に存在するすべての ISeq を辿るという API です。

ISeq には ISeq#pathというメソッドがあり、そのメソッドが定義されたファイル名を知ることができるので、これで pathと比較することで、必要な ISeq を絞ることができます。

次に、lineの絞り方です。これには2通りのやり方があります。

まず、ISeq#trace_pointsを用いて指定の行があるかどうかを調べる方法です。

1deffoo2    p 13end45ISeq = RubyVM::InstructionSequence# 長いので6  pp ISeq.of(method(:foo)).trace_points
     #=> [[1, :call], [2, :line], [3, :return]]

結果を見るとわかりやすいと思いますが、1行目に call イベント、2行目に line イベント、3 行目に return イベント、計 3 イベントがfooメソッド(を実装する ISeq)に登録可能である、ということを示しています。

場所を指定するので、line イベントを利用します。このメソッドでは、2 行目のみ、場所指定のブレイクポイントをしかけることができる、と捉えることができます(call, return イベントを利用すれば、もうちょっと頑張れますが、ここでは触れません)。

もう一つの絞り方ですが、ちょっと乱暴に、とにかく pathで絞って得られた ISeq を用いて enable(target: iseq, target_line: line)を指定してしまう、というものです。もし、対象 ISeq に target_line:で指定した行がなければ、例外を返します。

12deffoo3    p 14end56TracePoint.new(:line){}.enable(target: method(:foo), target_line: 100)
     #=> <internal:prelude>:137:in `__enable': can not enable any hooks (ArgumentError)

100行目が見つからなかったので、「フックがどこにも指定できなかった」という例外を出しています。これを利用すれば、とにかく TracePoint#enableをしまくってみると、いつかはヒットするかも、という戦略が取れます。

今回は、前者、ISeq#trace_pointsを利用する方法を用いて、場所指定のブレイクポイントを設定するメソッドである location_breakpointを作ってみましょう。

1require'iseq_collector'2deflocation_breakpoint path, line
     3ObjectSpace.each_iseq{|iseq|
     4if iseq.path == path &&
     5         iseq.trace_points.find{|(l, ev)| l == line && ev == :line}
     6TracePoint.new(:line){|tp|
     7          tp.binding.irb
     8        }.enable(target: iseq, target_line: line)
     9      end
    10    }
    11end1213deffoo a, b
    14    p a, b # line 1415end1617  location_breakpoint __FILE__, 1418  foo(10, 20)
    19  foo(30, 40)

実際に動かしてみると、きちんと動いていることがわかると思います。やりました!

さて、ご紹介した2つ、method_breakpointおよび location_breakpointですが、1つ大きな問題があります。それは、「すでに requireなどでロードしているプログラムにしか適用できない」です。

デバッガの通常の利用方法として、「プログラムのある場所にブレイクポイントを指定する。そして、実行を開始する」というものがあります。一度、すべてのプログラムをロードしたどこかのタイミングでブレイクポイントを(これまで作成してきたメソッドを利用して)設定する、ということをしてもいいですが、Ruby の場合、この「すべてのプログラムをロードしたどこかのタイミング」を特定するのは困難です。dynamic reloading などをしていると、そもそもそういうタイミングがありません。

この問題を解決するのが、Ruby 2.6 から導入された新しいイベントである script_compiled です。

script_compiled によるコンパイル後の処理

Ruby 2.6 から、script_compiled イベントが新しく追加されました。

MRI は Ruby スクリプトを実行するために、

  • (1) スクリプトをパースして AST(構文木)を作成
  • (2) AST を ISeq に変換
  • (3) ISeq を実行

という手順を踏んで実行します。 script_compiled イベントは、(2) 終了時に挿入されるフックです。

この機能を使うと、例えば、どんなファイルが requireloadで実行されるか、実行直前で知ることができます。

コンパイルされ、生成された ISeq は TracePoint#instruction_sequenceメソッドで取得することができます。

では試しに、net/httpライブラリを requireすると、何が起こるのか、観察してみましょう。

TracePoint.new(:script_compiled){|tp|
  pp ["#{tp.path}:#{tp.lineno}", tp.instruction_sequence.path]
}.enable

require'net/http'

このプログラムでは、Ruby スクリプトを ISeq に変換したとき、「ロードしたソースコードの位置」、「ロードされたファイル名」を指定します。

["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:23",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/protocol.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/socket.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/timeout.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1645",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/exceptions.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1647",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/header.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1649",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/generic_request.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1650",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/request.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1651",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/requests.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1653",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/response.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1654",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/responses.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1656",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/proxy_delta.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1658",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/backward.rb"]

このように、多くのファイルがロードされていることがわかります。

また、同じことを fileutilsで試してみます。

["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils/version.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1670",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
...
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1670",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1695",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
...
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1695",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1721",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
...
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1721",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]

なぜか、同じ場所で沢山のプログラムがロードされているようです。ちょっとソースコードを確認してみましょう。

1669      names.each do |name|
  1670module_eval(<<-EOS, __FILE__, __LINE__ + 1)
  1671          def #{name}(*args, **options)  1672            super(*args, **options, verbose: true)  1673          end  1674        EOS  1675      end  1676      private(*names)

module_eavlがある部分です。eval(str)module_eval(str), instance_eval(str)なども同様です)も requireなどと同様に、スクリプト srcを ISeq に変換して実行するので、script_compiled イベントでフックできるのです。

Ruby 2.6 からは、evalに渡した文字列を取得する TracePoint#eval_stringというメソッドがあります。これを利用することで、どんな文字列を evalで実行しているかがわかります。なお、requireなどファイル指定でファイルをロードした場合はこのメソッドは nilを返します。

では、試してみましょう。

TracePoint.new(:script_compiled){|tp|
  pp ["#{tp.path}:#{tp.lineno}", tp.instruction_sequence.path, tp.eval_script]
}.enable

require'fileutils'

結果はこんな感じです。

["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb",
 nil]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils/version.rb",
 nil]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1670",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb",
 "        def cd(*args, **options)\n" +
 "          super(*args, **options, verbose: true)\n" +
 "        end\n"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1670",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb",
 "        def mkpath(*args, **options)\n" +
 "          super(*args, **options, verbose: true)\n" +
 "        end\n"]
...

module_evalによって何が実行されようとしているかがよくわかりますね。

さて、これらの機能を使うと、スクリプトがロードされた瞬間にブレイクポイントを仕込む、ということが可能になります。

では、これまでの知識を利用して、reserve_location_breakpoint path_pattern, lineというメソッドを作ってみましょう。パスを指定するパラメータ名が pathではなく path_patternなのは、パス名は Regexpでパターンマッチ出来た方が便利かなと思ったためです。

require'iseq_collector'deflocation_breakpoint path, line
  ObjectSpace.each_iseq{|iseq|
    if iseq.path == path &&
       iseq.trace_points.find{|(l, ev)| l == line && ev == :line}
      TracePoint.new(:line){|tp|
        tp.binding.irb
      }.enable(target: iseq, target_line: line)
    end
  }
enddefreserve_location_breakpoint path_pattern, line
  TracePoint.new(:script_compiled){|tp|
    compiled_script_path = tp.instruction_sequence.path
    if path_pattern =~ compiled_script_path
      location_breakpoint(compiled_script_path, line)
    end
  }.enable
end

reserve_location_breakpoint(/lib\/ruby\/2.6.0\/fileutils\.rb/, 9)

# ロードするrequire'fileutils'

実行結果です。

From: /home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb @ line 9 :

     4:   require 'rbconfig'
     5: rescue LoadError
     6:   # for make mjit-headers
     7: end
     8:
 =>  9: require "fileutils/version"
    10:
    11: #
    12: # = fileutils.rb
    13: #
    14: # Copyright (c) 2000-2007 Minero Aoki

irb(main):001:0> exit

ちゃんと、これからロードするファイルに対してブレイクポイントを設定できました。

あと少し整備すれば、デバッガなんて簡単に作れそうですね! やってみたくありませんか?

(場所指定ブレイクポイントは、だいたいこれでいいとして(出来た!)、ではメソッド指定ってどうやりますかね。こちらは、実はなかなか難しいけど、さすがに長くなりすぎるので省略します)

TracePoint#enable(target:)の背景と実装

さて、ここまでの記事は、Ruby 2.6 で導入された新しい TracePoint の拡張を用いて、デバッガのブレイクポイントをどうやって作るのか、ということを題材に、使い方をご紹介しました。これくらいの記事なら、ちょっと気の利いた人なら書けそうです。

本稿では差別化を図るために、これをどうやって実装したか、その背景とテクニックをご紹介します。これはさすがに私しか書けないでしょう。「筆者はこのとき何を考えていたか答えなさい」ってやつですね。

この拡張を導入した背景

TracePoint#enable(taget:)なんですが、ずっと導入したかった機能でした。それこそ、TracePoint 導入した Ruby 2.0 から。しかし、なかなか API が決められなかったんですよね。いろいろ考えちゃって、あーでもない、こーでもないと悩んで、時間がかかりました。欲しいって言う人も居ないし。今回、入った原因は2つ、外圧と割り切りでした。

まず、外圧ですが、背景として、RUBY_EVENT_SPECIFIED_LINEという隠し機能が、たしか Ruby 2.1 だかそこらで入れてありました。まさに、ブレイクポイントを実装するための仕組みです。が、Ruby 2.5 で命令書き換えによる trace prefix 命令への置換へ舵を切ったタイミング(詳細は Ruby 2.5 の改善を自慢したい)で、削除したんですよね。同じことは、選択的に命令書き換えすれば出来るだろう、って。

ただ、そのインターフェースを用意していなかった。正確には、用意をしていたんだけど、使おうとするととても使いづらかった。で、RUBY_EVENT_SPECIFIED_LINEなんて隠し機能で誰も知らないだろうおと思っていたら、JetBrain でデバッガを作るために使っていたそうなんですよね。で、復活させろー、というリクエストが来まして。ただ、今更戻すのは、いろいろな理由で良くない、ということで、本腰を入れて考え始めました。

割り切りは、ある TracePoint オブジェクトに対して、1 target しか指定できないようにしたことです。いろいろ考えていたときは、複数箇所をどのように対応させるか、それを指定するインターフェースはどうするべきか、というのが悩みどころだったんですが、もう単純に 1 TracePoint object ごとに 1 target、増減はしない! と決めるとすんなり API が決まりました。

この拡張の技術的要点

ISeq ごとに、有効にする hooks を持たせた、というのがキモです。target:で指定された ISeq に、必要な命令を trace_ prefix 命令に変換しておきます。

target_line:指定があったときは、該当する行の命令のみ、trace_ prefix 命令に置き換えています。ただし、global に有効になる TracePoint と conflict すると、登録していないのに発火する hook を作ることが出来てしまうので、line で内部的にフィルタする仕組みを導入しました。

この辺はソースコードをちゃんと説明しないとわかりづらいので、雰囲気だけ、お伝えしました。

まとめ:TracePoint の拡張

TracePoint の拡張の話をちょっと説明しようと思ったら、Ruby でどうやってデバッガのブレイクポイントを実装するか、という説明になってしまいました。お楽しみ頂けたでしょうか。デバッガ作りたくなってきませんか? 私はなりました。

TracePoint は、デバッガを作る以外にも、いくつか使いようがあります。

例えば、今回導入された script_compiled イベントを用いると、実際にどこでソースコードがロードされたかがわかります。特に、evalの利用を検知することができます。大規模プロジェクトで、なぜか良くわからない挙動をする、「もしかしたらどっかで意図しない evalがあるかも?」といったときに、こっそり含まれていた evalを検出する、などといった応用ができるかもしれません。

なお、幸いなことに、script_compiled イベントでは、コンパイル結果の ISeq を差し替えることはできません。平和は保たれました。

用法用量を守って、楽しくお使い頂ければと思います。

Ruby 2.6 で入れようと検討していて、時間切れで入らなかった機能が、沢山あるのですが、その中でも下記の 2 つについては、Ruby 2.7 で、可能なら入れたいと思っています。欲しい人、一緒に検討しませんか?

  • caller/callee での call/return イベント
    • 現状、call/return イベントは、caller で止まるのか callee で止まるのか、決まっていません(call/return は callee、c_call/c_return は caller)。どちらも明確に用意したいと思っています。
    • その際、渡すパラメータ一覧なんかが取れると良さそうだと思っています。
  • method_defined など、メソッド定義等などの変更をフックするイベント

長くなりましたが、TracePoint の拡張については、この辺で終わりにします。

Transient Heap (theap) の導入

もう誰もここまで読んでいない気がしますが、もう少しだけ続けます。

Ruby 2.6 では、Transient Heap (theap) という、メモリ管理のための新しい仕組みを導入しました。Ruby のメモリ管理が、また複雑になりました。

theap を大雑把に紹介すると、世代別コピーGCのテクニックを使うことで、対応済みのオブジェクトについて、短寿命なオブジェクトのために確保されたメモリを効率よく管理する仕組みを導入した、というものです。Array、Object(ユーザー定義クラス)、Struct、および 8 要素以下の Hash が theap を利用しています。

本章では、この theap について、かいつまんで介します。

なお、ここからは C のコードばかりになります。

現在のメモリ管理

Ruby でメモリ管理というとガーベージコレクタ(GC)を思い浮かべると思います。メモリ領域(とか、リソース)は GC によって寿命が管理されているので、やはり GC は花形です。昔の Ruby は遅い、と言われていましたが(今も、利用分野によっては言われていると思います)、GC の性能に問題があったことが、その理由の1つであったかと思います。

今(Ruby 2.2 以降)は、世代別インクリメンタル GC を実装しているので、あまり GC アルゴリズムが性能に問題を与えることは少なくなったのではないかと思います。現在の Ruby の GC の話は、何を見るのが一番いいかわからなかったんですが、とりあえず YARV Maniacs 【第 12 回】 インクリメンタル GC の導入を参考文献としてあげておきます。

さて、GC はオブジェクトの寿命管理をします。今回は、その話とは(あんまり)関係ありません。寿命管理は GC に任せて、その際に生じるメモリ割り当て・解放の話が今回の主役です。

オブジェクトを 1 つ生成すると、GC 管理の領域から 1 つ、メモリ領域を割り当てられます。このメモリ領域のことを、ここでは RValue と呼んでおきましょう。1 word をポインタのサイズとして、RValue は 5 word の固定長のメモリ領域になります。イマドキの 64 bit CPU では、8 byte * 5 = 40 byte のメモリ領域ですね。GC 対象のすべてのオブジェクトは RValue を最低限割り当てられる、ととらえても良いと思います*1

RValue の 5 words のうち、最初の 2 words には、RBasic というヘッダが含まれています。RBasic は flags という、オブジェクトの管理に必要になる情報、および klass という、オブジェクトのクラス情報が含まれています。すべてのオブジェクトはクラスを持っているので、RValue にクラスの情報がついているわけですね。

さて、Ruby のオブジェクトには型があります。クラスとは違う概念で、この RValue をどのように利用するか、というデータ型です。例えば、文字列を格納するために使うなら T_STRING型、配列を扱うなら T_ARRAYです。型の種類は、Ruby 2.6 では 15 種類、インターナルで利用するための 4 種類(Ruby プログラムからは見えないが、インタプリタには存在する)の計 19 種類あります。この情報は、RBasic::flagsの最初の 5 bit に格納されています。

参考までに、Ruby 2.6 の include/ruby/ruby.hから、どんな型があるか、引用してみます(RUBY_ prefix は無視してもらって構いません)。

enum ruby_value_type {
    RUBY_T_NONE   = 0x00,

    RUBY_T_OBJECT = 0x01,
    RUBY_T_CLASS  = 0x02,
    RUBY_T_MODULE = 0x03,
    RUBY_T_FLOAT  = 0x04,
    RUBY_T_STRING = 0x05,
    RUBY_T_REGEXP = 0x06,
    RUBY_T_ARRAY  = 0x07,
    RUBY_T_HASH   = 0x08,
    RUBY_T_STRUCT = 0x09,
    RUBY_T_BIGNUM = 0x0a,
    RUBY_T_FILE   = 0x0b,
    RUBY_T_DATA   = 0x0c,
    RUBY_T_MATCH  = 0x0d,
    RUBY_T_COMPLEX  = 0x0e,
    RUBY_T_RATIONAL = 0x0f,

    RUBY_T_NIL    = 0x11,
    RUBY_T_TRUE   = 0x12,
    RUBY_T_FALSE  = 0x13,
    RUBY_T_SYMBOL = 0x14,
    RUBY_T_FIXNUM = 0x15,
    RUBY_T_UNDEF  = 0x16,

    RUBY_T_IMEMO  = 0x1a, /*!< @see imemo_type */
    RUBY_T_NODE   = 0x1b,
    RUBY_T_ICLASS = 0x1c,
    RUBY_T_ZOMBIE = 0x1d,

各データ型ごとに、T_STRINGなら struct RStringT_ARRAYなら struct RArrayのように、 5 word のメモリ領域のレイアウトを示すための構造体が定義されています。それぞれ最初に 2 word の RBasic を持っているので、残り 3 word をどのように利用するか決める、というのが、RString だったり RArray だったりの役目になります。

というところまで紹介しましたが、ちょっと不思議なことがあります。1つのStringオブジェクトが持つ文字列の長さは(メモリのある限り)いくらでも大きくすることが可能です。そのため、3 word に収まるわけがないのです。そこで、MRI はどうしているかというと、例えば RString では、1 word に malloc()で確保したメモリへのポインタ、1 word に確保したメモリのサイズ、1 word に文字列の長さを格納しています*2。この構造でしたら、扱う文字列の長さを RString の大きさに気にすることなく大きくすることが可能です。

String オブジェクトを例にご紹介しましたが、他のデータ型も、3 word で収まらない場合は同じようなテクニックを使います。

このあたりのレイアウトは、RHG が執筆された 2002 年から、ほとんど変っていません。というわけで、詳細は RHG の第2章をご参照ください:第2章 オブジェクト

さて、この malloc()したメモリですが、オブジェクトが GC によって回収されるときに free()されます。つまり、Ruby のオブジェクトがある程度の大きさのメモリを必要とする場合、生成と解放時に malloc()free()を実行することになります。もちろん、必要なメモリ量が変更される場合(例えば String オブジェクトが破壊的に伸張されるような場合)は realloc()などを用いて確保する量を変えたりします。

現在のメモリ管理の問題点

現在の問題点は、malloc()free()による確保・解放を頻繁に行っている、という点が挙げられると思います。細かい話はおいといて、だいたいこれくらいのデメリットがあります。

  • malloc/freeの操作が重い(malloc ライブラリの実装によります)
  • メモリの断片化が起こる
  • マルチスレッドプログラミングをするとメモリを余計に食ってしまう(malloc ライブラリの実装、設定によります)

GC を持つフツーの言語処理系は、たいてい GC で管理する領域を Ruby のように固定長ではなく、可変長にして、malloc()free()を用いないで実装されます。そのため、これらのデメリットは MRI ならでは、と言えるかも知れません。

Transient Heap のアイディア

malloc()free()を使うとまずそう、ということがわかりました。では、どうすれば良いか。

Transient Heap (theap)は、これを解決するために導入されました。theap のアイディアを簡単に説明すると、GC と仲良くするメモリ領域です。

malloc()free()で管理する領域の他にメモリ領域を用意して、malloc()で確保していたところを theap からメモリ確保するようにします。解放するときは、何もしません。オブジェクト回収時に free()を呼んでいたところは、何もしないように変えるだけです。GC 終了後、theap をまとめて消してしまうため、それぞれの領域を free()する必要がないんです。なんか、速そうじゃないですか。

また、theap は、単にポインタをずらすだけでメモリを確保します(bump allocation と言います。専門用語っぽくて格好いいですね)。なので、メモリ確保が malloc()より速いです。

theap 良さそうですね!

うまい話には裏がつきもので、今回の裏は「生き残るオブジェクトが余計な仕事をしなければならない」です。生き残るオブジェクトが theap を使っていると、GC 終了時に全部消されてしまうと困ります。そこで、生き残るオブジェクトが theap を使っていた場合、別の領域を割り当てて、そこにデータをコピーすることで対処します。この、別の領域を割り当て、そこにコピーすることを、ここでは 待避(evacuate)と言います。

ちなみに、すぐに(GC タイミングごとに)メモリが解放されてしまうので、「Transient(つかの間の) heap」と名付けました。

表にまとめるとこんな感じです。

malloc/free theap
確保 malloc() theap_alloc() (bump allocation)
生存 N/A evacuate
解放 free() N/A

theap のほうが、確保と解放が速いです。GC での生存時、malloc/freeでは、オブジェクトの生存時には何もする必要がありませんでしたが、theap では GC が起こる度に待避が必要です。利点と欠点があるんですが、さて、どっちが得でしょうか。

そこで、たびたび世代別GCの解説で紹介される世代別仮説というものを引用します。若いオブジェクトは死にやすく、古いオブジェクトは死ににくい、のではないかという経験則です。Ruby プログラムを見ていると、若いオブジェクトをどんどん作って捨てていく、というプログラムがそこそこありそうですので、この仮説は多分正しいことが多いんじゃないでしょうか。そして、生存する率が低く、待避操作が少ないのであれば、theap はうまく効きそうです。効くんじゃないかな。効いてくれると良いな。

そこで、使えるのが以前 インタプリタ開発者によるRubyの挙動解析への道ご紹介した debug_counter を見てみます。

discourse benchmark の結果を下記に引用します。

[RUBY_DEBUG_COUNTER]    obj_newobj                       162,910,687
[RUBY_DEBUG_COUNTER]    obj_free                         161,117,628
[RUBY_DEBUG_COUNTER]    obj_promote                        7,289,299

この結果を見ると、promote された、つまり古い世代になったオブジェクトの数は、(7,289,299 / 162,910,687) * 100 = 4.47% と、実に 95% のオブジェクトが新世代のまま、ということがわかります。まぁそんなわけで、多分効くんじゃないかな。効いてくれると良いな。

さて、待避のためには、待避先が必要になります。この領域には2つ候補があります。再度 theap から割り当てる方法、それから従来通り malloc()を利用する方法です。一度 malloc()領域に移してしまえば、以降 theap を気にすることがありません(待避をこれ以降行う必要はありません)。そこで今回は、オブジェクトが GC における若い世代であれば、待避先に theap の領域を選び、古い世代であれば、待避先を malloc()で確保する、ということにしました。

GC のたびに領域をコピーして待避するので、コピーGC、古い世代の管理を persistent 領域(malloc()領域)で行うので世代別、なので、世代別コピー GC に似ています(アイディアはそこそこ同じです)。寿命管理は mark&sweep なので、ちょっと特殊ですね。

なお、ものすごく大きいデータを theap に確保してしまうと、いざコピーが必要になったとき大変そうなので、theap で一度に確保できる領域は 2KB に制限しています。このサイズに何か根拠があったわけではなく、当てずっぽうです。もしサイズを超える場合は、最初から malloc()して、theap を利用しないようにします。

Transient Heap の工夫:待避のタイミング

theap 良さそうです。が、いくつか問題があります。一番の問題は、素朴に実装してしまうと C 拡張ライブラリの互換性が壊れてしまう、という問題です。どういうことでしょうか。

theap を使った Arrayオブジェクトについて考えてみます。Arrayは theap から確保したメモリへのポインタを持っています。

func(VALUE ary){
  /* ary から配列の実体を格納するポインタを取得 */
  const VALUE *ptr = RARRAY_CONST_PTR(ary);
  ptr[0]; /* (1) */
  func_can_cause_gc();
  ptr[0]; /* (2) */
}

例えば、こんな C のコードがあったとします。ptrが theap から確保したメモリへのポインタですね。

さて、theap を素朴に実装すると、aryが参照している Array オブジェクトは生きているので、GC が起きた直後に aryの実体を待避する、というものになります。

ただ、もしそうだとすると、ソースコードの (2) での ptrアクセスが危険なものになります。というのも、ptrを取得してから (2) までの間に GC が起こってしまった場合、待避が行われてしまうので、ptrは古い領域をさすことになり、おかしなことになります。この問題は、ptrの寿命が、C 言語のソースコードの見た目と直感的に反する、という言い方もできるかもしれません(GC がらみでは、こういう問題がいくつも出てきます)。

これを避けるためには、ptrが必要になったときには、毎回 ptrを Array オブジェクトから取得しなければなりません。

func(VALUE ary){
  /* ary から配列の実体を格納するポインタを取得 */
  const VALUE *ptr = RARRAY_CONST_PTR(ary); 
  ptr[0]; /* (1) */
  func_can_cause_gc();
  ptr = RARRAY_CONST_PTR(ary); 
  ptr[0]; /* (2) */
}

ただ、すでに公開されている C-extension は無数にあります。このようにすべて書き換えてください、というのは、そもそも、それを徹底するのが難しい、それから、実際に正しく書き換えるのが難しい(どこで GC が起こるか、すべてチェックする必要があります)、という問題から、現実的ではありません。

そこで、2つの工夫を行うことにしました。

待避のタイミングをファイナライザタイミングにする

GC は、いろいろなところで起きます。これを予測するのはとても難しい。

そこで、待避が行われるタイミングを、GC の直後ではなく、ファイナライザが起動するタイミングに遅延することにしました。え、なんでファイナライザ?

Ruby では、オブジェクトを解放するときに起動するファイナライザを、任意の Ruby コードで記述することができます。つまり、任意の Ruby プログラムが動き得るタイミングということになります。

任意の Ruby コードは、たとえばオブジェクトの破壊的操作を行うことができるので、C 側で取得したポインタが無効になることがあり得ます。つまり、C 側で取得したポインタは、任意の Ruby コードを実行した後では、それがそのまま利用可能かどうかわからない、ということです。

先ほどの例を見てみましょう。

func(VALUE ary){
  /* ary から配列の実体を格納するポインタを取得 */
  const VALUE *ptr = RARRAY_CONST_PTR(ary);
  ptr[0]; /* (1) */
  func_can_cause_gc();
  ptr[0]; /* (2) */
}

もし func_can_cause_gc()関数が任意の Ruby コードを実行するとしたら、この C 関数は危険です。なぜなら、そこで ary.replace(other_ary)といった、ptrが無効になる処理が実行されるかもしれないためです。というわけで、任意の Ruby コードを実行すると、事前に取得したポインタは保障できない、という問題は、昔からありました。C-extension 開発者は、この制限を受け入れているはずのですので、「このようなコードは無い」という仮定を置くことは妥当です*3

そこで、任意の Ruby コードが動くタイミングで待避を行うのは妥当と言えると思います。そして、GC の後で任意の Ruby コードが動くタイミングというのが、ファイナライザを動かすタイミングなのです。

ポインタを取り出すとき、theap を無効にする

先ほど利用した RARRAY_CONST_PTR()といった、ポインタを取り出す操作を行うと、malloc()ヒープにコピーすることで、theap から待避することにしました。C-extension が扱うポインタは、これで「待避されるかも...」という不安を払拭することができます。この、theap を無効にする処理を detransient と呼んでいます。rb_ary_detransient(ary)という処理を行っています。

だいぶ保守的な決定です。theap のままなら、性能はもう少し高いままかもしれないのに。ただ、動かなくなるプログラムがでるよりは良いだろう、とこのような仕様にしました。

theap に置いたままポインタを取り出したい、ポインタを使っている間は絶対に待避を起こさない自信がある、という場合は、RARRAY_CONST_PTR_TRANSIENT(ary)を用います。detransient しません。array.cなど、私が確認したメソッドの実装については、可能な限り theap のまま扱うようにしています。どっちかなー、ちょっと考えるの面倒くさいなー、というときは、detransient してしまうようにしています。

Array を例に説明しましたが、他のデータ型においても似たような方針で実装しています。

なお、本当は、Ruby のデータ型はの内部は時々変ってしまうので、ポインタを取り出すような操作をしないのが一番です。例えば、Array でしたら RARRYA_AREF(), _ASET()などを用いるのが良いでしょう。

Transient Heap の工夫:Hash の実装

Array, Object(ユーザ定義オブジェクト)、Struct に関しては、素直に theap を用いることが出来ました。しかし、Hash はそうはいきません。なぜでしょうか。

Array の場合は、RArray から、1つの連続したメモリオブジェクトを参照します。しかし、Hash の場合はハッシュテーブルという複雑なデータ構造を用いているため(Towards Faster Ruby Hash Tables)、ぽんっと theap を用いるようにすることが出来ませんでした。具体的なハッシュテーブルの実装は、st.cと言うファイルに収められています。この st.cが提供するハッシュテーブルは、Ruby の Hash オブジェクトだけでなく、インタプリタの中で利用する表などでも利用されています。そのため、Hash オブジェクトのためだけに、ごちゃごちゃ theap 対応を入れることが出来ません。

そこで、Hash オブジェクトの実装を2つにわけることにしました。8 要素以下の場合と、8要素以上の場合です。

8要素以下では、ar_table、8要素より大きい場合は st_table(st.cが提供するテーブル)を用いる、というものです。ar_table は、Hash ではありますが、単なる配列のみで実装されており、線形探索を行います。線形探索だけど、たかだか 8 要素だから、まぁいいかなと。もうちょっと工夫してもいいかもしれませんが。

ar_table は、連続した 8B * 3 words * 8 entries = 192 byte の領域を theap から取得します。

もし、要素の追加などで 8 要素以上になれば、st_table を利用するようにスイッチします。

実は、st_table でも、省メモリ化のために 4 要素以下の場合は同じように線形探索するテーブルに変更するようにしているのですが、その処理を ar_table という形で切り出した、というのが今回の変更になります。

さて、そもそも 8 要素以下の要素数の Hash オブジェクトはどの程度あるんでしょうか。先ほどと同じく、インタプリタ開発者によるRubyの挙動解析への道から、Hash の値を確認してみます。

[RUBY_DEBUG_COUNTER]    obj_hash_empty                     3,632,018
[RUBY_DEBUG_COUNTER]    obj_hash_under4                    4,204,927
[RUBY_DEBUG_COUNTER]    obj_hash_ge4                       2,453,149
[RUBY_DEBUG_COUNTER]    obj_hash_ge8                         841,866

この例では、空の要素数が 3M個、1~3要素までが4M、4~7要素が 2.5M個、それ以上が 0.8M個と、個数が 8 未満の Hash オブジェクトが支配的です。DB 的に Hash を用いると多くのエントリ数の Hash ができますが、沢山作るのは小さい要素数の Hash のようです。キーワード引数とかで使うからですかね。

ちなみに、ar_table の導入によって、theap を用いなくても(すべて malloc()で確保しても)若干 st_table よりも効率が良くなるケースが出てきました。

Transient Heap の工夫:その他

他にもいろいろ、ちゃんと動くようにするため、思ったよりも苦労しました。

  • ファイナライザタイミングまで遅延することと、旧世代オブジェクトとの食い合わせが悪かったので、いろいろ頑張った
  • 省メモリにするため、いろいろとケチケチするテクニックを使った
  • デバッグが大変だったので、書き込み禁止メモリを用いるオプションを作った
  • それでも後から後から、「時々」出現するバグが見つかるので、テストを沢山実行した

他にもあったような気がしますが、思い出せない。

面倒なので、詳細は割愛します。

Transient Heap の評価

実際、どの程度速くなるんでしょうか。少し評価をご紹介します。

どれくらい theap を使っているか

debug_counter に theap を使っているかどうかを見るカウンタを新設したので、それでチェックしてみましょう。

実行するのが簡単なので、rdoc ベンチマークの結果を見てみます。

# Object
[RUBY_DEBUG_COUNTER]    obj_obj_embed                           11,360
[RUBY_DEBUG_COUNTER]    obj_obj_transient                      541,445
[RUBY_DEBUG_COUNTER]    obj_obj_ptr                             47,567
# Array
[RUBY_DEBUG_COUNTER]    obj_ary_embed                        8,261,454
[RUBY_DEBUG_COUNTER]    obj_ary_transient                    2,818,104
[RUBY_DEBUG_COUNTER]    obj_ary_ptr                            558,292
# Hash
[RUBY_DEBUG_COUNTER]    obj_hash_empty                         523,811
[RUBY_DEBUG_COUNTER]    obj_hash_under4                        541,901
[RUBY_DEBUG_COUNTER]    obj_hash_ge4                             1,598
[RUBY_DEBUG_COUNTER]    obj_hash_ge8                             2,432
[RUBY_DEBUG_COUNTER]    obj_hash_ar                          1,066,943
[RUBY_DEBUG_COUNTER]    obj_hash_st                              2,799
[RUBY_DEBUG_COUNTER]    obj_hash_transient                     955,418
# Struct
[RUBY_DEBUG_COUNTER]    obj_struct_embed                       791,055
[RUBY_DEBUG_COUNTER]    obj_struct_transient                 1,351,924
[RUBY_DEBUG_COUNTER]    obj_struct_ptr                         543,895

Object は 90%、24%(ただし、埋め込み Array を排除すると83%)、Hash は 89%、Struct は50%(埋め込み Struct を除くと 71%)と、結構支配的です。きちんと、theap が活用されていることがわかります。

[RUBY_DEBUG_COUNTER]    heap_xmalloc                         8,244,862
[RUBY_DEBUG_COUNTER]    heap_xrealloc                          318,127
[RUBY_DEBUG_COUNTER]    heap_xfree                           7,905,729
[RUBY_DEBUG_COUNTER]    theap_alloc                          7,332,681
[RUBY_DEBUG_COUNTER]    theap_alloc_fail                       583,852
[RUBY_DEBUG_COUNTER]    theap_evacuate                       1,257,952

こちらでは、mallocした回数と、theap から確保した回数(theap_alloc)が見て取れます。malloc回数と同程度の回数、theap で割り当てていることがわかります。原因をさがしたら、もうちょっと theap の率を増やすことができるかもしれません。

待避(theap_evacuate)したのは、17% 程度みたいですね。そこそこ少なそうです。

theap_alloc_failは、theap から確保しようとしたが、何らかの理由で失敗した数です。もうちょっと減らせそうですね。

マイクロベンチマーク

次のグラフは、対応している Array オブジェクトの要素数を変えながら、沢山作るのを繰り返す、というものを、 theap ありとなしで比べた結果です。

f:id:koichi-sasada:20181226145007p:plain

このマイクロベンチマークでは、だいたい、1.5 倍程度の性能向上が得られているのがわかると思います。

f:id:koichi-sasada:20181226145011p:plain

Hash もだいたい 1.5~2倍程度の性能向上を実現しています。ただ、9要素になると theap を使わないため、以前と同程度の性能になります。

実アプリケーション

rdoc

USE_TRANSIENT_HEAPというマクロを 0 にすると theap を無効にできるので、オンとオフ、それぞれを調べてみます。

# without theap
      user     system      total        real
 23.757590   0.363964  24.121554 ( 24.124386)
VmHWM: 423932 kB

# with theap
      user     system      total        real
 22.334208   0.355940  22.690148 ( 22.693046)
VmHWM: 358584 kB

時間は 24.12/22.69 = 6% ほど速くなっています。良かった良かった。

VmHWM は、Linux だと取れる、必要になった実メモリのサイズですが、なぜか 18% ほど削減しています。あまりちゃんと調べていないし、他のアプリではどうか、という調査はしていないのですが、とりあえず「良かったね」と捉えておきます。

sinatra-benchmark

https://github.com/benchmark-driver/sinatraを用いてみました。

Calculating -------------------------------------
                     without-theap  with-theap    ruby_2_5
             sinatra       10.293k     10.493k     10.263k i/s -    100.000k times in 9.714985s 9.530253s 9.743398s

Comparison:
                          sinatra
          with-theap:     10492.9 i/s
       without-theap:     10293.4 i/s - 1.02x  slower
            ruby_2_5:     10263.4 i/s - 1.02x  slower

うーん、2% 速いらしい。微妙ですね...。

discourse

discourse rails benchmark で試したんですが、ちょっと手元に結果がないのですが、たしかさっぱり変らなかった気がします。

この辺は、そもそも malloc/free自体があまりオーバーヘッドになってないってところでしょう。まぁ、そうですよね。でも、もうちょっと効くと思ったんだけどなあ。

速くなった! という話があれば、お寄せ下さい。

こんな話もあるみたいです。

まとめ:Transient Heap

いろいろ複雑な工夫を入れた割に、Rails とかで効かないとか、イマイチ感動の少ない工夫ではありますが、効くところではもしかしたら効くかもな、という感じです。

一番 theap が効きそうな String ですが、対応していません。C レベルで、すぐにポインタを取り出す処理をしてしまうので、あまり効果がないためです。効果を出すためには、絶対に待避されない、ということを保障する必要がありますが、それを行うのが大変なのですよね。というわけで、将来のバージョンでは対応するかもしれないし、そもそも効果が無いから theap 自体が削除されるかもしれません。効くアプリには効きそうなんですけどねぇ。

もう一つ。現在 theap 用のメモリ領域は、最初に 32MB 固定長を確保しています。これを可変に、必要なときに多く、不用なときには少なく、みたいにするのも手かもしれません。現状は、その辺の調整が難しくて(例えば、OS に返す、要求する、を繰り返すと、とても遅くなります)、手を入れられていません。

おわりに

本稿では、私が Ruby 2.6 に導入した次の2つのトピックについてご紹介しました。

  • TracePoint の拡張
    • 新しいイベント script_compiled の導入
    • フックを有効にする場所を制限する機能の導入
    • デバッガの実装が、10~100倍くらい速くなる、かもしれない
    • ブレイクポイントの実装を例に解説
  • Transient Heap の導入
    • 短寿命メモリオブジェクトの高速化
    • 世代別コピーGCのアイディアを利用
    • Rails とかには効かないかも...。

三日連続で Ruby 2.6 の新機能をお伝えしました。いつになく長い記事になりましたが、冬休みにでもお楽しみ頂ければ幸いです。

では、良いお年をお迎えください。

*1:GC 対象でない Ruby オブジェクトもあります。

*2:本当はもっと複雑ですが、ここでは単純化しておきます。

*3:いや、たまたま動いてたから、と言うケースはそこそこありそうですが...。


クックパッド基幹システムのmicroservices化戦略 〜お台場プロジェクト1年半の軌跡〜

$
0
0

インフラストラクチャー部の青木峰郎です。 最近はDWH運用の傍ら、所属とまったく関係のないサービス開発のためのデザインスプリントをしつつ、 Java 10でgRPCサーバーを書きつつ、 リアクティブプログラミングを使った非同期オーケストレーション層を勢いだけで導入したりしています。

ですが今日はそれとはあまり関係なく、クックパッドの中核サービスであるレシピサービスの アーキテクチャ改善プロジェクト、「お台場プロジェクト」の戦略について話します。

これまで、お台場プロジェクトで行った施策について対外的に発表したことはあっても、 全体戦略について話したことはありませんでした。 その一番の理由は、正直に言って、プロジェクトオーナーであるわたしにもプロジェクト全体の姿が見えていなかったからです。 しかし現在プロジェクト開始から1年半が経過してようやく全貌が見えてきたので、すべてをお話ししようと思います。

クックパッドの「本体」システム

クックパッドは現在では大小様々なサービスをリリースしています。 しかしその中でも最初期から存在し、 現在でもあらゆる意味で中核にあるサービスがいわゆる「クックパッド」、 社内では「本体」や「cookpad_all」さらに略して「all」などと呼ばれているレシピサービスです。

このレシピサービスは世界最大のモノリシックなRuby on Railsサービスであり、 いま手元で適当に数えただけでRubyのコードが27万行(テストを除く)、 テストが51万行、HTMLテンプレートが14万行あります。 このコード量でウェブサービスのcookpad、APIサーバーのpantry、 バッチのkuroko、管理画面のpapaの4アプリケーションを主に実装しています。

お台場プロジェクトとは

そして、この巨大な「本体」システムのアーキテクチャを根本から刷新し、 改善するプロジェクトが「お台場プロジェクト」です。 わたしがこのお台場プロジェクトを開始したのは去年(2017年)のバレンタインデー、2月14日のことでした。 そのときやったことは「とりあえず改善したいことをリストアップする場所」としてGitHubにレポジトリを作っただけでしたが、 その後にお台場プロジェクトは技術部の注力課題に昇格してメンバーも大幅に増員され、1年半が経過しました。

約30万行というコードサイズは世間一般で言えば超巨大とは言えないでしょうが、 少なくとも容易なプロジェクトではないことは確かです。 2017年2月の時点では、このプロジェクトを達成できると言う人も、俺がやると言う人も社内にはいませんでした。 むしろ、本体には関わりたくない、できるだけ触りたくない、 最低限の機能追加以外の余計なことはしたくないという雰囲気が充満していたように思います。

お台場プロジェクトが目指すもの

お台場プロジェクトの目的は、「本体」にも大規模な機能追加・変更ができるようにすること、 さらにその大規模な変更をできるだけ少ない時間でできるようにすることです。 逆に言うと、プロジェクト開始時点では、思い切った機能追加はできなかったということになります。

大規模な変更を行えない技術的な理由としては以下のような点が挙げられます。

  • コードを変更すると意図しないところが壊れる。例えばウェブサービスをいじるとガラケーの認証が壊れる。
  • ライブラリが古かったとしても依存が多すぎて気軽に更新できない。
  • 実行環境が非常に複雑かつ特殊で、迂闊にデータベースを追加したりできない。
  • 普通のツールが動かない。例えばコードカバレージが取れない、並列テストが動かない。
  • ObjectクラスやStringクラスのような非常に基本的なクラスが改変されており、普通の動きをしない。

また、組織的・プロセス的な理由もあります。

  • あるコードのオーナーが誰かわからない。例えばuserリソースのAPIを変更したくても誰にも相談できない。
  • GitHubのissue・pull requestが多すぎてとても全部は見ていられない。通知も何も機能しない。
  • 「本体」をいじる開発者が多すぎて、改善系のpull requestを作ると頻繁にコンフリクトする。

「本体」で大きな変更を行おうと思うと、これらの問題がすべて同時に襲いかかってきます。 例えばI/Oの激しいシステムを追加するためにDynamoDBを使いたいと思ったとしても、 AWS-SDKのバージョンが古いのでまずはSDKのバージョンを更新するのに1ヶ月かかる、 テストが遅いので検証にも時間がかかり、そのあいだに別のpullreqがマージされてコンフリクト、 実装を進めていくと既存のクラスに変更が必要そうなことがわかってきたがオーナーが誰かはわからない、 がんばって実装してみたが触ってもいないバッチのCIが通らない、 ようやく理由がわかって直してデプロイしたらなぜかガラケーサイトが落ちた……という具合です。

これはさすがに誇張だろうと思われるかもしれませんが、残念ながらすべて事実を元にした話です。 こんな開発を続けていれば、大きな機能を追加しようという気がなくなって当然でしょう。

タスクの優先順位の決定

こんな状態ですから、なんらかの改善をしなければいけないことはわかります。 しかし、はたして何から手を付けたらよいものでしょうか。 まずは問題をリストアップしてはみたのですが、あまりにも問題が多すぎ粒度もバラバラ、しかもどれも大変そうで腰が引けます。 タスクを絞るところが肝であることはどう考えても明らかでした。 そこで、ビジネス上の価値と政治的判断を加味しつつ、「やること」「やらないこと」を次のようにエイヤと決めました。

やること:

  • APIサーバー(pantry)のアーキテクチャ改善
  • 不要なサービスの廃止
  • デッドコードの自動検出と削除
  • ストレージ数の削減
  • 特殊な実行環境・開発環境の廃止

やらないこと:

  • ウェブサービス(cookpad)のビュー改善
  • Railsのメジャーバージョンアップ
  • 細かい実装レベルの改善

最初に決めなかったこと:

  • 全面的にmicroservice化するかどうか

まず、ユーザー数と有料会員数の分布、将来向かう方向を考えると、 ウェブやガラケーよりもスマホアプリのAPIサーバー(pantry)が重要であることは明らかなので、 そのアーキテクチャ改善につながらないタスクは原則捨てると決めました。 そうすると例えばウェブのビューの実装改善などは自動的に「やらないこと」になります。

microservices化はギリギリまで先送り

一方で、先に述べたように、本体のコードはオーナーがよくわかりませんし、 最初からmicroservices化するとは決断できなかったので、 いきなりそこに大々的に踏み込んでいくことはしませんでした。 例外は検索システム1つだけです。

最終的にコードをなにかしら分割することは避けられないとは思っていましたが、 サービスとして分割する以外にも、たとえばRails engineとして分割するなど代替案はいくつかあります。 とにかく戦略的に、大々的に分割するぞーと宣言して分割はしたけど失敗でしたという事態だけは絶対に避けたかったので、 どうしても分割を避けられない時が来るまで決断を遅らせることにしたわけです。

これほど慎重になった背景にはもちろん理由があります。 実は、クックパッドでは2015年から2016年くらいにすでに一度microservices化を試して失敗した経験があるのです。

当時はmicroservices化の機運が社内で盛り上がっており、 今後「本体」に機能を追加するときは必ずmicroservicesに分けようということになりました。 しかしこのときの経過が非常にまずくて、業務プロセスを複数のアプリに分断してしまって作業が増えたり、 管理アプリだけ別サービスに分割したことによってひたすら新規APIを作るはめになったりと、 microservices化全般に悪い印象だけが残ってしまったのです。 後者の管理アプリに至っては最終的に「本体」の管理アプリに統合され、microservicesではなくなってしまいました。

この時の経験があったため、最初からシステム分割を行うのはできるだけ避けることにしました。 最初に社内のエンジニアにお台場プロジェクトの話をしたときも、 microservices化に反対する意見が出た場合に備えて想定問答集を作ったほどです。 もっとも実際に話してみると、当時とは社内外の状況が変わったこともあって、反発はほとんどありませんでした。

f:id:mineroaoki:20181228000116j:plain
お台場プロジェクト発表時の社内の様子

ちなみにそのときの写真がこれで、わたしが用意した大変わかりやすいスライドで成田CTOが喋っているの図です。 お台場プロジェクトが終わってからプレスリリースを打つときに使おうと思っていた秘蔵写真ですが、 いい機会なので公開します。

最初は確実に成果を出せるコード削除を実施

いきなりサービス分割をしない代わりにまずやったタスクが、古いサービスの廃止と、古いAPIサーバーの廃止です。 ほぼ誰も使っていない機能の廃止はトレードオフがほぼないので、どう考えてもやったほうが得であり、 しかも誰も反対しないからです。まず最初にわかりやすい成果を作るために最適のタスクでした。 社内に効果を示すためにも、チームが自信を得るためにも、 比較的簡単にできて結果のわかりやすいタスクから始めるのは妥当でしょう。 時期的にもちょうど大きな機能が分社されて消せるコードがたくさんありました。

また、今後コードを消していくうえで、 本番で使われていないコード(デッドコード)がツールで自動判定できたら非常に楽ができるので、 その方法を少し調べて実施することにしました。 その結果できあがったのがRubyのLazy Loadingを使って実行されないコードを探す手法です。 現在ではこのシステムによって自動的に不要コードを検知できるようになっています。 さらに、このデッドコード検出機能はブラッシュアップされてRuby 2.6にも取り込まれました

「Railsのメジャーバージョンアップはしない」

Railsのメジャーバージョンアップもお台場プロジェクトではやらないと最初に決めました。 これは単純に「Railsをバージョンアップしたところでアーキテクチャの根本改善にはつながらない」 という理由もありますが、その他に一種のシンボル的な意味もあります。

この点を説明するには、まず少しだけクックパッドの組織構造の話をする必要があります。 クックパッドでは永らく、 「本体」のソフトウェアアーキテクチャ(主にRails)については技術部の開発基盤というグループが責任を持ち、 それより上の機能については各事業部が分割して持つという責任分担が行われてきました。 結果として2016年までの数年は、開発基盤グループに新しい人が入るととりあえず 「本体」のRailsバージョンアップをするというのが洗礼の儀式のように行われていたのです。 しかしこのタスクが技術的にも政治的にも非常につらく、 結果として若者が「本体」に対するヘイトをためていく構造になっていました。

そういった歴史の結果として、クックパッドにおいては「Railsのバージョンアップ」というタスクが 「これまでの開発基盤の役割」とほぼ同じ意味を持っています。 そんな状況で、開発基盤で新しいプロジェクトを始めます、 それじゃあまずRailsバージョンアップをやりますと言ったら、いままでと何も変わりません。 中身は同じで名前だけ変えたんですねということになりかねないからです。

お台場プロジェクトはシステムアーキテクチャの改善プロジェクトであると同時に、 組織アーキテクチャの改善プロジェクトでもあります。 巨大な1つのシステムをメンテするのはもはや手に余るので分割統治しよう、 というのがお台場プロジェクトの目的ですから、組織もいまのままであるわけがありません。 具体的には、開発基盤グループが不要にならなければいけません。

つまりある意味で開発基盤を解散させるプロジェクトでもあるお台場プロジェクトで、 これまでの開発基盤と同じことをやるというのはどう考えても筋が通らないわけです。 ですから、お台場プロジェクトでは絶対にRailsのバージョンアップはしないと決めました。

アプリケーション構造の整理

コード削除とほぼ同時にやったのが、アプリケーション構造の整理でした。

これについてはそもそも問題自体の説明が必要でしょう。 「本体」システムはウェブサービスやAPIサーバー、非同期ジョブ、 バッチなど複数のRailsアプリケーションからなるのですが、 そのうちAPIサーバーと非同期ジョブはウェブサービスの「モード」として実装されていました。 ウェブサービスを起動したとき、特定の名前のファイルが存在したら APIサーバーのエントリポイントが生えてAPIサーバーとして動くという、凄まじい実装がされていたのです。

プロジェクト開始から2017年いっぱいくらいにわたって、このものすごい実装を排除しました。 APIサーバー(pantry)は独立したアプリケーションとし、非同期ジョブ(background-worker)はバッチ(kuroko)に置き換えるなどして廃止。 同時に古いAPIサーバー(api, api2)を消したこともあり、アプリケーション構造はだいぶシンプルにすることができました。

f:id:mineroaoki:20181228000249p:plain
アプリケーション構造の整理

全面的なmicroservices化を決断

当初はmicroservices化を前面に出すかどうかはまだ決めかねていたのですが、 現在はすでに全面的にmicroservices化することを決めています。 その決め手となったのは、最初に小さな機能を分割してみて、その効果が明白に感じられたことでした。

具体的には、スマホアプリのA/Bテストなどに使っているuser_featuresという機能を分割した時点です。 この機能はもともと技術部がオーナーでしたし、専用Redis 1つだけにアクセスする構造になっていたため、 政治的にも技術的にも都合がよかったのです。 そこでこの機能を分割してみたところ、分割したあとのほうが明らかにつくりがわかりやすく、 改善しやすくなりましたし、実際に改善が進みました。 誰が実装すべきかも明確で、それでいて他の部署の人間も逆に手を出しやすくなったと感じています。 やはりコード共有というのは「誰も持っていない」のではだめで、オーナーありきのほうがうまくいくなと感じます。

わたし個人としても最近、本体のAPIサーバー(pantry)にとある機能を追加するためにmicroserviceを1つ実装したのですが、 DynamoDBを中心としたアーキテクチャ設計からstaging環境・production環境の構築に最小の実装までを、わたし1人で1週間弱で終えることができました。 これはお台場プロジェクトをやっていなければとてもできなかったことです。 もし2016年時点のpantryでこれをやれと言われたら何ヶ月必要になっていたか予想できません。

すべてがHakoになる

もともとクックパッドではmicroservicesのためのインフラは整備されつつありました。 例えば次のようなアプリケーションやミドルウェアが稼働しています。

さらに直近ではRubyのgRPCライブラリの置き換えなども行われています。 すでに新規のサービスはすべてこれらのインフラに乗っていますが、「本体」をどうするかだけはずっと宙に浮いた状態だったわけです。

2018年になって、「本体」もこの共通インフラに乗せると決めた時点で、話は非常にシンプルになりました。 現在では徐々にではありますが「本体」がHako化(コンテナ化)されつつあり、来年内の完了を見込んでいます。 社内のすべてのシステムがmicroservices構成になり、コンテナで動く状態が視界に入ったと言えるでしょう。

microservicesへの分割戦略

さて、microservices化を決断した場合、次に問題になってくるのが、「どこでサービスを切るか」です。 正直、これはシステム設計の話なので、パッケージをどう分けるか、クラスをどう分けるかと同じようなものであり、 決定的な基準がありません。

しかし、特に「本体」システムに限って言えば分割する場合の成功パターンがわかってきました。

そのパターンとは、データベースがすでに分かれている機能については、データベースを中心としてそれに紐付く部分を分割することです。 「本体」はRailsアプリケーションにしては珍しいことに、非常に大量のデータベースにアクセスしています。 database.ymlを見る限りだと、実に20以上のデータベースが接続されているようです。 これらのデータベースを、データベースとそれに紐付くコードをまとめて分割すると、 意味的にもデータフロー的にも無理がなく分割できることがわかりました。 これは冷静に考えてみれば当然と言えば当然なのですが、 このことに気付いてからは、「データベースが切れているならシステムも切れる」というわかりやすい基準ができました。

具体的には、Solrを核として分離したレシピ検索のシステム(voyager)、 専用Redisを中心として分離したA/Bテスト機能(user_features)、 専用Auroraをベースとして分離した「料理きろく」機能などがこのパターンでうまく分割できた例です。 今後もこのパターンに沿って、専用MySQLを持つブックマーク機能(MYフォルダ)や、 投稿者向けの統計機能(キッチンレポート)を分割していく予定です。 逆に、メインの一番巨大なMySQLに紐付いた機能群は最後に分割することになるでしょう。

microservicesに分割するという話になった当初は分割の基準がよくわかっていなかったので、 例えば「レシピのようによく使うリソースを最初に切り出すのがよいのではないか」 「事業部3つに合わせていきなり3つに分割しよう」などなど、様々な考えが錯綜していました。 その根底には、ひとまず切り出せそうな部分はいくらか見えているのだが、 それを順番に地道に分割していくくらいではいつまでたっても本丸のコア部分の分割まで至らないのではないか……という焦りがあったと思います。

しかし実際にやってみて最もうまくいった分割方法はやはり「データフローが明確に切れるところで切る」ことです。 慌てず騒がずデータフローを分析して、端から削り切るのが結局は最短の道だと思います。

大きな静的データの共有問題

microservicesへ分割していくうえで他に困ることの1つが「大きな静的データの共有」の問題です。 例えばクックパッドだとテキストの分析に使われている専用辞書がこれにあたります。 この辞書は検索サービスからも検索バッチからもレシピサービスからも、 その他ありとあらゆるところから頻繁にアクセスされており、 これを果たして単純に単体サービスとして分割してしまっていいものか難しいところでした。

『マイクロサービスアーキテクチャ』などによると、 このようなタイプのデータは原則としてはサービスにするよりデータとして配布してしまったほうがよいようです。 クックパッドでは偶然にも、この辞書をGDBM化してメモリに乗せる仕組みが少し前に入っていました。 そこでこの仕組みを利用して、GDBMファイルを各アプリケーションに配布することでひとまず解決をみました。

その後、GDBMファイルのバージョン問題にぶちあたって少し方式を変更したりもしましたが、 いまのところうまく動いています。

APIオーケストレーション層の導入: Orcha

microservices化に関する直近の試みはAPIオーケストレーション層「Orcha(オルカ)」の導入です。 オチャではありません。 これはわたしが勢いだけで入れてみたものなのですが、思ったより便利で驚いています。

OrchaはJavaで実装されており、 Spring ReactorとSpring Fluxをベースとしたリアクティブプログラミングを活用しています。 下図のようにリバースプロクシ(rproxy)とAPIサーバー(pantry)の間に入り、 pantryを含めたmicroservices群のAPIを統合して、スマホアプリ用のAPIを提供します。

f:id:mineroaoki:20181228000351p:plain
APIオーケストレーション層Orcha

オーケストレーション層を入れようと思った最初の動機は、 スマホアプリから複数のAPIを呼ぶレイテンシーを削減することでした。 しかし実際に入れてみていま感じている最大の利点は、 「本体」にさわらずに既存のAPIを拡張できるという点です。 アーキテクチャを改善していくうえで非常に便利な道具が一つ加わったと感じています。

例えばレシピを取得するAPIに新しい情報を差し込みたい場合であれば、 本体のAPIサーバーが返したJSONを加工して情報を追加することで達成できます。 ようするに、「高機能なJSON用sed」のような動きをしているわけですね。

今後の展開

お台場プロジェクトは来年2019年から、最終の第4期に突入します。 これから1年強は、本体を片っ端から分離しHako化するという正面対決になるでしょう。 最初はデータベースが分かれている機能から分離を始め、徐々にメインDBを切り崩します。

また、せっかくOrchaという新しい自由な遊び場ができたので、 それを活用してGraphQLの導入を試してみようと思っています。 ちょうどスマホアプリの側でもiOS, Androidともにアーキテクチャが刷新されつつあるので、 新しい仕組みを導入するにはいいタイミングです。

まとめ

本稿では、クックパッドの中核たるレシピサービスのアーキテクチャを改善する 「お台場プロジェクト」について、その戦略のすべてをお話ししました。 特に意識して行ってきたことは次のような点です。

  • 意図的にこれまでとの違いを出すタスク選択
  • 最初は成果の出しやすいコード削除から
  • microservices化は小さく試して全面展開
  • オーケストレーション層で展開の自由度を高める

現在のところプロジェクトの最大の問題は、とにかく人が足りないということです。 エンジニアは全分野で足りていないのですが、サーバー側は特に足りません。 Railsならまかせろ!な方にも、Railsブッ殺す!な方にも、 やりごたえのある楽しいタスクがありますので、 ぜひ以下のフォームから応募をお願いいたします。

https://info.cookpad.com/careers

Special Thanks 〜またの名を戦績リスト〜

わたしはお台場プロジェクトについてはあくまで戦略レベルしか関与しておらず、 実装レベルの判断は特に聞かれない限り担当者にすべて任せています。 その点で、お台場プロジェクトは個々のエンジニアの力量によるところが大きいプロジェクトであり、 ここまで来られたのはすべてメンバーのおかげと言ってよいでしょう。 この記事の最後に、各自が撃破したタスクを記して、終わりにしたいと思います。 なお、プロジェクト開始から2018年内までに完了したものだけを、だいたい時間順に列挙しています。

※書き忘れてるやつあったらすまん……

Dynamic Type

$
0
0

モバイル基盤部のヴァンサン(@vincentisambart)です。

使っているアプリのフォントサイズを変えたいと思ったことありますか?目があまり良くないから文字を大きくしたい。逆にもっと多くの情報を一目で見られるために文字を少し小さくしたい。

フォントサイズをアプリ内で調整できるアプリもありますが、iOSではシステム全体のフォントサイズを調整できる「Dynamic Type」と呼ばれる機能があります。iOS全体の設定に一般→アクセシビリティ→さらに大きな文字(英語だとSettingsにGeneral→Accessibility→Larger Text)で変えられます。対応しているアプリではフォントサイズがその設定に合わせられます。

標準より少し小さくできるとはいえ、「アクセシビリティ」設定に入っているのもあって文字を大きくする方がメインのユースケースの気がします。

Dynamic TypeはiOS 7から使えるようになりましたが、関連している様々な便利機能がその後のiOSバージョンに追加されました。

一応Dynamic Typeを使うにはAuto Layoutは必須ではないのですが、手動レイアウトと一緒に使うのはおすすめできません。

システム設定変更

設定を変える前に、簡単に戻せるようにまずは標準設定を覚えておきましょう。標準設定は以下のスクリーンショットのように「さらに大きな文字」が無効で、下部のスライダーがど真ん中です。

Dynamic Type標準設定

スライダーを動かすとフォントサイズがどれくらい変わるのかすぐ見られます。一番小さい設定(extra smallまたはXS)にすると、以下のようになります。

f:id:vincentisambart:20190104132028p:plain:w320

「さらに大きな文字」無効のまま一番大きい設定(extra extra extra largeまたはXXXL)にすると以下のようになります。

f:id:vincentisambart:20190104132100p:plain:w320

アクセシビリティサイズはXXXLより大きく、「さらに大きな文字」を有効にすると選択できるようになる5つの設定です。その一番大きい設定(accessibility extra extra extra large、またはAX5)にすると以下のようになります。

f:id:vincentisambart:20190104131951p:plain:w320

因みにDynamic Type対応しているアプリでも、アクセシビリティサイズをXXXLと同じ扱いにしているアプリもあります。文字がとても大きいのでレイアウトが崩れやすいからでしょう。

設定の値

ユーザーに選択されたDynamic Type設定はUIContentSizeCategoryという型で、UIApplication.shared.preferredContentSizeCategoryまたは(iOS 8以上では)traitCollection.preferredContentSizeCategoryで取得できます。現時点(iOS 12)で利用できるすべての値は以下のとおりです。

  • unspecified (iOS 10以上)
  • extraSmall (XS, xSmall)
  • small (S)
  • medium (M)
  • large (L) – 標準設定
  • extraLarge (XL, xLarge)
  • extraExtraLarge (XXL, xxLarge)
  • extraExtraExtraLarge (XXXL, xxxLarge)
  • accessibilityMedium (AccessibilityM, AX1)
  • accessibilityLarge (AccessibilityL, AX2)
  • accessibilityExtraLarge (AccessibilityXL, AX3)
  • accessibilityExtraExtraLarge (AccessibilityXXL, AX4)
  • accessibilityExtraExtraExtraLarge (AccessibilityXXXL, AX5)

設定画面で「さらに大きな文字」が有効になっているときだけに選択できるアクセシビリティサイズは名前がaccessibilityから始まります。iOS 11以上では、アクセシビリティサイズかどうか確認するためにUIContentSizeCategoryisAccessibilityCategoryというメソッドがあります。また、同じくiOS 11以上でUIContentSizeCategory<, <=, >=, >で比較できるようになるのでtraitCollection.preferredContentSizeCategory > .extraExtraExtraLargeも使えます。

content size categoryの値によって、レイアウトを変えたりできます。例えばDynamic Typeの設定画面で大きいアクセシビリティサイズを選ぶ時、「さらに大きな文字」スイッチがラベルの右からその下に移ります。Dynamic Type設定によってUIStackViewの設定が変わるのでしょう。

f:id:vincentisambart:20190104131951p:plain:w320

自分のアプリ内、Dynamic Typeの設定に合わせてするレイアウト変更は作成時以外、どういうタイミングでやれば良いのでしょうか。

変更に反応

アプリがバックグラウンドにある間に、ユーザーがDynamic Typeの設定を変えるとアプリが終了されるわけではありません。ユーザーがアプリに戻ったら、アプリが変更を知らされる仕組みが2つあります:

  • UIContentSizeCategory.didChangeNotificationというnotificationがアプリに送られます。
  • iOS 8以上ではtraitCollectionDidChangeメソッドが呼ばれます。UITraitEnvironmentプロトコルのメソッドですが、UIViewUIViewControllerが実装しているので、自分のビューやビューコントローラに以下のコードでDynamic Typeの設定変更に反応できます。
overridefunctraitCollectionDidChange(_ previousTraitCollection:UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
        // 設定が変わったときにやりたいこと
    }
}

Text Style (書式)

設定によってレイアウトを変えても、Dynamic Typeの「Type」は文字、フォントのことなので、肝心のフォントサイズはどうすれば良いのでしょうか。アプリに使われているすべてのスタイルをcontent size categoryごとに用意するのは手間が掛かりすぎます。

そのため、iOS 7からシステムにいくつかのText Styleが用意されています。ワードプロセッサーでいうと「書式」と同じ概念です(ただし自分の好みに合わせて変えることができません)。Human Interface Guidelines(HIG)のTypographyページにスタイルのリストと各content size categoryでのフォントサイズが見られます。アクセシビリティサイズも同じページの少し下にAX1, AX2, AX3, AX4, AX5として紹介されてあります。因みにフォント自体はシステムフォント(San Francisco)です。

そのスタイルはコード上ではUIFont.TextStyleです。iOS 7ではスタイルが少なかった(body, caption1, caption2, footnote, headline, subheadline)のですが、iOS 9(callout, title1, title2, title3)やiOS 11(largeTitle)で少し増えました。

スタイルによって変わるのはフォントサイズだけではなく、headlineスタイルの場合、フォントがボルドになります。

large設定では、iPadで各スタイルを表示すると以下のようになります。

f:id:vincentisambart:20190104132124p:plain:w500

extraExtraExtraLarge設定では、以下のようになります。

f:id:vincentisambart:20190104132150p:plain:w500

accessibilityExtraExtraExtraLarge設定では、以下のようになります。この最大サイズでは各スタイルの差があまりないですね。

f:id:vincentisambart:20190104132207p:plain:w500

コードでは、UIFont.preferredFont(forTextStyle:)がメインのAPIです。UIFont.preferredFont(forTextStyle: .body)が今のDynamic Type設定に合っているbodyスタイルのフォントを返してくれます。

Interface Builderでは、スタイルをlabelやtext viewのFont設定にText Stylesの中で選べます。

f:id:vincentisambart:20190104131624p:plain:w390

自動フォント調整

上記のセクションの使い方だけでは、アプリがバックグラウンドにある間にユーザーがDynamic Typeの設定を変えてアプリに戻ったら、新しい設定が自動的に反映されるわけではありません。自分でcontent size categoryの変更に反応して、labelやtext viewのフォントを指定し直す必要があります。テキストスタイルをコードで指定していればまだ良いのですが、Interface Builderで指定した場合、コードで再指定することになってしまいます。

その問題を避けるため、iOS 10以上ではUIContentSizeCategoryAdjustingプロトコルに準拠しているクラス(UILabel, UITextField, UITextView)にadjustsFontForContentSizeCategoryというプロパティがあります。Interface Builder上の「Automatically Adjusts Font」と同じです。

f:id:vincentisambart:20190104131820p:plain:w325

以下その機能を「自動フォント調整」と呼ぶことにします。

自動フォント調整が上記のプロパティで有効になっているビューは、Dynamic Type設定変更後、バックグラウンドにあったアプリに戻るとフォントサイズが自動的に更新されます。ただし、フォントサイズが更新されるのは上記に紹介したtext style、またはあとで紹介するfont metrics、が使われている場合のみに有効です。

有効にされたら自動フォント調整が効く 有効にされても自動フォント調整が効かない
label.font = UIFont.preferredFont(forTextStyle: .body)label.font = UIFont.systemFont(ofSize: 12)
Interface BuilderでフォントにText Stylesのどれかを選んだ Interface Builderでカスタムやシステムフォントを選んだ

実際Interface Builderでテキストスタイルでないフォントを指定して、「Automatically Adjusts Font」にチェックを入れた場合、ビルド時に警告が出ます。コードでそのビューのフォントに自動フォント調整が効くフォントを指定したら、ちゃんと動きますがビルド時の警告が残ります。なので、コードでフォントを指定する場合、自動フォント調整を有効にするのもコードでやる(view.adjustsFontForContentSizeCategory = true)のが良いでしょう。

カスタム

テキストスタイルAPIはシステムフォントしか使えませんし、システムフォントを使うとしても、スタイルの種類が多くありません。

テキストスタイルAPIが用意されたスタイルを使う前提で作られたとしても、コードで自分の定義したサイズやフォントを使う方法がなかったわけではありません。少し考えるだけで以下の2つの方法がすぐ思いつくのでしょう。

  • switch preferredContentSizeCategory { ... }– すべてのcontent size categoryの分のフォントとサイズを用意します。かなり手間が掛かりますし、スタイルを変えたいときも大変です。
  • ((UIFont.preferredFont(forTextStyle: .body).pointSize / 17.0) * myFontSize).round() (17.0がbodyスタイルの標準のポイントサイズです)で自分のフォントのサイズをcontent size categoryに合わせて調整します。

実は、現時点でクックパッドiOSアプリがDynamic Typeに対応している唯一の画面、レシピ詳細では長い間後者を使っています。

もちろんこの2つの方法では、自動フォント調整が使えません。Dynamic Typeの設定に変更があったときにフォントサイズを調整したければ、「変更に反応」セクションで紹介した方法で変更に反応して各ビューのフォントを再指定します。

Deployment TargetがiOS 10以前のアプリはカスタムなスタイルを使いたかったら、上記の方法しかありませんが、iOS 11以上だと、もう少し楽なAPIが提供されています。

UIFontMetrics

iOS 11から誕生したUIFontMetricsクラスを使うと、好きなフォントやフォントサイズを選んで、そのカスタムなスタイルのサイズをDynamic Typeの設定に合わせられるだけではなく、自動フォント調整も使えます。

使い方は以下のようです。

letcustomFont= UIFont(name:"AmericanTypewriter", size:17)!// 好きなサイズのシステムフォント`UIFont.systemFont(ofSize: myCustomSize)`でも大丈夫ですletscaledFont= UIFontMetrics.default.scaledFont(for:customFont)
label.font = scaledFont
label.adjustsImageSizeForAccessibilityContentSizeCategory =true

もしフォントサイズが大きすぎるとレイアウトが崩れてしまう場合、最大フォントサイズを指定できます。

letscaledFont= UIFontMetrics.default.scaledFont(
    for:UIFont.systemFont(ofSize:customFont),
    maximumPointSize:50.0
)

UIFontMetricsにはフォントだけではなく、画像のサイズを文字の大きさに合わせるためのメソッドもあります。

letscaledSize= UIFontMetrics.default.scaledValue(for:sizeToScale)

シンプルですね。懸念点が1つあります:Interface Builderでその機能が使えません。ビューをInterface Builderで配置しても良いのですが、フォントはコードで指定する必要があります。

メトリックスはテキストスタイル次第

上記のコードでUIFontMetricsのインスタンスはUIFontMetrics.defaultを使っていましたが、UIFontMetricsのドキュメントを見ると、init(forTextStyle textStyle: UIFont.TextStyle)もあります。実はUIFontMetrics.defaultUIFontMetrics(forTextStyle: .body)に初期化されたものです。UIFontMetrics(forTextStyle: .title1)UIFontMetrics(forTextStyle: .caption2)なども使えます。

どうしてテキストスタイルを指定できるのかと言いますと、Dynamic Type設定によってフォントのサイズがどう変わるのかはテキストスタイルごとに少し違うためです。以下の図を見ればもう少し分かるかと思います。図に使われた数値をどう計算したのかあとで説明します。

f:id:vincentisambart:20190104131516p:plain

カスタムスタイルごとにサイズの推移をどのシステムスタイルに合わせたいのか決めるのが大変だと思うので、分からない時はUIFontMetrics.defaultを使って良い気がします。

UIFontMetricsの計算

上記は説明せずに図を出しましたが、実際サイズがどう計算されるのでしょうか?UIFontMetrics.scaledFont(for:)の返しているフォントの自動フォント調整対応は自分で実装できないかもしれませんが、計算くらいはできるのではないでしょうか。

実はUIFontMetrics.scaledFont(for:)に使われる計算が上記の「カスタム」セクションで紹介したすぐ思いつきそうな((UIFont.preferredFont(forTextStyle: .body).pointSize / 17.0) * myFontSize).round()に近いです。ただし、割合はpoint sizeからではなく、leadingから計算されています。

数字は既に紹介しましたHIGのTypographyページのDynamic Type Sizesにある「Leading」にあります。図に使った数字は対象のcontent size categoryのleadingを標準(large)のleadingで割っただけです。

因みにフォントに関して「Leading」は「リーディング」ではなく、「レディング」と読みます。

TypographyページにあるLeadingというのは日本語で「行送り」のことです。1行のベースラインから次の行のベースラインまでの距離です。

UIFontにあるleadingは違っていて、こっちは「行間」のことです。TypographyページにあるLeadingがUIFontでいうとleading + lineHeightです。

いろんな説明よりコードの方が分かりやすいかもしれません。下記のコードに使われているUIFont.preferredFont(forTextStyle:compatibleWith:)がiOS 10以上でしか使えませんが、もっと古いiOSバージョンではleadingの値のテーブルをコードに埋め込めば簡単に実装できるしょう。

因みに下記のコードに使われているUITraitCollectionが表示に影響ありそうな環境の状態(サイズクラス、画面スケール)や設定(Dynamic Type)をまとめるものです。UIFontMetricsの場合、サイズがどう変わるのか見たいとき、Dynamic Type設定をいちいち変えるのが大変なので、UITraitCollectionを渡すのが良いのですが、ビューのフォントを指定する場合は基本的にUITraitCollectionを渡さず、ユーザーの設定に合うものを求めます。

import UIKit

privateextensionUIFont {
    // https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/typography/#dynamic-type-sizes にある"leading"に相当します// 実はUIFontには`_bodyLeading`というメソッドがありますが、プライベートなので再実装が必要ですvarbodyLeading:CGFloat {
        return lineHeight + leading
    }
}

// `UIFontMetrics`の機能の一部を再実装するクラス// 注意:自動フォント調整対応のフォントを作るには本物の`UIFontMetrics`を使うしかありませんclassSimpleFontMetrics {
    privatevartextStyle:UIFont.TextStyleinit(forTextStyle textStyle:UIFont.TextStyle) {
        self.textStyle = textStyle
    }

    staticlet `default`:SimpleFontMetrics= .init(forTextStyle: .body)

    privatestaticletdefaultContentSizeCategoryTraitCollection= UITraitCollection(preferredContentSizeCategory: .large)

    privatefuncunroundedScaledValue(for value:CGFloat, compatibleWith traitCollection:UITraitCollection?) ->CGFloat {
        letdefaultFont= UIFont.preferredFont(forTextStyle:textStyle, compatibleWith:SimpleFontMetrics.defaultContentSizeCategoryTraitCollection)
        letcurrentFont= UIFont.preferredFont(forTextStyle:textStyle, compatibleWith:traitCollection)

        return (value * currentFont.bodyLeading) / defaultFont.bodyLeading
    }

    // `UIFontMetrics.scaledValue(for:compatibleWith:)`と同じですfuncscaledValue(for value:CGFloat, compatibleWith traitCollection:UITraitCollection? =nil) ->CGFloat {
        // 表示に使われている画面のスケール(ポイントごとのピクセル数)letdisplayScale:CGFloat// `traitCollection.displayScale`が0だったら未定だということなので`traitCollection`が指定されていないと同じ扱いですiflettraitCollection= traitCollection, traitCollection.displayScale !=0 {
            displayScale = traitCollection.displayScale
        } else {
            displayScale = UIScreen.main.scale
        }
        // ピクセル単位で四捨五入return (unroundedScaledValue(for:value, compatibleWith:traitCollection) * displayScale).rounded() / displayScale
    }

    // `UIFontMetrics.scaledFont(for:maximumPointSize:compatibleWith:).pointSize`に相当しますfuncscaledFontPointSize(for pointSize:CGFloat, maximumPointSize:CGFloat=0, compatibleWith traitCollection:UITraitCollection? =nil) ->CGFloat {
        assert(pointSize >=0, "You cannot create a font of negative size.")
        // フォントサイズの四捨五入はピクセル単位ではなくポイント単位ですletscaledPointSize= unroundedScaledValue(for:pointSize, compatibleWith:traitCollection).rounded()
        if maximumPointSize ==0 {
            return scaledPointSize
        } else {
            return min(scaledPointSize, maximumPointSize)
        }
    }
}

UIFontMetricsの少し不自然なところ

上記にUIFontMetricsがサイズ計算に使っている比率がフォントのサイズ(pointSize)のではなく、行送り(leading + lineHeight)のだと書きましたが、フォントサイズの計算にその比率が使われるから少し不自然な結果になることがあります。

letlargeTraitCollection= UITraitCollection(preferredContentSizeCategory: .large)
letaxxxlTraitCollection= UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge)
letbaseFont= UIFont.preferredFont(forTextStyle: .body, compatibleWith:largeTraitCollection)
UIFont.preferredFont(forTextStyle: .body, compatibleWith:axxxlTraitCollection).pointSize // => 53
UIFontMetrics.default.scaledFont(for:baseFont, compatibleWith:axxxlTraitCollection).pointSize // => 48

標準文字サイズ(large)のbodyスタイルのフォントをUIFontMetricsで一番大きいDynamic Type設定に拡大されたフォントと、一番大きいDynamic Type設定でのbodyのフォントはサイズが少し異なります。そのため、UIFont.preferredFontUIFontMetrics.scaledFontを同じ画面で混在させない方が良いかもしれません。

因みに、今回はその方が分かりやすかったのですが、普段はUIFont.preferredFontの戻り値をUIFontMetrics.scaledFontを渡さないでおきましょう。一応動くのですが、UIFontMetrics.scaledFontの戻り値をまたUIFontMetrics.scaledFontに渡すとObjective-Cの例外が発生します。

画像

テキストの横に画像があると、その画像をある程度テキストのサイズに合わせたいことがあるかもしれません。手動でUIFontMetrics.scaledValue(for:)を使ってもできますが、iOS 11からUIButtonUIImageViewが準拠しているUIAccessibilityContentSizeCategoryImageAdjustingプロトコルにadjustsImageSizeForAccessibilityContentSizeCategoryというプロパティが登場しました。Interface Builderにある「Adjust Image Size」と同じです。

f:id:vincentisambart:20190104132302p:plain:w325

そのプロパティがtrueの場合、Dynamic Type設定によってビューの画像が拡大されますが、アクセシビリティサイズが選ばれている場合のみです。他の設定ではサイズが変わりません。

サイズ調整で画像が大きくなると荒くなってしまう可能性があります。もとの画像がPDFでしたら、Asset Catalogの設定で「Preserve Vector Data」にチェックを入れたら綺麗に拡大されます。

f:id:vincentisambart:20190104132325p:plain:w325

その他

  • 広い画面でもコンテンツが広がりすぎないためにある読みやすい幅機能はDynamic Typeと一緒に使われるように設計されているので、ぜひ紹介記事をご覧ください。
  • 既存のアプリは全画面を一気に対応するのは難しいでしょう。1つの画面で対応しているビューとしていないビューが混在すると不自然な表示になりますので、画面単位で対応した方が良い気がします。
  • テーブルのセルの高さはできるだけシステムが自動計算するようにしましょう:estimatedRowHeightの指定を忘れず(指定されないと自動計算が動かないので)、高さ(rowHeight)をUITableView.automaticDimensionにします。rowHeightestimatedRowHeightUITableViewのプロパティで指定しても、UITableViewDelegateのメソッドで返しても、どっちでも大丈夫です。
  • Auto Layoutを使ってビューを配置するとき、ベースラインアンカー(firstBaselineAnchorまたはlastBaselineAnchor)に対する縦制約はconstraint(equalToSystemSpacingBelow:multiplier:)(またはlessThanバージョンgreaterThanバージョン)を使うと、制約の高さがフォントのサイズに合わせて変わります。Interface Builder上では「Constant」の値に「Use Standard Value」を選ぶのと同じです。

まとめ

Dynamic Type設定でユーザーが自分の視力や好みに合わせてフォントのサイズを選べますが、アプリ側での対応が必要です。

iOS 7でその機能が導入されてから、対応がやりやすくなる機能が少しずつ増えて、iOS 11以上ではだいぶ楽になったのではないでしょうか。

もっと多くのユーザーの使いやすさのために対応しているアプリを増やしておきましょう。

Cookpad TechConf 2019 を開催します!

$
0
0

こんにちは! 広報部のとくなり餃子大好き( id:tokunarigyozadaisuki)です。

2019年2月27日(水)、クックパッドの技術カンファレンス「Cookpad TechConf 2019」が開催されます。

公式サイト : https://techconf.cookpad.com/2019

f:id:tokunarigyozadaisuki:20190131135452p:plain

Cookpad TechConf 2019 について

【「Cookpad TechConf 2019」開催概要】
開催日:2月27日(水)12:30開場、17:30終了予定
場所:恵比寿ガーデンプレイス ザ・ガーデンホール(東京都目黒区三田1-13-2)
参加費:無料

「Cookpad TechConf 2019」は、クックパッドのサービスづくりのノウハウを発信する技術カンファレンスです。 私たちクックパッドは「毎日の料理を楽しみにする」というミッションを掲げ、世界中における食と料理の課題をテクノロジーで解決するために、様々な新規プロジェクトに挑戦しています。今年は、「クックパッドの新規事業を支える技術」をテーマとし、クックパッドのエンジニアやデザイナーがどのようにサービス開発に取り組んでいるのか、またその過程で得た技術的知見について公開します。

執行役 CTO 成田の基調講演のほか、前半は本カンファレンスのテーマである、クックパッドの新規事業を支える技術やデザイン開発の現場について、後半にはテクニカルセッションをご用意しております。詳しい内容については、公式サイトをご覧ください。

会場には、今回発表するセッションに関連するプロダクトを展示するブースもご用意いたしますので、ぜひお立ち寄りください。

自作キーボードキット Cookpad Pad をノベルティとして少量配布します

昨今、ブームになっている自作キーボードですが、この度「Cookpad Pad」という自作キーボードを設計し、作りました。ごくわずかではございますが、本カンファレンスにてノベルティとしてお渡ししたいと考えております! 6つのキーで、「C」「O」「K」「P」「A」「D」を打つことができます。オープンハードウェアとして公開もしています。

f:id:tokunarigyozadaisuki:20190131140559j:plain

みなさまのご参加お待ちしております! 

本カンファレンスへの応募締切は2/4(月)までとさせていただいております。まだ「カンファレンス+懇親会」のお申込みも受付中です! 当日は、登壇社員以外にも運営として関わる社員が多数おります。お見かけの際はぜひお声がけください。みなさまにお会いできますことを楽しみにしております。

cookpad.connpass.com

Hackarade #05: IoT

$
0
0

こんにちは!スマートキッチン事業部のシュガー(佐藤彩夏)です。趣味は電子部品アクセサリー作りです。

クックパッドでは、Hackaradeというエンジニアの技術力を底上げするための社内ハッカソンを定期的に開催しており、第5回目の今回は「IoT」のテーマで開催することになりました。

f:id:sugar_ayaka:20190201162614j:plain
開催風景

なお、過去には下記のようなテーマで実施しております。

まず簡単にIoTがテーマになった背景をお話します。

クックパッドには、今年の1月にスマートキッチン事業部という新しい事業部ができました(去年1年間は研究開発部の中の1グループでした)。
スマートキッチン事業部では、OiCyという、人と機器とレシピをつなぐことで毎日の料理を楽しみにするサービス&機器の開発を行っております。

つまり、ソフトウェアだけでなくハードウェアの開発もしており、ハードウェアを開発するメンバーもいます(私もその1人です)ので、
今回はハードウェアも絡むIoTをテーマで開催することになりました。

このブログでは開催概要と成果の一部をご紹介します。

開催概要

今回、参加者が約100人いたので、全員で回路を組んだり、ハンダ付けするのはあまり現実的ではないと思い、
M5Stack Grayという回路を組まなくても容易にセンサーをつなげる(Stack)ことができるモジュールを使用することにしました。

具体的には、ESP32(Wi-FiとBluetoothを内蔵する低電力なマイコン)、ディスプレイ、ボタン、バッテリー、SDカードスロット、スピーカー、9軸センサが内蔵していて、サイズは5cm四方くらいです(写真中央の四角いもの)。

f:id:sugar_ayaka:20190201182046j:plain
M5Stackと同梱物

全体としては下記のような流れで実施しました。

  • 事前準備:開発環境を整えてきてもらう&くじ引きタイム

  • 第一部:M5Stackの説明と演習(ハードウェア開発)

  • 第二部:AWS IoTの説明と演習(ソフトウェア開発)

  • 第三部:乾杯&発表会

事前準備:開発環境を整えてきてもらう&くじ引きタイム

まず事前準備として、下記をしてもらいました。

  • Arduino IDEのインストール

  • USBドライバーのインストール

  • Arduino Core for ESP32の導入

詳細はこのあたりの記事に詳しく掲載されてます。

そして当日の朝、運営側で用意したセンサーを1人1個くじ引きで引いてもらい、そのセンサーを使って好きなものを開発してもらうことにしました。

f:id:sugar_ayaka:20190201165036p:plain
ハズレのセンサーを引いてしまったCTO

センサーは、水分センサー、光センサー、アルコールセンサー、火炎センサー、ダストセンサー、温湿度気圧ガスセンサー、測距センサー、ジョイスティックを用意しました。

なお、弊社はビルの関係で火気厳禁なので、火炎センサーは喫煙所に行かないと試せないという罠。

加えて、会場には今回M5Stackとセンサーを購入したスイッチサイエンスさんにもお越しいただき、楽しそうなセンサーやコントローラーなどをお貸し出しいただきました。

f:id:sugar_ayaka:20190201174432p:plain
スイッチサイエンスさんからの差し入れ

第一部:M5Stackの説明と演習(ハードウェア開発)

第一部は、スマートキッチン事業部のハードウェアエンジニアの山本さんが、M5Stackの説明とセンサーの説明とハンズオン演習を行いました。

その資料が下記です。最後のページにサンプルプログラムへのリンクもありますので是非ご活用ください。

ここでの驚きは、だいたいハードウェアのハンズオンは半分くらいの人が引っかかるので、せいぜい10人くらいでやることが多いのですが、
100人規模でやったにも関わらず大きくつまずく人はおらず、見ていた限り全員が午前中に何かしらを動かせるようになっていたことです。
感動。

f:id:sugar_ayaka:20190201170229j:plainf:id:sugar_ayaka:20190201170312p:plain
開発の様子

第二部:AWS IoTの説明と演習(ソフトウェア開発)

続いて、AmazonのAWS担当の方から、AWS IoTを活用してM5Stackと連携するハンズオンを実施していただきました。

AWS IoTとは、インターネットに繋がるデバイス(今回でいうとM5Stack)とAWSクラウドを連携して双方向通信ができるサービスです。

とても丁寧でわかりやすい資料を使って下記の内容をご説明いただきました。

f:id:sugar_ayaka:20190201170733p:plain
Amazonさんのハンズオン資料(一部抜粋)

第三部:乾杯&発表会

ここまでで、全員がM5StackとAWS IoTを使って一通りサンプルを動かせるようになりましたので、
このあと、数時間の自由制作の時間を設け、夕方くらいに乾杯&発表したい人が発表するという形式を取りました。

f:id:sugar_ayaka:20190201171305p:plain
発表前にまずは乾杯&腹ごしらえ

成果物

面白い発表がたくさんあったのですが、全ては紹介しきれない(そして楽しすぎて多くの写真を撮りそこねた)ので一部だけご紹介します。

まずこちらは、測距センサーを用いた姿勢矯正デバイスです。

f:id:sugar_ayaka:20190201171734p:plain
姿勢矯正デバイス

PCの画面上にセンサーを取り付け、一定の距離近づくと姿勢が悪いことを検知してスクリーンセーバーでお知らせしてくれるというもの。

こういったものは世の中にいくつかありますが、ウェアラブルとかカメラを設置したりしなくても、シンプルに実装されているのがいいなと思いました。

続いて、スクワットを検出してレベルアップしていくデバイス。

f:id:sugar_ayaka:20190201174813g:plain:w250
スクワットデバイス

こちらは内蔵している9軸センサーを活用して、スクワットを検出し、回数に応じて音が変わるというもの。
例えば、まず5回スクワットすると音が鳴ってレベルが上がり、次は10回、20回・・・とハードルが上がっていく設計でした。

私も学生時代に運動支援研究をしていた頃、ひたすら縄跳びを飛んでいたことがあるので、
これはデバックが大変だっただろうなぁ、と努力賞を差し上げたい発表でした。

こんな感じで次々に面白い発表が続いて行ったのですが、ここでふと気づいてしまいました。

「あれ?これIoT(Internet of Things)じゃなくて、ただのT(Thing)じゃないか・・・?」

そうですね。これらはArduino(通信機能のついていないマイコン)とか使えばできちゃいますね。

ここで素晴らしい発表が。

システム障害が起きると、M5Stackの画面の色が緑から赤に変わって障害をお知らせしてくれるシステム。

f:id:sugar_ayaka:20190201172911p:plain
障害を通知してくれるシステム

これは、やっとIoT!しかも実務上も使えて素晴らしいシステムです。拍手。

他にも、色合わせゲームや、スマートロックシステムや、植物の水分が足りなくなると通知されるシステムや、Alexaと連動したキッチンタイマーや、素数が出るとゴリラのアスキーアートが画面に表示されるシステム(?)などなど、発想力豊かな発表がたくさんありました。
(そういえば料理に関係する発表はキッチンタイマーくらいしかなかったような)

f:id:sugar_ayaka:20190201180149p:plain:w300
ゴリラのアスキーアート

ちなみに私は、部屋の湿度や温度やガスの数値が一定以下になるとSlackで通知されるという、なんの工夫もない普通に便利なシステムを作りました。

f:id:sugar_ayaka:20190201163738p:plain:h300f:id:sugar_ayaka:20190201180607p:plain:h300
温湿度気圧ガスセンサーの値に応じてSlackに通知

これをきっかけに、社内のエンジニアがハードウェア開発にも目覚めてくれるといいなと願っています。

ということで、以上、第5回Hackaradeのレポートでした☆

DroidKaigi 2019 にクックパッド社員が1名登壇&ブースでお待ちしております!

$
0
0

こんにちは!広報部のとくなり餃子大好き( id:tokunarigyozadaisuki)です。

さて、エンジニアが主役のAndroidカンファレンス、DroidKaigi 2019の開催まであと二日となりました!

クックパッドは、本カンファレンスにゴールドスポンサーとして協賛します。そして、クックパッドに所属する @litmonが登壇し、@nshiba@shanonimが当日スタッフとして関わってくれております。 約20名のクックパッド社員が、DroidKaigi 2019 に参加致しますので、会場でお見かけの際にはお声がけいただけますと嬉しいです。

登壇の詳細

はじめに、登壇スケジュールと内容を紹介します。 @litmonが登壇するのは、2日目の最後のセッションです! 

2月8日(金)18:30〜 Room 4

門田福男 (@litmon) : Google Play Consoleのリリーストラックを有効活用してリリースフローの最適化を行った話

概要 : Google Play Consoleにはアプリのリリースを行う際にいくつかのトラック(alpha, beta, production)を選択することができる。また、2018年には新たにinternalトラックが開放された。 クックパッドアプリでは、これらのトラックを有効活用し、リリース自動化を行い、人間によるリリーススケジュールの管理をやめたときの話をする。 また、その際にぶつかった技術的制約などにどう対応したか、リリース自動化に向けて行った様々なTipsを紹介する。

コメント

クックパッドアプリは、複数の部署が協力して一つのアプリを開発しています。他部署とのコミュニケーションコストが肥大化していく中、打開策として機械による毎週自動でリリースを行う仕組みを実現しました。本セッションではどうしてクックパッドが自動リリースを行うようにしたのか、またAndroidアプリではそれをどのように実現したのか、その結果どうだったかなどを発表します。アプリのリリースフローについて悩んでいる方や、自動化に興味のある方、ぜひ遊びに来てください!

ブース

クックパッドは、DroidKaigi 2019 にてブースを出展いたします。Androidカンファレンスならではのクックパッドノベルティを数量限定でご用意いたしております! ぜひ、お立ち寄りくださいね。

Cookpad.apk #2 を開催します

本カンファレンス後、2/18(月)には昨夏#1を実施した「Cookpad.apk」の第2回を開催することにいたしました。DroidKaigi 2019 で惜しくも不採択となってしまったトークを中心に、現在社内で行っているAndroidアプリ開発に関する知見や学びについて共有いたします。懇親会の時間もございますのでお楽しみに! 

※本イベントはDroidKaigi 実行委員会が運営する公式イベントではありません。
※ご好評を頂き、全て満席となりましたのでご了承下さい。たくさんのお申し込みありがとうございました。

cookpad.connpass.com

おわりに

発表内容へのご質問やクックパッドにご興味をお持ちの方は、お気軽にブースまでお越しください! みなさまにお会いできることを楽しみにしております。

Viewing all 726 articles
Browse latest View live