Ruby フルタイムコミッタの仕事報告 2023年Q2-3

こんにちは、hsbt です。前回のエントリからしばらく経ってしまい、引き続き原神や崩壊・スターレイルをプレイしつつ、アサシンクリード・ミラージュやスパイダーマン2など、ホリデーシーズンに向けたゲームラッシュでいよいよ時間がなくなってきました。

今回は RubyKaigi 2023 以降、主に 2023 年の夏から秋にかけての Ruby のフルタイムコミッタの活動についてご紹介します。

Euruko 2023 への登壇

今年の夏は Ruby 本体や RubyGems や Bundler の開発はもちろんのことですが、9月に開催された Euruko 2023 の登壇の準備が中心になりました。Euruko とはどういうカンファレンスなのかを知らない方のために簡単に紹介をします。

Ruby の国際カンファレンスには日本で開催される RubyKaigi 、米国で開催される RubyConf などがあります。Euruko は RubyKaigi と RubyConf と並んで世界3大Rubyカンファレンス(諸説あり)の1つでヨーロッパで開催される Ruby のカンファレンスです。

Euruko は次の開催地を毎回立候補と参加者の投票で決めて、ヨーロッパの中を巡回しているのが特徴的です。RubyKaigi が首都圏などに縛られずに日本の国内の各地で開催されていますが、これのヨーロッパバージョンとイメージしてもらえるとわかりやすいと思います。

そして、昨年の Euruko 2022 の開催はフィンランドのヘルシンキでしたが、今年の開催地はフィンランドの少し南、リトアニアの首都のヴィリニュスでした。実際のカンファレンスへの移動や登壇、会場の雰囲気は私の個人の日記に記載しているので興味があればそちらをご覧ください。

なお、来年はリトアニアから更に南にあるボスニア・ヘルツェゴビナのトゥズラという都市だそうです。最初聞いたときは何処...?となりましたが、機会があれば来年も参加できるように Ruby で成果を出していきたいです。

さて、今回は Euruko 2023 で発表した RubyGems や Bundler の内部の紹介の中から、Bundler が行っている PubGrub を用いた依存性解決にスポットをあててご紹介します。

www.slideshare.net

今回の内容のスライドのフルバージョンは上記をご参照ください。

パッケージマネージャが備えるべき3つの機能

Bundler や PubGrub について話をする前にパッケージマネージャとは何か、について簡単にご紹介します。近年では、何かしらの言語を用いてソフトウェアを作成、または動かす際には、言語の本体機能だけではなく「よくある同じ処理」を何かしらの単位でまとめます。

そのまとめる単位のことをライブラリと呼ぶことが多く、そのライブラリをインターネットなどのネットワークを経由して共有し、再利用することで、「よくある同じ処理」を何度も記述することなく、ソフトウェアを作成、または動かすことが可能となります。

このライブラリを使うために、共有方法を仲介したり、ライブラリを共有するためのパッケージとして作成したりするソフトウェアがパッケージマネージャです。モダンなパッケージマネージャに最低限必要とされる機能は3つあると、私は考えます。

  • パッケージ操作のためのユーザーインターフェース
  • パッケージ間の依存性解決(= Dependency Resolution)
  • 依存性解決された複数のパッケージのみでソフトウェアを実行する機能(= Version Locking)

近年では Rust の Cargo や Node.js の npm などは上記の3機能にとどまらない、Linter や Formatter、またはタスクランナーやインタプリタのバージョン選択など、統合開発環境とも呼べるような高機能なものも登場したり、高機能なものに近づくような開発が行われているため、「パッケージマネージャ」という単語を聞いてイメージするものが人によって異なっています。

しかし、パッケージマネージャはあくまでもパッケージをマネージするソフトウェアを示していると私は考えるので、本稿では上記に示した3つの機能を提供するものをパッケージマネージャと呼ぶことにします。

さて、私が開発を続けている RubyGems や Bundler はパッケージマネージャですが、細かく機能を分類すると RubyGems と Bundler それぞれ単独では実行できない機能もあります。例えば、Ruby のライブラリの単位である Gem を作成したり、rubygems.org へアップロードする機能は RubyGems しかできませんし、依存性解決されたライブラリのみを用いてソフトウェアを実行する機能は Bundler にしかありません。

