💂

Guardian で巨大 Haskell レポジトリの依存関係を正気に保つ

2023/02/13に公開

TL;DR

巨大なモノレポはパッケージ間の依存関係に気を付けないと、変更が思わぬ所に波及して保守が大変だって?
DeepFlow 株式会社製ツール guardian を使って、Haskell モノレポのパッケージ間の依存関係が抽象化や意味論的な境界を侵犯していないかチェックしよう!

https://github.com/deepflowinc/guardian

この度 OSS 化したので、巨大 Haskell モノレポの依存関係管理に困っている皆さんは是非試してみてください。
GitHub Action もあるよ。

はじめに - 巨大モノレポを保守する悲しみ

大量のパッケージから成るモノレポ[1]を管理するのが大変だというのは、あらゆる言語で共通の悩みであろうと思われる[2]
こうしたモノレポというのは、「CIで全部ビルドできるようにしておく」というだけでは不十分で、ある箇所への変更が必要以上の部分のリビルドを惹き起こさないようにしないと、開発サイクルが全然回らずに苦労することになる。
今回扱うのは主に Haskell 製のレポジトリの話であるけれども、コンパイラ/トランスパイラ言語を使った大規模なプロジェクトであれば、どこも同じような事情を抱えているだろう。

この要請を手で人間が徹底するというのは、まあ大変だ。
プログラマと呼ばれる人々、特に Haskeller などという人々は本質的にとんでもなく Lazy で、それは美徳と呼ばれることもあるのだけれども、ライブラリの依存関係についてはその限りではない。
何か新しい機能を追加してえな、と思って、とりあえずモノレポの中を検索してみて、良さげな関数・型があった!と喜び勇んでとりあえず依存関係を追加する──こんな事を繰り返すうちに、モノレポは全てが互いに依存しあった巨大な編み目状のアメーバになって、たった一箇所の変更だけで本番用バイナリのビルドに一日の半分も使ってしまうようになる。

ご多分に漏れず、DeepFlow が2021年に直面していたのは正にこの問題だった。
我々のプロダクトは Haskell 製大規模並列数値計算ソルバであり、それは総計15万行弱・約70個弱ものパッケージからなるモノレポで管理されている。
我々は Haskell の機能を結構使い倒していて、アルゴリズムの正当性を保証するために型レベルハックはふんだんに使っているし、高性能計算のために GHC の強力な最適化機構に依存し倒している。
この辺の事情について興味のある向きは、以下のスライドを参考にされたい。もう四年くらい前になるので、細部についてはもう違う手法を採り入れたりしているが、大枠ではそんなに変わっていない。

https://speakerdeck.com/konn/da-gui-mo-shu-zhi-ji-suan-wozhi-eru-haskell-nil-nil-pragmatic-haskell-in-large-scale-numerical-computation-nil-nil

で、この巨大なモノレポというのは大雑把にいって以下のような要素から成っている[3]

  • 数値計算バックエンドの抽象化
  • その具体的な実装
  • ソルバを拡張するためのプラグインの抽象化
  • その具体的な実装
  • 方程式系に関する抽象化
  • その具体的な実装
  • 通信方法に関する抽象化
  • その具体的な実装(例:シングルスレッド、MPI)
  • ソルバ本体の抽象的なロジック
  • これらを全部組み合わせた具体的なソルバ実装

2021の我々のモノレポは、これらが複雑に絡み合った混沌の坩堝とでもいうべき状態だった。
パフォーマンスのためにバックエンド実装を一箇所かえれば即ち無関係な筈のプラグインのリビルドが走り、不要なインスタンスを一個削除したらふしぎなちからにより遠く離れた方程式系のリビルドが走る、というもう世紀末みたいな状態だ。
先述のように、我々は GHC の力をフルに使い倒していたので、テスト用のビルドはすぐに終わるとしても、本番用の最適化つきのビルドは最長六時間メモリ使用量100GBにも達した。
一箇所を変えるたびにこの始末だから、機能追加やデバッグのたびに膨大な時間が空費される。
開発体験を大きく損うだけではなく、我々のプロダクトはゆっくりと、しかし着実に死につつあった。

