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

キャッシュによるRubyの正規表現のマッチングの高速化の紹介

$
0
0

9月からRuby開発チームにインターンシップとして参加している@makenowjustです。 総合研究大学院大学の学生で、普段は情報セキュリティに関する研究をしています。

インターンシップでは、キャッシュ (メモ化) を利用したRubyの正規表現の高速化を行いました。 ReDoSと呼ばれる、バックトラックが爆発することでマッチング時間が膨大になる脆弱性があります (ReDoSについては、拙作ですがWEB+DB PRESSに掲載された記事があります)。 近年、ReDoSは多く報告されており、Rubyもその例外ではありません (参考1参考2)。 今回実装した最適化は、ReDoSを防ぐことを目的としたもので、多くの正規表現のマッチング時間が文字列の長さに対して線形となります。

ReDoSが起こる正規表現の例として、/^(a|a)*$/が挙げられます。 今回の修正の前後での実行時間を比較すると、次のようになります。

$ ruby --version
ruby 3.2.0preview2 (2022-09-09 master 35cfc9a3bb) [arm64-darwin21]

$ time ruby -e '/^(a|a)*$/ =~ "a" * 28 + "b"'
ruby -e '/^(a|a)*$/ =~ "a" * 28 + "b"'  8.49s user 0.04s system 98% cpu 8.663 total

$ ./miniruby --version
ruby 3.2.0dev (2022-10-27T13:39:56Z recache bc59b7cc1e) [arm64-darwin21]

$ time ./miniruby -e '/^(a|a)*$/ =~ "a" * 28 + "b"'
./miniruby -e '/^(a|a)*$/ =~ "a" * 28 + "b"'  0.01s user 0.01s system 8% cpu 0.310 total

修正前は実行に8秒かかっていた処理が、修正後は0.01秒で終了しており、800倍高速になっていることが分かります。

この最適化を含むパッチは既にマージされ、Ruby 3.2.0 Preview 3に含まれています。 そのため、すぐに手元で試すことができます。

本記事では、今回実装した最適化の実装や注意点について解説をしたいと思います。

正規表現とNFA

情報科学において、正規表現はNFA (非決定性有限状態オートマトン)と等価な表現力を持っていることが知られています。 そして、Rubyなどの正規表現マッチングは、バックトラックを用いてNFAの動作を模倣することで実装されています。

例えば、正規表現 /(a|a)*/に対応するNFAは次の図のようになります。

/(a|a)*/ に対応するNFA

このNFAは状態q_1が初期状態かつ受理状態で、文字aで自身に戻ってくる遷移が2つあります。 そのため、aaaaabのような文字 aのあとに a以外の文字に対してマッチングを行うと、バックトラックによって状態q_1に到達する回数がaの個数の指数回となります。 このようにしてバックトラック回数が爆発することが、ReDoSの原因となっています。

このことをバックトラックによるマッチングの実装を通して確かめてみましょう。 実装は次のようになります。

# nfa.rb# デバッグ出力を有効にするかどうか$debug = false# NFA (非決定性有限状態オートマトン) を表現するクラス。classNFA# @param [Symbol] init 初期状態# @param [Array] accepts 受理状態# @param [Hash] transition 遷移関数definitialize(init, accepts, transition)
    @init = init
    @accepts = accepts
    @transition = transition
  endattr_reader:init, :accepts, :transition# バックトラックを用いてNFAのマッチングを行う。## @param [String] input 入力文字列# @param [Symbol] state 現在の状態# @param [Integer] n 何文字目かdefrun_backtrack(input, state = init, n = 0)
    if$debug
      puts "run_backtrack(#{input.inspect}, #{state.inspect}, #{n.inspect})"endif input.size == n
      return accepts.include?(state)
    end

    c = input[n]
    (transition.dig(state, c) || []).each do |next_state|
      if run_backtrack(input, next_state, n + 1)
        returntrueendendfalseendend

この実装でマッチングを行ってみます。

irb(main):001:0> require_relative './nfa.rb'
=> true
irb(main):002:0> # 正規表現 `(a|a)*` に対応するNFA
irb(main):003:0> nfa = NFA.new(
irb(main):004:1*   :q1,
irb(main):005:1*   [:q1],
irb(main):006:1*   {
irb(main):007:2*     q1: { 'a' => %i[q1 q1] }
irb(main):008:2>   }
irb(main):009:1> )
=> #<NFA:0x00000001520cac08 @init=:q1, @accepts=[:q1], @transition={:q1=>{"a"=>[:q1, :q1]}}>
irb(main):010:0> $debug = true
=> true
irb(main):011:0> nfa.run_backtrack("a" * 2 + "b")
run_backtrack("aab", :q1, 0)
run_backtrack("aab", :q1, 1)
run_backtrack("aab", :q1, 2)
run_backtrack("aab", :q1, 2)
run_backtrack("aab", :q1, 1)
run_backtrack("aab", :q1, 2)
run_backtrack("aab", :q1, 2)
=> false
irb(main):012:0> nfa.run_backtrack("a" * 3 + "b")
run_backtrack("aaab", :q1, 0)
run_backtrack("aaab", :q1, 1)
run_backtrack("aaab", :q1, 2)
run_backtrack("aaab", :q1, 3)
run_backtrack("aaab", :q1, 3)
run_backtrack("aaab", :q1, 2)
run_backtrack("aaab", :q1, 3)
run_backtrack("aaab", :q1, 3)
run_backtrack("aaab", :q1, 1)
run_backtrack("aaab", :q1, 2)
run_backtrack("aaab", :q1, 3)
run_backtrack("aaab", :q1, 3)
run_backtrack("aaab", :q1, 2)
run_backtrack("aaab", :q1, 3)
run_backtrack("aaab", :q1, 3)
=> false

このように、"a" * 2 + "b"に対するマッチでは出力が7行、"a" * 3 + "b"では15行と、倍々に増えていきます。 "a" * 30 + "b"のような入力にすると、延々と出力が続いて終了しなくなるので注意してください。

また、バックトラックによって同じ状態の同じ位置に何度も訪れていることにも注目してください。 バックトラックの爆発が起こる場合、このような現象が見られます。

キャッシュ (メモ化) の導入

先程の実装で、バックトラックの爆発が起こる際には、同じ状態で同じ位置に何度も訪れていることが分かりました。 しかし、同じ状態で同じ位置に到達した場合のNFAの動作は全く同じになるので、すでにマッチに失敗すると分かっていれば、わざわざ実行する必要はありません。 そこで、状態と位置をキーにして、そこに到達したかどうかを記録しておくことで、余計なバックトラックを防ぐことができます。 これが、キャッシュ (メモ化) による正規表現マッチングの最適化の原理となります。

キャッシュによる最適化の実装は次のようになります。

# cache.rbclassNFA# キャッシュによって最適化されたNFAのマッチングを行う。## @param [String] input 入力文字列# @param [Symbol] state 現在の状態# @param [Integer] n 何文字目か# @param [Hash] cache キャッシュdefrun_cached(input, state = init, n = 0, cache = {})
    # ここでキャッシュの確認と追加を行う。returnfalseif cache[[state, n]]
    cache[[state, n]] = trueif$debug
      puts "run_cached(#{input.inspect}, #{state.inspect}, #{n.inspect})"endif input.size == n
      return accepts.include?(state)
    end

    c = input[n]
    (transition.dig(state, c) || []).each do |next_state|
      if run_cached(input, next_state, n + 1, cache)
        returntrueendendfalseendend

この実装を試してみます。 (先程のirbセッションの続きになっています)

irb(main):013:0> require_relative "./cache.rb"
=> true
irb(main):014:0> nfa.run_cached("a" * 2 + "b")
run_cached("aab", :q1, 0)
run_cached("aab", :q1, 1)
run_cached("aab", :q1, 2)
=> false
irb(main):015:0> nfa.run_cached("a" * 3 + "b")
run_cached("aaab", :q1, 0)
run_cached("aaab", :q1, 1)
run_cached("aaab", :q1, 2)
run_cached("aaab", :q1, 3)
=> false

今度は、"a" * 2 + "b"で3行、"a" * 3 + "b"で4行と、1行しか増えていません。 さらに"a"の個数を増やしても、4個だと5行、5個だと6行と、1行ずつしか増えていかず、先程のような指数的な爆発が起こらないことが確認できます。

たった2行の追加で、ここまで劇的に高速化できるのは驚きなのではないでしょうか?

実際の実装

実際のRubyの正規表現実装 (Onigmo) への修正は、次のPull Requestで行いました。

Implement cache optimization for regexp matching by makenowjust · Pull Request #6486 · ruby/ruby · GitHub

また、次のチケットで進捗を管理しています。

Feature #19104: Introduce the cache-based optimization for Regexp matching - Ruby master - Ruby Issue Tracking System

OnigmoはVM型の正規表現実装のため、状態と位置の組でキャッシュするのではなく、命令コードと位置でキャッシュを行います。

Pull Requestを見ると分かりますが、差分は800行弱と、想像よりも少ない差分になっています。 このように、キャッシュによる最適化は、元のコードを大きく変更することなく、正規表現のマッチングを高速化できることが、利点の一つだと考えています。

実装の工夫として、すべての命令コードに対してキャッシュをするのではなく、分岐の命令コードでのみキャッシュをするようにしています。 複数回訪れることがあるのは分岐の合流地点なので、本当はそちらでキャッシュした方がいいのですが、合流地点を求めるには分岐命令のジャンプ先などを全て記録する必要があり大変なため、現在は分岐命令でキャッシュを行っています。

これらの工夫や、キャッシュによる最適化というアイディアは、次の論文を参考にしました。

Davis, James C., Francisco Servant, and Dongyoon Lee. "Using selective memoization to defeat regular expression denial of service (ReDoS)." 2021 IEEE symposium on security and privacy (SP). IEEE, 2021. https://www.computer.org/csdl/proceedings-article/sp/2021/893400a543/1oak988ThvO

キャッシュ (メモ化) の注意点

今回のキャッシュ (メモ化) による最適化によって、マッチングの時間が線形になることが保証できました。 しかし、この実装にはいくつか注意するべき点があるので、それらについて説明します。

いくつかの正規表現は最適化できない

実装を見て気付いた方もいるかもしれませんが、キャッシュによる最適化はいくつかの正規表現の拡張や場合に対応していません。 具体的には、次のときに最適化が無効になるか、エラーになります。

  1. 後方参照 \1、部分式の呼び出し \g<foo>、先読みや後読み (?<=...)、アトミックグループ (?>)、非包含オペレータ (?~...)を使っている正規表現の場合
  2. 回数指定の繰り返しが繰り返しの中にある場合 (例 (fo{2})+)

2の場合に最適化できないのは完全に実装の都合なのですが、Onigmoの実装に多くの修正が必要になり、パッチの妥当性の検証が困難になることが予想されるため、実装を見送りました。

メモリを多く使う

キャッシュにはビット配列を用いているので、((キャッシュを行う命令コードの数) * (文字列の長さ)) / 8バイト程度のメモリをマッチの際に追加で使用します。 そのため、極端に大きな文字列でマッチングを行なう場合や、極端にたくさんの分岐のある正規表現を利用している場合に、メモリ使用量が急に増加して、問題になるかもしれません。 とくに、大きな回数指定の繰り返しを使っている場合は、その繰り返し回数だけ分岐の数が増えることになるので、問題になりやすいです。

ただし、(現在の実装では) バックトラックの回数が文字列の長さを越えた場合にのみ、キャッシュによる最適化が有効になります。 キャッシュ用のメモリの確保もこのときに行うため、元々の正規表現に大きな問題が無ければ、メモリ使用量で問題になることも少ないのではないかと考えています。

また、チケットの方にも書いた通り、実際に使われている正規表現でも、分岐の数は多くても80個ほどで、文字列の長さに対して10倍メモリが使用される程度に収まることを確認しています。

まとめ

この記事では、Rubyに新しく導入される、キャッシュによる正規表現のマッチングの最適化について紹介しました。 最後に、キャッシュによる最適化の特徴や注意点を整理します。

  • この最適化によって、正規表現のマッチングの時間が、文字列の長さに比例する程度に抑えられるようになる。
  • バックトラックの回数が文字列の長さを越えたときに、最適化が有効になる。
  • 一部の正規表現の拡張を使っている場合や、回数指定の繰り返しがネストしている場合に最適化が有効にならないことがある。
  • マッチングに使うメモリの量がこれまでよりも増える可能性がある。

この最適化がReDoSを根絶することができるわけではありませんが、多くの場合に有効な対策となっていることを期待しています。


Cookpad TechConf 2022を開催しました

$
0
0

こんにちは、クックパッドCTO室の緑川です。

先月の11月25日、約3年ぶりにクックパッドのエンジニアとデザイナーの 1 年の集大成となるCookpad TechConfを横浜の地で開催しました。当日は晴天にも恵まれ、多くの方にご来場いただけました。ご参加いただいた皆さま誠にありがとうございました。

この記事では、Cookpad TechConf 2022の開催レポートをお送りします。

Cookpad TechConf 2022 会場の様子

Cookpad TechConf 2022について

Cookpad TechConfは、クックパッドの「毎日の料理を楽しみにする」というミッションや「料理」という事業ドメイン特有のおもしろさ、そこに立ち向かうエンジニアやデザイナーのチャレンジ、またその過程で得た技術的知見やカルチャーについて公開するカンファレンスです。2016年から開催をしており、今回で5回目の開催となります。(2022年、2021年は未開催)

techconf.cookpad.com

当日のスライド資料や登壇動画は下記(またはイベント告知サイト)からご覧いただけます。クックパッドの取り組みや課題解決についてご興味がある方はぜひご覧ください。

Cookpad TechConf 2022 開催中の様子

基調講演「失敗を活かす組織」

登壇者:成田 一生, 米田 哲丈

www.youtube.com

speakerdeck.com

レシピサービスにおける持続的なプロダクト開発プロセスについて

登壇者:大石 英介 & 島 朋代

www.youtube.com

speakerdeck.com

巨大なレシピサービスのアーキテクチャを最高にしたい

登壇者:宮崎 広夢

www.youtube.com

speakerdeck.com

デザインシステムを使ってプロダクトのデザイン負債を解消する

登壇者:村山 賢太 & 見上 香桜里

www.youtube.com

speakerdeck.com

失敗から学ぶクックパッドマートにおけるプロダクト開発の成功の確度を上げるパターン

登壇者:勝間 亮 & 米田 哲丈

www.youtube.com

speakerdeck.com

生鮮食品をユーザーに届ける流通の仕組みと技術

登壇者:長 俊祐

www.youtube.com

speakerdeck.com

新規事業クックパッドマートを支える機械学習の技術

登壇者:深澤 祐援 & 山口 泰弘

www.youtube.com

speakerdeck.com

クックパッドが挑戦する「レシピ」と「かいもの」をつなぐ新しいサービス作り ~ 役割にとらわれず新しい価値に向き合い続けるチーム ~

登壇者:谷口 浩司 & 新妻 里夏

www.youtube.com

speakerdeck.com

Go Global Search 2

登壇者:Orgil Davaajargal

www.youtube.com

speakerdeck.com

LT大会

www.youtube.com

Cookpad TechConf 2022のノベルティ

Cookpad TechConf 2022では次のようなノベルティを用意しました。

  • サコッシュ
  • 味付け一覧表
  • キッチンタオル
  • 除菌ウェットティッシュ
  • レギュラーコーヒー(ドリップバッグ入り)

Cookpad TechConf 2022のノベルティ

名札とスタッフティシャツを含めて、全てのグッズを社内のデザイナーがデザインをしています。サコッシュは実は社名が入っていないため「日常でも使用している」との声や、「味付け一覧表やレギュラーコーヒーがノベルティなのはクックパッドらしい」との声をいただきました。

Cookpad TechConf 2022の10のチャレンジ

約3年ぶりの開催に当たって、Cookpad TechConf 2022の運営チームも従来どおりに開催するのではなく、登壇者たちに負けじと10の新しい企画にチャレンジしました!

1.Cookpad TechConfのリブランディング

過去のCookpad TechConfはリスのイラストを前面に出したデザインで統一していました。しかし、初の横浜・みなとみらい開催ということもあり、フレッシュな再スタートを切るために、もう一度コンセプトから見直して大きなリブランディングを行いました。今回のキービジュアルとなった×のロゴは、挑戦と成功には必ず付きまとう失敗と、料理の課題に対してエンジニアとデザイナーが力を掛け合わせて解決していくという思いを表しています。

リブランディングしたCookpad TechConfのロゴ

2.みなとみらいでの物理開催

今回は、クックパッドが横浜・みなとみらいに移転してから初めての物理開催でした。そして、その開催に当たって横浜・みなとみらいの地を参加者の方にぜひ知ってもらいたいという思いがあり、海の見えるパシフィコ横浜の施設の中でも、特に新しくてキレイな、換気システムも整っているノース棟を会場に選びました。

パシフィコ横浜ノースからの風景

3.完全招待制

コロナ禍での開催ということもあり、今回は完全招待制としました。クックパッドの社員一人一人が友人・知人を招待をしていたこともあり、会場の廊下を中心に物理開催のカンファレンスならではの団欒の輪があちらこちらで生まれていました。

4.野毛の懇親会

イベント会場の施設内に一日ずっと籠もっているのではなく、この横浜の地をより深く知ってもらいたいと考え、懇親会では横浜を代表する呑み屋街『野毛』に繰り出しました。イベント後、参加希望者を懇親会会場へ送迎バスでご案内し、スタッフも含めて多くの方と交流させていただきました。

5. 名札のバリアブル印刷

参加者の皆さまや社員には会期中に交流がしやすいように、TwitterまたはGithubのアイコン、ユーザー名入りの名札をお配りしました。特にアイコンの視認性は非常に高く、後述するCookpad TechConf 2022のDiscordサーバー、あるいはTwitterやGithubで見たことがある人がわかり、物理会場なので話せるというメリットが生まれました。また、完全招待制の演出にも一役買い、通常よりも少し手間もコストもかかりましたが社内でも好評でした。

Cookpad TechConf 2022の名札

6.ペア登壇

Cookpad TechConfは一般的な技術カンファレンスと違い、エンジニアとデザイナーという二つの要素を混ぜ合わせることで、クックパッドが大切にしているBeyond the Boundary(境界を越える)を体現しています。今回のセッションでは、さまざまな職種の組み合わせのペアが登壇しました。そして、司会者や基調講演もペアで行っています。その結果、それぞれの視点から生まれた知見を発表することができました。

7.ライブセッション

物理会場ならではのイベントとして、今回は以下3つのライブセッションと、ゲリラ的に発生した『Recipe Service Engineering Handbook』を読む会を実施しました。

  • ヒヤリングからはじめる特定の1人のためだけの料理サービスデザインデモ
  • Hako アプリの一生
  • Webフロントエンドパフォーマンスチューニングデモ ─バンドルサイズ削減編─

当日は実装者と解説者に別れ、皆さまの応援や質問に応えつつエンジニアやデザイナーがそれぞれの能力を発揮し、45分間で制作した成果物を披露しました。

ライブセッションの様子

8.グラフィックレコーディング

登壇内容の概要がパッと見て楽しく伝わることでイベントに興味をもってもらえるよう、デザイナーによるグラフィックレコーディングを実施しました。Cookpad TechConfはさまざまな職種の社員が登壇するので、どうしても専門知識の領域が散らばってしまうのですが、グラフィックレコーディングならポイントだけを抑えて伝えることができます。また、SNS やDiscordサーバーでも拡散しやすく、たくさんの方にイベントへ興味を持っていただくきっかけとなりました。

グラフィックレコーディングの一例

9.Discordサーバーの運営

Cookpad TechConf 2022 開催日の前後数週間、参加者の皆さまと交流できる場としてDiscordサーバーを用意しました。Discordサーバーでは開催当日の実況チャンネルはもちろんのこと、セッション毎の質疑応答、技術やデザインに関する議論や雑談、その他ゲーム・音楽・料理といった趣味のチャンネルも大いに盛り上がりました。また、誰でも参加可能なイベントや勉強会なども数多く実施しました。はじめはうまくいくか心配でしたが、普段のクックパッドの社内Slackの楽しい様子、活発な様子をお届けできたかと思います。なお、今回、Discordサーバーを参加者の皆さんに公開する前に、実は一週間だけ社内向けに先行公開し、ワイワイする暖機運転期間を設けていました。これによって参加者が集まった公開開始時から、Discordサーバーをうまく盛り上げられたように思います。

10.TechMTGの外部公開

かれこれ十年以上続く、クックパッドの全エンジニアが隔週で集まる『TechMTG』をクックパッド史上初めて外部公開しました。TechMTGでは投稿された技術ブログの解説、社内であったいい話をみんなにシェアするいいねトピックの紹介、さらには技術勉強会などを行っています。こちらもDiscordサーバーで行うことで実況タイムラインが盛り上がり、社外の方にも楽しんでいただけているようでした。

TechMTGの様子

アンケートの回答に関して

大変嬉しいことに、アンケートに答えていただいた方の78%の方からCookpad TechConf 2022に参加して非常によかった、残り22%の方から参加してよかったという回答をいただきました。また「クックパッドの雰囲気がよくわかった」「久しぶりの物理カンファレンスで活気があった」といった感想もいただいています。

また、電源の供給がなかったことや懇親会会場への移動時の換気対策など、皆さまにご不便をおかけしてしまったこと、改善すべき点もご指摘いただいております。

終わりに

改めてCookpad TechConf 2022にご参加いただいた皆さまありがとうございました。

皆さまからいただいたご感想やアンケートを基に更なる改善や新しいチャレンジをしながら、来年度もCookpad TechConfを開催したいと考えております。是非楽しみにしていてください!

クックパッドでは、このような組織カルチャーに共感するエンジニアやデザイナーの方を募集しています。ご興味のある方は、ぜひ採用サイトを覗いてみてください。また、今回のCookpad TechConf 2022で気になるトークがある方は、クックパッド採用へ DM をいただければ、カジュアル面談もできますので、ぜひどうぞ!

cookpad.careers

プロと読み解く Ruby 3.2 NEWS

$
0
0

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

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

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

Ruby 3.2 は、新しい機能をたくさん盛り込み、また非推奨の機能を削除するなどアグレッシブなバージョンです(ただ、往年の Ruby をご存じの方にとっては変更が足りず物足りないかもしれません)。性能改善もたくさん行われています。ぜひ使ってみてください。

ちなみに、Ruby 2.7はあと4ヶ月くらいでEOL(サポート終了)で、なんと Ruby 2.x がもうすぐ終わってしまいます。移行の準備はいかがですか?

Ruby 3.2 の目玉として、次のようなものがあげられています(リリースノートから抜粋)。

  • WASIベースのWebAssemblyサポート
  • 実用段階になったYJIT
  • ReDoSに対するRegexpの改善
  • SyntaxSuggest の導入

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

■言語の変更

メソッド引数の委譲に ***無名引数が使えるようになった

  • Anonymous rest and keyword rest arguments can now be passed as arguments, instead of just used in method parameters. [Feature #18351]

      def foo(*)
        bar(*)
      end
      def baz(**)
        quux(**)
      end
    

Rubyでメソッドに受けた引数を別のメソッドに渡す方法として、無名の***が利用できるようになりました。

# ポジショナル引数だけ委譲したいdeffoo(*)
  bar(*)
end# これと同じdeffoo(*args)
  bar(*args)
end# キーワード引数だけ委譲したいdefbaz(**)
  quux(**)
end# これと同じdefbaz(**kw)
  quux(**kw)
end

似たような話として、次のような機能がありました。

  • Ruby 2.7 で導入された ...記法(def f(...); g(...); end
    • Ruby 3.0 で引数が加えられるように拡張された(def f(...) = g(:first, ...); end
  • Ruby 3.1 で導入されたブロックを委譲するための記法 def foo(&) = bar(&)

今回の拡張は、これらの「無名引数」シリーズの延長かと思います。今回の拡張で委譲する引数にちょっと足して渡す、みたいなのがやりやすくなったんではないかと思います。

deff(*, **)
  g(:first, *, add_key: 1, **)
end# これと同じdeff(*args, **kw)
  g(:first, *args, add_key: 1, **kw)
end

とくに、キーワード引数ですかねぇ。余談ですが、上記例で渡された引数に add_keyキーワードが含まれていた場合、kwのほうが優先されるためadd_key: 1g()にわたりません。渡されたキーワード引数が優先される場合はこれで問題ないのですが、絶対に add_key: 1を渡したい場合は**よりも後ろに書く必要があります。

deff1(*, **)
  p(*, add_key: 1, **)
enddeff2(*, **)
  p(*, **, add_key: 1)
end

f1(add_key:10) #=> {:add_key=>10}
f2(add_key:10) #=> {:add_key=>1}

用途に応じて気を付けてください。

引数に名前を付けておけば、これまでと全く変わりません。無名にすることで名前を考える苦労がちょっと減るのですが、乱用するとわかりづらいコードになるので、容量用法にお気を付けください。

(ko1)

procの引数のコーナーケース挙動が少し変更

  • A proc that accepts a single positional argument and keywords will no longer autosplat. [Bug #18633]

引数を複数受け取るProcに、配列を1つ渡して呼び出すと、配列が勝手に分解されます。

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

しかし引数を1つだけ受け取るProcの場合は、分解されません。

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

ここで、引数を1つ受け取りつつ、キーワード引数も受け取るProcの挙動はどうあるべきでしょうか。

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

Ruby 3.1では分解される(つまり 1が返る)という挙動でしたが、Ruby 3.2からは分解されない(つまり [1, 2]が返る)ということになりました。

Procの引数はとてもむずかしいですね。

(mame)

定数の評価順序が「左から右」の原則を守るようになった

  • Constant assignment evaluation order for constants set on explicit objects has been made consistent with single attribute assignment evaluation order. With this code:

      foo::BAR = baz
    

    foo is now called before baz. Similarly, for multiple assignments to constants, left-to-right evaluation order is used. With this code:

      foo1::BAR1, foo2::BAR2 = baz1, baz2
    

    The following evaluation order is now used:

    1. foo1
    2. foo2
    3. baz1
    4. baz2

    [Bug #15928]

定数は、expr::FOOのように記述できるのはご存じでしょうか。exprにはたいていの Ruby 式を記述することができます。

classC; classD; end; end
d = (p C)::D#=> C (p C の出力)
p d          #=> C::D

さらに、この記法は多重代入の左辺に使うことができます。そうすると Ruby 3.1 で修正されたコード順の問題が出てきます(参考: 「多重代入の評価順序が変更された」プロと読み解く Ruby 3.1 NEWS - クックパッド開発者ブログ)。

NEWS にあるコード例を、実際に動くコードにしてみます。

classFOO; enddeffoo1 = (p(:foo1); FOO)
deffoo2 = (p(:foo2); FOO)
defbaz1 = p(:baz1)
defbaz2 = p(:baz2)

foo1::BAR1, foo2::BAR2 = baz1, baz2

これを Ruby 3.1 で実行してみると、

:baz1
:baz2
:foo1
:foo2

となります。つまり、右辺の2式が実行されたあと、左辺の2式が実行されています。そこで、Ruby 3.1 で多重代入の評価順序を修正した Jeremy Evans によって、ただしく「左から右」と評価されるように修正されました。

:foo1
:foo2
:baz1
:baz2

いやぁ、良かったよかった。ただ、これバイトコードだいぶ冗長にして若干遅くなるんですよね...。できれば、皆様にはこういう難しいコード(多重代入)は書かないでいただけると助かります。

ちなみに、すごいどうでもいいんですが、この多重代入が書けるようになったのは Ruby 1.8 以降なんですね。

(ko1)

Findパターンが実用段階

Findパターンがパターンマッチで利用可能になりました。正確に言うとRuby 3.1でも実験的機能として導入されていましたが、今回から正式な機能となります。違いとしては、使っても警告が出ません。

ary = [1, 2, 3, 4, 5]

# Find パターンif ary in [*, 3, *]
  p "3を含んでいる"endif ary in [*, 3, x, *]
  p "3の後にある値は#{ x }"end

上の例のように、最初にマッチした値の次の値、などを取りたい時に便利かもしれません。

(mame)

ruby2_keywordsが厳格に要求される

  • Methods taking a rest parameter (like *args) and wishing to delegate keyword arguments through foo(*args) must now be marked with ruby2_keywords (if not already the case). In other words, all methods wishing to delegate keyword arguments through *args must now be marked with ruby2_keywords, with no exception. This will make it easier to transition to other ways of delegation once a library can require Ruby 3+. Previously, the ruby2_keywords flag was kept if the receiving method took *args, but this was a bug and an inconsistency. A good technique to find the potentially-missing ruby2_keywords is to run the test suite, for where it fails find the last method which must receive keyword arguments, use puts nil, caller, nil there, and check each method/block on the call chain which must delegate keywords is correctly marked as ruby2_keywords. [Bug #18625] [Bug #16466]

Ruby 3.0で導入されたruby2_keywordsがより厳格に要求されるようになりました。

ruby2_keywordsは、メソッドがキーワード引数を可変長引数の一部として受け取るようにする注釈です。その引数を更に他のメソッドに渡すとき、キーワード引数だったものとして渡されます。Ruby 2のときの委譲を再現するために使われます。

# fooは普通の引数もキーワード引数もまとめて可変長引数として受け取る
ruby2_keywords deffoo(*args)
  # 受け取った可変長引数をtargetに委譲する
  target(*args)
enddeftarget(k:)
  k
end

foo(k: 1) #=> 1

今回の変更は、次のような多段の委譲(foo→bar→target)のときの話です。

ruby2_keywords deffoo(*args)
  bar(*args)
end# Ruby 3.2からはここにもruby2_keywordsを書く必要があるdefbar(*args)
  target(*args)
enddeftarget(k:)
  k
end

foo(k: 1)

fooruby2_keywordsが指定されていますが、barには指定されていないことに注意してください。 原則で言えばbarも委譲をしているのでruby2_keywordsを指定すべきですが、Ruby 3.1までは指定しなくても意図通りに委譲ができていました。

しかし、Ruby 3.2からはruby2_keywordsの注釈が厳格に求められるようになりました。

よって、ruby2_keywordsを書き足してもいいですが、もしRuby 2との互換性を気にしないのであれば、次のように委譲するのがおすすめです。Ruby 2.7は2023年3月末にEOLになる見込みです。あと3ヶ月。

defbar(*args, **opts)
  target(*args, **opts)
end

(mame)

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

Fiber#storageが導入された

  • Introduce Fiber. and Fiber.= for inheritable fiber storage. Introduce Fiber#storage and Fiber#storage= (experimental) for getting and resetting the current storage. Introduce Fiber.new(storage:) for setting the storage when creating a fiber. [Feature #19078]

    Existing Thread and Fiber local variables can be tricky to use. Thread local variables are shared between all fibers, making it hard to isolate, while Fiber local variables can be hard to share. It is often desirable to define unit of execution ("execution context") such that some state is shared between all fibers and threads created in that context. This is what Fiber storage provides.

    def log(message)
      puts "#{Fiber[:request_id]}: #{message}"
    end

    def handle_requests
      while request = read_request
        Fiber.schedule do
          Fiber[:request_id] = SecureRandom.uuid

          request.messages.each do |message|
            Fiber.schedule do
              log("Handling #{message}") # Log includes inherited request_id.
            end
          end
        end
      end
    end

You should generally consider Fiber storage for any state which you want to be shared implicitly between all fibers and threads created in a given context, e.g. a connection pool, a request id, a logger level, environment variables, configuration, etc.

ちょっと背景が長い話になります。

グローバル変数を使いたいときってありますよね。複数メソッドをまたいで情報を共有したい、でも引数で引き回すのも嫌。グローバル変数は、そんな我がままなあなたのための機能です。

ただ、グローバル変数はプロセス内で共有されてしまうので、複数のスレッドで共有したくないときは嫌ですね。そこで、スレッドローカルストレージ(TLS)が導入されていました。Thread#[]でアクセスできるやつです。Thread.current[:request_id] = 1としてけば、あるスレッド内で Thread.current[:request_id]と参照すると 1、それ以外のスレッドからは別の値(もしくは未設定の nil)が返るというものです。

その後、Ruby 1.9で、Fiber が導入されました。実は、TLS は Fiber ローカルな挙動にするためによく利用されてきました。例えば、pメソッドなどでの循環参照の検出などです。そこで、Thread#[]はTLSではなく、ファイバーローカルな値を扱う機能(FLS)になりました。

その後、Ruby 2.0で、あるスレッドで作ったFiber間で共有してほしいけど、スレッド間では共有してほしくない、という要望が出てきました。Fiber は Enumerator などでいつの間にか利用される可能性があるので、それらを使っていても同じ変数セットを使い続けてほしい、というものです。そこで、Thread#thread_variable_set/getが TLS として導入されました。

そろそろ混乱してきたのではないかと思うのでまとめておきます。

  • Ruby 1.8まで: Thread#[]は TLS
  • Ruby 1.9から: Thread#[]は FLS
  • Ruby 2.0から: Thread#[]は FLS、Thread#thread_variable_set/getは TLS

まぁ、正直 Thread#thread_variable_set/getが必要になるケースは、コンポーネント間の暗黙的な依存を増やすので、引数やインスタンス変数などで正しくデータを伝搬するべきだと思いますが...。

で、さらに次の要望が出てきました。現状のよくわからん TLS、FLS を一新する提案です。

  • スレッドの親子(親スレッドと子スレッド)で共有する変数セット
  • あるスレッドで作ったファイバで共有する変数セット
  • ファイバの親子(親ファイバと子ファイバ)で共有する変数セット
  • それらの変数セットを柔軟にとっかえひっかえしたい

つまり、スレッド ->スレッド、スレッド ->ファイバ、ファイバ ->ファイバの親子関係(スレッドを作るとファイバが作成されるので、そういう意味ではファイバ ->ファイバの親子関係しかないのですが)で共有される変数セットを用意したい、それをイイ感じに管理したい、というものです。これを、Fiber storage という機能群でまとめられました。利用用途は、a connection pool, a request id, a logger level, environment variables, configuration だそうです。

Fiber storage という名前ですが、Fiber local storage ではなく、Fiber 間で変数セットを共有するための仕組みです。ただし、ファイバを作成した時点で変数セットはコピーされるので、独立しています。

アクセスは Fiber[:key]で行います(つまり、別ファイバの storage にはアクセスできません)。

Fiber[:k1] = :k1Fiber.new{ # デフォルトでは親の Fiber storage が引き継がれる
  p Fiber[:k1] #=> :k1Fiber[:k1] = :updated_k1
}.resume

p Fiber[:k1] #=> :k1 (:updated_k1 ではない)

ファイバ生成時、引き継がない、独自の変数セットを持つ、という選択をすることもできます。

Fiber[:k1] = :k1Fiber.new(storage: nil){
  p Fiber[:k1] #=> nil
}.resume

Fiber.new(storage: {k1: :my_k1}){
  p Fiber[:k1] #=> :my_k1
}.resume

また、Fiber#storage(=)で変数のセットをまとめて取り出したり設定したりできます(なお、Fiber#storage=は experimental feature です)。

Fiber.current.storage = {k1: :new_k1, k2: :new_k2}
p Fiber[:k1] #=> :new_k1
p Fiber[:k2] #=> :new_k2

このまとめての設定機能が何に使うのかよくわからなかったんですが、スレッドプールからスレッドを取り出して、そこに今から実行するために必要になるストレージをガツンと設定するために必要なんだそうです。なるほどなぁ。

細かい話はもう少しあるんですが、十分細かい話をした気がするので、この辺で。

マニア向け:この機能が議論されていたきは、「ある種のダイナミックスコープ」と説明されていました(気になる人はググってください)。なるほどなぁ、並行実行単位を超えて共有されるダイナミックスコープ(並行実行単の生成を関数呼び出しととらえれば等価です)。確かに、便利そうではあるんだけど。ActiveRecord みたいな DSL で使いたいよなぁ。でも、これはこれで複雑そうなんで、大丈夫かなぁ。ほかの言語で似たような機能の成功例(もしくは失敗例)があったら教えてください。

人類は、必要に駆られてさまざまなスコープを導入してきました。

  • グローバルスコープ(グローバル変数、プロセスローカル変数、Rubyだと定数空間も)
  • ローカルスコープ(ローカル変数、ブロックローカル変数)
  • レキシカルスコープ(ローカル変数、親クロージャ(Rubyだとブロック)の変数)
  • ダイナミックスコープ(動的変数、Rubyにはない、今回のは似ている)
  • インスタンスローカルスコープ(インスタンス変数)
  • クラスローカルスコープ(クラス変数)
  • スレッドローカルスコープ(スレッドローカル変数)
  • ファイバローカルスコープ(ファイバローカル変数)
  • Ractor ローカルスコープ(Racotr ローカル変数)
  • (広げれば、分散システムのディレクトリサービスとかも入りそう)
  • (きっと探せばもっとありそう)

今回、これにファイバの親子関係によるスコープが導入されたわけですね。人類の業は深い。

(ko1)

Fiber::Scheduler#io_selectが導入された

  • Introduce Fiber::Scheduler#io_select for non-blocking IO.select. [Feature #19060]

Fiber scheduler で IO.selectをフックするためのインターフェースが導入されました。

(ko1)

IO#timeout=secが導入された

  • Introduce IO#timeout= and IO#timeout which can cause IO::TimeoutError to be raised if a blocking operation exceeds the specified timeout. [Feature #18630]

      STDIN.timeout = 1
      STDIN.read # => Blocking operation timed out! (IO::TimeoutError)
    

1度の I/O 操作(例えば、read とか write とか)に、その I/O が対応していれば、設定した時間を超えたらタイムアウトの例外(IO::TimeoutError)が発生するようになりました。

r, w = IO.pipe
r.timeout = 3# pipe の片方の w に何も書いていないので、r.read してもずっと sleep する
r.read(1) #=> Blocking operation timed out! (IO::TimeoutError)

(timed out! ってメッセージは、なんか元気いいですね。いいことあったのかな)

この API は便利そうですが、いくつか注意が必要です。

  • 1回の操作ごとにタイムアウトはリセットされるので「複数の I/O 処理全部を 3 秒のタイムアウトで」のようにするには、結構大変です。
  • 大きなバッファを write する場合、write が複数回に別れることがあります。このとき、途中の write でタイムアウトが生じると、書き出している途中で例外が発生するため、どこまで書き出したかわかりません。(read はそういうのないんだっけ? 遠藤さんによるとあるらしいです)というわけで、タイムアウトを検出しても、リトライできるとは思わない方が安全です。たとえば、ウェブのリクエストの場合は素直にエラーを返しましょう。
  • 対応する I/O、および操作にしか効きません。典型的にはブロックデバイス(いわゆるストレージ上のファイル)には効きませんし、close などにも効きません。ほとんどの場合、(人間の尺度では)一瞬で処理が終わるので問題になりませんが、ハードウェアが壊れていて時間がかかったり、NFS上で時間がかかったりしていても、これにタイムアウトは効きません。簡単には、ソケットにしか効かないって思っておくのが良いと思います。

というわけで、ちょっと使うのが難しそうな API なんですが、「どーしょーもない」ことを検出するための最後の手段としてご利用いただくのがいいでしょうか。私はイマイチ、この API の良い用法がわかっていません。

(ko1)

IO.newにpathキーワードが渡せるようになった

  • Introduce IO.new(..., path:) and promote File#path to IO#path. [Feature #19036]

ファイルディスクリプタ番号からIOを作る機能があるのですが、それにファイルパスを指定できるようになりました。

io = IO.for_fd(0, path: "hoge")

p io      #=> #<IO:hoge>
p io.path #=> "hoge"

inspectの結果に影響を与える程度なので、まあ、あまり使うことはないと思います。

いちおう導入の背景を書くと、

  • ptyという拡張ライブラリが無理やりIO#pathを指定していた(実装がC言語なので、強引に書き換えることができてた)
  • JRubyがpure Ruby(+FFI)でptyを模倣するライブラリを作っていたとき、IO#pathを指定する方法がないことに気づいた
  • 困るので導入された

という感じです。

(mame)

Class#attached_objectが導入された

  • Class#attached_object, which returns the object for which the receiver is the singleton class. Raises TypeError if the receiver is not a singleton class. [Feature #12084]

特異クラスから、それのインスタンスを得るメソッドが導入されました。

obj = Object.new
klass = obj.singleton_class

p obj                   #=> #<Object:0x00007f7919a60150>
p klass.attached_object #=> #<Object:0x00007f7919a60150>

Rubyの型チェッカであるSorbet、のための型定義のスタブ生成ツールであるtapioca、は、Rubyの黒魔術を活用しまくって型定義を動的に推定するのですが、その際にこれが欲しかったとのこと。

(mame)

Dataクラスが導入された

  • New core class to represent simple immutable value object. The class is similar to Struct and partially shares an implementation, but has more lean and strict API. [Feature #16122]
        Measure = Data.define(:amount, :unit)
        distance = Measure.new(100, 'km')            #=> #<data Measure amount=100, unit="km">
        weight = Measure.new(amount: 50, unit: 'kg') #=> #<data Measure amount=50, unit="kg">
        weight.with(amount: 40)                      #=> #<data Measure amount=40, unit="kg">
        weight.amount                                #=> 50
        weight.amount = 40                           #=> NoMethodError: undefined method `amount='

Dataという新しいクラスが導入されました。一言で言えば、書き換え不可のStructです。

Point = Data.define(:x, :y)
pt = Point.new(1, 2)

p pt.x    #=> 1
p pt.y    #=> 2

Data.defineStruct.newと読み替えれば、だいたい同じです。

違いというと、

  • 書き換えをするメソッドがないこと
  • 配列やハッシュのように扱うメソッド([]とかselectとか)がないこと
  • 初期化時にフィールドの値を省略できないこと

くらいです。

# 書き換えはできない
pt.x = 3#=> undefined method `x=' for #<data Point x=1, y=2> (NoMethodError)# []でフィールドを読み出せない
pt[:x]    #=> undefined method `[]' for #<data Point x=1, y=2> (NoMethodError)# newで引数が足りないとエラーPoint.new(1) #=> missing keyword: :y (ArgumentError)

一部のフィールドだけ置き換えた新しいインスタンスを作りたい場合は、Data#withが使えます。

pt2 = pt.with(x: 3)

p pt2 #=> #<data Point x=3, y=2>

以下余談。

Rubyに新しいクラスを導入する場合、問題になるのが名前です。下手に定数を増やしてしまうと、その名前を使っているgemがあったとき、非互換となってしまう可能性があります。にもかかわらずDataという思い切った名前になったのは、かつてRuby自身がDataというトップレベルの定数を定義していたからです。

これは拡張ライブラリ作者が内部実装に使うために用意されていたクラスだったのですが、なぜか誰も使いませんでした。そして長らく非推奨となっていて、Ruby 3.0くらいでついに削除されました。なので、2年ほど経ってはいますが、今ならまだ他のライブラリとの衝突の可能性が低いのでは?ということで、この名前になりました。

(mame)

Encoding#replicateが非推奨に

  • Encoding#replicate has been deprecated and will be removed in 3.3. [Feature #18949]

エンコーディングをコピーして新しい名前を付ける Encoding#replicateが非推奨になりました。

もともとは、無限にエンコーディングを増やすことを許すと、性能的に問題になるケースがある、ということで無限に増やす可能性があるけど、結局そんなに使うことないじゃん、ってことで、非推奨になりました。

(ko1)

Encoding::UTF_16Encoding::UTF_32の特別扱いをやめた

  • The dummy Encoding::UTF_16 and Encoding::UTF_32 encodings no longer try to dynamically guess the endian based on a byte order mark. Use Encoding::UTF_16BE/UTF_16LE and Encoding::UTF_32BE/UTF_32LE instead. This change speeds up getting the encoding of a String. [Feature #18949]

Encoding::UTF_16/32は、バイトエンディアンが決まっていない「仮」のエンコーディングです。そのため、Ruby 3.1ではこれらのエンコーディングが設定された文字列については、なんらかの文字列処理を行うたびに(エンコーディングを取り出すたびに)文字列先頭の Byte order mark (BOM) をチェックして LE か BE に動的に可能なら変更するようにしていました。

で、この変更は、この特別扱いをやめて、Encoding::UTF_16については何らかの文字列処理(のエンコーディングを取り出す処理)をしても動的に LE/BE にはしないようにしました。つまり、利用できる文字列処理が凄く少なくなりました。処理前に、先にアプリケーションでチェックして LE か BE か設定しておいてください。

この特別扱いは、実はふつーの(今は多くの文字列は UTF-8 でしょうか)文字列処理も分岐を一個増やすので遅くなっちゃう、という問題が指摘されたので、じゃあやめて速くしてやろ、ってことで、やめました。実際、どれくらい速くなったのかな。

(ko1)

Encoding テーブルが 256 個になった

  • Limit maximum encoding set size by 256. If exceeding maximum size, EncodingError will be raised. [Feature #18949]

エンコーディングシリーズの最後です。Encoding#replicateのところで述べた通り、無限にエンコーディングを増やせるようにすると、性能上問題がありました。具体的には、テーブルを拡張する必要があるので、Ractor並列実行でも問題ないように同期するようにしていました。これがあると、一般的な文字列操作のためにエンコーディング情報を取り出す操作が(ちょびっと)遅くなってしまう、という問題がありました。

そこで、テーブル長を256個にする、つまり Ruby インタプリタが扱うエンコーディングの数を 256個を上限とすることで、この同期を排除しました(エンコーディングのロード時には、まだ同期処理が入っています)。

256個に制限されたので、万が一「わしのエンコーディングリストは65,536個あるぞ」みたいな方がいらっしゃいましたらお早めにご連絡ください。例えば、起動時に上限を指定できるようにする、みたいな拡張は可能です。ちなみに、Internet Assigned Numbers Authority (IANA) の Character Setsによると、知られている文字エンコーディング(なのか?)の数は 258 だそうです。全部持ってこられるとちょっとあふれちゃうね。

というわけで、 [Feature #18949] で提案されたエンコーディングに起因する性能向上策は導入されたわけですが、さてどれくらい速くなったんだろうな。

(ko1)

Enumerator.productが導入された

  • Enumerator.product has been added. Enumerator::Product is the implementation. [Feature #18685]

組み合わせを簡単に列挙できるメソッドが導入されました。

Enumerator.product([1, 2], [3, 4]) do |x, y|
  p [x, y]
  #=> [1, 3]#   [1, 4]#   [2, 3]#   [2, 4]end

探索アルゴリズムなんかで便利そうですね。

Array#productというメソッドもあったのですが、こちらはなぜか配列を返してしまうので、探索に使うには不向きなのでした。

(mame)

Exception#detailed_messageが導入された

  • Exception#detailed_message has been added. The default error printer calls this method on the Exception object instead of #message. [Feature #18564]

エラーメッセージを拡張するためのAPIが導入されました。did_you_meanやerror_highlightやsyntax_suggestなど、エラーメッセージを拡張するgem向けのものなので、アプリ開発者が直接使うことはあまりないかもしれません。

いちおう簡単な利用例を載せておきます。

classMyError< Exceptiondefdetailed_message(highlight: true, **)
    super + "\n\n補足情報です"endendraiseMyError#=> test.rb:7:in `<main>': MyError (MyError)##   補足情報です

エラーを扱うフレームワークでは、#messageを呼び出していたところで#detailed_messageを呼び出すように変えたほうがよいかもしれません。

前述の通り、このメソッドはerror_highlightなどエラーメッセージを拡張するgemのために導入しました。というのも、エラーメッセージを下手に上書きすることは非互換になってしまい、いくつかのプロジェクトのテストを失敗させてしまうためです。このあたりの背景はRubyKaigiで話したので、よければそちらを見てください。

https://rubykaigi.org/2022/presentations/mametter.html#day3

(mame)

Hash#shiftが空ハッシュに対しては常に nilを返すようになった

  • Hash#shift now always returns nil if the hash is empty, instead of returning the default value or calling the default proc. [Bug #16908]

Hashの要素をArray#shiftのように一個ずつ取り出す Hash#shiftというメソッドがあります。

h = {k1: 1, k2: 2}
p(h.shift) #=> [:k, 1]
p h        #=> {:k2=>2}

さて、ここで空 Hash に shift するとどうなるでしょうか。

p({}.shift) #=> nil

(おそらく)期待した通り、nilが返ります。

ここまでは、とくに疑問もないと思うのですが、ではデフォルト値があったらどうしましょう。デフォルト値は、「無いときにはこれを返す」というのがルールでした。

p Hash.new{true}.shift

結論を書くと、こんな感じでした。

  • Ruby ~1.6: nil
  • Ruby 1.8~: true(デフォルト値だけ。key はなし)

キーと値のペアが返るはずなのに、trueが返ってくるというのが変です。さらに、デフォルト値をかえすときに、ついでレシーバを設定するようなケースだとどうでしょうか。

h = Hash.new {|h, k| h[k] = true }
5.times{
  p [h.shift, h]
}

# Ruby 3.1 での結果 #=># [true, {nil=>true}]# [[nil, true], {}]# [true, {nil=>true}]# [[nil, true], {}]# [true, {nil=>true}]

なんかよくわからない振動をしています(じっと見ると気持ちがわかります)。

で、これなんか変じゃない? ってことで、「空のときは(デフォルト値は無視して)単にnil返そう」ということになりました。

余談ですが、私は Hash#shiftというメソッドをこの議論で初めて知りました。

(ko1)

Integer#ceildivが導入された

切り上げの整数除算をするメソッドが追加されました。

# 5/3 を切り上げる5.ceildiv(3) #=> 2

もう (a + b - 1) / bと書かなくてすみますね。 pagenationなんかで便利かもしれません。つまり、1ページにn個のアイテムを持たせる時、全部でm個のアイテムを書くには何ページ必要か、を計算するとか。

(mame)

bindingが取れないコンテキストでは bindingメソッドは nilを返すようになった

  • Kernel#binding raises RuntimeError if called from a non-Ruby frame (such as a method defined in C). [Bug #18487]

これは気にしなくていいです。気にしたい人はきっと悪い人。

悪い人向けの補足です。これまで、C メソッドなど、Ruby で記述されたコンテキストでない場所で Binding を取ろうと、rb_funcall(:binding)みたいなことをやったら、その呼び出し側の Binding が取れていたんですが(たしか)、Ruby 3.2 から例外があがるようになりました。悪いことはするもんじゃないですね。

冗談はおいといて、まぁほとんど踏むことはないんじゃないかと思います。

(ko1)

Stringをバイト列として扱うメソッドがいくつか増えた

正規表現にマッチした位置を、「何文字めか」ではなく「何バイトめか」で返すメソッドが増えました。

"あいうえお" =~ /い/

# 「い」は3バイトめから6バイトめにある
$~.byteoffset(0) #=> [3, 6]
  • String#byteindex and String#byterindex have been added. [Feature #13110]

こちらはString#indexString#rindexと似ていますが、やはり「何バイトめか」を返すメソッドです。

"あいうえお".byteindex("い") #=> 3

おそらくTextbringerというRuby製エディタで必要だった機能と思われます。

(mame)

これも、文字列をバイト単位で文字列を編集するためのメソッドです。

s = 'hello'
p s.bytesplice(2, 2, 'LL') # s の 2 文字目から 2 文字分を 'LL'に置き換え#=> "LL"
p s
#=> "heLLo"

これ、なんで self じゃなくて "LL"返すんだろ。 (聞いてみたら、String#[]=にあわせたんだそうです。でも便利なのかなぁ?)

指定した箇所がコードポイントの途中だったりするとエラーがでるそうです。

(ko1)

Module.used_refinementsが導入された

現在実行中の箇所で、どの refinements が usingされているか返す Module.used_refinementsが追加されました。

moduleR1refineStringdoendrefineArraydoendendmoduleR2refineArraydoendendmoduleR3refineHashdoendrefineRegexpdoendendusingR1usingR2
p Module.used_refinements #=> [#<refinement:String@R1>, #<refinement:Array@R2>, #<refinement:Array@R1>]usingR3
p Module.used_refinements
#=> [#<refinement:String@R1>, #<refinement:Array@R2>, #<refinement:Array@R1>, #<refinement:Hash@R3>, #<refinement:Regexp@R3>]

返り値は、Ruby 3.1 で導入された Refinement クラスです。

(ko1)

Module#refinementsが導入された

そのモジュールが含む Refinement クラス一覧を取る Module#refinementsが導入されました。

moduleR1refineStringdoendrefineArraydoendend

p R1.refinements     #=> [#<refinement:String@R1>, #<refinement:Array@R1>]
p String.refinements #=> []

何に使うんでしょうね。

(ko1)

Refinement#refined_classが導入された

ある Refinement がどのクラスを refine しているか返します。上で紹介した Module#refinementsを使ってサンプルコードを書いてみます。

moduleR1refineStringdoendrefineArraydoendend

p R1.refinements.map{|r| r.refined_class}
#=> [String, Array]

(ko1)

Module#const_addedが追加された

定数が定義されたときに呼ばれる Module#const_addedが導入されました。

classCdefself.const_added name
    p name
    superendendclassCC_CONST = :C#=> :C_CONSTclassD#=> :D_CONSTD_CONST = :D#=> ここでは呼ばれないendend

この手のフックは別の人も使っているかもしれない(利用している複数のモジュールをprependしているかもしれない)ので、superを呼んでおくのが礼儀です。

この機能、何に使うといいですかねえ。あんまり「これやるから入れてほしい」って提案じゃなかった気がするんですよね。

(ko1)

undef されたメソッド一覧を返す Module#undefined_instance_methodsが導入された

Module#undef_method(もしくは undef式)と Module#remove_methodの違いをご存じでしょうか。どちらも、メソッドを使えなくしそうなんですが、Module#remove_methodは単純で、そのモジュール(クラス)に定義されているあるメソッドを削除します。そのメソッドを呼び出そうとすると、継承関係の親クラスにあるメソッドが呼ばれます。

classCdeff = :CendclassD< Cdeff = :DendD.remove_method(:f)
p D.new.f #=> :C (C#f が呼ばれている)

Module#undef_method(もしくは undef式)は、「継承関係があろうと、そこでメソッド探索は失敗」というメソッドエントリを作成します。

classCdeff = :CendclassD< Cdeff = :DendD.undef_method :f
p D.new.f
#=> undefined method `f' for #<D:0x0000027b746213c0> (NoMethodError)# (C#f は呼ばれない)

というわけで、そんな undefされたメソッド一覧を返すのが Module#undefined_instance_methodsです。

classCdeff = :CendclassD< Cdeff = :DendD.undef_method :f
p D.undefined_instance_methods #=> [:f]

これも、なんで欲しかったんだろう。

(ko1)

Proc のこまごました話

  • Proc#dup returns an instance of subclass. [Bug #17545]

Proc#dupが、もし Proc の サブクラスだった場合、そのサブクラスのインスタンスを返すようになりました。しかし、Procのサブクラスなんて作ることあるんかな...。

Proc は、生成方法が lambda(->)と proc(Proc.new)で、ちょっと引数の扱いが変わります。具体的には、lambda だとメソッドのように厳密に(引数の数が違うと例外)、proc だとブロックのようにあいまいに(引数の数が違ってもなんとなく動かす)なるのですが、Proc#parameterは、lambdaかprocかで結果が変わっていました。ただ、あるケースで proc で作った Proc であっても lambda のような引数が欲しい、ということがあったそうで、この挙動を指定する lambdaキーワードが導入されました。

p proc{|a|}.parameters                  #=> [[:opt, :a]]
p proc{|a|}.parameters(lambda: true)    #=> [[:req, :a]]
p lambda{|a|}.parameters                #=> [[:req, :a]]
p lambda{|a|}.parameters(lambda: false) #=> [[:opt, :a]]

多分、逆(lambda だけど proc 形式で欲しい)はおまけですかね?

(ko1)

FreeBSD で Proc::RLIMIT_NPTSが導入された

  • Process
    • Added RLIMIT_NPTS constant to FreeBSD platform

man によると「このユーザ id が作成することを許可する疑似端末の最大の数」とのことです。

(ko1)

正規表現エンジンにメモ化の最適化が導入された

  • The cache-based optimization is introduced. Many (but not all) Regexp matching is now in linear time, which will prevent regular expression denial of service (ReDoS) vulnerability. [Feature #19104]
  • Regexp.linear_time? is introduced. Feature #19194

正規表現エンジンが大幅に改良されました。最悪計算量が(多くの場合で)入力文字列長に対して線形になります。これにより、いわゆるReDoSと呼ばれる脆弱性の可能性を大きく減らすことができます。

人工的な例ですが、次の正規表現マッチングはRuby 3.1では10秒くらいかかります。これがRuby 3.2では一瞬で返ります。

/^(a|a)+$/ =~ "a" * 28 + "b"

時間がかかっていた理由をざっくりいうと、正規表現エンジンが同じマッチング失敗を何度も繰り返すせいでした。今回の最適化は、一度失敗したマッチングを記憶するようにして、同じ失敗を二度と繰り返さないようにしたという感じです。

これは @makenowjust さんがクックパッドにインターンに来て実装してくれた大成果になります。詳しくは作者本人が記事を書いてくれましたので、ご参照ください、

techlife.cookpad.com

いくつかのコードを用いた実験によると、おおよそ9割の正規表現が最適化できる(つまり線形時間に抑えられる)ことがわかりました。逆に言うと、1割程度の正規表現は最適化できないので、「RubyではもうReDoSの心配は一切しなくてよい」というわけではないことにご注意ください。念のため。

Regexp.linear_time?というメソッドで、正規表現が最適化対象かどうかを判定できます。

# 最適化できる
p Regexp.linear_time?(/^(a|a)+$/)   #=> true

# 後方参照の \1 があるので最適化できない
p Regexp.linear_time?(/^(a|a)+\1$/) #=> false

(mame)

正規表現マッチングにタイムアウトが指定できるようになった

  • Regexp.timeout= has been added. Also, Regexp.new new supports timeout keyword. See [Feature #17837]

正規表現マッチングの時間上限を設定できるAPIが導入されました。もうひとつのReDoS緩和策です。

# 正規表現マッチングを最長1秒で打ち切る
Regexp.timeout = 1

# 前述の最適化ができない正規表現で時間がかかると例外になる
/^(a|a)+\1$/ =~ "a" * 28 + "b"
  #=> regexp match timeout (Regexp::TimeoutError)

最適化できない1割の可能性が心配なら、これも併用すると良いかもしれません。

(mame)

正規表現のフラグを文字列で指定できるようになった

  • Regexp.new now supports passing the regexp flags not only as an Integer, but also as a String. Unknown flags raise ArgumentError. Otherwise, anything other than true, false, nil or Integer will be warned. [Feature #18788]

Regexp.newにおいて、正規表現のフラグを文字列で指定できるようになりました。

Regexp.new("foo", "i")  #=> /foo/iRegexp.new("foo", "m")  #=> /foo/mRegexp.new("foo", "im") #=> /foo/im

以前は Regexp.new("foo", Regexp::IGNORECASE | Regexp::MULTILINE)などと書く必要がありました。

なお、シンボルで :imなどとは書けません。

(mame)

RubyVM::AbstractSyntaxTree.parseにモードが増えた

  • RubyVM::AbstractSyntaxTree
    • Add error_tolerant option for parse, parse_file and of. [Feature #19013]
    • Add keep_tokens option for parse, parse_file and of. Add #tokens and #all_tokens for RubyVM::AbstractSyntaxTree::Node [Feature #19070]

Rubyのコードをパースするためのメソッドに新たなモードが増えました。

  • syntax errorのあるコードでもなんとなくパースするモード(error_tolerant: trueで有効になる)
  • 各ノードに対応するトークン列を保持するモード(keep_tokens: trueで有効になる)

どちらもRubyのIDEサポートなどを実装することを念頭においた拡張になっています。詳しくは作者のyui-knkさんの記事をご参照ください。

yui-knk.hatenablog.com

(mame)

Setが組み込み(?)になった

  • Set is now available as a built-in class without the need for require "set". [Feature #16989] It is currently autoloaded via the Set constant or a call to Enumerable#to_set.

setライブラリが組み込みになりました。

が、現状では、インタプリタがデフォルトでrequire "set"をするだけという感じです*1。といっても、Railsがrequire "set"をするらしいので、今更これの恩恵を受ける人はほとんどいないかもしれません。

残念ながらいまのところ、「Cで書き直されて高速」みたいな話はありません。{ 1, 2, 3 }みたいな専用の記法が導入されたわけでもありません。今後に期待ですね。

(mame)

Unicode 15.0.0 になった

  • Update Unicode to Version 15.0.0 and Emoji Version 15.0. [Feature #18639] (also applies to Regexp)

だそうです。

(ko1)

String#-@String#dedupという別名がついた

  • String#dedup has been added as an alias to String#-@. [Feature #18595]

文字列の重複を排除(deduplicate)するString#-@があったんですが、この別名で String#dedupが入りました。

s1 = -'foo'
s2 = 'foo'.dedup

p s1.object_id == s2.object_id #=> true

(ko1)

Structの初期化にキーワードが使えるようになった

  • A Struct class can also be initialized with keyword arguments without keyword_init: true on Struct.new [Feature #16806]

Structの初期化でキーワードを使えるようになりました。

Point = Struct.new(:x, :y)

Point.new(y: 2, x: 1) #=> #<struct Point x=1, y=2>

Struct.new(:x, :y, keyword_init: true)などとすれば同じような挙動は実現できていたのですが、このオプションが不要になります。

少し細かいことを言うと、keyword_initはtrue/false/nilでそれぞれ意味が微妙に違います。

  • keyword_init: truePoint.new(x: 1, y: 2)で初期化できる、Point.new(1, 2)はエラー
  • keyword_init: nilPoint.new(x: 1, y: 2)でもPoint.new(1, 2)でも初期化できる
  • keyword_init: falsePoint.new(1, 2)で初期化できる、Point.new(x: 1, y: 2)は要注意の挙動(次の動作例を参照)
# Ruby 3.1 の挙動:Point.new({x: 1, y: 2}) と同じ扱いPoint.new(x: 1, y: 2) #=> #<struct A x={:y=>2, :x=>1}, y=nil>

今回の変更は、keyword_initのデフォルトがfalseからnilに変わったということになります。

(mame)

Thread.each_caller_locationが導入された

自スレッドのバックトレース情報を取る Kernel#caller_locationsというメソッドがあります。

deff = g
defg = pp(caller_locations)
f #=> ["t.rb:1:in `f'", "t.rb:3:in `<main>'"]

で、これらのメソッドで全部取るんじゃなくて、必要な場所だけフィルタしたい、最初だけ欲しい、というときに、毎回ブロックに渡すインターフェースがあると便利そうです。で、最終的に着地点として Thread.each_caller_locationというメソッドが導入されました。

deff = g
defg = Thread.each_caller_location{|loc| p loc}
f
#=> "t.rb:1:in `f'"#   "t.rb:3:in `<main>'"

この例では、全部 pで出力していますが、フィルタしたり、途中で break したりしてもいいわけです。

(ko1)

Queue#pop(timeout: sec)とかが導入された

  • Thread::Queue
  • Thread::SizedQueue

Queue#popとか SizedQueue#push/popとかは、待ちが生じる操作ですが、そこに timeout: secキーワード引数でタイムアウトが設定できるようになりました。

p Queue.new.pop(timeout:1) #=> nil

タイムアウトしたら、例外ではなく、nilが返ります。

(ko1)

Timeがパターンマッチ可能になった

  • Time#deconstruct_keys is added, allowing to use Time instances in pattern-matching expressions [Feature #19071]

要するに、こんなのが書けるようになりました。

# 1時2分3秒に実行した場合Time.now => { hour:, min:, sec: }
p hour #=> 1
p min  #=> 2
p sec  #=> 3

指定できるキーは :year、:month、:day、:yday、:wday、:hour、:min、:sec、:subsec、:dst、:zone だそうです。

  • Added Date#deconstruct_keys and DateTime#deconstruct_keys same as [Feature #19071]

DateやDateTimeも同様らしいです。

(mame)

Time.newTime#inspectの結果の文字列フォーマットで時間を指定できるようになった

  • Time.new now can parse a string like generated by Time#inspect and return a Time instance based on the given argument. [Feature #18033]

Time.newTime.parseみたいなことができるようになりました。

Time.new("2022-12-25 15:00:00")       #=> 2022-12-25 15:00:00 +0900Time.new("2022-12-25 15:00:00 +0000") #=> 2022-12-25 15:00:00 +0000Time.new("2022-12-25 15:00:00Z")      #=> 2022-12-25 15:00:00 UTC

なんでもかんでも時刻として無理やり解釈しようとするTime.parseと違って、Time.newはフォーマットに厳格です。たとえば桁数は省略できません。

Time.new("2022-12-25 15:00:0")
  #=> two digits sec is expected after `:': :0 (ArgumentError)

ただし、ISO 8601の時刻表記のような "T"は許容されます。

# iso8601のような "T" は許容されるTime.new("2022-12-25T15:00:00Z")      #=> 2022-12-25 15:00:00 UTC

"T"のかわりに空白を許容する Time.iso8601に近いかも。強いて特典をあげるなら、Time.newは C 言語で書かれているので速いです。

(mame)

SyntaxError#pathが追加された

SyntaxErrorを引き起こした原因のファイル名を得られるようになりました。

beginload"broken.rb"rescueSyntaxError
  p $!.path #=> "broken.rb"end

後述するsyntax_suggestのために導入されました。一般的な用途は、あんまりないかも。

(mame)

CメソッドをフックしたときのTracePoint#bindingnilを返すようになった

  • TracePoint#binding now returns nil for c_call/c_return TracePoints. [Bug #18487]

Kernel#bindingの話と似ています。これまで、c_call/creturnフックでは、呼び出し元の Ruby コードの Binding が返ってきましたが、これを nilにしています。

TracePoint.new(:c_call){|tp|
  p [tp, tp.binding]
}.enable{
  p 1
}

#=># [#<TracePoint:c_call `p' t.rb:4>, nil]# [#<TracePoint:c_call `to_s' t.rb:4>, nil]# 1

多分、問題ないと思うんですが、もしはまったらゴメンなさい。

(ko1)

TracePoint#enable(target_thread: Thread.current)がデフォルトになった

  • TracePoint#enabletarget_thread keyword argument now defaults to the current thread if a block is given and target and target_line keyword arguments are not passed. [Bug #16889]

TracePoint#enableメソッドを用いると、その TracePont が有効になる対象をいろいろ決められるのですが、TracePoint#enable(target_thread:)で有効にするスレッドを指定できます。

今までは、これがないと全スレッドに有効になっていたんですが、enableにブロックがわたされているときは、まぁたいていはそのブロックを実行している自スレッドだけに効かせたいでしょう、ということで、target_thread: Thread.currentをデフォルトにするようにしました。nilにすると全スレッドに効きます。また、targettarget_lineキーワードなどがついていても、全スレッドに有効になります。

Thread.new{loop{1.inspect}}
TracePoint.new(:c_call){|tp| 
  p tp # 別スレッドには効かない
}.enable{
  sleep 0.5
}
p :term

(ko1)

UnboundMethodがレシーバの情報を持たなくなった

  • UnboundMethod#== returns true if the actual method is same. For example,String.instance_method(:object_id) == Array.instance_method(:object_id)returns true. [Feature #18798]
  • UnboundMethod#inspect does not show the receiver of instance_method. For example String.instance_method(:object_id).inspect returns"#<UnboundMethod: Kernel#object_id()>"(was "#<UnboundMethod: String(Kernel)#object_id()>").

これは多分興味ある人ほとんどいないと思うんだけど...。

これまで、Module#instance_method(method_name)で取り出した UnboundMethodインスタンスは、レシーバの情報をもっていました。

# Ruby 3.1
p m1 = String.instance_method(:object_id)
#=> #<UnboundMethod: String(Kernel)#object_id()>
p m2 = Array.instance_method(:object_id)
#=> => #<UnboundMethod: Array(Kernel)#object_id()>
p m1 == m2
#=> false

つまり、String や Array から取り出したメソッドだということがわかるようにしていたのです。 また、それらは別物とされていました。

ただ、そもそもそんな区別いる? という議論がされ、要らんのでは、ということになりました。

# Ruby 3.2
p m1 = String.instance_method(:object_id)
#=> #<UnboundMethod: Kernel#object_id()>
p m2 = Array.instance_method(:object_id)
#=> #<UnboundMethod: Kernel#object_id()>
p m1 == m2
#=> true

(ko1)

GC.latest_gc_infoneed_major_byを返すようになった

  • Expose need_major_gc via GC.latest_gc_info. GH-6791

GC.latest_gc_infoは直前の GC の実行状況を Hash で返すメソッドですが、その状況の中に、:need_major_byが追加されました。これは、「次の GC が major GC と決まっていたら、その決定理由を返す」という情報です。

pp GC.latest_gc_info
#=># {:major_by=>:oldgen,:need_major_by=>nil,     # nil なので(現時点では)次の GC は major GC を予定していない。:gc_by=>:newobj,
   :have_finalizer=>false,
   :immediate_sweep=>false,
   :state=>:none}

次の GC が major GC かどうかは、直前の GC の実行状況によって「逼迫してそうだから次は major GC にしよ」とどこかのタイミングで決まるので、その情報を返すためのものです。

ただ、need_major_byは「次のGC」の話でgc_byは「前回のGC」の話で、区別しづらい良くない名前になってしまっています。気づかないうちに入ってしまっていました。不覚。

(ko1)

ObjectSpace.dump_allが shape を返すようになった

  • ObjectSpace.dump_all dump shapes as well. [GH-6868]

主にヒープの状況の詳細な分析に利用するための ObjectSpace.dump_allの出力が、後述する object shape の情報を含むようになりました。

(ko1)

■stdlibのアップデート

BundlerがPubGrubバージョン解決アルゴリズムを利用するようになった

  • Bundler

BundlerはGemfileやgemspecに書かれた依存性を満たすバージョンを決定する必要があるのですが、これを行うアルゴリズムを早くて賢いものに変えたそうです。

このアルゴリズムはPubGrubと呼ばれていて、解説記事を眺めたところ、“unit propagation”、“logical resolution”、“conflict-driven clause learning”など、SATソルバの実装で聞くテクニックを使っているみたいです。

(mame)

CGI.escapeURIComponentが導入された

  • CGI.escapeURIComponent and CGI.unescapeURIComponent are added. [Feature #18822]

CGI.escapeCGI.unescapeの亜種が導入されました。

CGI.escapeURIComponent("foo/bar baz") #=> "foo%2Fbar%20baz"
CGI.escape("foo/bar baz")             #=> "foo%2Fbar+baz"

違いは空白の扱いだけです。CGI.escapeは空白を+に置換えますが、CGI.escapeURIComponent%20にします。

Rubyらしからぬメソッド名は、JavaScriptのescapeURIComponent関数と同じであることを明示するために選ばれました。

(mame)

evalされたコードのカバレッジを測定できるようになった

  • Coverage.setup now accepts eval: true. By this, eval and related methods are able to generate code coverage. [Feature #19008]
  • Coverage.supported?(mode) enables detection of what coverage modes are supported. [Feature #19026]

コードカバレッジを測定するCoverageライブラリが、requireloadしたファイルだけでなく、evalされたコードのカバレッジも測定できるようになりました。Coverage.start(lines: true, eval: true)などと初期化する必要があります。

どう便利かというと、要するに、ERBのカバレッジがなんとなく取れるようになります。次のPRを見てください。

https://github.com/simplecov-ruby/simplecov/pull/1037

使いかたとしては、simplecov 0.22.0以降で次のように設定することで有効にできます。

SimpleCov.start do
  enable_coverage_for_eval
end

実際にカバレッジ測定しているのはERBが生成したRubyコードで、その行番号をむりやりERBのカバレッジとして表示しているので、やや不自然に見えることもあるかもしれません。本格的にやるには、ERBにsourcemap的なものを吐かせる必要があるのだろうなあ。

(mame)

ERB::Util.html_escapeが速くなった

  • ERB::Util.html_escape is made faster than CGI.escapeHTML.
    • It no longer allocates a String object when no character needs to be escaped.
    • It skips calling #to_s method when an argument is already a String.
    • ERB::Escape.html_escape is added as an alias to ERB::Util.html_escape, which has not been monkey-patched by Rails.
  • ERB::Util.url_encode is made faster using CGI.escapeURIComponent.

ERB::Util.html_escapeが速くなりました。次のコードでRuby 3.1.3では1.3秒、Ruby 3.2.0では0.67秒くらいでした。

s = "foo"
10000000.times { ERB::Util.html_escape(s) }

ただし、このために少しだけ非互換があります。

  • エスケープ対象の文字が1文字もなかったら、引数の文字列をdupせずにそのまま返す(返り値を破壊的に使っている人は注意)
  • 引数が文字列だったときに #to_sを呼ぶのをやめた

あとついでにERB::Util.url_encodeも速くなったそうです。前述のCGI.escapeURIComponentを使ったとのこと。

(mame)

erbコマンドの-Sオプションが消された

  • -S option is removed from erb command.

erbコマンドからセーフレベルを指定するオプションが消えました。セーフレベル自体はRuby 3.0で消されています。コマンドライン引数はdeprecatedとして残されていましたが、今回ついに消えたようです。

(mame)

FileUtils.ln_srが導入された

  • Add FileUtils.ln_sr method and relative: option to FileUtils.ln_s. [Feature #18925]

ターゲットディレクトリからの相対パスでシンボリックリンクを生成するメソッドが増えました。

FileUtils.ln_s("src/file", "dest/symlink")とすると、dest/symlinkdest/src/fileへのシンボリックリンクになりますが、これは意図しない結果かもしれません。destと同じディレクトリにあるsrcの中のfileへシンボリックリンクをはりたかったら、FileUtils.ln_s("../src/file", "dest/symlink")とする必要がありました。つまり、第一引数には第二引数からの相対パスを示す必要がありました。これからはFileUtils.ln_sr("src/file", "dest/symlink")と書くだけですみます。

(mame)

IRBに多数のコマンドが導入された

  • Added many of new commands and improvements. see [ruby-3-2-irb]

たくさんの新しいコマンドが導入されています。詳細は下記の記事をご参照ください。

余談:irb は、これまでは Ruby の式しか実行できませんでした。コマンドに見えるものも、すべて Ruby のメソッドとして実装されてきていました。今回のリリースでは、この制限をとっぱらって、(pryなどのように)独自の構文を導入しました。これによって、pry の$といった機能が使えるようになっています。

個人的には、Ruby の式に限定していた理由をよく知らないのですが(単純性のため?)、この限定を解除したデメリットがないか、ちょっと興味があります。

(ko1)

Net::Protocolのバッファ実装が改善された

net-httpなどで使われているバッファの実装が効率的になったようです。以前はString#slice!を使って文字列を切り出していたため、長いバッファに対して先頭から一行ずつ読むような場合に遅くなっていたとのこと。改善後は切り出さずに先頭からのオフセットを管理するようにしたらしい。

(mame)

Pathname#lutimeが導入された

Pathnameに最終アクセス時刻と更新時刻を変更するメソッドが追加されました。utimeとの違いは、シンボリックリンクに対してlutimeを行ったとき、参照先のファイルではなくシンボリックリンク自身の時刻を変更するところです。

File.lutimeは以前からあったので、それのPathname版です。

(mame)

Socketに定数が追加された

  • Socket
    • Added the following constants for supported platforms.
      • SO_INCOMING_CPU
      • SO_INCOMING_NAPI_ID
      • SO_RTABLE
      • SO_SETFIB
      • SO_USER_COOKIE
      • TCP_KEEPALIVE
      • TCP_CONNECTION_INFO

Socket にいろんな定数が追加されました。詳しくないから詳細わかりません、ゴメン。

(ko1)

syntax_suggest gemが導入された

  • The feature of syntax_suggest formerly dead_end is integrated in Ruby. [Feature #18159]

endが足りなかったり多すぎたりした場合に、親切なエラーメッセージを出すようにするsyntax_suggest gem(旧名 dead_end gem)が導入されました。

classCdeffooenddefbar# endが欠けているif cond?
  enddefbazendend
$ ruby test.rb
test.rb: --> /tmp/wrong_syntax.rb
Unmatched keyword, missing `end' ?
   1  class C
   5    def bar
>  7      if cond?
   8    end
  12  end
/tmp/wrong_syntax.rb:12: syntax error, unexpected end-of-input (SyntaxError)

ifに対するendが足りていないことを、行単位の要約によって示してくれています。

dead_end gemはRubyKaigi Takeout 2021で作者のRichard Schneemanが発表していたものです。無事入ってよかったですね。

(mame)

WindowsでUNIXSocketをサポートした(かも)

  • Add support for UNIXSocket on Windows. Emulate anonymous sockets. Add support for File.socket? and File::Stat#socket? where possible. [Feature #19135]

最近の Windows では UNIX Domain Socket (ぽいもの)をサポートしているそうです。へー、知らなかった。 というわけで、対応していれば対応する、というパッチが入ったそうです。すごい。

(ko1)

ライブラリのアップデート

  • The following default gems are updated.

    • RubyGems 3.4.1
    • abbrev 0.1.1
    • benchmark 0.2.1
    • bigdecimal 3.1.3
    • bundler 2.4.1
    • cgi 0.3.6
    • csv 3.2.6
    • date 3.3.3
    • delegate 0.3.0
    • did_you_mean 1.6.3
    • digest 3.1.1
    • drb 2.1.1
    • english 0.7.2
    • erb 4.0.2
    • error_highlight 0.5.1
    • etc 1.4.2
    • fcntl 1.0.2
    • fiddle 1.1.1
    • fileutils 1.7.0
    • forwardable 1.3.3
    • getoptlong 0.2.0
    • io-console 0.6.0
    • io-nonblock 0.2.0
    • io-wait 0.3.0
    • ipaddr 1.2.5
    • irb 1.6.2
    • json 2.6.3
    • logger 1.5.3
    • mutex_m 0.1.2
    • net-http 0.3.2
    • net-protocol 0.2.1
    • nkf 0.1.2
    • open-uri 0.3.0
    • open3 0.1.2
    • openssl 3.1.0
    • optparse 0.3.1
    • ostruct 0.5.5
    • pathname 0.2.1
    • pp 0.4.0
    • pstore 0.1.2
    • psych 5.0.1
    • racc 1.6.2
    • rdoc 6.5.0
    • readline-ext 0.1.5
    • reline 0.3.2
    • resolv 0.2.2
    • resolv-replace 0.1.1
    • securerandom 0.2.2
    • set 1.0.3
    • stringio 3.0.4
    • strscan 3.0.5
    • syntax_suggest 1.0.2
    • syslog 0.1.1
    • tempfile 0.1.3
    • time 0.2.1
    • timeout 0.3.1
    • tmpdir 0.1.3
    • tsort 0.1.1
    • un 0.2.1
    • uri 0.12.0
    • weakref 0.1.2
    • win32ole 1.8.9
    • yaml 0.2.1
    • zlib 3.0.0
  • The following bundled gems are updated.

    • minitest 5.16.3
    • power_assert 2.0.3
    • test-unit 3.5.7
    • net-ftp 0.2.0
    • net-imap 0.3.4
    • net-pop 0.1.2
    • net-smtp 0.3.3
    • rbs 2.8.2
    • typeprof 0.21.3
    • debug 1.7.1

いろいろアップデートされました。詳細は各 gem のリリースノートを見てください。

自分が見ているので debug.gem のアップデートだけ紹介すると、新しいデバッグコマンド(untilなど)が足されたり、VSCode/Chrome連携がよくなったり、たくさんのバグフィックスが入っていたりします。

(ko1)

■サポートプラットフォーム

RubyがWebAssembly/WASIに対応しました。かんたんに言えば、Rubyがブラウザ上で動いて楽しいです。

しかしWebAssembly/WASIはブラウザにとどまらず、いろいろな活用が期待されているプラットフォームで、いわゆるエッジコンピューティングやプラグイン機構などでこれから使われていく、かもしれません。今回のRubyの対応は、そういう将来に備えた布石です。

RubyKaigi 2022の初日のキーノートで作者の @kateinoigakukun が発表していたので、記憶に残っている人も多いのではないかと思います。実装など詳しくはそちらの発表資料を御覧ください。

クックパッドはRubyKaigiのブース企画として、このWebAssembly/WASI対応を使ったコードパズルを作りました。興味あれば、次の2つの記事をご覧ください。

techlife.cookpad.com

techlife.cookpad.com

(mame)

■非互換

String#to_cのわずかな変更

  • String#to_c currently treat a sequence of underscores as an end of Complex string. [Bug #19087]

"1__0".to_cと書いたとき、Ruby 3.1では(10+0i)が帰っていましたが、Ruby 3.2からは(1+0i)が返ります。

(mame)

ENV.cloneが例外を投げる

  • Now ENV.clone raises TypeError as well as ENV.dup [Bug #17767]

ENV.cloneが例外を投げるようになりました。

(mame)

削除された定数

The following deprecated constants are removed.

消すぞ消すぞ、と言われていた古い定数が削除されました。

(ko1)

削除されたメソッド

The following deprecated methods are removed.

上記のメソッドが削除されました。ほとんどのメソッドは呼ぶと警告が出ていたものになります。

Dir.exists?は代わりにDir.exist?を使うようにしてください。Rubyのメソッド名は原則として原型を使うので、三単現のsがあるものは削除となりました。

Method#public?などはRuby 3.1で導入されたメソッドだったのですが、キャンセルして削除されました。これには次のような事情があります。

Ruby 3.2の仕様の議論ではメソッドオブジェクトのコーナーケースの整理が一大トピックとなっていて、非常に長時間をかけて細かい議論が行われていました。その中で、「メソッドの可視性というのは、メソッド自体が持つものではなく、それが所属するクラスが持つものである」という哲学的な整理にいたり、「Methodクラスがpublicかどうかを返すのはおかしい」ということで、Ruby 3.1で導入されたばかりだったということもあり、あっさり削除されることなりました。なお、この一大トピックの全体の結論としては、「Ruby 3.0のときの挙動がほぼ正しかった」となったので、内容は割愛します。

(mame)

拡張ライブラリのソースコード非互換性

  • Extension libraries provide PRNG, subclasses of Random, need updates. See [PRNG update] below for more information. [Bug #19100]

疑似乱数生成器を自分で実装するためのインターフェースに修正が入りました。興味ある人は資料を見てください。

(ko1)

エラーメッセージのエスケープをやめた

  • Ruby no longer escapes control characters and backslashes in an error message. [Feature #18367]

Rubyはエラーメッセージを表示する時に制御文字やバックスラッシュをエスケープしていたのですが、これをやめました。error_highlightでバックスラッシュのある行を表示するとき、下線位置がずれることがあったのですが、この変更でずれなくなります。

(mame)

includeが絡んだクラス・モジュールを定義するときの定数解決

  • When defining a class/module directly under the Object class by class/module statement, if there is already a class/module defined by Module#includewith the same name, the statement was handled as "open class" in Ruby 3.1 or before. Since Ruby 3.2, a new class is defined instead. [Feature #18832]
moduleXmoduleYendendincludeX

p Y#=> X::YclassYend#=># Ruby 3.1: class X::Y とオープンクラスをやろうとして、モジュールなのでエラー# Ruby 3.2: class Y を実行し、新しく ::Y を定義

このプログラムでは、トップレベルで Xを include しているので、トップレベルで Yが参照できます(X::Y)。

このとき、class/module文でクラスやモジュールを定義しようとすると、X::Yに対してオープンしようとしていましたが、Ruby 3.2 からはまったく新しい Yが定義されるようになりました。

(ko1)

■stdlibの非互換

libyamlやlibffiのソースコードのバンドルをやめた

  • Psych no longer bundles libyaml sources. And also Fiddle no longer bundles libffi sources. Users need to install the libyaml/libffi library themselves via the package manager like apt, yum, brew, etc. [Feature #18571]

Rubyのパッケージはlibyamlやlibffiのソースコードをバンドルしていて、これらがインストールされていない環境ではバンドル版をビルドして利用していたのですが、このバンドルをやめました。今後は各自でシステムにインストールしてください。

たしかRuby 2.0でPsychが導入されるとき、libyamlが簡単にインストールできない環境のためにバンドルを始めたのですが、libyamlに脆弱性が発見されたときにRubyもリリースを検討しないといけないなど、意外とコストが高かったので、バンドルをやめることは長らく懸案となっていました。

Windowsでlibffiを入れるためにパッチが必要と思われていたのがバンドル中止に踏み切れなかった理由だったのですが、libffi本家側の改善でそのパッチが不要になっており、vcpkgなどでも簡単にインストールできることがわかったので、今回思い切ってバンドル中止となりました。

(mame)

CGI::Cookieがname/path/domainの文字列を検査するようになった

  • Check cookie name/path/domain characters in CGI::Cookie. [CVE-2021-33621]

CGI::Cookieを生成する際、属性値の中身をRFC 6265にもとづいて検査するようになりました。これは脆弱性の可能性を防ぐためです。

バグ発見者の徳丸浩さんが記事を書いてくれましたので、あわせてご参照ください。

blog.tokumaru.org

(mame)

URI.parseが少しだけ変更

  • URI.parse return empty string in host instead of nil. [sec-156615]

次のような文字列をURLとしてパースした時に、ホスト名がnilだったところ、空文字列が返るようになりました。

URI("http:////example.com/").host #=> ""

(mame)

■C APIの更新

更新されたC API

The following APIs are updated.

  • PRNG update rb_random_interface_t in ruby/random.h updated and versioned. Extension libraries which use this interface and built for older versions need to rebuild with adding init_int32 function.

疑似乱数生成器を提供するためのAPIが変わったそうです。

(ko1)

追加されたC API

  • VALUE rb_hash_new_capa(long capa) was added to created hashes with the desired capacity.

事前に指定された分のメモリを確保しておく rb_hash_new_capa()が導入されました。

  • rb_internal_thread_add_event_hook and rb_internal_thread_add_event_hook were added to instrument threads scheduling. The following events are available:
    • RUBY_INTERNAL_THREAD_EVENT_STARTED
    • RUBY_INTERNAL_THREAD_EVENT_READY
    • RUBY_INTERNAL_THREAD_EVENT_RESUMED
    • RUBY_INTERNAL_THREAD_EVENT_SUSPENDED
    • RUBY_INTERNAL_THREAD_EVENT_EXITED

スレッドが停止したり実行可能になったり実際に実行再開したりするときに内部的なイベントを発行して、それをトラップすることでスレッドの挙動を計測することができるようになりました。

実際にこれを使ったツールを作っていただいています(ivoanjo/gvl-tracing: Get a timeline view of Global VM Lock usage in your Ruby app)。

  • rb_debug_inspector_current_depth and rb_debug_inspector_frame_depth are added for debuggers.

デバッガのために、スタックフレームの深さに関する情報を返す API が追加されました。

(ko1)

削除されたC API

The following deprecated APIs are removed.

  • rb_cData variable.
  • "taintedness" and "trustedness" functions. [Feature #16131]

rb_cDataというグローバル変数が削除されました。これはDataクラスの項で少し触れた、過去のData定数に入っていたクラスの残骸です。

また、Ruby 3.0で廃止されたセーフレベルに関するC API群も削除されました。

(mame)

■実装の改善

  • Fixed several race conditions in Kernel#autoload. [Bug #18782]

autoload の thread-safety の状況が改善されました。

(ko1)

  • Cache invalidation for expressions referencing constants is now more fine-grained. RubyVM.stat(:global_constant_state) was removed because it was closely tied to the previous caching scheme where setting any constant invalidates all caches in the system. New keys, :constant_cache_invalidations and :constant_cache_misses, were introduced to help with use cases for :global_constant_state. [Feature #18589]

定数のキャッシュが、いままで大雑把に管理していた(たとえば、ある定数を定義したら、すべての定数キャッシュを全部クリア)のを、より細かく制御することになりました。 それに関連して、RubyVM.statの返す値が変わりました。

(ko1)

  • Variable Width Allocation is now enabled by default. [Feature #18239].

Ruby 3.1 では off にしてリリースされていた Variable Width Allocation (VWA) がオンになりました。これは、オブジェクトのサイズを可変長にすることで、追加のメモリアロケーション(つまり malloc)を減らして性能向上を目指すものです。 実際どれくらいの高速化になっているのかは知らないんだよなぁ。

shopify.engineering

(ko1)

  • Added a new instance variable caching mechanism, called object shapes, which improves inline cache hits for most objects and allows us to generate very efficient JIT code. Objects whose instance variables are defined in a consistent order will see the most performance benefits. [Feature #18776]

オブジェクトシェイプという仕組みを使ってインスタンス変数アクセスの高速化が図られました。 とくに、JITにおいて、効くんじゃないかと思われるテクニックです。

詳細は、RubyKaigi 2022 の Jemma Issroff さんによる Implementing Object Shapes in CRuby - RubyKaigi 2022、それから RubyKaigi 2021 の Chris Seaton さん(先日早逝の報を聞いて悲しい)によるキーノートThe Future Shape of Ruby Objects by Chris Seaton - RubyKaigi Takeout 2021をごらんください。

(ko1)

  • Speed up marking instruction sequences by using a bitmap to find "markable" objects. This change results in faster major collections. [Feature #18875]

命令列(バイトコード)からマークするべきオブジェクト一覧を取り出すのに、ちょっと面倒なことをしていたのを、ビットマップを用いることで、ピタッとわかるようにしたという話です。このパッチにより、Major GCの性能が向上したそうです。

性能向上については、Shopify からの貢献が大きいです。リーダーの Ufuk の Twitter のスレッドが参考になりそうなので、(DeepLが)日本語に訳しておきました。

Ufuk's explanation of Ruby 3.2 achievements by Shopify

(ko1)

■JIT

YJIT

  • YJIT is no longer experimental
    • Has been tested on production workloads for over a year and proven to be quite stable.
  • YJIT now supports both x86-64 and arm64/aarch64 CPUs on Linux, MacOS, BSD and other UNIX platforms.
    • This release brings support for Mac M1/M2, AWS Graviton and Raspberry Pi 4.
  • Building YJIT now requires Rust 1.58.0+. [Feature #18481]
    • In order to ensure that CRuby is built with YJIT, please install rustc>= 1.58.0 before running ./configure
    • Please reach out to the YJIT team should you run into any issues.
  • Physical memory for JIT code is lazily allocated. Unlike Ruby 3.1, the RSS of a Ruby process is minimized because virtual memory pages allocated by --yjit-exec-mem-size will not be mapped to physical memory pages until actually utilized by JIT code.
  • Introduce Code GC that frees all code pages when the memory consumption by JIT code reaches --yjit-exec-mem-size.
    • RubyVM::YJIT.runtime_stats returns Code GC metrics in addition to existing inline_code_size and outlined_code_size keys: code_gc_count, live_page_count, freed_page_count, and freed_code_size.
  • Most of the statistics produced by RubyVM::YJIT.runtime_stats are now available in release builds.
    • Simply run ruby with --yjit-stats to compute and dump stats (incurs some run-time overhead).
  • YJIT is now optimized to take advantage of object shapes. [Feature #18776]
  • Take advantage of finer-grained constant invalidation to invalidate less code when defining new constants. [Feature #18589]
  • The default --yjit-exec-mem-size is changed to 64 (MiB).
  • The default --yjit-call-threshold is changed to 30.

YJIT にも色々変更入ってますね。

  • Rust で書き直し
  • x86-64 に加え、ARM にも対応した
  • コードGC に対応した

が大きいトピックでしょうか。細かいことは追えていないので説明できません。ゴメン。

(ko1)

MJIT

  • The MJIT compiler is re-implemented in Ruby as ruby_vm/mjit/compiler.
  • MJIT compiler is executed under a forked Ruby process instead of doing it in a native thread called MJIT worker. [Feature #18968]
    • As a result, Microsoft Visual Studio (MSWIN) is no longer supported.
  • MinGW is no longer supported. [Feature #18824]
  • Rename --mjit-min-calls to --mjit-call-threshold.
  • Change default --mjit-max-cache back from 10000 to 100.

MJITも書き変わっていて、いろいろRubyで書くという野心的な話をしています。 詳しくは国分さんの RubyKaigi 2022 での発表 Towards Ruby 4 JIT - RubyKaigi 2022をご覧になると良いかと思います。

(ko1)

おわりに

Ruby 3.2の新機能や改善を紹介してきました。ここで紹介した以外でも、バグの修正や細かな改善が行われています。お手元の Ruby アプリケーションでご確認いただければと思います。

Ruby 3.2では、いろいろアグレッシブに改善が行われています。Shopify ではリリース前から大規模に利用して、10%の性能改善を得ているそうです。それだけ安定しているってことですね。ぜひ、お手元にセットアップして新しい Ruby を楽しんでください。

では、よいお年をお迎えください。

(ko1/mame)

*1:正確にはlazyにロードするように少しだけ工夫されています。

Rubyインタプリタの品質向上のために個人的にやっていること

$
0
0

技術部の笹田です。Ruby 3.2 無事にリリースされて良かったよかった。

Rubyインタプリタは複雑なプログラムなので、当然のごとくバグが入ってきます。Rubyインタプリタ開発者は、これに対していろんな対策をしています。たとえば、テストを書いて、CI環境でチェックするとか、今となっては当然のことを、当然のごとくやっています(RubyCIchkbuildruby/spec: The Ruby Spec Suite aka ruby/specなどの整備や、実行環境の日々のメンテナンスの成果です)。

これに追加して、個人的にテストをとにかくたくさん繰り返し行うマシン群を用意しています。テストの実行頻度をなるべくあげて、「時々しか発生しない」というバグを炙り出して、Rubyインタプリタの品質向上を目指すためです。本稿ではそんな、ちょっとだけ変わったテスト環境についての話をご紹介します。

このテスト環境を用意するために、いろいろな方にご支援いただいております。本稿では感謝の意をこめて、そのご支援をご紹介させていただきます。

バグを「炙り出す」必要性

定期的にテストを実行する環境

よくある CI/CD の文脈では、リポジトリへのコミット(PR)単位でテストを実行します。もし問題がでたら、そのコミットに問題があることがわかるためです。GitHub Actions などでよく対象にするのはそんなテストです。

つまり、

「バグは修正に混入する → 修正ごとにテストを走らせることで、そのバグを見つけることができる」

という仮説のもとに定期的にテストする環境を用意するわけです。

この問題に対処するため、Ruby インタプリタ開発では、次のようなテスト環境を用意して利用しています。

  1. GitHub Actions による PR 単位、push 単位でのテスト環境
  2. chkbuild による網羅的なテスト環境(rubyci

1 も 2 も、基本的には直前に入った修正に問題がないか、チェックするための仕組みです。

2 は、いろいろな OS などの環境で、毎回 clean build して逐次テストすることで、正確なテスト結果を出します。ただ、時間がかかるため、2時間に1度程度、実行されています。

現在は、計算機の多くは一般社団法人 Ruby Association などからのご支援を受けて AWS 環境に構築しています。また、GitHub actions は GitHub 様から計算機資源の提供をいただいています。

そういえば、Shopify では、彼らの(おそらく膨大な)アプリケーションのテストをRubyの開発版で行っていただいているそうです。助かりますね。

ときどき落ちるテストを発見するテスト環境

Rubyインタプリタくらいの規模のソフトウェアになると、何も変わらないのに、時々落ちる、という現象にあたることがあります。また、修正はあっても、その修正では考えられない理由でテストが落ちる、ということもあります。こういうのを flaky test などということがあります。これには、いくつか理由が考えられます。

  1. テストが悪い
  2. 「時間」や「システムの状況」など外部要因に起因するテスト
  3. すでにバグは混入しているが、運が悪い(良い)ときにしか見つからない

経験上一番多いのは 1 のテストが悪いというものです。たとえば、テストするメソッドの順番に依存していたりすると、何かの拍子に問題が生じることになります。タイミングがシビアなテストを書いていると、ちょっとタイミングがずれて時々失敗する、みたいなこともあります(マシンスペックが変わって失敗する、とかもありますね)。

2 の外部要因に起因するテストも、テストが悪い、と言えなくもないですが、時々あります。たとえば ruby/zlibのテストが何もしていないのに失敗するようになった話 - @znz blogで紹介されている例は、特定の時刻でタイムスタンプが特定のデータを生成してしまい、テストが失敗してしまう、というものでした(テストを修正して解決)。

まぁ、上記は「テストが悪い」の範疇なので、インタプリタ自体の品質には直接関係ありません。ただ、これらを放置するとテスト結果を確認するのが億劫になるので、出来るかぎり早く修正する必要があります。われ窓理論ですね。

で、3の運が悪い(良い)と現れる問題が、インタプリタの品質にとって大切になります。1万回に1度、運が悪いと出現するようなバグでも、1日に利用者が1万人いるソフトウェアだと、1日に1度は踏んでしまうかもしれません。というか、踏んじゃいます。さらに悪いと、脆弱性のもとになってしまうかもしれません。

この手のバグが出やすいのは次のような場面です。

  • 自動メモリ管理(GC)
  • キャッシュを用いているもの
  • 並行・並列実行をしているもの
  • ネットワークなど、外部のシステムを利用しているもの

どれも、非決定的、つまり2度実行しても同じ結果にならないような挙動を持ち込みやすい部分です(そして私が良く扱う分野です)。ほかにも、システムによるメモリアドレスのランダマイズなど、「あれ、さっきと結果が違うぞ?」という状況を作る原因はいろいろあります。

で、いろんな工夫が考えられるのですが、われわれは「とにかく数を実行してみる」という手法を用いています。単純ですね。1万回に1度出るなら、1万回動かせば再現するだろう、という話です。

つまり、

「あまり出現しないバグがすでに混入している → テストの試行回数を増やせば、このようなバグを踏む確率が高くなる(炙り出せる)」

という仮説のもとに、とにかくたくさんテストを実行するテスト環境があるといいなぁと思うわけです。

先ほどご紹介した chkbuild では一日に 12 回程度(これに環境の数だけ掛け算)、GitHub actions ではイベントごと、ということで、「沢山実行する」というにはちょっと足りません。そこで、独自にテスト環境を作って5年くらい運用しています。

もともとは GC 開発時のデバッグで「時々起こる」バグに業を煮やし、1台でずっとテストを走らせていたことから始めました。

始めた当時は、while make up all test-all; do date; doneというコマンドで無限にテストを走らせました(失敗したら止まります)。ただ、これだと結果を確認するためにターミナルを見なければならず、また意図しないところで停止ししていると気付くことができません。また、スケールも難しいので、環境一式を作りこんでいった感じです。

たくさんテストを実行するための工夫

テストをたくさん実行するためには、次のような工夫を行いました。

  • マシンを複数台使う(スケールアウト)
  • 性能の良いマシンを使う(スケールアップ)
  • 1マシンで複数のテストを同時に実行してハードウェアリソースを使い切る
  • 1回のビルド・テストの試行時間を短くする

それぞれご紹介します。

利用するマシンの用意

お金があればクラウドでマシンを沢山用意してスケールアウトするのが一番確実(そして、慣れた人には簡単)なのですが、個人で行っている活動なので、用意できる金額に限りがあります。また、この手の計算機リソースを使い切る用途は、安いクラウドサービスにはあわないというものがあります。

自宅のスペースに若干の余裕があったので、現在は実マシンをてきとうにおいて運用しています(子どもたちが大きくなると余裕はなくなるため、この活動もそこで終了しそう)。AWS などの料金表をにらめっこしてみたのですが、やはり実マシンが一番安いですね...(割引プランをいろいろ探せばもっと安いんだろうか)。10万円弱で 8 cores 16 threads のちょっとした良いマシンが買えるのでありがたいです。現在は、4台のマシンで運用しています。

我が家においてあるマシン群

新しいマシンはどれも小さいです。以前はミドルタワーのマシンを並べていたんですが、さすがにむっちゃ邪魔で...。HX90 は先日のブラックフライデーでちょっと安かったので買ってしまいました。

テストの実行時間はCPUの動作周波数にきれいに相関していました。速ければはやいほど良い。

メモリは1つのテストスイートを走らせる程度なら、ビルドや各テストを並列実行しても2GB程度あれば十分なようで、意外にもそんなに必要ありませんでした。

電力計をつけているのですが、見ていると全部で 400Wh のあたりを上下している感じです。東京電力の料金 スタンダードプラン(関東)|電気料金プラン|東京電力エナジーパートナー株式会社を見ると、301kWh を超えると 30.57円/1kWh のようですので、この数字をもとに計算してみると 30.57円/kWh * 0.4kWh * 24h * 30d = 約 8804 円。まぁ1万円弱くらい。こちらも GitHub sponsors の収益で一部補填させていただいております。

(ちなみに、この電気代には先ほど紹介した rubyci/chkbuild で利用している Mac mini 3台が入っています。Mac mini は一般社団法人日本Rubyの会のご支援で購入したものです)

今は寒いからいいんですが、暑い時期は(エアコンを入れなかったので)ファンがすごい音をたてていました。火事が心配。今のところ、連続稼働でも1年以上は動いています。ただ、5年たったらミドルタワーのマシン2台が壊れました。小さいマシンはもっと寿命短そう。

マシン代は(古いのはおいといて)22万円で3年で減価償却するとして7万円/年くらい。電気代は大雑把に12万円/年。つまり20万円/年くらいでしょうか。場所代とメンテ人件費が要らないのでやっぱり安いですね。落ちたら大変、ってシステムもないので、SLA も要らない。まぁ、家でマシン並べるのは趣味ですよねぇ。

余談ですが、物理マシンを手元においているのはベンチマークをとるため、という側面もあります。クラウド上のマシンだと、インスタンスガチャみたいな話もあるので、なるべく物理マシンを利用したいというところです。例えば https://rubybench.github.io/のマシンは我が家でホストしているマシンになります(このマシンも日本Rubyの会様にご提供いただきました、ありがとうございます)。新しい機能のベンチマークを真面目にとらないといけないときは、動かしているテストをとめてベンチマークしたりしています(ベンチマークのために複数台必要になることがあるためです)。

ビルド・テストプロセスの並列実行

テストの回数を増やすために、テストスイートを実行するプロセスを1つのマシン上で複数起動する、という方法があります。

テストスイートを実行すると、リソースを消費するときと暇なときがあるので、あるテスト実行プロセスが暇なときに別のテスト実行プロセスを走らせることで全体のパフォーマンス向上を目指すという考え方です。ただ、同時実行テストプロセス数が多すぎるとリソースの取り合いにリソースが消費されてしまうため、全体のパフォーマンスは悪化する危険があります。

単純にテストプロセスを複数実行すると、テスト同士で干渉することがあったので(たとえば、ファイルシステムやネットワークのポート)、いろいろ試行錯誤しながら Docker コンテナで設定をいくつかいじれば大丈夫であると突き止めました。今は 1つのマシン上で 22 の Docker コンテナがそれぞれ同時にテストスイートを実行するようにしています(build-ruby/run_sp2.rb at master ・ ko1/build-ruby)。メモリは 32GB で何とか足りています(ただし、後述する RAM ディスクはあきらめました)。

Dockerコンテナでいろんなテストを同時実行している様子(メモリ消費)

ビルド・テスト時間の短縮

最新版のRubyをビルドしてテストスイートすべてを走り終わるまでの時間を短縮するため、次のような工夫をしています。

  • コンパイル結果などを再利用する
  • RAMディスクを用いる
  • ビルド・テストを並行処理する

rubyci.org に掲載されているテスト実行は、テストの結果を確実にするため、一切のコンパイル結果などの再利用をしません。ただ、今回は数を稼ぐことが目標なので、コンパイル結果を積極的に再利用するようにしています。ただし、再利用を起因とする問題もたまにあるので、2度連続で失敗したときは、一度コンパイル結果などをすべて消し、まっさらな状態からビルドするようにしています。

メモリが比較的余っている環境では、ビルド結果はすべてRAM ディスク(tmpfs)を用いて、ちょっとでもビルドが速くなるようにしています。ただ、これどれくらい効くかは微妙です。性能に関連しそうなデータは、OSが勝手にメモリ上にキャッシュに載せたりするためです。なんとなく速いような気がする、という気持ちの問題みたいな側面が大きいです。

ビルドの並列実行は make -jNとするやつです。10年くらい前は結構これに起因するバグもあったんですが、今ではほぼ問題なく並列ビルドできています。

テストを並列に実行する、というのは、テストスイートを分割し、その結果を並列に実行するというものです。この環境で実行するRubyのテストは大雑把にわけて3グループあるのですが、そのうち1つが以前より並列処理に対応していました。数を稼ぐという目標のために、さらに1つのグループ(btest)を並列実行可能にするように書き換えました。

これらの工夫により、速いマシンを占有して「最新版をビルド→テストの実行」を繰り返し実行している環境では、「最新版をビルド→テストの実行」が2分弱程度で終わることができるようになっています。つまり、常にリポジトリから最新版の Ruby を取得しテストするため、テストが通らなくなるような問題のあるコミットをすると、はやいと2分程度でテストの失敗通知が得られるようになっています(結果は Slack に通知される)。

テストを繰り返している様子

テスト回数

これらの工夫により、1日に2000回程度のビルド・テスト実行ができるようになっています。5日で1万回。

バグを炙り出す工夫

テストを増やす

バグが混入されているにしても、そのバグを絶対踏まないコードしかなければ、そのバグを検出することはできません。そのため、広範なテストが必要になります。すでに Ruby は大きなテストセットをもっているため、それを利用しています。

また、Rubyインタプリタのソースコードには、たくさんのアサーション(プログラムのこの箇所では必ずこうなっているだろう、という状態の表明)が入っています。これも、テストの一種と考えられるでしょう。自分がコーディングする部分では、このようなアサーションを増やすことで、おかしな状態を検出できるようにしています。

これらのアサーションは多くはデバッグビルドでのみチェックが有効になります。そのため、走らせているいくつかの環境でデバッグビルドを用いて実行させています。

テストについて、理想的には、著名なアプリやライブラリを持ってきて、そのテストを最新の開発版 Ruby で動かすと良いと思うのですが、そこまで手が回っていません。

テストパターンを増やす

実行するテストはすべて一緒ではなく、さまざまなパターンでテストを走らせることでバグを炙り出そうとしています。

  • いろいろなパラメータでビルドした Ruby インタプリタでテストを実行。
  • ビルド環境(コンパイラ)のバージョンを変えてテストを実行。
  • テストの順番をランダムにして実行。たとえば、テストの実行順によってメソッドキャッシュの状況が変わるので、そこで発見できるバグがあるかもしれない。
  • テストを繰り返し実行。同じく、同じテストを繰り返し行うことで、メソッドキャッシュの状況が変わる可能性がある。

この辺を一括で記述することができるように、設定に従って Ruby をビルドし、テストを走らせるソフトウェアを書きました(ko1/build-ruby: Build Ruby from source code.)。設定一覧は例えばこんな感じ: https://github.com/ko1/build-ruby/blob/master/docker/ruby/targets.yaml

エラーへの対処

予期しない問題に対処するため、いくつか工夫しています。

  • 全実行ログの記録
  • 無限に停止するこことをふせぐためにタイムアウトを設定可能に
    • タイムアウトがあったら、gdb で関連プロセスのバックトレースをダンプ
  • core を吐くような異常終了時には core をダウンロードできるように
  • 失敗が続いたらデータを全部削除したり、実行間隔をあけたり

しかし、テストが失敗しても結局原因はわからないことも多いです。もう少し工夫したいところです。

結果を確認するためのシステムの整備

結果を集約するサイト ci.rvm.jp を作っています。見る人は限られているので、DB は SQLite3 という雑さ(なので遅い)。本当にヨワヨワサーバなので、リンクにもしていません。

失敗ページを見ると、何がまずいかわかりやすいように stderr への出力だけ実行結果の概要ページで見えるようにするなど、ちょっと工夫しています(が、世のCIサイトは無限にやってそうな話ですね)。

テストが失敗したら Slack での通知(Rubyコミッタの方々が見ているところ宛て)とメールでの通知(これは私にだけ宛て)が飛ぶようになっています。必ず失敗するようなコミットが稀に入ってしまうのですが、そのときは通知がひどいことになります。

余談:その他の考えられる工夫

非決定的な挙動をテストするためにはいろいろな手法が考えられます。

例えば、入出力やスレッドスケジューリングなど、外部のイベントをすべて決定的になるようにOSなどのレベルで整備するものです。つまり、いろんな工夫で同じプログラム(と外部からの入力)については必ず同じ結果を返すようにする、というものです。一度、問題を発見できたら、その問題が必ず再現できる、となればはかどりそうですね。ただ、研究レベルではいろいろ聞いたことがあるんですが、実際どれくらい実用になるんでしょうね。

形式手法を用いて網羅的なテストや、網羅しやすくするデータを自動的に生成する、といった手法も考えられます。こういうのできるとかっこいいですよね。

成果

毎日数千回の試行があると、けっこうバタバタ失敗するため、最初はかなり頑張って修正しました。主にテストの不備が多いので、だいぶ頑張って修正しました。

また、タイミングに起因するバグも修正することができました。手元のメモに残っているパッチだとこんなものがありました。

おわりに

本稿では、品質向上のために個人的に行っている「テストの実行回数を増やしてレアなバグを見つける」ための仕組みについてご紹介しました。

プログラムにはバグはつきものですし、大きく複雑なプログラムのバグを見つけるのは大変です。今回は、そんな試行錯誤の一端をご紹介しました。今回ご紹介したものは、とにかく力業、という感じなので、もう少し科学的アプローチもできればいいなぁ、と思っています。いい方法知っていたら教えてください。

記事中でも言及したとおり、この仕組みは多くのご支援を受けて実現しております(ご紹介できなかった方もいるかもしれないですが、ごめんなさい、感謝してます)。改めて御礼申し上げます。

マシンについては、数年前に某社から不要になったメモリ3桁GBのごついラックマウントマシンを3台いただきまして、それを別の某N社に設置させていただき、これらを含めて運用してきました(マシンの運用はN社のS様にずっと面倒見てもらっていました)。先日、これらのマシンがさすがに古かろうということで撤去されたので、その供養と感謝を込めてこの記事を執筆しました。どうもありがとうございました。

では、良いお年をお迎えください。

... 今年は「Ruby 3.2 の XXX 自慢したい」記事がないのですが、それについてはまた来年ご紹介します。

施策を "Own it"するエンジニア 〜オーナーエンジニア制度の紹介〜

$
0
0

レシピサービス開発部の新井(@SpicyCoffee66)です。いろんなゲームが好きなのでどれも中途半端にしか上手くならないことに 10 年以上悩んでいます。

この記事では、クックパッドのレシピサービス開発に導入している "オーナーエンジニア"と呼ばれる制度について紹介します。

  • クックパッドでサービス開発をしているエンジニアがどういう働き方をしているのか知りたい
  • ディレクターやデザイナーと上手く協業する方法が知りたい
  • サービス開発エンジニアはやることが多すぎてどうやって仕事をすればいいか悩んでいる

といったような方の参考になると思いますので、興味があれば是非ご一読ください。

制度導入の背景

オーナーエンジニアという役割について述べる前に、まずはレシピサービスの開発を取り巻く環境について軽く解説します。
現在クックパッドのレシピサービスは、エンジニアが 10 名と少し、デザイナーが 5 名程度、ディレクター数名の、計 20 名少しのメンバーで機能開発をしています。 開発手法にはスクラムを活用していますが、組織に合うようなチューニングを進めた結果、現在は企画や施策立案を中心に取り組む Discovery Process と、その実装とリリースに取り組む Delivery Process に分かれて運用しています。 前者はプロダクトバックログ(以下 PBL)にアイテムを追加していく役割*1を、後者はそのアイテムをスプリントバックログ(いわゆるカンバン)に落とし込んで実現していく役割を担っています*2

レシピサービスの開発におけるスクラムの全体像
レシピサービスの開発におけるスクラムの全体像

スクラムの導入自体は、開発組織の持つ課題を解決するために昨年導入されました。それにより、当時抱えていた課題は概ね解消されたと言えますが、新たに以下のような問題が発生するようになりました。

  • PBL のアイテムを数時間程度のタスクに分解しようとしたときに、要件や仕様に生煮えの部分が多く分解することができなかった
  • ディレクターやデザイナーがエンジニアにラフな相談をしたいときのやり方がわからなくなった
    • スクラム導入以前は施策ごとに担当エンジニアがつくようなやり方で開発を進めていたチームが多く、良くも悪くも個々人のコミュニケーションが多かった
  • 企画側から開発の様子が見えづらく、進捗の遅れや意図のズレに気がつくのが遅れてしまった

改めて並べてみると、開発体制の変化によってディレクター・デザイナーとエンジニア間のコミュニケーションに課題が生まれ始めていたことがわかります。この課題を解決するために生まれたのが「オーナーエンジニア」と呼ばれる制度です*3

オーナーエンジニアの役割

概要

オーナーエンジニアは PBL の各アイテムごとに担当エンジニアがつく制度で、主に以下の役割を担っています。

  • 施策の技術仕様を現実的なものに落し込む
  • スプリントプランニングでアイテムが見積もりできる状態に持っていく
  • 施策の評価指標の設計が必ず行われるように提案・助言する

この他にも、アイテムに関しての技術的な相談窓口だったり、アイディアの壁打ち相手などをやることが多いです。
役割としては技術領域の責任者なのですが、言葉の意味としては「担当施策のオーナーシップを持つエンジニア」という解釈の方が正確で、実装を中心にしながらも施策が滞りなく進行することに自体に責任を持っているという認識です。そのため、求められる知識や能力は幅広く、例えば以下のようなものが挙げられます。

  • 施策コンセプトへの理解
  • 施策理解をベースにした、実現方法の策定
  • 開発のリード
  • プロジェクト進行への理解
  • 何かあったら自分がなんとかするという気概*4

要素を並べただけではイメージしづらいと思うので、ここからは自分が実際にオーナーエンジニアを担当したときにおこなったことを紹介していきます。

実例

1. オーナーエンジニアのアサインとキックオフ

バックログリファインメントにおいて、優先順位が上がってきたアイテムにはオーナーエンジニアがアサインされます。今回自分にアサインされたアイテムは「つくれぽを投稿することによって自分がつくりたいレシピに出会いやすくなる体験をつくる」といった、規模が大きく抽象度も高いものでした。叩きのアイデアはあるものの、ここから仕様を取捨選択し磨き込む必要があるフェーズのアイテムだったため、ある程度長期間のプロジェクトになることを見越してキックオフの設定を提案しました。
キックオフでは、施策の目的やコンセプト・手法の概要など現時点で決まっていることを施策オーナーからインプットしてもらい、今後の進め方と次のアクションを決定します。今回は、前述したようにある程度長期間の伴走が必要になるアイテムだと感じたため、一時的なプロジェクトと考えて定例ミーティングも設定してその場は解散となりました。

余談ですが、自分はキックオフミーティングに必要な要素を以下のようなモデルで捉えています。このモデル自体は組織や個人によって変わってくると思いますが、こういうイメージを頭の中に持っておくと、自分で設定する時は抜け漏れが減り、キックオフに招待されたときにも足りない部分をフォローすること等ができて便利です。

キックオフの概念モデル
キックオフの概念モデル

2. 施策コンセプトの理解

キックオフで聞いた概要をもとに、施策の目的やコンセプトを自分の頭に叩き込んでいきます。開発のフェーズになると他のエンジニアメンバーとコミュニケーションを取ることも増えるため、この施策ではどういう価値を提供したいのか、あるいは検証したいのか、部署の目標とはどう繋がっているのか、その結果どういう手法を取るつもりなのかといったことを自分の言葉で話せるようにしておきます。
具体的には施策オーナーやデザイナーへのヒアリングをしたり、仕様やデザイン案を読み込んだり、ユーザーインタビューの録画を見たりしました。この部分の解像度が低いと、後の仕様を詰めていく工程で削るべき部分や残すべき部分の判断がつかなかったり、設計工程において適切な判断ができなかったりするので、しっかりと労力を割きました。

3. 仕様の相談と決定

施策のコンセプトを理解した上で、ディレクターの仕様やデザイナーのデザインに対して改めて確認や提案をしていきます。最初に出てきている案は検証したい価値に対して機能過多になる傾向があります。そのため、基本的には自分の中で MVP になる体験をイメージしつつ、削れそうな仕様を探したりフェーズ分割できそうなポイントを探したりしながら、仕様をコンパクトにできないか提案することが多いです。

こういう開発はなるべく避けたい
こういう開発はなるべく避けたい

加えて、リリース後にどういう分析をしたいのかを確認し、提案を交えながら埋め込んでおく必要がありそうなログをリストアップしておきます。後で「あ〜〜〜!ログ埋まってなかった!!!」というのはやりがちなミスなので、この時点でケアできていることが望ましいです*5

やりがちな失敗
やりがちな失敗

また、施策オーナーは仕様の技術的な難易度を認識していないことが多いため、その辺りもすり合わせながら整理していきます。実装難易度が高くなりそうな箇所については、改めて重要性の確認をしたり、ザックリの工数感と代替案を提示した上で再度意思決定をお願いしたりします。逆に、それほど工数がかからないような詳細の磨き込み等は、こちらから仕様追加を提案することもあります。
他にも、施策オーナーの希望しているスケジュール感を確認し、現状開発チームが出せているベロシティによっては、PBL 上での優先順位を上げてもらうよう PO とコミュニケーションを取ってもらうように提案したりもします。開発チームの現状はエンジニアからの方がよく見えるので、プロジェクト進行面でのフォローも効果的です。

ここで心掛けているのは、あくまで施策のゴールを達成するために仕様を煮詰めていくという意識です。仕様を削れば削るほど実装は楽になりますが、それ自体が目的になってしまっては必要な仕様まで削ってしまうことになりかねません。エンジニア目線だけを持つのではなく、施策のゴール達成という目標を同じ目線で見るプロダクト開発者として考えることが重要です。そのためにも 2 のフェーズで施策に対する解像度を高めておくことが重要になります。

4. 概算見積もり

ある程度固まってきた仕様をもとに概算で工数を見積もっていきます。設計方針を決め、コード調査をし、場合によっては他のエンジニアに相談しながら進めていきます。
施策の目的が検証であればデーターソースは YAML でよいと割り切れる*6が、恒常的な機能を開発するなら DB に table をつくる。施策の確度が低く、リリース後もどんどん改善していく想定であれば後から消しやすいように別の table でデータを持つが、そうでないなら今ある table に column を追加する。今後想定している利用者数の推移に基づいてスケーラビリティをどの程度考慮するか決めるなどなど……。このフェーズでも施策理解の解像度に左右される意思決定が多数存在します。繰り返しになりますが、施策意図のインプットが非常に重要です。

参考までに、自分はデザイナーが Figma で描いてくれたデザインをコピーしてきて画面遷移図をつくり、その上に付箋をペタペタする形で見積もりをつくっていくことが多いです。こうすることで、必要実装の抜け漏れが減りますし、視覚的にどういう機能をつくればいいかがわかりやすくなります。Figma 上で仕様やデザインについての質問が完結するのも便利です。

Figma での見積もり
Figma での見積もり

一人でグッと考えていると行き詰まることも多いので、なるべくラフに他のエンジニアに相談するように心掛けています。実際に開発に入る前の段階からエンジニアメンバーに施策概要が浸透することにもつながるので、一人で抱え込むよりは早めに状況を開示することが大事だと考えています。今のチームは #recipe-sekkei-inquiry という slack チャンネルを運用しており、割と雑な質問や相談が飛び交っているのがいい環境だなと思います*7
最終的にはエンジニアが集まる「概算見積もり会」という会議体に持ち込み、参加者から「ざっくりよさそう」の合意が取れれば、優先順位の高いものから順にスプリントに乗っていくことになります。

5. 開発

ここから先は基本的には一般的なスクラムの進行になります。スプリントプランニングでアイテムの詳細見積もりをし、タスクに書き下してスプリントバックログに貼る。デイリースクラムで進捗を確認しながら、メンバーが各々取り組みたいタスクを選んで受け持っていく。といった流れです。
ただし、少し規模の大きな開発になる場合は、最初に「設計ドキュメントを書く」というチケットを作成し、エンジニアメンバー間で認識をすり合わせるためのドキュメントを執筆します*8。設計ドキュメントのフォーマットは現状規定されていませんが、issue へのリンク、最低限の仕様、デザインのスクリーンショット、レスポンスの形式、実装予定のサービス名や該当ファイルなどが含まれていることが多いです。このチケットは原則としてオーナーエンジニアが取ることが多く、なるべくスプリントの序盤に取り組むことが推奨されています。

設計ドキュメントのイメージ
設計ドキュメントのイメージ

開発が始まってからは、他のメンバーが実装してくれた箇所については、積極的にコードレビューに入るなどして、仕様漏れのキャッチや整合性の担保ができるように心掛けます。

6. 分析・評価

機能をリリースした後は、分析・評価をおこなって next action を決定するまでが施策です。といっても弊チームではエンジニアではないメンバーが平気な顔をして分析 SQL を書いたりするので、3 の工程で正しくログを設計できていればあまりやることはないのですが。最近では、機能リリースの目処が立った時点で「数字を見る会」なる会議体が設定され、そこで関わったメンバーみんなが数字を見ながらやいのやいの言いつつ next action が決まるケースが増えてきました。分析・評価が実施されることや、その透明性を担保するための一つのプラクティスになりそうです。

雑感

本記事では、クックパッドのレシピサービスで導入されているオーナーエンジニアという制度について紹介しました。
改めて並べてみるとなかなかやることが多く「これは本当にエンジニアの仕事なのか?」と感じる業務まで含まれているようにも見えます。しかし「専門職が集まって分業する」というのは、得てして下図のように、イメージと現実の間にギャップがあるものです。我々がユーザーへの価値提供にフォーカスする限りは、現実の中で境界にあるような仕事を誰かが拾って進めていく必要があり、そのためにもできることは多い方がいいでしょう*9

分業のイメージと現実
分業のイメージと現実

もちろん自分だって凄腕デザイナーに CSS の修正をしてもらったり、視野広ディレクターに考慮漏れを拾ってもらったりしたことが数多くあります。僕は、専門性に軸足を置きながらも役割にとらわれないメンバーが多いチームは強いと信じているので、自分もそうありたいと思います。

また、今年自分でやってみて思いましたが、シンプルにオーナーエンジニアの仕事は楽しかったです。僕は「サービス開発エンジニアになりたい!」と言いながら 2017 年にクックパッドに入社し、エンジニアをやったり PjM をやったり部長をやったりしていましたが、どれもエンジニアリングとサービス開発に対して同時に向き合うことは難しい仕事でした。今年それらの経験を経た上でエンジニアに戻り、オーナーエンジニアをやってみたところ

  • プロダクト開発と技術力の両面を同時に求められ、向き合う必要がある
  • それぞれの能力が施策のクオリティに直結する実感がある
  • その上で、エンジニアリングという専門性に軸足を置いているので(キャリア的な意味で)自我を保ちやすい

という実感がありました。サービス開発にはさまざまな要素が存在するため、日々の仕事の中で自分の役割に迷う方も多いのではないかと思います。そんな方の参考になれば幸いです。

この記事の内容について質問などある方は、気軽に Twitter などにご連絡ください。選考応募はもちろんのこと、カジュアル面談も積極募集しているため、チャネルにこだわらずお声がけいただければと思います。

*1:特定の人だけが PBL にアイテムを追加できるというルールではなく、あくまでメインの役割として担っているメンバーです。実際に、たとえば技術的負債解消のためのアイテムが Delivery Process のメンバーから起票されるようなこともあります。

*2:あくまで同じチームではあるため、レトロスペクティブ等は合同で開催しています。

*3:スクラム導入以前に発生していた課題については Cookpad TechConf 2022で発表された「レシピサービスにおける持続的なプロダクト開発プロセスについて」というセッションで、Discovery Process と Delivery Process やオーナーエンジニアについては Cookpad Lounge #15でも触れられているので、よろしければ併せてご覧ください。

*4:弊社 Values の一つ "Own it."の精神です。

*5:この辺りの考え方は、自分がリーンスタートアップをもとにプロダクト開発をしている影響もありそうです。過去には MVP に触れている記事も投稿しているので、よろしければ参照ください → https://techlife.cookpad.com/entry/2018/02/10/150709

*6:極端な例として挙げているものの多くの場合はよくない

*7:この辺りの話は Kaigi on Rails 2022 で弊チーム Techlead の akamatsu が話していたので、興味のある方は併せてどうぞ → https://kaigionrails.org/2022/talks/ukstudio/

*8:こちらも前述した akamatsu の発表で触れられています

*9:弊社エンジニアリングマニフェストにある "境界を越える"の精神です。

Next.js アプリケーションの共通コンポーネント開発

$
0
0

こんにちは。レシピサービス開発部のkaorun343です。クックパッドではスマートフォン向けページにおける開発者体験向上のために、レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話にて紹介したとおり、Next.jsとGraphQLを用いたモダンな環境へと移行を進めています。例えばモバイル端末からのアクセスでURLがトップページの /であれば Rails、レシピ詳細ページの /recipe/:idであれば Next.js アプリにルーティングされるようになっています。現在ではレシピ詳細ページだけではなく検索結果ページやつくれぽ詳細ページ、MYフォルダページなどもNext.jsアプリケーションに置き換わっています。今回はその移行により生じた課題と取り組み方、それから併せて実施したモノレポ環境整備について紹介します。

共通コンポーネントの導入背景

cookpad.com では上述の通りページによってホストするアプリケーションが異なる一方で、ヘッダーやサイドメニュー、フッターといった全てのページに共通して表示されているコンポーネントがあります。このような、Next.js と Rails アプリの両方から利用される UI コンポーネントのことをこの記事では "共通コンポーネント"と呼ぶこととします。

クックパッドではハロウィンやクリスマスにあわせてヘッダーやフッターのデザインを変更する施策を定期的におこなっています。したがって共通コンポーネントに変更を加えたいとき、2つのアプリケーションのその両方の実装を編集しなければなりません。さらにこの2つのアプリケーションは使用している言語が異なるため、実装担当者にとってはさらなるコストになっています。 そこで実装を一度で終えられる仕組みを導入しました。

トップページとレシピ詳細ページのサイドメニュー(共通コンポーネントの例)

実装方法

結論としては、共通コンポーネントを以下の方針で実装することにしました。

  • Reactコンポーネントとして実装し、Next.jsでは通常のReactコンポーネントとして利用する。
  • 少ない労力でRails上で表示するために、上記のReactコンポーネントをウェブコンポーネントでラップする。

developer.mozilla.org

Reactによる共通コンポーネント作成

共通コンポーネントはReactで作ることにしました。この方針には2つ理由があります。

1つめは、Next.js上では今まで通り通常のReactコンポーネントとして使うためです。Vue.jsやSvelteなど他のパッケージを使わないため余計なコードが含まれなくなり、バンドルサイズが増加したり表示が遅くなったりする心配がありません。加えて、Next.js上で容易にSSRすることができます。

2つめは、Next.js アプリに実装済みのコンポーネントを共通コンポーネントとして再実装する際にその実装をほぼそのまま使えるためです。実際、Next.js用に実装したコードを少し修正するだけで済みました。

Storybookによる開発環境

共通コンポーネントの開発に際してはStorybookを導入しました。クックパッドではプルリクエストごとにステージング環境を作れる基盤があるので、このStorybookもプルリクエストで確認できるようにしました。これで変更内容をレビュアーが容易に確認できます。

また、表示の確認だけではなくテストにも使っています。Storybookの中で事前にpropsを渡したりcontextのproviderで包んだりしているので、テストの実装を綺麗にできます。

import{ MyReactComponent } from './MyReactComponent'import{ MyProvider } from '~/contexts/MyContext'exportdefault{
  component: MyReactComponent,
  decorators: [
    (Story) => (
      <MyProvider>
        <Story />
      </MyProvider>
    )
  ],
}exportconst Default = {}exportconst Prop1 = {
  args: {
    prop1: 'prop-1',
  },
}
import{ composeStory } from '@storybook/testing-react'import{ axe, toHaveNoViolations } from 'jest-axe'import * as stories from './MyReactComponent.stories'

expect.extend(toHaveNoViolations)

const storyNames = ['Default', 'Prop1']

describe('MyReactComponent', () => {
  describe.each(storyNames)('%s', (storyName) => {const Story = composeStory(stories[storyName], stories.default)

    it('スナップショットテスト', () => {const{ container } = render(<Story />)
      expect(container.firstChild).toMatchSnapshot()
    })

    it('アクセシビリティチェック', () => {const{ container } = render(<Story />)
      expect(await axe(container)).toHaveNoViolations()
    })
  })

  describe('Prop1', () => {const Story = composeStory(stories.Prop1, stories.default)

    // 個別にテストしたいことを書く})
})

こちらの記事を参考にしました。 zenn.dev

リソース取得のためのAPIサーバー

Next.js版ではAPIサーバーとの通信に専用のGraphQLサーバーを使っています。共通コンポーネントの実装にあたっては、専用のGraphQLサーバーを用意するとサーバーの実装やCI/CD環境を構築する手間がかかることや、Next.jsのGraphQLサーバーと機能が重複していることから、Next.jsと同じGraphQLサーバーを利用することにしました。

ウェブコンポーネント作成

Rails アプリに直接 React コンポーネントをマウントしようとすると Rails アプリ側にたくさん JS のコードを書く必要があってつらいです。加えてRails側にReactコンポーネントを変換するための環境を構築しなければなりません。また、共通コンポーネントが内包するリセット用 CSS などをRailsアプリに影響させたくないといった課題がありました。 そこでRailsアプリケーションに導入する際は、Reactコンポーネントをウェブコンポーネントで包むことにしました。

ウェブコンポーネントは、再利用可能なカスタム要素を作成し、ウェブアプリの中で利用するための、一連のテクノロジーです。コードの他の部分から独立した、カプセル化された機能を使って実現します。 ウェブコンポーネント | MDNより

ウェブコンポーネントは複数の技術要素から成り立っています。カスタムエレメントが前者を、Shadow DOM が後者を解決してくれます。

カスタムエレメント

Reactコンポーネントをマウントする場合、自力でマウント対象の要素を探し、マウントしなければなりません。一方でカスタム要素にすることでそれを利用する側ではカスタム要素のタグを書くだけで済みます。

Shadow DOM

ReactコンポーネントをShadow DOMの子要素にマウントすることにしました。 共通コンポーネントはNext.jsアプリケーションと同じリセットCSSをベースにスタイリングしています。もしShadow DOMがなければRailsアプリケーションも同じリセットCSSを導入するか、共通コンポーネントのスタイリングそれぞれにリセットCSSを含めなければいけません。一方で Shadow DOMを使えばリセットCSSをShadow DOMの一番最初に追加するだけで済みます。

実装例

ウェブコンポーネント版の共通コンポーネントパッケージでは、Reactコンポーネントを表示するカスタムエレメントを提供します。

exportclass MyCustomElement extends HTMLElement {
  connectedCallback() {const mountPoint = document.createElement('div')
    // シャドウルートをカスタムエレメントに追加this.attatchShadow({ mode: 'closed'}).appendChild(mountPoint)
    // Reactコンポーネントのルートオブジェクトを作成const root = createRoot(mountPoint)
    const prop1 = this.getAttribute('prop1')

    // ウェブコンポーネント用のEmotionの設定const emotionCache = createCache({
      key: 'my-custom-element',
      container: mountPoint,
      prepend: true,
      speedy: true,
    })

    // Reactコンポーネントのレンダリング
    root.render(
      <CacheProvider value={emotionCache}>
        <GlobalStyles />
        <MyReactComponent prop1={prop1} />
      </CacheProvider>
    )
  }}

共通コンポーネント(ウェブコンポーネント)を利用する側では、まずカスタムエレメントを定義します。そしてHTMLにそのカスタムエレメントのタグ名を記述します。

import{ MyCustomElement } from '@cookpad/shared-components'

customElements.define('my-custom-element', MyCustomElement)
<my-custom-element prop1="prop-1"></my-custom-element>

モノレポ環境整備

モノレポ環境整備を整備する前の状況

共通コンポーネントはNext.jsとGraphQLサーバーのリポジトリに存在します。開発当初はモノレポ環境にしていなかったため、それぞれのパッケージが独立していました。ゆえに共通コンポーネントをNext.jsで使うためには一度npmパッケージとして公開するか、もしくは yarn link機能を使わなければならず手間がかかっていました。加えてCIジョブもそれぞれのパッケージごとに作っていたので、今後新たにパッケージを増やすとそのたびにCIジョブも作らなければいけませんでした。そこで共通コンポーネントの実装と並行して、モノレポ環境を整備していきました。

Yarn Workspacesの導入

ワークスペース機能を使って共通コンポーネントを直接参照できるようにしました。Next.jsに共通コンポーネントパッケージをインストールするときはYarn workspacesを利用し、別リポジトリにあるRailsにインストールするときは社内のプライベートnpmレジストリに公開してから使っています。

yarnpkg.com

Turborepoの導入

Turborepoはモノレポ管理ツールと呼ばれるものです。設定を比較的簡単に書けるのが特徴です。また他のモノレポでは複数の実行環境に対応しているものもありますが、TurborepoはNode.js用の管理ツールです。対象のリポジトリはJavaScript(Node.js)だけからなるモノレポなので、Turborepoを採用することにしました。 Turborepoを導入することで、CIのジョブを1つにまとめることができました。また、変更があったパッケージとそれに依存するパッケージのみ必要なタスクを実行してくれるので、ジョブの実行時間が短くなりました。例えば共通コンポーネントパッケージに変更があった場合は、Next.jsのパッケージもテストなどを実行してくれる一方でGraphQLサーバーについてはタスクの実行をスキップしてくれます。

turbo.build

さいごに

今回はレシピサービスの共通コンポーネント導入とモノレポ環境整備について紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を引き続き進めていきます。この取り組みを一緒に進めてくれる仲間を募集していますので、興味のある方はぜひご連絡ください。

cookpad.careers

エンジニアリングマニフェストを刷新しました: クックパッドの開発者文化をあらわす3つの言葉

$
0
0

CTO の成田(@mirakui)です。 クックパッドはいま、2017年からの10年間を投資フェーズとして位置づけていて、ミッションである「毎日の料理を楽しみにする」を実現するため、長期的に成長していけるための事業づくり、組織づくりに投資をしています。

クックパッドは価値のあるプロダクトをユーザに届け続けることが事業の中心にあり、そのためにはエンジニアの活躍が不可欠です。クックパッドにおけるエンジニアというのは、単なる「実装担当」ではなく、自発的に課題を発見し、一人ひとりの専門性や創意工夫をもって開発をしていくことが求められます。強い個人が集まって、それぞれの能力のうちの高い部分をお互いに引き立て合って、一人ひとりでは解決できないような大きな課題に取り組めるようになる。私は CTO として、クックパッドのエンジニア組織をそのように強化していきたいと考えてきました。

エンジニアリングにおいては、エンジニアの人数を増やしていくことが組織の生産性をあげるかというと、私はそうではありません。エンジニア10人の組織と100人の組織とでは、プロダクトの品質という意味では差が出てくるかとは思いますが、突破できる事業課題の大きさにはあまり人数は関係ないのではないかと考えています。それよりも一人ひとりが重要な課題に取り組めているかどうかが重要です。平均的な100人よりは精鋭の10人の組織でありたいですし、少数精鋭の10人が集まった100人の組織でありたいと考えています。そういったエンジニア組織を目指すため、チームワークによる生産性を目指すよりも先に、個々人の能力、リーダーシップを高めることを重要視しています。

私は今年、一人ひとりが自律的で卓越したエンジニアの集団を目指すために、クックパッドのエンジニアとしてのあるべき考え方・文化を言語化した「エンジニアリングマニフェスト」を制定しました。実はマニフェスト自体は以前から存在していたのですが、これからの成長を見据えて、新しい言葉で置き換えることにしました。それぞれ日本語と英語の表記を用意し、これを日本だけでなく海外のエンジニア組織も含めて統一しています。

クックパッドのエンジニアリングマニフェストは、次の3項目からなります。

  1. 境界を越える (Beyond boundaries): 私たちは一人ひとりが高いリーダーシップを持った個人になるために、自分の能力の限界や、責任範囲に対する思い込みを乗り越えて成長していくエンジニアを目指します。
  2. 技術を楽しむことに責任を持つ (Responsible for enjoying technology): 私たちは一人ひとりが技術を楽しむ集団であることを目指します。困難な状況であっても、常に技術的な挑戦の機会を諦めません。一人ひとりに技術を楽しんでいる自分を目指す責任があります。
  3. 作ったもので語る (Speak with what you make): 私たちは動くものをすばやく生み出せる存在として価値を発揮します。また、作ったものを実際に触れることで得られる気づきを尊重します。

以下、それぞれについて解説します。

1. 境界を越える (Beyond boundaries)

「境界を越える」は最もクックパッドのエンジニアらしさを表す言葉だと考えています。これは、知らず知らずのうちに自ら作り出している「境界」を見つけ出し、それを乗り越えていこう、ということを意味しています。境界は、例えば自分の能力の限界や、所属している部署の役割、自分の役職に与えられている裁量だったりします。

たとえばエンジニアが一人しかいないスタートアップであれば、その人はコーディングだけでなくさまざまな領域をカバーしなければなりません。しかし組織が大きくなると役割は細分化され、自分の限られた領域の仕事をしていれば済むようになります。その場合たとえ組織の人数が増えても、ただ役割を細分化していくのでは、本当の意味で課題に立ち向かう人数は増えていないということになりかねません。 自分の役割にとらわれずに、スタートアップの一人エンジニアのような責任感や柔軟さ、視座をもって立ち回れることは、クックパッドのエンジニアとしての大きな強みになると考えています。

2. 技術を楽しむことに責任を持つ (Responsible for enjoying technology)

二つ目として、私のエンジニアとしてのスタンスを最も表した言葉を選びました。

私たちの会社のミッションは「毎日の料理を楽しみにする」であり、「楽しみ」というものが生み出すエネルギーの大きさを信じています。世の中の技術は常に新しくなっていきます。新しい技術が登場してはコモディティ化し、エンジニアに求められる知識も日々変化しています。エンジニアの仕事というのは、そのような時代の流れのなかで常に技術の引き出しを新しくしながら磨いていく必要があります。どんなことでも、頼まれた仕事だから仕方なくやってる人は、楽しくて仕方が無いからやっている人には勝てません。

技術の学習についても同じで、これはどうやって動いてるんだろう、とか、この仕組みをもっと知りたい、とか。こんなことを出来る技術はあるのかな、とか。クックパッドのエンジニアには、こういった好奇心、技術的興味といったものを膨らませて、技術を楽しんで欲しい。

私はさらにこれを「楽しむことに責任を持つ」という強い言葉で結びました。これは、楽しんでいないのだとしたら、楽しんでる自分に持っていくことをそれぞれの責任でやってほしいという願いが込められています。誰かにそうしてもらうのではなく。

私はクックパッドで十年以上勤めてきたなかで、多くの卓越したエンジニアの方々と働く機会に恵まれました。卓越したエンジニアは、特にこの「技術を楽しむ」ということに長けています。忙しい業務のなかでも技術的なチャレンジをうまく入れ込んできます。日々の開発や技術の研鑽を「仕事だからやっている」から「楽しいからやっている」になれたとき、エンジニアとして次のレベルに成長できると考えています。

3. 作ったもので語る (Speak with what you make)

プロダクト開発では、初手から完成品を作るのではなく、まずは小さく価値を表現できる MVP を開発して検証する、というのはすでに世の中の当たり前になってきています。動くものを作り出せるというのは、エンジニアの強みどころか、存在理由でさえあると私は考えています。だからこそ、エンジニアがアイデアを表現する手段は、言葉よりもコードであってほしい。「こういうものがあったら良いよね」ではなく、「こういうものを作ってみたんだけどどうかな」という会話であるべきです。

私はクックパッドで働いてきて、サービス開発のプロセスは探索的であり、10個作って10個捨てることの繰り返しであると学びました。本当に優れたプロダクトを目指しているとき、比類無き一手を生み出せないくらいなら、一手も進めない方がマシだからです。打席に立つ回数や質を上げていくには、エンジニア一人ひとりが「作ったもので語る」ことが当たり前な文化があれば、より確度の高い一手に近づくことができるはずで、それによって未来を変えていけると私は信じています。

おわりに

クックパッドのエンジニア組織は、一人ひとりが卓越したエンジニアとなり、個人の能力の掛け合わせによって大きな事業課題を解決していくことを目指します。そのために重要と考える3つの要素を「エンジニアリングマニフェスト」として言語化し、これからのクックパッドのエンジニア文化としていきます。

【前編】企業所属のRubyコミッター対談! 〜企業に所属するOSS開発者って何?〜

$
0
0

こんにちはCTO室の緑川です。今回はアンドバッドさんが主催しているPodcast「ANDPAD TECH TALK」第12回目のゲストに弊社の@mameが出演したので、対談内容を書き起こしました。Podcastとしてお聞きしたい方は下記のアンドパッドさんの記事からお聴きください。

tech.andpad.co.jp

トーク本編

櫻井:皆さま、こんにちは。アンドバッドの開発本部でエンジニアリングマネージャーをしている櫻井です。

櫻井:12回目のANDPAD TECH TALKです。ANDPAD TECH TALKはアンドパッドの中の人をゲストに招いてあれやこれやお話するカジュアルなテック系Podcastなのですが、今回スペシャル対談会として、社外ゲストをお招きして、アンドパッドの中の人の対談をお届けしたいと思います。今回は企業に所属するRubyコミッターであるお二人をお招きしております。

櫻井:アンドパッドからはRubyコミッターでありフェローの柴田さん、対談相手はクックパッド株式会社所属のRubyコミッターであるmameさんこと遠藤侑介さんをお呼びしております。柴田さんと遠藤さんの対談は前編・後編の2回に分けて配信します。前編では「企業に所属するOSS開発者ってなに?」と題して話を掘り下げていきたいと思います。

自己紹介

櫻井:本日はよろしくお願いいたします。早速ですが、遠藤さんの自己紹介をお願いできますでしょうか?

遠藤:こんにちは遠藤といいます。僕は大学院を修了したあと、企業の研究員として働いている時に、趣味でRuby開発に携わり始めました。それをしばらく続けていくうちにクックパッドに転職をして、今は笹田耕一さんというRubyのVMまわりを主に開発しているRubyコミッターと一緒にフルタイムでRubyの開発をするチームに所属して、日々活動しています。

遠藤:自分のRuby開発の担当範囲はいろいろな範囲にわたっていて、一番みんなが使っているところで言うと、キーワード引数周りの設計や実装を担当したのが大きいかなと思います。他には、コードカバレッジの測定をするためのバックエンドとなるライブラリの開発はクックパッドに入る前からやっています。クックパッドに入社したあとはRuby 3に型システムを入れるという話があったので、型システムまわりの議論をまとめ、TypeProfという型システムの試作を中心にやってきています。趣味はQuine(クワイン)と呼ばれる自分自身を出力するプログラムを書くということで、この趣味が高じて本を書いています。

櫻井:ありがとうございます。ちなみに、遠藤さんはmameさんというお名前で活動されていると思いますが、本日は遠藤さん、とお呼びさせていただこうかと思っていますがよろしいでしょうか?

遠藤:はい

櫻井:ありがとうございます。ちなみにですね、mameさんというハンドルネームの由来をお伺いしてもよろしいですか?

遠藤:子どもの頃のあだ名ですね。遠藤豆とよく呼ばれていたので、そこから取ってきたハンドルネームですね。日本チームにしか伝わらない。

櫻井:ありがとうございます。すごいシンプルな由来ですね。

一同:笑

櫻井:遠藤豆さん、と言うところからですかね?

遠藤:そうですね、単純に。

櫻井:ありがとうございます。皆さん、シンプルな理由でした。では、柴田さん、自己紹介をお願いいたします。

柴田:はい、よろしくお願いします。柴田 博志といいます。アンドパッドには、2022年の11月1日にフェローという肩書きで入社しました。前職では、執行役員(VPoE)だとか技術部長を兼務していて、マネジメントを主に担当していたのですけれども、現在は開発本部の直下に所属している一エンジニアみたいな肩書きになっていて、毎日部門で見かけた課題だとかエンジニアやマネージャーなどの幅広い人の相談にのったりと、前職の経験を活かしながら開発組織を支援する業務を担当しています。あとですね、フルタイムでRubyの開発をするのがメインの業務としていまして、僕は主にRubyで書かれている(Cで書かれているものもあるんですけど)、Rubyのライブラリであるとか、ライブラリを使うための RubyGems bundlerというソフトや仕組みがあるんですけど、そっちの開発を行っています。あとはですね、そのRubyというソフトウェアを開発するために必要なインフラであるとか、あとはSaaSのようないろいろなサービスを利用しているんですけど、そのメンテナンスとか管理も担当しています。趣味としてはいろいろと幅広く、アウトドアの趣味としては最近は美術館巡りで、アート・芸術・社会学関係の本を適当に買って読むということをやったり、あとは家の中ではゲーム・アニメ・漫画など家の中でできる遊ぶ系のものは大体プレイしたりいろいろやっています。最近は先週くらいにThe Witcher 3というPlayStation 4で出てたものすごい名作であるゲームが、PlayStation 5バージョンで出たのでずっとそれをやっています。 

櫻井:ありがとうございます。The Witcher 3の5バージョンが出たんですね。

柴田:はい、出たんですよ。

櫻井:今、アウトドアとインドアと幅広い趣味を紹介いただいて、どこを深く聞こうかな、と思ったのですが、思わずThe Witcher 3に食い付いてしまいました。

一同:笑

柴田:そうなんですよ。PlayStation 4の時は、ちょっと買って1時間くらいプレイして、まぁいいやって置いといたんですよね。PlayStation 5バージョンが出たのでやってみるか、と思って1時間ではなく4時間くらいプレイしたらめちゃくちゃ面白いじゃんとなって、今20時間くらいプレイしているんですけど、全然終わる気配がないので、もっともっと時間を投入してプレイしようかと思います。

櫻井:The Witcher 3、こう聞くと、私もやってみてみたくなるので意識しておきます。

Rubyコミッターってそもそも何?

櫻井:Rubyコミッターってそもそも何? どういうことをする人なの? という方も視聴者の中にはいらっしゃると思いますので、本日はそういったところもお二方に聞いていけたらなと思っております。遠藤さんと柴田さんは古くからお互いをよく知る間柄だとお聞きしましたが、どういったきっかけで知り合うことになったのでしょうか?

遠藤:はい、柴田さんと知り合ったのはRubyの開発がまさにきっかけですね。僕が2012年ごろにRuby2.0というバージョンのリリースマネージャーをやっていて、Rubyをリリースするためにいろいろな活動をしていたのですけど、その時に柴田さんが新しいRubyでRailsアプリケーションを走らせてみたり、結構テストしていてくれていて動かないところを報告するという活動を継続していてくれていたのを見ていて、その時にRubyの開発者であるまつもとゆきひろさんに「柴田さんへコミット権限を差し上げたらどうでしょう?」という話をして「いいね、いいね」という話になって、それがきっかけで柴田さんがRubyのコミッターになったという話があります。

櫻井:なるほど、その頃から柴田さんも遠藤さんのことはご存じだったのですか?

柴田:ちょっと覚えていなくてですね、たしかこの時ってRubyの20周年イベント前後ぐらいでしたよね? 2012年の2月前後だったと思うんですけど、当時はSlackもなかったし、Twitterはあったけどどういう感じで使っていたかわからなかったので、おそらくメールとかRedmineという課題管理ソフトを使っているんですけど、その上で頻繁に見かける方だったなとか、Ruby2.0をリリースするためにいろいろチケットにコメントしたり、クローズしたり、オープンしたりしている方だなくらいの理解でしたね。

櫻井:なるほど。遠藤さんがRubyコミッターになったきっかけや馴れ初めはどうですか?

遠藤:大学時代からRubyに簡単なパッチを書いて送るといった貢献を少しずつしていたのですけども、会社に研究員として働き始めた後くらいからRuby 1.9.0のバージョンのリリースがあって、その頃から本格的に貢献を始めてコミッターになりました。1.9.0のRubyというのは野心的な新機能がたくさんあってバグが結構あったんですよね。なのでバグ出しやパッチ作成などの貢献の余地が非常に多くあったのと、Ruby 1.9.0のリリースをするのにいろんな人たちが寄ってたかってパッチを書いているのが学園祭というかお祭りみたいだったんですよね。それがすごく楽しかったと思ったので深く関わり出したきっかけです。

櫻井:なるほど。楽しそうな印象が伝わってきました。お二人が今の関係性になるまでどんな接点があったんでしょうか?

柴田:そうですね。いつの間にかRubyコミッター同士で知り合いというか仲がいいというか、大体みんなのことを知っているよね、と今はなっているのですけど、馴れ初めを思い出すとなかなか難しいな。僕の記憶だとRubyKaigiのいつだったかの開催の時に、mameさんがクックパッドに転職します、みたいな紹介の回があって、その時くらいから遠藤さんと話す機会が増えてきたと記憶しているんですよね。それまでは遠藤さんは別の会社で働かれていて、昼間であるとか、Rubyに関する活動以外の時間は何しているかは謎みたいな状況だったのですけど、そのクックパッドに転職されて以降はRubyもRuby以外の生活も最近の話だとかそういった部分でもいろいろと会話することがどんどん増えてきていて、実際にRubyの何かをする時だとか、例えばサーバーを何か引っ越したりだとかしますとか、開発に便利なものを何か作ります、というときも遠藤さんに結構手伝っていただいているので、二人でいろいろあーして、こーしてみたいなところを二人三脚でやるといった機会が増えてきて、今に至っているのかな、と思います。あと確か、間違っていたらひどい話なんですけど、同い年なんですよね?

遠藤:そうですね。

柴田:というのもあって、同年代エンジニアとして頑張るぞ、という感じで僕は捉えています。

櫻井:なるほど、それで今回、対談のお相手にぜひ遠藤さんと話したい、というお話が柴田さんからあったということですね。

柴田:はい、そうです。

遠藤:柴田さんとは、説明にあった通り、Rubyの開発をするインフラ管理なんかでだいぶお世話になっていますし、RubyKaigiとかRubyConfとかのRubyに関するイベント参加の時に一緒にうろうろするとか一緒にご飯食べるとかさせていただいています。なので、今回このアンドパッドにフルタイムのコミッターとして転職されて、どんな話をするのか非常に面白そうだと思って今回呼ばれました。

櫻井:今日のテーマが「企業に所属するOSS開発者ってなに?」なんですが、そもそもRubyコミッターってなに?という方も視聴者の中にはいらっしゃると思っています。なので、お二人から少し解説をいただけると嬉しいな、と思っていて、遠藤さんお願いいたします。

遠藤:はい、コミッターというのは、昔のバージョン管理システムの用語なんですけども、ソースコードの更新をする権限を持っている人のことです。gitだとコミットというのは履歴に変更を積むというだけの作業なんですけど、昔、gitよりも前のバージョン管理システムCVSだとかSVNだとかサーバー側に置いてあるソースコードを更新する行為のことを呼んでました。なのでgitの時代というとmergeだとかpushに相当するのですけど、その当時にコミットできる権限を持つ人をコミッターと、特に日本のRuby界隈ではソースコードを変更する権限を持っている人のことをコミッターというふうに呼ぶことが多いと思います。

櫻井:なるほど。そういう経緯からコミッターと呼ばれているんですね。僕も知らなかったですし、詳細までありがとうございます。

どうしてオープンソースで開発をしているのか?

櫻井:最近だと、オープンソースのソフトウェアを利用して、企業がサービスやプロダクトを開発するということが当たり前のようになってきているかな? と思うんですが、若い方はOSSとはそもそもなんなのか? あとどうしてオープンソースで開発をしているのか? といったようなところも知らないエンジニアもいらっしゃるのかな? と思っています。例えば、Googleなどの企業が母体となっているオープンソースプロジェクトなども増えてきている印象なんですが、その辺り柴田さんにお伺いしてみてもよろしいでしょうか?

柴田:はい、非常に難しい質問というか、解説になりそうな気がするのですけど、できる範囲でやろうかと思います。僕の理解としては、オープンソースは狭義のオープンソースと広義のオープンソースがあるかなと思っていて、狭義の意味としては「オープンソースデフィニション」という「これを満たしたソフトウェアはオープンソースであるよ」というものがあるんですね。それを満たしてたらなんでもオープンソースである、と言えます。それを開発している人はもちろんオープンソースの開発者になれる。なので、別にRubyみたいにプログラミング言語だけでなく、なんか自分で作った自分のカレンダーを管理するだけのソフトウェアであったとしてもそれがちゃんとオープンソースの定義に合致していればもちろんオープンソースですし、それを開発している方はみんなオープンソースの開発者であると、一旦狭い意味でのオープンソースです。で、もうちょっと定義ではないオープンソースソフトウェアというのは、経済とか社会とかとりまく環境の方で捉えてみるとオープンソースのソフトウェアは経済や社会みたいな部分だと今までにはない、不思議な存在となっていて、で、それが何かというとですね、会ったこともない人がなぜか自社の利益や売上を生むようなビジネスの中心にあるソフトウェアをメンテナンスしていて、なぜか何もお願いしていないのに不具合があったら直してもらえるし、欲しいなと思っている機能があったら開発をしてくれる、でそれを使ってそれぞれの所属している会社であるとか組織とかの利益や目標のためにそれを使うことができる。しかも無償で。というのがオープンソースソフトウェアの不思議な存在になっています。で、他の業界を見るとですね、たとえば、アンドパッドがメインとしているような建設業界だとか他の機械だとかいろいろな業界がありますけど、そういった業界ですと、こういうオープンソースのソフトウェアと似ているものはほとんどないな、と思っていて、ほとんどが特許、設計図だとかそういった規格は特許に守られていて、使うためには利用料金を払わなければなりません、とか、なんならもう使えない、みたいなものもいっぱいあるんですよね。ソフトウェア業界だと、オープンソースソフトウェアというものがあって、なぜか他の会社が開発したり、一個人が開発したものを無償で使って、それをビジネスの原動力に使える、というものになっています。でそれが今まで、現在進行形ですけど、オープンソースソフトウェアのざっくりとした説明になっていて、そのオープンソースソフトウェアのメンテナンスはいろんな人が何もお願いしなくてもずっと無限にやり続けてくれるのかというとそういうわけでもなくて、そのオープンソースソフトウェアというものがですね、ビジネスの中心になればなるほど、ビッグテックと呼ばれるようなGoogleだとかAppleだとかといった企業が主導してオープンソースのソフトウェアをメンテナンスしたり、会社によってはですね、社会全体の利益よりも自社が使いたい機能なので、といった形でそれを開発しているとか、いろいろ難しい問題も起きているというのが現状です。で、そのような中でですね、Rubyというプログラミング言語がありまして、私柴田であるとか、遠藤さんはそれぞれべつの企業に所属しながらも、それぞれの企業の事業の中心として使われているRubyのメンテナンスを業務として毎日行っているというのが現在になっています。

櫻井:Rubyの場合は母体となる企業があるわけではなくて、コミュニティが主体となって、活動してきているということですね。そういったコミュニティがメンテナンスや開発を進めていかないと、いけないというところで、本日のリスナーの皆さんが気になっている企業所属のOSS開発者とは何か? というところで、なぜ企業は自社のサービス開発ではなくてOSSの開発を主体とするエンジニアを迎え入れるのか、という話につながってくるのかな? と思います。と、いったところで、お二人が企業所属のRubyコミッターになった経緯をお伺いしたいと思います。まずは遠藤さんから、よろしいでしょうか?

遠藤:はい、前職の企業で研究者をやっていた時から趣味としてRubyの開発には参加していたのですが、自分も周囲も特に業務でRubyを使用しているわけではなかったので、いまいちRubyに実際にどうのような機能が求められているのか、といった現場感があまりなかったのがあります。そんな中でRuby2.0のリリースマネージャーを務めて、結構盛大なリリースパーティー、20周年かつ2.0のリリースパーティーといったものが行われたんです。実際のリリースはその翌日にですね、自分が自宅から、確か冬だったのでコタツで細々とリリース作業をするという自分ではあまり使用していないRubyをリリースするということをやっていて、一抹の寂しさのようなものを感じたというのがあります。そんな経緯もあって、そこからしばらくRubyの開発から1、2年ほど離れるんですけど、世間はその間にですね、ちょうどRuby on Railsが非常に盛り上がり始める時期だったので、Rubyの需要がさらに盛り上がっていき、遠目に見ていて楽しそうだな、と思っているところがありました。で、その時にクックパッドに転職をしたばかりの笹田さんからクックパッドでRubyの開発をしませんか? というお誘いをもらったので、転職をするきっかけになりました。その後はクックパッドの中で実際にRubyを開発に使っているという人たちからフィードバックをもらったり、会話したりしながらRubyの開発ができているので、とても毎日が楽しいです。

櫻井:柴田さんは11月にアンドパッドにジョインしたばかりで、当時、入社エントリーブログを読んだ方もいらっしゃるかもしれませんが、改めてお話を聞かせてください。

柴田:そうですね、私の場合はですね、Rubyを開発するのに必要なCIであるとか、インフラであるとか、周辺ライブラリのメンテナンスをすることをメインの担当領域としているのですけども、今まではですね、ぜんぜん前職ではRubyの開発はメインの仕事ではないので、いろいろとやる領域が広まれば広まるほどなかなか時間を作れなくって、Rubyのことをやるということが難しくなってきて、やりたいこととかやらなくちゃいけないことはいっぱいあるんですけど、時間をなかなか捻出できない、夜はもう疲れたので無理、みたいな形が続いてきて、そういった中で、たまたま話を聞いてみたアンドパッドのVPoEの下司さんにですね、最初の方はエンジニアリングマネージャーを採用したいといった形で僕に話をしてきたところに、僕がオープンソースの開発者として雇ってくれませんか?と逆にアピールをして「できるかもしれません」と下司さんにいろいろと動いていただいて、今に至って、今はRubyの開発をメインとしてやっています。で、遠藤さんも仰られた通り、会社の中でRubyを大規模に使って、プロダクトを作っているので、Rubyでこういうことに困っているだとか、こういう部分をよくしたいと思っている、みたいな部分ももちろんあるんですけど、最近はRubyの開発者の中でもフルタイムでRubyの開発を行う方が海外の会社も含めて増えてきていて、そういった中でこういう開発はどうかとか、こういうコードはどうかみたいな、そういう話であるとか、後は技術的な課題も含めて課題を議論したりだとか、そういったもので非常に毎日が刺激的なもので前職よりも1日の仕事をして疲れたというのが、11月からの現在の僕の状態です。

Rubyコミッターになるためのステップとは?

櫻井:このPodcastを聞いているリスナーの方々の中にもですね、Rubyコミッターになりたいという方もいらっしゃるかな?と思いますので、コミッターになるにはどうすればいいのか、どんなステップがあるのかを教えていただけたらと思います。

遠藤:はい、基本的にはRubyの重要なバグを修正していただけるとか、すごく重要ではないけれどたくさんのバグを修正していただけてくれるとか、他には大きな改善を提案してくれるとか、そういった貢献をしてくれた人には、そういう活動を続けてくださいとか、その改善をぜひメンテナンスしてくださいという意味でコミット権をお渡しして、その後勝手にやっていただくというのがよくあるパターンだと思います。

櫻井:ありがとうございます。他のオープンソースプロジェクトと同じようにコアコミッターでなくとも、Rubyに貢献することは誰でもできるということですね。貢献度の高い人はコアコミッターになれるという認識で大丈夫そうですかね?

柴田:そうですね。

櫻井:ありがとうございます。どうやって貢献していけばいいのか? は、また後半のRuby開発の裏話と今後の取り組みの方で深堀っていきたいと思います。Rubyコミッターは普段どんなことをしているんだろう、と気になっている方も多いのかな? と思います。アンドパッドのアドベントカレンダーで柴田さんがメールサーバーの移設の話を書かれていたと思いますが、そういったこともやられているというのは正直なところ驚きでした。

柴田:そうなんですよね、会社でプロダクトやサービスを開発する場合には、最近ですとSREというポジションの方がいたりだとか、社内のIT部門であるとかそういう専任で横断的な活動をする方がいらしゃって、そういった方達がプロダクトやサービスを作る人たちを支えるみたいな活動であるとか動きをしているものだと思うんですけど、Rubyの場合はですね、Ruby開発会社みたいなものがあるわけではなくて、言語としてのRubyを開発したいという人たちが10人、20人いますといった状態なんですね、なのでGitHubの設定だったりとか、GitHubのCIを誰がメンテナンスするのかだとか、後はもちろんお金がかかるものに関しては誰が請求書を切って、お金を払うのか? みたいな部分とか、そういった部分が宙に浮いちゃうわけですね。なので、私柴田とNaClという会社にいらっしゃる前田さんという方の二人が主にメインの担当としてRubyの開発の社内IT担当みたいな形でいろいろ動いているような動きになっています。

櫻井:遠藤さんは普段、どんなことをやられているのでしょうか?

遠藤:はい、日常的にはですね、Rubyに送られてきたバグ報告とか機能提案だとか脆弱性の報告なんかも来るんですけど、そういうのを読んで、トリアージしたり、簡単であれば修正したり、というのが日々やっていることになります。あと、RubyのCIっていろんな環境でテストが走っているんですけど、それが変更によって落ちたりし始めるんですよね。そういったことが起きたら直す、ということもやっています。と、日常的な話としては、Rubyは月に一回開発者会議というのを行って、送られてきているバグ報告や機能提案について議論するということをやっているんですけど、その時のアジェンダを作成したり、最終的に書かれた議事録を公開したりだとか運営作業も行っています。より長期的な仕事としては最近はRubyに大きな改善を計画してくれる人たちと協力したり提案してくれる人を支援したりすることも結構増えてきています。Ruby3に入ったRubyの基盤となる型記述言語RBSというのがあり、Steepという型チェッカーを作っていたんですけど、それに対してはいろいろ口を出させていただいてRubyの本体に連動させるようにいろいろとアレンジさせていただいていましたし、去年はWebAssemblyに対応させるという結構大きな話があって、それをほぼ一人でやってくれたkateinoigakukunといろいろ話したり、支援させていただくこともやっていました。最近だとですね、ちょうど9月くらいからクックパッドにインターンに来てくれていたMakeNowJustっていう人がRubyのReDoS脆弱性と呼ばれる一種の脆弱性のリスクを大きく下げる改善というのをしてもらってそれも無事Rubyにmergeされたので、その成果でRubyのコミッターになってもらうということもあったりしました。今後は個人的に注目しているのはShopifyという会社の人たちがRubyのパーサーを大きく改善するという計画を立てているというレポートを注入してくれているので、それもいい感じにアレンジさせてもらったり、期待しているところになります。

櫻井:ありがとうございます。ちなみに、Rubyコミッターの方は何人くらいいるんでしょうか?

遠藤:前数えた時はおおよそ100人ちょっとだったと思います。ただ昔コミッターになったけれども、今はほとんど活動していないという人も多いので、この一年くらいでアクティブに活動している人というと15人くらいから20人ちょっとくらいかな?と思います。

櫻井:なるほど。そのアクティブな方々の担当みたいなものってあったりするんですか?

遠藤:漠然とあったりなかったりする感じですね。macOSに関するバグだったら、この人に一旦話を振るだとか、Rubyのこの機能に、たとえばカバレッジだったら、僕に振るだとか比較的はっきりしているところもあったりしますけど、誰もあまり興味がないところはみんなが見たりしたりしています。

櫻井:漠然とというところですね、OSS開発はチーム開発と比べて、どう違うんでしょう?

柴田:そうですね、まずRubyに関する話をするので、そのオープンソースソフトウェアが全部こうだという話ではないというのは事前にお伝えしておきますけども、Rubyの場合はですね、開発計画みたいなものはないんですよね。プロダクトやサービスの場合だと、いつ頃までにこういうものを作って、どういうユーザー向けにこういうものを出します、というまず開発計画があって、それに対して、じゃあ、いついつまでに作るにはこうしないといけないよね、という解像度を上げ下げして、ソフトウェアの開発を進めていくというのが、まずチーム開発というか会社でのソフトウェア開発だと思うんですけど、Rubyの場合はですね、毎年1バージョンはリリースするということだけが決まっていて、あとはRubyの開発者であるまつもとゆきひろさんが今後はこういうことをやっていきたいなー、といういわゆるプロダクトマネージャーみたいなすごい大方針的なものがすごくものすごく、フワッとしたものだけあるので、じゃあ、誰がいつまでに何をどう作るのか?みたいなものは誰も決めていないし、決めようとしてもいないという感じですね。とはいえ、そのそれぞれ15人から20人くらいの人が何か自分の好きなものだけをがーと開発していると、じゃあRubyどうなっとるんだ?ってなって、結果として誰にも使われなくなるということは、みんな知っているので、それぞれ自分がやりたいことは持ちつつも、お互いに話したり、作ったパッチだったり、これはこうではないか?みたいなことをディスカッションしたりしながらプログラミング言語Rubyとして違和感がないものをみんなで頑張って作っていこうという形になっていますね。で最近はmameさんも仰られたけど、毎月開発者会議という形で話す時間を設けたり、日本語で行っているんですけど、オフィスアワーという形で毎週ゆるく話す時間を用意したりしていて、この機能のことだよね、いやいやそういうことではない、みたいな部分を擦り合わせたり、その認識のギャップみたいなものを洗い出すということをやっています。でコミッターと呼ばれる人たちはですね、それぞれが一人でわりかしなんでもできる人が多くて、そのインフラ作って、コード書いて、サービスをリリースします、なんならマネージャも別にやります、みたいな人とかばっかりでそれぞれがですね、チーム開発ではないんですけど、チームプレイをしながら、Rubyの開発をしていると。マネージャーはいなくて誰かが時間の管理だとか次に何をするかといった管理はしていないんだけど、コミッター同士でうまく話を合わせ、こういうことかな?っていうニーズを掘り出して、実際に作って、リリースするというような形で開発を進めているのがRubyというオープンソースソフトウェアの開発体制となっています。

櫻井:ありがとうございます。だいぶ具体的なお話も出てきてですね、とても面白い話になってきて、続きが気になる方も多いと思いますが、今回のANDPAD TECH TALKは以上とさせて頂きまして、この続きは次回柴田さんと遠藤さんのスペシャル対談の後編として企業所属のRubyコミッター対談、Ruby開発の裏側と今後の取り組みでお届けしたいと思います。

柴田:そうですね。後半ではもうちょっと技術的、もしくはRubyの課題のような部分に踏み込んだ話ができればな、と思います。

櫻井:どんな踏み込んだお話が聞けるのかワクワクしております。次回はお二人の具体的な活動や裏話を盛り沢山でお届けする予定です。ぜひお聴きいただければと思います。それでは次回、またよろしくお願いいたします。ありがとうございました。

さいごに

前半はここまでです。後半は1月27日(金)ごろにアップをする予定です。 クックパッドではサーバーサイドやOSSに関わりたい仲間を募集しています。ご興味のある方はぜひご連絡ください。

cookpad.careers


AWS re:Invent 2022 に参加してきました

$
0
0

SRE の @s4ichiです。年が明けてしまいましたが、昨年 11 月の終わりから 12 月の始めに開催された re:Invent 2022 へ現地参加してきたため、足を運んで得てきた体験をレポートとしてお届けします。今年もクックパッドから総勢5名の社員が参加してきました。

クックパッドと AWS re:Invent

クックパッドでは 2011 年ごろから AWS の各種サービスを利用しています。当時のサービスを AWS に移行するところから始め、かれこれ 10 年以上 AWS をコアに利用してきました。 re:Invent へは 2012 年から社員が参加しています。コアに利用するサービスのアップデートをキャッチアップするのはもちろんのこと、普段利用しないサービスの Workshop へ参加して知見を更新したり、AWS の開発者と直接議論やフィードバックをすることで、普段利用するだけでは見えてこない視点を養う機会になっています。

参加レポート

さて本題です。 re:Invent への参加は、参加した社員の知見更新だけでなく、帰国後に社内で実施する報告会や個々人の社内ブログ投稿を通して社内のエンジニアへ広められます。今回はそんな社内ブログに投稿されたレポートから取り上げた内容をいくつかピックアップして紹介します。

参加者それぞれ毛色の異なるセッションを聞いてきたので、それぞれの視点から書いたレポートをお届けします。今回レポートを書いたのは @s4ichi, id:sora_h, @hilinker, そして @naoki_shigehisaの4名です。

EBC (Enterprise Business Conference)

id:sora_hです。クックパッドは例年、AWS の担当アカウントチームに re:Invent を含めいろいろな機会で AWS のサービスチームとミーティング (EBC や Customer Meeting と呼ばれるもの) を組んでもらっています。re:Invent 2022 でも、複数のチームとディスカッションをすることができました。

詳細はここには書けませんが、基本的にやることは自分たちがどうサービスを使っていて、何に困っているかを持っていきます。現地ではそれに対して自分たちで workaround できる方法はあるか、サービスに機能を入れるとしたらどういう方向がいいのか、といった内容を議論することが多いです。内容は当然英語なので、基本的には事前にざっくりとした資料を作ったりして楽になるように、また限られた時間を有意義に活用できるようにしています。

担当者個人としては、re:Invent のような巨大カンファレンスはアーカイブに残らないコンテンツや機会を中心に時間を使うべきと考えていて、EBC は AWS との渉外を担当する自分にとっても、またマネージドサービスをしっかり利用している会社としても、重要なミッションです。今回も楽しく有意義な議論ができて満足しています (内容は書けないのですが)。

[NEW LAUNCH!] Using policies to manage permissions with Amazon Verified Permissions [Breakout Session]

@s4ichi です。このセッションは Amazon Verified Permissions という新機能に関するもので、その機能の説明からの技術の概要までを深堀りするセッションでした。Amazon Verified Permissions 自体も機能がかなり面白くて、簡単に言えば IAM Policy に似た記述を用いて任意のドメインに関する認証・認可を代替してくれるマネージドサービスになっています。 つまり、「ユーザーがレシピAを見るのに必要な権限があるか -> YES or NO」みたいなのを検証してくれる機能がサービスとして提供され、API を経由して利用できます。

そんな機能紹介はさておき、記述言語が独自言語であるため、その言語についての紹介が半分の時間を占めていました。プログラム言語に詳しい開発者の方が出てきて、作った言語の思想を紹介していく姿は、学会にでも来たのか?という状況が味わえる良いセッションでした。あとで調べたんですが、発表者の方はプログラム言語に関するエキスパートで、POPL や PLDI などでソフトウェアの Synthesis や Verification を専門とするアカデミアの方だそうです。そんな方が AWS の Scientist として登壇されていたので幅広い分野の方々が AWS を支えているのを実感しました。

記述言語は Cedar という名前です。Rust で実装されていて、公式サイトに Playground があります。

www.cedarpolicy.com

詳細には解説されていませんでしたが、この言語自体に対する仕様の検証や Policy の同値性証明などの話もありました。世紀の発明、というほどではないんですが、ニーズや要件を満たす最小限の言語がドンと出て機能の中核を担っているの、アツいものがありますよね。

セッションは動画も公開されています。

www.youtube.com

The evolution of chaos engineering at Netflix

@s4ichi です。Netflix はセッションの種別に NFX という Prefix があるくらい特別視されているらしく、このセッションはそんな NFX 系の Chaos Engineering についての発表です。

Chaos Monkey で一部のリソースを勝手に停止するような文字通り "Chaos"な取り組みから、今は FIT(Failure injection Technology)を構築している様子についての発表でした。 microservices 上の様々な要素を観測するための分散トレーシング基盤やサービスメッシュが Netflix のインフラに構築されており、その上で Failure injection を柔軟に扱う仕組みがあるそうです。Chaos Monkey のようなランダム性のある取り組みは方向性は良かったのですが、計算資源単位ではなくドメインやアーキテクチャを考慮した失敗、というのをシミュレートしたいというモチベーション。特定のサービス間で耐障害性が低くなる部分があったことが原因の障害があり、シナリオベースで障害を注入できる必要があると学んだ、とのことでした。

Netflix のサービスはソフトウェアエンジニアなら誰でも FIT を使った障害の注入が可能になっています(専用コンソールがありそうな雰囲気でした)。Injection Point や Scope、Treatment(回復手段)にその Scenario を定義することができて、それをリアルタイムに実行できます。

具体的には、リクエストヘッダに対して(例では x-ntfx-fit-fail でした)コンテキストを付与することができます。その内容は、各サービスやその横にある Envoy が解釈して Failure injection をシミュレートするためのコンテキストになるそうです。 例えば、「サービスAからBに通信するクライアント側で Failure する。Canary リリースされたユーザーのグループCのみに適用。障害時はリクエストを Delay して失敗させる」ぐらいの粒度で指定が可能です。シナリオを用意しつつ、それを Canary や Production 環境と組み合わせてシミュレートできるため、非常に機能的でした。

「Chaos is dead, Long live...」言っていたのが印象的です。可観測性を上げ、FIT によって特定の箇所を狙って障害をエミュレートできるようにし、リリースの戦略と合わせてテストをできるようにすることで、カオスはたった一つの道具にすぎないと話していました。例えば当時からある Chaos Monkey はそれ自体は良いけど、今は殆ど使われていないそうです。

セッションの動画も公開されています。

www.youtube.com

Deep dive into Amazon Aurora and its innovations [Breakout Session]

@hilinker です。このセッションは、前半で Amazon Aurora の仕組みを解説しつつ、後半では直近リリースされた機能の紹介をするものでした。クックパッドでは Amazon Aurora を非常に多くのアプリケーションで採用しています。Aurora の細かい利点については AWS の公式ドキュメントに譲りますが、かなり様々な恩恵を受けています。

さて、特にここでは直近で発表された Aurora の新規機能のうち、特に以下2つについて触れておきたいと思います。

1つ目は、Amazon Aurora zero-ETL integration with Amazon Redshift です。

aws.amazon.com

これは Aurora のデータをほとんどリアルタイムに Amazon Redshift 上に同期することができるというものです。 クックパッドではアプリケーションのデータベースとしては Aurora を利用し、分析などに用いる DWH として Amazon Redshift を採用しています。分析などの際には Aurora にあるアプリケーションのデータを Redshift 側にコピーして用いるのですが、このコピーは日次バッチなどを実行することで行っているケースがほとんどで、どうしてもデータの同期に時差が生じてしまいます。特に、最近は Redshift 上のデータを利用して組み上げたデータを元にして、リアルタイムにユーザーへコンテンツを表示したいという要求が社内で高まっていることもあり、Aurora、Redshift 間のデータの同期というのは悩みのタネでした。苦肉の策でバッチの実行間隔を狭めたりはしていましたが、あまりクールな解決とは言えませんでした。 そんな中で登場したこの新機能。いやぁ、待望と言わざるを得ないですね。これが入ると上述したような悩みのタネが一気に解決されます。 ただ、まだプレビュー版なのと、おそらく Aurora MySQL v3 からしか利用できないっぽいので、社内にある Aurora クラスタたちを v2 から v3 に上げていかなければならなそうなのはかなり大変かも。

2つ目は、Fully Managed Blue/Green Deployments です。

aws.amazon.com

これは、Aurora クラスタのバージョン更新作業などのメンテナンス時に、Blue/Green デプロイメントを AWS 側の用意してくれたスキームで実施できるというものです。これは re:Invent の keynote とかで発表されたわけではなく、初日ぐらいにさらっとニュースが出ただけなのですが、マジで待望の機能でした。

軽く説明すると、Aurora にはエンジンバージョンがあります。で、バージョンがあるということは当然 EOL もあり、EOL までにアップグレードをかけなければならないのですが、このアップグレードの際にどうしても DB にダウンタイムが発生してしまいます。in-place upgrade というボタンをポチッとするだけで上げられる方法を使うとアップグレード自体は簡単にできるんですが、read も write も平気で10分以上止まるので認証サーバーなどのクリティカルな DB では採用できません。

では、そいつらどうやってアップグレードするかというと、もう1個クラスタを用意してそっちにデータをレプリケーションしつつ、アプリケーションからアクセスする writer をえいやで切り替える方法を取ります。この方法だとアプリケーションから接続するエンドポイントを切り替えるだけなので、まず read のダウンタイムは発生せず、かつ write のダウンタイムも数分で済みます。

じゃあ全部レプリケーション方式でやればいいじゃん!と思うかもしれませんが、このレプリケーション方式、マジで準備が面倒くさいんですよね。いや、本当に作業と確認しなきゃいけない項目が多すぎて時間とメンタルが溶けます。

ということでここらへんの面倒な準備を AWS 側でいい感じにやってくれて、ぽちぽちすると切り替えができるようにしてくれる(らしい)のがこの Blue/Green Deployment 機能になります。早口でいっぱい喋っちゃうぐらいには嬉しい。

Build your first project with Amazon CodeCatalyst [Workshop]

@hilinker です。今回発表された新サービスの1つである Amazon CodeCatalyst を触ってきました。最初は、「CI/CD をいい感じにしてくれるぐらいかな〜」と思っていたらそれだけではなく、なんか開発に必要なサービス全部入ってますみたいな超巨大サービスでした。

まず、Git レポジトリの機能があります。そして、当然 issue/PR が作成できます。この時点で結構面白いんですが、さらにファイルを Cloud9 で開けて当然コードの編集ができます。レポジトリ内に manifest ファイルを定義するだけで PR の CI や CD の workflow を CodePipeline 上に作成できます。当然、実行や結果の確認、ログ出力なども CodeCatalyst 上でできます。さらに CloudFormation との連携も提供されているのでインフラ管理もお手のもの……

未だかつてなくオールインワンのサービスで、来るところまで来たなという感じがして笑っていました。難しいこと考えずにとりあえず AWS に乗っかれば大丈夫というのはだいぶ面白いですね。

ただ、こんだけオールインワンでやっていながら結構他からの移植性みたいなのは気にしていて、たとえば IDE で言えば Cloud9 だけでなく普通に VSCode や IntelliJ も使えたり、GitHub をレポジトリソースにできたり、Jenkins などと連携できたりと部分的に既存のものを使えるようにしているのは割と気を遣っているなという印象でした。

個人的にちょっと面白かったのは、CodeCatalyst のアカウントは AWS アカウント(やその中のユーザー)とは別のものということですかね。1人は1つの AWS Builder アカウントを持っており、それが複数の AWS アカウントと結びつくという関係らしいです。

[NEW LAUNCH!] Provision and scale OpenSearch resources with serverless [Breakout Session]

@naoki_shigehisa です。このセッションは新機能の OpenSearch Serverless についての Breakout session でした。名前の通りですが、 OpenSearch Service のServerless モードがプレビューとしてリリースされたという内容です。

クックパッドの検索では主にsolrが使われていますが、 OpenSearch Service もどこかで活用できないかなと思っていたところ keynote で Serverless モードが発表され、興味津々でセッションを聞きにいきました。

驚いたのは Serveless モードでサポートしている auto scaling の機能を実現するための構成が、クックパッドの solr を使った検索システムの構成とかなり似ていたことです。

techlife.cookpad.com

具体的には indexing 用の unit と search 用の unit が別に存在し、indexing 用の unit は index を作成して s3 に保存し、 search 用の unit は s3 に保存された index を使って起動するという構成です。時代がクックパッドに追いつきましたね。 現状ちょっと値段が高かったのですぐに活用するのは難しいかもしれませんが、せっかくなのでどこかで試しに使ってみたいなと思っています。

こちらのセッションは録画も公開されていたので、リンクを貼っておきます。

www.youtube.com

Improve search relevance with ML in Amazon OpenSearch Service [Workshop]

@naoki_shigehisa です。このセッションは SageMaker と OpenSearch Service を使って、単語の embedding を使ったテキスト検索を実現する workshop でした。

SageMaker 内で起動した notebook から OpenSearch へ embedding を含めたドキュメントを登録しておき、実際に検索する時にはSageMaker を使って作成したエンドポイントに対して検索クエリを投げて取得した embedding を使って OpenSearch に向けて検索リクエストを投げる形式で実装を行いました。これによって、単語の完全一致ではなくある程度意味を考慮してドキュメントの検索を行うことができます。

workshop用のコードの大半は予め用意されていてほとんど notebook 上でコードを実行するだけではありましたが、実装に必要な作業を大まかに把握できました。

機械学習の用途としては割とありがちなものだなと思いましたが、ほとんどの作業が SageMaker 内で起動した notebook 上で済むのはとても便利だなと感じました。モデルのデプロイやエンドポイントの作成まで notebook 上でコードを実行しているだけで完了するのは嬉しいですね。ぜひ活用してみたいです。

AWS GameDay: The New Frontier

id:sora_hです。先に述べたようなインタラクティブでアーカイブがないセッションとして、今年もGameDay に参加してきました (月曜午後の回)。当然、ランダムなチーム構成を希望して現地にいる他の知らない参加者とチームを組んで挑戦します。

最初に結果だけ書いておくと、2 度目の優勝を遂げて 2019年ぶりに Keynote の特等席を得たりしていました。

今年、というか近年の GameDay は他イベントでの再利用など横展開がしやすいようにするためか、初期の競技性があるもの — 全てをデプロイしてそれをちゃんと動かし続けてポイントを得る、ではなくクエストでポイントを稼いでいく形式、実質的に AWS コンソール操作 RTA レースみたいになっているのはちょっと残念と言えます。今回も多分に漏れずその傾向で、難易度が高いクエストから処理していけば上位にいけるので、悲しいところです。

いちおう Learning Opportunity の一環としてのゲームであるため、他のメンバーのサポートなどに回ったりしています。他の会社どころか他の国や出身の人達と同じ目的に対して頑張るという意味では貴重な機会なので、ここはたいへん楽しいところで、そしてたいへん運ゲーなところですね。

また頻繁に上位にいるせいで PM に顔を覚えられていて、またお前か…のような反応を得つつ公式からツイートもされる始末です。うかつな結果を残すわけにはいかないので、引き続き精進しようと思います。

思い出

参加メンバーで会期後にグランドキャニオン含むツアーに弾丸参加したときの写真です。(id:sora_hは Re:ステージ! のライブに行くために一足早く帰ったため、5人での集合写真が存在しませんでした)

さいごに

クックパッドでは AWS re:Invent の参加を通して、実際のサービス・基盤開発に活かせることを発見したり、より効率よく AWS を利用できないか研鑽する取り組みを続けています。こうしたブログでの発信に留まらない発見や知見更新のチャンスが社内に溢れているためです。

クックパッドには経験年数問わず、熱量のあるエンジニアがこうしたカンファレンスに参加できる環境があります。興味を持っていただけた方は下記のリンクより採用情報を参考にしてみてください。

cookpad.careers

【後編】企業所属のRubyコミッター対談! 〜Ruby開発の裏話と今後の取り組み〜

$
0
0

こんにちはCTO室の緑川です。今回はアンドバッドさんが主催しているPodcast「ANDPAD TECH TALK」のゲストに弊社の@mameが出演した記事の後半です。Podcastとしてお聞きしたい方は下記のアンドパッドさんの記事からお聴きください。

tech.andpad.co.jp

前編の記事はこちらです。

【前編】企業所属のRubyコミッター対談! 〜企業に所属するOSS開発者って何?〜 - クックパッド開発者ブログ

トーク本編

櫻井:皆さま、こんにちは。アンドパッドの開発本部でエンジニアリングマネージャーをしている櫻井です。

櫻井:13回目のANDPAD TECH TALKです。ANDPAD TECH TALKはアンドパッドの開発チームの中の人をゲストに招いて、あれやこれやお話しするカジュアルなテック系Podcastなのですが、今回は前回に引き続き社外ゲストをお招きしたスペシャル会の後編となっております。企業に所属するRubyコミッターであるお二人をお招きしています。

櫻井:アンドパッドからはフェローでありRubyコミッターの柴田さん。対談相手はクックパッド株式会社所属のRubyコミッターであるmameさんこと遠藤侑介さんをお呼びしています。前回はお二人の今までの経緯、Rubyコミッターが普段どんなことをしているのかをお話いただき、非常に良いところで後編となっていたところです。今回はRuby開発の裏話と今後の取り組みなど深振りをさせていただきたいと思っております。それでは後編をお聞きください。

Ruby開発の裏話

櫻井:まずは遠藤さんからお伺いしますが、開発裏話みたいなものはありますでしょうか?

遠藤:クックパッドでRubyをめちゃくちゃ現場で使っている職場に転職したから、現場感がつかめてなかったので、よくわからなかったところがうまくいくようになったのはちょっとあったりしますね。

遠藤:最初に述べたカバレッジの測定をする機能を担当しているんですけれども、カバレッジの測定を止めたり再開したりする機能が欲しいという要望が以前Rubyに来たことがあったんですけれどもその時は必要性がよくわからなかったんですね。実装するのも大変なので断っていたんですけども、クックパッドで働くようになって、現場で働いている人 から同じ問題で困っているという話を聞くことができて、どういうふうに困っているのか理解がちゃんとできたので今回対応することにし、oneshot coverageという機能を導入するようにした話があったりします。

柴田:その話で言うと、RubyコミッターRubyのコード書いていない問題みたいなものをよく言われたりしています。Cプログラマーの皆さんだから会社の中でRubyをどう使って何を書いているかとか、またRailsをメインに開発されている人とかRailsを使ってアプリケーションを開発している人とかが「Rubyでこういうことができるといいんだけどな」みたいな部分とあとRubyを開発しているRubyのコミッターの人たちが「こういうRubyのコードは書かないでしょ」って言った矢先に「書きますよ」みたいな話とか、逆にこう書けたら便利じゃないって「誰も書きませんよ、そういうこと」みたいな話は結構イベントとかSlackとかそういったところでよく散見されたりするのはあるあるネタですよね。

遠藤:そうですね。本当に現場感がない人がRubyを作っているっていうのはちょっと問題としてはあったりしますね。

柴田:ただ最近はShopifyのエンジニアであるとか、あとはよく喋る場みたいな部分が割と増えてきたような気はします。スタートアップ中心にRubyを採用している会社も結構な数があったりするので、「こういうことができるといいんだけどなー」みたいな部分とか拾ったりヒアリングしたりしやすくなったりしているのかなというのはありますよね。

遠藤:ありますね。

柴田:最近のライブラリというかプログラミング言語のGoとかRustみたいな言語はライブラリ自体がGoとRustで書いてあるというような言語なので、初学者の方とかが最初にGoとかRustをやりましょうみたいなときに割りかし開発を始めるまでのつまづくステップが比較的ないんですよね。それに比較するとRubyはC言語で書かれていて、C言語を動かすためにはコンパイラーとコンパイルした後の実行パイナリを実行する場所が必要になっていて、その辺の組み合わせで動かないとかビルドできないとか、何かプログラムを書こうと思ったんだけどプログラムを書くまでに1日とか何なら数日かかってしまう。Googleで検索しても何かエラーメッセージが出てこないみたいなことが増えていますと。

柴田:あとはAppleのmacOS、MacBookのアーキテクチャーがガラッと変わったことでいろいろビルドできない問題が引き続き多いとか、ARMのCPUの上では何かうまく動かないとかそういったいろいろな社会のコンピューティング環境の変化にともなって、自分が使いたいものがすぐ使えない、みたいな部分に散見されるなっていう問題があります。僕はいろいろやってはいるんですけど、その辺のプログラムを書こうと思ったときにすぐ書けるようになるみたいな部分の時間をとにかく小さくしたいと思っているので、その辺をいろいろやったり、前回遠藤さんの方から紹介があったkateinoigakukunさんがmacOSについてすごい詳しくて本当に助かったんですけれどもその辺のmacOSでビルドできない問題、動かない問題みたいな部分もいろいろ複数人で協力しながら解決していったりしているっていうのが現在進行系の話になってますね。

開発者に知ってもらいたいこと

櫻井:ありがとうございます。Ruby開発の裏側を聞いてきましたが、開発者に知ってもらいたいことなどがあれば、お二人からお伺いしたいんですけれどもいかがでしょうか?

柴田:はい、そうですね。昨今のプログラミング言語界隈の流れというか流行りみたいな部分の話をちょっとしたいんですけど、VS Codeと呼ばれるエディターが割とメインというかメジャーな存在となっていて、VS CodeはTypeScriptであるとか、Go言語であるとか、最近だったらRust言語みたいなもののサポートが非常に豊富なんですよね。

柴田:それもちろんMicrosoftが今すごい投資をしているんですけど、開発者体験という言葉があってデベロッパーエクスペリエンスというんですけど、開発者が何かをしようとした時に「うっ」てつまずかないように、なおかつ、こういうものを書きたいと思った時にスラスラっと書けます、テストも実行できます、不具合があった場所を見つけますみたいなものをできる限り提供していこうというのがどのプログラミング言語でも非常に重要視されています。

柴田:RubyもVS Codeのサポートであるとか、そういった型システムみたいな部分については少しずつ頑張っているんですけれども、やはり他の言語と相対的にはまだまだだなという部分があります。その中でも2022年にリリースするRubyのバージョン3.2でも今話したようなエラーを見つけましょうとか、そういう開発者にとってスラスラっとRubyのコードを書けるようにするための機能がいくつかあるので、その機能の開発を頑張っていた遠藤さんに詳細を聞くといいんじゃないかなと思います。

遠藤:僕がRuby3.2の中で新しくやったこととしてはRubyの例外が出たときに「このコードのせいでおそらく例外が出てるんじゃないの?」というのをエラーメッセージでサジェストをするという機能を少し拡充するのをやってみました。ErrorHighlightと呼ばれる機能なんですけども、それを拡充していました。また僕が作ったやつ以外にもSyntaxSuggestっていう機能がRuby3.2に増えまして、Rubyでコードを書いててありがちなのはendでステートメントを区切る言語なんで、endを書きすぎたり、逆にendが足りなかったりした時にどこにendが足りなかったのかというのはよくわからなくなりがちなんですよね。

遠藤:シンタックスサジェストとはコードの構造を大きく抜粋して「おそらくここにendが多すぎるんじゃないか」とか「足りないんじゃないか」というのをエラーメッセージの中にヒント情報として出してくれるという拡張が行われていて、これもエラーがでた時に開発者がどこを直せばいいのかというのをサジェスチョンしてくれるという機能がちょっとずつ増えています。

柴田:今の遠藤さんのお話はRubyのコードを実行したときに「この辺がエラーではないか?」というのを開発者の方にすぐお知らせするというような機能だったりするんですけど、他にもRubyの開発会議とか開発のこういうふうな変更を加えてはどうかみたいな時にも割と新しい機能を入れたりこういうメソッドを入れて警告を出すとか、「この辺が良くないのでは?」みたいなのを教えた方がいいんじゃないっていう提案が来るたびにじゃあそのエラーメッセージなり警告メッセージをプログラマーが見て「なにかできることはあるの?」みたいな話をしたら「いや、ないかも」みたいな話とか結構開発の方針を決めたりするときはあるあるネタです。メッセージを出したりプログラミング言語の動きとして、それを使った人が、「じゃあそれ見てなんかできることはあるの?」とか逆に「こうすればもっと良くなる」みたいなことをすぐ適切に知らせるにはどうしたらいいのかみたいなのは本当にRubyコミッターの中でも熟考というか結構紛糾しがちなネタだったりします。

柴田:例えばワード1個をセーフって言い切ってしまっていいのかみたいな話とかでも、1時間とか2時間とか議論して「いやこれセーフって言うとダメでしょう」とか「いやじゃあなんて言えばいいの」みたいな話とかは結構あるあるネタだったりしますよね。

遠藤:名前はね、本当にデベロッパーエクスペリエンスに直結するところなので、だいぶ長く議論をしますね。その結果がやや不自然な結果になることがあるんですけど本当に熱意を持って設計されているところだと思います。

今後の計画

櫻井:なるほど。ありがとうございます。さまざまなお話を伺ってきまして、ぜひ今後のお話も伺いたいと思うんですけれども、今後お二人がやっていきたいRuby開発にはどんなことがあるでしょうか? もし計画などがあればぜひ教えていただきたいです。

柴田:はい。計画はないのでそれぞれが勝手にやりたいことをやっている、という話のあとに計画の話をするというのもあれなんですけど、僕が思っている部分としては、やっぱりRubyを開発する人が、開発をもっとしやすくなるようにできるといいなと思っている部分があるのでそこの部分の支援ですね。

柴田:具体的にはRubyがちゃんとサポートしている動く場所、コンピューターの上として10個とか20個とか、Linuxの上であるとかmacOSの上であるとかWindowsの上であるとか、そういったいろんな部分で動かせるようにしましょうというのをユーザーと約束していて、ちゃんとそこの上で動かせるようにするというのをやっているんですけど、何かの変更を入れた時にWindowsは動きませんでしたとか、Windows向けに入れた変更はmacOSではダメでしたみたいなことがよくあるんですね。

柴田:ただ、やはりRubyコミッター1人1人が持っているコンピューターは1個とか2個とかに限られているので、あるRubyコミッターがサポートするよって約束している10個とか20個とかの環境に即座に自分の目の前のコードを実行したりテストできるようにするみたいな部分を来年何かしら用意したいなというのが1個目の野望というか計画で、2つ目はリリース作業をもっと楽にしたいと思っていて現状は僕と遠藤さんとあとは3、4名のリリース担当のRubyコミッターと呼ばれる人たちが18時ぐらいから大体いつも22時から23時過ぎまでいつもリリース作業と呼ばれる作業をします。皆様に最新のRubyをご提供みたいな仕事をしているんですけど、もうなんかやるたびに(少しずつ良くはなっているんですけど)ほぼみんながもうやりたくないなっていう風に考えて、また次頑張るみたいな消耗戦を繰り広げているので、もう念じたらリリースできるというくらいまではちょっと頑張りたいなと思っているところです。だからいろいろ問題があるんですよね。

遠藤:不思議ですよね。リリースは本当にびっくりするぐらい何かしら必ずはまるという感じで。

柴田:なんか解決したはずなのに新しい問題がまた起きるみたいなことを本当に繰り返していて、誰かがサボリとか悪意を持ってやってしまったとか、そういう話ではなくて本当に新しい技術的な課題が毎回起きていて、そのたびにちゃんとこれはポストモーテムして対策を入れるみたいのを毎回少しずつ対策してるんですけど毎回新しい問題が起きるんですよね。

遠藤:普通にソースコードをtarballにまとめるだけでなんでこんなにハマるんだっていう風に思う人もいるかもしれませんけど、本当になぜかtarballに固めたバージョンだけで発生するバグとかが毎回ちょっとずつ混入するのでパッケージを作ってテストしてみたら失敗するとかっていうのが発生するんですよね。なので、いざ本当にリリースバージョンを作ったら新しい問題が分かるのでそこから慌てて直すとかっていうのが何かしら発生する感じで大変ですよね本当に。

柴田:ですよね。Webサービスの場合は自分たちが面倒を見て全責任を持っているコンピューターの上で動かすようにソフトウェアをリリースしますっていう感じなんですけど、Rubyとかプログラミング言語の場合は自分たちの外に向かってソフトウェアをリリースするみたいな部分です。なので、使う人の数だけそのソフトウェアが動く場所があって可能な限りそれを広くカバーしたいけどカバーしきれないこともあって「とあるソフトウェアがこういうバージョンだったらビルドできませんでした。これあかんみたい」な話とかを何回も繰り返しています。そこの大変さをちょっと広い目っていうか大きいスコープで捉えて解決するような仕組みを用意することでリリースとかはもっと毎月でもバンバンやっていいんじゃないのくらいのほうが、短いサイクルのほうがちょっとした不具合があっても「3ヶ月待たないとRubyは直らないんだよな」から「来月直るし」みたいなほうがユーザーにとってはおそらくいいことだろうし、開発する側にとっても何かミスっても来月直せばええやみたいな感じになって、みんなが楽になると思うので、少しずつがんばりたいなっていうのは僕の2023年の目標活動に入れています。

遠藤:ほんとリリース頻度上げたいですよね。定期に3、4ヶ月くらいしたら1回入れるかどうかっていう。

柴田:年3、4回ですもんね。

遠藤:nodeとかは実質的に2ヶ月に1回リリースしてるみたいなので、そういうのをまねしていきたいですよね。

柴田:そのくらいになりたい、、、隔月くらいになりたいですよね。

遠藤:隔月くらいになるとユーザーも「そろそろRubyの新しいバージョンが出るらしい」みたいな備えができるかなと思っていて、いいなと思ってます。

柴田:それに雑に壊れても、次回直せばいいなみたいな感じにもなれると思うので、やっぱり儀式化すると挑戦する意欲を高めるのにもすごい時間がかかるし、やってしまったときの「あー」みたいな気持ちもすごい高まるので、その辺の敷居はどんどん雑に下げていきたいですね。遠藤さんも何かあるんですか?

遠藤:そうですね。最初のほうに話したTypeProfっていうやつを今まで作ってきてるんですけど、今年はですね今の実装のアプローチ、バイトコードを解析するというベースのアプローチだとちょっと限界を感じてきていて、抽象構文木ベースで解析し直すように作り直すというのを考えています。そのためにまずParserをどうにかするという必要があって、その辺りをShopifyの人たちがやってくれているのがそろそろ形になってきているのでそれをベースに2023年に作り直したいなというふうに思っています。それによってVS codeでのRubyの対応が弱いとかって言われているのを改善するように1つの提案ができたらなというふうに思っております。

個人の開発者がRuby開発へ貢献できる方法

櫻井:では最後にですね、ここまで聞いてきたリスナーで自分もRuby開発に貢献したいと思ったエンジニアが結構いるんじゃないかなと思ってるんですが、どうやったら個人の開発者がRuby開発へ貢献できるのでしょうか?

柴田:一番簡単な方法はとにかくRubyを使うっていうのがあって、手元の仕事で開発しているソフトウェアであるとか仕事じゃないソフトウェアもいっぱい皆さんのお手元にあると思うんですけど、その辺のコードをとにかくRubyで実行するということがまず最初の一歩です。

柴田:2つ目はですね、ここがちょっとアクションが必要になってくるんですけど、その動かした結果をRubyの開発チームに伝えるっていうのがポイントだと思っていて、動いたっていうことも重要なんですよ実は。

柴田:Rubyの開発チームはPreviewバージョンとRCバージョンっていうのを1年間に2、3回リリースするんですけど、その2、3回リリースしたPreviewバージョンを使って自分の手元のコードを実行してみて動いたら動いたって言ってほしいですし、動かなかったらこういうエラーが出て動かなかったっていうのを教えてほしい。どちらも実は重要でまず実行してもらわないことには不具合なり、そのちゃんと正しいっていう動きも我々が知ることができませんし。で実行した後に動いた動かないっていう状況を伝えてもらわないことには我々はそれを知ることができないっという二段構えになってまして、本当に本当に大事で、ベータバージョンを出してみんながテストしてくれたから、大丈夫だったと思ってベータじゃない正式バージョンをリリースしたら全然動きませんでした。「なんでだ?」「ベータバージョンは誰も実行していなかったからだ」みたいなのは本当ソフトウェアあるあるな話なので、とにかくベータバージョンみたいなものを触ってみて「なんだこれ?」みたいなものがあったら動かなかったっていう報告であるとか、あとこの動きはちょっとおかしいんじゃないとかもぜひ教えてもらいたいなと思います。

柴田:それで、教えてもらう方法もできる限りいろんな窓口を用意していて、メインで使ってるのはRedmineっていう課題管理ソフトウェアなので、Redmineの方で報告してもらうっていうのが一番の王道というかメインの手段なんですけど、それ以外にも例えばSlackにRubyのグローバルなコミュニティーとかもあったりするんですけど、そこの部分で動かなかったとか動いたみたいなものを僕とか遠藤さんにお伝えてもらってもいいですし、なんかTwitterとかそういう類似のソーシャルネットワークのサービスでメンションして、このコードが動きませんでしたみたいなことを伝えてもらってもいいですし、何かしらの手段でとにかくRubyの開発をしている「Rubyコミッターです」って名乗っている人たちに伝えるっていうのが一番最初にまずできることだと思いますね。

柴田:で、第3ステップ目がちょっとハードルが上がるのかなと思うんですけど「なんだこれ」みたいな動きに対して「こうした方がいいんじゃないのか」っていうようなコードを書いて、それをGithubなりのプルリクエストでサブミットするとかRedmineの方にコードの断片をパッチとして貼り付けて投稿するとか、そういった部分を繰り返していくっていうのがRuby開発への貢献。Rubyのコミッターサイドとしてすごいありがたいなっていうような動きになるのかなと思います。

柴田:皆さんが会社で行われてるような陸続きだと思っていて、RubyコミッターはただRubyを開発してる人でしかないので、チーム開発とかサービスを開発するときに隣の人が作った機能を実行してみたら動かなかったんだけどみたいなことだったら、皆さんは多分すぐSlackとかGitHubとか何かしらのチャットツールとかで動かなかったよって伝えると思うんですね。本当それと同じようなノリでいいと思っていて、ほんとまつもとゆきひろさんにTwitterで「これ動かないんですけど」みたいなって言うくらいでもいいと思って、まつもとさんすごいフレンドリーなので、まあそういう形でこうどんどん伝えて一緒に作っていくっていうのがRubyの魅力だと思うのでぜひなんかやっていただけるといいのかなと思います。

遠藤:そうですね。本当に動かなかったときに誰にも言わないで諦めてしまうっていうのが一番残念で、誰かが困った問題では他の人が困るのでいったん声を上げるっていうのが重要だと思います。声を上げるのもできればTwitterで誰ともなしに語るだけではなくて、僕らのようにRubyの開発やってる人になんとか伝わる形で、一番簡単なメンションとか、より理想的にはやっぱりバグトラッカーに報告をするっていう形で伝えてもらえたら嬉しいなと思います。本当に時々あるんですけどTwitterで動かなかったというツイートを誰かコミッターが拾って直すっていう対応をすることもあるんですけども たぶんほとんどのやつは気が付かずに流れていってると思うので伝えてもらえると嬉しいなと思います。個人の開発者がRuby開発へ貢献できるかっていう話だと、RubyはCで書かれているのでちょっとハードルが高いっていう風におっしゃる方が時々見かけるんですけれども、そんなに難しく考えなくても(もちろんCが書けるに越したことはないと思うんですけども)そのようにバグ報告をするっていうのも重要な貢献ですし、特に機能提案に関してこういうユースケースでこの機能が欲しいとか、この機能提案だとこういうケースで問題があるだろうという議論に参加するという形の貢献もあると思います。実際にそのRubyのコードに手を動かして貢献したいっていう時も何かしら声をかけてもらえれば課題を紹介したりとか書こうとしているプログラムを手伝ったりとかもできると思いますので、やっぱりこれも声をかけてもらうというのがすごく重要かなと思います。

櫻井:ありがとうございます。思っているだけではなくて、アクションに起こすことでRuby開発への貢献もできるし、コミッターへの道も開けるのではないかと。皆さんぜひどうしても見構えてしまう方もいるのかなというところがあるので、そういったところはちょっと一旦置いておいて、ちょっとした勇気を持ってコミュニケーションを取ってみるとそこから先に進めるのかなという気がしました。

遠藤:そうですね。自分が実際にRubyを使っていて、こういう機能が欲しいっていう思いから貢献してもらうのがベストではあるんですけども、何かやりたいけれども特にアイデアがないっていう時には、時々Google Summer of CodeとかRubyのコミュニティからこういう課題があるっていうのを紹介することがあるので、それを参考にこれだったら自分ができるかもというのを選んでもらうというのもいいかなと思います。

遠藤:この間柴田さんが書いたブログの記事とかね。そういう感じでRubyの長い懸案になっている課題みたいなのを紹介することもあるのでそういうのを考えてみてもらえといいかなと思ったりします。

柴田:そうですよね。結構発信してなかったなと思ったんで、こういうことをやりたいとか。プログラミング言語業界の懸案事項って実は結構あって、どの言語でも実は今この問題があって、どの言語も解決できてないとか、ある言語だけこういうアプローチで解決できているとか、何なら無視しているとか、結構そういうのはRubyコミッターの中では議論として出たりしているんで、そういったものをできる限り開発ネタとして発信をして意欲のある人が「えいっ」て頑張って作るみたいな部分でネタを提供したりもできればいいかなと思ってますね。

遠藤:そうですね。バグトラッカー上で議論しているのを頑張っておりますが、ちゃんとまとまってないのでどういう課題があるかというのを一覧できないのがやっぱり難しいですね。発信していかないとですね。

櫻井:ありがとうございます。今回も本当にいろいろな裏のお話だったりとか、深いお話もお伺いできたかなと思うのですが、そろそろお時間となりますので、今回のANDPAD TECH TALKとしては以上で終了とさせていただきたいと思います。

さいごに

Rubyコミッターによる対談はいかがだったでしょうか?クックパッドではサーバーサイドやOSSに関わりたい仲間を募集しています。Rubyについてもう少し詳細を知りたい方はカジュアル面談も実施していますので、ご興味のある方はぜひ気軽にご連絡ください。

cookpad.careers

SwiftUIで画面内の各コンテンツの表示ログを送る

$
0
0

こんにちは、レシピサービス開発部の@miichan_ochaです。普段はiOS版クックパッドアプリの開発をしています。

クックパッドアプリでは開発した機能の評価を行うために、画面のPVログや画面内の各コンテンツの表示・タップログなどの様々な行動ログを送っています。

今回は、SwiftUIで新たに作った画面内の各コンテンツの表示ログを送る仕組み(ShowContentLog)についてご紹介します。この仕組みは昨年7月にリリースされたiOS版クックパッドアプリ「のせる」タブ開発時に作られたもので、現在約半年ほど運用しています。

ShowContentLogの仕組み

ログの要件

レシピサービス開発部では、iOS版クックパッドアプリの画面内の各コンテンツの表示ログを以下の要件で取っています。

  1. コンテンツが初めて画面に表示される時に、そのコンテンツの表示ログを送る
  2. 画面のスクロールによって、コンテンツが一度画面外に出てから再度画面内に表示された時には、そのコンテンツの表示ログは送らない
  3. 一定時間経過後の画面自動更新やPull to Refreshによる画面更新を行った時は、更新後の画面に表示されているコンテンツの表示ログを送る
  4. 別画面にプッシュ遷移した後、遷移先から戻ってきて画面が再度表示された時に、その画面に表示されているコンテンツの表示ログを送る
  5. タブの切り替えによって画面が再度表示された時に、その画面に表示されているコンテンツの表示ログを送る
  6. アプリがバックグラウンドからフォアグラウンドに復帰した時に、復帰時の画面に表示されているコンテンツの表示ログを送る

各要件に対応するデモ動画

1 2 3
要件1のデモ動画要件2のデモ動画要件3のデモ動画
4 5 6
要件4のデモ動画要件5のデモ動画要件6のデモ動画

なお、ここでいう各コンテンツとは「表示・タップ回数を計測したいViewのまとまり」のことを指しており、ある画面では画面内のセクション単位であったり、別の画面ではバナー・カルーセルなどViewのコンポーネント単位であったりと、その粒度は画面によって様々です。

UIKitで作られた画面では、ViewController内で表示された各コンテンツのIDを管理するSetをpropertyとして保持し、UICollectionViewで作られた画面であればUICollectionViewDelegatecollectionView(_:willDisplay:forItemAt:)をトリガーにログを送信することでこの要件を実現していました。

仕組みが必要になった背景

きっかけは前述の通り、レシピサービス開発部で「のせる」タブの画面をSwiftUIで作ったことです。

「のせる」タブでは上記の仕様で各コンテンツの表示ログを送る要件があったため、SwiftUIの画面でも各コンテンツの表示ログを送る必要が出てきました。加えて、UIKitでは画面ごとに都度表示ログを送る実装をしていましたが、SwiftUIでは仕組み化して簡単に送れるようにしたいという動機から、今回の仕組みが生まれました。

動作する環境について

今回の仕組みはiOS版クックパッドアプリ上で使用されており、以下の条件・環境で動作しています。

  • iOS版クックパッドアプリで採用しているVIPERアーキテクチャに適合したまま、View層のみでSwiftUIを使っている

  • 表示ログを送る各コンテンツは、LazyVStackListなど、遅延ロードを行うViewの中に配置されている必要がある

    • 各コンテンツが表示されたかどうかは onAppear/onDisappear で判定しているため
  • Markdownで書かれたログ定義から自動生成された行動ログ(以下「自動生成行動ログ」と呼びます)を送る前提で設計されている(追加で別のログを送ることも可能)

  • iOS Deployment Targetが 14.0 の時代に開発された

    • iOS13での動作は未検証となっています

ShowContentLogの使い方

まずは、仕組みの使い方について簡単に説明します。

*説明のためコードを簡略化しています。完成版のコードに関しては「完成版のコード」の章をご覧ください。

最初に、UIHostingControllerを保持しているViewController内でShowContentLogControllerというクラスのインスタンスを作成します。

// ViewControllerprivatelazyvarshowContentLogController= ShowContentLogController(screenViewController:self)

次に、表示ログを送る各コンテンツを内包しているView(以下「大元のView」と呼びます)に ShowContentLogRootModifierというViewModiferを付与します。これにより、このViewModiferを付与したViewが表示されている時にだけ、各コンテンツのログが送られるようになります。

シンプルな画面ではUIHostingControllerの引数に渡すrootViewのViewにShowContentLogRootModifierを付与すれば良いですが、タブがある画面においては、各タブの中身のViewごとに付与します(各タブで表示状態が異なるため)。

ShowContentLogRootModifierの引数controllerには、先程作成したShowContentLogControllerのインスタンスを渡します。各タブの中身のViewごとにShowContentLogRootModifierを付与した場合は、タブの数だけShowContentLogControllerのインスタンスを作成し、タブごとに別々のインスタンスを渡してください。

*このViewModiferに対応するshowContentLogRootというメソッドをSwiftUI.Viewにextensionとして定義しています。

// ViewControlleroverridefuncviewDidLoad() {
    super.viewDidLoad()

    letrootView= HogeView(delegate:self, dataSource:dataSource)
        .showContentLogRoot(controller:showContentLogController)

    lethostingVC= UIHostingController(rootView:rootView)
    ...
}

最後に、表示ログを送りたい各コンテンツのViewそれぞれにPostShowContentLogModifierというViewModiferを付与します。引数eventにはLogCategory protocolに準拠したログイベントを渡します(「自動生成行動ログ」の全てのログイベントはLogCategoryに準拠しています)。

RecipeView()
    .postShowContentLog(SampleCategory.showRecipe(recipeId:recipe.id))

以上で、ログの要件通りに各コンテンツの表示ログを送ることができるようになります。

ShowContentLogの設計

次に、このShowContentLogの設計・内部実装について詳しく見ていきます。

*ここでもコードは適宜簡略化しています。簡略化されていないものは「完成版のコード」の章をご覧ください。

ShowContentLogController

ShowContentLogControllerは、表示ログを送る「大元のView」の表示状態を、子Viewである各コンテンツのViewに通知する役割を担うクラスです。isRootViewAppearingというPublisherを持ち、各コンテンツのViewはこれを監視することで「大元のView」の表示状態を知ることができます*1

@MainActorfinalclassShowContentLogController {
    privateletisRootViewAppearingSubject= CurrentValueSubject<Bool, Never>(false)

    lazyvarisRootViewAppearing:AnyPublisher<Bool, Never>= isRootViewAppearingSubject
        .removeDuplicates()
        .eraseToAnyPublisher()

    funcsetIsRootViewAppearing(_ appearing:Bool) {
        isRootViewAppearingSubject.send(appearing)
    }
}

ShowContentLogRootModifier

ShowContentLogRootModifierは、表示ログを送る「大元のView」に付与するViewModifierです。

このViewModifierは、

  • ShowContentLogControllerのインスタンスをEnvironmentValuesに設定する
  • ViewModiferが付けられた「大元のView」の表示状態をShowContentLogControllerに伝える

という2つの役割を担っています。

structShowContentLogRootModifier:ViewModifier {
    letcontroller:ShowContentLogController@StateprivatevarisAppearing:Bool=falsefuncbody(content:Content) ->some View {
      content
        .onAppear {
            isAppearing =true
        }
        .onDisappear {
            isAppearing =false
        }
        .onReceive(NotificationCenter.default.publisher(for:UIApplication.willEnterForegroundNotification)) { _ inif didEnterBackground {
                isAppearing =true
                didEnterBackground =false
            }
        }
        .onReceive(NotificationCenter.default.publisher(for:UIApplication.didEnterBackgroundNotification)) { _ inif isAppearing {
                isAppearing =false
                didEnterBackground =true
            }
        }
        .onChange(of:isAppearing) { appearing in
            controller.setIsRootViewAppearing(appearing) // 表示状態を ShowContentLogController のインスタンスに伝える
        }
        .environment(\.showContentLogController, controller) // EnvironmentValues に設定する
    }

PostShowContentLogModifier

PostShowContentLogModifierは、表示ログを送りたい各コンテンツのViewに付与するViewModifierです。 このViewModifierは、ShowContentLogControllerのisRootViewAppearing Publisherを監視しつつ、適切なタイミングで表示ログを送る役割を担っています。

また、ViewModifierが付与されたViewが現在表示されているか(isAppearing)、既に表示ログを送ったか(didPostLog)の状態をStateとして保持していて、監視しているisRootViewAppearingがfalseになった時(「大元のView」が非表示になった時)に、既に表示ログを送ったか(didPostLog)の状態をリセットしています。

ShowContentLogControllerRequired(とAppEnvironmentRequired)については「実用段階にするまでに用意した仕組み」の章で詳しく説明します。

structPostShowContentLogModifier<Category: LogCategory>:ViewModifier {
    letevent:Category@StateprivatevarisRootViewAppearing:Bool=false@StateprivatevarisAppearing:Bool=false@StateprivatevardidPostLog:Bool=falsefuncbody(content:Content) ->some View {
        AppEnvironmentRequired { appEnvironment in
            ShowContentLogControllerRequired { showContentLogController in
                content
                    .onAppear {
                        isAppearing =trueif isRootViewAppearing &&!didPostLog {
                            postLog(appEnvironment)
                        }
                    }
                    .onDisappear {
                        isAppearing =false
                    }
                    .onReceive(showContentLogController.isRootViewAppearing) { rootAppearing inif rootAppearing {
                            isRootViewAppearing =trueif isAppearing &&!didPostLog {
                                postLog(appEnvironment)
                            }
                        } else {
                            isRootViewAppearing =false
                            didPostLog =false// didPostLog の状態をリセット
                        }
                    }
            }
        }
    }

    privatefuncpostLog(_ appEnvironment:any AppEnvironment) {
        appEnvironment.activityLogger.post(event)
        didPostLog =true
    }
}

設計時に検討したこと

ログを送ったかどうかの管理をどこで行うか

UIKit時代は、表示したコンテンツのIDをViewControllerのSetで管理し、親Viewが中央集権的に各コンテンツのログ送信フラグの管理を行っていました。しかし、SwiftUIでは全てのViewがIdentityを持っており、ログを送るView自身がStateでログを送ったかどうかを管理する方がSwiftUI的に自然だと考えました(加えて子Viewから親Viewに自身の Identifierを伝えて管理させるやりとりも減らすことができて、実装もシンプルになります)。そのためShowContentLogでは、PostShowContentLogModifierを付与したView自身がログ送信フラグの管理を行う設計となっています。

実用段階にするまでに用意した仕組み

ShowContentLogController

isPresented

対応するログの要件

  1. アプリがバックグラウンドからフォアグラウンドに復帰した時に、復帰時の画面に表示されているコンテンツの表示ログを送る

開発中に気付いたのですが、ShowContentLogを使っている画面上でモーダルを表示し、そのモーダルが表示されている状態でバックグラウンド→フォアグラウンド復帰した時に表示ログが送られていました。原因は、モーダルが表示されている状態でバックグラウンド→フォアグラウンド復帰した時にShowContentLogRootModifierのwillEnterForegroundNotificationdidEnterBackgroundNotificationが発火していたことでした。

iOS版クックパッドアプリでは、モーダル表示を含めた画面遷移はUIKitで行われているため、ShowContentLogControllerの初期化時にSwiftUIのViewを表示しているViewControllerを渡して、モーダルを表示しているかどうかを取得するクロージャisPresented: () -> Boolを保持することにしました。

finalclassShowContentLogController {
    ...letisPresented: ()->Boolinit(screenViewController:UIViewController) {
        isPresented = { [weak screenViewController] in
            screenViewController?.presentedViewController !=nil
        }
    }
}

これをShowContentLogRootModifier内でdidEnterBackgroundNotificationの通知を受け取った時に参照することで、モーダルが表示されている状態でバックグラウンド→フォアグラウンド復帰した時には表示ログを送らないようにしています。

// ShowContentLogRootModifier
.onReceive(NotificationCenter.default.publisher(for:UIApplication.willEnterForegroundNotification)) { _ inif didEnterBackground {
        isAppearing =true
        didEnterBackground =false
    }
}
.onReceive(NotificationCenter.default.publisher(for:UIApplication.didEnterBackgroundNotification)) { _ in// モーダルが表示されていない時だけ処理を行うif isAppearing &&!controller.isPresented() {
        isAppearing =false
        didEnterBackground =true
    }
}

willRefreshOnForeground

対応するログの要件

  1. 一定時間経過後の画面自動更新やPull to Refreshによる画面更新を行った時は、更新後の画面に表示されているコンテンツの表示ログを送る
  2. アプリがバックグラウンドからフォアグラウンドに復帰した時に、復帰時の画面に表示されているコンテンツの表示ログを送る

iOS版クックパッドアプリの一部の画面では、一定時間経過後に再びタブ切り替えで戻ってきたりバックグラウンド→フォアグラウンド復帰したりすると、画面の自動更新が行われます(自動更新の判定はViewControllerで行われています)。この時UIApplication.willEnterForegroundNotificationが送られるタイミングが自動更新が走るタイミングより早いので、バックグラウンド→フォアグラウンド復帰時に自動更新が走る場合は、更新前後で古いコンテンツの表示ログと更新後のコンテンツの表示ログが2回送られてしまっていました。

この場合は更新後の表示ログのみを送りたいので、ShowContentLogControllerに画面の更新が予定されているかどうかを取得するwillRefreshOnForeground: () -> Boolというクロージャを保持し、バックグラウンド→フォアグラウンド復帰時に画面の更新が予定されている場合は更新が終わるまでisAppearingの変更を待つようにしました。

finalclassShowContentLogController {
    ...letwillRefreshOnForeground: ()->Boolinit(screenViewController:UIViewController, willRefreshOnForeground:@escaping ()->Bool= { false }) {
        isPresented =...self.willRefreshOnForeground = willRefreshOnForeground
    }
}
// ShowContentLogRootModifier
.onReceive(NotificationCenter.default.publisher(for:UIApplication.willEnterForegroundNotification)) { _ inif didEnterBackground {
        // 画面をリフレッシュする場合はリフレッシュを待ってから isAppearing を true にするif!controller.willRefreshOnForeground() {
            isAppearing =true
        }
            didEnterBackground =false
        }
    }
}

ShowContentLogRootModifier

forceDisappear

対応するログの要件

  1. タブの切り替えによって画面が再度表示された時に、その画面に表示されているコンテンツの表示ログを送る

forceDisappearをtrueにするとisAppearingの値を常にfalseにしてShowContentLogControllerに伝えることができます。

TabContentView(...)
    .showContentLogRoot(
        controller:tabState.showContentLogController,
        forceDisappear:selection!= tabState.tabType // 違うタブが選択されている時は `isAppearing` の値を常に false にする
    )
// ShowContentLogRootModifier
.onChange(of:forceDisappear) { newForceDisappear in
    controller.setIsRootViewAppearing(!newForceDisappear && isAppearing)
}
.onChange(of:isAppearing) { appearing in
    controller.setIsRootViewAppearing(!forceDisappear && appearing)
}

元々はiOS14のTabViewで、選択されていないタブのonAppearが呼ばれることがあり、それを回避するために生まれました。それ以外にも、OSバージョン問わずタブの選択が完全に切り替わっていない時(スワイプで隣のタブが少しだけ見えている状態)にも隣のタブのonAppearが呼ばれていたので、iOS14のサポートを終了してからもこの指定は続けています。

ちなみにsetIsRootViewAppearing自体をスキップしてしまうと、各種イベントの発火タイミングによっては本来送られるべきログが送られなくなってしまう可能性があるため、このように値を上書きしてsetIsRootViewAppearingを呼ぶ方法を取っています。

isRefreshing

対応するログの要件

  1. 一定時間経過後の画面自動更新やPull to Refreshによる画面更新を行った時は、更新後の画面に表示されているコンテンツの表示ログを送る

更新時にdidPostLog(ログを送ったかどうか)をリセットするために用意されているpropertyです。

現在レシピサービス開発部でSwiftUIで新規画面を開発する時は、下記のScreenStateのようなものを使って画面の状態管理をしているので、このpropertyを使う必要はありません(画面更新時はScreenStateloadingloadedとなりloadedに対応するViewが再生成されるため)。

enumScreenState<T, E: Error> {
    case initial
    case loading(T?)
    case loaded(T)
    case error(E)
}

例えば画面の表示切り替えをZStack内のViewのopacity変更で行っていて、画面更新時にViewが再生成されない場合にこのpropertyを使ってdidPostLogをリセットすることができます。

// ShowContentLogRootModifiervarisRefreshing:Bool...
    .onChange(of:isRefreshing) { refreshing in
        isAppearing =!refreshing
    }

PostShowContentLogModifier

ShowContentLogControllerRequired

ShowContentLogControllerRequiredは、PostShowContentLogModifierを付けたViewよりも上の階層でShowContentLogControllerのインスタンスがEnvironmentValuesに設定されていない(つまりShowContentLogRootModifierを付け忘れている)時にassertionFailureを起こすためのViewです。

import SwiftUI

structShowContentLogControllerRequired<Content: View>:View {
    @Environment(\.showContentLogController)privatevarshowContentLogController:ShowContentLogController?privateletcontent: (ShowContentLogController)->Contentinit(@ViewBuildercontent:@escaping (_: ShowContentLogController)->Content) {
        self.content = content
    }

    privatefuncnoEnvironment() ->some View {
        assertionFailure("You must pass the showContentLogController from a parent or ancestor view. If you use postShowContentLog modifier, add showContentLogRoot modifier to a parent or ancestor view.")
        return EmptyView()
    }

    varbody:some View {
        ifletshowContentLogController= showContentLogController {
            content(showContentLogController)
        } else {
            noEnvironment()
        }
    }
}

AppEnvironmentRequiredも同じような実装となっています(むしろShowContentLogControllerRequiredが先に実装されたAppEnvironmentRequiredの実装を参考にしています)。

AppEnvironmentRequiredがクロージャの引数に渡しているappEnvironmentは、iOS版クックパッド上で用意されている依存関係を取り出すためのDIコンテナで、PostShowContentLogModifier内ではappEnvironmentを用いて行動ログを送るための依存にアクセスしています。

appEnvironmentについては下記に詳しい説明があります(記事内ではEnvironmentと呼ばれています)。 https://techlife.cookpad.com/entry/2021/06/16/110000

onPostLog

onPostLogは「自動生成行動ログ」が送られるタイミングで呼び出されるクロージャで、「自動生成行動ログ」とは別のログを追加で送るためのものです。

// PostShowContentLogModifierprivateletonPostLog: ((any AppEnvironment) -> Void)?
...privatefuncpostLog(_ appEnvironment:any AppEnvironment) {
    appEnvironment.activityLogger.post(event)
    onPostLog?(appEnvironment)
    didPostLog =true
}

完成版のコード

以下が、「実用段階にするまでに用意した仕組み」を踏まえた完成版のコードです。

*動作確認時の環境: Xcode 14.1、iOS Deployment Target 15.0

ShowContentLogController

import Combine
import SwiftUI
import UIKit

@MainActorfinalclassShowContentLogController {
    privateletisRootViewAppearingSubject= CurrentValueSubject<Bool, Never>(false)
    lazyvarisRootViewAppearing:AnyPublisher<Bool, Never>= isRootViewAppearingSubject
        .removeDuplicates()
        .eraseToAnyPublisher()

    letisPresented: ()->BoolletwillRefreshOnForeground: ()->Boolinit(screenViewController:UIViewController, willRefreshOnForeground:@escaping ()->Bool= { false }) {
        isPresented = { [weak screenViewController] in
            screenViewController?.presentedViewController !=nil
        }
        self.willRefreshOnForeground = willRefreshOnForeground
    }

    funcsetIsRootViewAppearing(_ appearing:Bool) {
        isRootViewAppearingSubject.send(appearing)
    }
}

privatestructShowContentLogControllerKey:EnvironmentKey {
    staticletdefaultValue:ShowContentLogController?=nil
}

extensionEnvironmentValues {
    varshowContentLogController:ShowContentLogController? {
        get { self[ShowContentLogControllerKey.self] }
        set { self[ShowContentLogControllerKey.self] = newValue }
    }
}

ShowContentLogRootModifier

import SwiftUI

privatestructShowContentLogRootModifier:ViewModifier {
    privateletcontroller:ShowContentLogControllerprivatevarisRefreshing:BoolprivatevarforceDisappear:Bool@StateprivatevarisAppearing:Bool=false@StateprivatevardidEnterBackground:Bool=falseinit(controller:ShowContentLogController, isRefreshing:Bool, forceDisappear:Bool) {
        self.controller = controller
        self.isRefreshing = isRefreshing
        self.forceDisappear = forceDisappear
    }

    funcbody(content:Content) ->some View {
        content
            .onAppear {
                isAppearing =true
            }
            .onDisappear {
                isAppearing =false
            }
            .onReceive(NotificationCenter.default.publisher(for:UIApplication.willEnterForegroundNotification)) { _ inif didEnterBackground {
                    // 画面をリフレッシュする場合はリフレッシュを待ってから isAppearing を true にするif!controller.willRefreshOnForeground() {
                        isAppearing =true
                    }
                    didEnterBackground =false
                }
            }
            .onReceive(NotificationCenter.default.publisher(for:UIApplication.didEnterBackgroundNotification)) { _ inif isAppearing &&!controller.isPresented() {
                    isAppearing =false
                    didEnterBackground =true
                }
            }
            .onChange(of:isRefreshing) { refreshing in
                isAppearing =!refreshing
            }
            .onChange(of:forceDisappear) { newForceDisappear in
                controller.setIsRootViewAppearing(!newForceDisappear && isAppearing)
            }
            .onChange(of:isAppearing) { appearing in
                controller.setIsRootViewAppearing(!forceDisappear && appearing)
            }
            .environment(\.showContentLogController, controller)
    }
}

extensionView {
    funcshowContentLogRoot(controller:ShowContentLogController, isRefreshing:Bool=false, forceDisappear:Bool=false) ->some View {
        modifier(ShowContentLogRootModifier(controller:controller, isRefreshing:isRefreshing, forceDisappear:forceDisappear))
    }
}

PostShowContentLogModifier

import SwiftUI

privatestructPostShowContentLogModifier<Category: LogCategory>:ViewModifier {
    privateletevent:CategoryprivateletonPostLog: ((any AppEnvironment) -> Void)?
    @StateprivatevarisRootViewAppearing:Bool=false@StateprivatevarisAppearing:Bool=false@StateprivatevardidPostLog:Bool=falseinit(event:Category, onPostLog: ((any AppEnvironment) -> Void)?) {
        self.event = event
        self.onPostLog = onPostLog
    }

    funcbody(content:Content) ->some View {
        AppEnvironmentRequired { appEnvironment in
            ShowContentLogControllerRequired { showContentLogController in
                content
                    .onAppear {
                        isAppearing =trueif isRootViewAppearing &&!didPostLog {
                            postLog(appEnvironment)
                        }
                    }
                    .onDisappear {
                        isAppearing =false
                    }
                    .onReceive(showContentLogController.isRootViewAppearing) { rootAppearing inif rootAppearing {
                            isRootViewAppearing =trueif isAppearing &&!didPostLog {
                                postLog(appEnvironment)
                            }
                        } else {
                            isRootViewAppearing =false
                            didPostLog =false
                        }
                    }
            }
        }
    }

    privatefuncpostLog(_ appEnvironment:any AppEnvironment) {
        appEnvironment.activityLogger.post(event)
        onPostLog?(appEnvironment)
        didPostLog =true
    }
}

extensionView {
    funcpostShowContentLog<Category: LogCategory>(_ event:Category, onPostLog: ((any AppEnvironment) -> Void)? =nil) ->some View {
        modifier(PostShowContentLogModifier(event:event, onPostLog:onPostLog))
    }
}

まとめ

今回ご紹介したShowContentLogによって、SwiftUIの画面でもUIKit同様に各コンテンツの表示ログを送ることができるようになりました。また、UIKit時代は画面を作る度に一から表示ログの実装が必要だったのですが、仕組みを作ったことでSwiftUIでは表示ログを簡単に送ることができるようにもなりました。

仕組みを作る中で様々な意見・指摘をくれたチームの同僚に感謝します。この記事が、SwiftUIを使った画面で行動ログを送る際の参考に少しでもなれば幸いです。

*1:isRootViewAppearingを最初computed propertyにしていたのですが、それだとSwiftUIのViewのbodyが再実行される度にonReceiveで毎回新たなPublisherのインスタンスを購読するという挙動になりremoveDuplicatesが効かなくなってしまうので、lazy varで宣言しています。

工事設計認証(技適)をとってみた

$
0
0

こんにちは、クックパッドの齋藤です。 私はハードウェアPdMとして、クックパッドマートで事業に関わるハードウェア(マートステーション、プリンタ、温度監視システム等)の企画開発・開発ディレクション・調達・保守等をやっています。

クックパッドマートとハードウェア

クックパッドマートは2018年9月20日にリリースされた生鮮食品のECプラットフォームです。リリースから4年以上経ち、新規事業ならではのスピードを維持しつつサービス拡大のため試行錯誤を日々続けています。

https://cookpad-mart.com/

クックパッドマートはiOSとAndroidの専用アプリで利用可能です。このアプリで商品を購入して、近所の受け取り場所(マートステーションと呼んでいます)で受け取れます。有料で自宅配送するオプションもあります。

クックパッドマートでは、食材の輸配送や保管といった現実世界を相手にビジネスを展開しているため、冷蔵庫をはじめとした様々な機材が必要になるのですが、その中にはまだ世の中になく新しく開発する必要があったり、海外から調達する必要がある物がたくさんあります。

時には自分たちでオリジナルの基盤を開発・量産することもあります。

そのため社内にはハードウェアチームがあり、ハードウェアエンジニアや組込エンジニア等、普通のWeb系企業にはいない、ユニークな人材がいます。

今日はその中でも、海外のデバイスメーカーから工事設計認証(技適)を自分たちで取得したお話をしたいと思います。

なぜ技適を取るのか

今クックパッドマートではチルド食材の配送時、シッパーという断熱ボックスの中に食材と蓄冷剤を一緒に入れて、軽自動車のバンなどで運んでいます。

バンの中にはいくつもシッパーが入っているのですが、食材の安全性を担保するため、シッパー内の温度が異常になっていないか監視を行っています。

現在はGPSマルチユニットSORACOM Editionという機材を用いて温度監視を行っていますが、サービスローンチから時間が経って事業規模が大きくなってきたため、より低コストで温度監視ができる仕組みが必要となってきました。

techlife.cookpad.com

私たちはより効率的に配送中のシッパーの温度を把握し、異常を検知したり、万一品質不具合が出た時のトレーサビリティを確保する仕組み “TemperatureRightHear”、通称「TempRa(テンプラ)」を開発したりしています。

ざっくりとしたポンチ絵

開発するものとしては、

  1. シッパーの中に入れ、温度センサーが取得する値をBLEで送信するビーコン
  2. バンのシガーソケットに刺し、1のデータを受信してLTE経由で送信する車載IoTゲートウェイ
  3. 2のデータを集計分析し、蓄積したりアラートを促すバックエンドシステム

の3つがありますが、現在マートでは配送バンを数多く運用しており、その時のシッパーは膨大な量になります。

1で用いる市販のビーコンは、1個あたり9,000円程度してしまいます。配送時には数千ものシッパーを用いるため、普通に調達してしまうと数千万円もの高額出費を覚悟する必要があります。

そこでまず、必要な個数を減らせないか考えました。実はこのビーコンは既にマートステーションでの温度監視に用いていたため、これを回収して転用することで新規調達の個数を減らす計画です。マートステーションの側ではより安価な有線の温度センサーを利用するように変更します。

しかしながらそれでも足りないため、今回直接中国の深圳にあるメーカーから現地のビーコンを直接購入し、調達価格を抑えることとしました。 その場合1個あたり15米ドル程度で調達することができるため、1個あたりの差額は7,000円程度安く調達することができます。そのため大幅に調達価格を抑えることができるのです。

であればはじめからそうすればいいじゃん、となると思いきや、そうは問屋がおろしません。 日本で電波を発生する機器を使用する際は、「技術適合認証」(通称技適)を取得する必要があります。技適を取得していない機器で電波を発した場合、電波法違反という法令違反となります。

詳細な説明はこのブログ https://www.musen-connect.co.jp/blog/course/other/japan-radio-law-basic/がわかりやすいです。

これを取得するのは結構面倒くさいですし、それなりに電気や電波に関するエンジニアリングの知見が必要なため、なかなか大変だったりします。

しかしながらクックパッドマートには、強力なハードウェアエンジニアがいますし、私自身もマートステーションの設置をするときや将来的に物流倉庫等をIoT化するときに使えるかと思い「第一種特殊無線技士」という電波についての資格も取っていました。そこで認証機関に相談したところ3-50万円で取得できることがわかり、将来的なクックパッドの知見にもなると思ったので、社内で相談した結果、量産するもの1つ1つに技適を適用するときに用いる「工事設計認証」を取得した上で調達を行うこととしました。

取得の流れ

取得に当たっては、具体的に下のような流れですすめていきました。

  1. デバイスの調達元からデータシート、金額見積り等を照会します。 見積もりはサンプル費用、正式調達時の数量における単価と総額の2つにわけてもらいます。予算上問題がないことを確認し、サンプルを調達しました。

    サンプルデバイス

  2. サンプルデバイスについて『技適未取得機器を用いた実験等の特例制度』の届出をし、動作や私たちの要求仕様を満たしているか確認します。 制度の詳細は https://www.tele.soumu.go.jp/j/sys/others/exp-sp/をご確認ください。

  3. 認証機関に相談の上、申請書類を作成します。 認証期間はいくつかあるのですが、今回は老舗である「一般財団法人テレコムエンジニアリングセンター」(通称TELEC)に依頼しました。 書類作成は結構大変だったのですが、先方はとても親切で、細かい表現に至るまで細かくフィードバックいただけました。それだけ書類の「てにおは」含め細かく校正が必要で、このフィードバックがなければ書類作成ができませんでした。

  4. 認証機関で試験を行い、試験を行います。 試験の際はサンプルデバイスとは別にテスト用のデバイスが必要です。このデバイスはスペクトルアナライザ等に接続するためRF出力ができる必要があり、また先方の安定化電源に繋げられる必要があります。このテストデバイスを用いて、入力電圧などを変えながら挙動を確認する試験を行っていただきました。

取得費用

デバイスの仕様によって異なりますが、今回はBLEのみのデバイスということで、約30万円が費用としてかかりました。

申請書類

主な申請書類は認証申込書・別紙資料、工事設計書、無線設備系統図、確認方法書、 部品配置図又は写真、外観図又は写真です。詳細は https://www.telec.or.jp/services/tech/offer.htmlをご確認ください。 無線設備が1チップになっていたりしたときはどうするのか、その半導体の詳細構成が開示されてない場合はどうするのか、等細かい不明点が山のように出てきますし、製造元とのやりとりもいろいろ発生します。

面白かったのは、部品が容易に変えられないことを説明するやり取りです。調達予定のデバイスは普通のプラスドライバーでケースが開いてしまうのでそこがいけないのかと思い、ネジが保護ゴムで覆われていて容易には開けられないということを書いたところ、「部品が表面実装部品で構成されている」と書けばよいとのことでした。この辺のニュアンスは、慣れていないと全く分からないですね……。

試験当日

ここから書類の修正作業をしたりテストデバイスをメーカーから取り寄せたりに10営業日くらい使いました。申請書類がOKとなれば、試験当日です。 それまでにテストデバイスの制御用ソフトウェアの動作確認や、デバイスとの接続確認をした上で、試験機関を訪問してテストです。 テスト中デバイスが想定外の挙動をするなど、ヒヤヒヤする場面もありましたが、なんとかみんなのファインプレーで切り抜けることができました!

審査と認証書交付

その後先方内で審査があり、終了後1週間程度で無事認証書が交付されました! やったね!

実際の認証書

正式調達

実際に交付された認証番号をデバイスに記載した上で、ようやく正式調達です! せっかくなので、ロゴと弊社ミッション “Make everyday cooking fun!” も書いておいてもらいました!

正式調達のビーコン

終わりに

ということで、今回クックパッドではじめて、工事設計認証の取得を試みてみました。 今までマートのハードウェアチームはステーションの開発やプリンタの開発等、リアルワールドで仕事をする上で欠かせないハードウェアを開発・量産・保守してきました。 もちろん今回のデバイスはスクラッチ開発ではないですし、なんなら別メーカーにて日本に導入済みのデバイスです。 しかしながら今回取得した工事設計認証も、そういったハードウェアを扱えるチームがあってこそ、無事取得まで漕ぎ着けることができたのは間違いありませんし、 社内ブログ「Groupad」にて知見共有が積極的に行われていたので、そういったクックパッドならではの技術的知見で、はじめてのことでもチャレンジすることができました。 取引先との雑談でそういった話が出た時には、「そこまでやるんですか!」と言われることも多いです。

このような形で、ハードウェアチームはユーザの皆様に安全・安心で高品質な食材をお届けするという、「あたりまえのことを」「あたりまえに」実現するために日々開発や保守をおこなっています。

もしご興味がある方がいらっしゃったら、是非こちらから採用情報をご確認ください!

cookpad.careers

クックパッドが手がけるスマート冷蔵庫「マートステーション」の開発の歴史2023

$
0
0

こんにちは、クックパッドの齋藤です。 私はハードウェアPdMとして、生鮮食品のECプラットフォーム、クックパッドマートで事業に関わるハードウェア(マートステーション、プリンタ、温度監視システム等)の企画開発・開発ディレクション・調達・保守等を担当しています。

cookpad-mart.com

クックパッドマートはiOSおよびAndroidの専用アプリで利用可能なサービスです。このアプリで商品を購入して、街中のスマート冷蔵庫「マートステーション」で受け取れます。 マートステーションは駅やコンビニエンスストア、マンションの共用部といった人々の生活動線に設置し、共同納品を行っています。そのため自宅に個別配送をする他の生鮮食品ECに比べ、より物流コストを抑えた配送を実現しています *1

クックパッドマートは食材の輸配送や保管をはじめとした現実世界を相手に展開しているビジネスです。 そのためマートステーションや温度センサをはじめとした様々な機材が必要なこと、加えて社内にはハードウェアチームがあり、ハードウェアエンジニアや組込エンジニア等の普通のWeb系企業にはいない、ユニークな人材がいることを、前回下記の記事でお話ししました。

techlife.cookpad.com

本稿ではマートステーションの開発、進化の歴史をご紹介します。

前述したとおり、クックパッドマートでは駅やコンビニエンスストア、マンションの共用部等に設置し、配送した食材を保管するためのスマート冷蔵庫「マートステーション」を独自開発・運用しています。

マートステーションは単なる冷蔵庫として機能するのみならず、盗難防止用の電子錠や鍵として機能するQRコードのリーダー、さらには温度監視や死活監視の機能を搭載しています。

サービス開発初期、私たちの用途にかなう機材を探したものの、残念ながら世の中に存在しませんでした。従って自分たちで試行錯誤をしながら、以下のような開発を行っています。

techlife.cookpad.comascii.jpwww.wantedly.com

冷蔵庫の鍵は受け取りにきたお客様や配送ドライバー、メンテナンススタッフがQRコードを用いて開錠することができるようになっています。 また、すべての冷蔵庫に温度計を設置し、常に庫内温度をモニタリングしています。機材トラブルやドアの閉じ忘れ等、様々な要因による温度異常を検知した際には直ちに適切な対応が取れるようになっています。

庫内温度のモニタリングの様子

このようなハードウェアを開発するにあたって、私たちが得意としてきたソフトウェア開発とは異なるノウハウが必要であったため、チームで悪戦苦闘しながら、数多くの新規開発とアップデートを行ってきました。今回は私たちがその過程で獲得した開発に対する考え方と、2023年2月現在に活躍しているマートステーションのご紹介、そしてその進化の歴史についてご紹介します。

開発の方針

マートステーションは人々の生活動線上に設置先のオーナー等の承諾を得た上で間借りして設置されています。またその数は2023年1月末時点で一都三県に約1,000箇所にのぼるため、保守を行う上でも効率的に管理できることが求められます。 そのようなマートステーションを開発する上で重要となる開発方針を説明します。

ノーメンテナンスで安定稼働

メンテナンス要員が常に現場にいることを想定しておらず、その場で復旧および故障判断を即座に行いづらいマートステーションには、高い安定性が要求されます。 せっかくお客様に商品を購入していただいても、マートステーションにトラブルが発生するとお客様は商品を受け取ることができず、最悪の場合その日の食事の支度ができなくなってしまうからです。 しかしながら当初はハードウェア開発や管理等の知見が不足していたため、毎日不具合が発生しては現場にメンバーが向かって、復旧を行う必要がありました。お客様が購入した食材を受け取れない事態が発生することもありました。 ユーザ体験の著しい悪化を防ぐためにも、ステーションが安定して稼働していることは、とても重要です。

省スペース

たとえばコンビニエンスストア等の店舗では、既存の棚やラック等の店頭什器の代わりにマートステーションを設置するため、よりコンパクトなスペースで設置できることが求められます。スペースの都合で設置できないということも発生しうるため、よりコンパクトなマートステーションが開発できれば、それだけより多くの場所に設置できる可能性が広がります。

低コスト管理

マートステーションの「食材を冷やす」冷蔵庫機能については、冷蔵庫メーカの既存製品を用いています。 しかしながら、QRコードリーダや電子錠、温度監視、死活監視機能等、私たちがマートステーションとして運用する機材(以後コントロール・ユニットと表記します)およびシステムは、社内にて全体設計、機材選定、組込ソフトウェアの開発・運用を行っています。そのためコントロール・ユニットの変更を行えば、その分ファームウェアの更新や管理にかかるコストが増えてしまいます。極力同一のコントロール・ユニットで多様なモデルに適用できるように設計する必要があります。

コントロール・ユニット(JCM-Mk1)

より「やす」いマートステーションへ

上記に加え、開発では4つの「やすい」をキーワードに開発を行っています。機材を安定して低コストで調達するための方針です。

  • 調達しやすい:どれほど優れた機材でも、部品調達のリードタイムがかかってしまったり、市中在庫がなくなってしまっては、生産を行うことができません。特に現在の半導体をはじめとした部品不足により、マートステーションも調達リードタイムが伸びてしまい、欠品となるリスクに何度も直面しました。そのため、極力汎用品で代替調達が可能なものを部品として選定しています。
  • つくりやすい:部品点数が増えたり製造工程が複雑になってしまうと、組立ミスや組立コストの増加につながります。できる限り部品点数を削減し、つくりやすい設計を行うことで、コストを削減し、量産品質を安定させることができます。
  • 扱いやすい:品質の良いものを量産しても、設置後どうしても一定の割合で不具合が出てしまいます。最悪の場合、商品を購入したお客様が商品を受け取れなくなってしまう可能性もあるため、すぐに復旧できるよう保守チームにとって扱いやすい設計を常に意識する必要があります。
  • 価格もやすい:当然ではありますが、マートステーションは高額機材のため、1円単位の原価低減が非常に重要です。仕様設計についても常に優先順位をつけて、ある程度の割り切りを行う必要があり、高度なバランス感覚が必要となります。

現在設置されているマートステーションと開発の歴史

上記の考え方は最初から方針として打ち立てられていたものではなく、開発/量産/保守運用を行う過程やお客様からのフィードバックで得た気づきを、新たな開発や既存機への保守アップデートとして反映しながら身につけてきました。

本格量産を行えるマートステーション:JCM-Mk1/Mk2

当初マートステーションは市販のワインセラーを用いて製造していましたが、耐久性の問題もあり、業務用冷蔵ショーケースを用いて本格量産機と位置付けたのがJCM-Mk1とJCM-Mk2です *2。少量生産を行いながら鍵の位置を変えたりするなど、カイゼンを行いながら生産を行い、量産ノウハウを蓄積しました。

耐結露性能の向上と、安定性の向上を目指して:JCM-Mk3/Mk3.1

JCM-Mk1/Mk2は2つの問題を抱えていました。1つは冷蔵ショーケースをベースとして量産していたため、一部の環境下で夏場結露に悩まされたことです。そのため、ガラス面に断熱材を貼り付けて結露の発生を抑えたり、結露水を受けるトレイを設置したりする等、追加対処を行うことで対応をはかる必要がありました。
次期モデルJCM-Mk3では断熱性の高いLow-Eガラスを用いることで、結露耐性を引き上げることとしました。

もう1つの問題はコントロール・ユニットの安定性が欠けていたことです。 マートステーションのメインコンピュータはRaspberry Piを採用していました。Raspberry Piはプロトタイプ検証や工場内の設備稼働状況の監視等、IoTの文脈で広く用いられています。しかしSoCのデータシートが一般公開されていなかったり等、遠隔地に大量に設置する機材に採用するには向いていない側面がありました。

加えてLTEモジュールやQRコードリーダ、独自開発の基板等を重ねた複雑な機材構成になっていて、不具合発生時の要因の特定が難しい構成となっていました。当初は突然機材からの通信が途絶え、現場対応に向かうもその複雑さから何が原因で不具合が発生しているのか、不具合の切り分けができないことも多々ありました。 ときには運用を中断してマートステーションごと交換するといった多大なリソースを必要とし、毎日のように不具合に悩まされました。

そのため、社内に構築した検証用機材で不具合の解析をしたり、現場投入前に改善策の品質をテストしたりしました。合わせて現場からのフィードバックをもとに、その1つ1つの事象に対して、保守チームとエンジニアが対処方法を議論し、機材構成のカイゼンや、ファームウェアの安定性を向上させるアップデートを行いました。

検証で用いるために構築した環境

たとえば、解析の過程で基板への入力電圧のわずかな不足が判明し、電圧を引き上げたり、冷蔵庫内温度データを解析し設定温度の最適化をしたり、様々なカイゼンを行いました。

機材アップデートの例

techlife.cookpad.com

マイナーチェンジモデルJCM-Mk3.1では上記のカイゼン結果を反映しつつ、当時起こりつつあった半導体や電子部品の不足も考慮したアップデートを考える必要がありました。最終的に内部構成の変更や、部品点数の削減、また調達しやすい部品を多く採用することで、機材の安定性を向上させ、原価を低減し、さらには調達リードタイムを減らすことができました。

このような活動を通して、現在はJCM-Mk1からJCM-Mk3.1まで、運用上機材の差をほとんど意識せずとも運用が行えるまでに、高い安定性を獲得しています。 2年前と比較し、現在マートステーションは3倍ほどに増えていますが、保守チームの人数およびコストは増やすことなく運用を行っています。

JCM-Mk3.1で開発したロゴ入りのオリジナル基板

冷蔵庫/制御システムを一新:JCM-Mk4

JCM-Mk3/Mk3.1で行った結露対策をさらに強化するため、ショーケースのドアを鉄板に変更し、ドアに結露対策のヒータを内蔵する等の更なる対策をほどこしたのが、JCM-Mk4です。 構造を大きく変えたため冷蔵庫本体は新しくPSE認証を取得しています。

またRaspberry Piを産業用IoTゲートウェイであるFutureNet MA-S110に一新、構成部品をゼロベースで見直すことで、更なる安定性の向上を行うことができました。

さらにコントロール・ユニットの点検用ドアを手前に引っ張り出せる仕様としました。従来はメンテナンス用に確保する必要があったヒンジドアの可動部のスペースを無くすことで、設置スペースのコンパクト化にも寄与することができたほか、最悪の状況ではユニットごと取り替えられるようになりました。

新開発の引き出し式点検ドア

デザインも一新し、クックパッドマートのアピールポイントである「こだわりの新鮮な食材をおとどけする」ことを強調したクリエイティブとして、サービス認知向上に寄与するものとしています。 現在、このクリエイティブのトンマナはポスター・のぼり等販促品にも引き継がれています。

次世代ステーション:HZK-Mk1

フルモデルチェンジ相当のJCM-Mk4で、全てが解決したわけではありません。

これまでのベース機は中国で生産を行っていたため、春節等で一斉に工場が止まってしまうと生産が止まってしまうほか、ある程度まとまったロットでそれなりのリードタイムを想定して見込み生産を行う必要があったため、過剰在庫を抱えるリスクがありました。

また、冷蔵庫そのものの安定性をより向上させる必要もありました。 庫内が冷えないといった不具合が発生した際、交換を行うための金銭的コストや、対応期間中マートステーションとしての営業を中断する機会ロスが発生してしまいます。それはお客様のユーザ体験を悪化させる原因にもなります。

そして、安定的な調達の観点から複数社より調達できるルートも検討する必要がありました。これを踏まえて業務用冷蔵庫の製造メーカに相談した結果、開発・量産の合意に至ったのがHZK-Mk1です。 本機は国内生産の業務用冷蔵庫をベースとし、冷蔵庫本機の信頼性の高さが最大の特徴です。実際に先行量産を行って設置を行いましたが、現在トラブル0でそれを実証しています。 また更なるコンパクト化を行い、設置ハードルをさらに下げることができています。 コントロール・ユニットについてもJCM-Mk4とほぼ同様のものを流用し、ファームウェアの保守管理コストを抑えました。

HZK-Mk1(開発中の機材です)

その他の試作開発

その他にも複数のメーカと試作開発を行い、さらなる開発の検討を行っています。その際もなるべく現状のコントロール・ユニットを用い、モデルが増加しても不用意に社内の管理コストを増やさないよう、配慮することを心がけています。

終わりに

現在マートステーションの設置箇所は1,000を超えています。そういった状況でも、管理コスト・保守コストを設置拠点数の増加に比例させず、より安心・安全によりよいユーザ体験でお使いいただけるよう、エンジニア/デザイナ/保守/ハードウェアPdM一丸となって、頑張っています。

もしご興味がある方がいらっしゃったら、是非こちらから採用情報をご確認ください!

cookpad.careers

*1:なお、一部有料オプションで自宅配送サービスもあります。

*2:弊社では冷蔵庫本体の製造メーカと開発世代を組み合わせて、形式名称を分類しています。ここではJCM社の冷蔵ショーケースとそれを用いた1世代/2世代目の形式のものをさすため、JCM-Mk1/Mk2という表記を行っています。

SwiftUIでStickyなタブを実現する

$
0
0

こんにちは。クックパッド事業部でiOSアプリの開発をしている新堀(@tk108gabalian)です。
iOS版のクックパッドアプリではスクロール時にコンポーネントが上部に固着する画面があります。 所謂StickyHeaderというやつです。 今回はそのStickyHeaderをSwiftUIで、かつTabViewを使用つつ実現する方法について紹介します。

導入の背景

2022年7月にリリースした「のせる」画面には以下の要件がありました。

  • 画面上部にユーザー情報を表示する。
  • ユーザー情報の下にタブを表示する。
  • タブをタップするか、タブより下を横にスワイプすることでタブの切り替えが可能。
  • 画面全体をスクロールできるが、ユーザー情報が隠れるまでスクロールしたら画面上部にタブが固着し、以降はタブより下の部分のみスクロールする。(逆方向にスクロールする場合は再度ユーザー情報が表出する。)

また、事業的な希望ではありませんが、開発メンバー達の中ではSwiftUIで画面を実装したいという気持ちがありました。 というのも、この「のせる」以前の画面は基本的にUIKitで作られており、SwiftUIと比べてUIの変更に時間がかかることが課題としてあったからです。

UIKitでこのStickyHeader実現することは可能だと分かっていました。
なぜなら、同クックパッドアプリの「きろく」画面にて、UIKit製のStickyHeaderが既に実装されていたからです。
そのため、UIKitを使えば確実に要件を満たすものが作れると分かりつつも、SwiftUIで同様の振る舞いを実現する方法を探求することにしました。

TabViewなしのStickyHeaderの実現方法

横スクロールによるタブの切り替えを実現するため、SwiftUIのTabViewを使うことを検討していました。 しかし、検証を進めると、TabViewを使いつつStickyHeaderを実現する難しさが分かってきました。

まずはTabViewを使わない場合のStickyHeaderの実現方法を見てみます。
これはScrollView内のコンテンツでLazyVStackなどを使用し、pinnedViews.sectionHeadersを指定した上で、LazyVStackの中のSectionにheaderとなるViewを指定するだけです。

structContentView:View {
    varbody:some View {
        ScrollView {
            LazyVStack(pinnedViews: .sectionHeaders) {
                Section(header:Text("ここが固着する")) {
                    ForEach(1...50, id: \.self) { number in
                        Text("Row \(number)")
                    }
                }
            }
        }
    }
}

しかし、上記の方針だとScrollViewの中にTabViewを入れる時に問題が発生します。
ScrollViewにジェスチャーが吸われるので、TabViewの中身を横スワイプで切り替えることができません。 (一応TabViewのselectionを切り替えるボタンを用意すればタブを切り替えることはできます。)

また、ScrollView内にTabViewを配置する場合、TabViewの高さがframe modifierのheightによって明示的に指定されないと、高さが確定せずに何も表示されません。 よって、TabView内のコンテンツの高さを全て計算してheightを設定する必要があり、かつそれがタブごとに、またタブの切り替えごとに必要になります。

このように、TabViewとpinnedViewsを併用してStickyHeaderを実現しようと思うと、TabViewの旨みである横スワイプが無効になり、さらにTab内のすべてのViewの高さ計算が必要になってしまいます。

ここまでの調査でTabViewを使用しつつStickyHeaderを実現することが簡単ではないことが分かりましたが、検証を進めていく中で要件を満たす実装に辿り着くことができました。

動作環境

iOS Deployment Targetが 14.0 以上を想定しています。 ※ iOS13での動作は未検証となっています。

画面構成

まず初めに、StickeyHeaderを実現している画面の構成について説明します。

前提として、iOS版クックパッドアプリでSwiftUIを採用している画面では、SwiftUIのViewをUIHostingControllerでラップし、そのUIHostingControllerのviewを親となるUIViewControllerに載せて使っています。
上記の構成はSwiftUI を活用した「レシピ」×「買い物」の新機能開発で紹介されているので、詳しくはこちらをご参照ください。

SwiftUIのViewは以下のような階層になっています。

structFooTabView:View {
    @Bindingvar selection:TabTypeprivatevartopAreaHeight:CGFloat {
        Header.height + TabBar.height
    }

    varbody:some View {
        ZStack(alignment: .top) {
            tabView

            VStack(alignment: .center, spacing:0) {
                Header()
                TabBar(tabTypes:TabType.allCases, selection:$selection)
            }
        }
    }

    @ViewBuilderprivatevartabView:some View {
        TabView(selection:$selection) {
            ForEach(TabType.allCases) { _ in
                ScrollView {
                    Content()
                }
            }
        }
        .padding(.top, topAreaHeight)
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    }
}

ZStackを配置し、ZStack内にTabViewとヘッダー部分を配置します。 この時、ヘッダーが前面、TabViewが背面になります。 背面のTabViewにはTabごとに縦方向のScrollViewを配置します。 そのままではTabViewが上部のヘッダーに隠れてしまうので、TabViewのtopにpaddingを付けます。

TabViewありのStickyHeaderの実現方法

次にスクロールに合わせて前面のコンポーネントが動くように修正し、さらに一定以上は動かなくなる(画面上部で固着する)振る舞いを実現する方法を紹介します。
以下の2つのステップに分けて説明します。

  1. SwiftUIのScrollViewからスクロール量を取得する。
  2. 取得したスクロール量を使って上部のコンポーネントを動かす。

1. SwiftUIのScrollViewからスクロール量を取得する

スクロール量を取得するため、SwiftUIのGeometryReaderとPreferenceKeyを使います。
GeometryReaderは親となるViewのサイズや座標を取得するAPIです。 ScrollView内にGeometryReaderを配置することで、ScrollView内のコンテンツのY座標を取得できます。(このY座標が実質スクロール量になります。) GeometryReaderから座標とサイズを取得するには、GeometryReaderのinitializerのclosureからGeometryProxyを受け取る必要があります。 しかしこのclosureは@ViewBuilderとなっており、このスコープ内で@Stateな変数を上書きすることはできません。

そこでPreferenceKeyが登場します。
PreferenceKeyを使用すると子Viewから親Viewに値を受け渡すことができます。 まずGeometryReader内に透明なViewを配置します。これで@ViewBuilderのコンパイルできる条件を満たします。 そして上記の透明なViewでPreferenceKeyを使用し、親ViewにGeometryProxyのy座標を渡します。

ここまでの一連の処理を一つのViewで行えるようにし、利用元はclosureからスクロール量を受け取ることができるようにしました。

privatestructScrollViewOffsetYPreferenceKey:PreferenceKey {
    staticvardefaultValue:CGFloat= .zero
    staticfuncreduce(value:inoutCGFloat, nextValue: ()->CGFloat) {}
}

publicstructOffsetReadableVerticalScrollView<Content: View>:View {
    privatestructCoordinateSpaceName:Hashable {}

    privateletshowsIndicators:BoolprivateletonChangeOffset: (CGFloat)->Voidprivateletcontent: ()->Contentpublicinit(
        showsIndicators:Bool=true,
        onChangeOffset:@escaping (CGFloat)->Void,
        @ViewBuildercontent:@escaping ()->Content
    ) {
        self.showsIndicators = showsIndicators
        self.onChangeOffset = onChangeOffset
        self.content = content
    }

    publicvarbody:some View {
        ScrollView(.vertical, showsIndicators:showsIndicators) {
            ZStack(alignment: .top) {
                GeometryReader { geometryProxy in
                    Color.clear.preference(
                        key:ScrollViewOffsetYPreferenceKey.self,
                        value:geometryProxy.frame(in: .named(CoordinateSpaceName())).minY
                    )
                }
                .frame(width:1, height:1)
                content()
            }
        }
        .coordinateSpace(name:CoordinateSpaceName())
        .onPreferenceChange(ScrollViewOffsetYPreferenceKey.self) { offset in
            onChangeOffset(offset)
        }
    }
}

2. 取得したスクロール量を使って上部のコンポーネントを動かす

スクロール量が取得できたらそれを使ってヘッダー部分を動かします。
SwiftUIではoffset modifierを変更することで描画地点をずらすことができます。
①これを利用し、取得したスクロール量をoffsetに渡すことでヘッダーを動かします。

before after

しかし、スクロール量をそのままoffsetに反映させ続けると、固着して欲しいタブの部分が画面の上に突き抜けていってしまったり、動いて欲しい方向とは逆向きにヘッダーが動いてしまいます。
②そこで、offsetの範囲をあらかじめ決めておき、その範囲内でoffsetを変えるようにしています。

before after

また、ヘッダーが上に動いているのにヘッダーより下のコンポーネントが動いていないと、ヘッダーの下に隙間ができてしまいます。
③これを防ぐために、ヘッダーより下のコンポーネント(参考実装で言うところのtabView)のpaddingにoffsetを反映させ、ヘッダーに追従して動くようにしています。

before after

しかし、上記のpaddingの変更によってヘッダー以下のコンポーネントの位置が変わると、その分ScrollView内のコンテンツのY座標も動いてしまい、スクロールとpadding変更の両方が合わさった結果、倍速のスクロールが発生してしまいます。
④これを防ぐために、ScrollView内のコンテンツのoffset modifierにて反転したoffsetを指定することで、paddingの変更分を相殺し、倍速でスクロールしないようにしています。

before after
structFooTabView:View {
    @Bindingvar selection:s@Stateprivatevaroffset:CGFloat= .zero

    privatevartopAreaHeight:CGFloat {
        Header.height + TabBar.height
    }

    varbody:some View {
        ZStack(alignment: .top) {
            tabView

            VStack(alignment: .center, spacing:0) {
                Header()
                TabBar(tabTypes:TabType.allCases, selection:$selection)
            }
            .offset(y:offset) // ①
        }
    }

    @ViewBuilderprivatevartabView:some View {
        TabView(selection:$selection) {
            ForEach(TabType.allCases) { _ in
                OffsetReadableVerticalScrollView(onChangeOffset:updateOffset) {
                    Content()
                        .offset(y:-offset) // ④
                        .padding(.bottom, topAreaHeight)   
                }
            }
        }
        .padding(.top, topAreaHeight + offset) // ③
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    }

    privatefuncupdateOffset(_ newOffset:CGFloat) { // ②if newOffset <=-topAreaHeight { // HostingControllerを使わない場合、ここにsafeAreaを高さを足す必要がある。
            offset =-topAreaHeight
        } elseif newOffset >=0.0 {
            offset =0
        } else {
            offset = newOffset
        }
    }
}

ここまでの実装で、上部コンポーネントの移動とタブの固着が実現できます。

対処した問題

前章で紹介した実装でタブの固着は実現できるのですが、実装に進むと以下の問題点に気づきました。

  1. タブを切り替える時にoffsetが大きくずれてしまう。
  2. iOS15系だとサイズクラスの変更の際にTabView以下の表示が崩れてしまう。
  3. TabView内のコンテンツの高さがスクロール領域の高さと同じくらいの時に、画面がガタガタしてしまう。

タブを切り替える時にoffsetが大きくずれてしまう

スクロール量の取得とoffsetの変更はタブごとに行なっています。
そのため、タブを切り替えるとスクロール量も大きく変更される可能性があります。
その際にヘッダー部分のoffsetが追従していないと、ScrollView内のコンテンツがヘッダー部分に隠れたり、ScrollView内のコンテンツのTopとヘッダーの間に大きな余白が見えてしまいます。

これを防ぐために、タブごとのスクロール量を保持しておき、タブの切り替えの際に変更先のタブのスクロール量を流すようにしています。 (コードは簡略化したものです。)

structOffsetReadableTabContentScrollView<TabType: Hashable, Content: View>:View {
    lettabType:TabTypevarselection:TabTypeletonChangeOffset: (CGFloat)->Voidletcontent: ()->Content@StateprivatevarcurrentOffset:CGFloat= .zero

    publicvarbody:some View {
        OffsetReadableVerticalScrollView(
            onChangeOffset: { offset in
                currentOffset = offset
                if tabType == selection {
                    onChangeOffset(offset)
                }
            },
            content:content
        )
        .onChange(of:selection) { selection inif tabType == selection {
                onChangeOffset(currentOffset)
            }
        }
    }
}

iOS15系だと、サイズクラスの変更の際にTabView以下の表示が崩れてしまう

これはSwiftUI側の問題のようなのですが、iOS15系でTabViewを使用している時、タブを2ページ目以降にした状態でサイズクラスを変更すると、タブ内の表示が崩れる不具合がありました。(iOS14、16系では再現しない。)

そのため、iOS15系のみサイズクラスの変更を検知した際のworkaroundを入れることにしました。
「のせる」画面ではUIHostingControllerを使用していたため、UIHostingControllerのviewを保持するUIViewControllerでサイズクラスの変更を検知し、SwiftUIのViewへイベントを流すようにしました。

ただし、この方法には一つ別の問題があります。
それはサイズクラスの変更によってViewが再生成されるため、showログが送り直されたりスクロール位置がリセットされてしまうことです。 しかし、ユーザー数の多いiPhoneでは画面回転を無効にしていてサイズクラスの変更が発生せず、iOS15系のiPadのみで発生する可能性があることから、許容する方針としました。

overridepublicfuncviewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    if screenSizeDataSource.screenSize != view.bounds.size {
        screenSizeDataSource.screenSize = view.bounds.size
    }
}
structFooTabView:View {

    @StateprivatevarlayoutTrigger=falsevarbody:some View {
        ZStack(alignment: .top) {
            if layoutTrigger {
                tabView
            } else {
                tabView
            }
            ... 
        }
        .onChange(of:screenSize) { _ inif #unavailable(iOS 16.0), UIDevice.current.userInterfaceIdiom == .pad {
                layoutTrigger.toggle()
            }
        }
    }
    ...
}

TabView内のコンテンツの高さがスクロール領域の高さと同じくらいの時に、画面がガタガタしてしまう

ちょっとgifだと分かりづらいのですが、以下のような挙動になります。

ヘッダーが完全に縮小していないかつスクロールの最下部に達した状態でさらにスクロールしようとすると、スクロールのバウンス*1によってoffsetが小刻みに増減し、Viewが振動しているような振る舞いをします。
この挙動を回避するため、ScrollViewがコンテンツの最下部に達したかどうかを判定し、さらにバウンスが発生する向きにスクロールしようとしている場合はoffsetを更新しないようにしました。

privatefuncupdateOffset(_ newOffset:CGFloat, _ didUpdateByTabChange:Bool, _ hasReachedContentBottom:Bool) {
    // タブの中身のスクロールが最下部に達してもヘッダーが完全に縮小していない状態でさらに下にスクロールすると、スクロールのバウンスによって offset が小刻みに増減し画面がガタガタしてしまう// タブの中身のコンテンツの最下部が表示されている かつ さらに下にスクロールしようとしている場合は offset を更新しないようにする// ただし、タブの選択が切り替わった時( didUpdateByTabChange が true の時)の offset 更新は常に行うif!didUpdateByTabChange && hasReachedContentBottom && (newOffset < offset) { return }

    if newOffset <=-maxHeaderOffset {
        offset =-maxHeaderOffset
    } elseif newOffset >=0.0 {
        offset =0
    } else {
        offset = newOffset
    }
}

ScrollViewのコンテンツが最下部に達したかどうかの判定は透明なViewのonAppear/onDisapperによって行なっています。

privatestructReachedContentBottomTracker:View {
    @Bindingvar hasReachedContentBottom:Boolvarbody:some View {
        // View が表示された時に onAppear/onDisappear が呼ばれるようにしたいため LazyVStack で囲っている
        LazyVStack {
            Color.clear
                .frame(width:1, height:1)
                .onAppear { hasReachedContentBottom =true }
                .onDisappear { hasReachedContentBottom =false }
        }
    }
}

Content()
    .background(ReachedContentBottomTracker(hasReachedContentBottom:$hasReachedContentBottom), alignment: .bottom)

その他検討した実現方法

以下の2つの方法も実現が可能か調査しました。

  1. UIKitのUIScrollViewをUIViewRepresentableで使用する。
  2. Introspectを使用し、ScrollViewの裏側のUIScrollViewのdelegateを使用する。

1. UIKitのUIScrollViewをUIViewRepresentableで使用する

UIViewRepresentableはUIKitのViewをSwiftUIで使用するためのAPIです。
UIKitのUIScrollViewではcontentOffsetを取得することでスクロール量が分かります。 そのため、UIScrollViewをSwiftUIのViewに組み込めば、SwiftUIのScrollViewを使わずにスクロール量を取得することが可能です。
しかし、UIViewRepresentableを使用すると、過去にサイズクラスの変更やdynamicTypeの変更を追従してくれない不具合が発生しており、採用は見送りになりました。

一方で、UIKitのUIScrollViewを使用するメリットもありました。 それは精度の高いスクロール量を取得できることです。

後述しますが、今回紹介した方法でScrollViewから取得できるスクロール量は、UIScrollViewから取得できるスクロール量より粗いです。 取得できるスクロール量の精度はヘッダーの動きの滑らかさに直結するのですが、プロトタイプを元にデザイナーに相談しならがら検証を進めていき、許容できる範囲と判断してSwiftUIを採用することにしました。

2. Introspectを使用し、ScrollViewの裏側のUIScrollViewのdelegateを使用する。

IntrospectはSwiftUIの裏側で使用されているUIKitにアクセスし、UIKitの機能を使用できるライブラリです。
これを使用すればSwiftUIのScrollViewでUIScrollViewを取得し、スクロール量を取得することが可能です。 しかし、SwiftUIの内部実装の変更によって裏側のUIKitを取得できなくなる可能性があることから、採用は見送りになりました。

PreferenceKeyとGeometryReaderで取得できるスクロール量の精度についての注意点

「UIKitのUIScrollViewをUIViewRepresentableで使用する」でも書きましたが、今回紹介した方法でScrollViewから取得できるスクロール量は、UIScrollViewから取得できるスクロール量より粗いです。

スクロール量の元にViewを動かすのが今回の紹介したStickyHeaderの実現方法のため、取得するスクロール量の精度が粗いとその分Viewのカクつきが気になってきます。
そのため、ヘッダーの動く範囲が大きくてカクつきが目立ってしまうような画面での使用はお勧めできません。
その場合、UIScrollViewを使用して実装するか、そもそもStickyHeaderが必要ないデザインで同じ機能を実現できないか考えることも必要だと思いました。

まとめ

今回紹介させていただいたSwiftUIでStickyなタブを実現する方法は、注意点こそあれど、SwiftUIで実現できる要件を増やす一つの手段になると思っています。 同じようにSwiftUIは使いたいけどタブを実現できるか分からないという方がいましたら、この記事がお役に立てると幸いです。

また、本記事で紹介した内容はほぼすべて同チームの@miichan_ochaが検証、実装してくれたものです。 @miichan_ochaを筆頭に、調査や検証に協力してくれた同僚の皆さんに感謝します。

*1:スクロール領域の最後まで行った時にそれまでのスクロール方向とは逆向きに小さくコンテンツが跳ねること

クックパッドは RubyKaigi 2023 にWi-Fi & Rubyists on Rails Sponsorとして協賛しています

$
0
0

こんにちはCTO室の緑川です。

RubyKaigi 2023がいよいよ5月11〜13日に松本市で開催されますね。今年もクックパッドから多くのエンジニアが参加する予定ですので、会場でお話しできる機会がありましたら、どうぞよろしくお願いいたします。

さて、クックパッドはRubyKaigi 2023にWi-Fi & Rubyists on Rails Sponsorとして協賛します。聞きなれないスポンサー名だと思いますので、登壇者情報と合わせてこちらのエントリーでお知らせします。

スポンサーする背景

クックパッドはレシピサービスcookpadや、生鮮食品ECプラットフォームのクックパッドマートの開発にRubyを使用しています。また、Webサービスだけでなく、クックパッドマートで使用しているステーション(冷蔵庫)で稼働するソフトウェアやハードウェア開発にもRubyを使用しており、Rubyはクックパッドにとって重要な技術の1つです。このようにRubyに支えられているからこそ、Rubyに関するさまざまなオープンソースソフトウェアの発展に貢献したいと考えています。

Wi-Fi & Rubyists on Rails Sponsorとは

クックパッドは今年もカスタムスポンサーを行います。 Wi-Fi Sponsorに関しては、会場のWi-Fiに関する調達・設計・構築・運用などを引き続き id:sora_hが担当しています。

Rubyists on Rails Sponsorは、募集人数に制限はありますが、新宿-松本間を運行する特急あずさの乗車券・特急券をRubyKaigi 2023へ参加する方へ無料提供するスポンサー企画です。こちらの企画ではRubyist同士で松本への移動時間を共有することで、RubyKaigi 2023でのコミュニケーションの場をより創出することを目指しています。

応募に関しては、connpassから募集をしていますので、ご希望の方は是非お申し込みください。

【企画概要】
・企画概要:「新宿駅〜松本駅」間を運行する特急あずさの乗車券・特急券を本会議の参加者に抽選で無料提供します。
・運行区間:新宿駅→松本駅 *1
・運行日時:2023年5月10日(水)8:00発(10:37着予定)/15:00発(17:37着予定)

【参加方法】
・募集期間:2023年3月30日〜4月6日
・募集人数:
- 8:00発:30人募集
- 15:00発:30人募集
- どちらでも可:15人募集
・費用:無料
・URL:https://cookpad.connpass.com/event/277569/

Rubyists on Rails

登壇情報

クックパッドからは@koichisasada(ko1)、@mametter@osyoyuの3名が登壇します。

おわりに

RubyKaigi 2023の会場で皆様にお会いできることをクックパッド社員一同楽しみにしています。また、クックパッドの社員をお見かけの際には、ぜひお声がけいただけますと嬉しいです!

最後に、クックパッドではサービス開発に挑戦をしたいRubyistの方を募集中です。クックパッドで働くことにご興味のある方は気軽にブースにお越しください。

cookpad.careers

*1:本列車の運行は往路のみ、復路の実施はなし。


【RubyKaigi 2023】ゲストハウスのオーナーに聞いた松本グルメ! 松本を256倍楽しむためのお店

$
0
0

こんにちは、CTO室の緑川です。早いもので来月にはRubyKaigi 2023が開催されます。今年のRubyKaigiは長野県松本市で開催されるので、松本市で楽しめるグルメ記事を用意しました。今回は毛色を変えて、松本市で宿とサウナとジェラートのタビシロを営む小澤様にインタビューをし、オススメして頂いたお店を紹介しています。どのお店も人気店なので確実に訪れたい方は予約必須です。是非RubyKaigi中のお食事のご参考にしてください。

目次

宿とサウナとジェラート

tabi-shiro(タビシロ)

まずは今回お話を伺ったゲストハウスtabi-shiroのご紹介です。 tabi-shiroは何年もの間空き家だった築50年の元旅館を半年間かけてDIYしたそうです。昔ながらの日本建築でありながら、バーラウンジや完全貸切制のプライベートサウナがある充実したゲストハウスです。サウナはフィンランド式のセルフロウリュなので温度や湿度を調整でき、心地よい空間を作り上げることができます。オーナーのお話によると、最大のポイントは動線にあり、サウナから出てすぐに水風呂や外気浴を楽しむことができるそうです。ハウス内にはカフェもあり、サウナ後にジェラートを購入することもできます。 サウナ、ジェラート、バーだけのご利用も可能なので、是非チェックして下さい。

tabi-shiro.com

信州そば

三城 (さんじろ)

長野県といえば信州そばが有名で、松本市内にも多くのそば屋が存在します。数ある名店の中でも今回オススメなのが三城です。このお店にはメニューがなく、2000円のお任せコースがあるのみで、日本酒・突き出し・ざるそば・漬物・甘味が提供されます。松本市内で唯一、そば百名店2022に選出されたそばは少し太めで噛みごたえがあり、そばの風味を楽しめるのが特徴です。 ただ、飲める飲めない関係なく、お昼時でもお茶の代わりに日本酒が出てくるそうなので、カンファレンス前や運転される方はご注意ください。

tabelog.com

焼肉

味楽苑 (みらくえん) 伊勢町店

松本駅付近でオススメの焼肉屋さんがこちらの味楽苑です。こちらのお店は松本市で半世紀営業されていることもあり、信州牛をはじめ日本各地の特選和牛を仕入れているそうです。極上セットや牛肉三種セットといった大皿メニューがあり、上質なカルビやロースをリーズナブルなお値段で楽しむことができます。

tabelog.com

谷椿(たにつばき)

谷椿は松本駅付近にある昔ながらの焼肉屋さんです。お昼はラーメンや牛めしが人気のお店とのことですが、夜は名物店長が時に泥酔しながらジンギスカン鍋にバンバンと肉を乗せてくれるそうです。ホルモン焼きがメインですが、その他のお肉も鮮度や質が高く、美味しいお肉が大量に食べられるそうです。

tabelog.com

焼き鳥

山女や (やまめや)

山女やはコの字型のカウンターと手書きのメニューが壁の至る所に貼られている昔ながらの焼き鳥屋さんです。メインはもちろん焼き鳥で、中でも皮のタレがオススメだそうです。そして、焼き鳥以外のメニューも豊富で信州名物の山賊焼きや旬の野菜などさまざまなメニューがあり、そばも食べることができますので、飲み始めから締めまでいられるお店です。

tabelog.com

鳥しん

鳥しんはささみフライが最高に美味しい焼き鳥屋さんです。こちらもコの字型のカウンターがあるお店で、店内には20名ほどしか入れないそうです。毎日、多くのお客さんとチームワークが良い店員さんたちでいつも活気に溢れているそうです。こちらのお店では塩での味付がオススメで、独特な塩の掛け方が必見です。

tabelog.com

うなぎ

山勢 (やませい)

松本でうなぎというイメージはあまりないかもしれませんが、山勢は著名人も多く訪れるうなぎの名店です。ランチで約5,600円、夜だと一人あたり12,000円以上かかるそうですが、有名店ということもあり予約が困難だそうです。夜のコース料理ではうなぎに加え、名物のすっぽん焼きも味わえます。

tabelog.com

海鮮

三重鮨(みえずし)

松本市内だとお寿司屋さんはそれほど多くないそうですが、三重鮨は寿司にこだわりがある方にもオススメできるお寿司屋さんだそうです。リーズナブルなお値段で新鮮なお寿司が食べられるうえ、遅くまで営業しているのでゆったりと食事を楽しむことができます。

tabelog.com

居酒屋まるか

居酒屋まるかは海鮮の居酒屋ですが、馬肉や天ぷらなどのお酒に合う料理の種類が多いです。もちろん刺身の鮮度がよく、居酒屋としては珍しくお寿司も注文することができます。席はカウンターとテーブルで20人ほどしか入れないのですが、座敷もありますので、必要な方は予約をすると良いかもしれません。

tabelog.com

団体OKのお店

みつぶ 野菜巻き串

ここまで比較的省スペースのお店を紹介してきましたが、RubyKaigiだと大人数での食事もあるかと思います。松本市で大きいお店としてオススメなのがみつぶ 野菜巻き串です。このお店は農家直送の新鮮な野菜巻きが楽しめるお店とのことです。カンファレンスで会食の機会が多いと思いますので、体に良い野菜中心の食事もあるとよさそうです。

tabelog.com

やきそば

たけしや

松本市のソウルフードと言えば、焼きそば。たけしやはソースやきそば専門店として松本市でながい期間営業しています。ワンコインの並サイズでも麺の量が多く、自家調合の甘めのオリジナルソースが絡んだもちもちの麺を楽しむことができます。持ち帰りも可能なので、ホテルでゆっくり食べるのも良さそうです。

tabelog.com

カレー

松本メーヤウ 桐店

松本メーヤウはエスニックカレー専門店のお店です。日本式のカレーだけでなく、タイやインドのカレーが食べられます。また、桐店ではカレーがバイキング形式で食べられるので、値段を気にせずたくさんのカレーを味わうことができます。幅広い種類のカレーがあるので、何回も往復していろいろな種類のカレーを食べるとよさそうです。

tabelog.com

深夜

香根 (かね)

香根は深夜まで営業している町中華のお店です。多くの飲食店が12時に終わってしまう中、こちらのお店はAM 2:00まで営業しているので、締めとしてラーメンや水餃子が食べられます。ラーメンと言いましても、正確には酸辣湯麺(サンラータン)と呼ばれる中華料理で、ラー油や胡椒で味つけられた酸っぱ辛い味が特徴だそうです。二次会で行きたいお店ですね。

tabelog.com

Bar BRORA

落ち着いてお酒が飲みたい方はこちらのBar BRORAです。カウンターは迫力がある一枚板が置かれており、あまりの大きさにもう二度と出せないそうです。初めての人でも入りやすい雰囲気のBarです。

tabelog.com

昭和レトロスナック ヴィーナス

ヴィーナスは松本最古のレトロなスナックです。昭和の頃はスナックと言うと軽食をさしていたそうで、昔懐かしいナポリタンや鳥手羽などが食べられる昭和の雰囲気を現在まで残しているお店です。広い店内には楽器や照明設備が配備されており、事前に確認すれば自分達で弾けるそうです。

snackbar-venus.wixsite.com

スタンディング 8オンス

スタンディング 8オンスは酒屋のお店がやっている立ち飲みのBarです。生ちゅーはいが390円で、年代物のウイスキーも安い値段で楽しめます。評価が高まっている日本のウイスキーが手頃に飲めるので、お酒好きの方には喜ばれるかもしれません。 tabelog.com

Jazz喫茶 エオンタ

お酒が飲めない方はこちらのJazz喫茶 エオンタがおすすめです。Jazz喫茶なので話すことはできませんが、最高級のスピーカーがあるので、Jazz好きの方はもちろん、音楽好きの方にも行ってほしいお店です。

tabelog.com

朝食

Alps gohan

Alps gohanは料理研究家の人が素材や産地だけでなく、お醤油などの調味料にもこだわったお店で、長野県産の食材の魅力を十分に味わうことができます。ランチや夜はもちろん、朝ご飯も提供しており、8時からお店がオープンするとのことです。席は少ないので、大人数でいく際にはご注意ください。

tabelog.com

珈琲美学 アベ

朝食でもう一つオススメなのが、こちらの珈琲美学 アベです。多くの飲み物とサイドメニューからお好みの物を選ベるセルフビルドスタイルのモーニングです。コーヒーは一杯づつハンドドリップで作ってもらえ、コーヒーだけでなく、モカパフェやホットサンドなど喫茶店ならではのメニューもあります。カフェオレもオススメだそうです。

tabelog.com

銭湯

塩井の湯

カンファレンスでの疲れを癒すのに必要な銭湯も紹介します。塩井の湯はなんと明治時代から営業をしており、大正時代に建てられた銭湯を現在も使用しているそうです。外観も内観も貴重なものとなっています。

www.mcci.or.jp

松本のお土産

マサムラ本店(ベビーシュークリーム)

マサムラも半世紀以上営業している老舗の洋菓子屋さんです。さまざまな洋菓子が売られていますが、中でもお土産として喜ばれるのがベビーシュークリームです。ミニサイズながらもカスタードクリームとホイップクリームがバランスよくたっぷりと詰まっている一品です。

tabelog.com

スーパーツルヤ

長野県産のお土産を購入したい場合、スーパーツルヤというご当地スーパーに行くのがオススメです。スーパーツルヤはオリジナル商品が販売されており、中でも信州産ふじ りんごバターが人気とのことです。

shop.tsuruya-corp.co.jp

最後に

最後に今回ご協力いただいた宿とサウナとジェラートのタビシロの小澤様からお借りした松本散策マップをご紹介いたします。ぜひこちらもRubyKaigi 2023でのお店選びに役立てていただければと思います。

また、クックパッドはRubyKaigi 2023にWi-Fi & Rubyists on Rails Sponsorとして、新宿松本間の交通支援を実施させていただいています。Rubyists on Railsは4月6日まで参加者を募集中ですので、よろしければこちらもご確認ください。

cookpad.connpass.com

それでは松本でお会いしましょう!

Cookpad Summer Internship 2023 を開催します

$
0
0

Cookpad Summer Internship 2023 のロゴです。

こんにちは、技術部 SRE グループの石川です。

クックパッドでは例年サマーインターンシップを開催しています。今年も同様にインターンを開催するので、この記事ではその中でもエンジニア向けのコースたちについてご紹介します。

今年は 2 つのコースをご案内することにいたしました。

Tech Workshop Course

クックパッドが業務で実際に利用している技術に触れつつ、サービス開発の技法を経験するコースです。プロダクトを開発してユーザーの課題を解決する楽しさ、難しさ、そして面白さを体感してみませんか。

こちらのコースは、クックパッドが毎日の料理を楽しみにするために日々使っている技術や技法を、4〜5 日のワークショップに詰め込んだコースです。Ruby と GraphQL を使ったサーバーサイドアプリケーション開発、Swift を使った iOS アプリ開発、そしてユーザーの課題を解決し価値を届けるためのサービス開発、という内容でお届けする予定です。

どれもクックパッドが社内で実際に使っている内容を抜き出してきてワークショップ形式にしたものです。参加者の皆さんがサービス開発をする際に武器として使えるものを、コンパクトに学んでいただきます。

Tech Workshop Course は、同じ内容のものを 7 月と 8 月に 1 回ずつ開催することにしました。7 月の方は土日を 2 回、8 月の方は平日 5 日間連続のスケジュールとなっております。どちらか参加しやすい方を選んでいただければと思います。横浜みなとみらいでの開催になるので、遠方の方は 8 月の方が参加していただきやすいです。

  • 第 1 期:7 月 1 日、2 日、8 日、9 日
  • 第 2 期:8 月 7 日、8 日、9 日、10 日、11 日

Tech OJT Course

多くのユーザーにご利用いただいているサービスの開発チームの一員となり、社員と共に開発に携わるコースです。数週間に渡ってサービス開発に没頭し技術とノウハウを学ぶことで、プロダクトを開発する力を身につけませんか?

こちらのコースは Tech OJT Course と呼んでいたり就業型インターンシップと呼んでいたりするものです。

クックパッドにおける実際のサービス開発の現場で社員と共にサービス開発に取り組んでいただき、その手法を体験していただきます。数年前に公開した「新卒採用応募前にクックパッドでインターンシップしませんか?」という記事の説明が私の気持ちをぴったり表していたので、引用します。

クックパッドは非常にユニークでおもしろい会社です。 ただ、ひと目見ただけでその魅力を理解するのは容易ではありません。 ミッションである「毎日の料理を楽しみにする」という本当の意味や目指したい世界、ユーザーの課題をとことん考え抜く愚直なサービス開発とそのノウハウ、そして魅力あふれる社員たち。ぜひ、学生のみなさんに体験して感じてほしいです。

そして、クックパッドもみなさんのことをもっとたくさん知りたいと感じています。一定期間一緒に働くことで、何を考えどういうことを目指したいと思っているのか、1-2時間の面接よりもずっとよくみなさんのことを知ることができます。

新卒採用の応募前にそういった貴重な機会を持てると、より良いコミュニケーションになるのではないかと考えています。

Tech OJT Course は去年よりも枠を拡充してお待ちしております。具体的な時期や内容については一緒にご相談しながら決めることとなります。サービス開発コース、基盤開発コース、機械学習コースがあるので、興味のあるコースへご応募ください。

まとめ

Tech Workshop Course と Tech OJT Course のどちらも、参加者の皆さんがクックパッドのサービス開発をイメージでき、その一部に挑戦できるような内容になっています。毎日の料理が楽しみになるようユーザーさんに価値を届けるのは、難易度の高い業務です。その難しさに、私たちと一緒にチャレンジしてみませんか。

↓↓↓皆さんのご応募をお待ちしております!

クックパッドの toB 向け事業における ChatGPT API の活用事例紹介

$
0
0

メディアプロダクト開発部マーケティングサービス開発グループ(通称 msdev)の三條です。広告システムやメーカーズタウンというBtoBtoCプラットフォームなどクックパッドにおける toB 向け事業の開発・保守・運用を担当しています。
今週は msdev week と題して、 msdev のメンバーから連続で記事の投稿をしていきます。楽しみにしていてください!

今回は、今流行りの ChatGPT API をメーカーズタウンというプロダクトに活用して機能開発を行い、課題解決を試みた例を紹介したいと思います。
私たちのチームでは、新しい技術を積極的に取り入れつつ、楽しみながらサービスを作っていっていますので、もし興味を持っていただけたら末尾に採用サイトへのリンクがあるのでそちらからご応募いただけると嬉しいです!

メーカーズタウンとは

メーカーズタウンは、食関連メーカーとクックパッドユーザーが双方向のコミュニケーションを取りながら、さまざまな課題を解消していく、企業マーケティングを支援するユーザー参加型のプラットフォームです。
メーカーズタウンというプラットフォーム上で、企業はユーザーとコミュニケーションを取りながらマーケティングを行うことができます。 例えば、自社商品を活用したレシピをユーザーに募集したり、一部のファンユーザーに対して発売前の商品へのフィードバックをもらったりできます。

メーカーズタウンには他にも様々な機能があるのですが、今回はその中の機能のひとつである「トピックス」機能について焦点を絞って紹介します。

トピックスとは

トピックスは企業がユーザーに対して情報を発信することができる機能です。 ここでは企業に興味があるユーザーに対してレシピや商品の紹介、イベントの告知など企業主体で情報を発信することができます。

ユーザーはそのトピックスに対してコメントをすることができ、さらに企業はそのコメントに対して返信を行うことができます。
これらのやり取りを通して、企業とユーザーがコミュニケーションを行っています。

トピックスの例
トピックスの例

トピックスにおける課題

サービスの成長にともなってユーザーからコメントが投稿されることが多くなってきました。 企業もコメントが増えたことを喜んでいて、とても丁寧に返信作業を行ってくださっています。

その一方で、現在利用している企業の多くはメーカーズタウン専用の担当者がいるわけではなく、その他業務の傍らでメーカーズタウンの運用をしているため、ユーザーからのコメントが増えるにつれて返信作業の負荷が高くなってきています。
企業も慣れないなか、多くの時間を割いて返信作業を行っていますが、返信作業が滞ってしまったり、負担に感じている企業もいるのが現状です。
せっかくユーザーがコメントをしてくれているにも関わらず、返信がこなかったり時間がかかったりすると、ネガティブな印象を与えてしまうため、どうにか返信作業の負荷を下げたいという要望が企業や営業担当の方からありました。

しかし、安易に返信テンプレートを用意したりすると、サービスに同じような返信が投稿されることになるし、本来のコミュニケーションを取る目的からも逸れてしまうのではないか、ということでいい解決策が思いついていない状況でした。

そこで白羽の矢を立てたのが ChatGPT でした。

ChatGPT とは

ChatGPT は OpenAI が提供している AI チャットサービスです。
GPT と呼ばれる大規模言語モデルをチャット用にチューニングしたものを利用しており、自然言語の理解や文章の自動生成などを高度に行うことができます。
ChatGPT は GUI で提供されており、気軽に利用することができますが、開発者向けに API も提供されており、今回は API を利用しました。

ChatGPT では利用するモデルを選択できますが、今回は gpt-3.5-turbo を利用しています。(gpt-4 を使いたい)

ChatGPT API の活用

ChatGPT を使うとコメントに対してとても自然な返信内容を生成することができます。 返信作業の中で負荷が高いのは文の構成を 0 から考える部分であると考え、ChatGPT に基礎となる返信内容の骨組みを考えてもらい、それを元に企業が本来取りたいコミュニケーションにあわせて加筆修正を加えるだけで返信ができるようにすることで、負荷軽減ができるのではないかと考えました。

今回は返信内容の骨組みを生成することが目的で、加筆修正されることが前提にあるので、文言生成に求められる精度が高くないのもまだ不慣れな ChatGPT を活用する上でとても嬉しいポイントでした。

実際に作成した画面は以下になります。

作成したUI
作成したUI

この画面を構築するにあたって、私たちのチームにはデザイナーがいないため、ディレクターやビジネスチームと一緒になって UI や UX について議論しました。

UI/UX においての工夫点

以下、議論の中で考えた工夫点です。

  • 本来の目的であるコミュニケーションを阻害しないように注意する
    自分で 0 から考えたい担当者がいた際に意図せずバイアスを与えてしまうことを避けるため、担当者がアクションを起こした場合のみ、生成された文言を提示するようにしました。
  • あくまで参考程度にして、自身で加筆修正を行ってほしいことを伝える
    ChatGPT は不適当な内容を生成する可能性があり、企業とユーザーのコミュニケーションを行うのが本来の目的であるため、生成内容をそのまま投稿するのではなく、参考にしつつ自身の文言で返信をしてもらいたいと考えています。
  • ワクワク感の演出
    ChatGPT の Web UI の徐々に文言が表示される表現がワクワク感が出て楽しいのでそれを取り入れたいという意見から、メーカーズタウンでも同じようにローディングの演出を行いました。
    実装コストの観点から、1 文字ずつ表示されるものではなく、既存 UI で利用していたローディングを再利用しました。

実装においての工夫点

ChatGPT API において生成文言をコントロールするためには、以下が重要になってきます。

  • どのようなプロンプトを与えるか
  • HyperParameter をどう設定するか

今回は上述のように求められる精度がそれほど高くなかったのと実現までのスピードを優先したため、網羅的な実験を行ったわけではないのですが、プロンプトについていくつか工夫した点を紹介します。

返信内容を生成するにあたって、定量的に評価できる指標などを用意することができていなかったので、以下の 2 点を軸にして主観でよいと思う文言が生成されるようなプロンプトにしました。

  • コメント内容に自然に触れていること
  • 企業の返信スタイルに沿っていること

また、 HyperParameter のチューニングはできていないのですが、利用したパラメーターは temperature=0.7 でその他はデフォルト値にしています。
パラメーターの詳細はドキュメントに詳しく記載されています。

コメント内容に自然に触れていること

これは以下のようなプロンプトを ChatGPT に与えることでユーザーのコメントに触れつつ、自然な返信を生成することができました

[
  { 'system': system_role_prompt },
  { 'user': user_role_prompt },
]

system role prompt

メーカーである${企業名}の広報担当者として振る舞ってください。
あなたのSNSの投稿にユーザーからコメントが来たので返信を考えてください。

## メーカー名
${企業名}

## メーカー情報
${企業の説明文}

## 返信の目的
より${企業名}の商品を好きになってもらうこと。

## 制約
- ユーザーのコメント内容に間接的に触れること
- 投稿内容・コメント内容を繰り返さないこと
- 返信内容のみを回答すること
- ${その他システムやサービスの都合で必要な制約を列挙}

user role prompt

## あなたの投稿内容
${トピックスの内容}

## ユーザーの名前
${ユーザー名}

## ユーザーのコメント
${コメント内容}

具体的な生成例

生成文言例を作成するソースは、今回の記事用に用意したテストデータを利用しています。
この後説明する企業のスタイルに合わせて生成文言が変わっていることを示すための例なので、企業の説明文など細かい情報は省略します。

上記プロンプトを用いて生成された返信文言は以下になります。

種別 内容
コメント みそにこんなにたくさんの栄養素が含まれていることは知りませんでした!
生成された返信文言 クックWU2JRU☆さん、コメントありがとうございます!
そうなんです、みそは発酵させることでたくさんの栄養素が生成されるんですよ。食事の栄養補給にもってこいですね!私たちも、美味しさと共に栄養たっぷりの味噌をお届けできるよう取り組んでいます。ぜひ、新郷醸造の味噌を試してみてください!

かなり自然な受け答えになっていると思います。(個人的には「食事の栄養補給にもってこいですね!」あたりはやや違和感がありますが、このへんは加筆修正してもらう想定です。)

企業の返信スタイルに沿っていること

返信内容には企業によって特色が異なります。 例えば、絵文字をよく使う企業や、言い回し、ユーザー名の引用方法などは企業によって異なります。
これらを再現するため、過去の企業とユーザーのやり取りを利用し few-shot prompting を行いました。 具体的には以下のようにしました。

[
  { 'system': system_role_prompt },
  { 'user': ${過去のトピックスとユーザーコメント} },
  { 'assistant': ${過去の企業の返信} },
  ... // 3つ分繰り返す
  { 'user': user_role_prompt},
]

生成例

先程例を紹介したものと同じコメントに対して、 few-shot prompting を行った際の例を紹介します。

過去の企業の返信コメントを1つだけ与えてみました。(下表)
わかりやすく極端な例を用意しました。名前の引用方法や、絵文字を多用している点などが先程の生成された文言とは異なります。

種別 内容
コメント 「畑の肉」という別名かっこいいですね☆
豆知識勉強になります!
企業の返信内容 >クックW66VXK☆さん
コメントありがとうございます✨
私たちも「畑の肉」という別名があることに驚きました❗
今後もたくさんの役に立つ情報を発信していきますのでよろしくお願いします✨

これを例として与えた場合の生成文言は以下になります。

>クックWU2JRU☆さん
コメントありがとうございます!
そうなんです!みそにはさまざまな栄養素が含まれていて、体に良い影響を与えることが期待されています。また、無添加や有機の味噌もあるので、安心して食べることができますよ✨

例を与えなかった場合(zero-shot)と比べて、 few-shot では名前の引用や絵文字などが反映されていることがわかります。

種別 内容
コメント みそにこんなにたくさんの栄養素が含まれていることは知りませんでした!
zero-shot クックWU2JRU☆さん、コメントありがとうございます!
そうなんです、みそは発酵させることでたくさんの栄養素が生成されるんですよ。食事の栄養補給にもってこいですね!私たちも、美味しさと共に栄養たっぷりの味噌をお届けできるよう取り組んでいます。ぜひ、新郷醸造の味噌を試してみてください!
few-shot >クックWU2JRU☆さん
コメントありがとうございます!
そうなんです!みそにはさまざまな栄養素が含まれていて、体に良い影響を与えることが期待されています。また、無添加や有機の味噌もあるので、安心して食べることができますよ✨

過去に企業が実際に返信している例を参考情報として ChatGPT に与えることで、絵文字や言い回しなど、企業の返信スタイルを再現してくれるようになりました。

余談ですが、プロンプトのトークン数は、投稿内容によりますが、多いもので 2000~2500 トークンくらいなので、1つの返信内容を生成するのにだいたい $0.05 くらいの金額感でした。

今後の課題と試してみたいこと

今回は、コミュニケーションの負荷軽減を目的として、 ChatGPT API を利用して、返信の参考文言を生成しました。 ある一定のクオリティで文言を生成することができましたが、課題もあります。

  • 文言生成性能の定量評価
    定量的に評価するための指標が定められておらず、主観による定性評価しかできていません。
  • Hallucination の検知
    定量評価ができていないことにも関連しますが、生成された文言を評価することができていないので ChatGPT が生成する文言が不適当だった場合に自動で検知することができていません。
    現在はコメント数的に手動で確認できる量なので直接見て不適当なものがあれば、手動で再生成するようにしています。
  • UI/UX の改善
    UI/UX の専門家がいないので手探り状態です。これらは企業からのフィードバックをもらいながら徐々に改善していく予定です。

試してみたいこと

上記の課題に対して改善案として試してみたいことは以下になります。
企業からのフィードバックを元に優先度等を調整して、挑戦していく予定です。

  • 性能改善について
    few-shot の例として取得する過去事例を、今回対象とするトピックスやコメントの内容に近いものを利用するようにしてみたいなと考えています。
    現在は最新の事例を3つ取得しているので、テキストをベクトルに変換して類似度が高いものを採用するとより文脈に沿った文言を生成してくれるかもしれません。
  • 定量評価について
    定量評価についてはまだいい方法が思いついていないのですが、人手でいくつかの正解データセットを作り、評価するのが一歩目かなと思っています。
  • Hallucination の検知
    出力結果を評価するようにプロンプトで統制した GPT を利用して、生成された返信内容が適当かどうかを評価させるとどうだろうか、考えています。
    その GPT モデルが不適当と判断した場合は再生成したり、アラートを上げるなどして検知させる仕組みを構築できるといいなと考えています。
  • UX 改善
    企業が返信内容の主題を入力し、それを元に文言を生成させるのはどうだろうか、と考えています。
    現在は私たちが用意した目的が固定化されているので、企業の意図した返信内容ではない可能性があります。
    しかし、ここは本来の目的であるコミュニケーションに大きく関わる部分なので慎重に検討していきたいと思っています。

まとめ

今回は、ChatGPT API をメーカーズタウンというプロダクトに活用して機能開発を行い、企業の課題解決を試みた例を紹介しました。
網羅的な実験はできていないものの、うまく活用できた一例としてみなさんの参考になっていれば幸いです。
私たちのチームでは、新しい技術を積極的に取り入れつつ、楽しみながらサービスを作っていっていますので、もし興味を持っていただけたら下記の採用サイトからご応募いただけると嬉しいです!

また、今回、ChatGPT を活用するにあたり、機械学習グループの深澤さんに多大なるサポートをいただきました。
機械学習グループの目標として「ChatGPT などの LLM を会社全体で活用できる状態にする。」というものがあるので、プロダクトに LLM を活用していくことに興味がある方はぜひご連絡ください!
機械学習グループについては下記のインタビューをご覧ください。

cookpad.careers

note.com

ポリモーフィック関連を活用し、森羅万象の「いいね」を実現する手法

$
0
0

こんにちは!メディアプロダクト開発部マーケティングサービス開発グループ (msdev) のなどやま (@pndcat) です。業務では、クックパッドの広告の開発・運用や、新規サービスの開発をしています。本業の推し活動では、今年の夏はたくさんのイベントに参加するため、推し活動もがんばっていく予定です。

今週は msdev week として Techlife を更新しており、この記事は2日目になります。
1日目は三條さんによる「クックパッドの toB 向け事業における ChatGPT API の活用事例紹介」の投稿でした。
本記事も、メーカーズタウンに関するブログなので、ぜひこちらの記事もご覧ください!

はじめに

この記事では、Kaigi on Rails 2022 森羅万象に「いいね」するためのデータ構造というタイトルで発表をした、Rails を用いたデータ構造のリファクタリングについて紹介します。ポリモーフィック関連を用いたリファクタリングにより、テーブルやコードの重複を排除し、メンテナンス性や拡張性を向上させることができました。データ構造をリファクタリングする背景や、具体的な手法、そしてなぜその変更を行うことができたのかについてを詳しく説明します。

目次

  • データ構造の初期設計
    • 新機能の「いいね」を実装したい
    • 最初のデータ構造
    • リリースをして1年... どうなったか...
  • ポリモーフィック関連を使ったデータ構造に変更
    • 新しいデータ構造: likes と anonymous_likes
    • リファクタリングの手順
      1. 新旧いいねのテーブルに書き込む (Write)
      2. 旧いいねを新いいねにマイグレーションする (バッチ)
      3. 新いいねを使う (Read)
      4. 旧いいねのモデルとテーブルを削除する (Delete)
  • まとめ

データ構造の初期設計

新機能の「いいね」を実装したい

msdev では、中小の食関連メーカーとユーザーをつなぐコミュニケーションプラットフォーム「メーカーズタウン」を開発しています。メーカーズタウンでは、メーカーはトピックスの投稿や商品の登録を行い、ユーザーはコメントやクチコミを投稿したり、いいねをすることができます。

リリースのタイミングで、「いいね」機能を導入することになりました。要件は、以下の3つでした。

  • 何に「いいね」ができるか? → トピックスと商品
  • 1つの対象に何回「いいね」ができるか? → 1回
  • 「誰が」いいねをすることができるか? → 誰でも (ログインユーザーと、未ログインユーザー)

特に、最後の要件をどう実現するのかについて悩みました。メーカーズタウンでは、ログインユーザーと未ログインユーザーのデータの扱いが異なるため、「いいね」の種類を分ける必要がありました。

ここまでをまとめると、「いいね」は以下の4種類に整理することができます。

  • ログインユーザーの商品のいいね
  • ログインユーザーのトピックスのいいね
  • 未ログインのユーザーの商品のいいね
  • 未ログインのユーザーのトピックスのいいね

最初のデータ構造

データ構造の案として、以下の3つの方法を検討しました。

  • 対象別とユーザー区分別で、4つのテーブルを作成する
  • ポリモーフィック関連
  • STI

結果として、対象別とユーザー区分別のテーブルを作成することに決めました。将来的に「いいね」の種類が増える可能性があったため、ポリモーフィック関連やSTIを用いたテーブル構造を選択することもできましたが、サービスが使用され続ける中で仕様が変わる可能性があるため、必要に応じてデータ構造を改善する方針としました。

リリースをして1年…どうなったか?

1年後、コメント機能やクチコミ機能が増え、「いいね」の対象が4つに増えました。これに伴い、テーブルが8個になったことで2つの問題が生じました。

  • 「いいね」の対象が増えるたびに、テーブル・モデル・コントローラーを毎回追加するのでつらい
  • 新しい対象について、いいね数の集計バッチを追加することを忘れ、集計漏れが発生した

テーブルを毎回追加することはできますが、新しいテーブルが追加されるたびに、集計バッチの修正を行うことを意識するのは難しいことです。

一方で、「いいね」の対象が4つまで増えたことで、「いいね」の仕様はすべて共通であり、今後も「いいね」対象別の振る舞いはなさそうということがわかりました。

ポリモーフィック関連を使ったデータ構造に変更

ポリモーフィック関連は、複数のオブジェクトを関連付ける場合に適しています。今回の場合は、商品、トピックス、コメント、クチコミに「いいね」をつける必要があるため、ポリモーフィック関連を用いて実現することができます *1

サービスを1年間運用した結果、商品、トピックス、コメント、クチコミの「いいね」に関する仕様や振る舞いが共通していることがわかり、ポリモーフィック関連に移行することを決定しました。移行に際して、チームで以下の2つの制約に合意しました。

  • 今後も「いいね」の仕様を変更しない
  • if 文を書くと破綻するため、if 文を絶対に書かない (=異なる振る舞いはさせない)*2

新しいデータ構造:likes と anonymous_likes

新しいデータ構造は、likes (いいね) と anonymous_likes (未ログインのユーザーのいいね) の2種類にまとめました。 ログインユーザーには UserID がありますが、未ログインユーザーには UserID はありません。そこで、初めのデータ構造と同じように、ログインユーザーと、未ログインユーザーは、2種類のテーブルに分けることにしました。anonymous_likes は、UserID の代わりに like_identifier というカラムを作りました *3

今までの product_likes テーブルは product_id, user_idの2カラムでしたが、ポリモーフィック関連を利用すると下図の likes テーブルの likable_id, likable_type, user_idの3カラムで表現します (他の *_likes テーブルも同様)。

では、モデルはどうなるかというと、今までは、4つのモデル (ProductLike、TopicLike、CommentLike、KuchikomiLike) があり、それぞれに対応するテーブルが存在していました。しかし、ポリモーフィック関連を利用することで、1つの Like モデルで表現できるようになりました。現在は対象が4つしかない「いいね」ですが、10個でも100個でも「いいね」の実装ができる、森羅万象の「いいね」のデータモデルが完成しました🌲🌳

※ before / after を見やすくするために、コードを画像にしています。

リファクタリングの手順

データの移行は、以下の手順で行っています。移行手順は特別な手法ではありませんが、Rails のポリモーフィック関連のマイグレーションの一例として、紹介します。

  1. 新旧の「いいね」テーブルに書き込む (Write)
  2. 旧いいねを新いいねにマイグレーション (バッチ)
  3. 新しい「いいね」を使う (Read)
  4. 旧いいねのモデルとテーブルを削除 (Delete)

1. 新旧のいいねテーブルに書き込む (Write)

これは、コントローラーの create メソッドで実行されます。上部分では、既存の ProductLike モデル (旧いいね) を作成し、下部分で Like モデル (新いいね) も作成します。1つのメソッド内で「いいね」がされた場合、新旧のいいねにそれぞれ書き込まれるようにします。

削除も同様に、旧いいねを削除しつつ、新いいねも削除します。ただし、この時点で必ずしも旧いいねと対応する新いいねが存在するわけではありません。Like が見つからないときのために、Safe Navigation Operator の &を destroy の前に書く必要があります。

2. 旧いいねを新いいねにマイグレーションする (バッチ)

次に、データのマイグレーションについて説明します。まず、product_likes テーブルのデータを likes テーブルに合わせたハッシュをつくります。
Rails 6からは、upsert_all メソッドが導入され、ハッシュをそのまま渡すことでデータを生成することができます。バッチを冪等に実行することができるため、今回のようなマイグレーションを行いたい場合は、upsert_all メソッドをぜひ活用してください。

新いいねと、旧いいねの ID (primary key) の順番がばらばらになることが気になるかもしれませんが、likes テーブルは、product_likes や topic_likes などの4つのテーブルを1つにマージするためのテーブルであり、ID の順序は意味を持ちません。重要なのは、「いつ」「誰が」「何に」に対して「いいね」を行ったのかというデータを正しくマイグレーションすることです。ID の順序については気にする必要はありませんが、「いつ」の情報をコピーするために、created_at を忘れないように注意してください。

また、「1. 新旧のいいねテーブルに書き込む」の段階では、旧いいねと対応する新いいねが必ずあるわけではなかったため、新いいねの削除に Safe Navigation Operator を付けていましたが、データ移行が完了したため、Safe Navigation Operator を外します。

3. 新いいねを使う (Read)

新いいねにデータが入ったので、アソシエーションを以下のように変更します。これからは、product.product_likes ではなく、product.likes を使います *4

「新しい対象について、いいね数の集計バッチを追加することを忘れる」という問題もありましたが、今回の変更により、1クエリで「いいね」を合算することができます。

さらに、「ある時刻以降の Like を求める」「あるユーザーの Like を求める」というケースのクエリも簡単に書くことができるようになりました。

4. 旧いいねのモデルとテーブルを削除 (Delete)

すべてのコードで新いいねを参照したら、旧いいねの書き込みを削除します。最後に、旧いいねのモデルとテーブルを削除することができたら、ポリモーフィック関連への移行が完了です 🎉

まとめ

本ブログでは、似ているけどちょっと違う「いいね」の設計の紹介をしました。最初は、変更に耐えられるようなデータ構造にし、実装が複雑になったタイミングで、データ構造を見直しました。今回は、ポリモーフィック関連へのリファクタリングをしました。

ポリモーフィック関連を適用できるケースは多くないと思いますが、場合によってはとても強力なデータ構造の手法であり、特に Rails ではフレームワークレベルでのポリモーフィック関連付けの支援があるため、テーブルやコード量を減らし、かつ、わかりやすいコードに置き換えることが可能です。
ポリモーフィック関連を利用したことがない人や、ポリモーフィック関連は SQL アンチパターンでよく挙げられているから抵抗があるという人も多いと思いますが、今回のブログを通して、今後のデータ構造でポリモーフィック関連を選択肢の一つに入れてもらえると嬉しいと思います。

クックパッドでは、toB 事業をやりたい!データ構造の話をたくさんしたい!リファクタリングに興味がある!というエンジニアを募集しています。以下のリンクからのご応募をお待ちしています!

cookpad.careers

*1:ポリモーフィック関連の具体的な実装に関しては、Railsガイドを参照してください

*2:Techlife: Kaigi on Rails 2022 にて『森羅万象に「いいね」するためのデータ構造』の発表をしましたの「ポリモーフィック関連で if 文を書くと破綻するの例は?」 に詳細を書いています

*3:Techlife: Kaigi on Rails 2022 にて『森羅万象に「いいね」するためのデータ構造』の発表をしましたの「匿名いいねの like_identifier ってなに?」を詳細を書いています

*4:grep をしやすいように、product.likes ではなく product.product_likes の書き方をしていました。product_likes は likes に書き換える必要があります

モブプログラミングを1年以上継続するコツ

$
0
0

こんにちは、メディアプロダクト開発部のマーケティングサービス開発グループ(通称msdev)の id:asonasです。msdevウィーク最後の記事です。チームメンバーの記事も是非読んでみてください。

マーケティングサービス開発グループでは毎週月曜日13時から17時の決まった時間にモブプログラミングを実践しています。 このモブプログラミングの枠は1年以上継続していて、毎週様々な課題の解決や機能の開発をしています。この記事ではモブプログラミングを長く継続するためのコツをお伝えします。

モブプログラミングとは

まず、モブプログラミングとは、チームメンバーが同時にコーディングを行う手法です。重要な点はこの「チームメンバー」にはソフトウェアエンジニアだけではなく、プロダクトオーナーやデザイナーのような方々も含まれていることです。スクラムガイド(2020)の開発者と同じように考えてもらうと自然かもしれません。

モブプログラミングにはいくつかのメリットがあります。第一にチーム全員が共通の目標に向かって協力するためコミュニケーションの質が向上します。また、プロダクトに深く関わることで、知識の共有が促進され、チーム全体でより高い品質のコードを書くことができます。

さらに、モブプログラミングは開発プロセスを迅速化できます。複数の開発者が同時に機能を実装することで、エラーやミスがすばやく発見され、修正できます。これにより、開発プロセスがスムーズに進み、品質の高いコードをより迅速に提供できます。また、機能の実装途中に意見の分かれるポイントが出てきたときには、プロダクトオーナーやディレクター、デザイナーの方々の意見も取り入れることで"戻し"の作業を省くことができます。

モブプログラミングはチーム全体の知識や技術レベルを向上させることができます。開発者が一緒に開発をすることで、新しいアイデアやテクニックを学び、開発者自身のスキルを向上させることができます。これによりチーム全体の技術力が向上しより高度なプロジェクトに取り組むことで、いわゆる暗黙知から形式知、形式知から暗黙知へのループがモブプログラミングを通して回せます。

このようなモブプログラミングを2021年12月から16ヶ月以上実践しています。また、私たちのモブプログラミングは社員だけではなく、株式会社えにしテックさんの darashiさんと cafedomancerさんを招聘して毎週実践しています。

1年以上開催するうえでどのようなコツが必要でしょうか?

darashiさんとcafedomancerさんは遠方からZoomを使ってモブプログラミングに参加してくださっています。よくある定義として「ペアプログラミング・モブプログラミングはひとつのコンピューターでやる」とありますが、昨今の開発体験の進化により、Zoomによる画面共有や VSCode の Live Shareなどのツールの発展により遠隔地にいてもモブプログラミングは充分に満足できる形で開催できます。一昔前だと開発者全員でひとつの開発サーバーにsshで入ってscreenやtmuxのようなターミナルマルチプレクサのセッションを共有してコードを書いていましたね(なつかしい。10年以上前の話ですが)

定期的にモブプログラミングを実践するいくつかの良い方法を紹介します。 ひとつは毎週決まった曜日と時間に開催することです。

私たちのやっているモブプログラミングは毎週月曜の13時から17時に固定して実施しています(年末年始の休暇、祝日などが無い限りは基本的に開催です)。

これは私たちがスクラムを実践している点も関係しており、スクラムガイド(2020)には、

スクラムイベント スプリントは他のすべてのイベントの⼊れ物である。(略) スクラムにおけるイベントは、規則性を⽣み、スクラムで定義されて いない会議の必要性を最⼩限に抑えるために⽤いられる

とあります。私の中ではモブプログラミングもスクラムのイベントのひとつ*1として取り組んだほうがお得だと思っていることです。

また、モブプログラミングは13時から17時の間で実施されるのでその朝会で取り組むことを決めています。モブプログラミングで取り組みたいことは日々のスクラムイベントから適宜Issueに起票されてラベルで管理されています。

ふたつ目は毎週のログを取ることです。

開催時に必ず全員で見るGoogleドキュメントがあります。僕たちのチームでは「モブプロメモ」と呼ばれています。

モブプロメモの様子

朝会で取り組むことが決まれば事前にこのモブプロメモへ書いておきます。13時になりZoomへ人々が集まり、モブプログラミングで取り組みたいことをオーナーがメンバーに説明をして開始となります。 基本的には、このメモには会話した内容のメモやコードを書きながら発生した議論をまとめたりしてあとから読み直せるようにしています(全文をメモするようなことはしません)。 モブプロは毎週開催しておりその成果物はPull Requestになりますが、休暇などで参加できない方に向けてもこのメモは役に立ちます。

モブプログラミングの座組

誰から取り組むか、という点については毎週の参加者でランダムに順番をきめています。

その日の参加者の名前を書きつつshuffleするスニペット

持ち込んだ課題のオーナーからはじめても良いのかもしれませんが、私たちは特に気にすることなく、ランダムに順番を決めています。明確な理由付けはないですが、施策や課題の知識の偏りがチーム内にあります。この偏りをうまく利用する形で施策の目的や達成したいことはモブプログラミングを通して共有できるようになっています。

そこで私が大切にしていることのひとつとして、自分がドライバーの時は頭の中で思っていることをすべてしゃべるようにしています。「このコードの意図は何だろう」「なるほど、ブランドごとにトピックスの最新の1件を取ってきて、その順序でブランドの一覧を掲出しているのか」「フロントエンドのテストで行数を指定して実行方法はどうやるんだっけ」「ここのコードの修正はvimでやるほうが得意なのでvimに切替えますね」というようなことをしゃべるようにしています。これはドライバーが思っていることをすべて言うことでモブの方々がドライバーの思考を理解しやすくするためです。逐一しゃべりながらやるので少し大変なのですが、ライブコーディングのような感覚でやると楽しめます。エディタやツールのテクニックも実況しながらやると「それなに?」のような会話も発生します。時には取り組む課題とは関係のない話題についても触れて開発体験の共有をするのもよいなと実感しています。

ペアプログラミングでもそうですが、どんどん交代しながら課題を解決していくので、25分+5分休憩を1セットとして回していきます。2時間ほど経過するタイミングで休憩の時間を15分取っています。

5,6人で回していくとなんだかんだで3時間30分ほど経過するので、最後の30分の枠でふりかえりをします。 ふりかえりではよかったこと、取り組んだ課題の難しかったところ、白熱した議論についてなど様々なことが書かれています。

ある日の振り返りの様子。この日は業務分析をしたりReactのテストに苦戦する様子が描かれてる

私たちが実践するモブプログラミングのまとめ

カタはだいたいこのような流れです。ただ、このカタにハマらずブレることもままあります。前週に性能改善系の課題をこなしたときには冒頭で性能改善の様子をメトリクスを眺めたりもしますし、新しい参加者がいれば自己紹介をしたりしますし、Ruby/Rails、ReactなどだけではなくSQLを眺めてみんなでウンウン唸りながら改善をすることもありますしコードをほぼ書かずに業務分析をすることもあります。 おすすめのツールや設定、スニペットがあれば自慢してみたり、最近の技術的な話題で盛り上がることもあります。

モブプログラミングはとてもハードなプラクティスです。一日4時間もやるととてもヘトヘトになります。それでもチームのモチベーションを維持のためにも緩急をつけて、たまには雑談を挟むことで機械的な開催になることを避けるように心がけています。仕事として楽しめるような雰囲気作りもとても重要です。

モブプログラミングを実践するうえで、特にはじめて参加されるチームメンバーの場合はアプリケーションに精通していないこともあります。暗黙知が備わっていない、わからないことは当然あります。ドライバーになる人であれば分からない旨を伝えることも大事ですし、モブの方々も率先して自分たちが持つ知見を展開する心意気も重要です。ここのサイクルを回していくことでチームの生産性が向上しより早くユーザーに価値を届けることができます。

ただ、ここまで書いた内容は、一朝一夕でなしえたものではありません。毎週のふりかえり以外にもチームのモブプログラミングの意義を問うようなふりかえりも別途実施しました。チームメンバー各位がモブプログラミングに対する認識を揃えるなどを経て今のモブプログラミングのカタが完成しています。

モブプログラミングを継続してやっていくうえでメンバーの練度の差も次第に揃ってきます。特に導入時などはバタバタとしてしまうこともありました。それでも毎週のふりかえりの積み重ねでよりよい体験へと持っていくことができます。

モブプログラミングを長く続けることで対外的な登壇もしたりしました。前日の pndcatさんがKaigi on Railsで登壇するきっかけになったプロポーザルも実はモブプログラミングで取り組んだ成果でした。

kaigionrails.org

今は違うチームに異動してしまったのですが osyoyuさんもモブプログラミングで取り組んだことが採択されました。

kaigionrails.org

今回の記事では私たちのチームでうまく、そして継続的にモブプログラミングを実践する方法を紹介しました。定期的に開催しつつも、義務的にはならずソフトウェアエンジニアとして楽しく取り組めるような雰囲気作りを紹介しました。 もし私たちの取り組みに興味がありましたら、以下のリンク、またはTwitterなどでDMを頂ければカジュアルな面談からでも実施できればと思います。

cookpad.careers

*1:正確にはモブプログラミングはスクラムイベントの枠組みではありませんし、スクラムガイドには定義されていません。どちらかというともっと大きな枠組みの文脈で語られることが多いです。が、そこはスクラムやモブプロもXPのかけらということで解釈してもいいよなと考えています。

Viewing all 802 articles
Browse latest View live