ASP.NET Core MVC - 時間がかかるアクションメソッドをキャンセルできるようにする

ASP.NET Core 2.2からサポートされているインプロセスホスティングモデル。その特徴の1つをDocsから引用します。

クライアントの切断が検出されます。

クライアントが切断されると、HttpContext.RequestAbortedキャンセルトークンが取り消されます。

docs.microsoft.com

今回はこの機能を使って時間がかかるアクションメソッドのキャンセルを試していきます。

「クライアントの切断」というのはChromeFirefoxの×ボタンやEscキーで発生するようです。ウィンドウやタブの×ボタンではなく、通信中に更新ボタンと同じ位置に現れる×ボタンのことを言っています。

Edgeでも同じことを試してみましたが、どうも発生しない様子でした。

確認した各ブラウザのバージョンです。OSはWindows 10です。

ブラウザのウィンドウやタブを閉じたりとか、XMLHttpRequestの通信なども試していませんがどうなんでしょう。 このあたりの仕様はどこかにあるのかな。(調べずに言ってます。)

すべてのブラウザでキャンセルできるものではないという前提で話を進めていきます。

CancellationTokenをバインドする

アクションをキャンセルできるようにするには、アクションメソッドの引数にCancellationTokenを追加します。CancellationTokenModelBinderクラスがHttpContext.RequestAbortedをバインドしてくれるようです。

後はそのCancellationTokenをチェックするだけです。非同期メソッドの引数に渡していくというか。

public class SampleController : Controller {
    private readonly ILogger _logger;

    public SampleController(ILogger<SampleController> logger) {
        _logger = logger;
    }

    // キャンセルしたいアクション
    // CancellationTokenをバインドする
    public async Task<IActionResult> CancelTest(CancellationToken token) {
        try {
            _logger.LogInformation("Heavy task started.");

            // 何か時間がかかる処理
            // バインドしたCancellationTokenを引数に渡す
            await Task.Delay(TimeSpan.FromSeconds(10), token);

            _logger.LogInformation("Heavy task completed.");
        } catch (TaskCanceledException _) {
            // ブラウザのEscキーでキャンセルされる
            _logger.LogInformation("Heavy task canceled.");
        }

        return new EmptyResult();
    }
}

ChromeFirefoxでアクションのURLにアクセスしてみましょう。

そして途中でEscキーを押すと、アクションメソッドの中のTaskがキャンセルされることを確認できます。

// 出力されるログ
Heavy task started.
Heavy task canceled.

参考

ホスティングモデル自体についてはこのあたりが参考になると思います。

docs.microsoft.com

ASP.NET Core MVC - Serilogを使ってファイルにログ出力する

ASP.NET Core MVCでSerilogを使ってファイルにログ出力するサンプルを書いてみました。

Serilog — simple .NET logging with fully-structured events

サンプルでは次のことを実現しています。

  • ログをファイルに出力する
  • 日付ごとにログファイルを作成する
  • ログの中身をJSON形式にする
  • appsettings.jsonを参照してロガーを構築する

それではコードを見ていきましょう。

Serilogをインストールする

まずはNuGetで必要なパッケージをインストールします。

Install-Package Serilog
Install-Package Serilog.AspNetCore
Install-Package Serilog.Formatting.Compact
Install-Package Serilog.Settings.Configuration
Install-Package Serilog.Sinks.File

パッケージ名から想像できるかもしれませんが、それぞれのパッケージの役割は次のような感じです。

パッケージ 説明
Serilog 本体
Serilog.AspNetCore ASP.NET Core用(WebHostBuilderのUseSerilog)
Serilog.Formatting.Compact ログのJSON形式出力
Serilog.Settings.Configuration appsettings.jsonから設定を読み込む
Serilog.Sinks.File ログのファイル出力(日付ごとのログファイル作成もこれ)

今回のサンプルでは使っていませんが以下もよく使いそうですね。

パッケージ 説明
Serilog.Sinks.Console ログのコンソール出力
Serilog.Sinks.Debug ログのデバッグ出力

