フューチャー技術ブログ

あなたのGoアプリ/ライブラリのパッケージ構成もっとシンプルでよくない?

2023.10.5追記: Goチームからプロジェクトの目的に応じたディレクトリ構造についてのドキュメントが公式に公開されています。 https://go.dev/doc/modules/layout

Goでプロジェクトのフォルダ構成どうしよう、とググると見つかるStandard Go Project Layout。とはいえ、これはかなりコード量を増やしてしまう恐れがありますので、導入する場合のデメリットも考えておく方が良いです。

特に、プログラマーは、最初にみたプログラミング言語のフォルダ構成を親だと思う特性があり、Javaや.NETに影響されるとかなり細かくフォルダを切りたくなったり、package privateなど細かく可視性を制御しようとしたりして、なおかつ「privateのテストってどうすべきなんですか?」とか議論を始めたりもしますが、Go先生によればこれぐらいは1パッケージにファイルをぶっこんでもいいわけです。勇気を持ちましょう。

本エントリーでは、小中規模のGoプロジェクト、あるいは小さいところから少しずつ育てていく上でのパッケージ構成について紹介します。すでにメンバーが慣れている構成があるならそれを維持したほうが良いです。

あと、再利用という言葉の意味が同僚と話をしていて、人によって違いそうだなと思ったので「そのまま利用」「コピーして利用」というのは適宜追記しています。

(2021/04/28追記)golang-standards/project-layoutに関してはRussが言及していますね!

事前知識

Goには処理系にハードコードされたいくつか特別なルールがあります。

  • フォルダがパッケージ
    • フォルダ内のファイルは基本的に同じパッケージでないといけない(テスト専用の<package>_testだけは共存可能)
  • エントリポイントがあるパッケージはmainパッケージでなければならない
  • vendor
    • import時にまずこのパスを見にいきます。用途としては、go getせずにそのリポジトリだけで動くようになります。また、サードパーティのパッケージをちょっと改変したいときに使ったりしますが、今だとgo.modのreplaceディレクティブで同じことができます。
  • internal
    • この中に置いたコードは外部のパッケージから利用できなくなります。
  • 無視されるフォルダ
    • ._で始まる名前と、testdataと言う名前のフォルダ内部は、Goの処理系がコンパイル対象から除外します。例えば静的解析ツールを実装していてテストデータとしてgoコードを置いておきたい場合とか、NGケースのGo処理系でコンパイルエラーになるはずのコードなどはtestdataフォルダの中に置く
  • パッケージのimportが循環してはダメ
    • これは他の言語でも大抵そうだと思うので問題はないかと思います。動的言語のPythonだとパッケージグローバルではなく、関数の中でimportすることで循環してもなんとかする悪のテクニックはありますが、あまりやらない方がいいです
  • 親パッケージと子パッケージは独立したパッケージ
    • フォルダの上下関係で親子があっても、親と子は独立しています。親→子に依存があっても、子→親の依存があっても問題はありません。上記のルールの循環だけ気をつければOKです
  • go gettable
    • リポジトリにはソースコードのみ、成果物を入れるな、みたいなのはVSSとかRCSの時代からのコード管理の鉄則でしたが、良くも悪くもGoも含めて近年はパッケージ管理とも密接に結びついちゃっています。ライブラリの場合だと特にgo getしただけで動くgo gettableというのが好まれます。npmのようにビルドした結果をパッケージとしてアップする方式だと分離も可能ですし、npmだとさらにインストール後になんかをするスクリプトが組めるのですがGoにはどちらもありません。Goだと、例えばgRPCのためにprotobufから生成したファイルを入れた方が便利ですね、となります。

それ以外のポイントとしては同一のパッケージ名はなるべく避けた方が良いです。特に標準ライブラリとのコンフリクトは避けるべきです。import時に同一パッケージ名の要素をいくつも利用したくなったとすると、必ず別名を設定しないといけなくなります。処理系ではないですが、コード補完との相性があります。同一名称のパッケージがあると、コード補完がなかなかうまく決まらず、間違ったものを選択して想定外のimport文が追加されたりといった経験をお持ちの方は多いと思います。この問題があるので、細かくパッケージを分けるよりかは、まるっと大きめなパッケージにして、ファイルの先頭がimportで埋め尽くされる、みたいなことは避けた方がましかな、と思っています。

