初めてGoを使用し、文法もよくわからない状態でWebアプリケーションを開発しようとしたとき、他言語と勝手が違ったり、動的な言語(Perl,PHP,Python,Rubyなど)と違って型があったりして「いつもやっていることができない」となった際の事象や解決法を残しておきたいと思いこの記事を書くに至りました。
前提
- 使用したフレームワーク
- Gin
- 動作環境
- Docker20.10.8
- アプリの詳細
- 求人サイト
- 一般的なWebアプリケーションにあるDBからデータを取得して表示する/データを登録するといったことができる
Ginの恩恵を受けた箇所
Goのフレームワークは基本的にフルスタックではないので、なにからなにまでGinのお世話になったわけではなく、通信周り(ルーティング)やセキュリティ周り(セッションなど)が主なGinの出番でした。そのため今回紹介するのはこうした部分がメインとなります。
Ginによって楽に実装できたものの、Ginについての情報が少なく、これらの手法を見つけるのに苦労したため、残しておきたいと思います。
Ginで実装時に困ったこと
テンプレートファイルの扱い
動的な言語のフレームワークにおいては、特定の領域を指定して、そこにあるテンプレートファイルのpathを返すというようにして扱うと思います。
これと似た形で、Ginでテンプレートを適用する際にはLoadHTMLFiles関数またはLoadHTMLGlob関数でファイルpathを指定する必要があります。
前者は可変長引数で文字列を受け取り、後者はpatternという名で引数が設定されていることから正規表現で設定することが読み取れます。(実際にソースを追ってもらうとそのようになっていることがわかると思います。)
こちらの使用例のように
e := gin.Default()
e.LoadHTMLGlob("/templates/*/*.html")
と指定した場合、templatesの直下にindex.htmlがあるとそれを読み込むことはできません。(ちなみに論理和(|)を用いたパターンマッチングはできませんでした)
レガシーな技術に慣れていると、トップページ(index.html)などはtemplatesの直下に置きたくなってしまいます。
Webサーバ(apacheやnginx)とは異なり、ディレクトリ構成を変えても何ら問題は出ないのですが、モダンな技術を描き慣れない自分は当初このような感覚がなく、以下のようにしてカバーしていました。
e := gin.Default()
templates := []string{
"/templates/index.html"
"/templates/member/index.html"
"/templates/joboffers/index.html"
}
e.LoadHTMLFiles(templates...)
...を使用するとスライスを展開できることを活用して、stringスライスを無理やりLoadHTMLFilesに読み込ませる方法です。
しかし、これは当初の未熟な自分が考えた内容であり、テンプレートが増えるたびに配列に文字列を追加しなければならないためおすすめしません。
トップページのindex.htmlなども/templates/top/index.htmlなどに収納し
e.LoadHTMLGlob("/templates/*/*.html")
で全てのhtmlファイルを読み込む方が良いでしょう。
ただし、この方法ではtemplate/somedirの下にさらにディレクトリ構造を作った場合にはファイルを読み込めなくなってしまうので注意が必要。どうしてもディレクトリの階層を深くしたい場合は私がやっていたようなスライス展開の方法になるでしょう。アプリケーションが軽量であれば動作が重くなったりということもないので、そうした場合には使ってみてください。
セッション
GoでSessionを実装したい場合はgorilla/sessionsというパッケージが主流のようですが、GinにはGinのセッションパッケージがあります。
このパッケージも最終的にはgorilla/sessionsに行き着くようなのですが、ルーティング周りなどでGin特有の書き方をしているので素直にgin-contrib/sessionsに頼るのが一番でした。
使い方は以下(自分はセッション管理にRedisを用いることにしたので、Redisの部分から引用します)。
r := gin.Default()
store, _ := redis.NewStore(10, "tcp", "localhost:6379", "", []byte("secret"))
r.Use(sessions.Sessions("mysession", store))
r.GET("/incr", func(c *gin.Context) {
session := sessions.Default(c)
var count int
v := session.Get("count")
if v == nil {
count = 0
} else {
count = v.(int)
count++
}
session.Set("count", count)
session.Save()
c.JSON(200, gin.H{"count": count})
})
r.Run(":8000")
このサンプルコードだと理解できない点が何箇所もあって、自分の話で言うと
- "secret" is 何?
- "mysession" is 何?
- 先にGetしてからSetしたの??
などなど思うところだらけでした。これらで分かったことを書き留めます。
まず、"secret"の文字ですが、セッションにアクセスする際のキーのようなものです。
つまり、文字列はなんでもいいです。
次に、"mysession"の文字ですが、これはクライアント側に保存するクッキーのキー(名前)のようです。
よって、この文字列もなんでもいいです。
ただし、"secret"のほうはある程度長い文字列のほうがセキュリティ的には良いでしょう。
こうして考えると、非常に直感的に、かつ簡単にsessionの機能が利用できるパッケージだなと思います。
これらさえわかってしまえば、あとはGet,Setをする場所を工夫するだけです。
以下にどのように実装したか紹介したいと思います。
store, _ := redis.NewStore(10, "tcp", "localhost:6379", "", []byte("secret"))
r.Use(sessions.Sessions("mysession", store))
Redisへのコネクション確立とcookieデータの作成はmain.goで行っておきます。
package sessions
// ReadMiddleWare sessions.Defaultを呼び出す
func ReadMiddleWare(c *gin.Context) sessions.Session {
session := sessions.Default(c)
return session
}
// Manager セッションマネージャを定義するインターフェース
type SessionManager interface {
Get(*gin.Context) SessionManager
Set(*gin.Context) error
Destroy(*gin.Context) error
}
// Login ログイン情報セッション保存
type Login struct {
ID int
Login bool
}
// Get セッションから値を取得 => 構造体に格納
func (l *Login) Get(c *gin.Context) SessionManager {
session := ReadMiddleWare(c)
memberID := session.Get("ID")
if memberID != "" && memberID != nil {
l.MemberID = memberID.(int)
}
login := session.Get("login")
if login != "" && login != nil {
l.Login = login.(bool)
}
return l
}
// Set 構造体を受け取る => セッションに各値を格納
func (l *Login) Set(c *gin.Context) error {
session := ReadMiddleWare(c)
session.Set("ID", l.MemberID)
session.Set("login", l.Login)
// Setしたセッション情報を保存
if err := session.Save(); err != nil {
return err
}
return nil
}
// Destroy セッションを削除 削除対象のセッションキーは構造体ごとに決まるため関数内で定義する
func (l *Login) Destroy(c *gin.Context) error {
session := ReadMiddleWare(c)
keyList := [...]string{"ID", "login"}
for _, v := range keyList {
session.Delete(v)
}
// セッション情報の変更を保存
if err := session.Save(); err != nil {
return err
}
return nil
}
独自にsessionsパッケージを作成します。
上記の例ではログインセッションを残そうとしています。
- Login structに会員IDとログイン状態を定義し、Session型をinterfaceとして定義
- Session型はGet,Set,Destroyの3つの関数を持つように定義し、先ほどのLogin structに対してこの3つの関数を作る
- ログイン状態を確認したい箇所からGetで呼び出す
- Setは、ログインした際に呼び出しておき、ログイン情報を残す
- Destroyはログアウトした時などに呼び出す
これだけで実装できます。他にセッションを利用したいデータが出てきても、構造体にまとめてGet,Set,Destroyを追加すればいいだけです。
CSRFトークン
CSRFの対策を取るためにGET以外のPOSTリクエストなどに対してはトークンが発行されていないと400番台のエラーを返すようにします。これもまた、Ginにはパッケージが用意されています。
e := gin.Default()
e.Use(csrf.Middleware(csrf.Options{
Secret: common.GenerateString(32),
ErrorFunc: handlerfunc.ErrorCSRF,
}))
このように使うだけです。先程のセッションとは別に発行してもどちらもeに紐づいているため問題なく利用できます。
common.GenerateStringは独自に実装した関数で、ランダム文字列を返します。
package common
import "math/rand"
func GenerateString(length int) string {
b := make([]byte, length)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
あとはPOSTリクエストを行うあたりの箇所で
packege sample
import (
"net/http"
"github.com/gin-gonic/gin",
csrf "github.com/utrack/gin-csrf"
)
e := gin.Default()
e.GET("/get", func(c *gin.Context){
token := csrf.GetToken(c)
c.HTML(http.StatusOK, "index.html", gin.H{"token": token})
})
e.POST("/post", func(c *gin.Context){
// POST処理
})
このようにPOSTする前にGETで表示したページに対してトークンを返しておき、hiddenパラメータなどで持っておくことで安全にPOSTリクエストを行えます。
https通信
コンテナを使っている影響かRunTLS関数ではうまく動作せず
autocertというパッケージを使うと楽でした。
もしRunTLSでうまくいかない時はお試しください。