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

RecBole を用いてクックパッドマートのデータに対する50以上のレコメンドモデルの実験をしてみた

$
0
0

こんにちは。研究開発部の深澤(@fufufukakaka)です。

本記事では最近面白いなと思って watch しているレコメンド系のプロジェクト RecBole を紹介いたします。また、クックパッドが展開している事業の一つであるクックパッドマートのデータを使って数多くのレコメンドモデルを試す実験も行いました。その結果も合わせて紹介します。

TL;DR:

  • レコメンドモデルは作者実装に安定性がなく、またモデルをどのように評価したかも基準がバラバラで、再現性が難しいとされている(from RecSys 2019 Best Paper)
  • 再現性に取り組むプロジェクトとして 2020年12月に始まった RecBole がある。 RecBole を利用することでなんと 50個以上のレコメンドモデルを大体1コマンドで試せる
  • クックパッドマートでユーザに対してアイテムをレコメンドするシチュエーションを想定し実験を行った。その結果、テストデータの6000ユーザに対して2000ユーザ(三分の一)に正しい推薦を行うことができるモデルを発見できた

正しく強いレコメンドモデルを探すのは難しい

サービスの中で機械学習といえばレコメンド、といわれる機会は非常に多いかと思います。が、レコメンドは機械学習の中ではかなり特殊な問題設定です。クラス分類したり回帰したり、と様々な解き方をすることができるタスクなのがその要因です。「ユーザがこのアイテムを買ってくれる確率」を推定しても良いし「ユーザが好きなアイテムのランキング」を予測しても良い。そもそもアイテムの数が数万種類くらいある中で「このユーザはこれを買ってくれそう」を予測するのは非常に難しいです。つまり、レコメンド系は実社会サービスでの需要が高く非常に難しい問題、と言えます。

おかげでたくさんの研究が日々発表されています。それ自体は素晴らしいことです。RecSysというレコメンドのみを取り扱う国際会議も存在しています。最近ではDeep Learningを活用した研究が殆どを占めています。

ですが、この日々公開されている研究には再現性がないことが指摘されています。2019の RecSys ベストペーパーは「Are We Really Making Much Progress? A Worrying Analysis of Recent Neural Recommendation Approaches」でした。「本当にニューラルネットワーク系の手法で精度は上がっているのか?」というこの論文では衝撃の事実が明かされており

  • トップ会議(KDD, SIGIR, WWW, RecSys)のDNN関連研究18本を追試した
    • 18本のうち、現実的な努力を行った上で再現できたのが7本(半分以下!)
      • (RecSysでの発表によると、)実装が再現できない場合は、実装を原著者らに問い合わせて1ヶ月待った
    • 再現できたとしても 6/7がkNNベース(シンプルなモデル)+ハイパーパラメータ最適化に負けてしまった
    • 残りの1つもDNNではない線形の手法を調整したものに負ける場合もあった

(refs. https://qiita.com/smochi/items/98dbd9429c15898c5dc7 )

国際会議で「state-of-the-artだ」と主張している論文の殆どが、実際には10年以上前から存在しているシンプルな手法に負けてしまう、というサーベイ結果が出ており、非常に面白い論文でした。

この論文が示したように、レコメンドの研究は数多く発表されているものの殆どの実装に再現性がなく、また正しい比較ができていない、というのが現状です。当然ですが、論文内で提示されている GitHub リポジトリの実装は人によってまちまちです。再現実装は再利用が可能なものから環境構築自体が困難なものまで色々あります。前提としてコードを上げてくれることは非常にありがたいのですが、それぞれの手法で土台を揃えた実験を行うのはそもそもが難しい状況です。

RecBoleについて

RecBoleは中国人民大学・北京大学の研究室が共同で始めたプロジェクトのようで、去年の11月に arxiv に登場しました。今年の8月に提供しているモジュールがv1を迎えて、本格的に色々な人が利用するようになったようです。

RecBole 最大の魅力は、上述してきた再現性の難しいレコメンドモデルを統一したインタフェースで実装し、比較を容易にしているところにあります。そして実装されているモデル、適用できるデータセットの数が凄まじいです。モデルは現時点で70以上(モデルリストがすごい )、データセットは20以上のものについて即座に試せます。どれくらい即座に試せるかと言うと

pip install recbole
python run_recbole.py --model=<your favorite model> --dataset_name ml-100k

これだけで、レコメンド界隈の中で最も有名なベンチマークである MovieLens-100k データセットに対して70以上のモデルを即座に(追加の設定が必要なやつもありますが)試せます。これだけのモデル・データを試すことができる環境はそうないと思われます。また70以上の収録されているモデルたちは全て PyTorch ベースで丁寧に再実装が行われており信頼性は非常に高いです。predict関数などの基本的なインタフェースは統一されており、実験のし易い環境が整えられています。

RecBole を自分たちのデータで使えるようにする

実際に RecBole を使えるようにするためにはどうするばよいのか、について簡単にまとめてみました。

  1. ユーザとアイテムのアクション履歴をまとめたデータを用意する
  2. データをコントロールするクラスを用意する
  3. 配布されているスクリプトを使ってデータを RecBole が読める形式に変換する
  4. 学習に必要な設定ファイルを用意する
  5. 学習スクリプトを走らせる

1. ユーザとアイテムのアクション履歴をまとめたデータを用意する

自分の使いたいデータを持ってきて、以下のようなファイルで保存しておきます。

今回はクックパッドマートを対象としてデータを作りました。interact.csv ではあるユーザがあるアイテムを購入したログが表現されています。MovieLens のような Rating (Explicit Feedback)のついていない Implicit Feedback なデータセットです。

なお、ここで紹介している user_id, item_id などはいずれもダミーとなっています。

interact.csv

user_id,item_id,timestamp(Unix timestamp)
1,1,1630461974
2,2,1630462246
3,2,1630462432

items.csv

item_id,item_name,item_category_id
1,豚バラ,9
2,にんじん,7

users.csv

user_id,feature1,feature2
1,286,130
2,491,3
3,342,32

2. データをコントロールするクラスを用意する

続いて、RecBole 内でこれらのデータを扱うためのクラスを用意します。基本的には BaseDatasetと同じインタフェースを用意して、その内部をデータに合わせて調整するような作業になります。

import os

import pandas as pd
from src.dataset.base_dataset import BaseDataset  # https://github.com/RUCAIBox/RecSysDatasets/blob/master/conversion_tools/src/base_dataset.py をコピーして所定の場所に配置しておくclassCookpadMartDataset(BaseDataset):
    def__init__(self, input_path, output_path):
        super(CookpadMartDataset, self).__init__(input_path, output_path)
        self.dataset_name = "ckpd_mart"# input_path
        self.interact_file = os.path.join(self.input_path, "interact.csv")
        self.item_file = os.path.join(self.input_path, "items.csv")
        self.user_file = os.path.join(self.input_path, "users.csv")

        self.sep = ","# output_path
        output_files = self.get_output_files()
        self.output_interact_file = output_files[0]
        self.output_item_file = output_files[1]
        self.output_user_file = output_files[2]

        # selected feature fields# 型について -> https://recbole.io/docs/user_guide/data/atomic_files.html#format
        self.interact_fields = {
            0: "user_id:token",
            1: "item_id:token",
            2: "timestamp:float",
        }

        self.item_fields = {
            0: "item_id:token",
            1: "item_name:token",
            2: "item_category_id:token"
        }

        self.user_fields = {
            0: "user_id:token",
            1: "feature1:token",
            2: "feature2:token",
        }

    defload_inter_data(self):
        return pd.read_csv(self.interact_file, delimiter=self.sep, engine="python")

    defload_item_data(self):
        return pd.read_csv(self.item_file, delimiter=self.sep, engine="python")

    defload_user_data(self):
        return pd.read_csv(self.user_file, delimiter=self.sep, engine="python")

3. 配布されているスクリプトを使ってデータを RecBole が読める形式に変換する

https://github.com/RUCAIBox/RecSysDatasets/blob/master/conversion_tools/run.py

ここで公開されているスクリプトを使って RecBole 内で利用できる形式の Atomic Files に変換します。 refs

python src/dataset/convert.py --dataset ckpd_mart \
--input_path data/mart_data --output_path dataset/ckpd_mart \
--convert_inter --convert_item --convert_user

すると ckpd_mart.interckpd_mart.itemckpd_mart.userというファイルが所定の場所に配備されます。これでデータの準備は完了です。

4.学習に必要な設定ファイルを用意する

https://recbole.io/docs/user_guide/config_settings.html

RecBole が用意してくれている config 設定を読みながら自分のデータに合わせた設定ファイルを書いていきます。

# generalgpu_id:0use_gpu:False # GPUを使う時はTRUEにするseed:2020state: INFO
reproducibility:Truedata_path:'dataset/' # 使うデータが格納されている場所checkpoint_dir:'saved/' # モデル保存先show_progress:Truesave_dataset:False # True にすればtrain, valid, test で使ったデータを保存してくれるsave_dataloaders:False# Atomic File Formatfield_separator:"\t"seq_separator:"@" # 文字列があった場合この文字で区切られる。特徴量読み込み時にバグってしまう可能性があるため、できるだけデータを事前に処理しておき絶対に出現しない保障が取れている記号を書くべき(日本語の場合)# Common FeaturesUSER_ID_FIELD: user_id
ITEM_ID_FIELD: item_id
RATING_FIELD:~ # implicit feedback の場合TIME_FIELD: timestamp

# Selectively Loading# 使うデータだけを選んで loadしますload_col:inter:[user_id, item_id, timestamp]user:[user_id, feature1, feature2]item:[item_id, item_name, item_category_id]unused_col: # データとしては読み込むけど学習には使いたくないカラムはここで指定するinter:[timestamp]# Training and evaluation configepochs:50stopping_step:10 # 10 step valid_metric が改善しない場合は止めるtrain_batch_size:4096eval_batch_size:4096neg_sampling: # implicit feedbackなデータを扱っていて positive,negative両方のラベルが必要な手法を試す際に、negative samplingすることでデータを用意できるuniform:1eval_args:group_by: user  # user 単位でアイテムを集約して評価に使う。基本的にこれ以外使うことはないorder: TO  # Temporal Order。時系列順で train, valid, test を分けてくれるsplit:{'RS':[0.8,0.1,0.1]} # 80%, 10%, 10% で分けてくれるmode: full
metrics:['Recall', 'MRR', 'NDCG', 'Hit', 'Precision']topk:10valid_metric: MRR@10  # この指標をtrackするmetric_decimal_place:4

5. 学習スクリプトを実行する

おまたせしました。あとは実験をするだけです。

モデルによって与えるパラメータが微妙に違ったりするので、そこを吸収する以下のようなスクリプト(run_experiment.py)を用意して

import click
from recbole.quick_start import run_recbole

@click.command()
@click.option(
    "-m",
    "--model_name",
    required=True,
    type=str,
    help="Model Name(see recbole's model list)",
)
@click.option(
    "-d",
    "--dataset_name",
    required=True,
    type=str,
    help="Dataset Name(your custom dataset name or recbole's dataset name)",
)
@click.option(
    "-c",
    "--config_file_list",
    required=True,
    nargs=-1,
    help="config file path",
)
defmain(model_name, dataset_name, config_file_list):
    if model_name in [
        "MultiVAE",
        "MultiDAE",
        "MacridVAE",
        "RecVAE",
        "GRU4Rec",
        "NARM",
        "STAMP",
        "NextItNet",
        "TransRec",
        "SASRec",
        "BERT4Rec",
        "SRGNN",
        "GCSAN",
        "GRU4RecF",
        "FOSSIL",
        "SHAN",
        "RepeatNet",
        "HRM",
        "NPE",
    ]:
        # これらは non-sampling method# https://recbole.io/docs/user_guide/model/general/macridvae.html などを参照
        parameter_dict = {
            "neg_sampling": None,
        }
        run_recbole(
            model=model_name,
            dataset=dataset_name,
            config_file_list=config_file_list,
            config_dict=parameter_dict,
        )
    else:
        run_recbole(
            model=model_name, dataset=dataset_name, config_file_list=config_file_list
        )

if __name__ == "__main__":
    main()

あとは python run_experiment.py --dataset_name ckpd_mart --model_name <your favorite model> --config_files config/ckpd_mart.ymlするだけです。お疲れさまでした。

RecBole を試してみた結果

ここまで頑張って用意した土台を使って、早速 RecBole に収録されているモデルをクックパッドマートの購入履歴データ(2021年9月~10月)で試してみました。先程のスクリプトの引数を変えるだけで次々と実験を行うことができます。追加の設定ファイルが必要なものを除いて、50前後のレコメンドモデルを実験することができました。

それでは以下に結果の表を示します。モデル名と各指標、タイプ(行動データしか使わないgeneral・別の情報を使うcontext-aware、時間情報を用いるsequential)、論文名が一覧になっています。

モデル名recall@10mrr@10ndcg@10hit@10precision@10タイプ論文名
RecVAE0.27540.26260.24740.3140.0367generalRecVAE: A New Variational Autoencoder for Top-N Recommendations with Implicit Feedback
MacridVAE0.26510.24880.23640.3030.0347generalMACRo-mIcro Disentangled Variational Auto-Encoder
NAIS0.23240.24520.22440.26980.0325generalNeural Attentive Item Similarity Model for Recommendation
NNCF0.22480.17550.17670.25670.0282generalA Neural Collaborative Filtering Model with Interaction-based Neighborhood
RepeatNet0.27250.14680.17660.27250.0272sequentialRepeatNet: A Repeat Aware Neural Recommendation Machine for Session-based Recommendation.
NeuMF0.23440.16380.16990.2680.0304generalNeural Collaborative Filtering
LINE0.18590.15560.15290.21560.0237generalLINE: Large-scale Information Network Embedding
BPR0.17890.14550.14420.20880.0223generalBPR Bayesian Personalized Ranking from Implicit Feedback
SHAN0.17380.11890.1320.17380.0174sequentialSHAN: Sequential Recommender System based on Hierarchical Attention Network.
Item2vec0.1210.11830.1120.13720.0148generalItem 2 Vec-based Approach to a Recommender System
DGCF0.17030.09650.10990.19310.0201generalDisentangled Graph Collaborative Filtering
FFM0.1870.09220.10960.20990.0225context-awareField-aware Factorization Machines for CTR Prediction
FPMC0.1510.09350.1070.1510.0151sequentialFactorizing personalized Markov chains for next-basket recommendation
NARM0.16640.08470.10390.16640.0166sequentialNeural Attentive Session-based Recommendation
LightGCN0.15490.07940.09520.17150.0174generalLightGCN: Simplifying and Powering Graph Convolution Network for Recommendation
NGCF0.1260.08230.0890.14160.0148generalNeural Graph Collaborative Filtering
SASRec0.11420.06570.07710.11420.0114sequentialSelf-Attentive Sequential Recommendation
HRM0.09920.06840.07560.09920.0099sequentialHRM: Learning Hierarchical Representation Model for Next Basket Recommendation.
EASE0.12050.07520.07510.15590.0204generalEmbarrassingly Shallow Autoencoders for Sparse Data
MultiVAE0.11130.06810.07510.12450.0126generalVariational Autoencoders for Collaborative Filtering
NPE0.1230.05970.07440.1230.0123sequentialNPE: Neural Personalized Embedding for Collaborative Filtering
MultiDAE0.10110.05960.06710.11270.0114generalVariational Autoencoders for Collaborative Filtering
SRGNN0.11150.05150.06540.11150.0112sequentialSession-based Recommendation with Graph Neural Networks
ENMF0.10750.05450.06290.12610.0131generalEfficient Neural Matrix Factorization without Sampling for Recommendation
DCN0.10850.05080.060.12550.013generalDeep & Cross Network for Ad Click Predictions
FOSSIL0.0870.04810.05720.0870.0087sequentialFOSSIL: Fusing Similarity Models with Markov Chains for Sparse Sequential Recommendation.
ItemKNN0.06490.06660.05340.0940.0126generalItem-based top-N recommendation algorithms
DeepFM0.08730.03470.04420.10290.0106context-awareDeepFM: A Factorization-Machine based Neural Network for CTR Prediction
PNN0.08510.03530.04410.09940.0102context-awareProduct-based neural networks for user response prediction
FM0.08170.03250.04120.09610.0098context-awareFactorization Machines
BERT4Rec0.06850.03030.03910.06850.0069sequentialBERT4Rec: Sequential Recommendation with Bidirectional Encoder Representations from Transformer
xDeepFM0.07430.02810.03710.08580.0089context-awarexDeepFM: Combining Explicit and Implicit Feature Interactions for Recommender Systems
NFM0.07360.02880.03690.08670.0088context-awareNeural Factorization Machines for Sparse Predictive Analytics
AutoInt0.07410.02750.03620.08720.0089context-awareAutoInt: Automatic Feature Interaction Learning via Self-Attentive Neural Networks
AFM0.07180.02840.03610.08550.0086context-awareAttentional Factorization Machines: Learning the Weight of Feature Interactions via Attention Networks
FNN0.07030.02740.03490.08230.0083context-awareDeep Learning over Multi-field Categorical Data
GRU4Rec0.06820.02470.03480.06820.0068sequentialImproved Recurrent Neural Networks for Session-based Recommendations
SpectralCF0.07450.02380.03430.08760.0089generalSpectral collaborative filtering
WideDeep0.07040.02610.03420.08370.0085context-awareWide & Deep Learning for Recommender Systems
GCMC0.07650.02290.03410.08910.009generalGraph Convolutional Matrix Completion
DMF0.06330.02760.0340.07670.0078generalDeep Matrix Factorization Models for Recommender Systems
FwFM0.07030.02170.03150.08230.0084context-awareField-weighted Factorization Machines for Click-Through Rate Prediction in Display Advertising
STAMP0.06070.02080.030.06070.0061sequentialSTAMP: Short-Term Attention/Memory Priority Model for Session-based Recommendation
DSSM0.05820.02170.02870.06930.007context-awareLearning deep structured semantic models for web search using clickthrough data
SLIMElastic0.04950.02260.02630.06460.007generalSLIM: Sparse Linear Methods for Top-N Recommender Systems
LR0.05280.01670.02310.0640.0065context-awarePredicting Clicks Estimating the Click-Through Rate for New Ads
Pop0.04740.01360.02010.05640.0057generalなし
CDAE0.00260.00070.0010.00330.0003generalCollaborative Denoising Auto-Encoders for Top-N Recommender Systems

各モデルについて、テストデータに対する以下の指標を掲載しました。 @10は 10個レコメンドを表出した、という意味です。

  • recall ... ユーザが実際に嗜好したアイテムのうち、レコメンドリストでどれくらいカバーできたかの割合
  • precision ... レコメンドリストにあるアイテムのうち、ユーザが嗜好したアイテム(適合アイテム)の割合
  • hits ... 正解のアイテムを一つ以上含むレコメンドリストを作成できた割合
  • mrr ... mean reciprocal rank。レコメンドリストを上位から見て、最初にヒットしたアイテムの順位を逆数にしたものをスコアとする。それを平均したもの。
  • ndcg ... DCG: アイテムをおすすめ順に並べた際の実際のスコアの合計値 を正規化(normalize)したもの

また、いくつかの古典的なモデルを太文字にしています。

  • Pop ... popularity。人気のアイテムを表出する
  • ItemKNN ... アイテム間の類似度を行動履歴から簡単な計算で定義して(コサイン類似度)、それを使って「あるユーザが過去見ていたアイテムに近いアイテムを出す」というもの。2000年代くらいから。
  • BPR ... Bayesian Personalized Ranking 2009年の手法。行列分解をベイズ的なアプローチで解いてランキングを導出する。

今回試したモデルの全てがこれら3つの手法よりも後に発表され、Deep Learningを使い倒すためにGPUを何枚も用意して実績を積んでいます。当然全てのモデルが上回ってほしいところなのですが... 2019 RecSys ベストペーパーで報告された内容とほぼ同じく、古典的な手法(ItemKNNとBPR)は相当強かったです。

さて、他にもこの表からわかることがいくつかあるのでまとめてみました。

  • general(ユーザとアイテムのアクション履歴のみ使う)なモデルに対して、context-aware(ユーザとアイテムのside infomationも使う)・sequential(どの順番で購入したかの順序情報を使う)モデルは総じて低い結果となりました(付加情報を駆使しているのに...)。
  • RecVAEが圧倒的に強かった。これはユーザとアイテムのヒストリーを行列にした上で、 Variational Auto-Encoder というニューラルネットワークで圧縮・復元の学習を行い、ユーザとアイテムのヒストリー行列を正確に復元できるように学習したモデル(+いくつか工夫あり)です。
    • わかりやすい指標である hits@10 を題材にすると、一番良かった RecVAE が 0.3(30%は正解を含んだレコメンドリストを表出できる)だったのに対して、一番下の CDAE は 0.003(0.3%しか正解を含んだレコメンドができない)というのはかなり差が大きいと感じました
    • なおこの RecVAE の数字は、非常に優秀な数字です
  • タスクやデータの難易度に依存するものの、機械学習に取り組み上でモデル変更のみで20ポイント以上指標に差が開くことをみることはあまり多くはない
  • 推薦において、ユーザとアイテムのアクション履歴から情報を引き出すというタスクが、モデルによって得意不得意がはっきり分かれているのだと思う
  • 古典手法 BPR より良かったモデルはわずか 7モデル (50弱のモデルを実験して)

さて、50弱のモデルを実験するのにかかった時間は1日でした。本来であれば作者の参照実装を見に行って、その使い方を学んで、自分の適用したいデータセットをそれに合わせた方式に前処理して、動かそうとしてみてバグにあたって... 一つのモデルを動かすのに1日かかることのほうが多いです(むしろ1日で終わらない)。

それを非常に短い時間で網羅的に実験を行うことができる環境を得られるのは非常に良いことではないでしょうか。

各レコメンドモデルの挙動の違いについて

ではこれらの結果についてもう少し踏み込んでみましょう。以下のモデルについて様々な指標を見てみます。

  • BPR ... 古典的だが優秀な手法
  • ItemKNN ... 古典的だが優秀な手法2
  • Popularity ... 古典手法
  • Item2Vec ... 商品IDを単語、同じセッションで同時に購入された商品群をcontextとみなしてword2vecを学習するモデル → 実装
  • FFM ... Field-aware Factorization Machines。 context-aware モデル
  • RecVAE ... 今回のチャンピオンモデル

推薦リストに一つでも正解が含まれていたユーザ数

hitsを見れば大体わかりますが、グラフにしてみました。

f:id:fufufukakaka:20211102110803p:plain
(テストデータ)推薦リストに一つでも正解が含まれていたユーザ数

圧倒的に RecVAE でした。ちなみに今回のテストデータは 6000ユーザくらい。2位がBPRで古典手法でした。

過去出現したアイテムを推薦して正解している割合

レコメンドにおいて、そのユーザが過去アクションしたことがあるアイテムをどう出すか、はかなり重要です。RepeatNet というリピートに着目したモデルもあるくらい。EC系のサイトでよくあることなのですが、周期的に同じものを買っている、というのがドメインにもよりますが散見されます。マートはその例にもれず、「またあれ買って食べたい」がよく起きるサービスです。ということで、これをできるだけ取りこぼさずに推薦できると非常に良いだろうと推察できます。

ここでは割合を表示します。(過去出現したアイテムを推薦して正解している数)/(推薦が成功した数)

f:id:fufufukakaka:20211102110951p:plain
過去出現したアイテムを推薦して正解している割合

ここで面白いのは、RecVAE・BPRなど上位モデルの値がほとんど同じで90%以上であることです。RecVAEはたくさん推薦を成功させていますが、過去出現したことのあるアイテムを着実に当てて正解数を伸ばしていたということですね。成績の良かったモデルは取りこぼしが少なかった、と言えるかもしれません。

過去出現していないアイテムを表出して正解しているユーザ数

今度は反対に、そのユーザが一度もアクションしたことがないアイテムを表出して、しかもそれが正解だった、という数を見てみます。レコメンドに求められている新規機会創出という役割をまさに表している性能値だとも言えます。

割合にするとさっきと逆のグラフになるので、ここでは絶対数を見てみます。

f:id:fufufukakaka:20211102111012p:plain
過去出現していないアイテムを表出して正解しているユーザ数

200程度、と大分規模は小さくなりましたが相変わらず RecVAE は上位にいます。リピートも見逃さないし、いきなり今まで買ったことがないアイテムを買った、という人に対しても他のモデルよりは良い精度を出せています。

対してBPRはRecVAEの半分程度となっており、ここで差が開いたように思えます。

また、Item2vec は先程のリピートアイテムで推薦を成功した割合を見ると90%以上となっていました。ここでのグラフの数値を見る限り、ほとんどがリピートアイテムを当てることに特化していたようです。

レコメンドのバリエーション

次に、各レコメンドのバリエーション(coverage)を見てみます。バリエーションというのは、全アイテムを分母として、そのモデルが推薦したアイテムのユニーク数を分子とした時の値を指しています。要するに、同じ人気のアイテムばかり推薦していたら低くなります。

f:id:fufufukakaka:20211102111045p:plain
レコメンドモデルが表出するアイテムのバリエーション

  • popularityが一番低いのは、毎回同じアイテムしか出さないため

  • FFM(context-aware)が低い。point-wiseな推定をするモデルであるためだと思われる

    • point-wise ... Factorization Machine系は「こういう特徴を持っているユーザはこういう特徴を持っているアイテムを買うかどうか」という0,1の学習を行い、ユーザごとのアイテム購入確率を出します。その確率をソートしてレコメンドリストを生成するのですが、確率を点推定しているだけなので、順序関係などは全く気にしません。その結果、人気のアイテムの購入確率が高まりそればっかり出てくる、ということがよくあります。
  • 一番カバレッジが高いのは ItemKNN、ついで Item2Vec・RecVAE と続きます
    • ItemKNN ・Item2vec などアイテムの類似度を利用するモデルがいずれもバリエーション豊かな推薦を行う傾向にありました
    • Deep Learning を利用するモデルは学習設定を正しくしないと over fit により出力が偏ってしまうイメージがあったのですが、RecVAE が予想に反しており驚きました

まとめ

以上、RecBole を使ってクックパッドマートでのユーザに対するアイテムレコメンドを行う設定で、内部実験を行った結果をご紹介いたしました。多種多様なレコメンドモデルを比較検討する上で非常に良い選択肢ではないかと思います。開発したレコメンドモデルに対する有用なベンチマークとなるのではないでしょうか。

今後レコメンドが必要になった際にどんなモデルを実装すればよいのかについて、今回の結果を参考にしていきたいと思います。

最後に、クックパッドでは、サービス開発や基盤開発にチャレンジする就業型インターン・そして新卒採用・中途採用を通年で受付けております。気になった方は是非ウェブサイトよりご応募ください。


DroidKaigi 2021 において、「2020年代の WebView 実装」というタイトルで発表しました

$
0
0

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

先日行われた DroidKaigi 2021で「2020年代の WebView 実装」というタイトルで発表させていただいたので、今日はその発表を簡単にまとめようと思います。 当日聴いて頂いた参加者の方、本当にありがとうございました。 流れていくコメントを眺めていると他社でも WebView 増殖はやっぱり発生するんだなあという気持ちが沸き起こり、胸が熱くなりました。

現時点で話せる内容は大体話せたと思うので、この記事では DroidKaigi公式の動画と発表資料の共有、当日うまく答えられなかった質問への回答だけ記載しようと思います。 最高の WebView を作る作業はまだ継続中ですので、完全版 WebView に関しては完成し次第別記事でお知らせいたします。

動画

https://www.youtube.com/watch?v=IOnpHyOg5sc

資料

https://speakerdeck.com/nein37/saikou-no-webview-2021

補足

動画(とコメント)を見直していて、なぜそこまで WebView が必要なのかという部分についてうまく説明できていなかったと思ったので少し補足します。

クックパッドアプリの WebView の用途は大きく分けて3つ存在します。

  1. 新規登録などの一部の特殊な画面
    • 新規登録画面は過去ネイティブで実装されていましたが、2019年に Web ページ側の新規登録フローを改善した際にアプリでも WebView から新規登録するように変更しました
    • この部分の WebView は役割が非常にはっきりしていて分かり易く、クックパッドアプリの WebView の中でもかなり良い使い方だと思います
  2. 課金導線(LP)の表示
    • LPはキャンペーン施策によって画面表示を一斉に、大きく切り替えたい場合があります
    • 利用規約、プライバシーポリシーの変更や無料期間の修正などは即時切り替える必要があります
  3. APIが存在しない機能の表示
    • 古いサービスなのでそういうページもあります…
    • これはリソースを割けばネイティブなUIに置き換えられますが、リソース配分など様々な理由で現在でも WebView として提供しています

このうち、 1.の用途についてはこれまでほとんど問題なく動作していました。 もともとここだけ Kotlin + VIPER で実装されていたという事情もありますが、ネイティブ実装された画面に遷移しづらい新規登録画面ということも大きいと思います。 この画面は WebView でかなりうまく動いているので、これからも WebView のままになると思います(10年間 WebView のままかどうかはわかりませんが)

2.の課金導線ページ表示はかなり複雑です。 アプリ内の様々な箇所から課金導線ページへの遷移があり、課金導線ページ内からもレシピページなどネイティブ実装された画面への遷移が存在します。 課金導線ページはコンテンツの表示をサーバサイドで細かくコントロールしたい事情があるので、これからも WebView で実装され続けることでしょう。 この課金導線ページの表示をできるだけ簡単に実装できるようにするのがクックパッドアプリにおける最高の WebView への道だと考えています。

3.が存在する理由は単純で、 WebView でしか表示できないコンテンツがあるため WebView を使って表示しています。 このパターンのWebページからはネイティブ実装されたレシピ詳細画面や検索結果画面への遷移が頻繁に発生するため、最も複雑な実装になっています。 今回の発表にあった Routing に実装したネイティブ画面への遷移処理のほとんどは、このパターンのWebページの表示のために必要になった実装です。 WebView 以外の解決方法としてネイティブ画面で再実装することもできますが、アプリ開発に使えるリソースは有限です。 アプリが大きくなるにつれて、大きくコストを割けない機能というものはどうしても出てきます。 そういったコンテンツの表示をサポートし、ユーザーに価値を届けられる仕組みとして WebView は必要だと考えています。

クックパッドアプリの実装では 3.用途の WebView に若干 2.の機能が混ざったりしているので、今後の改修でどんどんシンプルで使いやすい WebView にしていく予定です。

最後に

発表の中でもお伝えしましたが、クックパッドではAndroidエンジニアを募集しています!

  • WebView 大好きなエンジニア
    • 一緒に最高のWebViewにしましょう!!!
  • WebView が好きじゃないエンジニア
    • 社内のほとんどのモバイルアプリエンジニアはそうなので大丈夫です!
  • WebView にできれば触りたくないエンジニア
    • こやまカニ大好き以外のメンバーは今ほとんど WebView の実装には触ってないので大丈夫です!

上記に該当しない方でも募集中なので気軽にご応募下さい

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

既存実装を活用しつつJetpack Composeを用いてクックパッドAndroidアプリの買い物機能を高速に開発している話

$
0
0

こんにちは、クックパッド事業本部 買物サービス開発部の佐藤(@n_atmark)です。 2019年新卒で入社後、クックパッドの新規サービスである「クックパッドマート」の開発に従事しており、2020年からはレシピサービス「クックパッド」iOSアプリの買い物機能を開発していました。

レシピサービス「クックパッド」の買い物機能は、iOSアプリ(以下: クックパッドiOS)で先行リリースしており、現在Androidアプリ(以下: クックパッドAndroid)でも同様の機能開発を行っています。 本稿ではクックパッドAndroidの買い物機能の開発について紹介します。

買い物機能とは

生鮮食品ECサービス「クックパッドマート」の仕組みと連携し、レシピサービス「クックパッド」のアプリから食材を注文できます*1

詳しくはiOSアプリ向けの買い物機能の開発について紹介している​​ SwiftUI を活用した「レシピ」×「買い物」の新機能開発をご覧ください。

画面スクリーンショット
買い物機能の画面例

なぜやるのか

クックパッドで目指している「毎日の料理を楽しみにする」の実現のためです。

現状のレシピサービスでは、料理に制約があります。冷蔵庫にある材料からレシピを探し、冷蔵庫にあるもので料理をつくる ━━ この流れは非常によくある形ですが、「冷蔵庫にある食材」に制約された窮屈な体験とも言えます。

あり物の食材からレシピを探している図
冷蔵庫にある材料からレシピを探す体験

楽しみが広がる、まったく新しい「買い物」を

クックパッドマートは、流通の仕組みから開発しているまったく新しい生鮮食品ECサービスです。

  • 1品からでも送料無料
  • 価格も割安
  • 新鮮
  • 品揃えも豊富
  • 受け取りも楽にできる

cookpad-mart.com

━━ そのような新たな買い物手段があることで、これまでになかった料理の選択肢や楽しみが広がります。

この新たな「買い物」をレシピサービスにうまく融合させることで、今まで以上に料理を楽しみにできると考えています。

例えば、「めずらしい野菜で作るハロウィン料理、気になる!」「バターナッツかぼちゃ美味しそう!食べてみたい!」のように、食べたいものを起点に料理を作りたくなる体験もその一つです。

ハロウィン料理にぴったりの珍しい野菜が載っているスクリーンショット
食べたいものを起点に料理を作りたくなる体験

この新しい「レシピ」×「買い物」の体験は現在クックパッドiOSでのみ提供していますが、既に多くのユーザーから好評をいただいています。もっと多くのユーザーさんに利用していただけるようにするため、今回クックパッドAndroidにも買い物機能を追加することになりました。

買い物機能を最速でリリースするために選択したこと

「レシピ」×「買い物」の新しい体験はまだまだ成熟しておらず、日々チーム内で新しいアイデアを議論したり、ユーザーインタビューを繰り返しています。

note.com

チームとしてはまだまだ体験を突き詰めたいフェーズなので、利用できるユーザーを増やすためのAndroidアプリ開発だけに全リソースを投入できる状況ではありません。そのため、最低限のリソースで最速にリリースを行い、リリースされてからの体験改善に時間やリソースを使いたいと考えています。

再利用可能なAndroid実装の活用

冒頭でも紹介した通り、今回開発中の買い物機能は生鮮食品ECサービス「クックパッドマート」の仕組みを利用しています。

クックパッドマートの利用に特化した専用アプリ(以下: マートAndroid)も既に開発されています。

下のスクリーンショットは、マートAndroidとクックパッドAndroidの買い物機能のそれぞれの商品詳細画面です。

マートAndroid
クックパッドAndroid

画面の表示内容や方法はデザイン上、似通っていることがわかると思います。 画面構成が同じ箇所に関しては実装の使い回しができるのではないかと考えました。

宣言的UIフレームワークの活用

SwiftUI を活用した「レシピ」×「買い物」の新機能開発にもあるように、クックパッドiOSの買い物機能は SwiftUI を用いて開発されています。約1年半 SwiftUI を用いてサービス開発をしていたこともあり、チーム開発で宣言的UIフレームワークを活用するためのノウハウも溜まってきていました。

一方、Android界隈でも Jetpack Compose が stable release され、クックパッドAndroidの開発にも Jetpack Compose が既に利用され始めていました。

SwiftUI でのノウハウを踏まえて、Jetpack Compose を活用することで実装コード量を減らすことや、プレビューを用いてビルドサイクルを短くすることで、素早く開発を進められるのではないかと考えました。

方針

これらの状況を踏まえて、クックパッドAndroidの買い物機能では Jetpack Compose を利用することで

  • マートAndroidの既存リソースを再利用できる
  • クックパッドiOSの買い物機能で培った宣言的UIフレームワーク活用の知見を利用できる

というメリットを活かす形で開発を始めました。

Jetpack Compose にはナビゲーションを行うためのコンポーネントなどもありますが、それらは使わずに素朴なUIコンポーネントのみをView部分で利用し、画面遷移などは既存のVIPERアーキテクチャ*2にのっかる方針をとっています。

これはクックパッドiOSの買い物機能と同様の設計方針です。VIPERアーキテクチャの View 部分にのみ SwiftUI を利用する設計を1年半続けた上で大きなハマりもなくメリットを享受できているためです。(詳しくは: SwiftUI を活用した「レシピ」×「買い物」の新機能開発

また、今回開発速度を重視するに当たって、画面のUIを意図的にクックパッドiOSの買い物機能ではなくマートAndroidに寄せている部分があります。

マートAndroidはほぼ全ての画面で RecyclerView を利用しており、各アイテムは Layout XML を用いた従来の View で実装されています。(詳しくは: クックパッドマートAndroidアプリの画面実装を最高にした話【連載:クックパッドマート開発の裏側 vol.4】

この特徴を生かして、Jetpack Compose における相互運用 API として用意されているAndroidView/AndroidViewBinding*3を用いることで、マートAndroid で画面を構成している Layout XML をそのままクックパッドAndroidで利用することで開発速度の向上を図っています。

実装について

Jetpack Compose版VIPERベースのアーキテクチャ

Jetpack Compose版アーキテクチャに関して、2020年のクックパッドAndroidアプリのアーキテクチャ事情から一部変更している箇所があるので、変更点について紹介します。

アーキテクチャの図
Jetpack Compose版アーキテクチャ

Presentationレイヤーについて

2020年のクックパッドAndroidアプリのアーキテクチャ事情ではInteractor や Routing との連係は Presenter が行っており、ViewModel は View 実装の一部という扱いで Contract には処理を記載していませんでした。Jetpack Compose版アーキテクチャでは Presenter は廃止され、Interactor や Routing との連係は新たにContract に記載された ViewModel(実態は AAC の ViewModel)が行うようになっています。

データフローについて

2020年のクックパッドAndroidアプリのアーキテクチャ事情では Rx を用いてレイヤー間の連係を行っていましたが、Jetpack Compose 版アーキテクチャでは Kotlin Coroutines を使用しています。

基盤実装としての ApiClient は Rx ベースで実装されているため、DataStore で kotlinx.coroutines.rx2.awaitを用いて Rx の Single を suspend 関数に変換しています

import kotlinx.coroutines.rx2.await
import javax.inject.Inject

class ApiProductsDataStore @Injectconstructor(
    privateval apiClient: ApiClient
) : ProductsDataStore {
    overridesuspendfun getProducts(): List<Product> {
        return apiClient.get(path = "/products")
            .await()
            .decodeJSONArray()
    }
}

UI層でのみJetpack Composeを活用する実装に関して

上述のようなアーキテクチャの差分を踏まえた上で、ViewとViewModelの連係について紹介します。

View層の概略図
Jetpack Compose を組み込んだVIPER View 層の概略図

Activity/Fragment が ComposeView として Screen-suffix の Composable 関数を持つような形をとっています。 Screen-suffix の Composable 関数には ViewModel を渡しており、Composable 関数からのクリックイベントなどは ViewModel の関数を呼び出すようにしています。また画面表示のために、ViewModel は StateFlow を公開しており、StateFlow の状態変化に応じて画面を更新できるようにしています(詳しくは後述)。

UILayerの実装に関して

クックパッドAndroidでは Activity/Fragment を用いた View ベースの設計になっています。そのため、Jetpack Composeの相互運用 APIとして用意されている setContent関数を呼び出して、Compose ベースの UI を追加しています。

Activity

class ProductListActivity : RoboAppCompatActivity() {

    privateval viewModel: ProductListCreateViewModel
        by lazy { ViewModelProvider(this).get(ProductListCreateViewModel::class.java) }

    overridefun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ProductListScreen(viewModel)
        }
    }
}

Fragment

@AndroidEntryPointclass ProductListFragment : Fragment() {
    @Injectlateinitvar viewModelFactory: ViewModelFactoryProvider<ProductListViewModel>

    privateval viewModel: ProductListViewModel by viewModels { viewModelFactory }

    overridefun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setContent {
                ProductListScreen(viewModel)
            }
        }
    }

    companionobject {
        fun newInstance() = ProductListFragment()
    }
}

