WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

Session 情報のストアに SQL Server を利用 (CORE)

by WebSurfer 2022年3月13日 15:52

ASP.NET Core アプリで Session 状態を利用する際に SQL Server をストアとして利用する方法を書きます。下の画像の赤線部分は Session 情報を Controller で ViewBag に設定しそれを表示したものです。

Session 状態の表示

ASP.NET Core アプリでの Session の使い方の詳しい話は Microsoft のドキュメント「ASP.NET Core でのセッションと状態の管理」にあります。

それを読めば大体分かると思いますが、.NET Framework 版の ASP.NET アプリの Session との大きな違いで自分が気が付いた点を以下にまとめておきます。

  1. Program.cs (.NET 6.0) または Startup.cs (.NET 5.0 以前) でサービスとミドルウェアの登録が必要。
  2. ASP.NET Core の分散キャッシュをデータのストアに利用する。 (なので、メモリ内キャッシュを使用する場合であってもセッションデータはシリアル化可能なでなければならない)
  3. String 型、Int32 型、Byte[] 型以外のデータ型は格納できない。(String 型、Int32 型、Byte[] 型以外のデータを格納する場合は JSON 文字列にシリアライズするなどの方法で格納する)
  4. 同時アクセスでもロックされず後書き優先になる。 (先の記事「SessionStateModule によるロック」で書いたロック機能は提供されてない)
  5. StateServer モードに相当する機能は「分散 Redis キャッシュ」を使って実現するらしい。(未検証・未確認です)

上の 2 に書いたように Session 情報のストアには分散キャッシュを利用するのですが、それには以下があるそうです。詳しくは Microsoft のドキュメント「ASP.NET Core の分散キャッシュ」を見てください。

  • 分散メモリ キャッシュ - AddDistributedMemoryCache
  • 分散 SQL Server キャッシュ - AddDistributedSqlServerCache
  • 分散 Redis キャッシュ - AddStackExchangeRedisCache
  • 分散 NCache キャッシュ - AddNCacheDistributedCache

上の「分散メモリキャッシュ」というのは、サーバーで実行されている Web アプリのメモリを使って ASP.NET Core の分散キャッシュを実現するためのもので、実際に「分散」されているわけではないそうです。

「分散メモリキャッシュ」を利用しての Session 状態の構成方法や使い方は Microsoft のドキュメント「ASP.NET Core でのセッションと状態の管理」に詳しく書いてありますのでそれを読めば容易に実装できると思います。

この記事では「分散 SQL Server キャッシュ」を利用しての Session 状態の構成方法を書きます。以下の説明と合わせて、Microsoft のドキュメントの「分散 SQL Server キャッシュ」のセクションも見てください。

(1) SQL Server のテーブルを作成

sql-cache create コマンドを実行して SQL Server にテーブルを作成します。以下の点に注意してください。

  • dotnet-sql-cache ツールをインストールしておく必要があります。dotnet tool install --global dotnet-sql-cache コマンドでインストールできます。
  • コマンドの接続文字列の Initial Catalog に設定するデータベースは事前に作成しておく必要があります。その際、sql-cache create コマンドを実行するユーザーのアカウントが SQL Server のログインに設定されていて、そのデータベースに対してテーブルを作成する権限を持っている必要があります。

この記事では開発マシンの SQL Server 2012 Express の既存のデータベース TestDatabase に Windows 認証で接続し、TestCache という名前でテーブルを作成しました。その結果は以下の画像の通りです。

SQL Server のテーブル

Microsoft ドキュメントの説明とは Id の Data Type が nvarchar(900) ではなくて nvarchar(499) と異なりますが、その他は同じ内容で TeestCache テーブルが生成されています。

(2) NuGet パッケージのインストール

NuGet パッケージ Microsoft.Extensions.Caching.SqlServer をインストールします。下の (3) 項でのサービスの登録で AddDistributedSqlServerCache を使うのに必要になります。

Microsoft.Extensions.Caching.SqlServer

(3) サービスとミドルウェアの登録

Program.cs (.NET 6.0) または Stratup.cs (.NET 5.0 以前) でサービスとミドルウェアを登録します。以下の例は Visual Studio 2022 で作成した .NET 6.0 の Program.cs での例です。

// ・・・前略・・・

// SQL Server 分散キャッシュを使用
// "DistCacheConnectionString" は appsettings.json に設定する
// SQL Server への接続文字列
builder.Services.AddDistributedSqlServerCache(options =>
{
    options.ConnectionString = 
        builder.Configuration.GetConnectionString(
                               "DistCacheConnectionString");
    options.SchemaName = "dbo";
    options.TableName = "TestCache";
});

// AddSession の呼び出し
builder.Services.AddSession(options =>
{
    // デフォルトは 20 分。検証用に 300 秒に設定
    options.IdleTimeout = TimeSpan.FromSeconds(300);

    options.Cookie.HttpOnly = true;  // デフォルトで true

    // デフォルトは false。true に設定しないとセッション状態は
    // 機能しない可能性あり
    options.Cookie.IsEssential = true;  
});

// ・・・中略・・・

// UseSession の呼び出し
// 順序が重要で、UseRouting の後かつ MapRazorPages と 
// MapControllerRoute の前に呼び出します
app.UseSession();

// ・・・後略・・・

(4) appsettings.json に接続文字列を設定

上の (3) 項の AddDistributedSqlServerCache メソッドで指定した "DistCacheConnectionString" という名前で構成ファイルから接続文字列を取得できるよう、appsettings.json ファイルに以下の設定を追加します。

"ConnectionStrings": {
  "DistCacheConnectionString": 
    "Data Source=(local)\\sqlexpress;Initial Catalog=TestDatabase;Integrated Security=True;"
}

上の接続文字列は SQL Server Express の sqlexpress という名前付きインスタンスにアタッチされている TestDatabase というデータベースに Windows 認証でローカルに接続するものです。そのあたりは個人の環境に合わせて変更してください。


以上で Session 状態は使えるようになります。

例えば HomeController で以下のように Session を設定して実行し、Home/Privacy に遷移した後で Home/Index に戻るとこの記事の一番上の画像の赤線で示したように Session データが表示されます。

public IActionResult Index()
{
    // Session は home/privacy で設定
    ViewBag.Name = HttpContext.Session.GetString("name");
    ViewBag.Test = HttpContext.Session.GetString("test");
    HttpContext.Session.Clear();

    return View();
}

public IActionResult Privacy()
{
    // Session の設定
    HttpContext.Session.SetString("name", "session value");
    HttpContext.Session.SetString("test", "test value");

   return View();
}

SQL Server に格納された Session データは以下のようになります。Chrome と Edge を立ち上げて両方からアクセスした結果で、それぞれ Session Id (デフォルトで .AspNetCore.Session という名前の cookie に格納されている) が異なるので、TestCache テーブルには Id が異なるレコードが 2 つ存在するという結果になっています。

SQL Server に格納された Session データ

上の画像のレコードはクライアントがブラウザを閉じてもそのまま残ります。と言っても、それが再利用されることはなく、再度ブラウザを立ち上げてアクセスして Session を使うと別の Id でレコードが追加されます。

上の Home/Index のコードにある HttpContext.Session.Clear() でも SQL Server のレコードは削除されません。Session Cookie も削除されません。Value の中のデータが削除され、ExpiresAtTime がその時点から AddSession メソッドの options.IdleTimeout で設定された時間まで延長されるのみです。

サーバー側で Session を使っているユーザーがオンラインか否かを判定する術はなく、サーバーではレコード削除の可否を判断できないのでそうせざるを得ないのではないかと思います。

ということはレコードは削除されずどんどん増えていく一方になるのではと思いましたが、翌日にまた調べてみたら、SQL Server には昨日のレコードは残っていたが、アプリを動かしてみると昨日のレコードは消えました。どうやら、アプリを起動する際(多分 Program.cs のコードが実行される時)に ExpiresAtTime が古いレコードは消去されるらしいです。

IIS を使ってのインプロセスホスティングモデルであればワーカープロセスがリサイクルされる時に Program.cs のコードが実行される(古いレコードは削除される)と思いますが、それ以外のホスティングモデルで Kestrel が使われる時はどうなるかは分かりません。そこは今後の検討課題ということにしたいと思います。

Tags: , ,

CORE

SessionStateModule によるロック

by WebSurfer 2018年4月6日 18:21

ASP.NET Web アプリで Session を利用する場合、同一ユーザー(= SessionID が同じユーザー)が同時にアクセスすると、最初の要求に受けた時点で Session へのアクセスがロックされるので、最初の要求に対する応答が返されるまで次の要求は待たされるという話を書きます。

(元の話は MSDN フォーラムのスレッド「非同期ポストバック中のキャンセルとその後のポストバックについて」です)

非同期ポストバック中のキャンセル

