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 を取得します。

ExecContext、クエリ文字列、オペレーション名、クエリ用の変数を与えてクエリを実行します。

クエリの実行結果は Response として返ってくるので json.MarshalJSON 文字列化して出力しています。

デフォルトで、GraphQL スキーマのフィールド(下記の idvalue)の値は、該当するメソッドから取得するようになっています。※

 ※ 大文字・小文字は区別せず、
    "_" を除いた名称が合致するメソッドを探している模様
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}}