DIGGLE開発者ブログ

予実管理クラウドDIGGLEのプロダクトチームが、技術や製品開発について発信します

React の Global State 管理で Jotai を採用した話

こんにちは。 DIGGLE エンジニアの ito です。 九月末になり、厳しい残暑に少しずつ終わりが見え始めた中いかがお過ごしでしょうか?

ここ数年は、残暑という名だけで本格的な暑さが続いているように感じています。 私はDIGGLEエンジニアという身分の他に自前の田んぼでお米を育てている兼業農家という身分もあるため、毎年稲刈りの時期までこの厳しい残暑が続いてしまうと困るなと思っています。 ですが、今年も無事残暑が落ち着いてから稲刈りを実施することができて安心しました。

毎年稲を刈る度に新しいライブラリが現れるフロントエンド界隈ですが、最近の DIGGLE のフロントエンドでは従来 ContextAPI で行なっていた React の Global State 管理を見直し、移行先として有力な Recoil と Jotai を比較した上で Jotai を導入することに決定しました。 導入決定の経緯と導入する際の工夫について今回はお話しさせていただきます。

React の Global State 管理に関して

React ではアプリケーションの規模が大きくなるとしばしば Global State を導入することになると思います。 導入理由は、コンポーネントを細分化していくにあたってstate/props のバケツリレーの階層が深くなり可読性が落ちていくためなど様々だと思われますが、 DIGGLE でも例に漏れず可読性向上を目的として Global State を導入して管理を行ってきました。

DIGGLE では従来 Global State として ContextAPI を利用しており、

react.dev

利用目的としては主に可読性向上だったため、Page コンポーネントなどの大元の親コンポーネントで Context を用意して子コンポーネントに伝播させていました。 (私の入社以前(2021年4月以前)には Redux を利用している時期もあったそうなのですが、ボイラープレートの管理が辛くメリットよりもデメリットが上回ったため ContextAPI に切り替えたとのことです)

上記のような使い方で可読性の向上をすることができたのですが、アプリケーションの成熟に伴って下記の問題が発生するようになりました。

  • 無駄な再レンダリングの発生
  • Context の肥大化
  • Provider が乱立

特にDIGGLEでは大きな表を描画する必要があるなど、パフォーマンス向上が必須となる機能特性上、「無駄な再レンダリングの発生」を抑止するために、今回手を入れることになりました。

問題解決に向けた Recoil の検討

「無駄な再レンダリングの発生」は ContextAPI の使い方の問題ではあるものの元々の目的である可読性を落とさずに解決する方法は難しく、setState + ContextAPI の構成の移行先としてよく挙げられる Meta から公開されている Recoil を検討することにしました。

recoiljs.org

Recoil は 2020年5月に Meta によってリリースされた React 向けの状態管理ライブラリで、atom という単位で Global State 管理を行います。

atom はデータを入れるための箱のようなもので atom を定義すると箱が用意され、各種 getter や setter によってデータを出し入れできるようになります。

recoiljs.org

また、レンダリングするか否かはコンポーネントごとに使われている atom の値に変更があるかで実施されます。

ContextAPI Recoil
再レンダリングの判定 Contextの値が変更されたら atomの値が変更されたら
再レンダリングの範囲 Context.Providerで囲われた範囲 atomの値が使われているコンポーネント

Recoilを利用した例

実際にRecoilを利用した場合で、どのように再描画が走るのかを確認したものが下記になります。

書いたコードは下記となっており、カウントをインクリメントするボタンを押してもuseRecoilValue を使っている GrandChildコンポーネントのみ再描画が走っていることがわかります。 また、ContextAPIと変わらず可読性高く記述ができることがわかります。

"use client"
import React from 'react'
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil'
import {hobbyAtomState} from "./atoms/HobbyAtom"

const GrandChild = () => {
  const count = useRecoilValue(hobbyAtomState)
  return (
    <div style={{backgroundColor: "blue", padding: "5rem"}}>
      <span>
        {count}
      </span>
    </div>
  )
}

const Child = () => {
  return (
    <div style={{backgroundColor: "yellow", padding: "5rem"}}>
      <GrandChild />
    </div>
  )
}

const Parent = () => {

  const setter = useSetRecoilState(hobbyAtomState)
  return (
    <>
      <div style={{backgroundColor: "green", padding: "5rem"}}>
        <Child />
        <button style={{marginTop: "1rem"}} onClick={()=>setter((count)=>count+1)}>
          count up
        </button>
      </div>
    </>
  )
}

export default function RenderTest(){
  return (
    <RecoilRoot>
      <div>テスト</div>
      <Parent />
    </RecoilRoot>
  );
};

一方で setState + ContextAPI を利用した場合では下記の図のようになり、カウントをインクリメントするボタンを押すとコンポーネント全体が再描画されていることがわかります。

上記検討で可読性を担保したまま再描画を抑えられることがわかったため Recoil での Global State 管理をおこなっていく方針で定めようと考えていました。 ですが、Recoil に懸念があることをある時メンバーから共有してもらったことで再度方針を考え直すことになります。

Recoil の懸念と Jotai の検討

Recoil で検討を進めていたところ DEV チームのメンバーから Recoil の開発継続性に対する不安があることを共有してもらいました。

実際のSlack上でのやり取り

github.com

上記の issue を確認するとRecoil の主要メンテナがレイオフされたり、Jotai に切り替える方がいることがわかります。 Recoil が Meta社によって開発されいてるため優先して検討をしていたのですが、上記状況を踏まえると Meta社による開発という優位性が怪しいものとなったため、Jotai と比較検討することにしました。

結論からお伝えすると Jotai を採用したのですが、採用理由は主に下記になります。

  • TypeScriptで開発されている
  • 開発が活発
  • callback 内などで atom を get する際に通常の atom の get と仕様が変わらない

TypeScriptで開発されている

Jotai は TypeSciprt で開発されているため、TypeScript で開発している DIGGLE のフロントエンドとも相性が良かったです。 型が細かく設定されており、型安全な状態で開発を進めることができました。

開発が活発

Recoil では先述の主要メンテナのレイオフであったり、major ver.の公開がされていないといった問題がありました。 Jotai は既に ver.2 が公開されており、月一以上でコンスタントに更新がされています。

callback 内などで atom を get する際に通常の atom の get と仕様が変わらない

Recoil にも Jotai にも useCallbackに似た hook が用意されています。

Recoil では useRecoilCallback であり、callback 内で atom の値を取得する際に snapshot と呼ばれるものを介して取得することになります。 最初に触った際にはここでも atom の時のように get で値を取得できれば嬉しいなと思っていました。

const logCartItems = useRecoilCallback(({snapshot}) => async () => {
    const numItemsInCart = await snapshot.getPromise(itemsInCart);
    console.log('Items in cart: ', numItemsInCart);
}, []);

recoiljs.org

一方 Jotai では useAtomCallback であり、atom の値を取得する際には atom の getter 同様に get で取得できます。

  const readCount = useAtomCallback(
    useCallback((get) => {
      const currCount = get(countAtom)
      setCount(currCount)
      return currCount
    }, [])
  )

jotai.org

似たような処理で同じような方法をとれることは可読性を上げ、実装する際には戸惑う可能性を減らすように感じました。 好みの問題ではあるのものの、上記の印象から私は Jotai の方が良さそうだと感じています。

Jotai への置き換えに関して

検討を通して Jotai を採用することに決めました。 すでに一部 Recoil で実装をしていた部分があったため、Recoil から Jotai への置き換えが発生しました。 ですが、 Recoil と Jotai には一部を除き仕様に大きな違いがなかったことや本格導入する前だったことから、置き換えの工数はそれほどかかりませんでした。

