BEACHSIDE BLOG

Azure と GitHub と C# が好きなエンジニアの個人メモ ( ・ㅂ・)و ̑̑

GitHub Actions で ASP.NET MVC ( .NET Framework 4.8 ) を Azure App Service (Web App) へデプロイ

ASP.NET MVC ( .NET Framework 4.8 ) のアプリを GitHub Actions での CI/CD して Azure の App Service (Web Apps) へのデプロイするまでの方法を書いていきます。

ここら辺のドキュメントとか情報は少ないのが書こうと思ったモチベーションでした...と思って下書きして数か月放置してして今に至ります。

序盤はビギナー向けに GitHub の UI で初めて YAML を書くときの流れや Tips をダラダラ書いています。
ビギナーじゃない方には、後半のセクション: YAML のコード全体 もおいてるのでそれだけ見ればいい感もあります。

準備: ASP.NET Web アプリケーション (.NET Framework) のプロジェクト作成

今回は ASP.NET MVC (.NET Framework) のアプリの CI/CD の話なので、VS 2022 で ASP.NET Web アプリケーション (.NET Framework) を選びます。ここら辺は説明雑に進めます。

今回は MVC のテンプレートを選びましたが、Web API や空のやつでもシンプルに C# /Razor だけでコード書くなら一緒だと思います。SPA はビルド時に色々あるのかなとかだし Web Form は使う気がないので試す気はないので、この二つは対象外。

プロジェクトができてフォルダーを見たら .gitignore のファイルが無かったので、.sln ファイルがあるフォルダで以下のコマンドで作成しました。

dotnet new gitignore

ちなみにルートのフォルダはこんな感じ。ルートに sln ファイルがあります。csproj のパスは .\AspnetMvc48\AspnetMvc48.csproj です。

あとは GitHub 上でやろうと思うので、GitHub の repo に push しちゃいます (ローカル環境で YAML 書いても別に一緒ですが) 。

GitHub Actions の作成

今回は入門向けに GitHub Actions の UI 上で workflow を作る方法で進めます。

慣れてくると GitHub Actions で実際に使う Action が公開されている repo の README を確認しながらやった方が早いかもです。

YAML ファイルの追加

GitHub でプロジェクトを置いた repo を開き Actions タブをクリックします。GitHub Actions の workflow が存在しない場合はこんな画面になるので、set up a workflow yourself をクリックします。

既になんらかの workflow がある場合は New workflow ってボタンをクリックすると上の画面に行きます。

これで、ファイルができるので、まずは画面上部にファイル名が入力できます。わかりやすい名前をつけてあげましょう。ここでは aspnet-netframework-cicd.yml としました。

あとは name と以下のコードを書きます。

name: ASP.NET MVC (.NET Framework 4.8) to App Service

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
  workflow_dispatch:

jobs:
  build:
    runs-on: windows-latest

    steps:
      - uses: actions/checkout@v3

先頭の name は Actions タブを開いた際にこんな感じで見えます。後ろの文字までは見えないので、その点を考慮して UI 上で判別しやすい名前を付けてあげましょう。

あとは on とか最初の actions/checkout は毎度のことなので説明は省きます。バージョンはちょいちょいアップデートがあるのでドキュメント見ながら進めましょう。

msbuild のセットアップ

.NET Framework のビルドには MSBuild を使うのでそれを入れます。使うアクションは以下です。

何使えばわからないときの探し方として、まずは右側の Marketplace の検索で「msbuild」と入れればなんか出てくることがほとんどなので、検索しましょう。

microsoft が作った setup-msbuild がでるのでクリックするとこんな画面になります。Version を選んでコピーボタンクリックして貼り付けてながらコメントみるのもよいですし、View full Marketplace listing をクリックすると使い方の説明が書いてあることが多いのでそこのコードをはるのもよいです。

今回はシンプルにこれだけを追加します。

      - name: Add msbuild to PATH
        uses: microsoft/setup-msbuild@v1.1

NuGet のセットアップと NuGet パッケージの復元

NuGet をセットアップして Restore する

まずは NuGet の復元を NuGet CLI で行う方法を書きます。前述同様に検索したりして見つけて使いましょう。

setup と restore で追加するコードはこちら。今回はルートに AspnetMvc48.sln がある前提で nuget restore をしていますが、そうでない場合はただしいパスを指定しましょう。

      - name: Setup NuGet
        uses: nuget/setup-nuget@v1

      - name: Restore NuGet packages
        run: nuget restore AspnetMvc48.sln

msbuild のオプションで復元も可能

この方法は諸条件が必要だったりするのでドキュメントのリンクだけにします。

ビルド

msbuild の実行

後は msbuild コマンドでビルドを実行するだけです。

今回は .sln ファイルのパスは .\AspnetMvc48.sln なのでそのファイルを指定します。

     - name: Build
        run: msbuild AspnetMvc48.sln /p:Configuration=Release /p:DeployOnBuild=true /p:DeployDefaultTarget=WebPublish /p:WebPublishMethod=FileSystem /p:publishUrl=${{ github.workspace }}\publish /p:PrecompileBeforePublish=true /p:EnableUpdateable=false

色々とオプションがついていますが、これは Azure の App Service (Web App) に最適なデプロイオプションだと思ってください。詳しい話はしばやんのブログが一番わかりやすいです。公式ドキュメントでは探しにくいです。

Azure Pipelines から Run From Package としてデプロイする - しばやん雑記

その他参考になるドキュメントとして、直接的なプロパティのドキュメントではないし .NET Core のドキュメントなので msbuild の話から多少それるんですが、以下のドキュメント書かれてるプロパティの項目・使い方は参考にしてます。

ASP.NET Core アプリを配置するための Visual Studio 発行プロファイル (.pubxml) | Microsoft Learn

より実践的な話として、このジョブ内で単体テストや .editorconfig を使ったフォーマットのチェックなど、p実行したいところですが、本質からはずれるのでここでは書いてません。

ポイント1 : プリコンパイルのオプション

ASPNET MVC の プリコンパイル をするオプションである /p:PrecompileBeforePublish=true は、プロジェクトの構成によっては仕様上エラーでできないこともあるので、エラーになる際は外しましょう。

ポイント 2: ビルド成果物 (build artifact) の出力パスの指定

ここでは build artidact は /p:publishUrl=${{ github.workspace }}\publish と指定しました。GitHub Actions の github context を使っています。