パッケージ依存関係の指標 - 依存性の逆転

このままでは不味いぞ、というので、何とかしようという機運が高まったのが2021年の9月頃。
そこで指標の一つとして選んだのが、OOP で SOLID 原則の一つとして良く知られた依存性逆転の原則だった:

  1. 上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。
  2. 抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである。

──Wikipediaより引用、省略は筆者(2023年02月08日閲覧)

これが依存性の逆転と呼ばれているのは、継承ベースのOOPでの伝統的な依存性の向きと逆だからだ。
出自じたいはOOPだが、この原則インタフェースの抽象化を持つ一般の言語でも有用で、だから型クラスという機能を持つ Haskell にも十分適用できる。

まずやったのは、特にルール2の部分をパッケージの粒度でも強制するために、70弱のパッケージを依存性ドメインと呼ばれる幾つかのグループに分割することだった。
この「ドメイン」という用語法やそれを静的にチェックしようというアイデアはGitHubがデータベースを分割した際の手法を説明したブログ記事からヒントを得ている。
GitHubが記事で掲げた目標はサービスをスケールさせることだったが、我々はプロジェクトの保守・ビルドをスケールさせることが目的だ。

開発チームでの徹底した討論の末、最終的に70あったパッケージは以下のドメインへと分割された[4]

  • infra: データ構造やアルゴリズム、XMLビルダなど、インフラ的に用いられるライブラリ群[5]
  • highlevel: バックエンドや方程式系、プラグインなどの高水準な抽象化
  • lowlevel: 主にバックエンド関連の低水準・具体的な実装
  • plugin: プラグインの具体的な実装
  • solver: ソルバの具体的な実装
  • tool: ソルバと独立したユーティリティ実行ファイル群
  • test: パッケージ間を跨いだ結合テスト・回帰テスト群

モノレポを構成するパッケージは、これらのいずれか一つのドメインに重複なく分類されることになる。
更に、各ドメイン間には有向非巡回グラフ(DAG)を成すように以下のような依存関係が定められている:

各パッケージは、自身が所属するドメインと、そのドメインが(推移的に)依存するドメインのパッケージにしか依存できない、という制約を徹底させる。
上の例では、ソルバや方程式系などの抽象化を提供する highlevel に属するパッケージは、同じドメインに属する他の抽象化や、 infra で提供されるようなインフラのパッケージにしか依存できない。
言い換えれば、抽象化レイヤである highlevel は具体実装を提供する lowlevel に依存できないように設計されている。
抽象・具体実装両方に依存できるのは具体的なソルバ実装を提供する solver に属するパッケージのみに制限される、というのがポイントだ。
こうして可能な限り抽象化と具体実装を分離して、パッケージレベルで依存性逆転の原則のルール2を強制するようにした訳だ[6]

Guardian、登場──パッケージ依存関係管理の実際

なんか良い感じの絵が描けた。あとは、実際のモノレポ上のパッケージ依存関係を、この絵に合うように変えていくだけだ。

……うん、まあ、「だけ」といえば「だけ」なのだが。
問題は人間の手でこうした制約を一々強制するのは非現実的だということで、どうしたって見落としがある。
依存性の制約が破れているところを頑張って見付けたとして、機能追加とバグ修正と並行してそれらを全部直すのには結構な時間がかかる。
一度がんばって整理したところで、また我々お得意の laziness というものが忍び寄って、気付いたら元の木阿弥になることは目に見えている。

うーん……。結局この絵は絵に描いた餅に過ぎないのか?

……というところで guardian の登場で〜す!

https://github.com/deepflowinc/guardian

guardian はこうした制約を設定ファイルとして静的・陽に定義して、パッケージの依存関係がそれに沿っているかどうかを自動的にチェックしてくれるツールだ。
この取り組みが始まると同時に DeepFlow 社内で開発が始まり、既に CI 上で足掛け三年に渡って我々のプロダクトの依存性境界を守護してくれている。

guardian に制約を教えてやるには、プロジェクトルートに dependency-domains.yaml という設定ファイルを作成すればよい。
形式は(拡張子からわかるように)YAMLで、以下のような内容になる:

domains:
  infra: 
    depends_on: []
    packages: [algorithms, geometry, linalgs]
  highlevel: 
    packages:
    - numeric-backends
    - plugin-base
    - equation-class
    depends_on: [infra]
  lowlevel:
    packages:
    - reference-backend
    - fast-backend
    - mesh-format
    depends_on: [infra]
  plugin:
    packages: [plugin-A, plugin-B]
    depends_on: [highlevel]
  solver:
    packages:
    - solver-standalone
    - solver-parallel
    depends_on: [plugin, lowlevel, highlevel]
  tool:
    packages: [post, pre]
    depends_on: [plugin, lowlevel]
  test:
    packages:
    - integration-tests
    - regression-tests
    depends_on: [solver, tool]

# テストとベンチマークの依存関係もカウントする
components:
  tests: true
  benchmarks: false

だいたい見てわかる通りだ。domains で依存性ドメインを辞書として定義している。
solver の依存関係に highlevel が含まれているが、plugin が既に highlevel に依存しているので、実際には指定しなくても問題はない。guardian は自動的に推移閉包を取って、solver から highlevel への依存も許容してくれる(この辺りは Onion Architecture にヒントを受けている)。ここでは、わかりやすいように陽に highlevelsolver の依存関係に含めているだけだ。

実際に我々が最初に導入したのは、これに例外規則を導入したものだ。
先にも述べたように、我々のモノレポは70弱ものパッケージから成っている。
大半は上で描いた絵に沿うように適切に依存関係を修正できるが、一部のパッケージ間の依存関係については、パフォーマンスを犠牲にせずにすぐに依存関係を正常化するのが困難なものもあった。
これらは最終的には解決されなければならないが、それには注意深く設計を変更する必要がある訳だ。

こうしたラスボス的な例を一時的に許容するために、 guardian では妥協として例外規則を使えるようになっている。
こんな感じに、パッケージ毎に例外的に依存を許容するドメイン名またはパッケージ名を指定する事ができる:

...
  plugin:
    packages:
    - package: plugin-A
      exception:
        depends_on:
        - lowlevel
        - package: fast-backend
    - plugin-B
    depends_on: [highlevel]
...

例外規則は、guardian が構築する依存関係グラフそのものには影響しない。
制約に反する依存関係が見付かったときに、その関係を許容するような例外規則があるか、というチェックにだけ使われる。
上の例では、plugin ドメインに属するパッケージは本来 lowlevel に依存できないが、例外的に plugin-Afast-backend パッケージか lowlevel ドメインに属するパッケージへの依存を許容されている[7]
重要な点は、この例外規則は plugin-A にだけ適用されるということだ。
たとえば plugin-Bplugin-A に依存していたとしても、 plugin-Blowlevel ドメインや fast-backend パッケージに依存できない。これは、例外規則の存在で制約が有名無実化するのを防ぐためにそうなっている。

一番大事な点は、例外規則はあくまで一時的な妥協の産物でしかないということだ。
開発が進んだある時点で、例外規則の使用は全て廃止される、というのが依存関係を健全に保つ上での大前提になる。
この立場を鮮明にするため、guardian は例外規則が使用された場合警告を発するようになっている:

$ guardian cabal
[info] Using configuration: dependency-domains.yaml
[info] Checking dependency of /path/to/project/" with backend Cabal
[warn] ------------------------------
[warn] * Following exceptional rules are used:
[warn]     - "A2" depends on: PackageDep "B1"
[info] ------------------------------
[info] All dependency boundary is good, with some additional warning.

そして可能になったら即座に例外規則を廃止できるように、使われなかった例外規則があればそれも warn で報告してくれる:

[warn] * 1 redundant exceptional dependency(s) found:
[warn]     - "A2" doesn't depends on: PackageDep "B1"

まとめれば、guardian は次のように動作する:

  • 全てのパッケージが唯一のドメインに属しているかチェックする
  • ドメイン間の依存関係が DAG を成すことを確認する
  • 例外規則を除いて、ドメイン間の依存関係制約が成り立つことを確認する
  • バリデーション結果を、例外規則の使用状況と共に報告する

注意を要するのは、guardian はあくまで *.cabalpackage.yaml で宣言されたパッケージ間の依存関係のみを基にバリデーションを行うという点だ。
言い換えれば、guardian はモジュールのインポート関係などは見ない。これは既にコンパイラの側でチェックしてくれるからいいよね、というのが guardian の立場だ。あくまで guardian はパッケージたちの面倒しか見ない。

DeepFlowでの例に戻ろう。
大半のパッケージ依存関係はすぐに最初に描いた絵に沿って修正できたが、リソースや案件の関係上どうしても短期的には修正が不可能な部分が幾つか見付かり、二つの例外規則が設定されることになった。
この二つの例外規則全てが撤廃されるまでには結局約半年かかり、最終的にモノレポ全体に渡る定式化・構成の大規模なリファクタリングの一環として実現されることになった。
実際のところ、チームとして大規模リファクリングの実施を決断した主要な動機の一つは、この例外規則を完全になくすことだった。
リファクタリングは現状の「重い」パッケージと並立して新しい定式化に基づくパッケージ群(*-ng── Next Generation パッケージ)を定義して、徐々にそちらに切り替えてゆく、という形で行われた。
この過程でも、依存性逆転を念頭に置いて依存関係をドメインに分けておいた事が有効に働いた。
パッケージの提供するデータ型・抽象化・実装は上で描いた依存性の絵にフィットするように設計して、結果的に疎結合を実現することができた。
こうして依存性の関係を見直し、他にも GHC のバージョンを上げたりモジュールを分割したり定式化を直したりした結果、プログラムは最長でも2時間半/20GiBもあればビルド可能なところまで持っていくことができた。
また、依存関係を整理した結果、単純な機能追加であれば30分もかからずに、すぐに本番用のプロダクションビルドが通るようになった。
最長時間やメモリの削減に関しては、GHCのバージョンアップや再定式化に依るところが大きいが、そうしたビルド最適化のための試行錯誤を行えたのは、前段階としてパッケージの関係を整理してイテレーションの効率を改善できていたからだ。

まとめれば、guardian を使ってパッケージを依存性ドメインに分類したことで、大規模なリファクタリングをする際のイテレーションを速められたし、設計の際の指標としても大いに役立った、ということになる。

こうした依存関係の整理は、リファクタリングを一回やったらおわり、という訳では全然ない。
プロダクトは日々進化する。この記事も、新機能を一個追加する片手間に書いている。
新機能を追加する際、新しいユーティリティ関数をどこに生やそう、とか、新しいパッケージを作ろう、というのを考える検討する際に、既に描いた絵を前提に所属するドメインを考える習慣がつく。
まあ得てして忘れてしまうこともあるのだが、そうした場合は CI で実行されている guardian が依存性境界の侵犯を検知して教えてくれる。
そしたら一歩たちどまって、自分や同僚と相談してどの機能をどこに棲まわせるのが適切か、という議論をすることができる。悩むべきところで強制的に悩ませてくれる。
腐敗するのを防ぎながら、プロダクトの健全な成長を実現できる、というわけだ。

おわりに - まとめと現状の機能

といった訳で、弊社謹製のツール Guardian を使って Haskell モノレポの依存関係の制約を課し、プロダクトが依存関係でがんじがらめになってしまうのを防ぐという弊社 DeepFlow での取り組みを紹介した。

https://github.com/deepflowinc/guardian

Guardian は既に実戦投入されて二年経っており、機能追加の際にちょくちょく我々プログラマの短慮を叱って頂くという重要な仕事を担っている。

まとめると、Guardian は以下を実現するためのツールだ:

  • 巨大 Haskell モノレポの依存関係をときほぐし、疎結合を保って保守性を高め、イテレーションの速度を上げる。
  • 新しいパッケージ・機能を追加する際に、依存関係を壊さないか自動的にチェックし、立ち止まって設計を見直す機会を設ける。

こうしたゴールを実現するために、Guardian は以下のような機能を提供している:

  • Guardian では、巨大モノレポのパッケージを「依存性ドメイン」と呼ばれる複数のグループに分割する。
  • ドメイン間の依存関係をDAGとして定義して、依存境界を防御する。
  • 既に走っているプロジェクトに後から適用できるように、一時的な例外規則も使用できる。

