Redash を少し便利に使う Chrome Extensions を書いた

エンジニアの佐野です。菅原が Redash について言及したのでそれに乗っかって自分も簡単な記事を書きます。

tech.kanmu.co.jp

まず Redash の基本的な使い方としては次の通りです。

  1. https://your-redash-domain/queries/new にアクセスする。
  2. データソース *1 を選択してクエリを書く。
  3. クエリを実行する。

ここで2のデータソースを選択する際なのですが、カンムは Redash を多用しているため大量のデータソースが存在し、都度用途に応じて選択する必要があります。次の画像が存在するデータソース一覧です。

例えば画像中でマスクしていない BigQuery (DWH) と readonly(RR) なのですが以下のような用途・特徴となっています。

データソース名 接続先 特徴 用途 主な利用者
BigQuery (DWH) Google BigQuery リアルタイム性はない *2 が、アナリティクスを含めバンドルカードのほぼすべてのデータが入っている ヘビーなクエリを投げて分析したいケース、カンムのほぼすべての DB を JOIN したいケース データチーム、マーケチーム
readonly(RR) バンドルカードのメインの DB *3 リアルタイムのデータにアクセスできる リアルタイム性が必要なデータを参照したいケース、メイン DB のデータのみで完結するような分析用途 バンドルカードのエンジニア

このデータソースは上の画像の通りプルダウンメニューから選択することになっているのですが、データソース名の辞書順でソートされて表示される仕様になっています。Redash のソースコードでいうと以下の箇所です。クエリ新規作成画面にアクセスしたときに Redash のサーバ側でソート済のデータソースのリストが画面に返されてそれが表示されるようになっています。

github.com

ここでクエリを書く際に面倒なことがあります。ある人は BigQuery をよく使う、またある人は readonly(RR) をよく使う...といったように人によってよく使うデータソースが異なります。加えて Redash 自体にユーザごとにデフォルトのデータソースを設定するような機能はありません。辞書順でソートされるのであればデータソース名に 1, 2, 3_... のようなプレフィックスをつけて頻繁に使われるものが上にくるようにすればいいと思うかもしれませんが、それは人によって異なるのでそれでは解決しません。

そこでこの問題を解決するために簡単な Chrome Extensions を書きました。Extension の設定画面にデフォルトのデータソースを設定すると、クエリの新規作成画面に飛んだときにそれが自動で選択状態になるといったものです。

github.com

私自身は日々のデータ分析や調査で頻繁に Redash からクエリします。そのときに毎度毎度自分がよく使う readonly(RR) を選択するのがかなり面倒でした。この Extension によって少なくとも自分自身のペインは解消されました。

JavaScript のようなフロントエンドの技術に関してはかなり素人なのでとりあえず動くものを書いたというのがこの Extension です。TypeScript への書き直しであったり PR を投げてもらえるととてもありがたいです。ちなみにそんなヨカタの自分がこの Extension の開発でちょっとだけ苦労した点としては、プルダウンメニューを示す要素が最初から画面にあるわけではなかったという点でしょうか...。Redash のクエリ新規作成画面は動的に描画されるようです。最初は「プルダウンメニューでターゲットとなるデータソース名を選択済にしてしまうコードを書けばさくっとできるだろう...そんくらいなら俺でも秒で書けるぞ。」と思ったのですがそうもいかなくて、 DOM の変化を監視してプルダウンメニューが表れたらそれをクリックさせる、というようにしました。これももっとスマートなやりかたがあったらご指摘いただけると幸いです。

今日は簡単ですが以上です。

おわり

*1:接続先の DB を表す Redash 上の概念。スプレッドシートCSV ファイルをデータソースとすることもできる。

*2:日次や月次で BigQuery にデータを同期している。

*3:正体は Amazon Aurora PostgreSQL の reader ノード。

ジョブキューのパフォーマンス問題の経験から学んだ PostgreSQL の内部理解

ソフトウェアエンジニアの新田です。

これは カンム Advent Calendar 2024 の8日目の記事です。昨日はフロントエンドチームのEMの佐藤さんの記事 チームが成長するたびに変わっていった朝会の話 - カンムテックブログ でした。 私は現在プラットフォームチームに所属しており、おなじようにチームで朝会をしているので非常に参考になりました。積極的にパクっていこうと思います。

さて、カンムではプロダクトのデータベースに Amazon Aurora PostgreSQL を採用しています。

ここで少し特徴的なのは、このデータベースは、アプリケーションのデータ管理だけでなく、ジョブキューの役割も担っています。このジョブキューには、 Que の Go 実装である kanmu/qg (以下 qg )を使っています。

qg を用いることで、トランザクション内でジョブをエンキューできるようになり、我々が主に開発しているカード決済などのプロダクトにおいて欠かせない、複数のサービス間のデータの整合性を効率的に担保しています。そのため、トランザクショナルなジョブキューは我々プロダクトの重要な技術基盤になっています。

この qg について説明した資料には以下のようなものがあります。

今回の記事は、この qg を用いたジョブキューの運用中に直面した、「デキュー時のパフォーマンス劣化」という問題についてお話しします。また、問題を正しく理解するために学んだ、 PostgreSQL の MVCC や Snapshot といった内部の仕組みについてもお話しします。

システムの概要

システムアーキテクチャの概要

今回問題が起きたサービスのデータベースとワークロードについて説明します。

データベースは Aurora クラスタです。クラスタは、 writer インスタンス (読み書き) と reader インスタンス (読み取り専用) で構成されています。このデータベースを使った複数のワークロードが動作しています。

  1. API ワークロード (以下 api)

    モバイルアプリや外部システムからのリクエストを受け付け、必要な処理を実行します。

  2. Worker ワークロード (以下 worker)

    非同期ジョブを処理する専用のワークロードです。これにより、リクエスト処理とジョブ実行を分離し、効率的なリソース管理を実現しています。

ジョブは que_jobs テーブルで管理されており、ジョブのエンキューは que_jobs テーブルへのインサートになります。この操作は api と worker の両方から行われます。たとえば、api は外部リクエストを受けた際にジョブをエンキューします。 worker は他のジョブをエンキューしたり、エラーになったジョブをリトライするために再度エンキューしたりします。

ジョブのデキューは worker が実行する必要のあるジョブのレコードを que_jobs テーブルから探します。このとき、 worker 同士が同じジョブを取得することを防ぐためにロックを獲得します。ここで、他の worker のクエリをブロックしないようロックを獲得する方法として pg_advisory_lock と Recursive CTE を組み合わせたアルゴリズムが用いられています。

worker は、ジョブ処理を正常に完了すると、 que_jobs テーブルから該当のレコードを削除します。

qg のロック獲得SQL

以下は、 qg がジョブをデキューする際に、ロックを獲得しながら1件のジョブを取得するための SQL です。

WITH RECURSIVE job AS (
  SELECT (j).*, pg_try_advisory_lock((j).job_id) AS locked
  FROM (
    SELECT j
    FROM que_jobs AS j
    WHERE queue = $1::text
    AND run_at <= now()
    ORDER BY priority, run_at, job_id
    LIMIT 1
  ) AS t1
  UNION ALL (
    SELECT (j).*, pg_try_advisory_lock((j).job_id) AS locked
    FROM (
      SELECT (
        SELECT j
        FROM que_jobs AS j
        WHERE queue = $1::text
        AND run_at <= now()
        AND (priority, run_at, job_id) > (job.priority, job.run_at, job.job_id)
        ORDER BY priority, run_at, job_id
        LIMIT 1
      ) AS j
      FROM job
      WHERE NOT job.locked
      LIMIT 1
    ) AS t1
  )
)
SELECT queue, priority, run_at, job_id, job_class, args, error_count
FROM job
WHERE locked
LIMIT 1

このSQLがどのように動作するかをステップごとに説明します。

まず全体像として WITH RECURSIVE job AS ... とあるように Recursive CTE を構成しています。 Recursive CTE は、最初に評価される初期ステップと、以降繰り返し評価される再帰ステップを UNION または UNION ALL オペレータで接続して構成されます。

それでは各部分の説明をしていきます。

初期ステップ

  SELECT (j).*, pg_try_advisory_lock((j).job_id) AS locked
  FROM (
    SELECT j
    FROM que_jobs AS j
    WHERE queue = $1::text
    AND run_at <= now()
    ORDER BY priority, run_at, job_id
    LIMIT 1
  ) AS t1

que_jobs テーブルから条件を満たすジョブを1件取得します。

このときに取得できたジョブの job_id に基づいて pg_try_advisory_lock を実行し、ロックを試みます。 pg_try_advisory_lock はロックを獲得できた場合は true を返し、すでに他のセッションにロックされている場合は false を返します。

再帰ステップ

  UNION ALL (
    SELECT (j).*, pg_try_advisory_lock((j).job_id) AS locked
    FROM (
      SELECT (
        SELECT j
        FROM que_jobs AS j
        WHERE queue = $1::text
        AND run_at <= now()
        AND (priority, run_at, job_id) > (job.priority, job.run_at, job.job_id)
        ORDER BY priority, run_at, job_id
        LIMIT 1
      ) AS j
      FROM job
      WHERE NOT job.locked
      LIMIT 1
    ) AS t1
  )

