6.2 Goはどのようにしてsessionを使用するか

前の節で、sessionはサーバサイドで実装されるユーザとサーバ間の認証のソリューションのひとつであることをご紹介しました。現在Goの標準パッケージにはsessionのサポートがありません。この節では実際に手を動かしてgoバージョンのsession管理と作成を実現してみます。

sessionの作成過程

sessionの基本原理はサーバによって各セッションにおける情報データを保護することです。クライアントサイドはサーバサイドとグローバルでユニークなIDひとつを頼ってこのデータにアクセスし、インタラクティブな目的が達成されます。ユーザがWebアプリケーションにアクセスする際、サーバサイドのプログラムはsession作成の要求に従います。この過程は3つのステップに分けることができます:

  • グローバルでユニークなIDの生成(sessionid)
  • データの保存スペースを作成。普通はメモリの中に対応するデータ構造を作成します。しかしこのような状況では、システムは一旦電源が切れると、すべてのセッションデータが消失します。もしeコマースのようなホームページであった場合、これは重大な結果をもたらします。そのため、このような問題を解決するためにセッションデータをファイルの中やデータベースの中に書き込むことができます。当然この場合I/Oオーバーヘッドが増加しますが、ある程度のsessionの永続化は実現できますし、sessionの共有にも有利です。
  • sessionのグローバルでユニークなIDをクライアントサイドに送信します。

上の3つのステップでもっとも重要なのは、どのようにこのsessionのユニークIDを送信するかというステップです。HTTPプロトコルの定義上、データはリクエスト行、ヘッダー部またはBodyの中に含めるしかありません。そのため一般的には2つのよく使われる方法があります:cookieとURLの書き直しです。

  1. Cookie サーバサイドはSet-cookieヘッダーを設定することでsessionのIDをクライアントサイドに送信することができます。クライアントサイドは以降の各リクエストすべてにこのIDを含めます。またsession情報を含んだcookieの有効期限を0(セッションcookie)、つまりブラウザプロセスの有効期限に設定することもよく行われます。各ブラウザはそれぞれ異なる実装がされていますが、差はそれほど大きくはありません(一般的にはブラウザウィンドウを新規に作成した際に反映されます)。
  2. URLの書き直し いわゆるURLの書き直しとは、ユーザに返されるページの中のすべてのURLの後ろにsessionIDを追加することです。このようにユーザがレスポンスを受け取った後、レスポンスのページの中のどのリンクをクリックしたりフォームを送信しても、すべて自動的にsessionIDが付与されます。これによりセッションの保持を実現します。このような方法はすこし面倒ではありますが、もしクライアントサイドがcookieを禁止している場合、このようなソリューションがまず選ばれます。

Goでsession管理を実現する

上のsession作成の課程の解説で、読者はsessionの大体の知識を得られたものと思います。しかし具体的な動的ページ技術においては、またどうやってsessionを実現しているのでしょうか?ここではsessionのライフサイクル(lifecycle)と併せてgo言語バージョンのsession管理を実現します。

session管理設計

session管理は以下のいくつかのファクターが関わってきます

  • グローバルなsessionマネージャ
  • sessionidがグローバルにユニークであることの保証
  • 各ユーザをひとつのsessionに関連付ける
  • sessionの保存(メモリ、ファイル、データベース等に保存できます)
  • sessionの期限切れ処理

以降ではsession管理の全体の設計構想と対応するgoのコード例について解説します:

Sessionマネージャ

あるグローバルなsessionマネージャを定義します

type Manager struct {
    cookieName  string     //private cookiename
    lock        sync.Mutex // protects session
    provider    Provider
    maxlifetime int64
}

func NewManager(provideName, cookieName string, maxlifetime int64) (*Manager, error) {
    provider, ok := provides[provideName]
    if !ok {
        return nil, fmt.Errorf("session: unknown provide %q (forgotten import?)", provideName)
    }
    return &Manager{provider: provider, cookieName: cookieName, maxlifetime: maxlifetime}, nil
}

