2021-05-17
こんにちは、僕です。
この記事は 技術メモ にまとめたものの総括みたいな感じです。
ということでGraphQL に入門したのでまとめます。
ここでは Query と Mutation については触れますが Subscription については触れません。(これで入門したとか言うな)
一応 Subscription については後日追記予定です。
手を動かした時間で言うと10時間もないのでそこまで深くやっているわけではありません。
一旦ここにまとめてから深めて行きたいなと思っています。
概念のお勉強は書籍を使った方がいいと思ってる人間なのですが、今回はネット上のものだけを拾いながら勉強しました(辛い)
参考にしたサイトはいかです。あとは手を動かして頑張りました。
ここら辺を参考にしました。
最初は何言ってるかわからなかったのでちょこちょこ行ったり来たりしながらやりました。辛かった〜〜。
まず導入としてここがよくわかりませんでした。
GraphQL にはどうやら Query
と Mutation
というものがあるということで何してるかわからなかったです。ここに一番時間使った気がします。
こんな感じです。
基本的にはこれだけでいいと思います。あとはやって覚えたほうが早い気がする。
次にリクエストを送る形式についてを説明します。
GraphQL は REST とはだいぶ異なる形でリクエストを送ります。
REST では完全に JSON という形でしたが GraphQL ではそうではありません。
まず Query と Mutation の場合で書き方が違います(ほぼ同じだけど)
ここではわかりやすいように以下のようなシンプルな JSON を使用して説明をしたいと思います。
[
{
"id": 1,
"name": "takurinton"
},
{
"id": 2,
"name": "hoge"
},
{
"id": 3,
"name": "fuga"
}
]
上で GraphQL は GET でも POST でもリクエストを送信できるという話をしました。
イメージとしては、GET リクエストの場合はクエリパラメータとして、POST リクエストの場合は body として持たせてあげる感じです。
REST の思想が強めの人はここで GET は取得、POST は作成という概念を消してください。(もっと言うと PUT, PATCH, DELETE のことも忘れてください)
また、基本的にはエンドポイントは1つです、それも頭に入れておくと良きかもしれません。
以下でちょこちょこ出てくる なんとかUser
というのはアプリケーション側で任意の名前をつけることができるものです。
Query の場合は以下のような書き方をします。
まずは先ほどの JSON の内容を全て取得したいと思います。
GET リクエストの場合は以下のような形で投げることができます。
curl -g http://localhost:8888/graphql?query={getUsers{id,name}}
次に POST リクエストの場合は以下のような形で投げることができます。
curl -X POST -H "Content-Type: application/json" --data '{ query { getUsers { id name } } }' http://localhost:8888/graphql
GET でも POST でもやってることは同じで、レスポンスは上の JSON が返ってきます。
また、id が1のユーザーを取得するなどといった、可変の値を渡すこともできます。以下の POST リクエストのような形で渡してあげることができます。
curl -X POST -H "Content-Type: application/json" --data '{ "query": " getUser(id:\"1\") { id name } " }' http://localhost:8888/graphql
これはアプリケーション側で getUser
は引数を取るよということを明示的に定める必要があります。
このような形で値を渡すこともできます。
Mutation は更新系の処理をしたい場合に使用します。
REST でいう GET 以外の部分がここに含まれると思ってください。
POST でリクエストを送ってみたいと思います。
以下の例は先ほどの JSON に値を追加したい時に送る Mutation です。
curl -X POST -H "Content-Type: application/json" --data '{ "query": "mutation { createUser(id:4, name:\"hogehoge\") { id name } }" }' http://localhost:8888/graphql
登録したい内容を送信しないといけないので先ほど同様、可変の値を投げる必要があります。もしこれが DB アクセスをするような場合だと id
はオートインクリメントになりそうだからいらないなあとか考えますね。
GraphQL の基本的な形式はこのような形です。
コードがないとわからないよという人は GraphQL に入門した(Go) を見てください。
レスポンスの形式は返ってくる値は JSON ですが REST とは若干異なります。
GraphQL のレスポンスの形式は以下のようになります。
{
"data":{"任意の名前":{"key":"value",...},
"errors": [ ... ]}
data
はうまく行った時のレスポンスの値、errors
は何かしらのミスがあったときにエラーを格納します。
例として、上の全件取得(Query)の場合のレスポンスは以下のようになります。
{"data":{"getUsers":[{"id":1, "name": "takurinton"},{"id":2, "name": "hoge"},{"id":3, "name": "fuga"}]}
一番外側に data
がついていて、任意の名前でラッピングされてるだけと思ったらそこまで難しくなく、普通の JSON じゃんと思うかもしれません。そうです。返ってくるのは JSON です。
あまり難しいことはしていないことがわかります。
エラーの場合も同様に errors
が返ってきます。例えば query
か mutation
かを明示的に定めないでリクエストを投げると以下のようなエラーが得られます。
{"data":null,"errors":[{"message":"Must provide an operation.","locations":[]}]}
エラーの中にはメッセージが格納されていて、何が悪いのかを教えてくれます。
REST だと自分で丸めてた部分をやってくれてる感じです。便利。
上で GraphQL は GET と POST が使用できるという話をしましたが、これらの違いはなんなのでしょうか?
APOLLO DOCS POST and GET format
で触れられています。そもそもの形式が違うらしいです。
また、GET を使う場合は URL をキャッシュしたいときなどに使用できるかもしれないなあなどと思いました。あまり詳しくないのでここらへん有識者の方アドバイスお願いします。
GraphQL でのやりとりは JSON を使用しているので HTTP header の Content-Type
には application/json
を使用します。しかし、どうやら application/graphql
が使用できるらしいです。
これは
iana.org の Media Types
で確認することができますが、application/graphql
は含まれていません。つまりこれは標準ではなく GraphQL 独自の仕様ということがわかります。
使い分けとしては、
GraphQL のドキュメント
で触れられています。
If the "application/graphql" Content-Type header is present, treat the HTTP POST body contents as the GraphQL query string.
とのことなので、body を勝手に GraphQL として扱ってくれるみたいです。意図しない挙動を防ぐためにここら辺は理解したみがあります。
最終的なコードは以下のようになります。
ディレクトリ構成としては以下のようになっています。
簡単な TODO リストを実装してみたいと思います。
フロントエンドは取得だけをします。ごめんなさい。あとで投稿もできるようにするので。(今の段階で TODO リストと呼べるのか怪しい)
データの形式は以下のような形になっています。 (サーバサイドから引用)
MariaDB > show columns from todo;
+------------+---------------+------+-----+---------------------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+---------------+------+-----+---------------------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| title | varchar(127) | YES | | NULL | |
| content | varchar(1023) | YES | | NULL | |
| is_active | tinyint(1) | NO | | 1 | |
| created_at | timestamp | NO | | current_timestamp() | |
| updated_at | timestamp | NO | | current_timestamp() | |
+------------+---------------+------+-----+---------------------+----------------+
6 rows in set (0.001 sec)
まずはフロントエンドからリクエストを投げる際に使用する query を定義します。PostQuery
では Int
型の引数を受け取るようにしています。
// querys/querys.js
export const PostQuery = `
query PostQuery($id: Int){
post (id: $id){
id
title
content
is_active
created_at
}
}
`
export const PostsQuery = `
query PostsQuery {
posts {
id
title
content
is_active
created_at
}
}
`
本質ではないので基本的な構成などはコードから理解してください。ここでは Posts.jsx
、Post.jsx
についてのみ説明します。
TODO の一覧を取得するためには query の定義が必要です。ということで先ほど定義した query を使用して書いていきます。
const { data, fetching, error } = result
if (fetching) return <p>Loading...</p>
if (error) return <p>Oh no... {error.message}</p>
この部分ですが、ドキュメントに記載があった通りに実装しました。一般的な書き方がわからないので有識者の方教えてください。
全体は以下のようになります。
あまり難しことはしていないので react の hook がわかれば問題ないと言った感じです。
// pages/Posts.jsx
import { Link } from '../router/prefetch';
import { PostsQuery } from '../querys/querys';
import { useQuery } from '@urql/preact';
import { Post } from './Post';
export const Posts = () => {
const [result] = useQuery({
query: PostsQuery,
});
const { data, fetching, error } = result
if (fetching) return <p>Loading...</p>
if (error) return <p>Oh no... {error.message}</p>
return (
<>
<h1>All Posts</h1>
{
data.posts.map(post =>
<Link href={`/post/${post.id}`}>
<Post id={post.id}>{ post.title }</Post>
</Link>
)
}
</>
)
}
TODO の一覧を取得する際は以下のような形になります。 この部分で引数を渡すことができます。これはライブラリの使用ではなく GeaphQL の仕様です。覚えておくようにしましょう。
const [result] = useQuery({
query: PostQuery,
variables: { id },
});
全体のコードは以下のような形になります。
// pages/Post.jsx
import { PostQuery } from '../querys/querys';
import { useQuery } from '@urql/preact';
export const Post = ({ id }) => {
const [result] = useQuery({
query: PostQuery,
variables: { id },
});
const { data, fetching, error } = result
if (fetching) return <p>Loading...</p>
if (error) return <p>Oh no... {error.message}</p>
return (
<>
<h1>title: { data.post.title }</h1>
</>
)
}
最終的なコードは以下のようになります。
ディレクトリ構成としては以下のようになっています。
簡単な TODO リストを実装してみたいと思います。
データの形式は以下のような形になっています。
MariaDB > show columns from todo;
+------------+---------------+------+-----+---------------------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+---------------+------+-----+---------------------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| title | varchar(127) | YES | | NULL | |
| content | varchar(1023) | YES | | NULL | |
| is_active | tinyint(1) | NO | | 1 | |
| created_at | timestamp | NO | | current_timestamp() | |
| updated_at | timestamp | NO | | current_timestamp() | |
+------------+---------------+------+-----+---------------------+----------------+
6 rows in set (0.001 sec)
まずはローカルに DB を作っているのでそれと接続する関数を定義します。
DB は彼女の名前に近いので MariaDB を使用しています。
この関数をデータベースとやりとりするたびに呼び出すという感じです。
// db/init.go
package db
import (
"os"
_ "github.com/go-sql-driver/mysql"
"github.com/jinzhu/gorm"
)
func DBConn() (*gorm.DB, error) {
DBMS := "mysql" // mariadb
HOSTNAME := os.Getenv("HOSTNAME")
USERNAME := os.Getenv("USERNAME")
DBNAME := os.Getenv("DB_NAME")
PASSWORD := os.Getenv("PASSWORD")
PORT := os.Getenv("PORT")
CONNECT := USERNAME + ":" + PASSWORD + "@(" + HOSTNAME + ":" + PORT + ")/" + DBNAME + "?parseTime=true"
db, err := gorm.Open(DBMS, CONNECT)
if err != nil {
return nil, err
}
return db, nil
}
次に Query を定義します。
Query を定義するためにまずは型を定義します。
当然ですが型はデータベースの型と合わせましょう。
// schema/schema.go
var TodoType = graphql.NewObject(graphql.ObjectConfig{
Name: "Todo",
Fields: graphql.Fields{
"id": &graphql.Field{
Type: graphql.Int,
},
"title": &graphql.Field{
Type: graphql.String,
},
"content": &graphql.Field{
Type: graphql.String,
},
"is_active": &graphql.Field{
Type: graphql.Boolean,
},
"created_at": &graphql.Field{
Type: graphql.DateTime,
},
"updated_at": &graphql.Field{
Type: graphql.DateTime,
},
},
})
次にフィールドを定義します。フィールドは上の型を使って TODO を全件取得するものと id に紐づく TODO を取得するものの2パターン作成します。TodoFields
では特定の id に紐づく TODO を1つ使用します。Args
を使用すると引数を受け取ることができます。Resolve
はその後の処理を定義することができます。また、Resolve
は GraphQL のレスポンスにもなります。
TodosFields
は TodoFields
よりもいくらかシンプルです。Resolve
の中で全件取得するようの関数を呼び出してそのまま戻り値としています。
// schema/schema.go
var TodoFields = &graphql.Field{
Type: TodoType,
Description: "get post detail",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{
Type: graphql.Int,
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
id, ok := p.Args["id"].(int)
if ok {
post, err := repository.GetTodo(id)
if err != nil {
return model.Todo{}, nil
}
return post, nil
}
return model.Todo{}, nil
},
}
var TodosFields = &graphql.Field{
Type: graphql.NewList(TodoType),
Description: "get all post",
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
return repository.GetTodos(), nil
},
}
Mutation も同様に定義することができます。
型は先ほどと同様に定義することができるため、フィールドのみの実装となります。
とは言ってもやってることは変わらないのでそこまで難しくないのかなと思います。
TODO を作成するための CreateTodoFields
と更新するための UpdateTodoFields
の2つを定義します。
CreateTodoFields
、UpdateTodoFields
共に先ほどと同様 Args
で引数を取得します。
// schema/schema.go
var CreateTodoFields = &graphql.Field{
Type: TodoType,
Description: "Create new todo",
Args: graphql.FieldConfigArgument{
"title": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
"content": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
"is_active": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.Boolean),
},
},
Resolve: func(params graphql.ResolveParams) (interface{}, error) {
title, _ := params.Args["title"].(string)
content, _ := params.Args["content"].(string)
isActive, _ := params.Args["is_active"].(bool)
_newTodo := model.Todo{
Title: title,
Content: content,
IsActive: isActive,
}
newTodo, err := repository.CreateTodo(_newTodo)
if err != nil {
fmt.Println("create data faild")
}
return newTodo, nil
},
}
var UpdateTodoFields = &graphql.Field{
Type: TodoType,
Description: "Create new todo",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.Int),
},
"title": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
"content": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
"is_active": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.Boolean),
},
},
Resolve: func(params graphql.ResolveParams) (interface{}, error) {
id := int64(params.Args["id"].(int)) // ちょっと汚い
title, _ := params.Args["title"].(string)
content, _ := params.Args["content"].(string)
isActive, _ := params.Args["is_active"].(bool)
_updateTodo := model.Todo{
Id: id,
Title: title,
Content: content,
IsActive: isActive,
}
updateTodo, err := repository.UpdateTodo(_updateTodo)
if err != nil {
fmt.Println("update data faild")
}
return updateTodo, nil
},
}
最後にこれらを1つの Schema として定義します。
先ほど一生懸命定義した関数を当てはめるだけです、簡単です。
// schema/schema.go
var Schema = graphql.SchemaConfig{
Query: graphql.NewObject(
graphql.ObjectConfig{
Name: "TodoQuery",
Fields: graphql.Fields{
"getTodo": TodoFields,
"getTodos": TodosFields,
},
},
),
Mutation: graphql.NewObject(
graphql.ObjectConfig{
Name: "TodoMutation",
Fields: graphql.Fields{
"createTodo": CreateTodoFields,
"updateTodo": UpdateTodoFields,
},
},
),
}
最後に schema.go
で実装した内容をまとめると以下のようになります。
// schema/schema.go
package schema
import (
"fmt"
"graphql_suburi/backend/model"
"graphql_suburi/backend/repository"
"github.com/graphql-go/graphql"
)
var TodoType = graphql.NewObject(graphql.ObjectConfig{
Name: "Post",
Fields: graphql.Fields{
"id": &graphql.Field{
Type: graphql.Int,
},
"title": &graphql.Field{
Type: graphql.String,
},
"content": &graphql.Field{
Type: graphql.String,
},
"is_active": &graphql.Field{
Type: graphql.Boolean,
},
"created_at": &graphql.Field{
Type: graphql.DateTime,
},
"updated_at": &graphql.Field{
Type: graphql.DateTime,
},
},
})
var TodoFields = &graphql.Field{
Type: TodoType,
Description: "get post detail",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{
Type: graphql.Int,
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
id, ok := p.Args["id"].(int)
if ok {
post, err := repository.GetTodo(id)
if err != nil {
return model.Todo{}, nil
}
return post, nil
}
return model.Todo{}, nil
},
}
var TodosFields = &graphql.Field{
Type: graphql.NewList(TodoType),
Description: "get all post",
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
return repository.GetTodos(), nil
},
}
var CreateTodoFields = &graphql.Field{
Type: TodoType,
Description: "Create new todo",
Args: graphql.FieldConfigArgument{
"title": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
"content": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
"is_active": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.Boolean),
},
},
Resolve: func(params graphql.ResolveParams) (interface{}, error) {
title, _ := params.Args["title"].(string)
content, _ := params.Args["content"].(string)
isActive, _ := params.Args["is_active"].(bool)
_newTodo := model.Todo{
Title: title,
Content: content,
IsActive: isActive,
}
newTodo, err := repository.CreateTodo(_newTodo)
if err != nil {
fmt.Println("create data faild")
}
return newTodo, nil
},
}
var UpdateTodoFields = &graphql.Field{
Type: TodoType,
Description: "Create new todo",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.Int),
},
"title": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
"content": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
"is_active": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.Boolean),
},
},
Resolve: func(params graphql.ResolveParams) (interface{}, error) {
id := int64(params.Args["id"].(int)) // ちょっと汚い
title, _ := params.Args["title"].(string)
content, _ := params.Args["content"].(string)
isActive, _ := params.Args["is_active"].(bool)
_updateTodo := model.Todo{
Id: id,
Title: title,
Content: content,
IsActive: isActive,
}
updateTodo, err := repository.UpdateTodo(_updateTodo)
if err != nil {
fmt.Println("update data faild")
}
return updateTodo, nil
},
}
var Schema = graphql.SchemaConfig{
Query: graphql.NewObject(
graphql.ObjectConfig{
Name: "TodoQuery",
Fields: graphql.Fields{
"getTodo": TodoFields,
"getTodos": TodosFields,
},
},
),
Mutation: graphql.NewObject(
graphql.ObjectConfig{
Name: "TodoMutation",
Fields: graphql.Fields{
"createTodo": CreateTodoFields,
"updateTodo": UpdateTodoFields,
},
},
),
}
ここまで定義したきたのであとは呼び出すだけです。
main.go で net/http
を使用してサーバを立ち上げ動作確認をしましょう。
25行目ではバリデーションをかけています。ここでリクエストの形式が違ったり不正があったりしたら弾き、CORS エラーになるようにしています。
また、35行目からの graphql.Do()
で GraphQL を実行しています。
// main.go
package main
import (
"encoding/json"
"fmt"
"graphql_suburi/backend/model"
"graphql_suburi/backend/schema"
"log"
"net/http"
_ "github.com/go-sql-driver/mysql"
"github.com/graphql-go/graphql"
)
func main() {
http.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Headers", "*")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
var p model.PostData
if r.Method == "OPTIONS" {
} else if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
w.WriteHeader(400)
return
}
schema, err := graphql.NewSchema(schema.Schema)
if err != nil {
log.Fatalf("failed to get schema, error: %v", err)
}
result := graphql.Do(graphql.Params{
Context: r.Context(),
Schema: schema,
RequestString: p.Query,
VariableValues: p.Variables,
OperationName: p.Operation,
})
if err := json.NewEncoder(w).Encode(result); err != nil {
fmt.Printf("could not write result to response: %s", err)
}
})
fmt.Println("listening on :8888 ...")
if err := http.ListenAndServe(":8888", nil); err != nil {
log.Fatalln(err)
}
}
動作確認をします。curl でリクエストを投げたいと思います。
リクエスト
curl -X POST -H "Content-Type: application/json" --data '{ "query": "{ getTodos { id title content } }" }' http://localhost:8888/graphql
レスポンス
{"data":{"getTodos":[{"content":"hoge","id":1,"title":"takumi"},{"content":"marinyan","id":2,"title":"marina"},{"content":"takurinton","id":3,"title":"test3"}]}}
リクエスト
curl -X POST -H "Content-Type: application/json" --data '{ "query": "{ getTodo(id:1) { id title content } }" }' http://localhost:8888/graphql
レスポンス
{"data":{"getTodo":{"content":"hoge","id":1,"title":"takumi"}}}
リクエスト
curl -X POST -H "Content-Type: application/json" --data '{ "query": "mutation { createTodo(title:\"takurinton\",content:\"wakuwakuwakuwaku\",is_active:true) { id title content is_active created_at } }" }' http://localhost:8888/graphql
レスポンス
{"data":{"createTodo":{"content":"wakuwakuwakuwaku","created_at":"2021-04-15T12:57:22.5392956Z","id":4,"is_active":true,"title":"takurinton"}}}
リクエスト
curl -X POST -H "Content-Type: application/json" --data '{ "query": "mutation { updateTodo(id:1,title:\"takumi katayama\",content:\"hoge\",is_active:false) { id title content is_active created_at } }" }' http://localhost:8888/graphql
レスポンス
{"data":{"updateTodo":{"content":"hoge","created_at":"0001-01-01T00:00:00Z","id":1,"is_active":false,"title":"takumi katayama"}}}
このような形でそれぞれのメソッドがしっかり動いてることを確認することができました。めでたしめでたし。
リクエストやレスポンスに型を持たせることができるのは非常に体験が良くなるなと感じました。
ただ、自分の中でまだまだ良さに気づけていない部分や深めなければいけない部分、ベストプラクティス、一般的な書き方についての理解が足りていないのでもっと頑張ってやっていきたいと思います。