違いのあった点としては、リスト表示する複数の要素や大きなオブジェクト要素を atom で管理する方法です。

Recoil では atomFamily や selectorFamily といった collection の形でデータを保持するための Utils を用意しており、Recoil を導入した際には両方を使って処理を行なっていました。

import { atomFamily } from 'recoil';
import { Fact } from '@/models';

export const factsAtomState = atomFamily<Fact, number>({
  key: 'atoms.factsAtom',
  default: new Fact(),
});

recoiljs.org

一方 Jotai では atom の中に atom を入れるなど柔軟な表現が可能なため、 配列やオブジェクトを扱う際には、 atoms in atom パターンの利用が提案されていました。

import { atom, PrimitiveAtom } from 'jotai';
import { Fact } from '@/models';

type Facts = { [id: number]: PrimitiveAtom<Fact> };
export const factsAtom = atom<Facts>({});

jotai.org

そのため、置き換えの際に atomFamily や selectorFamily を Jotai 流の書き方に置き換えました。

また、Jotai では大きなオブジェクトを扱う際には focusAtom や splitAtom で分割するなどできます。 Recoil は atom の中に atom が入らないため、オブジェクトを細かく分割した atom を用意していたのですが、Jotai ではそれらをまとめて必要な単位で分割して切り出す形に変更しました。

import { atom } from 'recoil';

export const dateAtomState = atom<string>({
  key: 'components.features.Fact.dateAtom',
  default: '',
});

export const valueAtomState = atom<number>({
  key: 'components.features.Fact.valueAtom',
  default: 0,
});

Recoilの場合

import { atom } from 'jotai';
import { focusAtom } from 'jotai-optics';

type Fact = { date: string; value: number };
export const factAtom = atom<Fact>({ date: '', value: -1 });
export const dateAtom = focusAtom(factAtom, (optic) => optic.prop('date'));
export const numberAtom = focusAtom(factAtom, (optic) => optic.prop('value'));

Jotaiの場合

jotai.org

Jotai では手が届かない部分について

Jotai で気になった点として、 変数のスコープが複雑になりやすいという点があります。

Jotai では Atom を利用するコンポーネントを Provider で囲う必要があります。 ProviderはネストすることができるのですがそれぞれのProviderで独立してatomの値を管理することになり、useAtom などで何も指定せずに取得をすると取得コンポーネントがネストされている Provider の中で一番深いものから値をとってきます。つまり、Provider のネストによって atom のスコープが切られます。

Jotai の Provider による Atom のスコープについて

const RootComponent = () => (
  <Provider> // Provider A
    <Provider> // Provider B
      <ComponentA />
      <Provider> // Provider C
        <ComponentB />
      </Provider>
    </Provider>
  </Provider>
)

DIGGLEでは一覧ページから詳細ページへ遷移した場合や数値表現のプロパティを複数ページで使いまわしたいなど、ページを跨いだ変数のスコープが欲しくなる一方でパスパラメータやクエリパラメータの伝播などページごとに変数のスコープを切りたいものがありました。そのような場合でも一応狙ったProvider の値を取得する方法が Jotai には用意されており、 https://jotai.org/docs/guides/migrating-to-v2-api の Provider's scope prop の項目に方法が記述されています。

const MyContext = createContext()
const store = createStore()

  // Parent component
  <MyContext.Provider value={store}> // Provider A
    <Provider> // Provider B
      <ComponentA />
      <Provider> // Provider C
        <ComponentB />
      </Provider>
    </Provider>
  </MyContext.Provider>

  // ComponentB Component
  const store = useContext(MyContext)
  useAtom(..., { store })

この方法では ContextAPI を使う必要があり、useAtom 時に使用する store を指定することから多用はしづらいように感じました。

現状変数のスコープを切って扱いたいものはパスパラメータやクエリパラメータに限定されていることもあり、どうせ ContextAPI を使う必要があるならと state を使わないことを前提に一部は従来のまま ContextAPI を利用することにしました。

そのため DIGGLE としては今後 Jotai / ContextAPI の組み合わせで使っていく方針としました。

まとめ

DIGGLEでは Jotai / ContextAPI の組み合わせを利用することに決定しました。 Jotai はシンプルでわかりやすく可読性とパフォーマンスを両立しながら今後の開発を行なっていけそうです。 基本的には Jotai を用いて Global State 管理を行い、state を使わずに変数のスコープを切って再利用したいものに関しては ContextAPI を利用していこうと思います。

今回は現時点での DIGGLE の環境において最善と思われる Global State 管理を検討したものになっており、結論は時期/環境によって大きく左右されると思います。 そのため Jotai は素晴らしいライブラリとは思うものの今回の決定に囚われることなく、その時々の状況に応じて適宜方針を検討し直していきたいと考えています。

今回は DIGGLE において Global State 管理を Jotai に決めた経緯を紹介させていただきました。 本記事が皆様の検討の際の一助になれば幸いです。

We're hiring!

予実管理の明日を切り開く為に日々精進している私達と一緒に開発してくれるメンバーを大募集です。

少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Python、Rust上でのデータフレームライブラリで速さ対決してみた!

※ Apache Arrow, Apache Arrow DataFusionは、米国およびその他の国におけるApache Software Foundationの登録商標または商標です。これらのマークの使用は、Apache Software Foundationによる保証を意味するものではありません。

こんにちは。5月入社のソフトウェアエンジニアのhirataと言います。日本全国地獄のように暑い日々ですが、元気で過ごしていますでしょうか?

私は入ったばかりで覚えることが多く毎日アップアップしていますが、それでも計算式を実行するエンジンを作ったり、予実の数字を分析する機能を作ったりして、楽しく過ごしております。

我々のサービス「DIGGLE」で取り扱っている「お客様の経営に関わるデータ」はそこそこ規模の大きいデータになります。このデータを高速に集計・分析できることはお客様への価値向上に直結します。今回はそれに関わる技術調査の一環で、各データフレームライブラリを比較する機会があったので記事にしてみました。

データフレームライブラリとは

データ分析のためのライブラリでPythonのpandasが有名です。2次元のdataframe、1次元のseriesというデータを格納するためのオブジェクトを持ち、データの操作を行う際にはこれらのオブジェクトにデータを格納し、オブジェクトに対して計算内容を指示していきます。

dataframe, seriesに対して一括して計算処理を行うライブラリであり、実際の計算処理はC++言語等で作成されたライブラリ内で行われるため、Python等のスクリプト言語においても大量のデータを高速に処理することができ、コンパイル言語並の高速性とスクリプト言語であることによる柔軟性とを両立できます。

このように書くと、コンパイル言語でデータフレームライブラリを使う恩恵が無いように感じられるかもしれませんが、そんなことはありません。スクリプト言語程の差では無いですが、データのメモリ配置等により大量データの計算に最適化されているため普通にプログラムして計算するよりも高速に処理できます。データ処理以外も必要な場合はコンパイル言語を使用することも選択肢となるでしょう。

ということで、今回はコンパイル言語に匹敵する計算速度が本当に出るのかの検証を、PythonとRustの二つで検証しました。

前提条件

今回の検証では以下の条件で処理時間を検証していきます。

  • ファイルフォーマットはparquet形式。サンプルデータ数は100万行。
  • 処理内容は三要素でgroup byしてsumする。

今回エントリーするライブラリ

今回の検証に使用するライブラリと処理系です。社内の事情で今回はPython3ではなく、Python2で計測を行います。