初期ステップでロックを獲得できなかった場合 (WHERE NOT job.locked )、次のジョブを探索してロックを試みます。このときに初期ステップで取得したジョブよりも後に位置するジョブを探索範囲とし、優先度の高い (qg では priory の値が低いほど優先度は高い) ジョブを1件取得します。条件に合うジョブがあれば pg_try_advisory_lock でロックを試みます。

結果の選択

SELECT queue, priority, run_at, job_id, job_class, args, error_count
FROM job
WHERE locked
LIMIT 1

再起的に探索したジョブの中から、ロックに成功した1件を取得します。

このようにして、 pg_try_advisory_lock によるロック試行を行うことで、複数の worker が同じジョブを処理することを防いでいます。

発生した問題について

qg のパフォーマンス悪化

日々の運用では、未処理ジョブが滞留せずに処理されているかを DataDog でモニタリングしています。ある日、大量のジョブが滞留したまま消化できていない状況が発生し、アラートが鳴りました。

調査したところ、大量のジョブ自体は毎日ほぼ同じ時間にエンキューされるもので、普段の投入量と大きな変化はありません。しかし、普段と比較して worker がジョブのデキューして処理する時間が著しく長くなっていました。

worker のログを確認したところ、ジョブ取得処理の時間 (lock_time) が通常よりも長くなっていることが判明しました。さらに、 DataDog で監視している dead tuples のメトリクス (postgresql.dead_rows) を確認したところ、 que_jobs テーブルの dead tuples が増え続けていることがわかりました。

とある時間帯から増え続ける dead tuples

dead tuples とは、 PostgreSQL において更新や削除操作によって古くなり不要となったレコードです(内部的には「タプル」と呼ばれます)。これらのタプルは即座に削除されるわけではなく、 VACUUM によってクリーンアップされます。

この dead tuples の増加が原因で、 qg のパフォーマンスが劣化することは既知の問題として把握していました。 詳細については以下の文献が非常に参考になりますが、本記事でも後ほど PostgreSQL の内部構造を踏まえて解説します。

原因の特定

さて、この状況を改善するには、 VACUUM による dead tuples の回収が必要です。本来、 autovacuum がこれを自動的に処理するはずですが、どうやら適切に機能していないようでした。

ここで、 pg_stat_acitivity を用いてアクティブなクエリを確認したところ、意図していないクエリが発行されていることが判明しました。 (後になって気づいたのですが、このクエリは Performance Insights でも目立つ形で表示されていました。当時は余裕がなく見落としていましたが、こうした状況ではどこを確認すべきかを形式化・習慣化する必要があると感じました…)

意図していないのは、そのクエリのユーザ (postgreSQL の user) です。それは社内で利用している BI ツール Redash 用に作成されたユーザでした。本来、 Redash からは reader インスタンスに向けてクエリを実行することを意図していましたが、過去に筆者が Aurora データベースを Redash に新しいデータソースとして登録する際に、誤ってクラスタエンドポイント(writer インスタンスを指す)を参照してしまっていたことが原因でした。正しくは reader エンドポイント (reader インスタンスを指す) を設定する必要があります。この設定ミスを修正し、 reader エンドポイントを指すように変更しました。

該当のクエリは、 Redash クライアントとの接続がすでに切れているにもかかわらず、 pg_stat_activity で観測され続けていました。このクエリの wait_event は io:BufFileWrite となっていました。このクエリは、大量のテーブルスキャンと CTE を組み合わせる、まさにデータ分析のためのアドホックなクエリで、非常に重い処理でした。

さらに、あろうことか、このクエリのユーザの設定には statement_timeout などのクエリタイムアウトの設定をしていなかったため、クライアント接続が切れても PostgreSQL のバックエンドプロセス自体はクエリの処理を続行していました。

問題の解消までの流れ

以下は、問題発生時に手動で実行した VACUUM のログです。 (VACUUM VERBOSE que_jobs というSQLを実行):

INFO:  vacuuming "service_x.que_jobs"
INFO:  "que_jobs": found 0 removable, 768195 nonremovable row versions in 15623 out of 15623 pages
DETAIL:  679473 dead row versions cannot be removed yet, oldest xmin: 1226078887
There were 1005 unused item identifiers.
Skipped 0 pages due to buffer pins, 0 frozen pages.
0 pages are entirely empty.
CPU: user: 0.08 s, system: 0.00 s, elapsed: 0.08 s.

0 removable とは削除可能な(不要とみなせる)タプルは0件であることを表しています。これは VACUUM で削除できる dead tuples が存在しないことを示しています。

768195 nonremovable row versions とは削除できないタプルが 768,195 件存在することを表しています。多くの行が nonremovable である原因は、これらがまだ参照されているためです (後述する oldest xmin と関連します)。 in 15623 out of 15623 pages はこのテーブルの全ページ(15,623ページ)がスキャンされたことを示します。

679473 dead row versions cannot be removed yet は、 679,473件の dead tuples が削除不能な状態にあります。これらタプルは他のクエリが参照できる状態にあるため削除できないのです。

そして oldest xmin: 1226078887 が、アクティブなクエリのなかで、最も古い xmin のスナップショットを保持するクエリが保持する xmin を示しています。

このログから読み取れることは以下です。

  1. 大量の dead tuples が削除不能に陥ってる

    679,473件の dead tuples が削除できず、 que_jobs テーブルの肥大化を引き起こしています。

  2. VACUUM が効いていない

    削除可能なタプルが 0 件であるため、 VACUUM を実行しても que_jobs の dead tuples は回収されず、 qg のパフォーマンスは改善されないままです。

このログの oldest xmin が指し示す xmin 1226078887 を保持するクエリこそ、まさに pg_stat_activity で見つけていたクエリでした。これは pg_stat_activity のカラム backend_xmin の値も 1226078887 と一致していました。

VACUUM を阻むクエリであることを確信したので、 pg_terminate_backend を実行して当該クエリのプロセスを強制的に終了させました。プロセス終了を確認後、再度 VACUUM を手動で実行したところ、今度は大量に dead tuples をクリーンアップしてくれました。

一気に回収される dead tuples

そして、 worker のデキューのパフォーマンスは無事正常状態に戻りました。

ここまでが当時発生した問題の原因の特定から解消までのオペレーションの流れになります。

que_jobs テーブルとは全く関係のないクエリであっても、おなじ DB インスタンス上で長時間実行されることで、 que_jobs テーブルの dead tuples を回収できずにテーブルを肥大化させ続け、結果的に qg のデキューパフォーマンスや DB の負荷上昇につながってしまっていました。

再発防止のための対策

この反省を踏まえて、以下の対策を進めています。

  1. Redash で writer エンドポイントを指定しても writer インスタンスに接続できないようにする。

    今回の問題は Redash が writer インスタンスに向けてしまった設定ミスから始まりました。

    そこで、プラットフォームチームの菅原さんが Redash コンテナの /etc/hosts を書き換えるアイデアで対応してくれました。 writer エンドポイントのホスト名に対応するIPアドレスをテスト用の無効なIPアドレス (TEST-NET-1) を指定することで、誤って writer エンドポイントを設定したとしても実際に接続することはできないようになります。

  2. PostgreSQL ユーザの statement_timeout を設定する。

    今回、クエリにタイムアウト設定がなかったため、長時間クエリが残り続けました。 PostgreSQL の statement_timeout を適切に設定し、長時間クエリを自動的に停止するようにして再発を防ぎます。

  3. デキューアルゴリズムの改善

    上述した文献にも紹介されるように、現在の qg では qg_advisory_lock と Recursive CTE を使用していますが、これらは dead tuples に影響を受けやすい設計です。

    たとえば比較的新しい PostgreSQL ジョブキュー実装である https://github.com/riverqueue/river では、より新しいロック獲得方法の SELECT FOR UPDATE SKIP LOCKED を利用するなどといった効率的な方法が採用されています。

    今後のサービスの拡大に伴い扱うジョブの増加に耐えられるよう、 qg の改善もしくは river のような技術の採用を検討していきたいと考えています。

PostgreSQL 内部の仕組み

今回取り上げた問題は、 実行時間の長いクエリによって VACUUM が効果を発揮しないPostgreSQL データベース運用の典型的なケースです。恥ずかしながら、今回の問題をきっかけに、自分の知識不足を痛感しました。

問題の根本原因を深く理解するため、改めて PostgreSQL の内部構造に目を向け、 MVCC (Multi-Version Concurrency Control)Snapshot といった仕組みを基礎から見直しました。それに加えて、 VACUUM や dead tuples が何を意味しているのか、我々のプロダクトの運用に対しどのように影響を与えるのかを理解することに努めました。

オススメの資料

