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

人気順検索のSolrはスケールのためにディスクを捨てた

$
0
0

技術部クックパッドサービス基盤グループの id:koba789です。
昨年まではデータ基盤グループというところで 最新のログもすぐクエリできる速くて容量無限の最強ログ基盤を作ったりしていました。
今年はちょっとチームを移動しまして、検索システムをいじっていました。今回はそのお話です。
なお、クックパッドには様々な検索システムがありますが、この記事では説明を簡単にするためにレシピの検索のみに焦点をあてています。

クックパッドの検索システムにあった課題

クックパッドにはレシピを検索できる機能があります。
プレミアム会員限定の人気順検索もこの機能の一部です。
しかし、この重要な機能を支える検索システムにはいくつもの課題がありました。

Solr が古すぎる

クックパッドでは、レシピ検索を含む多くの検索機能にSolrを用いています。
今年の始めに私がこの課題に取り組み始めた時点では、その Solr のバージョンは4.9でした。これは5年以上前にリリースされたバージョンです。
古いミドルウェアをあまりに長い間維持し続けてしまうと、OS や運用のためのツールといった周辺のソフトウェアのアップデートも困難になります。
OS だけ一気に最新のバージョンにアップデートしようとしても、互換性の問題で古いミドルウェアは動かなかったりするのです。
こうなると、もはやインクリメンタルなアップデートは非現実的で、古すぎること自体がアップデートをより難しくしているためデッドロックのような状況です。

現代的なプラクティスが実践されていない

上記のような状況から想像に難くないですが、インフラのアーキテクチャも大変古めかしいものでした。
クックパッドのインフラは AWS 上に構築されており、ほとんどのワークロードは ECS と Hako を用いてコンテナ化されています。
またその多くは運用にかかる金銭的コストを最適化するため、負荷に応じてオートスケールするようになっています。
しかしこの検索システムはそうではありませんでした。
Solr は専用の EC2 インスタンスにインストールされており、そのインスタンスはピークでもオフピークでも同じ数だけ動き続けていました。
また、この EC2 インスタンスをインスタンスの退役と入れ替えなどの理由で新規にプロビジョニングする場合は人による作業が必要でした。
その作業はスクリプトでほとんど自動化されてこそいるものの、このスクリプトも例に漏れず非常に古くなっており、保守性を悪化させていました。

これらの課題がありながらも、5年という長い期間ずっと変わらずにいました。
運用上は意外にも安定してしまっていたことと、サービスのコア機能に関わるため迂闊には手を出せなくなっていたことが理由です。 まさに触らぬ神に祟りなしといったところです。

ほぼすべてのワークロードがコンテナ化されたクックパッドにおいて、最後まで残り続け強烈な "レガシー"となっていたのがこの検索システムでした。

検索システムを見つめ直す

前述のような理由から、インクリメンタルなアップデートは諦め、ゼロからアーキテクチャを考え直すことにしました。

システムの作り直しは失敗しやすく困難なので、しばしば悪い方針だと言われます。
もし作り直しを成功させたいならば、新しいシステムで得られるものばかりに目を向けて既存のシステムの観察を怠るようなことがあってはいけません。
必要な要素を見落とせば不十分なシステムができあがって失敗するでしょう。 不要な要素を見極められなければ余計な工数がかかったり実現不可能になったりして失敗するでしょう。
私は既存のシステムを観察し、要件や事実を整理するところから始めました。

まず説明のために、既存のシステムの概略を紹介しましょう。 既存のシステムでは Solr の検索性能をスケールアウトさせるために、1台のプライマリからたくさんの(固定台数の)レプリカに向かってレプリケーションをしていました。 (実際の構成はもう少し複雑ですがここでは本質的ではないため単純化しています)

1台のプライマリからたくさんのレプリカに向かってレプリケーションをしている
既存システムの構成

そしてこのレプリケーションは自動ではなく、日に1度のバッチジョブで明示的にトリガーして実行していました。
そのジョブの内容はおよそ以下のとおりです。

  1. たくさんあるレプリカの中から1台選ぶ
  2. そのレプリカをロードバランサから外す(リクエストが来ないようにする)
  3. レプリケーションを実行する
  4. キャッシュを暖めるため "暖機運転"をする
  5. ロードバランサに戻す
  6. 以下、すべてのレプリカに対して繰り返し

わざわざレプリケーションを1台ずつ実行したり、レプリケーション中はロードバランサから外しておいたり、ロードバランサに戻す前に暖機運転をしたりするのは検索クエリへのレスポンスタイムを遅くしないためです。

さて、観察中に上記以外にも様々なことを発見しましたが、最終的に重要だった点は以下のとおりです。

  • インデックスは日に1度しか更新されない
  • レプリケーション後にキャッシュを暖めるため "暖気運転"をしている
  • Solr がインストールされている EC2 インスタンスのタイプは c4.2xlarge である
  • セグメントファイル(インデックスデータの実体。Solr が内部で使っている全文検索ライブラリである Lucene 用語)のサイズは 6GB 程度である

新しい検索システム

大前提として、新しい検索システムでは最新版の Solr を使うことにしました。
バージョンは4.9→8.6という大ジャンプでしたが、ほぼ schema.xml の書き換えのみで動かすことができました。
丁寧な移植作業の結果、バージョンアップ前後での検索結果の差は非常に少なくできましたが、それでも完全に一致させることはできなかったため、検索結果の品質について責任を持っているチームにお願いしてバージョンアップ後の検索結果の検証をしてもらいました。

また、新しい検索システムではすべてのワークロードをなんとかしてコンテナ化できないかと考えました。
もしコンテナ化できれば、社内に蓄積されたコンテナ運用の豊富なツールやノウハウの恩恵を受けることができ、圧倒的に運用が楽になるからです。

一般に、コンテナの利点を最大限に活かすためにはコンテナはステートレスであるべきとされます。 しかしながら、データベースや検索エンジンのようなミドルウェアは原理的にファイルシステムの永続化が必要でありステートフルです。
いきなり要件が矛盾しているようにも見えますが、一般論ではなく実際の要件に着目するのが大切です。
ここで最も重要なのはクックパッドのレシピ検索のインデックスは日に1度しか更新されないという点です。
ステートの更新頻度が十分に低いのであれば、ステートの変化の度にコンテナを使い捨てることでステートレスとすることができます。
以上のようなアイデアにより、新しい検索システムではセグメントファイルをファイルシステムではなく S3 に永続化することにしました。
また、セグメントファイルを S3 に置いたことでプライマリとレプリカ間のレプリケーションが不要になり、更新系と参照系を完全に分離することができました。

それでは、更新系と参照系について、動作を追って解説します。
まずは更新系の動作を見てみましょう。 なお、図中の s3ar は独自開発した S3 アップローダー・ダウンローダーです。詳細については後述します。

f:id:koba789:20201124151408p:plain
新しい更新系の動作
更新系のコンテナでは、まずインデックスの元になるデータを S3 から Solr に流し込み、すべてのデータを流し込み終えたら Solr を停止します。
この時点でローカルのファイルシステムにはセグメントファイルができあがっていますが、コンテナのファイルシステムは永続化されないため、このままコンテナを停止するとせっかくのセグメントファイルは失われてしまいます。
そのため、コンテナを停止する前にできあがったセグメントファイルを S3 にアップロードします。
アップロードが完了したらコンテナは停止し、破棄します。
この更新系のコンテナは日次のインデックス更新のバッチジョブの一部として起動されます。処理が完了するとコンテナは破棄されるため、処理中以外は計算リソースを消費しません。

続いて参照系の動作を見てみましょう。

f:id:koba789:20201124151412p:plain
参照系の動作
参照系のコンテナは起動するたびに最新のセグメントファイルを S3 からダウンロードします。
セグメントファイルのダウンロードが完了したら単純に Solr を起動するだけです。
参照系のコンテナはインデックスの更新の度に、つまり日に1度すべて作り直され、置き換えられます。
以上の説明から明らかなように、起動プロセスが完全に単純化されたため、スケールアウトのための手作業はもはや不要です。 これは参照系のオートスケールが可能になったことを意味します。

セグメントファイルのダウンロード高速化

ところでこの設計にはひとつ懸念点があります。 それはセグメントファイルのダウンロードに時間がかかると、当然ながら参照系コンテナの起動にも時間がかかるということです。
オートスケールを実践するにはスケールアウトに対する即応性が大切です。 さもなくば増加する負荷に処理能力が足らなくなり、サービスの提供は停止するでしょう。

新しい検索システムではこの問題を解決するためにいくつかの工夫をしています。

セグメントファイルを tmpfs に書く

ダウンロードしたセグメントファイルは tmpfs に書いています。 これはブロックストレージへの書き込みがダウンロードのボトルネックになるためです。
tmpfs を利用するとなると気がかりなのがメモリ使用量です。 ここで既存の検索システムの観察で得た情報の一部を思い出しましょう。

  • レプリケーション後にキャッシュを暖めるため "暖気運転"をしている
  • Solr がインストールされている EC2 インスタンスのタイプは c4.2xlarge である
  • セグメントファイルのサイズは 6GB 程度である

c4.2xlarge のメモリは 15GiB ですから、6GB のセグメントファイルのサイズや暖機運転をあわせて考えると、既存の検索システムはセグメントファイルのデータがすべてメモリ上のページキャッシュに乗り切っていることを期待していたことがわかります。 新しい検索システムでも同等以上の性能を達成したいので、セグメントファイルはすべてメモリに乗るようにすべきでしょう。

するとブロックストレージは完全に無駄であることに気が付きます。 結局ページキャッシュに乗せなければならないならはじめから tmpfs に書くべきです。

専用の高速な S3 アップローダー・ダウンローダー使う

S3 からのダウンロードにおいて、1接続あたりの速度はそこまで速くありません。 そのため、いかにしてダウンロードを並列にするかが高速化の鍵になります。

セグメントファイルは実際には複数のファイルで構成されているため、それぞれのファイルを S3 の1つのオブジェクトとして保存することでダウンロードの並列度を高められそうに思えます。
しかし、これはうまくいきません。なぜなら、それぞれのファイルサイズが大きく偏っており、並列化の効果を十分に発揮できないためです。

そこで、S3 の Multipart upload を利用し、1つのオブジェクトを複数のパートに分割してアップロードし、その分割されたパートごとにダウンロードします。 これは Performance Guidelines for Amazon S3でも紹介されており、AWS SDK for Java の TransferManagerや AWS CLI の s3 cp コマンドでも利用されているテクニックです。 これにより、ファイルサイズの偏りに関わらず、効果的にダウンロードを並列化できます。

また、高速なファイル書き込みではメモリコピーのコストが無視できなくなります。 通常のファイル書き込みで用いる write(2)システムコールはユーザーランドのバッファからカーネルランドのバッファへのコピーを伴います。 ブロックデバイスへの書き込みであればブロックデバイスは十分に遅いため無視できるコストですが、tmpfs のような高速なファイルシステムを使っている場合はそうではありません。
そもそも、せっかく tmpfs を使っていてファイルの実体がメモリ上にあるのですから、そのページに直接書き込みたいと思うのが自然でしょう。
実は、その願いは tmpfs 上のファイルを mmap(2)すると叶えることができます。 ファイルに対する mmap は page cache をユーザーランドに見せる操作であり、tmpfs はファイルの実体が page cache に存在するファイルシステムであるので、mmap すると tmpfs のファイルの実体がそのままユーザーランドから見えるようになるという理屈です。
(スワップを考慮していない雑な説明です)

これらのテクニックをすべて実装したのが独自開発した s3arという S3 アップローダー・ダウンローダーです。 これは Rust で書かれており、マルチコアを完全に使い切って超高速なダウンロードができます。 この s3ar を利用すると約 6GB のセグメントファイルのダウンロードは10秒程度で完了します。 これはスケールアウトの即応性として十分な速度です。

追記: s3ar のコードを公開しました。公開するつもりはなかったのでそれほど読みやすいコードではないのですが、ご笑覧ください。 github.com

まとめ

5年間も変化を寄せ付けず、強烈なレガシーとなっていたクックパッドの検索システムは、丁寧な観察に基づく大胆な設計とそれを実現する確かな実装によって近代化されました。

この記事では新しい検索システムの開発について紹介しましたが、既存の検索システムから新しいシステムへの切り替えについては触れていません。 次の記事では、この変化の大きな切り替えをいかにして安全に成し遂げたかについて、同僚の id:riseshiaが紹介します。


検索インフラを安全に切り替えた話

$
0
0

こんにちはこんにちは。技術部クックパッドサービス基盤グループの id:riseshiaです。

本記事では直前の記事で提案された新しい検索システム(以下、 solr-hako と呼びます)を利用し、レシピサービスの検索インフラの切り替えた話をします。 solr-hako の設計を直接参照する内容はありませんが、それを前提においた移行作業ですのでそちらの記事を先に読むことをおすすめします。

インフラ構成の変化

まずインフラ構成の変化ををみておきましょう。

f:id:riseshia:20201124172550p:plain
検索インフラ(変更前)

今まではこのようなインフラ構成でした。特徴としては、 search-cache というキャッシュサーバ(Varnish)が手前にあることくらいでしょうか。今回、 solr-hako を利用することで以下のような感じになりました。

f:id:riseshia:20201124172618p:plain
検索インフラ(変更後)

しれっとキャッシュレイヤーである Varnish がなくなったことがわかります。これに関しては後ほど述べます。 では、このインフラの切り替えのためにどういう作業をしてきたのかを話していきたいと思います。

取り組んだ施策

新しい検索システムにマイグレーションするために次のような施策を行いました。

  • solr-hako という新しい仕組みに沿った実装
  • Solr バージョンアップによる影響調査 & 対策
  • 負荷実験
  • 試験運転
  • 切り替え

solr-hako の設計に関しては同僚の id:koba789が書いた記事で紹介しているのでそちらを読んでください。実装に関してはひたすら必要な設定を書いたり、権限を付与したり、 kuroko2にジョブ定義を作成したりのようなものがいっぱいなので割愛し、本記事では残りの施策に関して説明します。

Solr バージョンアップによる影響調査 & 対策

あらゆるフレームワークに対して、バージョンアップの正攻法はまずリリースノートおよびチェンジログを調べるところから始まるのかなと思います。

ですが今回の場合だと、 Solr 4.9 から Solr 8.6 までの変更履歴を全て調べることになるので流石に無理です。 ではどうやって変更を調べるのか。今回は実クエリを利用することにしました。 実際のクエリを数日分抽出し、 Solr 4.9 と Solr 8.6 に投げてそのレスポンスの差分を取りました。そこで見つかった差分から関連の変更を逆引きし、必要な対策をするという流れです。

影響が大きかったのは以下の2つです。

前者は一応クエリの書き方を変えるだけでよかったのですが、後者は検索順番への影響がありました。 流れが大きく変わってしまうので詳細は省きますが、検索サービスの責任を持つサービスチームにお願いし、いくつかの指標を選定した上で検索の品質評価を行いました。結論としては品質に悪影響してない、むしろ少し改善した可能性がありそう?ということになったのでこのまま切り替え作業をやっていくことになりました。 これに関しては機会があれば、別の記事を通して紹介できるかなと思います。

性能実験によるパラメータ・チューニング

影響範囲が分かったし、検索順の変更に対する影響調査を依頼したところで、次は性能実験です。 Solr のバージョンが大幅に変わるので、 Solr の設定及びリソース要求を見直す必要があります。ぱっと思い浮かぶだけでも各種 Solr のキャッシュ設定、スキーマの設定、JVM の設定、 solr-hako で動かすわけですから、ECS のタスクのリソース割り当てなどもあります。 それにオートスケーリングも設定していくので負荷状況による影響なども把握したい。

これらの設定値をいい感じの組み合わせにして一つ一つ試しながら良さそうな組み合わせを探す必要があります。とはいえ、丁寧に新しい組み合わせを試すたび設定を更新して Docker image を作って〜というのは疲れるし、やってられません。 そこで Solr の Config APIに目をつけました。 Config API を利用すると Solr の設定を REST-like API で取得したり、更新したりすることができます。これを利用して実験をあるほど自動化できるツールを作ることにしました。

solr-hako-load-tester

設計目標としては次のようなことを上げました。

  • 多様な Solr のパラメータを試せること
  • 手軽に設定を変更できること
  • 試したい設定をキューに詰め込んで順番に実行できるようにすること

一方でこのツールは今後 solr のバージョンアップの時くらいにしか使われないと予想されるので、実装コストに見合わない機能は目標外にしました。

  • Solr の外のパラメーター(e.g. ECS タスクごとの CPU や JVM の設定)は考慮しない
    • これらは比較的変更が少ない値であり、かつこれらの設定を動的に変更可能な設計にするのは利点に比べて実装コストが高い
  • 積極的なメトリクスの収集
    • 負荷をかけるツール(k6)、 Prometheus、 CloudWatch があれば詳細なデータが取れるのでツールではデータ収集を頑張らなくても問題ない
  • 負荷テストの対象の状態管理
    • 費用はやや高くなるが実験の間は負荷テスト対象(ECS Service)を常駐にすることでエンドポイントを毎回用意する手間を減らせるし、メトリックス収集も楽になる

結果、どうなったかというと、S3 に設定セットをキューイングし、 kuroko2の定期実行でそれを消費しながら実験を行う仕組みが誕生しました。

f:id:riseshia:20201124172648p:plain
load-test の動作の流れ

この図からありそうな質問点をあげていくとこんな感じかなと思います。

  • 負荷をかけるときに使ったクエリはピークタイムのログをサンプリングしたものです
  • 実験ツールとして k6 を選択したのはクエリログをリプレイするという観点で非常にシンプルで扱いやすかったからです
  • 結果はどうみるかというと kuroko2 のジョブ実行ログ、 Prometheus、 CloudWatch の監視結果から確認できるのでそちらを参考にしました。
  • 直前の実験の影響を受けないように実験の直前には ECS タスクの入れ替えをしています

実験結果

適切そうな設定を決めることができました。 そしてどういう設定にしても Solr 4.9 より速い、現状より少ないリソースでも十分なスループットを提供できることが判明したので、急遽このタイミングで Varnish のキャッシュレイヤーを捨てるという意思決定が行われました。

試験運転

使えそうな設定が出来上がったところで実ワークロードでも問題なく動作するかを確認するために試験運転をすることにしました。 試験運転をする方法はいくつかあるかなと思うのですが、今回は Traffic shadowing という手法を選びました。これはリクエストをコピーし、テスト目的でサービスインしてない別の upstream に送る手法です。 もう一つの候補として、一部のユーザに対してロールアウトしてみるという選択肢もありましたが、検索サービスである voyager は検索をリクエストしているユーザが誰なのかわからないので、その情報を何らかの方式で渡すなり、新しい API をはやすなり、新しい Solr 用の voyager を用意するかなりいくつのサービスを跨る対応が必要でコストが増えるし、予想される設計もあまりうれしくないものでした。

一方 Traffic shadowing の場合、2つの利点があります。

  • ユーザに影響を出さない
    • 検索品質の検証は別途やっている、今回の実験の目的は実ワークロードでも安定して動くのかを確認するためで、必ずしもユーザに出す必要はないし、むしろ出さなくていいならそっちがいい
  • すべてのリクエストを流せるので実ワークロードを完全再現できる

ということで Traffic shadowing を試してみることにしました。

Envoy と request_mirror_policy

クックパッドは Service mesh を導入しており、その Data plane として Envoy を採用しています。そして Envoy は Traffic shadowing に利用可能な request_mirror_policyという設定を提供しています。 これはあるクラスタへのリクエストをコピーして別のクラスタに送る機能で、コピーして送ったリクエストのレスポンスはそのまま捨てられます(メトリクスは残ります)。設定を少しいじるだけなので 実際使ってみた感じだと以下のような感じでした。

  • envoy による CPU 負荷はやや増えるが、そこまで目立つ変化ではない
  • Host ヘッダーに -shadowという suffix をくっつけてくるので、 Host ヘッダーを利用する処理が必要な場合、注意が必要

それ以外の気になる点はなく、快適に利用できました。

実験結果 & 対策

予想外のスパイクによるキャパシティー不足

実環境だと利用サービス側のキャッシュパージやバッチ実行などによりリクエストのスパイクが発生するわけですが、これが予想以上に影響が大きく、観測した範囲だと直前の rps より最大2倍くらいに跳ねたり、レイテンシーが不安定になる現象が観測されました。対策としてはオートスケーリングの閾値をやや下げ、もう少し余裕をもたせることでレイテンシーを安定させることができました。

Cold start 問題

Solr は起動してからキャッシュが温まるまではリクエスト処理速度が遅く、いわゆる暖気が必要ということが知られているのですが、性能実験の時はあまり意味のある遅延がみられませんでした。 solr-hako は tmpfs を利用し、メモリーにインデックスを乗せることを推奨しており、実設計でもそれに従ってインデックスストレージとして tmpfs を採用していました。ですのでこれが tmpfs の力なのか?!と思っていたのですが、そんなことはなく実ワークロードだと普通に Cold start 問題が目立つようになってました。後になって思うと、性能実験にはピークタイムのクエリをサンプリングしていたのでクエリのパターンが偏っていたのかもしれません。 対策として2つ考えられて投入前にクエリをいくつか投げるとこで暖気を行う方法と ELB の Slow start modeを利用する方法がありました。 前者だと送られてくるクエリの傾向に合わせて暖気用クエリをメンテするコストがあるので、徐々にリクエストを流すことでレイテンシーの劣化を抑えることができる ELB の Slow start mode を有効にすることにしました。

切り替え

Traffic shadowing を一定期間運用したことにより、これは大丈夫だなという確信を得られたので実際に切り替えることにしました。ロールアウト作業は検索結果の順序の変化と、利用サービス側のキャッシュ事情が混ざるとややこしいことになりかねないので徐々に切り替えるのではなく一気に展開することにしました。試験運用で時間帯による要求キャパシティーも完全に把握できていたので特に懸念点もなく無事切り替えることができました。

と思っていたら、その数日後に移行漏れのバッチが発覚したのはまた別の話です 😇 移行後もしばらくの間、運用上の理由でロールバックする可能性を考慮し古い Solr サーバを残しておきインデックスも更新していたのがここで役に立ちました。備えあれば憂いなし。

結果

コスト節約

f:id:riseshia:20201124172749p:plain
コスト変化

実運用が始まったのが 9月中旬、古いインフラのリソースを片付けたのが10月初です。こちらは Cost Explorer からみた検索インフラのコストの推移ですが、新しいインフラコストが 1/3 くらいになったことがわかります。これは2つの理由があります。 今までの仕組みではそもそも運用しやすい形に実現するのが難しいのでオートスケーリングが有効ではなかったのが一つで、もう一つは Solr のバージョンアップによりそもそも Solr の性能が向上したことがあります。ピークタイムでも以前に比べて半分以下のリソースで運用できているんですね、これが。すごい。

レイテンシーの改善

f:id:riseshia:20201124172807p:plain
レイテンシーの変化

レイテンシーも相当改善されました。これも2つの理由があります。 わかりやすい理由は Solr の性能向上ですね。図の2つ目の崖がそれです。途中でも触れていましたが、 Varnish を介さなくなったのにも関わらず早くなったのが印象的です。 左側の崖は差分検証中に見つけた激重クエリが無駄な subquery を発行していることに気づいてそれを解消した結果発生したものです。

その他

その他の細かい改善点というと以下のようなものがありました。

  • Solr 起因の staging 環境でのエラーがほぼなくなった
    • staging 環境で利用してた Solr は本番用に比べると大変小さく、リクエスト数が急増すると悲鳴を上げがちだったのですが、今回の Solr の性能向上により安定するようになりました
  • 今まではインデックスの更新直後の負荷を恐れてピークタイムでのインデックス更新を避けていたが、そうする必要がなくなりました
  • 普通の ECS サービスになったことによりスキーマの更新と運用がしやすくなりました

まとめ

今回の検索インフラ改善作業の流れ、その時利用した技術などに関する説明は以上になります。このような面白いお仕事がいっぱいあるので興味があるエンジニアの方はぜひご連絡ください! https://info.cookpad.com/careers/jobs/

ドキュメントを書くときの「メンタルモデルの原則」

$
0
0

こんにちは。クリエイション開発部の丸山@h13i32maruです。

みなさんドキュメント書いてますか?私はドキュメントを書くのは結構好きです。最近もプライベートで開発しているJasperというGitHub用Issueリーダーのユーザ向けドキュメント(マニュアル)を書きました。でも良いドキュメントを書くのって難しいですよね。

そこで、本記事では「ツールやライブラリなどを対象にしたユーザ向けドキュメント」を書くときに私が考える原則を紹介します。ちなみに私はテクニカルライティングの専門家ではなく、普通のソフトウェアエンジニアです。そのあたりはいい感じに汲み取っていただけると🙏

🕵️メンタルモデルの原則

良いドキュメントとはどのようなものなのでしょうか?私は「そのツールやライブラリに対して読者がメンタルモデルを構築できる」のが良いドキュメントだと考えています。これを「メンタルモデルの原則」と呼びます(私が勝手に呼んでいるだけなので、ググっても出てこないかも)。

💡メンタルモデルを構築すると推測可能になる

メンタルモデルとは「これをするにはこうする。こうしたらこうなる。」というように動作や結果をイメージできるような心のありようです。あるツールについてメンタルモデルを構築できると、ドキュメントを読むこと自体が楽になります。また、いずれはドキュメントをほとんど読まなくてもツールを使いこなせるようになります。

例えば使い慣れたプログラミング言語であればドキュメントを読まなくても「こういう場合はこう書けそう」とか「多分このへんのドキュメントみたら使い方書いてありそう」となると思います。これはそのプログラミング言語に対してメンタルモデルを構築できているからです。

ようするにメンタルモデルを構築するとはその対象について色々なことを推測できるようになるということです。なのでメンタルモデルを構築できるドキュメントは有益なドキュメントだと考えています。

ではどのようにすれば「メンタルモデルを構築できるドキュメント」を書けるのでしょうか?それには以下のことを意識してドキュメントを書くことが重要だと考えています。

  1. 読者の現在のメンタルモデルや目的
  2. 演繹的・帰納的な説明

📝メンタルモデルの有無や目的に応じてドキュメントをかき分ける

ドキュメントを読む人は様々です。そのツールを使い始めようとしている人、特定の使い方を探している人、100%使いこなしたいと思っている人、などなど。そういった読者に合わせたドキュメントを書くことが重要です。このときに読者はどれくらいメンタルモデルを構築済みかも重要になってきます。

例として「初めて使う人向け」「特定の使い方を探している人向け」「もっと使いこなしたい人向け」のドキュメントについて説明していきます。

① 初めて使う人向け
初めてツールを使う人の場合メンタルモデルは全く構築されておらず、目的はとりあえずさくっと使ってみるというのがほとんどだと思います。そういう読者のためにセットアップ方法とすごく基本的な使い方のドキュメントとして「クイックスタート」や「チュートリアル」を用意するのがよいでしょう。

このとき注意すべきことはメンタルモデルが全く構築されていないので、多少冗長でも丁寧な説明をすることです。そうしないと思わぬところでハマってしまうことがあるでしょう。ただし、ツールの外側のことについてはメンタルモデルが構築されていると思うのである程度省略してもよいと思います。例えばJasperであればGitHubやMacアプリのインストール方法などはサラッと書くにとどめました。

② 特定の使い方を探している人向け
特定の使い方を探している人はある程度メンタルモデルが構築されています。なので「こういうことをするには、多分こういう機能がありそう」と思いながらドキュメントを読み始めます。よって「こういうことをするには」に相当するような「よくあるユースケース」や「サンプル集」を用意するのが良いでしょう。

③ もっと使いこなしたい人向け
こういう人はメンタルモデルがバッチリ構築済みなので、網羅的なドキュメントを用意するのが良いでしょう。いわゆる「リファレンス」や「辞書」みたいなものです。一つ気をつけるべきアンチパターンは「APIリファレンスしか用意されていないライブラリのドキュメント」みたいなものです。これは「もっと使いこなしたい人向け」以外の読者のメンタルモデルや目的を完全に無視していると思います。(それで問題ない場合ももちろんありますが)

ちなみにJasperでは以下のようにドキュメントの入り口を分けることによって、異なる読者を適切なドキュメントに誘導しています。

f:id:h13i32maru:20201126174616p:plain

🙆演繹的・帰納的な説明により推測を可能にする

人間がものを推測するときは大きく分けて演繹的な方法と帰納的な方法があります。

演繹的というのは「プログラミング言語には四則演算がサポートされている。TypeScriptはプログラミング言語である。よって、TypeScriptでは四則演算が使える」のような論理的な因果関係で推測することです。一方で帰納的というのは「TypeScriptは加算・減算・乗算が使える。なので除算も可能なはずであり、四則演算を使えるだろう」のようにいくつかの具体例をもとに推測することです。

よって演繹的・帰納的な方法で説明されたドキュメントを読むことで、そのツールの使い方や目の前のドキュメントには書かれていないことなどを推測できるようになっていきます(=メンタルモデルを構築する)。

例えばJasperでは「JasperはGitHubの検索クエリと完全互換があります」という説明をいれています。これによって読者は「ということはGitHub検索で使っていたあれもつかえるのでは?」という推測を可能にします。ほかにも「リポジトリ指定するにはこう、ユーザ指定するにはこう、ラベル指定するにはこう」という説明をしています。これによって「チーム指定する方法もあるのでは?」という推測を可能にします。

演繹的・帰納的という言葉を使いましたが、ようは「前提としていることを説明」「繰り返しやパターンで説明」を意識してドキュメントを書くとよいという話でした。


というわけでドキュメントを書く上での「メンタルモデルの原則」について紹介でした。良いドキュメントは著者も読者もwin-winなので、いいもの書いていきましょう。

レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話

$
0
0

技術部の外村(@hokaccha)です。今回はクックパッドのウェブサイトのフロントエンドを Next.js などを使って作り直している話を書きます。

この記事で紹介する新システムは、スマートフォン向けのレシピページで確認することができます。もし興味があるかたはレシピページをスマートフォンのユーザーエージェントで開いて DevTools などで確認してみてください。 Next.js と GraphQL で動いているのがわかると思います。

f:id:hokaccha:20201130205054p:plain

ご存じの方も多いかもしれませんが、クックパッドのウェブサイトはモノリシックな Rails で作られていて、10年以上 Rails で開発を続けてきました。10 年以上同じシステムで開発を重ねれば当然レガシーな部分が大量に生まれてきますが、特にフロントエンドはその影響が顕著でした。

どこから使われているかわからない CSS が大量にある、JS のコードは昔ながらの CoffeeScript*1と jQuery で書かれており、JS のライブラリの管理も vendor/assetsにファイルを入れてリポジトリにコミットするという運用。app/assets以下のファイルやディレクトリ名に規則や規約もなく、どこにファイルを置いていいかすらよくわからない、という状態です。

こういった状態なので当然開発効率は悪くなりますし、開発のモチベーションも低くなります。その結果ユーザーに価値を届けるまでのスピードが遅くなってしまうのが最大の問題です。この問題を根本的に解決するため、今回 Next.js でフロントエンドを作り直すという決断をしました。

一度にすべての画面を置き換えるのは無理*2なのでまずは最も活発に開発されておりユーザーからの利用も多いところから始めることにして、スマートフォン版のレシピページをターゲットに決め、今年の2月ぐらいから開発をはじめて先月リリースすることができました。比較的うまくいっているので今後も適用する画面を広げていく予定です。

以降では今回のシステムの技術要素やパフォーマンスへの影響について説明します。

技術要素

今回のシステムにおける重要な技術要素は以下です。

  • TypeScript(言語)
  • Next.js(フロントエンドフレームワーク)
  • GraphQL(API)

技術選択する上でまず最初に考えたのは、TypeScript を中心に据えることです。型チェックによる整合性の検査、補完やリファクタリングを中心としたエディタの支援など、TypeScript を導入することによる生産性の向上は非常に大きいものがあります。

次にフロントエンドの描画ライブラリはシェアの大きさやTypeScript との相性、使いやすさなどを考慮したうえで React を採用することにし、サイトの性質上 Server Side Rendering(以下 SSR)は必要になると思っていたので、React.js で SSR がいい感じに動くフレームワークということで Next.js を採用しました。Next.js は技術選定をした少し前に TypeScript 対応を強化したり、動的なルーティングをサポートしたりと、いい感じのアップデートがあったのも決め手の一つでした。

次に API です。クックパッドにはモバイルアプリなどで使われている、社内では Pantry と呼ばれている REST API のシステムがあります。最初は Pantry を直接 Next.js から利用する方向で考えていましたが、認証の問題や、リクエスト・レスポンスへの TypeScript の型付けの問題、将来的に Pantry 以外のサービスにもリクエストが必要になる可能性を考えると、BFF レイヤーに GraphQL を導入するのがよさそうという結論になり、GraphQL を導入することにしました。

この目論見はうまくいき、BFF のレイヤーとして GraphQL がいい感じに動いています。リクエスト・レスポンスの型定義には graphql-codegenを利用することで GraphQL のスキーマから TypeScript の型定義ファイルを自動生成しています 。また、GraphQL のサーバー実装も TypeScript (素朴な express-graphqlを使った実装)を採用し、単一の言語でフロントエンドと API を開発できるので言語のコンテキストスイッチが少なくなるようにしています*3

現状の GraphQL サーバーの仕事のほとんどは Pantry へのリクエストですが、Pantry へのリクエストのところを少し工夫して楽することに成功したので紹介します。

Pantry は Garageというフレームワークを用いて実装されている API サーバーです。Garage には fields というクエリストリングで取得するリソースのフィールドを絞り込めるという機能があります。

/v1/recipes/:id?fields=id,title,user[id,name]

このような感じです。カンマ区切りで取得したいフィールドを指定し、user[id,name]のようにネストしたリソースのフィールドも絞り込めます。これは GraphQL のクエリに非常によく似ています。GraphQL で表現すると次のようになるでしょう。

query {
  recipe(id: $id) {
    id
    title
    user {
      id
      name
    }
  }
}

Garage の fields は Facebook の Graph APIを参考にされて作られており、Graph API が GraphQL の原型であるという経緯により、このような類似したインターフェースになっているようです。今回のシステムではこの性質を利用し、GraphQL のクエリを Garage の fields に自動で変換し、Pantry へリクエストするという機能を実装しました。これはうまく動いており、 GraphQL のサーバー実装が大幅に簡略化されました。

システム構成

今回はすべての画面をリプレイスするわけではなく、一部の画面だけ新システムに向けるので、その制御を前段のリバースプロキシ(Nginx)で振り分けています。

f:id:hokaccha:20201130093752p:plain

/recipe/:idのスマートフォンからのリクエストを Next.js へ、/graphqlを GraphQL のサーバーへルーティングし、残りはこれまで通りの Rails のアプリケーションへルーティングします。Next.js のサーバーでは SSR をおこない、HTML を作ってユーザーに返します。GraphQL へのリクエストについては、Next.js が SSR 時に GraphQL の API を呼び出す場合と、クライアントがブラウザから直接 GraphQL の API を呼び出す場合があります。

SSR の是非については色々と議論があるでしょうが、パフォーマンス(特に LCP(Largest Contentful Paint))の最適化、OGP 対応などを考慮して SSR を採用しています。

社内では Node.js でサーバーを運用した知見がほとんどなかったので、性能や運用の面で不安がありましたが、社内には ECS によるコンテナのデプロイ基盤が整っており、Docker で動きさえすればマルチプロセス化などは考えずに 1vCPU でタスクを横に並べるだけでいいので思っていたよりも楽に運用が可能でした。性能面でも、Next.js の SSR サーバーは 200rps 強を 1vCPU のタスク 7 つ前後で捌けているのでまずまずといったところです*4

パフォーマンス

パフォーマンスの変化についても少し触れておきます。フロントエンドのパフォーマンス計測には Calibreというサービスを利用しており、以下が Calibre での before/after です。

before

f:id:hokaccha:20201127165227p:plain

after

f:id:hokaccha:20201127165251p:plain

これを見てもらうとわかるように、システムを刷新したことで大幅にパフォーマンスが向上しました。特に First Contentful Paint (以下 FCP) が圧倒的に速くなっているのがわかると思います。なお、これは低速回線(上記の数値は 3G 回線相当で計測)で特に顕著で、LTE 相当だともう少し差は小さくなります。

f:id:hokaccha:20201201101339p:plain
きれいな崖ができた様子

元の実装の FCP が遅かったのはシステムをリニューアルする前からわかっていた問題点のひとつで、巨大な CSS や defer できない JS が head で読まれていて、クリティカルレンダリングパスの最適化ができていないのが原因でした。なんとかしようにもどこで読まれているかわからない CSS が大量にあって消すのが難しい、haml(Rails の View)に埋めこまれた JS が head で読まれる JS に依存していて defer できない、などの理由で FCP の最適化が難しい状態でした。

ですので Rails が遅い、Next.js だと速い、というフレームワークの差ではありません。Rails でもスクラッチで書き直してチューニングすれば同程度のパフォーマンスはでます。ただ、Next.js はそういったパフォーマンス最適化をある程度自動でやってくる点においては非常に楽でした。また、Next.js に組み込まれた Web Vitals の計測機能を使って Web Vitals の数値を記録するようにしたのでこれを使って今後も改善を進めていく予定です。特に LCP、TTI(Time To Interactive) あたりはもう少しどうにかしたいですね。

まとめ

Next.js や GraphQL を使ってウェブサイトのフロントエンドのシステムを刷新している話を書きました。今後も適用範囲を広げていき、開発の生産性をあげることでユーザーに届ける価値を最大化していきます。また、今回書いた以外にも、認証やロギング、エラートラッキング、CSS in JS、描画のパフォーマンス最適化、A/B テストなど色々と面白い知見が溜まっているのでまた別の機会に共有したいと思います。

最後に、クックパッドではモダンなフロントエンドの基盤を作っていく仕事や、この基盤を使ってサービス開発する仕事が大量にあります(切実)。もし少しでも興味があればお気軽にお問い合わせください!

*1:CoffeeScript は今回の話とは別のプロジェクトでなくすことに成功しましたがこの話はまた別の機会に。

*2:調べたら現時点で1600以上のルーティングがありました

*3:Pantry を始めとするバックエンドの API サーバーは Ruby なのでそこを変更する必要があれば当然コンテキストスイッチは発生しますが。

*4:GraphQL のサーバーは別。

AWS の This is My Architecture(動画)でS3を中心としたセキュリティログ基盤の紹介をしました

$
0
0

技術部セキュリティグループの水谷です。

先日、Twitterに投稿もしたのですが、AWSのThis is My Architectureという事例紹介のシリーズでクックパッドが取り組んでいるセキュリティログ管理基盤の紹介ビデオが公開されました。この記事ではビデオの内容の補足、そして撮影の様子などを紹介したいと思います。

This is My Architecture とは?

AWSにおいて事例紹介となるようなアーキテクチャを構築・運用しているユーザが、自分たちのアーキテクチャを5分ほどのビデオで紹介するシリーズです。様々な分野で活躍しているユーザとAWSのアーキテクトの方と一緒にアーキテクチャを紹介する、という形式になっています。

aws.amazon.com

今回クックパッドでもオファーをいただき、社内でいくつかあった事例の中でセキュリティログ管理の話が紹介としては良さそうということで、私がAWSの桐山さんと共にビデオ撮影させていただきました。

動画の撮影

ここからは動画撮影に関するよもやま話をお伝えしようと思います。

撮影まで

この This is My Architecture の動画は毎年年末にラスベガスで開催されている1AWSのイベント re:Inventの会場で撮影されています2。そのため、この動画も撮影したのは実は去年のre:Invent(2019年12月)でした。

しかし、諸般の事情により私が現地に行って撮影する、というのが正式に決まったのが11月5日で、そこから超特急で渡航手配3や発表準備に取り掛かりました。10月にはre:Inventの参加するとは露ほども考えていなかったので、なんとか間に合ってよかったです。

原稿の準備

撮影準備で大変だったのはまず原稿の準備です。撮影をするうえで原稿を事前に用意しないといけないというルールがあったわけではないのですが、今回は英語で話すということと発表時間の制約が厳しい(原則5分以内)ことから、発表内容は全て原稿を用意しました。

このブログの後半に補足として書いていますが、今回のアーキテクチャとして伝えたいことはいろいろありました。なので、最初はもりもりの原稿になってしまい、かなり早口で説明しても2〜3分ほどオーバーしていた記憶があります。

そのため、このアーキテクチャの本質として伝えたいことに要点をしぼり、何度も推敲を重ねて実際に撮影に使用したバージョンまでブラッシュアップしました。特に私自身は英語が全く得意ではないので、わかりやすく丁寧に発音するためにはゆっくり喋れるような余裕が必要でした。そのため、さらに尺に余裕が出せるように原稿を短くしていきました。

発表練習

原稿の準備と並行して、発表練習も11月中にかなり時間をとって取り組みました。

f:id:mztnex:20201201143307p:plain

写真はAWSオフィスにて、実際の撮影時にある黒板をホワイトボードで想定しつつ、説明の手順を確認したり、発表原稿の読みあわせをしたときのものです。先に述べたように、私は英語がだいぶ苦手な部類だったため、流暢に話せるようにということも含めて練習していました。

発音に関しては(出来はさておき)かなり事前に確認しました。やはりどうしても日本人発音になってしまいがちなので、何度かネイティブスピーカーの人に発表を聞いてもらい、聞き取りづらいところなどをチェックして発音を修正する、というのを繰り返しました。また、自分が発音しづらかったりネイティブスピーカーの人に聞き取りづらいような単語は避け、別の単語に置き換えるような修正もしました。特に数字については結果を示すために非常に重要な要素にも関わらず、なかなか正確に聞き取ってもらうのが難しかったので、ボードに直接書く&特にはっきり話す、という作戦でいきました。

また、原稿準備のところでも触れましたが、今回は時間の制約が厳しかったことなどから発表原稿は一言一句丸暗記しました。英語でも日本語でも完全に丸暗記して発表するというのは実は初めての体験だったため、11月は暇さえあれば原稿をブツブツつぶやいていたなと記憶しています。

撮影本番

12月5日の朝(現地時間)から撮影でした。この前日は時差ボケ4と緊張で見事に一睡もできなかったのですが、意外と元気に撮影できました。

f:id:mztnex:20201201143414j:plain

撮影はre:Invent会場の一部が撮影専用のスタジオとなっており、そこで撮影してもらいました。This is My Architectureのページを見ていただくと分かる通り、かなりの数の事例紹介があるため、各チームが入れ替わり立ち替わりで撮影していました。

f:id:mztnex:20201201143434j:plain

ボードを用意してくれるスタッフの方にアーキテクチャ図の説明をしたりしつつ、その場でブラックボードに書き込みをしてもらうなど準備をして、撮影に突入しました。

f:id:mztnex:20201201143458j:plain

かなり入念に準備したかいあって、撮影自体は非常にスムーズに終わりました。一応、スタッフの方からは一発OKをもらったのですが、ちょっと言いよどんでしまったところもあったので英語版をもう一度だけ撮影し直して完了できました。

さらに「時間余ったからせっかくなんで日本語版も撮ろうか?」みたいな話になり急遽日本語版も撮影することになりました。過去のThis is My Architectureをざっと見た限り二ヶ国語で別々に撮影されたものはほぼ無さそうで、おそらくかなりレアケースだったようです。おかげで日本語版も撮影させてもらったのですが、それまでずっと英語で話していた内容で全く日本語版を想定していなかったので、撮影中は必死に英語から日本語に翻訳して喋っていました。

完成動画

ということで完成した動画がこちらとなります。動画の編集や公開のタイミングなどの問題で、最終的に公開されたのが少し遅くなってしまいましたが、無事公開していただきました。

英語版

www.youtube.com

日本語版

www.youtube.com

(動画の補足)S3を中心としたセキュリティログ管理基盤

今回撮影した動画は5分以内に収める&使えるアーキテクチャ図も限られていたため、詳しい説明を大幅に削ってエッセンスだけを話させてもらいました。(原稿の推敲にはだいぶ苦労しました)なのでこのブログで少し内容を補足させてもらえればと思います。ちなみに、この話はかなり前から取り組んでおり何度か講演やブログでも紹介させて頂いたものなので、それらと重複する部分がかなり含まれる点はご容赦ください。

https://techlife.cookpad.com/entry/2018/05/31/080000

過去の記事でも紹介したとおり、クックパッドではセキュリティ監視に使うログを全てS3に保存してから利用する、というアーキテクチャを採用しています。

従来、セキュリティ関連のログ管理ではSIEM(Security Information & Event Manager)などのログ管理ソリューションが用いられて来ました。細部は製品やサービスによって違うものの、大まかな発想としてはログを直接取り込んでアラートを検出するフローとログを保管するフローを別々に扱うものが多かったと思います。このアーキテクチャの利点は、取り込んだログをなるべく早くアラート検知に利用することで発報までの遅延が短くなることです。しかし一方で以下の2つの課題を抱えています。

  1. 障害対応時の対応負荷:センサーからのログ送信経路やアラート検知・ログ保管側のシステムに障害があった場合には対応・復旧作業が必要になります。再度センサーなどからの取り込みをしないとログが欠損してしまいますが、ログの取り込み元の数が多くなるほど対応の工数が大きくなります。また、各センサーで大きくバッファを持たないとログが消失する可能性もあります
  2. ログスキーマ管理の難しさ:利用するログの種類が多くなるとログのフォーマット、スキーマ管理も大きな課題になってきます。アラート検知やログ保管をする際にはログのフォーマットやスキーマを解釈して処理をする必要がありますが、これを事前に設定しておかないと取り込みに失敗します。失敗後のリトライで再度ログを送信する必要がでてくると、これもトラブル対応と同様にログの欠損や消失を防ぐための負荷が大きくなってしまいます。

この問題を緩和するために、クックパッドのセキュリティログ管理基盤ではアラート検知やログ検索のフローを実行する前に「S3にログを保存する」というレイヤーを挟んでいます。S3はバケット上にオブジェクトを作成された際、即座にそのイベント情報をSNS (Simple Notification Service) などへ通知する機能があります。これを使ってS3へのログデータ到達とほぼ同時にアラート検知のためのプロセスを発火させたり、検索システムへログデータを転送させることができます。検索システムへのログデータ転送は、先日AWSのブログで紹介されていたAWS サービスのログの可視化やセキュリティ分析を実現する SIEM on Amazon Elasticsearch Serviceでも同様のアーキテクチャが採用されています。

f:id:mztnex:20201201144027p:plain

このアーキテクチャの利点

先の述べた課題である 障害対応時の対応負荷および ログスキーマ管理の難しさが軽減されるのが、まず1つ目の利点です。利点として一見地味なのですが、継続して運用をすることを考えると、これらの負担軽減は大きな意味を持ちます。

S3は高い可用性を持っており、多くの場合自分でストレージを運用するのに比べてトラブルが少なくてすみます。そのためセンサーからS3へ「とりあえず」ログを投げ込んでおくことで欠損のリスクを大幅に小さくすることができます。また、S3に保存された後の処理が失敗しても、再度S3のオブジェクト生成イベントを流すことで容易に後続の処理を再開することができます(ただしこれは後続の処理が冪等になるよう設計されている必要はあります)。

ログスキーマ管理の観点では「センサーがログを送ってきたタイミングでスキーマが完璧に定義できている必要がない」というのが大きな利点になります。センサーのログを受け取った時点でパースするようなシステムの場合、送信の前に完璧にパースできるようスキーマの把握をしてパーサーを用意しておく必要があります。もしパースに失敗した場合はセンサー側から再送が必要になってします。しかしS3に保存する際にはスキーマは全く関係なく、もしパースに失敗してもS3から処理を再開できます。到着するまでスキーマがはっきりしないようなログでも、S3のオブジェクト生成イベントを一時的にどこかへ退避させることで、到着してからパーサーの準備をするということも可能です。

他にも、安価にログを保存できるS3でログ保管ができる、という利点があります。SIEMなどログ保管・検索を同じアーキテクチャに持つものだと、ログを保持するための料金が比較的高くなってしまいます。保管と検索を同時にしているため、保持しているログなら容易に検索できるメリットがある反面、ストレージ本体やストレージを動かすためのインスタンスないし筐体のコストが高価になりがちという問題があります。S3はデータの保存料金が非常に安価に抑えられておりコストメリットが大きいだけでなく、トータルの保存容量の制限もないためにストレージの残り容量を夜な夜な気にする必要もありません。ライフサイクルマネジメント機能を使って自動的に長期的に保存するのにも向いています。

このアーキテクチャの欠点

従来のアーキテクチャと比べたときに、S3を中心としたアーキテクチャの欠点として挙げられるのは、センサーからアラート検知やログ検索のシステムへログが到達するまでの遅延が発生することです。これはS3がオブジェクトストレージであるため、ログをなるべく細切れにせずにある程度の数を1つのオブジェクトにまとめたほうがリクエスト数が減ってコストメリットが出るためです。また、あまりにリクエスト数が多いとAPIの呼び出し制限にひっかかる恐れがあります(参考)。こうしたことから、センサー側で少しログをためた後にS3へアップロードするため、ログを貯める時間がそのまま遅延になります。この遅延がどのくらいになるかはログの流量やセンサーの能力や性質などに依存するため一概には言えませんが、筆者の経験則からするとおおよそ1〜2分、最大でも5分程度になるように各種設定するのが良いと思われます。

ここで問題になるのが、1〜2分ほどの遅延がセキュリティログの利用にどのくらいの影響を及ぼすのか? という点です。例えばManaged SOCのようなビジネスをしていたり専属の24/365体制なSOCを持つような組織の場合は、アラートが上がった場合に即応する体制が整っており、1〜2分の遅延を削ることに意味があるかもしれません。しかしそうでない場合は必ずしもアラート検出から1〜2分で対応できるとは限らず、遅延が致命的になるとは想像しにくいです。また、Managed SOCでもアラート発生から対応するまでのSLA(Service Level Agreement)が設定されている場合がありますが、最短でも15分程度です。その中の1〜2分であれば、多くの組織の場合許容できるのではないかと考えられます。クックパッド内でもこのような基準をもとに考え、多少の遅延は許容できると判断しました。

分析パートについて

この動画を撮影したのが実に1年前なので、分析パート(ボードのANALYSISの部分)について少し補足です。

アラートの検知については引き続きLambdaを使い、ログの中からアラートとして扱うべき事象の抽出をしています。アラート発報後の対応のフェイズについては サーバーレスで作るセキュリティアラート自動対応フレームワークで紹介しております。

また、ログ検索については動画中でgraylogを使っていると話していますが、その後Athenaをベースとしたセキュリティログ向け全文検索システムminervaに移行しており、graylogは退役しています。こちらについてはAmazon Athena を使ったセキュリティログ検索基盤の構築でも紹介しておりますので、よろしければあわせて御覧ください。

まとめ

最初、この撮影の話を聞いたときは「まあ5分喋るくらい大したことないでしょ」とか軽く考えていたのですが、正直な感想として想像の100倍くらい大変でした。しかし、撮影準備の過程で自分の作ったアーキテクチャについていろいろな人と議論できたり、その本質について考えさせられるなど良い経験もさせてもらいました。なにより、動画という良い形で成果を残せたことは、とても良かったと思います。この機会を提供してくれた&協力してくれたAWSの方々、そして桐山さんに改めて御礼を申し上げたいです。

少し話が変わりますが、クックパッドのセキュリティエンジニアはこのような自分たちが必要とする仕組みを自分たちで考え、組み立て、現場に活かしていくというのが役割の一つになっています。先日、CODE BLUE 2020でもこのような話をさせていただいたのですが、情報セキュリティの課題を知識や経験だけでなくエンジニアリングで解決していく、というのはとても刺激的で、私自身はとても楽しんで仕事をしています。しかし現状、一緒にチャレンジしてくれるメンバーが足りていないこともありセキュリティエンジニアのポジションは引き続き募集しています。もし興味のある方がいらっしゃいましたら、まずはzoomなどでカジュアル面談して実際どうよ?という話もできるかと思いますので、ぜひお気軽にお声がけください!


  1. (既に開催されていますが)今年のre:inventはバーチャルで11/30から3週間続くそうです https://reinvent.awsevents.com/

  2. なぜわざわざre:Inventの開催期間中なのかというと、re:Inventなら世界各地から関係者が集まるから一気に撮影できてよい、ということらしいです

  3. 直前に参加しようとした場合、最大の問題はホテルなのですが、これはre:Inventに参加する同僚の部屋が奇跡的に1人分余っている状態で、そこに転がり込ませてもらいました

  4. re:Inventの会期は12/2からで他の同僚はさらに前に現地入りしていたのですが、自分は12/1に引っ越しがあって、現地着が12/3 夜という突貫出張でした。おかげで当日までに時差ボケ治らず。

クックパッドマートのプロダクト開発チームに On-Call を導入した話

$
0
0

クックパッドマートでサーバーサイドエンジニアを担当している奥薗 基 ( @mokuzon ) です。

クックパッドマートのプロダクト開発チーム*1では半年ほど前からサービスの運用・障害・割り込み対応の当番として on-call を導入しています。直訳すると呼べばすぐ来る、待機しているという意味で、業務時間外も含めシステムを安定して運用するために待機するエンジニア、またはその制度そのものを指しています。SRE チーム*2には on-call は一般的ですし、プロダクト開発チームでも問題があれば直ぐに駆けつけることは一般的です。しかし、プロダクト開発チームで on-call を運用している事例は意外と世に出ていないので、モチベーションと運用方法、効果についてご紹介したいと思います。

クックパッドマートとは?

このブログでクックパッドマートに関するエントリーは久しぶりなので、改めてサービスの紹介をします。既にご存知の方はこのセクションは読み飛ばしても差し支えありません。

クックパッドマートは現在クックパッドが力を入れている新規事業の一つで、生鮮を中心とした EC プラットフォームです。

https://cookpad-mart.com/

以下がサービスの主な登場人物とその関係図です。

クックパッドマートの主な登場人物とその関係図

ユーザーが商品を購入し、販売者が商品を出荷し、ドライバーが各所に設置されたマートステーションに商品を届けます。 この流通を支えるために

  • ユーザー向け
    • EC モバイルアプリケーション
  • 販売者向け
    • 商品管理・出荷管理用 web アプリケーション
  • ドライバー向け
    • 作業用モバイルアプリケーション
    • 管理者向け web アプリケーション
  • マートステーション
    • 冷蔵庫そのもの
    • 遠隔監視・操作用のネットワークとアプリケーション

などを提供しています。

サービスローンチから2年経ち、最近では大手コンビニでマートステーション設置が始まるなど、鋭意拡大中のサービスです。 人員も急増しており、事業部の規模も社内最大になりつつあります。

なぜ On-Call を導入したか

クックパッドマートが on-call を導入することにした大きな理由は

  • 遅延が許されない流通に関わる処理が深夜に集中しているが、それが失敗することが多かった
  • 平日昼間の割り込みタスクが多い

の2つです。

深夜に起きる、遅延が許されない処理の失敗

これを説明するためにはクックパッドマートの特性について話しておく必要があります。

クックパッドマートの特性

1週間ごとに見直されるルーティング

理想はすべての販売者の商品をすべてのマートステーションで受け取り可能にすることですが、事業的・物理的制約でこれはまだ実現できていません。

そのため今は1週間ごとに販売者( 正確には販売者の共同出荷先 ) とマートステーションの組み合わせ、そしてそれを回るドライバーのルーティングを組み直しています。

購入可能かどうかが在庫だけではなく配送計画にも依存していること、そしてこの配送計画のデータ構造そのものが言葉にする以上に複雑です。

この複雑さにアプリケーションも人も対応しきれず、以前は入稿ミスやバグによるデータ不整合が多発していました。

山場が深夜から始まる

クックパッドマートの山場は販売者の商品の出荷から始まります。

市場や農家など、販売者の方々の朝はとても早いです。 早い販売者では深夜 02:00 から出荷が始まり、遅くても朝 10:00 までにほとんどの販売者が出荷を完了しています。 この出荷に合わせてシステムでは深夜から早朝にかけてユーザーの注文を締め切り集計し、出荷方法の指示をまとめて販売者に送信するという処理が動いています。

また、朝 08:00 にはドライバーの集荷・配送手順をまとめたデータ群が作成されます。

実際のドライバーへの指示
実際のドライバーへの指示

On-Call 導入へ

データ不整合があると販売者への出荷指示やドライバーへの集荷・配送指示の作成が止まり、比喩ではなく流通が止まります。流通はかなりギリギリのタイムラインで組まれているため、一刻も早く修正する必要があります。

これらを支える処理は販売者やドライバーが活動する前に処理が済んでいる必要があります。しかしまだアプリケーションが成熟していなかった頃は、この重要な処理を行うバッチが深夜に高頻度で落ち、夜な夜な対応に追われるということが続きました。

深夜に需要バッチが落ち悲鳴を上げるエンジニア達深夜に需要バッチが落ち悲鳴を上げるエンジニア達
深夜に需要バッチが落ち悲鳴を上げるエンジニア達

しかも原因が複雑さにあるために対応可能なエンジニアも少なく、特定メンバーの深夜労働時間が突出して伸びたり、毎晩深夜 02:00 のバッチが通るまでは心配で寝られない、という非常に不健全な状態に陥っていました。 そのため各日ごとに張り込むメンバーを決めて、それ以外のメンバーを解放することが急務でした。 この張り込みを当番化することが on-call 導入のきっかけになりました。

なお、現在は

  • 事前チェックスクリプトが整備され、深夜になる前に問題の修正ができるようになった
  • 万が一の場合も電話で通知されるようになった

ため、深夜のトラブルはほぼなくなり張り込みは完全になくなっています。

割り込み対応

もちろん理想は割り込みが起きないよう根本的な改善をしていくことですが、限られたリソースで新規開発を進めている以上、必ず割り込みは発生します。 そして悲しい現実として、割り込み対応をし続けると本来の業務へ使える時間は減っていき、人事評価に繋がる成果からは遠のきやすくなります。これでは負担が集中しているメンバーのメンタルは濁ります。

そこで、まずは最低限 on-call によりローテーションを決めて特定メンバーに割り込み対応が偏らないようにしています。 また、on-call の時とそうでない時で割り込み対応の有無を明確に分けることはエンジニアが集中しやすい環境としても重要です。

以下の3つがクックパッドマートの割り込み対応の大半を占めています。

データ修正

新規サービスに携わったことがある方々は分かると思いますが、サービス初期は素早くユーザーに価値を提供し事業を成立させることが最優先であるため、どうしても管理画面をはじめ運営用ツールの開発は後回しになりがちです。 しかし事業の規模や複雑さが増すにつれてデータ修正の件数は増えてきます。

すると何が起こるかと言うと、データ修正の依頼がエンジニアに殺到します。 管理画面や機能がない以上、非エンジニアはエンジニアにデータを直接書き換えてもらうかデータ更新のワンタイムスクリプトを書いてもらうしかないからです。

そしてこの割り込みは、関わるメンバーの多さや親切さなど理由はいくつかありますが、一部のメンバーに集中する傾向があります。今も個人への依頼を都度 on-call に誘導する努力を続けています。

非エンジニアからの質問を調査

非エンジニアの同僚から仕様を質問されたり、問題が起きた時に原因調査を依頼されたりすることはよくあります。 もちろんすぐに答えられたり責任範囲が明確だったりするものはそのメンバーが対応すればよいでしょう。しかし、エンジニアも自分が実装した内容を全て覚えてはいませんし、そもそも実装したエンジニアが今も在籍しているとは限りません。その場合結局エンジニアも都度コードを読んだり背景をたどったりして調べる必要があるのです。

特にユーザーからの問い合わせをまとめて受けている CS*3からの質問が圧倒的に多いです。 またクックパッドマートのようなプラットフォーム型サービスでは、提供者側 ( クックパッドマートでは販売者 ) に対しても CS が存在します。こちらは売り上げに直結するため温度感が総じて高く、優先度最高の割り込みになりがちです。

実は on-call 導入前はまだユーザー向けの CS 専任メンバーがおらず、大きな負担にはなっていませんでした。しかし、経験上専任メンバーが生まれたら爆発的に増えることはわかっていたのであらかじめ負担が分散される on-call の仕組みに乗せてしまいました。実際に予想通りになった上、ユーザー数の増加にあわせて CS 案件も増えているので、先手を打ててよかったと思っています。

アクセススパイクへの事前準備

クックパッドマートは EC サービスなので、販促イベント等の

  • いい商品が出た
  • 期間限定品が出た
  • 安く買える商品が出た

という状況ではアクセスが如実に跳ねます。具体的には販促のプッシュ通知やタイムセールが該当します。特にタイムセール開始時のアクセス数は平時の15倍以上になります。このようなアクセススパイクは平時のインフラ構成では捌ききれないので、適切に対策をしておく必要があります。

タイムセール時の rps
実際のタイムセール時の rps、 18:30 過ぎに跳ねている

クックパッドマートのシステムのインフラは殆ど AWS*4を利用して構成されています。さらに、プロダクト開発チームが自走出来るように SRE チームが権限の譲渡や基盤の整備などいわゆるセルフサービス化を進めているため、プロダクト開発チームでも容易にアプリケーションやデータベースのスケールアウトが出来る環境になっています。

販促イベントは不定期かつ内容も多彩なので、こうしたイベントがスケジュールされた際に過去の事例等からキャパシティプランニングをしてインフラの対策をし、該当時間に監視を行うのも on-call の重要な役目となっています。

このアクセススパイクとの奮闘はまた別の機会に詳細にご紹介できればと思います。

On-Call の運用方法

クックパッドマートでは2020年12月時点で 18 名のエンジニアで Primary と Secondary を12時間ないし24時間間隔でローテーションしています。 基本的には Primary が優先的に対応し、あふれた分を Secondary が拾うスタイルです。 現状では障害や割り込みといったタスクの種類と役割の紐付けは行っていません。

運用にあたっては以下のツールを使っています。

エンジニア依頼・質問専用の Slack チャンネル

弊社は全社的に Slack*5を使っており、クックパッドマートも大量のチャンネルを使っています。その中で、エンジニア依頼・質問専用のチャンネルを作りました。

これにより

  • 各チャンネルにいる身近なエンジニアに負担が集中することを防げる
  • 各チャンネルに散らばっていて全体像が見えない割り込み対応の規模・状況が俯瞰できる
  • 記録が一箇所に蓄積される
  • 新メンバーがコミュニケーションする場所を迷わない

などのメリットがあります。

GitHub Issue

よくフロー型とストック型のツールを適切に使い分けましょうと言いますが、上記の Slack 専用チャンネルでは正に情報をストックしづらく参照しにくいという課題があったため、GitHub*6の issue 上にて記録を残すことを意識したやり取りにシフトしつつあります。

こうすることで、初めて経験する問題に直面した際も、過去の同様の事例を元に対処しやすくなりました。

なお、この issue は一つ専用リポジトリを作って、issue open / close のみ先述の Slack チャンネルに通知を流すようにしています。

これにより通知で流れが阻害されることなく、各案件のアサイン割り振りや状況確認等を適切に Slack で行えるようになっています。

現在は以下の template を設けています。

## 概要

## 関係する URL

## 解決したい期限(あれば)

<!---
どなたかをこの issue にアサインして、誰がこの issue に取り組んでいるのか分かりやすい状態にしてください。

* エンジニア on-call で今 Primary の人をまずはアサインしてください。
    * その後、より適切な方がいればエンジニアの方で適宜アサインを変えていきます。
* よく分からなければ何もせずそのままにしておいてください。
    * 気づいたエンジニアの方、アサインの設定をお願いします。
    * もしちょっと待っても誰もコメントしていないようであれば #kaimono-dev-inquiry でエンジニアにご連絡ください。
-->

PagerDuty

当初は2ヶ月ほど Google スプレッドシート*7でローテーション管理をしていました。しかしこの運用は管理者とon-call 対象者に少なくない負担を強いていました。 さらに、通知などツールによる支援を受けたい需要も大きかったため、部署横断の SRE チームで既に利用実績のある PagerDuty*8を採用しました。

  • 夜間に重要な処理が多く走るため、電話をはじめ多くの通知方法を備えている点
  • 業務委託の方にも on-call をご対応いただく際に、業務時間外に割り振られないよう柔軟にローテーションのルールを決められる点
  • 各自のスケジュールを iCal 形式で出力できる点

が特に助かっています。

On-Call を導入して

これまで述べた役割を上記の方法で機械的に分担するようにした結果、

  • on-call 時以外の平和な夜
    • とはいえ今は on-call でもほとんど平和
  • on-call 時以外の割り込みが激減し、生産性が向上
  • on-call は予めスケジューリングされているため、それを見越した見積もりが出来る
  • 割り込み対応の偏りが軽減し、メンバーのメンタルに良い影響
  • 特定メンバーに集中していたアプリケーションの知識やノウハウの拡散

など、サービスの課題から組織の課題まで幅広く健全な状態に近づいたと考えています。

反面、参画して間もないメンバーにも在籍が長いメンバーと同様の負担を強いており、on-call に精神的負担を感じてしまう課題も出始めています。 この問題には、初期は経験豊富なメンバーとペアを組んだり、可能な限りドキュメント整備したりすることで改善を図っています。また、on-call のオンボーディングに関して SRE 本*9で非常に実践的な内容が紹介されており、SRE でなくともそのエッセンスは十分に活かせると考えています。

今後も、様々な問題を仕組みで解決するエンジニアらしさを武器にサービスも組織も更に成長させてゆきたいと思います。 そんなクックパッドマートは絶賛エンジニア大募集中なので、興味のある方はぜひ以下のリンクから詳細な採用情報をご覧ください。

https://cookpad-mart-careers.studio.site/

最後までお読みいただきありがとうございました。

*1:プロダクトの開発を行うチーム。イノベーションを生み出すことに責任があり、その速度も非常に重要視されている。チームには様々な職種のメンバーがいるが、本記事ではその中のエンジニアを指してこの用語を使っている。

*2:サイト・リライアビリティ・エンジニアリング ( Site Reliability Engineering ) を行うエンジニアチーム。Google が提唱していて、プロダクトの安定性に責任を持ち、そのためにソフトウェア・エンジニアリングのエッセンスを用いている。

*3:Customer Support、いわゆるお客様窓口。

*4:https://aws.amazon.com/

*5:https://slack.com/

*6:https://github.com/

*7:https://www.google.com/sheets/about/

*8:https://pagerduty.com/

*9:Betsy Beyer, Chris Jones, Jennifer Petoff, Niall Richard Murphy 編、澤田 武男、関根 達夫、細川 一茂、矢吹 大輔 監訳、Sky株式会社 玉川 竜司 訳『SRE サイトリライアビリティエンジニアリング―Googleの信頼性を支えるエンジニアリングチーム』 O'Reilly Japan, Inc. ( 2017 )

大規模プロジェクトにおけるモバイル基盤の取り組み

$
0
0

こんにちは。モバイル基盤部のこやまカニ大好き(id:nein37)です。

モバイル基盤部では普段CI環境の改善やアプリのビルド速度改善といったモバイルアプリを開発しやすくする様々な取り組みを行っていますが、大規模なサービス開発をサポートするため、直接プロジェクトに参加する場合もあります。

クックパッドAndroidアプリでは10月に大規模なリニューアルを行いました。 モバイル基盤部でも数カ月間このリニューアル作業に関わったので、今回は大規模プロジェクトにおけるモバイル基盤部の役割について書いてみることにします。

リニューアル前リニューアル後
f:id:nein37:20201203195817p:plainf:id:nein37:20201203195915p:plain

リニューアルプロジェクトの概要

3月に書かれたテストケース作成を仕様詳細化の手段とする実験という記事でも少し触れられていますが、クックパッドiOSアプリは半年ほど前に先行して同様の大規模リニューアルを行っていました。

今回のAndroidアプリのリニューアルプロジェクトは先行するiOSアプリの機能や画面構成を元にAndroidで違和感のないように再設計し、6人のAndroidアプリエンジニアを3ヶ月程度投入してプラットフォーム間の機能を揃えるというクックパッドアプリとしてはかなり大規模なプロジェクトでした。

このプロジェクトの実施は実際に機能開発を行う数ヶ月前から告知されていたので、モバイル基盤部ではプロジェクトに先行して準備期間を設定し、アプリ全体の開発効率を引き上げるための取り組みを行いました。 この記事では主にこの準備期間にモバイル基盤が行った作業について説明していきます。

なお、このリニューアルに際してアーキテクチャは大きく変更していないので、記事中に登場するVIPERアーキテクチャ関連の用語に関しては2020年のクックパッドAndroidアプリのアーキテクチャ事情を参照していただくとわかりやすいと思います。

やったこと

まず最初に、大規模リニューアルプロジェクトの実施に先駆けて、事前にやっておいたほうが良いことをissueで議論しました。

f:id:nein37:20201203195951p:plain

以下に出てくる内容もほとんどはこの issue で議論されてタスクとして設定されたものです。 実際に準備期間で行わなかったことでも今後の改善内容として意識することができたので、特に大きなプロジェクトがない場合でも定期的にこういったissueを立てて議論すると良いかもしれません。

minSdkVersion 23

2月に開催された Cookpad.apk #4で3月から minSdkVersion 23 にしますという話をしていたのですが、その後の情勢の変化により一時的に全ユーザーに人気順検索を開放することになったため、この施策で支援できるユーザーを減らしてしまう minSdkVersion の繰り上げは延期されていました。 人気順検索開放施策の終了後もしばらく minSdkVersion 21 だったのですが、今回のリニューアルプロジェクト準備施策の一環として再検討を行い、6月には minSdkVersion 23 にすることができました。

クックパッドにおけるminSdkVersion 23 にすることの利点は主に以下になります。

  • Drawable への tint 挙動を揃えることができる
    • Android では Drawable リソースをメモリに展開して使い回すようになっていますが、5.x系のOSでは tint 適用後のリソースを再利用してしまうため、 本来は tint を適用したくない箇所でも tint が適用され見た目がおかしくなる場合があります。
    • この挙動はDrawableのドキュメントNote:として書いているだけだったので当初は原因がわからず調査が大変でした。
  • android:foregroundによる ViewGroupへのタッチフィードバック実装
    • API21, 22 では FrameLayout以外の ViewGroupforegroundが正しく反映されないため、foregroundを利用してタッチフィードバック(ripple)を実装するとうまく反映されません。
    • stackoverflowの類似投稿
    • material-components のリポジトリにもForegroundLinearLayoutが存在しているので、他プロジェクトでも不便そうだなと思っています。

マルチモジュール関連

Cookpad.apk #1去年のブログ記事でもクックパッドアプリのマルチモジュール化についてお話していますが、現在でも多くの画面実装は :legacyモジュールという巨大なモジュールに残っている状態でした。 :legacyモジュールがあるとついつい :legacyに依存したモジュールを作成してしまうのですが、これだといつまでも :legacyモジュールを無くせないので、準備期間の間に :legacyに依存しない VIPER シーンモジュール、 :featureモジュールを作れるように整備しました。

簡略化していますが、だいたい以下のようなモジュール依存関係になっています。

f:id:nein37:20201203200009p:plain

赤枠の app と書かれた部分がクックパッドアプリのアプリケーションモジュール、青枠の feature と書かれた部分がVIPERシーンで構成された :featureモジュール、そして 緑色の library と書かれた部分がVIPERよりも低レイヤーの :libraryモジュールです。 :featureモジュールは画面機能ごとに完全に独立していますが、 :libraryモジュールは共通の画面実装機能を定義する:library:ui、画面遷移処理を定義する :library:navigation、認証・通信機能を実装する :library:networkなど役割に応じて分割され、必要に応じて :library同士でも依存関係を持っています。

モジュール階層の整理

モジュールの依存整理と直接関係のない変更ですが、 Android Studio 3.6 (当時はまだbeta)から /library/networkのような階層化されたモジュールを正しく Project ウィンドウで扱えるようになったため、モジュールの配置を種類に応じて階層化しました。

f:id:nein37:20201203200027p:plain

Android Studio(Android Gradle Plugin) 更新は基本的に安定版が出るたびに随時行っていますが、 beta を先行して利用したい場合などは以下のように突然 Slack で方針を決める場合もあります。

f:id:nein37:20201203201228p:plain

feature モジュールで必要な機能の移動

:legacyモジュールには CookpadMainActivityと呼ばれる2000行程度の ActivityCookpadMainActivityが管理する ActionBar、サイドメニュー実装なども含まれています。 これらの機能を :featureモジュールから :legacyに依存させずに呼び出すため、 :library:uiモジュールに必要な実装を切り出しました。 その他の細かい Util 系クラスも役割に応じて :library:infra:library:navigationといったモジュールに移動させています。

分離が必要な処理は :featureモジュールを実装してみるまでわからない場合も多いので、事前準備した部分だけでなくあとから必要になって :legacyから分離した機能もかなりあります。 今後も必要に応じて素早く :legacyからの機能分離ができるようにコード理解に努めていきたいと思います。

モジュール間の画面遷移設計

クックパッドアプリでは、 ボトムタブごとにFragmentの遷移履歴を残すために Primary navigation fragment) という仕組みを利用しています。 Primary navigation fragment には長い間公式の詳しいドキュメントがなかったのですが、最近のFragmentドキュメント刷新によってわかりやすくなりました。 (Primary navigation fragment については長くなるので省略します。Navigation コンポーネントの NavHostFragmentと同じようなことを自前でやっていると思ってください)

クックパッドアプリ内の画面遷移ではこの primary navigation fragment が管理している FragmentManagerを利用して主に Fragmentによる画面遷移を行っています。 ここで問題になってくるのが遷移先 Fragmentインスタンスの生成方法です。 基本的に :featureモジュール同士は画面遷移がある場合でもお互いに依存を持つことが出来ません。もし画面遷移が必要な場合にモジュール間の依存で解決しようとした場合、互いの画面を行き来するような :featureモジュールが循環参照になってしまいます。 :featureモジュール間で画面遷移を行うためには遷移先の画面が実装されたモジュールに依存しないようにしつつ、遷移先画面のインスタンスを生成しなくてはいけません。

この問題を解決するため、クックパッドアプリでは低レイヤーの :library:navigationモジュールに配置した AppFragmentFactoryという interface にほぼすべての Fragmentの生成メソッドを定義して抽象化しています。 AppFragmentFactoryの実装はすべての :featureモジュールへの参照を持つアプリケーションモジュールで行っており、各画面が扱う画面遷移用のパラメータに関しては :library:navigationモジュール内に専用の data class を持つようにしています。

また、今回のリニューアルから結果を返す Activityへの画面遷移については ActivityResultContractを利用するように変更しました。 これまでは Activityの処理結果が必要な場合も AppActivityIntentFactoryという interface から Intentを返していたため startActivity()で呼び出すべきか startActivityForResult()で呼び出すべきかわかりませんでしたが、この変更によって結果を返す Activityへの画面遷移は AppActivityResultContractFactoryに分離することができ、画面遷移実装の難易度を少し下げられました。

画面遷移に関しては将来的には公式実装である Navigation コンポーネントに置き換えていくことになると思いますが、クックパッドアプリでは :library:navigaionモジュールの存在によって将来的に別の仕組みにも移行しやすく無理のない実装になっていると思います。

デモアプリモジュールの実装

:legacyに依存しない :featureモジュールを作成できるようになったことで、特定の :featureモジュールのみに依存するアプリモジュール、デモアプリモジュールも作成できるようになりました。 :legacyに依存していてもデモアプリモジュールを作ることはできるのですが、 :legacyへの依存が入るとビルド速度がどうしても遅くなってしまうため、これまではデモアプリモジュールをあまり検討していませんでした。

デモアプリの仕組みはiOS アプリで先行してSandboxアプリとして実装されているものとほぼ同じです。 Androidプロジェクトでは demo という名前のモジュールで作られていることが多いので、クックパッドのAndroidアプリでも :demo:○○_demoというモジュールで作成しています。 大体以下のような構造になっています。

f:id:nein37:20201203200124p:plain

デモアプリモジュールは demo:app_baseへの依存を持ち、このモジュール内で :library:navigation:library:network系モジュールで定義された interface の空実装(stub と呼んでいます)を定義しています。 各デモアプリモジュールは必要に応じて stub を継承し、自分が参照する :featureモジュールへの依存や特定の DataSource が返す結果など必要な処理だけを上書きしています。 デモアプリモジュールではこの仕組によってユーザー状態やネットワークレスポンスをモックすることで様々な表示テストや挙動確認を行うことができる他、巨大な legacy モジュールにも依存していないため、ビルド時間も非常に高速です。 手元の環境で同一差分を :featureモジュールに与えてビルドしてみた所、通常のクックパッドアプリでのビルドは54秒かかるのに対しデモアプリのビルドは16秒でした。

実際に今回のプロジェクトでもつくれぽ送信画面改修時に demo:tsukurepo_demoモジュールでビルドされたアプリが非常に活躍しました。 demo:tsukurepo_demoは画像選択を行う Activityへの遷移処理をモックして固定の画像を返す機能をもっているため、画像の複数枚選択時の挙動を簡単に試すことができます。 以下のアニメーションがデモアプリで画像選択機能をモックして固定の画像を返すようにしているときの動作です。

デモアプリと直接関係のない変更でもデモアプリモジュールが依存している interfaceを編集するたびに stub の修正が必要になってしまうという欠点はありますが、デモアプリがうまく利用できる場面では開発効率が非常に良くなるため今後もデモアプリの運用を改善していく予定です。

スタイル再定義

これまでクックパッドアプリでは2016年頃に定義したスタイルやThemeを少しずつメンテナンスしながら使っていました。 2016年から現在までデザインの大きな変更がなかったため、アプリ全体の Theme/Style も当時のまま AppCompat をベースにしたものを利用していましたが、今回のリニューアルにより Material Components を利用したほうが効率的に実装できる箇所が増えたたため、 Theme.MaterialComponents.*ベースで Theme/Style を再定義することにしました。

ボタン定義

クックパッドアプリでは ButtonTextViewの左端にアイコンを置くデザインをよく使っています。 Android のボタンには上下左右にアイコンを表示するための android:drawableStart属性があり、これまではクックパッドアプリでもこの属性を利用してアイコンを表示していました。 android:drawableStartを利用した場合、以下のようにボタンの左端にアイコンが表示されます。

f:id:nein37:20201203200137p:plain

これまでは上記のデザインで問題なかったのですが、新しいデザインではこのアイコンを文字に揃えて中央寄せにしたいという要望がありました。

f:id:nein37:20201203200154p:plain

これを解決するため、 Material Components の部品である MaterialButtonを利用することにしました。 この部品は先述の android:drawableStartとは別に app:icon属性を持っており、これによってより細かいアイコン描画の制御を行うことが出来ます。 同時に app:iconSizeによる表示サイズの制御や app:iconTintによる表示色の変更もできるようになり、より柔軟な表示ができるようになりました。

MaterialButtonはアイコン表示の他にも app:cornerRadiusapp:strokeWidthといったこれまで背景画像や Shape を利用して描画していた角丸・枠線を描画する属性も備えており、より再利用性しやすい Style を定義することが可能になりました。

実装時に遭遇した問題として、当時の MaterialButton実装にバグが有り、android:background に drawable リソースを指定すると正しく反映されないという問題がありました。 これは簡単に回避する方法がなかったので背景色を android:backgroundTint + color state リソースにして解決しました。 角丸や枠線をすべて属性だけで解決できる MaterialButtonでは drawable リソースを android:backgroundに指定するケースはほとんどないので、結果的に背景リソースがシンプルになってよかったと思います。

他にもToggleButtonMaterialButtonと Style を共通化できなくなるなどの問題もありましたが、 ToggleButton自体の利用箇所が少なかったため、専用の Style を定義しなおして再実装できました。

上記のような問題がありつつも無事 MaterialButtonへの乗り換えができたので、 Hyperion のデバッグメニューからアクセス可能なボタンStyleのプレビュー画面を作成しました。こういった画面を作っておくとレイアウトXMLを実装サンプルとしても使えるので便利です。

f:id:nein37:20201203200107p:plain:w320

MaterialTheme の導入

MaterialButtonを利用することにしました」とさらっと書きましたが、MaterialButtonはアプリの Theme が Theme.MaterialComponents.*を継承している場合しかうまく動作しません。 そのため、アプリの Theme にも手を入れる必要があります。この作業は本当に大変でした。

クックパッドアプリはこれまで Theme.MaterialComponents.Lightを継承していましたが、基本的なボタンなどの Style などは整備されており、その中で StateListDrawableによる背景色切り替えをタッチフィードバックとして利用していました。 長い間、 colorPrimaryすら定義されない状態のまま長年運用してきていたのです。

しかし、 Theme.MaterialComponents.*ベースのアプリではそういうわけにはいきません。 colorPrimary未指定でも色々な箇所にリップルエフェクトがかかり、謎の紫色の tint が適用されます。デフォルトカラーなのかなんなのかわかりませんが、クックパッドアプリが部分的に紫色になってしまうのです。 これを直すために theme の color*系属性を指定し、いろいろな View のデフォルト style を整備し、実装のよくないレイアウトファイルを直しました。 おそらくすべて直せたと思っていますが、もしクックパッドアプリに変な紫のボタンやタッチフィードバックを見かけたら、それは僕の実装漏れです。こっそり教えて下さい。

幸いなことに Material Components の各属性の定義ドキュメントは本当にしっかりしているので、慣れると短期間で色々な箇所を実装できるようになりました。 後述する MaterialCardViewなど非常に素晴らしいView実装もあるため、これまでの AppCompat ベースの実装よりも実装効率が良いと思います。 ボタン Style の整備も含めて Material Components の完全導入には2週間以上掛かっていますが、これはやっておいて良かった変更でした。

もしまだ AppCompat ベースの theme を利用しているプロジェクトがあれば Material Components への切り替えをおすすめします。

MaterialCardView

Material Components を導入し、 Theme.MaterialComponents.*に切り替えたおかげで MaterialCardViewが利用できるようになりました。 このViewは本当に便利で、これまで複雑なViewを組んだり shape drawable + clipToOutline を用意して実現していたことを View 階層ひとつで解決してくれます。

  • 角丸がつけられる
    • これは普通の CardView でも実現できました
    • 内部のViewを自動的に切り取ってくれるので Glide での角丸処理などが不要で便利になりました
  • 枠線がつけられる
    • 角丸+枠線がこれひとつで出来ます。便利
  • ドキュメントから属性が探しやすい
    • Material Components の部品はすべてそうですが、実装例と属性が詳しく書いてあるので非常に実装しやすいです
    • 標準View の属性も Android Developers を見れば書いてありますが、あまりわかりやすくなかったのでこれは嬉しい変更です

たいていのレイアウトは MaterialCardView + ConstraintLayout で組めるので本当に便利になりました。

ShapeableImageView

ShapeableImageViewも Material Components を導入したおかげで使えるようになったView要素です。 Shape による画像の切り抜きや枠線をつけることができる ImageViewで、これまで Glide でやっていた処理をレイアウト側の定義だけで行えるようになりました。

画面実装ドキュメント整備

Material Components の導入による画面実装の変化やリニューアル実施前の相談によって決まった画面実装方針についてドキュメントをまとめました。 今回のリニューアルプロジェクトではAndroidアプリをこれまで開発していなかったメンバーも開発に参加することになったため、初学者にもわかりやすい内容と公式へのリンクをまとめました。 この内容については吉田さんが後日techlifeに記事を書いてくれる予定なので、主な内容だけ列挙しておきます。

  • ViewBindingの利用
    • 時期的にまだ Kotlin View Binding のサポート終了は告知されていませんでしたが、対象レイアウトファイルの取り違えが起きやすい等の問題があったため ViewBinding の利用を推奨していました
    • クックパッドアプリでは主に学習コストの問題から DataBinding はほとんど利用していません
  • Material Components の推奨
    • MaterialButtonShapeableImageViewの利用方法について書いています
  • ConstraintLayoutの使い方
    • よく使う機能や注意点についてまとめています
  • シンボルフォントの利用方法
    • クックパッドアプリでは一部のアイコン表示のためにカスタムフォント(ttf)を利用しています。
    • これを利用するための CookpadSymbolSpanという MetricAffectingSpanとそれを参照する style を用意しているため、その利用方法について書いています。
  • SampleData の利用方法

余談ですが、View実装ドキュメントをリポジトリに入れるPRのレビューにはクックパッドアプリだけでなくクックパッドマートアプリcookpadLive アプリの開発者もレビューに参加してくれていて、非常に良い雰囲気のPRでした。

統一ログ基盤の準備

@giginetさんがドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築という記事で iOSアプリのログ基盤について説明してくれていますが、 リニューアルプロジェクトの実施にあたりAndroidアプリでも同様のログ基盤を整備しました。

これにより、Android アプリでも iOS と同じ定義でログを実装できるようになったため、ログの実装や確認作業がかなり楽になりました。

ふりかえり

ここまでの施策を振り返ると目的別に振り返ると大体以下のような作業を行っていました。 画面構成が大きく変わるため、特にView実装の省力化にフォーカスしていることがわかります。

  • ビルド速度改善
    • feature モジュール依存整理
      • 画面遷移遷移再設計
    • デモアプリモジュール導入
  • 画面実装の省力化
    • minSdkVersion 23
    • Material Components 導入
      • Theme/Style 整備
      • 高効率な実装が可能なViewの導入
    • ドキュメント整備
  • 統一ログ基盤の実装

やってよかった施策

デモアプリモジュール

クックパッドアプリ全体の依存関係で見るとデモアプリモジュールというよりも:legacyモジュールと :featureモジュールの分離が達成できたというのが大きな成果でした。 個人的にはデモアプリモジュールは副産物としてしか見ていなかったのですが、実際にうまく活用できるケースでは実装時間や確認の手間を圧倒的に削減できたので、マルチモジュールプロジェクトでは取り組む価値はあると思います。

画面実装ドキュメント

画面実装は人によって実装方針がバラバラになりがちなので、記法方針をまとめたドキュメントがあることは実装・レビューの両方で時間の短縮に繋がり非常に良かったと思います。 リニューアルプロジェクトに向けて整備したドキュメントでしたが、今でもドキュメントを見て複雑な部分はまだ改善の余地があるということなので、今後の改善ツールとしても使っていける良い仕組みでした。

もうちょっと工夫できたなと思う施策

デモアプリモジュール

デモアプリはツールとしては非常に強力なのですが、クックパッドアプリではうまく動作させるための大量のモック実装(stub)が必要になってしまいます。 ほとんどの stub は :demo:app_baseに作成済みとはいえ、新規 :featureモジュールとセットで demo モジュールを作る作業はかなり大変なので省力化していく必要があると感じています。

リソースの命名規則

画面実装ドキュメントに書いておけば良かった項目の一つがリソースの命名規則です。 モジュール間でリソース名の重複が置きた場合、最後に解決されたモジュールのリソースで同名リソースがすべて上書きされてしまうため、 recipe_background.xmlのようなありがちな命名をしてしまうと意図せず他の画面のデザインを壊してしまう可能性があります。

クックパッドアプリではVIPERシーンという画面ごとの区切りがあるため、これを prefix として必ず入れるルールにすべきでした。 マルチモジュール構成のプロジェクトではありがちな事故なので、みなさんも気をつけてください。

おわりに

今回は大規模リニューアルプロジェクトを控えた状態で主に画面実装の効率を改善するための取り組みについて紹介しました。 リニューアルプロジェクトの作業も面白いですがこういう効率化のための裏方の作業もまた違った面白さがあるので、大きな改修を控えている場合は検討してみるのも良いと思います。

モバイル基盤部では他のエンジニアの開発効率を引き上げられるような取り組みについて常に考えています。こういった開発スタイルに興味がある Android エンジニアの方はぜひご連絡ください。

https://info.cookpad.com/careers/

Ruby 3 の静的解析ツール TypeProf の使い方

$
0
0

こんにちは、フルタイムRubyコミッタとして働いてる遠藤(@mametter)です。昨日、Ruby 3.0.0-preview2がリリースされました!

このリリースには、遠藤が開発している Ruby の静的型解析ツール TypeProf が初めて同梱されています。これの使い方をかんたんにご紹介したいと思います。

デモ

TypeProf は、型注釈のない Ruby コードを無理やり型解析するツールです。とりあえずデモ。

# user.rbclassUserdefinitialize(name:, age:)
    @name = name
    @age = age
  endattr_reader:name, :ageendUser.new(name: "John", age: 20)

typeprof コマンドは、Ruby 2.7 で gem install typeprofでインストールできます *1。TypeProf にこの Ruby コードを与え、typeprof user.rb -o user.rbsと実行してください。次のような内容の user.rbs が生成されているはずです。

# ClassesclassUserattr_readername: Stringattr_readerage: Integerdef initialize: (name: String, age: Integer) -> Integerend

TypeProf は、与えられた Ruby コードの型情報を推定して出力します。user.rbs は、Ruby ではなく、RBS という Ruby 3 標準の型情報記述の言語で書かれています。雰囲気でなんとなく読めるかと思いますが、たとえば def initialize: (name: String, age: Integer) -> Integernameというキーワード引数が Stringインスタンスを受け取り、ageというキーワード引数が Integerインスタンスを受け取る、ということを表現しています。返り値は Integerですが、initializeの返り値はあまり意味がないですね。

TypeProf の特徴は、メソッド呼び出しの情報をフル活用するところです。これは従来のふつうの型解析と本質的に異なるところです。これにより、def initialize(name:, age:) ... endという型注釈が一切ない定義に対しても、User.new(name: "John", age: 20)という呼び出しで渡される型を見てそれっぽい型情報を推定します。

もうひとつデモ

TypeProf は、Ruby コードだけでなく RBS も合わせて解析できます。デモ。

# test.rbdefhello_message(user)
  "The name is " + user.name
enddeftype_error_demo(user)
  "The age is " + user.age
end

user = User.new(name: "John", age: 20)

hello_message(user)
type_error_demo(user)
# user.rbsclassUserdef initialize: (name: String, age: Integer) -> void

  attr_readername: Stringattr_readerage: Integerend

user.rbs は、前の例の出力を少しだけ手修正したものです。これと test.rb をあわせて解析します。 typeprof -v test.rb user.rbsと実行してください。

# Errors
test.rb:7: [error] failed to resolve overload: String#+# ClassesclassObjectprivatedef hello_message: (User) -> Stringdef type_error_demo: (User) -> untyped
end

コマンドライン引数の -vはエラーの可能性を表示させるオプションです。 このため、今度は # Errorsという出力があります。 test.rb の 7 行目を見てみると、"The age is " + user.ageという計算をしていますが、これは StringIntegerを結合しようとしています。 TypeProf はこれをバグとして警告しています。

class Objectから endは、先程と同様に test.rb の型情報を推定したものです。 なお、7 行目に型エラーの可能性があって型の追跡ができなくなったため、type_error_demoメソッドの返り値は untypedとなってます。

Ruby TypeProf Playground

TypeProf は Ruby TypeProf Playgroundでブラウザ上で試せます *2

左上が Ruby コード、左下が RBS(書かなくても良い)で、Analyze ボタンを押すと右側に解析結果が表示されます。 Ruby コードをいじって解析することもできるので、期待に反する挙動を見つけたら Report bug ボタンでぜひ報告してください *3

TypeProf の課題と現状

TypeProf は「型注釈を書かない選択肢を Ruby に残す」ということを至上命題とした極端な設計になっているので、様々な問題点もあります。

  • とにかく解析が遅い *4
  • まともな解析のためにはテストコードが必要 *5
  • 現時点では解析精度が低く、誤検知や見逃しがとても多い
  • 手本になる前例がなく、TypeProf 自体の設計に試行錯誤が必要

そのため Ruby 3.0 の TypeProf では、型注釈のない Ruby コードに対して RBS スタブを生成する、という機能にフォーカスして設計・実装を進めました *6。 RBS スタブ生成機能として経験を積みながら、高速化や解析精度向上を進め、将来的にはかんたんな型検査器として使えるものにできたらいいなと思っています。

まとめ

Ruby 3 に同梱される型解析ツール TypeProf をご紹介しました。かんたんな使い方にフォーカスして書いたので、もう少し詳しいことはドキュメントや過去の発表をご参照ください。

github.com

rubykaigi.org

TypeProf の現状の完成度としては、Ruby パッケージに含まれるすべての .rb ファイルで解析が通る *7ことを確認できた程度です。出力の精度評価や速度向上はまだまだこれから頑張っていきます。ぜひ遊んでみて、気づいたことがあったらバグ報告でも感想でもいただけると泣いて喜びます。

RBS と TypeProf の関係は? Steep や Sorbet というのも聞いたが?

Ruby 3 の静的解析は固有名詞が多くてややこしいので、関係を別記事にまとめました。

techlife.cookpad.com

*1:ruby 3.0.0-preview2 なら gem install なしで typeprof コマンドが利用可能です。

*2:社内で「こういうのを作りたい」と語ったら、id:koba789さんが 1 時間で作ってくれました。

*3:とても適当に運用しているので、サーバが落ちたらごめんなさい。

*4:ふつうの型解析は基本的にメソッド単位で解析を行いますが、TypeProf は呼び出し元をたどる必要があるため、大変になりがちです。なお、RBS 言語を部分的に手書きすればするほど(理論上は)早くなります。

*5:解析のカバレッジを上げるため、スタブ実行というヒューリスティクスを実装しています。これは、どこからも呼ばれなかったメソッドに untyped な引数を与えて無理やり呼び出すものです。これにより、テストがなくても一通りの解析は行えるようになっています。

*6:なお、rbs コマンドにも Ruby コードから RBS スタブを生成する機能がありますが、こちらは基本的にすべての引数を untyped として出力するものです。やることが単純な分、速くて安定しているというメリットもあります。

*7:TypeProf が理不尽に例外終了しないことを確かめた程度ですが、やんちゃなコードだらけの test/ruby や ruby/spec はそれだけでも地獄の苦しみでした。


Ruby 3の静的解析機能のRBS、TypeProf、Steep、Sorbetの関係についてのノート

$
0
0

こんにちは、フルタイムRubyコミッタとして働いてる遠藤(@mametter)です。

Ruby 3 は「静的型解析」を備えることが目標の 1 つになっています。遠藤が開発してる TypeProf は Ruby 3 の静的型解析エコシステムの中の 1 ツールです。しかし Ruby 3 の静的解析というと、RBS、TypeProf、Steep、Sorbet などいろいろなツール名が出てきてよくわからない、という声を何回か聞いたので、かんたんにまとめておきます。

3 行まとめ

  • RBS:Ruby の型情報を扱う言語。Ruby 3 にバンドルされる。
  • TypeProf:型注釈のない Ruby コードを型解析するツール。Ruby 3 にバンドルされる。
  • Steep/Sorbet:Ruby で静的型付けのプログラミングができるツール。

詳しくはそれぞれ以下で解説します。

RBS とは

RBS は、Ruby 3 の型を扱うための基盤です。おおよそ、次の 4 種類のものからなります。

  • RBS 言語:Ruby プログラムの型情報を記述するための記法(拡張子 .rbs)
  • Ruby 組み込みライブラリの型情報:Ruby の組み込みクラス(ArrayStringなど)の型情報を書いた.rbsファイル群
  • RBS ライブラリ:.rbs ファイルのパースや解析などをするライブラリ
  • rbs コマンド:.rbs ファイルを扱うための便利コマンド

いろいろありますが、Ruby プログラマが直接意識するのは 1 つめの RBS 言語だけだと思います *1。よって、単に RBS と言ったら「RBS 言語」、または「RBS 言語で書かれたソースコード」を指すと考えるのがよいと思います。

RBS 言語の例を示しておきます(core/string.rbsよりものすごく抜粋)。Ruby っぽいですが、Ruby ではない別の言語になっています。

classStringdef empty?: () -> bool
end

この記述は、Ruby の組み込みクラスである Stringクラスの型情報(RBS言語で書かれている)の抜粋で、empty?という無引数のメソッドを持っていて、boolを返すということを表しています。 RBS ライブラリを使うと、こういう .rbs ファイルをパースして抽象構文木を得ることができます。 rbs コマンドは、.rbs ファイルを読んでメソッドを検索するなどができます。

RBS はそれ単体で何かをするものではなく *2、Ruby 3 の型情報を扱うツールが共通で使いたくなるものを集めた gem になっています。この gem は Ruby 3 に同梱されます。しかし基本的には型解析ツール向けの gem であり、普通の Ruby プログラマは RBS 言語を読み書きすることはあっても、RBS gem を直接使うことはあまりないと思います。

TypeProf とは

TypeProf は、型注釈のない Ruby コードを無理やり解析する静的型解析器です。Ruby 3 にバンドルされます。TypeProf は RBS 基盤を活用して作られています。

TypeProf の特徴はなんといっても、「型注釈を書かなくてもなんとなく型解析っぽいことができる」という性質に極振りして設計されているところです。 キーポイントは、メソッド呼び出しの情報を活用して解析するところです。 これにより、たとえばdef hello(user) ... endという型注釈が一切ないメソッドに対しても、hello(User.new)という呼び出しがあれば「メソッドhelloUserインスタンスを引数に取る」ということを推論します。

また、一部のクラスに RBS 言語で型情報を書いて TypeProf に与えることもできます。TypeProf はユーザが明示した型情報を無条件に信用するので、解析精度や解析速度が向上します。

TypeProf について詳しくは別の記事で解説しています。

techlife.cookpad.com

Steep/Sorbet とは

Steep は、Ruby の静的型検査器です。RBS を使って、伝統的な漸進的型付けによる型検査を行うことができます。 単に型エラーを検出できるだけではなく、LSP を実装しているので、エディタ上での型エラー表示、補完、ドキュメント表示なども実装されています。 現状で RBS を使って便利さを実感できるのは、Steep だけです。 このへんがわかりやすい記事にリンクしておきます。

qiita.com

Sorbet は、また別の Ruby の静的型検査器です。 こちらは RBS ではなく RBI という独自形式の型注釈を使います(RBS から RBI への変換器も開発されています)。 ものすごくざっくり言ってしまうと、できることは Steep とおおよそ同じです。 とはいえ、Stripe や Shopify という大企業ですでに数年ほど経験を積んでいるので、完成度はとても高いです。 解析器はC++で書かれていて、解析速度をものすごく重視しています。

Steep も Sorbet も Ruby 3 にバンドルされる予定はありません。 Ruby の設計者である matz が、「型注釈を書くことを Ruby本体として推進しない」と判断した結果です *3。この判断と相性の良い TypeProf は将来の期待とともに Ruby 3 にバンドルされますが、型注釈を書くことをいとわない人は Steep や Sorbet を使うとよいと思います。

なお、Steep 自体はバンドルこそされませんが、RBS はもともと Steep の型注釈言語でした。 Ruby 組み込みライブラリや gem の型情報を各種ツール間で共通化したかったので、RBS という形で共通基盤として切り離され、Ruby本体に同梱されることになりました。

再度まとめ

  • RBS: Ruby 3 の型情報を扱う言語を始めとする基盤。Ruby 3 にバンドルされる。
  • TypeProf: 型注釈のない Ruby コードを型解析するツール。Ruby 3 にバンドルされる。現状の主機能は Ruby コードからの RBS スタブ生成。
  • Steep/Sorbet: Ruby の静的型検査器。型注釈を書く必要はあるが、Ruby で静的型の便利なプログラミング体験ができる。IDE での補完やドキュメント表示も。

*1:厳密に言うと、rbs コマンドは触ることもあるかも。

*2:厳密に言うと、単体でも動的型検査機能が使えます。

*3:これについてはいろいろな意見があると思いますが、個人的にはそういう言語も面白いと思っていて、そのために苦しみながら TypeProf を開発しています。

基本の Android View 実装ドキュメントの紹介

$
0
0

モバイル基盤部の吉田です。 先日 Android アプリのリニューアル時に社内向けに用意した画面実装ドキュメントの内容を補足を交えてご紹介します。

用意した経緯

Cookpad の Android アプリの現在のコードベースは 2014 年に初回リリースされました。しかし当時の実装が 2020 年でもベストプラクティスであることは稀です。 Android 開発は日進月歩で様変わりしています。様々な時代のコードが入り交じるレポジトリで大規模なリファクタリングと新たなメンバーによる開発が始まるということで、新規実装の指針となる View 周りの実装ドキュメントの必要性を感じたので用意しました。

今回のドキュメントが View にフォーカスした理由は、全体設計に関しては既に VIPER の詳細なドキュメントが用意されていましたので、残りは View 周りの具体的な実装方針があればチームで大きなブレがない開発が出来ると考えたためです。

View のドキュメント以外にも、実装に必要な情報や slack 上の議論で決まった事項はdocs以下に明文化する文化があり GitHub Pages でいつでも読める状態を整えています。

View への参照方法

新しいコードでは ViewBinding を採用することにしました。 2020 年の夏の段階で私達のレポジトリでは DataBinding と ViewBinding と synthetics(KotlinAndroidExtension) の3つのツールが View への参照に使われていました。 昔から利用してきた DataBinding は 多機能なため他2つのツールが導入されても完全に置き換える意思決定が難しかったのですが、VIPER アーキテクチャの導入によって View に求められる役割が明確になったことで ViewBinding に統一することが出来ました。
また私達の意思決定とは無関係ですが、先日 synthetics は正式に非推奨なツールになったので ViewBinding への乗り換えが推奨されています。

Migrate from Kotlin synthetics to Jetpack view binding

レイアウトファイルの命名規則

レイアウト XML のファイル名は{component_type}_{screen_name}.xmlという命名規則としました。例えば RecipeActivity の場合、レイアウトファイル activity_recipe.xmlとなります。

コンポーネント 命名規則
Activity activity_xxx
Fragment fragment_xxx
CustomView view_xxx
ItemView item_view_xxx

ID の命名規則

実装からアクセスしたいビューオブジェクトには ID 属性で名前を付ける必要があります。この際ビューオブジェクトに割り振る ID 属性は camelCaseで命名することにしました。 ViewBinding から View にアクセスする際は自動で View の ID が camelCase に変換する仕様があるため、XML 側でも camelCase で記述することで対象アイテムを見つけやすくしています。

<TextViewandroid:id="@+id/recipeName" />

ConstraintLayout の活用

ConstraintLayout は以前から導入していましたが、利用箇所が限定的で十分に活用できていなかったので、新規 View を作成する際は ConstraintLayout で View の配置の指定するように定めました。 ConstraintLayout も非常に多機能ですべての機能は紹介しきれないですが、基本的となる考え方と私達が頻繁に利用する便利な機能を紹介します。

MATCH_CONSTRAINT について

ConstraintLayout は width や height に 0dp を指定してレイアウトすることがあります。これはMATCH_CONSTRAINTという状態で制約に従って最大の大きさにレイアウトすることを示しています。 意外に知られていませんが、ConstraintLayout で MATCH_PARENTを利用するのは非推奨であり下記で紹介する便利な制約のいくつかが正しく動作しない可能性があるので初めて使う際は覚えておきましょう。

Important: MATCH_PARENT is not recommended for widgets contained in a ConstraintLayout. Similar behavior can be defined by using MATCH_CONSTRAINT with the corresponding left/right or top/bottom constraints being set to "parent".

また maxWidthminWidthの代わりにlayout_constraintWidth_maxlayout_constraintWidth_minを利用する必要があるのもハマリポイントの一つです。

ConstraintLayout: Widgets dimension constraints(developer.android.com)

基本的な制約

ConstraintLayout が View の位置を決定するための制約方法は様々ですが、他の View との相対的な位置関係を使った制約を覚えると大体のレイアウトを組むことが出来ます。 相対的な位置関係を決める対象には id が振られている他の View と自分の親 View(parent)が指定可能です。制約は矛盾しない限りいくつでも追加できるので、例えば下記の例では2つ制約を組み合わせると水平方向の中央寄せを表現しています。

<RecipeViewandroid:id="@+id/recipeView"android:layout_width="300dp"android:layout_height="wrap_content"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintStart_toStartOf="parent"        />
属性 説明
layout_constraintTop_toTopOf 自分の上辺を指定した View の上辺の位置に合わせる
layout_constraintTop_toBottomOf 自分の上辺を指定した View の下辺の位置に合わせる
layout_constraintBottom_toTopOf 自分の下辺を指定した View の上辺の位置に合わせる
layout_constraintBottom_toBottomOf 自分の下辺を指定した View の下辺の位置に合わせる
layout_constraintStart_toEndOf 自分の左辺を指定した View の右辺の位置に合わせる
layout_constraintStart_toStartOf 自分の左辺を指定した View の左辺の位置に合わせる
layout_constraintEnd_toStartOf 自分の右辺を指定した View の左辺の位置に合わせる
layout_constraintEnd_toEndOf 自分の右辺を指定した View の右辺の位置に合わせる

下記の例では 「buttonB の左端が buttonA の右端になる」制約をつけることでボタン A,B が横並びに表示されています。(RTL 環境では左右が入れ替わります)

<Button android:id="@+id/buttonA" ... /><Button android:id="@+id/buttonB" ...app:layout_constraintStart_toEndOf="@+id/buttonA" />

f:id:kazy1991:20201207140915p:plain
ConstraintLayout (developer.android.com)

覚えておくと便利な機能

縦横比の指定

ConstraintLayout 以下の View では layout_constraintDimensionRatioが利用可能で View の縦横比を自由に制御できます。例えば"1:1"と指定すれば正方形の View を組むことが出来ます。 蛇足ですが意外にも正方形の View を組むのは大変で、昔はXxxSquareViewのようなカスタムクラスを用意する必要がありました。

<Button android:layout_width="wrap_content"android:layout_height="0dp"app:layout_constraintDimensionRatio="1:1" />

もう少し発展的な利用方法を紹介すると、constraintDimensionRatioは縦横どちらを基準に比率を決めるか指定することが出来ます。h,1:1とすると高さを基準にして 1:1、w,1:1とすると横幅を基準に 1:1 の大きさにレイアウトします。 また、縦横どちらもMATCH_CONSTRAINTの場合にconstraintDimensionRatioを利用すると条件を満たす最も大きなレイアウト方法で描画されるため、明示的に基準となる向きを指定するのがおすすめです。

<Button android:layout_width="0dp"android:layout_height="0dp"app:layout_constraintDimensionRatio="H,16:9"app:layout_constraintBottom_toBottomOf="parent"app:layout_constraintTop_toTopOf="parent"/>

バイアスの指定

基本的な制約の説明の際に中央寄せの例を出しました。これは ConstraintLayout 下では通常均等に制約の影響を受ける仕様を生かして簡単に中央寄せも表現できています。 位置を中央から調整したいケースも対応が簡単でconstraint(Horizontal|Vertical)_biasというプロパティが用意されているので、"0"を指定すると左の側の空間がなくなり左寄りにレイアウトされ、"1"を指定すると右寄りのレイアウトが可能です。

<!--- 左右の余白を3:7に調整したい場合 --><RecipeViewandroid:id="@+id/recipeView"android:layout_width="300dp"android:layout_height="wrap_content"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintStart_toStartOf="parent"app:layout_constraintHorizontal_bias="0.3"        />

文字列のベースラインを揃える制約

基本的な制約では Top や Bottom を利用した位置調整を紹介しましたが、文字列の高さ(ベースライン)を基準に制約をつけることも可能で、layout_constraintBaseline_toBaselineOfというプロパティが用意されています。

View のグループ化

Layerは要素のグループ化を行う疑似要素です。これまでは XML のネストによって View のグルーピングを表現していましたが、それらの代わりとして Layer を使う事ができます。 Layer は View を継承しているのでタップイベントなどのコールバックを受け取ることが出来ます。View なので background の指定も可能なのですが、私達が開発で利用していた2.0.0-beta6の時点では表示領域がおかしくなるケースがあったため、背景の指定は避けるようにしています。

同じような機能を持つものに Groupというものがあります。Group は View オブジェクトの Visibility をまとめて制御するための仕組みです。 ConstraintLayout の 1.1 から使える仕組みのため古い記事では Group を利用しているものが見つかるかもしれないですが、使い分ける必要はなくグループ化には Layer の利用を推奨しています。

<androidx.constraintLayout.helper.widget.Layerandroid:id="@+id/recipe_layer"android:layout_width="0dp"android:layout_height="0dp"android:background="?android:attr/selectableItemBackgroundBorderless"app:constraint_referenced_ids="recipe_count_label,recipe_count"app:layout_constraintStart_toStartOf="parent"app:layout_constraintTop_toTopOf="parent"        /><TextViewandroid:id="@+id/recipe_count_label"android:layout_width="0dp"android:layout_height="wrap_content"android:text="@string/my_kitchen_label_recipe"        /><TextViewandroid:id="@+id/recipe_count"android:layout_width="0dp"android:layout_height="wrap_content"android:text="@string/no_count"        />

その他の便利な機能について

ここまで紹介した機能だけで ConstraintLayout を活用して基本的なレイアウトは組めるはずです。少し慣れてきたら BarrierGuidelineFlowを使ってみたり、 ChainStyleの違いなどの理解を深めてより複雑なレイアウトに挑戦してみたり、崩れにくいレイアウトの組み方について考えてみると良いでしょう。

Material Components (for Android)

もしあなたのチームが Material Components (for Android) を導入していなかったら真っ先に導入することをおすすめします。 MaterialComponent は Theme を利用することで Button などの View コンポーネントを置き換えすることも出来ます。Theme の置き換えが簡単にいかない場合もフルパスを指定することで部分的に MaterialComponent の View を利用することが出来ます。

Material Components の Theme の導入に関しては 大規模プロジェクトにおけるモバイル基盤の取り組みで詳しく書かれているのであわせて御覧ください。この記事では実装時に特に重宝した ShapeableImageView と MaterialCardView について紹介します。

ShapeableImageView

角丸や円形のユーザーアイコンを表示する際は画像読み込みライブラリ側で調整していましたが、ShapeableImageView を使うと XML 上でデザインを表現できます。下記の例は自分で Overlay を定義して円形の画像を表示するコードですが、MaterialComponent が提供している Shape が多数あるのでShape Themingを参照して下さい。

<!-- styles.xml --><style name="ShapeAppearance.Circle"parent=""><item name="cornerFamily">rounded</item><item name="cornerSize">50%</item></style>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"android:layout_width="match_parent"android:layout_height="match_parent"tools:context=".MainActivity"><com.google.android.material.imageview.ShapeableImageViewandroid:id="@+id/image_view"android:layout_width="wrap_content"android:layout_height="wrap_content"app:layout_constraintStart_toStartOf="parent"app:layout_constraintTop_toTopOf="parent"app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"app:srcCompat="@tools:sample/avatars" /></androidx.constraintlayout.widget.ConstraintLayout>

f:id:kazy1991:20201210074324p:plain
黒背景は実際には描画されません

MaterialCardView

MaterialCardView はこれまで面倒だった内部要素を含めた角丸化したデザインが用意に組めるようになります。また strokeColorstrokeWidthを利用することで外枠を表現することも出来ます。不要であればtransparentを指定して隠すことも可能です。

<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"android:layout_width="wrap_content"android:layout_height="wrap_content"app:cardCornerRadius="@dimen/image_rounded_corner"app:strokeColor="@android:color/transparent"app:strokeWidth="0dp">
....
</com.google.android.material.card.MaterialCardView>

社内アイコンフォントの利用廃止

クックパッドには社内 FontAwsome のような便利なアイコンセットがあり Web、モバイルアプリなどの様々なプラットフォームで利用されています。 以前はフォントファイルでの配布だったのですが、先日に複合的な理由でフォント形式でのアイコンセットの運用が終わり、代わりに SVG が提供される事になったため Android では VectorDrawable でサポートすることにしました。

アイコンフォントを TextView で表示していた頃と比べて VectorDrawable に移行したことでいくつか改善した事柄あります。 これまで Drawable しか利用できない箇所(OptionMenu のアイコンなど)はアイコンを画像に書き出して対応していましたがこのような対応が不要になりました。 デザイナーとエンジニアが協力して画面を組み立てる状況において、画面のどこでアイコンセットが使えて、どこが画像切り出しが必要か考えなくてよいのは小さい改善ですが開発効率に繋がります。

また Material Components が提供する app:iconは非常に便利なので XML で Drawable して参照できる VectorDrawable は非常に快適です。その他にはこれまで一部端末でアイコンフォントを利用すると正しく表示されないケースが報告されていたのですがそのようなケースに無くなると考えています。

SVG から VectorDrawable に変換する手法は公式には Android Studio の Vector Asset Studio という GUI ツールしかありませんが、AOSP(Android オープンソース プロジェクト)のレポジトリをチェックアウトすることで、vd-toolという CLI ツールが利用可能です。 クックパッドではvd-toolを使って生成した VectorDrawable を AAR のライブラリ形式にパッケージして社内 Maven レポジトリから入手可能にしています。 vd-toolの詳細については過去に個人ブログにまとめたのでそちらをご確認ください。

RecyclerView

クックパッドのレシピサービスのアプリでは EpoxyGroupieを使用せずに直接 RecyclerView を利用しています。RecyclerView の実装に関しては模索している部分が多いですが、既に慣習になっている部分のみ明文化しました。 上述の通りクックパッドでは VIPER アーキテクチャに沿って実装しているのですが、View から Presenter を呼ぶ際のコールバック扱いと ConcatAdapter を利用して積極的に Adapter を分解する実装手法を推奨しています。

コールバックの扱い

RecyclerView 内で発生したタップイベントを Presenter まで伝えるための callback は RecyclerView.Adapter を継承したクラスの先頭にエイリアスを利用して定義します。

//ReycerView.Adaptertypealias RecipePageRequest = () ->Unitclass RecipeListAdapter(
    privateval recipePageRequest: RecipePageRequest
) : RecyclerView.Adapter<RecipeListViewHolder>() {
    overridefun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeListViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val binding = ItemViewRecipeListBinding.inflate(layoutInflater, parent, false)
        return RecipeListViewHolder(
            binding = binding,
            recipePageRequest = recipePageRequest
        )
  }
}

//Fragmentclass RecipeListFragment : CookpadBaseFragment(), RecipeListContract.View {
    overridefun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        recipeListAdapter = RecipeListAdapter(
          recipePageRequest = presenter::onRecipePageRequested
        )
    }
}

ConcatAdapter

これまでヘッダーやフッターを持つ RecyclerView は非常に実装が厄介でしたが、ConcatAdapterの登場によって直列に複数の Adapter を繋ぐことが可能になリました。Adapter を ViewType 毎に分割すると単一の Adapter と比べて、引数がシンプルになり ViewType による内部実装の分岐処理が必要がなくなります。

val concatAdapter = ConcatAdapter(headerAdapter, pagedListAdapter, footerAdapter)
binding.recyclerView.adapter = concatAdapter

その他の取り組みについて

ドキュメントの整備の他の取り組みとして、 PullRequest の CI で「未使用リソースのチェック」と 「ktlint によるフォーマットの確認」を自動化させています。

おわり

2020 年に Android 開発 で View について知っておきたいことはある程度網羅出来たと思います。何かしら参考になっていたら幸いです。 もしかすると 2021 年末には Jetpack Compose が デファクトスタンダードになりこの記事は無意味な情報になるかもしれません。そのような未来も非常に楽しみですね。

補足すると ConstraintLayout は Jetpack でも利用出来ますし MaterialComponent の Jetpack Compose サポートのニュースも最近あったので知識が全て無駄になることはありません。 今後もAndroidの開発環境は少しずつ良くなっていくと思うので、また社内ドキュメントを大きく見直す機会がありましたらまた記事にして紹介したいと思います。

キッチンでの微細な重量変化を捉えるには?

$
0
0

こんにちは.研究開発部の鈴本 (@_meltingrabbit) です.

クックパッドの研究開発部では,ユーザーの課題を解決する手段をスマホの中からスマホの外(実世界)に拡張しようとチャレンジしています. 特に,料理を「作る」時の課題を解決するため,様々なデバイスを開発し,調理に関する知識と組み合わせることで,新たな調理支援の方策を切り開こうとしています.

その中の一例として,キッチンのワークトップやコンロでの微細な重量変化が取得できるデバイスをフルスクラッチで構築しました. 本稿ではその取り組みについてご紹介します.

調理中/調理後に知りたい情報?

調理において,重要な情報とは何でしょうか?

  • 分量(重量,体積)
  • 火加減(熱量,温度)
  • 加熱時間(時間)
  • 味付けの濃さ(塩分などの濃度,調味料などの重さ)
  • 焼き色(色,温度)

... などなど,様々なことに目を配りながら,日々調理していると思います.

今回は重量に着目してみます. 調理中の重量変化が取得できると,どういった調理支援が考えられるでしょうか?

いくつか考えてみます.

まな板上にあるカット中の肉や野菜の重量がわかれば,何人前の料理を作っているかがわかります. それによって,その分量に合わせて微修正されたレシピを提示する,といったサポートが考えられます.

また,鍋やフライパンなどに入れた調味料の分量が自動で計量されていたら,今回の味付けが記録できるかもしれません. 食べた後に,「今回はちょっと味が濃かったなぁ」など感じたときに,その時の調理を振り返られるかもしれません.

さらに,例えば唐揚げは,揚げているときに鶏肉の重量が徐々に減少していくことが知られています. この重量変化をつぶさに捉えることができれば,「今がベストな揚げ終わりのタイミングだ!」などと教えてあげることができるかもしれません.

以上のようなモチベーションから,キッチンワークトップの重量を高精度に取得してみよう,と思い立ちました.

調理支援のために必要な重量センサとは

調理支援のための重量センサについて考えます.

上で記したようなことを実現しようとするのであれば,少なくとも小さじ1杯,つまり5 g程度の重量測定精度が不可欠です. そして,ワークトップにのる可能性がある物(コンロや鍋や食材,加えて人間の押し込み荷重など)を考えていくと,最大荷重は最低でも50 kg程度が求められます.

とすると,,,最大荷重は50 kgと仮定して,ダイナミックレンジが1/10,000!?の重量センサを欲している,というわけです. そんな精度でかつキッチンに設置できるものなど,そう簡単には手に入りません.

残された道は,フルスクラッチでの自作. 自作するために,重量計測に対する要求仕様を以下のように策定しました.

  • 測定精度は5 g以下で,最小測定分解能はその1/10以上
  • ダイナミックレンジは可能な限り広く.最大許容荷重は50 kgを超えることを目標
  • サンプリング周波数は高ければ高いほどよい.1,000 Hz程度は超えたい
  • 精度要求が厳しければ,リニアリティとオフセットドラフト,温度特性は妥協する.一方でヒステリシス特性やリピータビリティは重視する

また,既存のシステムキッチンを改造して重量センサを埋め込むのはハードルが高いため,すでにあるキッチンワークトップの上に設置し,その上で調理することにしました. そうすると,次のような構造的な要求が発生します.

  • 既存のキッチンワークトップに設置し,その上で調理するため,可能な限り背の低い形状にする
  • IHコンロとまな板とその他をその上に置くことを想定するので,天板の大きさは1,000 x 600 mm程度にする

得られたデータはリアルタイムに解析し,調理支援という形で調理する人にフィードバックできる必要があります. 高精度・高頻度に計測されたデータを後から抽出でき,解析できる,といったシステムでは,想定している調理支援には使えません. そこで,さらに次のようなシステム要求が追加されました.

  • 取得データはリアルタイムで社内システムで利用できる(例えばAWS IoT Coreに送信できる,など)

これらの要求を満たす重量センサをフルスクラッチしていきます.

実装

最初に実装結果を記します.

下図のようなものが完成しました.

先述したとおり,システムキッチン自体を改造するのは難しいため,既存のキッチンのワークトップに乗せて使用します. IHコンロ,まな板,はかりが乗っているアルミ天板の上の重量が高精度かつ高頻度に測定できます.

f:id:meltingrabbit:20201209030436j:plain
開発した重量センサ

達成されたスペックは,次のとおりです.

  • 測定精度は,設計値は4 gで,実際に使ってみると2 g程度はありそう
  • 許容最大荷重の実効値が54 kgで,絶対最大定格の実効値が84 kg
  • 最高計測周波数が100 kHz(実質的に使えるのはせいぜい数kHz程度か?)

構造の概要は下図に示すとおりで,上から,超低頭ネジ,アルミ天板,カーボンロッド,アルミ治具,荷重センサ(ロードセル),ネジ,アルミ治具,ネジ,アルミフレーム,です. 天板の四隅を荷重センサで支持,計量する構造となっています. (したがって,今後のソフトウェアの改良で重量分布も取得可能になる予定)

f:id:meltingrabbit:20201209030534p:plain
構造図

この重量センサを開発するのには,いくつかのハードルがありました.

まずは,高精度な荷重センサの選定です.いろいろと検討した結果,工場などで用いられる産業用のロードセルを用いていますが,もともとのセンサの想定使用方法通りには使われていません.

また,ダイナミックレンジを大きくするためには,天板をできるだけ軽く作る必要がありました. なぜなら,ロードセルの許容最大荷重が例えば60 kgだとしても,天板の重さが20 kgもあると,実質的に計量可能な最大荷重は40 kgになってしまうからです. 一方で,軽く作ることに集中し,天板の剛性が十分でないと,荷重によって天板がたわみ,余計な力がロードセルに加わってしまい,正確な重量計測ができなくなります.

このサイズですと,板厚3 mm程度のアルミ板ですらおよそ5 kgの重量にもなりますが,両端単純支持では中央に20 kg程度の負荷をかけただけで50 mm程度はたわんでしまうのです. 軽い金属のアルミですら,こんなもんです. この軽量と高剛性という相反する要求を満たすために,最適な天板の厚みを計算しました. そして,アルミ板のみでは剛性が確保できないので,カーボンロッドで補強するなどしています.

カーボンロッドとアルミ天板との接着には,アクリル樹脂系の2液式接着剤を使いましたが,これも大変でした. 2液式接着剤とは,接着前に2種類の溶剤を自分で混ぜて,それが固化する前に接着面に塗りつけ接着するというものです. 今回用いた接着剤は,混合から固化までの時間が90秒だった(カーボンとアルミが接着可能で入手性の良いものがこれしかなかった)ので,小さなパーツの接着ならまだしも,1 m弱の大きなロッドに対して,

  • そこそこ大量の溶剤を均一に混ぜ合わせる
  • それをカーボンロッドとアルミ板に均一に塗る(均一でないとそこで応力集中してしまう)
  • 位置を合わせて仮固定する

を90秒以内に終わらせるというタイムアタックをしなくてはいけないのがきつかったです.

さらには,ロードセルの計測値をリアルタイムに収集し,処理するのためのソフトウェアも重要です.

今回用いたロードセルは,工場などに組み込むことを前提として,RS232Cで計測信号を出力するインターフェイスはメーカーから提供を受けることが可能でした. しかし,RS232Cなので高頻度計測にはボーレートの問題もあるし,4つのセルからの信号を時刻同期しつつ高速に収集するミドルウェアを書くのはめんどくさいなぁ,と思ってしまいました.

最終的には,ロードセルの実体はひずみゲージのブリッジ回路なので,それを駆動して出力をAD変換できるロガーを別途購入し,そこからリアルタイムにデータを取得し,適切な信号処理を施し, 社内の調理支援システムへリアルタイムへデータを送信する,というソフトウェアを自作しました.

いざ,計測

要求仕様の出どころであった小さじ1杯を計測してみました.ワークトップ上に小皿を置き,そこに小さじ1杯を投入していったときの計測結果が下の2図です.

信号処理のフィルタのチューニングを変えていて,1枚目は高頻度な成分を抽出できるような設定になっており, 2枚目はかなり強めのLPFを挟むことで質量の増分を分解しやすい設定になっています.

そのため,1枚目では,水を小皿に垂らしたときの振動がよく計測されている一方で, 2枚目ではおよそ1,5,15秒のところでおおよそ大さじ1杯の水が投入されたということが明瞭に分かる結果となっています.

同じ操作を異なる信号処理で観察すると,違った特徴が見えるのは面白いですね.

次の図は,唐揚げを揚げたときの結果です. 3つの唐揚げを鍋に投入し,それをおよそ2分半揚げ,ひとつずつ取り出した履歴です.

0〜0.5分では,ひとつひとつの唐揚げの重さが分解できています. また,0.4〜2.7分頃では,揚げている最中に唐揚げ内部の水分が蒸発して軽くなっている様が観察できます.

まとめと今後

このように,これからの研究開発のための1センシングデバイスとしての重量センサが,無事に目指していたスペックを満たして実装することができました. 実は,クックパッドではソフトウェア開発のみならず,こういったハードウェアの開発も行っているのです. 今後はこれらを使い,調理中の様々なイベントを収録し,そして社内の様々な知見や技術(ハードもソフトも)を総動員して,「毎日の料理が楽しくなる」ような調理支援の開発を目指していきます!

分析用ログデータに対する品質保証としての異常検知

$
0
0

クックパッドでデータにまつわるあれやこれやをずっとやってる佐藤です。分析・調査に仮説検証にデータパイプラインにと色々やってました。ちなみに先日はCyberpunk2077休暇をとるなどという呑気なことをしていたら、この記事でやりたかったことがほぼできそうなサービスがAWSから発表されて頭を抱えながら書いています。

そのログはどこまで信頼できるのか

クックパッドではサービス改善のためにWebサイトやアプリからログを収集して開発を行っています。これらのログは集計された後、ダッシュボードの形で可視化されてサービス開発者たちの意思決定を支えています。
クックパッドのログ基盤はログ送信側(クライアントサイド)もログ格納側(DWHサイド)も十分に整っており、いつでも必要であれば簡単にログを送信・集計するだけの仕組みができあがっています。

f:id:ragi256:20201216120246p:plain
アプリログにおける大雑把なログ収集の図
(注:例として上図を載せましたが当記事の内容はアプリに限りません)

しかし一方で、送り続けているログの管理・保守にはここ数年課題を感じています。例えば、iOSアプリのダッシュボードを見ていて、去年の6月に突然トップページのDAUだけが激増していることに気付いたとします。しかし、この原因を見付けることは非常に難しいのです。
クックパッドではWebもアプリも多くの人々が開発者として関わっています。このため、誰かがいつかどこかに加えた変化によってアプリのログへ気づかないうちに影響を及ぼしていたということが起こりえます。自分たちの担当する領域で普段は見ない数値を確認してみたら、実は半年前に大きく動いていた。だが特に何かをした記憶がない。こういったケースではどのように原因を特定すればよいでしょうか? KPIに直結しない数値・特定の条件に限定して算出した数値・実数ではなく比率に変換した数値などで、後になってから気づくことが多くありました。

より快適なサービス開発を行うためには、安心してサービスに関わる数値を確認できる状態でなければなりません。そのためにはこういったログに関する課題は解決する必要があります。そこでまず、ログの品質を保証するためにどんなことができるか考えた結果、ログデータの異常検知に取り組むこととなりました。

どうやって開発をすすめるのか

今回、異常検知をやるにあたっていくつか当初から決めていたことがありました。

  1. 作り込みすぎない、とりあえず使える状態を目指す
  2. 全体をパーツとして作ってできる限り交換可能にする
  3. 異常検知そのものだけではなく全体フローの最適化を重視する

これらは異常検知という仕組みが、あくまでもログの品質維持の取り組みのひとつに過ぎないことが理由となっています。もし試してみて全然だめそうだったり、より有望そうな他の手段が思いつけばいつでもピボットしたいと考えていました。
一方で既存の研究分野で培われた時系列モデルやアルゴリズムは、いつかどこかで試してみるタイミングがやってくるとも考えていました。そうなった時、いつでも任意のポイントに対する差し替えが可能となるよう、機能分割のタイミングを逃さないよう開発を進めることとしました。
こういった事情があり、最初から「scikit-leanで回帰モデルを試す」「Prophetを利用する」といった手法ありきの取り組みや「異常を検知したらそれで終わり」といった姿勢を取らないように注意していました。全体的な検知フローを重視していかにしてログの品質保証に繋がるかを考えての方針です。

この方針のもと、異常検知の仕組みは次の3つのステップの順で開発を行っています。

Step.1 MVPを作って自分で試す

まず本当に異常検知すると嬉しいのかどうかを半信半疑になって確認する必要があります。ログの異常検知をすると決まった時点で、DWHに蓄積された各種ログの集計内容を監視して上振れ・下振れなどの変化を監視することは決定していました。ただし、この時点では変化点検知(change point detect)か外れ値検知(outlier detect)かはまだ決まっていません。

最も手軽に異常検知をしようと思った時、DWHからデータを引っ張ってきて、既存の異常検知ライブラリを使って判定するのは時間がかかりすぎるように感じました。
そこでまずMVPとして、SQLのみで異常検知することにしました。最も基本的な時系列モデルはちょっとしたSQLで書くことができるため、ここをベースラインとしてまず仕組み全体を作り上げてしまうことを考えます。
ベースラインとして採用したのは過去n日間の平均・標準偏差を利用した予測です。

f:id:ragi256:20201216120545p:plain
仮に過去平均7日間、σ係数を3と置いたときの図

これは集計済みテーブルさえ用意されていればwindow関数で手軽に書くことができます。もし予測範囲に収まらなかった場合、(少々行儀が悪いですが)ゼロ割を使って無理やりSQLをエラーにします。

select
    data_date
    , uu
    -- uu range: μ ± 3 * σ
    , casewhen uu between week_avg - 3 * week_stdev and week_avg + 3 * week_stdev
      then1-- passelse uu/0-- assert(ゼロ割)end alert
  from (
      select
        data_date -- 対象テーブルにある日付カラム
        , uu      -- 異常検知を行いたい対象となる数値のカラム(ここでは仮にuu)-- ↓平均と標準偏差の計算に当日は含まないため微妙にずれる
        , avg(uu) over (partition by uu orderby data_date rowsbetween8 preceding and1 preceding) asavg
        , stddev_pop(uu) over (partition by uu orderby data_date rowsbetween8 preceding and1 preceding) asstddevfrom
        $alert_target_table -- 異常検知をしかける対象のテーブルwhere
        data_date >= current_date - interval '8 days'
  )
  where
    data_date = current_date

このSQLをバッチジョブとして毎朝実行させ1、ジョブがゼロ割エラーでコケたらSlackに通知を流すようにしました。

f:id:ragi256:20201216121116p:plain
バッチがコケるととりあえずこのエラー通知がSlackに流れる

MVPだけあって当初は大量に誤報が鳴り、ほぼ毎回アラートがあがるのでこのままでは使い物にならないことがわかりました。ですが、このときアラートの精度に関しては一切考えず一旦ワークフローを固めることを考えました。仮にこの誤報が減り、今鳴っているアラートが正しい異常検知の結果であったとした場合、自分は次に何をしたくなるだろうかと考えます。
実際、自分がアラートを見た時には「これは誤報か?確報か?」と毎回調べていましたので次に何をするかは「アラートが何故なったのかを調べる」ということがわかっていました。異常検知アラートの作成者以外も「なぜ異常検知のアラートが鳴ったのか?」を容易に知ることができる状態にしておく必要があります。そこで異常検知している様子がわかりやすくなるように下記のようなグラフを作成し、自動更新がされるように準備しました。

f:id:ragi256:20201216121245p:plain
異常検知の様子をわかりやすく可視化するためのグラフ、オレンジと青が上限・下限で緑がn日間平均

同時にこのグラフを作ったことで何故こんなにも誤報が大量発生したのかも発覚しました。過去に収集していたが今はもう使わなくなったログ・送信条件が厳しく流量の少ないログなどが多く含まれていたため、異常検知に用いるには欠損点が多く不安定な時系列データとなっていたためでした。そこで品質を保証する意義のあるログは「多くのユーザーに」「ある一定期間は使われている」ログと見なして流量と取得日数をフィルターすることにしました。
Step1の始めに「外れ値検知か変化点検値か決めていない」と書きましたが、この時点で外れ値検知ではなく変化点検知を行うことに決定しました。この異常検知システムによって検知したいのは後から対処しようのないサービスの瞬間的な異常ではなく、ログに関する実装の修正を必要とするような開発時点でのエラー・修正ミス・抜け漏れなどを捉えたかったためです。

\ グラフ
f:id:ragi256:20201216121500p:plain
f:id:ragi256:20201216121519p:plain

こういった工夫により、平均と標準偏差という最も素朴な基準でも誤報を減らすことができるようになりました。ハイパーパラメータはモデルではなくモデルに投入するデータのほうにもあったようです。ここでフィルターに用いる各種パラメータをSQLから分離させて対象とするテーブルごとに変更できるようにしておきます。

Step.2 他の人にも使ってもらえるように触りやすい仕組みを整える

Step1の状態では異常検知の仕組みを作った自分しか扱い方がわからず、アラートがきても何をどうしたら良いかわからない状態でした。この仕組みをサービスの開発にも活かすためには、多くの人に使ってもらえるようアラートが鳴ったらどうするかわかりやすいインターフェイスにする必要があります。
そこで、より異常検知した状況をつかみやすく、その後のリアクションをとりやすくするために通知内容を改善することにしました。

通知内容を改善するにあたって、これまでのただエラーを流していただけの状態を改修する必要がありました。そこでまず、バッチジョブの中身を修正し2つの処理に分割することにします。この分割で片方の処理の持つ責務を「異常を検知すること」、もう片方の処理の持つ責務を「検知した内容をどうにかして伝えること」にわけます。こうすることで仮にSlack以外のツールに通知を流す場合や、通知先はSlackのまま異常検知方法を切り替えるといった作業をしやすくなります。
そして、Slackへの通知を行う処理としてSlack WorkflowのWebhookを利用することにしました。このSlack WorkflowはSlack上でステップやタスクを実施してもらうことで定形的なプロセスを自動処理しやすくする仕組みです。また、外部アプリやサービスとの連携も豊富なため、Workflow内のステップで起こしたアクションを外部に渡すことができます。 通常のWebhookでは単純に情報をSlackへ流すだけとなってしまい、検知に対するアクションをとってもらいにくいと考えてWorkflowを採用しました。

f:id:ragi256:20201216121713p:plain
Workflow builderで作成、フォームを2つ加えて後述のGoogleSpreadsheetと連携させている

上図のようにフローを組むことでSlackへ情報を流すとともにリアクションをとってもらえるようになります。今回このWorkflowで設定した異常検知に対するリアクションとは「対応の方針を決める」「なぜその方針に決定したのか理由を書く」の2つです。異常検知が正しく働いたとして、それでも何も対応する必要のないケースもあります。なので「このアラートは無視する、古いバージョンからのログなので放って置いても困らないため」「このアラートはきちんと調査をする、重要指標が減少していてもしも本当に落ち込んでいたら緊急事態のため」と書き込んでもらうことにしました。

f:id:ragi256:20201216121946p:plain
アラートが鳴ったときに流れるメッセージ
f:id:ragi256:20201216122007p:plain
上記メッセージのボタンに反応した後に続くメッセージ

この通知内容によって開発者にログの異常に関するリアクションをとってもらって、「このログ異常は何故起きたのだろう?」「このアラートは無視していいものだろうか」と考えてもらおうというのが狙いです。
このSlack Workflowへの通知切り替えを再度自分でも使ってみて、アラート量的にも対応負荷的にも問題なさそうと感じたあたりでStep3に移りました。

Step.3 他の人にも使ってもらう

いよいよ自分だけではなく誰か別の人にも使ってもらう段階です。手始めに社内用ブログに上記取り組みを投稿して軽く共有し、クックパッドアプリのiOSやAndroidエンジニアが集まるチャンネルで使ってもらうこととしました。
その週にはアラートが鳴り、何度かアラートの対応をしてもらうことができました。しかしすべてがスムーズに進んだというわけもなく、いくつかの改善点がSlackでの会話から浮かび上がりました。

f:id:ragi256:20201216122135p:plain
早速フィードバックがもらえている様子

Slack Workflowという多くの人の目に見える形でアラートをセットしたことで、このようなやりとりをSlack上でこなすことができるようになりました。
また、リアクションをしてもらった結果は自動でGoogleSpreadsheetに溜め込まれていきます。こちらのシートに溜まった知見を元に今後のアラートの改善にもつなげていこうと考えています。  

f:id:ragi256:20201216122221p:plain
2つのフォームから書き込まれた内容が貯まるシート

これから

冒頭にも書きましたとおり今年の re:inventでAWSからAmazon Lookout for Metricsが発表されました。こちらはまだプレビュー版ですが、今回作った異常検知フローをそのまま置き換えることができるかもしれません。幸いにして今回のフローはアルゴリズムやチューニングに注力することなく、最小の労力をもって「ログの品質を保つためにはどんな仕組みが必要となるか?」の模索した解決案の一つに留まっていました。このため最終的な唯一の課題解決手法ではなく、むしろ課題を理解するためのプロトタイプに近く、実際に運用してみることで品質維持のために求められる多くの要素を知ることができました。

  1. 古いバージョンのログをどうするか
  2. 流量の多いログと少ないログの両方同時に監視すると発生する変化量の差をどうするか
  3. 既存の時系列解析や異常検知の研究手法で使われているアルゴリズムやモデルをいつ・どうやって・どう判断しながら組み込んでいくか
  4. そもそも「異常検知」では応急措置的な対応しかできないが、品質維持のために根本的対策や事前防止策をとることはできないか
  5. (他多数)

これらの要素を元に今回作成したシステムとAmazon Lookout for Metricsを比較することでより良い解決策と改善フローを実行できると考えています。

ログの異常というのは本来は起きてほしくない状況ではあります。知らず知らずの内にそのログ異常が起こっていて後から困るという自体を防ぐために、変化点検知作業を自動化する仕組みを整えることができました。まだまだ実用上では粗い点もありますが、漸進的開発をしやすい開発方針をとってきたのでこれからも徐々に改善していくことで「クックパッドではこうやってログの品質を保証しています」と言い切れるデータ基盤を目指していきます。


  1. SQLとバッチジョブの実行に関しては弊社OSSのKuroko2bricolageを利用しています。

モバイルアプリの開発上の違和感・痛みに向き合い、少しずつでも前進するための取り組み

$
0
0

こんにちは、モバイル基盤部の茂呂(@slightair)です。 いやー12月になって寒くなってきましたね。

この記事では最近部で始めた「アーキテクチャ課題共有会」という取り組みについて紹介したいと思います。

開発中に感じる痛み

いきなりですが、モバイルアプリの開発中に痛みを感じたり違和感を持ったことはありませんか? 痛みというと大げさかもしれませんが、例えば以下のような設計・実装上のつらみ、悩みのタネたちのことを指します。

  • 同じような記述を毎回書く必要がある
  • 採用している設計パターンにあてはめようとすると、実装しづらい場合がある
  • 必要となる場面が多い割にできないことがある
  • 適切に扱うのが難しく、使い方を間違えやすいものがある
  • 複雑で理解するのが難しい、手を入れられない

特に複数人で開発しているアプリプロジェクトだと、自分でそういう痛みを感じることもあれば、他の人からそういう声が聞こえてくるという経験があるんじゃないかと思います。

僕たちが開発しているクックパッドアプリはサービスがリリースされてからずっと開発を続けているので、その間に開発規模や環境が大きく変わっています。 例えば、以下のものが挙げられるでしょう。

  • 開発人数・規模の増加
  • 人の入れ替わり
  • サービスのリニューアル
  • アーキテクチャの変更
  • モジュール分割などによるアプリ構造の変化

また、社内だけでなく、OSの更新、プラットフォームの進化、開発ツールの進化など、外部の要因もありますね。

そのような環境の変化があるたびに、それに合わせたやり方や仕組みの見直しを行うわけですが、導入した方針や仕組みがいつも最適な選択であるとはかぎりません。 最初は良さそうに思えたものであっても、使っているうちに欠点に気がついたり、状況が変わって適さなくなったものが出てきます。そうしたものが違和感や痛みとして表面化してきます。

痛みに気づいたら整理して解消すればよい

開発環境には変化が起こり続けているので、このような痛みや違和感が出てくるのは当然のことです。気づいたら課題を整理して解消していけばよいでしょう。

このような痛みに気づいて解消していくためには、どんな些細なことでもよいので、なにか思うところがあったら声を上げやすい雰囲気づくりが大切だと思います。幸いにもクックパッドのアプリ開発の現場ではそのような空気ができているように感じています。

Slack など普段会話をしているところで「こういうところつらくない?」というコメントが流れると「それな」「わかる」「オアーッ!」というようなリアクション*1も一緒に集まってきます。 丁寧な人であれば具体的に困っている設計上の課題や仕組みについて issue に書いてくれる場合もあります。

f:id:Slightair:20201215142521p:plain
Slack での会話

気づいて声を上げるところまではよいのだが…

痛みに気づいた人の声をきっかけに議論がはじまり、具体的な解決案がすぐにまとまればよいですが、必ずしもそのようにスムーズに話が進むわけではありません。 ほとんどの場合、大筋の合意を取って修正の方針を決めるまでが難しく、時間がかかります。

そのため、新しい課題が出てきても普段の仕事をたくさん抱えている中では、致命的なものでない限りなかなか向き合えないことがあります。 今までのやり方でも進められる場合は、違和感を持ちながらもそのまま進めることができてしまうからです。 すると、良くない部分に気づいているのにその問題が埋もれていってしまい、改善が進まないという状況になってしまいます。

最近でいうと、モジュール分割*2に関連する実装上の悩みや、アーキテクチャ*3についての課題が多く出ていました。仕組みの導入や整理した後に、それを実際に使ってサービス開発を進めることではじめて見えてくる問題点はたくさんあります。

部内の乗り越えたい課題

僕の所属するモバイル基盤部は、クックパッドでのモバイルアプリの開発環境を整えるという責務を担う部です。 サービス開発が円滑に進むようにクックパッドアプリの中核部分をメンテナンスしたり、設計パターン、実装方針の決定を主導するということが業務に含まれます。

部には、古くからクックパッドアプリの開発に関わっていて現状の実装方針の経緯に詳しい人もいればそうでない人もいます。 また、画面構築やモバイルアプリの設計パターンの知識、それらに対する興味・関心、得意不得意も人それぞれです。

そのため、改善の業務を進める上で部にもいくつか乗り越えたい課題がありました。

  • 現状のアーキテクチャや実装がどういう状態になっているのか、全員が認識している状態になる
  • 実際に基盤のツールやアーキテクチャを利用した際のサービス開発時の問題点を把握し、理解できるようになる
  • 現状認識の統一を図り、改善の方向性の意思統一ができるようになる

これらの課題は、アサインされたモバイル基盤部の業務を単純に遂行するだけでは落としやすいものばかりです。 浮かび上がってくる課題の理解を深め、真摯に解決に取り組み改善を進めていけるようなフローが必要だと考え始めました。

課題をきちんと拾って向き合う時間を作ろう

せっかく上がってきた課題や意見が Slack コメントや issue のままそこで止まらないように、定期的にきちんと課題に向き合う時間を作って運用してみることにしました。 前述の通り、アプリのアーキテクチャに関する話題が多かったので「アーキテクチャ課題共有会」という名前で始めています。

とりあえず以下のようなルールで始めてみました。

  • アプリのリポジトリとは別に、設計上の課題のみが集まるリポジトリを作り管理する
  • 毎週30分、ひとつ課題をとりあげて背景の理解とその解決方法について議論する
  • 参加者持ち回りで、課題の整理と可能であればその解決案を考えて事前にまとめてきてもらい、課題共有会の最初に説明してもらう
  • 議論中別の話題に発展したら、次回以降につづけるようにする
  • 対応方針が決まったら、やるだけの状態になるのでアサインする

このやり方で 2,3ヶ月運用してみて、うまく回っているところと改善できそうなところの両方がありますが、すでにいくつかの課題の解決に役立てる事ができています。

実際に上がってきた課題の例と課題共有会での進行

この期間で上がった課題の例をひとつ挙げると、「ユーザ情報を表現するオブジェクトの取得方法とその扱いがイマイチ」というものがありました。 この課題をテーマに議論することになりました。

アプリの機能や画面表示において、ログインしているかどうか、有料会員かどうかなどのステータスに依存するものはとても多いです。 このようなユーザ情報を取得したいケース、その使い方を整理して、イマイチな部分を改善しようということになりました。

事前準備

その回の担当者は事前に課題を以下のように整理してきました。

  • ユーザ情報の変化に追従する必要のある画面とそうでない画面があり、ほとんど後者である
  • 現状の仕組みでは全ての場面でユーザ情報の変化を意識しないといけない作りであり、複雑になってしまっている
  • モジュール分割の初期の仕組みに乗っているが、今は別の適切な実装手段があるので、新しい方法に乗り換えるべき

このような、現状とその問題点、それに対する解決案が参加者に共有された状態で議論を行います。 もしキャッチアップの段階で疑問点があれば、議論に入る前に解消します。

議論

課題の整理が済んだら提案された解決方法を評価したり、具体的な実装方法について話し合います。 議論中に別の課題が浮かんできたら元の議論が発散しないように注意し、次以降の課題共有会で話せるように内容を残しておきます。

この回では、ユーザ情報の変化を意識せずに各画面を実装するための仕組みの導入と、新しい実装手段への置き換えを行うことに決まりました。

方針の決定と導入

議論で修正方針が固まったら、どのような話が行われ、どのような結論に至ったかをまとめ、決定事項として残すようにします。 その決定事項をもとに、具体的な作業に落とした issue をアプリのリポジトリに作成するようにします。 あとは通常の修正と同じようにPRを作成し、レビューのステップを踏んで導入完了となります。

f:id:Slightair:20201215142621p:plain
課題共有会後の記録の例

課題共有会の良いところ、これから改善できそうなところ

アーキテクチャ課題共有会を始めたことで良かった点と今後の改善点を紹介します。

定期的に課題に向き合い、前に進めるようになった

まず、定期的に時間を取って課題に向き合うことにより、確実に前に進むことができるようになりました。 また、議題に上がった内容やそれに対して決めた「現時点の方針」をきちんと整理して残せるようになりました。

前述したように、開発環境や状況に応じて最適な戦略や方針は常に変わり続けるので、現時点で何を考えどういう方針を取ったのか記録として残るのが大切なところです。

毎週課題共有会を開いているので、方針が決まってあとはやるだけという状態のものがいくつも生み出せていますが、今度は決まったものの実装待ちが溜まりがちという問題も起きています。 前には進んでいるので今は一旦よしとしています。

参加者の負担を減らしつつ、個人の関心を高められる

課題共有会のはじめのキャッチアップをすばやく行うために、事前準備を行うようにしていました。 この準備はある程度手間のかかるものですが、持ち回りで準備をすることで、負担をうまく分散することができています。 負担の分散だけでなく、各個人で課題の深い部分に気づいたり意識をするようになって、議論を重ねるたびに話が進めやすくなる利点もありました。 また、長年の開発によって積もってきた実装方法や課題、歴史的経緯についても理解を深める機会にもなっています。

議論しているとあっという間に時間が過ぎてしまうので、なるべく小さい課題に分割してから議題として選ぶようにしていますが、それぞれの根となる課題は繋がっていることも多いのです。

アプリの設計方針、戦略についての意識合わせができる

当初の狙い通り、目線や今後の戦略についての意識合わせにも役立ちそうでした。 また、アプリ開発の知識量やアプリの経験値の埋め合わせにも効いています。

僕たちは自分たちのアプリの設計の議論からはじめてしまいましたが、もしモバイルアプリの設計パターンの知識が少なかったりメンバー間で大きく差がありそうな状況であれば、一般的な設計パターンの学習からはじめるのがよさそうにも思いました。 僕たちもこの取り組みを続けるうちにあまり良い答えが見つからなくなってきたら、途中で学習に切り替えようと思っています。

部の課題に関係したこともあり、基本的に今はモバイル基盤部のメンバーのみで課題共有会をやっていますが、取り上げる課題によってゲストを呼び、詳しい状況を聞いたり意見をもらったりしています。 アプリで採用する方針の決定にサービス開発者の現場の視点も入れた方が、より実際の利用に合った方針の選択が期待できるので、他の部署からの参加も呼びかけていきたいと思っています。

より形式的な課題管理の方法を試したい

課題共有会の取り組みはお試しだったため、ゆるーい運用ではじめてみましたが、段々と形になり始めているのでもう少し議題の選び方や粒度をしっかりしていけるとより有益な会にできそうと感じています。 情報の整理方法についても、何を書いて残すべきかはっきりするようなテンプレートを整備するなど、もっと形式的にやっても良さそうに感じています。 Issueのラベルで状態を示すようにしたり、いろいろな工夫をちょうど試しているところです。

まとめ

最近モバイル基盤部で始めた、アーキテクチャや開発環境に対する課題を共有し議論して前に進める取り組みについて紹介しました。 こうして書いてみると「課題を整理して時間を取って議論して記録を残そうね」という当たり前の部分が多く、チームによっては自然に行っていたプロセスかもしれませんが、やり始めてみたらしっくりはまってきたプロセスだったため、紹介しました。

このように様々な形でモバイルアプリ開発の環境を改善していきたいモバイルアプリエンジニアの方、またはこのような開発環境でクックパッドのサービス開発にじっくり取り組んでみたいモバイルアプリエンジニアの方がいましたら、ぜひご連絡ください。

*1:カスタム絵文字を作っておくと便利です

*2:〜霞が関〜 クックパッドiOSアプリの破壊と創造、そして未来 - https://techconf.cookpad.com/2019/kohki_miki.html

*3:2020年のクックパッドAndroidアプリのアーキテクチャ事情 - https://techlife.cookpad.com/entry/2020/11/17/110000

【開催レポ】Cookpad Tech Kitchen #25 日本最大レシピサービスのモバイルアプリ開発事情

$
0
0

こんにちは。クリエイション開発部の星川 (@star__hoshi) です。
2020年12月10日に Cookpad Tech Kitchen #25 日本最大レシピサービスのモバイルアプリ開発事情を開催しました。今回は新型コロナウイルスの影響もありオンラインでの開催となりました。

f:id:star__hoshi:20201217101301p:plain

クックパッドには多くのレシピが投稿され、利用者数も多いサービスとなっています。その規模の大きさから、サービスの改善には事業的にも技術的にも独自の困難がつきまといます。

  • コードの品質はどう保つか
  • 日々の業務効率を高めるために使えるツールはあるか
  • ログを始めとした技術基盤をどう整備するか
  • その基盤を活かしてどのようにサービスを開発していくか

今回はモバイルアプリの領域にフォーカスし、このような課題に日々立ち向かっている吉田、ジョセフ、三木、星川の4名が、日々の業務を通して得た知見について発表しました。

発表内容

「基本のAndroid View開発ドキュメント」/ 吉田 万輝 (@_k4zy)

クックパッドではAndroid開発経験やプロジェクトへ関わる期間が様々な人々がいる状況でチーム開発を行っています。
Android開発を効率的に進めるため「誰が書いても大体同じような実装になる」ことを目指し、モバイル開発基盤が主導して整備している開発方針のドキュメントについて発表しました。

基本の Android View 実装ドキュメントの紹介にも詳しい記載があるので、合わせてお読みください。

「Efficient app development using various debugging and verification tools(デバッグや検証ツールを活用した効率的なアプリ開発)」 / Joseph Iturralde

Androidアプリのリニューアルプロジェクトに伴い、開発効率改善のために様々なツールを利用したり、また必要に応じてツールを作成しました。
その時に役立ったツール、テクニック、デバッグ手法やビルドの高速化について紹介しました。

「モバイルアプリ行動ログ基盤を”大統一”した話」/ 三木 康暉 (@giginet)

モバイルアプリ上でユーザーの行動ログを記録する際、従来の方法では、仕様の共有や、ミスを防ぐのが難しいという問題がありました。
この発表では、ログのドキュメントから、モバイルアプリの実装を自動生成することで、安全なログ基盤を高速に構築した事例を紹介しています。

ドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築に記事がありますので、合わせてお読みください。

「大統一ロガーを利用したサービス開発」/ 星川 健介 (@star__hoshi)

giginet の発表した「大統一ロガー」によって、ログ基盤が整えられました。
その大統一ロガーはサービス開発者にとってどういうメリットがあるのか、また大統一ロガーを利用してどのようにクックパッドのiOSアプリを改善しているか紹介しました。

Q&A

オンライン開催のため、ZoomのQ&A機能を使いたくさんの質問をいただきました。そのうちのいくつかをピックアップして紹介します。

ConstraintLayout を使うとあらゆる View に id 名をつけると思いますが、id名の命名規則はどのようにしてますか xxxxText なのか textXxxx なのかとか

実はidの命名規則は決めてないのですが圧倒的に xxxxText が多数派です。
とても良いご指摘なのでidの命名規則もドキュメントに記載して統一しようと思います。

ログ定義はプラットフォーム間で共有されていますか?

大統一ロガーに関して、ログ定義はプラットフォーム間で共有されていません。
アプリケーションごとに画面構成が違ったり、ログの要件も違う場合があるので分離しています。

これは、過去の行動ログ基盤の運用を通し、複数のプラットフォームのログを統合すると、デメリットの方が多かったという経験に寄るものです。

ログをみてから仮説を得ることと仮説を確認するためにログをとることのどちらの方が多いですか? また、気になったことをいちいちログとりしていくの大変(=面倒くさい)ではないですか?

ログを見てから仮説を得ることもありますし、仮説を確認するためにログを取ることのどちらもあり、どちらが多いというのは難しいです。
気になったことをいちいちログ取りしていくのは大変ですが、施策を実施する前に「どの指標がどれくらい伸びたら成功とする」というのを決めることが多く、ログの取得はほぼ必須となっています。

感想

オンラインの開催となり、参加者が少なくならないか、質問があまり来ないかもと不安な中でのスタートでしたが、たくさんの方に参加いただき質問もたくさんいただきました。
Android/iOS共にアプリのリニューアルを行い、そこでの知見を発表させていただきました。Markdown でログ定義を作成しそこからロガーのコードを生成すると決定したときは「マジか、yml とかで定義書いてそこからドキュメントとコード生成した方がいいんじゃないか…」と思ったんですが、実際に運用してみると Markdown は書きやすいしすぐにプレビューも出来てとても便利でうまく回っています!

最後に

クックパッドでは、モバイル基盤部とサービス開発の部署が連携して開発しています。
クックパッドのモバイルアプリ開発やサービス開発について、もっと聞いてみたい、ディスカッションしたいという方がいらっしゃいましたら、個別雑談会も実施しているので是非お申し込みください! カジュアルに情報交換しましょう!
お申し込みはこちら → https://enq.cookpad.com/meet_cookpad_engineer

Taking Advantage of Debugging Tools for Android App Development

$
0
0

Hello! I'm Joseph and I'm an Android engineer from the Mobile Infrastructure team.

In this post, I will talk about some of the tools that we used in debugging while working on the renewal project. The contents of this post were presented in the recently held Tech Kitchen #25.

Renewal Project

As mentioned in the article テストケース作成を仕様詳細化の手段とする実験 published earlier this year, the Cookpad app for iOS has undergone a lot of changes as part of the renewal project.

Last October, it was Android's turn and we published the Cookpad app for Android which underwent a similar renewal project. Since this is a fairly large project, it's quite challenging to develop and debug. However, we used some tools that helped us improve our workflow.

Flipper

f:id:epishie:20201223161236p:plain

Some time ago, Facebook published a tool called Stetho, a debug bridge tool build for Android using Chrome Developer Tools. Stetho is quite useful but the functionality is limited and the client is dependent on Chrome. In 2018, they released Flipper to replace Stetho and now works on iOS, Android and ReactNative apps and with its own stand-alone client built on Electron.

Flipper has two components, the app SDK which is added as a dependency to the target app, and the desktop client where users can interact and view the data sent from the app via the SDK. Out-of-the-box, Flipper includes some core plugins users can use right away to inspect various app components with minimal setup but developers can also extend it and add or publish their own plugins.

Layout Inspector

f:id:epishie:20201223161654p:plain

One of the core plugins is the Layout Inspector. This is very useful in debugging complex UI especially when screen content is obtained from an API or user-generated like some of the screens that we've added in the renewal project.

Using Flipper, the view tree of the screen can be checked and verified if the views are inflated correctly.

Client App
f:id:epishie:20201223161759p:plain:w480f:id:epishie:20201223161806p:plain:w160

Selecting the view will reveal the view attributes, which can be updated while running the app without recompiling or restarting. Hovering on the view will highlight the said view within the app running on the device or on the emulator. It also shows within the app the view bounds and margins or paddings if the view has it. This is very useful not only for developers during debugging but also for designers when verifying the design specs. There's a lot of other features like view search and target mode so please check the documentation for more information.

Network Inspector

f:id:epishie:20201223162344p:plain

Network Inspector is also one of the core plugins. Using this tool, HTTP requests sent from and responses received by the app can easily be verified. Out-of-the-box, the Network Inspector plugin can be integrated with OkHttp as an Interceptor that can be added to the client. Since the inspector is added directly to the HTTP client, even HTTPS requests can be inspected without dealing with encryption and certificates. For clients other than OkHttp, integration code can be written by calling the appropriate methods of the SDK's network plugin object. Recently, they added the Mock feature to stub HTTP responses which is very helpful during debugging.

Shared Preferences Viewer

f:id:epishie:20201223163054p:plain:w320

Another core plugin is the Shared Preferences Viewer. In the Cookpad app, there's a custom component called Spotlight that we use when onboarding users to the features that were added in a release. This component consists of a custom view to highlight the new feature and a SharedPreference key-value to keep track of whether the user has been shown the onboarding or not.

f:id:epishie:20201223163245p:plain

Since onboarding is a one-off event, it requires deleting the app storage (SharedPreferences) to re-test and debug. With the Shared Preferences Viewer, instead of clearing the app storage, the actual key-values can be verified and modified while running the app to modify the behavior.

These are just 3 of the core plugins that we use during development. There are other plugins like the Databases plugin for inspecting local SQLite databases, and Images plugin for monitoring image loading using third-party libraries like Fresco.

Hyperion

Hyperion is a plug-and-play debug menu library designed to help fill in the gaps between design, development, and QA. By adding the library as a dependency to the Android project, usually for debug configuration only, the menu can be accessed while running the app by shaking the device or through the notification drawer.

f:id:epishie:20201223163307g:plain:w320

Hyperion includes a variety of core plugins each serving a different function. Like Flipper, Hyperion also supports custom plugins developers can add to support different use-cases.

Over the years, we've created our own custom Hyperion plugins to assist in debugging and verifying the app behavior.

Drawer Other Tools
f:id:epishie:20201223163435p:plainf:id:epishie:20201223163453p:plain

During the renewal project, some tools were created as needed to specifically help debug the new features that we were adding to the app.

Button Style Verification

Before starting to work on the renewal project, to be able to use the new Material Design components, we had to migrate from the AppCompat theme to the MaterialComponents theme. Since the project already had a lot of custom button styles declared used within the app, we were afraid existing UI design might break when changing the theme.

f:id:epishie:20201223163547g:plain:w320

Instead of checking each screen for each button style, we built a simple tool where designers can see a preview of all the button styles in a single screen. This is a very simple tool but it definitely cut the time that it takes between designers' feedback and bug-fixing.

Ken Burns Preview

f:id:epishie:20201223163618g:plain

In some of the the screens that we're added during the renewal project, series of images are shown in a slideshow with fade-in/fade-out transitions and custom animations which are called Ken Burns Effect.

The effect patterns are designed to be random, and depend on the number of images to be shown and whether there's a video included. Since this feature is already implemented in out Cookpad iOS app, the actual tweaking of the effect parameters were already done as discussed in detail in this post.

f:id:epishie:20201223163753g:plain:w320

However, designers still need to verify that the effects are played correctly and are the same as the iOS version. Testing on the actual screen is unreliable since the effects are dependent on randomness and the count and type of content. To help designers verify the feature quickly, we built this tool where they can check the effects, change the patterns and verify the effect with different combination of contents.

Quick Navigation

f:id:epishie:20201223163850p:plain:w320

During the renewal project, we were adding new features that has a set of list and details screens. Most of the time, these screens are implemented independently and simultaneously and sometimes by different developers. Because of the parent-child relationship of the screens, the actual navigation between the screens cannot be implemented until the set is completed. To allow such navigation during development, we built a simple navigation list screen so that it's possible to access the child screens.

Log Viewer

As with most apps these days, in the Cookpad Android app, we do record logs of user's actions which are useful in analyzing and understanding the status of our services. Since these logs are buffered before being sent to our log infrastructure, when adding logs to the app, to make sure that the implementation is correct, we had to wait for the logs to be printed in logcat and/or check the backend.

f:id:epishie:20201223163909p:plain:w320

To speed-up the development and debugging, we've added a simple Log Viewer tool. Since our logging library called Puree allows adding filters that can be applied to each log before sending, we created and added a filter where we can record the logs to a local database which the Log Viewer can query and display as a list.

Demo Apps

f:id:epishie:20201223163938p:plain:w320

The Cookpad Android project was split into feature modules for better code organization and cohesion, and to potentially improve the time it takes to build and run the app. However, even if the features are independent of each other, debugging and verification still involves the whole project and the whole app has to be built and run.

The Cookpad iOS app has the Sandbox apps which are mini-apps that contain a single feature that depends only on a subset of the app's modules. For Android we built a similar mechanism called Demo apps.

f:id:epishie:20201223164015g:plain:w320

With Demo apps, it's possible to build only the modules needed for a feature and provide a simple entry point for the screens a feature has instead of building and running the whole app. The details of how Demo apps are implemented in Android are described in this post.

Final thoughts

We all know that developing large projects like the Android renewal project is difficult. Debugging and testing of projects with large and complex feature set are even harder. However, in most situations, there are tools already available to address some of the pain points we encounter during development. In situations in which there's no tool yet, why not try creating one. If you do, you might want to share it so that everyone can use it too.


Compositional LayoutとDiffable Data Sourceを使ってiOSアプリのつくれぽ詳細画面を実装する

$
0
0

クックパッドの事業開発部でiOSエンジニアをしている角田(id:muchan611)です。普段はクックパッドiOSアプリの検索に関する機能を開発しています。

クックパッドの基本的な機能のひとつである「つくれぽ」を表示する「つくれぽ詳細画面」を、UICollectionViewCompositionalLayoutUICollectionViewDiffableDataSourceを使って実装したので、その過程や実装方針についてご紹介します。

背景

つくれぽとは、クックパッドのレシピを見て料理をした人が、その料理を他の人におすすめするために投稿するもので、検索ユーザーはつくれぽ通してレシピを探せるようになっています。

事業開発部では「つくれぽからレシピを決める」体験を増やす取組みを行っていますが、各施策の方針を決定するために、多くのユーザーインタビュー(※)や数値分析を実施し判断材料を得ています。
そのインタビューの中で「レシピを決定するには材料情報が必要だが、つくれぽ詳細画面にはそれが表示されておらず、レシピ決定の障壁になっている可能性がある」という課題が明らかとなり、つくれぽ詳細画面に材料を表示する施策が決まりました。

今回の開発では、これまでの実装を拡張するのではなく、CollectionViewを用いて画面を作り替えることとなったため、その際に得た知見や実装方針について、ひとつの例としてご紹介できればと思います。

課題と実装方針

実はiOSクックパッドでは、2020年の春に大きなリニューアルを実施し、その際につくれぽ詳細画面を大きく変更しました。
ただ、この時に実装されたつくれぽ詳細画面では、コンテンツが追加されることを想定していなかったため、スクロールができない画面となっていました。変更前後のつくれぽ詳細画面は以下のような見た目で、以前はViewControllerの上に直接各パーツが配置されていました。

以前のつくれぽ詳細画面

f:id:muchan611:20201223222235p:plain:w160

新しいつくれぽ詳細画面

f:id:muchan611:20201223222420p:plain:w160f:id:muchan611:20201223222452p:plain:w160

そして、今回材料コンテンツを実装するにあたって、以下の問題をクリアする必要がありました。

  • スクロールしないことを前提にした制約が多く、そのまま構造を変えずに実装を進めると、非常に複雑でメンテナンスしにくい状態になりかねない
  • 今後、材料以外にもレシピ決定に必要なコンテンツを追加していく可能性が高く、継続的にコンテンツを増やせるような構造にする必要がある

このような背景を踏まえて今後の継続的な開発を検討した結果、 UICollectionViewで画面を作り替えUICollectionViewCompositionalLayoutUICollectionViewDiffableDataSourceを利用する方針で開発を進めることにしました。主な理由は以下の通りです。

  • コンテンツの追加が容易に行える
    • 前述した通り、今後もレシピ決定に必要なコンテンツを追加する可能性があり、レイアウトの変更に強くシンプルで分かりやすい実装が実現できるCollectionViewが最適だった
  • UICollectionViewCompositionalLayoutを利用することで、section毎のカラム数指定や各コンテンツのサイズ指定が柔軟で容易になる
    • 例えば、材料sectionは2カラム、それ以外は1カラムで表示するといった、文字数によるコンテンツの高さ計算を自前で行う必要がなく、それらの調整をAutoLayoutに任せることが可能
  • UICollectionViewDiffableDataSourceを利用することで、データへのアクセスも容易で安全になる
    • 表示データをインスタンス変数に保持して利用するケースと比較すると、UICollectionViewDiffableDataSourceを利用することでデータの保持をフレームワーク側に任せることができ実装が簡素化できる
    • 型による制約が強いため、データとUIの不整合を防止できる

実装内容

全てのコードを載せると全体が分かりにくくなってしまうため、一部割愛しながら実装内容についてご紹介します。

DataSourceの定義

まずdataSourceですが、以下のような定義になっています。

vardataSource:UICollectionViewDiffableDataSource<Section, Item>!

SectionIdentifierTypeにはSectionを、ItemIdentifierTypeにはItemというenumを指定しています。 それぞれのenumの定義は以下の通りです。(TsukurepoViewItemは、APIから取得したつくれぽ情報をViewにとって都合の良い形に変換した構造体です)

enumSection:CaseIterable {
    case media
    case margin
    case recipeTitle
    case recipeDescription
    case ingredientsHeader
    case ingredients
    case showMore
}

enumItem:Hashable {
    case media(media:TsukurepoViewItem.Media?, tsukurepo:TsukurepoViewItem.Tsukurepo?)
    case margin
    case recipeTitle(TsukurepoViewItem.RecipeOverview?)
    case recipeDescription(String)
    case ingredientsHeader
    case ingredients(TsukurepoViewItem.Ingredients)
    case showMore
}

このように分けた背景についてですが、まず、UICollectionViewCompositionalLayoutでは、section毎にレイアウトを組む仕組みになっているため、Sectionはレイアウト単位で分けることにしました。

そして、Itemはcell単位で分けており、cellに渡したいデータをenumのassociated valueで持つようにしています。 UICollectionViewDiffableDataSourceの初期化時に指定するcellProvider内で、各cellの更新処理を実装するため、その際に必要なデータへ簡単にアクセスできるようにするためです。

dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView:collectionView) { [weak self] (collectionView:UICollectionView, indexPath:IndexPath, identifier:Item) ->UICollectionViewCell? inguardletself=selfelse { returnnil }
    switch identifier {
    caselet .media(media, tsukurepo):letcell= collectionView.dequeue(TsukurepoDetailsMediaCell.self, for:indexPath)
        cell.configure(media:media, tsukurepo:tsukurepo)
        cell.delegate =selfreturn cell
        //..以下省略..
    }
}

dataSourceへsnapshotをapplyする処理は、下記のapply(tsukurepo: TsukurepoViewItem?)内で実装しており、この関数はviewDidLoad()内やつくれぽ情報の取得が完了した際に呼びだされます。

overridefuncviewDidLoad() {
  super.viewDidLoad()
  //..途中省略..

  apply(tsukurepo:nil)

  presenter.tsukurepo
    .drive(onNext: { [weak self] tsukurepo inself?.apply(tsukurepo:tsukurepo)
    })
    .disposed(by:disposeBag)
}

viewDidLoad()が呼び出された時点では、まだつくれぽ情報を取得していないので、引数のtsukurepoがnilとなります。その場合は、mediamarginrecipeTitleItemIdentifierTypeのみを追加し、それぞれのcellではempty viewを表示するように実装しています。
つくれぽ情報取得後は全てのsectionにItemIdentifierTypeを追加し、材料については存在する材料の数だけingredientsを追加します。

funcapply(tsukurepo:TsukurepoViewItem?) {
    varsnapshot= NSDiffableDataSourceSnapshot<Section, Item>()
    snapshot.appendSections(Section.allCases)

    snapshot.appendItems([.media(media:tsukurepo?.media, tsukurepo:tsukurepo?.tsukurepo)], toSection: .media)
    snapshot.appendItems([.margin], toSection: .margin)
    snapshot.appendItems([.recipeTitle(tsukurepo?.recipeOverview)], toSection: .recipeTitle)
    iflettsukurepo= tsukurepo {
        ifletdescription= tsukurepo.recipeOverview.description {
            snapshot.appendItems([.recipeDescription(description)], toSection: .recipeDescription)
        }
        snapshot.appendItems([.ingredientsHeader], toSection: .ingredientsHeader)
        letingredients:[Item]= tsukurepo.ingredients.map { .ingredients($0) }
        snapshot.appendItems(ingredients, toSection: .ingredients)
        snapshot.appendItems([.showMore], toSection: .showMore)
    }

    dataSource.apply(snapshot, animatingDifferences:false)
}

レイアウトの生成

つくれぽ詳細画面の構造を簡略化するとこのようになります。(2枚目はスクロール後です)

f:id:muchan611:20201223222700p:plain:w300f:id:muchan611:20201223222719p:plain:w300

これを実現しているコードは下記の通りですが、section毎にコンテンツの高さを割合や絶対値、推定値で指定しています。
例えば、mediaはつくれぽ画像を含むsectionで、仕様上縦横比が3:4になるように表示したいのですが、この場合はgroupのサイズに次のような指定をします。

letgroupSize= NSCollectionLayoutize(widthDimension: .fractionalWidth(1.0),
                                       heightDimension: .fractionalWidth(1.33))
letgroup= NSCollectionLayoutGroup.horizontal(Layoutize:groupSize, subitem:item, count:1)

.fractionalWidth.fractionalHeightを指定することで、幅や高さに対する割合でコンテンツのサイズを決めることができるためです。また、説明文や材料などは文字数によって高さを可変にしたり、文字サイズ変更の際に適切な高さを適用したりするため、.estimatedを指定しています。そうすることで、コンテンツサイズが変更される時にシステム側で実際の値を計算し調整してくれます。また、最下部に表示する「このレシピを詳しく見る」ボタンの高さは固定にしたいため、絶対値で指定ができる.absoluteを利用しています。
これらのDimensionについては公式ドキュメントに詳細が記載されています。

letlayout= UICollectionViewCompositionalLayout { [weak self] (sectionIndex:Int, _:NSCollectionLayoutEnvironment) ->NSCollectionLayoutection? inguardletself=selfelse { returnnil }
    letsectionKind=self.dataSource.snapshot().sectionIdentifiers[sectionIndex]

    letitemHeight:NSCollectionLayoutDimensionletgroupHeight:NSCollectionLayoutDimensionswitch sectionKind {
    case .media:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .fractionalWidth(1.33)
    case .margin:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .fractionalHeight(0.03)
    case .recipeTitle:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .fractionalHeight(0.15)
    case .recipeDescription:letheight= NSCollectionLayoutDimension.estimated(72)
        itemHeight = height
        groupHeight = height
    case .ingredientsHeader:letheight= NSCollectionLayoutDimension.estimated(40)
        itemHeight = height
        groupHeight = height
    case .ingredients:letheight= NSCollectionLayoutDimension.estimated(35)
        itemHeight = height
        groupHeight = height
    case .showMore:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .absolute(108)
    }

    letitemSize= NSCollectionLayoutize(widthDimension: .fractionalWidth(1.0),
                                          heightDimension:itemHeight)
    letitem= NSCollectionLayoutItem(Layoutize:itemSize)
    letgroupSize= NSCollectionLayoutize(widthDimension: .fractionalWidth(1.0),
                                           heightDimension:groupHeight)
    letgroup= NSCollectionLayoutGroup.horizontal(Layoutize:groupSize, subitem:item, count:sectionKind.columnCount)

    return NSCollectionLayoutection(group:group)
}

そして、材料のsectionでは1行に2つのitemを表示したいため、countを指定することでsectionによって表示するitemの数を変えています。
sectionKind.columnCountは、材料sectionの場合に2、それ以外は1を返します。

letgroup= NSCollectionLayoutGroup.horizontal(Layoutize:groupSize, subitem:item, count:sectionKind.columnCount)

このようにUICollectionViewCompositionalLayoutを使う事で、カラム数を変えたりコンテンツサイズを柔軟に指定したりすることができ、複雑なレイアウトもシンプルで簡単に実現することができます。

iOS12以下のサポートについて

UICollectionViewCompositionalLayoutUICollectionViewDiffableDataSourceはiOS12以下で利用できないため、iOS12以下で同じような実装を実現したい場合はIBPCollectionViewCompositionalLayoutDiffableDataSourcesなどのバックポートライブラリを使用する必要があります。

クックパッドでも、主要な画面においては、iOS12で表示できるようにこれらのバックポートライブラリを利用するケースがありました。しかし、公式の仕組みとの挙動の違いから少なからずサポートコストがかかっていたため、今回はiOS13以上の端末でのみ新しいつくれぽ詳細画面を表示しiOS12以下をサポートしない、という事業判断を行いました。
(本実装を行った2020年11月時点において、クックパッドアプリではiOS12をサポートしていましたが、現在はサポート対象をiOS13.1以上に引き上げています)

まとめ

ここまでに述べたように、UICollectionViewCompositionalLayoutを用いることでsection毎のカラム数指定や各コンテンツのサイズ指定を柔軟で容易に行えるため、レイアウトの実装がシンプルかつ比較的簡単になります。また、UICollectionViewDiffableDataSourceを利用する事で、データの保持をフレームワーク側に任せることができ実装が簡素化できるほか、データとUIの不整合の防止にも繋がるため、より安全な実装が実現できます。
そして、これらの仕組みを利用してつくれぽ詳細画面を作り替えることで、新しいコンテンツの追加が容易となり、スムーズに追加開発を進められる状況になっています。

施策の結果については、(レシピ決定のひとつの指標である、つくれぽ詳細から遷移したレシピ画面での)クリップ率上昇やつくれぽ一覧画面の3日以内再訪率が上昇したことが分かり、「つくれぽからレシピを決める」体験を増やすことができたと評価しています。

このように、クックパッドではユーザーインタビューや数値分析を通して施策を考え開発を進めており、一緒にサービス開発を盛り上げてくれるiOSエンジニアを大募集しております!!
カジュアル面談なども実施しておりますので、少しでもご興味がある方はぜひお気軽にお問い合わせください!

https://info.cookpad.com/careers/


※現在、ユーザーインタビューはオンラインで実施しています

プロと読み解く Ruby 3.0 NEWS

$
0
0

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

本日 12/25 に、ついに Ruby 3.0.0 がリリースされました。一昨年、昨年に続き、今年も Ruby 3.0 の NEWS.md ファイルの解説をします。NEWS ファイルとは何か、は一昨年の記事を見てください(なお Ruby 3.0.0 から、NEWS.md にファイル名を変えました)。

Ruby 3.0 は、Ruby にとってほぼ 8 年ぶりのメジャーバージョンアップとなります(Ruby 2.0 は 2013/02/24)。高速化(Ruby 3x3)、静的型解析、並列並行の3大目標をかかげて開発されてきた記念すべきバージョンですが、NEWS.mdはわりと淡々と書かれているので、この記事も淡々と書いていきます。

他にも Ruby 3.0 を解説している記事はいくつかあります。見つけたものだけリンクを置いておきます。

なお、本記事は新機能を解説することもさることながら、変更が入った背景や苦労などの裏話も記憶の範囲で書いているところが特徴です。

■言語の変更

キーワード引数の分離

  • Keyword arguments are now separated from positional arguments. Code that resulted in deprecation warnings in Ruby 2.7 will now result in ArgumentError or different behavior. [Feature #14183]

Ruby 3では、キーワード引数が通常の引数とは独立した引数になりました。これは非互換な変更になっています。

# キーワード引数を受け取るメソッドdeffoo(key: 42)
end

foo(key: 42)      # OK: キーワード引数を渡している

opt = { key: 42 }
foo(opt)          # NG: 普通の引数を渡しているのでエラー(2.7では警告付きで動いていた)

foo(**opt)        # OK: ハッシュを明示的にキーワードに変換している

2.7では普通の引数をキーワード引数に暗黙的に変換していましたが、3.0からはこの暗黙的変換を行わないようになりました。多くのケースは上記の例のように、foo(opt)foo(**opt)のように書き換える、で対応できると思います。

なお、キーワード引数から普通の引数への暗黙的変換は維持されています(削除するには互換性の影響が大きすぎたため)。次のコードはRuby 3.0でも動作します。

# 普通のオプショナル引数を受け取るメソッドdeffoo(opt = {})
end

foo(key: 42) # OK: キーワード引数が暗黙的に普通の引数に変換される# # ↑は動きますが、今後は次のように書くのがおすすめです# def foo(**opt)# end

この変更についての詳細は、昨年の『プロと読み解くRuby 2.7 NEWS』や、Ruby公式サイトの移行ガイドを参照してください。

裏話

これの裏話を語りだすととても長いので、かいつまんで。

昨年の記事でも書いたことですが、Ruby 2.0でキーワード引数を最初に実装したのは私(遠藤)です。当時はRuby 1.8との連続性・互換性を意識しすぎたため、やや無理のある言語設計となっていました。そのため、非直感的挙動が頻繁に報告される(しかも本質的に壊れているので場当たり的な対応しかできない)という設計不良になっていました。これをどうにかすることは、Ruby設計者のmatzだけでなく、自分にとっても積年の悲願でした *1

とはいえ、多くのケースではそれなりに期待通りに動いてきた機能なので、2.7で変更を予告する警告を導入したところ、数多くの悲鳴や不満の声があがりました。変更の延期や中止も視野に入れつつ、Ruby on Railsの交流サイトにmatzがスレッドを立てて、ユーザの声を直接聞くことにしました。延べ40件ほどのさまざまなコメントをいただいたので、遠藤がすべてのご意見を何度も読み返し、分類集計しました。その結果、「変更予告の警告が出ること自体が不満 *2」「実務的な対応ノウハウが共有されていない *3」ということが不満の源泉で、問題の変更自体には意外と前向きな人が多いことがわかりました。そこで、前者の問題に対しては最善の対応ということで 2.7.2 でデフォルトで警告を無効にしました。後者の問題に対しては、コメント内で上げられた個別の問題に対して対処方法を一緒に考えていきました。また、警告を柔軟に非表示にできるdeprecation_toolkit gemがスレッド内で共有されたことも大きかったです。一方でRuby on Rails本体は(kamipoさんというすごい人やamatsudaさんなどのご尽力で)キーワード引数の分離に成功しました。分離を延期させるとRuby on Railsのリリーススケジュールに悪影響になる可能性がある *4ということもヒアリングでわかったので、熟考に熟考を重ねた上で、3.0で変更を決行することになりました。

(文責:mame)

deprecated警告がデフォルトで出ないことになった

  • Deprecation warnings are no longer shown by default (since Ruby 2.7.2). Turn them on with -W:deprecated (or with -w to show other warnings too). Feature #16345

「廃止予定である」という警告は原則として$VERBOSEモードでしか表示されないことになりました。キーワード引数分離の警告だけではなく、すべてのdeprecated警告が対象です。3.0.0 からではなく、2.7.2 も変更されています。

前節で延べたように、キーワード引数分離の経験がきっかけで、deprecated警告のありかたが見直されたためです。昔は原則として、「まず$VERBOSEモードでだけ警告を出す」「次に無条件で警告を出す」「最後に変更する」という3バージョンを経て廃止を行っていました。しかしこれは変更までに時間がかかるわりに、無条件警告のフェーズはエンドユーザ(Rubyで書かれたプログラムを使うだけのユーザ)に見せても詮無い警告を見せるだけになるのでかえって不便、というフィードバックを多数得たので、無条件警告フェーズをなくすということになりました。

(mame)

引数委譲の記法の拡張

  • Arguments forwarding (...) now supports leading arguments. [Feature #16378]

キーワード引数の分離の悪影響の1つに、引数を委譲するのがめんどうになることがあります。そのため、Ruby 2.7では引数を委譲するための構文が導入されたのですが、引数を一切変更できないので使えるケースが限定されていました。

Ruby 3.0では、次のように、先頭の引数を取り除いたり、新しい値を追加したりすることが許されるようになりました。

defmethod_missing(meth, ...)
  send(:"do_#{meth}", ...)
end

先頭の引数以外はやはり変更できないのですが、これだけでも多くのケースが救われるという声が前述のヒアリングスレッドなどでも聞かれたため、導入されました。

(mame)

ブロックがキーワード引数を受け取る場合の意味の整理

  • Procs accepting a single rest argument and keywords are no longer subject to autosplatting. This now matches the behavior of Procs accepting a single rest argument and no keywords. [Feature #16166]

あまり知られていないかもしれませんが、Rubyのブロックの引数は伏魔殿です。

proc {|a, b|    a }.call([1]) #=> 1proc {|a, k:42| a }.call([1]) #=> 1

上記のように、2引数以上を受け取るブロックに配列をひとつだけ渡して呼び出すと、配列の中身が引数として解釈されます。なので、上記の例ではa[1]ではなく1が入ります。この挙動はautosplatなどと呼ばれることもあります(正式な機能名なのかは知らない)。

1引数のブロックではautosplatはされません。

proc {|a| a }.call([1]) #=> [1]

また、可変長引数を受け取るブロックでもautosplatはされません。

proc {|*a| a }.call([1]) #=> [[1]]

ただし、普通の引数に加えて可変長引数を受け取るブロックではautosplatがされます。

proc {|x, *a| a }.call([1]) #=> []  # xに1が入り、可変長引数のaは空配列になる

正直、autosplatの条件は遠藤も正確に理解していません(コードを読んでも理解できません)。非常にややこしい挙動ですが、多くの場合でうまく動くので、熟考に熟考を重ねた上でなんとなくこうなっています。

さて今回の変更は、可変長引数とキーワード引数を組み合わせた場合の話です。2.7まではautosplatがされていましたが、3.0からはautosplatがされないことになりました。

proc {|*a, k:42| p a }.call([1]) #=> [1]    # 2.7proc {|*a, k:42| p a }.call([1]) #=> [[1]]  # 3.0

難しいですね……。

(mame)

$SAFE削除

  • $SAFE is now a normal global variable with no special behavior. C-API methods related to $SAFE have been removed. [Feature #16131]

古のセキュリティ機構である $SAFE機能は、Ruby 2.7 で廃止されましたが(プロと読み解くRuby 2.7 NEWS - クックパッド開発者ブログ$SAFEの廃止」)、まだ対応していないコードに警告などを出すため、$SAFE自体を特別扱いして、何か代入されたら警告を出す、もしくは例外を出す、という挙動になっていました。このような特別使いを Ruby 3.0 からやめて、本当にただのグローバル変数になった、ということです。

$SAFE = 42# Ruby 2.7 までは、エラー(0 or 1 しか許さなかった)、Ruby 3.0 からは素通し
p $SAFE#=> 42

(ko1)

$KCODE削除

  • $KCODE is now a normal global variable with no special behavior. No warnings are emitted by access/assignment to it, and the assigned value will be returned. [Feature #17136]

$SAFEと同じような話ですが、Ruby 1.9.0(ずいぶんと古いですね)から値を設定しても何も意味がなかった$KCODEについて、値を代入したり参照したりすると警告をだしていたのを、Ruby 3.0 からは特別扱いしないようにしました。

$KCODE = 42
p $KCODE#=> Ruby 2.7 以前# warning: variable $KCODE is no longer effective; ignored# warning: variable $KCODE is no longer effective# nil##=> Ruby 3.0# 42

(ko1)

シングルトンクラス定義の中での yieldが禁止に

  • yield in singleton class definitions in methods is now a SyntaxError instead of a warning. yield in a class definition outside of a method is now a SyntaxError instead of a LocalJumpError. [Feature #15575]

次のようなコードがエラー(LocalJumpError)になるようになりました。

deffooclass<< Object.new
    yieldendend

foo{ p :ok } #=> :ok

Ruby 2.7で廃止予定となり(プロと読み解くRuby 2.7 NEWS - クックパッド開発者ブログ「シングルトンクラスの中で yield は廃止予定」 )、順当に廃止された、という感じです。

(ko1)

パターンマッチが正式機能に

Ruby 2.7で試験的に導入されたパターンマッチですが、正式な機能となりました。

具体的な変更としては、パターンマッチを使うと出ていた警告が 3.0 では出なくなりました。

case [1, 2, 3]
in [x, y, z]
end# Ruby 2.7 では警告が出ていた(3.0 では出ない)#=> warning: Pattern matching is experimental, and the behavior may change in future versions of Ruby!

(mame)

右代入が導入された

  • One-line pattern matching is redesigned. [EXPERIMENTAL]
    • => is added. It can be used as like rightward assignment. [Feature #17260]

一部で待望の機能とされている、右代入が導入されました。

{ a: 1, b: 2, c: 3 } => hash

p hash #=> [1, 2, 3]

さて、これはパターンマッチの一部と言うことになっています。よって、右側には任意のパターンが書けます。ただし下記の通り、experimentalであるという警告が出ます(パターンが単一の変数のときだけは導入が確定的なので、experimental警告は出ません)。

{ a: 1, b: 2, c: 3 } => { a:, b:, c: }
# warning: One-line pattern matching is experimental, and the behavior may change in future versions of Ruby!

p a #=> 1
p b #=> 2
p c #=> 3

{ a: 1, b: 2, c: 3 } => { a:, b:, c:, d: }  # NoMatchingPatternError(キーワード `d` がないため)

裏話

自分は右代入の使いどころがよくわかっていないのですが、複数行に渡るメソッドチェーンの最後に代入するときなどに便利という人が何人かいる(matzを含む)ので導入されたようです。正直、無理に使う必要はないと思います。

いくつか注意点だけ書いておきます。

パターンマッチの一部として実現されているため、インスタンス変数などに右代入することはできません(インスタンス変数はパターンとして書けないので)。

{ a: 1, b: 2, c: 3 } => @a# SyntaxError

また、普通の代入と違って、返り値は利用できません。

ret = ({ a: 1, b: 2, c: 3 } => hash) #=> SyntaxError (void value expression)

さらに、うっかり引数に右代入を書こうとすると、キーワード引数になってしまうので注意です。

foo(val = expr)   # OK
foo(expr => val)  # NG: expr をキー、val を値とするキーワード引数

(mame)

一行パターンマッチが再設計された

前項の右代入は、Ruby 2.7では=>ではなくinという演算子で導入されていたものです。しかし、思ったほど使われなさそうということで、より右代入らしい記法で再試験導入することになりました。

そしてin演算子自体は、マッチの成否をtrue/falseを返すものに変わりました。

{ a: 1, b: 2, c: 3 } in { a:, b:, c: }     #=> true
{ a: 1, b: 2, c: 3 } in { a:, b:, c:, d: } #=> false# warning: One-line pattern matching is experimental, and the behavior may change in future versions of Ruby!

Ruby 2.7に引き続き、experimental警告が出ます。

なお、in=>は返り値以外は同じです。

(mame)

findパターンが追加された

配列の中でマッチする箇所を探索するパターンが試験導入されました。

case ["a", 1, "b", "c", 2, "d", "e", "f", 3]
in [*pre, String => x, String => y, *post]
  p pre  #=> ["a", 1]
  p x    #=> "b"
  p y    #=> "c"
  p post #=> [2, "d", "e", "f", 3]end

ちょっとややこしいですが、[*pre, String => x, String => y, *post]というパターンは、Stringが2連続で登場する箇所を探すパターンです。上記の例では、"b", "c"の箇所にマッチしています(最初にマッチしたところで探索は止まります)。

裏話

matzの肝いりの新機能です。ユースケースがあまり明確ではないのですが、matzの一声で入りました。

探索を行うパターンは、あまり一般的なパターンマッチにはない機能ですが、線形探索しか行わないようになっているので、そこまで複雑な挙動にはならないと思います。

(mame)

スーパークラスでクラス変数を再定義してしまったとき、サブクラスで参照したときに例外が出るようになった

  • When a class variable is overtaken by the same definition in an ancestor class/module, a RuntimeError is now raised (previously, it only issued a warning in verbose mode). Additionally, accessing a class variable from the toplevel scope is now a RuntimeError. [Bug #14541]

実は継承が絡むと難しいクラス変数ですが、わかりづらい例で例外が出るようになりました。

まず、例外が出ないケースをご紹介します。

classC@@foo = :CendclassD< C@@foo = :D# C の @@foo を変更しているendclassC
  p @@foo#=> :Dend

このとき、@@fooというのは、Dでも定義しているように見えて、実はCのクラス変数を変更しています。継承元を見るわけですね。ぱっと見た感じわかりづらい。

さて、C@@fooがあったときは、Dの文脈でクラス変数を設定する、ということはわかりました。では、先にDに設定したあと、その基底クラスであるCのクラス変数を設定したらどうなるでしょうか。

classCendclassD< C@@foo = :D# D の @@foo に代入endclassC@@foo = :C# C の @@foo に代入endclassD
  p @@foo# Ruby 2.7 以前#=> warning: class variable @@foo of D is overtaken by C#=> :C## Ruby 3.0#=> class variable @@foo of D is overtaken by C (RuntimeError)end

Ruby 2.7までは、Cに上書きされてしまったぞ、というような警告を出して、C@@fooが参照されるようになりました(そのため、警告の後、:Cが返る)。しかし、ちょっとわかりづら過ぎるだろう、ということで、警告の代わりにエラーが出るようになりました(RuntimeError)。

それからついでに(?)、トップレベルでクラス変数を設定したり参照したりすることが禁止(RuntimeError)されました。

# 設定も禁止
@@foo = 1
#=> class variable access from toplevel (RuntimeError)

class Object
  @@bar = 2
end

# 参照も禁止
p @@bar
#=> class variable access from toplevel (RuntimeError)

正直よくわかんないんで、クラス変数はなるべく使わない方がいいと思いますねぇ(とくに、継承を絡めて)。Ractorで使えないし。

(ko1)

Numbered parameter への代入が警告から禁止に

  • Assigning to a numbered parameter is now a SyntaxError instead of a warning.

1.times{|i| p i}の代わりに 1.times{p _1}のように、ブロック仮引数の名前を暗黙の引数名で書けるというNumbered parameterという機能が Ruby 2.7 から導入されました(プロと読み解くRuby 2.7 NEWS - クックパッド開発者ブログ「Numbered parameters」)。

_1などを特別扱いするにあたって、既存のコードで_1などの名前を利用している例について議論があったのですが、Ruby 2.7の段階では「まぁ、そんなに使ってないだろうから、警告だけ出しとこ」、となりました。Ruby 3.0 では、利用している箇所を全部エラーにするようにしました。

_1 = 10# ローカル変数名として利用defa _1  # 仮引数名として利用enddef_1# メソッド名として利用end1.times{|_1| p _1} # ブロックの仮引数名として利用

この例では、

  • Ruby 2.6 までは、問題なく利用可能
  • Ruby 2.7 では、パース時にそれぞれ "warning: `_1' is reserved for numbered parameter; consider another name"という警告
  • Ruby 3.0 では、パース時にそれぞれ "_1 is reserved for numbered parameter"というエラーメッセージで構文解析失敗

となります。最後の例は、意味が変わらないので通ってもよさそうですが、まぁ自分で変数名として使う分には一律禁止になりました。

(ko1)

一行メソッド定義が追加された

endのないメソッド定義の新文法が導入されました。

defsquare(x) = x * x

p square(5) #=> 25

次のように書くのとまったく同じ意味です。

defsquare(x)
  x * x
end

こんな単純なメソッドのために3行も書かなくて良くなりました。嬉しいですよね??

無引数のメソッドも素直に書けますが、=の前にスペースが必須です。

defanswer = 42# OKdef answer=42   # NG: SyntaxError

なぜなら、setterメソッドとの区別ができないためです。また、setterメソッドは見た目がややこしくなることもあり、一行メソッド定義では書けなくなっています。

defset=(x) = @x = x
#=> setter method cannot be defined in an endless method definition

裏話

もともと私(遠藤)が提案した機能です。「Rubyの文法はendを多用するので、Ruby が終わりそうで縁起が悪い」というエイプリルフールネタでした。

しかしmatzはエイプリルフールネタということを理解した上で「細かい点を除けば真面目にポジティブ」といい、nobuが細かい問題を解決した *5ので、入ってしまいました。

真面目な話、上記の squareメソッドのように簡単なメソッド定義で 3 行も書くのは無駄なような気はしていました。Rubyのパッケージに含まれているコードで調べると、なんと 24% のメソッド定義が 3 行であることがわかりました。まあ、それでも新文法を導入するのは躊躇しそうなものですが、「一部のプログラムで便利な可能性がありそう」というmatzの直感により導入されました。

Rubyの新機能提案ではユースケースを強く求められますが、matzだけは例外です。直感に基づく決断は、言語仕様を委員会制で決める言語ではできないと思うので、面白いなあと思っています。

なお、一行メソッド定義は十分シンプルな場合に使われることを想定しているので、副作用を伴う式などは書かないほうがよいです。setterメソッドが定義できなくなっているのには、そういう理由もあります。

(mame)

式展開を含む文字列リテラルは、frozen-string-literal: trueで freeze しなくなった

  • Interpolated String literals are no longer frozen when # frozen-string-literal: true is used. [Feature #17104]

# frozen-string-literal: trueを指定しておくと、その後にくる文字列リテラルがすべて frozen な状態となります。

# frozen-string-literal: true

p "foo".frozen? #=> true

これは、式展開を含む文字列リテラル(埋め込み文字列)も frozen にしていました。

# frozen-string-literal: true

p "foo#{42}bar".frozen? #=> true

Ruby 3.0からは、埋め込み文字列については freeze せんでもいいだろ、ってことで freeze されなくなりました(この例では falseが出力される)。

frozen-string-literal: true自体は、最初から freeze しておくことで何度も同じ文字列を生成しなくても済む、ということを意図していたけれど、埋め込み文字列はそういうわけにもいかないので、毎回生成しています。つまり、この利点はないのにわざわざ freeze しなくてもいいだろう、という提案です。

私の記憶が確かなら、この埋め込み文字列の挙動は、埋め込み文字列でも文字列リテラルの一種なので、文字列リテラルが frozen である、という指定なら、埋め込み文字列も freeze しちゃったほうが理解はしやすいよね、という意図で freeze していたと思うのですが、Matz が、まぁ freeze せんでもいいよね、って言ったので freeze しないようになりました。

個人的には、freeze したままのほうが良かったなぁ。

(ko1)

静的解析基盤が導入された

  • A static analysis foundation is introduced. See "Static analysis" section in detail.
    • RBS is introduced. It is a type definition language for Ruby programs.
    • TypeProf is experimentally bundled. It is a type analysis tool for Ruby programs.

RBSとTypeProfが導入されました。この辺はすでに別記事を書いているのでご参照ください。

techlife.cookpad.com

techlife.cookpad.com

(mame)

コマンドラインオプション

--helpとページャ

  • When the environment variable RUBY_PAGER or PAGER is present and has non-empty value, and the standard input and output are tty, --help option shows the help message via the pager designated by the value. [Feature #16754]

細かい話です。環境変数 RUBY_PAGERPAGERが空でない文字列で設定されていれば、ruby --helpという詳細ヘルプを出力するとき、それをページャーとして利用して出力するようになりました。最近、git とかでも見る挙動ですね(git では環境変数が設定されてなくても lessを起動しちゃうけど)。

関係ないけど、ruby -hで簡易版ヘルプ、ruby --helpで詳細版ヘルプが出ます。

(ko1)

--backtrace-limitオプション

  • --backtrace-limit option limits the maximum length of backtrace. [Feature #8661]

例外発生時のバックトレースの最大行数を指定するオプションが導入されました。

deff6 = raisedeff5 = f6
deff4 = f5
deff3 = f4
deff2 = f3
deff1 = f2
f1

みたいなコードを次のように実行すると ... 3 levels...のように省略されます。

$ ruby --backtrace-limit=3 test.rb
-e:6:in `f6': unhandled exception
        from -e:5:in `f5'
        from -e:4:in `f4'
        from -e:3:in `f3'
         ... 3 levels...

これは、後述するバックトレースの再逆転に際して導入されました。

(mame)

■組み込みクラスのアップデート

Arrayのサブクラスのメソッドが、サブクラスではなく、Arrayクラスのオブジェクトを返すようになった

  • The following methods now return Array instances instead of subclass instances when called on subclass instances: [Bug #6087]
    • Array#drop
    • Array#drop_while
    • Array#flatten
    • Array#slice!
    • Array#slice / Array#[]
    • Array#take
    • Array#take_while
    • Array#uniq
    • Array#*

何を言ってるかと言うと、Arrayを継承したクラスを定義した場合の話です。

classMyArray< Arrayend

ary = MyArray.new([1, 2, 3]).drop(1)

p ary       #=> [2, 3]
p ary.class #=> MyArray  # 2.7
p ary.class #=> Array    # 3.0

上記の通り、MyArray#dropなどはMyArrayのインスタンスを返していました。 一方で、MyArray#rotateは2.7でもArrayのインスタンスを返していたので、一貫性がない状態になっていました。 3.0からは、このようなメソッドは一貫してArrayを返すようになりました。

この問題はRuby 2.0のころに指摘されましたが、「直したい気もするけど非互換が気になるので次のメジャーバージョンのときに考えよう(=忘れてしまおう)」という判断になっていました。が、たまたま今年思い出してしまったので、直すことになりました。9年越しの修正。

わりと直前(リリース2ヶ月前)に変わっているので、非互換問題がおきないといいなあ。個人的には、ArrayStringのようなコアクラスはあまり継承しないほうがいいと思います。

(mame)

Stringのサブクラスのメソッドが、サブクラスではなく、Stringクラスのオブジェクトを返すようになった

  • The following methods now return or yield String instances instead of subclass instances when called on subclass instances: [Bug #10845]
    • String#*
    • String#capitalize
    • String#center
    • String#chomp
    • String#chop
    • String#delete
    • String#delete_prefix
    • String#delete_suffix
    • String#downcase
    • String#dump
    • String#each_char
    • String#each_grapheme_cluster
    • String#each_line
    • String#gsub
    • String#ljust
    • String#lstrip
    • String#partition
    • String#reverse
    • String#rjust
    • String#rpartition
    • String#rstrip
    • String#scrub
    • String#slice!
    • String#slice / String#
    • String#split
    • String#squeeze
    • String#strip
    • String#sub
    • String#succ / String#next
    • String#swapcase
    • String#tr
    • String#tr_s
    • String#upcase

前項と同じ変更は文字列の方でも行われています。

なお、この変更で Rails の SafeBuffer クラスが動かなくなっていました。Rails の最新版では修正されています。

(mame)

Dir.globの結果がソートされるようになった

  • Dir.glob and Dir. now sort the results by default, and accept sort: keyword option. [Feature #8709]

そのままです。

# Rubyのパッケージディレクトリで実行するDir.glob("*.c") #=> ["marshal.c", "symbol.c", "regparse.c", "st.c", ...]  # 2.7Dir.glob("*.c") #=> ["addr2line.c", "array.c", "ast.c", "bignum.c", ...]  # 3.0

Ruby 2.7まではDir.globはファイルシステムに依存する順序でファイルを列挙していましたが、Ruby 3.0からはデフォルトでソートされるようになりました。もしソートしてほしくない場合は、Dir.glob("*.c", sort: false)としてください

ファイル列挙はO(n)でできるのに、ソートをするとO(n log n)になってしまう、ということで若干の躊躇がありましたが、現実的にはファイルアクセスに比べて文字列ソートは無視できるほど速いこと、また、Linuxのglob(3)もデフォルトでソートするらしいことが決め手となり、変更されました。

「globの結果はsortして使え」というRubocopのルールがあるらしいですが、Ruby 3.0からは無意味になるのでやめたほうが良さそうです。

(mame)

Windows のデフォルト外部エンコーディングが UTF-8 になった

  • Windows: Read ENV names and values as UTF-8 encoded Strings [Feature #12650]
  • Changed default for Encoding.default_external to UTF-8 on Windows [Feature #16604]

Windowsでは、ロケールによらずに、デフォルトの外部エンコーディング(-Eオプションが指定されないときの Encoding.default_external)が UTF-8 になりました。

> ruby -e 'p Encoding.default_external'
#<Encoding:UTF-8>

> ruby -Ecp932 -e 'p Encoding.default_external'
#<Encoding:Windows-31J>

また、環境変数の値は、ロケールによらず UTF-8 になりました。

> ruby -e 'p ENV["PATH"].encoding'
#<Encoding:UTF-8>

> ruby -Ecp932 -e 'p ENV["PATH"].encoding'
#<Encoding:UTF-8>

(ko1)

IBM720 というエンコーディングの追加

IBM720 と、そのエイリアス CP720 というエンコーディングが追加されたそうです。

(ko1)

Fiber scheduler が導入された

  • Fiber
    • Fiber.new(blocking: true/false) allows you to create non-blocking execution contexts. [Feature #16786]
    • Fiber#blocking? tells whether the fiber is non-blocking. [Feature #16786]
    • Introduce Fiber.set_scheduler for intercepting blocking operations and Fiber.scheduler for accessing the current scheduler. See doc/scheduler.md for more details. [Feature #16786]
  • ConditionVariable
    • ConditionVariable#wait may now invoke the block/unblock scheduler hooks in a non-blocking context. [Feature #16786]
  • IO
    • IO#nonblock? now defaults to true. [Feature #16786]

    • IO#wait_readable, IO#wait_writable, IO#read, IO#write and other related methods (e.g. IO#puts, IO#gets) may invoke the scheduler hook #io_wait(io, events, timeout) in a non-blocking execution context. [Feature #16786]

  • Kernel
    • Kernel.sleep invokes the scheduler hook #kernel_sleep(...) in a non-blocking execution context. [Feature #16786]
  • Mutex
    • Mutex is now acquired per-Fiber instead of per-Thread. This change should be compatible for essentially all usages and avoids blocking when using a scheduler. [Feature #16792]
  • Queue / SizedQueue
    • Queue#pop, SizedQueue#push and related methods may now invoke the block/unblock scheduler hooks in a non-blocking context. [Feature #16786]
  • Thread
    • Thread#join invokes the scheduler hooks block/unblock in a non-blocking execution context. [Feature #16786]

I/O 処理など、実行するとブロックする処理では、それを待っている間に他の独立した処理を行うと効率が良くなることが知られています。これまでは、スレッドを使うか、EventMachine などを使って自分で組み立てていく必要がありました(いわゆる、ノンブロッキングなプログラミング)。これを、I/O などでブロックしたら、他の独立した Fiber を実行するようなスケジューラを、Ruby で記述するための仕組みが Fiber scheduler です。

機能の紹介

Fiber scheduler によって、I/O などの、待ちを多く含んだ大量の処理を並行に行わなければならない用途で、Fiber を使って、スレッドよりも軽量に扱うことができます。このために、イッパイ変更が並んでいますね。

現在の MRI のスレッドは、1つのRubyスレッドに対して1つのOSスレッドを作ります。そのため、生成が重い、上限がけっこうすぐくる、という問題があります。

$ time ruby27 -ve '(1..).each{|i|begin; Thread.new{sleep}; rescue; p [$!, i]; exit; end}'
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-linux]
[#<ThreadError: can't create Thread: Resource temporarily unavailable>, 32627]

real    0m7.305s
user    0m6.726s
sys     0m20.182s

$ time ruby30 -ve '(1..).each{|i|begin; Thread.new{sleep}; rescue; p [$!, i]; exit; end}'
ruby 3.0.0dev (2020-12-21T04:25:03Z master 74a7877836) [x86_64-linux]
[#<ThreadError: can't create Thread: Resource temporarily unavailable>, 32627]

real    0m14.677s
user    0m5.722s
sys     0m10.415s

このシステムだと、3万個程度で上限がきます(OSのプロセス数の上限)。あれ、Ruby 3で時間が倍くらいになってますね...なんでだろ。

Fiber ですと、こんな感じ。

$ time ruby27 -ve 'fs=[]; (1..).each{|i| begin; fs << (f = Fiber.new{Fiber.yield}); f.resume; rescue; p [$!, i]; exit; end }'
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-linux]
[#<FiberError: can't set a guard page: Cannot allocate memory>, 31745]

real    0m0.452s
user    0m0.244s
sys     0m0.208s

$ time ruby30 -ve 'fs=[]; (1..).each{|i| begin; fs << (f = Fiber.new{Fiber.yield}); f.resume; rescue; p [$!, i]; exit; end }'
ruby 3.0.0dev (2020-12-21T04:25:03Z master 74a7877836) [x86_64-linux]
[#<FiberError: can't set a guard page: Cannot allocate memory>, 31745]

real    0m0.497s
user    0m0.277s
sys     0m0.220s

あれ、数は3万個程度ですね。これは、メモリプロテクションのためにmmapを使っているのですが、この生成上限にあたっているのではないかと思います(Cannot allocate memory とあるのがそれ)。数はおいといて、生成速度を比べると、1桁違います。あと、ちゃんと書いていないですが、メモリ消費も Fiber のほうが少ないです。

このへんが、Fiber は軽量といっている理由になります。

Fiber もスレッドも、どちらも並行処理(たとえば、独立したIO処理、典型的にはウェブリクエストをうけてレスポンスする処理)を行うのは同じですが、スレッドはテキトーなタイミング(処理系依存のタイミングともいう)勝手に切り替わるのに対し、Fiber は自分で "resume/yield"などを利用して切り替えを行う必要があります。これは、勝手に切り替わらない、という Fiber のメリットでもあるのですが、Fiber を用いて IO 処理をやっていると、readなどでブロックしてしまうと切り替えるタイミングを逸してしまうので(他の実行可能な Fiber に処理をうつすことができないので)、readなどブロックするような処理を避けてプログラミングする必要がありました。

Fiber scheduler は、典型的なブロッキングをするような処理(readとか)が起こったら、ユーザーレベル(つまり、Ruby)で記述されたハンドラに飛ばして、自分で non-blocking IO 処理を書いて他の Fiber に処理をうつす、といったことを自分で書くことができるようにする仕組みです。このハンドラを定義するオブジェクトを、ここではスケジューラーと呼んでいます。

現在実行中のスレッドのスケジューラを設定するには、Fbier.set_scheduler(scheduler_object)のように指定します(スレッドローカルな属性です)。

ブロッキングするような処理が起きるとスケジューラーのハンドラが起動されます。現在次のような処理で、スケジューラを呼び出します。

  • ConditionVariable#wait
  • IO#wait_readable, IO#wait_writable, IO#read, IO#write and other related methods (e.g. IO#puts, IO#gets)
  • Kernel.sleep
  • Mutex#lock and related methods
  • Queue#pop, SizedQueue#push and related methods
  • Thread#join

どのメソッドが、どのようなスケジューラーのフックを呼ぶかどうかは、詳細なので立ち入りません(詳細は ruby/fiber.md at master ・ ruby/rubyをご覧ください)。

少し試してみましょう。sleepすると、スケジューラーのハンドラが呼ばれるので確認してみます。method_missingだけを定義したスケジューラを用意して、どのようなフックが呼ばれるか確認してみましょう。

classMySchedulerdefmethod_missing *args
    p args
  endendFiber.set_scheduler(MyScheduler.new)

Fiber.new{
  sleep(10)
}.resume

#=> [:kernel_sleep, 10]

MyScheduler#kernel_sleep(10)というメソッドが呼ばれていることがわかります。スケジューラーは、別の実行可能な Fiber に処理を移してもいいですし、実際に Kernel#sleep(10)を呼びだしてスリープしても良いわけです。

この機能の導入に際し、次のような変更が入っています。

  • Mutex が Fiber local になるといった変更がありました。つまり、Fiber scheduler を利用するプログラムは、スレッドプログラミングと同様に、注意深くロックを行うなどする必要があります。
  • IO は基本的に non-blocking モードになりました(が、普通に使う分には何も変わりません。IO#read してもブロックするように見えます。システム側の設定の話になります)
  • Fiber.new(blocking: false)というパラメータが増えました。true だと、スケジューラが呼ばれなくなります。root fiber (スレッドとセットで生成される Fiber)は、true になっています。
  • スレッド終了時、スケジューラがあり、そのスケジューラに #closeが定義されていれば、それが呼ばれることになりました。

難しそうな機能ですが、実際これを直接使うのは、多分とても難しいので、このインターフェースを直接使うのはあまりおすすめしません。これを利用して非同期 IO を実現する async gem(仕様提案者の Samuel さんが作っているライブラリ)などを利用するといいと思います。

この機能(を使ってスケジューラを提供する gem)を使うべきかどうかですが、既存のプログラムを直接動かせることを目的としているため、いろいろなハックが入っており、動かすことができる可能性は高いです。そして、スレッドの代わりに Fiber を用いることで、高い並行性を達成することができるかもしれません。ただ、これまでのプログラミングモデルと微妙に異なる部分がソコソコあるので、はまると大変だと思います。なので、小さなプログラムから試していくとよいのではないかと思います。目的に合致すると、良いものだと思います。

この新機能をまとめると、Ruby レベルで Fiber を切り替えて動かすスケジューラーを記述するための機能ということができます。この機能により、たとえば大量のウェブリクエストを同時にさばかなくてはならないという、C10K 問題が、Ruby で問題なく処理することができるようになると期待されます。

機能についての個人的な意見

この機能を導入するため、非常に多くの議論がなされました。もっとも本質的には、このスケジューラーを Ruby ユーザーに記述させることができる、という点です。

利点としては、同じスケジューラ実装を、このインターフェースを備えた MRI 以外の実装でも共有できるというものです。また、プログラムに適したスケジューラを自分で書くことができるというのも利点の一つだと思います(90年代のマイクロカーネル研究を思い出します)。

が、個人的にはRubyでかけないようにしたほうが良かったんじゃないかなと思っています。スケジューラが備えるべきインターフェースが何であるか、非常に難しい問題で、現在は結構アドホックな印象を受けます。また、ブロッキングするかもしれない処理には様々なものがあり、Ruby だけでなんとかできるものばかりではありません。というわけで、この方針で進むのは難しいんじゃないかなぁと思っています。最初は、I/O 限定で切り替わる限定的なスケジューラという話だったので、限定的なシチュエーションにおいては良さそうと思ったんですが、汎用的なスケジューラにむかっているので、大丈夫かなぁと少し不安に思っています。

将来的には、スレッドのバックエンドを Fiber が用いている context を用いて良い感じにスケジューリングする(いわゆるM:Nモデル)ものを作って、スレッド自体が Fiber scheduler と同等の性能になるようにしていくと良いのではないかなぁと思っています(基本的な設計はできているので、あとは作るだけ! いつできるだろう)。

(ko1)

Fiberごとのバックトレース情報が取れる Fiber#backtraceFiber#backtrace_locationsが導入された

  • Fiber#backtrace & Fiber#backtrace_locations provide per-fiber backtrace. [Feature #16815]

Thread#backtraceは、そのスレッドが現在実行中のバックトレースを出す機能でしたが、これを Fiber ごとに得る Fiber#backtraceFiber#backtrace_locationsが導入されました。

deffoo = Fiber.yield
defbar = Fiber.yield

f1 = Fiber.new{ foo }; f1.resume
f2 = Fiber.new{ bar }; f2.resume

pp f1.backtrace
#=> ["t.rb:1:in `yield'", "t.rb:1:in `foo'", "t.rb:4:in `block in <main>'"]
pp f2.backtrace
#=> ["t.rb:2:in `yield'", "t.rb:2:in `bar'", "t.rb:5:in `block in <main>'"]

これも、Fiber scheduler で(というか、スケジューラのデバッグで)便利に使うための機能ですね。

(ko1)

Fiber#transferの制限が緩和された

  • The limitation of Fiber#transfer is relaxed. [Bug #17221]

これまで、Fiber#resume/yieldFiber#transferを混ぜることは禁止していたのですが(この Fiber は resume/yield、この Fiber は transfer 専用、のように使ってほしかった)、この制限を緩和して、良い感じに使えるようにしました。詳細はチケットを見てください。簡単にいうと、resume/yield中の Fiber には transfer できない、transfer している Fiber には resume できないなどという制約だけでよさそうだ、というものです(本当はもう少し詳細)。

もともと、「なんかよくわからんけど resume/yield の関係が壊れるから transfer 混ぜられない」というのが、混ぜるの禁止にしていた理由なんですが、きちんと考えると、混ぜてはいけない理由がはっきりしてきたので、よく整理できたということです。

Fiber scheduler まわりでこの制限を緩和してほしい、というリクエストがあり、遠藤さんと延々と議論していたとき、「これで整理できるんじゃない?」というのがふってきて、二人で半日くらい議論して条件を洗い出すことができました。10年くらい気になっていた問題がきれいに解決して、とても嬉しい改善です(でも、影響はほとんどない)。

(ko1)

compaction GC を自動でやってくれる GC.auto_compact = trueが追加された

  • GC.auto_compact= and GC.auto_compact have been added to control when compaction runs. Setting auto_compact= to true will cause compaction to occur during major collections. At the moment, compaction adds significant overhead to major collections, so please test first! [Feature #17176]

Ruby 2.7 から、ヒープの中身をコンパクションする GC.compactが導入されました。これは、手動で好きなタイミングで行おう、というものですが、これを major GC(世代別GC で、時々行うヒープ全体を対象にする GC。遅い)のときに行おうというものです。

GC.compactについては、開発者の Aaron さんが解説する Rubyconf 2020 の動画がアップロードされていました: Automatic GC Compaction in MRI - Aaron Patterson - YouTube

GC.auto_compact = trueとすることで、major GC が起こるとコンパクションも実行してくれます。そのため、定期的にメモリの掃除をしてくれることになり、メモリ効率の向上、および局所性向上による性能改善が期待できます。が、ここにも書いてある通り、コンパクション自体が結構なオーバヘッドになるので、自分のアプリで効くかどうか確認してみるといいと思います。デフォルトは、そういうことで false です。

テクニカルには read-barrier とか導入していてマジかって感じです。色々大変そうで避けていたんですが、ちゃんと動くんだなぁ。

正直、まだ実装がこなれていないような気がするので(拡張ライブラリあたりが怪しいです)、みんながすぐにこれを使うってのには、ならない気がします(はまらなければ、使ってもいいと思います)。

(ko1)

Hash#exceptが導入された

  • Hash#except has been added, which returns a hash excluding the given keys and their values. [Feature #15822]
  • ENV.except has been added, which returns a hash excluding the given keys and their values. [Feature #15822]

ActiveSupportにあるHash#exceptが組み込みになりました。

{ a: 1, b: 2, c: 3 }.except(:b) #=> {:a=>1, :c=>3}

ENV#exceptも同様に追加されています。

要望は以前からありましたが、「名前がしっくり来ない、組み込みにするほどのユースケースがあるのかよくわからない」ということで先送りになっていました。excludeのような名前も検討されたようですが、結局ActiveSupportに従うことになりました。なお、exceptは「~を除いて」という前置詞しか知りませんでしたが、「除外する」という動詞の用法もあるようです。

(mame)

Hash#transform_keysがハッシュを受け取るように

  • Hash#transform_keys now accepts a hash that maps keys to new keys. [Feature #16274]

ハッシュのキーを変換するHash#transform_keysが、変換の対応をHashで示せるようになりました。

# ↓新機能
{ a: 1, b: 2, c: 3 }.transform_keys({ a: :A })              #=> { A: 1, b: 2, c: 3 }# ↓従来の機能で同じことをやるとしたら
{ a: 1, b: 2, c: 3 }.transform_keys {|k| k == :a ? :A : k } #=> { A: 1, b: 2, c: 3 }

JSONの変換のようなときに便利のような気はします。

(mame)

Kernel#clonefreeze: trueとしたら freeze されるようになった

  • Kernel#clone when called with freeze: false keyword will call #initialize_clone with the freeze: false keyword. [Bug #14266]
  • Kernel#clone when called with freeze: true keyword will call #initialize_clone with the freeze: true keyword, and will return a frozen copy even if the receiver is unfrozen. [Feature #16175]

2つの変更が語られています。いずれも細かい内容です。

まず1つめの変更について。Kernel#cloneはオブジェクトを複製するメソッドですが、freezeされたオブジェクトをcloneしたらfreezeされた複製を返します。

ary = [1, 2, 3].freeze
p ary.clone.frozen? #=> true

しかし、cloneでfreeze状態は保存してほしくないケースがあり、Ruby 2.4でfreeze: falseというキーワード引数が導入されました。

ary = [1, 2, 3].freeze
p ary.clone(freeze: false).frozen? #=> false

このとき、freeze: trueというのは「従来どおり、freeze状態を保存する」という意味になりました。よって、元のオブジェクトがfreezeされていない場合、freezeされていない複製が返されていました。

ary = [1, 2, 3].freeze
p ary.clone(freeze: true).frozen? #=> true

s = "str"# freeze されていない
p s.clone(freeze: true).frozen? #=> false

が、「freeze: trueと書いてあるのにfreezeされていない複製を返すのはバグでは?」という指摘が来たので、そうするようになりました。

s = "str"# freeze されていない
p s.clone(freeze: true).frozen? #=> Ruby 3.0 では true

なんだかレトロニムみたいな話ですね。

もうひとつの話の変更をかいつまんで。これはSetクラスを clone(freeze: false)したときに起きた問題に関する話です。Setクラスは内部的にHashで集合を表現しているのですが、Set#freezeすると内部のHashもfreezeします。よって、freezeしたSetインスタンスをclone(freeze: false)で複製しても、内部的なHashはfreezeされたままになるという問題がありました。そこで、clone時に呼ばれるinitialize_cloneメソッドにfreeze:キーワードを渡すようにして、内部的なHashのcloneにfreeze:キーワードを渡せるように変更されました。

(mame)

eval内のファイル名や行番号をbindingから継承しないようになった

  • Kernel#eval when called with two arguments will use "(eval)" for __FILE__ and 1 for __LINE__ in the evaluated code. [Bug #4352]
  • Binding#eval when called with one arguments will use "(eval)" for __FILE__ and 1 for __LINE__ in the evaluated code. [Bug #4352] [Bug #17419]

evalの中での__FILE____LINE__が微妙に変わります。次の例を見てください。

1: # eval-test.rb2: b = binding
3:
4: eval("p __LINE__", b) #=> Ruby 2.7では警告とともに2、Ruby 3.0では1

このコードは、Ruby 2.7で実行すると、次のように(警告とともに)2が出ていました。

$ ruby eval-test.rb
eval-test.rb:2: warning: __LINE__ in eval may not return location in binding; use Binding#source_location instead
eval-test.rb:4: warning: in `eval'
2

Ruby 2.7までのevalはデフォルトで、渡されたbindingのファイル名や行番号を継承していました。ここで表示される2は、bindingが作られた行番号です。

しかしこれは時として混乱の元でした。次の例を見てください。

1: b = binding
2:
3: eval(<<END, b)
4:5: raise6: END

これをRuby 2.7で実行すると、次のようなバックトレースが出ます。

$ ruby2.7 eval-test.rb
Traceback (most recent call last):
        2: from eval-test.rb:3:in `<main>'
        1: from eval-test.rb:3:in `eval'
eval-test.rb:2:in `<main>': unhandled exception

eval-test.rbの2行目で例外が出たことになっていますが、その行は空行です。謎でしかない。これは、bindingのファイル名と行番号を暗黙的に引き継いだ結果です。

Ruby 3.0からは、この引き継ぎを行わないようになりました。

$ ruby3.0 eval-test.rb
(eval):2:in `<main>': unhandled exception
        from eval-test.rb:3:in `eval'
        from eval-test.rb:3:in `<main>'

紛らわしい結果がなくなりました。

なお、もし従来どおりの挙動にしたい場合は、eval("p __LINE__", b, *b.source_location)のようにBinding#source_locationを使ってください。また、Binding#evalも同様に変わっています。b.eval(src)b.eval(src, *b.source_location)としてください。

(mame)

Kernel#lambdaにブロック引数を渡したら警告を出すようになった

  • Kernel#lambda now warns if called without a literal block. [Feature #15973]

どうやら、lambda(&pr)のように渡すと、Procオブジェクトを lambda に変換してくれる、という誤解があったようで、いくつかの場所で実際に使われていました。が、実はそんな機能は無いので、lambda{ ... }のようにブロックを指定するのではなく、lambda(&pr)のように Proc を渡した場合には警告を出すようになりました。

lambda(&proc{})
#=> warning: lambda without a literal block is deprecated; use the proc without lambda instead

将来的にはエラーになるのかなぁ。

(ko1)

後から行った Module#includeが無視されなくなった

  • Module#include and Module#prepend now affect classes and modules that have already included or prepended the receiver, mirroring the behavior if the arguments were included in the receiver before the other modules and classes included or prepended the receiver. [Feature #9573]

モジュールのincludeの順序によっては、includeが無視されるように見えるケースがありました。それが修正されたという内容です。

# モジュールを 2 つ作るmoduleM1; endmoduleM2; end# クラス C は M1 を include するclassCincludeM1end# M1 が後から M2 を include するmoduleM1includeM2end# C のスーパークラスに M2 が入っていなかったが、3.0 から入るようになった
p C.ancestors #=> [C, M1, Object, Kernel, BasicObject]      # 2.7
p C.ancestors #=> [C, M1, M2, Object, Kernel, BasicObject]  # 3.0

このように、あとから M2 を include しているのが無視されていました。無視されていたのは実装の都合でしたが、気合で修正されました。

個人的なオススメは、このように、あとからモジュールを include するようなことはしないことです。あとから include/prepend は他にも問題があることが知られています(include の順序によっては、ancestors に同じモジュールが複数回現れてしまうとか、prepend を絡めると意味がわからなくなるとか)。

(mame)

private attr_reader :fooと書けるようになった

  • Module#public, Module#protected, Module#private, Module#public_class_method, Module#private_class_method, toplevel "private" and "public" methods now accept single array argument with a list of method names. [Feature #17314]

  • Module#attr_accessor, Module#attr_reader, Module#attr_writer and Module#attr methods now return an array of defined method names as symbols. [Feature #17314]

  • Module#alias_method now returns the defined alias as a symbol. [Feature #17314]

表題のとおり、private な attr_reader などをシンプルに書ける様になりました。

具体的な変更としては、(1) attr_reader や attr_accessor が定義したメソッドのシンボルの配列を返すようになった、(2) public や private が配列を引数に受け取れるようになった、です。

classFoo# (1) attr_reader や attr_accessor が定義したメソッドのシンボルの配列を返すようになったattr_accessor:foo, :bar#=> [:foo, :foo=, :bar, :bar=]# (2) public や private が配列を引数に受け取れるようになったprivate [:foo, :foo=, :bar, :bar=]

  # 2 つを組み合わせると、次のように書いても同じ意味になるprivateattr_accessor:foo, :barend

また、alias_methodメソッドも定義されたメソッドのシンボルを返すようになりました。これも private alias_method :foo, :barと書けることを狙ったものです。

(mame)

Proc の等価判定(Proc#==, Proc#eql?)が少し緩和された

  • Proc#== and Proc#eql? are now defined and will return true for separate Proc instances if the procs were created from the same block. [Feature #14267]

これまで、Proc#==は、同じオブジェクトかどうかで判断していました(というか、Proc#==はなくて、Object#==が使われていた)。が、この制限を緩和し、同じメソッド呼び出しのブロックパラメータで作られたProcは、Proc#==でtrueを返すようになりました。正直、これを読んでも意味わからないと思うのですが、これが関係するところはマレだと思うので、あまり気にしなくていいと思います。基本的には、Proc#==なんて使わないでください。また、Hash のキーにするべきでもないでしょう。

一応、ちゃんと書いておきますと、これは Ruby 2.5 で導入された lazy proc allocation(Ruby 2.5 の改善を自慢したい - クックパッド開発者ブログ「Lazy Proc allocation によるブロックパラメータを用いたブロック渡しの高速化」 )の非互換を解消するためのものです。

defbar&b
  b
enddeffoo(&b1)
  b2 = bar(&b1)
  p b1 == b2
  p b1.equal? b2
end

foo{}

#=>              b1 == b2   b1.equal? b2# Ruby 2.4 以前  true       true# Ruby 2.5-2.7   false      false# Ruby 3.0       true       false

Ruby 2.4 では、b1Procを生成し、それをbar(&b1)として渡しても、すでにProcが生成されているので、単にその Proc を渡すだけでした。そのため、b1.equal? b2は true でした。

しかし、Lazy Proc Allocation によって、Proc の生成が遅延されてしまうので、bar で初めて Proc を作り、そしてその情報は foo 側には渡らないので foo でも新たに Proc を作り、b1 == b2b1.equal? b2ともに false になってしまっていたのでした。この挙動自体は非互換として当時から認識していたのですが、「まー誰も困らんやろ」と思っていたら、なんか RSpec で踏んだらしいんですよね。

ということで、どうするかと思っていたら、Proc#==を変えればいいのでは(違うオブジェクトでも、こういうケースなら true になるような Proc#==にすれば良いのでは)という素晴らしい解決策を得て、解決したのでした。

(ko1)

Ractor による並列並行プログラミングのサポート

  • New class added to enable parallel execution. See doc/ractor.md for more details.

Rubyで簡単に並列並行プログラミングを行うための Ractor が導入されました。

まだ、実験的機能(仕様が不安定、実装が不安定)なので、最初に Ractor.newで Ractor を生成するとき、警告が出るようになっています。

細かい仕様については、別の資料をご参考にしてください。下記に、私の発表した資料へのリンクを掲載しておきます。

(ko1)

Random::DEFAULT が非推奨に

  • Random::DEFAULT now refers to the Random class instead of being a Random instance, so it can work with Ractor. [Feature #17322]

  • Random::DEFAULT is deprecated since its value is now confusing and it is no longer global, use Kernel.rand/Random.rand directly, or create a Random instance with Random.new instead. [Feature #17351]

デフォルトの乱数生成器 Random::DEFAULTが非推奨になりました。代わりに Randomクラスオブジェクトが利用できます。また、Random::DEFAULTは、Randomクラスのインスタンスだったのが、Randomクラス自体が返るようになりました。

p Random::DEFAULT == Random#=> trueRandom::DEFAULT.srand(0)    # seed を指定して
p Random::DEFAULT.rand(10)  # => 5
p Random::DEFAULT.bytes(3) #=> "\xC0\xCC!"# Random クラスで同じことができるRandom.srand(0)
p Random.rand(10) #=> 5
p Random.bytes(3) #=> "\xC0\xCC!"

非推奨になったので、-w 付きで実行しているときに Random::DEFAULTを参照すると警告が出るようになりました。

$ ruby -w -e 'p Random::DEFAULT'
-e:1: warning: constant Random::DEFAULT is deprecated
Random

もともと、Randomクラスには randなどのメソッドがくっついていました。これらのメソッドは、Random::DEFAULTと同じ乱数生成器を参照して実行します。そのため、Random::DEFAULTの代わりに Randomを用いれば、だいたいうまくいくようになっています。ただ、クラスになったので、Marshalなどに対応しなくなったのが若干の非互換になっています(一応、公開されている gem を調べた限り、そのようなことをしているものはありませんでした)。

なんで Randomクラスが特異メソッドとして randなどを持っているのかわからなかったのですが(私は初めて知った)、聞いてみると、デフォルトの乱数生成器を用いるメソッドを置く場所が欲しかった、ということでした(Random.rand()などがついたのは 1.9.2、Random::DEFAULTができたのは 1.9.3で、ちょっと後なんですね)。すでに Kernel#randなどはありましたが、Rnadom#bytesなどは、確かに置く場所が困りそうでした。

この変更の背景をご紹介します。

Random::DEFAULTは、これまで Kernel#randなどが利用する疑似乱数生成器をさしていました。つまり、rand(10)などを実行すると、この Random::DEFAULTの生成器の乱数を消費していたわけです。

しかし、Ractor が入ると、同時に複数の Ractor が生成器を利用してしまうため、生成器の実装をスレッドセーフにする必要がありました。ただ、その対応は結構大変だなぁ、というので、生成器は Ractor ローカルとするのが良さそう、となりました(つまり、乱数生成器は Ractor をまたいで共有されない)。

現在の定義だと、Kernel#randなどは、唯一存在する Random::DEFAULTを乱数生成器として利用する、という定義なので、これがネックになりました。Ractor ごとに持つためには、Random::DEFAULTを使う、というわけにはいかないものですから。そこで、Random::DEFUALTの意味を変更する必要が出てきました。候補としては、次の二つです。

  • (1) Random::DEFAULTに特殊な Randomインスタンスを設定して、それは Ractor local なデフォルトの乱数生成器を参照する
  • (2) Rnadom::DEFAULTは、なぜか Randomインスタンスがもつメソッドを実装しているので、Random::DEFAULT = Randomという定義にしてしまい、Random.randなどは Ractor local な乱数生成器を参照する、という意味に変更する

というわけで、実装の面倒が少ない (2) を選ぶことにしました。特異メソッドなら、Ractor local なものを参照する、という特別な意味があります、と言い張っても受け入れらそうだし。

あまり、乱数生成器を意識することはないのではないかと思うのですが、ちょっと変わっているということはご承知おきください。

(ko1)

Symbol#to_procが lambda を返すようになった

Symbol#to_procで生成する Proc が lambda となるようになりました。

Proc は proc{}/Proc.new{}およびメソッドのブロック仮引数でうけて生成する場合と、lambda{}->{}で生成する場合で挙動が異なります。ここでは、前者をproc、後者を lambda と呼ぶことにします。Proc#inspectで、lambda の場合 lambdaと出ます。

p ->{} #=> #<Proc:0x00000280db845220 t.rb:1 (lambda)>

proc と lambda のもっともわかりやすい違いは、引数の数のチェック機能でしょう。proc は曖昧に解釈するので、渡された実引数の数と仮引数の数が違っても、何もなくなんとく良い感じに(この良い感じがバグというか混乱を呼んでいるんですが...)解釈します。lambda は違うとエラーになります。

proc{|a| p a}.call(1, 2)
#=> 1
->a{p a}.call(1, 2)
#=> `block in <main>': wrong number of arguments (given 2, expected 1) (ArgumentError)

で、Symbol#to_procで作ったProcオブジェクトは、lambda っぽい挙動になるのに、inspect しても lambda って出てこないのは変だよね、ということで、lambda になりました。

pr = :object_id.to_proc
p pr
#=> #<Proc:0x00000236441f1270(&:object_id) (lambda)> # ruby 3.0 から (lambda) がついた

p pr.call(1) # 1.object_id と同じ#=> 1.object_id の結果 3 が返る

p pr.call(1, 2) # 1.object_id(2) と同じ#=> in `object_id': wrong number of arguments (given 1, expected 0) (ArgumentError)

(ko1)

シンボルの名前に対応する文字列が返る Symbol#nameの追加

  • Symbol#name has been added, which returns the name of the symbol if it is named. The returned string is frozen. [Feature #16150]

:sym.name #=> "sym"となるような Symbol#nameが導入されました。でも、String#to_sでも同じような挙動だったんですよね。何が違うかというと、返ってくる文字列が frozen になったのでした。frozen になっているから、重複排除、つまり何回読んでも同じ文字列オブジェクトを返すことが可能になりました。みんな、文字列生成を排除したくてしょうがないんですね。

もともと、Symbol#to_sを freeze にしてしまおう、って提案があって、チャレンジされてたんですが、非互換がつらいということで reject になりました。なんか別の方法がないか、ということで、Symbol#nameという別案が用意されました。これ、RubyKaigi takeout 2020 のあとの zoom で、なんか盛り上がって入れたんでしたっけかね?

(ko1)

デッドロック検知を無効にするオプションが導入された

  • Thread.ignore_deadlock accessor has been added for disabling the default deadlock detection, allowing the use of signal handlers to break deadlock. [Bug #13768]

スレッドでロックをお互い待ってしまってにっちもさっちもいかなくなるような場合、デッドロックと呼ばれます。Ruby には簡単なデッドロック検出機能があり、すべてではないですが、デッドロックになったときに例外を発生させ、(多分バグでしょうから)デバッグに有用な情報を出力します。

q = Queue.new

Thread.new{
  q.pop
}
q.pop

__END__t.rb:6:in `pop': No live threads left. Deadlock? (fatal)2 threads, 2 sleeps current:0x000001b07776b280 main thread:0x000001b0721a80b0* #<Thread:0x000001b07221ca68 sleep_forever>   rb_thread_t:0x000001b0721a80b0 native:0x0000000000000128 int:0* #<Thread:0x000001b0777790d8 t.rb:3 sleep_forever>   rb_thread_t:0x000001b07776b280 native:0x0000000000000184 int:0   from t.rb:6:in `<main>'

この例では、1つの Queue をすべてのスレッドが待っているので、誰も起こすことは無いだろうということで、デッドロックと認定し、エラーを出力しています。

さて、世の中にはシグナルの到着により、スレッド実行を復帰させたい、というプログラムがあります。

q = Queue.new

trap(:INT){ q << 1 }
q.pop

__END__t.rb:4:in `pop': No live threads left. Deadlock? (fatal)1 threads, 1 sleeps current:0x0000019cecf68630 main thread:0x0000019cecf68630* #<Thread:0x0000019cecfdca98 sleep_forever>   rb_thread_t:0x0000019cecf68630 native:0x0000000000000128 int:0   from t.rb:4:in `<main>'

このような場合でも、trap の存在に気づかず、デッドロックと判定してしまいます。でも、プログラマー的にはデッドロックじゃないので何とかしてほしい、というリクエストが来ていました。

いろいろ議論したのですが(trap が1つでも設定されていれば deadlock 検知をスキップするとか、いやでもそれがプログラムの実行を再開するとは限らないしな、とか)、結局「デッドロック検知自体をオフにする」機能でいいのではないか、となりました。それが Thread.ignore_deadlock = trueです。

Thread.ignore_deadlock = true
q = Queue.new

trap(:INT){ q << 1 }
q.pop                 # Ctrl-C で終了する

まぁ、あんまり難しいことしないほうがいいですよ、シグナルとか難しい。

(ko1)

警告周りのメソッドが categoryキーワードを受け取るようになった

  • Warning#warn now supports a category keyword argument. [Feature #17122]

Ruby 2.7から、警告にカテゴリという概念が導入されました。いまのところ:deprecated:experimentalと「なし」という3種類のカテゴリだけです。 :deprecated:experimentalのカテゴリに属す警告はRubyのインタプリタ内部でしか作れなかったのですが、ユーザもカテゴリに属す警告を出せるようになりました。

warn("foo is deprecated", category: :deprecated)

上の警告は、Warning[:deprecated] = trueを有効にしていないと表示されません。

また、警告発生をフックするメソッドWarning.warnがあるのですが、これにもcategoryの情報が渡されるようになりました。

defWarning.warn(msg, category: nil)
  p [msg, category]
end

warn("foo is deprecated", category: :deprecated)
  #=> ["foo is deprecated", :deprecated]

(mame)

■標準ライブラリのアップデート

ライブラリも、いろいろアップデートしました。NEWS にいくつか載っていますが、今回は調べるのが面倒なので、スキップします。

  • BigDecimal

    • Update to BigDecimal 3.0.0

    • This version is Ractor compatible.

  • Bundler

    • Update to Bundler 2.2.3
  • CGI

    • Update to 0.2.0

    • This version is Ractor compatible.

  • CSV

    • Update to CSV 3.1.9
  • Date

    • Update to Date 3.1.1

    • This version is Ractor compatible.

  • Digest

    • Update to Digest 3.0.0

    • This version is Ractor compatible.

  • Etc

    • Update to Etc 1.2.0

    • This version is Ractor compatible.

  • Fiddle

    • Update to Fiddle 1.0.5
  • IRB

    • Update to IRB 1.2.6
  • JSON

    • Update to JSON 2.5.0

    • This version is Ractor compatible.

  • Set

    • Update to set 1.0.0

    • SortedSet has been removed for dependency and performance reasons.

    • Set#join is added as a shorthand for .to_a.join.

    • Set#<=> is added.

  • Socket

  • Net::HTTP

    • Net::HTTP#verify_hostname= and Net::HTTP#verify_hostname have been added to skip hostname verification. [Feature #16555]

    • Net::HTTP.get, Net::HTTP.get_response, and Net::HTTP.get_print can take the request headers as a Hash in the second argument when the first argument is a URI. [Feature #16686]

  • Net::SMTP

    • Add SNI support.

    • Net::SMTP.start arguments are keyword arguments.

    • TLS should not check the host name by default.

  • OpenStruct

    • Initialization is no longer lazy. [Bug #12136]

    • Builtin methods can now be overridden safely. [Bug #15409]

    • Implementation uses only methods ending with !.

    • Ractor compatible.

    • Improved support for YAML. [Bug #8382]

    • Use officially discouraged. Read OpenStruct@Caveats section.

  • Pathname

    • Ractor compatible.
  • Psych

    • Update to Psych 3.3.0

    • This version is Ractor compatible.

  • Reline

    • Update to Reline 0.1.5
  • RubyGems

    • Update to RubyGems 3.2.3
  • StringIO

    • Update to StringIO 3.0.0

    • This version is Ractor compatible.

  • StringScanner

    • Update to StringScanner 3.0.0

    • This version is Ractor compatible.

■非互換

正規表現リテラル、および Range オブジェクトが freeze された

だいたい Ractorの都合なんですが、正規表現リテラルとRangeオブジェクトのすべてが freeze されることになりました。

p /abc/.frozen?             #=> Ruby 3.0 から true
p /a#{42}c/.frozen?         #=> Ruby 3.0 から true

p Regexp.new('abc').frozen? #=> 変わらず false

p (1..2).frozen?            #=> Ruby 3.0 から true
p Range.new(1, 2).frozen?   #=> Ruby 3.0 から true

まぁ、誰もこれらのオブジェクトを変更しないよね、と思うので、普通の人には気にしなくてもいい変更じゃないかと思います。

Regexp.new('abc')が freeze されていないのは、実際にこれを変更する人がいたためです(特異メソッドを追加していた)。そんな非互換気にしなくていいよ、どんどん変更しようぜー、という意見もあったんですが(Matzとか)、ここは保守的にいきました。やる気のある人がいれば、これも freeze されるかもしれません。

こんな感じで、Immutable っぽいオブジェクトはどんどん freeze されています。

関係ないけど、その freeze 化の最初のほう、Symbolは 2013 年に freeze されました。

* include/ruby/ruby.h: make Symbol objects frozen. ・ ruby/ruby@1e27eda

コミットメッセージで "I want to freeze this good day, too."って寿いでますけど、これ、私が結婚した日だったんですよね。記念コミット。

(ko1)

Hash#eachが常に2要素配列をyieldするように

  • EXPERIMENTAL: Hash#each consistently yields a 2-element array [Bug #12706]
    • Now { a: 1 }.each(&->(k, v) { }) raises an ArgumentError due to lambda's arity check.

一言で言えば、最適化のバグ修正です。順に説明します。

Hash は基本的に、キーと値をタプルにした配列を yield します。

{ a: 1 }.each {|ary| p ary } #=> [:a, 1]

しかし、引数が2つあるときはautosplatされます。

{ a: 1 }.each {|k, v| p k } #=> :a

このとき、いちいち配列を作って分解するのは無駄なので、引数が2つあるときは内部的に配列を作らないようにする最適化が行われていました。

しかしこの最適化は、ブロックがlambdaであるときでも適用されてしまっていました。lambdaはautosplatをしないので、引数の数が間違っているという例外が出るのが正しかったです。3.0では原則に従い、ブロックがlambdaのときは例外を投げるようになりました。

# Ruby 2.7
{ a: 1 }.each(&-> (k, v) { p k }) #=> :a# Ruby 3.0
{ a: 1 }.each(&-> (k, v) { p k }) #=> ArgumentError (wrong number of arguments (given 1, expected 2))

(mame)

標準出力がクローズされた後に出力しようとしてもEPIPE例外を投げないようになった

  • When writing to STDOUT redirected to a closed pipe, no broken pipe error message will be shown now. [Feature #14413]

細かい改善です。Ruby 2.7までは、rubyの出力をheadなどで途中で止めると、rubyの例外バックトレースを見かけることがあったと思います。

$ ruby -e 'loop { puts "foo" }' | head
foo
foo
foo
foo
foo
foo
foo
foo
foo
foo
Traceback (most recent call last):
        5: from -e:1:in `<main>'
        4: from -e:1:in `loop'
        3: from -e:1:in `block in <main>'
        2: from -e:1:in `puts'
        1: from -e:1:in `puts'
-e:1:in `write': Broken pipe @ io_writev - <STDOUT> (Errno::EPIPE)

これは、クローズされたパイプに書き込みを行っていたためでした。しかし、このバックトレースは特に便利ではないこと、他 のインタプリタでは何も言わずに終了することから、Ruby 3.0からは同様に何も言わずに終了するようになりました。

(mame)

定数のTRUEFALSENILが定義されないようになった

  • TRUE/FALSE/NIL constants are no longer defined.

よく知らないんですが、非常に古代のrubyでは、trueやfalseやnilは、TRUEやFALSEやNILでした *6。それが現代でも互換性のためになんとなく残され続けていたのですが、ついに削除されました。お疲れさまでした。

(mame)

Integer#zero?が改めて定義された

  • Integer#zero? overrides Numeric#zero? for optimization. [Misc #16961]

これまで、Integer#zero?はなくて、スーパークラスの Numeric#zero?が使われてきていたんですが、高速化のために Integer#zero?を改めて定義しました、という話です。ほぼ影響はないんですが、万が一 Numeric#zero?を再定義しても、Integer#zero?には影響を与えないことになります。

(ko1)

Enumerable#grepgrep_vに正規表現を渡してブロックを渡さなかった場合、$~を更新しなくなった

  • Enumerable#grep and grep_v when passed a Regexp and no block no longer modify Regexp.last_match [Bug #17030]

見出しの通りです。

["foo", "bar", "baz", "qux"].grep(/ba./)

p $~#=> #<MatchData "baz"> in 2.7
p $~#=> nil in 3.0

ary.grep(REGEXP)ary.select {|e| e.match?(REGEXP) }より遅い(MatchData オブジェクトを生成するため?)、という問題に対する対応のようです。非互換を入れずに最適化できるところを探していこう、という雰囲気だった気がするのですが、気づいたら非互換が入ってました。大丈夫かな。

(mame)

open-uri が Kernel#openを上書き定義しなくなった

  • Requiring 'open-uri' no longer redefines Kernel#open. Call URI.open directly or use URI#open instead. [Misc #15893]

みんなが愛した open-uri の Kernel#openが消えました。今後は URI.openを使ってください。

require"open-uri"# 2.7 では警告付きで動いていた、3.0 ではエラー
open("https://example.com") {|f| f.read }
  #=> No such file or directory @ rb_sysopen - https://example.com (Errno::ENOENT)# 2.7 でも 3.0 でも動くURL.open("https://example.com") {|f| f.read }
  #=> "<!doctype html>\n<html>\n..."

セキュリティ向上のためだそうです。Kernel#openはファイルを開くだけでなく、パイプ経由でコマンドを実行できたり、open-uriの拡張でHTTPフェッチができたりする大変便利なメソッドです。しかしこれは攻撃者にとっても便利すぎるきらいがあるということで、ファイルを開く機能専用のFile.open("...")や、URLをフェッチする機能専用のURI.open("...")などに分割整理が進んでいます。その一環として、open-uriがKernel#openを上書きするのもやめたようです。

(mame)

SortedSetが削除された

  • SortedSet has been removed for dependency and performance reasons.

set.rb に抱き合わせで実装されていた SortedSetが別の gem に分離されました。

SortedSet にアクセスすると例外が出ます。

require"set"SortedSet#=> The `SortedSet` class has been extracted from the `set` library.You must use the `sorted_set` gem or other alternatives. (RuntimeError)

削除された理由は、SortedSetが標準添付でない rbtree gem に依存していること(rbtree がないときは pure Ruby の実装が動くけれど、それは遅いこと)だそうです。

gem install sorted_setすれば、そのまま動くようになります。実は、rbtree gem が 3.0.0 対応していないために直前まで動かなかった(本記事を書いて試したことで気づけた)のですが、メンテナの knu さんがリリースまでに対処してくれました。

(mame)

■標準ライブラリの非互換

Default gem 化

  • Default gems
    • The following libraries are promoted the default gems from stdlib.
      • English
      • abbrev
      • base64
      • drb
      • debug
      • erb
      • find
      • net-ftp
      • net-http
      • net-imap
      • net-protocol
      • open-uri
      • optparse
      • pp
      • prettyprint
      • resolv-replace
      • resolv
      • rinda
      • set
      • securerandom
      • shellwords
      • tempfile
      • tmpdir
      • time
      • tsort
      • un
      • weakref
    • The following extensions are promoted the default gems from stdlib.
      • digest
      • io-nonblock
      • io-wait
      • nkf
      • pathname
      • syslog
      • win32ole

これらのライブラリが default gem 化されました。Gemfile にバージョン指定があると、そちらが利用されます。

(ko1)

Ruby インストール時に、インストールされなくなったライブラリ

上記ライブラリが、Ruby インストール時にインストールされなくなりました。gem として別途インストールする必要があります。

WEBrick が一緒にインストールされなくなるのは、結構大きい変更ですね。時代を感じます。

(ko1)

C API updates

いくつか、C 拡張ライブラリを書くための C API が更新されています。

$SAFEに関する C API が削除されています。

  • C API header file ruby/ruby.h was split. [GH-2991] Should have no impact on extension libraries, but users might experience slow compilations.

今まで、ruby.hという大きなヘッダファイルにいろいろ書いてあったのを、複数のファイルに分割しています。 ただ、ruby.hがこれまで通り、すべてを include しているので、拡張ライブラリのビルドに利用する分には変更ありません。

  • Memory view interface [EXPERIMENTAL]

    • The memory view interface is a C-API set to exchange a raw memory area, such as a numeric array and a bitmap image, between extension libraries. The extension libraries can share also the metadata of the memory area that consists of the shape, the element format, and so on. Using these kinds of metadata, the extension libraries can share even a multidimensional array appropriately. This feature is designed by referring to Python's buffer protocol. [Feature #13767] [Feature #14722]

メモリ上の(多次元)配列データを、プロセス内の他のライブラリなどとメタデータ付きで交換するための Memory view interface が追加されました。主に、大きな行列データや画像データなどを、あるライブラリで処理しているときに、別のライブラリに渡して処理をしてもらう、といった用途で利用されます。Python だと buffer protocol と呼ばれている機能を参照して追加されたそうです。

対象となるライブラリが X と Y の2つであれば、X->Y、Y->X のデータの変換器を作るだけでよさそうですが、これが数が増えると変換器の数がどんどん増えていきます。Memory view interface を用いれば、統一されたメタデータのもとで交換することができるので、変換器を作らなくても良くなります。また、生のメモリをそのまま渡すことができるので、何か冗長なフォーマット(例えば CSV)に変換して渡す、といったことが不要になるので、性能的な利点もありそうです。

開発された mrkn さんによる記事も公開されています:MemoryView: Ruby 3.0 から導入される数値配列のライブラリ間共有のための仕組み - Speee DEVELOPER BLOG

  • Ractor related C APIs are introduced (experimental) in "include/ruby/ractor.h".

Ractor に関する C API が少し追加されました。正直、これで足りているのかわからないのですが、とりあえず必要かな、と思うところを足しています。

(ko1)

■実装の改善

メソッドキャッシュが刷新された

  • New method cache mechanism for Ractor [Feature #16614]

    • Inline method caches pointed from ISeq can be accessed by multiple Ractors in parallel and synchronization is needed even for method caches. However, such synchronization can be overhead so introducing new inline method cache mechanisms, (1) Disposable inline method cache (2) per-Class method cache and (3) new invalidation mechanism. (1) can avoid per-method call synchronization because it only uses atomic operations. See the ticket for more details.

メソッド探索のたびに、クラス継承木を辿ってメソッドを探し当てるのは時間がかかるので、メソッド探索の結果をある程度キャッシュするというのがメソッドキャッシュです。

Ruby 2.7 までは、二つのメソッドキャッシュを使っていました。

  • インラインメソッドキャッシュ:バイトコードにキャッシュを突っ込んでおく。Ruby 1.9 (YARV) から導入
  • グローバルメソッドキャッシュ:固定長の1個のテーブルを用意して、そこにメソッド探索結果を保存しておく。すごい古い Ruby からほぼ同じものを利用

それぞれちょっとずつ改善していっていたのですが、今回がらっと変更しました。というのも、複数の Ractor から同時にアクセスすると、最もヒットすることが期待される(実際、90%以上はだいたいヒットする)インラインメソッドキャッシュにおいて、毎回ロックが必要になる、という構造だったからです。ロックを扱うと、オーバヘッドがすごいので、ここではロックの不要なデータ構造が必要になります。

そこで、次のように変更しました。

  • (1) インラインメソッドキャッシュを、毎回ロックを取らなくてもよい仕組みにした
  • (2) グローバルメソッドキャッシュをやめ、クラスごとのキャッシュにした

仕組みをちゃんと説明するのはとても面倒なんですが、(1) インラインキャッシュについてのアイディアとしては、これまで1つのインラインキャッシュを都度更新してきたのが、キャッシュに必要な情報を1オブジェクトとしてまとめておいて、キャッシュするときには、バイトコードからそのオブジェクトへの参照を保存するというアトミックな処理で済むようにした、というものです。

(1) の変更のために、既存のグローバルメソッドキャッシュでは不足があり(そもそも色々不満があった)、この度 (2) クラスごとのメソッドキャッシュを用意しました。

性能改善セクションにあるんですが、実は Ruby 3.0 でマイクロベンチマークの性能が (JIT なしの場合) 少し落ちていて、これがその原因の一つです。ごめんよ。でも並列化してるから許して。

(ko1)

superで必要なメソッド探索を、結果をキャッシュすることで高速化した

  • super is optimized when the same type of method is called in the previous call if it's not refinements or an attr reader or writer.

superで呼び出すメソッドは、Ruby 2.7 以前では毎回メソッド探索をまじめにしていたのですが、今回探索結果をほかのメソッド呼び出しと同じく、キャッシュすることにして性能改善を行いました。

(ko1)

キーワード引数を渡すときに無駄なハッシュの複製をやめた

  • The number of hashes allocated when using a keyword splat in a method call has been reduced to a maximum of 1, and passing a keyword splat to a method that accepts specific keywords does not allocate a hash.

たとえばこういうコード。Ruby 2.7 では foo(**opt)の呼び出しでハッシュを 2 回複製していたのですが、3.0 では 1 回になりました。

deffoo(**opt)
end

opt = { a: 1 }
foo(**opt) # Ruby 2.7 ではこれでハッシュを 2 回複製していた、3.0 では 1 回になった

また、次のコードでは、複製回数が 1 回から 0 回に改善しました。

deffoo(a: 1)
end

opt = { a: 1 }
foo(**opt) # Ruby 2.7 ではこれでハッシュを 1 回複製していた、3.0 では 0 回になった

キーワード引数まわりで貢献しまくってくれた Jeremy らしい細やかな最適化です。

(mame)

JIT

  • Performance improvements of JIT-ed code
    • Microarchitectural optimizations
      • Native functions shared by multiple methods are deduplicated on JIT compaction.
      • Decrease code size of hot paths by some optimizations and partitioning cold paths.
    • Instance variables
      • Eliminate some redundant checks.
      • Skip checking a class and a object multiple times in a method when possible.
      • Optimize accesses in some core classes like Hash and their subclasses.
    • Method inlining support for some C methods
      • Kernel: #class, #frozen?
      • Integer: #-@, #~, #abs, #bit_length, #even?, #integer?, #magnitude, #odd?, #ord, #to_i, #to_int, #zero?
      • Struct: reader methods for 10th or later members
    • Constant references are inlined.
    • Always generate appropriate code for ==, nil?, and ! calls depending on a receiver class.
    • Reduce the number of PC accesses on branches and method returns.
    • Optimize C method calls a little.
  • Compilation process improvements
    • It does not keep temporary files in /tmp anymore.
    • Throttle GC and compaction of JIT-ed code.
    • Avoid GC-ing JIT-ed code when not necessary.
    • GC-ing JIT-ed code is executed in a background thread.
    • Reduce the number of locks between Ruby and JIT threads.

いろんな仕組みで JIT についての性能改善を行いました。詳細は今度開発者の国分さんが記事をかくらしいので、そちらをお待ちください。

(ko1)

■その他

そのほかの変更です。

ruby2_keywordが空のキーワード引数ハッシュを維持しなくなった

  • Methods using ruby2_keywords will no longer keep empty keyword splats, those are now removed just as they are for methods not using ruby2_keywords.

次のような挙動の違いが入りました。

ruby2_keywords defproxy_foo(*a)
  p a
end

proxy_foo(**{}) #=> [{}]  # 2.7
proxy_foo(**{}) #=> []    # 3.0

なぜこのような違いが必要になったは、すごくややこしいので、読み飛ばしてもらって大丈夫です。Ruby 2のキーワード引数がいかに壊れていたかが感じ取れるエピソードです。

素直な期待としては、**{}は何も指定していないのと同じ扱いであって欲しいです。しかしRuby 2では、**{}が「最後のハッシュの引数がキーワードでないことを示すためのトリック」として稀に必要になっていました。

# Ruby 2.7 での意味deffoo(opt = nil, k: "default")
  p [opt, k]
end# このメソッドのオプション引数 opt にハッシュ {k: "val"} を渡したい、どうする?# これはダメ
foo({k: "val"})       #=> [nil, "val"]             # キーワードとして解釈されてしまっている# これが正解
foo({k: "val"}, **{}) #=> [{:k=>"val"}, "default"] # opt にハッシュを渡せた

そして、このようなfooをターゲットとして委譲を行うproxy_fooというメソッドをruby2_keywords付きで宣言したケースを考えます。

ruby2_keywords defproxy_foo(*a)
  foo(*a)
end# 次のように動かないといけない
proxy_foo({k: "val"})       #=> [nil, "val"]
proxy_foo({k: "val"}, **{}) #=> [{:k=>"val"}, "default"]

つまり、proxy_foo**{}が渡されたかどうかを勝手に忘れるわけにはいかなかったということです。そのためにRuby 2.7では、呼び出し元で**{}がついているときに可変長引数の最後に空のハッシュを残すようになっていました。

さてRuby 3.0では、キーワード引数を渡したいときはfoo(k: "val")、ハッシュを普通の引数として渡したいときはfoo({ k: "val" })と書き分けることができるようになりました。よって、先のfooメソッドにオプション引数としてハッシュを渡したいときは、素直にfoo({ k: "val" })と書くだけで大丈夫です。

# Ruby 3.0 での意味deffoo(opt = nil, k: "default")
  p [opt, k]
end# このメソッドのオプション引数 opt にハッシュ {k: "val"} を渡したい、どうする?# 素直にこれだけでOK
foo({k: "val"}) #=> [{:k=>"val"}, "default"] # opt にハッシュを渡せた

これにより、**{}を使うトリックが不要になりました。よって、proxy_foo**{}が渡されたかどうかを覚えておく必要はなくなったので、簡潔にするために冒頭の変更がなされました。

(mame)

バックトレースの順序が再逆転

  • When an exception is caught in the default handler, the error message and backtrace are printed in order from the innermost. [Feature #8661]

バックトレースの順序はRuby 2.5で逆転したのですが、Ruby 3.0で再逆転しました(古い順に戻った)。

次のコードでのバックトレースを見ればわかると思います。

deffoo; raise; enddefbar; foo; end
bar

Ruby 2.7の出力。

が一番上。

$ ruby test.rb
Traceback (most recent call last):
        2: from test.rb:3:in`<main>'        1: from test.rb:2:in `bar'test.rb:1:in `foo': unhandled exception

Ruby 3.0の出力。

が一番下。

$ ruby test.rb
test.rb:1:in`foo': unhandled exception        from test.rb:2:in `bar'        from test.rb:3:in `<main>'

Ruby 2.5で逆転した動機は、バックトレースが長すぎるときに例外メッセージを見つけるために端末出力をスクロールしなければならないのがいやだったことでした。この問題を軽減するために、前述の --backtrace-limitが導入されました。

再逆転したのにはいくつか理由があります。

  • バックトレースの順序がツールや設定によってバラバラになってしまい、統一が進む様子もなかった ((仮にツールが対応してくれても、p *callerというコードで擬似的にバックトレースを出力させる技などがあり、これを逆転させるのは難しかった。))
  • 一部のRailsユーザから逆転させて欲しいという要望があって変わったが、本当に多くのRailsユーザが逆転を望んでいたのか怪しくなった
  • 「古い方の順に戻してほしい」という文句を3年間言い続けた人がいた(私です)

もし「Ruby 2.7の順序が本当に本当によかったのに!」という人がいたら、声を上げ続けるとよいと思います(流石に再々逆転はむずかしいと思いますが……)。

(mame)

未初期化インスタンス変数にアクセスしても警告が出ないようになった

  • Accessing an uninitialized instance variable no longer emits a warning in verbose mode. [Feature #17055]

未初期化のインスタンス変数を参照すると、-w 付きで実行していると警告が出てきてましたが、この警告が出なくなりました。挙動としては、単に nil が返ります。

$ ruby_2_7_ruby -we 'p @foo'
-e:1: warning: instance variable @foo not initialized
nil

$ ruby -we 'p @foo'
nil

この警告は、インスタンス変数名を typo に気づけるかも、ということで導入されていましたが、

  • この警告を排除するために、事前に初期化が必要で面倒
    • 書くのが面倒
    • 実行時に初期化コードが遅くなるのが面倒
  • そもそも、-w つきであんまり実行しないから、普段から気づかないよね

ということで、警告を出さなくなりました。そのため、initializeメソッドでの nil 初期化は、このためには不要になりました。

(ko1)

■おわりに

8 年ぶりにメジャーバージョンアップした Ruby 3.0 、年末年始のお休みにでも、ぜひ楽しんでみてください。

Ruby 3 では、静的検証や並行並列処理のサポートなど、大きな機能の導入がありました。 また、目標としていた Ruby 2.0 よりも3倍速い、Ruby 3x3 を JIT コンパイラの導入により達成しました。

Ruby はこれからも進化を続けていきます。ご期待ください!

では、ハッピーホリデー!

PS: 明日 12/26 (土) 13 時から、Ruby 3.0 のリリースについて、まつもとさんを交えて語るイベントを開催します(Ruby 3.0 release event - connpass)。もしよかったらご参加ください。

*1:また、後述する静的型解析のためにキーワード引数を扱いやすくしたいという狙いもありました。

*2:productionに投入できない、Rubyで書かれたツールを使っているだけの人に警告を見せても不安を煽るだけ、など。

*3:警告を止める方法は提供していたのですが、コミュケーションが不足していたり、より柔軟な警告除外指定が必要だったり、より簡単な方法が望ましかったり。

*4:将来のRails 7はRuby 3.0以降を要求する公算が高いので、Ruby 3.0で未分離、Ruby 3.1で分離、となると都合が悪い。

*5:遠藤の実力では def: foo(a) = expression というように def の後にコロンを必要とする文法しか実装できなかったのですが、bison を母語のように話せる nobu が一瞬でコロンなしで再実装してくれました。

*6:軽く調べたところ、少なくともruby-0.69(1995年頃)ではTRUEがあり、trueは未定義のようです。

Ruby 3.0 の Ractor を自慢したい

$
0
0

Ruby の開発をしている技術部の笹田です。娘が自転車に乗り始め、まだ不安なためずっとついていなければならず、少し追っかけまわしただけで息切れがヤバい感じになっています。運動しないと。

ここ数年、Ruby で並列処理を気軽に書くための仕組みである Ractor を Ruby 3.0 で導入するという仕事を、クックパッドでの主務として行ってきました(クックパッドから、これ、と言われていたわけではなく、Ruby を前進させるというミッションの上で行ってきました)。

Ractor は、もともと Guild という名前で開発をはじめ、2020年の春頃、Ractor という名前に変更することにしました。いくつかの機会で発表しています。下記は、RubyKaigi での発表の記録です。

そして、昨日リリースされた Ruby 3.0 で導入されました。やった! ただ、まだ仕様が変わりそうなことと、色々実装がこなれていないので、実験的機能として導入されており、使うと警告が出る状態です。

本稿では、Ractorの簡単なご紹介と、Ractor の(私の考える)位置づけ、そして将来の Ruby (主語が大きい)についてご紹介します。あまり how to な内容ではありません。

Ractor 自体の詳細は、ruby/ractor.md at master · ruby/rubyにあるのでご参考になさってください。また、先日の本ブログ記事 Ruby に Software Transactional Memory (STM) を入れようと思った話 - クックパッド開発者ブログにも、いくつか基本的な使い方が載っています。

簡単な Ractor の紹介

例を用いて、Ractor の機能と現状について簡単にご紹介します。

Ractor での並列処理で、実際に速くなる例

Ruby 3.0 のリリース文(Ruby 3.0.0 リリース)にある、Ractor プログラムの例を見てみましょう。ここ、私が書きました。引用します。

deftarai(x, y, z) =
  x <= y ? y : tarai(tarai(x-1, y, z),
                     tarai(y-1, z, x),
                     tarai(z-1, x, y))
require'benchmark'Benchmark.bm do |x|
  # sequential version
  x.report('seq'){ 4.times{ tarai(14, 7, 0) } }

  # parallel version
  x.report('par'){
    4.times.map doRactor.new { tarai(14, 7, 0) }
    end.each(&:take)
  }
end

(1行 def と呼ばれる新機能を使っているのがオシャレポイントです。定義自体は4行だけど)

このプログラムでは、ベンチマークでよく用いられる竹内関数(竹内関数 - Wikipediatarai(14, 7, 0)を、4回実行するか(seq)、Ractor を用いて4並列実行するか(par)で、実行時間を測っています。

Ractor.new { tarai(14, 7, 0) }が、新しい Ractor で tarai()関数を実行する部分です。Thread.new{}のように、ブロックの部分を新しい Ractor(の中で作った Thread)で実行します。Ractor をまたいだスレッドは並列に実行されるので、この tarai()も並列に実行されるというわけです。

Ractor#takeによって、その Ractor が値を返すのを待つことができます。

さらに、結果をリリース文から引用します。

Benchmark result:
          user     system      total        real
seq  64.560736   0.001101  64.561837 ( 64.562194)
par  66.422010   0.015999  66.438009 ( 16.685797)

結果は Ubuntu 20.04, Intel(R) Core(TM) i7-6700 (4 cores, 8 hardware threads) で実行したものになります。逐次実行したときよりも、並列化によって3.87倍の高速化していることがわかります。

このマシン、笹田の自宅にあるマシンなんですが、ちゃんと4並列で4倍近い性能が出ていてよかったね、という結果になっています。こんな感じで、Ractor を用いることで、並列計算機上で並列処理を行うことができ、うまくいけば並列実行による速度向上が狙えます。

現状の Ractor

先ほどの例では、4倍近い高速化を達成することができました。ただ、これベストケースというか、チャンピオンデータというか、うまくいく例でして、多くの場合、Ractor 自体は、まだまだうまいこと性能が出せていません。

例えば、リリース直前に発見した、性能上の大きな問題。デモのために、あまり意味がありませんが、tarai関数の先頭で、Objectを参照してみましょう。

deftarai(x, y, z) = Object&&
  x <= y ? y : tarai(tarai(x-1, y, z),
                     tarai(y-1, z, x),
                     tarai(z-1, x, y))

必ず真になるので、不要な参照です。では、同じようにベンチマークをとってみましょう。

          user     system      total        real
seq  79.807530   0.000000  79.807530 ( 79.807818)
par 902.635763 432.107713 1334.743476 (343.626728)

なんと桁違い。4倍速いならぬ、4倍遅い、という残念な結果になってしまいました。なぜこんなことになってしまうかというと、定数(Object)の参照が遅いためです。

理由を少し解説すると、次のようになります。

  • (1) 定数参照時に利用するインラインキャッシュがスレッドセーフでなかったため、main Ractor 以外ではキャッシュを無効にしていた
  • (2) 定数参照時、定数テーブルは Ractor 間で共有するため、ロックを行うが、ロックが競合するとむっちゃ遅い

(1) と (2) の相乗効果でだいぶ遅くなってしまっています。残念無念。リリース直前に発覚したので、これから直そうと思っています(修正自体は、そんなに難しくない)。Ractor 自体は、こういうのがチョイチョイありそう、というクオリティーになっています。

これに限らず、これからいろんなフィードバック(主に苦情)を受けると思います。それらに対処していくことで、完成度をあげていこうと思っています。というわけで、「これおかしいんじゃないの?」とか、「ここが遅いんだけど」といったフィードバックを歓迎します。伸びしろしかないRactorを、一緒に育てていってください。

というわけで、まだそういうクオリティなので、Ractor.new{}すると警告が出ます。

warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.

書いてある通り、仕様も fixed というわけではないので、変わるかもしれません。こちらもフィードバックをお待ちしております。

Ractor の基礎

Ractor の仕様は、下記のポイントを基礎としています。かいつまんでご紹介します。

  • Ractor.new{}で複数の Ractor を作ることができ、それらは並列の実行される
  • Ractor 間のオブジェクトの共有はだいたい禁止されている
    • 共有不可 (unshareable) オブジェクト
    • 特殊な共有可能 (shareable) オブジェクトだけ共有可能
      • Immutable オブジェクト
      • Class/Module オブジェクト
      • その他
  • 2種類のメッセージ交換方式
    • push型: r.send(obj) ->Ractor.receive
    • pull型: Ractor.yield(obj) ->r.take
r = Ractor.new doRactor.receive # :ok を受診し、それをブロックの返値とするend  

r.send(:ok) # r へ :ok を送る(push)
p r.take #=> r のブロックの返値 :ok を取得する(pull)
  • Ractor.selectによる同時待ち
r1 = Ractor.new{ :r1 }
r2 = Ractor.new{ :r2 }
r, msg = Ractor.select(r1, r2)
# どっちか早く終わったほうのメッセージが得られる
  • メッセージの送信方法
    • 複製: ディープコピーして送信
      • r.send(obj)
    • 移動: 浅いコピーを行うが、送信元ではそのオブジェクトを利用不可(使うと、どんなメソッドも method_missingになる) ruby r.send(obj, move: true) obj.inspect #=> `method_missing': can not send any methods # to a moved object (Ractor::MovedError)
      • 情報の世界では、自動的にコピーになることが多いので、「移動」という概念は面白いと思う(これが、Guild という言葉の由来だった)
  • 複数 Ractor を動かす場合、いくつかの機能に制限(後述)

詳細はドキュメント(ruby/ractor.md at master · ruby/ruby)、もしくはRuby に Software Transactional Memory (STM) を入れようと思った話 - クックパッド開発者ブログの冒頭の例などをご覧ください。

以降は、最近入って、まだあまり紹介されていない機能についてご紹介します。

Ractor#receive_ifによる選択的受信

Ractor.receiveは Ractor に送られたメッセージを、FIFO で取り出すという機能でした。ただし、これだと、複数の Ractor から順不同で送られてくるメッセージを、区別して扱うことができません。

Erlang/Elixir などの言語では、ここでパターンマッチを用います。

Processes - The Elixir programming languageから引用)

# elixir の例
iex>receivedo...>   {:hello, msg} -> msg
...>   {:world, _msg} ->"won't match"...>end"world"

この例では、receiveで、pat -> exprのように、patにマッチしたメッセージが見つかれば、exprを実行する、のように記述することができます。

Ractor.receiveで似たようなことをすると、マッチしなかったとき、incoming queueにメッセージを戻すことができないため、似たような機能を作ることができません(receive済みのメッセージをためておく仕組みと、そこから取り出す仕組みを作って、receiveは直接用いない、とすればできんこともないです)。

そこで、Ractor.receive_ifが(結構リリース直前に)導入されました。

Ractor.receive_if{|msg| /foo/ =~ msg}

この例では、受信したメッセージのうち、/foo/にマッチする場合、ブロックが true を返し、そのときはじめて incoming queue からメッセージを削除します。

この機能を用いることで、あるパターンに合致したメッセージのみ受信することができます。

ただ、Erlang/Elixir にあったような、パターンA なら処理A、パターンBなら処理B、というようなことは書けません。というのも、このブロックは述語として true/false を返すべきものであるからです。

無りやり書くとすると、こんな感じで Proc (labmda) を返し、それをブロックの外側で実行する、として記述することが可能です(break などでブロックを抜けると、true を返したときのように incoming queue からメッセージを抜きます)。そして、その後に実行したい処理を Proc で返しているので、それを呼べば対応する処理(taskA か taskB)を実行できる、というものです。

Ractor.receive_if do |msg|
  case msg
  when patA
    break -> { taskA(msg) }
  when patB
    break -> { taskB(msg) }
  endend.call

が、これも正直書きたくないので、Ruby 3.1 以降にマクロが入れば、なんかいい感じにできそうだなぁ、と考えています。

複数 Ractor を動かす場合、いくつかの機能に制限

これまで、Ractor がなければ問題なく使えてきた機能が、Ractor 間でのオブジェクトの共有を排除するため、複数 Ractor 環境において制限されました。Ractor を使わなければ(main Ractor だけで利用するなら)、これまで通り制限はありません。

具体的には、次の操作が main Ractor だけで利用可能になります。

  • (1) グローバル変数、クラス変数の設定・参照
  • (2) 定数に共有不可オブジェクトの設定・参照
  • (3) 共有可能オブジェクトのインスタンス変数の設定・参照(とくに、クラス・モジュールで問題になる)

どの機能も、使われていると一発で main Ractor 以外で利用できなくなります。この中で、一番はまりそうなのは、(2) と (3) でしょうか。

C = ["foo", "bar"]  # NG: (2) 定数に共有不可オブジェクトを設定classC@bar = 42# NG: (3) 共有可能オブジェクトのインスタンス変数を設定defself.bar@bar# NG: (3) 共有可能オブジェクトのインスタンス変数を参照enddefself.bar=(obj)
    @bar = obj  # NG: (3) 共有可能オブジェクトのインスタンス変数を設定endend

よく使われていそうなプログラムです。この制限により、多くのライブラリを、複数 Ractor 上で利用することが、現在できません。今後、うまいこと書き換えが進むと、Ractor は利用しやすいものになっていくと思います。

さて、ではどのようにすればいいでしょうか。

(2) については、# shareable_constant_value: ...というプラグマが新設されました。

# shareable_constant_value: literalC = ["foo", "bar"]

このオプションで none(デフォルトのモード)以外を選ぶと、定数が共有可能オブジェクトを参照している、ということを保証できます。この例では、literalを選んでいます。これは、定数の右辺値、つまり代入するオブジェクトがリテラルのように記述されたオブジェクトなら、再帰的にfreezeしていくことで、immutable な共有可能オブジェクトを生成し、定数に代入します。リテラルでなければ、共有可能であるか実行時にチェックすることで、共有不可オブジェクトが定数に代入されることを防ぎます。

指定できるオプションは、noneliteral以外に、この2つが指定できます。

  • experimental_everything
    • 右辺値の値を共有可能オブジェクトに変換する
  • experimental_copy
    • 右辺値の値をまずコピーし、コピーに対して共有可能オブジェクトに変換処理を行う

everythingは副作用が気になりますが、copyは元のオブジェクトに影響を与えないため、副作用がほぼ起こりません。ただし、コピーによって若干時間がかかるかもしれません。

(3) の、共有したい mutable な値については、gem になりますが、Ractor::TVarractor-tvar | RubyGems.org)を用いると良いと思っています。

classCBAR = Ractor::TVar.new 42defself.barRactor::atomcally{ BAR.value }
  enddefself.bar=objRactor::atomcally{ BAR.value = obj }
  endend

Ractor::atomcallyを毎回書かないといけないのは冗長な気もしますが、ここで Ractor 間にまたがる共有状態を操作している、というのが明示できて、長い目で見ると利点になるのではないかと思っています。

拡張ライブラリの Ractor 対応

C などで記述された拡張ライブラリは、デフォルトでは main-Ractor 以外では動きません(提供されているメソッドを呼ぼうとすると、Ractor::UnsafeErrorになります)。

対応させるためには、複数 Ractor で実行していいことを確認して、rb_ext_ractor_safe(true)で、この拡張ライブラリが Ractor のサポートをしていることをインタプリタに教えてあげることが必要です。

対応させるためのチェックポイントについて、詳細は、ruby/extension.rdoc at master · ruby/rubyにまとめてあります。ただ、あんまり変なことしてなければ、たいてい Ractor 対応は簡単じゃないかなと思っています。

Ractor の背景

ここからは、具体的なコードの話ではなく、Ractor に関する検討について、その一端をご紹介します。

複数コアのCPUが普通になってきた昨今、並列計算を記述する、というニーズはどんどん高まっています。というフレーズは、私が大学で研究していた10年以上前から定番の前振りでした。実際、高性能なソフトウェアを書くのに、並列計算は必須であることにどなたも異論はないでしょう。

並列計算を行うためには、プログラムが並列計算に対応していなければなりません。そのためには、並列プログラミングが必要になります。すでに、多くのプログラミング言語が並列計算のための仕組みを備えています。

スレッドプログラミングは難しい

ただ、並列計算を行うプログラムは、だいぶ面倒くさいことが知られています。とくに、スレッドプログラミングは、いろいろな理由から、正しいプログラムを書くことが困難です。Ruby でも、スレッドは Thread.new{ ... }と書くことで、簡単に作ることができます。

たとえば、同じメモリ領域に、複数のスレッドが同時に読み書きすると、おかしなことになります。「この順番で読み書きしているから大丈夫」とすごく考えてプログラムをかいても、コンパイラの最適化によって読み書きの順序が変わったりして、逐次処理しているときには気づかなかった問題が生じることも多いです。この問題を、よくスレッド安全の問題といいます。

デバッグ時には、非決定的(non-deterministic)な挙動が問題になります。逐次処理は、2度実行すれば、だいたい同じ結果になります(そういう筋の良いバグが多いです)。しかし、複数のスレッドがどのように動くかは、スレッドマネージャの仕事になり、一般的には制御することは難しく、2度目の実行では異なる結果になることが多いです。そのため、問題が発覚しても、その問題を再現することが難しく、つまりデバッグがすごくしんどいわけです。

この非決定性は、ほんとうにタイミングよく何かしないと起きないバグなんかだと、めったに再現しないので、がんばって修正をしても、本当にその問題が解決したのかわからない、といった話もあります。

メモリを共有した同時の読み書きは難しい

スレッドプログラミングの1つの問題点は、複数スレッドでメモリを同時に読み書きが可能である、という点が挙げられます。同時に読み書きが起こる可能性があるメモリ領域においては、ロックをかけて排他制御するなど、他のスレッドと同期しながら処理を進める必要があります。

が、往々にして、こういう「ちゃんとアクセスする前にはロックをとる」みたいなものは、忘れがちです。人間は、ウッカリをするものです。私はしょっちゅう忘れて痛い目にあっています。

「私は大丈夫、ちゃんと同期とか仕込める」という人も、うっかりやっちゃう可能性はいくらでもあります。いくつか、うっかりしそうな例を並べてみます。

  • プログラムの規模が大きくなり、想定と別の用途でデータを用いて、うっかりロックが必要であることを忘れる
  • データ構造が複雑化し、共有されていることに気づかず、うっかりロックを忘れる
  • 別の人(将来の自分かも)がうっかりロックを忘れてアクセスする

他にもいろいろあると思います。

ちなみに、「ちゃんと動くプログラムを書く」というのも難しいですが、さらに「速いプログラムを書く」というのも難しい問題です。例えば、異なるメモリには、異なるロックを本当に必要な時にだけ用いたほうが(つまり、細粒度ロックを用いるほうが)並列度はあがり、並列処理の性能向上をますが、ロックの処理(獲得と開放)を頻繁に行う必要が出てきて、下手に作ると遅くなってしまいます。

難しさに対する対応策

もちろん人類は賢いので、様々な対策を考えてきました。

  • ロックなどをきちんと使っているか、チェックするツールの利用(valgrin/helgrind、thread-sanitizer、...)
  • ロックなどを自然に使うことができるデータ構造の導入(同期キュー、Transactional memory、...)
  • 型によるデータの所有系の明示(Rustなど)
  • 書き込みを禁止して、同時に読み書きを起こさない(Erlang/Elixir, Concurent-haskell など)
  • そもそもプロセスなどで分離して、共有しない(shell, make)

が、どれも完全に解決するのが難しいか、Ruby に導入するのは困難です(個人の見解です。別の見方もあると思います)。

  • ツールは漏れが生じます。また、MRI の構成上、(現実的なコストで)実現がなかなか困難です
  • データ構造を正しく扱えば問題なくても、ロックを忘れるのと同様にうっかり正しくない使い方をしてしまいます
  • Ruby にはこの手の型を記述する方法がないため困難です(文法を入れるのはきっと難しい)
  • 書き込み禁止(例えば、インスタンス変数への代入禁止)は、互換性を大いに壊します

最後の「そもそもプロセスなどで分けて、原則状態を共有しない」という shell などで利用されているアプローチは、Ruby でもマルチプロセスプログラミングとしてすでに行われています。dRubyやUnicorn、paralle.gem のプロセスモードなどがこれですね。通信する場合にひと手間かける、というアプローチになっています。

このモデルでは、それぞれのコンポーネントを単純に作ることができ、まさに UNIX 流の開発の利点が効いてきます。パイプなどでうまくつなげることで、それぞれが独立に並列実行させることができたり、make で依存関係のルールを記述することで、それぞれのタスクを良い感じに並列実行させることができます。また、別の計算機に処理を分散させることも、比較的容易です。

ただ、プロセスを複数いい感じに並べるだけだと、パイプだけだとちょっと表現力が弱く(パイプライン並列処理に特化している)、make も、あまり複雑なことは書けません。先述した Unicorn なども、あるパターンに特化していますね。

それから、コミュニケーションを主にパイプで行うため、通信のための手間が、複雑なコミュニケーションを行う場合は結構大変になります。また、実行単位がプロセスになることが多いので、タスクが多い場合、リソース消費が問題になる可能性があります。

Ractor の狙い

Rubyのモットーは「たのしいプログラミング」というところだと思います。できるだけ、難しいこと、面倒なことはしなくても良いようにするといいと思っています。

(現在、Ruby でも行うことができる)スレッドプログラミングは、その点で考えることがたくさんで、いざバグが入ると直すのが難しいという、「たのしさ」からは離れた機能ではないかと思うようになりました(難しいスレッドプログラミングをきちんとやる「たのしさ」もあると思うので、まぁ一概には言えないのですが)。この話は、手動メモリ管理と自動メモリ管理の話に似ていると思っています。つまり、ちゃんと作れば手動メモリ管理は効率的だったりしますが、うっかり間違えてしまったり、バグの発見は難しいし、というような。

そのため、多少性能を犠牲にしても(最高性能は出ないにしても)、なんとなく書けばちゃんと並列に動く、というのを目指すと良いのではないかと思い、Ractor を設計しています。

前節で述べた並列並行プログラミングの問題点を解決するために、Ractor はどのようなアプローチをとっているかご紹介します。

共有しない、がちょっと共有する

並列プログラミング言語において、あるメモリに対する read/write を混ぜない、というのは大事な観点であることをご紹介しました。並行並列に実行する処理が、共有状態を持たないと、問題が簡単にいなるわけです。

そこで、Ruby でこれを実現するのが Ractor です。Ractor という単位でオブジェクト空間を「だいたい」分けて、お互いに干渉させないようにさせます。これで、いわゆる同期漏れによるスレッド安全の問題が、だいぶ解決されます。

ただし、全部分けるとプロセスと同じでそれはそれで不便となるので、いくらか共有してもだいたい大丈夫だろうと思われるものを共有します。これが、プロセスで完全に分離してしまうことに対する利点になります。

この、ちょっとだけ共有することで、下記の利点が生じます。

  • Ractor 間の通信が、少し書きやすくなる
  • Ractor 間の通信が、少し速くなる
  • Ractor 間でメモリを共有することで、メモリ消費が減る

ウェブアプリケーションサーバにおいて、スレッドモデルが好まれるのが、「メモリ消費が減る」ではないでしょうか。Ractorでは、そのへんをそこそこ狙っています。

ただし、ちょっと共有することで、スレッド安全に関する問題が残ります。これは、本当に難しい問題で、利点を取るか欠点を取るか、ずいぶん悩んだのですが、今回は利点を優先することにしました。「まぁ、だいたい大丈夫だろう」というやつです。

ほかの言語では、Racket という言語で place という、Ractor と似た isolation を行う仕組みがあります(Places: adding message-passing parallelism to racket | Proceedings of the 7th symposium on Dynamic languages)。Ractor とよく似ていますが(だいぶ参考にしました)、通信の方法が、Go 言語のように、チャンネルを用いるというのが Ractor と異なります。

Actor model と CSP (Communicating Sequential Processes)

よく Erlang と Go の比較で、前者が Actor、後者が CSP を採用している、みたいな話があります。大雑把に言うと、前者が通信対象を並行実行単位(アクター)に、後者を並行実行単位をつなぐチャンネルに対して行うのが特長になるかと思います(厳密には多分違うと思うんですが、ここではそうとらえてみます)。

Ractor は、名前の通り Actor model を強く意識して設計されています。実は、2016年の開発当初では、とくに何も考えずに CSP 的なモデルを考えていました。ただ、数年色々考えた結果、Actor model のほうがいいかな、と思って、現在の設計になっています。

Actor model の利点はスケールがしやすいことと言われています。これ、作ってみるとわかるんですが、待ちが生じるのが、いわゆるアクターへ送られたメッセージを受信する操作に限定されるんですよね。複数のチャンネルを待ったりするより、自分自身に送られてきたメッセージを監視するだけのほうが楽なのです。他にも、コンポーネントを疎にしやすい(例えば、アクターが別の計算機にいてもよい)といった良い性質を持ちます。

が、あまりそのへんが Actor model 型のインターフェースにした理由ではなく、例外の伝搬を適切に行うことができるか、という観点から、現在のデザインにしました。

相手を指定する操作において(具体的には、Ractor#sendによる送信時に、もしくは Ractor#takeによる受信時)、相手の Ractor がすでに例外などで終了していた場合、エラーで気づくことができます。つまり、エラーが出ていることを、Ractor 間で適切に伝搬させることができるわけです。

CPSでは、処理の対象がチャンネルなので、その先につながっている並行実行単位の状況はわかりません(そもそもつながっていないかもしれない)。適切にチャンネルをクローズする、という手もありますが、ひと手間かかります(つまり、一手間を忘れる可能性があり、そして可能性があれば人は忘れる)。ソケットなんかは似たようなモデルですが、プロセスに紐づいているので相手側に状況が伝わります。こういうモデルでもよかったかなと思うのですが、うまいこと簡単に扱うAPIに落とし込めませんでした(チャンネルをさらにほかのRactorに渡すような用途で、うまいことモデリングできませんでした)。

いくつかのパターンでは、CPS のほうが書きやすい、というのがわかっていたのですが、Ractor 自体をチャンネルのように使えば、性能を気にしなければ、実は CPS とほぼ同じようなことができることがわかったので、とりあえず Actor model 風のインターフェースをベースにしました。性能はあとでなんとかしよう、と思っています。Actor っぽい push 型のコミュニケーション手段と、Actor っぽくない pull 型のコミュニケーション手段が混ざっているのは、この辺を作りやすくするためです。

コピーと移動

互いに分離された環境で通信するとき、コピーによってメッセージを渡すのは、よくある方法です。ただ、それだけだと他と同じで面白くないな、と思って考え付いたのが移動です。

情報の分野において、送ったメッセージが、その後参照できなくなるとういうのは、あまり聞いたことがないので面白いなぁと思って、Guild といってた時の目玉機能と思っていました。そもそも、Guild という名前は、Guild のメンバーが移籍する(moveする)という意図で見つけた名前でありました。

が、まぁ普段は使わない(コピーで十分)ということで、あまり前面に出さないようにして、そうすると Guild という名前もなんだね、ということで、Ractor に改名されました。

Ractor が使えるようになるまでの、まだまだ長い道のり

このように、とりあえず入った Ractor ですが、便利に利用するにはいくつかのハードルがあります。

利用者としての課題

まずは、ライブラリの対応がたくさん必要になります。とくに、先に述べた2点

  • (2) 定数に共有不可オブジェクトの設定・参照
  • (3) 共有可能オブジェクトのインスタンス変数の設定・参照(とくに、クラス・モジュールで問題になる)

については、だいぶ書き換えが必要になると思います。本当に大丈夫か、ってくらい。あと、説明してませんでしたが、define_methodによるメソッド定義で使うブロックが、ふつうのブロックだと他の Ractor では使えないというのがあります。まずそうな点です。

ライブラリがないと Ruby の魅力はものすごく下がってしまいます。そのため、これらの変更に追従していただけるかどうかが、Ractor が成功するかどうかの分水嶺になるかと思います。

使いづらいところがあれば、Ractor 側で改良したり、便利なライブラリを提供していったりしていきたいと思います。フィードバックをお待ちしております。

書き換えはいろいろ面倒なのですが、これは、スレッド安全を解決するための、見直すための良い指針の一つになる可能性があります。いままで、スレッド安全について、テストで問題ないし、なんとなく平気かな、と思っていたところが、ぜったい大丈夫、という安心感に代わるんではないかと思います。

並行並列処理時代の Ruby に書き換えるという、個人的には Ruby の性質を変える話じゃないかと思います。

実装上の課題

最初にご紹介した通り、性能上の問題、そしてバグが残っています。随時直していこうと思いますので、こちらもフィードバック頂ければと思います。

おわりに

本稿では Ractor についてご紹介しました。

自慢したいことは、まだ仕様・実装ともに不十分ではありますが、Ractor を導入までもっていったこと、それから娘が自転車に乗れることです。

新しい Ruby の一つの形ということで、楽しんでいただければ幸いです。

では、よいお年をお迎えください。

データ基盤チーム0人で運用は回るのか?! 前人未踏チャレンジ・クックパッドデータ基盤のすべて2020

$
0
0

技術部データ基盤グループの青木です。

ここ1、2年はなぜか成り行きでBFFをでっちあげたり、 成り行きでiOSアプリリニューアルのPMをしたりしていたので あまりデータ基盤の仕事をしていなかったのですが、 今年は久しぶりに本業に戻れたのでその話をします。

突然の1人チーム、そして0人へ……

今年のデータ基盤チームは消滅の危機から始まりました。

間違いなく去年末は5人のチームだったと思うのですが、 メンバーがイギリスへグローバルのデータ基盤チームを作りに行ったり、 山へ検索システムを直しに行ったり、川へレシピ事業の分析業務をやりに行ったり、 海へ広告のエンジニアリングをしに行ったりするのをホイホイと気前よく全部聞いていたら、 なんと4月から1人だけのチームになってしまいました。

事はそれで終わりません。 恐ろしいことに10月にはわたし自身も育休に入ることになったので、 10月はデータ基盤が0人になることが決まりました。

えっ……マジで……? ヤバない……?

もちろん大変ヤバいです。そんなわけで今年は徹底的な運用改善、 できれば完全無人運用が可能なシステムが最優先目標になりました。

アーキテクチャの概要

まずは前提として、クックパッドのデータ基盤アーキテクチャをざっくり説明しておきます。

f:id:mineroaoki:20201229002438p:plain
クックパッドのデータ基盤アーキテクチャ

中心とするデータベースはAmazon Redshiftです。 2016年から同じサイズのクラスターを使い続けています。

データインポートはマスター、ログ、それ以外の3系統。 各種アプリケーションのマスターテーブルは内製のPipelined Migratorまたは AWS DMS(Data Migration Service)で取り込んでいます。 MySQLがmigrator、PostgreSQLがDMSという使い分けです。 ログにはRedshift Spectrumを使っており、Spectrumへのロードにはこれまた内製の Prismというシステムを使っています。 それ以外のSaaSやDynamoDBのデータについては、アドホックなバッチジョブを Bricolageフレームワークで作ってロードしています。

Redshift内での処理はBricolageを使ったSQLバッチが大半です。 ごく一部はUDFを使ったり他システムへ処理を投げたりしていますが、 9割以上はpure SQLで処理しています。

一方のデータエクスポートも3系統あります。 管理画面などの社内アプリケーション、BIツール(社内標準はTableau)、 それに他システムへのバルクエクスポートです。

バルクエクスポートについてのみ詳細を説明すると、 基本的にはQueuery(きゅーり)という内製のシステムを使っています。 QueueryはHTTPのAPIでRedshiftにクエリーを投げられる薄いシステムで、 内部ではRedshiftのUNLOADを使っています。 アプリケーションはUNLOADされたデータをS3から読むので、 読み込みの負荷をRedshiftから切り離すことができる利点があります。

特にRubyからは、redshift-connectorというライブラリで Queueryを簡単に使えるようにしています。

2020年に行った施策

以上がデータ基盤の概要です。

アーキテクチャは最初に設計した2016年からほとんど変わっていませんが、 5年たったので細部の実装はいろいろと変わってきています。 2020年はさきほど述べたように運用改善が最優先だったので、 そのあたりを中心に対応しました。以下の5本立てでお送りします。

  1. Redshift Spectrumへの移行が(だいたい)完了
  2. Prismの運用改善
  3. ログ定義からのクライアント自動生成
  4. Redshiftのワークロード管理機能の活用
  5. Tableau運用フローの改善

1. Redshift Spectrumへの移行が(だいたい)完了

今年最大の成果はなんと言ってもSpectrum化が「だいたい」終わったことです。

Spectrum化作業はDWHチームのべ5人で交代しながらチマチマやっていたせいで、実に丸3年かかりました。 Redshiftの内部ディスクにあったログテーブルを300本近く捨てたことで、 クラスターのディスク容量は50%を切りました。これまでは常時カツカツで、 80%を越えるたびに過去のデータを消しては凌いでいたことを思うと隔世の感があります。

実際にやったことは1000本近いバッチジョブをひたすら書き換えるだけの簡単なお仕事です。 書き換えては数値検証、書き換えては数値検証で、検証の時間が一番長かったですね。 横長スプレッドシートと仲良くなれます。

また、移行が完了したことで、内部テーブルにロードするために使っていた 旧システム(strload v2, v3)を捨てられるようになりました。 これでようやくロードシステム3系統をメンテする地獄から解放されます。

2. Prismの運用改善

Spectrum化を進めていくうえで大きな課題になってきたのがPrismの運用のつらさです。

今年はコロナの影響などもあって、3月〜4月ごろにやたらとログの流量が増えており、 しょっちゅうPrismマージジョブのメモリが溢れて死に続ける事故が起きていました。 しかし、その当時のPrismはモニタリングするにもDBを直接見るしかなく、 ジョブのログテーブルもなかったので、ジョブが死んでも何の処理中に死んだのかもよくわからない有様……。

これではさすがにやってられないので、 Prismの前に使っていたstrloadというロードシステムの管理画面を流用し、 2日くらいでコンソールをでっちあげました。

f:id:mineroaoki:20201229002600p:plain
Prismの管理画面

もっとも、管理画面を作ったところで現状が見えるようになっただけにすぎません。 根本的に問題を解決した施策は、遅延ログの扱いを変更したことでした。

これまでPrismはどんなに遅れて到着したログもすべて受け入れて既存パーティションへ マージしていたのですが、今年からはそれを14日で捨てるように変えました。 これはBigQueryもそういう仕様ですし、問題はなかろうということである日突然えいやっと切り替えました。

この点は開発前にはよくわかっていなかったところの1つなのですが、 遅れたログを永久に受け入れていると、ロードシステムの負荷が非常に大きいのです。

例えばプッシュ通知を配信したときに、その処理のためにアプリがバックグラウンドで動く場合があります。 すると端末側のログバッファがいっせいにフラッシュされ、 しばらく休眠していたユーザーも含めて過去のログがまとめて到着します。 すると結果として「プッシュ通知を送るたびに全ログ全期間をマージしなおす」という事態に陥ってしまうわけです。 これは負荷の面でも、コストの面でもさすがに看過できません。

遅延ログを14日で切るようにしたらPrismマージジョブの数が激減して(下図)、 いきなりすべてが安定しました。

f:id:mineroaoki:20201229002627p:plain
6/15から山岳地帯がサバンナに激変

ちなみに、Prismもだいぶ安定してきたので、来年は残りの懸念を潰してオープンソース化するつもりです。

3. ログ定義からのクライアントコード自動生成

今年はiOSアプリのリニューアルという大きな動きがあったので、 そのどさくさに紛れて新しいログの仕組み、通称「大統一アクティビティログ」を導入してもらいました。 この仕組みを使うと、特定のMarkdown形式でログのイベントを定義しておくことで クライアントのロガーとログ定義が自動生成されて、型のズレを根絶することができます。 詳しくは id:giginetの記事「ドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築」を参照してください。

この仕組みは本当によくできていて、リリース以前にログをちゃんと考える契機になるうえ、 自動的にログのドキュメントが整備されるようになっています。 さらに自動生成システムはAndroidアプリやウェブでもそのまま再利用できたため、 コスパも非常によかったです。

データ基盤側の視点では、この仕組みを導入したことによって、 ログの型が事前に決まるようになった点が最大の利点でしょう。

これまではまず最初にアプリでログ出力が実装されて、 実際にログが届き始めてからログの定義(型)をもらい、 両者のズレをなんとかするというフローでログを運用していました。 しかし当然ながらこの順序では、想定通りのログが出ていなかったり、 データ基盤に設定するログ定義を間違えてしまうことが頻繁に起きます。 するとデータ基盤側でもデータを入れ直すタスクが発生して、 そのたびにいちいち手作業で対応していたわけです。 大統一ログの導入後はこのようなミスマッチも手作業も、いっさいなくなりました。

さて、定義が自動生成されるようになったので、このさい設定の適用も自動化しようということで、 ログ定義をGitHubにコミットしたら自動的に定義を本番適用するツールを開発しました。 このツールができたことによって、ログを追加するには次の3ステップで済むようになりました。

  1. 特定のMarkdown形式でログのイベントを定義する
  2. クライアントのロガーとログ定義を自動生成する
  3. ログ定義を専用のレポジトリにPull Requestしてマージする

このステップはすべて各アプリケーションの開発者が自分で行うことができます。

実はログ定義の本番適用とその後のフォローは、 データ基盤で発生する定期作業の中でも最も頻度が高い作業でした。 この作業を自動化できたことがデータ基盤0人期間を乗り切るための決定打となってくれました。

4. Redshiftのワークロード管理機能の活用

Redshift上のワークロードに関しては、 Concurrency ScalingとUsage Limit、それにAutoWLMを有効にして、 日々襲来する負荷の波をやりすごすことに取り組みました。

Concurrency Scalingは、Redshiftのread onlyクラスターを一時的に増やして、 クエリーの処理キャパシティを上げる機能です。これには当然ながら(?)お金がかかるのですが、 1日1時間の無料枠があるため、1時間だけスケールさせておけば無料で使えます。 そして1時間でスケールを止めるためにUsage Limitを使います。

f:id:mineroaoki:20201229002652p:plain
Concurrency ScalingとUsage Limit

Concurrency ScalingとUsage Limitについてはチームメンバーが書いた記事(英語ですが)があるので、 詳細を読みたいかたはぜひそちらをご参照ください。 結果だけざっくり言うと、コミット待ちの時間が約15%減りました。

最後のAutoWLMはこれまでのManual WLMと違い、CPUとI/Oも配分できるところが特徴です。 Manual WLMではせいぜいメモリしか配分できなかったので、ようやく普通のWLMになったなという印象です。

AutoWLMの設定にはあまりこっていません。 短かそうなクエリーにはリソースを多めに与えて速攻で終わらせる、 長い時間動いているクエリーは徐々にペナルティを増やしてリソース割り当てを減らす、この2つだけです。

様々なクエリーが混在する混合ワークロード環境では、短かいクエリーを早く終わらせることが肝要です。 何も知らないとついうっかり重いクエリーにリソースを割り当てたくなるのですが、実はそれが最もよくありません。 重いクエリーにリソースを割り当てると、大量のリソースがずっと占有されることになり、結果としてすべてのクエリーが詰まります。 むしろ短いクエリーに多すぎるくらいにリソースを割り当てて、とっとと次のクエリーが入る場所を空けさせたほうがパフォーマンスは上がります。

もっともAutoWLMを適用した効果は正直よくわからず、数値では明確に出せませんでした。 体感だとなんとなく待たされることが減っている気がしますが、プラシーボかもしれません。

5. Tableau運用フローの改善

いまのところ、クックパッドでは次のようにTableauワークブックの標準運用を定めています。

  1. 最初はカスタムクエリー(ワークブック埋め込みのSQL)を使って手軽に作る。データソースは抽出にする。
  2. カスタムクエリーをRedshiftのビューに変換する。
  3. ビューが重くなったら蓄積バッチ化する。

Tableauのカスタムクエリーは、作るときには簡単ではあるものの、 一度Serverにアップロードしてしまうと、ワークブックをダウンロードして 開かないと見ることができません。またそのときにRedshiftユーザー名を データソースの個数だけ要求されたりするので使い勝手が最悪に近いです。 できるだけ早くビューにしてしまって、 Tableauワークブックではビューをselectするだけにすべきでしょう。

しかしビューにするためにも結局カスタムクエリーを見る必要があり、 そのためにまたワークブックをダウンロードして20回ユーザー名を入力しなければいけないわけです。 これはあまりにもアホくさいですし、誰もやってくれないので、 カスタムクエリーをS3にダンプする日次バッチを作りました。

またRedshiftのビューを更新するにはバッチユーザーの権限が必要なので、 手作業でやるとわたしがボトルネックになりますし、 手更新はチームでのレビューがやりにくいという問題もあります。 そこで、ビュー定義をGitHubにコミットしたら自動的にビューを作成・更新する仕組みを作りました。 これはほぼ同じ事例をネットで見かけたので、やはりどこも同じことを考えるものですね。

結果、0人期間は乗り越えられたのか?

以上が今年やってきたことです。 ほぼ全方位にわたってとにかく手作業を減らし、そもそも定期的なメンテ作業が発生しないようにすること、 開発者にセルフサービスで問題を解決してもらえるようにすることに注力しました。

結論を言えば、データ基盤0人期間はなんとか乗り切ることができました。 実際には2、3回ちょっとした問題が起きたのですが、 元データ基盤のメンバーたちが首尾よく解決してくれたのでノーカンです。 完全無風とはいきませんでしたが、乗り切れたのでよしとしましょう。 元メンバーのみんなには感謝です! 今度おごります。

アーキテクチャ選定で後悔していることと、していないこと

ところで、この年末でクックパッドのデータ基盤は開発開始からほぼ丸5年を迎えます。 この節目に、アーキテクチャの選択について後悔していることと、していないことを総括したいと思います。

後悔していないこと

まず、Redshiftを選んだことは後悔していません。

一時期はBigQueryがうらやましすぎて、 他社のデータ基盤の人にBigQueryの話を聞くたびに 「ソーダヨネービッグクエリーベンリダヨネースゴイスゴーイ」 を無表情で連呼する機械と化していましたが、 まあいまでもうらやましい点もあるんですが、 トータルで見れば現状は悪くないなと思うようになりました。 あと5年戦って10年までいけそうな気がしています。

第一に、クックパッド全社のアーキテクチャを見たときにRedshiftは最もシンプルかつ安価なソリューションです。 やはりアプリケーションとデータ基盤をAWSで統一できるという点は非常に大きいと思います。 人間が分析をするだけならばデータ基盤を外出しにしてもたいして問題はないと考えていますが、 他システムとのやりとりが増えてくると、認証の複雑化なども含むデータ移動のコストがばかになりません。 これからますますデータ基盤と他システムとのデータ連携パスが増える一方であることを考えると、 アプリケーションとデータはできるかぎり近くの、連携が容易な場所に置くべきでしょう。

第二に、SpectrumやPartiQLによって、 ログをRedshiftで扱いやすくなったことが挙げられます。 もし仮にこれらの新機能がないままだったら、さすがに後悔していたでしょう。 Redshiftは次々に新機能がリリースされるので、いま困っていることでも 少し待っていたらどうにかなるのではないかという謎の安心感があります。

最後に、Redshift Federated Queryの存在が挙げられます。 Federated QueryはアプリケーションのDB(MySQLやPostgreSQL)に Redshiftから直接接続してクエリーすることができる機能です。 これはAWSでシステムを統一してこそ活用できる機能なので、 クックパッドにとってはまさに狙い通り、待望の機能でした。 今年はPostgreSQL限定だったので試験運用にとどまりましたが、 来年はいよいよMySQLサポートがやってくるので、大々的に使っていくつもりです。

来年はRA3ノードも導入する予定ですし、まだまだRedshift周辺は楽しめそうです。

後悔していること

逆に最も後悔した選択はTableauです。

この記事でもTableauのカスタムクエリーをダンプする仕組みなどについて述べましたが、 そもそもこれはTableauのダメなところをカバーする仕組みであって、 こんなロクでもない機能を実装しなければいけない時点でもうダメです。

運用面では、共有に向いていないデータソースの仕組みと、 抽出(extract)更新の管理機能が弱すぎる点が癌です。 利用者側から見るとコラボレーションと共有の機能が貧弱すぎます。 総じてTableau Serverの機能不足が目立ちますね。

ちなみに、以前に利用していたRedashは手軽さは最高によかったのですが、 データ更新ジョブの実行ログが貧弱である(というかない)こと、 クエリーの並列実行でキューが壊れまくることが課題でした。 いったい何回Redis(クエリー実行キューがある)をflushdbしたかわかりません。

データ基盤チームは仲間を募集しています

さすがに1人チームは無理があるということがわかったので、来年からはメンバーが1人増えることになっています。 もう1人くらいは社内から増やせそうな気がしますが、 できればさらにもう1人ほしいので、データ基盤チームでは仲間を大募集しています。 データ基盤を整備するどさくさに紛れて新しいシステムを開発したい人はぜひご応募ください。 以下のページの「データエンジニア」がデータ基盤チームです。

クックパッド採用情報 https://info.cookpad.com/careers/jobs/?jobs=engineer

カスタムなSF SymbolsをSVGから自動生成する

$
0
0

(English version here)

明けましておめでとうございます。モバイル基盤部のヴァンサン(@vincentisambart)です。

最近Appleがアプリの画面で使えるシンボルSF Symbolsに力を入れています。SF SymbolsはAppleの用意してくれたシンボルだけではなく、自分の作ったカスタムシンボルも使えます。Appleの紹介しているカスタムシンボルを作るワークフローに従うと手間がかかるので、既存のSVGからカスタムシンボルを自動生成できないか挑戦してみました。

経緯

だいぶ前からiOSクックパッドアプリで色んな画面で使われている単色アイコンはCookpadSymbolsというシンボルのみのフォントが使われていましたが、数ヶ月前デザイナーからシンボルの運用をフォントファイルからSVGに変えたいという要望が挙がりました。

アイコンは元々SVGで作成されていましたが、変更を加える度にSVGや設定ファイルをウェブ上のツールに読み込ませてフォントを生成するステップを省きたかったそうです。今となってはSVGを直接使えるようになった場面が多いですし。

CookpadSymbolsはこんな感じです。

f:id:vincentisambart:20201228112920p:plain

iOSでは、SVGとして用意されたシンボルを使うには以下の3つの方法があるかと思います。

  1. サイズの決まったピクセル画像として使う(実質PNGに変換されたかのように)
  2. ベクターデータのまま画像として使う(Asset CatalogのPreserve Vector Data設定)
  3. Xcode 12以上で直接SVGを使えるようになりましたが、iOS 12以下でベクターデータとして扱うにはSVGを事前にPDFに変換する必要があるようです。
  4. カスタムシンボルとして使う(カスタムシンボルは簡単に言いますと自分で用意したカスタムなSF Symbolsのことです)
  5. iOS 13以上が必要です。

iOSクックパッドアプリでは、シンボルは今までフォント形式で扱っていて、同じシンボルは画面によって違うサイズで表示されるので、固定サイズ画像として扱うとなるとだいぶ不便になります。元がベクター画像なので簡単に様々なサイズを自動的に用意できるとはいえ。

最近AppleがSF Symbolsを大きくプッシュしているようですし、デザイナーの要望が挙がった当時すぐiOS 12のサポートを終了する予定だったので、方法3でやってみることにしました。もしもiOS 12のサポート終了が大幅に遅れる場合や、実装している途中で大きい問題が発生した場合、最悪方法2にフォールバックすれば良いでしょうし。

結局iOS 12のサポート終了が当初の予定より遅れましたが、方法3のままで進みました。どうやって実装したのか説明しようと思いますが、その前にカスタムシンボルをもう少し説明しておきましょう。

カスタムシンボルとは

カスタムシンボルを紹介するには、まずSF Symbolsの話をしなければいけません。SF SymbolsはiOS 13以上に使える機能で、iOS開発者がアプリで使えるシンボル(色んなサイズで使えるシンプルな単色アイコン)です。普通の固定サイズの画像ではなく、文字と一緒に使えるように設計されています:サイズはフォントのポイントサイズで指定しますし、配置はフォントのベースラインに合わせることができます。

Appleの用意してくれたSF SymbolsはSF Symbolsアプリで以下のようにリストを見たり検索したりできます。

f:id:vincentisambart:20201228112934p:plain

Appleの用意してくれたシンボルだけではなく、自分の用意したカスタムなシンボルも合わせて使えます。カスタムなシンボルを用意するには、公式ガイドに従うと、まず公式のSF Symbolsアプリで追加したいシンボルに一番近いシンボルを選んで、SVGとしてエキスポートします。そのSVGをベクター画像編集ソフト(Illustratorなど)で編集して、Xcodeで使えるシンボルを用意します。

クックパッド内で使われているCookpadSymbolsはシンボルが現状300個近くあります。1つずつ手動で編集するとしたら手間が大きいです。運用変更の主な経緯がデザイナーにとってもっと運用しやすくなるためでしたので、手動でやりたくありません。自動化はプログラマーの大事な役目ですし、SVGは結局XML なので、なんとかなると思って作業を始めました。

SVGをXcodeに読み込ませてみる

SVGは既にデザイナーによって用意されていました。因みにそのSVGはウェブでもAndroidでも使われています。用意されていたSVGの1つが以下の通りでした(中身を細かく理解する必要はありません)。

<svg height="64"width="64"xmlns="http://www.w3.org/2000/svg"viewBox="0 0 64 64"><circle cx="32"cy="12"r="8"/><path d="M52.7 50.941l-7.913-4.396-3.335-8.34-.642-8.994 3.257.723 1.517 6.826a3.504 3.504 0 004.176 2.658 3.5 3.5 0 002.658-4.176l-2-9a3.5 3.5 0 00-2.658-2.658l-9-2a3.416 3.416 0 00-1.276-.037c-.16-.022-.319-.047-.484-.047h-8c-.163 0-.32.031-.479.055a3.48 3.48 0 00-3.254 1.26l-7.163 8.953-4.679.781a3.501 3.501 0 001.15 6.904l6-1a3.513 3.513 0 002.158-1.266l4.18-5.225 1.346 6.279-6.126 8.752A3.503 3.503 0 0021.5 49v9a3.5 3.5 0 107 0v-7.896l5.322-7.604h1.808l3.12 7.801a3.51 3.51 0 001.55 1.76l9 5a3.5 3.5 0 004.759-1.36 3.5 3.5 0 00-1.359-4.76z"/></svg>

ウェブブラウザーやベクター画像編集ソフトに読み込ませてみると、以下のように表示されます。

f:id:vincentisambart:20201228112940p:plain

Xcode 12がSVGを読み込めるので、深く考えずにこのSVGをXcodeでAsset Catalogにドラッグ&ドロップしてみると以下のようになります。

f:id:vincentisambart:20201228113043p:plain

求めているものとだいぶ違います。用意されていたSVGが最適化されている(不要なものが省いてある)ように見えるので、その最適化のどこかがXcodeと相性が悪いのかなと思いました。中身をよく見ると、最適化されているように見えるとはいえ、path004.176のように、数字の冒頭に無駄に見える0がある箇所があるのが少し不自然に感じました。単なるテキスト(XML)ファイルなので、ネット上のSVGの仕様をチラ見してから、試しにテキストエディターですべての不自然な0の後にスペースを入れてみて(004.1760 0 4.176など)、改めてXcodeに読み込ませてみたら以下のようになりました。

f:id:vincentisambart:20201228113052p:plain

まだ完璧ではないが、だいぶよくなりました。やはりXcodeの使っているSVG読み込みコードのSVGの仕様の解釈が不完全なようです。

XcodeのSVGの解釈を自分で補うことにするとしたらSVGの仕様を細かく理解する必要が出てくるので、自分でやる前にやってくれるツールがないでしょうか。

デザイナーに用意されていたSVGのレポジトリをよく見てみたら、SVGはSVGOというツールを使って最適化されていたようです。そのツールの設定を調べてみたら、それらしいpathに関する設定がありました。既にあった設定ファイルsvgo.ymlの最後に以下の2行を足して、SVGOを実行してみたら、なんと用意されたどのSVGも無事にXcodeに読み込まれるようになりました。

  - convertPathData:
      # Xcode doesn't handle properly paths without spaces after flags
      noSpaceAfterFlags: false

f:id:vincentisambart:20201228113102p:plain

1つだけの設定変更で済んで良かったです。

SVGファイルが以前に比べてほんの少し大きくなりますが、プラットフォームごとに設定を変えるとしたら運用が大変なので、どのプラットフォームも上記の設定で最適化されたSVGを使うことにしました。

Xcodeが読み込めるSVGになったのは大事な第一歩ですが、SVGを普通の画像としてではなく、シンボルとして使いたいので、SVGを元にシンボルを用意する必要があります。

シンボルを用意

公式ガイドに従うと、シンボルの用意の第一歩がSF Symbolsアプリから既存のSF Symbolsをまずエキスポートすることです。一番シンプルそうなcircleをエキスポートすると、以下のようなSVGファイルが書き出されます。

f:id:vincentisambart:20201228113116p:plain

シンボルごとにサイズ3つ、ウェイト8つを用意できますし、全部用意できたら一番良いのでしょうが、公式ガイドを読むとRegular Medium(Regular-M)だけが必須です。ひとまずは必須のもののみを用意することにしました。図形の縮尺を変えるだけなら、他のサイズはあとで簡単にできそうですし。

シンボルの運用を楽にしたいので、SF Symbolsアプリからエキスポートしたテンプレートに既存のSVGの中身を入れるのはガイドの説明のように手動ではなく、スクリプトでやることにしました。僕にとって書きやすいからRubyで書きましたが、XMLを扱うライブラリがあれば、どの言語でも簡単にできると思います。以下のコードはシンプルにしてコメントを多めにしたので、Rubyが分からなくてもやっているこを問題なく追えると思います。コード内のセレクターはできるだけCSSセレクターを使っています(#abcdがXML内にidの値がabcdであるノードを示します)。

最初は前準備です。ライブラリを読み込んで、必要な定数を定義して、テンプレートを読み込みます。

require"nokogiri"# XMLライブラリを使います# SF SymbolsアプリからエキスポートしたファイルへのパスTEMPLATE_PATH = "path/to/circle.svg"# 用意されたSVGへのパスSOURCE_SVG_PATH = "icon.svg"# 出力されるSVGへのパスDESTINATION_SVG_PATH = "icon-symbol.svg"# 期待されているアイコンサイズICON_WIDTH = 64ICON_HEIGHT = 64# SF Symbolsに近いサイズになるために必要な倍率(色々試した結果これで良さそうでした)ADDITIONAL_SCALING = 1.7# SVG内の#left-marginと#right-marginの幅MARGIN_LINE_WIDTH = 0.5# 左右に足している余白ADDITIONAL_HORIZONTAL_MARGIN = 4# テンプレートを読み込みます
template_svg = File.open(TEMPLATE_PATH) do |f|
  # もっときれいなSVGを生成するために、ホワイトスペースを無視するようにNokogiri::XML(f) { |config| config.noblanks }
end

テンプレートが3つのグループ(#Notes, #Guides, #Symbols)に分かれているXML(SVG)です。

<?xml version="1.0" encoding="UTF-8"?><!--Generator: Apple Native CoreSVG 149--><!DOCTYPE svg
PUBLIC"-//W3C//DTD SVG 1.1//EN""http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg version="1.1"xmlns="http://www.w3.org/2000/svg"xmlns:xlink="http://www.w3.org/1999/xlink"width="3300"height="2200"><!--glyph: "uni100000.medium", point size: 100.000000, font version: "Version 16.0d18e1", template writer version: "8"--><g id="Notes">
  (中略)
 </g><g id="Guides">
  (中略)
 </g><g id="Symbols">
  (中略)
 </g></svg>

#Symbolsグループにシンボルが以下のように入っています

<g id="Symbols"><g id="Black-L"transform="matrix(1 0 0 1 2854.05 1556)"><path d="(中略)"/></g><g id="Heavy-L"transform="matrix(1 0 0 1 2558.39 1556)"><path d="(中略)"/></g><g id="Bold-L"transform="matrix(1 0 0 1 2262.88 1556)">

必須の#Regular-M以外のシンボルは用意しないので、消しておく必要があります。

TEMPLATE_ICON_SIZES = ["S", "M", "L"]
TEMPLATE_ICON_WEIGHTS = ["Black", "Heavy", "Bold", "Semibold", "Medium", "Regular", "Light", "Thin", "Ultralight"]

# "Regular-M"だけを入れるので、それ以外の図形を消しますTEMPLATE_ICON_SIZES.each do |size|
  TEMPLATE_ICON_WEIGHTS.each do |weight|
    id = "#{weight}-#{size}"nextif id == "Regular-M"# 必須な図形だけを残します
    template_svg.at_css("##{id}").remove
  endend

テンプレートの冒頭の#Notesグループが主にベクター画像編集ソフトで見るためにあるテキストです。

<g id="Notes"><rect height="2200"id="artboard"style="fill:white;opacity:1"width="3300"x="0"y="0"/><line id=""style="fill:none;stroke:black;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="292"y2="292"/><text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;font-weight:bold;"transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text><text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:middle;"transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
  (中略)
  <text id="template-version"style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:end;"transform="matrix(1 0 0 1 3036 1933)">Template v.2.0</text>
  (中略)
 </g>

#Notesという名前だから消しても問題ないと最初は思いましたが、まるまる消してはいけません。実は公式ドキュメントをちゃんと読むと書いてありますが、#Notesの中に#template-versionという大事なテキストノードがあります。#template-versionノードを消してしまうと、シンボルSVG内の左右のマージンの位置やその中の図形の水平位置が無視されてしまいます。#artboardを消さないのも推奨されています。 余計なノードを消したいなら、#Notesの子ノードの中でidが空文字列な場合や存在しないノードだけが良いかと思います。

#Notesグループのすぐ下に大事な#Guidesグループがあります。

<g id="Guides">
  (中略)
  <line id="Baseline-S"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="696"y2="696"/><line id="Capline-S"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="625.541"y2="625.541"/>
  (中略)
  <line id="Baseline-M"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="1126"y2="1126"/><line id="Capline-M"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="1055.54"y2="1055.54"/>
  (中略)
  <line id="left-margin"style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;"x1="1391.3"x2="1391.3"y1="1030.79"y2="1150.12"/><line id="right-margin"style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;"x1="1508.39"x2="1508.39"y1="1030.79"y2="1150.12"/></g>

Regular-Mシンボルだけを用意するので、そのシンボルの#Baseline-M#Capline-Mに対する垂直位置、#left-margin#right-marginに対する水平位置、が大事になります。それぞれのグループの位置を取得しておきます。

因みにシンボルが文字の横に置かれるように設計されているため、capline(キャップライン)もbaseline(ベースライン)もフォントに関する用語です。上記のテンプレートの画像を見ると、左側に参照用にAがあるのはそのためです。

defget_guide_value(template_svg, axis, xml_id)
  guide_node = template_svg.at_css("##{xml_id}")
  raise"invalid axis"unless%i{x y}.include?(axis)
  val1 = guide_node["#{axis}1"]
  val2 = guide_node["#{axis}2"]
  if val1 == nil || val1 != val2
    raise"invalid #{xml_id} guide"end
  val1.to_f
end# #left-marginノードの"x1"の値("x2"と同じ値のはず)を取得
original_left_margin = get_guide_value(template_svg, :x, "left-margin")
# #right-marginノードの"x1"の値("x2"と同じ値のはず)を取得
original_right_margin = get_guide_value(template_svg, :x, "right-margin")
# #Baseline-Mノードの"y1"の値("y2"と同じ値のはず)を取得
baseline_y = get_guide_value(template_svg, :y, "Baseline-M")
# #Capline-Mノードの"y1"の値("y2"と同じ値のはず)を取得
capline_y = get_guide_value(template_svg, :y, "Capline-M")

SVGアイコンを読み込んで期待しているサイズなのか確認しておきます。

# アイコンのSVGを読み込みます。
icon_svg = File.open(SOURCE_SVG_PATH) do |f|
  # もっときれいなSVGを生成するために、ホワイトスペースを無視するようにNokogiri::XML(f) { |config| config.noblanks }
end# デザイナーに用意されていたSVGはサイズが64x64固定でしたので、後の計算はそれを元に書かれています。# 期待しているサイズでなければエラーで終了します。# SVGのwidth/heightは数字だけではなく、パーセントとかも使えるので、もっと幅広いSVGに対応する場合、もっと複雑になります。if icon_svg.root["width"] != ICON_WIDTH.to_s ||
  icon_svg.root["height"] != ICON_HEIGHT.to_s ||
  icon_svg.root["viewBox"] != "0 0 #{ICON_WIDTH}#{ICON_HEIGHT}"raise"expected icon size of #{icon.source_svg_path} to be (#{ICON_WIDTH}, #{ICON_HEIGHT})"end

用意されたアイコンのサイズをAppleのテンプレートのサイズに合わせる必要があります。

SF Symbolsアプリからエキスポートされるテンプレートは選ばれたシンボルによって左右のマージンの位置が変わりますが、#Baseline-M#Capline-Mが固定なので、サイズを#Baseline-M#Capline-Mの間隔に合わせます。

scale = ((baseline_y - capline_y).abs / ICON_HEIGHT) * ADDITIONAL_SCALING
horizontal_center = (original_left_margin + original_right_margin) / 2

scaled_width = ICON_WIDTH * scale
scaled_height = ICON_HEIGHT * scale

# テンプレートのマージンをそのまま使う場合、出来上がったシンボルの幅が選んだテンプレートによって変わります。# テンプレートを気にしたくないので、計算したシンボルのサイズを元に左右のマージンの位置を調整します。
horizontal_margin_to_center = scaled_width / 2 + MARGIN_LINE_WIDTH + ADDITIONAL_HORIZONTAL_MARGIN
adjusted_left_margin = horizontal_center - horizontal_margin_to_center
adjusted_right_margin = horizontal_center + horizontal_margin_to_center
left_margin_node = template_svg.at_css("#left-margin")
left_margin_node["x1"] = adjusted_left_margin.to_s
left_margin_node["x2"] = adjusted_left_margin.to_s
right_margin_node = template_svg.at_css("#right-margin")
right_margin_node["x1"] = adjusted_right_margin.to_s
right_margin_node["x2"] = adjusted_right_margin.to_s

全ての計算が終わったので、調整したテンプレートに読み込んだアイコンを正しい位置とサイズで入れてファイルを出力します。

# 元のテンプレートをコピーする。# 今回シンボル1つしか生成しないが、一気にいくつものシンボルを生成する場合コピーを編集した方が安全です。
symbol_svg = template_svg.dup

# ついに肝心の#Regular-Mノードに手をつける時が来ました。
regular_m_node = symbol_svg.at_css("#Regular-M")

# 図形がガイドの中央になるよう移動させます。
translation_x = horizontal_center - scaled_width / 2
translation_y = (baseline_y + capline_y) / 2 - scaled_height / 2# 上記に計算された移動や倍率を元に変換行列を用意します。
transform_matrix = [
  scale, 0,
  0, scale,
  translation_x, translation_y,
].map {|x| "%f" % x } # 文字列に変換
regular_m_node["transform"] = "matrix(#{transform_matrix.join("")})"# #Regular-Mノードの中身を用意されていたアイコンに置き換えます。
regular_m_node.children = icon_svg.root.children.dup

# 最後に生成したシンボルを書き出します。File.open(DESTINATION_SVG_PATH, "w") do |f|
  symbol_svg.write_to(f)
end

実装中に起きた問題

もちろん上記のコードが出来上がるまでは、スクリプトを実行して、ベクター画像編集ソフトやXcodeで確認して、スクリプトの修正する、の繰り返しでした。実装が進んでいたら、Xcodeでの確認はAsset Catalog内だけではなく、普通のXcodeプロジェクトに取り込んで使ってみるのも含んでいました。

問題の1つは、色んなSVGからシンボルを生成したら、一部の生成されたシンボルファイルに元の図形の横に別の図形がありました。よく見たら、用意されていたSVGの一部に(0, 0, 64, 64)枠の外に図形が入っていました。viewport0 0 64 64だったのでその外が見えていなくて誰も気づいていませんでした。デザイナーにその枠外図形を消してもらいました。

もう1つは実装の説明でも書きましたが、#Notesノードが要らないだろうと思って消してしまったが間違いでした。それで図形を左右マージンの間にどこに置いても(中央寄りでも左寄りでも右寄りでも)、左右マージンをもっと幅広くしても、生成されたシンボルが変わりませんでした。#Notesに入っている#template-versionが残るように修正することで期待通りに動くようになりました。

幅固定のよしあし

上記のスクリプトで生成されたシンボルはAsset Catalogを入れて問題なく使えます。ただし、幅をすべてのシンボル共通にしましたが、それに良し悪しがあります。枠の幅が共通でも、その中の図形自体の幅がそれぞれなので、左右の余白がバラバラです。iOSのカスタムシンボルはシンボルごとに幅を変えられますし、実際Appleの用意したSF Symbolsの幅が様々です。そうした主な理由が2つあります。

  • いくつかのシンボルを同じ画面内で配置する場合、幅が共通だった方が配置しやすいと思います。
  • 図形の形を解析して本当の幅を計算するのとなると複雑になりますし、もっと細かく確認する必要があるからです。また、その道を歩み始めると、本当のサイズと目に見えるサイズ(光学的サイズ)がちょっと違ったりしますし、シンボルによって微調整したくなったりします。

どうするのかユースケースによると思いますが、シンプルでいくことにしました。

因みに幅を共通にしましたが、なぜかコードでシンボルから生成された画像はシンボルによって幅に0.5~1.0 ptの差があります。iOS 13よりiOS 14の方がましのようだけど、iOS 14でも起きています。まぁpixel perfectを求めるなら、ベクター画像ではなく、ピクセル画像を用意することですね。

もう少し便利に

上記のスクリプトは分かりやすさのためSVG 1つだけを生成するものです。社内で用意したスクリプトはそれより少し強力です。

元はファイル1つではなく、特定なディレクトリーのすべてのSVGファイルを処理していきますし、生成しているのはSVGだけではなく、Asset Catalog(xcassetsディレクトリー)を丸々生成していますし、シンボルのリストのSwift enumのコードも生成しています。

Asset Catalogは形式がとても簡単です。Asset Catalogはフォルダーの「Provides Namespace」にチェックを入れるとその中身がネームスペースに入るので便利です。

以下のような enumのコードを生成しています。

publicenumCookpadSymbol:String, CaseIterable {
    publicenumPackage {
        // Asset Catalogにカスタムシンボルを入れたネームスペースpublicstaticletnamespace="cookpad"publicstaticletversion="2.0.0"
    }

    case access
    case clip
    case clipAdd ="clip_add"case clipAdded ="clip_added"case clipRemove ="clip_remove"case lock

    // Asset Catalog内の名前publicvarimageName:String { "\(Package.namespace)/\(rawValue)" }
}

カスタムシンボルの使い方

シンボルを生成したのは良いが、Asset Catalogに入れたらアプリ内でどうやって使えるのでしょうか。

UIImageView

カスタムシンボルを表示するには基本的にUIImageViewを使います。

letsymbolIconView= UIImageView()
// CookpadSymbol.imageNameが上記にenumに定義されたAsset Catalog内の名前です。
symbolIconView.image = UIImage(named:CookpadSymbol.lock.imageName, in: .main)
symbolIconView.tintColor = .red
symbolIconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize:10)

preferredSymbolConfigurationでサイズが決まります。ただし、UIImage.SymbolConfiguration(pointSize: 10)を使うとDynamic Type設定の変更が反映されません。Dynamic Type対応が必要な場合、UIImage.SymbolConfiguration(textStyle:)を使うか、Dynamic Typeの設定によってサイズを変えるフォントをUIImage.SymbolConfiguration(font:)に渡すかです。

letsymbolConfiguration= UIImage.SymbolConfiguration(font:UIFontMetrics.default.scaledFont(for: .systemFont(ofSize:10)))

UILabelと違ってadjustsFontForContentSizeCategoryのように別途に設定する必要あるプロパティがありません。

NSAttributedString

UIImageViewの他に、NSAttributedStringに入れて、UILabelUITextViewでも表示できます。

letattributedText= NSMutableAttributedString()
letimageAttachment= NSTextAttachment()
imageAttachment.image = UIImage(named:CookpadSymbol.lock.imageName, in: .main)
attributedText.append(NSAttributedString(attachment:imageAttachment))
attributedText.append(NSAttributedString(string:" 非公開"))
label.attributedText = attributedText

UILabel.attributedTextの懸念点はUILabel.textと違って、adjustsFontForContentSizeCategorytrueにしても、Dynamic Typeの設定変更がすぐ反映されないところです。

UIImage

カスタムシンボルをUIImageとして扱いたい場合、サイズをUIImage.SymbolConfigurationで明記して、UIImage(named:in:with:)に渡すか、UIImage.applyingSymbolConfiguration()(またはUIImage.withConfiguration())に渡すかです。

letconfiguration= UIImage.SymbolConfiguration(pointSize:12)
letsymbolImage= UIImage(named:CookpadSymbol.lock.imageName, in: .main, with:configuration)

色の指定はUIImage.withTintColor()を使います。

letredSymbolImage= symbolImage?.withTintColor(.red)

tintColorを指定しても、シンボルから作成したUIImageUIImageViewに入れるとき、UIImageViewtintColorが優先されるので、どうしても画像自体の色を優先させたい場合は以下のようにできます。

letreallyRedSymbolImage= symbolImage?.withTintColor(.red, renderingMode: .alwaysOriginal)

SwiftUI

SwiftUIでも簡単に使えます。

Image(CookpadSymbol.arrowRight.imageName, bundle: .main)
    .font(.caption)
    .foregroundColor(.green)

ヘルパー

生成されたenumにいくつかのヘルパーを用意するとさらに使いやすくなります。ここでBundleは固定で.mainを渡していますが、自分のユースケースに合わせてください。

// UIKitextensionCookpadSymbol {
    publicfuncmakeImage(with configuration:UIImage.Configuration? =nil) ->UIImage? {
        UIImage(named:imageName, in: .main, with:configuration)
    }

    publicfuncmakeAttributedString(
        with configuration:UIImage.Configuration? =nil,
        tintColor:UIColor? =nil
    ) ->NSAttributedString {
        varimage= makeImage(with:configuration)
        iflettintColor= tintColor {
            image = image?.withTintColor(tintColor)
        }
        letimageAttachment= NSTextAttachment()
        imageAttachment.image = image
        return NSAttributedString(attachment:imageAttachment)
    }
}

// SwiftUIextensionImage {
    publicinit(_ symbol:CookpadSymbol) {
        self.init(symbol.imageName, bundle: .main)
    }
}

SF Symbols

余談ですが、上記のコードがカスタムシンボルのためですが、UIImage(named:in:)UIImage(systemName:)に変えると、SF Symbolsで使えます。カスタムシンボルがカスタマイズされたSF Symbolsなので、使い方が近いのは自然かと思います。

Interface Builder

Interface Builder(Xcode内インターフェースエディター)内でImage ViewのプロパティでAsset Catalogのように簡単にカスタムシンボルを選ぶことができますし、コードのようにサイズを簡単に選べます(ただしUIFontMetricsを通ったフォントは渡せません)。

f:id:vincentisambart:20201228113122p:plain

やってみてどうだった

カスタムシンボルの作り方の公式ガイドに自動化に関する話はありませんでしたが、SVGはベクター画像編集ソフトでもテキストエディターでも確認できるファイル形式ですし、デザイナーが用意してくれていたSVGがきれいでシンプルでしたので、カスタムシンボルの生成は割りとスムーズにできたと思います。今後もっと幅広く使えるカスタムシンボルを扱うツールが増えたらさらに楽になるかと思います。

カスタムシンボルを使い始めてから時間がまだあまり経っていないので、今後気づく懸念点は出てくるかもれませんが、いまのところ簡単に色んな場面で使えて便利です。

SF SymbolsもカスタムシンボルもiOS 13以上を必要としているのは一番の懸念点だと思いますが、時間が解決してくれます。

Viewing all 734 articles
Browse latest View live