ASP.Netのセッション変数をmemcachedにする

ASP.Netのセッション変数をmemcachedに変更してみようという試みです。

ASP.Netでのセッション変数

まずASP.Netのセッション変数での扱いをおさらいします。

ASP.Netでは大きく4パターンあります。

InProc

対象のアプリが稼働しているアプリケーション(IIS)のメモリ内にセッション情報を保存します。

メリット

初期設定であり、他に用意するものもなくとてもお手軽にセッションを扱えます。

また、格納対象がSerializableでなくてもよかったり一番制約が少ないです。シリアライズされない上にローカルのメモリに入れるので速度も一番速いと思われます。

デメリット

各サーバー毎のローカルなメモリ内に情報を格納する為、水平分割するとクライアントとサーバーを1対1で紐づける必要が出てきます。その為バランシングに高機能なロードバランサーが必要になってくる上、分割の効果が下がります。

また、せっかく冗長確保しているにもかかわらずいずれかのサーバーが死ぬとそこにセッションを持っていたユーザーはセッション情報を失う事になります。

更に、IISはアプリケーションを定期的に再起動かけるためそのタイミングでどちらにしろセッション情報を失ってしまいます。

基本的にこれは小規模もしくは開発用のアプリケーションに向いていると言えます。

StateServer

外部のASP.NET State Serviceにセッション情報を格納します。

メリット

WEBサーバーとセッション管理が分離される為、フロントがバランシングされても特に問題がなくなり、さらにフロント側で障害が発生してもセッションを維持する事が出来ます。

デメリット

当たり前ですが、1台で構成したら単一障害点になります。

InProcの場合はサーバーが1台死んでも一部のユーザーのセッションが途切れるだけで再度つなぎなおすと大丈夫ですが、コチラはすべてのユーザーが影響をうけ、しかも復帰しない限りすべてのサービスが不能になります。

ところがどうもこの設定では冗長化が出来ないようです。しいて上げるとスタンバイを置いてすぐに差し替えて影響範囲を抑えるくらいでしょうか。

SQLServer

外部のSQL Serverにセッション情報を格納するようにします。

メリット

State Serverと基本的には同じです。

コチラはState Serverと違い冗長化が可能です。

冗長化の方法は通常のSQL Serverとまったく同じ手段で可能です。

デメリット

やはり構築が面倒になります。特に冗長用にクラスタリングを組んだりすると構築の手間が増えます。

さらに、サーバーの料金面でも高くなりがちです。

現状挙げた3つの中では一番パフォーマンスが良くないです。

Off

セッション管理をしません。

メリットもデメリットもくそもありません。セッション管理は何もできません。

Custom

独自のセッション管理システムを導入します。

SessionStateStoreProviderと呼ばれるクラスを自分で実装する必要があります。

メリット

実装の如何で好きに出来ます。

デメリット

実装コストが高いです。

さらに、実装の仕方によってパフォーマンスに直結します。

memcachedを使う

今回はセッションモードをCustomにして保存先をmemcachedにします。

それにあたってまずはASP.Net上からmemcachedへアクセスできるようにします。

とりあえずひな形

コントローラ

using System.Web.Mvc;

namespace memcachedtest.Controllers
{
    /// <summary>
    /// memcachedのサンプル
    /// </summary>
    public class HomeController 
        : Controller
    {
        /// <summary>
        /// 確認ページ
        /// </summary>
        /// <returns></returns>
        public ActionResult Index()
        {
            return View();
        }

        /// <summary>
        /// セッションを設定
        /// </summary>
        /// <returns></returns>
        public ActionResult SetSession()
        {
            Session["session-test"] = "SetSession";
            return Redirect( "~/" );
        }

        /// <summary>
        /// セッションを削除
        /// </summary>
        /// <returns></returns>
        public ActionResult ClearSession()
        {
            Session.Remove("session-test");
            return Redirect( "~/" );
        }
    }
}

ビュー

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>memcachedテスト</title>
        <style>
            div {
                margin: 12px;
            }
        </style>
    </head>
    <body>
        <div>
            @if ( Session["session-test"] != null ) {
                <p>セッション変数が設定済</p>
            }
        </div>

        <div>
            @Html.ActionLink("セッション設定", "SetSession")
        </div>

        <div>
            @Html.ActionLink( "セッション削除", "ClearSession" )
        </div>
    </body>