依存性ドメインをどう区切るかはケースバイケースだが、我々の経験上以下の指標が役に立つ:

  • 抽象化レイヤと具体的な実装を可能な限り分離せよ(依存性逆転の原則の系)
    • 理想的には、アプリケーションの実装や結合テストのみが両方に依存できる形にするとよい(あくまで理想)
    • 依存性逆転を心のかたすみに:抽象化は具体的実装に依存すべきではない!
  • ドメインは抽象/具体の区別だけでなく、意味論・機能に応じても分けるとよい(ドメイン駆動のドメインですね)
  • モノレポ全体の設計の指標として活用できるように設計しよう
    • 新機能・パッケージ追加時に、一覧を眺めながらどこに足すのが「正しい」のか考える土台として使えるようにしよう
    • 例外規則の数 ≒ モノレポの潜在的な構造的欠陥

我々の場合、ドメイン構成の大枠はそんなに変わっていないが、細かな分類やパッケージの所属先は都度議論して変えている。
依存性ドメインの定義は、あくまでその時々のベストエフォートの反映であって金科玉条ではない
プロダクトが成長するに従って窮屈になるのなら、その度にしっかり議論をして新しく立て直していくべきものだ。

現在、Guardian はプロジェクトを管理するバックエンドとして cabal-install と stack が利用可能である。
直接リンクしているので、これらのツールがインストールされている必要はない[8]が、 cabal-installwith-compiler を使っている場合は、対応するコンパイラが PATH で参照可能な必要がある。

GitHub Workflow の一部として、guardian を呼び出すための GitHub Action も本レポジトリで提供している。
deepflowinc/guardian/action にある。以下のような感じでお使いのワークフローに組み込める。

jobs:
  check-dependecy-boundary:
    name: Checks Dependency Constraint
    runs-on: ubuntu-20.04
    continue-on-error: true
    steps:
      - uses: actions/checkout@v3
      - uses: haskell/actions/setup@v2
        with:
          ghc-version: 9.0.2  # Install needed version of ghc
      - uses: deepflowinc/guardian/action@v0.4.0.0
        name: Check with guardian
        with:
          backend: cabal    # auto, cabal, or stack; auto if omitted
          version: 0.4.0.0  # latest if omitted

もちろんスタンドアロンのツールとしても使える。ビルド済バイナリも、Linux と macOS 向けに用意してある。

https://github.com/deepflowinc/guardian/releases

細かい使い方は README にあるので参照のこと。

https://github.com/deepflowinc/guardian#usage

そんな訳で、Haskell 製巨大モノレポを管理されている皆様におかれましては、是非 guardian の御活用を御検討いただければ幸い。

Happy Practical Haskelling!

脚注
  1. monolithic repository; 大量の関連するライブラリが一つのレポジトリでまとめて管理されてるやつ。 ↩︎

  2. そもそもモノレポで管理すべきものとそうでないものがあるよね、という話題もあるが、本記事ではその辺の是非については論じず、モノレポであることは所与とする。 ↩︎

  3. 書きぶりから何かに気付いたかもしれない。まあ、待ってくださいよ ↩︎

  4. ここで示したのは例示のために単純化したものであり、実際にはあと数個ほどドメインがある。 ↩︎

  5. これらの中には他の人にとっても有用と思われるものが結構あるので、状況がゆるせばいつかOSSにしたいですね ↩︎

  6. もちろん、お行儀の悪い開発者が抽象用途のパッケージに具体実装を書いてしまうこともありうる。今のところソルバー開発に携わるメンバーは片手で数えられる程度なので、問題意識が共有できているが、将来的にはより踏み込んだコードレビューをして徹底していくことになるだろう。 ↩︎

  7. もっとも、fast-backendlowlevel に属しているので、package: fast-backend の例外規則は本来不要だ。ここでは説明のために敢えて両方指定させている ↩︎

  8. 本当は plan.json をパーズするとか、環境にある stack を直接呼ぶなどすべきかもしれないが、互換性を考慮したりするのが面倒で、全部特定のバージョンとリンクしている。 ↩︎

DeepFlow Tech Blog

Discussion