Open7

Go + GraphQL + gRPC + React + RecoilでTodoアプリケーションを作る話

作るもの

技術スタック

  • クライアントアプリケーション
    • ブラウザで動く想定
    • 技術スタックはReact + TypeScript + Recoil
    • URLを変えていくのは大変そうなので、やらない
  • サーバサイド
    • BFF(=GraphQL)(made by Go)とバックエンドサーバ(made by Go)とMySQLとRedisを使う
    • バックエンドサーバはレイヤードアーキテクチャをイメージする
    • BFFはgqlgenを使う
    • バックエンドサーバは特に何も使わずにできる限り標準ライブラリでやる
    • BFFとバックエンドサーバの通信はgRPCでやる
  • インフラ
    • gcp
    • k8s

できること

  • ログイン/ログアウト
  • ログインユーザはTODOの管理できる
  • ログインユーザはTODOにコメントできる
  • ログインユーザはコメントに複数枚の画像を添付できる、また削除できる

やること

  • DB設計
  • Schemaの設計
  • gqlgenのセットアップ
    • gqlgenが生成したコードを解読して、クライアントから指定されたクエリ、フィールドをどうやって返しているのか?を知りたい。
  • モックサーバの提供
  • フロントの実装
    • デザイン適当
    • Recoil Relay使ってみる
  • protocol buffer実装
  • バックエンドサーバ実装
  • バックエンドとBFFつなぎこみ
  • インフラ設計
    • 許されるならspanner使ってみたいが、たぶん厳しい
  • インフラ実装
  • デプロイ
  • 終わり

DB設計

  • users
id name password_digest
UUID string string
  • tasks
id title body user_id
UUID string string UUID, foreign key
  • comments
id body created_at task_id
UUID string date UUID, foreign key
  • task_images
id url task_id
UUID URL UUID, foreign key
  • comment_images
id url comment_id
UUID URL UUID, foreign key

メモ

  • taskとcommnetのimagesはもっともうまくやれそうだけど、考えるのがめんどくさいので、いったんこれで進める。

gqlgenについてのメモ

生成されたコードを読みつつ、どうしてこれでGraphQL Serverが実装できるのか調べる。
まず、cmd/main.goを読む。
やっていることは以下。

  • http serverを起動
  • ルートパスでplaygroundを、/queryでクエリを待ち受ける。

実装は以下3行が肝になっていそう。

srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))

http.Handle("/", playground.Handler("GraphQL playground", "/query"))
http.Handle("/query", srv)

1つ1つ実装を読み解く。
まず、NewDefaultServer がやっていることについて。
これはServer構造体を返し、引数としてExecutableSchemaを受け取る関数。
Server構造体の定義は以下。

type (
	Server struct {
		transports []graphql.Transport
		exec       *executor.Executor
	}
)

で、この関数ではServer#AddTransportやServer#SetQueryCache、Server#Useメソッドを呼び出し、サーバの設定を行っている。行っている設定は主に以下(たぶん)。

  • GET,POST,OPTIONSメソッドの許可
  • WebSocketの設定
  • Query Cacheの設定
  • IntrospectionやAPQなどの利用を設定

これら設定をした後にServer構造体のインスタンスを返す。

Server構造体のインターフェイスを見てみると、SetXxxxというメソッドがいくつか定義されている。これらのメソッドはServer.execフィールドに対して値をセットする、薄いラッパーメソッドみたいなもん。

ということで、Server.execフィールドについて見ていく。

Server構造体を返すNew関数の実装を見てみると

func New(es graphql.ExecutableSchema) *Server {
	return &Server{
		exec: executor.New(es),
	}
}

となっている。

executor.Newを見てみる。

// Executor executes graphql queries against a schema.
type Executor struct {
	es         graphql.ExecutableSchema
	extensions []graphql.HandlerExtension
	ext        extensions

	errorPresenter graphql.ErrorPresenterFunc
	recoverFunc    graphql.RecoverFunc
	queryCache     graphql.Cache
}

