クローズしたサービスの管理画面を静的サイトにする

こんにちは、技術部の石川です。

ある日、社内の各種アプリケーションを眺めている中で、とあるクローズしたサービスの管理画面を担っていたウェブアプリが今も動いていると気付きました。簡単にヒアリングしたところ、サービス自体はクローズしたものの、保有していたデータが次のチャレンジに生かせるため管理画面だけ残しているとのことでした。

一方で、その管理画面へのアクセスはそう多くありませんでした。毎日ちょっとだけのリクエストを処理するためだけにデータベースとサーバーが動いており、少し無駄がある状態になっていました。

やや気になったので検討した結果、最終的にこの管理画面アプリを Next.js 製の静的なデータビューワーサイトとしてリニューアルし、社内向けの GitHub Pages として提供されている状態にできました。この記事ではその顛末をご紹介します。

技術選定

いくつか事前調査をした結果、今回の管理画面について以下のことが分かりました。

  • Rails が動いている。サーバー側では graphql-ruby を利用した GraphQL API が動いていて、ウェブフロントエンド側では素の React が API にリクエストを行いながらページを作っている。データベースは PostgreSQL (Amazon RDS for PostgreSQL)。
  • データ量はそこまで多くないが、目で全件確認できるほど少なくもない。
  • ページの種別は、Rails の app/javascript/pages 下にある index.tsx を数えてみると 80 程度。移植しなくて良いページもそれなりにありそう。
  • 画像や映像を表示しているページがある。
  • データの追加や更新はもう行わない。
  • 認証・認可は不要になる。正確には、社内ネットに閉じた環境であれば全公開で構わない。
  • 予定としては今後数年アクセスするつもりがある。
  • データの一覧ページのところにある検索機能は残したい。

この状況下で、現状の管理画面アプリに替わる運用として以下の選択肢を考えました。

  • DWH へのクエリで済ませてしまう。クックパッドでは Amazon Redshift に各種データを集積し、DWH として活用しています。*1今回のアプリが利用しているデータベース上の情報は DWH にもあるため、キレイな画面は消してしまって素朴な SQL クエリにしてしまうことは可能です。クエリ結果を共有するアプリが常用されていたりもします。*2
  • BI ツールのダッシュボードとして再実装する。
  • データベースやアプリをサーバーレスな構成に移植する。
  • 静的サイトジェネレーターの何かしらを使って再実装する。
  • 今ある管理画面に対して古典的なウェブスクレイピングを行って全ページのファイルを取得し、それを手直ししたうえで静的サイトとして提供する。検索機能は諦めて、ブラウザのページ内検索を使う。

このうち、DWH 案とダッシュボード案はすぐに取り下げました。画像や映像とテキストが横に並んだ状態でパッと一覧できる現状を保ちたいという利用者からの要望があったのと、再実装したいページ種別数がそれなりにあってクエリやダッシュボードとして作るには時間がかかりそうだったのが理由です。

サーバーレス化をやってみるのも面白そうではありました。たとえばデータベースだけ Amazon Aurora Serverless にしてアプリはそのまま、というのは社内に過去事例もありできそうでした。一方でアプリが残る以上そのうちセキュリティアップデート等の対応は必要になるため、もっとラクができるなら嬉しいと考えました。クローズしたサービスのアプリはオーナー不在になる確率が高そうで、誰がメンテナンスするのかという問題もありました。*3

というわけで静的サイトジェネレーターかスクレイピングの 2 択になりました。ラクそうだったスクレイピングを選んでも良かったのですが、ここで少し欲を出して、静的サイトジェネレーターを試してみたいという気分になりました。

静的サイトジェネレーターを使う場合、元々の画面が React で実装されているので、移植の容易さを考えると Next.js や Gatsby などの選択肢が考えられます。実は Gatsby で静的サイト化するのは別の小さな社内向けアプリで前例がありました。ただ 2023 年現在の社内では Next.js を利用しているアプリが多くあり、また Next.js の静的サイト向け機能である Static Exports を自分で使ってみたことがなかったため、技術検証の意味も込めて Next.js を使ってみることにしました。*4

React 以外の依存関係も確認しておきましょう。元々の管理画面のコードで使われている package.json を見てみたところ、UI ライブラリとして Ant Design (antd)、GraphQL クライアントとして Apollo が入っており、その他小さなライブラリがすこし入っているという状況でした。このくらいの複雑度なら移植できそうだなと判断しました。

さて、Next.js の Static Exports で再実装するのであれば、PostgreSQL に入っているデータをどうするか考えなければいけません。AWS 上にデータベースが残ったままだとコストはあまり減りませんし、ローカル環境のデータベースに移すならシンプルな形にしたいです。考えた結果、今回のデータはそこまで巨大では無かったため、SQLite へ移植して .sqlite3 ファイルとして持ってしまうことにしました。jsonb 型など PostgreSQL 固有の機能を使っている箇所もあったのですが数箇所しか無かったため SQLite 向けに書き直し、SQLite のみだと足りない処理はデータベースからデータを取ってきた後にアプリ側で行うようにしてしまいました。

データ取得まわりについては GraphQL を使うことにしました。元々の Rails 製 GraphQL API をコピペしてくれば GraphQL スキーマとサーバーが出来ますし、クライアント側でも型が自動生成できてラクが出来そうという目論見がありました。SQLite に対して直接 SQL クエリを走らせても良かったのですが、自分が元の管理画面の実装にそこまで詳しくなかったというのもあり、どこまで複雑なクエリが必要になるのか実装前の時点で判断しづらかったためコピペにしてしまいました。