ViewModelの実装に関して

ViewModel では Interactor/Routingの連係と、UI の状態と表示するコンテンツを管理しています。 実装クラスは ViewModel (Jetpack) が担当し、ViewModel interface に基づいた画面を実装します。 MutableStateFlow によって画面の状態を管理しますが、状態の変更は内部で行い外部には StateFlow を公開しています。

class ProductListViewModel @Injectconstructor(
    privateval interactor: ProductListContract.Interactor,
    privateval routing: ProductListContract.Routing
) : ViewModel(), ProductListContract.ViewModel {

    privatevar _state = MutableStateFlow<ScreenState>(ScreenState.Loading)

    overrideval state = _state.asStateFlow()

    init {
        fetchProducts()
    }

    overridefun onProductDetailPageRequested(productId: Long) {
        routing.navigateProductDetail(productId)
    }

    privatefun fetchProducts() = viewModelScope.launch {
        runCatching {
            interactor.fetchProducts()
        }
        .fold(
            onSuccess = {
                _state.value = ScreenState.Idle(
                    ProductListContract.ViewModel.ScreenContent(
                        products = it
                    ) 
                )
            },
            onFailure = {
                _state.value = ScreenState.Error(
                    reason = it.errorStatus.reason,
                    reloadAction = ::reload
                )
            }
        )
    }
}

Screenの実装に関して

Activity/Fragment が ComposeView として表示している Composable 関数を Screen という命名にしています。

@Composablefun ProductListScreen(
    viewModel: ProductListContract.ViewModel
) {
    val state = viewModel.state.collectAsState()
    AsyncLoadSurface(state = state.value) { content: ProductListContract.ViewModel.ScreenContent ->
        ProductListScreenContent(
            products = screenState.products,
            onTapProduct = viewModel::onProductDetailPageRequested
        )
    }
}
 
@Composableprivatefun ProductListScreenContent(
    products: List<Product>,
    onTapProduct: (Long) ->Unit
) {
    LazyColumn {
        item {
            HeaderSection()
        }
        items(products) { product ->
            ProductSection(
                product = product,
                onTapProduct = onTapProduct
            )
        }
        item {
            FooterSection()
        }
    }
}

@Composableprivatefun ProductSection(product: Product, onTapProduct: (Long) ->Unit) {
    AndroidViewBinding(
        factory = ViewProductSectionBinding::inflate,
        modifier = Modifier
            .background(CookpadColor.Ivory)
            .padding(horizontal = 20.dp)
    ) {
        textView.text = product.name
        button.setOnClickListener {
            onTapProduct(product.id)
        }
    }
}

買い物機能は基本縦スクロールの画面構成となるため、画面実装は LazyColumn をベースにした画面実装にしています。 意味のあるまとまりで画面を要素分割し、LazyColumn の item に要素ごとのまとまりを入れています。この item の各要素をSection という単位で分割しています。

画面をSectionで分割しているスクリーンショット
LazyColumnのitem単位の分割

Section の中で実装している画面は、既存リソースがある場合は AndroidView/AndroidViewBinding を用いて再利用を行い、新規で実装が必要な部分に関しては Jetpack Compose を利用してレイアウトを作成しています。

実際どうだったのか

うまくいった点

  • Jetpack Compose ベースのUI構築をしたことで、RecyclerView を用いる際の Adapter/ViewHolder を書く必要がなくなり、記述量が削減されることで開発速度の向上に繋がった
  • LazyColumn の中身を Section という単位で分割する指針を早いうちに決められた
    • これによって、AndroidView/AndroidViewBinding を用いて Layout XML や Android View などの既存リソースを使う場合も、RecyclerView を利用する際のリストアイテムを苦労なくJetpack Composeベースのレイアウトに組み込むことができた
  • SwiftUI でチーム開発している時に課題となったコンポーネントの粒度について早めに共通認識をつくることで、読みやすく保守しやすい状態を維持して開発できた*4

うまくいかなかった点

  • Jetpack Compose に慣れてない段階では、既存の Layout XML をコピーして一部を変更する方が速かったので、AndroidViewBinding を活用してレイアウトできることがメリットになっていた
    • しかし、Jetpack Compose に慣れてくると、該当画面を探してコピーしてくるより、一から Jetpack Compose で書く方が速くなり、恩恵を受けていたのは最初のうちだけだった

まとめ

買い物機能を最速でリリースするために、利用可能な既存実装の再利用や Jetpack Compose を用いて、速度向上を図っている事例を紹介しました。買い物機能は2022年頭にファーストリリースを目指して絶賛開発中です。今後も開発状況を発信していきたいと思っていますので、ぜひ応援よろしくお願いします。 リリースされたらぜひ使ってみてくださいね!

クックパッドでは一緒に働く仲間を募集しています!

今回は買い物機能の開発にあたっての工夫や Jetpack Compose の活用事例についてご紹介しました。

新しいフレームワークを活用して、高速にサービス開発を進めることで事業をドライブしたいエンジニアを大募集しています。

カジュアル面談や学生インターンシップなども随時実施していますので、ぜひお気軽にご連絡ください。

info.cookpad.com

*1:近隣地域の生産者や市場直送の新鮮でおいしい食材を、1品から送料無料で購入できる。https://info.cookpad.com/pr/news/press_2020_1015

*2:クックパッドAndroidアプリはVIPERアーキテクチャを用いて開発されています。 詳しくは 2020年のクックパッドAndroidアプリのアーキテクチャ事情を参照ください。

*3:AndroidView/AndroidViewBindingはJetpack Composeの相互運用 APIとして用意されているもので、AndroidView や XML Layout を Compose UI のレイアウト階層に含めることができる。

*4:コンポーネント粒度に関して共通認識をつくるための取り組みに関して チームでSwiftUIを書くために ~読みやすく保守しやすい設計について考えたこと~にまとめていますので、こちらも参照ください。

Redshiftのデータをサービス改善に役立てるデータ転送システム Queuery

$
0
0

こんにちは、技術部データ基盤グループの佐藤です。この記事では最近業務として主に取り組んでいたDWHから外部へのデータ転送基盤であるQueuery(きゅうり)について、OSSとしてGitHubへの公開しましたのでこの記事でご紹介をします。

github.com

Queueryというシステムは2017年の春頃にid:koba789の手により作られ、クックパッドのデータ基盤における重要な立ち位置を担っています。

背景

従来、RedshiftでSELECT文などの取得系クエリを実行するためにはRedshiftに直接接続してクエリを発行していました。この方法ではクエリ結果が巨大な場合にクライアント側のリソースを逼迫させることがありました。

しかし、それを避けるためにカーソルを使おうものなら今度はたちまちRedshiftのリーダーノードの具合が悪くなってしまいます。Redshiftから巨大な結果を得るクエリを外部から実行するためには様々な工夫が必要でした。

さらに通常の(PostgreSQLプロトコルを使った)接続方式では遠隔地(別AWSリージョン)からの接続が難しかったり、よくコネクションが切れたり、コネクションが切れると結果が取得できなかったりします。AWSのSecurity Groupの設定も忘れがちです。 また、セットアップがActiveRecord経由になるため単純に設定が面倒です。しかもActiveRecordを使ったあらゆる使い方ができてしまうため、標準化も困難です。

Queueryはこれらの問題を解決するためにあります。Queueryを使うことで、クライアントはRedshiftに直接接続せずHTTP APIで取得系クエリを実行できるようになります。

f:id:ragi256:20211202115017p:plain
アプリケーションからRedshiftへ接続する手法

仕組み

QueueryはRedshiftへUnload文を投げる役割を持つAPIサーバーと、Unload結果をS3から取得するクライアントに分かれています。クライアント側から投げられたSELECTクエリをHTTP API側で受け取り、Unload文へラップしてRedshiftに投げます。クライアント側はその結果をポーリングし続け、Unloadが完了したらS3へアクセスして結果を取得するようになっています。

できうる限りQueuery利用者の開発を単純化するため、クライアントはgem化されており、Gemfileに追加して設定ファイルを追加すればすぐ利用できるようになっています。

クライアントのサンプルコード

下記のコードをクライアント側でジョブに書き、必要なタイミングでバッチ実行するだけでRedshiftにあるデータを扱えるようになります。

Queueryの設定ファイル

# configurationRedshiftConnector.logger = Logger.new($stdout)
GarageClient.configure do |config|
  config.name = "queuery-example"endQueueryClient.configure do |config|
  config.endpoint = 'queuery_api_server_host'
  config.token = 'XXXXXXXXXXXXXXXXXXXXX'
  config.token_secret = '*******************'end

Queueryのクライアントコード

select_stmt = 'select column_a, column_b from the_great_table; -- an awesome query shows amazing fact up'
bundle = QueueryClient.query(select_stmt)
bundle.each do |row|
  # do some useful works
  p row
end

コンソール

また、簡易的なものではありますがWebコンソールも付属で用意してあり、コンソールではクライアント側の認証に必要なトークンを発行・無効化したり、直近でQueueryに投げられたクエリの様子を確認できます。

f:id:ragi256:20211202111451p:plain
QueueryのWebコンソール画面の例

QueruryサーバーのAPI側はシンプルなRailsで作られており、コンソールのフロントエンドはTypeScriptとReactでSPAにしています。

最近の改修内容

Queueryを紹介するついでに今年自分が改修を行った箇所について書いておきます。

QueueryアカウントとRedshiftユーザーの紐付け

以前はQueueryのコンソールから好きな名前のQueueryアカウントを誰でも作ることができ、既存Queueryアカウントの認証用トークンを誰でも有効・無効切り替えができる仕様になっていました。また、RedshiftでのUnload文実行はQueuery専用に用意された1つのRedshiftユーザーによって行われていました。

このままでは社員の誰かが他チームのQueueryアカウントに手を加えてしまう恐れがあります。また、Unloadに使うユーザーの権限をQueueryアカウント毎、個別に分けることもできません。DWHに関するDevOpsを進めていく一環として、利用者の権限をきちんと分離し、Queueryアカウントをそのアカウント所有者・所有チームのRedshiftユーザーにしか扱えないようにする必要がありました。

そこで、Queueryアカウントについて、作成・認証用トークン作成、削除のタイミングでRedshiftユーザーの認証を求めるようにしました。認証作業自体はRedshiftにチェック用の単純なクエリを直接を投げるのみとし、本人確認がとれればユーザー名のみ記録することとします。 その後、実際のUnload文実行時には登録されたユーザー名を使ってGetClusterCredentials APIで一時的なユーザーを作成することにしました。

temporal_credential = Aws::Redshift::Client.new.get_cluster_credentials({
  db_user: redshift_user,
  db_name: database_name,
  cluster_identifier: cluster_identifier,
  auto_create: false
})

ds.config.merge!(username: temporal_credential.db_user, password: temporal_credential.db_password)
export_execute(datasource: ds, query_statement: sql, logger: logger)

こうすることでQueueryアカウントの管理は所有者であるRedshiftユーザーのみが行え、アカウント毎のクエリ実行はそのアカウントに紐付けられたRedshiftユーザーに基づいて実行されるようになりました。

ただし、現状ではRedshiftユーザーとQueueryアカウントとの2重管理になっており、権限管理が無用に複雑化しているという問題も抱えています。この点については今後Queuery側でのアカウント管理をやめ、RedshiftユーザーをそのままQueuery側のアカウントとして扱えるようにしようかと検討しています。

Unload文のmanifest.jsonを使った型キャスト

これまでQueueryにより出力されたファイルは全て圧縮&分割されたCSVとしてS3に出力されており、Queueryクライアントではその型を自動判別することができませんでした。そのため、Queueryクライアントを利用する開発者は取得した結果に対して手動で型キャストを行うコードを書く必要がありました。

RedshiftのUnload文には様々なオプションがあり、その中にはUnload結果に関するメタ情報を出力する、MANIFESTオプションがあります。
https://docs.aws.amazon.com/ja_jp/redshift/latest/dg/r_UNLOAD.html

このオプションにより出力されるJSON形式のマニフェストファイルの中には列名とデータ型に関する情報が含まれています。このマニフェストファイルを読み、自動で各カラムの型を判別して型キャストができるよう、QueueryサーバーとQueueryクライアントの両方に改修を行いました。

sql = "selectt 1, 1::bigint, 1.0, 'hoge', false, date '2021-01-01', timestamp '2021-01-01 00:00:00', null"

bundle1 = QueueryClient.query(sql) # 従来
bundle1.each do |row|
  p row # => ["1", "1", "1.0", "hoge", "f", "2021-01-01", "2021-01-01 00:00:00", ""]end

bundle2 = QueueryClient.query(sql, enable_cast: true) # 型キャストオプション追加
bundle2.each do |row|
  p row # => [1, 1, 1.0, "hoge", false, Fri, 01 Jan 2021, "2021-01-01 00:00:00", nil]end

また、副産物として従来では文字列型の空文字列と区別しづらかったnullをきちんと区別できるようになりました。

BarbequeからRedshift DataAPIへの非同期処理移行

QueueryではSQLを受け付けてからUnload文の実行結果を返却するまで、処理時間はSQLの内容に依存しています。SQLによっては非常に時間がかかってしまうため、非同期化をする必要がありました。そこで、元々はBarbequeというキューシステムを利用してジョブの非同期化をしていました。BarbequeはDockerとSQSを利用したジョブキューシステムです。

以前はこれでうまくいっていたのですが、2020年4月に起きたSQS障害で影響を受けたことや、Queueryの構成が複雑化していたことなどもあり、もっとシンプルで頑健性の高い仕組みにできないかと考えられていました。 そこで、2020年にRedshift Data APIが発表され、そのAPIに含まれるexecuteStatementdescribeStatementを利用すればBarbeque依存を外せそうだという案が上がりました。調査したところ非同期処理の周辺をこちらで保つ必要がなく、Queueryの構成をシンプル化できそうだということがわかりました。

f:id:ragi256:20211202115057p:plain
移行によるQueuery構成の変化

移行後は特に問題らしい問題が発生すること無く安定して稼働し、無事Barbequeからの依存を取り除くことができました。

Queueryと弊社データ基盤の構成

そもそもRedshift Data APIが扱えるのであれば、各開発者が自由にexecuteStatementをし、各自がUnloadをすればよいのではないか? そうすればこのシステムと運用は不要になるのではないかという意見もあるかと思います。

「背景」に書いたような理由から単にデータ取得をUnload文に絞りたいというのも理由にありますが、本当はもっと根本的な理由もあります。 弊社データ基盤では権限管理やデータガバナンスなどの運用観点から、設計思想にもとづくいくつかのポリシーがあります。(下記は一部抜粋で、他にもこういったポリシーもあります)

  1. Redshift内部がカオス化するのを避けるため、Redshiftへの書き込みはDWHチームが管理しやすいよう手段を限定する
  2. Redshiftへのバルク&ストリーミングロード、DWH内部のETLバッチ(集計処理など)、外部へのデータ転送は各種専用ツールを使ってワークフローを分ける
  3. できる限り自動化を進め、権限を移譲できる部分はできる限り強い権限を各チームに移譲し、各自でやってもらう

弊社がQueueryやBricolageといったDWH用ツールを作り、運用している理由はここにあります。DWHチームによる中央集権ではなく、できる限り民主的なデータ活用を推進していくにあたって、無秩序や混沌を避けるための必要な施策がDWH周辺ツールの充実でした。Queueryもまたその1つです。

Queueryを扱うことで社内の開発者誰もが気軽にRedshiftを活用できるようにしつつも、DWHチームによるデータフロー把握や障害時対応がしやすくなります。Redshiftからのデータ取得手段をQueueryに絞ってしまうことで何か不便であったり問題が発生するようなことがあれば、その都度上記のポリシーを考慮しつつチームで解決策を考え、実装していけばいいという方針です。

つまり、利用者の権限を緩めて自由に利用してもらいつつも、必要なところは手段を固定し、DWHチームによる運用負荷を減らすために必要だったということです。

DWH基盤を整えるためのエコシステムとQueuery

2021年を通して上記のような改修作業を続け、活発な開発が行われてきたQueueryでしたがOSSとしてGitHubに公開されていたのはクライアント側の実装のみでした。開発を続けてきて構成もシンプル化できたこともあり、今回OSSとしてサーバー側の実装も公開することとしました。これで、Redshiftに対するbatchシステム用ツールファミリー bricolages以下にQueuery周辺ツールが全て揃いました。

(※ redshift_connectorはRedshiftからデータを取得した後、ActiveRecordを利用してRDBMSのテーブルを簡単に更新できるようにするgemです)

Techlifeでも何度かご紹介している(2017年版2019年版2020年版)通り、弊社データ基盤グループはRedshiftを中心としてDWHとその周辺システムを構成しています。Redshiftを活用したデータ基盤構築するために必要なツール群のほとんどは内製であり、ツールを組み合わせて運用しています。

今回、また1つQueueryというデータ基盤を構築するエコシステムの一部を新たに公開することができました。クックパッドではDWHだけに留まらず、bdash-serverDmemoなどの多くのデータ関連ツールをOSSとして開発し、公開しています。これらのツールがより多くの人に使われ、活発な開発のもと相互に連携し扱いやすいエコシステムを形成する未来が訪れれば良いと考えています。

Ruby 3.1はエラー表示をちょっと親切にします

$
0
0

こんにちは、ruby-devチームの遠藤(@mametter)です。 Among Usというゲームをやってるのですが、友達が少なくてあまり開催できないのが悩みです。

今日は、Ruby 3.1に導入される予定のerror_highlightという機能を紹介します。

どんな機能?

NoMethodErrorが起きたとき、次のような表示が出るようになります。

f:id:ku-ma-me:20211201172801p:plain
error_highlightの動作例

どこのメソッド呼び出しで失敗したかが一目瞭然ですね。これだけの機能ですが、使ってみると意外と便利です。

もう少し詳しく

この機能が本領を発揮するのは、RailsのparamsやJSONデータの取り扱いなどのときです。 たとえばjson[:articles][:title]みたいなコードを書いて、undefined method '[]' for nil:NilClassという例外が出たとします。 このとき、変数jsonnilだったのか、json[:articles]の返り値がnilだったのかは、残念ながらコードだけ見ても判断できません。 特定するには、デバッグ出力を挟んで再実行する必要がありました。

error_highlightがあると、これがひと目で判別できます。

$ ruby test.rb
test.rb:2:in `<main>': undefined method `[]' for nil:NilClass (NoMethodError)

title = json[:articles][:title]
            ^^^^^^^^^^^

↑は、jsonnilだったケースです。

$ ruby test.rb
test.rb:2:in `<main>': undefined method `[]' for nil:NilClass (NoMethodError)

title = json[:articles][:title]
                       ^^^^^^^^

↑は、json[:articles]nilを返したことがわかります。

実装について

error_highlightはRubyインタプリタの実装に深く関わってます。ざっくりイメージで紹介します。

Rubyは、プログラムを抽象構文木に変換し、それをバイトコードにコンパイルした上で実行しています。 たとえば json[:articles][:title]というコードは、次のような抽象構文木(イメージ)に変換されます。

f:id:ku-ma-me:20211201163014p:plain
Rubyの抽象構文木(イメージ)

それぞれの四角は抽象構文木のノードを表します。ノードは、メソッド名などの付加情報や、レシーバや引数などの子ノードへの参照を持ちます。それに加え、ノードは"ID"と"column"という情報を持っています。"ID"はノードを一意に特定する番号です。"column"は、そのノードに対応するコードの範囲を表しています*1

それから、この抽象構文木をおおよそ次のようなバイトコード(イメージ)にコンパイルします。RubyのVMはスタックマシンで、まあなんとなく読めるかと思います*2

1: getlocal  :json     # ID: 3
2: putobject :articles # ID: 4
3: send :[]            # ID: 2
4: putobject :title    # ID: 5
5: send :[]            # ID: 1

このとき、各命令が由来となったノードのIDを持っているのがRuby 3.1で新たに実装したところで、error_highlightの肝になります。

json[:articles]nilを返し、nilに対して[]メソッドを呼んでしまった場合、5番目のsend命令が失敗します。このとき、5番目の命令は"ID: 1"という参照を持っているので、どのノードで実行失敗したのかがわかります。そして、抽象構文木のノードには"column"情報があるので、コード中のどの範囲でエラーが起きたかという情報を得ることができます。

ID 1のノードのcolumnは 0...23 となっていて、これは json[:articles][:title]という文字列全体の範囲に対応しています。この全体に下線を引くとどこでエラーが起きたかわかりにくいので、error_highlightはなるべくメソッド名の位置を特定して線を引くようにしています。大まかに言えば、レシーバの子ノードである ID 2のノードの終端より後、つまり [:title]の下にだけ下線を引くようになっています。

クレジットと裏話

ノードにカラム情報をもたせるようにしたのはyui-knkさんです(RubyKaigi 2018の発表)。 error_highlightの原型もyui-knkさんが作っていたのですが、放置状態になっていたので、今回遠藤が引き取って完成させました。

なぜ引き取ったかと言うと、RubyKaigi Takeout 2021で遠藤が発表したTypeProf for IDEの実装のために必要だったからです。 IDEではエラー箇所をカラム単位で下線を引いて示すのが普通なので、ほぼ同じ機構が必要でした。 そのために必要な拡張を実装すると、そのおまけとしてerror_highlightが実現可能になりました。

命令ごとに由来となったノードIDを記録するためには、多少メモリを消費してしまいます*3*4。 ここは、開発体験向上とのトレードオフでした。 各命令にノードIDではなくカラム位置自体をもたせてしまう方法もあるのですが、よりメモリ消費量が大きくなるので、抽象構文木を経由して位置を特定する現在の方法になりました。

なお、抽象構文木自体はメモリに保存されておらず、必要になった時(つまりエラーが発生したとき)に、ファイルにあるソースコードを再度読み込んでパースし直しています。 このアプローチでは、ソースファイルが書き換えられた場合などはノードを正しく同定できなくなる可能性があります。 また、evalで実行されているソースコードについてはerror_highlightは動きません(このため、現在のところirbでは動きません)。 現在のところ隠しコマンドですが、RubyVM.keep_script_lines = trueとすると、インタプリタがソースコードを保持し続けるようになり、ソースコードの再読み込みは行われなくなり、evalについてもerror_highlightが動くようになります。

まとめ

Ruby 3.1では、NoMethodErrorのエラー表示がちょっと親切になります。 ささやかな改良なので過剰に期待されると困りますが、「Ruby 3.1.0-preview1を実際に使ってみたら予想外に開発体験がよくなった」という声をちらほら頂いているので、ほどほどにご期待ください。

*1:実際にはノードはカラム番号だけでなく、行番号も持っていますが、省略しています。

*2:getlocalはローカル変数読み出し、putobjectは即値オブジェクトのpush、sendはメソッド呼び出しです。

*3:機能提案時点の実験では、rails newして作ったWebアプリのメモリ消費量が97 MBから100 MBくらいに増えました。

*4:余談ですがノードIDの記録方法は、ちょっと工夫されています。以前書いた記事『簡潔ビットベクトルでRubyをlog N倍速くした』をご参照ください。

「サービス開発とその進め方」というタイトルで授業をしました

$
0
0

こんにちは、クリエイション開発部の橋本和幸(@funwarioisii)です。今期は同じ本部に橋本さんが3人いたので3倍反応しました。

12月2日に(一昨年私が卒業した)岩手県立大学で、「サービス開発とその進め方」について講義をしました。*1

この記事では「何を提供しようとしたか」「どういう反応が得られたか」を紹介します。 それでは背景から説明していきます。

背景

授業の立て付け

授業の名前はシステムデザインPBLで、デザイン思考を学び、アジャイルに開発してみようというものでした。 外部からの講師を招待し、どういう開発をしているかの話が聞ける授業でもありました。特にアジャイルやデザイン思考など、現在の大学の枠組みで教えることが難しいものを中心に扱う授業でした。

私も学生時代に受講していて、コードを書くというよりモノを作ってる感じがして、つらくも楽しい授業でした。 一方で何か物足りず、何かが欠けていたような気がしていました。 それは「何を作るかの決め方」、つまり企画の話でした。この視点は入社し、働くうちに得られたと思い、授業ではそれを補完することを個人的な目標としました。

発言の気まずさ

大学の授業、特に外部講師の授業を思い出してみると、最後の質問コーナーは気まずい雰囲気が流れがちだったのはどこの大学も同じでしょうか。 明日も顔を合わせる人(同期や後輩など)の前で、ちょっと間の抜けた質問なんかはできないな…という意識が働いたのを思い出します。

また、授業中に講師が学生に問いかけると、みんな無表情でフリーズを起こす、あの時間が苦手でした。話しかける側からすると、理解がどのくらいまでされているのかを知りたいのですが、話しかけられた側はその講師との関係性が1対1ではなく、周りに他の学生がいます。上述の通り明日からのこともあるものですから、油断した返答はできません。緊張した雰囲気が流れます。 授業中に大学で何かを発言するのはリスキーな行為でした。

何を提供しようとしたか

なので、以下の2点を提供するような授業を考えていました。

  1. 授業で話されていない企画の話
  2. 雑に発言できる場

授業で話されていない企画の話

どういう話を提供したかというと、最初に貼ったスライドの通り、サービス開発の進め方についてです。

特にスライドに入れたかったトピックは学生時代にわからなかった、「どうアイデアを出すか」「いいユーザ体験を生むためにコードを書かずにできることは何か」でした。 この2つの話だけをしても仕方がないので、やはりサービス開発全体の話に盛り込む必要があると判断しました。

さらっとサービス開発というものを説明しても良いのですが、自分ごとに考えられると理解が深まると思ったので、シナリオを設定しました。 「サービス開発は知らない人にプレゼント」というのは弊社でよく語られる喩えですが、これを少し改変することで、自分ごととして考えやすくなりそうなので、これを説明の軸にしました。

雑に発言できる場

授業中にリスクなく発言できる場もほしいです。 眠い授業をしたくないし、ひっそり盛り上がると楽しいじゃないですか。 そのため、私は以下の3点が必要だと思っていました。

  • 完全に匿名
  • 盛り上がってる感がある
  • 簡易なリアクションができる

また、最後に質疑応答のコーナーを設ける必要がありました。 大量に質問が来た時に備えて「いいね順に並び替え」があると便利そうでした。

これらを満たしそうな既存のサービスを調べてみたのですが、コメントに投票できそうなものは見つけられませんでした。 なので直前に Flutter Web と Firebase で作りました。 ビルドしたものを Firebase Hosting に置いて、 Firestore を読み書きするシンプルな構成のチャットアプリです。

これを授業前にQRコードとURLをスクリーンに掲出し、案内しました。 画像は授業で使ったものです。

授業で使ったチャットアプリ
授業で使ったチャットアプリ

どういう反応が得られたか

授業後アンケートを担当教員からいただいたので、目を通しました。 すごく熱量の高いコメントが多く、やった意義を強く感じられました。 アンケートに答えてくださり、ありがとうございます。

「授業で話されていない企画の話」について

いただいたコメントから、いくつか紹介させていただくと

品質、提供時間、保守性のバランスについて、品質の良いものだけを作れば良いだけではないとおっしゃられていましたが、実際自分が買う時は一番良いものを買うとはほとんどないにも関わらず、企画する際には一番良いものを考えようとしてしまっていました

このコメントでは普段の自分の購買行動と結びつけ、今回の講義を捉え直しているのが素晴らしいと思いました。私も見落としていた考え方だったので勉強になります。

私は研究活動でも調査や分析に見切りをつけることを苦手としていて,ついつい時間を割いてしまいます.

企画からまた企画への流れなども,開発だけではなく研究へも取り入れることが出来そう

というコメントもありました。研究活動に転用できそうと、自分の中で解釈して他の活動に結びつけようとしている方がいたのはうれしいです。

また、「アイデアの取捨選択」に言及されている方が多かったのは一見、意外でした。 ただ、授業を受けていた頃を思い返すと、私もどれに手を付ければいいのかわからない経験をしていたので納得です。

拝読したコメントから、話したことがうまく伝わったと感じられました。

「雑に発言できる場」について

スマホでの質問システムよかったです.

チャットあると口頭では聞きにくい質問もできるのでありがたかったです。

と、ポジティブな意見がありうれしいです。

また、盛り上がらないだろうと思っていたQ&Aコーナーで挙手してくれる学生さんがいたのは嬉しい誤算でした。

Q&Aで良いなと思ったのは、「このサービスに次に機能を追加するなら何を追加したいですか?」という質問です。 「上部の絵文字ボタンを取る」「他の授業でも使えるようにする」あたりでしょうか。以降の反省点にも書く内容ですが、機能追加の前に、これを活用した授業の再設計から試すのが良いかもしれません。 普段から授業をしている先生方にも聞いてみたいです。

ちなみに、Q&Aもチャットも盛り上がらなかったときのフォールバック先として、「会社員になってわかったこと」コーナーも用意していました。 このコーナーをする必要がない程に、皆さんからの質問をチャットとQ&Aコーナーで頂きました。

反省点

せっかくなのでいくつか反省点も書いておきます。

Q&Aで「学生時代にもっと勉強しておけばよかったと思う科目は?」という質問に対して、私は統計と答えました。その結果、アンケートではちゃんと統計を勉強しようと思います。という回答を頂いていました。 誠に申し訳ないのですが、自分が不真面目だったのが祟っただけで、同じ学年に卒業した同期は、かなりすぐA/Bテストを理解していました。

次に、双方向性のある体験をうまく作れていなかった点です。 双方向性がある講義が必ずしも素晴らしいとは思いませんが、チャットアプリを用意した割に、いささか中途半端なものになってしまいました。30分という時間は長くなく、レスポンスを待つことが難しいというのもありました。

最後に、検証企画に終始してしまった点です。 大きな絵を描いた上で、細かく取り組めるといいのですが、細かい話が多かったかもしれません。

アンケートでコメントを頂けたことで、自分が至らなかった点や期待した点について評価できました。ご回答いただきありがとうございました。 自分が期待した以上に話が伝わり、自分にとって新しい学びが得られました。

また機会があれば、反省点を踏まえて発表したいものです。

最後に

この記事では、授業で企画の話をするために考えたことと、実際にどう授業を設計したかについて説明しました。学生時代から2年弱しか時間が経ってないこともあり、学生に比較的近い目線で彼らが聞きたい話を提供できた実感があります。 PBL関係の授業を設計されている先生や、外部講師として招待された方への参考になればと思います。

また、発表したスライドも公開していますので、御覧ください。時間の都合で十分に議論されていない部分もありますが、進め方においては網羅的に説明しているつもりです。進め方に悩んでいる方の参考になればと思います。

最後の最後に今回の記事でクックパッドのサービス開発が気になった方へ! クックパッドでは、サービス開発に取り組む就業型インターン・そして新卒採用・中途採用を通年で受付けています。 是非下記のウェブサイトよりご応募ください。

info.cookpad.com

*1:リモート授業で、Zoom越しでした。

モバイルアプリ開発において宣言的UIフレームワークを利用する際のコンポーネント粒度についての考察

$
0
0

こんにちは、クックパッド事業本部 買物サービス開発部の佐藤(@n_atmark)です。

私の所属する買物サービス開発部ではクックパッドアプリにおける買い物機能*1の開発を行なっており、私は2020年の上期から買い物機能のモバイルアプリ開発の担当をしています。

2020年上期〜2021年上期では、クックパッドiOSアプリの買い物機能開発に、

2021年下期からは、クックパッドAndroidアプリの買い物機能開発に携わっています。

クックパッドアプリの買い物機能開発に関する詳細はクックパッド開発者ブログにもまとまっていますので、ぜひ合わせてご覧ください。

techlife.cookpad.com

techlife.cookpad.com

前述の通りクックパッドアプリにおける買い物機能をiOS/Android両プラットフォーム向けに開発しているのですが、実はそれらの開発には共通している点があります。

クックパッドiOSアプリの買い物機能ではSwiftUIというAppleプラットフォーム向けの宣言的UIフレームワークを利用しており、 クックパッドAndroidアプリの買い物機能ではJetpack ComposeというAndroid向けの宣言的UIフレームワークを利用している点です。

モバイルアプリ開発で宣言的UIフレームワークを利用する利点

SwiftUIやJetpack ComposeといったUIフレームワークを利用することで、従来のUI構築の手法と比べて以下のようなメリットがあります。

  • コード量を減らすことができる
  • 複雑・多様な条件のある画面実装をシンプルに書ける
  • コンポーネント単位で使い回し・改変がしやすい
  • UIのプレビュー表示が可能で、アプリ全体をビルドせずにUIの確認ができる

UI変更がより柔軟に、簡単になることで、作って壊しの試行錯誤の回数を増やすことができ、スピード感を持ってサービス開発を行うことができるため、買い物機能では宣言的UIフレームワークを積極的に利用しています。

宣言的UIフレームワークを用いた開発を続けていく中で直面した問題

先行開発していたiOS向けの買い物機能をリリースしてから1年が経ち、宣言的UIフレームワークを利用したコードも増え、開発に携わる人も増えてきました。

開発を続けていくにつれて宣言的UIフレームワークの柔軟性の高さゆえに、UIの組み方が人によって大きく変わってしまい、かえって可読性が落ちてしまっていたり、保守しづらいコードが現れてきていると感じるようになりました。

そこで、チーム内で時間を設けて設計について話す会を設けてみました。

設計について話す会の様子
チーム内で開催したSwiftUIの設計について話す会の様子

宣言的UIフレームワークを用いたUI組みに関して、暗黙知としてよしなに実装している部分が多く、コンポーネントの分割粒度や、適切なレイアウト設計について悩んでいることが分かりました。

せっかく作って壊しやすいという利点を尊重して採用した宣言的UIフレームワークが、かえって既存の実装を変えたい場合に、既存実装が読みづらく変更に弱い状態になっているのは勿体ないと感じました。

今後もチームで宣言的UIフレームワークを活用していくために、宣言的UIフレームワークを使っていて感じている問題は一つずつ取り除いていけるとよいはずです。

開発者それぞれがコンポーネント分割の粒度を把握できるようにするために

UIの組み方が人によって変わってしまう問題を解決するために、コンポーネントを各開発者が適切な粒度で分割できるように何かしらルールのようなものが存在するとよいのではないか、と考えるようになりました。

チーム内で他のプラットフォームの事例を参考に、コンポーネント粒度の考え方として、Atomic Designに目をつけました。

https://bradfrost.com/wp-content/uploads/2013/06/atomic-design.png
https://atomicdesign.bradfrost.com/

Atomic Designは分解可能な最小単位でパーツを作成し、パーツを組み合わせてより大きなコンポーネントを作成し、コンポーネントを組み合わせて画面を定義していくようなデザイン手法です。

Atomic Designはモバイルアプリ開発において宣言的UIフレームワークを利用する際の銀の弾丸になりうるのか

Atomic Designによって、コンポーネント分割の粒度を示すことができました。例えば買い物機能で利用されている、商品グリッドをこのように分割してみました。

Atomic Designに沿って買い物機能で利用されているコンポーネントを粒度分割した図
Atomic Designに沿って買い物機能で利用されているコンポーネントを粒度分割した図

一見すると、これによって開発者がAtomic Designに沿ってコンポーネントを粒度によって分割できそうに見えます。

ところが、実際にはそう上手くはいきません。

Atomic Designを用いた画面設計を運用をしたことのある社内のデザイナーに話を聞いてみると

  • Atomic Designを運用していたけれどやめてしまった
    • Atoms/Moleculesを分離するメリットが薄いと感じた
    • Molecules/Organismsの分割が難しい
    • コンポーネントをどう分けるかで議論を生んでしまうこともあり、かえって消耗してしまった

と伺うことができました。

厳密にAtomic Designに沿ってコンポーネント分割を行おうとすると下のような点に問題が出てくることが分かりました。

  • MoleculesとOrganismsどっちに分類すべきなのか分からない
  • Atomsレベルのコンポーネント分割を厳密にやろうとすると開発速度が出ない

モバイルアプリ開発における宣言的UIフレームワークを利用する際のコンポーネント粒度に関する最適解とは

Atomic Designをそのまま取り入れることに関して

  • そもそもWebの考え方なのでモバイルアプリには当てはまらない部分もある
  • 厳密にコンポーネントの粒度を定義したいことが目的ではない
  • Atomic Designにおける用語(Atoms, Molecules...)が一人歩きしてしまう

という点を踏まえて改めて当初の目的を考えてみると、 「開発者がコンポーネント分割をルールに沿って無心でできるようにすることで、保守しやすい状態は保ちつつ、開発速度をあげたい」のが目的でした。

そこで、Atomic Designのエッセンスだけを取り入れつつ、モバイルアプリ開発に適したコンポーネント粒度を考えるのが良さそうと考えました。

モバイルアプリ開発における特徴を踏まえた上で設計を考える

モバイルアプリ開発(というと、やや主語が大きいので、特にクックパッドのアプリ開発)においては、下のように縦スクロールの画面が多く登場します。

