Go言語で GraphQL - graph-gophers/graphql-go で Query, Mutation, Subscription を試す
Go言語で GraphQL を扱うライブラリはいくつかありますが、今回は下記を試しました。
文字列として定義した GraphQL スキーマを使うようになっており、それなりに使い易いと思います。
今回のソースは http://github.com/fits/try_samples/tree/master/blog/20210112/
Query 処理
まずは、単純な Query 処理を実装してみます。
MustParseSchema
に GraphQL スキーマ文字列(以下の schemaString
)と処理の実装(以下の resolver
)を与えて、Schema
を取得します。
Exec
に Context
、クエリ文字列、オペレーション名、クエリ用の変数を与えてクエリを実行します。
クエリの実行結果は Response
として返ってくるので json.Marshal
で JSON 文字列化して出力しています。
デフォルトで、GraphQL スキーマのフィールド(下記の id
や value
)の値は、該当するメソッドから取得するようになっています。※
※ 大文字・小文字は区別せず、 "_" を除いた名称が合致するメソッドを探している模様
sample1.go
package main import ( "context" "encoding/json" graphql "github.com/graph-gophers/graphql-go" ) const ( // GraphQL スキーマ定義 schemaString = ` type Item { id: ID! value: Int! } type Query { one: Item! } ` ) type item struct { id string value int32 } // GraphQL の id フィールドの値 func (i *item) ID() graphql.ID { return graphql.ID(i.id) } // GraphQL の value フィールドの値 func (i *item) Value() int32 { return i.value } type resolver struct{} // Query の one を実装 func (r *resolver) One() *item { return &item{"item-1", 12} } func main() { // GraphQL スキーマのパース schema := graphql.MustParseSchema(schemaString, new(resolver)) q := ` { one { id value } } ` // GraphQL クエリの実行 r := schema.Exec(context.Background(), q, "", nil) // JSON 文字列化 b, err := json.Marshal(r) if err != nil { panic(err) } println(string(b)) }
実行結果は以下の通りです。
ビルドと実行
> go build sample1.go > sample1 {"data":{"one":{"id":"item-1","value":12}}}
構造体のフィールドを使用
graph-gophers/graphql-go のソースコード internal/exec/resolvable/resolvable.go
を確認してみたところ、GraphQL フィールド値の取得先は以下のように探しているようでした。
- (1) 該当するメソッドを探す(findMethod の実施)
- (2) (1) で見つからず、UseFieldResolvers が適用されている場合は該当するフィールドを探す(findField の実施)
そこで、UseFieldResolvers
を適用し、item 構造体のフィールドから値を取得するようにしてみました。
sample2.go
・・・ type item struct { ID graphql.ID Value int32 } type resolver struct{} func (r *resolver) One() *item { return &item{graphql.ID("item-2"), 34} } func main() { // UseFieldResolvers 適用 schema := graphql.MustParseSchema(gqlSchema, new(resolver), graphql.UseFieldResolvers()) ・・・ }
実行結果は以下の通りです。
ビルドと実行
> go build sample2.go > sample1_field {"data":{"one":{"id":"item-2","value":34}}}
なお、このコードで UseFieldResolvers を適用しなかった場合、実行時に panic: *main.item does not resolve "Item": missing method for field "id"
となりました。
Mutation, Subscription 処理
最後に、GraphQL の enum や input を使って Mutation や Subscription を行う処理を実装してみました。
enum は string、input は 構造体で扱う事ができます。
Subscription は Subscribe
で実施し、その実装メソッドは受信用 channel(<-chan T
)を戻り値にします。
Exec の戻り値である Response の Data フィールドの型は json.RawMessage
となっているので、構造体や map へアンマーシャルする事が可能です。
sample3.go
package main import ( "context" "encoding/json" "log" "sync" "github.com/google/uuid" graphql "github.com/graph-gophers/graphql-go" ) const ( schemaString = ` enum Category { Standard Extra } input CreateItem { category: Category! value: Int! } type Item { id: ID! category: Category! value: Int! } type Mutation { create(input: CreateItem!): Item } type Query { find(id: ID!): Item } type Subscription { created: Item } ` ) // input type createItem struct { Category string Value int32 } type item struct { id string category string value int32 } func (i *item) ID() graphql.ID { return graphql.ID(i.id) } func (i *item) Category() string { return i.category } func (i *item) Value() int32 { return i.value } // item 管理 type store struct { sync.RWMutex items []*item } func (s *store) add(i *item) { s.Lock() defer s.Unlock() s.items = append(s.items, i) } func (s *store) get(id graphql.ID) *item { s.RLock() defer s.RUnlock() for _, i := range s.items { if i.ID() == id { return i } } return nil } // Subscription 用の channel 管理 type broker struct { sync.RWMutex subscribes []chan<- *item } func (b *broker) subscribe(ch chan<- *item) { log.Println("[INFO] subscribe") b.Lock() defer b.Unlock() b.subscribes = append(b.subscribes, ch) } func (b *broker) unsubscribe(ch chan<- *item) { log.Println("[INFO] unsubscribe") var tmp []chan<- *item b.Lock() defer b.Unlock() for _, s := range b.subscribes { if s != ch { tmp = append(tmp, s) } } b.subscribes = tmp } func (b *broker) publish(i *item) { b.RLock() defer b.RUnlock() for _, s := range b.subscribes { s <- i } } type resolver struct { store *store broker *broker } // Mutation の実装 func (r *resolver) Create(args struct{ Input createItem }) (*item, error) { id, err := uuid.NewRandom() if err != nil { return nil, err } i := item{id.String(), args.Input.Category, args.Input.Value} r.store.add(&i) go func() { r.broker.publish(&i) }() return &i, nil } // Query の実装 func (r *resolver) Find(args struct{ ID graphql.ID }) *item { return r.store.get(args.ID) } // Subscription の実装 func (r *resolver) Created(ctx context.Context) <-chan *item { ch := make(chan *item) r.broker.subscribe(ch) go func() { // Context キャンセル時 <-ctx.Done() log.Println("[INFO] context done") r.broker.unsubscribe(ch) close(ch) }() return ch } // Subscription の受信 func onCreated(ch <-chan interface{}) { for { r, ok := <-ch if !ok { log.Println("[INFO] closed channel") return } b, _ := json.Marshal(r) log.Println("[SUBSCRIPTION]", string(b)) } } func printResponse(r *graphql.Response) error { b, err := json.Marshal(r) if err != nil { return err } log.Println(string(b)) return nil } func main() { resolver := resolver{new(store), new(broker)} schema := graphql.MustParseSchema(schemaString, &resolver) ctx, cancel := context.WithCancel(context.Background()) s := ` subscription { created { id category value } } ` // Subscription の実施 ch, err := schema.Subscribe(ctx, s, "", nil) if err != nil { panic(err) } go onCreated(ch) m1 := ` mutation { create(input: { category: Standard, value: 10 }) { id } } ` mr1 := schema.Exec(context.Background(), m1, "", nil) _ = printResponse(mr1) var cr1 struct { Create struct { ID string } } // Mutation 結果の data の内容を構造体へアンマーシャル err = json.Unmarshal(mr1.Data, &cr1) if err != nil { panic(err) } q := ` query findItem($id: ID!) { find(id: $id) { id category value } } ` qr1 := schema.Exec(context.Background(), q, "", map[string]interface{}{"id": cr1.Create.ID}) _ = printResponse(qr1) m2 := ` mutation Create($p: CreateItem!) { create(input: $p) { id } } ` // GraphQL のクエリ用変数 vs := map[string]interface{}{ "p": map[string]interface{}{ "category": "Extra", "value": 123, }, } mr2 := schema.Exec(context.Background(), m2, "", vs) _ = printResponse(mr2) var cr2 map[string]map[string]interface{} // Mutation 結果の data の内容を map へアンマーシャル err = json.Unmarshal(mr2.Data, &cr2) if err != nil { panic(err) } qr2 := schema.Exec(context.Background(), q, "", cr2["create"]) _ = printResponse(qr2) // Subscription のキャンセル cancel() mr3 := schema.Exec(context.Background(), m2, "", map[string]interface{}{ "p": map[string]interface{}{ "category": "Extra", "value": 987, }, }) _ = printResponse(mr3) mr4 := schema.Exec(context.Background(), m2, "", map[string]interface{}{ "p": map[string]interface{}{ "category": "Standard", "value": 567, }, }) _ = printResponse(mr4) qr5 := schema.Exec(context.Background(), q, "", map[string]interface{}{"id": "invalid-id"}) _ = printResponse(qr5) }
ビルドと実行
> go build sample3.go > sample3 2021/01/11 21:03:40 [INFO] subscribe 2021/01/11 21:03:40 {"data":{"create":{"id":"507dae03-1f93-4b1a-a75e-3fc54b297ad4"}}} 2021/01/11 21:03:40 [SUBSCRIPTION] {"data":{"created":{"id":"507dae03-1f93-4b1a-a75e-3fc54b297ad4","category":"Standard","value":10}}} 2021/01/11 21:03:40 {"data":{"find":{"id":"507dae03-1f93-4b1a-a75e-3fc54b297ad4","category":"Standard","value":10}}} 2021/01/11 21:03:40 {"data":{"create":{"id":"b47bf46c-5c10-4a8f-892e-9ebfa83d576a"}}} 2021/01/11 21:03:40 [SUBSCRIPTION] {"data":{"created":{"id":"b47bf46c-5c10-4a8f-892e-9ebfa83d576a","category":"Extra","value":123}}} 2021/01/11 21:03:40 {"data":{"find":{"id":"b47bf46c-5c10-4a8f-892e-9ebfa83d576a","category":"Extra","value":123}}} 2021/01/11 21:03:40 [INFO] closed channel 2021/01/11 21:03:40 [INFO] context done 2021/01/11 21:03:40 [INFO] unsubscribe 2021/01/11 21:03:40 {"data":{"create":{"id":"aef942a6-3aa7-4b31-89c4-cd44f748bed6"}}} 2021/01/11 21:03:40 {"data":{"create":{"id":"fe3db2a4-5578-4d33-b54a-26a8b6e281f3"}}} 2021/01/11 21:03:40 {"data":{"find":null}}