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

# はじめに
[Go言語で開発したWebアプリをローカルサーバーでデバッグする(VSCode)](https://qiita.com/melty_go/items/eca50ac224ec42b85af1)ことができましたので、
Go/Gin/Vue.js/MySQLでSPA（単一のWebページでアプリケーションを構成）を開発してみます。
![アーキテクチャ.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406684/9bd5ead7-f186-8674-a718-cb8cf9eedabf.jpeg)
**・フロントエンドはイマドキのWebアプリ風にSPAで開発したいため[`Vue.js`](https://jp.vuejs.org/)を使用します。**
JSフレームワークはAngular・React・Vue.jsなどがありますが、各々を比較検討し、
コンポーネント内記述がHTMLとJSとで独立しているのでソースをより綺麗に書けて、
双方向データバインディング（データと描画を同期する仕組み）と、
スモールスタートが可能なVue.jsを選定しました。

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

**・DBはシンプルなWebアプリに向いていて無料の[`MySQL`](https://dev.mysql.com/)を使用します。**
多機能で無料の[`PostgreSQL`](https://www.postgresql.org/)と悩みましたが、
簡単なWebアプリには、シンプルなDBの方がマッチしているのでMySQLにしました。

# 前提
## 必要なソフトウェアをインストールすること
- [Git](https://git-scm.com/downloads)
- [MySQL(8.0.13)](https://downloads.mysql.com/archives/installer/)

## バージョン情報
- 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`

## プロジェクトを初期化します
ターミナルを開いて以下コマンドを実行します
```bash
cd shoppingapp

go mod init shoppingapp
```

## GitHubから必要なライブラリをimport
```bash
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](https://hijiriworld.com/web/mvc-concept/)に基づいてザックリ分けてみました。

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

## フロントエンド開発

### トップページ
```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>
```

### スタイルシート
```css: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
```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
                }
            })
        },
        // １つの商品情報を取得する
        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')
```

## バックエンド開発

### サーバー
```go: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)

	// １つの商品情報の状態の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)
	}
}
```

### コントローラー
```go: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情報に合わせてください。
```go: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 は 商品テーブルのレコードを１件取得する
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()
}
```

### エンティティ
```go: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406684/56a7c49d-5658-a620-1e47-886494cfd635.gif)

# 参考にしたサイト
・[React vs Vue vs Angular](https://media.itkaikei.com/2019/01/31/react-angular-vue/)
・[【2019年度版】Go言語のおすすめフレームワーク 5選](https://rightcode.co.jp/blog/become-engineer/go-flamework)
・[Ginの日本語ドキュメント](https://gin-gonic.com/ja/docs/)
・[GORM ガイド](http://gorm.io/ja_JP/docs/index.html)
・[vue.jsを使ってaxiosを学ぶ](https://reffect.co.jp/vue/vue-axios-learn)

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

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

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