この記事でもこのあと説明しますが、以下の資料は PostgreSQLトランザクション、MVCC、スナップショットなどをわかりやすく丁寧に説明しています。ぜひ読んでいただくことをおすすめします。

MVCC を実現するタプルの xmin, xmax

PostgreSQL では一貫性を保ちながら複数トランザクションの平行処理を効率化する仕組みとして MVCC (Multi-Version Concurrency Control) を採用しています。

DELETE の SQL が実行されても、実際に削除対象であるタプルをその場で削除することはなく、削除マークを付け加えるだけです。 UPDATE の場合は、削除マークを付け加え、更新後の値を保持する新しいタプルを作成します。

とあるトランザクションが DELETE しても、そのトランザクションが COMMIT されるまでのあいだ、他の進行中のトランザクションからすればそのタプルはまだ使用可能である必要があるためです。

各タプルは、データそのものとメタデータ(タプルヘッダ)を持っています。このヘッダに格納されている xmin, xmax が、タプルの可視性を判定する基盤となっています。

  • INSERT 時にセットされるのが xmin です。
  • DELETE 時にセットされるのが xmax です。
  • UPDATE では、既存タプルの xmax に値をセットし、新しいタプルに xmin に値をセットします。

xmax が設定されているものこそが、更新や削除により不要となったタプル、 dead tuple です。

スナップショット

スナップショットは、クエリが「どのデータを可視とするか」を決定するためのデータ構造です。

スナップショットの実態は SnapshotData という構造体です (code)。いろんなメンバがありますが、今回取り上げたいのは xmin, xmax, 進行中のトランザクションの配列 xip です。

typedef struct SnapshotData
{
    // ...
    TransactionId xmin;
    TransactionId xmax;
    TransactionId *xip;
    // ...
} SnapshotData;

スナップショットのこれらのメンバは可視性判定時に以下のように振る舞います。

具体例を用いて説明します。以下のテーブル fruit に3つのタプルがあるとします。

name xmin xmax
apple 101 105
banana 102 null
cherry 104 null

ここで、進行中のトランザクションが2つあるとします。 (XID=104, XID=105)

このとき、 XID=103 のトランザクション上で以下のスナップショットが作成されたと考えます。

  • xmin=103
  • xmax=106
  • xip=[104, 105]

このスナップショット情報とタプルの情報を突き合わせることで、このスナップショットからのタプルの可視性を判定できます。

  • apple タプル (xmin=101, xmax=105)
    • xmin=101 : XID < 103 のため挿入は完了している。
    • xmax=105 : 進行中のトランザクションのため、削除はこのスナップショットからは見えない。
    • 結果 : 可視
  • banana タプル (xmin=102, xmax=nulll)
    • xmin=102 : XID < 103 のため挿入は完了している。
    • xmax=null : どのトランザクションからも削除されていない。
    • 結果 : 可視
  • cherry タプル (xmin=104, xmax=nulll)
    • xmin=104 : 進行中のトランザクションのため、挿入はこのスナップショットからは見えない。
    • 結果 : 不可視

このようにして、タプルのメタデータである xminxmax とスナップショットの xminxmax を用いた「可視性」の判定によって、 PostgreSQLトランザクションごとに一貫性のあるデータビューを提供していることがわかります。

スナップショット作成のタイミング

弊社の PostgreSQL データベースでは、トランザクション分離レベルを READ COMMITTED に設定しています。この場合、トランザクションではないクエリにおいても、クエリを実行する際にスナップショットを作成します

この説明が正しいか実際の PostgreSQLソースコードを確認してみます。

SQL を処理する exec_simple_query() の中では、すべてのクエリで GetTransactionSnapshot() が呼び出されます。この関数内では、現在のトランザクション分離レベルに応じて動作が異なります。

  • READ COMMITTED の場合
    • 各クエリで常に新しいスナップショットを作成するため、GetSnapshotData() が毎回呼び出されます。
  • REPEATABLE READ の場合
    • 最初のスナップショットを取得した後は、それをキャッシュして再利用します。

この仕組みによってトランザクションの分離レベルに応じたスナップショット管理が実現されていることがわかります。

https://github.com/postgres/postgres/blob/792b2c7e6d926e61e8ff3b33d3e22d7d74e7a437/src/backend/utils/time/snapmgr.c#L280

VACUUM が dead tuples を回収できなかった理由

Redash から実行されたクエリは、スナップショットをその時点で作成し (READ COMMITTED) 、それ以降に作成された que_jobs テーブルのタプルがたとえ DELETE で削除されていても、そのクエリからは可視であったためでした。

よって、 VACUUM は que_jobs テーブルの多くのタプルは Redash から実行されたクエリにとって可視であることを理由にタプルのクリーンアップをしない判断をとっていたのです。

qg のクエリパフォーマンスへの影響

タプルの xmin, xmax はタプルのヘッダ上に含まれています。つまり、タプルが live なのか dead なのかを確認するためにはタプルにデータアクセスする必要があります。インデックス自体にはタプルの可視性といった情報は保持されていません。

qg のロック獲得SQLを振り返ってみると、 WHERE 句で絞る条件には queue と run_at しか用いられていません。

        WHERE queue = $1::text
        AND run_at <= now()

そのため、それらの条件を満たすが既に dead であるレコードに対してもインデックスの走査対象となり、可視性の確認のために都度タプルをフェッチする非効率なことが行われているのです。

まとめ

以上、この記事では、 PostgreSQL を用いたジョブキュー qg の運用において発生したデキューパフォーマンス問題を題材に、問題解決の過程、および得られた学びについて共有させていただきました。 具体的には、 VACUUM が dead tuples を回収できなかった原因を MVCC やスナップショットの仕組みに基づいて解析し、 dead tuples の増加に伴いデキューパフォーマンスが劣化する原因を可視性の判定にタプルのフェッチが必要となるオーバーヘッドの観点から説明しました。

この問題を通じて、運用上のいろいろな教訓や学びを得ることができました。今後もプロダクトの成長とともにこのジョブキュー基盤の利用は多くなってくるので、改善を重ねて信頼性の高い基盤づくりをしていきたいと思います。 この記事がどなたかの役に立てば幸いです。

カンムではバックエンドエンジニアを募集しています。ご興味ある方はぜひご連絡ください!

team.kanmu.co.jp

チームが成長するたびに変わっていった朝会の話

こんにちは、カンムのフロントエンドチームでエンジニアリングマネージャーをしている佐藤です。

これは カンム Advent Calendar 2024 の7日目の記事です。昨日は always_allokay によるお世話になっているライブラリをちょっと見てみるシリーズ、shopspring/decimalの記事でした!

この記事ではチームで行っている朝会の様子や改善してきたことを紹介します。

背景

バンドルカードチームでは、今年の頭から各職能チーム(Unit)のメンバーが異なる目的を持ったチーム(いわゆるフィーチャーチーム)に属し、それぞれが独自のプロジェクトやタスクに取り組む形でプロダクト開発を進めています。フロントエンドチームメンバーもそれぞれがそれらのチームに属す形になっています。チーム数などは実際とは異なりますが、構成としては以下の図のような一般的なイメージのものです。進捗確認や細かい機能のアサインなどのやり取りはフィーチャーチーム内でスムーズに行われています。

目的

フィーチャーチームの誕生前から存在していたフロントエンドチームでの朝会では、各メンバーの「直近の仕事の予定や、今の困りごとを相談する」という目的で実施され、チームの知識の平均化や、気軽なコミュニケーションの場などとして一定の機能を果たしてきました。

他にも、朝会での何気ない一言が他のメンバーに引っかかり、潜在的な問題を早めに発見できる役割もあります。基本的にはフィーチャーチームでの各プロジェクトの仕様や進め方は各メンバーが決めていくという体制ですが、フロントエンド観点で最低限抑えておくべきものなどを広い視点で拾うことができる良い場だと感じています。

なお、ここまでわかりやすさのため「朝会」と書いていましたが、実施は午後の15時にしています。これは、午後の集中力が落ちてくるタイミングで気分転換を図るという狙いである時点からそうしています。様々なライフステージのメンバーがいるなかでも参加率も高く好評でした。

課題

概ね良い部分もありつつ、フロントエンドチームへの人数の増加や、それぞれのフィーチャーチームにおける中期的なプロジェクトが本格化したことで、議論の幅や深さが増してきました。
プロジェクトの状況や課題、それらの実装など共有するトピックが格段に増えたことで、単純に時間を大幅にオーバーすることが増えたり、あとの方に話すメンバーの持ち時間が足りなくなるという部分に課題感がありました。

そういったこともあり、何度か手法の見直しを行ってきたので少し紹介します。

試行錯誤の過程

とりあえず全部吐き出す

まずは各メンバーが実装から仕様まで様々なそこそこ大きなトピックを話す機会が増えたということで、もともと30分だったMTG時間を1時間に拡大し、思う存分話すようにしてみました。
仕様の議論から実装の詳細まで幅広いトピックを扱う場として機能し、他のチームの状況の共有やそこそこ深い議論ができるのは非常に良い点でした。

一方で、内容が詳細になりすぎたり、情報量が多すぎて議論への集中が難しくなる場面も多々ありました。また、そういった内容を毎日1時間のMTGで話すのは次第にヘビーに感じられるようになっていました。

ゆるい時間制限とファシリテーションの導入

課題を感じながらも上記の運用を続けた後に、大きな問題であった時間不足に対処するため、MTG時間を30分に戻し、1人あたり5分程度を目安とする時間制限を設けてみました。

もともとは話し始める人を入社順にしていましたが、後の方になると時間が足りなくなるという課題があったので、日ごとに話し始める人兼ファシリテーターがおおむね輪番になるようにしました。

また、ファシリテーターが固定だと聞き役も偏ってしまう状態でもあったため、持ち回りで担当することで、自然な会話が生まれるようにする意味もあります。ファシリテーターの選出には、朝会に使用しているNotionの適当なデータベースに組んだNotion数式をとりあえずと思って使いましたが、意外と使い勝手もよくそのまま使用しています。

このあたりを進めることである程度共有の効率が上がりました。とはいえ時間制限が曖昧だったため、結局時間オーバーになることが多く、どの程度の詳細さで話すべきかという悩みは解消されませんでした。なにより、盛り上がっているトピックの話をファシリテーターが中断するのはかなり勇気が必要でした。

厳密な時間管理と議論コーナーの設置

そこで、厳密な3分制限を導入し、時間管理を徹底してみることにしました。また、それだけだとあまり変化がないので話し足りない内容や少し議論したいトピックのために、一通り全員が話し終わったあとに議論や雑談のコーナーを設けました。これまでも少し大きなトピックの場合は別でMTGを設定するようにはしていましたが、朝会で盛り上がった内容を改めてMTGとして設定する塩梅が結構難しく、この変更でそのあたりのバランスがとれたと思っています。

盛り上がった会話だったり、あらかじめ話したい話題を書いておき、後で取り上げている

この形式により、共有の効率が飛躍的に向上し、綺麗に時間内に全員が発言できるようになりました。詳細な議論も後半で行えるため、話す粒度の調整も容易になりました。

時間制限に関して特に大きくやり方を変えたわけではなく、曖昧になっていた認識を揃えたこと、後続で話せるコーナーを設けたことで自然にあとで議論する流れが作れています。 これは今のところよく機能していて、多少のメンバー増加が伴っても問題はなさそうです。

色々と試したが、いまはこんな感じでタイマーをNotionページに埋め込んでいる

終わりに

この一年間でチームメンバーが急増し、それに伴って場が機能しているかを意識する機会が増えました。特に朝会はその中の一つであり、メンバー全員が価値を感じられる場を模索してきました。

こうして改めて振り返ると、ルールの設計はもう少し最初からいい感じにできたな、と感じる部分もあります。とはいえ、それ以上に重要だったのは自分を含めたメンバーが場に対してどのような点に価値を見出しているのかを理解し、それを上手く残していくことだったとも感じます。こうした過程を経ていったんの最適解を見つけるのも案外良かったのではと思っています。

現状としては情報共有の場以上に良い形になっていると感じますが、朝会に限らずこういった場は組織体制の変化に応じて柔軟に形式を見直していく必要があると思います。 似たような組織体制や課題を抱えるチームの参考になれば幸いです。

カンムでは引き続き、フロントエンドエンジニアを募集しています。興味のある方は、ぜひご連絡ください。

team.kanmu.co.jp

使っているライブラリを見てみよう(shopspring/decimal編)

こんにちは. Poolでエンジニアをしている @always_allokay です。

こちらはカンム Advent Calendar 2024 の6日目の記事です。 昨日はprinさんによるボボステの記事でした。

タイトルの通り、お世話になっているライブラリをちょっと見てみるシリーズです。(今はじめました)
今回は、shopspring/decimal について見ていきます。

shopspring/decimalとはなにか

まずはライブラリのREADMEをみます。

Arbitrary-precision fixed-point decimal numbers in go.

Note: Decimal library can "only" represent numbers with a maximum of 231 digits after the decimal point.

記載の通り、「Goでの任意精度固定小数点10進数」を実現するライブラリです。

浮動小数点はよく聞くワードですが、固定小数点とはなにか?という疑問が湧きます。 詳細は以下の資料にゆずりますが、数値を格納するときに、整数部と小数部の桁数をあらかじめ決めてある、というのが固定小数点です。 とてもわかりやすくて良いスライドなのでぜひ一読をお勧めします。

speakerdeck.com

なにを解決するライブラリか?

さきほどのスライドでも言及がありますが、コンピュータでは最終的に2進数でデータを扱う都合上、小数点以下を厳密に表現することができない、というよく知られた問題があります。
日常的にプログラミングする分には問題ない精度であることがほとんどなのですが、金融や決済などの領域では、致命的な誤差になりえます。 これを解決(≒ほとんどの場合に問題ないように)するためのライブラリです。
カンムでも、決済や利率計算の際に誤差がでないように利用しています。

ライブラリの構造

それではさっそくみていきます。まずはrepository全体を概観。

$ tree .
.
├── CHANGELOG.md
├── LICENSE
├── README.md
├── const.go
├── const_test.go
├── decimal-go.go
├── decimal.go
├── decimal_bench_test.go
├── decimal_test.go
├── go.mod
└── rounding.go

1 directory, 11 files
$ wc -l *
      76 CHANGELOG.md
      45 LICENSE
     139 README.md
      63 const.go
      34 const_test.go
     415 decimal-go.go
    2339 decimal.go
     314 decimal_bench_test.go
    3649 decimal_test.go
       3 go.mod
     160 rounding.go
    7237 total

全体でもたった11ファイル、テストコードを除けば、3000行弱のソースコードで、実現されていることがわかります。

github.com

// Decimal represents a fixed-point decimal. It is immutable.
// number = value * 10 ^ exp
type Decimal struct {
    value *big.Int

    // NOTE(vadim): this must be an int32, because we cast it to float64 during
    // calculations. If exp is 64 bit, we might lose precision.
    // If we cared about being able to represent every possible decimal, we
    // could make exp a *big.Int but it would hurt performance and numbers
    // like that are unrealistic.
    exp int32
}

ライブラリの基礎となるdecimal.Decimal structに注目すると、Decimalは、*big.Int型のvalueとint32型のexpの二つからなることがわかりました。 そして、このライブラリが提供する最大精度もexpがint32であることに起因することがわかります。 であれば、これをより大きな数値を扱える型にするとよいのではと安直に思ってしまいますが、それに関する2つのトレードオフについてコメントされています。

  • ひとつは、64bitにする場合について。計算途中で、float64にキャストする部分があり、そこで桁溢れを起こさないためとありますね。 オーバーフローを起こすと、精度云々ではなく数値がぶっ壊れてしまうのでこれは納得。
    • 実際にfloat64にcastしている箇所は以下でした。 github.com
  • もうひとつは、big.Intなどとする場合について。こちらは性能が損なわれてしまうことと、そこまで大きな数値を扱うケースが現実的ではないことから使わないと判断されているようです。

一つ賢くなりました。

さらに、Add, Sub, Mul, Divなどの四則演算をみていくと、実はMulメソッドは、不正確な数値を返しそうになる場合panicを発生させていることを発見しました。 普段使ってるメソッドにも知らないことがあるものですね。とはいえ、現実に扱う数値では遭遇することはなさそうです。

github.com

そのほかの発見としては、丸め処理にも、通常の四捨五入以外にも、BankerRouding、CashRouding, RoundDown, RoundUpという丸め処理があることがわかりました。
それぞれ、近い方の偶数に丸める、最小通貨単位に丸める、0に近づくように丸める、0から遠ざかるように丸めるという処理です。
現在は利用していないのですが、CashRoundingは実は任意の円単位(千円、万円、億円 etc)に数字を丸めるときに使えるかもしれません。

得られた知見

  • shopspring/decimalは、231桁の精度まで担保している。これは言い換えると小数点以下2,147,483,647桁≒小数点以下21億桁の精度なので、現実的には十分な精度である。
  • 丸め処理にはBankerRouding, CashRoudingをはじめ多くのバリエーションがあること。

一言

もうちょい深掘りしたかったのですが、アルゴリズムとか最適化らへんは歯が立たなかったです。大学生ぶりにアークタンジェントとかみました。 つぎは標準ライブラリや準標準(x/exp/...)のライブラリを読みたいですね。

カンムはソフトウェアエンジニアを募集しています

team.kanmu.co.jp

最近のRedash

SREの菅原です。 この記事はカンム Advent Calendar 2024の4日目の記事です。

最近のRedashの開発状況について、知っている範囲ですこし書いてみたいと思います。


redash.io

Redashといえば様々なデータソースをSQLを使って可視化できるBIツールで、カンムでも業務のデータ分析に使われています。 ただ、一昔前にRedashがはやっていた頃に比べると、最近ではトレンドからは外れたような印象があります。

実際、SaaS Redashが終了した2021年から2023年の4月あたりのGitHubのアクティビティを見ると、活動が停滞しています。

Contributors to getredash/redash · GitHub

この頃、CVE-2023-0286の対応のため、私はRedashのDockerイメージのベースをDebian busterからbullseyeに更新しようとしたのですが、そもそもDockerイメージをビルドするCIが壊れていたので、それを直すPull Requestを作成してたりしました。

github.com


2023年4月にRedashがコミュニティ主導のプロジェクトになるアナウンスがされます。

github.com

これを契機に開発が再び活発になり、古いIssueやPull Requestが整理され、新しいメンテナの方々によって依存ライブラリのバージョンアップなどのPull Requestがどんどんマージされるようになりました。

あわせてGitHub Actionsへの移行、CIの改修、docker composeまわりの改善も進み、開発しやすい体制が整っていきます。

私もいくつかPull Requestを作成してマージしてもらいました(懸念だったベースイメージの更新もできました)。


2024年は前年ほどではないものの粛々と開発は続いています。

ずっとWarningを出していた開発停止ライブラリが削除されたり

github.com

ARM64のDockerイメージの作成されたり。

github.com

また開発ドキュメントの整備も進んでいます。

github.com


懸念は安定版がなかなかリリースされないことです。 (最新の安定版のリリースは3年前)
以下のディスカッションで議論はされていますが、外からはメンテナンスチームの動きがあまり見えないので、気になるところではあります。

github.com

プレビュー版のリリースは活発なので、もし来年の前半ぐらいまでに安定版がリリースされないのであれば、プレビュー版に移行していくしかないかな…となんとなく考えています。

まとめ

ほとんど更新が止まったかに思えたOSSが再びよみがえっていく様子を見られたのはなかなかうれしいことでした。 まだ懸念点はあるものの、コミッターには日本人の方もおり、改善のPull Requestも受け入れられやすい状況にあるのではないかと思います。

カンムでは引き続きRedashを使っていく予定なので、今後もRedashコミュニティに多少なりとも貢献できればと考えています。

モダンな状態管理の探求:Redux からZustand へ ― 実装から原理まで

はじめに

こんにちは、エンジニアの王です。 フロントエンドの開発者として、私はいくつかのプロジェクトでRedux Toolkitを使用してきました。Redux Toolkitは確かに強力な状態管理ツールですが、プロジェクトが進むにつれて、その構造的な複雑さを次第に強く感じるようになりました。 そこで、より軽量で使いやすい状態管理ライブラリを探し始めました。新しいソリューションの条件として、Redux Toolkitの堅牢性は維持しつつ、より簡潔なAPIと学習コストの低さを重視しました。その過程で出会ったのが、Zustandです。

Reduxの実装の複雑さ

シンプルなカウンターを管理するためにRedux Toolkitで必要なコード量を見てみましょう:

// store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer
  }
})

// store の型定義
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

// 型付きフックの定義
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

// counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from './store'

// 状態の型定義
interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0,
}

// スライスの作成と型定義
export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload
    },
  },
})

// コンポーネントでの使用
// Counter.tsx
import { useAppSelector, useAppDispatch } from './store'
import { increment, decrement } from './counterSlice'

export function Counter() {
  // 型付きフックの使用
  const count = useAppSelector((state) => state.counter.value)
  const dispatch = useAppDispatch()

  return (
    <div>
      <button onClick={() => dispatch(decrement())}>-</button>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+</button>
    </div>
  )
}

このRedux Toolkitの実装でも、以下のような課題が残ります:

  1. ファイル構成が複雑:slice、store、コンポーネントと、複数のファイルでの実装が必要
  2. 概念的な理解の必要性:createSlice、reducer、dispatch等の概念を理解する必要がある...初心者には理解が難しい
  3. 型サポートの設定:TypeScriptプロジェクトでは、追加の型定義の設定が必要
  4. ボイラープレートコードの存在:Redux Toolkitによって簡略化されているものの、なお一定量のセットアップコードが必要

これらの課題は、特に小規模なプロジェクトや、新しいチームメンバーの参加時に影響を与える可能性があります。

Zustandとの出会い:エレガントな代替案

代替案を探す過程で、Zustandを発見しました。Zustandで同じカウンター機能を実装してみましょう。

Poimandres(旧PMND)チームによって開発・メンテナンスされているZustandは、React向けの小さくて高速、そしてスケーラブルな状態管理ソリューションです。2019年にリリースされて以来、GitHubで48k以上のスターを獲得し、多くの開発者から支持されています。 GitHub: https://github.com/pmndrs/zustand

import { create } from 'zustand'

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

const useStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 }))
}))

// コンポーネントでの使用
const Counter = () => {
  const { count, increment, decrement } = useStore()
  
  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  )
}

違いがわかりますか?Zustandを使用すると、以下のことが可能です:

  1. すべての状態ロジックを1つのファイルで完結
  2. 設定不要で、Providerのラップも不要
  3. 直感的なAPI:create関数でストアを作成し、set関数で状態を更新
  4. シンプルな理解で済む:actions、reducersの概念がなく、直接状態を変更可能

Zustandの魅力:具体的な改善点

Zustandの実装例を見てきましたが、このシンプルさの裏には、従来の課題を解決するための様々な工夫が詰まっています。Zustandを活用することで、以下のような具体的なメリットが得られます:

  1. 状態管理の簡素化

    • Providerやconnectの設定が不要でプロジェクト構造がクリーンに
    • コンポーネント内で直接状態にアクセス可能な設計
  2. TypeScriptとの親和性向上

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

const useStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 }))
}));

この例からわかるように、型定義がシンプルかつ直感的で、開発者の負担を大きく軽減できます。

  1. 状態更新の柔軟性向上 Zustandは複雑な状態管理や非同期処理においても、シンプルで理解しやすい実装を可能にします。
    • 状態の直接的な修正が可能になり、コードの見通しが改善
    • 非同期操作もmiddlewareなしでスムーズに実装可能
// 非同期操作の実装例:TODOリストの取得
import { useEffect } from "react";
import { create } from "zustand";
const useStore = create((set) => ({
  todos: null,
  error: null,
  fetchData: async () => {
    try {
      const res = await fetch(`https://jsonplaceholder.typicode.com/todos`);
      const todos = await res.json();
      set({ todos });
    } catch (error) {
      set({ error });
    }
  },
}));

export default function App() {
  const { todos, fetchData, error } = useStore();
    useEffect(() => {
      fetchData();
    }, []);
    if (!todos) return <div>Loading...</div>;
    if (error) return <div>{error.message}</div>;
    return (
      <div>
        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
           ))}
        </ul>
      </div>
   );
}
  1. パフォーマンスの最適化 Zustandは状態が変更された時に必要なコンポーネントのみを再レンダリングすることができます。Reduxと比較すると、より簡潔な実装方法でありながら、同等のパフォーマンス最適化を実現しています。主な特徴は以下の通りです:
    • 選択的な状態購読
    • shallow比較やカスタム比較関数を使用して不要な再レンダリングの防止
    // 基本的な購読
    const count = useStore(state => state.count);
    
    // 複数の状態を選択的に購読
    const { user, settings } = useStore(state => ({
      user: state.user,
      settings: state.settings
    }));

    // shallow比較を使用して再レンダリングを最適化
    const { todos, done } = useStore(
      state => ({ 
        todos: state.todos,
        done: state.done 
      }),
      shallow
    )

    // カスタム比較関数の使用
    const todoCount = useStore(
      state => state.todos.length,
      (a, b) => Math.abs(a - b) < 5
    )

シンプルさを追求したZustandは、チームの生産性と新規参入のしやすさを両立させ、まさに現代のReactアプリケーションが求める状態管理ソリューションと言えるでしょう。

実践的なTodoリストの実装: 実践からパフォーマンス最適化まで

前の内容では、Reduxの複雑さとZustandへの移行理由とについて説明しました。今回は、完全なTodoアプリケーションの例を通じて、Zustandの使用方法とパフォーマンス最適化の実践について詳しく見ていきます。

完全的なSource Code:https://github.com/WWK563388548/simple-zustand-example

TodoアプリケーションのURL:https://regal-empanada-b5967a.netlify.app/

基本実装:Todoアプリケーションの構築

Storeの設計

まず、機能を完備したTodo storeを作成します:

import { create } from "zustand";

export type Todo = {
  id: string;
  title: string;
  completed: boolean;
}

type StatusType = 'all' | 'completed' | 'incompleted';

type Store = {
  todos: Array<Todo>;
  status: StatusType;
  setStatus: (status: StatusType) => void;
  setTodos: (fn: (todos: Array<Todo>) => Array<Todo>) => void;
}

export const useTodoStore = create<Store>((set) => ({
 // 初期状態
  status: 'all',
  todos: [],
 // 状態更新メソッド
  setStatus(status: StatusType) {
    set({ status })
  },
  setTodos(fn: (todos: Array<Todo>) => Array<Todo>) {
    set((prev) => ({ todos: fn(prev.todos) }))
  },
}));

