そのstruct、実は不要になるかもしれません

これはGo Advent Calendar 2020の3日目の記事です。

とりあえずで登録してしまったので、特に肩に力の入った力作などは用意してないです。この記事は、本来のアドベントカレンダーの趣旨に沿った(?)ちょっとした内容の記事にしようと思います。

はじめに断っておくと、あんまり役に立つ内容ではありません。へー、そういうのもあるんだー程度の軽い気持ちで読むことをおすすめします。

goでのweb API用のクライアントライブラリ

goでweb API用のライブラリを実装するとき、とくにREST API用のクライアントライブラリを実装してみるときに、パスのネスト構造をそのままstructとして定義する実装をよく見ます。これはstructを一種の名前空間として使うことで名前の衝突を避ける機能、そして実装をちょうど良い粒度で分割するためのアイデアと言えるかもしれません。

例えば、以下のようなAPIが存在した場合を考えてみましょう。ここで、teamsとusersという共通項に目をつけてTeamServiceとUserServiceというstructに分けてみることにします。

GET    /api/teams           -- 一覧を返す
GET    /api/teams/{team_id} -- データを一つ返す (詳細データ)
PUT    /api/teams/{team_id} -- 更新
DELETE /api/teams/{team_id} -- 削除

GET    /api/users           -- 一覧を返す
GET    /api/users/{user_id} -- データを一つ返す (詳細データ)
PUT    /api/users/{user_id} -- 更新
DELETE /api/users/{user_id} -- 削除

このネスト構造中の1つのノード部分をとりあえず便宜上「Service」と呼ぶことにします。例えば、/teams用のTeamServiceと言った感じです。

一覧のendpointだけに対して、試しに書いてみると、以下のような感じになりますね。

type TeamService struct { ... }
func (s *TeamService) List(...) (..., error) {
    ...
}

type UserService struct { ... }
func (s *UserService) List(...) (..., error) {
    ...
}

structがそれぞれ分かれているので、同名の List() というメソッドが持てますね。一種の名前空間として見ることができます。この種の実装は至るところで見つかります (例えばgoogle apiのライブラリ中でなど)。

この「Service」の定義をどうするのか?というのが今回の主題です。

普通はservice毎にstructを定義する

普通Serviceを定義するときには、以下のようにstructを分けて定義すると思います。

type Service struct {
    Client *http.Client
    BasePath string

    Team *TeamService // for /teams
    User *UserService // for /users
}

type TeamService struct {
    Service *Service
}
type UserService struct {
    Service *Service
}

func NewService(client *http.Client) *Service{
    s := Service{Client: client, BasePath: "/api"}
    s.Team = &TeamService{Service: s}
    s.User = &UserService{Service: s}
    return s
}

// 使うときは NewService(client).Foo.List(...) と言う形

そして、例えば、List()の実装についてだけみてみると、内部で認証用のtokenを付加したり、何らかの共通の付加的な操作を含んだ形で個々のエンドポイントにアクセスします。

func (s *TeamService) List() (..., error) {
    // 例えば authorization headerなどを汎用的に付けたり、backoffがついていたり
    // *http.Client.Do()をwrapした何か
    return s.Service.Do("GET", s.Service.BasePath + "/team")
}

これが普通の実装ですね。特に問題はないです。

しかし、ここで、ちょっとだけ実装を変えてみることを考えてみましょう。実は特定の条件のときに限れば、各Service毎でのstructの定義は不要になります。

new typeが使えるなら、structは不要にできるかもしれない

type <TypeName> <base>という形式で書くことで、baseとなる型(underlying type)から派生した別の型を作ることができます。これをnew typeと呼ぶことにします。このnew typeは type <TypeName> = <base> と書くようなtype aliasとは違い、<TypeName><base>は別の型として扱われます。

new typeによって定義された型TypeNameは、メソッドセットは空になるものの、フィールドのアクセスなどは引き続き可能です(詳しくは https://golang.org/ref/spec#Type_declarations )。これをつかって見ましょう。

メソッドセットが空になるんです。つまり名前空間として使う分にはnew typeで十分かもしれません。やってみましょう。

type Service struct {
    Client *http.Client
    BasePath string

    gw *gateway

    Team *TeamService // for /teams
    User *UserService // for /users
}

type gateway struct {
    Service *Service
}

type TeamService gateway
type UserService gateway

func NewService(client *http.Client) *Service{
    s := &Service{Client: client, BasePath: "/api"}
    gw := &gateway{Service: s}
    s.gw = gw

    s.Team = (*TeamService)(gw)
    s.User = (*UserService)(gw)
    return s
}

大まかな定義は、先程のstructを個別に定義したものと変わっていないです。gwという謎のフィールドが生えていますが。

New部分の定義も少し気持ち悪い気もするかもしれません。TeamもUserも実態は共に同じ値なものの、型が異なるので別々に定義した同名のメソッドをそれぞれ呼び分ける事ができます。

ちなみに、この場合でのTeamServiceでの List() の定義は以下の様になります。特に変わりはないですね。gateway自体はServiceを持っているので、Serviceで定義したメソッド Do() を利用する事ができます。

func (s *TeamService) List() (..., error) {
    // authorization headerなどを汎用的に付けたり、backoffがついていたり

    // 例えば *http.Client.Do()をwrapした何か
    return s.Service.Do("GET", s.Service.BasePath + "/team")
}

structの定義は不要になりましたね。

実行できるように整形したコード例のgistです。

利用例

func main() {
    s := New()
    fmt.Println(s.Team.List())
    fmt.Println(s.User.List())
}

実行結果

GET: request /api/team <nil>
GET:    request /api/user <nil>

go-github での利用

実はこのトリビアルな定義と同様の定義はgo-githubで使われていたりします。

はい。

有用 or not?

これがものすごく有用なhackか?あるいは誰しもが使うべきベストプラクティスか?というと、そうでもない気がします。せいぜいヒープに対するそれぞれ1つ程度のstruct用のメモリー割当が減らせると言う程度なので。それほどパフォーマンスやメモリーサイズに影響があるとは思えません。。(と言う認識です)

まとめ

まとめは以下のような感じです。

  • structを名前空間的に使うコードを見かけることがありますね?
  • そのstruct、もしかしたら不要になるかもしれません
  • new typeを使ったときには、同じ値でも型が違うので別のメソッドが持てますよね?

たまたまgo-githubのコードを読む機会があって見つけたのですが、まぁ実装自体もちょっとしたgoのクイズみたいな感じで面白いんじゃないでしょうか?

おしまい。