実装

方針が決まってしまえば後は実装するだけです。API サーバーは元の Rails のコードをそのままコピペしてきて動かすようにし、フロントエンドは create-next-app しました。Next.js 13 で App Router を使いつつ、Static Exports のために next.config.js が

const nextConfig = {
  output: 'export',
}
module.exports = nextConfig

になっています。*5

next build をする際には横で SQLite に繋がった Rails 製 GraphQL API を動かしていて、必要に応じて API にリクエストすることでデータを取ってきます。Static Exports の場合はビルドが終わると HTML 等のファイル群が生成されるので、開発環境では serve を使うなどすれば閲覧できます。

実装の移植について、ページや React Components の移植はまあまあコピペで終わりました。元々の実装がそれなりにコンポーネント化されていて全容を把握しやすかったのと、元と変わらず GraphQL を使っているあたりが効きました。

ただし Server Components と Client Components、つまりどのコンポーネントの処理はサーバー側で行われどの処理はブラウザ側で行われるのかについては整理する必要がありました。Static Exports を行ううえでは API からデータを取得する部分はすべてサーバー側で行われていないといけませんし、逆に useState を使うような箇所はブラウザ側で行われなければなりません。元々の管理画面ではブラウザから GraphQL リクエストを行っていたため、至るところにある API リクエストはサーバー側に集中するよう書き換えが必要でした。データの流れを整理した結果としてひとつのコンポーネントを分割して Server Component と Client Component に分けたりもしました。

とはいえ「データの追加や更新はもう行わない」という制約がとても強く効いていて、実装は比較的シンプルになりました。データの追加・更新のために存在していた React コードをばっさり削除していった結果、大抵のページは「Server Components でデータを取得して、子となる UI コンポーネントにデータを渡す。子コンポーネントは必要であれば Client Components にする」くらいの単純さになりました。

Client Components を用いたのは主に、検索フォームを設置しているところと antd 5.8 が必要とするところです。*6

検索機能については、元の管理画面ではサーバー側に検索機能を実装していましたが静的サイトでは不可能なので、ブラウザ側の JavaScript で素朴に String.prototype.includes を使って絞り込むことで実現しました。ページネーションして見た目上の表示件数を減らしはしましたが、それなりの数のデータがあっても高速に動作するのでブラウザは凄いですね……。もし複雑な全文検索が欲しくなる箇所があれば Lunr.js 等を使ってみるつもりでしたが、今回はそこまで複雑な検索は無かったため使わずに済んでしまいました。

そんなこんなで詳細が固まり、まずは複雑そうなページから実装してみたところ上手く動いたため実装を進め、必要なページすべてについて実装しきることができました。

振り返り

全部終わったあと振り返ってみると、Next.js の App Router と GraphQL を使うことにしたのは成功だったと感じます。コピペできたのもそうですし、実装している最中、App Router のディレクトリ構造を使って GraphQL クエリや小さい React Components のファイルたちをページの実装の近くに配置できるのがラクでした。

具体的にはたとえば graphql-codegen で near-operation-file を使うようにしたうえで、以下のようなファイル配置になるわけです。

app
├── _lib
│   └── types.generated.ts
├── recipes
│   ├── _lib
│   │   ├── Table.tsx
│   │   ├── query.generated.ts
│   │   └── query.graphql
│   └── page.tsx
:
:

元々の実装もページごとのコードとページ固有の React Components、それとたまに全体で共有の React Components という感じだったので、それをそのまま持ってくることが出来ました。データの流れを整理する過程で小さい Server Components や Client Components が生まれたのですが、このディレクトリ構造だと気になりません。

Static Exports は何の問題もなく動いてくれました。移植の際に <a> タグをすべて next/link の <Link> に書き換えたため、ただでさえ静的サイトで速いのに prefetch のおかげで更に速く感じられるサイトが出来上がりました。

いちおう実装前の不安点として、antd などの依存ライブラリは実装時点での最新バージョンまで上げないと App Router および React Server Components 対応の不充分な点がありそうだとは分かっていました。この関係で元の管理画面で使っていたバージョンからメジャーバージョンを上げないといけないライブラリもありました。とはいえ実装前にザーッと各ライブラリの変更履歴を眺めた結果そこまで困らなさそうと判断し、実際あまり困りませんでした。

また App Router の関係なのか Static Exports の関係なのかはちゃんと調べていませんが、いくつかのエラーで原因を調べるのに少し時間がかかりました。エラーメッセージとスタックトレースが分かりづらかったのですよね……。"use client"; をつけて Client Components にしたら直るのだけどエラーメッセージから直接は分かりづらい類のエラーにはいくつか遭遇しました。

とはいえ始まりから終わりまで見ると、そう労力をかけずに移植できたので満足です。この管理画面が提供しているデータは社内を見渡してもそれなりにユニークな料理データでして、これを参照しやすい形で残し続けられることには価値があると、個人的にも考えています。そういった意義のついでに技術検証も出来たオトクな仕事でした。

*1:https://techlife.cookpad.com/entry/2019/10/18/090000

*2:https://techlife.cookpad.com/entry/2021/06/11/120000