言語 ライブラリ
Python(2.7.17) pandas(1.1.5) with pyarrow(6.0.1)
Python(2.7.17) polars(0.29.0) lazy
Rust(1.69.0) datafusion(25.0.0)
Rust(1.69.0) polars(0.29.0) lazy

それぞれについての簡単な解説と計算を行うためのソースを以下に載せていきます。

Python pandas について

https://github.com/pandas-dev/pandas

言うまでも無いとは思いますが、データ処理ではデファクトスタンダードとなっているライブラリです。 今回はapache arrowというインメモリのフォーマットを使うためのライブラリであるpyarrowも同時に使っています。 ソースは以下のような感じです。今回は数値計算ライブラリ(blas等)等の高速化のためのライブラリ導入は行っていません。試しに入れて見たところ今回の処理内容では実行速度が変わらなかったため、素のpandasライブラリを使っています。

import time
from pyarrow import fs
import pyarrow.dataset as ds

def main():
    start_time = time.perf_counter()
    
    dataset = ds.dataset("test.parquet", format="parquet")
    dataframe = dataset.to_table(columns=['date', "account_id", "unit_id", "value"]).to_pandas()
    series = dataframe.groupby(["date", "account_id", "unit_id"])["value"].sum()
    
    print('time:', time.perf_counter() - start_time)

if __name__ == '__main__':
    main()
Python polars について

https://github.com/pola-rs/polars

こちらもapache arrowを用いたrust製のデータフレームライブラリのpythonバインディングで、かなり高速との噂です。lazyな実行とeagerな実行の両方ができます。

lazyの方が速いので今回はlazyで評価しています。今回評価を行うためのソースは以下。

import time
import polars as pl

def main():
    start_time = time.perf_counter()
    df = pl.scan_parquet("test.parquet")
    series = df.groupby(["date", "account_id", "unit_id"]).agg(pl.col("value").sum()).collect()
    
    print('time:', time.perf_counter() - start_time, series)
    print(series)

if __name__ == '__main__':
    main()
Rust dafafusion について

https://github.com/apache/arrow-datafusion

apache arrowを用いたrustのデータフレームライブラリです。こちらもApache製の様なのでapache arrowのrust向け公式ライブラリっぽいです。

今回評価を行うためのソースは以下。SQLで書いてますが、SQLじゃなくても速度は変わらずでした。

use std::time;
use std::sync::Arc;
use datafusion::prelude::*;
use datafusion::arrow::record_batch::RecordBatch;

#[tokio::main]
async fn main() -> datafusion::error::Result<()> {
    let now = time::Instant::now();

    let ctx = SessionContext::new();

    let path = "test.parquet";
    
    // 集計用クエリの定義: SQL形式で記述可能
    let opts = ParquetReadOptions::default();
    ctx.register_parquet("facts", &path, opts).await?;
    let df = ctx.sql("SELECT date, account_id, unit_id, sum(value) FROM facts GROUP BY date, account_id, unit_id").await?;
    
    // collect() の実行で実際にクエリがexecuteされる
    let results: Vec<RecordBatch> = df.clone().collect().await?;
    
    let time = now.elapsed();
    println!("time: {:?}, result size: {}", time, df.count().await?);
    Ok(())
}
Rust polars について

https://github.com/pola-rs/polars

上記Python polarsで使用したpolarsのrustバインディングです。高速、省メモリに書けるのがrustのメリットということで、今回の最有力候補です。ソースは以下のとおり。

use polars::prelude::*;
use std::time;

#[tokio::main]
async fn main() {
    let now = time::Instant::now();
    let df = LazyFrame::scan_parquet("test.parquet", Default::default()).unwrap();
    let df = df.groupby(["date", "account_id", "unit_id"])
        .agg([col("value").sum()])
        .collect()
        .unwrap();
    let time = now.elapsed();
    println!("time: {:?}, result size: {}", time, df);
}

結果

以下計測結果です。

⁠実装 ⁠実行時間
Python pandas 0.54s
Python polars 0.76s
Rust datafusion 1.53s
Rust polars 0.25s

やはり期待通りRustでのpolarsが一番速いですが、Python版でもそれほど遅くなっていませんでした。またpandasの速さが予想以上でRust polarsとの差が2倍ほどに収まっています。この測定では処理が途中でライブラリ外に出る事が無いために、pythonオブジェクトに変換するためのオーバーヘッドがほとんど無いせいでしょう。Pythonで書く容易性を考慮すると、この程度の差であれば大抵の場合はpandasで十分ということになりそうです。

またRust datafusionが期待に反して遅いですが、ここはユーザ数が膨大で歴史もあるpandasの最適化具合には敵わなかったといったところでしょうか。「今回のユースケースでは」といったところもあるかと思いますので、皆さんが実際に使用する際には事前に想定されるユースケースでの計測を行う事をお勧めします。

まとめ

pandas素晴らしいですね。気軽に書けてこれだけ速度が出れば、デファクトスタンダードになる訳だなと思いました。

また、これは反省なのですが、実はこのブログを書くにあたりメモリの使用量を取ってなかった事に気がつきました。途中で処理速度にフォーカスした為、取らなくても良いかとその時は思っていました。が、改めて見直してみると、それがどの程度だったのかがとても気になります。メモリが気になりがちな大量データの処理なのでそこを曖昧にしておかずに、きちんとメモリ使用量も計測・記録しておいた方が今後の為に良いデータとなったでしょう。次回に向けての反省点です。

We're hiring!

予実管理の明日を切り開く為に日々精進している我々と一緒に開発してくれるメンバーを大募集です。

少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Ruby のバージョンを 3.1 系から 3.2 系にアップデートしたら Ruby on Rails アプリの動きが変わったのを解決した話

私たちは Ruby on Rails の主要なマルチテナントライブラリ apartment を使ってサービスを提供しています。 Ruby のバージョンを 3.1 系から 3.2 系に上げたときに CSV ファイルを処理する部分でこのテナントの切り替えが意図通りに動作しませんでした。 この事象が興味深かったので共有します。

現在はこの事象に対応済で、私たちの環境は Ruby3.2 系で動作しています。

apartment ではマルチテナント対応部分をほとんど吸収してくれるので、アプリケーションのコードのほうにはあまりマルチテナント特有の処理が出てこず、個別処理のコードに集中できるメリットがあります。 事象が発生したコードは以下のような形式でした。

CSV.parse(filename, headers: true, header_converters: ->(header) {
  current_tenant = Apartment::Tenant.current
  # current_tenant を利用したテナント別の処理が入る
})

この事象は、以下のトピックの複合的な組み合わせによって影響が顕在化しました。

  • CSV ライブラリに渡したオプションブロックを実行する土台の Fiber が 3.2.6 から切り替わっている
  • Ruby の 3.1 から 3.2 に上げると CSV ライブラリのバージョンも変わり、動作が切り替わる
  • Thread[]Thread[]= は、Thread ローカルではなくて Fiber ローカルな変数を扱っている
  • Rails がデフォルトで利用しているサーバー Puma ではThreadでリクエストを処理する
  • apartment はリクエストローカルな値を設定するつもりで Thread[]= を使ってしまっている
  • apartment は最近アップデートがなく、最新版を使っていてもこの問題にぶつかる

CSV ライブラリに渡したオプションブロックを実行する土台の Fiber が 3.2.6 から切り替わっている

私たちが作った Ruby プログラムを windows や linux で実行するとき、そのプログラムは Process の上にある Thread の上にある Fiber の上で実行されていると認識してよいでしょう。(異論があれば教えてください m(__)m) その実行の土台となる Fiber が 3.2.5 までと、3.2.6 からは切り替わっています。