</html>

初期設定InProcで動いてます。

これにまずmemcachedにアクセスする処理を追記し、アクセスできることを確認してからSessionStateStoreProviderを実装しようと思います。

memcachedクライアントを用意する

(古いけど)何個かmemcachedの.Net用のクライアントライブラリはあるようです。

その中でnugetにあったのが、NMemcachedなのでこれを利用したいと思います。

PM> Install-Package NMemcached.Client

接続する

memcachedのサーバーは稼働済の前提で進めます。

コントローラ

using System;
using System.Collections.Generic;
using System.Web.Mvc;

namespace memcachedtest.Controllers
{
    /// <summary>
    /// memcachedのサンプル
    /// </summary>
    public class HomeController
        : Controller
    {
        /// <summary>
        /// 接続先memcached
        /// </summary>
        private readonly IReadOnlyCollection<string> kMemcachedHost = new List<string>( new[] { "192.168.0.4" } );

        /// <summary>
        /// 確認ページ
        /// </summary>
        /// <returns></returns>
        public ActionResult Index()
        {
            string data = "";
            using ( var client = new NMemcached.Client.MemcachedClient( kMemcachedHost ) ) {
                data = Convert.ToString( client.Get( "session-test" ) );
            }
            return View( data as object );
        }

        /// <summary>
        /// セッションを設定
        /// </summary>
        /// <returns></returns>
        public ActionResult SetSession()
        {
            using ( var client = new NMemcached.Client.MemcachedClient( kMemcachedHost ) ) {
                client.Set( "session-test", "SetSession", DateTime.Now.AddHours( 1 ) );
            }
            Session["session-test"] = "SetSession";
            return Redirect( "~/" );
        }

        /// <summary>
        /// セッションを削除
        /// </summary>
        /// <returns></returns>
        public ActionResult ClearSession()
        {
            using ( var client = new NMemcached.Client.MemcachedClient( kMemcachedHost ) ) {
                client.Delete( "session-test" );
            }
            Session.Remove( "session-test" );
            return Redirect( "~/" );
        }
    }
}

ビュー

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>memcachedテスト</title>
        <style>
            div {
                margin: 12px;
            }
        </style>
    </head>
    <body>
        <div>
            @if ( Session["session-test"] != null ) {
                <p>セッション変数が設定済</p>
            }

            <p>Memcached : @Model</p>
        </div>

        <div>
            @Html.ActionLink("セッション設定", "SetSession")
        </div>

        <div>
            @Html.ActionLink( "セッション削除", "ClearSession" )
        </div>
    </body>
</html>

とっても簡単です。

SessionStateStoreProviderを実装する

memcachedへの接続が出来ることは確認できたので本命のSessionStateStoreProviderを実装します。

これには用意されているSessionStateStoreProviderBaseクラスを継承したクラスを用意し、そこに必要なメソッドを実装していきます。

using System;
using System.Collections.Generic;
using System.Web;
using System.Web.SessionState;

namespace memcachedtest
{
    /// <summary>
    /// memcachedへセッション情報を保存する
    /// </summary>
    public class MemcachedSessionStateStoreProvider
        : SessionStateStoreProviderBase
    {
        /// <summary>
        /// 接続先memcached
        /// </summary>
        private readonly IReadOnlyCollection<string> kMemcachedHost = new List<string>( new[] { "192.168.0.4" } );

        public override SessionStateStoreData CreateNewStoreData( HttpContext context, int timeout )
        {
            throw new NotImplementedException();
        }

        public override void CreateUninitializedItem( HttpContext context, string id, int timeout )
        {
            throw new NotImplementedException();
        }

        public override void Dispose()
        {
            throw new NotImplementedException();
        }

        public override void EndRequest( HttpContext context )
        {
            throw new NotImplementedException();
        }

        public override SessionStateStoreData GetItem( HttpContext context, string id, out bool locked, out TimeSpan lockAge, out object lockId, out SessionStateActions actions )
        {
            throw new NotImplementedException();
        }

        public override SessionStateStoreData GetItemExclusive( HttpContext context, string id, out bool locked, out TimeSpan lockAge, out object lockId, out SessionStateActions actions )
        {
            throw new NotImplementedException();
        }

        public override void InitializeRequest( HttpContext context )
        {
            throw new NotImplementedException();
        }

        public override void ReleaseItemExclusive( HttpContext context, string id, object lockId )
        {
            throw new NotImplementedException();
        }

        public override void RemoveItem( HttpContext context, string id, object lockId, SessionStateStoreData item )
        {
            throw new NotImplementedException();
        }

        public override void ResetItemTimeout( HttpContext context, string id )
        {
            throw new NotImplementedException();
        }

        public override void SetAndReleaseItemExclusive( HttpContext context, string id, SessionStateStoreData item, object lockId, bool newItem )
        {
            throw new NotImplementedException();
        }

        public override bool SetItemExpireCallback( SessionStateItemExpireCallback expireCallback )
        {
            throw new NotImplementedException();
        }
    }
}