ログ出力の準備をする

一旦appsettings.jsonのことは忘れて、まずはコードでログ出力の準備をしたいと思います。

公式のサンプルなどを見ているとWebHostを構築する前にロガーを準備するといいようで、Programクラスを次のような感じにします。

public class Program {
    public static void Main(string[] args) {
        // ロガーを構築する
        Log.Logger = new LoggerConfiguration()
            // ファイルに書き込む
            .WriteTo.File(
                // JSON形式で出力
             formatter: new CompactJsonFormatter(),
             path: @".\log\webapp.txt",
             restrictedToMinimumLevel: LogEventLevel.Information,
                // 日付ごとに新しいファイルを作る
             rollingInterval: RollingInterval.Day)
            .CreateLogger();

        try {
            Log.Information("Starting Web Host");

            CreateWebHostBuilder(args)
                .Build()
                .Run();
        } catch (Exception exception) {
            Log.Fatal(exception, "Host terminated unexpectedly");
        } finally {
            Log.CloseAndFlush();
        }
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>()
            // ログプロバイダーとしてSerilogを使う
            .UseSerilog();
}

コントローラでログを出力する

コントローラのアクションでログを出力するコードを書き足します。

public class HomeController : Controller {
    private readonly ILogger _logger;

    public HomeController(ILogger<HomeController> logger) {
        _logger = logger;
    }

    public IActionResult Index() {
        // ログを出力
        _logger.LogInformation(
            "Log {@param}",
            new { controller = "Home", action = "Index" });

        return Content("Home.Index");
    }
}

これでアクションを呼び出すとログが出力されるようになりました。

指定した出力先にwebapp20181205.txtといった日付を含んだ名前のファイルが作成されます。また出力されるログの中身は整形すると次のようなものです。

{
    "@t": "2018-12-05T02:18:12.3417756Z",
    "@mt": "Log {@param}",
    "param": {
        "controller": "Home",
        "action": "Index"
    },
    "SourceContext": "WebApp.Controllers.HomeController",
    "ActionId": "",
    "ActionName": "WebApp.Controllers.HomeController.Index (WebApp)",
    "RequestId": "",
    "RequestPath": "",
    "CorrelationId": null,
    "ConnectionId": ""
}

appsettings.jsonを参照する

今度はappsettings.jsonから設定を読み込んで上記と同じロガーを構築したいと思います。

appsettings.jsonを作成して、公式を参考にしながら設定を書いていきます。formatterを指定する方法に悩みましたが多分これでいいはずです。

{
    "Serilog": {
        "Using": [
            "Serilog.Sinks.File",
            "Serilog.Formatting.Compact"
        ],
        "WriteTo": [
            {
                "Name": "File",
                "Args": {
                    "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact",
                    "path": ".\\log\\webapp.txt",
                    "restrictedToMinimumLevel": "Information",
                    "rollingInterval": "Day"
                }
            }
        ]
    }
}

Programクラスのロガーを構築する部分は、設定ファイルを読み込むように変更します。

// appsettings.jsonを読み込む準備
var config = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json")
    .Build();

// ロガーを構築する
Log.Logger = new LoggerConfiguration()
    .ReadFrom.Configuration(config)
    .CreateLogger();

これでアクションを呼び出すとさっきと同じようなログが出力されます。

おしまい。

AutoMapper - ポリモーフィズムを使ったマッピングを試す

マッピング元はポリモーフィズムもいけるよね?と確認したくてサンプルを書いてみました。

いつものごとくモデルとプロファイルを用意します。

// マッピング元の親クラス
public abstract class SampleSrcBase {
    public int Id { get; set; }
    public string Value1 { get; set; }
}

// マッピング元
public class SampleSrc : SampleSrcBase {
    public string Value2 { get; set; }
}

// マッピング先
public class SampleDst {
    public int Id { get; set; }
    public string Value1 { get; set; }
    public string Value2 { get; set; }
}

// マッピングのプロファイル
public class SampleProfile : Profile {
    public SampleProfile() {
        CreateMap<SampleSrc, SampleDst>();
    }
}

SampleSrcBase(SampleSrc)からSampleDstにマッピングします。

// 準備
Mapper.Initialize(config => {
    config.AddProfile<SampleProfile>();
});
Mapper.AssertConfigurationIsValid();

// マッピング元のSampleSrcBaseを作る関数
SampleSrcBase getSrc() => new SampleSrc {
    Id = 1,
    Value1 = "x",
    Value2 = "y",
};

// 変数srcはSampleSrcBase型(中身はSampleSrc)
var src = getSrc();

// マップ
var dst = Mapper.Map<SampleDst>(src);

// 確認
// Value2もマッピングされている
Console.WriteLine(dst.Id);  // 1
Console.WriteLine(dst.Value1);  // x
Console.WriteLine(dst.Value2);  // y

ちゃんとマッピングできると。

参考

AutoMapper - コンストラクタを使ってマッピングする

ちょっと発見だったのでメモ。

AutoMapperでは、マッピング先のコンストラクタを呼び出してマッピングすることができます。マッピング先にはpublicなsetterプロパティも必要ありません。

ということでサンプルコード。

モデルとプロファイルを用意して、

// マッピング元
public class SampleSrc {
    public int Id { get; set; }
    public string Value { get; set; }
}

// マッピング先
public class SampleDst {
    // このコンストラクタを使ってマッピングする
    public SampleDst(int id, string value) {
        Console.WriteLine($"{nameof(SampleDst)} constructor({id}, {value})");
        // SampleDst constructor(1, x)

        Id = id;
        Value = value;
    }