CSV ライブラリには header_converters オプションでブロックを渡せます。 https://docs.ruby-lang.org/ja/3.2/method/CSV/s/new.html

Ruby 3.1 系では、実行開始時の Fiber と header_converter の Fiber は同じでした。 Ruby 3.2 系では、実行開始時の Fiber と header_converter の Fiber は異なっていました。

$ ruby -v
ruby 3.1.4p223 (2023-03-30 revision 957bb7cb81) [arm64-darwin22]
$ ruby -r csv -e 'main_fiber = Fiber.current; CSV.new("id,name,age\n1,2,3\n", headers: true, header_converters: ->(h) { p Fiber.current == main_fiber }).to_a;'
true
true
true


$ ruby -v
ruby 3.2.2 (2023-03-30 revision e51014f9c0) [arm64-darwin22]
$ ruby -r csv -e 'main_fiber = Fiber.current; CSV.new("id,name,age\n1,2,3\n", headers: true, header_converters: ->(h) { p Fiber.current == main_fiber }).to_a;'
false
false
false

私はこの現象は特にバグというわけではないと思っています。 ただ、ユーザーは特に言及されていない限り同じ Fiber で実行されるという暗黙の期待を持ってしまいがちかなとも感じているので、ここのミスマッチはトラブルを引き起すことがありそうです。 気をつけたいですね。 ちなみに Fiber は Enumerator で利用されているので、直接は利用していなくても each を利用しているあたりでその影響を受けたりします。 今回の影響も each から next を使うよう変えたときに切り替わったようでした。

Ruby の 3.1 から 3.2 に上げると CSV ライブラリのバージョンも変わり、動作が切り替わる

当初、上記の事象は Ruby 3.1 系から Ruby 3.2 系にアップデートしたときに起きたため、Ruby の言語系で何か変更があったのかなと考えましたが、そうではありませんでした。 Ruby 標準添付ライブラリ csv のバージョンが 3.2.5 から 3.2.6 に上がるときに変わった動作でした。

ただし、この標準添付ライブラリ csv は 3.1 系が 3.2.5、3.2 系が 3.2.6 と切り替わっていました。 言い替えると、Ruby 3.1 系でも、利用する csv のバージョン 3.2.6 へと上げると同じ影響が出ます。

$ ruby -v
ruby 3.1.4p223 (2023-03-30 revision 957bb7cb81) [arm64-darwin22]
$ ruby -r csv -e 'p CSV::VERSION'
"3.2.5"

$ ruby -v
ruby 3.2.2 (2023-03-30 revision e51014f9c0) [arm64-darwin22]
$ ruby -r csv -e 'p CSV::VERSION'
"3.2.6"
Ruby バージョン 標準添付の CSV ライブラリバージョン
3.1.4 3.2.5
3.2.6 3.2.6

Thread[]Thread[]= は、Thread ローカルではなくて Fiber ローカルな変数を扱っている

Ruby では、1 つの Thread 上では複数の Fiber が動きます。Ruby の Thread クラスは、OS が生成するスレッド(ネイティブスレッド)に対応していて、Fiber クラスは Ruby プログラムが生成するスレッド(ユーザレベルスレッド)に対応しています。

Thread[]= は、Thread オブジェクトへと設定するため Thread ごとに設定できる値(Thread ローカル)になると思ってしまいますが、そうではありません。 Fiber ごとに設定できる値(Fiber ローカル)です。 https://docs.ruby-lang.org/ja/3.2/method/Thread/i/=5b=5d=3d.html

以下のように、新しい Fiber を Fiber.new で作った中では Thread[]= で作った値が共有できていません。

irb
irb(main):001:0> Thread.current[:a] = "hello"
=> "hello"
irb(main):002:0> p Thread.current[:a]
"hello"
=> "hello"
irb(main):003:0> Fiber.new { p Thread.current[:a] }.resume
nil
=> nil

るりまの Thread[]= の説明にもある通り、Thread#thread_variable_setThread#thread_variable_get を使うことで Thread ローカルな変数を扱えます https://docs.ruby-lang.org/ja/3.2/method/Thread/i/thread_variable_set.html

irb
irb(main):001:0> Thread.current.thread_variable_set(:a, "hello")
=> "hello"
irb(main):002:0> p Thread.current.thread_variable_get(:a)
"hello"
=> "hello"
irb(main):003:0> Fiber.new { p Thread.current.thread_variable_get(:a) }.resume
"hello"
=> "hello"

Rails がデフォルトで利用しているサーバー Puma ではThreadでリクエストを処理する

https://github.com/puma/puma/blob/v6.3.0/docs/architecture.md#how-requests-work のアーキテクチャのとおり、Puma では 1 リクエストを処理するのに 1 スレッドが対応しています。そこでリクエストの処理を開始するときに、Thread ローカルな変数に値を格納しておけば、そのリクエストの処理が終わるまで同じ値を取得できて都合がよいです。言い替えるとリクエストローカルな変数を扱うのに、Thread ローカルがばっちり対応しているということです。

apartment ではリクエストの内容からどのテナントかを判別します。判別した結果をレスポンスを作る処理の中で共有できると都合がよいです。そこでリクエストローカルな変数にテナントの情報を格納します。

余談になりますが、リクエストを実行する単位が Fiber なアプリケーションサーバーもあるので、リクエストローカルな変数を Proces、Thread、Fiber、Ractor などのどれに設定するか切り替え可能にしておかないとうまく動かないという点は将来直面する課題かもしれません。Puma を使っているうちは問題ありませんので今回はよしとします。

apartment はリクエストローカルな値を設定するつもりで Thread[]= を使ってしまっている

apartment は本家と、本家での動きがみられなくなったあとに fork した rails-on-services 版があります。どちらもリクエストローカルな値にテナントの情報を格納するつもりで Thread[]= を使ってしまっています。このため、リクエストを処理している中で Fiber が切り替わるとテナントの情報が取得できなくなります。

https://github.com/influitive/apartment/blob/f266f73e58835f94e4ec7c16f28443fe5eada1ac/lib/apartment/tenant.rb#L26 https://github.com/rails-on-services/apartment/blob/7d626d1fd53259da7c193a1710495b384cad6481/lib/apartment/tenant.rb#L22

apartment は最近アップデートがなく、最新版を使っていてもこの問題にぶつかる

問題があれば、PR を送るなりして修正すればよいですね。現在 https://github.com/rails-on-services/apartment/pull/182/files でも PR が作られています。 私たちの問題もこの PR のコードを参考にしたモンキーパッチで解決していますので、コード自体には問題ないように見えます。

この PR は 2021 年の 12 月に作られたもので、もしこの PR が既に取り込まれていれば私たちが今回直面した問題も予防できていたでしょうし、他のユーザーもこの問題にあたらなくなり良い影響となりそうですが、今のところ取り込まれていません。

また、リポジトリのコード自体 2022 年から動きがない状況で、現状メンテナンスがうまくいかないのかなという印象です。

まとめ

マルチテナントライブラリ apartment と標準添付の csv を使い、csv で convert_header にブロックを取ってその中でテナントに応じた処理を切り替えている場合、Ruby3.1 から Ruby3.2 に上げると、動きが変わってしまうという複合的な問題でした。どれか一つでも条件を満たしていなければ顕在化しなかったもので、興味深いですね。