余談にはなりますが、このやりたいことに対して実行するコマンドが異なっている、というのはこれから新しく Ruby を学ぶ人にとってだけではなく、先に紹介したような Cargo などのより便利なソフトウェアと比較した場合に相対的な言語の魅力の減少につながるので、少しずつではありますが、RubyGems と Bundler のコマンド体系やコードを統合しています。

PubGrub を用いた依存性解決の方法

前の章で紹介したパッケージマネージャの 3つの機能のうち、依存性解決とは何かを紹介します。まず、ライブラリがライブラリに依存する、とはあるライブラリAを動かすためにはライブラリBを必要とするという状況を指します。

ここで、ライブラリはそれぞれバージョン、例えば v1.0.0 や v2.0.0、v2.1.0 などを持ちます。依存性解決とは、複数の異なるライブラリが異なるライブラリのバージョンそれぞれに依存した状態で依存の条件を満たすライブラリとバージョンの組み合わせを導出することです。

この導出するアルゴリズムを備えたソフトウェアのことを Dependency Resolver と呼びます。RubyGems は Resolver としてMolinillo、Bundler は PubGrubを用いています。なお、Molinillo は iOS 用のパッケージマネージャである CocoaPods でも使われている Resolver であり、PubGrub は Dart 言語のパッケージマネージャである Pub のために開発された PubGrub の Ruby 移植版です。

それでは、PubGrub に実際にライブラリとバージョンを与えながらどのような動きになるかを見ていきましょう。

source = PubGrub::StaticPackageSource.new do |s|
  s.add 'foo', '2.0.0', deps: { 'bar' => '1.0.0' }
  s.add 'foo', '1.0.0'
  
  s.add 'bar', '1.0.0', deps: { 'foo' => '1.0.0' }
  
  s.root deps: { 'bar' => '>= 1.0.0' }
end

solver = PubGrub::VersionSolver.new(source: source)
result = solver.solve
p result
#=> {#<PubGrub::Package :root>=>0, "bar"=>#<Gem::Version "1.0.0">, "foo"=>#<Gem::Version "1.0.0">}

PubGrub では StaticPackageSource のインスタンスに addroot メソッドを用いて依存性解決を行います。add によって依存による制約、root によって求める依存性解決の条件を与えます。この例では、foo の 1.0.0 と 2.0.0、bar の 1.0.0 が存在し、foo の 2.0.0 は bar の 1.0.0 に依存し、bar の 1.0.0 は foo の 1.0.0 に依存するような制約です。

この制約下で、bar-1.0.0 より大きいものを PubGrub によって求めようとします。上記の依存性解決を図で示したものが下記です。

pubgrubのサンプル1の図示

結果として、foo-1.0.0 と bar-1.0.0 が導出されます。また、root として deps: { 'foo' => '>= 2.0.0' } を与えた場合は PubGrub::SolveFailure という例外が発生します。

pubgrub のサンプル2の図示

例外が発生するの上記の様に、foo-2.0.0 またはより大きいバージョンを選ぶ場合には、foo-2.0.0 の依存から bar-1.0.0 を選びますが、bar-1.0.0 は foo-1.0.0 に依存しています。これでは最初に与えた条件の foo は 2.0.0 以上、という条件を満たすことができません。そのため PubGrub は与えられた制約と条件では依存性解決ができずに例外を発生させます。

3つ目として、以下のような例ではどうでしょうか?

source = PubGrub::StaticPackageSource.new do |s|
  s.add 'foo', '3.0.0', deps: { 'bar' => '> 1.0.0' }
  s.add 'foo', '2.0.0', deps: { 'bar' => '1.0.0' }
  s.add 'foo', '1.0.0'
  
  s.add 'bar', '1.0.0', deps: { 'foo' => '1.0.0' }
  s.add 'bar', '2.0.0'

  s.add 'buzz', '1.0.0', deps: { 'foo' => '> 1.0.0' }
  
  s.root deps: { 'buzz' => '1.0.0' }
end

solver = PubGrub::VersionSolver.new(source: source)
result = solver.solve

foo、barに加えて buzz というバージョンが加わり、foo も bar もバージョンが増え、それぞれの依存が少し複雑になりました。このような時に buzz のバージョン 1.0.0 を条件として与えたときの依存性解決はどのように行われるでしょうか? コードの最後に表示される結果は今回は後に示すので一旦考えてみてください。

