はじめに

この記事について

  1. 新卒エンジニアが全く触ったことのなかった技術でTodoアプリを作ってみた話
  2. ややチュートリアル形式
  3. cliなどを使って楽に構築
  4. 各技術についての説明は少なめ
  5. vueやGraphQLの雰囲気を掴みたい方向け?
  6. 間違いの指摘やより効率的な書き方、今後の学習法などのアドバイスは歓迎

Todoアプリ概要

要件

  • ユーザーの認証が出来ること
  • TodoのCRUD操作が出来ること

技術

  • Vue.js
  • graphcool(serverless GraphQL)
  • Apollo(クライアント側でGraphQLを扱いやすくするためのもの)

開発環境

  • OS: Ubuntu 16.04.3 LTS
  • node: v8.1.4
  • npm: 5.5.1
  • docker: 17.0.5.0-ce

Todoアプリ作成

完成品はこちらになります。

graphcoolの用意

まずはGraphQLの環境から用意していきます。

mkdir todo
cd todo
npm install -g graphcool
graphcool init graphcool

これでgraphcoolという名前のディレクトリが作成されて、準備完了です。簡単!

vueの用意

次にvueの環境を用意していきます。
後々使うものも入れてしまいます。

npm install -g vue-cli

# 色々質問されますが今回私は基本的にそのままで、テスト関係だけnoにしました
vue init webpack vue
cd vue
npm install
npm install --save vue-apollo apollo-client-preset apollo-link-context graphql graphql-tag

これでvueという名前のディレクトリが作成されて、準備完了です。簡単!!

さて、それではまずDB側から作っていきたいと思います。

graphcool側の実装

スキーマ作成

今回のTodoアプリではユーザーの認証が出来ることが要件に含まれています。
graphcoolでは認証を行うためのテンプレートが用意されているため、
簡単に認証機能を実装することが出来ます。

cd graphcool
graphcool add-template graphcooltemplates/auth/email-password

これで認証に必要なものが追加されました。すごい!!!
(ちなみに、graphcoolのサイトのチュートリアルではfacebookを利用したログイン方法も載っていました)

ただ、追加してすぐは機能を利用することが出来ません。
利用するためにはgraphcool.ymltypes.graphqlのコメントアウトを外す必要があります。
コメントアウトを外すついでにTodoアプリのテーブル設計も行います。

graphcool/graphcool.yml
types: ./types.graphql

functions:
  signup:
    type: resolver
    schema: src/email-password/signup.graphql
    handler:
      code: src/email-password/signup.ts

  authenticate:
    type: resolver
    schema: src/email-password/authenticate.graphql
    handler:
      code: src/email-password/authenticate.ts

  loggedInUser:
    type: resolver
    schema: src/email-password/loggedInUser.graphql
    handler:
      code: src/email-password/loggedInUser.ts

permissions:
  - operation: "*"

graphcool.ymlについてはコメントアウトを外すだけです。
これで認証の機能が使えるようになります。

graphcool/types.graphql
type User @model {
  id: ID! @isUnique
  createdAt: DateTime!
  updatedAt: DateTime!

  email: String! @isUnique
  password: String!
  todos: [Todo!]! @relation(name: "UserTodos")
}


type Todo @model {
  id: ID! @isUnique
  createdAt: DateTime!
  updatedAt: DateTime!

  title: String!
  done: Boolean!
  author: User! @relation(name: "UserTodos")
}

types.graphqlでスキーマ定義を行います。
Userはシステム項目email,password,todosを持ちます。
Todoは同じシステム項目title,done,authorを持ちます。
titleはtodoの名前、doneは完了したtodoか、authorは誰のTodoか。
基本的なTodoアプリのテーブル設計と同じかと思います。
ちなみに!は、必須項目(not null)であることを意味しています。

DB作成

さて、スキーマ定義が終わったのでDBを立てていきます。
今回はDocker上に作成します。
私はDockerにあまり慣れていませんが、問題なく利用できました。
困った時の呪文としては「データボリュームの全削除」です。

graphcool local up

# Please choose the cluster you want to deploy toと
# 聞かれるので、一番下のlocalを選択。その後はそのままでok
graphcool deploy

成功したら色々表示されると思います。
そして下の方にSimple API:というものが書かれていると思うので、そのurlを控えておきます。
vueから接続するときに必要になります。
控え忘れてしまった場合はgraphcool infoなどで確認できます。

さあこれでGraphQLが利用できます!やったね!

ユーザー作成

Todoアプリ内でユーザー登録は今回作成していません。
なので、別の場所からユーザーを登録します。次のコマンドを打ちましょう!

graphcool playground

すると以下のような画面が開かれます。
ここから直接GraphQLを利用出来ます。
(ちなみに、右上の歯車マークからVIM MODEも選択できます。)

graphcool1.png

ではユーザーを作成してみたいと思います。
今回認証機能を追加しているので、sigupUserというmutationsを利用します。
mutationsとはquery以外(更新削除作成)の操作のことです(多分)
右側に表示されているSCHEMAのMUTATIONSの欄を見ていただけると雰囲気がなんとなくわかると思います。

以下のように書いて、真ん中の実行ボタンを押します。

mutation {
  signupUser(email:"test@mail.com" password:"test")
  {
    id
    token
  }
}

graphcool2.png

signupUser()が利用するAPIの名前で()内が引数です。
{}内は結果を返してくれます。
今回の場合はユーザーを作成した後idtokenを返しています。
これでユーザーが作成されました。

さて、次に作成したばかりのユーザー情報を見てみます。
idをコピーして、以下のqueryを書きます。

query {
  User(id:"cjatrk2ti02eb0189w0zgclot")
  {
    id
    createdAt
    updatedAt
    email
    password
    todos{
      id
      title
    }
  }
}

graphcool3.png

ちゃんと作成出来ていました!やったね!

Vue側の実装

さて、いよいよVue側の実装になります。
Vueですが、業務でAngularに触れたことがあって「雰囲気似ている…?」という理由からチュートリアルをあまり読まずに使ったところ結構はまりました。
よろしくない実装もあると思いますのであしからず。

ログイン機能

まずはログインです。
変更ファイルはvue/src/main.jsvue/App.vueです。
追加ファイルはvue/src/component配下のLogin.jsLogout.jsと、
vue/src/constants配下にgraphql.jssettings.jsです。

まずApp.vueを見ます。
こちらがいわゆるindexページです。
styleはそのまま利用しているのでここでは省いています。

vue/src/App.vue
<template>
  <div id="app">
    <logout v-if="isLoggedIn"></logout>
    <login v-else></login>

    <router-view/>
  </div>
</template>

<script>
  import Logout from './components/Logout'
  import Login from './components/Login'

  export default {
    name: 'app',
    components: {
      Logout,
      Login
    },
    computed: {
      isLoggedIn () {
        return this.$root.$data.token
      }
    }
  }
</script>

ログインとログアウトのコンポーネントを読み込んで表示しているだけです。
isLoggedIn()でtokenが保存されているかによって表示を切り替えています。

vue/src/components/Logout.vue
<template>
  <button @click="logout()">Logout</button>
</template>

<script>
  import {AUTH_TOKEN} from '../constants/settings'

  export default {
    name: 'Logout',
    methods: {
      logout () {
        localStorage.removeItem(AUTH_TOKEN)
        this.$root.$data.token = localStorage.getItem(AUTH_TOKEN)
        this.$router.push({path: '/'})
      }
    }
  }
</script>

次にLogout.vueですが、これもシンプルで、
ログアウトボタンが1個あって、押されたらlocalstorageに保存しているtoken情報を削除してトップへ移動させているだけです。

vue/src/components/Login.vue
<template>
  <div>
    <input v-model="email" type="text" placeholder="Email">
    <input v-model="password" type="password" placeholder="Password">
    <button @click="confirm()">Login</button>
  </div>
</template>

<script>
  import {AUTH_TOKEN} from '../constants/settings'
  import {LOGIN_MUTATION} from '../constants/graphql'

  export default {
    name: 'Login',
    data () {
      return {
        email: '',
        password: ''
      }
    },
    methods: {
      confirm () {
        const {email, password} = this.$data

        this.$apollo.mutate({
          mutation: LOGIN_MUTATION,
          variables: {
            email,
            password
          }
        }).then((result) => {
          const token = result.data.authenticateUser.token
          localStorage.setItem(AUTH_TOKEN, token)
          this.$root.$data.token = localStorage.getItem(AUTH_TOKEN)

          this.$router.push({path: '/todos'})
        }).catch((error) => {
          alert(error)
        })
      }
    }
  }
