🤯

実戦でGorm + GinにGraphQLを入れる上で詰まったところ

2023/02/28に公開

はじめに

こんにちは、スペリスで開発をしている安永です。

この記事では、実戦でGraphQL(gqlgen)を入れる上で詰まったところとその解決策を紹介していきます。

プロジェクトの構成

まずはじめに前提として、私の場合プロジェクトにGraphQLを導入しようとしました。
また、そのプロジェクトは元からGinというフレームワークを使用しており、REST + MVCを採用していました。ORMはGormを使っています。

https://gin-gonic.com/ja/

またフロントエンドはNuxt.jsを使っています。

https://nuxtjs.org/

そこからプラスでGraphQLを入れたく、Ginとの統合例やリソースが豊富なgqlgenというGoのライブラリを入れてみようと決まりました。

REST・MVCについて

gqlgenとは?

gqlgenは、GraphQLを使いスキーマ駆動のAPIサーバの開発ができるようになるGoのライブラリの一つです。

https://gqlgen.com/

インストール後の開発の簡単な流れとしては、

  • GraphQLのスキーマを定義
  • gqlgenのコマンドを実行 go run github.com/99designs/gqlgen generate
  • 定義したスキーマを元に、Goの構造体や関数が自動的に生成される
  • その中の関数などを実装してサーバーに組み込むと、GraphQLのクエリを理解でき、求められたJSONを返すAPIが構築できる

という流れになります。

それでは、ここから導入するに当って詰まった点などを紹介します。

Ginへの組み込み

まずgqlgenをどう組み込むのか迷いました。

ただこちらは単純に、公式のやり方を踏襲すれば良かったです。gqlgenはGinへの組み込みの例がGithubに上がっていて、それを見れば一通りできます。

参考

まず必要なパッケージをimportします。

最初のimportの行はgo run github.com/99designs/gqlgen generateコマンドで生成される、graph(デフォルト名)フォルダの配下のパッケージ名になります。適宜変更してください。

main.go
package main

import (
	"github.com/[username]/gqlgen-todos/graph" // この部分はコマンドで生成されるパッケージまでのパスになります。適宜変更してください。
	"github.com/gin-gonic/gin"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
)

それからGraphQLハンドラとPlayGroundハンドラを定義します。

generated.go、resolver.goはデフォルトではgraphフォルダ配下にあります。
また、Playgroundのエンドポイントを用意しておくと、localhost:8080/にアクセスした時に、GraphQLのPlaygroundが起動できます。

デバッグの時に便利なので、用意しておくと良いでしょう。

// Graphqlハンドラーを定義
func graphqlHandler() gin.HandlerFunc {
	// NewExecutableSchemaはgenerated.goに
	// Resolverはresolver.goにあります。
	h := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: &graph.Resolver{}}))

	return func(c *gin.Context) {
		h.ServeHTTP(c.Writer, c.Request)
	}
}

// Playgroundハンドラを定義
func playgroundHandler() gin.HandlerFunc {
	h := playground.Handler("GraphQL", "/query")

	return func(c *gin.Context) {
		h.ServeHTTP(c.Writer, c.Request)
	}
}

最後に、ginに定義した関数を組み込みます。

func main() {
	// Ginのセットアップ
	r := gin.Default()
	r.POST("/query", graphqlHandler())
	r.GET("/", playgroundHandler())
	r.Run()
}

こうすれば、POST http://localhost:8080/query がGraphQLのエンドポイントになります。

参考

*gin.Contextへのアクセス

ここでは、*gin.Contextへのアクセスも紹介されています。
まずmiddlewareを定義し、それを使用します。

func GinContextToContextMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		ctx := context.WithValue(c.Request.Context(), "GinContextKey", c)
		c.Request = c.Request.WithContext(ctx)
		c.Next()
	}
}

func main() {
	// Ginのセットアップ
	r := gin.Default()
	r.Use(GinContextToContextMiddleware()) // 👈追加
	r.POST("/query", graphqlHandler())
	r.GET("/", playgroundHandler())
	r.Run()
}

