はじめに
- Go/Vue/GraphQL初学者が学習用にCRUD操作のできるアプリを作ってみた
- サーバーサイド編とフロントエンド編に分けてその手順を紹介
- ざっくりとした構成は
- サーバー(Go + gqlgen)
- フロント(Nuxt + Apollo)
- データベース(MySQL)
- ORM(gorm)
- 開発環境(docker-compose)
完成イメージ
※ タスクの並び順は操作できません
関連リンク
免責事項
- GoもVueもそんなに知見がありません
- そのため見苦しい書きぶりをしている箇所が多々ありますがご容赦ください🙏
フロントエンド
Nuxt起動
front/Dockerfile
を作成し、docker-compose.yml
にfrontコンテナを追加します
※ front
ディレクトリも作成。以後、フロントのコードはこの配下に置きます
FROM node:14.2-alpine3.11
ENV LANG ja_JP.UTF-8
ENV TZ Asia/Tokyo
WORKDIR /front
RUN apk add python make g++
# Nuxt生成後にコメントを外す
# COPY package.json ./
# COPY yarn.lock ./
# RUN yarn install
version: '3'
services:
# frontコンテナを追加
front:
build:
context: ./front
ports:
- 3000:3000
volumes:
- ./front:/front:cached
- front_node_modules:/front/node_modules
tty: true
stdin_open: true
command: yarn dev
server:
build: ./server
tty: true
ports:
- 8080:8080
environment:
LANG: ja_JP.UTF-8
TZ: Asia/Tokyo
volumes:
- ./server/:/go/src/github.com/MrFuku/kanban-go-nuxt-graphql/server
db:
image: mysql:5.7.24
ports:
- 3306:3306
environment:
TZ: 'Asia/Tokyo'
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: localuser
MYSQL_PASSWORD: localpass
MYSQL_DATABASE: localdb
volumes:
- mysql_data:/var/lib/mysql
command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci
# ボリュームも追加
volumes:
front_node_modules:
mysql_data:
Nuxtをインストールします
# frontコンテナを立ち上げ、shを起動
❯ docker-compose run front sh
# createコマンドでNuxtをインストール
❯ yarn create nuxt-app .
yarn create v1.22.4
[1/4] Resolving packages...
warning create-nuxt-app > sao > micromatch > snapdragon > source-map-resolve > resolve-url@0.2.1: https://github.com/lydell/resolve-url#deprecated
warning create-nuxt-app > sao > micromatch > snapdragon > source-map-resolve > urix@0.1.0: Please see https://github.com/lydell/urix#deprecated
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Installed "create-nuxt-app@2.15.0" with binaries:
- create-nuxt-app
create-nuxt-app v2.15.0
✨ Generating Nuxt.js project in .
? Project name front
? Project description My wondrous Nuxt.js project
? Author name
? Choose programming language JavaScript
? Choose the package manager Yarn
? Choose UI framework None
? 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 (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Choose test framework None
? Choose rendering mode Universal (SSR)
? Choose development tools (Press <space> to select, <a> to toggle all, <i> to invert selection)
🎉 Successfully created project front
To get started:
yarn dev
To build & start for production:
yarn build
yarn start
Done in 48.14s.
起動時の環境変数を設定
yarn dev
でNuxtを立ち上げる時に環境変数HOST=0.0.0.0 PORT=3000
が設定されるようにします
{
"name": "front",
"version": "1.0.0",
"description": "My wondrous Nuxt.js project",
"author": "",
"private": true,
"scripts": {
"dev": "HOST=0.0.0.0 PORT=3000 nuxt",
"build": "nuxt build",
"start": "nuxt start",
"generate": "nuxt generate"
},
"dependencies": {
"nuxt": "^2.0.0"
},
"devDependencies": {}
}
コメントアウトしていた箇所を戻す
FROM node:14.2-alpine3.11
ENV LANG ja_JP.UTF-8
ENV TZ Asia/Tokyo
WORKDIR /front
RUN apk add python make g++
COPY package.json ./
COPY yarn.lock ./
RUN yarn install
dockerイメージを再ビルドしてNuxtが起動するか確認
# dockerイメージを再ビルド
❯ docker-compose build front
# frontコンテナを立ち上げ、Nuxtが起動することを確認
❯ docker-compose up front
http://localhost:3000/
にアクセスし、Nuxtが起動していればOK
Apolloインストール
# frontコンテナを立ち上げ、shを起動
❯ docker-compose run front sh
# Apolloインストール
❯ yarn add @nuxtjs/apollo
nuxt.config.js
のmodulesにapolloを追加し、apolloプロパティを新しく追加します
...
** Nuxt.js modules
*/
modules: [
'@nuxtjs/apollo'
],
apollo: {
clientConfigs: {
default: {
// GraphQLサーバーのエンドポイント
httpEndpoint: 'http://server:8080/query',
browserHttpEndpoint: 'http://localhost:8080/query',
}
},
},
...
で、ここでNuxtを起動させると鬼のようなエラーが出てきます
ERROR Failed to compile with 39 errors friendly-errors 14:59:40
These dependencies were not found: friendly-errors 14:59:40
friendly-errors 14:59:40
* core-js/modules/es6.array.find in ./.nuxt/client.js friendly-errors 14:59:40
* core-js/modules/es6.array.from in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js friendly-errors 14:59:40
* core-js/modules/es6.array.iterator in ./.nuxt/client.js friendly-errors 14:59:40
* core-js/modules/es6.date.to-string in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js friendly-errors 14:59:40
* core-js/modules/es6.function.name in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js friendly-errors 14:59:40
* core-js/modules/es6.object.assign in ./.nuxt/client.js friendly-errors 14:59:40
* core-js/modules/es6.object.keys in ./.nuxt/client.js friendly-errors 14:59:40
* core-js/modules/es6.object.to-string in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js and 1 other friendly-errors 14:59:40
* core-js/modules/es6.promise in ./.nuxt/client.js friendly-errors 14:59:40
* core-js/modules/es6.regexp.constructor in ./.nuxt/utils.js friendly-errors 14:59:40
* core-js/modules/es6.regexp.match in ./.nuxt/client.js friendly-errors 14:59:40
* core-js/modules/es6.regexp.replace in ./.nuxt/utils.js, ./.nuxt/components/nuxt.js friendly-errors 14:59:40
* core-js/modules/es6.regexp.search in ./.nuxt/utils.js friendly-errors 14:59:40
* core-js/modules/es6.regexp.split in ./.nuxt/utils.js, ./node_modules/babel-loader/lib??ref--2-0!./node_modules/vue-loader/lib??vue-loader-options!./.nuxt/components/nuxt-build-indicator.vue?vue&type=script&lang=js& friendly-errors 14:59:40
* core-js/modules/es6.regexp.to-string in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js friendly-errors 14:59:40
* core-js/modules/es6.string.includes in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js friendly-errors 14:59:40
* core-js/modules/es6.string.iterator in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js friendly-errors 14:59:40
* core-js/modules/es6.string.repeat in ./.nuxt/utils.js friendly-errors 14:59:40
* core-js/modules/es6.string.starts-with in ./.nuxt/utils.js friendly-errors 14:59:40
* core-js/modules/es6.symbol in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js friendly-errors 14:59:40
* core-js/modules/es7.array.includes in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js friendly-errors 14:59:40
* core-js/modules/es7.object.get-own-property-descriptors in ./.nuxt/utils.js friendly-errors 14:59:40
* core-js/modules/es7.promise.finally in ./.nuxt/client.js friendly-errors 14:59:40
* core-js/modules/es7.symbol.async-iterator in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js friendly-errors 14:59:40
* core-js/modules/web.dom.iterable in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js friendly-errors 14:59:40
friendly-errors 14:59:40
To install them, you can run: npm install --save core-js/modules/es6.array.find core-js/modules/es6.array.from core-js/modules/es6.array.iterator core-js/modules/es6.date.to-string core-js/modules/es6.function.name core-js/modules/es6.object.assign core-js/modules/es6.object.keys core-js/modules/es6.object.to-string core-js/modules/es6.promise core-js/modules/es6.regexp.constructor core-js/modules/es6.regexp.match core-js/modules/es6.regexp.replace core-js/modules/es6.regexp.search core-js/modules/es6.regexp.split core-js/modules/es6.regexp.to-string core-js/modules/es6.string.includes core-js/modules/es6.string.iterator core-js/modules/es6.string.repeat core-js/modules/es6.string.starts-with core-js/modules/es6.symbol core-js/modules/es7.array.includes core-js/modules/es7.object.get-own-property-descriptors core-js/modules/es7.promise.finally core-js/modules/es7.symbol.async-iterator core-js/modules/web.dom.iterable
結論から言うとcore-js
をダウングレードさせないといけないようです(詳しい理由を知っている方いましたら、ぜひ教えていただきたく🙏)。
https://qiita.com/shunk-py/items/27bd33ee8df6d3e505f4
# frontコンテナ内でcore-jsをダウングレードする
❯ yarn add core-js@2.6.9
クエリ定義
サーバーにリクエストを送る際に使うクエリを定義します
mutation($id: String!, $text: String!, $done: Boolean!, $userId: String!) {
updateTodo(input: { id: $id, text: $text, done: $done, userId: $userId }) {
id
text
done
user {
id
name
}
}
}
mutation($name: String!) {
createUser(input: { name: $name }) {
id
name
}
}
mutation($id: String!) {
deleteTodo(input: $id){
id
text
done
user{
id
name
}
}
}
mutation($id: String!, $text: String!, $done: Boolean!, $userId: String!) {
updateTodo(input: { id: $id, text: $text, done: $done, userId: $userId }) {
id
text
done
user {
id
name
}
}
}
query todos {
todos {
id
text
done
userId
user {
id
name
}
}
}
query users {
users {
id
name
}
}
Vuetifyインストール
デザイン付けを簡単にするため、デザインフレームワーク(Vuetify)を使います
# frontコンテナを立ち上げ、shを起動
❯ docker-compose run front sh
❯ yarn add @nuxtjs/vuetify
front/nuxt.config.js
を修正し、moduleを追加します
...
/*
** Nuxt.js modules
*/
modules: [
'@nuxtjs/apollo',
'@nuxtjs/vuetify'
],
...
画面側の実装
詳細な説明は省きますが、以下の通り画面を実装します
正直なところイケテナイ書きぶりになっています、悪しからず(こんな書きぶりがいいよ!などありましたら是非フィードバックいただきたく!🙏)
`front/pages/index.vue`
<template>
<v-app id="inspire">
<Header
:drawer="drawer"
@change="drawer = !drawer"
>
<SideNav />
</Header>
<v-content>
<v-container
fluid
fill-height
>
<v-row style="height: 90%;">
<v-col cols="6">
<Board
:todos="unfinishedTodos"
:status="false"
@updateTodo="updateTodo"
>
<h3>未完了</h3>
</Board>
</v-col>
<v-col cols="6">
<Board
:todos="finishedTodos"
:status="true"
@updateTodo="updateTodo"
>
<h3>完了</h3>
</Board>
</v-col>
</v-row>
</v-container>
</v-content>
</v-app>
</template>
<script>
import Header from "~/components/Header";
import SideNav from "~/components/SideNav";
import Board from "~/components/Board";
import todos from "~/apollo/queries/todos.gql";
import users from "~/apollo/queries/users.gql";
import updateTodo from "~/apollo/mutations/updateTodo.gql";
export default {
components: {
Header,
SideNav,
Board
},
data() {
return {
drawer: true
};
},
computed: {
unfinishedTodos() {
return this.todos.filter(t => !t.done);
},
finishedTodos() {
return this.todos.filter(t => t.done);
}
},
methods: {
updateTodo(todoId, status) {
let todo = this.todos.find(t => t.id === todoId);
console.log(todoId, status)
if (!todo || todo.done === status) return;
const { id, text, userId } = todo;
const done = status;
this.$apollo
.mutate({
mutation: updateTodo,
variables: {
id,
text,
done,
userId
},
refetchQueries: [
{
query: todos
}
]
})
.catch(err => {
console.log(err);
});
}
},
apollo: {
todos: {
prefetch: true,
query: todos
},
users: {
prefetch: true,
query: users
}
}
};
</script>
`front/components/Board.vue`
<template>
<v-card
class="blue-grey lighten-4"
height="100%"
@drop="putTodo($event)"
@dragover.prevent
@dragenter.prevent
>
<v-container>
<slot></slot>
<v-row dense>
<v-col
v-for="(todo, i) in todos"
:key="i"
cols="12"
>
<TaskCard :todo="todo" />
</v-col>
</v-row>
</v-container>
</v-card>
</template>
<script>
import TaskCard from "~/components/TaskCard";
export default {
components: {
TaskCard
},
props: {
todos: {
type: Array,
required: true
},
status: {
type: Boolean,
required: true
}
},
methods: {
putTodo(event) {
const todoId = event.dataTransfer.getData("todoId");
this.$emit("updateTodo", todoId, this.status);
}
}
};
</script>
`front/components/Header.vue`
<template>
<div>
<v-navigation-drawer
:value="drawer"
:clipped="$vuetify.breakpoint.lgAndUp"
app
>
<slot></slot>
</v-navigation-drawer>
<v-app-bar
:clipped-left="$vuetify.breakpoint.lgAndUp"
app
color="blue darken-3"
dark
>
<v-app-bar-nav-icon @click.prevent="$emit('change')"></v-app-bar-nav-icon>
<v-toolbar-title class="ml-0 pl-4">
<span class="hidden-sm-and-down">Kanban App</span>
</v-toolbar-title>
</v-app-bar>
</div>
</template>
<script>
export default {
props: {
drawer: {
type: Boolean,
required: true
}
}
};
</script>
`front/components/SideNav.vue`
<template>
<div>
<v-list dense>
<v-list-item @click="taskDialog = true">
<v-icon>mdi-calendar-check</v-icon>
タスク作成
</v-list-item>
<v-list-item @click="userDialog = true">
<v-icon>mdi-account</v-icon>
ユーザー作成
</v-list-item>
</v-list>
<TaskForm
:dialog="taskDialog"
mode="new"
@close="taskDialog = false"
/>
<UserForm
:dialog="userDialog"
@close="userDialog = false"
/>
</div>
</template>
<script>
import TaskForm from "~/components/TaskForm";
import UserForm from "~/components/UserForm";
export default {
components: {
TaskForm,
UserForm
},
data() {
return {
taskDialog: false,
userDialog: false
};
}
};
</script>
`front/components/TaskCard.vue`
<template>
<div>
<v-card
draggable
@dragstart="stashTodo($event, todo)"
>
<v-card-text class="font-weight-bold">{{ todo.text }}</v-card-text>
<v-card-subtitle class="d-flex">
{{ todo.user.name }}
<v-spacer></v-spacer>
<v-icon @click="taskDialog = true">mdi-square-edit-outline</v-icon>
<TaskDeleteBtn :todo-id="todo.id" />
</v-card-subtitle>
</v-card>
<TaskForm
:dialog="taskDialog"
:todo="todo"
mode="edit"
@close="taskDialog = false"
/>
</div>
</template>
<script>
import TaskForm from "~/components/TaskForm";
import TaskDeleteBtn from "~/components/TaskDeleteBtn";
export default {
components: {
TaskForm,
TaskDeleteBtn
},
props: {
todo: {
type: Object,
required: true
},
},
data() {
return {
taskDialog: false
};
},
methods: {
stashTodo(event, todo) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.dropEffect = "move";
event.dataTransfer.setData("todoId", todo.id);
}
}
};
</script>
`front/components/TaskDeleteBtn.vue`
<template>
<v-dialog
v-model="dialog"
max-width="290"
>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-delete</v-icon>
</template>
<v-card>
<v-card-title class="headline">タスクの削除</v-card-title>
<v-card-text>本当に削除しますか?</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="dialog = false">キャンセル</v-btn>
<v-btn
color="error"
@click="deleteTodo"
>削除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import todos from "~/apollo/queries/todos.gql";
import deleteTodo from "~/apollo/mutations/deleteTodo.gql";
export default {
props: {
todoId: {
type: String,
required: true
}
},
data() {
return {
dialog: false
};
},
methods: {
deleteTodo() {
this.$apollo
.mutate({
mutation: deleteTodo,
variables: {
id: this.todoId
},
refetchQueries: [
{
query: todos
}
]
})
.then(res => {
this.dialog = false;
})
.catch(err => {
console.log(err)
});
}
}
};
</script>
`front/components/TaskForm.vue`
<template>
<v-dialog
:value="dialog"
@input="$emit('close')"
width="500"
>
<v-card>
<v-card-title
class="headline grey lighten-2"
primary-title
>
{{ title }}
</v-card-title>
<v-container>
<v-form
ref="form"
v-model="valid"
>
<v-textarea
v-model="editTodo.text"
outlined
label="タスクの内容"
:rules="textRules"
/>
<v-row>
<v-col cols="6">
<v-select
:items="todoStatuses"
v-model="editTodo.done"
outlined
dense
label="完了ステータス"
/>
</v-col>
<v-col cols="6">
<v-select
:items="users"
item-text="name"
item-value="id"
v-model="editTodo.userId"
outlined
dense
label="担当者"
:rules="userIdRulues"
/>
</v-col>
</v-row>
</v-form>
</v-container>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn @click="$emit('close')">
キャンセル
</v-btn>
<v-btn
color="primary"
@click="exec"
:disabled="!valid"
>
{{ submitLabel }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import todos from "~/apollo/queries/todos.gql";
import users from "~/apollo/queries/users.gql";
import createTodo from "~/apollo/mutations/createTodo.gql";
import updateTodo from "~/apollo/mutations/updateTodo.gql";
export default {
props: {
dialog: {
type: Boolean,
required: true
},
mode: {
type: String,
required: true
},
todo: {
type: Object,
default: () => {
return {
text: "",
userId: "",
done: false
};
}
}
},
data() {
return {
editTodo: {},
todoStatuses: [
{ text: "未完了", value: false },
{ text: "完了", value: true }
],
textRules: [
v => !!v || "タスクの内容は必須です",
v => (v && v.length <= 1000) || "1000文字以内で入力してください"
],
userIdRulues: [v => !!v || "担当者は必須です"],
valid: false
};
},
methods: {
createTodo() {
const { text, done, userId } = this.editTodo;
this.$apollo
.mutate({
mutation: createTodo,
variables: {
text,
done,
userId
},
refetchQueries: [
{
query: todos
}
]
})
.then(res => {
this.$emit("close");
})
.catch(err => {
console.log(err);
});
},
updateTodo() {
const { id, text, done, userId } = this.editTodo;
this.$apollo
.mutate({
mutation: updateTodo,
variables: {
id,
text,
done,
userId
},
refetchQueries: [
{
query: todos
}
]
})
.then(res => {
this.$emit("close");
})
.catch(err => {
console.log(err);
});
}
},
computed: {
title() {
if (this.mode === "new") return "新規タスク作成";
if (this.mode === "edit") return "タスク編集";
},
submitLabel() {
if (this.mode === "new") return "作成";
if (this.mode === "edit") return "更新";
},
exec() {
if (this.mode === "new") return this.createTodo;
if (this.mode === "edit") return this.updateTodo;
}
},
watch: {
// フォームの入力内容を初期化する
// 雑なやり方しか思い浮かばず。他に良いやり方があれば教えてください
dialog(newValue) {
if (newValue) {
this.editTodo = Object.assign({}, this.todo);
} else {
this.editTodo = Object.assign({}, this.todo);
this.$refs.form.resetValidation();
}
}
},
apollo: {
users: {
query: users
}
}
};
</script>
`front/components/UserForm.vue`
<template>
<v-dialog
:value="dialog"
@input="$emit('close')"
width="500"
>
<v-card>
<v-card-title
class="headline grey lighten-2"
primary-title
>
新規ユーザー作成
</v-card-title>
<v-form
ref="form"
v-model="valid"
>
<v-container>
<v-text-field
v-model="name"
:rules="nameRulues"
outlined
label="ユーザー名"
/>
</v-container>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn @click="$emit('close')">
キャンセル
</v-btn>
<v-btn
color="primary"
@click="createUser"
:disabled="!valid"
>
作成
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</template>
<script>
import users from "~/apollo/queries/users.gql";
import createUser from "~/apollo/mutations/createUser.gql";
export default {
props: {
dialog: {
type: Boolean,
required: true
}
},
data() {
return {
name: "",
nameRulues: [
v => !!v || "ユーザー名は必須です",
v => (v && v.length <= 1000) || "1000文字以内で入力してください"
],
valid: false
};
},
methods: {
createUser() {
const name = this.name;
this.$apollo
.mutate({
mutation: createUser,
variables: {
name
},
refetchQueries: [
{
query: users
}
]
})
.then(res => {
this.$emit('close');
})
.catch(err => {
console.log(err)
});
}
},
watch: {
// フォームの入力内容を初期化する
// 雑なやり方しか思い浮かばず。他に良いやり方があれば教えてください
dialog(newValue) {
if (!newValue) {
this.name = "";
this.$refs.form.resetValidation();
}
}
}
};
</script>
以上で完成です!(お疲れ様でした)