はじめに
Go言語で開発したWebアプリをローカルサーバーでデバッグする(VSCode)ことができましたので、
Go/Gin/Vue.js/MySQLでSPA(単一のWebページでアプリケーションを構成)を開発してみます。
・フロントエンドはイマドキの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
フロントエンド開発
トップページ
<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>
スタイルシート
.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
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')
バックエンド開発
サーバー
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)
}
}
コントローラー
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情報に合わせてください。
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(®isterProduct)
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()
}
エンティティ
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/
ひと通り操作してみた
参考にしたサイト
・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を開発と言いつつも、開発完了までに多くの学びがありました。