174
156

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Go/Gin/Gorm/Vue.js/MySQLで超簡単なSPAを開発

Last updated at Posted at 2019-10-06

はじめに

Go言語で開発したWebアプリをローカルサーバーでデバッグする(VSCode)ことができましたので、
Go/Gin/Vue.js/MySQLでSPA(単一のWebページでアプリケーションを構成)を開発してみます。
アーキテクチャ.jpg
・フロントエンドはイマドキのWebアプリ風にSPAで開発したいためVue.jsを使用します。
JSフレームワークはAngular・React・Vue.jsなどがありますが、各々を比較検討し、
コンポーネント内記述がHTMLとJSとで独立しているのでソースをより綺麗に書けて、
双方向データバインディング(データと描画を同期する仕組み)と、
スモールスタートが可能なVue.jsを選定しました。

・バックエンドはイケてる企業で流行りのGo/Ginを使用します。
Go言語のWAFは潤沢にありますが、各々を比較検討し、
比較的パフォーマンスと生産性が高く、日本語ドキュメントもあるGinを選定しました。

・DBはシンプルなWebアプリに向いていて無料のMySQLを使用します。
多機能で無料のPostgreSQLと悩みましたが、
簡単なWebアプリには、シンプルなDBの方がマッチしているのでMySQLにしました。

前提

必要なソフトウェアをインストールすること

バージョン情報

  • Go:1.19
  • Gin:1.8.1
  • Gorm:1.9.16
  • Vue.js:3.2.45
  • MySQL:8.0.13
  • MySQLドライバ:1.7.0

開発するSPA

「買い物リスト作成アプリ」をSPAで開発してみます。

機能は大まかに以下の通りです。
 ・商品を登録できる(品名、メモ)
 ・商品を削除できる
 ・商品の状態を更新できる(未購入⇔購入済)
 ・登録した商品を表示できる
 ・表示する商品を指定できる(すべて・未購入・購入済)

事前準備

プロジェクトディレクトリを作成します

ディレクトリ名はshoppingapp

プロジェクトを初期化します

ターミナルを開いて以下コマンドを実行します

cd shoppingapp

go mod init shoppingapp

GitHubから必要なライブラリをimport

go get -u github.com/gin-gonic/gin
go get -u github.com/jinzhu/gorm
go get -u github.com/go-sql-driver/mysql

SPA開発

準備は整いましたので、実際に手を動かしてSPAを開発してみます。
(すみませんが、Goのパッケージ構成やGo/Gin/Vue.jsについては独学なので、ベストプラクティスではないかもです・・)

パッケージ構成

プロジェクト直下にはサーバー起動用ソース(main.go)を配置し、
他のソースはMVCに基づいてザックリ分けてみました。

ShoppingApp
│  main.go
│  
├─controllers
│  └─controller
│         productController.go
│
├─models
│  ├─db
│  │      productDb.go
│  │
│  └─entity
│         Product.go
│
└─views
    ├─css
    │     product.css
    │
    ├─js
    │     vueProduct.js
    │
    └─static
          index.html

フロントエンド開発

トップページ

index.html
<html>
    <head>
        <meta charset="utf-8">
        <title>お買い物リスト</title>
        <!-- スタイルシート -->
        <link rel="stylesheet" type="text/css" href="/views/css/product.css">

        <!-- Vue.js -->
        <script src="https://cdn.jsdelivr.net/npm/vue@3.2.45/dist/vue.global.min.js" defer></script>

        <!-- axios:HTTP通信を超簡単に行うことができるJavaScriptライブラリ -->
        <script src="https://cdn.jsdelivr.net/npm/axios@1.1.2/dist/axios.min.js" defer></script>

        <!-- Vueインスタンス -->
        <script src="/views/js/vueProduct.js?ver=3" defer></script>
    </head>
    <body>
        <div id="app">
            <h1>お買い物リスト</h1>

            <!-- 検索条件 -->
            <label v-for="label in options">
                 <input type="radio" v-model="current" v-bind:value="label.value">{{ label.label }}
            </label>
            <p>品名:<input type="text" name='productName' v-model="productName" v-bind:class="{'alert-color': !validate }" value='' size="40" placeholder="品名を入力してください※必須"></p>
            <p>メモ:<input type="text" name='productMemo' v-model="productMemo" value='' size="40"></p>
            
            <!-- 追加ボタン -->
            <button v-on:click="doAddProduct" v-bind:disabled="!isEntered">
                追加
            </button>
            <hr>
            <table>
                <!-- テーブルヘッダー -->
                <thead v-pre>
                    <tr>
                        <th class="index">No</th>
                        <th class="name">商品名</th>
                        <th class="memo">メモ</th>
                        <th class="state">状態</th>
                        <th class="delete">削除</th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="(item, index) in computedProducts">
                        <td class="index">{{ index + 1 }}</td>
                        <td class="name">{{ item.name }}</td>
                        <td class="memo">{{ item.memo }}</td>
                        <td class="state">
                            <!-- 状態変更ボタン -->
                            <button v-on:click="doChangeProductState(item)">
                                {{ labels[item.state] }}
                            </button>
                        </td>
                        <td class="delete">
                            <!-- 削除ボタン -->
                            <button v-on:click="doDeleteProduct(item)">
                                削除
                            </button>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
    </body>
