Goのパッケージ構成の失敗遍歴と現状確認

この記事は Gunosy Advent Calendar 2017の5日目の記事です。前回の記事はGunosyのパーソナライズを支える技術 -ワークフロー編-でした。

GoでAPIを書くときの問題

僕の在籍するGunosyはGoを昔(?)から本番採用しておりまして、ノウハウも潤沢に溜まっている企業だと言えます。

しかし、contextの扱いやベストなパッケージ構成、テスト、net/httpでAPIを書くノウハウなどなど、迷うことは多々あります。

これは弊社特有の事情ではなく、Goのサーバーサイドエンジニア全員にとっての問題です中でも、パッケージ構成をどうすればいいのか(相互参照せずに快適に開発を進められるパッケージ構成とは)を見つけるのは結構難しく、各々のチームにお任せ、という状況です。

今回は上記の問題のうち、パッケージ構成に踏みこんで見たいとおもいます。会社でもよくパッケージ構成をどうするべきか、という話が上がるので、説明する時の備忘録としてここに書きます。

最適なパッケージ構成とは

最適なパッケージ構成、あれば教えて欲しいんですが、それを求めて僕も試行錯誤を重ねていました。 今回の記事では、その過程を書くとともに、現状適切だと思っているパッケージ構成をまとめることにします。

第1ケース: プロジェクトルートに展開する

.
├── db.go
├── errors.go
├── handler.go
├── main.go
└── model.go

Goに関して特にインプットなく、愚直に書こうとしたら、ほぼ全てのファイルをプロジェクトルートに展開するやり方です。 mainパッケージに集約するということですね。

util的な共通処理も、構造体もインターフェースもその実装も、全て同じ階層にあれば、そもそもパッケージを切る必要がありません。

CLIツールや小さめのサーバー、あるいは小さめのパッケージなどは、このやり方で済ませても全く問題ないでしょう。

とはいえ、中規模以上のAPIサーバーを用意するとなると、これではきつくなります。どこに何が書いてあるかも分かりにくくなりますし、個人的には20個とかファイルが乱立していると結構ウッときます。

第2ケース: MVC

.
├── main.go
├── clients
├── config
├── controllers
├── models
└── views

他の言語のアプリケーションフレームワークを使用しているとよくやるやつです。僕も最初はこうしてました。一見するとこれはこれで綺麗ですね。ですが、あくまでプロジェクトルートが綺麗なだけです。その下にいくとどうなるのでしょう。

例えば、modelsディレクトリの直下に行ったとき、本当はこうしたくなります。

.
├── article
└── user

リソースごとにパッケージを区切りたくなりますが、これではだいたい相互参照の問題にぶつかります。それを回避するために、article.gouser.goなど、構造体やそれに付随する実装を全てmodelsディレクトリ直下に展開する、という流れがあったりします。

これも小さめのAPIなら許容できなくはないですが、主要APIとして利用されるサーバーのコードを書くときは、リソースが多くなって、goのソースコードのファイルも多くなります。辛いです。

第3ケース: MVCにこだわらないレイヤー化(失敗)

.
├── config
├── domain
│ ├── fetcher
│ ├── parser
│ └── worker
├── main.go
└── repository
├── configuration
├── fetcher
└── repository.go

MVCだと相互参照で辛くなるので、別のレイヤー化を考える必要が出てきます。そこで変更してみた構成のサンプルとして、僕が以前クローラーAPIを書いた時の構成を書いておきます。DDDを意識しようとしますが、失敗していた時期のパッケージ構成です。

端的にいうと大失敗だったのですが、GoConで得意げに発表しました。勇気がありますね。その時の資料が以下です。

ドメインというものを勘違いして、データアクセス以外の責務をdomain配下のファイルに集約していました。本来domainというのはどこにも依存せず、参照される定義や、構造体に少しパラメーターの加工を担う関数が生えている程度に収めるべきなのですが、この時DDDがよくわかっておらず、domain パッケージに全てを集めることになりました。

もう少し詳しく書くと、domainというパッケージの配下には、記事をクロールするクロールしてきた内容を加工して、適切な構造体に詰め込むそもそもの構造体の定義などが詰まっています。責務とは?という感じですね。

また、repositoryには共通処理を書いたファイルと、domainにほぼ1:1対応するパッケージを切って、データアクセスする処理を書いていました。ただ、例えばMySQLにアクセスする場合も、ファイルストレージにアクセスする場合も、同じファイルに書いて”リポジトリ”としてひとまとめにしていたので、これまた接続先によって分けられていません。

重ねて反省点として挙げられるのが、repositorydomainから参照する手段として、contextの中に入れる、という暴挙に出ていたことです。

contextというのは「リクエストに限定的で」「タイムアウト管理などに使う」用途を想定しているので、このようにリクエストを横断したオブジェクトを格納するグローバルな入れ物として使ってはいけません。パッケージ構成と何の関係があるんだ、という話ですが、パッケージの切り方をミスると別のパッケージのオブジェクトを参照するために、どでかい入れ物が必要になるが、それはよくないよね、ということです。この時の失敗は、以下のスライドに書いてあります。