SessionStateModule によるロックメカニズムはマイクロソフト公式解説書「プログラミング Microsoft ASP.NET 4」という本の 17.2.1 章「セッション状態 HTTP モジュール」の「セッション状態へのアクセスの同期」というセクションに詳しく書いてあります。その一部を抜粋すると:

"セッション状態モジュールはリーダー / ライター方式のロックメカニズムを実装し、状態値へのアクセスをキューで管理します。セッション状態への書き込みアクセスを許可されたページは、リクエストの処理が終了するまで、そのセッションのライターロックを保持します。 ・・・(中略)・・・ セッション状態への読み取りアクセスを許可されたページは、リクエストの処理が終了するまでセッションのリーダーロックを保持します"

上記の通り、Session を使うケースでは、応答が返されるまで Session へのアクセスはロックされ、その間次の要求は処理されないのですが、普通に同期ポストバックを行っている限り、それにユーザーが気が付くことはなさそうです。

ところが、ScriptManager + UpdataPanel を利用して非同期ポストバックで要求を出すようにし、それにキャンセル機能を実装した場合は話が違ってきます。

ユーザーが、処理に長い時間がかかる「要求 A」を出したが、応答が返ってくるのを待ちきれないので、一旦その要求をキャンセルして、処理に時間のかからない(=すぐ応答が返ってくる)「要求 B」を出すというケースを考えてみてください。

ユーザーは、「要求 A」はキャンセルしたのだから、「要求 B」はすぐ処理されて応答が返ってくると期待するはずです。

ところが Session を利用しているアプリの場合はユーザーの期待通りにはなりません。「要求 A」をキャンセルする / しないにかかわらず、「要求 B」の応答が返ってくるのは、普通に「要求 A」を処理する時間だけ待たされた後になります。

ユーザーが「要求 A」をキャンセルしてもサーバー側での実行が中断されるわけではなく、サーバーは「要求 A」を処理して応答を返すところまで行うということがポイントです。

Session を利用していない場合、「要求 B」をサーバーが受けると、先に受けた「要求 A」の処理と並行して、直ちに「要求 B」の処理が始まって応答が返されます。結果、ユーザーの期待通り、処理に時間のかからない「要求 B」の応答はすぐ返ってきます。

ところが、Session を利用している場合、Session ロックによって、「要求 A」の処理が終わって応答が返されるまで、「要求 B」の処理は待たされます。結果、「要求 A」の処理のキャンセルが効いてないように見えます。

下の画像を見てください。この記事の下の方に示したサンプルコードを実行し、Fiddler で要求・応答をキャプチャしたものです。

Fiddler によるキャプチャ

実行手順は以下の通りです。

  1. [非同期]ボタンをクリックして非同期ポストバックによる「要求 A」をかける。(この記事の一番上の画像がその時のもの)
  2. [Cancel]ボタンをクリックして「要求 A」をキャンセル。
  3. [同期]ボタンをクリックして「要求 B」をかける。

上の Fiddler のキャプチャ画像で、キャンセルした上記 1 の要求もサーバー側では正しく処理され、完全な応答が返ってきているのが分かるでしょうか? (ただし、ブラウザで abort されています)

問題のページで Session に書き込んでいる場合は何ともならないですが、そうでない場合は以下の解決策で対応できるはずです。

  1. 他のページで Session を使っているが、問題のページでは使ってなければ EnableSessionState を False に設定する。
  2. 問題のページも Session を使っているが、読み取りのみの場合は EnableSessionState を ReadOnly に設定する。

なお、自分では Session は使っていないつもり(コードで明示的に Session["Data"] = xxx のようなことはしていない)でも、Global.asax に Session_Start ハンドラがあるとすべてのページで Session を使っているのと同じことになります。

Visual Studio のテンプレートを使ってプロジェクトを作ると、Global.asax に空の Session_Start ハンドラが生成されることがありますので注意してください。

最後に、この記事を書くときに検証用に使ったサンプルコードを以下に書いておきます。

<%@ Page Language="C#" EnableSessionState="ReadOnly" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<script runat="server">
    protected void Button_Click(object sender, EventArgs e)
    {
        System.Threading.Thread.Sleep(5000);
    }

    protected void Button2_Click(object sender, EventArgs e)
    {
        
    }