</html>

スタイルシート

product.css
.alert-color {
    border-color: #ff0000;
}
table {
    border-collapse: collapse;
}
thead, tbody {
    display: block;
}
tbody {
    height: 250px;
    width: 592px;
    overflow-x: hidden;
    overflow-y: scroll;
}
th,td {
    background-color: #ffffff;
    border: 1px solid #ffcc66;
    padding: 0 8px;
    line-height: 40px;
}
thead th {
    background-color: #fff5e5;
    border: 1px solid #ffcc66;
    color: #ff9900;
}
th.index, td.index {
    width: 50px;
}
th.name, td.name {
    width: 100px;
}
th.memo, td.memo {
    width: 200px;
}
th.state, td.state {
    width: 70px;
    text-align: center;
}
th.delete, td.delete {
    width: 70px;
    text-align: center;
}
tbody tr:hover td {
    background: #ccffff;
}

Vue.js

vueProduct.js
Vue.createApp({
    // data オブジェクトのプロパティの値を変更すると、ビューが反応し、新しい値に一致するように更新
    data() {
        return {
            // 商品情報
            products: [],
            // 品名
            productName: '',
            // メモ
            productMemo: '',
            // 商品情報の状態
            current: -1,
            // 商品情報の状態一覧
            options: [
                { value: -1, label: 'すべて' },
                { value:  0, label: '未購入' },
                { value:  1, label: '購入済' }
            ],
            // true:入力済・false:未入力
            isEntered: false
        }
    },
    // 算出プロパティ
    computed: {
        // 商品情報の状態一覧を表示する
        labels() {
            return this.options.reduce(function (a, b) {
                return Object.assign(a, { [b.value]: b.label })
            }, {})
        },
        // 表示対象の商品情報を返却する
        computedProducts() {
          return this.products.filter(function (el) {
            var option = this.current < 0 ? true : this.current === el.state
            return option
          }, this)
        },
        // 入力チェック
        validate() {
            var isEnteredProductName = 0 < this.productName.length
            this.isEntered = isEnteredProductName
            return isEnteredProductName
        }
    },
    // インスタンス作成時の処理
    created: function() {
        this.doFetchAllProducts()
    },
    // メソッド定義
    methods: {
        // 全ての商品情報を取得する
        doFetchAllProducts() {
            axios.get('/fetchAllProducts')
            .then(response => {
                if (response.status != 200) {
                    throw new Error('レスポンスエラー')
                } else {
                    var resultProducts = response.data

                    // サーバから取得した商品情報をdataに設定する
                    this.products = resultProducts
                }
            })
        },
        // 1つの商品情報を取得する
        doFetchProduct(product) {
            axios.get('/fetchProduct', {
                params: {
                    productID: product.id
                }
            })
            .then(response => {
                if (response.status != 200) {
                    throw new Error('レスポンスエラー')
                } else {
                    var resultProduct = response.data

                    // 選択された商品情報のインデックスを取得する
                    var index = this.products.indexOf(product)

                    // spliceを使うとdataプロパティの配列の要素をリアクティブに変更できる
                    this.products.splice(index, 1, resultProduct[0])
                }
            })
        },
        // 商品情報を登録する
        doAddProduct() {
            // サーバへ送信するパラメータ
            const params = new URLSearchParams();
            params.append('productName', this.productName)
            params.append('productMemo', this.productMemo)

            axios.post('/addProduct', params)
            .then(response => {
                if (response.status != 200) {
                    throw new Error('レスポンスエラー')
                } else {
                    // 商品情報を取得する
                    this.doFetchAllProducts()

                    // 入力値を初期化する
                    this.initInputValue()
                }
            })
        },
        // 商品情報の状態を変更する
        doChangeProductState(product) {
            // サーバへ送信するパラメータ
            const params = new URLSearchParams();
            params.append('productID', product.id)
            params.append('productState', product.state)

            axios.post('/changeStateProduct', params)
            .then(response => {
                if (response.status != 200) {
                    throw new Error('レスポンスエラー')
                } else {
                    // 商品情報を取得する
                    this.doFetchProduct(product)
                }
            })
        },
        // 商品情報を削除する
        doDeleteProduct(product) {
            // サーバへ送信するパラメータ
            const params = new URLSearchParams();
            params.append('productID', product.id)

            axios.post('/deleteProduct', params)
            .then(response => {
                if (response.status != 200) {
                    throw new Error('レスポンスエラー')
                } else {
                    // 商品情報を取得する
                    this.doFetchAllProducts()
                }
            })
        },
        // 入力値を初期化する
        initInputValue() {
            this.current = -1
            this.productName = ''
            this.productMemo = ''
        }
    }
}).mount('#app')

バックエンド開発

サーバー

main.go
package main

import (
	// ロギングを行うパッケージ
	"log"

	// HTTPを扱うパッケージ
	"net/http"

	// Gin
	"github.com/gin-gonic/gin"

	// MySQL用ドライバ
	_ "github.com/jinzhu/gorm/dialects/mysql"

	// コントローラー
	controller "shoppingapp/controllers/controller"
)

func main() {
	// サーバーを起動する
	serve()
}

func serve() {
	// デフォルトのミドルウェアでginのルーターを作成
	// Logger と アプリケーションクラッシュをキャッチするRecoveryミドルウェア を保有しています
	router := gin.Default()

	// 静的ファイルのパスを指定
	router.Static("/views", "./views")

	// ルーターの設定
	// URLへのアクセスに対して静的ページを返す
	router.StaticFS("/shoppingapp", http.Dir("./views/static"))

	// 全ての商品情報のJSONを返す
	router.GET("/fetchAllProducts", controller.FetchAllProducts)

	// 1つの商品情報の状態のJSONを返す
	router.GET("/fetchProduct", controller.FindProduct)

	// 商品情報をDBへ登録する
	router.POST("/addProduct", controller.AddProduct)

	// 商品情報の状態を変更する
	router.POST("/changeStateProduct", controller.ChangeStateProduct)

	// 商品情報を削除する
	router.POST("/deleteProduct", controller.DeleteProduct)

	if err := router.Run(":8080"); err != nil {
		log.Fatal("Server Run Failed.: ", err)
	}
}

コントローラー

productController.go
package controller

import (
	// 文字列と基本データ型の変換パッケージ
	strconv "strconv"

	// Gin
	"github.com/gin-gonic/gin"

	// エンティティ(データベースのテーブルの行に対応)
	entity "shoppingapp/models/entity"

	// DBアクセス用モジュール
	db "shoppingapp/models/db"
)

// FetchAllProducts は 全ての商品情報を取得する
func FetchAllProducts(c *gin.Context) {
	resultProducts := db.FindAllProducts()

	// URLへのアクセスに対してJSONを返す
	c.JSON(200, resultProducts)
}

// FindProduct は 指定したIDの商品情報を取得する
func FindProduct(c *gin.Context) {
	productIDStr := c.Query("productID")

	productID, _ := strconv.Atoi(productIDStr)

	resultProduct := db.FindProduct(productID)

	// URLへのアクセスに対してJSONを返す
	c.JSON(200, resultProduct)
}

// AddProduct は 商品をDBへ登録する
func AddProduct(c *gin.Context) {
	productName := c.PostForm("productName")
	productMemo := c.PostForm("productMemo")

	var product = entity.Product{
		Name:  productName,
		Memo:  productMemo,
		State: entity.NotPurchased,
	}

	db.InsertProduct(&product)
}

// ChangeStateProduct は 商品情報の状態を変更する
func ChangeStateProduct(c *gin.Context) {
	reqProductID := c.PostForm("productID")
	reqProductState := c.PostForm("productState")

	productID, _ := strconv.Atoi(reqProductID)
	productState, _ := strconv.Atoi(reqProductState)
	changeState := entity.ChangeState(productState)

	db.UpdateStateProduct(productID, changeState)
}

