Help us understand the problem. What is going on with this article?

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

はじめに

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にしました。

前提

1.以下リンク先の内容を実施済みであること(すぐ終わります)
  ・VSCodeでGo言語の開発環境を構築する
  ・Go言語で開発したWebアプリをローカルサーバーでデバッグする(VSCode)

2.以下をインストール済みであること(ソッコー終わります)
  ・Git(2.21.0)
  ・MySQL(8.0.13)

開発するSPA

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

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

準備

SPAを開発する前に、GinやDB接続用ドライバをインストールして動作確認を行います。

Gin

Ginをインストール

先に述べた通り、WAFはGinを使います。
以下のコマンドを実行し、Ginをインストールします。

コマンドプロンプト
go get github.com/gin-gonic/gin

コマンド実行後、ローカルに $GOPATH/src/github.com/gin-gonic/gin がインストールされます。

Ginの動作確認

GinをインポートしたGo言語のソースファイルを作成&実行し、ブラウザで動作確認を行います。

サーバを起動するソース作成

以下のソースファイルをローカルに作成します。

main.go
package main

import (
    // WAFのGinをインポート
    "github.com/gin-gonic/gin"
)

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

    // ルーター設定
    // ブラウザで「/」 にアクセスしたら「Hello Gin!」と表示される設定です
    router.GET("/", func(c *gin.Context) {
        c.String(200, "Hello Gin!")
    })
    router.Run(":8080")
}

VSCodeで実行

VSCodeでF5キーを押下して「main.go」を実行します。
hello_gin.jpg

ブラウザで動作確認

ブラウザで http://localhost:8080/ にアクセスしてみます。

hello_gin.jpg

MySQL

ドライバをインストール

以下のコマンドを実行し、GoでMySQLに接続するためのMySQL用ドライバをインストールします。

コマンドプロンプト
go get github.com/go-sql-driver/mysql

コマンド実行後、ローカルに $GOPATH/src/github.com/go-sql-driver/mysql がインストールされます。

SQLを実行するソース作成

以下のソースファイルをローカルに作成します。
ORMはGORM(Go言語用のORMフレームワーク)を使用します。

以下のコマンドを実行し、GORMのパッケージをインストールします。

コマンドプロンプト
go get github.com/jinzhu/gorm

コマンド実行後、ローカルに $GOPATH/src/github.com/jinzhu/gorm がインストールされます。

main.go
package main

import (
    "fmt"

    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/mysql"
)

// DB上のテーブル、カラムと構造体との関連付けが自動的に行われる
type Product struct {
    ID          int    `gorm:"primary_key;not null"`
    ProductName string `gorm:"type:varchar(200);not null"`
    Memo        string `gorm:"type:varchar(400)"`
    Status      string `gorm:"type:char(2);not null"`
}

func getGormConnect() *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(&Product{})

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

// 商品テーブルにレコードを追加
func insertProduct(registerProduct *Product) {
    db := getGormConnect()

    // insert文
    db.Create(&registerProduct)
    defer db.Close()
}

// 商品テーブルのレコードを全件取得
func findAllProduct() []Product {
    db := getGormConnect()
    var products []Product

    // select文
    db.Order("ID asc").Find(&products)
    defer db.Close()
    return products
}

func main() {
    // Productテーブルにデータを運ぶための構造体を初期化
    var product = Product{
        ProductName: "テスト商品",
        Memo:        "テスト商品です",
        Status:      "01",
    }

    // 構造体のポインタを渡す
    insertProduct(&product)

    // Productテーブルのレコードを全件取得する
    resultProducts := findAllProduct()

    // Productテーブルのレコードを全件表示する
    for i := range resultProducts {
        fmt.Printf("index: %d, 商品ID: %d, 商品名: %s, メモ: %s, ステータス: %s\n",
            i, resultProducts[i].ID, resultProducts[i].ProductName, resultProducts[i].Memo, resultProducts[i].Status)
    }
}

VSCodeで実行&動作確認

実行してDBへの登録結果を確認します。
テーブル「Product」に、ソースに書いたデータが登録されました。
go_dbregist.jpg

買い物リスト作成アプリ開発

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

パッケージ構成

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

ShoppingApp
│  server.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@2.5.13/dist/vue.js" defer></script>

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

        <!-- Vueインスタンス -->
        <script src="/views/js/vueProduct.js" 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
new Vue({
    // 「el」プロパティーで、Vueの表示を反映する場所=HTML要素のセレクター(id)を定義
    el: '#app',

    // data オブジェクトのプロパティの値を変更すると、ビューが反応し、新しい値に一致するように更新
    data: {
        // 商品情報
        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 = ''
        }
    }
})

バックエンド開発

サーバー

server.go
package main

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

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

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

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

    // コントローラー
    controller "./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 "../../models/entity"

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

// 商品の購入状態を定義
const (
    // NotPurchased は 未購入
    NotPurchased = 0

    // Purchased は 購入済
    Purchased = 1
)

// 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: 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 := NotPurchased

    // 商品状態が未購入の場合
    if productState == NotPurchased {
        changeState = Purchased
    } else {
        changeState = NotPurchased
    }

    db.UpdateStateProduct(productID, changeState)
}

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

    productID, _ := strconv.Atoi(productIDStr)

    db.DeleteProduct(productID)
}

モデル

productDb.go
package db

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

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

    // エンティティ(データベースのテーブルの行に対応)
    entity "../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"`
}

動作確認

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を開発と言いつつも、開発完了までに多くの学びがありましたので、
次回は便利なWebアプリを開発して公開したいところです。

melty_go
エンジニアです。 外資系SIerに新卒入社し、提案活動、要件定義、設計、開発、アプリ・インフラ保守運用まで一通り経験しました。 その後、5年目くらいのときに新しいスキルが得にくくなってきたことに危機感を感じ、Web系企業へ転職しました。 Java/JavaScript/Cの経験が長めで、現在はGoの勉強中です。
https://power-antenna.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした