Golang GenericsでREST APIを作る

Go 1.18が出てしばらく経ちました。みなさんGenerics使っていますか?
@maaashです。
これは Nature Engineering Blog祭 の2日目の記事です。

祭り

最初は歓喜し、mapやfilter的なfor文を少しずつgithub.com/samber/loに切り替えたり、 internalなsliceパッケージを作ってみたり。

ですが心のどこかで欲求不満が蓄積されていくのを感じていました。 使い尽くしていないのでその真価が理解できていなくて物足りないような、 こんなに面白いおもちゃが与えられたのに遊び尽くしていないような感覚です。

Genericsを使ったコードを書きたい!と思いながら日々の業務でREST APIを書いていると、、 あれ、これはかの When To Use Genericsの結論にある:

If you find yourself writing the exact same code multiple times, where the only difference between the copies is that the code uses different types, consider whether you can use a type parameter.

Aren't we writing the exact same code many times?
私たちはREST APIを何度も何度も書いていはしないか?!
これはGenericsを使うべき時なのではないか?!
と思うに至りました。

ちょうどブログ祭りの話がありネタを探していたこともあり、REST API的なものを(おことわり: RESTという言葉はこの記事では雰囲気で使っています) 一発試しにGenericsを使って作れないものか、 と考えて数日実験してみた結果が以下です。

Ghost - Build REST APIs from structs using Generics

github.com/mash/ghostパッケージのREADMEにはこんなふうに用例を書きました。

type User struct {
    Name string
}

type SearchQuery struct {
    Name string
}

func main() {
    store := ghost.NewMapStore(&User{}, SearchQuery{}, uint64(0))
    g := ghost.New(store)
    // g is a http.Handler and it provides GET /?name=:name, GET /:userid, POST /, PUT /:userid, DELETE /:userid
    http.ListenAndServe("127.0.0.1:8080", g)
}

なんとなく意図は伝わるでしょうか。 User型であるリソースがあり、POST, GET, PUT, DELETE HTTPメソッドを使ったHTTPリクエストがそれぞれCRUDアクションを呼び出します。 SearchQuery型でリソースを検索できリストを返します。 uint64型のリソース識別子がpathに入るURLがリソースの場所を表すREST APIを提供します。

REST APIと聞くとさまざまな異なるイメージをみなさま思い浮かべるでしょう。 リクエスト、レスポンスのボディはJSONでしょうか、MessagePackでしょうか。エラーを返す際にはエラーコードと人間が読めるようなmsgを含むでしょうか。

なるべくさまざまなスタイルの実装が可能なようconstraintは緩く作り、 スタイルの違いを表現できるようGenericな部品を入れ替え、組み合わせて使えるようにしたいと考えました。

3つのGenericな型を使います。 この例ではUser型であるリソースはtype Resource any, SearchQuery型であるListクエリーはtype Query any, uint64型である識別子はtype PKey interface { comparable }をそれぞれtype constraintとしています。

...

これを書きながら自分が面白かった発見をいくつか紹介します。

1. Genericなinterfaceを使ってGenericな世界から脱出する

Goを書くとき好んで使うデザインパターンに、Middleware Design Patternがあります。

Middleware Design Pattern - https://docs.pylonsproject.org/projects/pylons-webframework/en/latest/concepts.html#wsgi-middleware より

玉ねぎの中心にある操作を包むように、前後に操作を挿入する標準的なインターフェースを提供することで、単機能な部品(玉ねぎの層)を組み合わせて全体として複雑な操作を記述できます。 http.Handlerではよく使いますね。 最初に見て衝撃を受けたのはPSGI/Plackでしょうか(懐かしい)。

REST APIでも同様のパターンを適用すると効果がありそうと考え、以下のようなGenericなインターフェースを定義しました。

type Store[R Resource, Q Query, P PKey] interface {
    Create(context.Context, *R) error
    Read(context.Context, P, *Q) (*R, error)
    Update(context.Context, P, *R) error
    Delete(context.Context, P) error
    List(context.Context, *Q) ([]R, error)
}

