Go言語のWebフレームワーク「Echo」を使ってみる ④（Cookieを使用したセッション管理）

> すべての記事の一覧はこちら。
**Go言語でWebサイトを作ってみる：目次**
http://qiita.com/y_ussie/items/8fcc3077274449478bc9

前回のセッションデータストア作成篇 http://qiita.com/y_ussie/items/b1db86b0b54ec69bb928 の続きです。

引き続き、セッション毎のデータ管理を行ってみようと思います。
前回サーバー側に新規セッションIDの発行とセッションデータストアの読み書きを実装しましたので、一連の流れを作ってみます。

#やりたいこと

以下の一連の流れを実装します。

* サーバー側でセッションIDを発行する。
* 発行されたセッションIDをブラウザ側の Cookie に保存しておく。
* ページ①にて、 Coockie に保存されたセッションIDをキーにして、サーバー側のセッションデータストアにデータを保存する。
* ページ②にて、セッションデータストアに保存されたデータを、 Cookie に保存されたセッションIDをキーにして読み込む。

作成するリクエストハンドラは以下になります。

## GET /session_form

以下のフォームを出力します。
![1.png](https://qiita-image-store.s3.amazonaws.com/0/70686/885d42e5-c66d-4fe6-f1dd-0aaa37e84ff4.png)


## POST /session

セッションを新規作成し、発行されたセッションIDをブラウザの Cookie に保存します。
フォームより送信されたデータを、セッションIDをキーにしてセッションデータストアに保存します。

## GET /session

ブラウザの Cookie よりセッションIDを読み出し、それをキーにしてセッションデータストアよりデータを読み出します。
読み出したデータを表示します。

## POST /session_delete

ブラウザの Cookie より読み出したセッションIDをキーにして、セッションの削除を行います。

# ということで作ってみた。

```go:main.go
package main

import (
	"context"
	"html/template"
	"io"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"./session"
	"github.com/labstack/echo"
	"github.com/labstack/echo/middleware"
	"github.com/labstack/gommon/log"
)

// レイアウト適用済のテンプレートを保存するmap
var templates map[string]*template.Template

// セッション管理のインスタンス
var sessionManager *session.Manager

// Template はHTMLテンプレートを利用するためのRenderer Interfaceです。
type Template struct {
}

// セッションCookieに関する設定
const (
	// Cookie名
	sessionCookieName string = "sampleapisrv_session_id"
	// 有効期間
	sessionCookieExpire time.Duration = (1 * time.Hour)
)

// Render はHTMLテンプレートにデータを埋め込んだ結果をWriterに書き込みます。
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
	return templates[name].ExecuteTemplate(w, "layout.html", data)
}

func main() {
	// Echoのインスタンスを生成
	e := echo.New()

	// ログの出力レベルを設定
	//	e.Logger.SetLevel(log.INFO)
	e.Logger.SetLevel(log.DEBUG)

	// テンプレートを利用するためのRendererの設定
	t := &Template{}
	e.Renderer = t

	// ミドルウェアを設定
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	// 静的ファイルのパスを設定
	e.Static("/public/css/", "./public/css")
	e.Static("/public/js/", "./public/js/")
	e.Static("/public/img/", "./public/img/")

	// 各ルーティングに対するハンドラを設定
	e.GET("/session_form", HandleSessionFormGet)
	e.GET("/session", HandleSessionGet)
	e.POST("/session", HandleSessionPost)
	e.POST("/session_delete", HandleSessionDeletePost)

	// セッション管理を開始
	sessionManager = &session.Manager{}
	sessionManager.Start(e)

	// サーバーを開始
	go func() {
		if err := e.Start(":3000"); err != nil {
			e.Logger.Info("shutting down the server")
		}
	}()

	// 中断を検知したらリクエストの完了を10秒まで待ってサーバーを終了する
	// (Graceful Shutdown)
	quit := make(chan os.Signal)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := e.Shutdown(ctx); err != nil {
		e.Logger.Info(err)
		e.Close()
	}

	// セッション管理を停止
	sessionManager.Stop()

	// 終了ログが出るまで少し待つ
	time.Sleep(1 * time.Second)
}

// 初期化を行います。
func init() {
	loadTemplates()
}

// 各HTMLテンプレートに共通レイアウトを適用した結果を保存します（初期化時に実行）。
func loadTemplates() {
	var baseTemplate = "templates/layout.html"
	templates = make(map[string]*template.Template)
	templates["session_form"] = template.Must(
		template.ParseFiles(baseTemplate, "templates/session_form.html"))
	templates["session"] = template.Must(
		template.ParseFiles(baseTemplate, "templates/session.html"))
	templates["error"] = template.Must(
		template.ParseFiles(baseTemplate, "templates/error.html"))
}

// HandleSessionFormGet は /session_form のGet時のHTMLデータ生成処理を行います。
func HandleSessionFormGet(c echo.Context) error {
	return c.Render(http.StatusOK, "session_form", nil)
}

// HandleSessionGet は /session のGet時のHTMLデータ生成処理を行います。
func HandleSessionGet(c echo.Context) error {
	sessionID, err := readSessionID(c)
	if err != nil {
		return c.Render(http.StatusOK, "error", "Session cookie Read Error: "+err.Error())
	}
	sessionStore, err := sessionManager.LoadStore(sessionID)
	if err != nil {
		return c.Render(http.StatusOK, "error", "Session store Load Error: "+err.Error())
	}
	return c.Render(http.StatusOK, "session",
		map[string]interface{}{"msg": "", "data": sessionStore.Data})
}

// HandleSessionPost は /session のPost時のHTMLデータ生成処理を行います。
func HandleSessionPost(c echo.Context) error {
	key1 := c.FormValue("key1")
	key2 := c.FormValue("key2")
	sessionID, err := sessionManager.Create()
	if err != nil {
		return c.Render(http.StatusOK, "error", "Session Create Error: "+err.Error())
	}
	writeSessionID(c, sessionID)
	sessionStore, err := sessionManager.LoadStore(sessionID)
	if err != nil {
		return c.Render(http.StatusOK, "error", "Session store Load Error: "+err.Error())
	}
	sessionData := map[string]string{
		"key1": key1,
		"key2": key2,
	}
	sessionStore.Data = sessionData
	err = sessionManager.SaveStore(sessionID, sessionStore)
	if err != nil {
		return c.Render(http.StatusOK, "error", "Session store Save Error: "+err.Error())
	}
	return c.Render(http.StatusOK, "session_form",
		map[string]interface{}{"msg": "セッションデータを保存しました", "data": sessionStore.Data})
}

// HandleSessionDeletePost は /session_delete のPost時のHTMLデータ生成処理を行います。
func HandleSessionDeletePost(c echo.Context) error {
	sessionID, err := readSessionID(c)
	if err != nil {
		return c.Render(http.StatusOK, "error", "Session cookie Read Error: "+err.Error())
	}
	err = sessionManager.Delete(sessionID)
	if err != nil {
		return c.Render(http.StatusOK, "error", "Session delete Error: "+err.Error())
	}
	return c.Render(http.StatusOK, "session",
		map[string]interface{}{"msg": "セッション " + sessionID + " を削除しました"})
}

// ブラウザのcookieに session.ID を書き込む
func writeSessionID(c echo.Context, sessionID session.ID) error {
	cookie := new(http.Cookie)
	cookie.Name = sessionCookieName
	cookie.Value = string(sessionID)
	cookie.Expires = time.Now().Add(sessionCookieExpire)
	c.SetCookie(cookie)
	return nil
}

// ブラウザのcookieから session.ID を読み込む
func readSessionID(c echo.Context) (session.ID, error) {
	var sessionID session.ID
	cookie, err := c.Cookie(sessionCookieName)
	if err != nil {
		return sessionID, err
	}
	sessionID = session.ID(cookie.Value)
	return sessionID, nil
}
```

使用しているHTMLテンプレートは以下です。

```html:templates/session_form.html
{{define "content"}}
<h2> Create Session Data</h2>
<form action="/session" method="POST">
    <p>
        <label for="key1">Key 1: </label>
        <input type="text" id="key1" name="key1" value="{{.data.key1}}" />
    </p>
    <p>
        <label for="key2">Key 2: </label>
        <input type="text" id="key2" name="key2" value="{{.data.key2}}"/>
    </p>
    <p>{{.msg}}</p>
    <input type="submit" />
</form>
{{end}}
```


```html:templates/session.html
{{define "content"}}
<h2>Session Data</h2>
{{range $key, $value := .data}}
<p>{{$key}}: {{$value}}</p>
{{end}}
{{.msg}}
<form action="/session_delete" method="POST">
<input type="submit" value="セッションを削除" />
</form>
{{end}}
```


```html:templates/error.html
{{define "content"}}
<h2>Error</h2>
<p>{{.}}</p>
{{end}}
```


これまでに作成していた main.go にコードを追加していますが、とりあえず今回の機能に関係ないリクエストハンドラの処理は省いています。

一番色々な処理をやっているのは HandleSessionPost() ですが、ここでは

* echo.Context#FormValue() でフォームデータの読み込み
* session.Manager#Create() で新規セッション作成
* ブラウザの Cookie にセッションIDを書き込むためのレスポンスヘッダ作成
* session.Manager#LoadStore() でセッションデータストアの読み出し
* 読み込んだデータストアにフォームから読み込んだデータをセット
* session.Manager#SaveStore() でセッションデータストアの保存

を行っています。

Cookieの読み込み、書き込みはそれぞれ readSessionID()、 writeSessionID()の中にて、echo.Context#Cookie()、 echo.Context#SetCookie() を使用して行っています。

あと前回までの main.go では終了シグナルを拾わず即プロセス終了していましたが、今回から終了時には session.Manager の停止処理を実行したいため、

```go
	quit := make(chan os.Signal)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
```

上記の処理にて SIGTERM と SIGINT を受けた際に quit チャネルのブロックから抜け、終了処理が走るようにしています。ついでに echo の公式のサンプル https://echo.labstack.com/cookbook/graceful-shutdown を参考に Graceful Shutdown （処理中のリクエストが完了するまで少し待ってからサーバーを終了させる）を有効にしています。

# 実行結果

## GET /session_form
![2.png](https://qiita-image-store.s3.amazonaws.com/0/70686/8b3d3ca2-5ddf-f199-b182-fe8b030addcf.png)
表示されたフォームにデータを入力して送信ボタンを押下します。

## POST /session
![3.png](https://qiita-image-store.s3.amazonaws.com/0/70686/02a4461e-d2fc-e7c4-9f5f-3226e85c5953.png)
セッションが作成され、セッションデータストアにデータが保存されます。ログは以下のような感じ。

```
{"time":"2017-06-12T22:57:16.3641384+09:00","level":"DEBUG","prefix":"echo","file":"asm_amd64.s","line":"2197","message":"Session[77e0c290-dc49-43a4-8852-b8e6ee3c54be] Create. expire[2017-06-12 23:00:16.3641384 +0900 JST]"}
{"time":"2017-06-12T22:57:16.365131+09:00","level":"DEBUG","prefix":"echo","file":"asm_amd64.s","line":"2197","message":"Session[77e0c290-dc49-43a4-8852-b8e6ee3c54be] Load store. store[{map[] a62df232-1112-47ea-a586-ec09f287c356}] expire[2017-06-12 23:00:16.365131 +0900 JST]"}
{"time":"2017-06-12T22:57:16.365131+09:00","level":"DEBUG","prefix":"echo","file":"asm_amd64.s","line":"2197","message":"Session[77e0c290-dc49-43a4-8852-b8e6ee3c54be] Save store. store[{map[key2:セッションデータ key1:Session Data] 6069e8b4-63bd-4bb1-8cd4-5e56ed65c322}] expire[2017-06-12 23:00:16.365131 +0900 JST]"}
{"time":"2017-06-12T22:57:16.3661278+09:00","id":"","remote_ip":"::1","host":"localhost:3000","method":"POST","uri":"/session","status":200, "latency":4966900,"latency_human":"4.9669ms","bytes_in":95,"bytes_out":565}
```

## GET /session
セッションデータを表示してみます。
![4.png](https://qiita-image-store.s3.amazonaws.com/0/70686/502a4fb9-f92a-f5d1-6ca9-814eb1fc1f74.png)
さきほどフォームから入力したデータが表示されています。「セッションを削除」を押下してセッションデータを削除してみます。

## POST /session_delete
![5.png](https://qiita-image-store.s3.amazonaws.com/0/70686/08600f6e-092b-61bc-77df-461112cba2b6.png)
セッションが削除されました。

## GET /session
再度セッションデータを表示してみると、Not Foundでエラー画面が表示されます。
![6.png](https://qiita-image-store.s3.amazonaws.com/0/70686/32eb6cdc-fdbe-0258-81f3-06f2ee98e44d.png)

##Cookieの状態
Chromeの開発者ツールで見てみると、以下のようにセッションIDが保存されています。
![7.png](https://qiita-image-store.s3.amazonaws.com/0/70686/4908a1be-2ba9-1172-64af-bf0cd9f7f976.png)


# 次回予定
今回作った仕組みを使用して、ログインユーザーのみが表示できるページを作成してみようかと思います。