2019-12-09

Ginを使って簡単なアプリを作ってみたよ

表題通りGinを使って簡単なサンプルアプリを作ってみたので必要最低限の使い方をまとめてみます。 https://github.com/gin-gonic/gin

準備

info
  • Goのコードは全て一つのmainファイルにまとめることもできたんですが、記事の中でいくつかに分けて埋め込みたかったので分割しました。
  • 過剰に分割されているかもしれませんが、スルーでお願いします。

今回の環境は以下の docker-compose.ymlで用意します。

docker-compose.yml
version: '3.7'
services:
  gintest:
    image: golang:1.13-stretch
    container_name: gintest
    tty: true
    volumes:
      - .:/root/
    working_dir: /root/
    ports:
      - "8080:8080"

  redis:
    image: redis:latest
    container_name: gin_redis
    tty: true
    expose:
      - "6379"
networks:
  gin_network:


以降の操作はすべて gintest コンテナ内で行う想定です。

プロンプトは慣例的に \$ に置き換えていますが、コンテナ内の操作ユーザは root になるので適宜読み替えてください。

パッケージをインストールします。

$ go get -u github.com/gin-gonic/gin $ go get -u github.com/gin-contrib/sessions

今回はセッションも扱いたいので gin-gonic/contrib も入れています。

ginでセッション管理するパッケージは以下の2種類があるようです。

https://github.com/gin-gonic/contrib https://github.com/gin-contrib/sessions

前者はあんまりメンテされないみたいなので今回は後者の gin-contrib/sessions を使います。

最終的に go.mod はこんな感じになりました

go.mod
module gintest

go 1.13

require (
	github.com/gin-contrib/sessions v0.0.1
	github.com/gin-gonic/gin v1.5.0
	github.com/go-playground/universal-translator v0.17.0 // indirect
	github.com/gorilla/sessions v1.2.0 // indirect
	github.com/json-iterator/go v1.1.8 // indirect
	github.com/leodido/go-urn v1.2.0 // indirect
	github.com/mattn/go-isatty v0.0.10 // indirect
	golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e // indirect
	gopkg.in/go-playground/validator.v9 v9.30.2 // indirect
	gopkg.in/yaml.v2 v2.2.7 // indirect
)

以降、この(go.modが置かれている)ディレクトリは gintest というパッケージとして扱います。

Usage

使い方はシンプルです。

gin.Default()gin.New() で作った *Engine に対して、以下のメソッドを使って パスハンドラ関数(ビュー) の紐付けを行って Run() メソッドでサーバを起動するように記述するだけです。

本来これらは RouterGroup のメソッドですが、Engine に埋め込まれているため、Engineからも呼び出すことができます。

少し(後述する)無駄な設定が混ざってますが、だいたいこんな感じです

main.go
package main

import (
	"gintest/debug"
	"gintest/users"

	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/redis"
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	store, _ := redis.NewStore(10, "tcp", "redis:6379", "", []byte("secret"))
	//store := cookie.NewStore([]byte("secret"))
	r.Use(sessions.Sessions("session", store))
	r.LoadHTMLGlob("templates/*.tmpl")
	r.GET("/", users.Index)
	r.GET("/debug", debug.ParamDebug)
	r.POST("/debug", debug.ParamDebug)
	r.GET("/login", users.LoginForm)
	r.POST("/login", users.Login)
	r.GET("/logout", users.Logout)

	a := r.Group("/")
	a.Use(users.AuthRequired)
	{
		a.GET("/mypage", users.Mypage)
	}
	r.Run("0.0.0.0:8080")
}

あとは、 main.go を実行すれば..