この設計はZustandの主要な利点を示しています:

  • TypeScriptフレンドリーな型定義
  • 状態と操作メソッドの一元管理
  • シンプルで直感的な更新API

コンポーネントの実装

Todoアプリケーションの各コンポーネントを実装しましょう。 まず、App.tsxコンポーネント

// src/App.tsx
import { FormEvent } from "react"
import { v4 as uuid } from "uuid";
import { useTodoStore } from "../stores/todoStore"
import StatusGroup from "./StatusGroup"
import FilteredItemList from "./FilteredItemList";

const App = () => {
  const { setTodos } = useTodoStore()
  const add = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const title = e.currentTarget.inputTitle.value
    e.currentTarget.inputTitle.value = ''
    setTodos((prevTodos) => [
      ...prevTodos,
      { title, completed: false, id: uuid() },
    ])
  }

  return (
    <form onSubmit={add}>
      <StatusGroup />
      <input name="inputTitle" placeholder="Type ..." />
      <FilteredItemList />
    </form>
  )
}

export default App

後は、FilteredItemList.tsxStatusGroup.tsxコンポーネント

// src/FilteredItemList.tsx
import { a, useTransition } from "@react-spring/web"
import { useTodoStore } from "../stores/todoStore"
import TodoItem from "./TodoItem"

const FilteredItemList = () => {
  const { todos, status } = useTodoStore()
  const filterTodo = todos.filter((todo) => {
    if (status === 'all') return true
    if (status === 'completed') return todo.completed
    return !todo.completed
  })
  const transitions = useTransition(filterTodo, {
    keys: (todo) => todo.id,
    from: { opacity: 0, height: 0 },
    enter: { opacity: 1, height: 40 },
    leave: { opacity: 0, height: 0 },
  })
  return transitions((style, item) => (
    <a.div className="item" style={style}>
      <TodoItem item={item} />
    </a.div>
  ))
}

export default FilteredItemList;

// src/StatusGroup.tsx
import { useTodoStore } from "../stores/todoStore"
import { Label } from "../components/ui/label"
import { RadioGroup, RadioGroupItem } from "../components/ui/radio-group"

const StatusGroup = () => {
  const { status, setStatus } = useTodoStore()
  return (
    <RadioGroup onValueChange={setStatus} value={status}>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="all" id="r1" />
        <Label htmlFor="r1">All</Label>
      </div>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="completed" id="r2" />
        <Label htmlFor="r2">Completed</Label>
      </div>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="incompleted" id="r3" />
        <Label htmlFor="r3">Incompleted</Label>
      </div>
    </RadioGroup>
  )
}

export default StatusGroup;

最後は、TodoItem.tsxコンポーネント:

// src/TodoItem.tsx
import { Todo } from "../lib/types"
import { useTodoStore } from "../stores/todoStore"

const TodoItem = ({ item }: { item: Todo }) => {
  const { setTodos } = useTodoStore()
  const { title, completed, id } = item

  const toggleCompleted = () =>
    setTodos((prevTodos) =>
      prevTodos.map((prevItem) =>
        prevItem.id === id ? { ...prevItem, completed: !completed } : prevItem,
      ),
    )

  const remove = () => {
    setTodos((prevTodos) => {
      return prevTodos.filter((prevItem) => {
        return prevItem.id !== id
      })
    })
  }

  return (
    <>
      <input type="checkbox" checked={completed} onChange={toggleCompleted} />
      <span style={{ textDecoration: completed ? 'line-through' : '' }}>
        {title}
      </span>
      <span onClick={remove}>X</span>
    </>
  )
}

export default TodoItem;

以上の実装により、ユーザーは以下の操作が可能になります:

  1. Todoの表示状態を「全て」「完了」「未完了」で切り替える
  2. 各Todoアイテムの完了状態を変更する
  3. 不要なTodoを削除する

これでTodoリストアプリケーションの基本機能が完成しました。シンプルながら、タスク管理に必要な主要機能を備えています。

パフォーマンスの問題と最適化

上記の実装では、いくつかのパフォーマンス問題が発生する可能性があります。具体例を通じて、これらの問題を理解し、解決方法を学びましょう。

レンダリングの問題を理解する

例えば、FilteredItemListコンポーネントで以下のような実装があったとします:

let renderCount = 0;
const FilteredItemList = () => {
  renderCount++;
  // store全体を購読してしまっている
  // ❌ 問題のある実装例
  const { todos, status } = useTodoStore();
  
  console.log('レンダリング回数:', renderCount);
  const filteredTodos = todos.filter(todo => {
    if (status === 'all') return true;
    if (status === 'completed') return todo.completed;
    return !todo.completed;
  });
  return (
    <div>
      {filteredTodos.map(todo => (
        <TodoItem key={todo.id} item={todo} />
      ))}
    </div>
  );
};

この実装には以下の問題があります:

  1. FilteredItemListコンポーネントがstore全体を購読しているため、todosstatus以外の状態が変更されても再レンダリングが発生
  2. StatusGroupコンポーネントで状態を変更すると、不必要な再レンダリングが発生する可能性がある

最適化手法

1. セレクター関数の使用

パフォーマンス最適化の最も基本的な方法は、useStore にセレクター関数を渡すことです。 セレクター関数を使用すると:

  • 必要な状態(この場合はtodos)のみを購読
  • 関係のない状態の変更による再レンダリングを防止
const FilteredItemList = () => {
  // 必要な状態だけを選択的に購読
  // ✅ 改善された実装:必要な状態のみを購読
  const todos = useTodoStore(state => state.todos);
  const status = useTodoStore(state => state.status);
  
  const filteredTodos = useMemo(() => {
    return todos.filter(todo => {
      if (status === 'all') return true;
      if (status === 'completed') return todo.completed;
      return !todo.completed;
    });
  }, [todos, status]);
  return (
    <div>
      {filteredTodos.map(todo => (
        <TodoItem key={todo.id} item={todo} />
      ))}
    </div>
  );
};
2. createSelectorsの活用

大規模なプロジェクトでは、全てのuseStore呼び出しにセレクター関数を手動で追加するのは:

  • 面倒
  • ミスが発生しやすい
  • コードが冗長になる

この課題を解決するため、ZustandはcreateSelectorsというユーティリティを提供しています。これを使用することで、セレクター関数を自動的に生成できます。

// storeの作成
const useStoreBase = create((set) => ({
  todos: [],
  status: 'all' as 'all' | 'completed' | 'incompleted',
  setStatus: (status: 'all' | 'completed' | 'incompleted') => set({ status }),
  setTodos: (updateFn: (todos: Todo[]) => Todo[]) => 
    set((state) => ({ todos: updateFn(state.todos) }))
}));
// セレクターを自動生成
const useTodoStore = createSelectors(useStoreBase);
// 最適化されたコンポーネント
const FilteredItemList = () => {
  const todos = useTodoStore.use.todos();
  const status = useTodoStore.use.status();
  // ...
};
3. 浅い比較の使用

デフォルトでは、状態が変更された時、以下のプロセスで再レンダリングの要否が判断されます:

  1. セレクター関数を使用して最新の状態を計算
  2. Object.isを使用して、前回の状態と新しい状態を比較
  3. 比較結果に基づいて再レンダリングを判断

例えば:

const { todos, setStatus } = useStore((state) => ({
  todos: state.todos,
  setStatus: state.setStatus
}))

このコードの実行フロー:

  1. 初回レンダリング

    • セレクター関数が実行され、todossetStatusを含む新しいオブジェクトを生成
    • このオブジェクトが初期状態として保存
  2. statusフィールドが更新された場合:

    • セレクター関数が再度実行される
    • 新しいオブジェクトが生成される
    • Object.isで比較が行われる
    • todosとsetStatusの値自体は変更されていないが、新しいオブジェクトが生成されているため、Object.isはfalseを返す
    • 結果として不要な再レンダリングが発生

この問題を解決するため、ZustandはuseShallowで浅い比較を提供しています:

  • オブジェクトの最上位のプロパティ(この場合todosとsetStatus)それぞれについて個別に比較を行う
  • プロパティの値が実際に変更された場合のみ再レンダリングが発生
  • 無駄な再レンダリングを防ぎ、パフォーマンスが向上

このように、浅い比較を使用することで、オブジェクトの参照変更だけでは再レンダリングが発生せず、実際の値の変更があった場合のみ再レンダリングが行われるようになります。

const TodoSummary = () => {
  // ✅ `useShallow`で浅い比較をする
  const { todos, setStatus } = useTodoStore(
    useShallow(state => ({
      todos: state.todos,
      setStatus: state.setStatus
    }))
  );
  
  // ...
};

Zustand の核心的なAPI の解説と自作による原理理解

重要な前提

Zustand v5からはReact 18に依存するようになり、useSyncExternalStoreというReactの組み込みAPIを直接利用しています。これは重要な変更点であり、以下の特徴があります:

  • React 18ではuseSyncExternalStoreが組み込みAPIとして提供され、外部ストアとの同期を効率的に行えます
  • React 18未満のバージョンを使用する場合は、use-sync-external-storeのshimをインポートする必要があります