</script>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
  <script type="text/javascript">
  //<![CDATA[
  var manager;

  function pageLoad(sender, args) {
    if (args.get_isPartialLoad() === false) {
      manager = Sys.WebForms.PageRequestManager.getInstance();
      manager.add_initializeRequest(OnInitializeRequest);
      manager.add_endRequest(OnEndRequest);
    }
  }

  function OnInitializeRequest(sender, args) {
    $get("<%=UpdatePanel1.ClientID%>").style.display = "none";
  }

  function OnEndRequest(sender, args) {
    $get("<%=UpdatePanel1.ClientID%>").style.display = "block";
  }

  function AbortPostBack() {
    if (manager.get_isInAsyncPostBack()) {
      manager.abortPostBack();
    }
  }
  //]]>
  </script>

    <style type="text/css">
    #UpdatePanel1 { 
      width: 200px; 
      height: 200px; 
      border: gray 2px solid;
      position: relative;
      float: left; 
      margin-left: 10px; 
      margin-top: 10px;
    }

    #UpdateProgress1 {
      width: 400px; 
      background-color: #FFC080;
      border: gray 1px solid;
      /*bottom: 0%; 
      left: 0px; 
      position: absolute;*/
    }

  </style>

</head>
<body>
    <form id="form1" runat="server">
    <asp:ScriptManager ID="ScriptManager1" runat="server">
    </asp:ScriptManager>

    <asp:Button ID="Button1" runat="server" 
        Text="非同期" OnClick="Button_Click" />  
    <asp:Button ID="Button2" runat="server" 
        Text="同期" OnClick="Button2_Click" />
    <br />
    <asp:UpdatePanel ID="UpdatePanel1" runat="server">
        <ContentTemplate>
            UpdatePanel
            <hr />            
            <%=DateTime.Now.ToString() %> <br />
            
            <br /><br />
            [非同期]ボタンをクリックすると、
            5 秒後にこのパネル内が更新されます。
            その間 UpdateProgress が表示されます。
        </ContentTemplate>
        <Triggers>
            <asp:AsyncPostBackTrigger ControlID="Button1" 
                EventName="Click">
            </asp:AsyncPostBackTrigger>
        </Triggers>
    </asp:UpdatePanel>

    <asp:UpdateProgress ID="UpdateProgress1" runat="server">
        <ProgressTemplate>
            非同期ポストバックで更新中です・・・  
            <input type="button" value="Cancel" 
                onclick="AbortPostBack()" />            
        </ProgressTemplate>
    </asp:UpdateProgress>
    </form>
</body>
</html>

Tags: ,

ASP.NET

ReportViewer と Session

by WebSurfer 2015年9月7日 15:22

ReportViewer は Session を使うという話を書きます。

IE で ReportViewer を表示

上の画像は、ReportViewer を使用してパラメーターを含む詳細 (RDLC) レポートを作成する というチュートリアルに従って作ったものですが、どこにも明示的に Session を使うようなコードは含まれていません。

ところが、このページを要求すると、下の画像(Fiddler2 でキャプチャしたもの)のように応答ヘッダにセッション Cookie が設定され(画像の赤枠で囲った部分)、Session の使用が開始されます。

応答ヘッダのセッション Cookie

知ってました? 実は自分は知らなかったです。(汗) 最近まで ReportViewer と Session は何の関係もないと思っていましたが、MSDN フォーラム でのやり取りを通じてこのことを知りました。

MSDN Blog の記事 Did Your Session Really Expire? によると、ReportViewer は再生するのが簡単ではないデータを保存するのに Session を使うとのことです。

そして、ポストバックの際などに期待したデータが Session にないと AspNetSessionExpiredException をスローするとのことです。

AspNetSessionExpiredException 例外がスローされる原因として次のことが書かれています:

  1. 本当にセッションがタイムアウトした。(例:ping をかけてタイムアウトにならないようにしているが、サーバーが忙しいとか接続が不安定で失敗した)
  2. 要求を処理しているサーバーにセッションがない。(例:セッションのモードが InProc で、Web Garden / Farm 構成になっているとか、ワーカープロセスがリサイクルされた)
  3. ブラウザがクッキーを受け付けない設定になっている。

上記 1 のことは MSDN ライブラリの ReportViewer.KeepSessionAlive プロパティ に書かれています。デフォルトは true で、セッションがタイムアウトしないように自動的に ping がかかります。

上記 2 のワーカープロセスのリサイクルによる Session の消失を防ぐためには、Session の格納場所を InProc ではなく、StateServer または SQLServer にするのがよさそうです。商用に使うのであれば、InProc モードは論外という意見もあるようですし。設定方法の詳細は MSDN ライブラリの記事 セッション状態モード を見てください。

Tags: ,

ASP.NET

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年3月  >>
252627282912
3456789
10111213141516
17181920212223
24252627282930
31123456

View posts in large calendar