</script>

ログインです。
templateとしてはemailとpasswordを入力してもらうフォームと送信ボタンです。
フォームで入力された値はdata()emailpasswordにバインドされます。

methodsとしては、送信ボタンが押された時にユーザーを認証する処理のconfirm()があります。
this.$apollo.mutateがGraphQLのmutationを呼び出しています。
mutationの実態としてはvue/src/constants/graphql.jsに定義されている以下です。

vue/src/constants/graphql.js
export const LOGIN_MUTATION = gql`
  mutation LoginMutation($email: String!, $password: String!) {
    authenticateUser(email: $email, password: $password) {
      id
      token
    }
  }
`

これで認証が上手く行ったらidtokenが返ってくるので、tokenをlocalstorageに保存しています。
そしてその後にtodoのページに飛ぶ形になります。

さて、ここで保存したtokenですが利用している場所はどこかというとvue/src/main.jsになります。

vue/src/main.js
import {AUTH_TOKEN, DB_URL} from './constants/settings'
import {ApolloClient} from 'apollo-client'
import {HttpLink} from 'apollo-link-http'
import {setContext} from 'apollo-link-context'
import {InMemoryCache} from 'apollo-cache-inmemory'
import Vue from 'vue'
import App from './App'
import router from './router'
import VueApollo from 'vue-apollo'

Vue.config.productionTip = false
Vue.use(VueApollo)
let token

const authLink = setContext((_, { headers }) => {
  token = localStorage.getItem(AUTH_TOKEN)
  return {
    headers: {
      authorization: token ? `Bearer ${token}` : null
    }
  }
})

const httpLink = new HttpLink({
  uri: DB_URL
})

const apolloClient = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache()
})

const apolloProvider = new VueApollo({
  defaultClient: apolloClient,
  defaultOptions: {
    $loadingKey: 'loading'
  }
})

/* eslint-disable no-new */
new Vue({
  el: '#app',
  apolloProvider,
  router,
  data: {
    token
  },
  template: '<App/>',
  components: { App }
})

authLinkで、リクエストヘッダーにtokenを追加しています。
また、httpLinkで前に自分で立てたDBのSimple APIのurlが指定されています。
この2つをApolloClientlinkとして渡しています。

さて、これで以下のようなログインログアウトが出来るようになりました。

ログイン前:
Screenshot-2017-12-5 vue(1).png

ログイン後:
Screenshot-2017-12-5 vue(2).png

デザインのデの字もなく寂しいですね。残念です。

Todo機能

ようやく本題のTodo機能です。

todoは
vue/src/components配下のTodos.vue, Todo.vue, CreateTodo.vueになります。
基本的にTodos.vueでデータの取得を行い、その子コンポーネントであるTodo.vueCreateTodo.vueでDBの更新を行っています。

まず子コンポーネントのTodo.vueから見ていきます。

vue/src/components/Todo.vue
<template>
  <div>
    <input type="checkbox" @click="done" :checked="todo.done">
    <input v-model="title">
    <button @click="edit">編集</button>
    <button @click="del">削除</button>
  </div>
</template>

<script>
  import {UPDATE_TODO, DELETE_TODO} from '../constants/graphql'

  export default {
    name: 'Todo',
    props: ['todo'],
    data () {
      return {
        title: this.todo.title
      }
    },
    methods: {
      done () {
        this.$apollo.mutate({
          mutation: UPDATE_TODO,
          variables: {
            id: this.todo.id,
            done: !this.todo.done
          }
        })
      },
      edit () {
        this.$apollo.mutate({
          mutation: UPDATE_TODO,
          variables: {
            id: this.todo.id,
            title: this.title
          }
        })
      },
      del () {
        this.$apollo.mutate({
          mutation: DELETE_TODO,
          variables: {
            id: this.todo.id
          }
        }).then(() => {
          this.$emit('refresh')
        })
      }
    }
  }
</script>