このインターフェースを実装し、github.com/go-playground/validator を使って、CRUDの操作の前にリクエストパラメータのバリデーションをするための github.com/mash/ghost/store/validator が出来ました。その抜粋を以下に示します。

type validatorStore[R ghost.Resource, Q ghost.Query, P ghost.PKey] struct {
    store    ghost.Store[R, Q, P]
    validate *validator.Validate
}

func NewStore[R ghost.Resource, Q ghost.Query, P ghost.PKey](store ghost.Store[R, Q, P], validator *validator.Validate) ghost.Store[R, Q, P] {
    return validatorStore[R, Q, P]{
        store:    store,
        validate: validator,
    }
}

func (s validatorStore[R, Q, P]) Create(ctx context.Context, r *R) error {
    if err := s.validate.StructCtx(ctx, r); err != nil {
        return validationError(err)
    }
    return s.store.Create(ctx, r)
}

シンプルで良いですね。

このような用意されたバリデーションでは不足する場合もあるでしょう。 カスタムのバリデーションを実装するためにCRUDする前にフックを呼べると良いのでは。 と考え hookStore を作りました。

type hookStore[R Resource, Q Query, P PKey] struct {
    store Store[R, Q, P]
}

func NewHookStore[R Resource, Q Query, P PKey](store Store[R, Q, P]) Store[R, Q, P] {
    return hookStore[R, Q, P]{
        store: store,
    }
}

type BeforeCreate interface {
    BeforeCreate(context.Context) error
}

type AfterCreate interface {
    AfterCreate(context.Context) error
}

func (s hookStore[R, Q, P]) Create(ctx context.Context, r *R) error {
    if h, ok := any(r).(BeforeCreate); ok {
        if err := h.BeforeCreate(ctx); err != nil {
            return err
        }
    }
    if err := s.store.Create(ctx, r); err != nil {
        return err
    }
    if h, ok := any(r).(AfterCreate); ok {
        if err := h.AfterCreate(ctx); err != nil {
            return err
        }
    }
    return nil
}

Genericな型Rである引数rは、any(r)にキャストしてからBeforeCreate, AfterCreateインターフェースを満たしているかtype assertionしてrの関数を呼べます。 ここまでだとGenericsでなくともできますね。

type BeforeList[Q Query] interface {
    BeforeList(context.Context, *Q) error
}

type AfterList[R Resource, Q Query] interface {
    AfterList(context.Context, *Q, []R) error
}

func (s hookStore[R, Q, P]) List(ctx context.Context, q *Q) ([]R, error) {
    var r R
    if h, ok := any(&r).(BeforeList[Q]); ok {
        if err := h.BeforeList(ctx, q); err != nil {
            return nil, err
        }
    }
    l, err := s.store.List(ctx, q)
    if err != nil {
        return l, err
    }
    if h, ok := any(&r).(AfterList[R, Q]); ok {
        if err := h.AfterList(ctx, q, l); err != nil {
            return nil, err
        }
    }
    return l, nil
}

R,Qをtype parameterとするGenericなインターフェースBeforeList[Q], AfterList[R, Q]にtype assertionすることもできるんですね!? 他のコードで見たことがない感じになってきました。

これらインターフェースを実装するコードがテストにあります。

func (u *HookedUser) BeforeList(ctx context.Context, q *SearchQuery) error {
    globalCalled["BeforeList"]++ // テスト用に呼び出しを記録する
    return nil
}

func (u *HookedUser) AfterList(ctx context.Context, q *SearchQuery, rr []HookedUser) error {
    globalCalled["AfterList"]++
    return nil
}

ほ〜これは面白い。。。 Genericな世界の中から、Genericでない型(なんて呼ぶんでしょう具体の型のこと)を引数に持つ関数の呼び出しが出来るんですね。 typoしてインターフェースを実装しなくなるミスを防ぐために以下のように書いておくと安全です。

var _ ghost.BeforeList[SearchQuery] = &HookedUser{}
var _ ghost.AfterList[HookedUser, SearchQuery] = &HookedUser{}

