イノベーション エンジニアブログ


株式会社イノベーションのエンジニアたちの技術系ブログです。ITトレンド・List Finderの開発をベースに、業務外での技術研究などもブログとして発信していってます!


このエントリーをはてなブックマークに追加

本の管理アプリをVue.js,Laravel,Docker,GCP,gitlab,ElementUIを使って作った話。

こへです。

そろそろ
Vue.js Laravel Docker GCP gitlab
を使って社内の本の管理アプリを作りたい時期が来ましたので作成することにしました。

お金を1円もかけたくない
と言う裏目標も持ちます。

完成物

ginee1.0.gif

Version管理

version管理に githubではなくgitlabを使います。

理由:

  • private repository が無料

  • Container Registory が使える

デザイン

Vue.js用のライブラリElementを使います。 簡単にいい感じのデザインを作れる予感がするので選択。

構成

├── docker-compose.yml
├── nginx
│   ├── Dockerfile
│   └── etc
│   └── nginx
├── phpfpm
│   └── Dockerfile
└── www
  └── BookApp

環境構築(Docker)

Dockerの構成や、中身などは ↓の記事にかかれてあります。

上記の設定に加え、今回はメール認証&Login機能をつけたいので メール受信を開発用に Mailhog コンテナを使用する用に docker-compose.yml に追記、

  mailhog:
    image          : mailhog/mailhog:latest
    container_name : mailhog
    hostname       : mailhog
    ports:
      - "1025:1025"
      - "8025:8025"

そして localhost:8025 にアクセスすると受信BOXが使えます。

ログイン機能

本の貸し借りということで、必須機能の1つであるログイン機能を作ります。

↑主にこちらの記事を参考に作らせていただきました。 メール送信にはAmazon SESを使おうと思いましたが、 認証に使うだけなので、本番ではお手軽にできるGmailを使って、メールを送ることにしました。

メール認証もする必要がなければ php artisan make:auth 実行するだけで、 できてしまうのは非常に便利ですね。
参考:https://readouble.com/laravel/5.5/ja/authentication.html

localhostにアクセスするとこんな感じ。

ginee2.0.png?

そしてRegisterで登録。

ginee3.0.png?

Vue.js

※Vue.jsの開発の補助ツールをChromeの拡張機能で入れる。 https://chrome.google.com/webstore/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd

├── app.js
├── app.vue
├── bootstrap.js
├── components
│   ├── Books.vue
│   ├── addBook.vue
├── router.js
└── services
└── http.js

構成はこんな感じ。

app.js

一番最初に呼ばれる。

require('./bootstrap');

import Vue from 'vue'
import router from './router'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css';

import locale from 'element-ui/lib/locale/lang/ja'
import http from './services/http.js'
Vue.use(ElementUI, { locale })


const app = new Vue({
    router,

    created () {
      http.init()
    },
    el: '#app',
    render: h => h(require('./app.vue')),
})

router.js

その名の通りURLに合わせて、指定のコンポーネントを呼び出す。

import VueRouter from 'vue-router'
import Vue from 'vue'

Vue.use(VueRouter)

export default new VueRouter({
  mode: 'history',
  routes: [
    { path: '/', name:'books', component: require('./components/Books.vue') },
    { path: '/addbook', name:'addBook', component: require('./components/addBook.vue') },
    { path: '/addjob', name:'addJob', component:
  ],
  scrollBehavior (to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { x: 0, y: 0 }
    }
  },
})

app.vue

基本的なレイアウトを書いていく。 ※navigation bar など

<template>
  <div id="app">
    <el-container style="height: 100%; border: 1px solid #eee">
        <el-aside width="200px" style="background-color: rgb(238, 241, 246)">

         <el-menu :default-openeds="['1', '1']">
          <el-submenu index="1">
            <template slot="title"><i class="el-icon-star-off"></i>Book</template>
              <router-link :to="{ name: 'books'}">
                <el-menu-item index="1-1">
                  Book List
                </el-menu-item>
              </router-link>
              <router-link :to="{ name: 'addBook'}">
                <el-menu-item index="1-2">
                  Add Book
                </el-menu-item>
              </router-link>
          </el-submenu>
          <el-submenu index="2">
            <template slot="title"><i class="el-icon-tickets"></i>Job</template>
              <router-link :to="{ name: 'jobList'}">
                <el-menu-item index="2-1">
                  Job List
                </el-menu-item>
              </router-link>
              <router-link :to="{ name: 'addJob'}">
                <el-menu-item index="20">
                  Add Job
                </el-menu-item>
              </router-link>
          </el-submenu>

         </el-menu>

        </el-aside>
        <el-main>
            <div>
              <router-view></router-view>
            </div>
        </el-main>
    </el-container>
  </div>
</template>

Book.vue

本の一覧ページ

<template>
<div>
<el-autocomplete
  v-model="search_word"
  @keyup.enter="testSubmit"
  :fetch-suggestions="querySearchAsync"
  placeholder="Please input"
  @select="handleSelect"
></el-autocomplete>
<el-button type="primary" icon="el-icon-search" :loading="is_loading" @click="fetchBooks">Search</el-button>