クックパッドアプリの画面構成の例
クックパッドアプリの画面デザインの例

例えば、次のような買い物機能のカート画面*2を実装するとします。その時、適切にコンポーネントが分割されないと実装も縦に長くなりがちです。(SwiftUIの例を提示します。)

買い物機能のカート画面
買い物機能のカート画面

structCartView:View {
    varbody:some View {
        ScrollView {
            VStack {
                Text("ご注文内容")
                    .padding(.top, 32)
                    .padding(.bottom, 4)
                    .padding(.horizontal, 16)

                VStack {
                    ForEach(dataSource.cart.cartProducts) { cartProduct in
                        HStack(alignment: .bottom) {
                            AsyncImage(url:cartProduct.product.thumbnailUrl)
                                .frame(width:74, height:74)
                                .cornerRadius(6)
                            VStack {
                                Text(cartProduct.product.name)
                                    .font(.subheadline)
                                Text("\(cartProduct.product.salesUnit)\(cartProduct.product.productionArea)")
                                    .font(.callout)
                                    .foregroundColor(.gray)
                                Spacer()


                                HStack {
                                    Image(systemName:"minus")
                                        .foregroundColor(.gray)
                                        .font(.system(size:14))
                                        .frame(width:30, height:30, alignment: .center)
                                        .cornerRadius(15)
                                        .onTapGesture(perform:decrement)

                                    Text("\(cartProduct.count)")
                                        .font(.subheadline)
                                        .frame(minWidth:26, alignment: .center)

                                    Image(systemName:"plus")
                                        .foregroundColor(.gray)
                                        .font(.system(size:14))
                                        .frame(width:30, height:30, alignment: .center)
                                        .cornerRadius(15)
                                        .onTapGesture(perform:increment)
                                }
                                .padding(5)
                                .overlay(
                                    Capsule()
                                        .stroke(Color.gray, lineWidth:0.5)
                                )
                            }

                            Text("単価 ¥").font(.subheadline) + Text("\(cartProduct.product.price)").font(.headline)
                        }
                            .padding(.horizontal, 20)
                            .padding(.vertical, 16)

                        Divider()
                    }
                }

                VStack(spacing:16) {
                    HStack {
                        Text("商品小計").font(.title)
                        Spacer()
                        Text("¥").font(.subheadline) + Text("\(cart.subTotal)").font(.body)
                    }

                    HStack {
                        Text("消費税").font(.title)
                        Spacer()
                        Text("¥").font(.subheadline) + Text("\(cart.tax)").font(.body)
                    }
                }
                .padding(.horizontal, 16)
                .padding(.vertical, 24)

                Divider()

                HStack {
                    Text("計").font(.body)
                    Spacer()
                    (Text("¥").font(.headline) + Text("\(cart.subTotal)").font(.system(size:28)))
                        .foregroundColor(.orange)
                }
                    .padding(.horizontal, 16)
                    .padding(.vertical, 20)

                Divider()

                // ・・・以降省略
        }
    }
}

適切にコンポーネント分割なされていない例

CartViewに90行ほどのコードを書いてみましたが、これでも下のスクリーンショットの赤枠部分の実装しかありません。

例示コードの実装部分
例示コードの実装部分

これを適切にコンポーネント分割したい場合、どこから取り掛かれば良いでしょうか。

ここで、宣言的UIフレームワークを導入する前のことを考えてみましょう。

我々モバイルアプリ開発エンジニアは、従来のUI構築では意味のあるまとまりごとにUITableViewにおけるUITableViewCellや、RecyclerViewにおけるItemViewといった単位で画面を行分割することによってUIを構築してきました。 (iOSの例を提示します。)

functableView(_ tableView:UITableView, cellForRowAt indexPath:IndexPath) ->UITableViewCell {
    switch Section.allCases[indexPath.section] {
    case .cartProducts:return tableView.dequeueReusableCell(with:CartProductsTableViewCell.self, for:indexPath)
    case .cartPrice:return tableView.dequeueReusableCell(with:CartPriceTableViewCell.self, for:indexPath)
    case .pickupNameSetting:return tableView.dequeueReusableCell(with:PickupNameSettingTableViewCell.self, for:indexPath)
    case .deliveryInformation:return tableView.dequeueReusableCell(with:DeliveryInformationTableViewCell.self, for:indexPath)
    case .pickupSteps:return tableView.dequeueReusableCell(with:PickupStepsTableViewCell.self, for:indexPath)
    case .notes:return tableView.dequeueReusableCell(with:NotesTableViewCell.self, for:indexPath)
    case .faq:return tableView.dequeueReusableCell(with:FAQTableViewCell.self, for:indexPath)
    }
}

UITableViewを用いたUI構築の例

これはモバイルアプリ開発エンジニアにとっても馴染みの深いコンポーネント分割と言えるでしょう。 意味のあるまとまりでレイアウトを分割することができます。

Section単位での行分割の図
Section単位での行分割の図

同じように、これを宣言的UIフレームワークで実現すると

// SwiftUIの例structCartView:View {
    varbody:some View {
        ScrollView {
            LazyVStack {
                CartProductsSection()
                CartPriceSection()
                PickupNameSettingSection()
                DeliveryInformationSection()
                PickupStepsSection()
                NotesSection()
                FAQSection()
            }
        }
    }
}
// Jetpack Composeの例@Composablefun CartScreen() {
    LazyColumn {
        item {
            CartProductsSection()
        }
        item {
            CartPriceSection()
        }
        item {
            PickupNameSettingSection()
        }
        item {
            DeliveryInformationSection()
        }
        item {
            PickupStepsSection()
        }
        item {
            NotesSection()
        }
        item {
            FaqSection()
        }
    }
}

このように画面をSectionという意味のある単位で行分割することで、画面実装をシンプルかつ、見通しよく書くことができるようになりました。 画面実装におけるUI定義のトップレベルであるViewのbodyやScreen関数の中でこのように記述することで、それらが 画面の設計図として機能するようになります。

また、条件に応じたViewのトルツメなども

// SwiftUIの例structCartView:View {
    varbody:some View {
        ScrollView {
            LazyVStack {
                CartProductsSection()
                CartPriceSection()
                PickupNameSettingSection()
                DeliveryInformationSection()
                if shouldShowPickupSteps { PickupStepsSection() }
                NotesSection()
                FAQSection()
            }
        }
    }
}
// Jetpack Composeの例@Composablefun CartScreen() {
    LazyColumn {
        item {
            CartProductsSection()
        }
        item {
            CartPriceSection()
        }
        item {
            PickupNameSettingSection()
        }
        item {
            DeliveryInformationSection()
        }
        if(shouldShowPickupSteps) { 
            item {
                PickupStepsSection()
            }
        }
        item {
            NotesSection()
        }
        item {
            FaqSection()
        }
    }
}

のように、見通しよく書くことができます。

これによってAtomic DesignのOrganisms相当のコンポーネント粒度に関して、Atomic Designの定義を使わずに分割の指標を示すことができました。

実際にクックパッドiOS/Androidの買い物機能でもSection-suffixな命名で画面をこのように分割を行なっており、モバイルアプリ開発者にも分かりやすく使える指標として今のところ上手くワークしているのではないかと考えています。

Sectionに関する補足

意味のあるまとまりで画面を行分割したそれぞれのコンポーネントをSectionと呼んでいます。 Sectionは単体で意味のあるまとまりとして機能し、さらに小さな粒度のコンポーネントをとりまとめる役割を持っています。

意味のあるまとまりとしてのSectionの図
意味のあるまとまりとしてのSectionの図

例えば、この赤枠の部分を実装する時は「受け取り名」のラベルと「受け取り用のニックネームを登録」のボタンをまとめたものをSectionと呼べそうです。

Sectionの役割は、複数コンポーネントをとりまとめ、コンポーネント間のマージン調整をすることです。

複数コンポーネントの取りまとめと、マージン調整レイヤーとしてのSectionの役割を示す図
複数コンポーネントの取りまとめと、マージン調整レイヤーとしてのSectionの役割を示す図

例えば、下のような実装ができそうです。

// SwiftUIの例privatestructPickupNameSettingSection:View {
    vardidTap: ()->Voidvarbody:some View {
        VStack(alignment: .leading, spacing:12) {
            Text("受け取り名")
            CookpadButton(style: .secondary, text:"受け取り用のニックネームを登録", didTap:didTap)
        }
        .padding([.leading, .trailing, .bottom], 20)
        .padding(.top, 28)
    }
}
// Jetpack Composeの例@Composableprivatefun PickupNameSettingSection(onClick: () ->Unit) {
    Column(
        verticalArrangement = Arrangement.spacedBy(12.dp),
        horizontalAlignment = Alignment.Start,
        modifier = Modifier
            .padding(
                start = 20.dp, 
                top = 28.dp, 
                end = 20.dp, 
                bottom = 20.dp
            )
    ) {
        Text(
            text = stringResource(R.string.pickup_name_setting_section_title), // 受け取り名
        )

        CookpadButton(
            style = CookpadButtonStyle.secondary,
            text = stringResource(R.string.pickup_name_setting_section_button_text),  // 受け取り用のニックネームを登録
            onClick = onClick
        )
    }
}

Sectionはあくまで画面に紐づくものとして用意していて、複数画面で利用しないようにしています。

コンポーネントをどう並べるかであったり、どういうマージンを持たせるかは画面の中で特定のコンテキストを持つ場合が多いので、あえて汎化させていません。 (それでもSectionを汎化させたいような場合は、Section内で利用しているコンポーネントの粒度を見直した上で、Section内部にコンポーネントを一つだけ持つSectionを作るようにしています)

Sectionより小さなコンポーネント

「モバイルアプリ開発における特徴を踏まえた上で設計を考える」の章で、Atomic DesignのOrganisms相当のコンポーネント粒度を、Sectionという行分割されたコンポーネントによって指標を示せた。という話を書きました。 Atomic Designの定義に沿うと、Organismsより小さなコンポーネント粒度としてMolecules/Atomsレベルのものを考える必要がありそうです。

ここでAtoms/Moleculesに関しては

  • Atoms: 分解可能な最小単位のコンポーネント
  • Molecules: 複数のAtomsがまとまったコンポーネント

と便宜上定義することにします (これは厳密にはAtomic Designの定義とは少し外れています)

Atoms相当のコンポーネントとは

前項の商品グリッドの分割の例を見てみると、Atomsに関しては割と明確にコンポーネントを分割できそうです

Atoms単位のコンポーネント分割の例

しかし、Atomsレベルのコンポーネントに関して

  • Atomsレベルのコンポーネント分割を厳密にやろうとすると開発速度が出ない

という課題もあります。

そこで、Atomsレベルのコンポーネントのうち共通化されるものだけを考えてみます。共通化して使われるものに関しては適切にAtomsレベルのコンポーネント分割をする意味があると言えるでしょう。

アプリ内で共通化されるものは、例えばデザインシステム*3で定義されているボタンなどが挙げられます。

https://assets.st-note.com/production/uploads/images/56051609/picture_pc_a88d51300483095fe7e7e8a13d5ede68.png?width=800
クックパッドで利用しているデザインシステム(Apron)の例

これらは、アプリ内で共通して利用されることが分かっており、利用されるスタイルがデザインシステムによって定められているため、コンポーネント化もしやすそうです。

他にもテキストの実装を考えてみます。場所によってフォントサイズやテキストカラーが異なりますが、これらそれぞれのコンポーネント化は過剰と言えるでしょう。

その代わりに、デザインシステムで定義されているテキスト色やフォントサイズの種類などをスタイルとして提供する方が柔軟に利用できるでしょう。

// SwiftUIの例structHeaderText {
    vartext:Stringvarbody:some View {
        Text(text)
            .fontWeight(.bold)
            .font(.system(18))
            .foregroundColor(.black)
    }
}
// Jetpack Composeの例@Composablefun HeaderText(text: String) {
    Text(
        text = text,
        fontSize = 18.sp,
        fontWeight = FontWeight.Bold,
        color = colorResource(R.color.black)
    )
}

コンポーネントとして提供する例

// SwiftUIの例
Text("テキスト")
    .cookpadFont(size: .large, weight: .bold)
    .foregroundColor(.cookpad(.black))
// Jetpack Composeの例
Text(
    text = "テキスト",
    style = CookpadTextStyle.Large.bold().black()
)

スタイルとして提供する例

デザインシステムで定義されているスタイルやコンポーネントはプロジェクト内で共通して使えると良さそうなので、例えばUIライブラリとして提供したり、UIモジュールとして提供したりできそうです。

アプリ内で共通して利用されることがわかっているコンポーネントやスタイルに関してはプロジェクト全体で利用できるようにしておき、画面実装の際にデザインシステムで定義されていないAtomsレベルのコンポーネント分割に関してはそこまで頑張らなくても良いのではないかと考えています。

Molecules相当のコンポーネントとは

画面実装の際にAtomsレベルのコンポーネント分割はほとんど必要なさそう。と書きましたが、実際の利用シーンを考えてみてもデザインシステムで定義されていないようなコンポーネントに関しては、使い回しの単位はMolecules相当の大きさくらいにならないとあまり発生しないだろうと考えています。

そのため、画面実装の際に一番意識する必要があるのがMolecules相当のコンポーネントだと考えています。

複数の要素を組み合わせたコンポーネントの例
複数の要素を組み合わせたコンポーネントの例

特に、商品タイルと商品グリッドのように、あるコンポーネントと、そのコンポーネントを内包する別コンポーネントのような関係が容易に発生します。

ここで、厳密にAtomic Designの定義に沿おうとすると、商品タイルはMoleculesっぽいということは分かりますが、商品グリッドはグリッドとして構成される最低限の機能を持つMoleculesと言えるかもしれませんし、MoleculesであるタイルをまとめたグループとしてのOrganismsとみなせるかもしれません。

  • MoleculesとOrganismsどっちに分類すべきなのか分からない

というような課題が残ります。

ここで、解決したいのはコンポーネントを適切に分割できることであってMolecules/Organismsの厳密な定義ではないので、一旦全てをフラットなコンポーネントとして考えてみます。

Molecules相当のコンポーネント分割で気にすべきもの

例としてSectionでもなく、ライブラリとして提供されるスタイルでもない粒度のものを、全てComponent-suffixをつけてコンポーネント化してみました。

全てComponent-suffixをつければ良いので、開発者が粒度を気にすることなくコンポーネントを分割できて、一見問題を解決しているように見えます。

しかし、利用する際のことを考えてみると、

// SwiftUIの例
VStack {
     HeaderComponent() // ヘッダーっぽい
     ProductsComponent() // 商品を表示してそう
} 
// Jetpack Composeの例
Column {
    HeaderComponent() // ヘッダーっぽい
    ProductsComponent() // 商品を表示してそう
}

利用されているのがコンポーネントであることは分かるものの、なんのコンポーネントなのか全然分かりません。

上の例示における ProductsComponent()は商品を表示していそうなことは分かるのですが、例えばグリッドで商品を表示しているのか、カルーセルで商品を表示しているのか、リストで商品を表示しているのか、コンポーネント側の実装を見ないと把握できません。

これを踏まえて、Moleculesレベルのコンポーネントに関しては、コンポーネントの粒度を分類することよりも、種類を分類して適切に命名することが大事なのではないかと考えるようになりました。

Moleculesレベルの種類の分類を適切にするために

コンポーネントに関して、コンポーネントの役割が分かるような種類で分類を行い、適切な命名をすると良さそうなことがわかったのですが、その命名はどうしても開発者依存になってしまいます。 しかし、この部分を厳密に定めてしまうとMolecules/Organismsのどっちに分類すべき問題に再度遭遇してしまうため、コンポーネントの種類の分類を厳密に定めることはしない方が良さそうです。

そのため、何かしらの方法で開発者が適切に種類を分類できる方法を提供できると良さそうです。 例えば、コンポーネントの例としてList/Grid/Carousel/Tableなどの凡例をカタログとして用意しておくとどうでしょうか。

コンポーネント分類のためのカタログ
コンポーネント分類のためのカタログ

カタログを見ながら開発者がコンポーネントの種類を判断できると良さそうです。

カタログを見て分類できるものは、カタログにそった命名にする (ProductsGrid/ProductsCarousel等)、カタログに分類できないものに関してはComponent-suffixのコンポーネントにしてしまうなどの方針を立てられると、コストは抑えつつ、ある程度保守しやすい状態にできるのではないかと考えています。

考察を踏まえて

これらの考察を踏まえてカート画面の実装を再度考えてみると、下のような実装ができそうです。(SwiftUIの例を提示します。)

structCartView:View {
    varbody:some View {
        ScrollView {
            LazyVStack {
                CartProductsSection()
                CartPriceSection()
                PickupNameSettingSection()
                DeliveryInformationSection()
                if shouldShowPickupSteps { PickupStepsSection() }
                NotesSection()
                FAQSection()
            }
        }
    }

    privatestructCartProductsSection:View {
        varbody:some View {
            SectionHeader("ご注文内容")
            CartProductList()
        }
    }

    privatestructCartPriceSection:View {
        varbody:some View {
            CartPriceTable()
        }
    }

    privatestructPickupNameSettingSection:View {
        varbody:some View {
            SectionHeader("受け取り情報")
            CookpadButton("受け取り用のニックネームを登録") {  // 略 }
                .padding(16)
        }
    }

    privatestructDeliveryInformationSection:View {
        varbody:some View {
            DeliveryInformationTable()
        }
    }


    privatestructPickupStepsSection:View {
        varbody:some View {
            SectionHeader("受け取りまでの流れ")
            PickupStepsCarousel()
        }
    }

    privatestructNotesSection:View {
        varbody:some View {
            SectionHeader("注意事項")
            NotesComponent()
            TermsList()
        }
    }

    privatestructFAQSection:View {
        varbody:some View {
            FAQList()
        }
    }
}

だいぶ見通しが良くなり、画面に紐づくViewのbodyやScreenの定義と、それに紐づくSectionの実装をみるだけでおおよその画面の構成を把握することができるようになったと言えるでしょう。

まとめ

  • Atomic Designはモバイルアプリ開発において宣言的UIフレームワークを利用する際の銀の弾丸ではなさそう
  • Atomic Designをそのまま利用しても効果は薄そうだが、Atomic Designの考え方のエッセンス自体は参考にできる部分が多い
  • 従来のUI構築で画面を意味のあるまとまりで行分割を行なっていた考え方は、モバイルアプリ開発における宣言的UIフレームワークを用いたコンポーネント分割の指標として相性が良かった
  • デザインシステムで定義されているようなコンポーネントやスタイルはプロジェクト全体で利用できるようにしておくと良い
  • 画面実装の際に使い回し利用するようなコンポーネントは厳密に粒度を分類するより、適切に種類を分類する方が良さそう

さいごに

Webに比べると、モバイルアプリ開発で宣言的UIフレームワークを用いてチーム開発をしたり、既存実装の保守・運用を行う知見についてはまだまだ少なく、試行錯誤を繰り返していく中で「正解は分からないけど、なんとなくこういう方針が良いんじゃないか」と考えていることを記事にしてみました。

SwiftUI/Jetpack Composeを用いて開発されているプロダクトが世間的にも増えてきている背景の中で、今後保守・運用のしやすさについて注目されることも増えてくると思うので、今回の記事が参考になれば嬉しいです。

クックパッドでは一緒に働く仲間を募集しています!

モバイルアプリ開発において宣言的UIフレームワークを利用する際のコンポーネント粒度について、普段開発しているプロダクトの特徴などを踏まえて考察を書いてみました。

クックパッドでは、SwiftUIやJetpack Composeなどの宣言的UIフレームワークを用いて、保守しやすい状態は保ちつつ、素早くユーザーに価値を届けられるようなサービス開発を一緒に進めてくださるエンジニアを大募集しています。

カジュアル面談や学生インターンシップなども随時実施していますので、ぜひお気軽にご連絡ください。

info.cookpad.com

*1:近隣地域の生産者や市場直送の新鮮でおいしい食材を、1品から送料無料で購入できる。https://info.cookpad.com/pr/news/press_2020_1015

*2:説明用に実際のものとは少し構成を変えています

*3:クックパッドではApronというデザインシステムを用いています。 https://note.com/fjkn/n/nf73742ec925a

クックパッドマートアプリの開発体験改善に向けたJetpack Compose導入の検討と実践、そして新たな課題

$
0
0

こんにちは、買物プロダクト開発部のYuto Koguchi(@10llip0p)です。2020年に新卒入社し現在はクックパッドマートのAndroidアプリ開発に従事しています。 クックパッドマートアプリ(Android)はリリースから約2年半が経ち、おかげさまで日々多くのユーザーにご利用いただいております。それに伴って実装の規模や複雑さも増していることから開発効率向上のために日々様々な改善を行っています。 その上で本稿ではクックパッドマートアプリのUI実装の課題とその改善に向けたJetpack Compose導入の検討、そして実際に導入に取り組んだことで直面している課題について紹介します。また本稿はAfter Party DroidKaigi 2021での発表とその後日談が主な内容になります。

これまでのクックパッドマートアプリの画面実装

クックパッドマートアプリでは画面実装にGroupieを使うことで全ての画面をRecyclerViewで実装していました。Groupieの概要や技術選定の詳細はリリース当初に公開したクックパッドマートAndroidアプリの画面実装を最高にした話をご覧ください。こうして最高な実装方式を採用していたわけですが、一方で開発やメンテナンスを続けているうちに徐々にその最高も感じづらいものになっていました。具体的にGroupieでいまいちに感じていた点は以下です。

画面単位でのプレビューができない

Groupieでは画面を構成するコンポーネント(GroupieItem)が行単位で分割されることでActivityやFragmentの肥大化を防止できることが利点の1つでした。しかしこれによりActivityやFragmentには基本的にRecyclerViewだけを置くことになり、レイアウトエディター上のプレビューだけではどんな画面が表示されるのかわかりづらくなっていました。例えば以下に示す画像は商品詳細画面のスクリーンショットとそのFragmentのプレビューですが、比較するとほとんどプレビューとして機能していないことがわかると思います。

f:id:u_10llip0p:20211221171956p:plain
プレビューが機能していない例

さらにECサービスという特性上商品や配送といったデータは時系列等で変動するため同じ画面でも状態に応じて表示内容が変わります。 例えば以下の画像は全て商品受け取り画面のスクリーンショットですが、注文方式の違いや注文キャンセルなどに応じて異なる表示をしています。 このようにデータの内容が画面上のどの部分の表示に影響するのかを調べるには各GroupieItemの実装を読み解く必要があり、また実際にアプリをビルドして動かすまで画面全体の表示を確認できないことを手間に感じていました。

通常注文 自宅配送オプション 注文キャンセル
f:id:u_10llip0p:20211221171945p:plainf:id:u_10llip0p:20211221171949p:plainf:id:u_10llip0p:20211221171952p:plain

ネストされたRecyclerView構成の複雑さ

GroupieはRecyclerViewの実装を簡素化できるため縦一方向にスクロールするUIを構築する上ではとても便利です。しかし画面の一部でコンテンツが横並びするような表示が必要になった際にはRecyclerView in RecyclerViewな構成を避けることができず実装が複雑になってしまいます。 例えば以下のスクリーンショットではカルーセルのような表示のRecyclerViewと画面全体のRecyclerViewがネストした構成となります(矢印方向がRecyclerViewでの表示)。

ネストしたRecyclerViewの例

現在のクックパッドマートアプリでは商品の一覧表示などで横並びするコンテンツが多用されているためこのような構成での実装が多く存在しています。またRecyclerViewの階層が深くなることで先述したプレビューのしづらさも相まって実装の見通しの悪さにも繋がっていました。

Jetpack Composeの登場と導入の検討

Groupieに対しての細かな不満感が募りつつある中、今年ついにAndroidの新しいUIライブラリJetpack Composeが正式リリースされました。今ではDroidKaigiやQiita, Zennなどで活発に知見が共有されており既に各社様々なプロダクトで採用事例があるため、読者のAndroidエンジニアの方々にももう馴染み深いものになっているかもしれません。クックパッドでもJetpack Composeを使った試みには意欲的に取り組んでおり先日も 既存実装を活用しつつJetpack Composeを用いてクックパッドAndroidアプリの買い物機能を高速に開発している話という記事を公開しています。こちらもぜひご覧になってください。 Jetpack ComposeがAndroidアプリのUI実装のトレンドとなっていく中でクックパッドマートアプリでもJetpack Composeの導入を検討しました。具体的にJetpack Composeに対して導入のモチベーションとなったのは以下の点です。

  • ColumnやRowなどのリスト形式の表示を基本としたUI構築
    • RecyclerViewベースで構築したアプリ構成を大きく崩さずに移行を進められる
    • RecyclerView in RecyclerViewで実装していた複雑なUIをシンプルな実装に置き換えられる
  • 強力なプレビュー機能の標準サポート
    • コンポーネントが分割されていても画面全体のプレビューが簡単にできる
    • 本番データと同じデータモデルを使って実際の表示パターンの確認ができる
  • LiveDataやFlowなどのJetpackライブラリとの組み合わせやMVVMアーキテクチャを想定した設計
    • 同様の技術選定で実装しているクックパッドマートアプリと相性が良い

またJetpack ComposeはGroupieの利点であった差分更新の機能も備えています。以上のことからJetpack Composeを導入することでGroupieで得ていたUI実装のメリットと同様の恩恵を保ちつつ、Groupieで感じていた開発体験の課題改善を期待できるためクックパッドマートアプリへのJetpack Compose導入を進めることにしました。

既存のUI実装からの段階的なJetpack Compose移行

プロダクションでサービス運用しているアプリでは日々様々な機能開発や施策を進めており、GroupieからJetpack Composeへの移行のような大きなアーキテクチャ移行を一度に行うには以下のような難しさがあります。

  • 大規模な実装変更、コードレビュー、動作検証など人的・時間的リソースが大量に必要
  • アーキテクチャ移行へのリソース投入により新規開発・施策進行をストップさせる事業上の判断が必要
  • 事業上の意思決定を行うために各ステークホルダーとの相談・合意が必要

そのためクックパッドマートアプリでは日常の開発の中で段階的に既存のUI実装をJetpack Composeに移行できる方法を模索しました。

既存のUI実装構成からの移行アプローチ

Jetpack Composeへの移行にあたっては公式ドキュメント(Compose をアプリに導入する)でもいくつかのアプローチが例示されていますが、Groupieのような特殊なケースは想定していないためそのままは参考にできません。そのためクックパッドマートアプリでは独自のアプローチでJetpack Compose移行を進めることにしました。 Groupieを使った実装の構成は模式図にすると以下のようになります。(Compose の思想宣言型パラダイム シフトの図を真似てます)

Groupieを使った実装構成

画面上のUIを行単位で個別のGroupieItemに実装しRecyclerView上に各Itemを縦に並べる構造です。コード例で示すと以下のようになります。

// 画面一行分のUIdataclass HogeItem(
    val hogeData: HogeData,
) : BindableItem<ItemHogeBinding>() {
    overridefun getLayout(): Int = R.layout.item_hoge

    overridefun getId(): Long = layout.toLong()

    overridefun initializeViewBinding(view: View): ItemHogeBinding =
        ItemHogeBinding.bind(view) 

    overridefun bind(viewBinding: ItemHogeBinding, position: Int) {
        hogeTitle.text = hogeData.title
        hogeMessage.text = hogeData.message
    }
}

class HogeFragment : Fragment(R.layout.fragment_hoge) {
    overridefun onViewCreated() {
        val adapter = GroupAdapter<ViewHolder>()
        recyclerView.adapter = adapter 

        adapter.update(mutableListOf<Group>().apply {
            add(HogeItem(hogeData)) 
            add(FugaItem()) // HogeItemの下の行のUI
        })
    }
}

そこでこの構造をベースとして、まずは各Itemの中のUI実装だけをJetpack Composeで置き換えていくことで段階的に移行していけると考えました。

Jetpack Compose移行後のGroupieを使った実装構成

具体的にはGroupieItemにComposeViewのみをbindしてComposable functionの実行環境として使用する方法です。既存のGroupieItem実装からは元実装のレイアウトXMLとAndroidViewBinding)を使うことで一旦簡易的に移行できますし、移行途中で新規にGroupieItemの追加が必要になった場合にも画面全体の移行を待つことなくJetpack ComposeでUIを実装することができます。以上のことからクックパッドマートアプリではJetpack Compose移行に次のようなアプローチの採用を決めました。

  1. 各GroupieItemのUI実装を順次Jetpack Compose(Composable function)で置き換える
  2. 1つの画面のGroupieItemが全てJetpack Compose移行を完了したらGroupie層をJetpack ComposeのLazyColumn/Columnに置き換える

f:id:u_10llip0p:20211221172029p:plain
GroupieからJetpack Composeへの段階的移行アプローチ

段階的移行を支援するための汎用GroupieItem実装

上記アプローチでJetpack Compose対応したGroupieItemは基本的にComposeViewを表示するだけの役割になります。またJetpack ComposeをRecyclerView上で利用するにあたってはViewHolder, Adapterの実装でいくつか注意点があり、Groupieで同様の役割を担うGroupieItemの実装でも考慮する必要があります。したがってJetpack Compose対応したGroupieItemの実装は大部分が冗長なボイラープレートコードになるため、移行段階でも極力Jetpack Composeの実装だけに集中できるように共通コンポーネントとしてComposeItem(汎用GroupieItem)を用意しました。

ComposeItemの具体的な実装は以下のようになります。

dataclass ComposeItem<T>(
    privateval data: T,
    privateval composable: @Composable () ->Unit
) : BindableItem<ItemComposeBinding>() {

    overridefun getLayout(): Int = R.layout.item_compose

    overridefun getId(): Long = layout.toLong()

    overridefun initializeViewBinding(view: View): ItemComposeBinding =
        ItemComposeBinding.bind(view).also {
            it.composeView.setViewCompositionStrategy(
                ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
            )
        }

    overridefun bind(viewBinding: ItemComposeBinding, position: Int) {
        viewBinding.composeView.setContent(composable)
    }

    overridefun hasSameContentAs(other: Item<GroupieViewHolder>): Boolean =
        other is ComposeItem<*> && other.data ==this.data

    overridefun unbind(viewHolder: com.xwray.groupie.viewbinding.GroupieViewHolder<ItemComposeBinding>) {
        viewHolder.binding.composeView.disposeComposition()
        super.unbind(viewHolder)
    }
}

特筆する点としてはViewBindingの初期化処理でComposeViewのViewCompositionStrategyDisposeOnViewTreeLifecycleDestroyedを指定しています。またGroupieItemがunbind(recycle)される際にComposeViewを明示的にdisposeComposition)しています。 これらの実装はRecyclerViewのComposeの実装手法を踏襲しており、RecyclerViewをhostするFragmentが破棄された際やComposeItemが画面外に移動した際に適切に描画リソースをメモリから解放してくれるようになります。

またGroupieは表示するデータを更新した際にGroupieItemのインスタンスの同値性を見てよしなに差分更新してくれるのですが、GroupieItemの共通化によりこの仕組みがうまく機能しなくなります。そこで表示するデータの同値性から判定するようにhasSameContentAsをoverrideすることでこの問題を解決しています。

以上によりJetpack Composeへの移行段階でも表示するComposable functionだけを作れば良い状態になりました。実際にGroupieでComposeitemを使った実装例はこのようになります。

@Composablefun HogeSection(
    hogeData: HogeData,
) {
    AndroidViewBinding(ItemHogeBinding::inflate) {
        hogeTitle.text = hogeData.title
        hogeMessage.text = hogeData.message
    }
}

class HogeFragment : Fragment(R.layout.fragment_hoge) {
    overridefun onViewCreated() {
        val adapter = GroupAdapter<ViewHolder>()
        recyclerView.adapter = adapter 

        adapter.update(mutableListOf<Group>().apply {
            add(
                ComposeItem(
                   data = hogeData,
                ) {
                    HogeSection(hogeData)
                }
            ) 
            add(FugaItem())
        })
    }
}

プレビュー活用に向けたモジュール構成

Jetpack Composeのプレビュー機能はパラメータの変更などに対しては即座に表示を更新してくれますが、新規にComposable functionを実装した際やUI要素を追加した際は更新にprojectをbuildする必要があります。Jetpack Compose導入以前のクックパッドマートアプリのアーキテクチャでは(一部のレイヤを除き)単一のappモジュールに実装する構成となっていたため、buildの完了に数分程度かかることが欠点でした。プレビュー機能はJetpack Compose導入のモチベーションの1つであり、開発体験の改善を目的とする上で快適に利用できることは必須です。そこでアプリのモジュール構成を整理することで高速にプレビューできる開発環境を構築しました。

整理後のアーキテクチャでは旧appモジュールをuiモジュールとappモジュールの2つに分割したマルチモジュール構成にしました。それぞれのモジュールの役割やファイル構成は以下のようになります。

  • uiモジュール
    • 画面表示に関わる実装・リソースを集約
    • appモジュールの実装に依存しない
    • 含めるファイル
      • Composable function
      • Composable functionで表示するデータの定義
      • UI関連のresource(layout, drawable, font, color, etc…)
  • appモジュール
    • uiモジュール以外のアプリケーション本体やビジネスロジックに関わる実装を集約
    • uiモジュールの実装を参照して画面表示や状態管理をする
    • 含めるファイル
      • Application
      • Activity, Fragment
      • ViewModel

プレビュー活用を意識したmodule構成

この構成変更によりプレビューを更新する際はuiモジュールだけのbuildで完結するようになり、実行時間はおおよそ数秒程度に短縮することができました。

実装移行後に発覚した新しい課題

以上の内容までがAfter Party DroidKaigi 2021で発表したものになります。その後クックパッドマートアプリでは上記方針で実際にGroupieからJetpack ComposeへのUI実装移行に取り組み、期待通りの開発体験の改善を得ることができました。 一方で実装移行を進めていくうちに移行作業の障害となる当初予想していなかった問題に直面しました。そのため現在はアプリのユーザービリティを最優先と考え、やむを得ず以下の方針で開発を行っています。

  • 実装移行中の既存画面を一旦元のGroupie実装に戻してGroupieでの実装を継続する
  • 新規の画面はJetpack Composeを使って実装する

これらの対応が必要となった主な問題を紹介します。

スクロールが引っかかる

画面をスクロール中にAndroidView in Jetpack Composeで構成されたGroupieItemに指が触れた際にスクロールが停止する挙動が見つかりました。最下層のAndroidViewにクリックイベントが設定されていた場合に再現し、focusableをfalseにするなど思いつく対策は試しましたが解決することはできませんでした。そもそもAndroidView in Jetpack Compose in AndroidView in RecyclerViewのような歪な構造になっているのが良くなく、スタンダードな実装からも外れているため将来的なJetpack Composeのアップデートでも改善は期待できないと思っています。

スクロールが引っかかる様子

スクロール中に表示がずれる

画面をスクロールしてComposeItemが表示領域に入った瞬間に表示コンテンツがガクッとずれる現象が起きていました。これはComposeItemがRecyclerViewによって再描画される際にComposeViewの表示 → composable functionの順に実行され、一瞬だけheightが0dp(wrap_content)な状態が描画されるのが原因です。対策として

  • ComposeItemの高さを保存して再描画時に復元する
  • RecyclerViewがItemをリサイクルしないようにする

といったことで解決できますが、だいぶ無理やりなworkaroundですし長大なコンテンツ表示やページネーション等でOOMのリスクがあります。

スクロールがガクッとずれる様子

既存画面実装をComposeItemを使った実装体験に近づける工夫

実用上の課題によりGroupieとJetpack Composeを混在させる方式で既存画面のUI実装を置き換えていくのは難しいことがわかりました。 一方で段階的な実装移行を試みたことで、特にComposeItemを使った実装の感触から、GroupieItemのボイラープレートコードを削減するだけでもGroupieでの実装の生産性を向上できる気づきがありました。 そのため今後も既存実装のJetpack Compose移行を念頭に起きつつ、 まずは目下の課題である開発面の改善に向けて素のGroupieItemの実装体験をComposeItem(+ AndroidViewBinding)を使った時の実装体験に近ける工夫を行っているので紹介します。

ViewBindingItem

ComposeItemと同様にGroupieItemの共通化を既存画面実装でも可能にするため、非Jetpack Compose用の汎用GroupieItemとして ViewBindingItemを作りました。 以下がそのコード例です。基本的にはComposeItemと同じような実装をしています。

class ViewBindingItem<T : ViewBinding>(
    @LayoutResprivateval layoutResource: Int,
    privateval callInitializeViewBinding: (View) -> T,
    privateval content: Any?,
    privateval id: Long = layoutResource.toLong(),
    privateval callUnbind: T.() ->Unit = { },
    privateval callBind: T.(Int) ->Unit = { },
) : BindableItem<T>() {
    overridefun getLayout(): Int = layoutResource

    overridefun getId(): Long = id

    overridefun hasSameContentAs(other: Item<*>): Boolean =
        if (other is ViewBindingItem<*>) {
            content == other.content
        } else {
            super.hasSameContentAs(other)
        }

    overridefun initializeViewBinding(view: View): T = callInitializeViewBinding(view)

    overridefun bind(viewBinding: T, position: Int) {
        viewBinding.callBind(position)
    }

    overridefun unbind(viewHolder: GroupieViewHolder<T>) {
        super.unbind(viewHolder)
        viewHolder.binding.callUnbind()
    }
}

ViewBindingItemを使ったActivity/Fragment上での表示処理は以下のようになり、非Jetpack Composeな実装でもComposeItemと同様に画面の構築(ViewBinding)だけに集中できるようになりました。

class HogeFragment : Fragment(R.layout.fragment_hoge) {
    overridefun onViewCreated() {
        val adapter = GroupAdapter<ViewHolder>()
        recyclerview.adapter = adapter

        adapter.update(mutableListOf<Group>().apply {
            add(
                ViewBindingItem(
                   layoutResource = R.layout.item_hoge,
                   callInitializeViewBinding = ItemHogeBinding::bind,
                   content = hogeData,
                ) {
                   // this is ItemHogeBinding
                   hogeTitle.text = hogeData.title
                   hogeMessage.text = hogeData.message
                }
            ) 
        })
    }
}

よりJetpack Compose likeな表示実装

実際のクックパッドマートアプリでのGroupie実装ではGroupAdapterを簡易に扱うためのユーティリティクラス GroupBuilderを使っています。 そこで ViewBindingItemの生成処理を GroupeBuilderの拡張関数にすることで、GroupieItemをComposable functionのような関数コンポーネントとして実装できるようにしました。