apartment を使っていなくても、Ruby on Rails のリクエスト毎に current_user などをリクエストローカルな変数に格納しておきたい場合は多いと思われます。 その部分に Thread[]= ではなく Thread#thread_variable_set を使えているかは再度点検しておくとトラブルを未然に防げそうです。 みなさんのお手元のコードが問題なく動いていても、それはたまたま利用しているライブラリ群が新しい Fiber を作っていないだけかもしれません。

niku がお送りしました。

RubyKaigi 2023 にエンジニア4人でブース出展しました #rubykaigi

こんにちは、DIGGLE エンジニアの miyakawa と ito です。

前回のブログ記事でお伝えした通り、DIGGLE は RubyKaigi 2023 に Platinum スポンサーとして参加/ブース出展/幕間CM提供を行いました。

diggle.engineer

今回の記事では RubyKaigi のブース出展側の視点で、出展準備から当日の運営までを振り返っていきたいと思います。

初めてのブース出展にあたって何をする必要があるのか暗中模索しながら準備を進める中で、いろいろな方の過去の RubyKaigi レポートを参考にすることで意思決定をすることができました。
本記事が、今回の弊社と同じ立場(RubyKaigi に初出展される企業)の方がさまざまな意思決定をしていく際の判断の一助になることを願っています。

出展の計画/準備

RubyKaigi へのブース出展を決めたのが1月で、準備に動き出したのが2月上旬でした。
(スポンサーブースは抽選だったので、実際に出展できることが決定したのは2月中旬頃でした)

RubyKaigi へのスポンサーとしての参加は皆初めてで、RubyKaigi に一般参加したことがあるメンバーを頼りに準備を進めていきました。

準備期間の大まかなタイムラインは以下の通りです。

いつ 何をした
1月上旬 スポンサー申込
2月中旬 スポンサーブース当選
2月下旬 ノベルティ準備開始
ホテル確保
3月上旬 CM制作(外注)プロジェクト始動
3月下旬 ノベルティ・備品等の発注完了
4月上旬 ブース出し物(シューティングゲーム)制作
4月中旬 CM完成
5月10日~ RubyKaigi 本番

取り組んだこと

週1の定例ミーティング

週に1回ミーティングを行い、準備することを整理しながら各人にタスクを振り分け進めていきました。
ミーティングでは例えば、どの様なノベルティを用意するのか?当日必要な備品は何か?どこに宿泊するのか?会場で流れるCMはどう言ったものを用意するか?ブースの展示内容はどうするか?と言った内容をざっくばらんに話し合っていました。

ミーティングにはバックオフィスのメンバーも参加してもらい、必要になった備品は都度手配を依頼していく形で進めていました。
RubyKaigi ではない他のイベントの出展で使用した備品はバックオフィスが全て把握しているため、コミュニケーションコストを減らす意味でもバックオフィスと連携を密に準備を進めていけたことはとてもよかったです。

また、ノベルティやCMの制作にはマーケティングチームにも協力をしてもらい非常に助かりました。

情報収集

兎にも角にもブース出展に関する情報が足りなかったためメンバーで手分けして情報収集にあたりました。情報収集先としては過去の RubyKaigi 参加レポートのブログ、Twitter、RubyKaigi に参加経験のあるメンバーの頭の中などです。

困ったこと

ノベルティ等の配布物

ブースで頒布するノベルティや会社説明のチラシなどブース出展する立場になった際に考えなければいけないことの情報収集が必要でした。 他社事例や参加レポートを参考にしながら、どこまで用意するのかを話し合い、最終的に下記をノベルティとして用意することに決定しました。

  • ステッカー
  • クリアファイル
  • 企業説明のチラシ

ブースでの出し物

少しでも来場者の目を引いてブースに来てもらいたい!という思いで、どのような出し物を用意しようか悩みました。悩んだ末に ruby.wasm でシューティングゲームを作ることにしたのですが、その詳細については下記のブログ記事で書いているのでぜひご覧ください。

diggle.engineer

前日の設営

いざ松本へ

今回 RubyKaigi に参加したメンバーは住んでいる所が離れている(それぞれ北海道、埼玉、東京、愛知)ことから、前日に現地集合することにしました。

ito は愛知から特急しなので松本へ

miyakawa は新宿から特急あずさに乗って

設営実施

前日に会場のブースの設営を行うことができたため、当初は二人を先発組にして設営を行う計画を立てていました。 設営当日、一人のメンバーが手伝いに来てくれたため三人で設営したのですが、結果的には三人での設営がちょうどよかったです。

困ったこと

設営の際にノベルティのクリアファイルに会社ロゴが入っていないことが発覚しました。 準備段階でのコミュニケーションミスが原因でしたが、元々クリアファイルの中に会社説明のチラシを入れて頒布する想定だったため大きな問題にはなりませんでした。 準備期間が短かく細かい認識のずれを確認する場を持つことが難しかったために発生したと感じており、通常業務をこなしながらイベント参加の準備を進める難しさを痛感しました。

逆にそれ以外の問題は特になかったため、それまでの定期的なミーティングが機能したことと、備品手配を抜かりなくやってくれたバックオフィスメンバーの優秀さを改めて実感する出来事でした。

当日のブース運営

当日のブース運営は4人を2人ずつのグループに分けて行うことを計画していました。 2人がブース運営を行なっている間、他の2人はセッションを聞きにいくといった形です。

実際に作成したタイムシフト

1日目の運営では、セッション開催時にブースに来る方は少なく、lunch break や afternoon break でブースにくる方が多い傾向が見られました。 セッション開催時には1人でブースを回せるほどで、逆に break 時には4人で協力して回す必要があるほどでした。

特に、1日目のLTの間はクックパッドさんが配っていたクラフトビールを片手に散策している方も多く、ブースに来られる方はほぼいませんでした。

2日目では1日目の傾向を踏まえて、セッション開催時にブース運営を任せる人数を1人に変更したのですが、それは失敗でした。
2日目からはスタンプラリーが開催されており多くの方がブースに訪れていただいたためです。

朝に1人でブースを回していたメンバーはパンクしてしまいました。
(後から考えると2日目のスタンプラリーを踏まえて1日目はブース訪問を見送ってセッション参加をされた方も多かったのだと思います)
2人での運営も難しく、最終的には絶対に行きたいセッション以外は参加を見送って基本的に全員でブース運営を行う形にシフトしました。

上記の対応をしたにもかかわらず、ブースに訪れてくれた方を最大限おもてなしすることは難しかったです。 先述の通りブースではシューティングゲームを展示していたのですが、ブースで実際に遊んでいただくことが難しくご自身のPCにて遊んでいただく様に案内するしかなかった場面もありました。

3日目は2日目ほど忙しくはなかったものの、それまでのハードなブース運営によってメンバー全員が疲労困憊になっていました。After Party を楽しみにしながら気力で乗り切りました。

困ったこと

ノベルティの在庫切れ

会社説明のチラシを250部ほど用意していたものの、在庫が2日目の半ばで切れてしまいました。
来場者数の予測をもとに部数を見積もって用意していたのですが、300 ~ 400部ほどでも捌けそうなほどでした。

ちなみにチラシが切れた後は、弊社EMのzakkyさんのTwitterのリンクをQRコードで掲示し、下記のtweetを固定表示してそちらからアクセスできるように案内を変更して対応しました。

海外の方向けの対応

海外の方がブースに訪れる場面も多く日本語のチラシしかなかった弊社では、四苦八苦しながら会社の説明や展示物の説明を行っていました。
海外の方に向けた説明のために何を用意するのか?は事前に話し合った方が良さそうです。

ブース運営を振り返って

ブース出展を行ったことで、さまざまな方と交流を持てました。
ブースに立ち寄ってくれた方々に大変感謝しています。