$ go run main.go [GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. - using env: export GIN_MODE=release - using code: gin.SetMode(gin.ReleaseMode) [GIN-debug] Loaded HTML Templates (7): - - debug.tmpl - header.tmpl - header - index.tmpl - login.tmpl - mypage.tmpl [GIN-debug] GET / --> gintest/users.Index (4 handlers) [GIN-debug] GET /debug --> gintest/debug.ParamDebug (4 handlers) [GIN-debug] POST /debug --> gintest/debug.ParamDebug (4 handlers) [GIN-debug] GET /login --> gintest/users.LoginForm (4 handlers) [GIN-debug] POST /login --> gintest/users.Login (4 handlers) [GIN-debug] GET /logout --> gintest/users.Logout (4 handlers) [GIN-debug] GET /mypage --> gintest/users.Mypage (5 handlers) [GIN-debug] Listening and serving HTTP on 0.0.0.0:8080

サーバが起動しました。あとは 起動したホストにアクセスするだけです。

ここからは各機能について掘り下げていきます。

Handler function

ハンドラ関数は Context のポインタを引数にとり、 HTMLJSON メソッドを使ってレスポンスを返却します。

以下はパラメータを表示する関数です。

params.go
package debug

import (
	"encoding/json"
	"net/http"

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

func ParamDebug(c *gin.Context) {
	q := c.Query("q")
	dq := c.DefaultQuery("dq", "default value")
	qa := c.QueryArray("qa")
	p := c.PostForm("p")
	dp := c.DefaultPostForm("dp", "default value")
	pa := c.PostFormArray("pa")

	j, _ := json.Marshal(map[string]interface{}{
		"q":  q,
		"dq": dq,
		"qa": qa,
		"p":  p,
		"dp": dp,
		"pa": pa,
	})
	c.HTML(http.StatusOK, "debug.tmpl", gin.H{
		"json": string(j),
	})
}

メソッドの第一引数には HTTPステータスコード を指定します。

Context はパラメータを解析するためのメソッドを持ちます。

Query
  • クエリストリングから指定された特定のパラメータ値を文字列で取得する
  • 同じパラメータがあった場合は戦闘のパラメータが返却される
  • パラメータがなかった場合、空文字が返却される
DefaultQuery
  • Queryと概ね同じ挙動
  • パラメータがなかった場合、第2引数に指定した文字列が返却される
  • パラメータに空文字が指定された場合、空文字が返却される
QueryArray
  • クエリストリングから指定されたパラメータ値をすべて文字列のスライスで取得する。
  • パラメータがなかった場合、空のスライスが返却される
PostForm
  • リクエストボディから指定されたパラメータ値を文字列で取得する。
DefaultPostForm
  • PostForm と概ね同じ挙動
  • パラメータがなかった場合、第2引数に指定した文字列が返却される
  • パラメータに空文字が明示された場合、空文字が返却される
PostFormArray
  • リクエストボディから指定されたパラメータ値をすべて文字列のスライスで取得する。
  • パラメータがなかった場合、空のスライスが返却される

これらのメソッドを使ってパラメータをJSONで表示すると以下のような感じになります。

params.png

Template

Context.HTML で描画する テンプレートは特定のディレクトリに配置し r.LoadHTMLGlob("templates/*.tmpl") のように登録します。

先程の画面は以下のテンプレートを描画しています。

debug.tmpl
<html>
	<a href="/">TOPにもどる</a>
	<pre style="font-size: 20px;">
	{{ .json }}
	</pre>

	<fieldset><legend>GET method で送信する</legend>
		<form method="get">
		<table>
		<tbody>
			<tr><th>Query(q):</th><td><input name="q" value="a" /></td></tr>
			<tr><th>Query(q):</th><td><input name="q" value="b" /></td></tr>
			<tr><th>DefaultQuery(dq):</th><td><input name="dq" value="c" /></td></tr>
			<tr><th>DefaultQuery(dq):</th><td><input name="dq" value="d" /></td></tr>
			<tr><th>QueryArray(qa):</th><td><input name="qa" value="e" /></td></tr>
			<tr><th>QueryArray(qa):</th><td><input name="qa" value="f" /></td></tr>
		</tbody>
		</table>
		<button type="submit">送信</button>
		</form>
	</fieldset>

	<fieldset><legend>POST method で送信する</legend>
		<form method="post">
		<table>
		<tbody>
			<tr><th>PostForm(p):</th><td><input name="p" value="g" /></td></tr>
			<tr><th>PostForm(p):</th><td><input name="p" value="h" /></td></tr>
			<tr><th>DefaultPostForm(dp):</th><td><input name="dp" value="i" /></td></tr>
			<tr><th>DefaultPostForm(dp):</th><td><input name="dp" value="j" /></td></tr>
			<tr><th>PostFormArray(pa):</th><td><input name="pa" value="k" /></td></tr>
			<tr><th>PostFormArray(pa):</th><td><input name="pa" value="l" /></td></tr>
		</tbody>
		</table>
		<button type="submit">送信</button>
		</form>
	</fieldset>

</html>

header.tmpl
{{ define "header" }}
<div style="width: 100%; padding: 10px; box-sizing: border-box; background-color: #bbb;">
	{{ if .user.LoggedIn }}
	{{ .user.Greeting "Hello" }} <a href="/logout">ログアウトする</a>
	{{ else }}
	<a href="/login">ログインする</a>
	{{ end }}
</div>
{{ end }}

テンプレートの可変部分は {{}} (波括弧2つ)で囲みます。 数値はそのまま評価され、 . で始まる場合はコンテキストとして評価されます。

info
  • テンプレートのコンテキストは gin.H というマップを使って渡します。
    c.HTML(http.StatusOK, "debug.tmpl", gin.H{ "json": string(j), })

それ以外は関数呼び出しになります。 関数はコンテキストとして渡すことはできず SetFuncMap でを登録する必要があります。(今回はやりません)

今回使っている関数は予め登録されている definetemplate です。 {{ define テンプレート名 }} で命名し、 {{ template テンプレート名 }} で取り込みます。

お気付きの通り、関数やメソッドの実行は丸括弧で囲まず、スペースで引数を区切ります。

これらのテンプレート記法は Gin オリジナルというわけではなく、標準パッケージの template に従うので詳しく知りたい場合はそちらを参照してください。

Session

操作中のユーザを特定し、情報を持たせるためにセッション管理をします。

今回は以下のような User 構造体を定義します

user.go
package users

import "fmt"

type User struct {
	ID       int    `json: id`
	Password string `json: password`
	Name     string `json: name`
	Memo     string `json: memo`
}

func (u User) LoggedIn() bool {
	return u.ID != 0
}

func (u User) Greeting(word string) string {
	return fmt.Sprintf("%s, I am %s.", word, u.Name)
}

info
  • この記事でやることをこれ以上増やしたくないので データストアとして RDBMS は使わず、以下のような JSON ファイルをDB代わりに使います。
  • users.json
    [
      {"id": 1, "password": "password1", "name": "user name1", "memo": "first user aaaaaaaaaaaaa"},
      {"id": 2, "password": "password2", "name": "user name2", "memo": "second user"},
      {"id": 3, "password": "password3", "name": "user name3", "memo": "third user"}
    ]
    
    

gin-contrib/session はセッション管理機能を提供してくれます。

以下のセッションストアを利用できます。

  • cookie-based
    • セッションに入れたデータすべてをCookieにいれます。
    • 入れるデータが大きくなればなるほどCookieが肥大化して通信が圧迫されるので、何でもかんでもCookieに入れるのは考えものです。
  • Redis
  • Memcached
  • MongoDB

ここでは Redis をセッションストアとして利用します。

README に従って store を作成し、以下のように与えてあげるだけで利用可能になります。 なお、ここで与えている接続先は記事の先頭に載せた docker-compose で作成しています。

store, _ := redis.NewStore(10, "tcp", "redis:6379", "", []byte("secret")) r.Use(sessions.Sessions("session", store)) // ここに指定した名前のCookieが作られる
info
  • Key "github.com/gin-contrib/sessions" does not exist と言われる場合、セッションミドルウェアを登録し忘れていないか確認してみましょう。 https://github.com/gin-contrib/sessions/issues/40#issuecomment-354376874
    // code from README r := gin.Default() store := sessions.NewCookieStore([]byte("secret")) r.Use(sessions.Sessions("mysession", store)) // your middleware r.Use(utils.AuthMiddleware())

セッションを利用する各関数で session := sessions.Default(c) としてユーザに紐づく session を取得。

sesion.Get(key string):

  • キーに紐づく値を取得

sesion.Set(key string, value []byte):

  • キーに値を値を保存

session.Clear():

  • すべての値を削除

session.Delete(key string):

  • キーに一致する値を削除

この session に対して上記の各種操作を行った後に session.Save() でセッションストアに反映します。

セッションに紐づく「ユーザを取得」さらにそれを使い「未ログインユーザを弾く」ユーティリティ関数は以下のように書けます。

utils.go
package users

import (
	"encoding/json"
	"net/http"

	"github.com/gin-contrib/sessions"
	"github.com/gin-gonic/gin"
)

func GetUser(c *gin.Context) (user User) {
	session := sessions.Default(c)
	user = User{}
	if v := session.Get(userKey); v != nil {
		json.Unmarshal(v.([]byte), &user)
	}
	return
}

func AuthRequired(c *gin.Context) {
	user := GetUser(c)
	if !user.LoggedIn() {
		c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
		return
	}
	c.Next()
}

// マイページから未ログインユーザを弾く例 a.Use(users.AuthRequired) { a.GET("/mypage", users.Mypage) }

最後にセッションを使った基本的な画面を書いてみました。前で定義した GetUser 関数も使っています。

views.go
package users

import (
	"encoding/json"
	"io/ioutil"
	"net/http"
	"strconv"

	"github.com/gin-contrib/sessions"
	"github.com/gin-gonic/gin"
)

const userKey = "user"

func Login(c *gin.Context) {
	session := sessions.Default(c)
	binary, _ := ioutil.ReadFile("./users.json")
	users := make([]User, 0)
	json.Unmarshal(binary, &users)

	for _, u := range users {
		if strconv.Itoa(u.ID) == c.PostForm("id") && u.Password == c.PostForm("password") {
			text, _ := json.Marshal(u)
			session.Set(userKey, text)
			if err := session.Save(); err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"})
				return
			}
			c.Redirect(http.StatusFound, "/mypage")
			return
		}
	}
	c.HTML(http.StatusUnauthorized, "login.tmpl", gin.H{
		"errorMessage": "ログイン失敗",
	})
}

func Logout(c *gin.Context) {
	session := sessions.Default(c)
	session.Delete(userKey)
	if err := session.Save(); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"})
		return
	}
	c.Redirect(http.StatusFound, "/")
}

