Go API のための再利用可能で型安全なオプションの実装方法

Reusable and type-safe options for Go APIs

translated on

背景

本記事では、Rob Pike 氏と Dave Cheney 氏により記述された「Functional Option Pattern」の拡張について説明したいと思います。このパターンに慣れていない人は、まず彼らの記事を読むことをおすすめします。

問題

このパターンの限界を見るために、etcd v3 client について考えてみます。特に、key-value の値を取得したり登録したりするための API である KV インターフェースに着目してください。例えば、Get の API はこのような形です:

Get(ctx context.Context, key string, opts ...OpOption) (*GetResponse, error)

ここで、opts は functional options のリストです。この API を呼び出すには、以下のように書きます:

resp, err := kvc.Get(ctx, "sample_key", WithPrefix(), WithRev(sample_rev))

ここでは WithPrefixWithRev という2つのオプションを API に渡しています。ここで注意していただきたいのは、この2つのオプションは関数だということです。したがって、任意のオプションを取ることができます。

このアプローチの問題ですが、正しいかどうかにかかわらず、API に対して OpOption により任意のオプションを渡すことができる、ということです。例えば、 WithLease オプションが Put にのみ利用可能なものでも、Get に渡すことができます。

したがって、そのオプションは型安全ではないと言えます。ちなみに、間違ったオプションを渡した場合、ランタイムでのみ検出可能で、コンパイル時には検出できません。

誤った解決策

API(GET や PUT)ごとに違った引数の型を定義することにより解決したくなります。例えば、Get を受け付ける GetOptionPut のみを受け付けられる PutOption を定義する、などです。

このアプローチの問題点は、それぞれの API が同じような実装をもってしまうことです。Go 言語が関数のオーバロードをサポートしていないので、以下のように、各APIに一つ、同じオプションを持った別々の関数を定義する必要があります。以下のような形です:

func WithPrefixForGet() GetOption { ... }
func WithPrefixForDelete() DeleteOption { ... }
func WithPrefixForWatch() WatchOption { ... }

これは、開発者にとってもユーザーにとっても明らかに理想的ではありません。各 API に対してひとつのパッケージとしてオプションを定義したくなるかもしれませんが、より扱いにくくなるだけです。

解決策

ここに例を載せました。簡単にするために、GETDELETE という2つの API と WithPrefixWithRev という2つのオプションのみを扱うことにします。

まず最初に、API を定義します:

func Get(key string, ops ...GetOption)
func Delete(key string, ops ...DeleteOption)

そして、各 API に一つ、インターフェースを定義します:

type GetOption interface {
    SetGetOption(*getOptions)
}

type DeleteOption interface {
    SetDeleteOption(*deleteOptions)
}

最後に、各オプションに対して1つの関数を定義します:

// WithPrefix は Get と Delete で使えます
func WithPrefix() interface {
    GetOption
    DeleteOption
} {
    // 上記リンクにある実装を参照してください
}

// WithRev は Get でのみ使えます
func WithRev(rev int64) interface {
    GetOption
} {
    // 上記リンクにある実装を参照してください
}

ここで注意すべきことは、関数が *Option インターフェースが埋め込まれた無名のインターフェースを返していることです。これは、型を見ることで、どの API を使用できるかをすぐに知ることができるという点で、コード自身がドキュメントの役割を果たしている(また godoc フレンドリーでもある)という利点があります。

次に、オプションが実際に再利用可能で、型安全であるかどうかを見てみましょう。WithPrefix()GetOptionDeleteOption を実装しているので、以下のコードは問題なく動作します:

Get("sample_key", WithPrefix())
Delete("sample_key", WithPrefix())

しかし、DeleteWithRev を渡した場合、コンパイルエラーが起きます:

Delete("sample_key", WithRev(1))
./main.go:88:30: cannot use WithRev(1) (type interface { SetGetOption(*getOptions) }) as type DeleteOption in argument to Delete:
        interface { SetGetOption(*getOptions) } does not implement DeleteOption (missing SetDeleteOption method)

これは、WithRevDeleteOption ではないということを教えてくれています!

まとめ

以下に、オプションを定義するためのパターンをまとめました:

  • 複数の API で同じオプションを使用できるという点で再利用可能です。
  • コンパイル時にエラーが発生するという点で型安全です。間違ったオプションを渡すと、エラーが発生します。