展示内容のシューティングゲームも多くの方にポジティブな反応をいただけ、いろいろな方に DIGGLE という名前を認知していただけたのではと感じています。
(弊社の事業である予実管理 SaaS よりもシューティングゲームの印象の方が強く残っていそうなことは否めませんが...)

ただ、ブースに立ち寄ってくれた方との交流ができた反面、RubyKaigi のセッションを生で聴講する機会を失ってしまったことが残念でした。
4 人全員がブース運営に注力することが必要になってしまったため、全体としての余裕が全くない状態になってしまったためです。
RubyKaigi の真髄を味わいつつ十分なブース運営を回すには、少なくとも倍の人数(8人)程度はいた方が良いと感じました。
(懇親会等で他企業の皆さんとお話しした限り、弊社のブース運営人数が一番少ないようでした。「エンジニア4人でブース回してます」と言うと驚かれました😅)

おわりに

以上、RubyKaigi 2023 にブース出展した際の弊社での流れをお届けしました。

今後ブース出展される方々のお役に立てば幸いです。

We're hiring!

私たちと一緒に開発してくれるメンバーを募集しています。少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

RubyKaigi 2023 の雰囲気をお届けします #rubykaigi

こんにちは、DIGGLE エンジニアの ito と miyakawa です。

前回のブログ記事でお伝えした通り、DIGGLE は RubyKaigi 2023 スポンサーになりました。

diggle.engineer

今回の記事では RubyKaigi で初現地参加の様子を通して、会場の雰囲気をお届けします。

前日の準備

スポンサーブースの設営は前日から可能だったため、5月10日 15:00 ごろに松本駅に到着しました。

松本駅では RubyKaigi2023 の垂れ幕が下がっており、駅に降り立った段階からイベントの熱気を感じることができました。

会場は松本市民芸術館で、松本駅からは徒歩で15分ほどの場所にあります。

会場内は松本をイメージした意匠が施されていたため、 それらの意匠の邪魔にならないようにブースの配置をしました。

入場・受付

午前に開催される Matz さんの Keynote に参加するため、9:40 ごろに入場しました。

入場の列はそれほど長くなく、スポンサーでの入場ということも相まってすんなり入場することができました。

また、受付のためのチケットは事前に iPhone のウォレットに登録してあったため、入場はそちらの提示ですみました。

会場

会場に入って受付に向かうまでの階段です。

受付周辺の様子です。
たくさんの人で賑わっています。
ノベルティ配布に並ぶ列がありそこに並んだのですが、受付の列より断然長かったです^^;

こちらはセッション会場の様子です。

ブースエリアの様子。セッションの合間は混雑しますが、セッション中は比較的空いています。

Keynote セッション

1日目のセッションは Matz さんの Keynote から始まりました。
Ruby 30年の歴史を振り返るという内容で、Ruby 歴の浅い自分たちにとってはどのエピソードも興味深いものでした。

DIGGLE ブースの様子

こちらは Keynote セッション後、ブースの準備が終わって気合万全!の図。

DIGGLE のブースでは、ruby.wasm を使って作成した弾幕シューティングゲームをプレイできます。
このゲームの制作過程については前回のブログで紹介しています。

diggle.engineer

かなりの鬼難易度でゲームオーバーが続出する中、ついに1名クリアした方が現れました^^

他社様のブースの様子

ブース対応の間に他の企業様のブースにいくつかお邪魔させていただきました。

こちらは食べチョクさんのブースで、長野県名産のリンゴのノベルティ(?)をいただきました。 写真左の方は生産者の方とのことでリアルに産直を感じれた素敵なブースでした。

こちらはマネーフォワードさんのブースで、仕事に大切にしていることアンケートをされていました。みなさんはどれを選びますか??
他にもコードレビュー募集コーナーなど面白い企画をされていて、ブース展示側としてとても勉強になりました。

ご飯

お昼ご飯は会場に用意された山賊焼弁当をいただきました。
左手に写っているのは cookpad さんが配られていたリンゴジュースです(甘くて美味しかったです^^)。

ちなみに前日の夜はホテルの食堂でご飯を食べたのですが、松本のお野菜がどれも美味しく、特にトマトが絶品でした。
みなさん、機会があれば松本のトマト食べてみてください。

おわりに

以上、RubyKaigi 2023 の雰囲気をお届けしました。

明日以降来場される皆様のお役に立てば幸いです。

We're hiring!

私たちと一緒に開発してくれるメンバーを募集しています。少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

【RubyKaigi 2023】スポンサーに当選したので弾幕シューティングゲームを作ってみた

こんにちは。DIGGLEエンジニアリングマネージャーのzakkyです。発売以来スプラトゥーン3のフェスに全敗しているので「zakkyと逆張りすれば勝てる説」が立証されつつあります。

はじめに

早速本題です。

弊社では、今年RubyKaigi 2023Platinum Sponsorsに初めて応募しまして、そして無事に当選することができました。

弊社以外のRubyKaigi 2023スポンサーには、本当に有名な企業が並んでおりまして、そんな有名企業に挟まれる初参加企業ということで、「何かかまさなきゃ! 何か目をひくものを作らなくちゃ!」という事で、弾幕シューティングゲームを作りましたのでご紹介させていただきます。

ちなみに、ruby.wasmに関する調査~実装完了までを週末の空き時間(10hくらい)で済ませましたので、結構ハードルは低いのではないかと思います。

なんで弾幕シューティング?

RubyKaigiへの出展ということで、「Rubyに関する出し物を作ろう!」ということで、色々とメンバーと考えていたのですが、その中で、Ruby 3.2からWASIベースのWebAssemblyサポート(以降ruby.wasm)が行われたので、ruby.wasmを使って何か作れないか。という話が出ました。

作成物の候補としては・・・

  • ゲーム
    • シンプルに目をひきそう
  • アニメーション動画
    • CMっぽいものをアニメーションとして作る

上記が挙がったのですが、「実際触れるものが良いだろう」ということでゲームを作ることになりました。

・・・で、弾幕シューティングに決めた理由ですが、「ノリで実装してたらいつのまにか完成してた ruby.wasmで実装した場合、実際どれくらいのパフォーマンスで動くのかを視覚的に見やすく・面白く表現する方法として考えました」となります。

ruby.wasmってなに?

「そもそもruby.wasmって何?」という方に向けた説明なのですが、一言で言うと、「ブラウザ上でRubyを動かす仕組み」となります。

今までJavaScriptで書いていた部分をRubyを使って書けるようになったので、Railsでerbやhamlを使って開発しつつ、インタラクションなどのDOM操作周りをruby.wasmで実装すれば、「JavaScriptを一切使わずに一気通貫な実装が可能になった」とも言えます。

どうやって実装するの?

ruby.wasmで検索すると色々と出てきますので、詳しくはそちらを参考にしていただくとして、ruby-head-wasm-wasiを読込むだけでOKです。

 <script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/browser.script.iife.js"></script>

rbファイルとして切り出し