Go以外の他のルールとしては、GitHubがGitHub pagesのウェブサイトを公開する場合に、masterブランチの/docsフォルダを公開する、というのがあります。

このあたりはいちユーザーの意思で変わるものではないので、素直に従う必要があり、これ前提のルールを作る必要があります。

ハードコーディングされていない推奨の考え方としてはパッケージ名は1つの単語で熟語にはしない、というのがありますが、たまに他のルールとぶつかるので、ここは柔軟に対応すればいいと思います。

最小構成

一番Goらしさが溢れる最小構成はこれだと思います。実行ファイルはcmdフォルダ内にさらにアプリケーション名のフォルダを作ります。そこの内部はmainパッケージにして、main関数が含まれるmain.goを置きます。

必要であれば、/docs(ドキュメント)、/testdataなどを足しましょう。

<projectroot> (projectrootパッケージ)
+ cmd
| + <application1> (mainパッケージ)
| | + main.go
| + <application2> (mainパッケージ)
| + main.go
+ project.go
:

cmdもなくして、全部をmainパッケージにしてしまうというさらにエクストリームな方法もありますが、起動部分とコアの部分を分けることは、再利用(そのまま呼ぶ)やテストのしやすさの観点では最低限守るべきラインかと思います。また、コードが育って大きくなったときに、mainの位置を変える、パッケージ名を変えるというのはコード全般に影響のある大きな変更になるので、仮に全部mainパッケージでおさまる程度の小さいコードであっても、大した手間ではないので、cmdフォルダを作ってその中でやる方が変更時の手間も削減できます。

この構成の場合は、mainパッケージはなるべく薄くして、なるべくprojectのルートの方のmainではないパッケージにコードを寄せていく方が良いです。僕はコマンドライン引数のパースと起動時の条件確認ぐらいはmainパッケージでやりたいですが、mainを究極まで薄くしたい派の人もいますし、ここはお好みで。

もっとコードを大きくしていく場合

コードが順調に育ってフォルダを分けたくなりました。今までは、起動部分はcmd/<application>にあり、コードの大部分や例えばアプリケーションのモードを表すenum的な型とか定数は<projectroot>にいたとします。ここでサブパッケージを追加するのですが、ここでコーディング方針の意思決定が必要になります。

単純にフォルダを切ってコードをそっちに持っていくと、共通の定数やら型定義が<projectroot>にいるので、この<projectroot>と、サブフォルダで循環しちゃうのですよね。対策はいくつかあります。

common的なパッケージを作る

ナイーブに設計していくと登場しがちなのがこのパターンです。循環参照しちゃった場合、両方から参照されうるものを切り出して移動することで解消します。commonという名前は良くない、と言われることが多いのですが、もし、その切り出したものを表すきちんとしたパッケージ名が編み出せるならありです。

commonとかbaseとかutilという名前を付けるぐらいなら1つのパッケージにまとめてしまえという人もいます。

ルートのロジックを廃していく

HTTPのハンドラーの初期化はhandlersパッケージ、DB初期化コードはrepositoriesパッケージ、のように切り分けて、コードを全部サブフォルダ側に移動します。欠点としてはエントリーポイントのmain.goが太りやすい点ですかね。

ルートの定数や共通のものを末端パッケージに移動する

起動後のちょっとした処理(DB初期化とか)がルートにあったとして、<projectroot>からhandlersなどのサブフォルダに一部ロジックとともに定数定義などもまるごと移譲する方法があります。うまくいけば、完全に一方的に利用されるだけの疎なパッケージができます。ここではhandlersみたいなアプリケーションの一部を例に説明していますが、独立した機能でパッケージが構成できれば、そのまま切り出してgo getで利用する独立パッケージ化して他のプロジェクトから利用したり、OSS化できたりもするでしょう。

欠点としては、ここではサブパッケージは1つだけなので問題ないですが、さらにパッケージが増えて各パッケージの定数で似たようなものが登場すると、定数変換みたいなロジックが必要になったり、詰替え作業が発生する可能性がある点ですかね。

抽象と具象で階層化する

今回のようにアプリケーションが育っていく過程でロジックを分割する流れだとそこまで発生しないかもしれませんが、標準ライブラリのcryptohashなどは、親のパッケージが抽象インタフェース、子のパッケージが詳細実装のように分かれています。依存は子から親方向です。ちょっと大規模なライブラリでは共通要素を置く方法としてこのケースが登場することもあるでしょう。