*3:もちろん静的サイトにしたとしても JavaScript ライブラリの更新が必要になる可能性が無いとは言えないのですが。数年くらい経って新しいブラウザーでうまく動かなくなったときくらいでしょうかね。

*4:この管理画面を開発していたエンジニアはサービスクローズ後も社内に在籍しているのですが、このあたりの技術検証をしてみたいという自分の要望から自分が実装してみることにしたのでした。

*5:最終的な next.config.js では更に、next/image の最適化は Static Exports では意味が無いので unoptimized: true にしたり、実装の都合で trailingSlash: true にしたりもしています。GitHub Pages にデプロイする前には basePath を調整するのもやっています。

*6:antd で必要になるのは https://ant.design/docs/react/use-with-next#using-nextjs-app-router に書かれている "if you use the above sub-components in your page, you can add "use client" to the first line of the page component to avoid warnings" です。

Rubyの並列並行処理のこれまでとこれから

技術部の笹田です。今日で退職するので、バタバタと返却などの準備をしています。

本記事では、Rubyの並行並列処理の改善についての私の取り組みについて、おもに RubyKaigi 2022 と 2023 で発表した内容をもとにご紹介します。

並行と並列はよく似た言葉ですが、本記事では次のような意味で使います。

並行処理(concurrent processing)は、「複数の独立した実行単位が、待っていればいつか終わる(もしくは、処理が進む)」という論理的な概念で、古典的にはタイムシェアリングシステムなどが挙げられます。

並列処理(parallel processing)は、「複数の独立した実行単位のうちのいくつかが、あるタイミングで同時に動いている」という物理的な概念で、古典的には複数のCPU上で同時に実行させる、というものです。最近では、1つのCPU上で複数コアが同時に動いている、というのが普通になってきましたね。

Ruby(CRuby/MRI)は古くからThreadによる並行処理のための仕組みを提供しており、並列処理はUnixなどのプロセスなどを用いる、つまりRubyの外側(?)の機能と組み合わせて使う必要がありました。そして、Ruby 3.0から導入された Ractor で Ruby プロセス内で利用できる並列処理の仕組みが導入されました。まだまだ不十分なので、これからもっと頑張っていこう、っていう内容の記事になります。

簡単な歴史

Ruby 1.8 まで

Ruby 1.8 までは、Rubyはユーザーレベルスレッドと呼ばれる仕組みで、OSなどが提供するネイティブスレッド(下記NTとも表記、PthreadやWindows APIのスレッド)を1つつかって、複数のRubyスレッド(下記、RTとも表記、Thread.new{}で作るやつ)を管理していました。1つのネイティブスレッドで複数(M個)の Rubyスレッドを管理するので、M:1 モデルということもあります(世間的には 1:N スレッドモデルということが多いのですが記事の都合上、M:1 と書いておきます)。

M:1 (N:1) model, Green threads, user level threads, quoted from my RubyKaigi2022 talk

複数のRubyスレッドは、(1) I/Oや sleep、Mutex などで待ちが入るタイミング、(2) 一定時間(タイムスライス)経過したときのタイミングで切り替わります。I/O に関しては、select(に類する)システムコールで準備できたかを定期的に確認しながら管理します。

この手法の利点と欠点は次の通りです。

  • 利点:
    • ユーザレベルスレッドなので、生成は速い
  • 欠点:
    • 当時はRubyスレッドの切り替えをスタックを丸々コピーするという手法を使っていたので、結構遅いものでした。
    • select で待ちを制御できない処理、たとえば waitpid()flock()、ドメイン名解決、待ちが入るようなライブラリ関数などで待っていると他のRubyスレッドに切り替わらない
    • ポーリング出来る処理は都度ポーリングで対処していたが、スレッド数が増えるとスケールしない可能性がある
    • ネイティブスレッドを占有するほうが都合がよいライブラリ(GUI系など)を素直に使えない
    • 作るのが(メンテし続けるのが)結構大変

Ruby 1.9 Thread

Ruby 1.9 では、Rubyスレッド一つにつきネイティブスレッド1つ用意する 1:1 モデルに変更されました。というのも、ユーザレベルスレッドはいろいろ tricky で、実際に実装していた私には手がおえなかったからです。また、1:1 モデルにすることで、並行処理だけでなく、並列処理への拡張も視野に入っていたからです。

1:1 model, quoted from my RubyKaigi2022 talk

Ruby 1.9 で 1:1 モデルを導入する際には、GVL(Global VM Lock)というのを導入しました。ただ一つのGVLをロックしているRubyスレッドしか動かない、というモデルとすることで、同時にRubyスレッドはたかだか1つしか動かない、というのものです。そのため、Rubyスレッドは相変わらず並行処理をサポートしますが、並列処理はサポートしません。いろいろな理由はあるのですが、要は「実装が簡単だから」ということに尽きます。

(余談:どちらも簡単に拘っているのは、手抜きしたいというのが本音なのですが、建前でいうと、マンパワーのない状況で安定した処理系を提供するためには適切なトレードオフであるのではないかな、と思っており、この判断は今振り返っても妥当だったんじゃないかなと思います。2006年ごろですかね)

  • 利点:
    • 1:1 スレッドモデルは結構簡単
    • GVL があるので並列に実行されないためにさらに簡単
    • GVL を解放することで(C-extension)、ブロックする処理をしている間、他のRubyスレッドに処理を移すことができる
    • GVL を解放することで(C-extension)、複数の処理(Rubyで記述してある処理ではない)を並列に実行することができる。例えば、Bignumの時間がかかる計算や、I/O の処理(Ruby インタプリタとは独立した処理)など。
    • Rubyスレッド切り替えはネイティブスレッドによって実装されるので速い
    • スケジューリングをネイティブスレッドに任せてしまうので楽
  • 欠点:
    • 1:1 モデルなので Ruby スレッドを1つ作るたびにネイティブスレッド 1 つ必要になり、Rubyスレッド数がスケールしない(ネイティブスレッドの実装による)
    • GVL があるので並列実行できない
    • Rubyスレッド間で処理を受け渡すような処理が遅い(CPU core 間で処理を頻繁に切り替えるときに遅い)
    • スケジューリングをネイティブスレッドに任せてしまうので、Ruby側からの細かい制御が難しい

Ruby 1.8 であった問題がだいぶ解決されているのがわかると思います。そのほかはトレードオフですね。

Ruby 1.9 Fiber

Ruby 1.9 では、ユーザレベルスレッドの利点をちょっとのこしておこうということで Fiber が導入されました。Fiber は Ruby 1.8 のユーザレベルスレッドとほぼ同様ですが、タイムスライスがなく、I/O などで自動的にスイッチすることのない、という点が異なります。

Fiber は当初は Ruby 1.8 のスレッド切り替え処理をそのまま踏襲していたのですが、のちのバージョンで改善され、今では CPU ごとにアセンブラを用いて記述する、というものになりました。

Ruby 3.0 の Fiber scheduler

Ruby 3.0 で導入された Fiber scheduler は、I/O など、ブロックするような処理を契機にフックを呼び出す仕組みで、自分で Fiber をスケジュールする処理(これを総称して Fiber scheduler)を記述するための仕組みです。実際には、自分で記述するのではなく、すでにある gem を利用するといいでしょう。

  • 利点:
    • ユーザレベルスレッドの利点(低コストな生成)を得られる
    • 自分でスケジューラーを記述できる
    • タイムスライスがないため予測可能性が上がる
  • 欠点:
    • Ruby 1.8 スレッドと同じ(管理できないブロックする処理では他のFiberに切り替えられない)
    • Fiber を意識する必要がある
    • タイムスライスがない

アプリケーションに特化したスケジューラを記述できるというのは、最高性能を目指すという観点からは良いものですが、多くの場合 too much じゃないかなぁ、というのが私の感想です。

Ruby 3.0 の Ractor

そもそも Ruby スレッド、というかいわゆるスレッド一般って、変更可能(Mutable)データの共有によるデータレース、レースコンディションの問題があり、Mutexなどで正しく排他制御が必要です。なので、私は、スレッド難しいなぁ、あんまり便利にしたくないなぁ、という意見を持っていました。この問題に対処するために、例えば他の言語では次のような工夫をしていました。

  • 領域を完全に分けてしまう: Unix などのプロセス、Racket(のPlace)
  • すべてのデータを Immutable にして、Immutable なデータしか共有できないようにする: Erlang, Elixir(のProcess)
  • 型でいい感じになんとかする: Rust
  • データを共有する方法を標準化する: Java, Golang(Goroutine)
  • 実行時にやばそうなところを検知して修正する: Valgrind, Thread sanitiser, ...

Rubyで取れそうな戦略は何かな、ということを考えて、「"Immutable なデータしか共有できないようにする" なら、なんとかなりそうかな?」という発想で Ractor を設計しました。イメージとしては、Unixなどのプロセスに近いですが、共有可能な部分は共有するし、Mutable でもロックを必須とするなら共有可能にする、というところが近いです。

  • 利点:
    • 並列実行が可能
    • データレースなどの問題が原理上起こらない
  • 欠点:
    • 共有可能オブジェクトを制限するので、ふつうのRubyプログラムが multi-Ractor の上では動かない
    • 実装が悪いので色々遅い

「実装が悪い」という部分は、改善すればいいのですが、いくつか大きな問題がありました。

  • 1:1 スレッドを踏襲している(Ractor 内に 1 個 Ruby スレッドを作るが、それが 1 ネイティブスレッドを要求する)
  • すべてをとめて GC しなければいけないので Ractor 数にスケールしない

欠点の1つ目の問題から、なかなか利用されず、ではスクラッチで Ractor ベースに書き直せばよくなるか、というと2つ目の欠点である実装が悪いという点から利用も進まない、となれば順当に実装をよくしていくぞ、というのが今年の RubyKaigi 2023 での私の発表でした。

“Ractor” reconsidered, or 2nd progress report of MaNy projects

"Ractor" reconsidered - RubyKaigi 2023

要点としては、

  • Ractor はせっかく入ったんだけど
    • 既存のコードがすぐには動かないから使われていない
    • 性能が悪いことが多くてなかなか使われていない
  • とりあえず性能よくすることで、小規模なコードから使われるんじゃないだろうか
  • そのためにはこういうことをやったし、これからこういうことをするよ

Future expected situation on Ractor, quoted from my RubyKaigi2023 talk

というものでした。

M:N スレッドの導入

というわけで「こういうことをしたよ/するよ」という話です。

まず、RactorやRubyスレッドは、1:1モデルであることで、生成が遅かったり生成できる数が少なかったりします(1:1モデルの欠点)。そこで、M:N スレッドモデルを導入できないか、今(というか去年から)開発中です。

という内容が RubyKaigi 2022 での私の発表でした。

Making MaNy threads on Ruby

Making MaNy threads on Ruby - RubyKaigi 2022

ちなみに、M:N スレッドモデルの実装なので MaNy プロジェクトというコードネームを中田さんにつけてもらいました。

  • M:1スレッドは、RubyスレッドM個にたいしてネイティブスレッドが1個(Ruby 1.8 まで)
  • 1:1スレッドは、Rubyスレッド1個にたいしてネイティブスレッドが1個(Ruby 1.9~)
  • M:Nスレッドは、RubyスレッドM個にたいしてネイティブスレッドがN個

というモデルです。N をコア数にすることで、十分小さい数のN個のネイティブスレッドで、M個のRubyスレッド(例えば1000個)を賄おうというものです。これで、「1:1スレッドモデルがスケールしない」という問題が解決します。

Thread system implementation techniques, quoted from my RubyKaigi2022 talk

欠点としては、実装が複雑であることですが、そこは頑張りました/頑張っています。

同じRactorに属するRubyスレッドは同時並列には動かないので、1つしかRactorを動かさない場合はNをいくら多くしても2個以上のネイティブスレッドを使うことはありません。なので、その点はユーザレベルスレッドと同じです。 あまりRubyスレッドをよくしたいという動機はないのですが(Ractor 使ってほしい)、副次的に現在のマルチ Ruby スレッドプログラムがユーザレベルスレッドモデルを用いることで改善されることもあるかもしれません。

M:1 Thread level scheduling in a Ractor, quoted from my RubyKaigi2022 talk

ユーザレベルスレッドで問題となっていた、どーしょーもなくブロックしてしまう処理は、そのブロックしてしまう(可能性のある)処理を実行中、1:1スレッド、つまり1つのRubyスレッドが1つのネイティブスレッドを占有する、という状態にしておきます。ちなみにこの状態から戻らないと、他のRubyスレッドに処理がうつらないような気がしますが、ちゃんと移すために、準備のできたRubyスレッドを実行するためのネイティブスレッドを1個余分に追加します。つまり、N はそのようなスケジュール可能なネイティブスレッドの数であり、占有されている状態のネイティブスレッドは数に入れません(上限なしにネイティブスレッドが作られる可能性がありますが、動かないよりまし、というスタンスです)。

Handle unmanaged blocking operations, quoted from my RubyKaigi2022 talk

この工夫により、ユーザーレベルスレッドモデルで問題であった「何か処理が止まってしまう」ときに別のRubyスレッドに切り替わらなくなる、という問題が解決します。 M:Nスケジューラは、切り替わらなくなるかもしれない、といった危険のなくなった、そしてタイムスライスで切り替わる(プリエンプションがある)組み込みの Fiber scheduler みたいなもの、というとらえ方もできると思います。

このM:Nスレッドの実装は、Go language の構成によく似ています。ちょっとした違いとしては、goroutine はどれも順不同で実行できるのですが、Rubyのスレッドは同じRactorに所属している場合、同時に動くことはできない(GVLをもつRubyスレッドしか動かせない)、という制約です。この制約を満たすため、Ractor内のRubyスレッドについてのスケジューラと、どのRactorを動かすか選ぶRactorについてのスケジューラの2つのスケジューラによる構成となっています(もちろん細かい違いはほかにもいろいろあります)。

M:Nスレッドは、多くの場合で(ちゃんと作って有れば)問題ないと思われるのですが、どうしても仕組み的にネイティブスレッドの Thread local storage に依存した作りになっているコードを利用すると破綻する、という問題があります(あるRubyスレッドが異なるネイティブスレッドで実行するようになるため)。そこで、今のところ M:N スレッドモデルはデフォルトではオンにならないようにしようということにしています。より正しくは、メインRactorの中でRubyスレッドを作る場合(つまり、ふつうのRubyによるスレッドプログラムの場合)、1:1 スレッドという従来のスレッドモデルで実行されることになります。複数Ractorを利用する場合は、メインRactor以外はM:Nスレッドモデルで実行されます。

今のところ、RUBY_MN_THREADS=1 という環境変数でメインRactorでのM:Nスレッド対応を指定出来るようにする予定です。もし M:N スレッドの実装がマージされたら試してみてください。ちなみに、ネイティブスレッド数の上限Nを指定するには、今のところRUBY_MAX_CPU=nという環境変数で指定できるようにする予定です。

詳細は当該チケットをご覧ください: Feature #19842: Introduce M:N threads - Ruby master - Ruby Issue Tracking System

性能改善などはあまりきちんとできていないのですが、去年の RubyKaigi 2022 の発表で、少し述べています。場合によってはだいぶ速くなっています。

Ring example, quoted from my RubyKaig 2022 talk

Ring example, compare with Go/Loop time, quoted from my RubyKaigi 2022 talk

Ractor local GC の導入

現在 Ractor が遅いもっとも大きな理由が GC です。並列に実行される Ractor ですが、GC をするためにはすべてを止める必要があり、とめた状態で唯一のネイティブスレッド上でGCが実行されるようになっています。これは色々遅いので、RactorごとにGCをそれぞれ並列に実行する、というRactor local GCができないか試行錯誤中です(他の方が試験実装中)。