そこそこ大きいコードを書こうとすると、ファイルの分割が必要になってきますが、JavaScriptファイルの読込みのように、scriptタグで書いてあげればOKです。(type="text/ruby"という書き方、新鮮ですね!

<script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/browser.script.iife.js"></script>
<script type="text/ruby" src="ruby/game/point.rb"></script>
<script type="text/ruby" src="ruby/game/box.rb"></script>

rbファイルの読込みは上記で完了しているので当たり前ではありますが、ファイル間(=クラス間)のアクセスに関して、requireなどの記述は不要となります。

※ただし、コードの読込むrbファイルの順番には気を付ける必要があります。

class Box < Point # requireをすることなくBoxクラスからPointクラスを参照できる
  class Area
    attr_reader :top, :right, :bottom, :left
    def initialize(x, y, size_x, size_y)
      @left = x
      @top = y
      @right = x + size_x
      @bottom = y + size_y
    end

    # NOTE: シンプルに現在位置同士で衝突を判定し、移動経路上での衝突は見ない
    def hit?(area)
      left <= area.right && right >= area.left && top <= area.bottom && bottom >= area.top
    end
  end

  attr_writer :size
  attr_reader :size
  def initialize(x, y, size_x, size_y)
    super(x, y)
    @size = Point.new(size_x, size_y)
  end

  (省略)
end

初期セットアップ周りの覚書

ローカルで検証をする際に何も考えずにrbファイルを切り出すとCORSエラーになる

ruby.wasmに限らず遭遇する問題ですが、ローカルファイルをそのまま開くとrbファイルがCORSエラーとなって読込めない問題が発生しました。

何かしらの方法で http://localhost/でアクセスできるようにさえしてあげれば良いので、今回はnpmのreloadを使ってローカルでの動作検証を行いました。(他にもgemのhttpdやnpmのhttp-serverなども試してみましたが、どこかでキャッシュするらしく、うまく動かないので不採用にしました)

修正したrbファイルが更新されない

上記のreloadを使っても、まだキャッシュされてしまうようで、rbファイルを修正&ブラウザをリロードしても上手く更新されない問題がありました。 結論としては、reloadを使用したうえで、更にmetaタグの指定が必要でした。

  <head>
    <title>DIGGLE rubykaigi 2023</title>
    <link rel="icon" href="favicon.ico" />
    <meta charset="utf-8" />
    <meta http-equiv="Pragma" content="no-cache" /> <!-- ここの記述が必要 -->
    <meta http-equiv="Cache-Control" content="no-cache" /> <!-- ここの記述が必要 -->
  </head>

実装まわりの覚書

JavaScriptに絡むパラメータや関数の呼び出し方法

Rubyには、他言語と違いメソッド呼び出しの末尾の()が不要となる仕様があります。そのため、パラメータを参照したい場合、[:parameter]として呼び出す必要があります。

    @canvas = JS.global[:document].querySelector('#canvas')
    @context = canvas.getContext("2d")

    body = JS.global[:document].querySelector('body')
    @display_size = Point.new(body[:clientWidth].to_i, body[:clientHeight].to_i)

上記ですと、documentはパラメータなので、[:document]として取得し、querySelectorは関数なので、JS.global[:document].querySelector('#canvas')という記述になります。ここは、普段JavaScriptで書くお作法とは結構違うので最初は戸惑いました。

型変換が面倒

JavaScriptから取得したオブジェクトは、そのまま文字列や数字として使用することができません。

例えば、下記のようなコードを書くとエラーが発生します。

button[:textContent] = button[:textContent] + "_suffix"

型変換をしない場合にはエラーが発生

そのため、下記のようにto_sやto_iなどで変換する必要があります。

button[:textContent] = button[:textContent].to_s + "_suffix"

同じく、JavaScript側へ想定外の型で渡そうとするとエラーになります。

button[:width] = 100.0

想定外の型でJavaScriptへ渡すとエラー

このエラーに関してはエラー内容からは原因が類推できないため、かなり悩まされました。今回のゲームの特性上、floatで弾の移動ベクトルを持たせていたのですが、どこか1か所でもto_iを書き漏れていると↑のエラーが発生してしまい、泣きそうになりました。

また、nullやundefinedがnilとは違う扱いになっており、ruby.wasmから別途定義&提供されているJS::NullやJS::Undefinedと比較する必要がある点も注意が必要です。*1*2

if tbody[:firstChild]) == JS::Null
  puts 'null'
end

ActiveSupportが使えない

当たり前ではありますが、ActiveSupportは使えません。普段Railsを使っていてActiveSupportのことを意識せず恩恵を受けている私は「delegate_missing_toってActiveSupportに入ってたのか~」などなど、改めてActiveSupportのありがたさを嚙み締めながら実装することになりました。

実際のゲーム画面

ということで、スプラトゥーンで負けが込んで気分転換をしているうちにこんな感じのゲームになりました。実際に触りたい方は是非ブースまでお越しください!

実際のゲーム画面

まとめ

ruby.wasmを使ってみましたが、結構簡単に実装できるので、Railsを使ってお手軽に小さくサービスを作る際には重宝するかもしれません。ただ、性能はそこまで良くないので、注意が必要となります。(画面左上にFPSを出しているのですが、画面に表示している弾数が多くなるとFPSがガクッと下がります)

宣伝

最後に宣伝なのですが、RubyKaigi 2023ではスポンサーブースを出しておりますので、ご来場予定の方がいらっしゃいましたら、是非お立ち寄りください。(もちろん私も参加します)

ブースでは、今回ご紹介する弾幕シューティングゲームのURL配布や、撃破タイムのランキング掲示などを行おうと思っておりますので、RubyKaigi 2023に参加する予定の方は、一度お立ち寄りいただけますと幸いです。

追記

ソースコードの公開はRubyKaigi終了後を予定しておりましたが、想像以上に希望される方がいらっしゃいましたので、当初予定よりかなり早いですが公開します。

github.com

We're hiring!

こんな私と一緒に開発してくれるメンバーを募集しています。少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

【Ruby 3.2 新機能】るりまの Data クラスリファレンス作成 PR をレビューした

こんにちは。にくといいます。github だと niku です。2022 年 12 月に DIGGLE に入社しました。

さて Ruby 3.2 からは Data というクラスが使えるようになりました。 Ruby の日本語リファレンスるりま*1(以下「るりま」)にも class Data (Ruby 3.2 リファレンスマニュアル) というページができていますね。すごい!

「るりま」は GitHub の Pull Request を用いてメンテナンスされています。今回 Data のリファレンスも

github.com

で作られた PR が元になっています。 この PR のレビュアーとして私も参加したので、その時の様子も交えて「るりま」のドキュメントのメンテナンスの雰囲気をお伝えしたいと思います。

Data クラス

この記事を楽しむために、まずは Data クラスというのがどういうものなのかを大まかに説明します。 Data クラスは、Strcut クラスに似たインターフェースを持っています。

~ ()
irb
irb(main):001:0> SMeasure = Struct.new(:amount, :unit)
=> SMeasure
irb(main):002:0> DMeasure = Data.define(:amount, :unit)
=> DMeasure
irb(main):003:0> s_one_km = SMeasure.new(1, :km)
=> #<struct SMeasure amount=1, unit=:km>
irb(main):004:0> d_one_km = DMeasure.new(1, :km)
=> #<data DMeasure amount=1, unit=:km>

一方で Struct クラスは値を書き換えることができるのに対し、Data クラスは値を書き換えることができません。 Struct クラスは構造体クラスと呼ばれる概念を表すものに対し、Data クラスは値オブジェクトと呼ばれる概念を表すもので、値オブジェクトというものの性質の一つに「変わらない(不変)」があるためです。

