運用しやすい社内用ライブラリを開発するときに考えること

以前から社内用ライブラリをホストするためにGitHub Packagesを運用できる体制を作ったりしていた。

tech.pepabo.com

ところで、そもそもアプリケーションとライブラリでは開発やメンテナンスにおいて気にする点が少し異なる。そのあたりに関する初歩的なことがらを共有するための社内用ドキュメントを書いていたが、基本的に社内事情はあまり関係なくWebに置いても問題ない内容だったので、ここに置くことにする。

一応、次のコンテキストがあることに注意。

  • RubyGemsのライブラリ (gem) をホストすることが多いのでRubyの話として書く
  • 社内用ライブラリなので、外部サービスのクライアントのように社内横断で共通するロジックをライブラリで切り出すケースが多い
  • (どこもそうだとは思うが)ライブラリの運用を専門に仕事をしている人はおらず、サービスの開発者が片手間でメンテナンスするので、省力化したい
  • 必ずしもOSSとしてのライブラリを開発、メンテナンスしたことがある人が触るとは限らない

はじめに

社内用ライブラリとして、外部決済サービスのクライアントのように複数サービス横断で必要な機能をgemとして実装しておくことで、開発コストを削減できるという利点がある。しかし、毎年Rubyの新バージョンはリリースされ、依存するgemの状況も刻一刻と変化するなかで、ライブラリが必ずしも新しい状況に追従しやすくなっていないこともある。

将来にわたって使いやすくメンテナンスしやすいライブラリにするために考慮すべきポイントをいくつか挙げる。

メンテナンスされているランタイムであれば動くようにする

gemはライブラリなので、利用者の状況(e.g. 各サービスが使っているRubyのバージョン)によって、さまざまなバージョンのRubyで動く可能性がある。最低限、その時点でメンテナンスされているバージョンのRubyであれば、どのバージョンでも動くようなコードにする。また、その範疇にとどまらず、社内の主要なアプリが使っているRubyのバージョンはできるだけサポートしておく。

たとえば、Ruby 3.0がメンテナンス対象である2023年の段階でgemのコードを書くときは、Ruby 3.1で入ったハッシュのキー省略記法は使わない、という感じになる。

foo, bar = 1, 2

# Ruby 3.0が社内で使われているあいだは、gemの中で次の記法は使わない
h = { foo:, bar: }

また、古いバージョンでdeprecatedになった機能などがある場合、どうしてもRubyのバージョンごとに機能の使い分けをしたいなら、バージョンで条件分岐する。が、これは最後の手段。サービスのアプリケーションで対応できるなら、そちらでランタイムをアップデートできたほうが長期的にはよいだろう。

if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.0.0")
  # Ruby 3.0.0以上のときのコード
else
  # Ruby 3.0.0未満のときのコード
end

CIでもマトリックスビルドを使うなどして、各バージョンでテストできるようにしておく。

# GitHub Actionsの場合
jobs:
  test:
    strategy:
      matrix:
        ruby:
          - 2.7
          - 3.0
          - 3.1
          - 3.2
# ...

ライブラリの依存先としてのライブラリがどのバージョンでもできるだけ動くようにする

ライブラリ自体が依存するライブラリ(gemだとgemspecでadd_dependencyを使って依存に追加するgem)のバージョンも利用者の状況によって変化する。たとえば、社内の各サービスで運用しているRailsアプリのGemfile.lockに書かれているすべてのgemのバージョンををgemの開発者が制御できるわけではない。

よって、基本的にはライブラリ開発者が細かく依存先のライブラリのバージョンを指定すべきではない。できるだけ、依存するライブラリがどのバージョンであっても動くようにコードを書く。また、gemの場合は、テストを実行するとき依存先のgemのバージョンが固定されないように、gemのリポジトリではGemfile.lockをバージョン管理から外す*1

とはいえ、依存するライブラリがメジャーアップデートして破壊的変更が入り、その変更には新しいバージョンを発行して対応するという判断になるときもある。そのような場合は依存先ライブラリのバージョンに制約を入れる。

また、移行措置として依存先のライブラリのバージョンによってコードを書き分けたいこともありうる。Rubyの場合は、依存先のgemのバージョンを参照して条件分岐するなどの方法が考えられる。

if Gem::Version.new(FooBar::VERSION) >= Gem::Version.new('2.0.0')
  # foo_bar gemがv2.0.0以上のときのコード
else
  # foo_bar gemがv2.0.0未満のときのコード
end

lib/foo_bar/version.rbに定義するバージョン定数は、gemリリースのたびにちゃんと更新すると利用者の役に立つ可能性があるということになる。

他のライブラリに依存するときはよく考える

上述した項目にも通ずる話として、作るライブラリの規模にもよるが、ライブラリ自体が依存するライブラリは少ないほうが利用者もライブラリ開発者も楽。

他のライブラリを使うにしても、できるだけAPIが安定しているものに依存するのがよい。よくも悪くも全開発者がちゃんと従っているルールではないが、Semantic Versioningだとv0.y.zなバージョンのライブラリは規約上APIが破壊的に変わりうる。

メジャーバージョンのゼロ(0.y.z)は初期段階の開発用です。いつでも、いかなる変更も起こりえます(MAY)。この時のパブリックAPIは安定していると考えるべきではありません(SHOULD NOT)。

なので、APIがまだ不安定なライブラリに気軽に依存することは推奨しない。とくに社内用だと依存先ライブラリのバージョンアップなどで改修に余計なコストをかけたくないという点で、この判断基準の重みが大きくなる。とはいえ、世の中には広く使われているものの長いあいだv0.y.zなままのライブラリもあるので、そのあたりは実際の利用状況をあらかじめリサーチして判断するのがよい。

また、npmなどエコシステムによっては依存が多くなりやすいこともある。そういう場合は、あまり無理に依存を減らそうとせずエコシステムの文化を考慮に入れるほうがいいかもしれない。


まとめ

書いてみて、やっぱり社内用に限った話ではない感じになった。要約は次のとおり*2

  • 社内用ライブラリは、さまざまなバージョンのランタイムで動作可能な設計が必要である。これは、異なるサービスや開発者が様々なバージョンのランタイムを使用しているためで、それぞれのバージョンでライブラリが動作するコードが必要となる。
  • 社内ライブラリが依存する他のライブラリも、そのバージョンに関わらず動作するように設計することが求められる。これにより、開発者は各ライブラリのバージョンを制御する必要がなくなり、また、どのバージョンでも動作するコードを書くことが可能となる。
  • ライブラリが他のライブラリに依存するときは十分に考慮が必要である。特に、APIがまだ安定していないライブラリに対しては依存を避けるべきだ。これは、依存関係のバージョンアップなどによる修正に余分なコストをかけたくないという観点が関係している。

*1:Railsエンジンなどで親アプリの環境を固定したいときにGemfile.lockをバージョン管理するケースもある

*2:ChatGPT (GPT-4)で生成したものを微調整