    // getterプロパティのみ
    public int Id { get; }
    public string Value { get; }
}

// マッピングのプロファイル
public class SampleProfile : Profile {
    public SampleProfile() {
        CreateMap<SampleSrc, SampleDst>();
    }
}

マッピングします。

// 準備
Mapper.Initialize(config => {
    config.AddProfile<SampleProfile>();
});
Mapper.AssertConfigurationIsValid();

// マッピング元
var src = new SampleSrc {
    Id = 1,
    Value = "x",
};

// SampleSrc => SampleDst
var dst = Mapper.Map<SampleDst>(src);

// 確認
Console.WriteLine(dst.Id);  // 1
Console.WriteLine(dst.Value);   // x

参考

ASP.NET Core MVC - ビューを相対パスで指定する

ASP.NET Core MVCではビュー名を指定してビューを探すと思いますが、ビュー名だけでなくて相対パスも指定できますよというメモ。

相対パス~/ではじまる文字列です。

Controller.Viewメソッドの引数viewName

ViewResultを生成するメソッドですね。このメソッドの引数viewNameに相対パスを指定できます。

// サンプルコントローラ
public class SampleController : Controller {
    public IActionResult Index() {
        // 引数に相対パスを指定できる
        return View("~/Views/Sample/Index.cshtml");
    }
}

部分ビューのタグヘルパーPartialTagHelperのname属性

PartialTagHelperはASP.NET Core 2.1で追加されました。部分ビューをレンダリングするタグヘルパーです。

name属性に相対パスを指定できます。

@* name属性に相対パスを指定できる *@
<partial name="~/Views/Sample/_Partial.cshtml" />

ViewComponent.Viewメソッドの引数viewName

ViewViewComponentResultを生成するメソッドの引数viewNameも相対パスを指定できます。

// サンプルビューコンポーネント
public class SampleViewComponent : ViewComponent {
    private Task ActionAsync() => Task.CompletedTask;

    public async Task<IViewComponentResult> InvokeAsync() {
        // なにかの処理
        await ActionAsync();

        // 引数に相対パスを指定できる
        return View("~/Views/Components/Sample/Default.cshtml");
    }
}

部分ビューやビューコンポーネントをSharedフォルダに置くのはちょっと気が引けるけど、いくつかのビューで使い回ししたいときがありましたが、そんなときに相対パスを使うのもありかなと。ケースバイケースでしょうが。