Go の Gin と GORM で最低限の掲示板を作るチュートリアル

ゴールデンウィークなので、Go言語の Gin と GORM を使って、最低限の機能を持った掲示板を作ってみました。


# 環境
下記は `go.mod` の中身です。利用しているパッケージとそのバージョンを確認できると思います。
module 名は bbs にしています。

```
module bbs

go 1.20

require (
	github.com/gin-gonic/gin v1.9.0
	gorm.io/driver/mysql v1.5.0
	gorm.io/gorm v1.25.0
)

require (
...（省略）...
)
```

その他、下記を利用しています。
- Docker（golang:1.20）
- MySQL 8.0
- [Air](https://github.com/cosmtrek/air)


# テーブル構成
掲示板の記事を保存するテーブルとして、下記のような articles テーブルを作成します。GORMでもマイグレーションできると思いますが、今回はCREATE TABLE文を作り、それを実行して、テーブルを作成しています。

| 物理名     | 論理名   | 型           | NOT NULL | DFAULT | 備考               | 
| ---------- | -------- | ------------ | -------- | ------ | ------------------ | 
| id         | ID       | unsigned int | ○       |        | PK, AUTO INCREMENT | 
| name       | 名前     | text         |          | NULL   |                    | 
| body       | 本文     | text         | ○       |        |                    | 
| created_at | 作成日時 | datetime     |          | NULL   |                    | 
| updated_at | 更新日時 | datetime     |          | NULL   |                    | 
| deleted_at | 削除日時 | datetime     |          | NULL   |                    | 


# ディレクトリ構成
任意のディレクトリに下記のようなディレクトリとファイルを作成しました。

```
root@df32d644590b:/go/src# tree
.
|-- go.mod
|-- go.sum
|-- handler            // ルーティングで呼ばれる関数を記述するファイル を置くディレクトリ
|   `-- article.go     // article に関する処理を書く
|-- repository         // DBにアクセスする処理を書くファイル を置くディレクトリ
|   |-- article.go     // articles テーブルに関する処理を書く
|   `-- repository.go  // repository 配下の全ファイルで利用する処理を書く
|-- route.go           // ルーティングを書く
`-- templates          // テンプレートファイルを置くディレクトリ
    `-- article.html   // 掲示板を表示するテンプレート
```


# ソースコード
## ルーティング
では、各ファイルの中身を見ていきます。まずは `route.go` です

```route.go
package main

import (
	"bbs/handler"
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	// テンプレートの設定
	r.LoadHTMLGlob("templates/*")

	// ルーティング設定
	r.GET("/articles", handler.GetArticles)
	r.POST("/articles", handler.CreateArticle)

	r.Run()
}
```

こちらは、Ginのドキュメント [HTML をレンダリングする](https://gin-gonic.com/ja/docs/examples/html-rendering/) の通りで、特にわかりにくいところも無いと思います。ルーティングで呼び出す関数は、`handler/article.go` に記述する関数 `GetArticles()`, `CreateArticle()` を指定しています。

## リポジトリ
続いて、`repository` ディレクトリ配下の、DBにアクセスする処理を書くファイルを見ていきます。
まずは `repository/repository.go`　です。

```repository/repository.go
package repository

import (
	"fmt"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

// DB接続設定 本当は環境変数から読み込むのが望ましい
const (
	user     = "root"
	password = "password"
	host     = "mysql"
	port     = "3306"
	dbname   = "sample"
)

// CreateDB DB に接続して gorm.DB を返す
func CreateDB() (*gorm.DB, error) {
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", user, password, host, port, dbname)
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	return db, err
}
```

ここでは、DBに接続してコネクション（という表現でいいのかな？）を返す処理 `CreateDB()` 関数を定義しています。こちらもGORMのドキュメント [データベースに接続する](https://gorm.io/ja_JP/docs/connecting_to_the_database.html#MySQL) とほぼ同じ記述なので、難しいところは無いと思います。どのテーブル（今回は articles テーブルしか無いですが）の処理を行うにしても必要な処理なので、この関数だけを切り出して `repository/repository.go` に記述しました。


続いて `repository/article.go` です。

```repository/article.go
package repository

import (
	"gorm.io/gorm"
	"time"
)

// Article 記事用の struct
// gorm.Model 構造体を使えば ID, CreatedAt, UpdatedAt, DeletedAt の記述は省略できる
// nullable なカラムについてはポインタ型を指定
type Article struct {
	ID        int
	Name      *string
	Body      string
	CreatedAt *time.Time
	UpdatedAt *time.Time
	DeletedAt *time.Time
}

// CreateArticle DBに記事を保存する
func CreateArticle(name string, body string) (*gorm.DB, error) {
	// gorm.DB を取得
	db, err := CreateDB()
	if err != nil {
		return nil, err
	}

	// DBへ投入するデータを作成
	article := Article{
		Name: &name,
		Body: body,
	}
	
	// 実行
	result := db.Create(&article)

	return result, nil
}

// GetArticles DBから記事を取得する
func GetArticles() ([]Article, *gorm.DB, error) {
	// gorm.DB を取得
	db, err := CreateDB()
	if err != nil {
		return nil, nil, err
	}

	var articles []Article
	// 実行 最新の投稿から表示したいので id の降順で並べ替えておく
	result := db.Order("id desc").Find(&articles)

	return articles, result, nil
}
```

articles テーブルに対応する構造体 `Article` と、articles テーブルに記事を保存する関数 `CreateArticle()`、articles テーブルから記事を取得する関数 `GetArticles()` を定義しています。処理が正常に終了したときはその結果を、エラーが発生したときはエラーを返すようにしています。
各処理の前にコメントも付けておきましたので参考にしてください。GORMの使い方については、ドキュメント [レコードの作成](https://gorm.io/ja_JP/docs/create.html#%E3%83%AC%E3%82%B3%E3%83%BC%E3%83%89%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B)、[レコードの取得](https://gorm.io/ja_JP/docs/query.html#%E3%81%99%E3%81%B9%E3%81%A6%E3%81%AE%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E5%8F%96%E5%BE%97%E3%81%99%E3%82%8B) も参考にしてください。

## ハンドラー
次に `handler/article.go` を見ていきます。

```handler/article.go
package handler

import (
	"bbs/repository"
	"github.com/gin-gonic/gin"
	"net/http"
	"strconv"
)

// ArticleRequest 記事投稿リクエストを受ける struct
// バリデーションルールを binding に記述する
type ArticleRequest struct {
	Name string `form:"name" binding:"required"`
	Body string `form:"body" binding:"required"`
}

// CreateArticle 掲示板の記事を作成する
func CreateArticle(c *gin.Context) {
	// バリデーション＆バインド
	var req ArticleRequest
	if err := c.ShouldBind(&req); err != nil {
		// バリデーションエラーならここでリダイレクト
		// クエリパラメータ error=true をつけて、バリデーションエラーかどうかわかるようにしておく
		c.Redirect(http.StatusFound, "/articles?error=true")
		return
	}

	// DBに保存
	_, err := repository.CreateArticle(req.Name, req.Body)
	if err != nil {
		panic(err)
	}

	// リダイレクト
    // リロードにより記事を重複して保存しないようリダイレクトさせる
	c.Redirect(http.StatusFound, "/articles")
}

// GetArticles 掲示板の記事を取得し画面表示
func GetArticles(c *gin.Context) {
	// クエリパラメータ取得
	// パラメータ error
	// CreateArticle() からのリダイレクト時バリデーションエラーが起きたかどうか
	// 文字列なので、bool型にキャストする
	hasValidateErrorStr := c.DefaultQuery("error", "false")
	hasValidateError, err := strconv.ParseBool(hasValidateErrorStr)
	if err != nil {
		panic(err)
	}

	// DBから記事取得
	articles, _, err := repository.GetArticles()
	if err != nil {
		panic(err)
	}

	// 画面表示
	c.HTML(http.StatusOK, "article.html", gin.H{
		"articles": articles,
		"error":    hasValidateError,
	})
}
```

リクエストパラメータを受け取る `ArticleRequest` 構造体、掲示板記事の作成をする `CreateArticle()` 関数、掲示板の記事を取得してそれを表示する `GetArticles()` 関数を定義しています。関数はルーティングにより呼ばれるものです。処理中に何らかのエラーが発生したときは、このハンドラー関数内で `panic()` を利用するようにしました。

各処理がどういう処理なのかについては、ソース内にコメントを付けました。また、なぜこのような書き方になるのかは、下記 gin のドキュメントが参考になるので、合わせて参照してください。
- [HTML をレンダリングする](https://gin-gonic.com/ja/docs/examples/html-rendering/)
- [モデルへのバインディングとバリデーションする](https://gin-gonic.com/ja/docs/examples/binding-and-validation/)
- [クエリ文字列のパラメータ](https://gin-gonic.com/ja/docs/examples/querystring-param/)
- [リダイレクト](https://gin-gonic.com/ja/docs/examples/redirects/)

## テンプレート
最後に `template/article.html` です。

```template/article.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>掲示板</title>
</head>
<body>

<div class="container">
    <h1>掲示板</h1>

    <!-- エラーがあったら表示 -->
    {{ if .error }}
    <span class="error">投稿エラーです。入力内容を確認してください。</span>
    <br><br>
    {{ end }}

    <!-- 投稿フォーム -->
    <form method="POST" action="/articles">
        <div class="flex-box">
            <label class="form-label" for="input-name">名前</label>
            <input type="text" name="name" id="input-name">
        </div>
        <div class="flex-box">
            <label class="form-label" for="input-body">本文</label>
            <textarea name="body" id="input-body" cols="60" rows="5"></textarea>
        </div>
        <input type="submit" class="post-article-button" value="投稿する">
    </form>

    <hr class="delimiter-line">

    <!-- 取得した記事の数だけループ -->
    {{ range .articles }}
    <div class="flex-box">
        <span class="article-id">#{{ .ID }}</span>
        <span class="article-datetime">{{ .CreatedAt }}</span>
        <span class="article-name">名前: {{ .Name }}</span>
    </div>
    <div class="article-body">{{ .Body }}</div>
    <hr class="delimiter-line">
    {{ end }}
</div>

</body>
</html>
```

記事投稿フォームと、記事一覧を同じページで表示しています。記事投稿後に遷移するのも同じこの画面です。 `{{ .error }}` や `{{ .articles }}` などで、ハンドラーの関数から渡されたデータを利用しています。テンプレートの書き方については、[こちらの記事](https://golang.hateblo.jp/entry/golang-text-html-template)などが参考になります。

試しに記事をいくつか投稿してみた画面は下記のようになります。
![min-bbs.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252417/6e403519-3536-8a9c-f38e-f7f42ea08624.png)


# 最後に
これで掲示板としての機能は最低限実装されていると思いますが、このままだと下記のような問題点があります。
- **CSRF脆弱性対策がされていない（ので、このまま本番環境に公開してはいけません！）**
- 記事投稿時に改行を入れても、表示時に改行されていない
- 日時表示がなんだか難しい表示になっている
- ページングが無いので、記事数が増えるにつれ、処理が重くなっていく
- なんか見た目がださい

[次回の記事](https://qiita.com/yumgoo17/items/6016df5d9972185ca713)で、これらの問題点を解消していきます。


# 参考
- [Ginドキュメント](https://gin-gonic.com/ja/docs/)
- [GORMドキュメント](https://gorm.io/ja_JP/docs/)
- [Go言語(golang) テンプレートの使い方 - golangの日記](https://golang.hateblo.jp/entry/golang-text-html-template)