fun GroupAdapter<*>.updateTo(function: GroupBuilder.() ->Unit) {
    update(GroupBuilder().apply(function).build())
}

class GroupBuilder {
    privateval groups = mutableListOf<Group>()

    fun add(item: Group) {
        groups.add(item)
    }

    fun build(): List<Group> = groups
}

fun<T : ViewBinding> GroupBuilder.viewBindingItem(
    @LayoutRes layoutResource: Int,
    initializeViewBinding: (View) -> T,
    content: Any?,
    id: Long = layoutResource.toLong(),
    unbind: T.() ->Unit = { },
    bind: T.(Int) ->Unit = { },
) {
    add(
        ViewBindingItem(
            layoutResource = layoutResource,
            callInitializeViewBinding = initializeViewBinding,
            content = content,
            id = id,
            callUnbind = unbind,
            callBind = bind,
        )
    )
}

これを使うことで先述の ViewBindingItemを使った実装例は次のように書き換えることができ、よりJetpack Composeに近い表現で実装することが可能になりました。

fun GroupBuilder.hogeSection(hogeData: HogeData) {
    viewBindingItem(
        layoutResource = R.layout.item_hoge,
        initializeViewBinding = ItemHogeBinding::bind,
        content = hogeData,
    ) {
        // this is ItemHogeBinding
        hogeTitle.text = hogeData.title
        hogeMessage.text = hogeData.message
    }
}

class HogeFragment : Fragment(R.layout.fragment_hoge) {
    overridefun onViewCreated() {
        val adapter = GroupAdapter<ViewHolder>()
        recyclerView.adapter = adapter

        adapter.updateTo {
            hogeSection()
        }
    }
}

/* Jetpack Composeを使った場合の実装例@Composablefun hogeSection(hogeData: HogeData) {     AndroidViewBinding(ItemHogeBinding::inflate) {        // this is ItemHogeBinding        hogeTitle.text = hogeData.title        hogeMessage.text = hogeData.message    }}@Composablefun hogeScreen() {    Column {        hogeSection()    }}*/

また ViewBindingItemのインタフェースをComposable function(AndroidViewBinding)に近づけたことで今後GroupieをJetpack Composeに置き換える際も最小限の変更差分で済むようになりました。

既存画面の今後の実装移行について

以上の工夫により既存画面でのGroupieを使った実装の開発効率の改善し、同時にJetpack Composeを使っている新規画面の実装とのコード表現の統一を実現しました。 ただあくまでこれらはJetpack Compose移行に向けた繋ぎの対応であり実装移行の戦略は並行して練っています。 実装移行を前提とした開発環境の整備は将来的にスムーズな実装移行に繋がると考えており、既存画面実装の工夫は今後も続けていく予定です。

また既存画面の実装移行にあたり過去の実装資産を活かすにはAndroidViewBindingが役立つため積極的に活用したいと考えています。 一方でAndroidViewBindingにはリソースが適切に解放されずメモリリークする不具合を確認しており、アップデートで改善されるまでは多用するべきでないと判断しました。 したがってJetpack Composeを取り巻く環境がより成熟するまでは、再び手戻りがないように既存画面の実装移行は慎重にタイミングを探っているのが現状です。

まとめ

クックパッドマートアプリのGroupieでのUI実装課題を踏まえたJetpack Compose導入について、段階的な実装移行の方針とそれに向けた実装環境の整備について紹介しました。実際の実装移行については予想外のトラブルで計画通りに進められていないのが現状ですが、Jetpack Composeへの移行を諦めたわけではありません。 Jetpack Composeを使うことによる実装効率や生産性の恩恵は既にチームメンバー全体が実感しており、実装移行の機運はますます高まっています。課題は多いですが様々な工夫をしつつ移行に向けたトライは現在も続けています。

最後に

今回紹介したJetpack Composeだけでなくクックパッドでは新しい技術を積極的に活用してAndroidアプリ開発に取り組んでいます。技術的な挑戦やサービス開発に興味のあるAndroidエンジニアを絶賛募集してますのでお気軽にご応募ください。

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


プロと読み解く Ruby 3.1 NEWS

$
0
0

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

本日 12/25 に、ついに Ruby 3.1.0 がリリースされました(Ruby 3.1.0 リリース)。今年も Ruby 3.1 の NEWS.md ファイルの解説をします。NEWS ファイルとは何か、は以前の記事を見てください。

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

今回リリースする Ruby 3.1 は総論として、Ruby 3.0との互換性を重視したリリースとなっています。 つまり、あまり大きな非互換はありません。 比較的アップグレードしやすいと思いますので、みなさん是非試してみてください。

ちなみに、Ruby 2.6はあと4ヶ月でEOL(サポート終了)で、Ruby 2系列最後の2.7も1年4ヶ月でEOLになると思われます。 月日が立つのは早いですね。

Ruby 3.1 の目玉として、次のようなものがあげられています。

  • ハッシュリテラルやキーワード引数の省略記法の導入
  • 新しいJITコンパイラであるYJITの導入による性能向上
  • 開発環境の向上
    • デバッガの刷新
    • エラー箇所に下線をひく error_highlightの導入
    • IRB のオートコンプリートとドキュメント表示

本記事では、これらを含めて NEWS ファイルにあるものをだいたい紹介していきます。

■言語の変更