Ractor local GC, quoted from my RubyKaigi2023 talk

これを実現するためには、Ractorがなまじっか Immutable オブジェクトは Ractor 間で共有できるといった仕組みから、きちんと動かすためには分散GCが必要になります。現在、実装しながら問題を見つけ解決していくような手探りな感じで開発を進めています。来年くらいに何かご紹介できるといいですね。

Ractor local GC needs distributed GC, quoted from my RubyKaigi2023 talk

おわりに

本稿では、Rubyの並行並列処理について外観し、利点と欠点をまとめました。そして、それらの欠点を解消するために M:N スレッドモデルの実装を行っており、現状をご紹介しました。また、さらにまともな性能にするためにはRactor local GCが必要であるということをご紹介しました。

いまのところ、M:Nスケジューラは、1:1モデルにしておけば(デフォルトです)テスト通っているのですが、M:Nスケジューラを有効にすると「あれー?」というところでバグを出すので、Ruby 3.3 にマージできるのか予断を許さない感じです。がんばろ。

クックパッドでは、実はずっとこの辺をやっていました。

  • 2016: Guild (のちの Ractor)構想の発表
  • 2017: Ractor につなげるための、Fiber 周りの整理(この年にクックパッド入社)
  • 2018: Ractor の実装の検討
  • 2019: Ractor の実装(RubyKaigi 2019 では Ruby で MRI を書くための話をしていたけど)
  • 2020: Ractor の入ったRubyのリリース(Guild -> Ractor に名前が変わったのもこの年)
  • 2021: Ractor もデバッグできるようにするために debug.gem の開発(まだ Ractor では動かないんだけど)
  • 2022: M:N スケジューラ構想の発表とプロトタイプ
  • 2023: M:N スケジューラで Ractor を動かせるように

(やっていたのはこれだけじゃないけど)同じテーマで何年やってるんだ、という気もしますが、長い目で開発を支えてくれたクックパッドに深く感謝します。

そんな感じでまだまだやることがイッパイありますが、「並列並行処理を書くならRubyもいいね」といってもらえるように、これからも頑張っていきたいと思います。

読まなくてもよい余談です。私の卒論(2002)はスレッドライブラリ(pthread)の実装でして、20年たっても似たようなことしかやってねーな、という感想があります(でも、20年ずっと楽しいので幸せ。ちなみにYARV開発は2004年からなのでもうすぐ20年)。M:N スケジューラはその頃から考えてはいたんだけど、当時は逆に遅くなるかもなぁ、などと思っていたところでした。Go がだいたい同じようなことをしている、ということを確認できたので、結構自信をもって進めているという次第です。まぁ、Go はほかのランタイムを全部自分でかいているので、TLSみたいな互換性問題があまり起きないというところはあるとは思うんですが。2018年のRubyConf で、まつもとさんの部屋でこんな構想を話して、「でも2020年には間に合わないよなー」と言っていた内容が、やっと形になってきました。うまくまとめたいな。回り道しすぎ?

というわけで、またどこかで成果をおみせできることを楽しみにしています。

Happy hacking!

TechMTG文字起こしレポート:クックパッドマートのAndroidアプリのUI開発のこれまでとこれから

こんにちは、CTO室の緑川です。クックパッドでは隔週で全エンジニアが集まるTechMTGというミーティングを行っています。今回はTechMTGで話した技術的な取り組みや解説を文字起こしレポートとしてお届けします。

今回は4月19日に発表されたクックパッドマートでのAndroidアプリのUI開発についてです。 以下、レポート本文です。

クックパッドマートのAndroidアプリのUI開発のこれまでとこれから

こんにちは、門田です。 2016年に新卒入社をして2019年にクックパッドマートに移動し、今は買物プロダクト開発部というところでAndroidエンジニアをしています。

今回はAndroidアプリのUI開発の話をしようと思います。 Jetpack Composeの話がメインになると思っていたんですけど、去年の11月や先月にもJetpack Composeの話は社内でしていたので、今回はそんなに話すこともなくなったかなと思ったのですが、「クックパッドマートでJetpack Composeをどういうふうに使ってきたか?」という話はできていなかったと思うので、今回はそれを話そうかなと思っています。

アプリの実装で最も時間がかかる部分

最初に問題定義をしたいのですが、アプリの実装をするときに最も時間がかかるところって皆さんどこだと思いますか? 僕の中ではなんといってもUI開発が一番大きいと思っています。今回はこれを前提に話していこうかなと思います。

アプリの開発をしていく上で素早くUIを構築することが、素早く機能を開発していくことに最も寄与するんじゃないかなと思っています。じゃあそれをどうすれば実現できるのだろうというところを考えたときに、パターン化して簡単に誰でも早く開発できればいいんじゃないかということを考えて、クックパッドマートの開発初期から結構そのことを考えて実装してきました。

2019年にクックパッドマートを作り始めたときはAndroid View、いわゆるXMLを使って構築して、コードで味付けしていくみたいな方法が一般的だった時代なんですけど、この時にはRecyclerViewという要素をいっぱい並べるライブラリと、あとそれを簡単に使うためのGroupieっていうライブラリを使って複雑な画面の構築を簡単にしていくのをやっていました。これのメリットとしてはレイアウトファイル、たとえば商品詳細だったりとか、長いレイアウトのファイルでもある程度のまとまりごとに分割して組み立てていくことができるので、全体がシンプルに構築できるということに一つ大きなメリットがあります。