func Mypage(c *gin.Context) {
	user := GetUser(c)
	c.HTML(http.StatusOK, "mypage.tmpl", gin.H{
		"user": user,
	})
}

func Index(c *gin.Context) {
	user := GetUser(c)
	c.HTML(http.StatusOK, "index.tmpl", gin.H{
		"title": "Sample",
		"user":  user,
	})
}

func LoginForm(c *gin.Context) {
	c.HTML(http.StatusOK, "login.tmpl", gin.H{})
}

ログイン後に Redis を覗いてみると登録できていますね。よかった。

# redis-cli 127.0.0.1:6379> keys * 1) "session_BP3G7FZGYKDPXM7XKUJYO3Q2BN2ZD75ZLWNS4RCNRTQCUCJLS54Q" 127.0.0.1:6379> get session_BP3G7FZGYKDPXM7XKUJYO3Q2BN2ZD75ZLWNS4RCNRTQCUCJLS54Q "\x0e\xff\x81\x04\x01\x02\xff\x82\x00\x01\x10\x01\x10\x00\x00g\xff\x82\x00\x01\x06string\x0c\x06\x00\x04user\a[]uint8\nJ\x00H{\"ID\":2,\"Password\":\"password2\",\"Name\":\"user name2\",\"Memo\":\"second user\"}"

今回のようにデータベースに入っているような恒久的なデータをセッションに入れるときは、更新時にセッションデータも更新することを忘れないようにしましょう。

ひとまずDBアクセスを除き最低限の機能は触れたと思います。

シンプルで好感が持てるフレームワークですね。 全部入りで何でもかんでもやってくれるフレームワークよりも学習コストは低いと思います。

またなにかあったら記事にします。

参考