とりあえず継承させるとこんな感じです。

次に必要なメソッドを順次実装します。

using NMemcached.Client;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.SessionState;
using System.Collections.Specialized;
using System.Threading;

namespace memcachedtest
{
    /// <summary>
    /// memcachedへセッション情報を保存する
    /// </summary>
    public class MemcachedSessionStateStoreProvider
        : SessionStateStoreProviderBase
    {
        /// <summary>
        /// 接続先memcached
        /// </summary>
        private readonly IReadOnlyCollection<string> kMemcachedHost = new List<string>( new[] { "192.168.0.4" } );

        /// <summary>
        /// 接続クライアント
        /// </summary>
        private MemcachedClient client_ = null;

        /// <summary>
        /// セッション変数の排他用
        /// </summary>
        private ConcurrentDictionary<string, ManualResetEvent> lockObjects_ = new ConcurrentDictionary<string, ManualResetEvent>();

        /// <summary>
        /// セッションデータ
        /// </summary>
        private ConcurrentDictionary<string, SessionDataCollection> sessionItems_ = new ConcurrentDictionary<string, SessionDataCollection>();

        /// <summary>
        /// memcachedへの保存用
        /// </summary>
        [Serializable]
        public class SessionParcel
        {
            public List<string> Keys { get; set; }
            public List<object> Values { get; set; }
        }

        /// <summary>
        /// セッション変数
        /// </summary>
        private class SessionDataCollection
            : NameObjectCollectionBase, ISessionStateItemCollection
        {
            /// <summary>
            /// memcached接続
            /// </summary>
            private MemcachedClient client_;

            /// <summary>
            /// セッションID
            /// </summary>
            private string id_;

            /// <summary>
            /// 中身が変わっているか
            /// </summary>
            private bool isDirty_ = false;

            /// <summary>
            /// セッション用にデータを構築
            /// </summary>
            /// <param name="client"></param>
            /// <param name="id"></param>
            public SessionDataCollection( MemcachedClient client, string id )
            {
                id_ = id;
                client_ = client;
                if ( id != null ) {
                    var parcel = client_.Get( id ) as SessionParcel;
                    for ( int i = 0; i < parcel.Keys.Count; ++i ) {
                        BaseAdd( parcel.Keys[i], parcel.Values[i] );
                    }
                }
            }

            /// <summary>
            /// データを取得
            /// </summary>
            /// <param name="index"></param>
            /// <returns></returns>
            public object this[int index]
            {
                get { return BaseGet( index.ToString() ); }
                set { BaseAdd( index.ToString(), value ); Dirty = true; }
            }

            /// <summary>
            /// データを取得
            /// </summary>
            /// <param name="name"></param>
            /// <returns></returns>
            public object this[string name]
            {
                get { return BaseGet( name ); }
                set { BaseAdd( name, value ); Dirty = true; }
            }

            /// <summary>
            /// データの変更があるか
            /// </summary>
            public bool Dirty
            {
                get { return isDirty_; }
                set { isDirty_ = value; }
            }

            /// <summary>
            /// データすべて削除
            /// </summary>
            public void Clear()
            {
                BaseClear();
                Dirty = true;
            }

            /// <summary>
            /// データ削除
            /// </summary>
            /// <param name="name"></param>
            public void Remove( string name )
            {
                BaseRemove( name );
                Dirty = true;
            }

            /// <summary>
            /// データ削除
            /// </summary>
            /// <param name="index"></param>
            public void RemoveAt( int index )
            {
                BaseRemoveAt( index );
                Dirty = true;
            }

            /// <summary>
            /// memcached更新
            /// </summary>
            public void Update()
            {
                if ( Dirty ) {
                    var parcel = new SessionParcel();
                    parcel.Keys = new List<string>( BaseGetAllKeys() );
                    parcel.Values = new List<object>( BaseGetAllValues() );
                    client_.Add( id_, parcel );
                    Dirty = false;
                }
            }

            /// <summary>
            /// memcached更新
            /// </summary>
            /// <param name="id"></param>
            public void Update(string id)
            {
                id_ = id;
                Dirty = true;
                Update();
            }
        }

        /// <summary>
        /// 接続
        /// </summary>
        private void Connect()
        {
            if ( client_ == null ) {
                client_ = new MemcachedClient( kMemcachedHost );
            }
        }

        /// <summary>
        /// 新しいセッション変数を用意する
        /// </summary>
        /// <param name="context"></param>
        /// <param name="timeout"></param>
        /// <returns></returns>
        public override SessionStateStoreData CreateNewStoreData( HttpContext context, int timeout )
        {
            return new SessionStateStoreData( new SessionDataCollection( client_, null ), null, timeout );
        }

        /// <summary>
        /// セッションの有効期限切れの際に、セッションの再取得に利用される
        /// </summary>
        /// <param name="context"></param>
        /// <param name="id"></param>
        /// <param name="timeout"></param>
        public override void CreateUninitializedItem( HttpContext context, string id, int timeout )
        {
            (CreateNewStoreData( context, timeout ).Items as SessionDataCollection).Update(id);
        }

        /// <summary>
        /// 接続破棄
        /// </summary>
        public override void Dispose()
        {
            if ( client_ != null ) {
                client_.Dispose();
                client_ = null;
            }
        }

        /// <summary>
        /// 接続破棄
        /// </summary>
        /// <param name="context"></param>
        public override void EndRequest( HttpContext context )
        {
            Dispose();
        }

        /// <summary>
        /// アイテムを取得する排他処理はしない
        /// </summary>
        /// <param name="context"></param>
        /// <param name="id"></param>
        /// <param name="locked"></param>
        /// <param name="lockAge"></param>
        /// <param name="lockId"></param>
        /// <param name="actions"></param>
        /// <returns></returns>
        public override SessionStateStoreData GetItem( HttpContext context, string id, out bool locked, out TimeSpan lockAge, out object lockId, out SessionStateActions actions )
        {
            // 排他はしないが別のコネクションで排他されている可能性があるので同期処理は行う
            var data = GetItemExclusive( context, id, out locked, out lockAge, out lockId, out actions );
            if ( locked ) {
                return null;
            }
            // 即時解放
            var e = lockObjects_[id];
            e.Set();
            return data;
        }

        /// <summary>
        /// 排他を考慮してセッションデータを取得
        /// </summary>
        /// <param name="context"></param>
        /// <param name="id"></param>
        /// <param name="locked"></param>
        /// <param name="lockAge"></param>
        /// <param name="lockId"></param>
        /// <param name="actions"></param>
        /// <returns></returns>
        public override SessionStateStoreData GetItemExclusive( HttpContext context, string id, out bool locked, out TimeSpan lockAge, out object lockId, out SessionStateActions actions )
        {
            bool lockTaken = false;
            var e = lockObjects_.GetOrAdd( id, ( i ) => new ManualResetEvent(true) );
            lockAge = TimeSpan.FromHours( 1 );
            lockId = id;
            lockTaken = e.WaitOne(TimeSpan.FromSeconds(1));
            if (!lockTaken) {
                actions = SessionStateActions.None;
                locked = true;
                return null;
            } else {
                e.Reset();
                locked = false;
            }
            SessionDataCollection temporary;
            actions = sessionItems_.TryGetValue( id, out temporary ) ? SessionStateActions.None : SessionStateActions.InitializeItem;
            var item = sessionItems_.GetOrAdd( id, ( i ) => new SessionDataCollection( client_, i ) );
            return new SessionStateStoreData( item, null, TimeSpan.FromHours( 1 ).Milliseconds );
        }

        /// <summary>
        /// 接続の開始
        /// </summary>
        /// <param name="context"></param>
        public override void InitializeRequest( HttpContext context )
        {
            Connect();
        }

        /// <summary>
        /// GetItemExclusiveが利用された際に排他が終わったら呼び出される
        /// </summary>
        /// <param name="context"></param>
        /// <param name="id"></param>
        /// <param name="lockId"></param>
        public override void ReleaseItemExclusive( HttpContext context, string id, object lockId )
        {
            ManualResetEvent e;
            if (lockObjects_.TryGetValue( id, out e )) {
                e.Set();
            }
        }

        /// <summary>
        /// アイテムを削除する
        /// </summary>
        /// <param name="context"></param>
        /// <param name="id"></param>
        /// <param name="lockId"></param>
        /// <param name="item"></param>
        public override void RemoveItem( HttpContext context, string id, object lockId, SessionStateStoreData item )
        {
            SessionDataCollection data;
            if ( sessionItems_.TryGetValue( id, out data ) ) {
                data.Remove( id );
            }
        }

        /// <summary>
        /// 有効期限が更新される
        /// </summary>
        /// <param name="context"></param>
        /// <param name="id"></param>
        public override void ResetItemTimeout( HttpContext context, string id )
        {
        }

        /// <summary>
        /// データを更新しつつ排他を解放する。
        /// セッション変数が変更された場合に呼び出される。変更されていない場合はReleaseItemExclusiveが呼び出される。
        /// </summary>
        /// <param name="context"></param>
        /// <param name="id"></param>
        /// <param name="item"></param>
        /// <param name="lockId"></param>
        /// <param name="newItem"></param>
        public override void SetAndReleaseItemExclusive( HttpContext context, string id, SessionStateStoreData item, object lockId, bool newItem )
        {
            (item.Items as SessionDataCollection).Update( id );
            if ( newItem ) {
                sessionItems_.AddOrUpdate( id, ( i ) => item.Items as SessionDataCollection, ( i, o ) => item.Items as SessionDataCollection );
            }
            ReleaseItemExclusive( context, id, lockId );
        }

        /// <summary>
        /// 有効期限切れのコールバックの指定
        /// </summary>
        /// <param name="expireCallback"></param>
        /// <returns></returns>
        public override bool SetItemExpireCallback( SessionStateItemExpireCallback expireCallback )
        {
            return false;
        }
    }
}