それから、context.Contextからgin.Contextに変換する処理を書きます。

func GinContextFromContext(ctx context.Context) (*gin.Context, error) {
	ginContext := ctx.Value("GinContextKey")
	if ginContext == nil {
		err := fmt.Errorf("could not retrieve gin.Context")
		return nil, err
	}

	gc, ok := ginContext.(*gin.Context)
	if !ok {
		err := fmt.Errorf("gin.Context has wrong type")
		return nil, err
	}
	return gc, nil
}

あとは、リゾルバの中でその関数を使えば、*gin.Contextが取得できます。

func (r *resolver) Todo(ctx context.Context) (*Todo, error) {
	gc, err := GinContextFromContext(ctx)
	if err != nil {
		return nil, err
	}

	// ...
}
参考

セッションが使えない

当初使っていたセッションが使えないという問題がありました。
具体的には、以下のように使用していたのですが、どうしてもidがnilになってしまっていました。

session := sessions.Default(c)
id := session.Get(SessionKey)

原因はサーバー側というよりクライアント側にありました。
元々、axiosを使ってリクエストを送信していたのですが、新しくNuxt GraphQL requestというライブラリを導入し、リクエストを送っていたところ、axiosのwithCredentials: trueに相当するものが設定されておらず、セッションが空になっていたわけでした。

https://nuxt-graphql-request.vercel.app/

this.$graphql.default.setHeader('credentials', 'include');
this.$graphql.default.setHeader('mode', 'cors');

こんな感じで設定しても上手くいかず、、、途方に暮れていた時に、nuxt.config.jsに以下のように定義をしたらセッションが使えるようになりました🎉

nuxt.config.js
export default {
  graphql: {
    clients: {
      default: {
        options: {
          credentials: "include",
          mode: "cors",
        },
      },
    },
  },
}
参考

Queryを複数定義する方法

次はQueryを複数定義する方法について少し詰まりました。
というのも、graphqlのスキーマを定義する際にはファイルを分割するかと思うのですが、Queryを複数のファイルに書いてコマンドを実行すると、エラーになりました。

例えば以下のようにskills.graphqlとusers.graphqlの二つのファイルがあるとして、以下のように定義します:

skills.graphql
type Skill {
	id: ID!
	name: String!
}
type Query {
	skills: [Skill!]!
}
users.graphql
type User {
	id: ID!
	name: String!
}
type Query {
	users: [User!]!
}

コマンドを叩くと以下のようにエラーが出ます。

failed to load schema: graph/skills.graphql:13: Cannot redeclare type Query

こちらはtype Queryの前にextendをつけることで解決することができました。

extend type Query {
	skills: [Skill!]!
}
extend type Query {
	users: [User!]!
}

ID!がString型になる

スキーマを定義して、コマンドを実行すると、そのスキーマに応じてGoの構造体を定義してくれます。(デフォルトでは、/graph/model/models_gen.go)

なかなか便利なのですが、IDを定義すると型がstring型になるという問題がありました。

user.graphql
type User {
	id: ID!
}

変換後:

models_gen.go
type User struct {
	ID string `json:"id"`
}

こちらはGraphQL公式がID型をstringとして定めているためですね。一部引用すると:

ID: The ID scalar type represents a unique identifier, often used to refetch an object or as the key for a cache. The ID type is serialized in the same way as a String; however, defining it as an ID signifies that it is not intended to be human‐readable.
https://graphql.org/learn/schema/

ID: IDスカラ型は、オブジェクトを再取得したり、キャッシュのキーとして使われる、ユニークな識別子を表現します。ID型はStringと同じようにシリアライズされますが、IDとして定義することは、人間が読めるように意図していないことを示します。

自分の場合、既に定義してある構造体のIDはuintだったため、そことの整合性が取れずにレコードを作成しようとするとエラーになるといったことが起きました。

