#はじめに
本記事ではVue Apolloを利用して、GraphQLとVueアプリを接続した後に、CRUD機能並びにSubscriptionの実装を目指す内容となります。
大まかな概要としては、Apolloサーバーから取得したデータをVueを利用して表示・クライアント側からGraphQLを操作することがゴールとなります。
記事内でGraphQL等の基本的な説明は省略しております。
もし宜しければ、GraphQLの基礎の基礎、並びにApolloサーバーとSubscriptionについて解説している前回の記事も合わせてご一読ください(Apolloサーバー部分は前回と同一のコードを利用します)
##Apolloサーバーを作る(前回の記事で既に作ってある方は省略)
先ずはApolloを使ってGraphQLのAPIサーバーを作ります!
プロジェクトを作った後に、npm経由でapollo-serverをインストールします。
$ mkdir graphql-server
$ cd graphql-server
$ mkdir resolver
$ touch index.js db.js
$ resolver/{Query.js,Mutation.js,Subscription.js}
$ npm init
$ npm install apollo-server --save
データベース代わりのJavaScriptファイルを用意します。
const posts = [{
id: '1',
title: 'こころ',
author: '夏目漱石'
}, {
id: '2',
title: '舞姫',
author: '森鴎外'
}, {
id: '3',
title: '羅生門',
author: '芥川龍之介'
}]
const db = {
posts,
}
module.exports = db;
続いてQuery,Mutation,そして今回のテーマであるSubscriptionのリゾルバ関数を書いたファイルを順番に用意していきます。
const Query = {
posts(parent, args, { db }, info) {
//クエリを書いた時に引数が「ない」時
//模擬データベースの内容を全て表示
if (!args.query) {
return db.posts
//クエリを書いた時に引数が「ある」時
//引数とtitle or authorが一致したものだけを表示
}else{
return db.posts.filter((post) => {
const isTitleMatch = post.title.toLowerCase().includes(args.query.toLowerCase())
const isAuthorMatch = post.author.toLowerCase().includes(args.query.toLowerCase())
return isTitleMatch || isAuthorMatch
})
}
}
}
module.exports = Query
Queryのリゾルバ関数です。
const Mutation = {
createPost(parent, args, { db, pubsub }, info) {
const postNumTotal = String(db.posts.length + 1)
const post = {
id: postNumTotal,
...args.data
}
//データベース更新
db.posts.push(post)
//サブスクリプション着火
pubsub.publish('post', {
post: {
mutation: 'CREATED',
data: post
}
})
return post
},
updatePost(parent, args, { db, pubsub }, info) {
const { id, data } = args
const post = db.posts.find((post) => post.id === id)
if (!post) {
throw new Error('Post not found')
}
if (typeof data.title === 'string' && typeof data.author === 'string') {
//データベース更新
post.title = data.title
post.author = data.author
console.log(post)
//サブスクリプション着火
pubsub.publish('post', {
post: {
mutation: 'UPDATED',
data: post
}
})
}
return post
},
deletePost(parent, args, { db, pubsub }, info) {
const post = db.posts.find((post) => post.id === args.id)
const postIndex = db.posts.findIndex((post) => post.id === args.id)
if (postIndex === -1) {
throw new Error('Post not found')
}
//データベース更新
db.posts.splice(postIndex, 1)
//サブスクリプション着火
pubsub.publish('post', {
post: {
mutation: 'DELETED',
data: post
}
})
return post
},
}
module.exports = Mutation
Mutationのリゾルバ関数では、データベースの更新とSubscriptionの着火をしています。
const Subscription = {
post: {
subscribe(parent, args, { pubsub }, info) {
return pubsub.asyncIterator('post')
}
}
}
module.exports = Subscription
Subscriptionのリゾルバ関数です。
pubsub.asyncIteratorでSubscriptionのイベントを非同期でリッスンします。
説明の関係で最後になりましたが、
スキーマの定義とサーバー起動のファイルになります。
const {ApolloServer,PubSub,gql} = require('apollo-server');
const db = require('./db')
const Query = require('./resolver/Query')
const Mutation = require('./resolver/Mutation')
const Subscription = require('./resolver/Subscription')
//スキーマ定義
const typeDefs = gql`
type Query {
posts(query: String): [Post!]!
}
type Mutation {
createPost(data: CreatePostInput!): Post!
deletePost(id: ID!): Post!
updatePost(id: ID!, data: UpdatePostInput!): Post!
}
# Subscription
type Subscription {
post: PostSubscriptionPayload!
}
input CreatePostInput {
title: String!
author: String!
}
input UpdatePostInput {
title: String
author: String!
}
type Post {
id: ID!
title: String!
author: String!
}
######################
# Subscriptionで利用
######################
# enum型でMutation.js内のサブスクリプション着火と連動
enum MutationType {
CREATED
UPDATED
DELETED
}
# Subscriptionのフィールド
type PostSubscriptionPayload {
mutation: MutationType!
data: Post!
}
`
//PubSubのインスタンスを作成,サブスクリプションが利用可能に!
const pubsub = new PubSub()
const server = new ApolloServer({
typeDefs: typeDefs,
resolvers: {
Query,
Mutation,
Subscription,
},
context: {
db,
pubsub
}
})
server.listen().then(({ url, subscriptionsUrl }) => {
console.log(`🚀 Server ready at ${url}`);
console.log(`🚀 Subscriptions ready at ${subscriptionsUrl}`);
});
サーバーを立てる際にスキーマやリゾルバ、PubSubなどを引数に指定しています。
指定した引数はQueryやMutationそしてSubscription、それぞれの処理で利用をしています。
準備が出来たらターミナルから起動させます。
$ node index.js
🚀 Server ready at http://localhost:4000/
🚀 Subscriptions ready at ws://localhost:4000/graphql
こちらをGraphQLのエンドポイントとして利用します。
本記事ではサーバーが立ち上がっていないと、クライアント側からデータの表示や更新が出来ないので、必ずサーバーを立てることを忘れないようにしてください😊
##Apolloクライアントの設定
###Vueプロジェクトを作ろう
Vue CLIを使ってプロジェクトを作ります。
$ vue create apollo-client #2系を選択してVueプロジェクトの作成
$ cd apollo-client
$ npm run serve # 起動
プロジェクトの作成に成功していた場合、下記のような画面になります。
http://localhost:8080/
###Vueプロジェクト内の設定
続いてVueでGraphQLを扱うために、Vue Apolloをインストールしていきます。
本記事においては設定は全てデフォルトで進みます(色々聞かれますが「No」と答えてます)
$ vue add apollo
見た目をリッチにするために、Vuetifyもインストールします。
$ vue add vuetify
以上にて、プロジェクトの設定は完了です。
Vue-ApolloとVuetifyの設定はmain.jsに記述があります。
import Vue from 'vue'
import App from './App.vue'
import { createProvider } from './vue-apollo'
import vuetify from './plugins/vuetify';
Vue.config.productionTip = false
new Vue({
apolloProvider: createProvider(),
vuetify,
render: h => h(App)
}).$mount('#app')
importしているvue-apollo.jsではエンドポイントやログイン/ログアウトなどの設定が出来ます。
Queryの実装
QueryとMutationの実装はこちらの記事を参考にさせて頂きました。ありがとうございます!
模擬データベース内のデータを「読み取り」するGraphQLのクエリを作成します。
$ mkdir src/graphql
$ touch src/graphql/post-query.js
前回までの記事でlocalhost内で立ち上げたIDEに書いていたクエリがこちらの部分に該当します。importしているgqlについてはこちらを参照ください。
import gql from 'graphql-tag'
export const ALL_POSTS = gql`
query{
posts{
id
title
author
}
}
`
続いてApp.vue内を書き換えます(説明の省略のため、こちらのファイルに書いていますが、component化した方が良いです!)
「apollo」のオプション内で別ファイルで定義したクエリを呼び出し、
dataプロパティで定義したpostsオブジェクトに格納します。
<template>
<v-app>
<v-main>
<v-container>
<v-row
style="width: 550px;"
>
<!--ツールバー-->
<v-toolbar color="grey lighten-1">
<v-toolbar-title>本棚</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn color="primary" dark class="mb-1">新規追加</v-btn>
</v-toolbar>
<!--本棚の中身-->
<div
v-for="post in posts" :key="post.id"
>
<v-card
class="mx-auto"
width="550px"
outlined
>
<v-list-item three-line>
<v-list-item-content>
<v-list-item-title class="headline mb-1">
{{ post.title}}/{{ post.author}}
</v-list-item-title>
<v-list-item-subtitle>From Apollo-Server</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-card>
</div>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
<script>
//Query
import {ALL_POSTS} from "./graphql/post-query"
export default {
name: "App",
data: () => ({
//本棚の中身を定義
posts: [],
}),
apollo: {
//本棚の中身
posts: {
//クエリを書いている部分
query: ALL_POSTS,
}
},
methods: {
}
}
</script>
ここまで書き終えたらブラウザで確認しましょう。
$ npm run serve
Vueを利用して模擬データベースの値を表示することが出来ました!
##Mutationの実装
続いてMutationの実装を行います。
こちらの章を終えると①書き込み、②更新、③削除 が出来るようになります。
先ずはファイルを作りましょう。
$ touch src/graphql/post-mutation.js
Mutationのクエリを書いていきます。
import gql from 'graphql-tag'
// POSTの新規追加
export const CREATE_POST = gql`mutation ($title: String!, $author: String!) {
createPost(data: { title: $title, author: $author}) {
id
title
author
}
}`
// POSTの更新
export const UPDATE_POST = gql`
mutation updatePost($id: ID!, $title: String!, $author: String!) {
updatePost(id:$id,data: {title: $title, author: $author}) {
id
title
author
}
}
`
// // POSTの削除
export const DELETE_POST = gql`
mutation deletePost($id: ID!) {
deletePost(id:$id){
title
author
}
}
`
App.vueのmethod内に①書き込み、②更新、③削除の関数を作成。
updateQueryメソッドで上記3つを実行することが出来ます。
<template>
<v-app>
<v-main>
<v-container>
<!--入力フォーム-->
<v-dialog v-model="dialog" max-width="500px">
<v-card>
<v-container>
<h2 v-if="isCreate">本棚に追加する</h2>
<h2 v-if="!isCreate">本棚を更新する</h2>
<v-form ref="form" v-model="valid" lazy-validation>
<!--名前-->
<v-text-field
v-model="post.title"
:rules="titleRules"
:counter="20"
label="タイトル"
required
></v-text-field>
<v-text-field
v-model="post.author"
:rules="authorRules"
:counter="20"
label="作者"
required
></v-text-field>
<!--追加ボタン-->
<v-btn
v-if="isCreate"
:disabled="!valid"
@click="createPost"
>
追加
</v-btn>
<!--更新ボタン-->
<v-btn
v-if="!isCreate"
:disabled="!valid"
@click="updatePost"
>
更新
</v-btn>
<v-btn @click="clear">クリア</v-btn>
</v-form>
</v-container>
</v-card>
</v-dialog>
<v-row
style="width: 550px;"
>
<!--ツールバー-->
<v-toolbar color="grey lighten-1">
<v-toolbar-title>本棚</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn color="primary" dark class="mb-1" @click="showDialogNew">新規追加</v-btn>
</v-toolbar>
<!--本棚の中身-->
<div
v-for="post in posts" :key="post.id"
>
<v-card
class="mx-auto"
width="550px"
outlined
>
<v-list-item three-line>
<v-list-item-content>
<v-list-item-title class="headline mb-1">
{{ post.title}}/{{ post.author}}
</v-list-item-title>
<v-list-item-subtitle>From Apollo-Server</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<!-- 編集・削除ボタン -->
<v-card-actions>
<v-btn
color="success"
small
@click="showDialogUpdate(post.id,post.title,post.author)"
>
<v-icon small>
編集する
</v-icon>
</v-btn>
<v-btn
color="error"
small
@click="deletePost(post.id,post.title)"
>
<v-icon small>
削除する
</v-icon>
</v-btn>
</v-card-actions>
</v-card>
</div>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
<script>
//Query
import {ALL_POSTS} from "./graphql/post-query"
//Mutation
import {CREATE_POST,UPDATE_POST,DELETE_POST} from "./graphql/post-mutation";
export default {
name: "App",
data: () => ({
//本棚の中身を定義
posts: [],
// フォーム入力値
post: {
id: '',
title: '',
author: '',
},
// バリデーション
valid: true,
titleRules: [
v => !!v || 'タイトルは必須項目です',
v => (v && v.length <= 20) || 'タイトルは20文字以内で入力してください'
],
authorRules: [
v => !!v || '作者名は必須項目です',
],
// ローディングの表示フラグ
progress: false,
// ダイアログの表示フラグ
dialog: false,
// 新規・更新のフラグ
isCreate: true,
}),
apollo: {
//本棚の中身
posts: {
//クエリを書いている部分
query: ALL_POSTS,
}
},
methods: {
// --------------------------------
// 新規作成
// --------------------------------
createPost: function () {
if (this.$refs.form.validate()) {
this.progress = true
this.$apollo.mutate({
mutation: CREATE_POST,
variables: {
title: this.post.title,
author: this.post.author,
},
})
.then(() => {
//UIの更新
this.$apollo.queries.posts.fetchMore({
updateQuery: (previousResult, {fetchMoreResult}) => {
// console.log(previousResult) //変更前
// console.log(fetchMoreResult) //変更後
return {
posts: fetchMoreResult.posts
}
}
})
this.dialog = false
this.progress = false
}).catch((error) => {
console.error(error)
})
}
}
,
// --------------------------------
// 更新
// --------------------------------
updatePost: function () {
this.progress = true
this.$apollo.mutate({
mutation: UPDATE_POST,
variables: {
id: this.post.id,
title: this.post.title,
author: this.post.author,
}
}).then(() => {
this.$apollo.queries.posts.fetchMore({
updateQuery: (previousResult, {fetchMoreResult}) => {
// console.log(previousResult) //変更前
// console.log(fetchMoreResult) //変更後
return {
posts: fetchMoreResult.posts
}
}
})
this.dialog = false
this.progress = false
}).catch((error) => {
console.error(error)
})
},
// --------------------------------
// 削除
// --------------------------------
deletePost: function (id,title) {
console.log(id)
console.log(title)
if (!confirm(title + 'を削除してもよろしいですか?')) {
return
}
this.progress = true
this.$apollo.mutate({
mutation: DELETE_POST,
variables: {
id: id
}
}).then(() => {
this.$apollo.queries.posts.fetchMore({
updateQuery: (previousResult, {fetchMoreResult}) => {
// console.log(previousResult) //変更前
// console.log(fetchMoreResult) //変更後
return {
posts: fetchMoreResult.posts
}
}
})
this.progress = false
}).catch((error) => {
console.error(error)
})
},
// --------------------------------
// フォームのクリア
// --------------------------------
clear: function () {
this.$refs.form.reset()
},
// --------------------------------
// 新規追加ダイアログの表示
// --------------------------------
showDialogNew: function () {
// this.clear()
this.isCreate = true
this.dialog = true
},
// --------------------------------
// 更新ダイアログの表示
// --------------------------------
showDialogUpdate: function (id, title, author) {
this.post.id = id
this.post.title = title
this.post.author = author
this.isCreate = false
this.dialog = true
},
}
}
</script>
CRUD処理が出来るようになりました!!
##Subscriptionの実装
最後にSubscriptionの実装を行います。
こちらの章を終えるとリアルタイムでの書き込みの共有が出来るようになります。
実際のアプリケーションでは通知機能を作る時などに使う部分となります。
今回もファイルを作ります。
$ touch src/graphql/post-subscription.js
Subscriptionのクエリを書きます。
import gql from 'graphql-tag'
// Subscription
export const SUBSCRIPTION_POST = gql`
subscription {
post{
mutation
data{
id
title
author
}
}
}
`
App.vueを変更します。
「apollo」のオプション内にSubscriptionの処理を追加しており、既存の投稿と同一の投稿がなかった場合、新規作成した投稿を本棚に追加しています。
<template>
<v-app>
<v-main>
<v-container>
<!--入力フォーム-->
<v-dialog v-model="dialog" max-width="500px">
<v-card>
<v-container>
<h2 v-if="isCreate">本棚に追加する</h2>
<h2 v-if="!isCreate">本棚を更新する</h2>
<v-form ref="form" v-model="valid" lazy-validation>
<!--名前-->
<v-text-field
v-model="post.title"
:rules="titleRules"
:counter="20"
label="タイトル"
required
></v-text-field>
<v-text-field
v-model="post.author"
:rules="authorRules"
:counter="20"
label="作者"
required
></v-text-field>
<!--追加ボタン-->
<v-btn
v-if="isCreate"
:disabled="!valid"
@click="createPost"
>
追加
</v-btn>
<!--更新ボタン-->
<v-btn
v-if="!isCreate"
:disabled="!valid"
@click="updatePost"
>
更新
</v-btn>
<v-btn @click="clear">クリア</v-btn>
</v-form>
</v-container>
</v-card>
</v-dialog>
<v-row
style="width: 550px;"
>
<!--ツールバー-->
<v-toolbar color="grey lighten-1">
<v-toolbar-title>本棚</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn color="primary" dark class="mb-1" @click="showDialogNew">新規追加</v-btn>
</v-toolbar>
<!--本棚の中身-->
<div
v-for="post in posts" :key="post.id"
>
<v-card
class="mx-auto"
width="550px"
outlined
>
<v-list-item three-line>
<v-list-item-content>
<v-list-item-title class="headline mb-1">
{{ post.title}}/{{ post.author}}
</v-list-item-title>
<v-list-item-subtitle>From Apollo-Server</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<!-- 編集・削除ボタン -->
<v-card-actions>
<v-btn
color="success"
small
@click="showDialogUpdate(post.id,post.title,post.author)"
>
<v-icon small>
編集する
</v-icon>
</v-btn>
<v-btn
color="error"
small
@click="deletePost(post.id,post.title)"
>
<v-icon small>
削除する
</v-icon>
</v-btn>
</v-card-actions>
</v-card>
</div>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
<script>
//Query
import {ALL_POSTS} from "./graphql/post-query"
//Mutation
import {CREATE_POST,UPDATE_POST,DELETE_POST} from "./graphql/post-mutation";
//Subscription
import {SUBSCRIPTION_POST} from "./graphql/post-subscription";
export default {
name: "App",
data: () => ({
//本棚の中身を定義
posts: [],
// フォーム入力値
post: {
id: '',
title: '',
author: '',
},
// バリデーション
valid: true,
titleRules: [
v => !!v || 'タイトルは必須項目です',
v => (v && v.length <= 20) || 'タイトルは20文字以内で入力してください'
],
authorRules: [
v => !!v || '作者名は必須項目です',
],
// ローディングの表示フラグ
progress: false,
// ダイアログの表示フラグ
dialog: false,
// 新規・更新のフラグ
isCreate: true,
}),
apollo: {
//本棚の中身
posts: {
//クエリを書いている部分
query: ALL_POSTS,
//サブスクリプション
subscribeToMore: {
document: SUBSCRIPTION_POST,
updateQuery:(previousResult, { subscriptionData }) =>{
// console.log(previousResult) //前の投稿
// console.log(subscriptionData.data) //新規作成した投稿
// 既存の投稿と同一の投稿がなかった場合、新規作成した投稿を本棚に追加
if (previousResult.posts.find(post => post.id === subscriptionData.data.post.data.id)) {
return previousResult
}else{
return {
posts: [
...previousResult.posts,
// Add the new data
subscriptionData.data.post.data,
],
}
}
}
}
}
},
methods: {
// --------------------------------
// 新規作成
// --------------------------------
createPost: function () {
if (this.$refs.form.validate()) {
this.progress = true
this.$apollo.mutate({
mutation: CREATE_POST,
variables: {
title: this.post.title,
author: this.post.author,
},
})
.then(() => {
//UIの更新
this.$apollo.queries.posts.fetchMore({
updateQuery: (previousResult, {fetchMoreResult}) => {
// console.log(previousResult) //変更前
// console.log(fetchMoreResult) //変更後
return {
posts: fetchMoreResult.posts
}
}
})
this.dialog = false
this.progress = false
}).catch((error) => {
console.error(error)
})
}
}
,
// --------------------------------
// 更新
// --------------------------------
updatePost: function () {
this.progress = true
this.$apollo.mutate({
mutation: UPDATE_POST,
variables: {
id: this.post.id,
title: this.post.title,
author: this.post.author,
}
}).then(() => {
this.$apollo.queries.posts.fetchMore({
updateQuery: (previousResult, {fetchMoreResult}) => {
// console.log(previousResult) //変更前
// console.log(fetchMoreResult) //変更後
return {
posts: fetchMoreResult.posts
}
}
})
this.dialog = false
this.progress = false
}).catch((error) => {
console.error(error)
})
},
// --------------------------------
// 削除
// --------------------------------
deletePost: function (id,title) {
console.log(id)
console.log(title)
if (!confirm(title + 'を削除してもよろしいですか?')) {
return
}
this.progress = true
this.$apollo.mutate({
mutation: DELETE_POST,
variables: {
id: id
}
}).then(() => {
this.$apollo.queries.posts.fetchMore({
updateQuery: (previousResult, {fetchMoreResult}) => {
// console.log(previousResult) //変更前
// console.log(fetchMoreResult) //変更後
return {
posts: fetchMoreResult.posts
}
}
})
this.progress = false
}).catch((error) => {
console.error(error)
})
},
// --------------------------------
// フォームのクリア
// --------------------------------
clear: function () {
this.$refs.form.reset()
},
// --------------------------------
// 新規追加ダイアログの表示
// --------------------------------
showDialogNew: function () {
// this.clear()
this.isCreate = true
this.dialog = true
},
// --------------------------------
// 更新ダイアログの表示
// --------------------------------
showDialogUpdate: function (id, title, author) {
this.post.id = id
this.post.title = title
this.post.author = author
this.isCreate = false
this.dialog = true
},
}
}
</script>
確認してみましょう。ブラウザのウィンドウを2つ用意します。
こちらも上手くいきました!!!
###おわりに
以上、今回はGraphQLをVueで扱ってみました。
基本的なCRUD機能は抑えたので、今回の内容を元に発展的なアプリケーションを作ることが可能かと思います。
それでは、また😊