// DeleteProduct は 商品情報をDBから削除する
func DeleteProduct(c *gin.Context) {
	productIDStr := c.PostForm("productID")

	productID, _ := strconv.Atoi(productIDStr)

	db.DeleteProduct(productID)
}

モデル

あらかじめMySQLでローカルDBを作成してください。
そしてソース内のDB接続情報をローカルDB情報に合わせてください。

productDb.go
package db

import (
	// フォーマットI/O
	"fmt"

	// Go言語のORM
	"github.com/jinzhu/gorm"

	// エンティティ(データベースのテーブルの行に対応)
	entity "shoppingapp/models/entity"
)

// DB接続する
func open() *gorm.DB {
	DBMS := "mysql"
	USER := "root"
	PASS := "root"
	PROTOCOL := "tcp(localhost:3306)"
	DBNAME := "Shopping"
	CONNECT := USER + ":" + PASS + "@" + PROTOCOL + "/" + DBNAME
	db, err := gorm.Open(DBMS, CONNECT)

	if err != nil {
		panic(err.Error())
	}

	// DBエンジンを「InnoDB」に設定
	db.Set("gorm:table_options", "ENGINE=InnoDB")

	// 詳細なログを表示
	db.LogMode(true)

	// 登録するテーブル名を単数形にする(デフォルトは複数形)
	db.SingularTable(true)

	// マイグレーション(テーブルが無い時は自動生成)
	db.AutoMigrate(&entity.Product{})

	fmt.Println("db connected: ", &db)
	return db
}

// FindAllProducts は 商品テーブルのレコードを全件取得する
func FindAllProducts() []entity.Product {
	products := []entity.Product{}

	db := open()
	// select
	db.Order("ID asc").Find(&products)

	// defer 関数がreturnする時に実行される
	defer db.Close()

	return products
}

// FindProduct は 商品テーブルのレコードを1件取得する
func FindProduct(productID int) []entity.Product {
	product := []entity.Product{}

	db := open()
	// select
	db.First(&product, productID)
	defer db.Close()

	return product
}

// InsertProduct は 商品テーブルにレコードを追加する
func InsertProduct(registerProduct *entity.Product) {
	db := open()
	// insert
	db.Create(&registerProduct)
	defer db.Close()
}

// UpdateStateProduct は 商品テーブルの指定したレコードの状態を変更する
func UpdateStateProduct(productID int, productState int) {
	product := []entity.Product{}

	db := open()
	// update
	db.Model(&product).Where("ID = ?", productID).Update("State", productState)
	defer db.Close()
}

// DeleteProduct は 商品テーブルの指定したレコードを削除する
func DeleteProduct(productID int) {
	product := []entity.Product{}

	db := open()
	// delete
	db.Delete(&product, productID)
	defer db.Close()
}

エンティティ

Product.go
package entity

// Product はテーブルのモデル
type Product struct {
	ID    int    `gorm:"primary_key;not null"       json:"id"`
	Name  string `gorm:"type:varchar(200);not null" json:"name"`
	Memo  string `gorm:"type:varchar(400)"          json:"memo"`
	State int    `gorm:"not null"                   json:"state"`
}

// 商品の購入状態を定義
const (
	NotPurchased = 0 // 未購入
	Purchased    = 1 // 購入済
)

func ChangeState(currentState int) int {
	changeState := NotPurchased

	if currentState == NotPurchased {
		changeState = Purchased
	}

	return changeState
}

動作確認

VSCodeでサーバーを起動して以下のURLにアクセスし、動作確認をしてみます。
http://127.0.0.1:8080/shoppingapp/

ひと通り操作してみた

shoppingList.gif

参考にしたサイト

React vs Vue vs Angular
【2019年度版】Go言語のおすすめフレームワーク 5選
Ginの日本語ドキュメント
GORM ガイド
vue.jsを使ってaxiosを学ぶ

さいごに

私はGoやVue.jsの業務経験はありませんが、ググりながら超簡単なSPAを開発することができました。

サーバサイド開発は、業務アプリでよくあるJava/Spring/Doma2などの組み合わせよりも遥かに容易だと感じました。
環境構築においては、FWの導入はgo getコマンド+ソース内でインポートのみで済み、
ローカルサーバー(Tomcatサーバーなど)のインストールが不要で、
ソースにおいては、コード量が比較的少なくて済み、SQLの記述については不要だったからです。

超簡単なSPAを開発と言いつつも、開発完了までに多くの学びがありました。

174
156
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
174
156

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?