第4ケース: DDDライクなレイヤー化を求めて(再度失敗)

.
├── config
├── domain
│ ├── auth
│ └── streaming
├── handler
│ ├── auth
│ └── streaming
├── main.go
├── middleware
├── repository
│ ├── auth
│ └── streaming
└── vendor

このころはまだDDDというものもいまいちおぼつかず、手探りでディレクトリを分けていました。第3ケースの大きな問題だった、contextへのrepositoryオブジェクトの投入をやめて、微妙にそれっぽいAPIになった時期の階層構造です。

これはgodddプロジェクトを参考にしています。

やった!これでDDDってやつが体験できるぞ!と意気揚々に取り組んだわけですが、どうもDDDに詳しい方からは、「インフラストラクチャ層とかはどこにあるのか」的な質問が出ました。そりゃそうですね。

データアクセスを定義する時、インターフェースをdomainに、repositoryディレクトリに実装を集約してましたが、微妙にDDDっぽくはなくただのレイヤー化にすぎない、という話でした。失敗ですね。

なお、この時の設計に関しては以下の資料がございます。

第5ケース: DDDライクなライトなパッケージ構成

.
├── application
│ ├── auth.go
│ ├── auth_test.go
│ ├── setting.go
│ ├── streaming.go
│ └── streaming_test.go
├── config
│ └── auth.go
├── domain
│ ├── authenticator.go
│ ├── entity
│ ├── error.go
│ ├── language.go
│ ├── repository
│ ├── service
│ └── tokenizer.go
├── infrastructure
│ ├── authenticator.go
│ ├── persistence
│ └── token.go
├── interfaces
│ ├── auth
│ ├── customhandler.go
│ ├── errors.go
│ ├── middleware
│ ├── profiler
│ ├── renderer.go
│ ├── settings
│ ├── streaming
│ └── validator.go
└── library
└── datastore.go

さて、今のパッケージ構成です。実際に自分が書いているものをそのまま持ってきました。個別に解説します。

applicationは、いわゆるビジネスロジックの実装を持っています。Railsを使ってる人なら闇を抱えがちなServiceレイヤーを想像してもらえるといいかと思います。

domainは、構造体定義、infrastructureのインターフェース定義(repository)、applicationのインターフェース定義(service)や、他構造体に付随する共通処理などを書いています。

infrastructureは、上記のdomainで定義したデータアクセスの実装を持っています。persistenceというパッケージが永続化層への実装を持っております。AWSならRDS、GCPでいうとDatastoreへのアクセスをこれに担わせています。同じような構成の別プロジェクトでは、storageというパッケージを作って、Google Cloud Storageへのアクセスをになったりしています。

interfacesは、リクエストハンドラや、カスタムハンドラを実装しています。また、main.goで呼び出すミドルウェアの実装もここに置いています。

libraryは、外部ライブラリとして切り出して良い、ないし瑣末な共通処理です。utilみたいな細かいレベルではなく、第3者のパッケージを拡張したり、本当に外部パッケージとして切り出しても良いと思う処理だけをまとめるようにしています。

このパッケージ構成は、以下の2つの情報源を元に第4ケースを修正したものです。

ちょうど第4ケースが良さそうと勘違いしていた時期に、直後この2つの発表があって、すぐさま修正しました。domain層がどこにも依存せず、定義を担う層としてちゃんと機能しており、infrastructureapplicationなどがそのdomainを元に実装を担う、という役割分担になっています。

懸念点も少しだけありまして、

  • ユーザーが見つからない時のエラーハンドリングはapplicationinfrastructureのどっちが担うのか
  • libraryに共通処理を切り出す基準
  • interfacesがハンドラとして少し大きいのではないか

などなどです。とはいえ、今までの4ケースに比べたら良いものになったのではないでしょうか。

終わりに

以上、僕なりに試行錯誤したGoのAPIの失敗遍歴と、現状のパッケージ構成の確認でした。

登壇内容などで結果がわずかに垣間見えるものの、愚直な実装はなかなか表に出てこないものです。そうした課題意識もあって、今回は失敗遍歴を書くことで、試行錯誤すること自体は間違っていないよね、不安になるよね、というメッセージを込めました。

Go界隈の盛り上がりはすごいもので、golang.tokyoの運営に携わらせていただいたり、同勉強会やGoConで発表させていただく中、その熱気を直に感じられるのは、とても貴重な経験です。

しかし、Goはまだまだ現場ノウハウと言えるものが、表に十分出てきているとは言えません。こうした内容をもっと掘っていくことで、より実益に繋がるコードが、効率的に生み出されるきっかけになればと思います。

現場で培ったわかりやすいノウハウがある方、ぜひ教えてください。引き続き設計を磨いていきたいと思います。

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.