補足:useSyncExternalStoreはReact Reduxなども採用している外部ストア同期のための公式APIです。 これにより、並行レンダリング環境での信頼性の高い状態管理が実現できます。 しかし、Zustandはより軽量で設定が簡単ですよ

本文では、use-sync-external-storeパッケージのuseSyncExternalStoreWithSelectorを採用しております。Zustand5の公式実装方法については、Zustandのソースコードをご参照ください

Zustandの基本構造

Zustandの内部実装は非常にエレガントで理解しやすい構造になっています。主に以下の3つのコア機能で構成されています:

  • createStore: ストアの作成と管理
  • useStore: React Hooksとの連携
  • create: 上記2つを組み合わせた最終的なAPI

事前に型の準備

基本構造と各基礎的な機能を実装する前に、まずStoreの型定義について説明していきましょう。 (ユーザーがどのような値を渡すかわからないため、ジェネリックTを使用して実装していきます)

まず、現在の状態を取得する関数getStateの型を定義します:

type GetState<T> = () => T

次はsetStateの型定義 setStatecreate関数のコールバックパラメータとして使用され、Storeの状態を更新するために使用されます。以下の3つの使用パターンがあります:

  • 完全な状態更新
    • 完全な状態オブジェクトを渡して、現在の状態全体を置き換えます
    • 状態全体を一度に更新する場合に使用します
  • 部分的な状態更新
    • 状態の一部のみを渡します
    • Zustandは自動的に既存の状態と新しい部分的な状態をマージします
    • 特定のフィールドのみを更新する場合に便利です
  • 関数による更新
    • 現在の状態に基づいて計算や処理を行う関数を渡します
    • 関数は現在の状態を受け取り、新しい状態または部分的な状態を返します
    • 返された結果は現在の状態にマージされます

これらの使用パターンに対応する型定義:

type SetState<T> = (
  partial: T | Partial<T> | ((state: T) => T | Partial<T>),
) => void

そして、subscribe関数はuseSyncExternalStoreWithSelectorの引数として使用されるため、その型定義を流用します:

// subscribeの型定義
type Subscribe = Parameters<typeof useSyncExternalStoreWithSelector>[0]

最後に、これまでのすべてのAPI型を含むStore全体の型を定義します:

// Store APIの型定義
type StoreApi<T> = {
  setState: SetState<T>
  getState: GetState<T>
  subscribe: Subscribe
}

基本構造の実装

Zustandのcreate関数には以下の3つの特徴があります:

  1. 関数を引数として受け取る
  2. Storeを作成する
  3. 内部状態にアクセスするためのフックを返す

これらの特徴を踏まえて、基本的な実装を見ていきましょう。

import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'

type Subscribe = Parameters<typeof useSyncExternalStoreWithSelector>[0]
type GetState<T> = () => T
type SetState<T> = (
  partial: T | Partial<T> | ((state: T) => T | Partial<T>),
) => void
type StoreApi<T> = {
  setState: SetState<T>
  getState: GetState<T>
  subscribe: Subscribe
}

const createStore = <T>(createState): StoreApi<T> => {
    let state;  // storeの内部状態はstateに保存される
    const getState = () => state; 
    const setState: SetState<T> = () => {}; // setStateはcreateが受け取る関数の引数
    // subscribeするたびにsubscribeをlistenersに追加し、コンポーネントの再レンダリングをトリガーする
    const subscribe: Subscribe = () => {};
    const api = { getState, setState, subscribe };
    state = createState(setState); // stateの初期値はcreateStateの呼び出し結果
    return api;
}

const useStore = (api, selector, equalityFn) => {};

export const create = (createState) => {
  // storeを取得し、storeを操作するすべてのメソッドを含む
  const api = createStore(createState);
  const useBoundStore = (selector, equalityFn) =>
    useStore(api, selector, equalityFn);
  return useBoundStore;
};

createStoreはStoreを作成し、内部状態とStoreを操作するためのAPI関数を管理します。主に以下の機能を提供します:

  • getState(): Store内の現在の状態を取得するための関数
  • setState(): Storeの状態を更新するための関数
  • subscribe(): コンポーネントがStoreを購読するための機能。状態が変更された際に、購読しているコンポーネントの再レンダリングをトリガー

useBoundStoreは2つの重要なパラメータを受け取るカスタムフックです:

  • selector: Storeの完全な状態から必要な部分だけを選択する関数。状態の一部分だけを使用する場合のパフォーマンス最適化に役立つ
  • equalityFn: 選択された状態の変更を比較するための関数。状態が実際に変更されたかどうかを判断し、不要な再レンダリングを防ぐ

useStoreuseSyncExternalStoreWithSelector(useSyncExternalStoreWithSelectorはReactの組み込み機能です)を利用して以下の機能を提供します:

  • Storeの購読管理
  • 状態の選択的取得
  • レンダリングの最適化
  • 選択された状態の返却

createは上記のすべての機能を組み合わせて、最終的なAPIを提供します:

  • createStoreでStoreを作成
  • useStoreを通じて状態管理を実現
  • useBoundStoreで状態の選択と最適化を可能に

useStoreの実装

create関数から返されるフックであるuseBoundStoreselectorequalityFnを受け取り、これらのパラメータをapiと共にuseStore関数に渡すことで、状態管理の機能を実現しています。

useStoreの実装は比較的単純な構造となっており、主にuseSyncExternalStoreWithSelectorという関数を活用しています。この関数は状態の購読や更新を管理するために、5つの重要なパラメータを必要とします。具体的には、api.subscribeによる状態変更の購読機能、api.getStateによるクライアントおよびサーバーサイドでの状態取得機能、渡されたselectorによる状態の計算機能、そしてequalityFnによる状態変更の比較機能が含まれています。

selectorパラメータにおいては、その指定が任意となっており、当該パラメータの指定を省略した場合においてはストアに保持される全状態が返却されることとなりますが、一方で当該パラメータを指定した場合においては、指定されたselector関数の実行結果がslice値として返却されることとなります。

これらの要素を組み合わせた実装コードは以下のようになります:

const useStore = <State, StateSlice>(
  api: StoreApi<State>,
  selector: (state: State) => StateSlice = api.getState as any,
  equalityFn: (a: StateSlice, b: StateSlice) => boolean
) => {
  const slice = useSyncExternalStoreWithSelector(
    api.subscribe,
    api.getState,
    api.getState,
    selector,
    equalityFn
  );
  return slice;
};

subscribeの実装

コンポーネントがStoreから状態を取得する際には、Storeに対する購読(subscribe)が必要となりますが、これはStore内部の状態が変更された際にコンポーネントの再レンダリング(re-render)を確実に実行するために不可欠な仕組みとなっています。

この購読の仕組みを実現するために、以下のような実装を行っています:

type StateCreator<T> = (setState: SetSet<T>) => T

const createStore = <T>(createState: StateCreator<T>): StoreApi<T> => {
  const listeners = new Set<() => void>();
  let state: T;
  const setState = () => {};
  const getState = () => state;
  const subscribe: Subscribe = (subscribe) => {
    listeners.add(subscribe);
    return () => listeners.delete(subscribe);
  };
  const api = { setState, getState, subscribe };
  state = createState(setState);
  return api;
};
//...

この実装において特に重要な点は、購読関数(subscribe)がコンポーネントの再レンダリングを実行するための関数をパラメータとして受け取り、それをSet構造体に保存する仕組みを採用していることです。さらに、コンポーネントがUnmountされる際に購読を解除するための関数を返却することで、メモリリークを防止するための配慮もなされています。

先に実装したcreateStore関数と比較すると、今回の実装ではlistenersというSet構造体を新たに導入しており、これによってStoreの状態が変更された際(つまりsetStateが呼び出された際)に、保存されているすべての関数を順次実行することで、該当するStoreを購読しているすべてのコンポーネントの再レンダリングを効率的に実現することが可能となっています。 このような実装アプローチを採用することにより、以下のような利点が得られています:

  • Set構造体を使用することで、同一のlistenerが重複して登録されることを防止しつつ、効率的なlistenerの管理が可能となっています。
  • コンポーネントのライフサイクルに応じた適切な購読管理が実現され、不要なメモリ消費を防ぐことができています。
  • Storeの状態変更時に、関連するすべてのコンポーネントを確実に更新することができ、アプリケーションの一貫性が保たれています。

setStateの実装

subscribeの実装に関する前述の説明から、コンポーネントの再レンダリングをトリガーするすべての関数がlistenersに保存され、状態が変更された際にはこれらの関数を順次実行する必要があ ることが理解できました。このような仕組みを踏まえ、setStateの実装においては以下の2つの処理を実現する必要があります:

  1. storeの状態を更新すること
  2. listenersに保存されているすべてのパラメータを順次実行すること