var _ graphql.GraphExecutor = &Executor{}

// New creates a new Executor with the given schema, and a default error and
// recovery callbacks, and no query cache or extensions.
func New(es graphql.ExecutableSchema) *Executor {
	e := &Executor{
		es:             es,
		errorPresenter: graphql.DefaultErrorPresenter,
		recoverFunc:    graphql.DefaultRecover,
		queryCache:     graphql.NoCache{},
		ext:            processExtensions(nil),
	}
	return e
}

重要そうなフィールドが並んでいるが、特に重要そうなのはes、ExecutableSchemaっぽさそう。
これはどんな定義か見てみる。
ちなみに、このインターフェイスを満たす構造体を冒頭のNewDefaultServerが受け取っている。

type ExecutableSchema interface {
	Schema() *ast.Schema

	Complexity(typeName, fieldName string, childComplexity int, args map[string]interface{}) (int, bool)
	Exec(ctx context.Context) ResponseHandler
}

見た感じ、*ast.Schemaというのは、GraphQL Schemaを構文解析した結果を表す構造体っぽさそう。それを返す関数を期待している。
ComplexityとExecは命名からだけだと何をするのかいまいちわからない。

このinterfaceを満たす構造体は以下。ついでに関連する構造体やinterfaceも載せる。

// graph/generated/generated.go
type executableSchema struct {
	resolvers  ResolverRoot
	directives DirectiveRoot
	complexity ComplexityRoot
}

type ResolverRoot interface {
	Query() QueryResolver
}

type QueryResolver interface {
	HealthCheck(ctx context.Context) (bool, error)
	ListCommentByTaskID(ctx context.Context, taskID string) ([]*model.Comment, error)
	GetImage(ctx context.Context, typeArg *model.ImageType, objectID string) (*model.Image, error)
	GetTaskByID(ctx context.Context, id string) (*model.Task, error)
	ListTasksByUserID(ctx context.Context, userID string) ([]*model.Task, error)
	ListTasks(ctx context.Context) ([]*model.Task, error)
	LoginUser(ctx context.Context) (*model.User, error)
}

これはわかりやすい。要するにGraphQL Schemaを読み込み、Query名とインターフェイスをもとにQuery Resolver interfaceが生成される。で、このinterfaceを実装する構造体は graph/resolvers/schema.resolvers.go にあり、この構造体は自動生成されたresolver関数が定義されている。

調べたいのはクライアントから指定されたクエリ、フィールドをどうやって返しているのか?
これが分かればまぁ良しとしたい。

モックサーバの提供はwireでモックロジックをDIすることで実現する。
wire使ってみたいがゆえにそうする。本当ならgraphql-fakerとか使ったほうが良い気がする。

https://github.com/google/wire

このあたりにドキュメントがある。

https://github.com/google/wire/tree/main/docs
ドキュメントを雑に訳しつつ、メモをしていく。ちょこちょこdeeplちょっと直した、みたいなところがある。

User Guides

Basics

Defining Providers

wireの主要な仕組みはProviderである。これはある値を返す関数である。
例えばProvideFooはFoo構造体を返すProviderである。

package foobarbaz

type Foo struct {
    X int
}

// ProvideFoo returns a Foo.
func ProvideFoo() Foo {
    return Foo{X: 42}
}

Providerはexportされてる必要がある。

また、Providerはパラメータを取ることができる。
これはFoo構造体をパラメータに取るProviderである。

package foobarbaz

// ...

type Bar struct {
    X int
}

// ProvideBar returns a Bar: a negative Foo.
func ProvideBar(foo Foo) Bar {
    return Bar{X: -foo.X}
}

さらにProviderはerrorを返すこともできる。

package foobarbaz

import (
    "context"
    "errors"
)

// ...

type Baz struct {
    X int
}