これは、gqlgen.ymlを以下のように変更することでID型をuintにすることができました。

gqlgen.yml
models:
  ID:
    model:
      - github.com/99designs/gqlgen/graphql.Uint # 変更
      - github.com/99designs/gqlgen/graphql.Int
      - github.com/99designs/gqlgen/graphql.Int64
      - github.com/99designs/gqlgen/graphql.Int32

既存の構造体とスキーマを紐付けるにはどうする?

自動的に構造体ができるのは素晴らしいのですが、自分の場合元々構造体をDBのスキーマと合わせて定義していたので、元々あった構造体とGraphQLのスキーマを紐付けたく、それをどうするのか悩みました。

こちらは、以下のようにgqlgen.ymlを変更することで、既存の構造体とGraphQLのスキーマを紐づけることができました。

gqlgen.yml
models:
  Skill:
    model: github.com/me/entity.Skill

modelsの下に、GraphQLのtype名、その下に、model: ローカルのパッケージまでのパス.構造体の名前でその構造体に紐づけることができました。(私の場合は、package entityの下に入れていたので、entityとしています。)

または、directiveを使うやり方もあるみたいです。こちらの方がファイルを行き来しなくて良いので楽ですね。

directive @goModel(
	model: String
	models: [String!]
) on OBJECT | INPUT_OBJECT | SCALAR | ENUM | INTERFACE | UNION

type User @goModel(model: "github.com/my/app/models.User") {
}
参考

変数名の変換が上手くいかない

自動的にコードを生成した際に、生成された変数名が命名規則と合わない問題がありました。

例えば、以下のようなスキーマからGoのコードを生成すると、skillIdsskillIdsのままになってしまう問題がありました。

extend type Query {
	skills(skillIds: [ID!]!): [Skill!]!
}

そのため、Goのコード内では、省略語(idなど)は全て大文字(ID, URLなど)で揃えていたため、スタイルの崩れが起こってしまいました。

// Skills is the resolver for the skills field.
// skillIdsをskillIDsにしたい!
func (r *queryResolver) Skills(ctx context.Context, skillIds []uint) ([]*entity.Skill, error) {
  panic(fmt.Errorf("not implemented: Skills - skills"))
}

こちらはdirectiveとinputを定義することで、解決しました。

directive @goField(name: String) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION

input SkillsInput {
	skillIds: [ID!]! @goField(name: "SceneIDs")
}

extend type Query {
	skills(SkillsInput): [Skill!]!
}

ちなみに以下のようにinputを定義しないとエラーになりました。

extend type Query {
	skills(skillIds: [ID!]! @goField(name: "SceneIDs")): [Skill!]!
}
参考

*.resolvers.goが勝手にできる

こちらは例えば、以下のように、定義済みの構造体のフィールドとGraphQLのスキーマ定義の名前が異なっている場合、そのフィールドの関数ができてしまう問題がありました。

skill.go
type Skill struct {
	Name string `json:"name"`
}
skill.graphql
type Skill {
	skillName: String!
}

この状態でコマンドを実行すると、skill.resolvers.goができてしまう。

skill.resolvers.go
func (r *queryResolver) SkillName(ctx context.Context) (string, error) {
	panic(fmt.Errorf("not implemented: SkillName - skill"))
}

こちらはgqlgen.ymlを以下のようにすることで、その関数が作られないようになりました。

gqlgen.yml
models:
  Skill:
    model: github.com/me/entity.Skill
    fields:
      skillName:
        fieldName: Name

もしくは、directiveで定義するやり方もあるみたいです。こっちの方がファイルを行き来する必要がないので良いかもしれませんね。

directive @goField(
	forceResolver: Boolean
	name: String
) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION

type Skill {
	skillName: String! @goField(name: "Name")
}

独自の型定義の場合

Goで独自、またはサードパーティのライブラリの型定義を使用していた場合、スキーマの型定義と合わずにリゾルバが作成される問題がありました。