これらの処理を実現するにあたり、以下のような異なるケースについて個別に考察する必要があります。

関数をパラメータとして受け取る場合

以下のような実装例を考えてみましょう:

const useDataStore = create((set) => ({
  data: { count: 0, text: 'test' },
  inc: () => 
    set((state) => ({
      data: { ...state.data, count: state.data.count + 1 },
    })),
}))

この実装例においては、データを保存するためのdataパラメータと、countを1増加させるためのinc関数を定義しており、特筆すべき点として、set関数に対して状態更新用の関数を渡してい ることが挙げられます。この関数は前回の状態を表すstateを受け取り、それに基づいて新しいcount値を計算する仕組みとなっています。 したがって、実装においては受け取ったパラメータが関数であるかどうかを判定し、関数である場合にはそれを実行する処理が必要となります。

具体的な値を受け取る場合

次のような実装例も考えられます:

const useDataStore = create((set) => ({
  count: 1,
  text: 'test',
  setCount: () => set({ count: 10 }),
}))
最終的な実装

これらの要件を満たす実装は以下のようになります:

const setState: SetState<T> = (partial) => {
  const nextState =
    typeof partial === "function"
      ? (partial as (state: T) => T)(state)
      : partial;
  
  if (!Object.is(nextState, state)) {
    state =
      typeof nextState !== "object" || nextState === null
        ? (nextState as T)
        : Object.assign({}, state, nextState);
    listeners.forEach((listener) => listener());
  }
};

この実装の重要な特徴として、状態を更新する際にすべての状態を明示的に指定する必要がないという点が挙げられます。例えば、() => set((state) => ({ ...state, count: 10 }))のように 全状態を記述する代わりに、更新したい部分のデータのみを渡すことで、ZustandがObject.assign({}, state, nextState)を使用して自動的に状態の統合を行ってくれます。

さらに、状態の更新においては完全な置き換えではなくパッチ方式を採用しているため、まず渡されたパラメータの型を判定し、Objectであり、かつnullでない場合にのみ統合処理を実行する という方式を採用しています。また、パフォーマンスの最適化として、前回のstateと計算されたnextStateが異なる場合にのみ再レンダリングがトリガーされる仕組みも実装されています。

おわりに

Reduxの課題から始まり、Zustandの基本実装、実践的なTodoアプリケーションの構築、そしてライブラリの核心的な仕組みまで、幅広く探求してきました。Zustandは単なるReduxの代替ではなく、モダンなReactアプリケーション開発における新しい状態管理のパラダイムを示していると言えます。

特に重要だと思っているポイントは、Zustandが実現した「シンプルさと柔軟性の共存」です。ボイラープレートコードを最小限に抑えながら、強力な状態管理機能を提供する approach は、開発者の生産性を大きく向上させます。また、TypeScriptとの優れた親和性や、パフォーマンス最適化のための充実したAPIは、大規模アプリケーション開発における実用性の高さを示しています。

今後のフロントエンド開発において、状態管理はますます重要な課題となっていくでしょう。その中で、Zustandの示す「必要十分な機能」と「直感的なAPI設計」という方向性は、次世代の状態管理ライブラリの在り方を示唆しているのかもしれません。

社内ものづくりイベント「Builders Day」を初開催した話

こんにちは、nakaji_dayoと申します。カンムでエンジニアリングマネージャーをしています。

2024年9月に「Builders Day」という社内ものづくりイベントを初開催しました。本稿ではイベント企画の背景や開催時の様子の紹介をします。

Builders Dayとは

はじめにイベントの概要ですが、「Builders Day」は普段の業務や職種に関係なく色々なメンバーがチームを組み、アイディアを出し合い、ものづくりを楽しむイベントです。

作るものは業務に関係なく完全に自由で、制作には1営業日を使います。また、発表会を社員総会に合わせる形でオフライン開催することにしました。

note.com (↑社員総会についてはこちら)

開催の裏側

ここで少し脇にそれますが、開催に至るまでの背景など裏側を紹介します。

このイベントは完全に新規という訳ではなく、カンムでは以前より「金融のテックカンパニー」を目指してTechDayというイベントを毎月開催してきました。今回のBuilders Dayはその改善から誕生しました。

akirachiku.com (↑これまでのTechDayでは業務効率化などの成果が生まれ続けています)

一方、当初に対し「社員数が増えた、リモートワークが主流」などの環境変化で、「参加のハードルや成果の出しづらさ」などの課題も出ていたことから、よりよい在り方はないかという所で有志のPJを作り検討を開始しました。

検討を進める中で「カンムはものづくりの会社である」という理念から「誰もが」「楽しく」「ものづくりを行える」イベントを目指したいと考え、以下の改善・変更を加えて「Builders Day」としての開催を行いました。

  • 年2回と開催頻度を減らすことで各回の確保時間は増やし、制作の成果を出しやすくする・運営にもリソースをかけられるようにする
  • オフラインの発表会により、イベントとして盛り上がるものにする
  • チーム参加を取り入れ、職種をまたぎアイディアを出しやすくする・参加ハードルを下げる => 今回は運営側でチームマッチングを行った
  • 推しテーマを設定し、そのワークショップや利用のサポートを行い、制作に取り組みやすくする => 今回は推しテーマを「Zapier」とし、その利用のワークショップを開催した

結果としては、任意参加ながら「人事/広報/業務/法務/事業開発/開発」など様々な職種の方、また入社から比較的日が浅い方も含めて、20名+の方々に参加いただけました。そして多くのチームがものづくりに熱中し、オフライン開催での発表会も盛り上がるものになったのではと思っています。 以降で制作Day・発表会の様子を紹介いたします。

制作日の様子

制作日はオンラインメインの実施で、チーム毎にSlack channelを作り、Google meetなどを使いつつ各チームが制作に取り組みました。 午前中には開会式と任意参加の「Zapierワークショップ」実施しました。

一部のチームはオフィスで開発

発表会の様子

発表会では、計12のチームまたは個人がデモなど交え制作物を紹介しました。 前述のとおり懇親会内で開催したので、観客は酒などを片手に発表を聞き、アイディアや作りきったことへの感心の声や、ユニークな目の付け所に笑いなども起こり、オフラインならではの盛り上がりとなりました。

Zapierも活用した「採用面談・面接の通知bot」の発表

発表会の様子

表彰式の様子 / 受賞作品の紹介

今回のイベントでは社員投票による「チーム・個人賞」とCTO審査による「審査員特別賞」、及びささやかな賞品を用意しました。 以下では受賞作品を紹介します。

チーム賞: 図に載せる君 / pocketnori3(ポケのり)

  • 制作物(2点)
    • 図に載せる君: ponzuとyokoiを図に乗せてくれる
    • pocketnori3(ポケのり): あるキーワードを与えると:nori3: が発言したslackメッセージとesaからいい感じのものを3つづつ選んでリンクを返してくれる
  • チーム名: チーム1_思考回路エクスプレス

こちらのチームは、@nori3(= 社内のあらゆる疑問に答えるすごい人)がソースの情報を探すslack botを制作し、これがシンプルながら大勢に刺さっていました。

チーム賞受賞の様子

個人賞: そこに法人データがあるから

データの整備・提供という地道ながら確実に実用的なエンジニア魂あふれる取り組みした @achiku が個人賞に選ばれました。

@achikuの発表の様子

審査員特別賞: esa watch/ リリース審査員

  • 制作物(2点)

こちらのチームは、ドキュメント共有サービスesaの記事をディレクトリ単位でなど柔軟に変更監視できるツールと、プレスリリースの作成を承認フロー実行やAIレビューによりサポートするツールの2本の業務上便利なツールを開発しました。

審査員特別賞受賞の様子

開催してみて

終わりにイベントを開催してみての私の感想を残します。

作品の全体感

  • Zapierを使った作品も多く、アイディアを手軽に試して見れている様子だった
  • ChatGPTを組み合わせた作品も多く時代を感じた。中にはInstruction Tuningの利用検証などの取り組みもあり面白かった。

良かった点

  • 普段関わりの少ない人達が共同でものづくりに取り組め、「広報・人事・法務など」を含むチームならではの視点での作品が生まれた
  • 作ったものが役立っていて、開催後も使われている
  • 開催後「Zapierですべての業務を自動化したい」などの声も上がっており、普段の業務の改善が期待できる
  • 発表会が盛り上がった

改善したい点

  • 制作日に「チームで物を作る楽しさ」を参加者全員に感じてもらうために、十分な環境や仕組みを用意できなかった
  • Zapierは多くの人の取っ掛かりになった一方で、エンジニアからは込入ったことをすると大変だったという声があり、チーム制作やテーマ設定の難しさを感じた

最後に

最後の最後にですが、本Builders Dayの企画運営PJは、@michiomochi , @sho_hata_, @nakaji_dayoのメンバー +@knee (CTO)のサポートで取り組んでいます。大感謝。


\カンム、積極採用中/

herp.careers

Builders Dayやカンムに少しでも興味を持っていただけたらお話だけでも是非!