ネストの深いプログラムを書くとき、
プログラム全体で共有したいオブジェクトをどうやって引き回すかというのはいつも悩む問題です。
グローバル変数
小さなプログラムの場合は雑にグローバル変数に入れてしまうのも悪くはないですが、
プログラムが大きくなるにつれ、このやりかたはつらくなります。
シングルトン
シングルトンは基本的にはグローバル変数と同じことです。
ただしファクトリ関数をうまく使えばグローバル変数をそのままつかうより柔軟な取り回しが可能になるかもしれませんが。
グローバル変数にせよシングルトンにせよプログラム全体で状態を共有しますが、
そうではなく関数ごとに引数としてサービスを渡していく方がテスタビリティも高いし好ましいと思います。
コンテキスト
APIサーバをたてるときによくやってしまいますよね。
リクエスト処理全体で共有したいような変数をミドルウェア経由でコンテキストにセットするというのは
わりと一般的なやり方なんじゃないかと思います。
同じやり方でプロセス中でグローバルなオブジェクトもコンテキスト経由でアクセスするようにすれば
サービスへのアクセス経路が統一出来て分かりやすいですよね。
このやり方の問題点はいくつかあって、
一つは、少なくともGo標準のcontext
パッケージは、そもそも共有オブジェクトを持ち運ぶために作られたわけではないということです。
context
はあくまでリソースのライフタイムを管理することが主目的のライブラリです。
例えばリクエスト単位でキャンセル処理を入れるためにはリクエスト毎に共有するオブジェクトを管理する仕組みが必要なので、コンテナとしての機能も持たせてあるというだけです。
参考: https://dave.cheney.net/2017/08/20/context-isnt-for-cancellation
公式にもcontext
に入れるデータはリクエスト・スコープ内で使われるデータのみ入れろと書かれています。
とはいえ全体共有のオブジェクトもコンテキスト経由で渡すというのはついついやってしまいがちです。
だって他にhttp.Handler
のインターフェース経由でオブジェクトを渡す経路がないですからね。
もう一つの問題はValue
メソッドの返り値がinterface{}
型だってことです。型情報が消えてるので値を取り出すときにダウンキャストが必要になります。ポインタ型の場合キャスト後にもう一度nilチェックが必要になります。
これはバグの温床になります。
まだほかにも問題はありますがこの記事では上記二つを扱います。
後者の問題への対応としては、Value
メソッドで取り出すのはコンテキストで共有の構造体一つとし、
各値はその構造体のメンバとしてアクセスする、というやり方が挙げられます。
そうすればキャストは1回だけすればあとは型情報を持った構造体として取り回しできます。
グローバルな共有オブジェクトはどうするのかですが、この構造体に共有オブジェクトへのポインタを持たせ
「オブジェクト本体はグローバルスコープだが、参照はリクエストスコープ」ということにします。
苦しい言い訳ですが。
サービスロケータパターン
ところで、このような全体で一つのオブジェクトを共有し、そのオブジェクトを入り口として各共有オブジェクトを取得するようなやり方ってどこかで見たことありますよね。
つまりサービスロケータです。
やっと本題に来ました。
ある単体のオブジェクトが個々の共有オブジェクト(サービス)へのアクセサを持っていて、そのアクセサがインターフェースを返すのであればそれはサービスロケータです。
Goにジェネリクスがあればインターフェース型をキーにその型のオブジェクトを返す汎用なアクセサを作れますが、
現状ジェネリクスは存在しないので、サービスごとにそれぞれアクセサを定義する必要があります。
package locator
type ServiceLocator struct {
database IDatabase // DBを表すインターフェース型
cache ICache // キャッシュサービスを表すインターフェース型
}
func (sl *ServiceLocator) GetDatabase() IDatabase { return sl.database }
func (sl *ServiceLocator) GetCache() ICache { return sl.cache }
このようにインターフェースを返すようにすれば、テストの際モックに差し替えたりするのも楽になります。
また、引数が複雑になったとき、構造体でまとめてしまってから、引数にはその構造体一つを渡すという書き方をGoではよく見かけますが、
サービスロケータそのものを引数として渡してしまえば構造体の初期化すら書かずに済み非常に楽です。
func execute() {
serviceLocator := locator.Setup()
doSomething(serviceLocator)
}
サービスロケータはアンチパターンか?
ところでサービスロケータに対してアンチパターンであるとかDIを使った方がよいという意見をよく見かけます。
自分はGoにおいてはその批判はあたらないんじゃないかと思っています。
サービスロケータの欠点としてよく上げられるのは以下の二点です。
- サービスロケータを利用するオブジェクトがサービスロケータそのものに依存してしまう
- サービスロケータ経由でどのサービスを利用しているか、オブジェクトの外からわかりづらい
1はサービスロケータ自体を具象型として渡してしまっているために起きる問題でしょう。
2はサービスロケータのアクセサ全てを呼び出せるために起きている問題です。
// サービスロケータを引数に取り
// 各サービスはサービスロケータ経由で取得するような関数を考える
func doSomething(sl locator.ServiceLocator) {
// この関数はlocatorパッケージとServiceLocator型に依存してしまっている
db := sl.GetDatabase()
/*
実装を見ないと中でDBが使われているのか、
Cacheが使われているのかが分からない。
*/
}
これらはGoのインターフェース型を使えば解決できます。
// この関数にサービスロケータを渡す
func doSomething(dbProvider interface {
GetDatabase() IDatabase
}) {
// 引数はGetDatabase()を実装さえしていればよい
db := dbProvider.GetDatabase()
/*
この関数で利用されるサービスはDBのみ。
*/
}
上記の実装では引数はServiceLocatorどころかlocatorパッケージにも依存しておらず、
定義はこのパッケージ内で閉じています(IDatabaseが別パッケージならそっちには依存するかも)。
なのでテストなどでモックを作って渡すのも簡単ですし、そのモックもServiceLocatorのメソッド全てを実装する必要はなくGetDatabaseさえ実装していればよいので非常に楽です。
関数内で利用されるサービスも、引数定義だけ見てしまえば何が必要かは一目でわかります。
このようなことができるのもGoのStructual Subtypingによる柔軟なインターフェース型のおかげです。
ちなみに、DIを使った方が良いという意見に対しては、そもそもサービスロケータはDIと併用できるデザインパターンですし、実際上のような書き方でDIを実現できています。
このやり方なら少なくとも依存関係の逆転という観点ではDIコンテナと同等の機能が実現できているんじゃないかと思います。
おわりに
サービスロケータはアンチパターンと言われていますがGoのStructual Subtypingと組み合わせれば実はきれいに実装できるんじゃないかと思っています。
なのでプログラム全体でサービス群を共有するための箱として使うには結構よい手法で見直してよいのではないかと思い本記事を書きました。
サービスロケータ自体をどうやって共有するかですが、トップレベルでシングルトンとして呼び出すか、もしくはhttp.Handler
だったらコンテキスト渡しでもいいんじゃないかと思ったり。それくらいは許してほしいかな、と。