Goで実現される全体のフローは概ねこのようなものになります。mainパッケージにおいてグローバルなsessionマネージャを作成します。

var globalSessions *session.Manager
//この後init関数で初期化されます。
func init() {
    globalSessions, _ = NewManager("memory","gosessionid",3600)
}

我々はsessionがサーバサイドに保存されるデータであることを知っています。これはどのような方法で保存されてもかまいません。例えばメモリ、データベースまたはファイルの中に保存します。そのため、Providerインターフェースを抽象化することでトークンsessionマネージャが低レイヤで構造を保存します。

type Provider interface {
    SessionInit(sid string) (Session, error)
    SessionRead(sid string) (Session, error)
    SessionDestroy(sid string) error
    SessionGC(maxLifeTime int64)
}
  • SessionInit関数はSessionの初期化を実装します。操作に成功するとこの新しいSession変数を返します。
  • SessionRead関数はsidが示すSession変数を返します。もし存在しなければ、sidを引数としてSessionInit関数をコールし、真新しいSession変数を新規に作成して、返します。
  • SessionDestroy関数はsidに対応するSession変数を廃棄するために用いられます。
  • SessionGCはmaxLifeTimeに従って期限の切れたデータを削除します。

ではSessionインターフェースはどのような機能を実装しなければならないのでしょうか?Web開発の経験のある読者であればご存知だとは思いますが、Sessionに対する処理の基本は 値を設定する、値を取得する、値を削除する、現在のsessionIDを取得する の4つの操作となります。ですので我々のSessionインターフェースもこの4つの操作を実装します。

type Session interface {
    Set(key, value interface{}) error //set session value
    Get(key interface{}) interface{}  //get session value
    Delete(key interface{}) error     //delete session value
    SessionID() string                //back current sessionID
}

以上の設計構想はdatabase/sql/driverに由来します。先にインターフェースを定義して、その後実際にsessionを保存する構造が対応するインターフェースを実装し登録すると、対応する機能が使用できるようになります。以下はオンデマンドに登録しsessionの構造を保存するRegister関数の実装です。

var provides = make(map[string]Provider)

// Register makes a session provide available by the provided name.
// If Register is called twice with the same name or if driver is nil,
// it panics.
func Register(name string, provider Provider) {
    if provider == nil {
        panic("session: Register provide is nil")
    }
    if _, dup := provides[name]; dup {
        panic("session: Register called twice for provide " + name)
    }
    provides[name] = provider
}

グローバルでユニークなSession ID

Session IDはWebアプリケーションにアクセスした各ユーザを識別するために用いられます。その為これはグローバルでユニークであることを保証する必要があります。(GUID)、下のコードはどのようにこの要求を満足させるか示しています。

func (manager *Manager) sessionId() string {
    b := make([]byte, 32)
    if _, err := io.ReadFull(rand.Reader, b); err != nil {
        return ""
    }
    return base64.URLEncoding.EncodeToString(b)
}

sessionの作成

各ユーザに対して彼らと結びつくSessionを与えたり取得することで、Session情報に従って操作を検証する必要があります。SessionStartという関数はあるSessionが現在アクセスしているユーザと既に関係しているか検査するために用いられます。もし無ければ新規にこれを作成します。

func (manager *Manager) SessionStart(w http.ResponseWriter, r *http.Request) (session Session) {
    manager.lock.Lock()
    defer manager.lock.Unlock()
    cookie, err := r.Cookie(manager.cookieName)
    if err != nil || cookie.Value == "" {
        sid := manager.sessionId()
        session, _ = manager.provider.SessionInit(sid)
        cookie := http.Cookie{Name: manager.cookieName, Value: url.QueryEscape(sid), Path: "/", HttpOnly: true, MaxAge: int(manager.maxlifetime)}
        http.SetCookie(w, &cookie)
    } else {
        sid, _ := url.QueryUnescape(cookie.Value)
        session, _ = manager.provider.SessionRead(sid)
    }
    return
}

前のlogin操作で示したsessionの運用を利用します:

func login(w http.ResponseWriter, r *http.Request) {
    sess := globalSessions.SessionStart(w, r)
    r.ParseForm()
    if r.Method == "GET" {
        t, _ := template.ParseFiles("login.gtpl")
        w.Header().Set("Content-Type", "text/html")
        t.Execute(w, sess.Get("username"))
    } else {
        sess.Set("username", r.Form["username"])
        http.Redirect(w, r, "/", 302)
    }
}

値の操作:設定、ロードおよび削除

SessionStart関数はSessionインターフェースを満足させる変数を返します。ではどのようにこれを利用してsessionデータに対し操作を行うのでしょうか?

上の例のコードsession.Get("uid")において基本的なデータのロード操作をお見せしました。ここではより詳しく操作を見ていくことにしましょう:

func count(w http.ResponseWriter, r *http.Request) {
    sess := globalSessions.SessionStart(w, r)
    createtime := sess.Get("createtime")
    if createtime == nil {
        sess.Set("createtime", time.Now().Unix())
    } else if (createtime.(int64) + 360) < (time.Now().Unix()) {
        globalSessions.SessionDestroy(w, r)
        sess = globalSessions.SessionStart(w, r)
    }
    ct := sess.Get("countnum")
    if ct == nil {
        sess.Set("countnum", 1)
    } else {
        sess.Set("countnum", (ct.(int) + 1))
    }
    t, _ := template.ParseFiles("count.gtpl")
    w.Header().Set("Content-Type", "text/html")
    t.Execute(w, sess.Get("countnum"))
}

上の例には、Sessionの操作とkey/valueデータベースに似た操作である:Set、Get、Deleteといった操作が見受けられます。

Sessionには有効期限の概念がありますので、GC操作を定義しました。アクセス期限が切れるとGCのトリガー条件を満たし、GCを呼び出します。しかし我々が任意のsession操作を行うと、Sessionエンティティに対し更新を行い、最終アクセス時間の修正を行います。このようにGCが行われる際は誤ってまだ使用されているSessionエンティティを削除してしまわないようにします。

sessionの再設定

Webアプリケーションにはユーザのログアウト操作があります。ユーザがアプリケーションをログアウトする時、このユーザのsessionデータを破棄する必要があります。上のコードはすでにどのようにsessionの再設定操作を使用するか示しています。ここではこの関数がこの機能を実装します:

//Destroy sessionid
func (manager *Manager) SessionDestroy(w http.ResponseWriter, r *http.Request){
    cookie, err := r.Cookie(manager.cookieName)
    if err != nil || cookie.Value == "" {
        return
    } else {
        manager.lock.Lock()
        defer manager.lock.Unlock()
        manager.provider.SessionDestroy(cookie.Value)
        expiration := time.Now()
        cookie := http.Cookie{Name: manager.cookieName, Path: "/", HttpOnly: true, Expires: expiration, MaxAge: -1}
        http.SetCookie(w, &cookie)
    }
}

sessionの破棄

ではSessionマネージャがどのように破棄を管理しているのかみてみることにしましょう。Mainが呼び出される際に実行するだけです:

func init() {
    go globalSessions.GC()
}

func (manager *Manager) GC() {
    manager.lock.Lock()
    defer manager.lock.Unlock()
    manager.provider.SessionGC(manager.maxlifetime)
    time.AfterFunc(time.Duration(manager.maxlifetime), func() { manager.GC() })
}

GCが十分にtimeパッケージのタイマー機能を利用していることがおわかりいただけるかと思います。時間がmaxLifeTimeを超えた後GC関数をコールした際、これによってmaxLiefTime時間内でsessionが利用できることを保証できます。このような方法はまたオンラインユーザの数といった統計に用いることもできます。

まとめ

これまでに、WebアプリケーションにおけるグローバルなSession管理に用いられるSessionManagerを実装してまいりました。Sessionを提供するために用いられるストレージを定義し、Providerのインターフェースを実装しました。次の節では、インターフェースの定義を通してProviderを実装します。ぜひご参考ください。

results matching ""

    No results matching ""