pubgrub のサンプル3の図示1

PubGrub はまず、buzz-1.0.0 を見に行き、依存である foo-1.0.0 より大きいバージョンを順番に参照します。続いて、foo-2.0.0 を参照し、その依存である bar-1.0.0 を参照します。次に bar-1.0.0 の依存を参照すると foo-1.0.0 が求められています。これでは、buzz-1.0.0 の依存の制約である foo-1.0.0 より大きいバージョンを満たすことができません。そのため、PubGrub はこの時点で、foo-2.0.0 という制約条件を除外して、次の条件を見に行きます。

pubgrub のサンプル3の図示2

次の条件である、foo-3.0.0 は bar の 1.0.0 より大きいバージョンに依存します。bar はバージョンとして、2.0.0 が与えられているので、bar-2.0.0 を参照します。bar-2.0.0 は依存がないので、ここで PubGrub は依存性解決の処理を終了し、最終的に以下の様な結果を導出します。

#=> {#<PubGrub::Package :root>=>0, "buzz"=>#<Gem::Version "1.0.0">, "foo"=>#<Gem::Version "3.0.0">, "bar"=>#<Gem::Version "2.0.0">}

今回示したものは人間が頭で考えても解決できるような例ですが、実際に Bundler で使われる PubGrub の処理はもっと複雑です。

Bundler と Rails の事例

Bundler では、PubGrub へ Gemfile から読み込んだ以下のデータを渡します。

  • Gemfile に記載されている gem 全てと、それぞれについてRubyGems.org に存在するすべてのバージョンの配列
  • Gemfile に記載されている使うべきバージョンと gem の組み合わせ
    • バージョンが未指定の場合は利用可能な最も大きい(=最新)のバージョン
    • バージョンが指定されている場合はそのバージョン

1つ目は文字通りの意味になりますが、例えばみなさんが Gemfile に rails と書いた場合、rubygems.org の rails のページ から確認できるすべてのバージョンを Bundler が保持しています。すなわち、バージョンが多い gem を多く Gemfile へ記載することで PubGrub が処理すべきデータ量が増えることを意味します。

Rails のサンプル1

実際に Bundler が Gemfile からデータを取り出して、PubGrub へ渡した際に一度で解決できることは殆どありません。特に Rails の場合は、Rails と Rails の上で動くことを想定している gem は railtie と呼ばれるグルーライブラリに依存している事が多く、バージョン間で依存解決ができずにすぐに PubGrub::SolveFailure を発生させます。

Rails のサンプル2

Bundler では、この PubGrub::SolveFailure が発生した際にリカバリ処理をうまく行うことで、ユーザーが求めているライブラリとバージョンの組み合わせを高速に導出する用に作られています。このアルゴリズムについては、まだ私の理解がおいついていない面もあるため、今回は「とにかく Bundler はいい感じにやってくれる」とだけ紹介して割愛させていただきます。

まとめ

Ruby のパッケージマネージャである RubyGems と Bundler について紹介し、パッケージマネージャが備えるべき基本的な機能や PubGrub を題材としたライブラリの依存性解決の例について見ていきました。

このライブラリの依存性解決とパッケージマネージャは、私が近年注力している領域です。今や、ソフトウェアを開発するに当たって、必要とされる処理をすべて開発者が記述するということはなく、ライブラリを以下に効率的かつ、効果的に用いるかが主戦場となっています。

私は Bundler を開発した wycats が作った Cargo こそが至高のパッケージマネージャと感じていますが、Node.js や Python でも様々な人々が新しいパッケージマネージャやパッケージマネージャを組み込んだ言語ランタイムを開発しています。言語に加えてパッケージマネージャを使ったときの開発者体験を最大化することが、言語が使われる領域を拡張していくと感じています。

RubyGems や Bundler が Ruby を使う人々にとって、これは良いものである、と感じれるように今後も開発を頑張りたいと思います。アンドパッドでは、「幸せを築く人を、幸せに。」というミッションの実現のため、一緒に働く仲間を大募集しています。 会社や事業、開発チームにご興味を持たれた方は、下記のサイトをぜひご覧ください。

https://engineer.andpad.co.jp/