27 ポイント 投稿者 GN⁺ 2025-04-24 | 4件のコメント | WhatsAppで共有
  • Go言語は パッケージ間の循環参照を厳格に禁止 しているため、これは自然に 階層的設計(layered design) を促す
  • この記事は、Goプロジェクトが 必然的に持つことになる階層構造 を説明し、その上に別個のアーキテクチャを強制しなくても十分に有効だと主張する
  • 循環依存が発生した場合、それを解決するための 具体的で実用的なリファクタリング戦略 を段階的に提示する
  • 各パッケージは独立して意味のある機能単位を持つように設計され、テスト、保守、マイクロサービス分離 にも有利
  • 結果としてこの方式は、実際のコード設計でよく起こる 「バナナが欲しかったのにジャングルごと持ってくる」問題 を防ぐ

Goにおけるレイヤード設計アプローチ

基本原則

  • Goは パッケージ間の循環参照を禁止 する
  • すべてのGoプログラムのimport関係は 有向非巡回グラフ(DAG) を構成しなければならない
  • この構造は選択ではなく 言語レベルで強制される設計規則 である

パッケージレイヤリングの自動形成

  • 外部パッケージを除くプロジェクト内部のパッケージは、参照の深さに応じて自動的にレイヤリング可能
  • 下図のように最下層には metrics、logging、共通データ構造などの中核ユーティリティパッケージ が位置する
  • その後、上位パッケージが徐々に 機能を組み合わせながら上に積み上がる構造 を形成する

この設計方式の特性

  • レイヤは 階層的抽象化ではなく参照の方向性に基づく
  • 1つのパッケージは 複数の下位レベルのパッケージを参照可能
  • MVC、ヘキサゴナルアーキテクチャなど既存の設計方式も、この構造の上に 「適用」 できる
    → ただし、Goの構造的制約を必ず考慮する必要がある

循環参照の解決戦略

循環参照が発生した場合は、以下の順でリファクタリングを試みる:

1. 機能の移動

  • 最も推奨される方法
  • 循環を引き起こす機能を 正確に分析 し、論理的に 適切な場所へ移す
  • 頻繁に使う方法ではないが、概念的な明確さを最も向上させる

2. 共通機能を別パッケージに分離

  • 両側で共通に使う型や関数(Username など)を 第3のパッケージへ移動 する
  • パッケージが小さく見えても思い切って分離する
    → 時間がたつにつれて、そのパッケージが大きく育つ可能性が高い

3. 上位の合成パッケージを作成

  • 循環している2つのパッケージを 組み合わせる第3のパッケージ を作成する
  • 例: CategoryBlogPost の双方向依存を上位パッケージへ分離
    → 下位パッケージは dumb struct のまま維持し、実際の機能は上位パッケージで組み合わせる

4. インターフェースの導入

  • 構造体や関数が必要とするメソッドだけを持つ インターフェースで依存性を置き換える
  • 不要な依存性を除去し、テストのしやすさ を確保する
  • ただし、使いすぎるとかえって設計が複雑になることがある

5. コピー(Copy)

  • 依存対象が非常に小さい場合は、単純にコピーして使う
  • DRY違反に見えるかもしれないが、実際には設計の明確化に役立つ ことも多い

6. 1つのパッケージに統合

  • 上記の方法がすべて不可能なら 2つのパッケージを統合 する
  • 大きすぎるパッケージにならないなら許容可能
    → ただし、無条件の統合は避け、慎重に判断する

この設計方式の実用的な利点

  • 各パッケージは それ自体で意味のある機能単位 を持ち、独立してテスト可能
  • パッケージ内の参照が制限されるため、コード全体を理解しなくても個別パッケージを理解できる
  • 意図しない全体依存の連結(=ジャングル問題)を避け、必要なものだけを使うコードを書くよう促す
  • マイクロサービス分離時にも容易に抽出可能
    → ほとんどの依存関係が明確に定義されている

結論

  • Goのパッケージ設計上の制約は煩わしい制約ではなく 良い設計を促す装置 である
  • 特別なアーキテクチャがなくても パッケージ間の参照構造だけで堅牢な設計を実現可能
  • 循環参照に対する 精緻な分析とリファクタリング戦略 は、Goだけでなく他の言語にも有効

4件のコメント

 
bus710 2025-04-25

最初はとりあえず書いて動かしていると楽しいけれど
テストを書き始めると
あのときどうしてあんなふうにしたんだろうと考えるようになります。

 
bungker 2025-04-24

「バナナが欲しかったのにジャングルを持ってこられた」という言い回し、すごく面白いですね。

 
iwanhae 2025-04-24

Springで開発するとき、いちばん大変なことの一つは依存関係の循環だった気がしますね..
無限にお互いを初期化しながら、メモリリークで落ちてしまうあのもどかしさといったら...

 
GN⁺ 2025-04-24
Hacker Newsのコメント
  • 循環依存を許可しないのは、大規模なプログラムを構築するうえで優れた設計上の選択である

    • これは関心の適切な分離を強制する
    • 循環依存が発生したら設計に問題があるということであり、この記事はその解決方法をうまく説明している
    • ときどき、別のパッケージが再定義する関数ポインタを使って循環依存を解決する
    • Goコンパイラが循環依存を作ったときに、もっと有用な出力を提供してくれたらよいのにと思う
    • 現状ではループに関係するすべてのパッケージの一覧が表示されるが、これはかなり長くなることがあり、通常は問題を引き起こしたのは最後に変更したものだ
  • 素晴らしいブログ記事である

    • このWebサイトには驚くほど良い記事が多くあり、関数型プログラミングについて学ぶのが好きならチェックすることを勧める
    • リンク
  • 「3つ目のパッケージに移す」という助言に関連するおまけのテクニック

    • 多くのモデル構造(SQL、Protobuf、GraphQLなど)を生成すると、生成されたレイヤー間に明確な方向性を設定できる
    • すべての生成コードをアプリケーションコードに「ベースパッケージ」として提供し、すべてを一緒に構成する
    • このテクニックを導入する前は「モデルがモデルを循環的にインポートする」問題があったが、構造的な追加レイヤーを導入したことで完全になくなった
  • Yourdonの構造化手法に関する本を読んでいるようだ

  • パッケージは互いに循環参照できない

    • 実際には、Goでは go:linkname を使って可能である
  • ランダマイザの具体概念を思い起こさせる

  • Golangの面白い特徴は、パッケージレベルでは循環依存を持てないが、go.modでは持てることだ

    • 要するに、それもやるべきではない
  • Jerfがパッケージをどう考え、循環依存をどう扱うかについての見事な説明である