ハッシュやキーワードの省略記法が導入された

  • Values in Hash literals and keyword arguments can be omitted. [Feature #14579]

{ x: x, y: y }の省略記法として { x:, y: }と書けるようになりました。

x = 1
y = 2# h = { x: x, y: y } と同じ意味
h = { x:, y: }

p h  #=> {:x=>1, :y=>2}

また、キーワード引数でも同様の省略ができるようになりました。

deffoo(a:)
  p a
end

a = 1# foo(a: a) と同じ意味
foo(a:)

この機能の導入には実に6年の歳月がかかりました。

最初は、JavaScript(ECMAScript 6)の { x, y }と同じものが Ruby にも欲しい、という提案でした(Feature #11105)。 しかし、この記法は数学の集合にしか見えない(実際、Pythonではこの記法でSetが作られます)ということで、却下されました。

それから2年ほど経ち、{ x:, y: }という記法が提案されました(Feature #14579)。 これはコロンが入っているので、数学の集合の表記と誤解することはありません。 また、必須キーワード引数を受け取る記法 def foo(x:, y:)と似ているので、既存の記法との親和性も一応ありました。 しかしながら、JavaScript と異なる記法で導入して良いのか確信が持てないことや、記法の必要性に確信が持てなかったことなどから、一旦却下されました。

しかしその後、複数の人達から、同様の提案が断続的に送られてきました。 これにより、記法の需要が確からしいことがわかってきました。 そして、RubyKaigi Takeout 2021の延長戦でこの記法の話が上がり、ついにmatzが承認するに至り、この度無事に導入されました。

提案および実装をしたshugoさんの記事も合わせてご覧ください。

nacl-ltd.github.io

(mame)

ブロックを移譲する記法が導入された

  • The block argument can now be anonymous if the block will only be passed to another method. [Feature #11256]
deffoo(&)
  bar(&)
end

ブロックを受ける引数を無名にして渡すことができるようになりました。意味的には、次のように引数の名前をつけたものとほぼ同じです。

deffoo(&b)
  bar(&b)
end

元々の提案では、&で受けて &で渡せば Procオブジェクトにしなくても良いから速いよね、というものでしたが、Ruby 2.5 で Lazy Proc Allocationが導入されたので、性能の利点はなくなりました。単に、無名で Procを受け、渡すことができる、というものです。名前を考えるのが面倒なときに便利です。&_でいいじゃん、という気もしますが、若干気軽、なのかな?

受け取った引数をすべて受け渡したいということであれば、Ruby 2.7で導入されたArgument forwardingを使えばよいのですが、ブロックを受ける引数だけ、特別扱いしたい、というのは時々あるので、専用の構文が導入されました。

ちなみに、括弧を書かずにこの構文を使うと、次の行に変数名があると解釈されます。

deffoo&
  a        #=> def foo(&a) と解釈される
  p a      #=> #<Proc...>end

foo{}

ただ、似たような話である [Bug #18396]は、改行で引数の解釈を止めちゃってもいいかな、という議論もあるので、これも改行で引数の解釈を止める(上記例だとdef foo(&)と解釈される)ように変わるかもしれませんね。

というわけで、今のところ括弧をつけて利用するのが良いと思います。

この提案のもう少し先の話ですが、「ブロックを使わないメソッド」にブロックを渡したら警告したい、という話があります。例えば、Kernel#pはブロックを受け取りませんが、p{raise}はブロックを無視して動いてしまいます。うっかり「ブロックを取るだろう」と思っているメソッドにブロックを渡すバグって、時々ありますよね。あれを防ぎたい。

一度、これができないか試してみたことがあるんですが([Feature #15554]。ブロックを受けないProc.newが禁止された一つの理由)、意図的にブロックを無視するような書き方が若干あって、できなかったんですよね。今回、無名のブロックを受ける引数が入ったので、意図的ならこれ(&)を1個書いておいて、って言いやすくなるかもしれません。

(ko1)

パターンマッチの改善

パターンマッチが正式な言語機能になった

  • One-line pattern matching is no longer experimental.

一行パターンマッチのexperimentalが外れました。つまりRubyの正式な言語機能になりました。

ary = [1, 2, 3]

# 一行パターンマッチ(右代入の形式)
ary => [x, y, z]

p x #=> 1
p y #=> 2
p z #=> 3

一行パターンマッチには上記の右代入の形式(マッチ失敗したら例外になる)だけでなく、in演算子の形式(マッチの成否を真偽値として返す)があり、こちらも正式になりました。

ary = [1, 2, 3]

# 一行パターンマッチ(in演算子の形式)if ary in1, 2, z
  p z  #=> 3end

Ruby 3.0ではこれらの構文を使うと「experimentalである」という警告が出ていましたが、Ruby 3.1では出ません。

(mame)

一行パターンマッチのカッコが省略できるようになった

  • Parentheses can be omitted in one-line pattern matching. Feature #16182

一行パターンマッチで、カッコが省略できるようになりました。 右代入での多重代入っぽい記法が、より多重代入っぽく書けます。

ary = [1, 2, 3]

# カッコ省略した一行パターンマッチ
ary => x, y, z
# ary => [x, y, z] と同じ意味

配列パターンだけでなく、ハッシュパターンでもカッコが省略できます。

h = { a: 1, b: 2, c: 3, d: 4 }

h => a:, b:, c:
# h => { a:, b:, c: } と同じ意味

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

上記は右代入の形式ですが、in演算子でも同様に省略できます。

ちなみに1行パターンマッチは、普通の式として使えない式になっています。 これはパーサの技術的な制限によるのですが、次の例を考えると、何がむずかしいのかがわかるのではないかと思います。

# これは SyntaxError になる
foo(ary in x, y, z)

# foo((ary in x), y, z) なのか、# foo((ary in x, y), z) なのか、# foo((ary in x, y, z)) なのかが決まらない

まあ、右代入の形式は文として、in演算子はif文の条件式としてのみ使うようにするのが無難だと思います。

(mame)

パターンマッチのピン演算子に任意の式が書けるようになった

パターンの中に式を書けるピン演算子が導入されました。

ary = [1, 2, 3]

# ^ がピン演算子if ary in [x, ^(1 + 1), z]
  p x  #=> 1
  p z  #=> 3end

このif文はif ary in [x, 2, z]と同じ意味になります。 つまり、^(1 + 1)のところは、2にマッチするパターンとなります。

正確に言うと、式のところがローカル変数であるピン演算子はRuby 3.0でも許されていました。 Ruby 3.1からは、その位置に任意の式を書けるようになりました。ただしかっこが必要です。

ary = [1, 2, 3]
val = 2# Ruby 3.0でも3.1でも動くin ary in [x, ^val, z]
  p x  #=> 1
  p z  #=> 3
end

# Ruby 3.1から書ける(かっこが必要)in ary in [x, ^(1 + 1), z]
  p x  #=> 1
  p z  #=> 3
end

# すでにマッチした変数パターン`x`を参照することもできるin ary in [x, ^(x + 1), z]
  p x  #=> 1
  p z  #=> 3
end

なお、インスタンス変数、クラス変数、グローバル変数はかっこなしでピン演算子に書けます。

ピン演算子のカッコの中に副作用のある式(^(p(1))とか)を書くこともできてしまいますが、書かないようにしましょう。

(mame)

多重代入の評価順序が変更された

  • Multiple assignment evaluation order has been made consistent with single assignment evaluation order. (略) [Bug #4443]

多重代入の評価順が微妙に変更されました。次の例で説明します。

foo[0], bar[1] = a, b

Ruby 3.0までは、次の順で評価されていました。

  1. a
  2. b
  3. foo
  4. bar
  5. (fooの評価結果)[0] = (aの評価結果)
  6. (barの評価結果)[1] = (bの評価結果)

abが、foobarより前に評価されていることに注意してください。 Rubyの評価は原則として「左から右」なのですが、この評価順序は微妙にこの原則に反しています。 似た代入式である foo[0] = aは、次のように原則通りの順序で評価されます。

  1. foo
  2. a
  3. (fooの評価結果)[0] = (aの評価結果)

つまり、多重代入のときだけ評価順序が逆転するという問題がありました。 Ruby 3.1ではこれが修正され、原則通り、次の順序で評価されるようになります。

  1. foo
  2. bar
  3. a
  4. b
  5. (fooの評価結果)[0] = (aの評価結果)
  6. (barの評価結果)[1] = (bの評価結果)

ちなみにこれは11年前に私が報告したのですが、いろいろあって忘れられていた問題でした。 今回、Jeremy Evansというコミッタがチケットを発掘し、修正してくれました。

Jeremyは近年、放置されているチケットをcloseしていく活動を続けてくれています。 聞くところでは、数千以上あったopen状態のバグ報告チケットが、最近では350程度にまでなったそうです。すごい。

(mame)

main Ractor 以外でも、クラスとモジュールのインスタンス変数を参照することができるようになった

  • Non main-Ractors can get instance variables (ivars) of classes/modules if ivars refer to shareable objects. [Feature #17592]

これまで、あらゆるインスタンス変数は main Ractor(起動時に自動的に生成される Ractor、ふつうは意識することは無い)しか読み書きできなかったのですが、これを他の Ractor でも、格納されている値が shareable であれば、読めるようになりました。

classC@a = 1defself.a = @aendRactor.new do
  p C.a #=> 1end.take

この変更で、プロセスグローバルな設定を、クラスやモジュールのインスタンス変数に格納することができるようになりました。

個人的にはこの変更は、レースコンディションが生まれてしまうため、反対でした。例えば、@a@bというアトミックに扱わなければならない2変数があるとき、main が片方を変更中にほかの Ractor が両方を読んでしまうと、中途半端な @a@bのペアを読むことになってしまいます。

このようなことがないように、Transactional Memory などが提案されているんですが、去年提案しても入らなかったし、現実的に問題は生まれなさそうだし(2つ以上のアトミックに扱わなければならないデータってそもそもそんななさそう)、しょうがないかと思って観念しました。

というわけで、これを利用する場合は、問題が起こらないように、

  • 初期化時にしかセットしない(ほかのRactorがいなければ問題ない)
  • 複数のアトミックに扱わなければならないインスタンス変数は使わない(必要なら配列やハッシュにする)

などを心がけていただければと思います。

(ko1)

一行メソッド定義でカッコなしメソッド呼び出しが書けるようになった

  • A command syntax is allowed in endless method definitions, i.e., you can now write def foo = puts "Hello". Note that private def foo = puts "Hello" does not parse. [Feature #17398]

次のとおりです。

# Ruby 3.0でも書けたdeffoo = puts("Hello")
  
# Ruby 3.1で書けるようになったdeffoo = puts "Hello"

ただ、パーサの技術的な制約により、次のコードは書けません。

# private をつけたら SyntaxErrorprivatedeffoo = puts "Hello"

(mame)

■コマンドライン引数の変更

  • --disable-gems is now explicitly declared as "just for debugging". Never use it in any real-world codebase. [Feature #17684]

RubyGemsを無効化するオプションはデバッグ専用以外に使うべきでないと宣言されました。

$ ruby --help
...(略)...
Features:
  gems            rubygems (only for debugging, default: enabled)

RubyGemsはRuby 1.9のころに標準装備となりましたが、当時はまだRubyGemsを使わないユーザも少なくなかったので、このオプションが導入されました。 しかし現代ではRubyGemsを使わないことが珍しくなり、このオプションの意義が薄くなっていました。 一方で、「--disable-gemsの下でgemが動くようにしてほしい」というバグ報告がたびたび観測されるようになり、逆に非生産的になっているということで、--disable-gemsが事実上廃止となりました。

なお、完全に削除されなかったのは、Rubyインタプリタの開発者がデバッグ時にこのオプションを使うことがあるためです。 よって、普通のコードではもう使わないでください。

(mame)

  • --jit, --mjit, --yjit

JIT関連のオプションが変わりました。あとでまとめて紹介します。

(ko1)

■組み込みクラスのアップデート

共通要素の有無を判定するArray#intersect?が導入された

2つの配列に共通の要素があるかどうかを調べるメソッド Array#intersect?が導入されました。

# 2 が共通しているので true になる
[1, 2, 3].intersect?([0, 2, 4]) #=> true# 共通の要素がないので false になる
[1, 2, 3].intersect?([4, 5, 6]) #=> false

(mame)

子クラスの一覧を得るClass#subclassesが導入された

  • Class
    • Class#subclasses, which returns an array of classes directly inheriting from the receiver, not including singleton classes. [Feature #18273]

クラスから子クラスの一覧を得るClass#subclassesが導入されました。

classA; endclassB< A; end

p A.subclasses    #=> [B]

Railsの中でしばしば必要になっているということで導入されました。 Class#inheritedを定義して子クラスの一覧を自力で把握するコードを書いたことがある人はそこそこいるのではないでしょうか。

これは一見かんたんに見える機能でしたが、実装を安定させるのが意外と大変でした。 というのも、子クラスへの参照は弱参照(weak reference)なんですよね。 Rubyではクラスはオブジェクトなので、定数などに代入されていない無名クラスはGCに回収されてしまいます。 現在の実装では、各クラスは子クラスのリストを管理しているのですが、Class#subclassesがそのリストをたどっている最中にGCが発生した場合、現在たどっていた位置のリストノードがfreeされてしまい、segmentation faultが起きていました。 Railsのテストで実際に発生するので、3.1.0-preview1のリリース前後で泣きながら直していました。

なお、subclassesが返すのは直属の子クラスのみです。

classA; endclassB< A; endclassC< B; end# 直接の子クラスは B のみ(C は含まない)
p A.subclasses    #=> [B]

Ruby 3.1リリース直前まで、子孫クラスすべての一覧を返すClass#descendantsも導入予定だったのですが、モジュールの扱いをどうするべきか検討が必要などの理由でリリース1週間前に見送りとなりました。

(mame)

なお、Class#descendantsが Ruby 3.1 に入るという想定で Rails 7.0.0 がリリースされたのですが、入らなかったので Rails 7.0.0 は Ruby 3.1.0 に対応していません(やってみるとエラーが出ます)。すでに開発版では対応されているので(Remove feature checking for Class#descendants ・ rails/rails@bc07139)、Rails 7.0.1 に期待ですね。

(ko1)

nilを取り除くEnumerable#compactが導入された

Enumerableの要素からnilを取り除いた配列を返すEnumerable#compactが導入されました。

classFooincludeEnumerabledefeachyield1yield2yieldnilyield3endend

p Foo.new.to_a    #=> [1, 2, nil, 3]
p Foo.new.compact #=> [1, 2, 3]

Array#compactをEnumerableでもできるようにしたということですね。

また、これのlazy版であるEnumerable::Lazy#compactも導入されました。

[1, 2, nil, 3].lazy.compact.each {|x| p x } #=> 1, 2, 3

これはcompactの時点では配列を作らないので、要素数が非常に多いときには効率的になります。

(mame)

Enumerable#tallyがハッシュを受け取るようになった

  • Enumerable
    • Enumerable#tally now accepts an optional hash to count. [Feature #17744]

要素数をカウントするEnumerable#tallyに、カウント結果を格納・蓄積するハッシュを指定できるようになりました。

ary = ["A", "B", "C"]

# これは従来どおりの挙動
p ary.tally #=> {"A"=>1, "B"=>1, "C"=>1}# 空のハッシュを渡すと、そのハッシュを使ってカウントする
h = {}
ary.tally(h)
p h #=> {"A"=>1, "B"=>1, "C"=>1}# そのハッシュを再度tallyに渡すと、カウント結果を加算してくれる
["A"].tally(h)
p h #=> {"A"=>2, "B"=>1, "C"=>1}

最後の実行結果で "A"のカウント数が 2 になってるところがポイントです。 これにより、カウントしたい要素の列があらかじめ揃っていなくても、続きからカウントを再開できるようになりました。

卜部さんが見つけたベンチマークプログラムがきっかけで導入された機能です。

(mame)

Enumerable#each_conseach_sliceがselfを返すようになった

  • Enumerable
    • Enumerable#each_cons and each_slice to return a receiver. [GH-1509]

Array#eachなど多くのeach系メソッドはselfを返すのですが、なぜかeach_conseach_sliceはnilを返していました。 これが修正されました。

[1, 2, 3].each_cons(2){}
# 3.0 => nil# 3.1 => [1, 2, 3]

[1, 2, 3].each_slice(2){}
# 3.0 => nil# 3.1 => [1, 2, 3]

(mame)

File.dirname(name, level)で、ディレクトリのレベルを指定することができるようになった

  • File.dirname now accepts an optional argument for the level to strip path components. [Feature #12194]

パス名を受け取り、ファイル部分を削除してディレクトリ名を返すメソッドである File.dirnameに、親ディレクトリを何個たどって返すかを指定する第二引数 levelが追加されました。

こんな感じです。

p File.dirname('/home/ko1/foo.txt')    #=> "/home/ko1"
p File.dirname('/home/ko1/foo.txt', 0) #=> "/home/ko1/foo.txt"
p File.dirname('/home/ko1/foo.txt', 1) #=> "/home/ko1"
p File.dirname('/home/ko1/foo.txt', 2) #=> "/home"
p File.dirname('/home/ko1/foo.txt', 3) #=> "/"
p File.dirname('/home/ko1/foo.txt', 4) #=> "/"

(ko1)

GCの実行時間を計測する新しい方法が追加された

  • "GC.measure_total_time = true" enables the measurement of GC. Measurement can introduce overhead. It is enabled by default. GC.measure_total_time returns the current setting. GC.stat[:time] or GC.stat(:time) returns measured time in milli-soconds.
  • GC.total_time returns measured time in nano-seconds. Feature #10917

GCの時間を計測する新しい方法 GC.stat(:time)(ミリ秒で返る)、および GC.total_time(ナノ秒で返る)を追加しました。

ただ、GCの時間を正確に計測しようとすると、時間を測るためのオーバヘッドがかかってしまうため、GC.measure_total_time = trueのように、on/off の制御ができるようになっています。デフォルトは on です。つまり、遅くなります! が、ここのオーバヘッドが現実に効くようなケースは滅多にないだろうと思ってデフォルト on になっています。

これまでも、GC::Profilerを使う方法がありましたが、Sweepの時間などを計測しないなど、問題がありました。そこで、その辺正確に測るための仕組みを入れました。GC.stat(:time)がミリ秒なのは、JRubyなどですでに同じフィールドがミリ秒で返しているらしく、そのことの互換性を要求されたためです。

(ko1)

Integer.try_convertが導入された

to_intを使って引数のInteger化を試みるInteger.try_convertが導入されました。

# Integerならそのまま
p Integer.try_convert(1) #=> 1# FloatにはFloat#to_intがあるので変換される
p Integer.try_convert(1.0) #=> 1# String#to_intはないのでnilが返される
p Integer.try_convert("1") #=> nil

String.try_convertArray.try_convertなどとの対称性のためのようです。

(mame)

Kernel#loadの第二引数で任意のモジュールを指定できるようになった

  • Kernel#load now accepts a module as the second argument, and will load the file using the given module as the top-level module. [Feature #6210]

Kernel#load(file)は、requireのようにファイルを読み込み、Ruby プログラムとして評価するメソッドです。

# x.rbdeffoo = p(:foo)
# main.rbload(File.join(__dir__, 'x.rb'))
foo() #=> :foo

この例では、x.rbに定義されてあるメソッド fooが、トップレベルに定義されています。

ただ、メソッドや定数(クラスやモジュール定義含む)をトップレベルに定義されると困ることがあるかもしれません。そこで、Kernel#load(file, true)と第二引数にtrueを与えると、匿名のモジュールの中で定義され、実行されます。

load(File.join(__dir__, 'x.rb'), true)
foo() #=> undefined method `foo'

このとき、x.rbは、

Module.new do# ここに x.rb の中身が入るdeffoo = p(foo)
  # ここまでend

こんな感じで実行されます。厳密には selfが違ったり、実はそのモジュール自体を extendしていたり、いろいろ違うんですが、まぁ大雑把にはこんな感じです。ちなみに、ロードされるファイル(x.rb)でModule.nestingなどでその無名モジュールを見ることができます。

この第2引数に、true/falseではなく、モジュールを直接与えて、自動的に無名モジュールを作るのではなく、利用するモジュールを指定できるようになりました。

moduleM; endload(File.join(__dir__, 'x.rb'), M) # M#foo が定義される# foo() #=> undefined method `foo'includeM
foo() #=> :foo

DSL に使える、のかなぁ?

(ko1)

Marshal.load(data, freeze: true)で frozen object としてロードできるようになった

  • Marshal.load now accepts a freeze: true option. All returned objects are frozen except for Class and Module instances. Strings are deduplicated. [Feature #18148]

data = Marshal.dump(obj)とすると、シリアライズされたデータを取り出せます。これを、Marshal.load(data)とすることで、Ruby オブジェクトに戻すことができますが、このときfreeze: trueというキーワード引数を加えることで、戻した Ruby オブジェクトを freeze することができるようなりました。

Marshalは、deep copyに利用することができることが知られていますが(文字列の配列の配列、みたいな場合、その文字列と配列を全部コピーするのがdeep copy)、この deep copy 時についでに全部 freezeしてまわることができます。

ちなみに、文字列をMarshal.loadで戻す場合は、文字列リテラルでのfreeze"foo".freeze)のように、重複排除が行われます。つまり、同じ文字列は同じオブジェクトが返るようになります(frozen でないと、別の文字列オブジェクトにしなければならない)。

ary1 = ["hello", "hello"]

ary2 = Marshal.load(Marshal.dump(ary1))
p ary2[0].object_id == ary2[1].object_id #=> false

ary3 = Marshal.load(Marshal.dump(ary1), freeze: true)
p ary3[0].object_id == ary3[1].object_id #=> true

(ko1)

正規表現でキャプチャした部分文字列を返すMatchData#matchが追加された

正規表現のマッチ結果から、キャプチャされた部分文字列を返すメソッドが追加されました。

"abcdefg" =~ /(...)(....)/

p $~.match(1) #=> "abc"  # $~[1]や$1と同じ
p $~.match(2) #=> "defg" # $~[2]や$2と同じ

といっても、MatchData#[]とほとんど同じです。 強いて言うと、Rangeは受け取れないようです($~[1..2]は書けるけど$~.match(1..2)は書けない)。

(mame)

正規表現でキャプチャした部分文字列の長さを返すMatchData#match_lengthが追加された

正規表現でキャプチャされた部分文字列の長さを返すメソッドが追加されました。

"abcdefg" =~ /(...)(....)/$~.match_length(1) #=> 3 # $1.lengthと同じ$~.match_length(2) #=> 4 # $2.lengthと同じ

$1.lengthだと一旦文字列オブジェクトを作ってしまうので、それを避けるために導入されました。

こういう細かい最適化のためにメソッド追加するのではなく、処理系側の改善でどうにかなってほしいなあ。

(mame)

メソッドの可視性をチェックするメソッドが追加された

  • Method#public?, Method#private?, Method#protected?, UnboundMethod#public?, UnboundMethod#private?, UnboundMethod#protected? have been added. [Feature #11689]

まぁ見ての通りなのですが、MethodUnboundMethodpublic?などの可視性を確認するメソッドが追加されました。

deffoo = :foo

p method(:foo).public?  #=> false
p method(:foo).private? #=> true

チケットには pry とかで情報を表示するときに便利、ってありますね。自分は使うことあるかなぁ。

(ko1)

include済みのモジュールに対するprependが継承ツリーに反映されるようになった

  • Module#prepend now modifies the ancestor chain if the receiver already includes the argument. Module#prepend still does not modify the ancestor chain if the receiver has already prepended the argument. [Bug #17423]

include と prepend が混ざると混乱するんですが、これはそんな話です。

すでにクラス C に include されたモジュール M が別のモジュール P を prepend しても、Ruby 3.0 までは、C の継承ツリーに P は出てきませんでした。Ruby 3.1 からは、include されたモジュールに対しても P が出現するようになっています。

moduleP; endmoduleM; endclassCincludeMendM.prepend P
p C.ancestors
#=> Ruby 3.0: [C, M, Object, Kernel, BasicObject]#=> Ruby 3.1: [C, P, M, Object, Kernel, BasicObject]C.prepend P
p C.ancestors
#=> Ruby 3.0: [P, C, M, Object, Kernel, BasicObject]#=> Ruby 3.1: [P, C, P, M, Object, Kernel, BasicObject]

まぁ、難しいのであんまり多用しないほうがいいと思います。

(ko1)

privatepublicなどのメソッドがシンボルなどを返値を返すようになった

  • Module#private, #public, #protected, and #module_function will now return their arguments. If a single argument is given, it is returned. If no arguments are given, nil is returned. If multiple arguments are given, they are returned as an array. [Feature #12495]

メソッドの可視性を制御するprivatepublicなどのメソッドは、従来はselfを返していました(トップレベルではObject)。これを、指定したメソッドのシンボル(や、シンボルの配列)を返すように変更されました。対象が指定されない場合は nilが返ります。

classC
  p private(deffoo; end)
  #=> Ruby 3.0: C#=> Ruby 3.1: :foodefbar; end
  p private(:foo, :bar)
  #=> Ruby 3.0: C#=> Ruby 3.1: [:foo, :bar]

  p private#=> Ruby 3.0: C#=> Ruby 3.1: nilend

さらにメタプログラミングやっちゃうんですかね。

(ko1)

forkイベントをフックするためのProcess._forkが追加された

  • Process
    • Process._fork is added. This is a core method for fork(2). Do not call this method directly; it is called by existing fork methods: Kernel.#fork, Process.fork, and IO.popen("-"). Application monitoring libraries can overwrite this method to hook fork events. [Feature #17795]

Process._forkというメソッドが追加されました。 が、普通のコードで使うものではないので、忘れてください。

以下、物好きな人のための解説と裏話です。

一言でいうと、これは、Rubyがforkする瞬間をフックするライブラリのために導入されたメソッドです。

たとえばDataDogのようなアプリケーションモニタは、Rubyプログラム内にスレッドを立ててプログラムの状態を観測します。 しかしスレッドは、forkで作られた子プロセスには継承されません。 そういうライブラリは、forkシステムコールが呼ばれたとき、子プロセス側で速やかに新たな観測スレッドを立ち上げ直す必要があります。

しかし「forkシステムコールが呼ばれたとき」をフックするのは意外とむずかしいことでした。 なぜかというと、Rubyには、forkシステムコールを呼ぶ方法がいくつもあるのです。 Kernel#forkが代表的ですが、Process.forkもあります。 また、Kernel.forkという書き方も稀に使われています。 さらに、ほとんど知られていなかった極秘機能ですが、IO.popen("-")でもforkが可能です。 ActiveSupportにForkTrackerという、forkイベントを追跡するためのモジュールがあるのですが、これらをすべてを適切にフックするのはなかなか大変でした(IO.popen("-")なんかは気づいてなかったようです)。

そこで今回、Rubyがforkシステムコールを呼ぶメソッドをProcess._forkメソッドに一本化しました。 Kernel#forkProcess.forkIO.popen("-")たちはすべてProcess._forkを呼びます。 これで、forkイベントをフックしたいライブラリは、Process._forkをオーバーライドするだけでできるようになります。

結論から見るとかんたんな話に見るかもしれませんが、これも提案から導入まで10年かかってます。 当初はfork前後で実行されるブロックを登録するat_forkとして提案されましたが、

  • 複数の人が思い思いのユースケースを語っているが、具体的に何が、どうして必要なのか整理されていない
  • fork前、fork後(親プロセス側)、fork後(子プロセス側)の3つのフックポイントがあり、どれが実際に必要なのかわからない
  • 完全にプロユースの API だが at_forkはカジュアル感がありすぎる
  • 複数のライブラリが at_forkしたとき、何個目のフックが呼ばれているのかがわからない(運悪くフックの処理内容が競合していると例外が起きるかもしれないが、バックトレースを見てもわからない)
  • Ruby のどこでどのように fork が使われているか把握しきれていない
  • すでに自力でKernel#forkなどを再定義してフックしているライブラリといい感じに共存できるかわからない

などなど非常に多数の課題があり、停滞していました。 今回、気合を出して交通整理をし、要求と要件をまとめて提案チケットを作り直してもらい、開発者会議で議論を重ねて、「ライブラリにProcess._forkというメソッドをオーバーライドさせる」という形で一応決着させることができました。

(なお、方針が決定してからも、_forkメソッドに一本化する実装が地味に大変だったり、_forkという名前で決まるまでにも2ヶ月くらいかかったり、いろいろ大変でした)

(mame)

Structがkeyword_initされたかどうかを知るメソッドが追加された

Structがkeyword_init: trueで定義されているかどうかを返すメソッドが導入されました。

Foo = Struct.new(:foo, :bar, keyword_init: true)

p Foo.keyword_init? #=> true

keyword_init: falseの場合は false、未指定の場合は nilを返します。

Bar = Struct.new(:foo, :bar, keyword_init: false)

p Bar.keyword_init? #=> falseBaz = Struct.new(:foo, :bar)

p Baz.keyword_init? #=> nil

なお、将来的には keyword_initキーワード引数は不要にしていく方向です。 詳しくは次の項目を見てください。

(mame)

Structの最初のメンバをキーワード引数で初期化するのがdeprecateされた

  • Struct

    • Passing only keyword arguments to Struct#initialize is warned. You need to use a Hash literal to set a Hash to a first member. [Feature #16806]

Struct#initializeのメンバをハッシュで初期化するとき、ちゃんとハッシュを渡さないとダメになりました。

Foo = Struct.new(:foo)

# Ruby 3.0: ハッシュを渡したように動く# Ruby 3.1: Ruby 3.0と同じ(ただし警告が出る)# Ruby 3.2: エラーになる予定
p Foo.new(a: 1, b: 2)

# ちゃんとハッシュを渡せばOK(Ruby 3.1で警告は出ず、Ruby 3.2でも動く予定)
p Foo.new({ a: 1, b: 2 }) #=> #<struct Foo foo={:a=>1, :b=>2}>

これは何を狙っているかと言うと、明示的なkeyword_initを不要にすることです。 つまりRuby 3.2では次のように書けるようになる見込みです。

Foo = Struct.new(:foo, :bar) # 明示的な keyword_init: true を書かない# Ruby 3.2 では次のように初期化できる予定
p Foo.new(foo: 1, bar: 2) #=> #<struct Foo foo=1, bar=2>

このコードは、Ruby 3.0ではキーワード引数から通常引数への暗黙的変換により Foo.new({foo: 1, bar: 2}, nil)のように解釈されてしまいます。 こういうコードを修正してもらうために、Ruby 3.1では移行措置として、動作自体は維持しつつ、警告を出すようになりました。

Foo.new(foo: 1, bar: 2)
#=> warning: Passing only keyword arguments to Struct#initialize will behave differently from Ruby 3.2. Please use a Hash literal like .new({k: v}) instead of .new(k: v).

(mame)

Unicode 13.0.0 が導入された

タイトルの通りなんですが、絵文字もどんどん増えますねえ(Emoji Version 13.0 List)。

(ko1)

String#unpackにoffsetを渡せるようになった

  • String
    • String#unpack and String#unpack1 now accept an offset: keyword argument to start the unpacking after an arbitrary number of bytes have been skipped. If offset is outside of the string bounds ArgumentError is raised. [Feature #18254]

String#unpackで読み取りを始めるオフセットを指定できるようになりました。

# 65 は "A" の ASCII コード"fooA".unpack("C", offset: 3) #=> [65]

要素をひとつだけ返すString#unpack1も同様に拡張されています。 バイナリデータのパースで便利なこともあるかもしれません。

(mame)

Queueの初期化時に初期値をセットできるようになった

  • Thread::Queue#initialize now accepts an Enumerable of initial values. [Feature #17327]

Thread::Queueの初期化時に#to_aメソッドを持っているオブジェクトを指定して初期化できるようになりました。

q = Thread::Queue.new(5.times)
5.times{p q.pop}

#=>01234

(ko1)

Thread#native_thread_idが追加された

ログに表示するために、Rubyのスレッドが現在使っているシステムのスレッドのIDが欲しい、というリクエストに応えるために追加されました。

p Thread.current.native_thread_id #=> 19192

が、そもそも「システムのスレッドID」という概念がなかなか難しいのです。

Rubyのスレッドはシステムが提供するスレッドをどう使うか、理論的にはいろんな方法がありえるわけです。例えば、Ruby 1.8までは、1つのシステムスレッドしか使っていませんでした(その場合、native_thread_idは1つの値しか返さないのでしょう)。現在は1つのRubyスレッドに対して1つのシステムが提供するスレッドを使う実装になっていますが、今後それが変わるかもしれません。そもそも、「システムが提供するスレッド」も、Linux が提供するものだったり、Linux 上で実装されたユーザーレベルスレッドであるかもしれなくて、まぁいろいろです。

というわけで、この値はあるRubyスレッドごとに唯一の固定値が返るわけではない(今はそうだけど)みたいなことを、この辺に興味ある人は覚えておくと良いかと思います。

元々のリクエストは、外部のモニタリングツールで得られた native thread id と Ruby のスレッドとの関連みたいなのが確認したいみたいなので、とりあえずはこれでいいのだと思います。

(ko1)

--backtrace-limitに指定された値を読み出すメソッドが追加された

  • Thread::Backtrace

    • Thread::Backtrace.limit, which returns the value to limit backtrace length set by --backtrace-limit command line option, is added. [Feature #17479]

バックトレースの長さを指定する --backtrace-limitというコマンドライン引数があるのですが、これに渡された値を読み出すメソッド Thread::Backtrace.limitが追加されました。

$ ruby --backtrace-limit 42 -e 'p Thread::Backtrace.limit'42

エラーメッセージの文字列を模倣して作るライブラリがこれの情報を必要とするとのことでした。

(mame)

Time.new(in: timezone)でタイムゾーンがキーワード引数で指定できるようになった

  • Time.new now accepts optional in: keyword argument for the timezone, as well as Time.at and Time.now, so that is now you can omit minor arguments to Time.new. [Feature #17485]

これまで、タイムゾーンを指定した Timeオブジェクトの生成は、

p Time.new(2021, 1, 1, 0, 0, 0, "+09:00") #=> ok: 2021-01-01 00:00:00 +0900

こんなふうにオプショナル引数を全部指定した最後に渡してあげないといけなかったようです。

# チケットに書いてある期待に反する例Time.new(2021, 1, 1, "+09:00")          #=> bad: 2021-01-01 09:00:00 +0900Time.new(2021, 1, "+09:00")             #=> bad: 2021-01-09 00:00:00 +0900Time.new(2021, "+09:00")                #=> ArgumentError (mon out of range)

これを、:inキーワードを受けるようにして、書きやすくしました。

# チケットに書いてある利用例Time.new(2021, 1, 1, in: "+09:00") #=> ok: 2021-01-01 00:00:00 +0900Time.new(2021, in: "+09:00")       #=> ok: 2021-01-01 00:00:00 +0900

Time.nowTime.atでも同様にin:キーワードを受けるようになったそうです。

Time.newの引数チェックが厳しくなった

  • At the same time, time component strings are converted to integers more strictly now.

というわけで、これまではなんとなく(多分、意図とは異なるように)動いていた次のようなケースでエラーが出るようになりました。

p Time.new(2021, 12, 25, "+07:00")
#=> Ruby 3.0: 2021-12-25 07:00:00 +0900#=> Ruby 3.1: invalid value for Integer(): "+07:00" (ArgumentError)

(ko1)

この変更の背景は、次の Ruby 3.0 の挙動を見るとわかりやすいです。

# Ruby 3.0Time.new("2021-12-25") #=> 2021-01-01 00:00:00 +0900

直感に反して、1月1日になっていることに注意してください。"2021-12-25".to_iした結果が年として使われ、月日は無指定なのでデフォルトで1として解釈されていました。

このように、明らかに意図と異なると思われるコードが散見されたので、Ruby 3.1 からは引数が文字列の場合にもうちょっと厳しくチェックされるようになりました。

# Ruby 3.1Time.new("2021-12-25") #=> invalid value for Integer(): "2021-12-25" (ArgumentError)

(mame)

Time#strftimeがRFC 3339 UTCのunknown offset local timeに対応した

  • Time#strftime supports RFC 3339 UTC for unknown offset local time, -0000, as %-z. [Feature #17544]

Time#strftimeが、"%-z"といったフォーマットに対応したようです。Time わからな過ぎてこれ以上書けません。

# テストから抜粋
    assert_equal("+0000", t2000.strftime("%z"))
    assert_equal("-0000", t2000.strftime("%-z"))
    assert_equal("-00:00", t2000.strftime("%-:z"))
    assert_equal("-00:00:00", t2000.strftime("%-::z"))

(ko1)

再入を許す TracePoint#allow_reentryが追加された

  • TracePoint.allow_reentry is added to allow reenter while TracePoint callback. [Feature #15912]

TracePointは、何かイベントが起こると、指定したコールバックを実行するための仕組みですが、そのコールバックを実行中にTracePointイベントが起こると、どんどんコールバックが再帰してしまって書きづらいです。そのため、これまではコールバックを実行中は、コールバックを許さないようにしていました。

TracePoint.allow_reentry do ... endを使うことで、ブロックの実行中はコールバックを許す、という指定をできるようにしました。使い方を間違えるとすぐに無限再帰してしまうので、注意して使ってください。

というか、正しい制御をするのは多分むっちゃ難しいので使わない方がいいです。

TracePoint.new(:line){|tp|
  p tp # ここでは reentrance ではないTracePoint.allow_reentry{
    # ここは reentrance、なので、またこの callback が呼ばれて無限再帰
    p :reentry
  }
}.enable

a = 1

デバッガなどでTracePoint機能を利用してプログラムを止めているとき、そこでユーザーが指定するプログラムを評価する、という機能がありますが、そのプログラム中でTracePointclassイベントが発火してくれないとZeitwerkが困る、というのが一番わかりやすい要求だったんですが、それ以外にもそういう実行の際でのブレイクポイントが効かなくなる、といった話もありました。

まだこれ使ってデバッガを拡張していないんですが、実装しないとなぁ。大変そうだなぁ。

(ko1)

$LOAD_PATH.resolve_feature_pathが失敗時にnilを返すようになった

  • $LOAD_PATH

$LOAD_PATH.resolve_feature_pathはライブラリが見つからなかったときに例外を投げていましたが、nil を返して欲しいという要望があったので変わりました。

p $LOAD_PATH.resolve_feature_path("not-found") #=> nil

(mame)

Fiber scheduler の対応が広がった

  • Add support for Addrinfo.getaddrinfo using address_resolve hook. [Feature #17370]
  • Introduce non-blocking Timeout.timeout using timeout_after hook. [Feature #17470]
  • Introduce new scheduler hooks io_read and io_write along with a low level IO::Buffer for zero-copy read/write. [Feature #18020]

Fiber scheduler は、ブロックしてしまう処理があるとスケジューリングができなくなるのですが、Addrinfo.getaddrinfoなどにフックを呼ぶようにするような対応が入りました(多分...)。

また、IO::BufferというIOを直接使うために便利な仕組みが導入されました。

  • IO hooks io_wait, io_read, io_write, receive the original IO object where possible. [Bug #18003]

これらのメソッドでは、これまで fd がやってきた(のかな?)のが、IOオブジェクトを直接渡してくれるようになりました。

Monitorが Fiber ごとに効くようになりました。

スタックのコピーで行っていたコルーチン(のための primitive)の実装が、pthread を用いたものに置き換わりました。Ruby 1.8 以前の伝統のスタックコピーによるコンテキスト切り替えが、これでなくなることになります(あれ、まだ callcc のために残ってるかな?)。

(ko1)

Refinementクラスが導入された

  • New class which represents a module created by Module#refine. include and prepend are deprecated, and import_methods is added instead. [Bug #17429]

Module#refineはこれまで無名のモジュールを作っていたのですが、これをRefinementという専用クラスで作るようになりました。

moduleMrefineIntegerdo
    p self#=> #<refinement:Integer@M>
    p self.class
    #=> Ruby 3.0: Module#=> Ruby 3.1: Refinementendend

Refinementでは、includeprependは非推奨になり、多分将来は使えなくなるのではないかと思います。

その代わり、import_methodsという別の拡張の仕組みが導入されました。

moduleDivdef/(o)
    Rational(self, o)
  endendmoduleMrefineIntegerdo
    import_methods DivendendusingM

p 1/2

includeと似ていますが、その時点のスナップショットをとってくる、という点が異なります。つまり、include Mの場合、Mに変更があると、その変更がincludeしたクラスなどに影響しますが、import_methods Mではその時点のメソッド定義をもってくるので、Mが変わっても影響をうけません。

(ko1)

標準ライブラリの更新

  • The following default gem are updated.
    • RubyGems 3.3.3
    • base64 0.1.1
    • benchmark 0.2.0
    • bigdecimal 3.1.1
    • bundler 2.3.3
    • cgi 0.3.1
    • csv 3.2.2
    • date 3.2.2
    • did_you_mean 1.6.1
    • digest 3.1.0
    • drb 2.1.0
    • erb 2.2.3
    • error_highlight 0.3.0
    • etc 1.3.0
    • fcntl 1.0.1
    • fiddle 1.1.0
    • fileutils 1.6.0
    • find 0.1.1
    • io-console 0.5.10
    • io-wait 0.2.1
    • ipaddr 1.2.3
    • irb 1.4.0
    • json 2.6.1
    • logger 1.5.0
    • net-http 0.2.0
    • net-protocol 0.1.2
    • nkf 0.1.1
    • open-uri 0.2.0
    • openssl 3.0.0
    • optparse 0.2.0
    • ostruct 0.5.2
    • pathname 0.2.0
    • pp 0.3.0
    • prettyprint 0.1.1
    • psych 4.0.3
    • racc 1.6.0
    • rdoc 6.4.0
    • readline 0.0.3
    • readline-ext 0.1.4
    • reline 0.2.8.pre.11
    • resolv 0.2.1
    • rinda 0.1.1
    • ruby2_keywords 0.0.5
    • securerandom 0.1.1
    • set 1.0.2
    • stringio 3.0.1
    • strscan 3.0.1
    • tempfile 0.1.2
    • time 0.2.0
    • timeout 0.2.0
    • tmpdir 0.1.2
    • un 0.2.0
    • uri 0.11.0
    • yaml 0.2.0
    • zlib 2.1.1

これらのライブラリのバージョンアップがありました。

  • The following bundled gems are updated.
    • minitest 5.15.0
    • power_assert 2.0.1
    • rake 13.0.6
    • test-unit 3.5.3
    • rexml 3.2.5
    • rbs 2.0.0
    • typeprof 0.21.1

これらの bundled gems のアップデートがありました。

  • The following default gems are now bundled gems.
    • net-ftp 0.1.3
    • net-imap 0.2.2
    • net-pop 0.1.1
    • net-smtp 0.3.1
    • matrix 0.4.2
    • prime 0.1.2
    • debug 1.4.0

これらは bundled gems になりました。Bundler とともに利用するときは Gemfile に書くのを忘れないようにしてください。

(ko1)

カバレッジライブラリが測定を一時停止・再開できるようになった

  • Coverage measurement now supports suspension. You can use Coverage.suspend to stop the measurement temporarily, and Coverage.resume to restart it. See [Feature #18176] in detail.

カバレッジの測定を一時停止できるようになりました。

oneshot coverageと組み合わせることで、特定のエンドポイントの処理に使われるコードを把握でき、Rails モノリスの分割の一助になるのでは? という構想で入りました。

カバレッジ測定の一時停止は昔からときどき要望が来ていた機能だったのですが、カバレッジライブラリの作者である自分がユースケースを理解できなかったため、導入を見送り続けてきました。 今回、クックパッド社内でも同様の需要があることがわかったので、雇い主の要望ならしょうがないですよね実際に困っている人から詳しく話が聞けて納得できたので、ついに導入することにしました。 oneshot coverage が導入されたことで、昔よりもユースケースに妥当性が増したこともあります。

Ruby 3.1 の新機能なのでさすがにすぐにサービス投入にはならないのですが、そのうち記事や発表ができるといいなあ。

(mame)

Random::Formatterrandom/formatter.rbに移された

  • Random::Formatter is moved to random/formatter.rb, so that you can use Random#hex, Random#base64, and so on without SecureRandom. [Feature #18190]

これまでは securerandomをrequireするとRandom#base64Random#hexというメソッドが付け加わったようなのですが、本質的にSecureRandomとは無関係なので、これを'random/formatter'というライブラリに分けました。

# Ruby 3.0 (and works on 3.1)require'securerandom'
p Random.base64 #=> "cHn6rPPl75CwaTxNOL36tA=="
# Ruby 3.1require'random/formatter'
p Random.base64 #=> "mczNU8TeKq+ihK3p2e2hzw=="

(ko1)

■非互換

  • rb_io_wait_readable, rb_io_wait_writable and rb_wait_for_single_fd are deprecated in favour of rb_io_maybe_wait_readable, rb_io_maybe_wait_writable and rb_io_maybe_wait respectively. rb_thread_wait_fd and rb_thread_fd_writable are deprecated. [Bug #18003]

これらの関数は deprecated になったようです。

(ko1)

■標準ライブラリの非互換

ERB.newの引数がキーワード引数のみになった

  • ERB#initialize warns safe_level and later arguments even without -w. [Feature #14256]

ERB.newに普通の引数を渡すと、廃止予告の警告が出るようになりました。

# Ruby 3.1では警告が出るERB.new("src", nil, "%")
#=> -e:1: warning: Passing safe_level with the 2nd argument of ERB.new is deprecated. Do not use it, and specify other arguments as keyword arguments.#   -e:1: warning: Passing trim_mode with the 3rd argument of ERB.new is deprecated. Use keyword argument like ERB.new(str, trim_mode: ...) instead.# キーワード引数渡しなら警告が出ないERB.new("src", trim_mode: "%")

より正確に言うと、Ruby 3.0でも-wコマンドラインオプションを渡すと警告が出ていました。 Ruby 3.1からはこの警告がデフォルトで出るようになりました。

歴史的に、ERB.new("src", nil, "%")という呼び出し方が長らく使われていたと思うのですが、おそらく次のバージョンくらいでエラーになると思われます。 これからは上記のようにキーワード引数で渡すようにしてください。

ちなみに第2引数はsafe_levelでしたが、safe_levelはすでにRuby 3.0からサポートされていないので、対応するキーワードはありません。

(mame)

古い debug.rb が debug.gem に変わった

  • lib/debug.rb is replaced with debug.gem

実装もインターフェースも、完全に別のものに変わりました。万が一古いほうを使いたいときは、debug.gem のv0.2を使ってください。debug.gem については、あとで紹介します。

(ko1)

Kernel#ppの表示がデフォルトでターミナルの幅に合わせるようになった

  • Kernel#pp in lib/pp.rb uses the width of IO#winsize by default. This means that the output width is automatically changed depending on your terminal size. [Feature #12913]

オブジェクトをいい感じにフォーマットして出力するKernel#ppが、ターミナルの幅を考慮してフォーマットするようになりました。

画面幅が十分にあるときは一行で表示します。

f:id:ku-ma-me:20211222165430p:plain
画面幅が広いと折りたたまずに表示する

画面幅を狭めて同じコードを実行すると、勝手に折りたたみます。

f:id:ku-ma-me:20211222165317p:plain
画面幅が狭いと折りたたんで表示する

(mame)

Psych.loadのデフォルトの挙動が安全になった

  • Psych 4.0 changes Psych.load as safe_load by the default. You may need to use Psych 3.3.2 for migrating to this behavior. Bug #17866

Psychが3から4にメジャーバージョンアップしました。 Psych.loadが任意オブジェクトの読み込みをデフォルトで無効化したという、大きめの非互換があります。

少し詳しく説明します。

PsychはYAMLの読み書きをするライブラリです。 通常YAMLは、文字列や配列など、基本的なデータ構造を書くものですが、アプリケーションごとに拡張が可能になっています。 Psychはこの拡張を利用して、任意のRubyオブジェクトを表現することを許しています。 たとえば次のYAMLをPsych 3のPsych.loadで読み込むと、クラスFooのインスタンスが生成されていました。

--- !ruby/object:Foo {}

この挙動はしばしばセキュリティ問題につながることが知られています。 アプリケーションが信頼できないYAMLをロードすることで、変なオブジェクトが作られてしまい、そこから任意コード実行などいろいろなことに繋がる可能性があります。 任意オブジェクトの読み込みを無効化したPsych.safe_loadも提供されていたのですが、慣習的にPsych.loadが使われ続けているので、問題はなかなか止まりませんでした。

そこで今回、Psych.loadのデフォルトの挙動をPsych.safe_loadにしてしまうという変更がPsych 4でされました。 これによって、脆弱性問題は起きなくなります。

必然的に、YAMLの任意オブジェクトの読み込みに依存していたアプリケーションは動かなくなります。 Psych.loadPsych.unsafe_loadに置き換えれば以前通りの挙動になりますが、脆弱性問題が復活する可能性があるので、それよりはpermitted_classesキーワード引数を使って必要なクラスのみを明示的に許可するほうがおすすめです。

classFoo; end

p Psych.load("--- !ruby/object:Foo {}", permitted_classes: [Foo])
#=> #<Foo:0x00007f7e095b4530>

ちなみに、任意オブジェクトの読み込み以外にも、データ内のエイリアスも同様に無効化されました。 Psych.load(str, aliases: true)で有効化できます。

(mame)

■C API の更新

C API についての Doxygen のドキュメントが大量に追加されました。

(ko1)

  • rb_gc_force_recycle is deprecated and has been changed to a no-op. [Feature #18290]

「このオブジェクト、もう要らんわ」というときに rb_gc_force_recycle(obj)と指定することで早めに解放を指示することができました。が、実はこの関数で要らんと言われても、長らく「何も触れないオブジェクト」として特別扱いしていました(次の sweep を待つ必要があった)。GC を実装しているといろいろ邪魔なので、いっそのこと何もしない関数にして、将来的には消したいね、としました(消すのはでも当分先かも)。

細かい話はこちらに詳しいです: rb_gc_force_recycle is deprecated in Ruby 3.1 - Peter Zhu

(ko1)

■実装の改善

クラス変数の読み込みにインラインキャッシュがついた

  • Inline cache mechanism is introduced for reading class variables. [Feature #17763]

クラス変数の読み込み時にインラインキャッシュを使うことで、読み込みを高速化するようになりました。

クラス変数を更新するとき、具体的にどのクラス変数を使うのか、というのは実はソコソコ面倒な処理が入ります(仕様、もう覚えていないくらい面倒くさい)。つまり、遅いです。そこで、「以前この場所でクラス変数をあるクラスで読んだなら、同じクラスに対するクラス変数なら、きっと同じ場所のクラス変数を読むだろう」というのは自然な発想です。というわけで、そういう実装が入りました。

クラス変数は仕様が微妙だなぁ、と思って、目を向けないようにしていたんですが、Rails ではよく使うから、ということで入りました。ますます仕様と実装が複雑になって嫌だなぁ。多分、クラスやモジュールのインスタンス変数を使った方がわかりやすいと思うんだよなぁ。

(ko1)

instance_eval/execで特異クラスの生成を遅延した

  • instance_eval and instance_exec now only allocate a singleton class when required, avoiding extra objects and improving performance. [GH-5146]

obj.instance_eval{ ... }のブロックでメソッドを定義したら、どこに定義されるか知ってますか? 実は、obj.singleton_classに定義されます。

o = Object.new
o.instance_eval dodeffoo = :fooend

p o.foo() #=> :foo
p foo()   #=> undefined method `foo' for main:Object

これを実現するために、instance_evalを実行する前に毎回 singleton class を準備していたんですが、メソッド定義することって稀ですよね、だいたい self差し替えたいだけですよね、という知見から、本当に必要なときまで singleton class の生成を遅延するようになりました。メソッド定義を行わない場合に、instance_eval/execがすごく速くなったらしいですよ。

(ko1)

Structのアクセサを高速化した

  • The performance of Struct accessors is improved. [GH-5131]

Structのメンバーへのアクセスが妙に遅かったので、だいたいインスタンス変数アクセス程度の性能になるくらいに速くしておきました。前からやりたかったんですよね。ついに重い腰を上げました。これで、匿名Struct(Feature #16986: Anonymous Struct literal)があれば、もっと便利に使えるんだけどなあ。

(ko1)

必須引数のみのメソッドを記述できるようにした(MRI 実装用)

  • mandatory_only? builtin special form to improve performance on builtin methods. [GH-5112]

いままでCで書いていたメソッドをRubyで書き直すって話をちょっとずつ進めているんですが、オプショナル引数を取るメソッドなどで、C に性能的に勝てないことがありました(オプショナル引数を代入したり、そこから取り出す Ruby のコードが動いてしまうため)。一番よく使われるのはオプショナル引数がない場合なので、そのときの性能をなんとかあげたい、ということで考えたのが必須引数しかうけない場合の特殊化したメソッドの定義方法を作りました。特殊化した場合と、一般的な場合の2つのメソッドを1つのメソッドに同居させています。

現状、これが必要になるのはだいぶ稀なので(メソッドの実体の実行時間が十分小さい場合に限る)、ちょっとずつ使って行こうと思います。

リリース直前にこれに絡む大きな設計ミスに気づいて修正にだいぶ時間がかかって、この原稿書くのがだいぶ遅れました。

ちなみにこれ、いわゆるオーバーロードを実装する話です。将来的には、もう少し Ruby の internal で活用していければと思っています。Ruby の言語仕様に出てくるかは微妙(多分、出てこない)。

(ko1)

可変長オブジェクトに対応したGC拡張が導入された(デフォルトではオフ)

  • Experimental feature Variable Width Allocation in the garbage collector. This feature is turned off by default and can be enabled by compiling Ruby with flag USE_RVARGC=1 set. [Feature #18045] [Feature #18239]

新しい GC の拡張が入りました。といっても、まだデフォルトには有効になっておらず、Ruby をビルドするときにUSE_RVARGC=1と指定する必要があります(例えば configure だとcppflags=-DUSE_RVARGC=1を追加)。

これまで、Rubyのオブジェクトを確保すると、40バイト(64bit CPUの場合、ポインタ長 8 バイトの5倍)の固定長のメモリを確保していました。このメモリをRVALUEといいます。GCの対象となるのは、このRVALUEです。Rubyオブジェクトは、もちろんこれよりも大きなメモリが必要になるので、どうするかというとmalloc()などで外部メモリを保持しておき、そこへのポインタを保持していました。外部メモリを持っている場合は、解放時にfree()などします。

これを、Variable Width Allocation(略してVWA)では、このRVALUEが40バイト固定長の制限をとって、必要に応じて大き目のメモリサイズを確保できるようにしたものです。なお、RVALUEに確保できるメモリサイズには制限があるので、それ以上確保したいとき(例えば、大きな文字列を確保するとき)は、これまで通り外部メモリを確保します。ある意味、GCのある言語処理系が用いる「ふつう」の方法です。

この方法の利点と欠点は次の通りです。

  • 利点
    • 外部メモリを確保しなくてよいので、RVALUE内に必要な情報がそろうことになり、メモリの局所性があがり、キャッシュヒット率が高くなり、性能向上が期待できる。
    • オブジェクト解放時、外部メモリを解放する必要がなくなる(ことが多い)ため、解放処理のオーバヘッドが下がる。
  • 欠点
    • 確保するサイズごとにメモリ領域を作るので、フラグメンテーションが問題になる(コンパクションによって解決可能)。
    • 今は40, 80, 160, ... と大雑把なメモリサイズでしか確保しないので、例えば84バイト確保しようとすると、160バイトのメモリ領域を確保するため、無駄が多い(より詳細なチューニングで解決可能)。

これまでは、フラグメンテーションの問題が気になって、なかなか導入を躊躇っていたんですが、ここ数年の compaction 実装の向上で、問題なくなった、のかなぁ。うまくいくといいですね。

そんなわけで、Ruby 3.1 で有効にするのは怖かったので、Ruby 3.2 で有効にできるように、Ruby 3.1 リリース後にはデフォルトで有効になる予定です(固定長で確保する方法をなくす予定)。

この拡張はShopifyの皆様の提案なのですが、すでにShopifyの一部で使っても問題なかった、という報告も受けています。今は、固定長のRVALUEという制限のもとでRubyインタプリタが構成されているので、この機能を有効にしてもいまいち性能は変わらないのですが、今後このデータ構造にあわせてインタプリタの抜本的な修正が入りそうなので、今後期待できそうですね。

(ko1)

■JIT

  • Rename Ruby 3.0's --jit to --mjit, and alias --jit to --yjit on non-Windows x86-64 platforms and to --mjit on others.

これまで、--jitというオプションは MJIT を有効にするオプションでしたが、Ruby 3.1 からは可能ならYJIT、そうでなければMJITを有効にするオプションとなりました。YJIT は Windows 以外での x86-64 プラットフォームで(多分)利用可能です。

(ko1)

MJIT

  • The default --mjit-max-cache is changed from 100 to 10000.

これまで、デフォルトでは100メソッド(など)しかコンパイル結果を残していませんでしたが、この上限を10,000まで上げました。

  • JIT-ed code is no longer cancelled when a TracePoint for class events is enabled.

class イベントをフックするための TracePoint では、コンパイルをキャンセルしなくなりました。

  • The JIT compiler no longer skips compilation of methods longer than 1000 instructions.

1,000命令以上あるメソッド(など)を、スキップしなくなりました。

  • --mjit-verbose and --mjit-warning output "JIT cancel" when JIT-ed code is disabled because TracePoint or GC.compact is used.

--mjit-verbose と --mjit-warningで、JITしたコードが TracePoint や GC.compact で無効となったとき、"JIT cancel"と出力されるようになりました。

(ko1)

YJIT: New experimental in-process JIT compiler

New JIT compiler available as an experimental feature. [Feature #18229] See this blog post introducing the project.

  • Disabled by default, use --yjit command-line option to enable YJIT.
  • Performance improvements on most real-world software, up to 22% on railsbench, 39% on liquid-render.
  • Fast warm-up times.
  • Limited to macOS & Linux on x86-64 platforms for now.

Ruby 3.1 の目玉である YJIT です。実際に利用されているRailsのコードなどが高速化されるそうです。詳しい結果は開発した Shopify の皆さんの YJIT: Building a New JIT Compiler for CRuby — Development (2021)という記事をご覧ください。

YJITは、Ruby用JITコンパイラで、MJITと違いx86-64ネイティブコードを直接生成するJITコンパイラです。ある意味、ふつうのJITコンパイラですね。生成時には、Basic Block Versioning (BBV) というテクニックが利用されており、本当に必要な部分だけ、ネイティブコードに変換します。

例えば、次のようなプログラムについて考えます。

deffoo a
  if a
    a + 1elsenilendend

メソッドfooが10回呼ばれると、これはよく利用されるメソッドだと確認してYJITがネイティブコードに「変換しながら実行します」。変換しながら実行、というのがキモです。これによって、「今実行している値」を確認しながら、コンパイルができるからです。他のJITコンパイラでは、パラメータの統計情報などをとっておき、それに応じてバックグラウンドでコンパイルする、とすることもありますが、YJITではコンパイル時にたまたま使った値を素直に利用します。そして、「ちょっとずつ」コンパイルしていきます。

さて、foo(10)という呼び出し時にコンパイルするとしましょう。このとき、aは10なので、if文はthen節を通ります。そして、aはFixnum(小さな数値)です。そこで、次のような機械語列を生成します。

  • (1) もし a が falsy ならコンパイルをやりなおす
  • (2) もし a が Fixnum(小さな数値)じゃなければ素直に a.+(1)メソッドを呼び出し、メソッドの返値とする
  • (3) a (Fixnum) + 1 の計算を行い、メソッドの返値とする

具体的には、こんなコードが生成されました。

元のバイトコード:
local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] a@0<Arg>
0000 getlocal_WC_0                          a@0                       (   2)[LiCa]
0002 branchunless                           10
0004 getlocal_WC_0                          a@0                       (   3)[Li]
0006 putobject_INT2FIX_1_
0007 opt_plus                               <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
0009 leave                                                            (   7)[Re]
0010 putnil                                                           (   3)
0011 leave                                                            (   7)[Re]

生成された機械語:
== BLOCK 1/3: 24 BYTES, ISEQ RANGE [0,4) =======================================
  5603dcbcd131:  mov    rax, qword ptr [r13 + 0x20]
  5603dcbcd135:  mov    rax, qword ptr [rax - 0x18]
  5603dcbcd139:  mov    qword ptr [rbx], rax
  5603dcbcd13c:  test   qword ptr [rbx], -9
  5603dcbcd143:  je     0x5603e4bcd0a6
== BLOCK 2/3: 19 BYTES, ISEQ RANGE [4,9) =======================================
  5603dcbcd149:  mov    rax, qword ptr [r13 + 0x20]
  5603dcbcd14d:  mov    rax, qword ptr [rax - 0x18]
  5603dcbcd151:  mov    qword ptr [rbx], rax
  5603dcbcd154:  mov    qword ptr [rbx + 8], 3
== BLOCK 3/3: 74 BYTES, ISEQ RANGE [7,10) ======================================
  5603dcbcd15c:  test   byte ptr [rbx], 1
  5603dcbcd15f:  je     0x5603e4bcd0f1
  5603dcbcd165:  mov    rax, qword ptr [rbx]
  5603dcbcd168:  sub    rax, 1
  5603dcbcd16c:  add    rax, qword ptr [rbx + 8]
  5603dcbcd170:  jo     0x5603e4bcd0f1
  5603dcbcd176:  mov    qword ptr [rbx], rax
  5603dcbcd179:  mov    rcx, qword ptr [r13 + 0x20]
  5603dcbcd17d:  mov    eax, dword ptr [r12 + 0x24]
  5603dcbcd182:  not    eax
  5603dcbcd184:  test   dword ptr [r12 + 0x20], eax
  5603dcbcd189:  jne    0x5603e4bcd112
  5603dcbcd18f:  mov    rax, qword ptr [rbx]
  5603dcbcd192:  add    r13, 0x40
  5603dcbcd196:  mov    qword ptr [r12 + 0x10], r13
  5603dcbcd19b:  mov    rbx, qword ptr [r13 + 8]
  5603dcbcd19f:  mov    qword ptr [rbx], rax
  5603dcbcd1a2:  jmp    qword ptr [r13 - 8]

ここで生成されるコードは、aが 10 の時(Fixnumのとき)の特別なコードです。そのため、aが falsy だと、「コンパイルをやり直す」ということが起きます。アセンブラ中の je 0x5603e4bcd0a6、とか je 0x5603e4bcd0f1がそれにあたります(やり直すぞ、というところにジャンプしています)。

試しに、この後でfoo(nil)と呼んでみます。

== BLOCK 1/5: 24 BYTES, ISEQ RANGE [0,4) =======================================
  55c00fbaf131:  mov    rax, qword ptr [r13 + 0x20]
  55c00fbaf135:  mov    rax, qword ptr [rax - 0x18]
  55c00fbaf139:  mov    qword ptr [rbx], rax
  55c00fbaf13c:  test   qword ptr [rbx], -9
  55c00fbaf143:  je     0x55c017baf0a6
== BLOCK 2/5: 19 BYTES, ISEQ RANGE [4,9) =======================================
  55c00fbaf149:  mov    rax, qword ptr [r13 + 0x20]
  55c00fbaf14d:  mov    rax, qword ptr [rax - 0x18]
  55c00fbaf151:  mov    qword ptr [rbx], rax
  55c00fbaf154:  mov    qword ptr [rbx + 8], 3
== BLOCK 3/5: 74 BYTES, ISEQ RANGE [7,10) ======================================
  55c00fbaf15c:  test   byte ptr [rbx], 1
  55c00fbaf15f:  je     0x55c017baf0f1
  55c00fbaf165:  mov    rax, qword ptr [rbx]
  55c00fbaf168:  sub    rax, 1
  55c00fbaf16c:  add    rax, qword ptr [rbx + 8]
  55c00fbaf170:  jo     0x55c017baf0f1
  55c00fbaf176:  mov    qword ptr [rbx], rax
  55c00fbaf179:  mov    rcx, qword ptr [r13 + 0x20]
  55c00fbaf17d:  mov    eax, dword ptr [r12 + 0x24]
  55c00fbaf182:  not    eax
  55c00fbaf184:  test   dword ptr [r12 + 0x20], eax
  55c00fbaf189:  jne    0x55c017baf112
  55c00fbaf18f:  mov    rax, qword ptr [rbx]
  55c00fbaf192:  add    r13, 0x40
  55c00fbaf196:  mov    qword ptr [r12 + 0x10], r13
  55c00fbaf19b:  mov    rbx, qword ptr [r13 + 8]
  55c00fbaf19f:  mov    qword ptr [rbx], rax
  55c00fbaf1a2:  jmp    qword ptr [r13 - 8]
== BLOCK 4/5: 24 BYTES, ISEQ RANGE [0,4) =======================================
  55c00fbafa9d:  mov    rax, qword ptr [r13 + 0x20]
  55c00fbafaa1:  mov    rax, qword ptr [rax - 0x18]
  55c00fbafaa5:  mov    qword ptr [rbx], rax
  55c00fbafaa8:  test   qword ptr [rbx], -9
  55c00fbafaaf:  jne    0x55c00fbaf149
== BLOCK 5/5: 52 BYTES, ISEQ RANGE [10,12) =====================================
  55c00fbafab5:  mov    qword ptr [rbx], 8
  55c00fbafabc:  mov    rcx, qword ptr [r13 + 0x20]
  55c00fbafac0:  mov    eax, dword ptr [r12 + 0x24]
  55c00fbafac5:  not    eax
  55c00fbafac7:  test   dword ptr [r12 + 0x20], eax
  55c00fbafacc:  jne    0x55c017baf7b8
  55c00fbafad2:  mov    rax, qword ptr [rbx]
  55c00fbafad5:  add    r13, 0x40
  55c00fbafad9:  mov    qword ptr [r12 + 0x10], r13
  55c00fbafade:  mov    rbx, qword ptr [r13 + 8]
  55c00fbafae2:  mov    qword ptr [rbx], rax
  55c00fbafae5:  jmp    qword ptr [r13 - 8]

これまで通ってこなかったパスが増えたので、再度コンパイルされました(既存の機械語列に追加されました)。ブロック 5/4 とブロック 5/5 が増えたのがわかるでしょうか。ちょっと中身をよく知らないんですが、これは 4/5 が代わりにエントリーポイントになり、aが truthy だったら 2/5 にジャンプ、となっているのかな。そんな気がします。つまり、1/5 が 4/5 にバージョンアップしてるわけですね。

ちなみに、各ブロックは必ずしも隣り合ったメモリに存在するわけではありません。新しいバージョンが生成されると、可能なら既存のコードへジャンプするようなコードが生成されます。この配置するメモリ領域は、Rubyインタプリタ起動時にドーンと確保されます。--yjit-exec-mem-sizeという起動オプションで制御でき、デフォルトは 256MB です。

詳しい人は読むとわかると思いますが、最適化の余地がまだまだ死ぬほどあるので、今はほぼテンプレートベースの置き換えですが、さらに性能向上を進めることができるような気がします。また、ARM の対応もすると言ってました。楽しみですね。

なお、この機械語の表示を確かめるには、次のようなプログラムで行うことができます。ただし、Ruby のビルド時に(configure 時に)libcapstone-dev という、逆アセンブルを行うライブラリが必要です(Ubuntu なら apt install libcapstone-devで入りました)。

deffoo a
  if a
    a + 1elsenilendend20.times{|i|
  p foo(i)
  asm = RubyVM::YJIT.disasm(method(:foo))
  if asm
    puts asm
    breakend
}

p foo(nil)

puts RubyVM::YJIT.disasm(method(:foo))

RubyVM::YJIT.disasm(method(:foo))が、どのような機械語でコンパイルされているか、という結果が返ります。10回目にコンパイルされるので、それまでは nil が返ります。

ネイティブコードに直接コンパイルするため、一般的にメンテナンスが困難になります。Shopify の皆様なら、きっと継続してメンテナンスしてくれるだろうという期待もあって、今回 YJIT が導入されました。

この辺を弄ってた人間としてはいろいろ考えることはあるのですが、余白が少なすぎるようです。とりあえず、速さは正義。

(ko1)

■静的解析

RBS

  • Generics type parameters can be bounded (PR).
  • Type aliases can be generic. (PR)

Rubyコードの型を表現する言語RBSが拡張されました。 ジェネリクスのbounded型が導入されこと、ジェネリックな型エイリアスが書けるようになったこと、の2点です。 ただ、まだTypeProfもSteepもこの新記法に対応していないので、現時点で使う意味はありません。今後の布石です。

rbs collectionという機能が追加されました。 メジャーなgemに対するRBSを集めたリポジトリgem_rbs_collectionから自分のプロジェクトで必要なRBSファイルをフェッチする機能(正確に言うと、現在の実装ではリポジトリ全体をcloneした上で必要なファイルのみをコピーする)や、Gemfileで表現されていないdefault gemへの依存を表現する機能などがあります。 詳しくは作者のpockeさんの解説記事をご覧ください。

  • Many signatures for built-in and standard libraries have been added/updated.
  • It includes many bug fixes and performance improvements too.

他にも、多くの組み込みライブラリの型が追加・改善された、高速化のためにパーサがC言語で書き直された、など、さまざまな改善がされています。

(mame)

TypeProf

TypeProfは、TypeProf for IDEという実験的なIDEサポートが導入されました。

f:id:ku-ma-me:20211222173430p:plain
TypeProf for IDEの動作例

メソッド定義の上に推定された型シグネチャのRBSで灰色で表示されます。 また、型エラーに赤線が出たり、補完が出たりしている様子もわかると思います。

詳しくはRubyKaigi Takeout 2021のキーノートで話したので、そちらの動画や資料をご覧ください。

rubykaigi.org

(mame)

■デバッガ

  • A new debugger debug.gem is bundled. debug.gem is a fast debugger implementation, and it provides many features like remote debugging, colorful REPL, IDE (VSCode) integration, and more. It replaces lib/debug.rb standard library.
  • rdbg command is also installed into bin/ directory to start and control debugging execution.

debug.gemという、Ruby 用デバッガを書き直しました。2021年は、笹田はこの仕事しかやっていないってくらい時間を使って実装しました。ちょっと時間を使いすぎた。

細かい話は GitHub のドキュメントを読んでいただくとして、他のデバッガに比べて次のようなメリットがあります。

  • 速い: 行ブレイクポイントを設定しても、速度低下は一切ありません。
  • リモートデバッグにネイティブに対応しています。
    • UNIX domain socket
    • TCP/IP
  • 標準で IDE などリッチなフロントエンドにつながります。
  • 柔軟にデバッガを実行できます
    • rdbg を利用: rdbg target.rb
    • ruby -r: ruby -r debug/start target.rb
    • require: require 'debug/start'とかいろいろ
  • その他
    • マルチプロセスプログラミング(fork追跡)に対応(多分)
    • Threadプログラミングのデバッグに対応(多分、だいたい)
    • Ractorプログラミングのデバッグに対応、したい(まだできていない)
    • Control+C で任意の場所でプログラムを停止
    • バックトレースに引数を表示
    • レコーディング&リプレイ機能とか、なんか面白い機能いろいろ

f:id:koichi-sasada:20211225212902p:plain
debug.gem: 色付きの REPL
f:id:koichi-sasada:20211225212906p:plain
debug.gem: VSCode インテグレーション

発表資料など:

Rubyのデバッガって「いざというときのツール」という感じで、あんまり使われていない印象をもっているんですが、気軽なコードリーディングとかでも使ってもらえるように、進化させていきたいなぁと思っています。

もともとは、既存のデバッガのアーキテクチャではRactor対応できないなー、デバッガないと並列プログラミング厳しいよなぁ、と思って作り始めたんですが、結局まだ Ractor 対応できていないんですよねぇ。

ちなみに、rdbgコマンドがインストールされます(リモートデバッガのクライアントなどに使います)。gdbみたいに rdbって名前にしたかったんですが、あまりに RDB (Relational Database) に近いだろうってことで却下されました。

(ko1)

■error_highlight

  • A built-in gem called error_highlight has been introduced. It shows fine-grained error locations in the backtrace. (略)

NameErrorが起きたときに、その例外が起きた位置をエラーメッセージで表示するようになりました。

f:id:ku-ma-me:20211201172801p:plain
error_highlightの動作例

詳しくは別の記事で解説しているので、そちらもご覧ください。

techlife.cookpad.com

(mame)

■IRBに自動補完とドキュメント表示が実装された

  • The IRB now has an autocomplete feature, where you can just type in the code, and the completion candidates dialog will appear. You can use Tab and Shift+Tab to move up and down.
  • If documents are installed when you select a completion candidate, the documentation dialog will appear next to the completion candidates dialog, showing part of the content. You can read the full document by pressing Alt+d.

IRBに自動補完やドキュメント表示の機能が実装されました。"Hello".と入力するだけで、String のメソッドが候補として表示されます。

f:id:ku-ma-me:20211225180610p:plain
irbの自動補完とドキュメント表示の動作例

補完自体はこれまでもあったのですが、タブキーを押さないと候補が出てこないので「半自動補完」みたいな感じでした。今回からは、"Hello".と入力するだけでポップアップっぽく出てきます。 タブキーとShift+タブキーで候補を選び、エンターキーで補完を決定します。また、選択中の候補について、ドキュメントがあれば右側に表示するようにもなっています(上の画面参照)。

なお、まだ少し荒削りなので、いじっていると画面が壊れることもあるかもしれません。irb --noautocompleteと起動すれば、自動補完を無効にできます。

(mame)

■その他の変更

objspace/traceライブラリの追加

  • lib/objspace/trace.rb is added, which is a tool for tracing the object allocation. Just by requiring this file, tracing is started immediately. Just by Kernel#p, you can investigate where an object was created. Note that just requiring this file brings a large performance overhead. This is only for debugging purposes. Do not use this in production. [Feature #17762]

Rubyで込み入ったバグを追っているとき、「このオブジェクトが確保された場所が知りたい」ということがときどきあると思います。 それを可能にする便利ライブラリが追加されました。

require"objspace/trace"#=> objspace/trace is enabled# objを4行目で確保する
obj = Object.new

p obj #=> #<Object:0x00007f2063126a80> @ test.rb:4

require "objspace/trace"によってオブジェクト生成の追跡を有効化します(有効化されたという警告も出ます)。 その上で、4行目でObject.newによって作ったオブジェクトをKernel#pに渡すと、@ test.rb:4という表示が出ているのがわかると思います。

注意点としては、require "objspace/trace"を呼ぶ前のオブジェクトの確保位置は特定できません。 また、このオブジェクト生成の追跡はそれなりに遅いし、メモリも消費します。 デバッグ専用のものなので、基本的にプロダクションでは使わないでください。

実は、この機能自体は昔からあります。 追跡を有効化するのはObjectSpace.trace_object_allocations_startで、オブジェクトを確保した位置のファイル名を得るのがObjectSpace.allocation_sourcefile(obj)、行番号を得るのがObjectSpace.allocation_sourceline(obj)です。

これらのAPI名が極端に長いのは意図的でした。 Rubyには「気楽に使うべきでないAPI名は長くして気楽に使わせないようにする」という不文律があり、それに従っています。 ただ、デバッグ用途のものであることを考えるとあまりに不便すぎたので、objspace/traceを導入しました。

ko1注: これらの長い API は、自分で便利メソッドを定義して使ってね、という意図でこういう名前にしていました。

反動で、極端に短く使えるようになっています。 require "objspace/trace"をしてpを呼ぶだけで位置が表示されます。 ただ、pの意味を変えるのはやりすぎという声もあり、リリース後ももし評判が悪ければ変更するかもしれません(あくまでデバッグ用なので、互換性はそれほど重要でないと考えています)。 なお、require "objspace/trace"を書いたままうっかりコミットしてしまうリスクがあるということで、requireしただけで警告が出るようにしました。

(mame)

ファイナライザ内で警告が起きたらバックトレースを表示するようになった

  • Now exceptions raised in finalizers will be printed to STDERR, unless $VERBOSE is nil. [Feature #17798]

オブジェクトのファイナライザの内で補足されない例外が投げられた場合、バックトレースが表示されるようになりました。

obj = Object.new

# オブジェクトにファイナライザを登録するObjectSpace.define_finalizer(obj, proc {
  # 例外を投げるraise
})

# ファイナライザを登録したオブジェクトへの参照を消す
obj = nil# GCを起こす(注:オブジェクトが必ず回収されるとは限らない)GC.start
#=> <internal:gc>:34: warning: Exception in finalizer #<Proc:0x00007f46a527a4a0 test.rb:4>#   test.rb:6:in `block in <main>': unhandled exception#           from <internal:gc>:34:in `start'#           from test.rb:13:in `<main># エラーは表示されるけれど実行はそのまま続く
puts "Hello"#=> Hello

バックトレースが表示されるだけで、実行自体は続くことに注意してください。 あくまで、エラーが出力されるだけです。Thread.report_on_exception = trueと同じようなものと考えてください。

これまでは、ファイナライザ内で例外が投げられても黙殺されていました。 なので、もしその挙動に依存しているコードがどこかにあると、GCが走るときに何か出力されるという変化があるかもしれません。 ファイナライザなんか使わないのがオススメです。

(mame)

ruby -run -e httpdが URL を表示するようになった

簡易HTTPサーバであるruby -run -e httpdコマンドを実行すると、https://127.0.0.1:8080のようなループバックURLが出力されるようになりました。

$ ruby -run -e httpd
[2021-12-2120:25:28] INFOWEBrick1.7.0
[2021-12-2120:25:28] INFO  ruby 3.1.0 (2021-11-17) [x86_64-linux]
[2021-12-2120:25:28] INFOWEBrick::HTTPServer#start: pid=105322 port=8080
[2021-12-2120:25:28] INFOTo access this server, open this URLin a browser:
[2021-12-2120:25:28] INFO      http://127.0.0.1:8080
[2021-12-2120:25:28] INFO      http://[::1]:8080

何かと便利ですね。

(mame)

ruby -run -e colorizeでターミナル上での Ruby コードの色つけ表示ができるようになった

  • Add ruby -run -e colorize to colorize Ruby code using IRB::Color.colorize_code.

irb の色つけ機能を使って、Ruby コードに色を付けて表示するだけのちょっとした機能が追加されました。

f:id:ku-ma-me:20211222165611p:plain
ruby -run -e colorizeによるRubyコードの色つけ

(mame)

■おわりに

Ruby 3.1の非互換や新機能を紹介してきました。ここで紹介した以外でも、バグの修正や細かな改善が行われています。お手元の Ruby アプリケーションでご確認いただければと思います。

Ruby 3.1では、冒頭で述べた通り、互換性を最大限に考慮するため、あまり大きな変更はありませんでしたが、Ruby 3.2では(3.1で我慢した分もふくめて)、いろいろと変更される予定です。これからも進化し続ける Ruby にご期待ください。

なにはともあれ、まずは新しい Ruby を楽しんでください。ハッピーホリデー!

Ruby 3.1 の debug.gem を自慢したい

$
0
0

技術部の笹田です。今日保育園に娘を送りにいったら、娘が先生に「サンタさんにプレゼントもらったよ! お母さんもプレゼントもらってたけどお父さんはもらってなかった!」と報告しており、私だけが悪い子と保育園に伝わってしまいました。

2021年は、笹田は Ruby 3.1 に導入された debug.gem (ruby/debug: Debugging functionality for Ruby)に結構長い時間をかけました(かけてしまいました)。だいたい半年で終わるだろうと思ってたんですが、終わらず。Ractor をもっとやる予定だったんだけどなぁ。ソフトウェア開発の見積もりは難しいですね。

本記事では、debug.gem について、導入の背景、簡単な使い方、それからちょっと面白い機能までご紹介します。

youtu.be

(本稿では動画をいくつか載せていますが、動画作成時と記事執筆時が違うので、それぞれ違うコードで掲載しています。ご了承ください。なお、動画は RubyConf 2021 の発表のために使ったものを引用しています)

なお、Ruby 3.1については先日公開した記事(プロと読み解く Ruby 3.1 NEWS - クックパッド開発者ブログ)をご覧ください。

debugger 再実装の背景

すでに Ruby にはいくつかのデバッガが存在していました。

Ruby 3.0 までは、lib/debug.rb というのが大昔からバンドルされており、ruby -r debug app.rbとすると、デバッガコンソールが立ち上がり、デバッグコマンドで何やか操作する、というものでした。

ただ、lib/debug.rb は多分誰も真面目にメンテナンスしていなくて、使ってる人もほとんど居なかったんじゃないかと思います。作り直すときに色々実験したんですが、機能によっては動かないものもありました(が、多くの機能はそのまま動いていたので Ruby の互換性は意外と凄い)。

おそらく、もっとも多くの人が利用しているのは byebug(deivid-rodriguez/byebug: Debugging in Ruby 2)ではないでしょうか。もともと Ruby 2 に導入された新 API を用いたデバッガだったんだと思いますが、コンソールから利用するデバッガとしては、一番利用されていたかと思います。pry-debug(deivid-rodriguez/pry-byebug: Step-by-step debugging and stack navigation in Pry)も、byebug を利用していました。

また、Rubymine や VSCode の ruby extension では debase(ruby-debug/debase)を用いていました。こちらも、すでに実績のあるデバッガです。

ただ、これらの既存のデバッガには、次のような不満点がありました。

  • リモートデバッグがしづらい
    • 多分、やればできるんですが、なかなか難しい
    • そもそも、そういう前提で作ってない感じに見える
  • ブロックしている処理を中断できない
    • プログラムが「刺さっている」状況を確認したい、ということありますよね(私は gdb -p [PID]でよく Ruby インタプリタの状況を確認してます)。これが、従来のデバッガだとできないんですよね。
    • byebug 上でプログラムを実行しながら Ctrl-Cを入力すると、うちの環境だと落ちちゃうんだけど、うちだけ?(デバッグコンソールに入ってほしい)
  • ブレイクポイントを設定するとい遅い
  • Ractor 対応していない

ブレイクポイントについて。既存のデバッガでは、ブレイクポイントを設定すると、いろいろな理由から、そのブレイクポイントにたどり着か居ない場合でも遅くなる可能性がありました。

f:id:koichi-sasada:20211227172833p:plain
デバッガの性能評価

この欠点は、実はソースコード中に byebugdebuggerメソッドで byebug のデバッグコンソールを起動する、みたいにすれば問題ないんですが、プログラムが走っているときに、さらにブレイクポイントを追加しようってときに問題になります。

Ruby 2.6 で導入した TracePoint#enable(target_line:)を使うと指定した行のみで TracePoint が有効にすることができ、この辺を使えば速くなる(性能劣化が起こらない)ということはわかっていました。機能を入れたので、誰かやってくれないかな、と思ってたんですが、あまり使われる気配がないので自分で活用することにしました。

なお、lib/debug.rbはブレイクポイントを設定しなくても2桁くらい遅くなってました(昔はそう作るしかなかった)。lib/debug.rb って名前もいいところ使っているのに放置されているのは勿体ないなあ、というのが理由の一つでした。

そして、一番大きな不満というか、どうしようもない点として、Ractor 対応していないというものです。

Ractor は Ruby 3.0 で導入された並行プログラミングのための仕組みですが、そもそも並行プログラミングはデバッグが困難なので、サポートするための仕組みをいれたかったのですが、まずはデバッガの対応が要るだろうということで、2021年2月ごろから着手しました。

(ちなみに、着手してみたのはいいのですが、結局 Ractor に対応するためには Ruby 自体に機能がいくつか足りないことがわかって、その仕様を検討していたら Ruby 3.1 には間に合いませんでした。来年がんばります。)

そんなこんなで、新しく作ることにしました。新しく作るのなら、いろいろモダンにしようと思って、ここ数年の irb をカッコよくしている立役者の reline を利用したり、コンソールをカラフルにする仕組みを使ったりして、pretty な REPL を実現していたりします。また、いろいろ便利機能を思いつく限り盛り込んでいます。

debug.gem の利用方法

簡単に debug.gem の利用方法をご紹介します。いろいろ端折ってご紹介しているので、詳細はドキュメント(ruby/debug: Debugging functionality for Ruby)を参照してください。

起動の方法

まずは、debug.gem つきで Ruby を起動しなければなりません。debug.gem の使い方はいろいろあるので、用途に合わせて使ってみてください。

起動方法 その1:binding.pryとか binding.irbみたいに使う

先に、require 'debug'とプログラムの先頭に置いておき、プログラム中で debuggerbinding.breakなどと記述すると、その行でデバッグコンソールが開きます。

$ cat app.rb
deffib n
  if n <= 1
    debugger
    1else
    fib(n-2) + fib(n-1)
  endend

p fib(10)

この状態で、ruby -r debug appと起動してみます。なお、ここでは、require 'debug'と書く代わりに、-r debugオプションを ruby 起動時に加えています。実はこの方法だと、Ruby 3.0 以前では、lib/debug.rb が優先されてしまうかもしれません。その場合は、Gemfile をおいて gem 'debug'などと指定してみてください。

$ ruby -r debug app.rb
[1, 10] in app.rb
     1| def fib n
     2|   if n <= 1
=>   3|     debugger
     4|     1
     5|   else
     6|     fib(n-2) + fib(n-1)
     7|   end
     8| end
     9|
    10| p fib(10)
=>#0    Object#fib(n=0) at app.rb:3
  #1    Object#fib(n=2) at app.rb:6
  # and 5 frames (use `bt' command for all frames)
(rdbg)

この通り、3行目で止まって、デバッグコンソールが開いているのがわかります((rdbg)というプロンプトが出て、デバッグコマンドの入力を待ち受けています)。

なお、このブログですと、文字にすると色情報が抜けちゃってるんですが、エスケープシーケンスで次のように綺麗に色付けした状態になっています。

f:id:koichi-sasada:20211227172639p:plain
色付き REPL の例

ここに、デバッグコマンドを入力できるのですが、イッパイあります(Debug command on the debug console)。

例えば、とまっている状態のバックトレースを確認するのは backtraceコマンドです。よく使うので btというエイリアスが用意されています。

(rdbg) bt    # backtrace command
=>#0    Object#fib(n=0) at app.rb:3
  #1    Object#fib(n=2) at app.rb:6
  #2    Object#fib(n=4) at app.rb:6
  #3    Object#fib(n=6) at app.rb:6
  #4    Object#fib(n=8) at app.rb:6
  #5    Object#fib(n=10) at app.rb:6
  #6    <main> at app.rb:10
(rdbg)

バックトレースが表示されました。

ここで、ポイントが2つあります。

  1. # backtrace commandと、btが何の略か書いてある
  2. n=0のように、各メソッドのレシーバ(のクラス)や引数の情報が書いてある

1番目は、このブログでの解説のためにいれたコメントではなく、デバッガで入力中に右のほうに出てきます。ちょっと気の利いた感じがしませんか。あとで、この小粋な機能がなぜ必要か、もう少し説明します。

2番目は、引数の情報が出ることです。callerメソッドなどでとれる情報はファイル名と行番号だけですが、debug.gem ではこんな感じで情報が色々出てきます(しかも、可能ならカラフルに)。

起動方法の紹介のはずだったのに、ちょっと脇道にそれました。

起動方法 その2: rdbgコマンドを使う

debug.gem をインストールすると(つまり、Ruby 3.1.0 をインストールすると)、rdbgコマンドがインストールされます。rdbgコマンドを用いて、いろいろな起動オプションとともに debug.gem を有効にして起動できます。

実例をお見せします。

$ rdbg app.rb
[1, 9] in app.rb
=>   1| def fib n
     2|   if n <= 1
     3|     1
     4|   else
     5|     fib(n-2) + fib(n-1)
     6|   end
     7| end
     8|
     9| p fib(10)
=>#0    <main> at app.rb:1
(rdbg)

このように、app.rb の先頭で止まり、デバッグコマンドを受け付ける状態になりました。この状態でデバッグコマンドを使ってブレイクポイントを指定し(例:b 3でブレイクポイントを設定できます)、実行を再開(continueコマンド、もしくはc)すると3行目に到達した時点で止まります。

(rdbg) b 3    # break command
#0  BP - Line  /home/ko1/app/app.rb:3 (line)
(rdbg) c    # continue command
[1, 9] in app.rb
     1| def fib n
     2|   if n <= 1
=>   3|     1
     4|   else
     5|     fib(n-2) + fib(n-1)
     6|   end
     7| end
     8|
     9| p fib(10)
=>#0    Object#fib(n=0) at app.rb:3
  #1    Object#fib(n=2) at app.rb:5
  # and 5 frames (use `bt' command for all frames)

Stop by #0  BP - Line  /home/ko1/app/app.rb:3 (line)
(rdbg)

「起動方法 その1」と比べると、ソースコードに何も足さなくても利用できるというのは利点です。例えば、間違ってコミットするようなことが起こりません(diff で気づくかな...)。しかし、ブレイクポイントの指定のことを考えると、ちょっと面倒かもしれません。

ちなみに、ruby コマンドじゃない場合、-cを使って別のコマンドを実行できます。

$ rdbg -c -- rails # rails コマンドを起動
$ rdbg -c -- rake  # rake コマンドを起動
$ rdbg -c -- bundle exec rspec # bundle exec rspec を起動

byebugコマンドだと、これがやりづらかったんですよねえ。

起動方法 その3:リモートデバッグを行う

別プロセスをデバッグできます。

$ rdbg -O app.rb
DEBUGGER: Debugger can attach via UNIX domain socket (/tmp/ruby-debug-sock-1000/ruby-debug-ko1-29540)
DEBUGGER: wait for debugger connection...

-O--open)付きで実行すると、UNIX Domain socket(この場合、/tmp/ruby-debug-sock-1000/ruby-debug-ko1-29540)を開いて止まります。

別のターミナルで、次のように rdbg -Ardbg --attach)してください。

$ rdbg -A
[1, 9] in app.rb
=>   1| def fib n
     2|   if n <= 1
     3|     1
     4|   else
     5|     fib(n-2) + fib(n-1)
     6|   end
     7| end
     8|
     9| p fib(10)
=>#0    <main> at app.rb:1
(rdbg:remote)

こんな感じで、別のプロセスにつながります。プロンプトが (rdbg:remote)になっています。他にも UNIX Domain socket でデバッグポートを開いているプロセスがいると、具体的にどこにつなぐかファイルを指定する必要があります。

TCP/IP で開く場合は、-Oで開くときに --portオプションなどを指定します(-Aでつなぐ側はポート指定が必要)。

youtu.be

起動方法 その4: VSCode から使う

VSCode の拡張(VSCode rdbg Ruby Debugger - Visual Studio Marketplace)をインストールしていただくと、VSCode のデバッグ機能から debug.gem を利用することができます。

f:id:koichi-sasada:20211227171454p:plain
debug.gem: VSCode からの利用

".rb"のつくファイルを開いて、F5 キーを押すと「どんなコマンドでデバッグしますか?」という意味で "Debug Command line"というダイアログが開きます(デフォルトは、ruby path/to/file.rb)。そして、OK を押すとデバッグが始まると思います。ここで、rakerspecなども指定できます。もし、うまくいかなかったら設定が必要かもしれませんので、ドキュメントを読んでみてください。

ブレイクポイントは、ソースコードの行の右端をクリックすると設定できます(他の言語と同じですね)。ブレイクコマンドを指定する方法は、これが一番楽だと思うんですよね。よかったら活用してください。

youtu.be

デバッグコマンドを使う

先ほど、次の3つのコマンドをご紹介しました。

  • backtrace(bt)
  • continue(c)
  • break(b)

他にもよく使いそうなコマンドをご紹介します。

なお、デバッグコマンドは、gdb や lldb、lib/debug.rb などを参考にしながら新たに作りました(私が gdb をよく使うので、そちらに引っ張られているところが多い)。

Ruby の式を入力する

いきなりデバッグコマンドじゃないのですが、「デバッグコマンド以外」が入力されると、Ruby の式として評価されます。

(rdbg) "Hello".upcase    # ruby
"HELLO"

ただ、デバッグコマンドと同じ式だと、そちらが優先されます。

次の例では、nというローカル変数の中身を確認しようとしたら、nextコマンドが実行されてしまったという例です。

(rdbg) n    # next command
[4, 9] in app.rb
     4|   else
     5|     fib(n-2) + fib(n-1)
     6|   end
     7| end
     8|
=>   9| p fib(10)
=>#0    <main> at app.rb:9

入力行右のほうに「# ruby」や「# next command」と表示されているのがわかると思いますが、Ruby の式と間違えてデバッグコマンドを入力してしまうのを防ぐために表示しているというわけです。

個人的には、p <expr>pp <expr>という、<expr>の中身を pppメソッドと同じように表示するデバッグコマンドがあるので、p nのように、Ruby の式を入力して結果を確認するのがオススメです。

なお、実行されるコンテキストは、現在選択中のフレームです。フレームについてはあとでご紹介します。

ブレイクポイントを設定する

b 3とすると、break 3の意味であり、そのファイルの3行目をブレイクポイントとして登録します。

  • b line現在のファイルの line 行に設定。
  • b file:lineファイル:行に設定。まだ読み込んでないファイルも、読み込まれた時点で設定される。
  • b Foo#bar Fooクラスのbarメソッドに設定。まだ定義されていないメソッドも、定義された時点で設定される。
  • b Foo.baz Fooクラスのbar暮らすメソッドに設定。
  • b ... if: <expr>ブレイクポイントにおいて、<expr>を評価して真の場合にブレイク(止まってデバッグコンソールを出す)。
  • b if: <expr>毎行で<expr>を評価して、真の場合にブレイク。
  • watch @...ウォッチポイントを設定。指定したインスタンス変数の値が変わったらブレイク(遅いです)。
  • catch FooException FooException が raise されるタイミングにブレイクポイントを設定。

他にもあったかも。

実際にこの辺のコマンドを毎回書いてるのはだるいので、エディタとイイ感じに連携できるといいと思っています。VSCodeみたいに。~/.rdbgrcというファイルや、rdbg -x FILEで指定したファイルを起動時に自動的に読み込む機能があるので、その辺にイイ感じに設定する機能が欲しいですね。自分でも途中まで作ったんですが、ペンディング中です。

不要になったブレイクコマンドはdeletedel)で削除できます。

プログラムの中身をチェックする

先ほども pppbacktraceコマンドをご紹介しましたが、実際に止めたらプログラムの状態を確認したくなります。そんな時に便利なコマンドはこちら:

  • bt or backtrace: バックトレースを表示。
  • l or list: ソースコードを表示。
  • edit: EDITOR で指定しているエディタを起動。
  • i or info: 現在のフレームからアクセスできるローカル変数などを表示。下記のように、表示するものを指定して詳細表示することも可能。
    • i locals: ローカル変数一覧
    • i ivars: インスタンス変数一覧
    • i const: 定数一覧
    • i globals: グローバル変数一覧
    • i threads: スレッド一覧
  • o or outline: pry の lsみたいなやつ。
  • irb: binding.irbを実行。
  • p, pp: 式を評価して表示。

とりあえず、iを覚えておくと良い気がします。

フレーム操作

デバッガでは、バックトレースで表示される各行、これをフレームというのですが、このフレームを表示したり選択できたりします。表示は btコマンドでご紹介しました。選択とは何かというと、pなどで評価する式を、そのフレームで実行したかのように実行する、というものです。

  • f or frame <num>: <num>番のフレームを選択。
  • up: 1個上のフレームを選択。
  • down: 1個下のフレームを選択。

よくわからないと思うので、実演します。

(rdbg) p n    # command                     <- フレーム #0 の n は 0
=> 0
(rdbg) bt    # backtrace command
=>#0    Object#fib(n=0) at app.rb:3
  #1    Object#fib(n=2) at app.rb:5
  #2    Object#fib(n=4) at app.rb:5
  #3    Object#fib(n=6) at app.rb:5
  #4    Object#fib(n=8) at app.rb:5
  #5    Object#fib(n=10) at app.rb:5
  #6    <main> at app.rb:9
(rdbg) f 2    # frame command               <- フレーム #2 を選択
=>   5|     fib(n-2) + fib(n-1)
=>#2    Object#fib(n=4) at app.rb:5
(rdbg) p n    # command                     <- n は 4
=> 4
(rdbg) down    # command                    <- フレーム #3 を選択
=>   5|     fib(n-2) + fib(n-1)
=>#3    Object#fib(n=6) at app.rb:5
(rdbg) p n    # command                     <- n は 6
=> 6
(rdbg)

プログラムの実行を制御する

ブレイクポイントで止まって調査を終えたら、プログラムを再開する必要があります。

  • continue or c: 再開
  • step or s: ステップイン
  • next or n: ステップオーバー
  • finish or f: ステップアウト

c はすでにご紹介したとおり、プログラムの実行を再開するもので、難しくありません。 s/n/f は「ちょっとずつ実行する」というコマンドですが、意味が微妙に違います。

  • step or s: ステップイン:次の行で止まる。もし現在行がメソッド呼び出し(など)の場合は、そのメソッドを呼び出した先で止まる。
  • next or n: ステップオーバー:次の行で止まる。もし現在行がメソッド呼び出し(など)の場合でも、次の行で止まる。* finish or f: ステップアウト:現在実行中のメソッドが終了したら止まる。

f:id:koichi-sasada:20211227182626p:plain
step in/over/out(RailsConf 2021 の発表資料より引用)

実は、ステップオーバーは、Ruby だとブロックが絡むと難しい挙動になるんですが、なるべくイイ感じになるように動作を調整してあります(こういう調整に物凄い時間がかかりました)。

発展的な機能

VSCode や Chrome を途中で開いちゃう機能

VSCode から実行するのもいいのですが、普段は VSCode 使っていない人からすると、移行するのも大変かもしれません。そこで、デバッグだけ VSCode で実行したい、という人のために、デバッガが VSCode を開く方法を用意してあります。

  • rdbg --open=vscode ...として実行
  • デバッグコマンドで open vscodeとして実行

後者を用いると、デバッガで止まっているタイミングで VSCode を開いてみることができます。

youtu.be

また、同じく Chrome ブラウザをデバッガフロントエンドとして利用することができます。

youtu.be

動画では Chrome ブラウザに URL を貼っていますが、最新版 1.4.0 だと(Chrome のパスが探せれば)自動的に開いて表示することができるようになっています。

ポストモーテム(検死)デバッグ

プロセスが例外で死んでしまったとき、その例外発生時にさかのぼって状況を調査したいというニーズがあり、これを ポストモーテム(postmortem/検死)デバッグというそうです。

config postmortem = trueとして設定しておくと、このモードがオンになります。continueなどはできなくなりますが(もう死んでいるので)、backtracep varとして変数の中身を調べる、などといったことができます。

youtu.be

すべての例外発生時にコンテキスト情報を付加するようになるため、実行時性能はちょっと悪くなります。

レコード&リプレイデバッグ

コード実行を記録しておき、あとで再生することができます。再生とは、「ちょっと前のステップ(行)を確認する」といったことができます。

record onとすると有効になります。

youtu.be

(動画では VSCode で実行していますが、コマンドラインでも利用できます)

実は見栄えのするデモを作るために入れた機能なので、性能はからっきしです(多分、rails は起動することもできない)。ごく範囲を絞って使うには便利かもしれません。例えば、step 実行をしているときとか。

debug.gem の今後

debug.gem 自体は、ご紹介の通りまだ開発して1年たっていません。それにも関わらず、多くの方にすでに使っていただいており、多くのフィードバックを頂いており、ありがたい限りです。実際使っていると不満な点がいくつか出てくると思いますので、改善点などを見つかったら、よかったら教えてください。GithubのIssue や PR も歓迎ですが、Twitter や ruby-jp slack(#debuggerがあります)などで気軽にお声かけ頂いてもかまいません。

個人的な積み残しは、Ractor 対応もそうなのですが、もう少し「普段使い」するための気軽さをもう少しつけたいなぁと思っています。

例えば、コードリーディングで気軽に使えるようにするとか。今は、ステップ実行しても、Rubyの制御は難しいので「あれ、なんでここに移動したの?」というのがよくわからないんですよね。trace機能をつけているんですが、これをもう少し見やすいようにするとか、いろいろ工夫があり得るのではないかと思っています。

普段使いでいうと、binding.irbは Ruby 2.5 から、何もしなくても(require 'irb'をしなくても)「その行で irb 実行する」ということが実現できています。debug.gem は、そういうことができない(rdbgで起動するか require 'debug'が必要)なので、その点も今一歩ですよね。

なんとかならんかということで、こっそり rdbg --util=initというコマンドを付けています。ここで出てくる設定を .bash_profleに入れておくと、何もしなくても debuggerと書いた行で止まるようになります。rbenv でRubyのバージョンを変更すると動かなくなるとか、まだ今一歩なんですが(なので、まだドキュメントに書いていないし、削除されるかもしれない)、こういう仕組みを充実させていって、定番のツールにしていければなぁと思っています。

おわりに

本稿では、Ruby 3.1 に添付された debug.gem についてご紹介しました。debug.gem 自体は、Ruby 2.6 以降であれば gem でインストールできますので、よかったら利用を検討してみてください。

今年は debug.gem を作るために Ruby をたくさん書きました。いつもは C を触っていることのほうが多いのですが、いやぁ本当に Ruby は書きやすくていいですね。来年は Ractor 改善するためにまた C の世界に戻ります。

今回は、中身の話(どうやってデバッガを作っているか)ができませんでした。何かの機会にご紹介できるといいなと思います。ちなみに、次のような既発表資料があり、若干中身の話をしております。

それでは、よいお年をお迎えください。

【イベント登壇】モバイルアプリエンジニア向け勉強会「iOS/Androidアプリ開発のマルチモジュール化」で児山が発表しました

$
0
0

はじめに

こんにちは!クックパッド採用グループのカレー大好きくろきです。 1/19にアンドパッドさん、Sansanさんとの合同イベントモバイルアプリエンジニア向け勉強会「iOS/Androidアプリ開発のマルチモジュール化」が開催されました。

クックパッドからは児山(こやまカニ大好き)が登壇しました。 この記事では、合同イベントで発表した内容について、児山のコメントを添えてご紹介します。 当日の様子は #ANDPAD_cookpad_Sansanからもどうぞ!

登壇概要

発表タイトル:クックパッド Android アプリのマルチモジュール化とデモアプリの活用 by こやまカニ大好き

クックパッドでは Android アプリのマルチモジュール化に取り組んでおり、各機能をライブラリモジュールとして実装しています。マルチモジュール化することで差分ビルド時間は短縮できますが、アプリ全体をビルドして動作確認する作業は面倒で時間がかかります。 このセッションでは、各機能をより高速に開発するためのデモアプリモジュールの紹介と、その実現のための実装上の工夫についての取り組みを紹介しました。

発表内容

発表スライド

発表者コメント

こやまカニ大好きです。 今回のイベントでは、クックパッド Android アプリのマルチモジュール化とデモアプリの活用 というタイトルで発表させていただきました。 今回紹介したデモアプリのような仕組みは本当に便利なので、ぜひ皆さんのプロジェクトでも検討してみて下さい。 デモアプリの依存解決と Stub の差し込みは本当に悩んでいるポイントなので、より良い解決方法があれば教えて頂けると嬉しいです。 クックパッドでも引き続きマルチモジュール構成とデモアプリの改善は進めていくので、進捗があれば共有していこうと思います。 最後になりますが、イベントにご参加頂いた皆さん、本当にありがとうございました。

クックパッドではモバイルエンジニアを募集しています

クックパッドでは「毎日の料理を楽しみにする」というミッションを実現するため、一緒にチャレンジしてくれる仲間を募集しています。 まずは話してみたい方はカジュアル面談申し込みフォームからお申し込みいただければと思いますので、ぜひお気軽にご連絡ください。 モバイルエンジニアの募集については、AndroidiOSの募集要項をご覧ください。 その他の募集中の職種一覧はこちらの採用情報からどうぞ!

また、明日1/19(水)にMeetup「Meet the Cookpadders #2」を開催予定です。

cookpad.connpass.com

エンジニアを含むプロダクト開発に関わるメンバーが座談会形式でお話しします。こちらも合わせてご覧ください!

クックパッドの基盤をフル活用して新卒が新規アプリケーションを作った話

$
0
0

はじめに

こんにちは。クックパッドレシピサービス開発部の宮崎(HN:どや)です。 私は2021年新卒としてクックパッドに入社し、そろそろ1年が経とうとしています。時の流れははやいですね。

さて、表題にも記しましたが、去年の末に新しくサーバーサイドのアプリケーションを作成しました。

クックパッドではサービスメッシュを用いたマイクロサービスアーキテクチャを採用しており、ドメインやチームに応じてアプリケーションが小さく分割されています。今回、クックパッドで利用されるマイクロサービスの1つとして、新しいアプリケーションサーバーを立ち上げました。

社内のマイクロサービスの状況については以下の記事が詳しいので、ぜひ読んでみてください。

techlife.cookpad.com

「アプリケーションサーバーを立ち上げる」と一口に言っても、新しいアプリケーションを作るのは意外と大変です。そもそも、新しいアプリケーションをリリースできる状態に持っていくには、考慮すべき点や手を動かす内容が結構あります。

たとえば、アプリケーションを動かすインフラを用意するのは当然のこと、ログの適切な保存も必要ですし、マイクロサービスアーキテクチャを採用していれば他のサービスとの協調なども考えなければなりません。加えて、クックパッドのサービスの裏側に新しくアプリケーションを追加するとなれば大きな負荷にも対応する必要があります。習熟した人間であればいさ知らず、未習熟の人間がやろうとすれば非常に大きな工数が必要となるでしょう。

本記事では、アプリケーションの立ち上げの話を通じてクックパッドの強力な基盤を紹介します。クックパッドの基盤パワーは恐ろしく、rails new すらまともにしたことが無かった新卒が学習を進めながら2ヶ月足らずでアプリケーションの作成を完遂できました。

アプリケーション作成の経緯

今回作成したアプリケーションはクックパッドのアプリにおける「買い物機能」のドメインにまつわるロジックやリソースを置くサーバーです。

買い物機能は、クックパッドアプリ上で Cookpad Mart のプラットフォームを利用して食材などを購入できる機能です。Cookpad Martというのは、クックパッドが提供している生鮮 EC のサービスで、クックパッドとは全く別のアプリとして Android/iOS でリリースされています。

ややこしいのですが、Cookpad Mart という独立した生鮮 EC サービスがあり、そのプラットフォームをクックパッドアプリからも利用できるようにしたのが買い物機能*1です。

さて、買い物機能は比較的新しく開発が始まった機能で、元々そのドメイン固有の API サーバーを持っていませんでした。購入や決済などの基本的な機能は既に Cookpad Mart のアプリケーションサーバーにあったため、そこに相乗りする形で開発をしていました。

すなわち、「Cookpad Mart では利用しないが、買い物機能では利用したい」API などがあった場合は Cookpad Mart のアプリケーションサーバーに強引に乗せるか、BFF などのオーケストレーション層に乗せるほかない状況でした。

このような決定をした理由としては以下のようなものが挙げられます。

  • 初期は Cookpad Mart の既存機能しか使わないため、相乗りする形でも大きく困らなかった。
  • 開発チーム内でサーバーサイドエンジニアが不足していた。
  • 新規事業であり撤退する可能性もあったので大きな工数を取らなかった。

特に2つ目が大きく、アプリケーションは開発するだけでなく保守運用なども発生します。当時、サーバーサイドエンジニアが新卒1人しかいない状況で、新しくアプリケーションを作成するのは難しいという判断がなされました。

しかし、買い物機能の機能や体験の拡充に伴い、以下のような問題が発生してきました。

  • どうしても既存のアプリケーションサーバーに置くには難しいデータやロジックなどが出てきた。
  • 買い物機能は大きく体験を変える新規機能であることから開発チーム自体も Cookpad アプリ本体の開発チームとは分かれていたため、Cookpad Mart のアプリケーションを触る際に Cookpad Mart の開発チームとのコミュニケーションコストが増大していた。

こうした課題が無視できなくなってきたため、「アプリケーション分割」という形で、買い物機能用のアプリケーションサーバーを新しく作成する決定をしました。チームのサーバーサイドエンジニアが増えたのも、この意思決定を後押ししています。

このアプリケーション分割において、入ってまだ半年も経たない新卒が旗振りをすることになります。もちろん押し付けられたとかではなく、私が経験も知識もないのに「やりたい!」と言ったらやらせてもらえました。新卒がこういったことをやらせてもらえるのもクックパッドの良いところですね(宣伝)。

逆に言えば、未熟な新卒1人でもアプリケーション分割が許容可能な工数で実現できるほどに、基盤やベストプラクティスが整っているとも言えます。今回、買い物機能の開発速度を落とさないままにアプリケーション作成を行わなければならないため、1、2ヶ月ほどで少ない人数でアプリケーション分割を完了させる必要がありました。もし、後述する開発基盤が整っていなければ必要な工数はもっと大きくなっており、分割をするコストを捻出できず相乗りしたままつらい状況を続けていたかもしれません。

しかしながら、クックパッドの基盤を用いた開発によって工数や複雑性を大幅に縮小でき、許容可能な工数に収めることができました。ここからは、アプリケーション分割の道筋を述べながら、そんなクックパッドの便利な基盤の一端に触れていただきたいと思います。

アプリケーション作成までの道筋

具体的にアプリケーション分割をどのような手順で行なっていったかを順番に見ていきます。その中で、クックパッドの開発を支える優れた基盤を紹介します。

API のインターフェース決定

アプリケーションサーバーを作成するにあたって、要求を元にアプリケーションの要件を定義しました。今回定義したアプリケーションの要件は機能的にも非機能的にも比較的シンプルでした。

今回はまずアプリケーションのフレームワークとして社内で多くの採用実績がある Ruby on Rails を採用しました。クックパッドでは多くのケースでサーバーの実装に Ruby on Rails が採用されており、社内にも大量の知見やライブラリなどの資産があることが決定の主な理由です。

また、新規作成するアプリケーションの API の方式として、以下の2つを検討しました。

  • RESTful
    • Garageなる Restful API をサポートする gem がある。
    • 認証を設定すればクライアントから直接叩くことができる。
  • gRPC
    • Griffinなる自前の gRPC サーバー実装がある。
    • Schema を BFF 層などと共有できる。

それぞれ、Garage や Griffin といった社内資産があり、1から自分で構築をする必要もなく Rails の手軽さと合わせてシュッと API サーバーを作ることができました。

今回は BFF 層の裏に回すユースケースが多そうだったことから、社内のスタンダードにもなっている gRPC を採用しました。クックパッドではスマートフォン向けの BFF 層として Orchaというものが存在しており、これは Java で書かれています。gRPC を採用したことで Schema から Java 向けの型定義を生成できるのも、gRPC を選択した1つの理由でした。

gRPC サーバーの立ち上げを既存の gem などを使って自前で実装しようとすると、マルチプロセス対応やインターセプターの導入など考慮することが多くありますが、Griffin や既存のインターセプター実装である griffin-interceptorsのおかげで手軽に作成が完了しました。

インフラ・ミドルウェアなどの準備

クックパッドではアプリケーションにまつわるインフラの管理は主に以下の2つを通して行なっています。

  • Terraform
    • RDS、ElastiCache、VPC、CloudWatch などのリソースの管理・デプロイを行う。
  • Hako
    • 主に実装したアプリケーションの管理・デプロイなどを行う。

この Terraform や Hako といった基盤が既にあるため、レビュープロセスを経ながら安心かつ手軽に各リソースを準備することができました。

社内の Hako と ECS を組み合わせたエコシステムは強力で、開発者はタスク設定を記述するだけでこのエコシステムに乗ることができます。設定ファイルを書いておくことでサイドカーコンテナの定義や環境変数の注入、ALB との紐付け、Autoscale 設定など ECS の設定 + α が簡単に実現でき、後述する運用面の力強いサポートも受けることができます。これにより EC2 インスタンスを用意して、Ruby をインストールして、ミドルウェアも入れて……といったような煩雑かつ工数の多い操作が不要なため、簡単にアプリケーションの追加・修正が行えます。

サービスメッシュでアプリケーション間通信できるようにする

今回新規作成したアプリケーションは、BFF の裏側にあります。つまり、BFF との疎通を考える必要があり、またマイクロサービスにおけるサービス間通信はエラー時の対応など色々と考えることが多く大変です。クックパッドでは Envoy を採用してサービスメッシュを実現することでこの課題を解決しています。

サービスメッシュの設定を記述する共通のレポジトリがあり、そこに設定を追加するだけで簡単にサービスをサービスメッシュに乗せることができます。新規アプリケーションを upstream として登録し、BFF の設定に upstream のアプリケーション名を追加するだけで、Control Plane である Itachoが設定を読み出してよしなにサービス間通信を制御してくれます。

いざデプロイ

CI/CD の実現のための基盤も色々と整っています。

CI については、クックパッドは Jenkins と AWS CodeBuild を採用しています。

PR に対してテストを実行するであったり、ブランチがマージされたタイミングで CI を回すであったりといった設定も、Jenkins のテンプレート設定を元にして簡単に作成できます。

デプロイは Rundeck でデプロイ用のスクリプトを呼び出しています。この Rundeck のジョブを起動すると、デプロイが走り ECS の task や service が作成・更新されます。あとは ECS がよしなにやってくれるので、デプロイ完了です。Rundeck のジョブの起動も Slack や後述する hako-console 上から簡単に実行できます。

リリースに向けて

デプロイが完了したからと言ってすぐさまリリースができるわけではありません。たとえば、以下のような観点は考慮しておくべきでしょう。

  • 監視などのためにログやメトリクスが適切に取られているか。
  • リリース後に負荷に耐え切れるのか。

これについても問題ありません。

まず、ログについては ECS タスクが出力するものは Hako のエコシステムが自動で処理しており、hako-console と S3 で閲覧や検索が可能になっています。hako-console はアプリケーションのメトリクスやシステムについての情報を一覧することができる社内向けの便利なコンソールツールです。

techlife.cookpad.com

アプリケーションレベルのログは fluentd を利用して送信することもでき、Hako のエコシステムを活用すれば fluentd の設定も簡便に行えます。

また、メトリクス については ECS や ALB から CloudWatch に出力されたものや、自前で運用している Prometheus、cadvisor 由来のものがあります。これらのアプリケーションで収集された rps やレイテンシーといったメトリクスは Grafana Dashboard で閲覧することができ、このダッシュボードについても hako-console によってサービスごとに自動で提供されます。

また、リリース後の負荷についても、負荷試験の基盤が整っています。シナリオを作成して、Web コンソール上から負荷試験を実施することができます。

techlife.cookpad.com

ログやメトリクスの収集や可視化は、保守運用の上でとても大切です。またリリース時に負荷に耐え切れるのか負荷試験を行なってシステムを検証することも大規模サービスでは必要になります。しかし、これらの設定や実施のための手順が煩雑だと、後手に回ってしまう可能性があります。上述したようにそのハードルを下げることで、保守運用や安全なリリースのために必要なことを簡単に実現できるようにしています。

こうしてログ・メトリクスの整備や負荷試験も行い、無事にアプリケーションをリリースすることができました。 当日のリリース時にも監視を行なっていましたが、上で用意したメトリクスやログを元に容易に監視が行え、特に障害等は発生せずリリースが完了しました。

まとめ

クックパッドの基盤をフル活用して新卒でもアプリケーションを作成、デプロイ、リリースまで持っていけたよという事例を紹介しました。作業の流れを通じて、クックパッド社内の基盤を紹介しました。

クックパッドには数多くの基盤があり、生産性の向上やサービス品質の安定に一役買っています。今回紹介したのはその一部で、他にも色々な基盤が存在しています。それらは過去のクックパッドの歴史の中で試行錯誤の中で生み出されてきた知識の結晶とも呼べ、いまのクックパッドの開発の大きな支えとなっています。

特に、複雑なアーキテクチャや高い負荷などが見込まれる中で、サービスのために必要な開発が比較的低コストかつ安全に行えるというのは、非常に恵まれていると感じました。

さて、クックパッドでは「毎日の料理を楽しみにする」というミッションを実現するため、一緒にチャレンジする仲間を募集しています。 本記事で紹介した基盤は日々改善が重ねられています。本記事を読んでクックパッドの基盤開発やそれを活かしたサービス開発に興味を持った方は、ぜひカジュアル面談などで一度お話ししましょう!

info.cookpad.com

*1:買い物機能の詳細や技術的な挑戦は以下の記事も参考にしてください。 https://techlife.cookpad.com/entry/2021/01/18/kaimono-swift-ui

AWS CodeBuildでのRailsアプリのdocker buildを早くしたい

$
0
0

メディアプロダクト開発部の後藤(id:mtgto)です。

世間ではバレンタインですね。最近私はハンドメイドスイーツオークションというWebサービスの立ち上げをやっていました。ライブ配信でバレンタインのスイーツを作っていただき、ライバーのファンがスイーツをオークション形式で実際に購入できるというサービスです。 私のチームでは仮想DOMを扱うのにVue 2を使うことが多いのですが、今回は期日がずらせないイベントだったことや必要なライブラリがReact版しか提供されていなかったこともあり私がVueより使い慣れているReactで作りました。

本記事ではAWS CodeBuildでのRailsアプリのDocker buildを早くするための工夫を紹介します。

docker buildを早くしたい理由

クックパッドでは多くのアプリケーションを運用していますが、その多くはAWS ECS上で動いています(ref. ECS インフラの変遷)。 デプロイにかかる時間の大部分を占めるのはCIおよびDocker imageのビルド時間なのでコード修正→ステージング環境へのデプロイ→動作確認→デプロイのデベロップルーティンを日に何度もくり返すような生活をしているときに発生する待ち時間を減らすためにもdocker buildにかかる時間を減らすことができれば幸せになります(主に私が)。

まずは現状どれだけかかっているかを見てみましょう。今回使うのは生まれてから数年経っておりVue.jsを含むフロントエンド実装がそこそこあるRailsアプリです。

f:id:mtgto:20220214133414p:plain

CodeBuildのフェーズ詳細で確認すると、現状のDocker buildのためのCodeBuildジョブには10分弱ほどかかっていることがわかりました。

今回はこれを半分の時間にするのを目標としてみます。

今回のRailsアプリケーション

今回の実験に使用したRailsアプリケーションです。数年の歴史を経てかなりのページ数を持っています。

  • Ruby 420ファイル
  • TypeScript 180ファイル

PROVISIONINGフェーズにかかる時間が長い

まずはAWS CodeBuild Console上の最近のビルド履歴のフェーズ詳細からどのフェーズに時間がかかっているかを確認しましょう。すると PROVISIONINGというフェーズに232秒かかっていることがぱっと目につきます。「CodeBuild PROVISIONING 遅い」で検索すると、CodeBuildに利用しているDockerイメージが古いと環境構築にかかる時間が長くなってしまいそれがPROVISIONINGフェーズが長くなる原因となるようです。実際、今回実験に使用したプロジェクトでは aws/codebuild/standard:2.0という大変古いバージョン 1を使用していました。 これを「ビルドの詳細」→「環境」から現行の最新の aws/codebuild/standard:5.0に変更し、ビルドしてみます。同時にDockerのバージョンも18から20に変更します。

f:id:mtgto:20220214133450p:plain

PRIVISIONINGフェーズで232秒かかっていたのが95秒になりました。これだけで2分以上改善できました。

BUILDフェーズの地道な改善

次になんとかしたいのは309秒かかっているBUILDフェーズです。このフェーズではDockerfileをもとにdocker build && docker pushを行なっているため、工夫次第で改善できそうです。

今回のプロジェクトのDockerfileはマルチステージビルドを使っており、後述の yarn installbundle installをDockerのレイヤーキャッシュでなるべくスキップするような工夫はすでにされていました。それにもかかわらず5分近くかかっているのであれば感覚的にはこれは短縮できそうです。さっそくプロジェクトを見ていきましょう。

babel-loader → swc-loaderを使う

swc (Speedy Web Compiler) はRustで書かれたJavaScript / TypeScriptのトランスパイラで、babelよりも早いという特徴があります。Parcel v2ではTypeScriptのトランスパイルにデフォルトでswcを使ってくれたりするので、知らない間にswcのお世話になっているかもしれません。

今回実験に使用したプロジェクトではCodeBuildでのwebpack buildに約192秒かかっていました (JavaScript/TypeScriptの他にscss/cssの処理やコピーだけですが画像アセットの処理を含みます)。これならbabelからswcに変更するメリットがありそうです。

実際にbabel-loaderからswc-loaderに切り替えたところCodeBuildでのwebpack buildは192秒→174秒に改善しました。

チャンク分割

swc-loaderの導入により早くはなりましたが劇的には早くなりませんでした。この背景として今回のプロジェクトでは元々出力JSファイル数が多く、またチャンク分割 (splitChunk) の設定もしてないことが原因と思われました。

まずはこの仮説が正しいかを調べてみましょう。 webpack-bundle-analyzerを使って出力されるJavaScriptにどのようなパッケージが含まれているかを見てみたところ、このプロジェクトでは32個のJavaScriptファイルが生成され、そのうち4ファイルが1MBを越えていました。同じnpmパッケージを複数のJSがもっていることもわかったため、webpackのチャンク分割の設定を行い共通部分をまとめるようにしたところCodeBuildでのwebpack buildの実行時間は174秒から50秒に一気に短縮されました。

今回は時間の都合で行っていませんが、webpack-bundle-analyzerで見たところかなりの部分をaws-sdkが占めていることがわかりました。aws-sdkをv2からv3にアップデートすることで必要なサービス用のライブラリだけをインストールすることができるようになるためTree shakingなどビルド時間や生成ファイルサイズのさらなる改善も得られそうです。

AWS SDK for JavaScript v3

それでもだめなときの最終手段「金」

これまでいくつかの改善を行ってきましたがあとすこしだけCodeBuildの実行時間を半分にするには足りませんでした。そこで最後の手段である「お金で殴る」を使ってみることにしました。

CodeBuildではビルドの設定で利用するマシンスペックを変更できます。これまで使用していた「3GBメモリ / 2vCPU」で不足なのであればその上の「7GBメモリ / 4vCPU」を使用してみましょう。

これによりBUILDフェーズが56秒短縮されました。まさに「時は金なり」です。

ちなみにAWS CodeBuild の料金はビルド一分あたりの料金で計算され、だいたいvCPUが倍になれば料金も倍になります(分単位に切り上げ)。今回のプロジェクトのようにアセットのコンパイルにかかる時間がボトルネックな場合には強いマシンスペックを選ぶことでビルド時間が短縮されることが期待できます。コスパを考えつつスペックを選択しましょう。

実施済みの改善ポイント

今回実験に利用したプロジェクトでは既に実施済みでしたが、以下のような設定もビルド時間削減が期待できます。

webpackerをやめる

docker buildの高速化とは直接つながりませんが、webpackerをやめて直接webpackを使えるようにすることで、RubyGemsのインストールをアセットコンパイルの依存から外すことができます。request specやfeature specを実行するためにはRailsからアセットが利用できないといけないため、アセットコンパイルをBundle installと並列して行うことによりCIの時間短縮も期待できます。

Rails 7からはWebpackerは標準で入らなくなり、2022/1/19にはwebpackerは以降セキュリティパッチのみの対応で機能追加は後続のShakapackerへの移行が必要になりました。

Webpacker自体は悪ではないとは思いますが、なにをやっているかわからずRailsやwebpackのバージョンアップのたびにwebpackerへのマイグレーションで苦労していたので私の周りでは脱webpackerすることが多いです。

Webpackerを外すときには config/webpacker.ymlおよび config/webpackの git logやgit blame を見てどんな修正をしているか確認します。大した修正をしてなさそうだとわかったらwebpackerを外してしまって1からwebpackの設定をしてしまうのが楽なんじゃないかなと思っています (これはWebpackおじさんがチームにいる場合なので異論は認めます)。

一度webpackerを外してしまえば今回やったようなbabel => swcの導入などもしやすくなるでしょう。

Dockerレイヤーキャッシュを活用する

CodeBuildでもDockerレイヤーキャッシュを利用する設定ができるためライブラリのインストールをDockerのレイヤーキャッシュを使ってスキップすることを期待できます。

Webアセットを含むRailsアプリの場合、

  • package.json, package-lock.json (or yarn.lock) だけを先にCOPYしてからnpm install && webpackする
  • Gemfile, Gemfile.lock だけを先にCOPYしてからbundle installする

のようにライブラリのインストールに必要なファイルだけを先にCOPYしておくことで、それ以外のファイルを変更しても上記のファイルが更新されない限りはキャッシュが有効になることを期待できます。

まとめ

今回実施した工夫によりCodeBuild実行時間は9分半から4分44秒になり、目標とした半分の時間にすることができました。

  • 最適化前にかかっていた時間 570秒
  • 利用イメージのバージョンアップによりPROVISIONINGフェーズの改善 -137秒
  • babelからswcに変更 -18秒
  • webpackのチャンク分割 -124秒
  • CodeBuildのスペック変更 -56秒
  • トータル 570 - 335 = 235秒
改善前 改善後
f:id:mtgto:20220214133414p:plainf:id:mtgto:20220214133607p:plain

これ以上の最適化は今回のプロジェクトではコスパが悪そうなので、まずは社内の別のプロジェクトでもCodeBuildで古いイメージを使ってないかを確認していこうと思います。

この記事がCodeBuildユーザーやDocker Buildが遅くて困っている方の参考になれば幸いです。


  1. 古すぎても4.0未満はもはやAWS Console上で選択できません。

レシピページのOGP画像を動的に生成する

$
0
0

こんにちは、クックパッドでエンジニアをやっている @morishinです。入社してわりと長い間 iOS アプリやそのバックエンドの開発を中心にやってきましたが、最近は専らウェブフロントエンドとその基盤をいい感じにするというのをやっています。先日クックパッドウェブサイトのレシピページの OGP 画像を素敵に刷新したのでそのお話をしたいと思います。

※ ここで OGP 画像と呼んでいるのは Open Graph Protocolで定義されている og:imageプロパティに指定する画像のことです。
※ OGP 画像と呼んでいますが厳密には今回変更したのは Twitter Card (twitter:image) 用の画像のみなので、その他の SNS に表示される画像 (og:image) は変わっていません。

できたもの

これまではレシピ作者さんがアップロードされた料理写真を単にクロップしたものが表示されていましたが、料理写真の横にクックパッドのロゴやレシピ・作者さんの名前を添えたいい感じの画像が表示されるようになりました!画像を見ただけでクックパッドのレシピページであること、なんという料理であるかなどの情報をパッと認識することができるようになっています。

Before After
f:id:morishin127:20220210160959p:plainf:id:morishin127:20220210161035p:plain

動機

SNS にシェアされたレシピがより魅力的に見えるようにしたいという思いと、クックパッドのレシピであることがひと目で伝わってほしいという気持ちから OGP 画像のデザインを変えられないかという話が挙がりました。開発はそれなりにかかりそうなので、まずはクックパッドの公式ツイッターアカウントから「画像ウェブサイトカード」(Twitter for Business の機能) を使って特定のレシピページに対して手作業で作ったいくつかのパターンの画像を当てて複数回プロモーションツイートを投稿し、パターンごとのエンゲージメント率などを見ながらどのデザインが良さそうかを検討しました。検証を経て現在のデザインに決まったので、次に実現方法の検討に移りました。

▼画像ウェブサイトカードの例

パターン例1パターン例2

実装方針

OGP 画像用の URL はウェブページの HTML 中で次のような meta タグで指定します。

<meta property="og:image"content="<画像のURL>">

この content に指定する URL へのアクセスを受けるサーバは HTML でなく画像データを返さなければならないため、全てのレシピに OGP 画像を用意しようとするとレシピの数だけ画像データが必要になります。バッチ処理で事前に全レシピの画像を生成しておくことも不可能ではありませんが300万品以上のレシピに対して画像を生成するコストは重く、また実際に OGP 画像がリクエストされるのはそのうちのごく一部のレシピであるため無駄も大きいです。そのため今回は OGP 画像用の URL にリクエストがあったタイミングで動的に画像データを生成して返すアプリケーションを作成することにしました。

実現方法にはいくつか選択肢が考えられましたが、最終的にはこのようなアーキテクチャになりました。インフラには AWS のサービスを利用しています。

f:id:morishin127:20220210161124j:plain
アーキテクチャ図

OGP 画像として利用したいビューを HTML として生成するページをクックパッドのウェブアプリケーション上に作り、AWS Lambda 上で実行した puppeteer (headless chrome) でそのページへアクセスしてスクリーンキャプチャを撮ることで、HTML を画像データにします。Lambda のトリガーを API Gateway にして HTTP エンドポイントからアクセスできるようにし、レスポンスのキャッシュ用途でその前段に CloudFront を置いています。

処理がシンプルであること、メンテナンスコストが低いことからサーバレスなアーキテクチャを選択しましたが、リリース後に AWS の CostExplorer を確認したところ $1/day 未満のコストで済んでいるため、金銭面でも良い選択だったのではないかと思っています。

アーキテクチャの選定

上述の構成に決定するまでに検討した内容についてお話します。

他社のサービスで OGP 画像の動的生成を実現しているものを見かけてることはありましたが、どのように作っているのかは知りませんでした。しばらく調べてみた感じではどうやら次のような選択肢がありそうでした。

  • サードパーティの画像配信 CDN サービスを利用する
  • ImageMagick や node-canvas を使ってサーバーサイドで画像処理を行い生成する
  • HTML として描画したビューのキャプチャを撮ることで画像を生成する

デザインの自由度が高く作成・変更が容易であって欲しい、また金銭コストも気になるというところでまずはサードパーティサービスではなく自作ができないかを考えました。ImageMagick や node-canvas で画像を作るのは単純に実装が難しく消耗しそう、また変更も大変そうに思ったので3つ目の HTML を画像化する手法を第一に試すことにしました。Vercel 社の vercel/og-imageがこの方針を取っていること、GitHub 社の新しい OGP に関するブログ記事でもこの方法でやっていることが窺えたので、その事実にも後押しされました。オープンな文化™️、めっちゃ助かる。

上述のブログ記事と OSS である vercel/og-image の実装を参考にすることで、puppeteer で HTML のキャプチャ画像を生成するところは実現できました。これが Lambda で動かせれば ok です。Lambda 上で puppeteer を実行するためには chromium のバイナリを含むコードを Lambda 上にデプロイする必要がありますが、バイナリのサイズが大きいため通常の Lambda のアップロードサイズ上限 (250MB) に引っかかってしまいます。幸い Docker イメージとしてデプロイする方式だとサイズ上限が 10GB まで引き上げられる仕様であったため、Lambda 上で動かすアプリケーションは Docker イメージとして作成しました。細かい話ですが vercel/og-image が依存している chrome-aws-lambdaパッケージは Lambda の 250MB 制限に引っかからないように chromium バイナリを Brotli 圧縮したものが使われていましたが、今回はサイズ上限を気にしないでよくなったのでこのこのパッケージは使わず素の puppeteer を利用しています。

デプロイフロー

インフラリソースの構築とアプリケーションコードのデプロイには AWS CDK を利用しました。クックパッドではほとんどの AWS リソースは Terraform で管理されており (参考: AWS リソース管理の Terraform 移行 - クックパッド開発者ブログ) 個々のアプリケーションとは別に Terraform 定義用のリポジトリがあります。しかし今回作ったアプリケーションでは例えば API Gateway のエンドポイントの定義であったり CloudFront のキャッシュポリシーであったり Lambda 実行環境のスペックであったり、そういった AWS リソースの設定値をアプリケーションのソースコードと同列に扱いたくて、また頻繁に手を加えるようにも思ったので、アプリケーションコードと同一のリポジトリに置いておきたいと考えました。そこでインフラリソースの定義を CDK で書き、そのソースコードはアプリケーションと同一のリポジトリに置いて管理することにしました。実際、リリースした次の日にはキャッシュポリシーを変更したりしていて、変更・デプロイが楽にできたと感じました。

CDK のスタック定義の実装としては AWS が用意してくれている AWS Solutions Constructs@aws-solutions-constructs/aws-cloudfront-apigatewayを使い回す形でほぼ実現できたのですが、細かいところで API Gateway の REST API ではなく HTTP API (参考) を利用したかったため一部実装に手を加えて利用する形になりました。典型的なインフラの構成がソースコードとして配布されていて利用することができる点が CDK の大きな利点に感じました。あと TypeScript などで記述した場合 JSON や YAML と違い型定義がありエディタの機能で定義にジャンプして、リソースがどういうプロパティを取りうるかがパッと分かるのが良いですね。毎度ググってドキュメントを見に行かなくても済みます。

まとめ

OGP 画像を動的に生成するアプリケーションをサーバレスな構成で実現してみました。結果として運用コストが低く金銭的コストも低い、またデザインの変更も容易な設計になったと思っていて、おおむね満足しています。仕組みとしては汎用的なものでレシピページに限らず他のページ向けにも OGP 画像を生成させることができるので、今後も活用していきたいと思っています。

クックパッドでは、技術とサービス作りが大好きなエンジニアを募集しています!
実装についてもっと詳しい話を聞きたい方、クックパッドでエンジニアとして働くことに興味のある方、よければオンラインでカジュアルにお話しませんか🙋‍♂️
🔜 カジュアル面談 申し込みフォーム

Twitterなどで雑にお声がけいただいても大丈夫です。クックパッドはエンジニアを積極採用中でございます。

【宣伝】

https://www.youtube.com/playlist?list=PLGT7Exkshx4gQwDgEM1a2wRJgAv2yuzIBというサービス開発者向けのライブ配信イベントをやっていて、次回は 2月24日(木) 20:30~に「数千万レコードをリアルタイムに捌くクックパッドマートの開発」というタイトルでやるのでよかったら観にきてください!
👇️👇️👇️👇️👇️👇️
cookpad.connpass.com

3/12 (土) 開催!「6社合同SRE勉強会」のクックパッドのセッションを紹介します

$
0
0

6社合同SRE勉強会について

クックパッド技術部 SRE チームの @eagletmt@mozamimyが、以下の Connpass で告知・募集されている「6社合同SRE勉強会」に登壇します。

https://line.connpass.com/event/236497/

6社合同SRE勉強会は、IT 企業 6 社 (LINE/メルカリ/クックパッド/ディー・エヌ・エー/サイバーエージェント/リクルート) が合同で開催する、Site Reliability Engineering (SRE) 領域の勉強会です。各社が特徴的な事例を共有し、各セッションのAsk the Speakerでは違う会社の登壇者がモデレーター兼聞き手を務めて、知見共有&深堀りを行なっていきます。

多様なバックグラウンドを持つ各社の SRE が取り組んでいる課題について、技術的な面から組織的な面までを絡めた、魅力的な内容のセッションが盛りだくさんとなっています。

以下、クックパッド SRE によるセッションの概要をご紹介します。現在、鋭意資料の準備中ですので、本番では細かい内容が変更になる可能性がありますがご容赦ください。

Track B 13:30-14:15: 少人数でも運用できるインフラ作り

技術部 SRE グループの鈴木 (id:eagletmt) です。このセッションではクックパッドにおけるインフラ構成の変遷を概観しつつ、その変遷の根底にあったマネージドサービス化やセルフサービス化について話そうと思っています。

私がクックパッドに入社した2014年当時、社内の多くのエンジニアはレシピサービス (cookpad.com) の開発にかかわっており、モノリシックな Rails アプリケーションが Amazon EC2 上にデプロイされてレシピサービスが実現されていました。SRE *1の仕事もレシピサービスに関することが中心でした。一方でみんなのカフェや料理教室といったサービスをレシピサービスとは独立したアプリケーションで提供しており、API での連携の基礎ができてきたりマイクロサービス化が始まりつつありました https://techlife.cookpad.com/entry/2014/09/08/093000。レシピサービスのマイクロサービス化についてはお台場プロジェクト https://techlife.cookpad.com/entry/2018-odaiba-strategyへと続いていき、このお台場プロジェクトによって大きく進みました。現在のレシピサービスは Web フロントエンドの一部を Next.js で提供するようになったり https://techlife.cookpad.com/entry/2020/12/01/093000、スマートフォンアプリ向け API の一部は Java で実装された BFF を利用していたり https://techlife.cookpad.com/entry/2019-orcha-bff、Go で実装された広告配信サーバがあったり https://techlife.cookpad.com/entry/dynamodb-accelerator-usecase-adserver、実装言語も異なる様々なアプリケーションから成り立っています。そして事業面でもレシピサービスだけでなくクックパッドマートにも力を入れていて、多くのサーバーサイドエンジニアがレシピサービスの Rails アプリ開発に従事していた頃とは大きく状況が変わっています。

このようなアプリケーションの変化に合わせて、インフラ構成や SRE の役割も変化していく必要があります。新規事業や新しいマイクロサービスがどんどん増えていく状況では、10人にも満たない SRE チームがアプリケーションに合わせて EC2 インスタンスや MySQL を用意して運用していてはそれだけで手一杯になってしまいます。そこでサービス開発において SRE がボトルネックとならないようにするため、可能な限り AWS のマネージドサービスを利用するようにして SRE にとっても運用負荷を軽減しつつ、事業部のエンジニアへと運用を委譲しやすくしました。さらに、新規のアプリケーションを本番環境で動かすために必要な作業を自動化したり、インフラ操作の権限を委譲していくことで、できるだけ SRE の手作業無しでも新規リリースやミドルウェアの変更を行えるようにセルフサービス化を進めてきました。このセルフサービス化の考え方について話しつつ、とくに自分が中心的に関わっていた ECS 関連や Terraform 移行について一例として触れようと思っています。

また、SRE の運用負担について話す上でオンコール対応は切っても切れない話題でしょう。マイクロサービス化に合わせてインフラが複雑化する中、アラーティングの基準やオンコール体制をどう変化させたかについても触れようと思っています。

関連記事

Track A 15:15-16:00: AWS コストを可視化して「説明」できるようにするための取り組み

技術部 SRE グループの mozamimy です。本セッションでは、クックパッドにおける、AWS のコストを可視化・管理し、最適化までカバーする取り組みについて話す予定です。AWS コストを、と銘打っていますが、その根本的な考え方や各テクニックの本質的な部分は GCP や Azure といった他のパブリッククラウドでも役に立つでしょう。

会社のミッションを達成するための原動力として、お金は有限のリソースであり適切に管理するべきです。サービスを提供するためのインフラはそれ自体が利益を生むことはできません。したがって、ユーザ向けに同じクオリティのサービスや、社内の開発者向けに同じ開発効率を提供できるのであれば、インフラコストを下げて、事業に直接関係する投資などの有意義なお金の使い方をするほうがよいでしょう。

AWS のコスト管理・最適化の枝葉のテクニックはいろいろありますが、「コストを説明できる」というところに集約できるとわたしは考えています。クックパッドではこの考え方をベースにして、年次の AWS の予算案の作成、月次の経営層をまじえてのコストの振り返り、週次の SRE 定例ミーティングなどを中心とした、コスト管理の業務プロセスを実践しています。

本セッションでは、まず「コストを説明できる」というのはどういうことかを説明したのち、過去 (2019 年頃) のクックパッドにおける AWS コスト管理の課題を振り返り、どのような流れで現在の業務プロセスになったのかを説明します。

次に、その業務プロセスを支える技術について、以下の仕組みについてそれぞれ説明します。

  • RI / Savings Plans を管理するための仕組み
  • コスト配分タグを管理と月次レポートの半自動生成機能を中心とした、スタッフ向けのウェブツール
  • 開発向けサンドボックス AWS アカウントのリソースを定期的に掃除する仕組み

これらの仕組みは AWS のマネージドサービス (Cost Explorer や CloudWatch カスタムメトリクスなど) をうまく利用しつつ、足りない部分を内製で補うようになっています。

さいごに、これらの仕組みを整えたことによりメリットがあったと実感できた事例や、社内のプロダクト開発チームに与えた影響、これからの展望についても述べます。

本セッションは本ブログの以下の記事をベースにしつつ、内容をより整理した形で、最新のアップデートとともにお届けする予定です。

まとめ

3/12 (土) に開催される6社合同SRE勉強会のイベント概要と、クックパッドの登壇情報をお伝えしました。各社興味深い発表が目白押しですので、以下の各社のブログへのリンクからセッション内容をチェックしつつ、ぜひご参加ください。

事前質問について

クックパッドのセッションも事前質問を受付中です。以下のリンクからお気軽にどうぞ!

*1:正確には当時の名前は技術部およびインフラ部


工学的知見と実際の観測データに基づいて物理世界にサービスを展開しています

$
0
0

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

クックパッドの研究開発部では,「毎日の料理を楽しみにする」というミッションを実現するために,様々な野心的なチャレンジをしています.一方で,我々研究開発部のメンバーには,自分のもてる技術を用いて,部署内外の課題を発見し,解決するという使命もあるのです.

今回はクックパッドの生鮮 EC プラットフォームサービスであるクックパッドマートで食品流通のために展開している冷蔵庫の温度管理についてご紹介します.クックパッドマートで使用されてる冷蔵庫は,サービスの安定稼働のため,次のように工学的知見と実際の観測データに基づき運用されています.

マートステーションに設置されている冷蔵庫の課題

クックパッドマートでは,ユーザーの皆さまが食品を受け取る場所をマートステーションと呼んでおり,そこには下図のような冷蔵庫が設置されています.

f:id:meltingrabbit:20220222223237j:plain
マートステーション

そして,この冷蔵庫の冷却設定は,「より安全側になるように,少し強めに冷却する」というような設定で運用しています.しかしながら,冷蔵庫というハードウェアを実環境に展開している以上,どうしても目標の温度に対して多少上がりすぎてしまったり,冷えすぎてしまったりするという課題がありました.

クックパッドマートでは,すべての冷蔵庫に温度計を設置し,リアルタイムで庫内温度をモニタリングしています.それによって,社内規定の温度ポリシーを逸脱し食品が危険にさらされた場合や,葉物野菜等が低温障害によって品質が損なわれた場合に,直ちに適切な対応がとれるようになっています.

冷蔵庫の温度設定を見直すことで,弊社にとっては返品ロスの削減,ユーザーの皆さまにとっては注文した商品が高い品質で届くというサービスクオリティの向上が図れるのではないか,という考えに至りました.ただし,「温度設定を見直し,場合によっては設定温度を上げる」という施策は,心理的ハードルが高く,なかなか実行に踏み切れていませんでした.

冷蔵庫の温度をより適切にするには?

最適化したい対象が目の前にあるとき,まず最初にしなければならないことは,対象の正しい理解です.

はじめに,使用している冷蔵庫を “理想環境” で稼働させたときの特性を見てみます.理想環境とは,外乱の少なく,安定した,想定した環境,さらには,製造誤差なども無視できる状況下,つまり冷蔵庫でいえば,外気温・湿度が一定でかつ,その他の外的要因がない環境,です.

この下図が,その時の温度グラフです.一番上の紫線が室温(恒温槽)の気温で,下方の3線が3台の冷蔵庫の庫内温度です.冷蔵庫は基本的には,「庫内がある温度を超えたら冷却ON,ある温度を下回ったら冷却OFF」というように2状態で制御されています(いわゆるバンバン制御,ないしはオンオフ制御). つまり,

  • 庫内温度が上昇し,ある基準温度を上回ったら冷却を開始.
  • 冷却に伴い庫内温度が下降.ある別の基準温度を下回ったら冷却を停止.
  • 停止に伴い庫内温度が上昇.(以下繰り返し)

というような挙動を示します.このようにして,ここでは庫内温度がおよそ0〜4度に制御されています*1

f:id:meltingrabbit:20220222224526p:plain
使用している冷蔵庫の理想環境での庫内温度(スペックシートより)

理想環境での挙動がわかったら,今度は “実環境” でどうなるか,です.ソフトウェアでも,デプロイしてみたら,想定外の時間に想定以上のアクセスがあった,とか,想定外のデータが飛んできた,とかありますよね.ハードウェアを実世界に展開したときでももちろん,似たような想定外や外的要因が発生するものです.

次の画像が(故障や異常な冷蔵庫を除いて)無作為に抽出したある冷蔵庫 60台の温度ログです.ハードウェアを扱う以上,現実世界は理想状態からは程遠く,そこまで単純ではありません.

f:id:meltingrabbit:20220222231125p:plain
冷蔵庫 60台分の温度時系列ログ

ひと目見ただけでも,次のようなことが理想環境下とは異なることがわかります *2

  1. グラフの形状が異なる(理想環境ではきれいな一次遅れ系だが,実環境では異なる).
  2. 振幅が異なる(理想環境では0〜4度の4度ほどだが,実環境では3度以下のものもちらほら).
  3. 実現される温度帯がばらついている(理想環境では0〜4度の範囲に入るとされているが,ステーション実測では個体ごとにばらつき,全体として-2〜6度の範囲に).

1, 2は,理想環境下では無負荷(庫内が空)で気流がきれいに流れている一方,実環境下の冷蔵庫では庫内に食品が入っているため,空気の循環が異なることなどが原因として考えられます.3は製造公差,環境外乱などが原因でしょう.この冷蔵庫の温度制御回路はアナログ回路です.つまり回路定数などに誤差が発生しますし,冷風の温度計測にもバイアスがのります.また,冷蔵庫はコンビニの店内といった空調の効いた環境からマンションのエントランスといった半屋外など,環境の異なる場所に設置されています.そういったことを考慮に入れると,このばらつきも納得できます.

さらに,庫内というのは実は3次元的な空間であり,大きさを持っています.つまり,冷蔵庫上段奥と,冷蔵庫下段手前では,その温度は異なります.実際にたくさんの計測機器を冷蔵庫に設置し,実験を繰り返すことで,このような庫内温度分布といった特性を理解していき,そしてその特性が食品に与える影響を考えていくのです.

本記事では詳細は省略しますが,理想環境では食品をおおむね0〜4度(2±2度)で保存できると思いきや,実環境では,

  • 庫内温度のバンバン制御により±2度
  • 公差,外乱などにより±2度
  • 庫内温度分布などの影響により±1.5度

などの誤差が累積し,同じ型番の冷蔵庫を展開したとしても,食品が体感する可能性がある温度帯はレアケースも含め-3.5〜7.5度(2±5.5度)に分布すると推定されました.

工学的知見と観測データに基づく解析から,エビデンスをもって施策決定を行う

さて,本題です.

工学的知見と実験等によるデータから,マートステーションで使われている冷蔵庫の実環境における特性を理解することができました.あとは,多数の実環境データを用いた統計的解析により,冷蔵庫の最適な温度設定を探っていきます.

はじめに,運が悪く最悪ケース級の外れ値を引き当ててしまった冷蔵庫や,そもそも不具合を抱えている冷蔵庫といった,社内規定である温度ポリシーを違反しうる可能性の高い冷蔵庫を洗い出します.

冒頭でお見せしたように,冷蔵庫の温度はバンバン制御しており,周期的に上昇と下降を繰り返しています.さらに扉の開閉などによっても温度は変化します.このように,瞬時的な温度ですべての異常を検知するのは困難であるため,以下のような可視化と解析を行いました.

  1. 扉の開閉やその他の影響が少ないと思われる,深夜0時~早朝6時までのデータを収集
  2. 周期的に振動する時系列データから,外れ値などの影響を取り除くため,下図赤線のような上側温度と下側温度を求める(算出方法の詳細は割愛)
  3. 下側温度を横軸,上側温度を縦軸にとった散布図を描く

f:id:meltingrabbit:20220222231627p:plain
青線:温度履歴,赤線:算出される上側温度と下側温度

結果は下図(見やすさのため極端な外れ値は除いてます)の紫点のようになります,赤枠は,これまでにわかっている冷蔵庫の特性と解析方法から予想される温度域(を大雑把に直線で描画したもの)で,概ねそこに収まっていることがわかります.

f:id:meltingrabbit:20220222232741p:plain
異常冷蔵庫検出結果

一方で,この赤枠からおおきく外れているものは何かがおかしく,詳細に見ていくと,

  • マートステーションが稼働する前のデータの混入
  • 冷蔵庫本体の故障
  • 半ドアや施工ミス(冷蔵庫の設定ミスや,そもそもの設置方法のミス)などの冷蔵庫が不適切な状態
  • データ欠損などのデータが不完全によるもの
  • その他(いろいろな誤差が悪い方へ重なり範囲を逸脱したもの.どうしてもハードウェアは外れ値を出すものはある.)

に分類でき,最初のスクリーニングとしては十分な威力を発揮してくれました.

このようにして検出された潜在的に温度ポリシー違反となりうる可能性の高い冷蔵庫は順次是正していくことにします.そうすると,「庫内の設定温度を少し上げても,食品の危険性はそこまで増加せず,むしろ低温障害などの影響が低減できる」ということがわかってきました.また,管理コストの問題から,冷えすぎている冷蔵庫のみではなく,展開しているすべての冷蔵庫の温度設定を一括で変更したいと考えました.一方で,冒頭に記したとおり,安全側に寄せるために強めにしている冷却を弱める,というのは心理的ハードルがつきまといます.

この心理的ハードルを乗り越えるために,「庫内の温度の設定値を上げること」がどの程度効果があり,そして現実的に可能であることを検証していきます.そのためには実験あるのみです.数十台の冷蔵庫の温度設定を変更した時のデータを取得し,解析します.そうして得られた工学的知見に基づくエビデンスをもとに,「マートステーションの冷蔵庫の設定温度を一括で上げること」,「温度設定の変更は,冷却モードを1段引き下げるのが最適であること」を提案しました.

提案文章内のエビデンスの概要は,だいたい次のようなものを用意しました.

f:id:meltingrabbit:20220223002921p:plain

  • 左図が冷蔵庫の温度が低い時間帯の温度(下側温度)分布
    • 青色で示した箇所が,今回の施策で氷点下を脱する可能性が十分ある冷蔵庫
    • 赤色で示した箇所が,今回の施策で氷点下を脱することができない見込みの冷蔵庫.とはいえ,これは下側温度であり,常に氷点下に陥っているわけではないことに注意すること.
  • 右図が冷蔵庫の温度が高い時間帯の温度(上側温度)分布
    • 青色で示した箇所が,今回の施策で一時的にでも10度を超えうる冷蔵庫だが,そもそもここに現れているのは何らかの異常を抱えているものであるので(サービスでの利用が停止されている),強く意識する必要はない(別の方法で是正していくべきもの)
  • 平均温度は全体的に0.5~1.0度ほど上昇するが,幸運なことに上側温度に対して下側温度の方が温度上昇幅が大きいことが事前実験の解析から予想される
    • したがってリスクは小さい
  • 冷却モードを1段(およそ1度程度)引き下げるのが,ちょうどよい
    • 1段ではまだまだ冷えすぎてしまう冷蔵庫もあるが,全体分布としては1段が最適
    • 今回救えなかったものは,個別に適当な処置を施すのがよい

こうして社内の合意形成を得た施策を実施した結果は,ほぼぼぼ予想通りでした. このようにして,様々な検討事項に対してエビデンスを示し,施策提案を行うことで,クックパッドマートのサービスクオリティを向上させることができました.

終わりに

このように,クックパッドでは,しっかりと事前解析を行いエビデンスを示すことで,生鮮食品流通といった場でも,重要な施策の意思決定が実現されています.そのエビデンスの裏では,工学的な知見や実験データ,運用データに基づく解析が行われています.ここでは紹介しきれませんでしたが,クックパッドマートではサービスクオリティを向上させるために,下図のような実際の食品をつかった実験や,実際に冷蔵庫を恒温槽で動作させ,様々なデータを取得する実験等も行っています.こういったデータを収集することで,工学的・物理的な現象理解がすすみ,適切な運用ができるようになると考えています.

f:id:meltingrabbit:20220223003924j:plain
実際の食品を用いて冷蔵庫庫内だけではなく,食品内部・表面等の温度特性を測定している

f:id:meltingrabbit:20220223004031j:plain
冷蔵庫の恒温槽試験の様子

*1:この図から,実験設備(恒温槽)の空調もバンバン制御されてることがわかっておもしろいですね.

*2:時々温度が急上昇しているのは,長時間,または断続的に扉が開けられていることによるもの

Cookpad Summer Internship 2022 を開催します!

$
0
0

cookpad summer intern 2022

こんにちは、ボイスサービス部の ymd (@y_am_a_da) です。今年は新卒採用エンジニアリーダーもやっています。

今年もクックパッドはサマーインターンシップを開催します!本記事ではエンジニアコースについてご紹介いたします。

以下のサイトからご応募頂けます。

internship.cookpad.jp

今年は、 15-day Tech Course と 3-day Tech Course の 2 種類をご用意しております。

15-day Tech Course

こちらのコースでは、前半の5日間でクックパッドが日々のプロダクトづくりに活用しているサービス開発の手法論や、Rubyによるサーバーサイド開発とSwiftによるモバイルアプリケーションの開発についての講義を実施します。 そして後半の10日間では、 OJT として前半の講義で得た知識を用いてメンターと共に実際に現場で業務を行って頂きます。

15 日間という短い期間ですが、実務を通してメンターからレビューやフィードバックを受けることで、グッと一回り大きく成長できる内容となっております!

今年は以下の日程で開催されます。

  • 8月15日(月)〜9月2日(金)

昨年の様子については、以下の記事をご覧ください。

techlife.cookpad.com

3-day Tech Course

このインターンシップでは、ユーザーに価値を届けるプロダクトづくりのノウハウの講義・実践、そしてフィードバックを3日間に詰め込んだサービス開発スキルをグッと向上させることができるコースです。

まずは、クックパッドがプロダクトをつくる上で大切にしている考え方やサービス開発で活用しているフレームワークを中心に講義を実施します。そして、実際にユーザーとコミュニケーションを取りながら価値仮説を検証し、課題解決のためのプロダクトづくりに取り組んでいただきます。

サービス開発に日々注力している社員のサポートを受けつつ、アウトプットに対して実際のユーザーからレビューやフィードバックを受けることができる短い期間ながら実践的な内容となっております!

こちらの開催日や詳細については別途、後日に公開を予定しております。

最後に

参加してくださる皆さまのため、サマーインターンシップには毎年会社を挙げて取り組んでいます。 クックパッドのエンジニアになった「未来の自分」を体験できた、と参加して頂く皆様に実感してもらえることを目指して準備を進めていきます。

また、長期の就業型インターンシップも通年で募集しています。 興味のある方は、以下のページからご応募ください。

internship.cookpad.jp

皆様のご応募お待ちしております!

internship.cookpad.jp

CookpadTV の開発スタイルとエンジニアマネージャーの役割

$
0
0

メディアプロダクト開発部の長田(@osadake212)です。
私の主な仕事は、ライブ配信サービスの cookpadLive などを運営しているクックパッドグループの CookpadTV 株式会社のサービス開発をすることです。CookpadTV ではサービス開発部の部長としてエンジニア組織の運営を通してサービス開発に関わっています。
本記事では、CookpadTV で取り組んでいることと、そこでのエンジニアチームの動き方や、エンジニアマネージャーの役割についてざっくりお話しします。

CookpadTV?

CookpadTV はクックパッドグループで動画関連サービスを提供している会社で、2018年4月に設立されました。もともとはクックパッドの事業部の一つだったのですが、素早く意思決定し多くのチャレンジをするために分社化しました。

CookpadTV は料理が持つ人を幸せにする力を信じていて、今まで良いきっかけがなくて料理を楽しめていない人たちに向けて、既存の考え方に囚われない新しい切り口でサービスを提供することで、私たちが信じている価値を届けることにチャレンジしています。
そんな私たちが運営しているサービスの一つに cookpadLive というクッキング Live アプリがあります。このサービスは 2018年3月にリリースされ、本記事の執筆時点で5年目に突入しました。
f:id:osadake212:20220330174305j:plain

AppStore
GooglePlay

cookpadLive ではアイドル・声優・お笑い・俳優など、さまざまなカテゴリの著名人のクッキング Live を視聴することができます。今まで料理をする良いきっかけがなかった人たちにむけて、自分の推しを通じて料理の楽しさを届けており、Live 配信前後の Twitter の反応を見ていると、「普段は料理をしないけど、〇〇さんの配信を見て作ってみました!」のような投稿を見かけることがあります。普段料理をしない人にとって料理をするという行為はとてつもなくハードルが高いものですが、そのハードルを少しでも下げるきっかけを与えることができています。

今年の1月には「cookpadLive cafe 表参道」をオープンしました。自社の配信スタジオにカフェが併設されていて、配信の様子を観覧しながらキャストと同じ料理を食べて楽しむことができる体験ができるスペースになっています。
有名人に会えるLive観覧カフェ「cookpadLive cafe」が東京出店! CAMPFIREにてクラウドファンディングを開始!

f:id:osadake212:20220330174332p:plain

またこのカフェでは様々なアニメ作品とコラボした「AniCook」という企画が開催されており、コラボカフェ史上最高クオリティの「美味しさ」に挑戦し、アニメファンの皆さんはもちろん、アニメの版元企業からも大絶賛いただくメニューを次々に生み出していこうとしています。

サービス開発部の様子

2021年の開発の様子

サービス開発部には現在、私を含め5人のエンジニアが在籍しています。CookpadTV では大小含め多くのサービスを展開していて、50以上のサーバーアプリケーション(staging 環境や社内マイクロサービスを含む)と 9つの iOS/Android アプリケーション(独自 MDM 配信を含む)を開発・保守しています。

エンジニアの人数に対して管理しているアプリケーションの数が多いので、サーバー・クライアントのどちらの開発もできる人材が多いのがチームの特徴です。
とはいえ、並行して開発できるプロジェクトには限りがあるので、優先順位を決め、事業的にチャレンジする価値が高いものから順番に開発に取り組んでいます。

私の部では半年に一度振り返りのタイミングを設けていて、半年間でできたこと、できなかったこと、これからやりたいことの認識合わせをしています。
以下はその振り返り会の2021年の資料の抜粋なのですが、チームで開発・リリースしたものについてまとめたものです。

f:id:osadake212:20220330174352p:plainf:id:osadake212:20220330174425p:plain

この他にも細かい修正や小規模な開発は常に行われており、同時期に複数の開発プロジェクトが走っている状態です。

各プロジェクトの開発の流れ

普段の開発は以下のような流れで行われています。

  • アイディア出し
  • 要件整理
  • 仕様整理
  • 設計
  • 実装
  • テスト
  • リリース
  • 運用

特別珍しい開発工程が含まれているわけではないのですが、全ての工程にメンバーが関わっています。エンジニアの人数もそうなのですが、開発・配信ディレクターやデザイナーの人数も多くはないので、開発メンバーそれぞれの守備範囲が広いのが特徴です。

開発サイクルは1週間を区切りとしていて、以下のような定例をぐるぐる回しています。

  • 各サービスの意思決定者が集まる定例(ディレクター会)
  • 決定事項の共有と1週間の開発予定を計画する定例(開発定例)
  • プロジェクトのフェーズ毎に必要に応じて打ち合わせ(分科会)

この他にも短い進捗報告の場があったりするのですが、基本的にはこれらの会を軸に開発を進めています。

ディレクター会はその名の通り、開発ディレクターが集まってアイディア出し、問題報告、要件、仕様整理、開発の進捗報告、などを行っています。プロジェクトによってはこの会でエンジニアがオーナーとなり、そのプロジェクトを運用まで持っていくことに責任を持っています。

例えば、運用フェーズでも運用者に渡す前・渡した後にエンジニアが運用することがあり、自分たちで作った運用フローが業務を圧迫していないかや、事故を未然に防ぐことができているかなどを確認しています。
具体的には、実際に Live 配信の現場に立ち会って、急にトラブルが起きたらどうするかをイメージしてシステムを設計したり、 Live 配信中に大量に販売した商品を梱包して発送する作業をして、発送ミスが起きないようにするにはどういう仕組みが必要なのかを考えたりしています。

開発定例会はエンジニアが1週間をどう過ごすかを計画する場として運用しています。
ディレクター会で展開された情報をキャッチアップしつつ、1週間のタスクをどういう温度感で取り組むべきなのかをそれぞれの開発メンバーが意識できるようにしています。
また、システムで発生したエラーの振り返りや AWS のコスト振り返り、障害が発生したら障害の振り返りなども行い、システム面でも対応漏れが起きてないかを確認したりしています。

分科会は開発工程に応じてさまざまな会が都度開催されています。
設計前雑談、設計レビュー、Pull Request 補足説明、動作確認会、使い方説明会、夕会など、さまざまです。設計は Google Docs を使って情報共有をすることが多いです。

f:id:osadake212:20220330174447p:plain

プロジェクトによって内容は変わるのですが、だいたい上の図のオレンジで囲んだような項目をプロジェクトをリードする人がまとめて設計レビューで展開します。

この設計レビューを開催する前に設計前雑談というのもやっていて、準備なしで「さぁどうしますかねー」から話始める短い打ち合わせをやっているのも特徴です。

エンジニアマネージャーの仕事

エンジニアマネージャーとしては上述の仕事を視座・視野・視点を変えて取り組む必要があります。
開発だけじゃなく、マネジメント・経営・ビジネスについて理解できていないことや、そもそも知らないことも本当に多いのですが、この会社・このチームのエンジニアマネージャーとして、私が普段から心がけていることを紹介します。

社内で起こっていることをちゃんと把握する

CookpadTV はエンジニア以外にも多くの役割の社員が在籍しており、 cookpadLive をはじめ、全てのサービスは他の事業部の人と協力して開発・運営しています。すごく当たり前のことを書いているのですが、最近になって改めて重要なことだと気付かされています。

CookpadTV のように複数の新規事業を立ち上げたり、立ち上げた事業を成長させようとしてるフェーズの企業の開発では、開発するものの仕様・タイミング・優先順位が重要になります。
事業計画に沿って新しいチャレンジをするためにはいつまでにどういう検証・機能が出来ている必要があるかを把握していないと、その時に必要なシステムや仕組みが何なのか、将来発生するリスクや開発は何なのかを判断することができないはずです。
この判断を大きく間違えてしまうと、せっかく開発したサービスを使うタイミングがなかったり、マネタイズの機会損失をして事業を撤退しなければいけなくなったりします。

CookpadTV の開発スタイルでは、エンジニアが要件・仕様整理することが大小問わずあるので、上で述べたような事業の状況に合わせた判断が必要になるのですが、これを開発メンバー全員が行うのは無理があります。マネージャーとしては、いろんな情報をキャッチアップしておいて、できるだけこの判断がいい感じにできるように心がけています。
例えば、ユーザー向けの新機能は Live 配信の企画に沿って運用できるものになっているのかを配信ディレクターの様子から伺ったり、季節のイベントに合わせた大型企画を最大限成功させるためにできる工夫がないかを探ったり、トライアルに間に合わせるために作った暫定的なシステムを用意した時に発生しうるイレギュラー対応を先回りして仕組み化しておくべきものは無いかを探ったりしています。

こういう情報は、隣の席で何気なく話されていることから分かるものもあったりするので、一見無駄かもしれないようなことでも隙があれば積極的に関わるようにしていて、オフィスでオープンに行われている打ち合わせに聞き耳を立てたり、なんとなく雑談していることから読み取れることはないかを意識しています。

この仕事は誰のどういう成長・成果に繋がるかを考える

プロジェクトがあったとして、それを誰がどのように取り組むのかをアサインする権限を私は持っています。単純にチームの最高速度が出せるように開発リソースをアサインする場合もありますが、期初にチームの目標を設定するときに、その人の Will・Can・Must を整理しているので、基本的にはそこに合うように計画を立てます。

例えば、「システム全体の設計するスキルを高める」という Will を持った人がいれば、その1年の山場となりそうなプロジェクトを任せる前に、早めに小さいプロジェクトから素振りをしてもらったり、「プロジェクトの管理をする」という Can を身につけて欲しい人がいれば、他部署の打ち合わせに同席してもらったりと、単純に他の人がやったほうが早いものであってもチームの成長・成果に合わせて仕事を設計しています。

ちょっと話は逸れますが、やる気があってモチベーションが高い人が一番成長するというのを実感しているので、「どうしてもこの仕事やりたいけど、今着手しているものが終わってない...」みたいな状況の人がいたとして、その仕事をやったほうが成長に繋がると感じた場合は、今着手しているものをなんとか整理して仕事を引き剥がしたりもします。

まぁこのへんの判断を助けてあげるのがマネージャーの仕事かなーと思ってます。

最後は自分がなんとかするという覚悟をする

これは結構大事だと思ってます。自分ができることは限られていますが、やれる範囲のことはなんとかするし、範囲外のことはどうしたら解決できるかを考えます。
Live 配信中に現場でトラブルが起きても、全然知らないシステムで大障害が起きても、自分の守備範囲外のスキルを持ったメンバーが辞めても、最後の砦としてやっていく気持ちが必要です。(でも実際は、真の最後の砦が後ろに何個かありますw)
そういう気持ちで取り組んでいると自然と守備範囲が広がりますし、自分の成長にも繋がるし、チーム的にも強くなっていくはずです。

ちょっと話は逸れますが、逆にこれは課題になってると感じることもあって、自分の能力の上限がチームの能力の上限になってしまいます。自分が見えていることをうまく伝えつつ、この課題が解消されるように試行錯誤しています。言いたいことは「マイクロマネジメントは悪か?よりよい組織をつくるためのマネジメント形態についての考察」という同僚の新井(@SpicyCoffee66)さんの記事にまとまってるので見てみてください。

キャリアについて

「キャリアについて」とかいう、また釣り針の大きい話をしてしまう割には面白いことは話せないのですが、「どう作る」のかではなく「何を作る」のかが重要だと思っています。
何を今更当たり前のことを言ってるんだ、と思われる方も多いかと思うのですが「技術は目的ではなく手段」というのを CookpadTV の開発を通して痛感していて、そういう志向のエンジニアは、マネージャーというキャリアは非常に財産になるんじゃないかと思っています。

一般的に、マネージャーになるとコードを書く時間がなくなってしまう、とか、技術力のないマネージャーは使えない、とか言われるのでとてもネガティブなイメージがあるかと思います。(し、それが間違ってるとも思わないです。)
実際のところ一般的な開発業務と比較すると、プロジェクトの進行管理、障害・サポート対応、他の事業部のキャッチアップ、メンバーの成長の設計、採用活動などの業務の優先順位が上がってしまうので、システムの設計や開発から離れがちになってしまうのですが、既存システムの設計や実装に携わらない期間が長くなったり、最新技術のキャッチアップを怠ったりすると、すごいスピードで置いて行かれている感覚があります。
なので、バランスをとりながら仕事をする必要があります。

幸いにも、私のチームは同じメンバーで長く働けていて、マネジメントコストが高くないことや、CookpadTV の方針である、やらないことを潔く決めてチャレンジすることに重きを置いていることや、各個人の強みを活かしてチャレンジすることが推奨されていることなどのお陰で、私自信も継続的に開発に関わることができています。
2021年に私が作った pull request は 489 件で、1日1回は少なくとも何かしらコードを書くことを心がけていました。

かなりタフネスが求められる仕事ではありますし、がっつり設計したり実装したりする時間はとりづらいのですが、「何か」をチームで生み出す筋肉が鍛えられているのを実感できていて、その経験は普遍的なものになり得ると感じているので頑張ってます。
クックパッドにはこういうタイプのエンジニアもいるんですよ、というのが少しでも伝われば幸いです。
まぁ嫌になったらまたメンバーに戻る選択肢も無いわけではないですし、実際クックパッドにはそういうキャリアを歩いて活躍している人が何人かいます。

さいごに

クックパッドではエンジニアを募集しています。特に、自分の守備範囲を超えて活動するのが苦じゃない人は大活躍できる環境があります。そこに興味がある方はまずカジュアルにお話ししましょう。お待ちしています!
@osadake212
クックパッド株式会社 | クックパッド株式会社 採用サイト

複数リージョンへの Alexa Skill のデプロイを可能な限り自動化する

$
0
0

こんにちは、ボイスサービス部の ymd (@y_am_a_da) です。今年からは採用にも関わっています。

クックパッドは日本で No.1 のレシピサービス*1ですが、ここ数年は海外展開にも注力しており、世界 74 ヶ国 32 言語で展開しています。

ボイスサービス部は、もともとクックパッドのひとつのプロジェクトとして音声 UI を利用したサービスの開発だったり、デモを作成*2していたのですが、サービスの拡大などにより最近独立した部署として設立されました。 規模が一番大きい Amazon Alexa (以下 Alexa) 向けのサービスは、レシピサービスと比較するとまだまだ小規模ですが現在 9 ヶ国 3 言語で展開をしています。

Alexa 向けのサービスも、このくらいのサービス規模になってくるとコードのデプロイや、サービスのメタデータの更新など、手動でやるのはなかなかに大変な規模になってきています。 この記事では、 Alexa 向けサービスのデプロイを省力化するために、クックパッドで実際に行っている自動化のテクニックについて紹介をさせていただきます。

クックパッドスキルの全体像

Amazon Alexa では、いわゆるモバイルアプリケーションにおけるアプリに相当するものとしてスキルというものがあり、ユーザーはこれを有効化してサービスを利用します。クックパッドでもこのスキルを介してサービスを提供しています。 具体的なデプロイのプロセスのお話をする前に、まずはこのスキルの全体像を紹介し、この記事で紹介するデプロイとはそもそも何をすることなのかを説明します。

以下の画像が、クックパッドスキルの全体像になります。実際にはもう少し複雑なのですが、デプロイの説明をするために必要最小限のリソースを記載しています。

クックパッドスキルの全体像

Alexa は基本的にクラウドベースで稼働しており、 Amazon Echo デバイス上での処理は必要最小限です。

これはスキルに対しても言えることで、例えばユーザーがクックパッドスキルに何かを話しかけた場合、 Amazon Echo が取得した音声が Alexa Skill (この場合はクックパッドスキル) に送信され、 Intent と呼ばれるコマンドに変換されます。 Alexa Skill では、開発者は生の発話を扱い処理するのではなく、もう一段回抽象化された Intent と呼ばれるものを扱います。 Android の開発者には馴染みのある単語かもしれませんが、 Alexa における Intent の概念も非常に近いです。 この Intent などを含んだリクエストを AWS Lambda に送信し、処理の結果を返すことで Amazon Echo に応答の発話をさせることができます。

また、現在 Amazon Echo には液晶付きのデバイスも存在しており、スキル側で応答の発話と一緒に画面に何かを表示させることも可能です。

表示内容は JSON ベースの Amazon Presentation Language (APL) という言語で記述します*3。この記述は AWS Lambda からのレスポンスとして直接渡すこともできるのですが、ファイルとして置いておいてレスポンスではそのファイルの URL だけを渡し、別途デバイス側でアクセスしてインポートさせることもできます。 後者の仕組みは APL Package と呼ばれており、Lambda から返すレスポンスのサイズを節約できるだけでなく (最近まで 24KB 以内に抑える必要があったため切実)、ファイルのキャッシュにより表示も高速化されるため、クックパッドでは積極的に利用しています。

DynamoDB は表記の通り、セッションを越えて永続化したいデータを保存しています。主にいくつかのパーソナライズされた体験を提供するために利用しています。

クックパッドスキルのデプロイ

全体像がわかったところで、 クックパッドスキルでのデプロイはどういうことをやっているのか。について紹介したいと思います。 クックパッドスキルでは大まかに、デプロイによって以下の画像に記載のものを更新しています。

デプロイで更新するリソース

簡単なところからいうと、 AWS Lambda のコードや、 Lambda 関数の設定をアップデートしています。ここでいう設定は、Lambda 関数のランタイムだったり、環境変数だったりを指しています。また、S3 に存在する APL のファイルもアップデートしています。 Alexa Skill の Interaction Model は、発話と Intent のマッピングファイルのことで、開発者はこれを記述することでユーザーのどういった発話をどういった Intent に変換するべきかを定義することができます。 Skill Manifest はやってきたリクエストをどこに送信するのかのエンドポイント (クックパッドスキルでは Lambda の ARN を指定しています) や、スキルストアに公開する紹介文、パーミッション関連などスキルに関する様々なデータになります。

クックパッドスキルのデプロイという行為は、 PR のマージ後に走る CI と、その後にチャットボットを利用したデプロイコマンドの実行により上に述べたリソースを最新のものにアップデートし、必要に応じて審査にサブミットすることを意味します。

ここまでの説明だけを見ると、登場するリソースも少ないですし、ただ CI とチャットボットがあるだけで仕組みも非常に簡単そうに見えますが、 Alexa 固有の仕様によって工夫が必要な部分もいくつかありますのでここで紹介をしていきます。

Lambda 関数の管理について

スキルストアに公開されている Alexa Skill は、1 つのスキルに 2 つの実体が存在します。Live バージョンと Development バージョンです。 Live バージョンは名前の通り一般公開され実際に使用されているバージョンのスキルです。 Development バージョンはこちらも名前の通り開発用に使用するスキルです。

Live バージョンは基本的にデータの一切の変更が不可能です。開発者は Development バージョンのスキルに必要な変更を加え、審査に出し、パスすることで変更を Live バージョンに反映することができます。

以下の図が Development バージョンのスキルを審査に提出した際のフローになります。それぞれのバージョンが持つメタデータやバージョンの変化に対するの理解を簡易にするためにこのような書き方をしておりますが、実際の挙動は明らかではありません。 すなわち、図では Development バージョンのスキルが審査を経て新しい Live バージョンになっていますが、ただ単に Development バージョンのスキルのデータで Live バージョンのスキルを上書きしていることもありえます。

スキルのライフサイクル上記の図から以下のことが言えます。

  • Development バージョンのスキルは、少なくとも審査提出時には本番環境の Lambda 関数をエンドポイントに設定する必要がある
  • Lambda 関数に互換性の無い変更をする場合、審査に提出する Development バージョンのスキルは Live バージョンと異なる Lambda 関数を利用する必要がある

後者が少し厄介です。もう少し詳しく説明すると、例えば新しい発話への対応や、インタラクションフローの変更をする場合、 Live バージョンで利用している Lambda 関数と互換性がないため、異なるエンドポイントにコードをデプロイをし、それを Development バージョンのエンドポイントとして設定し、審査に提出する必要があります。

こういった仕組みを開発者が簡単に使える形で実現するにはどうすれば良いでしょうか。クックパッドでは以下のことをやっています。

1. 開発用のスキルを別途用意する

Development バージョンのスキルは少なくとも審査提出時には本番環境の Lambda 関数をエンドポイントとして設定する必要があります。うっかり開発環境の Lambda を設定したまま提出すると酷いことになります。

スキルのエンドポイントに開発版 Lambda 関数をセットし審査に出した場合

このエンドポイントの切り替えを自動でよしなにやるようにしても良いのですが、とはいえ事故が怖いので基本的には審査提出前の最終動作確認以外では使わず、開発時には別の開発用スキルを利用するようにしています。

2. Lambda のバージョニングとエイリアスを活用する

これが一番重要です。上に述べている通り、互換性の無い変更を加えたい場合、審査に提出する Development バージョンのスキルの Lambda 関数は Live バージョンのものと別でなければならず、かつ審査後そのまま新たな Live バージョンになるため本番環境のものである必要があります。

互換性の無い Lambda 関数をエンドポイントに設定する場合

これを解決するにあたって、 Lambda 関数のバージョニングやエイリアスを作成する仕組みを利用します。バージョニングは、最新の Lambda 関数のコードや設定をもとにバージョンを発行し、独立した関数として使用できる機能です。エイリアスは、ある Lambda 関数のバージョンに紐づくポインタのようなものを作成出来る機能です。

これを利用することで、例えば互換性の無い変更をしたい場合には新しくエイリアスを発行し、新しいバージョンの関数と紐付け、それを審査に提出するスキルのエンドポイントとすることで安全に本番環境の Lambda 関数を利用できます。

このエイリアスは、コードを管理している GitHub リポジトリの tag から自動的に生成できるようにしています。tag は セマンティックバージョニングに倣ったルールで各 PR ごとに開発者が付与します。 エイリアスは、ここで付与された tag のメジャーバージョンをもとに必要に応じて作成します。 開発者は破壊的な変更をした時にのみメジャーバージョンを上げるだけで自動的に最適なエイリアスにコードがアップロードされるようになっています。

スキルのエンドポイントは、このエイリアスを参照させるようにすることで互換性の無い場合でも安全にそれぞれのコードを参照できるようになっています。

バージョニングとエイリアスを利用して互換性の無い変更を安全にデプロイする

Alexa Skill の管理について

クックパッドでは、展開している国や言語ごとにスキルを別で分けています。複数スキルがあると管理が大変なので、 1 つのスキルで複数リージョンへ展開することも選択肢としてはあったのですが、 1 つにした場合、スキルの審査は展開している全てのリージョンで行われ、その全てでパスをしないと公開できないようなので複数に分けています。 例えば、あるリージョンに向けただけの変更をしただけなのに、全く関係のない別のリージョンで審査に落ちて公開ができない。というのは避けたいですし、そもそも国や時期によって審査のスケジュール感もばらつきが大きいのでこのようにしています。

しかし、複数スキルがあると、 Skill Manifest や Interaction Model の管理が大変なので、リポジトリでファイルベースで管理し、 ask-cli という Alexa Skill 用の CLI ツールを利用して自動で更新出来るようにしています。

具体的には、 Alexa Skill には Skill Package という概念があり、これは Skill Manifest や Interaction Model などスキルを構成する要素をまるごと含んだものなのですが、 ask-cli にはこの単位でスキルをアップデートするコマンドがあるため、それを利用してスキルごとにまとめてアップデート出来るようにしています。

また、 ask-cli には少し前に CI 上からでも簡単に利用できるようになったため、 PR がマージされるごとに CI で Skill Package を更新するようにし、意識しなくても常にそれぞれのスキルが最新の情報に更新されるようにしています。 https://github.com/alexa/ask-cli/blob/develop/docs/concepts/CI-CD.md

ちなみに、スキルは細かく分けていますが、 Lambda のエンドポイントは AWS のリージョンごとにしか分けていないのでいくつかのスキルでは同じ関数を利用しているものもあります。今後展開する国が増えてきたらまた変わるかもしれませんが、少なくとも今はこのエイリアスやバージョニングのおかげで特に不便はしていません。

APL ファイルの管理について

最後は APL ファイルの管理についてです。上で述べたとおり、 APL は URL から別の APL ファイルを取得し、それを利用する仕組み (APL Package) を備えています。クックパッドでも APL Package を利用していくつかのファイルは Amazon S3 上に保管するようにしています。

基本的には、ただ S3 上に APL のファイルを置いておき、その URL を渡せば良いだけなのですが、少しだけ厄介な部分があります。

公式でも説明されているのですが、 APL Package で取得されたファイルは強くキャッシュされるため、ただ新しくファイルを更新してもそれが反映されるまで大変長い時間がかかります。

これを回避する方法はいくつかあるんですが、クックパッドではリージョンや Lambda のバージョンごとにディレクトリを分けるようにし、デプロイする度に新しい APL ファイルを参照させるようにしています。 AWS Lambda は AWS_LAMBDA_FUNCTION_VERSION という環境変数で関数のバージョンを取得することが出来るため、これをもとに APL ファイルの URL を動的に生成させることで、自動的に適切な APL ファイルを指す URL を渡すことができるようになっています。

この仕組みのもう一つのメリットとしては、デプロイした後に問題が発覚して切り戻したい場合でも、 Lambda のバージョンを戻すだけで自動的に APL ファイルも一つ前のバージョンに戻せることです。キャッシュも気にする必要がありません。

リージョンを分けている理由としては、上に述べた通り AWS Lambda はリージョンごとに用意しているため、それに合わせるためです。 言い換えると、 Lambda 関数ごとにディレクトリを用意していて、その中でさらにバージョンごとにディレクトリを用意し、そこに APL ファイルをアップロードしている。という運用になります。

S3 にアップロードする APL ファイルは、リポジトリで全て管理をしており、デプロイのタイミングで AWS S3 にアップロードするようにしています。

まとめ

今回は Alexa 向けのサービスデプロイに関わる色々な仕組みや工夫について紹介をさせていただきました。

今回は紹介しきれませんでしたが、昔と比べるとかなり整備はされてきているものの、まだまだ管理や運用が難しい部分も多く、かつ展開するリージョンが多くなるほど大変になるものもあるため、これらを省力化できるようにすることは非常に意義のあることだと思っています。

まだまだ複数リージョンに展開をしているスキルの数は少なく、言い換えると事例や実践的な情報も少ないため、ここで紹介した内容が皆さまの日々の開発に役立てばと思います。

弊社ではこのように色々な技術スタックを持ったエンジニアが数多く在籍しております。絶賛エンジニア募集しておりますのでご興味ありましたらぜひこちらのサイトをご覧ください。

info.cookpad.com

*1:2021 年 12 月 31 日時点/iOS,Androidアプリ 1日あたりのアクティブユーザー数(2021年10月〜12月 App Annie)

*2:こちらに作成したデモの紹介記事があります https://techlife.cookpad.com/entry/2020/10/26/090000

*3:今回のテーマとは少し異なりますが、クックパッドにおける APL の tips はこちらの記事で他にも色々と紹介しているのでぜひ合わせてご覧ください。 https://techlife.cookpad.com/entry/2021/05/28/110000

クックパッドに転職して感じたこと

$
0
0

こんにちは。 レシピサービス開発部でエンジニアを担当しているnaraです。
クックパッドには2022年1月に転職し、早くも3ヶ月が過ぎました。
まだまだ分からないことが多いですが、日々楽しく開発に携わっています。

今回は転職間もない自分が、クックパッドで"実際に働いてみて感じたこと"を紹介したいと思います。
本エントリーが、クックパッドに興味を持っている方の参考になれば幸いです!

働き方が柔軟

突然ですが、私は現在イギリスの大学でComputer Scienceを専攻しています。
日中は仕事を行い、仕事が終われば学業に専念するといったハードな生活を送っており
これらを両立させるには、時間を上手くコントロールする必要があります。

特に、試験期間中は学業に専念したくなるのですが、クックパッドではフルフレックスで働けるので、 業務時間を柔軟に調整できています。
実際、前期の試験期間中は"稼働を減らして、試験に専念する"といった選択を取ることができ、 試験を無理なく乗り越えられました。

このように、柔軟に働き方を調整できるため、自分のような社会人学生も
働きやすい環境だと実感しています。

あらゆる情報がオープン

クックパッドでは、あらゆる情報がオープンに発信されています。
前職では、コミュニケーションが部内に閉じられており、他部署の情報が入ってくることがありませんでした。
そのため、ほぼ全ての情報が公開され、部署関係なく自由に意見が交換されている
クックパッドの文化に最初は驚きました。
現在は、前職のように組織の壁を感じることがほとんどなく、部署間の連携もスムーズに行えていると感じます。

また、コミュニケーションだけでなく、データもオープンにされており
全ての社員がDWHにアクセスして、分析SQLを発行できます。
SQLの実行結果を共有する仕組みも存在しており、デザイナーが分析SQLを共有しているのを見たときは驚愕しました....

ユーザとの距離がとても近い

クックパッドで働いていると、社員同士だけでなくユーザーとの距離も近いと感じます。
特にそう感じる理由として、ユーザーインタビューがほぼ毎週のように実施されており
エンジニア・デザイナー問わずユーザーと触れ合う機会が多いためです。

前職でもユーザーへのインタビューは実施されていましたが、年に数回行われるレベルだったため
仮説を定性的に評価するのが難しいと感じた経験がありました。

この点、クックパッドではユーザーインタビューの仕組みが整っているため
仮説を定性的にも評価した上で、サービス開発を行えています。

ユーザーインタビューに関する取り組みは、以下の記事を読むと面白いと思います。
note.com

業務の域を超えて仕事ができる

個人的に入社して最も驚いたのは、業務の域に制限がないことです。
前職では、部署/職種単位で細かく役割が分かれており、基本的にトップダウンで与えられた役割に従って個々が動いていました。

しかし、クックパッドでは「何をやって欲しいか?」より「何をやりたいか?」が問われ
個人の挑戦したいことをベースに、個々が全体の目標に向かって動いています。

そのため、業務の域を超えて仕事に携わることができ、 実際に自分もエンジニアとしての機能設計・開発に留まらず、 企画やデータ分析まで自分の得意分野を活かしながら、幅広く業務に携われています。

また、エンジニアとして業務の幅も広く
モバイルエンジニアでありながらサーバーサイドの開発に携わったり
複数のサービスの開発に携わって活躍しているメンバーもいます。
(個人的に、一つの領域のみやっているエンジニアの方が少ないのでは?と思っています)

もちろん「複数の業務に携わる」 = 「仕事の範囲が増える」ので、
その分タフネスが要求されますが、主体的にチャレンジするのが好きな人にとって
やりがいのある職場だと感じています。

助け合いの文化である

前述のように、何でもチャレンジできる職場ではあるのですが
クックパッドのシステムは歴史があり、アーキテクチャも複雑です。
また、レシピサービスは複数のマイクロサービスのもと稼働しており
一つの機能を開発するにも、様々なアプリケーションに手を入れる必要性が出てきます。

実際にエンジニアとして開発に携わってみて、設計や実装の難しさを日々感じています。

しかし、クックパッドではSlackで雑に助けを求めると、誰かがすぐに助けてくれたり
チーム外の人がPull Requestのレビューに参加して、アドバイスをくれたりします。
また、ペアプロにも気軽に応じて貰えます。

個人的にはこの助け合いの文化のおかげで、経験のない言語で書かれたアプリケーションでも、 率先して開発にチャレンジする事ができています。

まとめ

本エントリーでは、クックパッドで"実際に働いて感じたこと"を書いてみました。
これまで自分が経験してきた職場と異なり、クックパッドのエンジニアは裁量が大きく
何でもチャレンジできる反面、主体性が求められていると日々感じます。

また、エンジニアとして課題にぶち当たることも多いですが、 職種やチームを超えて助けあう文化があるので、楽しく開発にチャレンジできています!

最後に

クックパッドではエンジニアを絶賛募集しています。
主体的にサービス開発を楽しみたい方には、活躍できる場がたくさんあります!

ぜひ、お気軽にご応募ください!
info.cookpad.com

Viewing all 802 articles
Browse latest View live