あとは一覧画面と詳細画面みたいなところで実装のパターン化ができて、これも実装速度の向上に大きく寄与をしたんじゃないかなと思っています。これに関しては当時テックブロックにも書いてたのでこれを参照してください。

techlife.cookpad.com

具体的にはどんなふうになっていくかというと、上の方からSectionっていう画面の単位を分けていってそれを横側に書いていくと、画面全体の構築も割と見やすい状態になっているし、一つ一つのレイアウトのファイルの単位も左側のlayout XMLが構築されていたら右側でそれに対して名前を入れたりとか画像を設定したりとか、というふうに設定していくと結構わかりやすいシンプルなレイアウトを組めたんじゃないかなと思っています。

ただ、この辺は開発上の問題が結構あって、たとえば動的なUIの更新というのが地味に難しかったりするんですよね。たとえば、要素を追加したりとか削除したりとかチップみたいなUI要素を入れたい時とかにViewを追加削除するのは結構力技になりがちで難しかったり、角丸とかボーダーとかの表現がAndroidだと地味に面倒くさいところがあってすごく難しいところでした。

あとは複雑なUI状態というのがLayout XMLのPreviewって一つしかないので、実際にデータを当てはめて確認するしか道がないので結構難しいというのがあります。クックパッドマートだと特に商品の受け取りの状態って複数あって、6・7種類ぐらいあるんですけどステージング環境だとその状態を再現するのにも一苦労するので、その動作確認がすごく大変だったっていうのがあります。あとは全体をコードで組み立てているので画面全体の構築っていうところが確認できないのが結構難しいところでした。

たとえばUIを追加とか、削除とかするみたいな話なんですけど、jQueryとかを見てもらえると分かると思うんですけど、特定のdivタグに子要素のビューを全部消してもう一回入れ直すみたいなことを力技で入れていた気がして、実行してみないとうまくできたかわからないとか、入れたところのビューの大きさだったり要素間のマージンだったりとかが結構調整しづらかったりとか、そういう問題がいっぱい起きました。あと角丸をつけたいだけでこれだけ書かなきゃいけなくて、これをbackgroundに設定するみたいなことをやらなきゃいけなくてすごく面倒くさかったっていうのがあります。

あとはパターン化を考えるときに、たとえばこのおためし価格っていうところは、当日適用されるパターンとされないパターンがあるんですけど、されないパターンをどういうふうに確認するのかというと、されないパターンを心の目で感じ取って実装するしかないというのが結構きついです。

AndroidとかiOSの開発って結構レイアウトが崩れることがあると思うんですけどこういうところが大きいんじゃないかなと僕は思ってます。

Jetpack ComposeはUI開発の何を変えたか?

2022年は結構Jetpack Composeの年になったかと思うんですけど、Jetpack ComposeってUI開発の何を変えたかっていうと、そういう動的なViewの追加削除だったりとか角丸だったりとかボーダーだったりとかそういう今までちょっとやりづらかったUIの表現だったりとかを簡略化してくれたのが結構大きいかなと思うんです。

他には高性能なPreviewを作ってくれたっていうのが僕の中では一番 Jetpack Composeの中でいいと思っていて、これが強力すぎてこれを扱うための開発スタイルの設計を去年はしていました。Jetpack Composeは直感的にif文を書いたりとかして条件式によってこのViewが表示されるされないみたいなものを制御できたりとか、あと角丸を表示するときに.clipって書くだけで一行で表現できたりとかこういうのが本当に助かりますね。

Previewのここがすごい

あと、今日はPreviewの話をすごくしたいんですけど、Previewはめちゃくちゃ良くて、PreviewのすごくいいところはPreviewを自分で書くことができるところです。逆に言うと自分で書かないといけないんですけど、任意の要素に対してPreviewを自分で書けるんです。なので、どんなPreviewでも自分で作ることができて、つまり同じUIに対しても複数のデータを入れてPreviewを書くことももちろんできるというのがかなり強いところかなと思います。あとはLive Editってホットリロードみたいなやつですね。Composeのコードを編集していると横で自動でコンパイルを走ってPreviewがどんどん更新されていってその状態が見えるという感じになってます。

Previewが出たばっかりのときはAndroid Studioが結構不安定でうまく動かなかったときもあったんですけど、最近はかなり安定して使えるようになってきたので、これなしではUI開発が進まないというぐらいに最高になってきました。

あとInteractive ModeだったりとかDeploy Previewっていうのがあって、作ったPreviewを実機とかエミュレーターにもインストールできるんです。たとえばボタンを押したときにトースト通知を表示してくださいとか、ボタンを押したときにこの要素を隠してくださいとかそういうイベントとかも全部出てきているので、実はPreviewだけでアプリも作れて軽いプロトタイピングみたいなところもできるというのが結構大きいなと個人的には思っています。

で、これをどういうふうに扱っていこうかというところを考えたときにLive Editにすごく注目していて、Live Editを有効に扱うためにはアプリのコンパイル時間を短くする必要があるんです。   なんでかと言うと、さっき言ったとおりLive Editするときは横でコンパイルを走って自動でPreviewが更新されるという話をしてたので、逆に言うとコンパイル時間が長かったら編集している際にコンパイルがすごく時間がかかって、Previewが表示されるまで30秒とか1分とかかかるっていうような状態になると結構きついと思うので、その時間を短くする必要があります。Composeの関数を書くためだけのモジュールを各フィーチャーごとに分離するような開発手法をクックパッド マートでは取り組みました。で、これでPreviewしたいモジュールだけビルドをすればよくなるので、かなりコンパイルも楽になったという感じです。