例えば、gormにはgorm.DeletedAtというtypeがあり、論理削除のフラグとして使用されます。

その場合、以下のように定義し、コマンドを実行すると、リゾルバに関数ができてしまい、deletedAtがskillsのカラムだと認識されませんでした。

skill.go
type Skill struct {
	DeletedAt *gorm.DeletedAt
}
skill.graphql
scalar Time

type Skill {
	deletedAt: Time
}

その場合は、独自のMarshal・Unmarshal関数を作ることで解決できました。(場所はどこでも良いと思いますが、ここではresolver.goに書いております。)

graph/resolver.go
func UnmarshalDeletedAt(v interface{}) (*gorm.DeletedAt, error) {
	s, ok := v.(*gorm.DeletedAt)
	if !ok {
		return nil, fmt.Errorf("deleted at must be a time value")
	}
	return s, nil
}

func MarshalDeletedAt(time *gorm.DeletedAt) graphql.Marshaler {
	return graphql.WriterFunc(func(w io.Writer) {
		_, _ = io.WriteString(w, time.Time.String())
	})
}

こんな感じで書いて、gqlgen.ymlを以下のように追加します。

gqlgen.yml
models:
  DeletedAt:
    model: github.com/me/graph.DeletedAt

また、graphqlのスキーマも以下のように変更。

skill.graphql
scalar DeletedAt

type Skill {
	deletedAt: DeletedAt
}

これでコマンドを実行するとリゾルバの関数ができなくなりました。
その他の型も以下に定義を載せておきます。

参考

Void

mutationで何も返さない場合、scalar Voidを定義してそれを返すようにしました。

scalar Void

type Mutation {
	createTodo: Void
}

sql.NullBool

こちらもDeletedAtと大体同じです。

func UnmarshalNullBool(v interface{}) (sql.NullBool, error) {
	s, ok := v.(sql.NullBool)
	if !ok {
		return sql.NullBool{}, fmt.Errorf("null bool must be a sql.NullBool")
	}
	return s, nil
}

func MarshalNullBool(s sql.NullBool) graphql.Marshaler {
	return graphql.WriterFunc(func(w io.Writer) {
		if s.Bool {
			_, _ = io.WriteString(w, "true")
		} else {
			_, _ = io.WriteString(w, "false")
		}
	})
}
gqlgen.yml
models:
  NullBool:
    model: github.com/me/graph.NullBool
scalar NullBool

type Skill {
	isCurrent: NullBool
}

sql.NullString

こちらもDeletedAtと同様です。

func UnmarshalNullString(v interface{}) (sql.NullString, error) {
	s, ok := v.(sql.NullString)
	if !ok {
		return sql.NullString{}, fmt.Errorf("null string must be a sql.NullString")
	}
	return s, nil
}

func MarshalNullString(s sql.NullString) graphql.Marshaler {
	return graphql.WriterFunc(func(w io.Writer) {
		_, _ = io.WriteString(w, s.String)
	})
}

gqlgen.yml
models:
  NullString:
    model: github.com/me/graph.NullString
scalar NullString

type Skill {
	name: NullString
}

sql.NullTime

こちらもDeletedAtと同様です。

func UnmarshalNullTime(v interface{}) (sql.NullTime, error) {
	s, ok := v.(sql.NullTime)
	if !ok {
		return sql.NullTime{}, fmt.Errorf("null time must be a sql.NullTime")
	}
	return s, nil
}

func MarshalNullTime(t sql.NullTime) graphql.Marshaler {
	return graphql.WriterFunc(func(w io.Writer) {
		_, _ = io.WriteString(w, t.Time.String())
	})
}
gqlgen.yml
models:
  NullTime:
    model: github.com/me/graph.NullTime
scalar NullTime

type Skill {
	createdAt: NullTime
}

おわりに

いかがでしたでしょうか。同じエラーや事象で悩んでおられる方の助けになれば幸いです。
ここまで読んでいただきありがとうございました。

Discussion