各論

internal使う?

個人的には使わなくてもいいかなと思います。少なくともアプリケーションでは完全にナンセンスですね。他からアクセスされないようにするというのはライブラリとしての利用の時なので、アプリケーションコードでは完全に無です。ムー

Go本体のコードの悪いところでもあると思うのですが、internal内部で宣言されているせいで、同じことをやりたいためにコードの丸コピーが作られたりして、かえって保守性が悪くなったりというのもあります。

importでそのまま利用する再利用性を担保したい場合には使う理由は一切ありません。コードコピーで再利用する方針であれば使っても良いです。

ドメイン/レイヤー? or レイヤー/ドメイン? or ドメイン? or レイヤー?

クリーンアーキテクチャをGoに導入する場合にどうすればいいのか議論になりがちなのがここですね。議論が盛り上がるポイントです。

ドメイン/レイヤーでも、レイヤー/ドメインでも、2階層作ると、例えばユーザー認証のDBのモデルとか、共通で使いたい部品とかが出てきて、ドメインまたぎでimportしたくなった場合にちょっと問題が発生します。同一名称のパッケージが複数あるため、コード補完が聞きにくく、場合によってはパッケージ名のエイリアスを毎回定義しないといけなくなったりします。コードの可読性は落ちますね。

レイヤーで分けるか、ドメインで分けるかですが、個人的にはドメインで分けたいですね。ドメインで分けると、ドメイン間で依存が発生する場合に循環に気をつけないといけないのですが、循環しないようなドメイン設計ができればデータモデルなどの構造はきれいになるかと思います。循環できないことを逆手に取って設計をきれいにする。マイクロサービス化しますねー、というときもやりやすくなるかと思います。あと、クリーンアーキテクチャ的にはNGかもしれませんが、Goって構造体にタグを書いてデータを流し込みますよね。JSONタグと、ORMタグの両方を書いて、HTTP APIとDBアクセスの両方で同じ構造体を使っちゃうのも選択肢かなぁと思います。

レイヤーで分けると、ドメイン間の依存は比較的自由でやりやすくなります。またレイヤー間での依存は比較的観測しやすくなるため、ジュニアなメンバーが多くて、コードレビューの負荷を減らしたい、という場合に良いと思います。その代わり、レイヤー間でのバケツリレーなコードは一生懸命作らないといけなくなるかもしれません。

このあたりはチームで話し合って決めると良いと思います。

なお、同一DBテーブルを参照する場合にも、ドメイン間でimportしないで、全部コピーしてドメイン間の依存をなくすという過激派もいます。コードは重複しますが依存性やimport文のエイリアス問題は解決します。

あえてフォルダを分けるケース

このエントリーではフォルダをあまりわけない方向で話を進めていますが、明確にフォルダを切った方が良いケースもあります。

gocloud.devでは、共通APIの下に、各クラウドサービスのアダプタのサブパッケージがあります。それぞれのパッケージはimportするだけでinit()が各アダプタをインストールして使えるようにします。init()の副作用があり、依存が大量についてまわるので、分けるほうが良いでしょう。GCPとAWSとAzureの全SDKをリンクすると、結構なバイナリサイズになってしまいますので。

import _ "gocloud.dev/docstore/gcpfirestore"

OpenTelemetryはinit()の副作用はなく、明示的に初期化が必要ですが、パッケージは別れています。連携先ごとにパッケージが独立したほうが、自分でプラグインを書いてみたい人が勉強するには単機能のコードだけがわかりやすく分離されているので学びやすいですね。init()はなくても、パッケージグローバルな変数定義とかで利用していない機能の依存が発生してしまう可能性はあるので、分ける方が安全といえば安全。

https://github.com/open-telemetry/opentelemetry-go

vendor使う?

vendorは最初に説明したとおり必要性は薄くなっています。既製のライブラリの改変はgo modが使えますからね。一方で、ちょっと便利かなと思っているのはプライベートリポジトリに依存する場合です。

アプリケーションも共通なライブラリも両方プライベートな場合にDockerイメージを作る、CIでビルドするといったときに結構厄介なのがプライベートリポジトリへのアクセス方法です。環境変数で秘密鍵をわたして、RUN芸を駆使して、一筆書きでprivateリポジトリのチェックアウトをして消すとか、いろいろ頑張っているコードを見たことがあります。