ながーくなりました。半日くらいでサクッと作りました。

ちゃんと確認していないのと有効期限を考慮していない為不十分ではありますが、とりあえず動きます。

おそらく実際サービスに利用するには厳しい出来だと思います。

特に留意が必要な項目としては

ISessionStateItemCollection

これはSession[“”]でセッション変数を操作するときの本体です。

ISessionStateItemCollectionを実装すると必然的にNameObjectCollectionBaseを継承する必要が出てきます。

継承した後はNameObjectCollectionBaseのBase***メソッドを利用してコレクションを操作します。

マルチスレッド

次に気を付けるべきがマルチスレッドのようです。

このインスタンスはマルチスレッドで呼び出される為、それを考慮した実装が必要です。

特にExclusiveがついた名前のメソッドでは独自の排他処理を自前で行う必要があります。

今回はとりあえずManualResetEventで適当に実装してます。(MonitorとかMutexだと上手くできないので)

MSDN

一応この辺のメソッドで実装する内容はMSDNに記載があります。

 

Web.configを設定する

セッション管理をこのプロバイダを利用するように設定します。

Web.configの<system.web>内に以下を記述します。

    <sessionState
        mode="Custom"
        timeout="2629800"
        customProvider="MemcachedSessionStateStoreProvider">
      <providers>
        <add name="MemcachedSessionStateStoreProvider" type="memcachedtest.MemcachedSessionStateStoreProvider" connectionStringName="" />
      </providers>
    </sessionState>

大事なのはmodeとproviderです。

customProviderにはその直後のproviders内のnameを指定します。

providersではtypeにクラスの名前をパッケージ込みで指定します。理想は完全修飾名ですが、とりあえず解決さえできれば大丈夫です。

最後に

Customプロバイダーは割と実装面倒だなーという感想です。あとはmemcachedのクライアントもあまり無いのもネックです。

 

……ここまで書いといて何ですが、実はmemcached用のSessionStateStoreProviderはnugetに存在していますので、そっちを使った方が良いと思います。

タイトルとURLをコピーしました