やや長いですが、中身は非常にシンプルです。
親側からは1行のTodoデータが渡されます。
templateではそのデータを表示、加工用ボタンを設置しています。
methodsdone,edit,delはDBを更新してします。
doneなら完了にして(未完了にして)更新、editならtitleの変更です。
それとこれは親側の話になりますがdelthis.$emimt('refresh')が起こるとデータの再取得を行います。
本来は再取得ではなくリストを変更したほうがいいのでしょうが、実装の仕方がいまいちわからず、この形に落ち着きました。
ちなみに、doneeditの場合だとイベント起こさなくても勝手に変わってくれます。
どうしてそういう動きをするのかについてはわかりませんでした。
どなたか教えてください!!

さて、次に親コンポーネントのTodos.vueです。

<template>
  <div>
    <h4 v-if="loading">Loading Todos...</h4>
    <div v-else>
      <h3>タスク追加</h3>
      <create-todo @refresh="resetStore"></create-todo>

      <h3>未完了タスク</h3>
      <todo v-for="(todo, i) in tasks" @refresh="resetStore" :key="todo.id" :todo="todo"></todo>

      <h3>完了タスク</h3>
      <todo v-for="(todo, i) in done" @refresh="resetStore" :key="todo.id" :todo="todo"></todo>
    </div>
  </div>
</template>

<script>
  import {USER_TODOS, LOGGED_IN_USER} from '../constants/graphql'
  import Todo from './Todo'
  import CreateTodo from './CreateTodo'

  export default {
    name: 'Todos',
    data () {
      return {
        todos: [],
        user: '',
        loading: 0
      }
    },
    components: {
      Todo,
      CreateTodo
    },
    computed: {
      done () {
        return this.todos.filter(todo => todo.done === true)
      },
      tasks () {
        return this.todos.filter(todo => todo.done === false)
      }
    },
    apollo: {
      user: {
        query: LOGGED_IN_USER,
        update (data) {
          return data.loggedInUser.id
        }
      },
      todos: {
        query: USER_TODOS,
        variables () {
          return {
            id: this.user
          }
        },
        update (data) {
          return data.User.todos
        }
      }
    },
    methods: {
      resetStore () {
        this.$apollo.provider.defaultClient.resetStore()
      }
    }
  }
</script>

templateでは新規登録欄の表示とtodo一覧の表示を行っています。
computedで完了タスクと未完了タスクを分けて、分けられたものをそれぞれ表示しています。

そしてapolloでGraphQLのqueryを実行しています。
userはヘッダーに詰められたtokenを元に、現在のユーザーを返すqueryになっています。
vue/src/main.jsで詰めたtokenがここで利用されています。
(ちなみに、graphcoolのplaygroundでこのquery(LoggedInUser)を利用するときは、左下のHTTP HEADERS(0)からautorizationとBearer tokenを渡してあげる必要があります)

todosでは、現在のユーザーのtodo一覧を取得します。
variablesをリアクティブにしてuserが変更されるたびに実行しています。
このようにしているのは、userを取得した後todosを取得する、という方法がわからなかったためです。
実はこのtodos実行されると「userがないんだけど!」って1回怒られます。

さあ、ここまでくれば、あとは同じ要領でvue/src/components/CreateTodo.vueを実装して、todosをrouterに追加して完成です。
以下のようなTodoアプリが完成しました!やったー!

Screenshot-2017-12-5 vue(3).png

style指定してないからやっぱり寂しい。けど完成です!!!

おわりに

ここまでお付き合いありがとうございます。
何かしらお役に立てば嬉しいです。

作ってみた所感としては、
apolloの情報が上手く見つけられず少し辛かったこと、
vueで配列操作が思い通りに出来ず少し辛かったこと、
そしてそもそもweb開発の知識が足りてなくてtokenの使い方とか全然わからなかったことなどなどあるのですが、
総括して、触ったことない技術に触れられて楽しかった!といった具合です。

まあ新卒なのでほとんどが触ったことない技術なのですが!
記事にするかはわかりませんが、次は今回作ってみたこれをAWSに乗っけてみたりもしようかなって思っています。

ではでは。

参考

GraphQL入門 - 使いたくなるGraphQL
Vue + Apollo Tutorial - Introduction (情報がやや古いので注意)
Vue
Graphcool Docs
vue-apollo

1473685759