cloud buildみたいなそれに対するソリューションを用意してくれているものもありますが、いっそのこと、vendorに依存コードを全部入れてからビルド、みたいにしたほうが楽じゃないかと思います。Docker内部でプライベートリポジトリアクセスが必要じゃなくなりますからね。

Dockerfileの中で頑張るのか、docker buildの前にgo mod vendorを実行するようにするか、いっそのことgo mod vendorを実行した結果もリポジトリに入れてしまうか。どこをシンプルにしたいか次第かな、と思います。最近のGoもNode.jsみたいに依存使いまくる感じになってきてコンテキストのコピーの時間が増えているのでトレードオフはあります。

tools使う?

コード生成等に使うスクリプトやツールなどの宣言場所としてtoolsフォルダが好まれてきました。特にgo getして動くGo製アプリケーションを利用する場合、アプリケーションのルートのgo.modでツールを宣言してしまうと、go getする人が、ライブラリの利用では必ずしも必要がないツールまでダウンロードさせられてしまいます。みんな在宅で、家でNetflixとかを見まくって回線が輻輳して遅くなっている昨今では、余計なダウンロードをさせるのは邪悪な行為です。そのため、toolsフォルダ内でgo mod initして、開発者のみが利用するツールはそこで宣言するということが行われてきました。

現在は、わざわざパッケージを作らなくても、go:generatego runから書くことで、ダウンロードして実行まで一行で対応できます。ルートのgo.modに依存を書く必要はありません。Go製以外のツールを使う場合にはあってもいいと思いますが、Go製ツールだけなら今どきは不要と言えます。

//go:generate go run github.com/go-swagger/go-swagger/cmd/swagger generate server -f swagger.yaml --target=webapi --exclude-main

Standard Go Project Layoutについて個人的に気になる点

いろんなプロジェクトの最大公約数でしかないので、あんまり教条的にこれに従うとかは考えなくてもいいかなと思います。気になったフォルダのREADMEにはどのプロジェクトが使っているか、といったことが書かれているので、それを見て取り入れたいものだけを取り入れるスタイルでよいかと思います。個別に気になる点は以下の通り。

  • testdataには触れていないですね。少なくともGoでのテスト系の要素はGoコードじゃなくてもtestじゃなくてtestdataに入れる方が良いかと思います。あえて分ける必要もないので。
  • examplesはコンパイル対象になってしまう。_examplesとかにした方が良さそう
  • 本文でも説明したがinternalはアプリケーションでは意味がないという説明がない
  • ルートフォルダの扱いについては説明がないですね。ここに共有の定数定義とかEnum定義を書くとか。共有コードの置き場所についても特になさそう
  • websiteはgithub pagesで公開するならdocsにドキュメントの成果物を入れる運用の方がスムーズで良いなと思います。gh-pagesブランチとかもありますが、ブランチだとstableとunstableの複数バージョンで内容違いとかが作れないので。ドキュメントのソースのmdなりsphinxをどこに置くのかは検討が必要ですが、僕はdocsrcとかにするかな?
  • 別にStandard Go Project Layoutのせいじゃないけど、クリーンアーキテクチャの用語のEvilさはなんとかならないですかね。DDDもそうだけど。言葉を大事にして欲しい。「リポジトリ」という言葉で惑わされる若者を今週また見かけました。

まとめ

フォルダ構造を考えるときでも、普段のコーディングでも、そのルールを変える要件が発生したときにいつでも変えられる柔軟さをもったまま、なるべくシンプルであれ、と思っているため、それを書いて見ました。

人はルールを作るときは厳しめのルールの方がいい、と思ってしまいがちです。特に、ルールを決める人と、そのルールに従う人が別の場合は顕著です。Goでも細かーく丁寧にフォルダを切りまくっている案件を数多く見てきました。物事はバランスです。厳しくなるフォースだけしかないと、バランス調整が働かず、不具合発生のたびに厚くなる障害対策マニュアルみたいになりがちです。ちょうど良いバランスを維持するためには「そんな頑張らなくてもいいサー」「なんくるないさー」と言うおっさんが1人は必要なわけです。とくにGoのパッケージ構成でググると最初に出てくるのが一番厳しめルールということで、ただでさえコードが長くなりがちなGoのコードが長くなって、Go嫌いになったりしたらいやだなぁ、Goはシンプルにスタートできるんですよ、という気持ちで書きました。