<hr>

  <el-table
    v-loading="loading2"
    element-loading-text="Loading..."
    element-loading-background="rgba(0, 0, 0, 0.8)"

    :data="books"
    height="500"
    style="width: 100%">
    <el-table-column
      prop="name"
      sortable
      label="Name"
      width="300">
    </el-table-column>
      <el-table-column
      width="180"
      label="Operations">
      <template slot-scope="scope">
        <el-button
          v-if="user_id === scope.row['user_id']"
          size="warning"
          @click="returnBook(scope.$index, scope.row)" plain>Return</el-button>
        <el-button
          v-else
          size="success"
          @click="borrowBook(scope.$index, scope.row)" plain v-bind:disabled="scope.row['is_lend'] == 1">Borrow</el-button>
      </template>
    </el-table-column>
    <el-table-column
      prop="lend_date"
      sortable
      label="LendDate"
      width="180">
    </el-table-column>
    <el-table-column
      prop="user_name"
      label="BorrowUser"
      width="180">
    </el-table-column>
  </el-table>
</div>
</template>

<script>
  import http from '../services/http'

  export default {
    mounted() {
      //run when load this page
      this.fetchAllBooks();
    },
    data() {
      return {
        books: [],
        books_backup: [],
        author: '',
        created_at: '',
        is_loading:false,

        sujests: [],
        search_word: '',
        timeout:  null,
        user_id: parseInt(document.getElementById('user_id').value),

        loading2: true,

      }
    },
    methods: {
      fetchBooks () {
        console.log(this.search_word);
        this.is_loading = true;
        this.loading2 = true;
        http.post('books', {name: this.search_word}, res => {
          this.books = res.data;
          this.is_loading = false;
          this.loading2 = false;

        });
      },

      fetchAllBooks () {
        this.loading2 = true;

        http.get('books', res => {
          this.books = res.data;
          this.books_backup = res.data;

          let names = [];
          for (let i in res.data){
            names.push({"value": res.data[i].name, "id": res.data[i].id});
          }
          this.sujests = names;
          this.loading2 = false;
          console.log(names);
        })

      },

      querySearchAsync(queryString, cb) {
        let sujests = this.sujests;
        let results = queryString ? sujests.filter(this.createFilter(queryString)) : sujests;

        clearTimeout(this.timeout);
        this.timeout = setTimeout(() => {
          cb(results);
        }, 3000 * Math.random());
      },
      createFilter(queryString) {
        return (link) => {
          return (link.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0);
        };
      },
      //既存のデータから選択した情報だけ出す
      handleSelect(item) {
        console.log(item.id);
        this.books = this.books_backup.filter(function(element, index, array) {
           return (element.name == item.value);
        });
      },

      borrowBook(index, row) {
        const book_id = row['id'];
        console.log(row["id"]);
        row['is_lend'] = true;
        row['user_id'] = this.user_id;
        http.get('book/borrow/' + book_id, res => {
          console.log(res.data);
        });
         this.$notify.success({
          title: 'Info',
          message: 'You borrow ' + row['name'],
          showClose: false
        });
      },
      returnBook(index, row) {
        console.log(row["id"]);
        const book_id = row['id'];
        row['user_id'] = null;
        http.get('book/return/' + book_id, res => {
          console.log(res.data);
        });
         this.$notify.success({
          title: 'Info',
          message: 'You return ' + row['name'],
          showClose: false
        });
      },
    }
  }
</script>

addBook.vue

本の追加ページ

<template>
<el-form :inline="false" :model="addBookForm" ref="addBookForm" class=""  >
  <el-form-item
  prop="name"
  label="Name"
  :rules="[{
      required: true ,message: 'name is required', trigger: 'blur'
      }]"
  >
    <el-input v-model="addBookForm.name" placeholder="name"></el-input>
  </el-form-item>
  <el-form-item label="Author">
    <el-input v-model="addBookForm.author" placeholder="author"></el-input>
  </el-form-item>

  <el-form-item>
    <el-button type="primary" @click="onSubmit('addBookForm')">Submit</el-button>
  </el-form-item>
</el-form>

</template>

<script>
  import http from '../services/http'

  export default {

    data() {
      return {
          addBookForm: {
            name:'',
            author:'',
            response:[],
          }
      }
    },
    methods: {
      onSubmit(formName) {

        //check validation
        this.$refs[formName].validate((valid) => {
          if (valid) {

            http.post('addbook', {name: this.addBookForm.name, author: this.addBookForm.author}, res => {
              this.response = res.data
            });

            this.$message({
              showClose: true,
              message: 'Congrats, this is a success',
              type: 'success'
            });

            this.addBookForm.name = "";
            this.addBookForm.author = "";

          } else {
            this.$message({
              showClose: true,
              message: 'error',
              type: 'error'
            });
            console.log('error submit!!');
            return false;
          }
        });

        console.log(this.response);
      },

    }
  }
</script>

コンパイル

php-fpmコンテナに入り npm run watch-pollを行う。

裏側(Laravel)

リクエストに合わせていい感じにデータを返すだけ。 ※Auth認証したユーザしか受け付けないように注意。

DB

Tables

  • Books

  • Users

  • BookLendHistories

ローンチ

GCPの米国リージョンのt2microインスタンスは無料で使えるので、そのインスタンスを立てる。

そしてDockerとDockerComposeをインストールし、 このsourceを落とし、docker-compose upを行うだけ! お手軽!

ドメイン

Freenom の無料ドメインを使おうとしたが、会員制のサイトは規約に違反しているラシク断念。 お名前ドットコムで1年間1円のものを適当に買いました。

次回

GKEを使い、smallインスタンスのノードのクラスターを組み、 それにCloud SQLをつなげ運用していけるようにしようと考えています。