あとは表示パターンをPreviewで網羅できるようになったということで、ちゃんと網羅しようといういくつかの実装ルールだったりとかも決めたりしました。 実際にPreviewを書くと@Previewって付いているアノテーションが書いてある関数がPreview用のComposeの関数で、実際のComposeの関数に対して引き数にデータを渡してあげれば右側にデータが出てきます。上に書いてあるProviderが実際にこのPreviewに渡したいデータの一覧みたいな感じになっていて、これを増やしていくことでいろんな状態のPreviewをいっぱい作ることができたりします。

で、これ受取一覧画面の受取のデータの種類をバーッとPreviewで作ってみたいやつなんですけどこれすごく便利でアプリで実機で実際に確認しようとするとこれ全部再現するのめちゃくちゃ大変なんですけど、Previewで確認するだけでこんなに簡単に作れるっていうのはすごく良かったなっていう感じです。

あとはさっきのモジュールの分離の話をすると、モジュールはいっぱいわけています。で、Previewに必要なビルド範囲を最小限にしています。アプリのフルビルドって結構時間かかっていてクックパッドマートのアプリだと5分とか、最悪7分くらいかかったりするんですけど、フィーチャモジュールの一つだけのビルドとかだと数十秒程度で終わったりするので、これで開発速度が変わっているなという印象がありますね。

あとは開発方針の話でいくつかの実装ルールを分けた話を軽くしたと思うんですけど、画面全体のことをScreenというふうにクックパッドマートでは呼んでいって、区切り線で囲まれた部分をSectionというふうに呼んでいます。あとはSectionをいくつかの単位でまとめたのをContentというふうに呼んでいるんですけど、実装に落としていくとContentが並んでいるみたいな実装だったり、ファイルを分けてそのContentの中身にはラベルにあるテキストとSectionとみたいな感じでそれぞれ分けているというふうにして、しかもこれに対してそれぞれでPreviewも作れるので画面全体の構築もそうだし画面の細かいところに対してのUIのPreviewもかなり簡単に作れるようになったっていうのがすごく大きい変化だったなというふうに思います。

これからの話

ここまでが2022年ぐらいまで、僕らが今まで開発してきたUI開発の話だったんですけど2023年だからこれからの話でちょっとしようかなと思います。最近Relayっていうツールが気になっていて、これが何かと言うとfigmaのデータをComposeのコードに変換してくれるツールなんですね。で、この辺冷静に考えてみると結構面倒くさい作業で、デザイナーさんがfigmaでデザイン作ってくれて、我々アプリエンジニアがSwift UIだったりとかJetpack Composeでそれを実現するっていう開発フローになってるんですけど、これっていうのはfigmaとSwift UIとJetpack Composeで同じデザインで3回作っているっていうことになっていて、すごくだるくないかっていうふうに最近は考えています。逆に言うと、これをなくせればUIの開発って最高になるんじゃねって最近はちょっと考えたりしています。

コード変換だったりコード生成だったりを行ってくれるツールっていろいろあると思うんですけど、このRelayってツールは何がいいかっていうとAndroid Developersが公式で出してるツールなんですよね。だからちょっと興味があって、ちょっと見てるんですけどまだまだな段階といえそうな予感はしています。

ちょっと使ってみたんですけど、商品詳細画面のfigmaをComponentとしてRelayに喰わせるとComposeの関数を勝手に作ってくれます。今はレイアウトとか崩れちゃっているんですけど、フォントを調整して上手くいくととわりときれいに描画されたりとかして、結構簡単に作れてUI開発がわりとスムーズになるんじゃないかなというふうに妄想してるところです。

Relayを簡単に触れてみた所感としてはfigmaのリンクを貼るだけでコードが生成されるので簡単に使えるっていうのはすごくよくて、デザインのアップデートをfigmaにして、実装も右クリックで簡単にアップデートできるのでいいんじゃないかなと思ってるんですけど、Android Studio上からアップデートするしかなくてCIとかで更新するのがちょっと難しそうっていうところはひとつネックかなと思ってます。あとはfigmaのデザインがAuto Layoutっていう結構きれいにかかなきゃいけないという仕組みを使わないといけなくて、それがちょっと面倒くさいなっていうところがあります。

あと細かい制御が難しいっていうところがあるんですけど、割り切って使える場面はありそうかなと思っていて、共通Componentになるものだけ切り出すとか、そういうところはうまく使えるところがないかなって探しながら妄想しているところです。クックパッドマートでは細かく定義されてるものがあるので、小さなComponentぐらいだったらうまく使えないかなっていうのはちょっと妄想してたりします。

クックパッドマートのAndroidのUI開発についてたくさん話してきたんですけど、Jetpack Composeはいいぞと今まで皆さんも聞いてきたと思うんですけど、Jetpack Composeはいいんですよ。特にLive Previewが最高にいいので、皆さんもぜひ使ってみてください。あとは最後に言ってた通りデザインをコードに写し込む写経みたいな作業をいつか終わらせられたらいいなと思っているところです。