実戦でGorm + GinにGraphQLを入れる上で詰まったところ
はじめに
こんにちは、スペリスで開発をしている安永です。
この記事では、実戦でGraphQL(gqlgen)を入れる上で詰まったところとその解決策を紹介していきます。
プロジェクトの構成
まずはじめに前提として、私の場合プロジェクトにGraphQLを導入しようとしました。
また、そのプロジェクトは元からGinというフレームワークを使用しており、REST + MVCを採用していました。ORMはGormを使っています。
またフロントエンドはNuxt.jsを使っています。
そこからプラスでGraphQLを入れたく、Ginとの統合例やリソースが豊富なgqlgenというGoのライブラリを入れてみようと決まりました。
REST・MVCについて
gqlgenとは?
gqlgenは、GraphQLを使いスキーマ駆動のAPIサーバの開発ができるようになるGoのライブラリの一つです。
インストール後の開発の簡単な流れとしては、
- 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(デフォルト名)フォルダの配下のパッケージ名になります。適宜変更してください。
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
に相当するものが設定されておらず、セッションが空になっていたわけでした。
this.$graphql.default.setHeader('credentials', 'include');
this.$graphql.default.setHeader('mode', 'cors');
こんな感じで設定しても上手くいかず、、、途方に暮れていた時に、nuxt.config.js
に以下のように定義をしたらセッションが使えるようになりました🎉
export default {
graphql: {
clients: {
default: {
options: {
credentials: "include",
mode: "cors",
},
},
},
},
}
参考
Queryを複数定義する方法
次はQueryを複数定義する方法について少し詰まりました。
というのも、graphqlのスキーマを定義する際にはファイルを分割するかと思うのですが、Queryを複数のファイルに書いてコマンドを実行すると、エラーになりました。
例えば以下のようにskills.graphqlとusers.graphqlの二つのファイルがあるとして、以下のように定義します:
type Skill {
id: ID!
name: String!
}
type Query {
skills: [Skill!]!
}
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型になるという問題がありました。
type User {
id: ID!
}
変換後:
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にすることができました。
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のスキーマを紐づけることができました。
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のコードを生成すると、skillIds
がskillIds
のままになってしまう問題がありました。
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のスキーマ定義の名前が異なっている場合、そのフィールドの関数ができてしまう問題がありました。
type Skill struct {
Name string `json:"name"`
}
type Skill {
skillName: String!
}
この状態でコマンドを実行すると、skill.resolvers.goができてしまう。
func (r *queryResolver) SkillName(ctx context.Context) (string, error) {
panic(fmt.Errorf("not implemented: SkillName - skill"))
}
こちらは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のカラムだと認識されませんでした。
type Skill struct {
DeletedAt *gorm.DeletedAt
}
scalar Time
type Skill {
deletedAt: Time
}
その場合は、独自のMarshal・Unmarshal関数を作ることで解決できました。(場所はどこでも良いと思いますが、ここでは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を以下のように追加します。
models:
DeletedAt:
model: github.com/me/graph.DeletedAt
また、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")
}
})
}
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)
})
}
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())
})
}
models:
NullTime:
model: github.com/me/graph.NullTime
scalar NullTime
type Skill {
createdAt: NullTime
}
おわりに
いかがでしたでしょうか。同じエラーや事象で悩んでおられる方の助けになれば幸いです。
ここまで読んでいただきありがとうございました。
Discussion