ORマッパー、gormを使ったgormStoreも作り、同じような仕組みで自分で書いたフックを実装できるようにしました。 リソースをどのようにデータベースに保存するか、は三者三様でしょうから、フックは実装されていればGenericな実装は使わないようにしました。 興味があれば見てみてください。

2. 特殊化?

Cに近いC++しか書いたことがなくJavaもなるべく避けてきたためGenericsの語彙が不足しています。 これをなぜ面白いと感じるのか説明が難しいのですが突き進んでみましょう。

上の例を実行すると/:useridというpathができ、:userid部分にはuint64が入ります。 pathは文字列なので、文字列をstrconv.ParseUintしてuint64に変換しています。

REST APIを作るときに、slugと言われる文字列を識別子に使いたい場合もありますよね。twitter.com/maaashのmaaash部分のような。 ですがslugをstrconv.ParseUintしてもしょうがありません。

ということで、それぞれ別に実装しました。

type PKey interface {
    comparable
}

type PUintKey interface {
    comparable
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

type PStrKey interface {
    comparable
    string
}

type Identifier[P PKey] interface {
    PKey(*http.Request) (P, error)
}

// PathIdentifier is an Identifier which extracts the PKey from the request URL path.
type PathIdentifier[P PKey] func(string) (P, error)

func (pi PathIdentifier[P]) PKey(r *http.Request) (P, error) {
    var p P
    _, lastpath := path.Split(r.URL.Path)
    if lastpath != "" {
        return pi(lastpath)
    }
    return p, ErrNotFound
}

func UintPath[P PUintKey](s string) (P, error) {
    i, err := strconv.ParseUint(s, 10, 64)
    return P(i), err
}

func StrPath[P PStrKey](s string) (P, error) {
    return P(s), nil
}

上の方に

スタイルの違いを表現できるようGenericな部品を入れ替え、組み合わせて使えるようにしたいと考えました。

と書きました。 Identifierは、その一つの部品であり、HTTPリクエストからPKeyを取り出すGenericなinterfaceです。 PathIdentifierはそのGenericな実装の一種で、URL.PathからPKeyを取り出します。 PKeyがuintの場合、stringの場合とをそれぞれUintPath, StrPathが処理します。

ghost全体としてはtype PKey interface { comparable }な緩いconstraintでありながら PKeyの制約をより厳しくしたPUintKeyPStrKeyを使うことで最もメジャーであるユースケースをカバーし、 パッケージのユーザーが特殊な型を使いたい場合には自分でIdentifierインターフェースを実装すればghost全体に組み込むことができる。。。すごい。。。

(伝わりませんね)

...

終わりに。 ここまで辿り着いた方は、やりすぎだ、、と思われたでしょうか。正直自分もそう思います。インターフェースで良いだろ、と言う批判もあるでしょう。 が一方で、これはGenericなインターフェースを実装する部品が増えたりすると使い物になるのでは。。。??という気もしています。

まずはサイドプロジェクトでちょっと使ってみようかなと思っています。

Ghost作ってみて、Generics触り切れていない欲求不満感が解消されてよかった。 そしてGo1.18にGenericsがこのような使いやすい文法で入って本当によかった。Genericsが入って読みにくいコードが増える、というような懸念もあったが本当に読みやすい。

GoのGenericsについての最大の不満は、 Github Copilotの補完が弱いこと。。。まじCopilotの提案するコードの精度が悪いと生産性下がります。 みんなGenericsを使ったpublicなコードを書いて、Copilotくんの餌を生産しましょう!

面白いと思っていただけた方はレポジトリ@maaash にフィードバックをいただければ喜びます。

注: このブログ祭りはNatureで使っている技術の紹介、というテーマがありますが、ここで紹介したGhostはNatureでは使っていません(!) 今日初めてお披露目したので :) どう思います? @弊社Gopher

...

エンジニア積極採用中です

Natureでは一緒に開発してくれる仲間を募集しています。

herp.careers

カジュアル面談も歓迎なので、ぜひお申し込みください。

herp.careers

Natureのミッション、サービス、組織や文化、福利厚生についてご興味のある方は、ぜひCulture Deckをご覧ください。

speakerdeck.com