お題
簡易ToDoアプリ(と言っても、この記事上では単に「ToDoの新規登録」と「登録済みの全ToDoの表示」しか出来ない)を題材として、表題の組み合わせでGraphQL通信ロジックを書くとどんな感じになるかを確認してみる。
※今回はRDBを使った永続化などはしないで、バックエンドからは固定値を返す。
前提
Nuxt.jsやGoについては、個人ないし会社での開発経験があり、フロント->サーバの接続は(例えば)Axiosなど使ってRESTでやってたけど、GraphQLに変えてみたいという人が対象。
ただ、「そもそもGraphQLとは?」とか「RESTと比べたメリット・デメリットは?」みたいなことは書かない。(既に記事がいっぱいある。)
また、お題に則り最小限の構造で実装する。
巷で流行りのClean ArchitectureやDDDに即したパッケージ構成とかは考えない。
そのへんは、一度↓みたいな記事を書いた。↓でなく、もっといい記事はググればたくさん出てくる。
Clean Architecture by Golang(with Echo & Gorm & wire)
関連記事索引
- 第12回「GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)」
- 第11回「Dataloadersを使ったN+1問題への対応」
- 第10回「GraphQL(gqlgen)エラーハンドリング」
- 第9回「GraphQLにおける認証認可事例(Auth0 RBAC仕立て)」
- 第8回「GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例」
- 第7回「GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)」
- 第6回「GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)」
- 第5回「DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動」
- 第4回「graphql-codegenでフロントエンドをGraphQLスキーマファースト」
- 第3回「go+gqlgenでGraphQLサーバを作る(GORM使ってDB接続)」
- 第2回「NuxtJS(with Apollo)のTypeScript対応」
- 第1回「frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る」
開発環境
# OS - Linux(Ubuntu)
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.2 LTS (Bionic Beaver)"
# フロントエンド
Nuxt.js
$ cat yarn.lock | grep "@nuxt/vue-app"
"@nuxt/vue-app" "2.10.2"
パッケージマネージャ - Yarn
$ yarn -v
1.19.1
IDE - WebStorm
WebStorm 2019.2.4
Build #WS-192.7142.35, built on October 29, 2019
# バックエンド
言語 - Go
$ go version
go version go1.13.3 linux/amd64
パッケージマネージャ - Go Modules
IDE - Goland
GoLand 2019.2.5
Build #GO-192.7142.48, built on November 8, 2019
参考
GraphQL
フロントエンド
バックエンド
実践
GitHub上に適当なリポジトリを作ってローカルにgit clone
自分の環境だとこんな感じ。
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql
フロントエンド
create-nuxt-appコマンドを使ってフロント用のNuxt.jsプロジェクトを作成
ツール類の選択は以下参照。
- UIはVuetify.js
- SSRとSPAはどっちでもいいけどデフォのSSR
- 今回はテストまで考えてないのでテストフレームワークは無し
- サーバーサイドとの接続はGraphQLなのでAxiosなどは無し
$ yarn create nuxt-app frontend
yarn create v1.19.1
・・・
success Installed "create-nuxt-app@2.11.1" with binaries:
- create-nuxt-app
create-nuxt-app v2.11.1
✨ Generating Nuxt.js project in frontend
? Project name frontend
? Project description My ace Nuxt.js project
? Author name sky0621
? Choose the package manager Yarn
? Choose UI framework Vuetify.js
? Choose custom server framework None (Recommended)
? Choose Nuxt.js modules (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Choose linting tools ESLint, Prettier
? Choose test framework None
? Choose rendering mode Universal (SSR)
? Choose development tools (Press <space> to select, <a> to toggle all, <i> to invert selection)
yarn run v1.19.1
$ eslint --ext .js,.vue --ignore-path .gitignore . --fix
Done in 2.90s.
🎉 Successfully created project frontend
現時点で以下のようなディレクトリ構成となっている。
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql
$
$ tree -L 2
.
├── frontend
│ ├── assets
│ ├── components
│ ├── layouts
│ ├── middleware
│ ├── node_modules
│ ├── nuxt.config.js
│ ├── package.json
│ ├── pages
│ ├── plugins
│ ├── README.md
│ ├── static
│ ├── store
│ └── yarn.lock
└── README.md
そして、パッケージのdevDependenciesも念のため記載。
$ cat frontend/package.json
{
・・・
"dependencies": {
"nuxt": "^2.0.0"
},
"devDependencies": {
"@nuxtjs/vuetify": "^1.0.0",
"@nuxtjs/eslint-config": "^1.0.1",
"@nuxtjs/eslint-module": "^1.0.0",
"babel-eslint": "^10.0.1",
"eslint": "^6.1.0",
"eslint-plugin-nuxt": ">=0.4.2",
"eslint-config-prettier": "^4.1.0",
"eslint-plugin-prettier": "^3.0.1",
"prettier": "^1.16.4"
}
}
nuxtjs/apolloの導入
パッケージ追加
$ cd frontend/
$
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql/frontend
$
$ yarn add @nuxtjs/apollo
yarn add v1.19.1
[1/4] Resolving packages...
[2/4] Fetching packages...
info fsevents@2.1.2: The platform "linux" is incompatible with this module.
・・・
Done in 15.54s.
nuxt-config.jsへ設定追記
$ git diff frontend/nuxt.config.js
diff --git a/frontend/nuxt.config.js b/frontend/nuxt.config.js
index 25a0fe4..afc0db4 100644
--- a/frontend/nuxt.config.js
+++ b/frontend/nuxt.config.js
@@ -42,7 +42,7 @@ export default {
/*
** Nuxt.js modules
*/
- modules: [],
+ modules: ['@nuxtjs/apollo'],
/*
** vuetify module configuration
** https://github.com/nuxt-community/vuetify-module
@@ -64,6 +64,18 @@ export default {
}
}
},
+
+ apollo: {
+ clientConfigs: {
+ default: {
+ // Goサーバを 8080 ポートで起動する予定のため
+ httpEndpoint: 'http://localhost:8080/'
+ }
+ },
+ // 任意だけど、これがないとGraphQL的なエラー起きた時に原因が掴みづらいため
+ errorHandler: '~/plugins/apollo-error-handler.js'
+ },
+
/*
** Build configuration
*/
GraphQLエラーハンドリングプラグイン
$ cat frontend/plugins/apollo-error-handler.js
export default (error, context) => {
console.log(error)
context.error({ statusCode: 304, message: 'Server error' })
}
参考:https://github.com/nuxt-community/apollo-module
フロントエンド起動
ここまででいったん起動してみる。
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql/frontend
$
$ yarn run dev
yarn run v1.19.1
$ nuxt
WARN Address localhost:8080 is already in use. 11:40:12
ℹ Trying a random port... 11:40:12
╭─────────────────────────────────────────────╮
│ │
│ Nuxt.js v2.10.2 │
│ Running in development mode (universal) │
│ │
│ Listening on: http://localhost:44171/ │
│ │
╰─────────────────────────────────────────────╯
ℹ Preparing project for development 11:40:13
ℹ Initial build may take a while 11:40:13
✔ Builder initialized 11:40:13
✔ Nuxt files generated 11:40:13
● Client █████████████████████████ building (35%) 215/225 modules 10 active
node_modules/vuetify/dist/vuetify.js
● Server █████████████████████████ building (19%) 81/81 modules 0 active
ERROR Failed to compile with 1 errors friendly-errors 11:40:31
ERROR in ./plugins/apollo-error-handler.js friendly-errors 11:40:31
Module Error (from ./node_modules/eslint-loader/dist/cjs.js): friendly-errors 11:40:31
/home/sky0621/src/github.com/sky0621/study-graphql/frontend/plugins/apollo-error-handler.js
2:3 warning Unexpected console statement no-console
5:1 error Delete `⏎` prettier/prettier
✖ 2 problems (1 error, 1 warning)
1 error and 0 warnings potentially fixable with the `--fix` option.
friendly-errors 11:40:31
@ ./.nuxt/apollo-module.js 93:13-57
@ ./.nuxt/index.js
@ ./.nuxt/client.js
@ multi eventsource-polyfill webpack-hot-middleware/client?reload=true&timeout=30000&ansiColors=&overlayStyles=&name=client&path=/__webpack_hmr/client ./.nuxt/client.js
friendly-errors 11:40:31
ℹ Waiting for file changes 11:40:31
ℹ Memory usage: 433 MB (RSS: 559 MB) 11:40:31
ERROR ENOSPC: System limit for number of file watchers reached, watch '/home/sky0621/src/github.com/sky0621/study-graphql/frontend/node_modules/@nuxt/vue-app/template/views/loading' 11:40:31
at FSWatcher.start (internal/fs/watchers.js:165:26)
at Object.watch (fs.js:1329:11)
at createFsWatchInstance (node_modules/@nuxt/builder/node_modules/chokidar/lib/nodefs-handler.js:118:15)
at setFsWatchListener (node_modules/@nuxt/builder/node_modules/chokidar/lib/nodefs-handler.js:165:15)
at NodeFsHandler._watchWithNodeFs (node_modules/@nuxt/builder/node_modules/chokidar/lib/nodefs-handler.js:330:14)
at NodeFsHandler._handleDir (node_modules/@nuxt/builder/node_modules/chokidar/lib/nodefs-handler.js:551:19)
at runMicrotasks (<anonymous>)
at processTicksAndRejections (internal/process/task_queues.js:93:5)
at async NodeFsHandler._addToNodeFs (node_modules/@nuxt/builder/node_modules/chokidar/lib/nodefs-handler.js:600:16)
怒られた。Prettierさんチェックが入った。
言われた通り、lint fix する。
$ yarn run lint --fix
yarn run v1.19.1
$ eslint --ext .js,.vue --ignore-path .gitignore . --fix
/home/sky0621/src/github.com/sky0621/study-graphql/frontend/plugins/apollo-error-handler.js
2:3 warning Unexpected console statement no-console
✖ 1 problem (0 errors, 1 warning)
Done in 2.27s.
すると、起動できた。
なんか1件 WARN は出てるけど・・・これはエラーハンドラー内でデバッグ用にconsoleログ吐いてるからか。
本番リリース時は消すという前提でいったん無視(まあ、本番リリースしないし)。
〜〜〜
↻ Updated plugins/apollo-error-handler.js 11:45:05
✔ Client
Compiled successfully in 694.86ms
✔ Server
Compiled successfully in 823.17ms
WARN Compiled with 1 warnings friendly-errors 11:45:07
Module Warning (from ./node_modules/eslint-loader/dist/cjs.js): friendly-errors 11:45:07
/home/sky0621/src/github.com/sky0621/study-graphql/frontend/plugins/apollo-error-handler.js
2:3 warning Unexpected console statement no-console
✖ 1 problem (0 errors, 1 warning)
friendly-errors 11:45:07
You may use special comments to disable some warnings. friendly-errors 11:45:07
Use // eslint-disable-next-line to ignore the next line. friendly-errors 11:45:07
Use /* eslint-disable */ to ignore all warnings in a file. friendly-errors 11:45:07
起動ログの冒頭で「 http://localhost:44171/
」と記載があったので、そのポートで起動チェック。
うん、OK。GraphQL的なあれこれはバックエンド作ってから確認するので、フロントエンドは、ひとまずここまで。
バックエンド
バックエンド用ソース格納ディレクトリとGraphQLスキーマ格納ディレクトリを作成
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql
$ tree -L 1
.
├── frontend
└── README.md
1 directory, 1 file
$ mkdir backend
$ tree -L 1
.
├── backend
├── frontend
└── README.md
Goプロジェクト初期化
$ cd backend/
$ go mod init github.com/sky0621/study-graphql/backend
go: creating new go.mod: module github.com/sky0621/study-graphql/backend$ tree -L 1
.
└── go.mod
0 directories, 1 file
$ cat go.mod
module github.com/sky0621/study-graphql/backend
go 1.13
gqlgenコマンドによるスケルトン生成
一気にいろいろなファイルが自動生成される。(GraphQLスキーマすら書いてないのにサンプルを用意してくれる)
$ go run github.com/99designs/gqlgen init
Exec "go run ./server/server.go" to start GraphQL server
$
$ tree -L 2
.
├── generated.go
├── go.mod
├── go.sum
├── gqlgen.yml
├── models_gen.go
├── resolver.go
├── schema.graphql
└── server
└── server.go
自動生成物のチェック
GraphQLスキーマ
# GraphQL schema example
#
# https://gqlgen.com/getting-started/
type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}
type User {
id: ID!
name: String!
}
type Query {
todos: [Todo!]!
}
input NewTodo {
text: String!
userId: String!
}
type Mutation {
createTodo(input: NewTodo!): Todo!
}
実は、お題を『簡易ToDoアプリ』としたのは、gqlgen がデフォルトで自動生成するスキーマが↑だからでした。
gqlgenはスキーマファーストを前提とするライブラリなので、このスキーマを元にして、初回だけ自動生成されるソースとgqlgenコマンド実行のたびに自動生成されるソースとを組み合わせて機能を実現していく。
リゾルバー
GraphQLスキーマで定義されたQuery(この事例だと「todos: [Todo!]!
」(要するにToDo一覧取得))とMutation(この事例だと「createTodo(input: NewTodo!): Todo!
」(要するにToDo新規登録))のロジックを実装するソース。
※このソースは、gqlgenコマンドによって初回だけ自動生成されるソース。(以降は、このファイルを消すか、gqlgen.yml
の設定をいじらない限り再生成はされない。)
package backend
import (
"context"
) // THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.
type Resolver struct{}
func (r *Resolver) Mutation() MutationResolver {
return &mutationResolver{r}
}
func (r *Resolver) Query() QueryResolver {
return &queryResolver{r}
}
type mutationResolver struct{ *Resolver }
func (r *mutationResolver) CreateTodo(ctx context.Context, input NewTodo) (*Todo, error) {
panic("not implemented")
}
type queryResolver struct{ *Resolver }
func (r *queryResolver) Todos(ctx context.Context) ([]*Todo, error) {
panic("not implemented")
}
上記の panic("not implemented")
のところを、フロントエンドから呼ばれた時に実行したロジックに変える。
というわけで、↓のような感じで修正した。
$ git diff
diff --git a/backend/resolver.go b/backend/resolver.go
index d015cbe..9bbe326 100644
--- a/backend/resolver.go
+++ b/backend/resolver.go
@@ -16,11 +16,38 @@ func (r *Resolver) Query() QueryResolver {
type mutationResolver struct{ *Resolver }
func (r *mutationResolver) CreateTodo(ctx context.Context, input NewTodo) (*Todo, error) {
- panic("not implemented")
+ return &Todo{
+ ID: "todo001",
+ Text: "部屋の掃除",
+ Done: false,
+ User: &User{
+ ID: "user001",
+ Name: "たろー",
+ },
+ },nil
}
type queryResolver struct{ *Resolver }
func (r *queryResolver) Todos(ctx context.Context) ([]*Todo, error) {
- panic("not implemented")
+ return []*Todo{
+ &Todo{
+ ID: "todo001",
+ Text: "部屋の掃除",
+ Done: false,
+ User: &User{
+ ID: "user001",
+ Name: "たろー",
+ },
+ },
+ &Todo{
+ ID: "todo002",
+ Text: "買い物",
+ Done: true,
+ User: &User{
+ ID: "user001",
+ Name: "たろー",
+ },
+ },
+ },nil
}
Goサーバー起動ロジック
gqlgenコマンドによる自動生成対象にmain関数及びWebサーバー起動ロジックも含まれている。
8080ポートで起動し、ルートパスでGraphQLのプレイグラウンドが表示されるようになっている。(もう、いたれりつくせり)
package main
import (
"log"
"net/http"
"os"
"github.com/99designs/gqlgen/handler"
"github.com/sky0621/study-graphql/backend"
)
const defaultPort = "8080"
func main() {
port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}
http.Handle("/", handler.Playground("GraphQL playground", "/query"))
http.Handle("/query", handler.GraphQL(backend.NewExecutableSchema(backend.Config{Resolvers: &backend.Resolver{}})))
log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
GraphQLサーバー起動
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql/backend
$
$ go run server/server.go
2019/11/16 12:33:49 connect to http://localhost:8080/ for GraphQL playground
で、ブラウザで「http://localhost:8080/
」を開くと、こんな感じ。
左側にGraphQLサーバー宛にクエリを書く欄があるので、赤枠のように記載し、
真ん中の実行ボタンを押すと、
resolver.go
のTodos(ctx context.Context) ([]*Todo, error)
に実装したレスポンスが返ってくる。
はい。こんなふうに赤枠内のようなクエリを書くプラットフォームを用意すれば、クエリの内容に応じた結果を返してくれる。
ちなみに、GraphQLの凄いところは、以下のように、欲しい情報だけに絞ってクエリ書けば、それだけが結果として返ってくるというところ。
これが今までのRESTに対するGraphQLの大きなメリットのよう。
ただ、今回はresolver.go
の中で固定値を返しているので気にならないけど、RDBへ永続化することを考えると、わりとバックエンドの実装がつらくなりそうな感は出てくると思う。
実際、業務でGraphQLのフロントエンドとバックエンド両方書いているけど、情報のやり取りという点に限って言えば、バックエンドの方が圧倒的につらい。
(まあ、そのへんは、次の記事あたりでRDB接続するコードを書く予定なので、その時に)
さて、Todo新規登録の方の確認は(クエリと一緒なので)端折りつつ、バックエンドはここまで。
最後は、フロントエンドをいじって、バックエンドのGraphQLサーバーと接続させる。
再びフロントエンド
Apolloを使ってGraphQLサーバ接続
Queryファイルの用意
バックエンドでプレイグラウンドで試したように、GraphQLサーバにリクエストするにはQueryを書く必要があるので用意。
apollo
というディレクトリを追加し、配下に構造を作って「Todo一覧取得」用のQueryファイルを格納。
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql/frontend
$
$ tree -L 1
.
├── apollo
├── assets
├── components
・・・
$
$ tree -L 2 apollo/
apollo/
└── queries
└── todos.gql
$
$ cat apollo/queries/todos.gql
query todos {
todos {
id
text
done
user {
id
name
}
}
}
フロントエンドトップページ
開くとTodo一覧を表示するよう修正。
フロントエンドは以下のディレクトリ構成になっている。
$ tree -L 1
.
├── apollo
├── assets
├── components
├── layouts
├── middleware
├── node_modules
├── nuxt.config.js
├── package.json
├── pages
├── plugins
├── README.md
├── static
├── store
└── yarn.lock
このうち、pages/index.vue
を修正する。
また、 pages
から呼び出すコンポーネントとして components/TodoCard.vue
を作成する。
<template>
<div>
<TodoCard />
</div>
</template>
<script>
import TodoCard from '~/components/TodoCard.vue'
export default {
components: { TodoCard }
}
</script>
ページの方はコンポーネントを呼び出すだけだから良いでしょう。
以下のコンポーネントで、Todo一覧をApollo経由で取得している。
<template>
<div>
<v-row>
<v-col cols="12" sm="6" offset-sm="3">
<v-card>
<v-list two-line subheader>
<v-list-item v-for="todo in todos" :key="todo.id" link>
<v-list-item-avatar>
<v-icon>mdi-gift-outline</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>{{ todo.text }}</v-list-item-title>
<v-list-item-subtitle>{{ todo.done }}</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-content>
<v-list-item-title>
{{ todo.user.name }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script>
import todos from '~/apollo/queries/todos.gql'
export default {
data() {
return {
todos: []
}
},
apollo: {
todos: {
prefetch: true,
query: todos
}
}
}
</script>
GraphQLサーバに問い合わせるQueryは、あらかじめ用意していた拡張子 gql
のファイルからimportすることが出来る。
import todos from '~/apollo/queries/todos.gql'
nuxt-config.js
で @nuxtjs/apollo
をモジュールとして読み込む設定にしており、Vueファイル上で以下のように定義することでGraphQlサーバに問い合わせた結果を data に定義した todos
に格納してくれる。
apollo: {
todos: {
prefetch: true,
query: todos
}
}
一覧表示に使う Vuetify のコンポーネントは v-card
を使うことにした。( v-data-table
でもよかったけど)
表示部分は上記で todos
に取得したTODO一覧を v-for
でぐるぐる回しつつ各項目を表示しているだけなので説明は無しで。
あぁ、そうだ。忘れてた。GraphQLサーバのパスは「/query
」なのでnuxt-config.js
を以下のように修正。
diff --git a/frontend/nuxt.config.js b/frontend/nuxt.config.js
index afc0db4..8454218 100644
--- a/frontend/nuxt.config.js
+++ b/frontend/nuxt.config.js
@@ -69,7 +69,7 @@ export default {
clientConfigs: {
default: {
// Goサーバを 8080 ポートで起動する予定のため
- httpEndpoint: 'http://localhost:8080/'
+ httpEndpoint: 'http://localhost:8080/query'
}
},
で、このように実装したものをブラウザで表示してみると、↓のようになる。
ちゃんとGoのGraphQLサーバーで resolver.go
から返すように実装した内容が表示されている。
まとめ
時間の都合でTodo新規登録は端折ったけど、GraphQL使ってフロントエンドとバックエンドつなぐ実装事例は出せたかな。
ただ、今回のお題と作り込みレベルだとGraphQLを使うべきモチベーションは、まだ湧かないかも・・・。
フロントエンド目線からすると、1ページに一覧として複数階層構造の情報を表示する時に、これまでは階層ごとにAPIを叩いてたみたいなのがなくなって、1ページに必要な情報が一気に取ってこれて(GraphQLライブラリの恩恵で)フロントエンドで使いやすいよう構造化までしてくれるというのが嬉しいかな。
が、逆に、バックエンドは考慮しないといけないことが目白押し、かつ、GraphQL自体は仕様としては存在するものの、例えば「認証」、「ページング」など個別の具体的な実装までは規定していないので、使うライブラリ次第では自前でゴリゴリ車輪の再発明をする必要性に迫られる。
Prismaのようなフレームワーク的(?)なものもあるようだけど、もうちょっとほうぼうでの実績、ナレッジが溜まってこないとプロダクションレベルで使うのは怖い。。。
今回の全ソースは下記。
https://github.com/sky0621/study-graphql/tree/v0.1.0