// ProvideBaz returns a value if Bar is not zero.
func ProvideBaz(ctx context.Context, bar Bar) (Baz, error) {
    if bar.X == 0 {
        return Baz{}, errors.New("cannot provide baz when bar is zero")
    }
    return Baz{X: bar.X}, nil
}

Providerをprovider setsにグルーピングできる。

package foobarbaz

import (
    // ...
    "github.com/google/wire"
)

// ...

var SuperSet = wire.NewSet(ProvideFoo, ProvideBar, ProvideBaz)

他のProviderをprovider setに加えることもできる。

package foobarbaz

import (
    // ...
    "example.com/some/other/pkg"
)

// ...

var MegaSet = wire.NewSet(SuperSet, pkg.OtherSet)

この辺りのprovider setはレイヤードアーキテクチャみたいなパターンだと、レイヤーごとにsetを作って、レイヤーに新しいProviderができたら追加していく、みたいなことができるので、便利そう。

Injectors

アプリケーションはInjectorを使ってこれらのProviderを繋ぎ合わせることができる。Injectorは順々に依存を解決するようにProviderを呼び出す関数である。
Wireであれば、Injectorのシグネチャを書くことで、Wireは関数の中身を生成する。

Injectorはwire.Buildを呼び出す関数定義をすることで定義される。その返り値はそれらが正しい型である限り問題ない。それらの値は生成されたコード内では無視されるだろう。
さっそくexample.com/foobarbazというパッケージ内でProviderが定義されている。

要するにinitializeBazがInjectorである。返り値は関数シグネチャと一致していいれば何でも良い。

// +build wireinject
// The build tag makes sure the stub is not built in the final build.

package main

import (
    "context"

    "github.com/google/wire"
    "example.com/foobarbaz"
)

func initializeBaz(ctx context.Context) (foobarbaz.Baz, error) {
    wire.Build(foobarbaz.MegaSet)
    return foobarbaz.Baz{}, nil
}

Providerのように、InjectorはProviderへ渡される入力値を受け取ることができ、エラーを返すこともできる。wire.Buildへの引数はwire.NewSetと同じである。要するにProviderを任意の数渡すか、set構造体として渡すことができる。この引数はInjectorのコード生成の間使われるProvider setである。

Wireはwire_gen.goというファイルにInjectorの実行結果を書き込みます。書き込んだ結果は以下のとおりです。

// Code generated by Wire. DO NOT EDIT.

//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//+build !wireinject

package main

import (
    "example.com/foobarbaz"
)

func initializeBaz(ctx context.Context) (foobarbaz.Baz, error) {
    foo := foobarbaz.ProvideFoo()
    bar := foobarbaz.ProvideBar(foo)
    baz, err := foobarbaz.ProvideBaz(ctx, bar)
    if err != nil {
        return foobarbaz.Baz{}, err
    }
    return baz, nil
}

As you can see, the output is very close to what a developer would write themselves. Further, there is little dependency on Wire at runtime: all of the written code is just normal Go code, and can be used without Wire.

Once wire_gen.go is created, you can regenerate it by running go generate.

見たとおり、その出力内容は開発者が自分自身でDIした時とかなり近いものとなっている。さらに実行時にWireの依存はほとんどない。要するに出力された全てのコードが通常のGoであり、Wireなしでも使うことができる。

wire_gen.goが作られたとして、go generateを実行することでそれを再生成することができる。

Advanced Features

ここから紹介する機能はProviderとInjectorのコンセプトの上に成立している。

Binding Interfaces

Frequently, dependency injection is used to bind a concrete implementation for an interface. Wire matches inputs to outputs via type identity, so the inclination might be to create a provider that returns an interface type. However, this would not be idiomatic, since the Go best practice is to return concrete types. Instead, you can declare an interface binding in a provider set:

よく依存注入は具体的な実装をインターフェイスに結びつけるために使われる。Wireは型の同一性によって入力と出力をマッチングさせるので、インターフェース型を返すプロバイダを作ろうと思うかもしれません。
しかし、Goのベストプラクティスは具象型を返すことなので、これは一般的ではありません。代わりにprovider setでインターフェースバインディングを宣言することができます。

type Fooer interface {
    Foo() string
}

type MyFooer string

func (b *MyFooer) Foo() string {
    return string(*b)
}

func provideMyFooer() *MyFooer {
    b := new(MyFooer)
    *b = "Hello, World!"
    return b
}

type Bar string

func provideBar(f Fooer) string {
    // f will be a *MyFooer.
    return f.Foo()
}

var Set = wire.NewSet(
    provideMyFooer,
    wire.Bind(new(Fooer), new(*MyFooer)),
    provideBar)

wire.Bindの第1引数は期待するインターフェース型の値へのポインタで、第2引数はインターフェースを実装する型の値へのポインタである。インターフェースバインディングを含むセットは、具象型を提供するプロバイダーも同じセット内になければなりません。

この辺りはちんぷんかんぷん。まず、GoのBest Practiceを知らないので、ようわからんし、コードを読んでもようわからん。Bindを使った時にどういうコードが生成されるのか見る必要がある。

Struct Providers

構造体は提供された型を使って構築される。構造体型を構築するためにはwire.Struct関数を使い、Injectorにどのフィールドが注入されるべきなのか伝えないといけない。Injectorはそのフィールドの型のためにproviderを使ってそれぞれのフィールドを埋めるだろう。結果として構造体型Sのために、wire.StructはSと*S両方を提供する。
以下の例を見てみると、FooBar構造体のMyFoo、MyBarフィールドにProvideFooとProvideBarが使われていることがわかる。

type Foo int
type Bar int

func ProvideFoo() Foo {/* ... */}

func ProvideBar() Bar {/* ... */}

type FooBar struct {
    MyFoo Foo
    MyBar Bar
}

var Set = wire.NewSet(
    ProvideFoo,
    ProvideBar,
    wire.Struct(new(FooBar), "MyFoo", "MyBar"))

下は上から生成されたコード。

func injectFooBar() FooBar {
    foo := ProvideFoo()
    bar := ProvideBar()
    fooBar := FooBar{
        MyFoo: foo,
        MyBar: bar,
    }
    return fooBar
}

The first argument to wire.Struct is a pointer to the desired struct type and the subsequent arguments are the names of fields to be injected. A special string "" can be used as a shortcut to tell the injector to inject all fields. So wire.Struct(new(FooBar), "") produces the same result as above.

For the above example, you can specify only injecting "MyFoo" by changing the Set to:

var Set = wire.NewSet(
    ProvideFoo,
    wire.Struct(new(FooBar), "MyFoo"))
Then the generated injector for FooBar would look like this:

func injectFooBar() FooBar {
    foo := ProvideFoo()
    fooBar := FooBar{
        MyFoo: foo,
    }
    return fooBar
}

If the injector returned a *FooBar instead of a FooBar, the generated injector would look like this:

func injectFooBar() *FooBar {
    foo := ProvideFoo()
    fooBar := &FooBar{
        MyFoo: foo,
    }
    return fooBar
}

It is sometimes useful to prevent certain fields from being filled in by the injector, especially when passing * to wire.Struct. You can tag a field with wire:"-" to have Wire ignore such fields. For example:

type Foo struct {
    mu sync.Mutex `wire:"-"`
    Bar Bar
}

When you provide the Foo type using wire.Struct(new(Foo), "*"), Wire will automatically omit the mu field. Additionally, it is an error to explicitly specify a prevented field as in wire.Struct(new(Foo), "mu").

翻訳飽きたので、ここまで。他にも色々な機能がある。Interface bindingだけいまいちわからなかったので、後々調査する。

ログインするとコメントできます