相対パスで書いても全然 OK です。
ただ、より複雑なワークフローを作ってると workind-directory を使ったりコマンド内でパスを変更することがあると出力のパスが変わって後続の処理で影響が出るケースもことがある `${{ github.workspace }}`` を明示的に書いています。

(と、私がむかーしはまったので、個人的にはこういう策をとっています)

ポイント 3: GitHub Actions の msbuild の実行ログ

GitHub Actions で msbuild 実行時にログに大量に出てよくわからんとかログをもっと出したいとかは、ログのレベルを変えることで見やすくできます。以下ドキュメントリンクで -verbosity:level と書かれている部分に設定できるオプションの記載があります。省略形だと -v:m とか -v:diag とかで指定します。エラーの解析時にあまりにエラーが多いときは m (minimal) だと比較的エラーのみをすっきり出してくれます。

ポイント 4: コマンドの実行はまずローカルで試そう

msbuild のコマンドのエラーがでると GitHub Actions 上で調査は生産性が低いので、まずは実行したいコマンドをローカル PC で実行して動くことを試しましょう。その際、先述の github コンテキストを使ったパスの指定は自分の PCのパスや相対パスに直す必要があります。

Artifact のアップロード

GitHub Actions では、ジョブ間でファイルを共有する場合、専用のストアへアップロードする必要があります。
ジョブ自体は基本的に個別に動いているため、特定のジョブでファイルを GitHub Actions のホストに置いたからといって別のジョブではそのファイルは存在しないのです。

アップロードは actions/upload-artifact を使うだけで簡単です。

name は適当ですが、アップロードするファイルのパスはビルド時に指定した場所になります。これでこのジョブは完了です。

      - name: Upload Artifact
        uses: actions/upload-artifact@v3
        with:
          name: app
          path: ${{ github.workspace }}\app.zip

Artifact のデプロイと App Service へのデプロイ

前のジョブでアップロードした build artifact をダウンロードするのは actions/download-artifact を使ってサクッとできます。

App Service へのデプロイは azure/webapps-deploy を使っているだけなのでここでは詳しく書きませんが azure/webapps-deploy の README みれば。

 deploy:
    needs: build
    runs-on: windows-latest
    steps:
      - name: Download app
        uses: actions/download-artifact@v3
        with:
          name: app
          
      - name: Deploy to App Service
        uses: azure/webapps-deploy@v2
        with: 
          app-name: "app-eru-netframework-mvc"
          publish-profile: ${{ secrets.AZURE_APP_SERVICE_PUBLISH_PROFILE }}
          package: ${{ github.workspace }}\app.zip

補足な余談として、Azure へのログインはこれでやるのが現時点のベストな方法です。

blog.beachside.dev

YAML のコード全体

作るための手順を長々と書きましたが、できたものはこちら。

利用している action のバージョンはこのブログ書いてる時点の最新版なので、未来でこれを参考にする際は最新のバージョンを確認しながら構成していきましょうね。

まとめ

.NET Core にしたらもうちょっとしゅっとできて平和になります。

ASP.NET Core の SPA の Template が動かない(dist のフォルダーが作られない)

事象の再現方法は、

  • ASP.NET Core の SPA の Tempates から Angular のプロジェクトを作成(ここでは正常に動きます)
  • GitHubVSTSにPushする。
  • (別の人が)リモートからクローンしたソースをデバッグでエラーが出て動かない

というものです。

ちなみに.NET Core 1.1で作った際の事象です。Preview 版で出てる2系ではGitHubのコードを見る限り修正済みになってました♪

続きを読む

ASP.NET Core と Angular2 と webpack で 開発 のことはじめ

2017年02月追記 >>>>>
.NET CoreのSDKの更新により、Angularのほか、ReactやAureliaとかのプロジェクトの作成もサクッとできるようになりました♪
ということで、以下の記事を見た方がよいかもしれません♪

React も Angular も ASP.NET Core で Get Started♪ - BEACHSIDE BLOG

<<<<<      

ASP.NET CoreにAngular2とwebpackを使って開発する際のセットアップ方法を書いていきます。AngularはもちろんTypescriptで書くようセットアップします。

ということで、Advent Calendarの3日目です。
qiita.com

Overview

続きを読む

ASP.NET5 MVC6 でのModelStateの単体テスト

ASP.NET 5 MVC6 と xUnit で ModelState の単体テストを書こうのコーナーです。

「ModelStateの単体テストコード、うまくできませぬ」と言われたのでコードを見せてもらったら、コントローラーをnewしてなんちゃらしていた事件があったので、書いておこうと思いました。

f:id:beachside:20160202230255p:plain

Environment

  • Visual Studio 2015 Enterprise Update1
  • ASP.NET and Web Tools 2015 (RC1 Update 1)
  • xUnit(2.2.0-beta1..あたり)

Overview

ASP.NET5 MVC6 のプロジェクトを作って、Depenency Injection(DI)について、4種類がどう異なるのかを見たい今日この頃です。

いつものことですが、Visual Studioは英語版なので日本語の正確な項目名わかりません...涙。
それにしてもASP.NET5が 「ASP.NET Core 1.0」に名称変わるのでタイトルもそう書きたいところですが、名称変更となるRC2、今月(2016/2)リリース予定がTBDに戻ってたので、もう少しこの名称で書いておきましょう。。。

続きを読む

ASP.NET MVC5 とかでバックグラウンド処理( QueueBackgroundWorkItem 編)

f:id:beachside:20160130162918p:plain
AzureをプラットフォームにしてASP.NETで、戻りを待つ必要のないおもし蟹な重い処理をバックグラウンド処理として実装したいなーと思うと、

  • QueueBackgroundWorkItem でサクッと実装?
  • WebJobsでささっと実装
  • Worker Roleで?
  • Azure Batchでごりっごり...

その他、Web Roleなどもあると思いますが、それはさておき...

バックグラウンド処理をする際の手法の選択は、その処理数や処理自体の重さに応じて、どのリソースを使うべきかに応じて選択肢が変わりますでしょうか。
今回は、色んな意味で簡易・容易な QueueBackgroundWorkItem のお話です。

> Environment

サンプルを書いた環境は以下です。

DNX周りの話ではないのでご注意を。

1. QueueBackgroundWorkItem 概要

QueueBackgroundWorkItem は、.NET Framework 4.5.2で実装された機能で、それ以前の.NET Frameworkではもちろん使えません。
リリースノートはこちら。
What's New in the .NET Framework
IIS がすべての処理が完了するまで待ってくれるようなので、安心安定ですね。

QueueBackgroundWorkItem は、System.Web.Hosting 名前空間は、HostingEnvironmentクラスのメソッドです。
2つのオーバーロードを持ってます。

ざっくりまとめると、
QueueBackgroundWorkItemとは....
「QueueBackgroundWorkItemメソッドにデリゲートを渡せばバックグラウンド処理してくれる」
ということです。

最も簡単に実装できる類のバックグラウンド処理かなーと思ってます。

注意点として、
IISの環境下でバックグラウンド処理をしてくれるので、それ以外だとあたーーーりまめに死にます。
以前、QueueBackgroundWorkItemを使って、Worker Roleで動かないと悩んでいた方が相談に来たことがあったので...念のため書いておきました。

2. QueueBackgroundWorkItem の実装

おもむろにASP.NET MVC5プロジェクトを作り、HomeControllerクラスのIndexメソッドに例を追記してみましょう。
workItemというFuncを定義して渡すだけです。
Funcには、LongLongMethodAsyncメソッドを用意しておきました。

public ActionResult Index()
{
    Func<CancellationToken, Task> workItem = LongLongMethodAsync;
    HostingEnvironment.QueueBackgroundWorkItem(workItem);

    return View();
}

private async Task LongLongMethodAsync(CancellationToken cancellationToken)
{
    //なんか重い処理...
    await Task.Delay(10000);
}

ActionやFuncを定義せず、ラムダ式で書いてしまうのもよく使いますでしょうか。

public ActionResult Index()
{
    HostingEnvironment.QueueBackgroundWorkItem(cancellationToken =>
    {
        // なにかおもーい処理を...
    });
//...以下省略

ラムダか~ら~の~awaitableな何かを呼びたい場合は、QueueBackgroundWorkItemのパラメータの最初にasyncを書いて...

public ActionResult Index()
{
    HostingEnvironment.QueueBackgroundWorkItem(async cancellationToken =>
    {
      await LongLongMethodAsync(cancellationToken);
        // なにかおもーい処理を...
    });
//...以下省略

ですね。

次は、WebJobsを書く予定....

ASP.NET Core ( 旧 ASP.NET5 MVC6) でDI ( Dependency Injection )の設定

ASP.NET5 MVC6 (名前が新しくなって) ASP.NET Core で Depenency Injection(依存性の注入)をする際のメモです。

> この記事自体が ASP.NET5 MVC6 という旧称の時代に書かれたものですが、中身は変わってないです。

本家のドキュメントがあります。(あ....結構前に見たときから更新されてる......)
Dependency injection in ASP.NET Core | Microsoft Docs

MSDNのBlogでは、2014年に出ているので、既に古いネタと言える内容ですね....
ASP.NET Blog | Dependency Injection in ASP.NET vNext

> Environment

  • Visual Studio 2015 Enterprise
  • ASP.NET and Web Tools 2015 (RC1 Update 1)

1. ASP.NET5 の Depenency Injection で設定できる種類

DIの設定は、Startup.csの中にあるConfigureServicesメソッドの中で設定します。
(といっても..たくさんDIするならメソッド化した方が良いとは思いますが)

DIを設定するための種類は、4種類あります。
ライフサイクルの長い順にざっくり説明すると....

種類 概要
Instance Web サーバー(って表現は正しい?)のインスタンス単位で作成される
Singleton インスタンスの単位は「Instance」と同じですが、lazy load
Scoped 1リクエストに対して、1インスタンス
Transient インスタンス生成時、常に新しいインスタンスが作られる

と(私は)理解しています。
「Instance」は、ConfigureServicesでインスタンス化して、シングルトン。
「Singleton」は、使われるタイミングでインスタンス化してシングルトン。
「Scoped 」と「Transient 」は上記通り、そして、今回の動作検証で最も確認したいとこ。

余談ですが、
インスタンス化...って、私はよく聞くし、だから私も言いますが、
英文でmaterializeって書いてあった本があったんですが、インスタンス化って言わない方がいいの?和製英語なの?とふと疑問..

では、作って動作見てみます。

2. Implimentation: ASP.NET5 MVC6プロジェクトの作成とDIするクラスの準備

Visual Studioでプロジェクトを作りますが、ここら辺の作業は省略します。

で、DIするクラスを作るところから書きます。
今回のデモでは、前述4種類に対応する**Serviceクラスを作ってDIします。
ということで、まずインターフェースを4つ用意。

using System;

namespace MVC6DiDemo.Services.Abstractions
{
    public  interface IScopedService:IService
    {
    }

    public interface ISingletonService : IService
    {
    }


    public interface ITransientService : IService
    {
    }


    public interface IInstanceService : IService
    {
    }

    public interface IService
    {
        DateTime CreateDateTime { get; }
        int CtorCount { get; }
    }
}

デモなので、4つのインターフェースを1つのファイルに書いて手抜きってます。で同じコード書くのがつらいので、「IService」を継承。

次に、実体のServiceクラスたち。DIの4種類にあわせて4つのクラスを用意します。クラス名と、実装してるインターフェースが違うだけで中身は一緒です。
やっていることは、コンストラクターが呼ばれた時間と、その回数を見れるようにしています。
(ここは、諸事情により継承してない)

InstanceService クラス

using System;
using MVC6DiDemo.Services.Abstractions;

namespace MVC6DiDemo.Services
{
    public class InstanceService : IInstanceService
    {
        public InstanceService()
        {
            _createDateTime = System.DateTime.Now;
            _count++;
        }
        private static DateTime _createDateTime;
        public DateTime CreateDateTime => _createDateTime;

        private static int _count;
        public int CtorCount => _count;
    }
}

SingletonService クラス

using System;
using MVC6DiDemo.Services.Abstractions;

namespace MVC6DiDemo.Services
{
    public class SingletonService : ISingletonService
    {
        public SingletonService()
        {
            _createDateTime = System.DateTime.Now;
            _count++;
        }
        private static DateTime _createDateTime;
        public DateTime CreateDateTime => _createDateTime;

        private static int _count;
        public int CtorCount => _count;
    }
}

ScopedService クラス

using System;
using MVC6DiDemo.Services.Abstractions;

namespace MVC6DiDemo.Services
{
    public class ScopedService : IScopedService
    {
        public ScopedService()
        {
            _createDateTime = System.DateTime.Now;
            _count++;
        }
        private static DateTime _createDateTime;
        public DateTime CreateDateTime => _createDateTime;
        private static int _count;
        public int CtorCount => _count;
    }
}

TransientServiceクラス

using System;
using MVC6DiDemo.Services.Abstractions;

namespace MVC6DiDemo.Services
{
    public class TransientService: ITransientService
    {
        private static int _count;
        private static DateTime _createDateTime;
        public TransientService()
        {
            _createDateTime = System.DateTime.Now;
            _count++;
        }
        public DateTime CreateDateTime => _createDateTime;
        public int CtorCount => _count;
    }
}

3. Implimentation: DIの設定

プロジェクト直下にある「Startup.cs」のStartupクラスのConfigureServicesメソッドの下の方に以下のコードを追加します。

public void ConfigureServices(IServiceCollection services)
{
    // ..デフォルトで書いてあるコードは省略...

    services.AddInstance<IInstanceService>(new InstanceService());
    services.AddSingleton<ISingletonService, SingletonService>();
    services.AddScoped<IScopedService, ScopedService>();
    services.AddTransient<ITransientService, TransientService>();
}

IServiceCollectionの「AddInstance」「AddSingleton」「AddScoped」「AddTransient」で設定します。
「Instance」で設定する場合のみ、実装方法が異なります。ここでがっつりインスタンス化してます。

4. Implimentation: コントローラー

デフォルトで用意されているHomeControllerと使ってしまいます。

まずは、メンバー変数とコンストラクターを追加します。
「Scoped」と「Transient」は、1リクエストで複数インスタンスある場合に動きが異なるようなので、2づつ用意しています。

public class HomeController : Controller
{
    private readonly IInstanceService _instanceService;
    private readonly ISingletonService _singletonService;

    private readonly IScopedService _scopedService1;
    private readonly IScopedService _scopedService2;

    private readonly ITransientService _transientService1;
    private readonly ITransientService _transientService2;

    public HomeController(IInstanceService instanceService, ISingletonService singletonService, ITransientService tran1, ITransientService tran2,IScopedService scope1,IScopedService scope2)
    {
        _instanceService =  instanceService;
        _singletonService = singletonService;
        _transientService1 = tran1;
        _transientService2 = tran2;
        _scopedService1 = scope1;
        _scopedService2 = scope2;
    }

DI対象の**Serviceクラスがたくさんあるので、コンストラクター見苦しい...。

クライアントサイドから呼ばれる「Index」メソッドは、以下のように、
各Serviceクラスのコンストラクター呼ばれた時間と回数をViewBagに入れて、Viewで取得できるようにしてます。

private static string Stamp(IService service) => $"time: {service.CreateDateTime}; count:{service.CtorCount};";

public IActionResult Index()
{
    ViewBag.CurrentDate = DateTime.Now;

    ViewBag.InstanceTime = Stamp(_instanceService);
    ViewBag.SingletonTime = Stamp(_singletonService);

    ViewBag.TransientTime1 = Stamp(_transientService1);
    ViewBag.TransientTime2 = Stamp(_transientService2);

    ViewBag.ScopedTime1 = Stamp(_scopedService1);
    ViewBag.ScopedTime2 = Stamp(_scopedService2);

    return View();
}

5. Implimentation: ビュー

コントローラに合わせて、Viewは、HomeのIndexビューを変更しましょう。
デフォルトで作られるViewのカルーセル麻紀の下あたりに、VeiwBagの値を呼び出してみます。

<h2>
    current time:   @ViewBag.CurrentDate
</h2>
<div class="row">
    <div class="col-md-3">
        <h2>Instance</h2>
        <p>
            @ViewBag.InstanceTime
        </p>
    </div>
    <div class="col-md-3">
        <h2>Singleton </h2>

        <p>
            @ViewBag.SingletonTime
        </p>

    </div>
    <div class="col-md-3">
        <h2>Scoped </h2>
        <p>
            ScopedTime1: @ViewBag.ScopedTime1
        </p>
        <p>
            ScopedTime2: @ViewBag.ScopedTime2
        </p>
    </div>
    <div class="col-md-3">
        <h2>Transient </h2>
        <p>
            TransientTime1: @ViewBag.TransientTime1
        </p>
        <p>
            TransientTime2: @ViewBag.TransientTime2
        </p>

    </div>
</div>

6. 動作検証

あ、Startupのコンストラクターで、起動時の時刻をコマンドに表示するコードも書きました...
デバッグは、今回は「Web」でします。
f:id:beachside:20160115153437p:plain


デバッグを開始した時間がコンソールに表示されます。
f:id:beachside:20160115153557p:plain


では、「http://localhost:5000/」(URLは人によって異なる場合あり)で、Viewを表示

f:id:beachside:20160115153734p:plain

インスタンスを起動したタイミングとほぼ同じ時間で、「Instance」を指定しているクラスは生成されています。
「Singleton」を指定したクラスは、呼び出した時間と同時刻くらいに生成されているのがわかります。

また、「Scoped」のクラスは、1リクエストに対してシングルトンなので、コントローラーに2インスタンス持っていますが、コンストラクターの呼び出し回数は1回ですね。
それに対して「Transient」は、コントローラーに2インスタンスあるので、2回コンストラクターが呼び出されています。

リロードを2回してみると....
f:id:beachside:20160115154352p:plain

「Instance」と「Singleton」は、変わらず。
「Scoped」は、合計3回コールしているので、インスタンスの呼び出し回数が3回、「Transient」は、6回で、時間も更新されていますね。


ということで、想像通りの動きで良かったです。

ASP.NET5 MVC6 Entity Framework 7 を使って Database First する

ASP.NET Advent Calendar 2015 20日目です。二日酔いで原因不明で体調が悪くて遅刻でした。すいませんm(_ _)m。....次のチャック先生もまだ未公開のようですね....ふふふ...

個人的に Database First 派なので、EntityFramework 7で対応方法をここでお勉強♪。
「Dabase First って何?」をザックリいうと、既存のデータベースからリバース エンジニアリングでPOCOなエンティティ・クラスを作成することです。

> Environment

ASP.NET5は、現時点での上記ツールを入れたバージョンとなります。
ASP.NET and Web Tools 2015 (RC1 Update 1)入れてないとテンプレートがでない(んでしたっけ?)とかあるので、試される方は、ググっていただきインストールが必要です。
バージョンによって多少テンプレートの中身も異なる可能性が大きいし、直ぐにバージョンも上がると思うので、きっと数週間~数か月でここで書く方法とは古くなるんだろうなーと思ってます。

> 事前準備

DBは、Azure の SQLDatabase に接続しています。事前にデータベースとテーブルを作っておきます。
うむ、それくらいでしょうか。

>1. ASP.NET5 MVC6 のプロジェクト作成

VSでプロジェクトを作成します。新規プロジェクトで、[ASPNET Web Appication]を選び、targetのframeworkは、デフォルトで表示される4.6.1、テキトーにプロジェクト名とかつけ、[OK]します。
f:id:beachside:20151221173241p:plain

テンプレートの選択では、MVCのテンプレートとなる[Web Application]を選択して[OK]します。
f:id:beachside:20151221173631p:plain

MVC6のテンプレートということで、ControllersとかServiceとかModelsなどのフォルダができています。
bowerの設定変えたほうがいんじゃね的な内容が、ハンセルマン先生のブログにあったりなかったりしますが、
www.hanselman.com
今回の趣旨ではないので、放置プレイです。

>2. Nuget Packageで必要なもの導入

現時点でプロジェクトを作成すると、Entityframework関連は、以下が入ってます。

  • EntityFramework.MicrosoftSqlServer(7.0.0-rc1-final)
  • EntityFramework.Commands(7.0.0-rc1-final)
  • EntityFramework.AspNet.Identity.Framework(3.0.0-rc1-final)

Identityは、個人認証のプロジェクトを作ったから私のには入っているだけですが、その他は必須なはずです。
これに加え、DabaseFirstするために以下をインストールします。

  • EntityFramework.MicrosoftSqlServer.Design(7.0.0-rc1-final)


コマンド叩いてインストールしてもOKですが、今回は何となくNugetPackageManagerでやってます。
画面上部の[Tools] > [Nuget Package Manager] > [Manager Nuget Packages for Solution...]を開きます。
f:id:beachside:20151221180122p:plain

[Browse]を選択して、「EntityFramework.MicrosoftSqlServer.Design」と入力して検索します。一つヒットすると思いますので、その子をインストールします。
f:id:beachside:20151221175907p:plain

>3. コマンドで Database First する

「Database First する」とか日本語変じゃね?的なことはさておき、Database First するにはコマンド叩きますので、コマンドプロンプトとか開きます(画面キャプチャはConsole2です。)

まず、プロジェクトのディレクトリに移動します。今回だと、プロジェクト名が「mvc6ef7」なので、
「{プロジェクトを作ったディレクトリ}/mvc6ef7/src/mvc6ef7」
になります。ようは、「Models」とか「Controllers」とかあるディレクトリです。

ここまでの操作は、nugetで「productivity power tools 2015」を入れていると、
プロジェクト名(「mvc6ef7」)の上で右クリック > [Power Commands] > [Opne Command Prompt]でさっと開けます。

次に、dnvmコマンドを使う準備的なこととして、以下のコマンド打ちます。(バージョンが上がるとコマンドも変わるのでご注意を)

dnvm use 1.0.0-rc1-update1

f:id:beachside:20151221183757p:plain


次に、本題の Database First するわけですが、コマンドはこんな感じです。

dnx ef dbcontext scaffold "Server={接続文字列!}" EntityFramework.MicrosoftSqlServer --outputDir {出力先のディレクトリ}

今回はAzure SQL Databaseに接続するので、Azureのポータルから接続文字列を確認しつつておきます。出力先はご自由にということで、DAL/DbContextsというディレクトリに出力します。
具体的なコマンドのサンプルですが、

  • 接続先のサーバーが「tcp:beachside-dev-sv.database.windows.net,1433」
  • Database名が「sqldatabase」
  • SQL認証のユーザーは「Hogehoge」
  • パスワードは「passHogehoge」

だと、以下のコマンドになります。

dnx ef dbcontext scaffold "Server=tcp:beachside-dev-sv.database.windows.net,1433;Database=dev-sqldatabase;User ID=Hogehoge;Password=passHogehoge" EntityFramework.MicrosoftSqlServer --outputDir DAL/DbContexts

コマンドを実行すると、ダラララララーと動いて、問題なければ最後にDoneとでます。
ソリューションエクスプローラーを確認すると、テーブルの定義に添ったエンティティ・クラスが追加されていることが確認できます。

f:id:beachside:20151221185035p:plain

>4. 接続文字列の設定、動作検証

接続文字列は、デフォルトではDbContextのクラスに書かれます。今回、データベース名が「dev-sqldatabase」だったので、DbContextのクラスは、「dev_sqldatabaseContext」になっています。
クラスを開くと、DbContextクラスが継承されており、最初の方に接続文字列の設定が書かれています。

f:id:beachside:20151221191723p:plain

接続文字列は、「Startup.cs」でごねごねするのが一般的なようなので、[OnConfiguring]メソッドごと削除します。
あ、その前に、接続文字列自体は利用するので、どこかにコピーしておきましょう。

ごねごねする前に、ソリューションエクスプローラー直下にある「appsettings.json」を開いて接続文字列を書きます。こんな感じで。

{
  "Data": {
    "DefaultConnection": {
      "ConnectionString": "Server=tcp:beachside-dev-sv.database.windows.net,1433;Database=dev-sqldatabase;User ID={ひみつ};Password={ひみつ};MultipleActiveResultSets=true"
    }
  },
  "EntityFramework": {
    "dev_sqldatabaseContext": {
      "ConnectionStringKey": "Data:DefaultConnection:ConnectionString"
    }
  },
  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Verbose",
      "System": "Information",
      "Microsoft": "Information"
    }
  }
}

「"Logging"」はデフォルトのままです(今回のネタとは関係ないので)。

接続文字列となる「ConnectionString」の値はコピペした通り書きますが、最後に「MultipleActiveResultSets=true」つけておくと良いでしょう。「それ何?」という方は以下あたりを参照に...。
複数のアクティブな結果セット (MARS) の有効化

さらに小ネタとして、「EntityFramework」を追加し、DbContextのクラス名(今回だと「dev_sqldatabaseContext」)を指定して、「ConnectionStringKey」に上記の通り書きます。お察しの通りですが、さっき書いた接続文字列を指定しているだけです。

次に、「Startup.cs」を開きましょう。
クラス内に[public void ConfigureServices(IServiceCollection services)...]というメソッドがありますので、以下を追記します。

var connection = Configuration["Data:DefaultConnection:ConnectionString"];
services.AddEntityFramework()
    .AddSqlServer()
    .AddDbContext<dev_sqldatabaseContext>(options => options.UseSqlServer(connection));

これで、設定は完了です。
動作検証ということで、ざっくりなコードをHomeControllerに書いて試してみましょう。

public class HomeController : Controller
{
public async Task<IActionResult> Index()
{
    using (var context = new dev_sqldatabaseContext())
    {
        var customers = await context.Customer.Where(c => c.Title == "Mr.").ToListAsync();
        Debug.WriteLine(customers.Count);
    }
            
    return View();
}

雑にもHomeのIndexにコードを追加してしまったので、デバッグしてUrlを叩くと無事に動作することが確認できました。今回のサンプルでは、「Customer」テーブルが存在し、そこに「Title」列があって、その中で「Mr.」の行を取得した例になります。
(AzureのSQL Databaseでサンプルデータ付きで作れるのでそれを使ってます。中身は、みんな大好きAdventureWorksです。)


Startupクラスで接続文字列を取得してごねごねしてますが、別の書き方もできます。
「appsettings.json」で"EntityFramework"に関するコードを書いていますので、[public void ConfigureServices(IServiceCollection services)...]にて、こんな書き方でも問題なく接続できます。

services.AddEntityFramework()
    .AddSqlServer()
    .AddDbContext<dev_sqldatabaseContext>();

「AddDbContext」でoptionを書かないパターンです。ケースバイケースでどちらでもって感じですね。

>まとめ

現時点では、問題なくデータベース・ファーストで作れます。
以前から同様にpartialなクラスになってるので、必要に応じてコンストラクタを追加したり色々できますね。
一日半以上の遅刻となりましたが以上です。

Oh、次のチャック先生は、既にブログ公開済みになってますね...。

ASP.NET MVC でパスワードの有効期限対応をする

さてさてVisual Studio2015リリース直前な時期ですが、Visual Studio2013でのASP.NET MVCな常にログインが必要な業務系アプリ想定で、以下の実装例をメモしておきます。

  • 新規ユーザー登録(&仮パスワード発行)後、ユーザーの初回ログイン時にパスワード変更画面へ強制遷移
  • ユーザーのパスワード有効期限を設定し、過ぎたらログイン時にパスワード変更画面へ強制遷移

> Environment

今回の開発環境はこんな感じです。

> OverView

ASP.NET MVCで個人認証のテンプレート使って、DBの接続はざっくりEntityFrameworkでアプリ作るところに、パスワード要件を実装と思ってください。
AuthorizeAttributeのFilterを作って、アプリに適用させることで実現します。

手順の全体像は、

  1. ASP.NET MVC5で、個人認証つきのプロジェクト作成
  2. Attribute作成前の準備
  3. 要件を満たすためのAttribute作成
  4. アプリに適用

です。

> Implimentation 1. ASP.NET MVC5、個人認証のプロジェクト作成

これはただの下準備なので、さっくり進めます。
Visual Studioで新規プロジェクトを作成します。テンプレートの選択は、もちろん[Web]です。
f:id:beachside:20150712145712p:plain

たまに[VisualStudio2012]を選択する方がいるのですが、選択するのは、上図の通りWebです。お間違えなく。

そして、MVCで個人認証で進めます。
f:id:beachside:20150712150704p:plain

> Implimentation 2. Attribute作成前の準備

パスワード変更の最終変更日を管理するので、DBにカラムを持ちます。
ASP.NET Identity2.2.1で個人認証すると、データベースに[AspNetUsers]ってテーブルできますよね。
f:id:beachside:20150712152313p:plain
このテーブルでパスワード変更の最終変更日も管理しようと思います。

話は脱線しますが、わからんことがあって放置している点を書いておくと、
MembershipプロバイダーのLastPasswordChangedDateあたりを使うべきなのかなーとかが??です。AspNetUsersテーブルもあるのにこっちも使うのは面倒では?と何も調べず知らずに書いてます。よいプラクティスをご存じなMVPさん方々、ご教授願いますm(_ _)m...


さて、話は戻ります。
[AspNetUsers]テーブルに[LastPasswordChangedDate]列を追加します。
パスワード変更の日付は、DBファーストでもコードファーストでもどちらで書いても構わないのですが、コードファーストでいきますか(いつもはコードファーストが嫌いなのでDBファーストで書いてます...)。

[ソリューションエクスプローラ]の[Models] > [IdentityModels.cs]を開きます。
以下のように、[ApplicationUser]クラスに[LastPasswordChangedDate]プロパティを追加します。

public class ApplicationUser : IdentityUser
{
    public async Task<ClaimsIdentity> GenerateUserIdentityAsync(UserManager<ApplicationUser> manager)
    {
        // authenticationType が CookieAuthenticationOptions.AuthenticationType で定義されているものと一致している必要があります
        var userIdentity = await manager.CreateIdentityAsync(this, DefaultAuthenticationTypes.ApplicationCookie);
        // ここにカスタム ユーザー クレームを追加します
        return userIdentity;
    }

    public DateTime? LastPasswordChangedDate { get; set; }

}

あとは、Migrationすればデータベース側に反映されます。個人的には、マイグレーションで面倒なことが起きるのでDBファーストが好みです。
しばらくMigrationなんてしてないのでどうするんだっけ感がありますが、パッケージマネージャーコンソールで、

enable-migrations

を実行して、Migrationを有効にします。
次は、こんな感じで適当に...

add-migration InitialCreate

最後に、

update-database

でデータベース側も反映されます。
f:id:beachside:20150712160030p:plain

migrationについて不明の方は、ここら辺をご参考いただければと。
www.asp.net

> Implimentation 3. 要件を満たすためのAttribute作成

それでは本題のAttribute作成です。
プロジェクトに適当にフォルダを追加します。今回は以下のようにフォルダと[PasswordExpirePeriodAtteribute]クラスを追加しました。
f:id:beachside:20150712160726p:plain

コードは以下のようにしています。

public class PasswordExpirePeriodAttribute : AuthorizeAttribute
{
    private static readonly int PasswordExpirePeriod = 90;

    public override void OnAuthorization(AuthorizationContext filterContext)
    {

        if (filterContext.ActionDescriptor.ControllerDescriptor.ControllerName.ToLower() == "manage") return;

        var user = HttpContext.Current.User;
        if (user != null && user.Identity.IsAuthenticated)
        {
            using (var context = new ApplicationDbContext())
            {
                var passwordChangedDate = context.AspNetUsers.Find(user.Identity.GetUserId()).LastPasswordChangedDate;
                // NULLの場合=初回ログイン
                if (passwordChangedDate == null)
                {
                    filterContext.Controller.TempData["ExpirePeriodMessage"] = "初期パスワードの変更をしてください。";
                    filterContext.Result = new RedirectResult("~/manage/changepassword/");
                    return;
                }

                //期限切れの場合
                if (DateTime.Today >= passwordChangedDate.Value.AddDays(PasswordExpirePeriod))
                {
                    filterContext.Controller.TempData["ExpirePeriodMessage"] = "パスワードの有効期限が切れています。変更するまでここから逃げることはできませんよ?";
                    filterContext.Result = new RedirectResult("~/manage/changepassword/");
                }

            }
        }
    }
}


まず、[PasswordExpirePeriodAttribute]クラスは、[AuthorizeAttribute]を継承したクラスと定義し、フィルターとしての動作をさせます。
パスワードの有効期限は、staticな変数を持たせて90日を設定しました。多少真面目に書く場合は、webconfigとかにもたせて読み込む感じでしょうか。

動作のトリガーとして、[OnAuthorization]メソッドをoverrideします。
ここら辺が不明の場合は、"authorization filter asp.net mvc 5"あたりでググってみるとよいと思います。

次に、manageのコントローラーに来た場合は、このフィルターを抜けるようにしています。

 if (filterContext.ActionDescriptor.ControllerDescriptor.ControllerName.ToLower() == "manage") return;

今回作っているASP.NET MVCのプロジェクトのパスワード変更が、[ManageController]の[ChangePassword]メソッド2つにリダイレクトするようにしています。
そのため、ManageControllerでも同様にフィルターを書けてしまうと無限リダイレクトループしてしまうからです。

次の処理は、DBにアクセスして[LastPasswordChangedDate]がnullだったら初期パスワード発行状態と判断し、パスワード変更画面へリダイレクトします。
そのため、ユーザーの登録時には、[LastPasswordChangedDate]を登録せずNULLにしておくという勝手なルールがあることはお察しくださいませ。
[LastPasswordChangedDate]の型がNullableな理由です。

ここで一点注意していただきたいのは、以下のコード部分です。

 using (var context = new ApplicationDbContext())
{
       var passwordChangedDate = context.AspNetUsers.Find(user.Identity.GetUserId()).LastPasswordChangedDate;
      ...

[ApplicationDbContext]は、私は勝手にカスタマイズしているので、デフォルトで同様のコードを書いても[AspNetUsers]のテーブルにアクセスできません!DbContextをちゃんと作って、そのcontextを呼ぶ必要があります。このEntityframeworkの入門としてweb上に溢れていると思うので省略しちゃいます。

それ以降のコードでは、パスワードの最終更新から90日過ぎたかをみて、リダイレクトを判断します。

> Implimentation 4. アプリに適用

さて、最後にアプリに適用です。
ものがものなので、今回の例ではGlobalFiltersに登録しちゃいます。
ソリューションエクスプローラーの[App_Start]フォルダ > [FilterConfig.cs]を開きます。
[RegisterGlobalFilters]メソッドに、以下のようにフィルターを追加します。

filters.Add(new PasswordExpirePeriodAttribute());

GlobalFiltersに登録するということは、アプリ全体に適用されるので、それが不適合な場合は、必要なクラスのみのフィルターをつけます。

これで動作しますが、パスワードを変更した際にLastPasswordChangedDateを更新させてあげる処理を追加する必要はありますのでお忘れなく。
あと、パスワード変更のView(~\Views\Manage\ChangePassword.cshtml)では、TempData["ExpirePeriodMessage"]に値があれば表示するみたいなことしてあげれば、メッセージを表示できます。


なんか泥臭いコードなのであまり満足度低めなのですが、取り急ぎメモということで...

さて焼肉食べに行こう...

ASP.NET Web API2.2で、Swagger(Swashbuckle 5.1.5)を使う

WebAPIを作る用事があったのでSwaggerの設定方法をメモしておきます。ちなみにIIS Hostedです。ということで面白味はありません...。

Swaggerとは、言語に依存せず様々なプラットフォームに実装することができるRESTful APIのドキュメント作成ツールといったところです。
詳しくは、みそ先生がまとめておられます。miso-soup3.hateblo.jp

コードをベースにドキュメントが作成されるので、便利で楽です。

> Environment

今回の開発環境はこんな感じです。

> OverView

手順の全体像は、

  1. WebAPIのプロジェクト作成
  2. Swaggerをインストール
  3. Swaggerを使うための設定
  4. WebAPIのコントローラーをさっと作ってSwaggerの動作を確認

です。

> Implimentation 1. WebAPIのプロジェクト作成

Visual StudioでWebAPIのプロジェクトを作ります。

説明するほどもないかもしれませんが...手順を追いましょう。
プロジェクトの新規作成します。
テンプレートは、左ペインの「Web」を選択します。プロジェクトの名前は適当に...(今回は「SwaggerDemo」しました。)
f:id:beachside:20150531192908p:plain
次の画面では、「WebAPI」を忘れずに選びます。
f:id:beachside:20150531202936p:plain

> Implimentation 2. Swaggerをインストール

プロジェクトが作成できたら、SwaggerのPackageをNuGetからインストールします。
上部メニューの「ツール」→「NuGetパッケージマネージャー」→「パッケージマネージャーコンソール」を開き、以下のコマンドを実行します。

Install-Package Swashbuckle

f:id:beachside:20150531192949p:plain
今回のバージョンは、「5.1.5」です。
バージョンによって若干設定方法が変わりますので、後述の設定で「あれ?」という場合はバージョン依存と思ってください。

インストールが完了すると、プロジェクトの「App_Start」ディレクトリ配下に「SwaggerConfig.cs」ができたりします。

> Implimentation 3. Swaggerを使うための設定

XMLコメントをファイルに出力する設定を行います。
XMLコメントについて知らない方は...にぃにさまのサイトとかで復習をして頂ければ...ufcpp.net


プロジェクトのプロパティをクリックして、左ペインで「ビルド」を選択し、「XML ドキュメント ファイル」のチェックボックスをオンにします。パスが自動で表示されます(このパスは後で使います)。
f:id:beachside:20150531203336p:plain


このファイルをSwaggerで読み込むための設定を行います。

余談ですが、
Swashbuckleのバージョンで多少設定が変わってきます。特にversion 4.1.0とかだと 「
Swashbuckle.Bootstrapper.Init(....」みたいな設定が必要です。今回のバージョン(Version5くらい)からは以下の設定だけでOKです。

本題に戻り、
「App_Start」ディレクトリ内の「SwaggerConfig.cs」を開きます。
コメントたくさんのコードが作られています。以下のコード(158行目あたり)のコメントをはずします。

c.IncludeXmlComments(GetXmlCommentsPath());

これでXMLコメントのドキュメントを読み込む設定がされます。といっても、GetXmlCommentsPathメソッドがないので作りましょう。

private static string GetXmlCommentsPath()
{
        return string.Format(@"{0}\bin\SwaggerDemo.XML", System.AppDomain.CurrentDomain.BaseDirectory);
}

「bin\...」の部分のパスは、プロジェクトのプロパティでチェックボックスをオンにしたときに表示されたパスを入力します。

念のための確認ですが、167行目あたりの

.EnableSwaggerUi(c =>

のコメントが外れていることも確認しておきます。以前(多分Swashbuckleの別のバージョン)でやった時は自分でコメントを外した記憶があるので..。


これでSwaggerの設定完了です。

> Implimentation 4. ...Swaggerの動作を確認してみる

以下の感じで適当にWebAPIのコントローラーをつくりました。(scaffoldingで作られたコードなだけですが...)
XMLコメントも書きます。

using System.Collections.Generic;
using System.Linq;
using System.Web.Http;
using System.Web.Http.Description;

namespace SwaggerDemo.Controllers
{
    public class ValuesController : ApiController
    {
        /// <summary>
        /// 全てのvalueを返します。
        /// </summary>
        /// <returns></returns>
        [ResponseType(typeof(IEnumerable<string>))]
        public IEnumerable<string> Get()
        {
            return new string[] { "value1", "value2" };
        }

        /// <summary>
        /// 指定のidのvalueを返します。
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        [ResponseType(typeof(string))]
        public string Get(int id)
        {
            return "value";
        }

        // POST api/<controller>
        public void Post([FromBody]string value)
        {
        }

        // PUT api/<controller>/5
        public void Put(int id, [FromBody]string value)
        {
        }

        // DELETE api/<controller>/5
        public void Delete(int id)
        {
        }
    }
}

さてさてデバッグしてみましょう。
実行されたURLの後ろに「/swagger」と入力してアクセスすると、無事にSwaggerのUIが表示されます。
f:id:beachside:20150531194015p:plain

XMLコメントを付けたメソッドは、その内容が表示されています。
ここからAPIを呼んでレスポンスを確認できるので、すこぶる便利ですね。

Double SubmissionをActionFilterで制御する

仕事が派手にドッタンバッタンしたので更新が途切れましたなー。

WebでSubmitボタンを連打された時の防止策についてメモしておきます。

まず、この記載での開発環境は、ざっくり

  • Visual Studio2013update4
  • .NET Framework4.5
  • ASP.NET MVC5

です。


制御は、ActionFilterだけでよいといえばよい気もしますが、個人的には、クライアント側でもjavascriptで制御してます。この実装についてはここでは触れませんが、ここらへんを参考にしてやってます。
http://technoesis.net/prevent-double-form-submission-using-jquery/technoesis.net

Reference

さて、本題ですが、ActionFilterの実装は、この方のサイトを参考にしています。
rion.io


まず、ActionFilterの基本については、こちらのサイトを参照くらいでしょうか。
https://msdn.microsoft.com/ja-jp/library/dd381609(v=vs.100).aspx

ざっくりまとめると、

  • クラスを作ってActionFilterAttributeを継承させると、ActionFilterクラスの出来上がり。4つのオーバーライドメソッドをオーバーライドすることで制御を行う。
  • OnActionExecutingメソッドはActionが呼び出される直前に動く
  • OnActionExecutedメソッドは、Actionが終了したときに動く
  • OnResultExecuting メソッドは、アクションによって返された ActionResult インスタンスが呼び出される直前に動く
  • OnResultExecuted メソッドは、結果が実行された直後に動く

Overview

やりたいことは、Submitボタンを押された後、処理が終わる前にsubmitを連打されれいたら、検知して、無視するとかエラー返すとかです。

ActionFilterのOnActionExecutingメソッドで、以下のことを実装して実現します。

  • Requestがあったユーザーを特定する仕組みをつくる
  • ユーザーが最初のアクセス(=submit)から特定秒数の間にアクセスがあったら、検知して処理(エラーとか無視とか)を行う

Implimentation : 1

まず、検証するControllerとView作っちゃいます。

Controllerはこんな感じでざっくり。

public class DemoController : Controller
{
    public ActionResult Index()
    {
        return View();
    }

    [HttpPost]
    public async Task<ActionResult> DemoSubmit()
    {
        await Task.Delay(3000);

        TempData["SummittedTime"] = DateTime.Now.ToString("hh:MM:ss fff");
        return RedirectToAction("Index");
    }
}

submitしたら、3秒待って、Viewを表示するだけです。ただ、submitした時間を表示します。
今回の話題とは全く関係ない小ネタですが、理由なくRedirectToActionさせて時間を表示するのでViewBagではなくTempDataに時間を入れて、表示できるようにしています。
ViewBagに入れるとRedirectしたらデータ消えちゃいますよね...。

Viewは、Index.cshtmlをこんな感じでまったり作りました。

<h2>demo prevent doubl submittion</h2>

@if (TempData["SummittedTime"] != null)
{
    <p>@TempData["SummittedTime"] </p>
}

@using (Html.BeginForm("DemoSubmit", "Demo"))
{
    <input class="btn btn-warning" type="submit" name="DemoSubmit" value="3秒以内の連打禁止だおー" />
}

Implimentation : 2

では、本題のActionFilterです。
Propertyをいくつか用意してます。
まず、「DelayTimer」。同一ユーザーから一度Requestを受けたら、delayする時間を設定するプロパティ。秒で設定します。今回はデフォルトで3秒を設定しました。
後は、エラーメッセージだったり、RedirectするUrl(今回は全然触れてない...)。

using System;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Web.Caching;
using System.Web.Mvc;

public class PreventDoubleSubmitAttribute : ActionFilterAttribute
{
    #region class header

    private int _delayTimer = 3;
    public int DelayTimer
    {
        get { return _delayTimer; }
        set { _delayTimer = value; }
    }

    private string _err = "DoubleSubmit occurred";
    public string ErrorMessage
    {
        get { return _err; }
        set { _err = value; }
    }

    public string RedirectUrl { get; set; }

    #endregion
}

では、コアとなるOnActionExecutingの処理です。
まず、ユーザーを一意に認識させるために、リクエストの中の"HTTP_X_FORWARDED_FOR"を取得します。nullだったら、UserHostAddressを取得します。
その情報に、リクエストのUserAgentを追加しています。
あと、リスエストのUrlとパラメータを取得します。
それらの情報からMD5ハッシュを生成して、ユーザーの一意となるキーを作ります。
そして、ハッシュがCacheに存在するかを確認します。
存在する場合は、Prevent対象と判断し、なんらかの処理を入れます。今回は、BadRequestを返してしまっています。

存在しなければ、初回のリクエストと判断し、Cacheに値をセットします。その際に、そのCacheの有効期限にDelayTimerプロパティの秒数をセットします。

public override void OnActionExecuting(ActionExecutingContext filterContext)
{
    var request = filterContext.HttpContext.Request;
    var cache = filterContext.HttpContext.Cache;

    //一意となるデータを取得
    var firstrequest = request.ServerVariables["HTTP_X_FORWARDED_FOR"] ?? request.UserHostAddress;
    firstrequest += request.UserAgent;

    var current = request.RawUrl + request.QueryString;

  //ハッシュ生成
    var hash = string.Join("", MD5.Create().ComputeHash(Encoding.ASCII.GetBytes(firstrequest + current)).Select(s => s.ToString("x2")));
    if (cache[hash] != null)
    {
        //今回は、BadRequestをなげます。
        filterContext.Result = new HttpStatusCodeResult(HttpStatusCode.BadRequest);
        base.OnActionExecuting(filterContext);

        //おもむろにログを出してみたり...。
        var controller = filterContext.RouteData.Values["controller"].ToString();
        var action = filterContext.RouteData.Values["action"].ToString();
        Trace.TraceWarning("Double Submit Occured: {0} - {1}", controller, action);
    }
    else
    {
    //存在しなければ、キャッシュに値をセットする
        cache.Add(hash, string.Empty, null, DateTime.Now.AddSeconds(DelayTimer), Cache.NoSlidingExpiration, CacheItemPriority.Default, null);
    }

    base.OnActionExecuting(filterContext);
}

Implimentation : 3

最後にControllerにAttributeを追加してあげれば完了です。

[HttpPost]
[PreventDoubleSubmit]
public async Task<ActionResult> DemoSubmit()
{
....

これで、一度Submitした後、3秒以内に連打するとBadRequestが出力されます。

DelayTimerの時間など、プロパティにアクセスしたい場合は、こんな感じで書いてあげればよいです。

[HttpPost]
[PreventDoubleSubmit(DelayTimer = 5)]
public async Task<ActionResult> DemoSubmit()

今回は、javascriptで制御しているのに、それを掻い潜って連打攻撃してきたのであればBadRequestで沈める想定なのですが、ModelStateにエラーメッセージを追加したいとかもあるでしょうか。

if (cache[hash] != null)
{
    filterContext.Controller.ViewData.ModelState.AddModelError("DoubleSubmit", ErrorMessage);
}
else
{
...

こうした場合、controller側でModelstateのエラーを拾ってあげればよいです。

if (ModelState.Values.Select(vals => ModelState["DoubleSubmit"]).Any(v => v != null))
{
    //なんかエラー処理を...
}


なんか色々ざっくり書いてしまったメモですが、今回はこの辺で...。