irb(main):005:0> s_one_km.amount = 2
=> 2
irb(main):006:0> s_one_km
=> #<struct SMeasure amount=2, unit=:km>
irb(main):007:0> d_one_km.amount = 2
(irb):7:in `<main>': undefined method `amount=' for #<data DMeasure amount=1, unit=:km> (NoMethodError)
  from /Users/niku/.asdf/installs/ruby/3.2.1/lib/ruby/gems/3.2.0/gems/irb-1.6.2/exe/irb:11:in `<top (required)>'
    from /Users/niku/.asdf/installs/ruby/3.2.1/bin/irb:25:in `load'
  from /Users/niku/.asdf/installs/ruby/3.2.1/bin/irb:25:in `<main>'

ちょっと横道にそれますが「プログラムでできないことが増える」ことを導入するのに、それが嬉しい理由というのはあるんでしょうかね?私は以下の記事で覚えたことを大事にしていて「できないことが増える」のは便利なことだと感じています。

上が力が強く、下へ行くほど力が弱くなります。力が強いと何でもできてしまうので、コードの意図が不明瞭となり、また間違いが入り込みやすくなります。力が弱いとできることは限られるのでコードの意図は明確となり、間違いが入りにくくなります。 「目的に合う一番力の弱い手段を使う」のがよいプログラムを書くための大原則です。たとえば、草を刈るのにチェーンソーは使うべきではありません。もちろん草も切れますが、大切な植木も傷つけてしまうかもしれませんから。

Haskellの文法(再帰編) - あどけない話

「るりま」 Data クラス PR との出会い

私は Ruby 3.0 から実験的に導入された並列処理機能 Ractor https://techlife.cookpad.com/entry/2020/12/26/131858 に興味を持っていて、Ractor 間のデータのやりとりには値の変わらないオブジェクトがあると便利であることを、Erlang や Elixir といった他のプログラミング言語を通じて知っていました。Data 導入の issue https://bugs.ruby-lang.org/issues/16122 か PR https://github.com/ruby/ruby/pull/6353 をみてから、Ruby に導入できるのを心待ちにしていました。

Ruby の英語リファレンス である ruby-doc には Ruby 3.2 リリース (2022/12/25) 時点から Data クラスのリファレンス https://ruby-doc.org/3.2.0/Data.html がありました。一方で、日本語のリファレンスはその時点では存在していませんでした。kakutani さんが Ruby3.2.0 の新機能のほうの Data のリファレンスマニュアルがあってほしいという issue を rurema に立てた https://github.com/rurema/doctree/issues/2764 のをみて、私は「るりま」でリファレンスを書いたことがなかったので、そのうちやってみようと思っていました。

2023/1/7 に時間がとれたので、Data クラスのリファレンスを作ろうとしました。念のため「るりま」の PR 一覧を眺めてみると、なんと前日の 2023/1/6 に Data クラスを「るりま」に載せるための PR が kyanagi さんによって作られていました。そこで PR を書くのではなく、PR のレビューで貢献することとしました。

そのときの私の喜びのコメントです

欲しいなあと思っていたので PR があって嬉しく思いました✨ぜひマージされてほしい。 通りすがりですが、助けになればと思い一通りの変更をみました。コメントを残させてください。

https://github.com/rurema/doctree/pull/2777#pullrequestreview-1239634811

PR レビューの様子

結局、他の方のレビューも含めて、PR が作られたのは 2023/1/7 マージされたのは 2023/2/9 と Data クラスの記事作成には約一ヶ月かかったようです。私がレビューしたのは 2 回くらい、軽微なものを含めると数回といったところで、PR の出だしから完成度が高かったので、やりとりの回数はそれほど多くなく完成となりました。

「構造体」という言葉は Struct とは強く結びついていますが、Data とはそうでもなさそうに感じます。 別の言い回しだとさらに読者が理解しやすかったりしないでしょうか。

https://github.com/rurema/doctree/pull/2777#discussion_r1063972590

API は Struct に似ているので、Struct のリファレンスを元にしてくださったのかなと想像しています。Struct には構造体という言葉が似合うのですが、私は Data のリファレンスには似合わないと思ったので、コメントで問い掛けていました。

この後に記載されているサンプルは全てエラーになっているようです。また私の手元にある Ruby3.2 で試しても同じ結果になりました

https://github.com/rurema/doctree/pull/2777#discussion_r1063974100

例示されているサンプルを実行してみても、エラーになるので、どういうことかよくわからず相談したコメントです。結果としてこのふるまいは正しく、狙いのある API なのでした。どういうことなのかは完成系の https://docs.ruby-lang.org/ja/3.2/class/Data.html#S_--5B--5D 「new に渡す引数の数がメンバの数より多い場合は new でエラーになります。new に渡す引数の数がメンバの数より少ない場合は new ではエラーにならず、そのまま initialize に渡されます。ユーザが initialize のオーバーライドを通して、少ない引数のときの適切な振舞いを実装可能とするためです。」のあたりをご覧ください。例も含めて kyanagi さんがたいへん伝わりやすくしてくれました。

https://docs.ruby-lang.org/en/3.2/Data.html#method-c-define だと、るりまの定義とは異なり(略)こちらは単に記法の違いによるものになるでしょうか?

https://docs.ruby-lang.org/en/3.2/Data.html#method-c-define にある、 引数を取らずに Data.define() と定義して、パターンマッチングに使う便利なやり方はるりまにも記載があると嬉しそうです。

https://github.com/rurema/doctree/pull/2777#discussion_r1063975629

先行して実装されていた ruby-doc の Data と照らしあわせながらコメントしていました。この質問の回答で知ったのですが kyanagi さんは私が参照していた ruby-doc のリファレンス訂正 PR https://github.com/ruby/ruby/pull/7038 も作られていました。素敵です。

細かいですが、Object#hash の定義をみると、hash が必ず満たさなければいけないのは「メンバの値を eql? で比較して同じなら、hash の値も同じでなければならない」ということで、「メンバの値を eql? で比較して異なるときに hash の値が異ならなければならない」ではなさそうに思います。

https://github.com/rurema/doctree/pull/2777#discussion_r1063978482

Object#hashObject#eql? は異なるメソッドですけれどいい感じに協調するように実装しないと望ましい動作が得られない性質を持っているので、リファレンスの表現も(書くなら)気をつかいますよね。これは Ruby 特有ではなくて Java などでもそういうものですよね。結局この表現は省くことになりました。実装詳細に踏み込まないのは穏当な判断かなと感じました。 参考までに Java の場合のドキュメントも貼っておきます

常に、このオブジェクトに対するequalsの比較で使用される情報が変更されていなければ、hashCodeメソッドは常に同じ整数を返す必要があります

https://docs.oracle.com/javase/jp/17/docs/api/java.base/java/lang/Object.html#hashCode()

「るりま」 PR レビューの感想

現状「るりま」に深くかかわっているわけではない第三者の私がふらっときてレビューさせてもらっても特に困ることはありませんでした。「るりま」をメンテされているみなさん、kyanagi さん、親切にしてくださってありがとうございます。

私はレビューするときに強く確信を持っている場合をのぞくと、だいたい「私には状況がaのように見えていて、その場合私ならxと書くところyと書かれていて違いを感じました。ここの意図を詳しく教えていただけますか?」といった問い掛け形式でレビューしていることを再発見しました。PR の書き手が見ている状況と読み手が認知している状況が異なることはよくあるので、認知している状況を自ら開示するのは、すれ違いによってやりとりがややこしくなるのを防ぐために重要ですね。

DIGGLE は RubyKaigi2023 スポンサーになりました

DIGGLE は今年開催される RubyKaigi2023 の Platinum スポンサー になりました。よろしくおねがいします! RubyKaigi 現地でブースも出展し、私も居ますので、遊びにいらしてください。 この記事のことや、Ractor や、よいレビュー、その他について雑談しましょう〜

*1:たぶん びー ふぁれんす にゅあるの略