ぷろじぇくと、みすじら。

Browser Platform Status Trackerを.NET Core 2.0に更新した

Created at:

3年ぐらい前からWebブラウザのプラットフォーム機能の開発状況が公開されるようになり、その更新を追いかけて変更内容を一覧するサイトBrowser Platform Status Trackerを公開しています。

最近までASP.NET MVC 5と.NET 4.6で動作していたのですが.NET Coreの機運の高まりを感じたので、.NET Core 2.0 + ASP.NET MVC Core 2.0に移行しました。といっても2か月ぐらい前に一度.NET Core 1.1にして、その後.NET Core 2.0にという感じです。まあそもそもサイトの性質的には動的じゃなくてもいい説もありますがそこは気にせず…。

.NET Core 1.1 + ASP.NET MVC Core化のタイミングで折角なのでHTTPSを有効にして、ドメインもAzureのApp Service(azurewebsites.net)だったので platformstatus.io というドメインへも変更しています。

サイト自体はコンパクトなのでASP.NET Coreへの移行は大した苦労はなかったのですが、やはりRazorテンプレートにヘルパーがなくなった所に関しては既存のASP.NET MVCのアプリを移行する際のハードルになると感じました。数が少ない場合には頑張ってPartialにすればよいのですが、ヘビーに使っている場合には使い勝手も違うので移行不能に近い状態になりそうです。

気づいたこととか

ASP.NET Core 2.0でリバースプロキシの後ろにある場合にスキームを維持したい

Created at:

ASP.NET Core 2.0でアプリケーションがリバースプロキシの後ろにあるケースで、リバースプロキシがHTTPSで受け、アプリにはHTTPで受け渡すような構成というのはよくあるかと思います。

しかしASP.NET Coreは標準のままでは Request.Scheme には"http"が入ることになり、"https"が入っていなくて不都合が…という状況が発生します。

そんな時は大抵のリバースプロキシが送り出すであろう X-Forwarded-* ヘッダーフィールドを反映するミドルウェアがあるのでそれを有効にすることで、リバースプロキシにアクセスされた際の情報を維持できます。

その機能を有効にするには UseForwardedHeaders メソッドでミドルウェアを使用するようにし、その際にどのヘッダーフィールドを使うかを指定します。次の例は上位サーバーからのリクエストの X-Forwareded-Proto (スキーム)を見るようにするという設定です。

app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
});

ASP.NET MVC Core 2.0のアプリケーション全体でHTTPSを強制する

Created at:

ASP.NET MVC Core 2.0のアプリケーションでHTTPSを強制するには、RequireHttpsフィルター(RequireHttpsAttributeクラス)をグローバルフィルターに追加することで実現できます。

public void ConfigureServices(IServiceCollection services)
{
    // Add framework services.
    services
        .AddMvc(options =>
        {
            options.Filters.Add(new RequireHttpsAttribute());
        });
}

注意点として、RequireHttpsAttributeフィルターはOnAuthorizationメソッド、つまりフィルターパイプラインの一番最初である認可フェーズで処理されるので、ほかの認証/認可フィルターはこのフィルターより後に登録する必要があります。

MicrosoftのサイトにはRewriteエンジンを使う方法も書かれていますが、大抵のケースではフィルターで追加する方法で間に合うと思います。

MVCを使わないASP.NET Coreでもリダイレクトしたい

また、MVCではないASP.NET Coreで同じようなことをしたい場合には以下のようなMiddlewareを書くとよいでしょう(動作未確認)。

public static IApplicationBuilder UseRedirectToHttps(this IApplicationBuilder app)
{
    return app.Use((context, next) =>
    {
        var req = context.Request;
        if (!req.IsHttps)
        {
            var newUrl = new StringBuilder().Append("https://").Append(req.Host.Host).Append(req.PathBase).Append(req.Path).Append(req.QueryString);
            context.Response.Redirect(newUrl.ToString(), permanent: true);
            return Task.CompletedTask;
        }

        return next();
    });
}

IHostingEnvironmentをConfigureServiceで使いたい

ところでグローバルなフィルターを追加するのはいいのですが、環境によっては例えばデバッグ実行時や開発環境では追加したくないということもあります。

Configure メソッド内であれば env.IsProduction() といった感じで切り替えできるのですが、ConfigureService メソッドには IHostingEnvironment が渡ってこないのです。

ではどうするかというと Startup クラスにプロパティやフィールドをはやして、保持しておくという方法で解決できます。まあ Configuration も保持してますしね。

public Startup(IHostingEnvironment env)
{
    var builder = new ConfigurationBuilder()
        .SetBasePath(env.ContentRootPath)
        .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
        .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
        .AddEnvironmentVariables();
    Configuration = builder.Build();
    HostingEnvironment = env;
}

public IHostingEnvironment HostingEnvironment { get; }

一度保持してしまえば ConfigureServices から参照可能になるので環境ごとに切り替えができます。

public void ConfigureServices(IServiceCollection services)
{
    // Add framework services.
    services
        .AddMvc(options =>
        {
            if (HostingEnvironment.IsProduction())
            {
                options.Filters.Add(new RequireHttpsAttribute());
            }
        });
}

ASP.NET MVC Core 2.0が返すJSONをASP.NET Web APIと同様にPascal Caseにしたい

Created at:

ASP.NET MVC CoreのJSONシリアライズではプロパティ名をデフォルトで"camelCase"に変換して吐き出しますが、従来のASP.NET Web APIは"PascalCase"だったので移植する場合などに以前の挙動になってほしいということがあります。

幸いシリアライザの挙動を変更する手段が用意されているのでそこで変更できます。

StartupConfigureServicesでASP.NET MVCを利用するためAddMvcメソッドを呼び出しますが、その際に返ってきたIMvcBuilderAddJsonOptionsを呼び出すことで設定を変更できます。

ContractResolverというプロパティがシリアライズするときに使うリゾルバなのでこれを変更します。従来のASP.NET Web API相当のシリアライザはJSON.NETのデフォルトなのでDefaultContractResolverContractResolverとして設定すれば期待の挙動となります。

services
    .AddMvc()
    .AddJsonOptions(options =>
    {
        options.SerializerSettings.ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver();
    });

他にもJSONのシリアライズをカスタマイズしたい場合にはこの辺りで変更を加えたり、ContractResolverをカスタムしてあげればよいということになります。

ASP.NET MVC Core 2.0で作ったAPIでXMLなどの形式を返したい

Created at:

ASP.NET MVC Coreはアクションの戻り値が IActionResult ではない場合、デフォルトでJSONをシリアライズしたものを返します。しかし場合によってはJSONだけでなくXMLで返したいという場合もあるかと思います。

そのようなニーズにこたえるべくフォーマッターを登録することで様々な形式でレスポンスを返すことができる仕組みがあります。ここではXMLで返す方法を説明します。

まずXML用のフォーマッターはNuGetから Microsoft.AspNetCore.Mvc.Formatters.Xml パッケージをインストールできるのでインストールします。

次にConfigureServiceメソッドのASP.NET MVC Coreの設定でXMLのフォーマッターを登録します。

services.AddMvc(options =>
{
    // XMLを返すフォーマッターとそのMIME Typeと拡張子のマッピングを登録
    // XmlDataContractSerializerOutputFormatterはASP.NET Web API相当
    options.OutputFormatters.Add(new XmlDataContractSerializerOutputFormatter());
    // options.OutputFormatters.Add(new XmlSerializerOutputFormatter());
    options.FormatterMappings.SetMediaTypeMappingForFormat("xml", "application/xml");
});

この状態で Accept ヘッダーフィールドに application/xml を指定してリクエストするとXMLでレスポンスが返ってくるようになります。簡単ですね。

XMLシリアライザを使うときの注意

XmlDataContractSerializerOutputFormatterでシリアライズするクラスは引数なしのコンストラクタやプロパティにsetterが必要だったりするのでご注意ください。

この辺りを忘れていると406 Not Acceptableが返ってきて悩むことになります。

URLの .json/.xml のような拡張子で出し分けたい

通常は Accept によるコンテントネゴシエーションで返すフォーマットを決定すればよいのですが、URLの一部でフォーマットを指定したいというということもあります。例えば、/People/Alice.jsonとアクセスした場合はJSONで、/People/Alice.xmlであればXMLを返すといった形です。

これを行うには FormatFilter フィルター(FormatFilterAttributeクラス)をコントローラーにつけ、ルーティングに{format}を足します。例えば以下のようになります。

[FormatFilter]
[Route("[controller]/{id}.{format?}")]
public class PeopleController : Controller
{
    [HttpGet]
    public Person Get(string id)
    {
        return /* Person のインスタンスを取得 */;
    }
}

これでフォーマットが指定された場合はそれにマッチするフォーマッターが、指定されていなければデフォルトのフォーマッターが使用されます。

$ curl http://localhost:4743/People/Alice.json
{"name":"Alice","age":17}

$ curl http://localhost:4743/People/Alice.xml
<Person xmlns:i="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://schemas.datacontract.org/2004/07/WebApplication19.Controllers"><Age>17</Age><Name>Alice</Name></Person>

デフォルトのフォーマッターを変更する

Acceptやフォーマット名(拡張子)によってフォーマッターを決定できない場合、フォーマッターの登録順で見て行って一番最初に利用できるもの(=必ずしも一番最初ではない)がデフォルトのフォーマッターとなります。

つまり標準では JsonOutputFormatter が選択されてJSONが返されるわけですが、デフォルトをXMLにしたいという場合もあるかもしれません。そのような場合は一度 JsonOutputFormatter を抜いて、XMLのフォーマッターを登録してから再度 JsonOutputFormatter を登録します。

var jsonOutputFormatter = options.OutputFormatters.OfType<JsonOutputFormatter>().First();
options.OutputFormatters.RemoveType<JsonOutputFormatter>();

options.OutputFormatters.Add(new XmlDataContractSerializerOutputFormatter());

options.OutputFormatters.Add(jsonOutputFormatter);

MessagePack-CSharpを使ってみる

折角なのでレスポンスでMessagePackを返すのも試してみましょう。まあWeb APIでMessagePackを受けたり返したりしたいシーンがどのぐらいあるのかは謎といえば謎なのですが。

C#界最速のシリアライザであるところのMessagePack-CSharpにはASP.NET MVC Core用のフォーマッターが用意されているので使うのは簡単です。

まずNuGetからMessagePackMessagePack.AspNetCoreMvcFormatterをインストールします。

インストールできたらXMLの時と同様に登録します。MessagePackのMIME Typeはapplication/x-msgpackなので拡張子をマッピングを登録する場合やリクエストのAcceptにはそれを指定してください。折角なのでInputFormatterも登録しておきます。

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc(options =>
    {
        options.FormatterMappings.SetMediaTypeMappingForFormat("msgpack", "application/x-msgpack");
        options.OutputFormatters.Add(new MessagePackOutputFormatter());
        options.InputFormatters.Add(new MessagePackInputFormatter());
    });
}

あとは適当なコントローラーとデータを作ります。ここでは /People/Alice といった形でアクセスできるようなものを作ってみます。

レスポンスに使う Person クラスはMessagePackでシリアライズできるように MessagePackObject としてマークしておきます。これはMessagePack-CSharpのお約束です。

[FormatFilter]
[Route("[controller]/{id}.{format?}")]
public class PeopleController : Controller
{
    [HttpGet]
    public Person Get(string id)
    {
        switch (id)
        {
            case "Alice": return new Person("Alice", 17);
            case "Karen": return new Person("Karen", 17);
            default: return null;
        }
    }
}

[MessagePackObject]
public class Person
{
    [Key(0)]
    public string Name { get; }
    [Key(1)]
    public int Age { get; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

あとは /People/Alice.msgpack または Accept: application/x-msgpack 付きででリクエストを投げることでMessagePackで返ってくるはずです。

$ curl -H 'Accept: application/json' http://localhost:4743/People/Alice 2>/dev/null | od -c
0000000   {   "   n   a   m   e   "   :   "   A   l   i   c   e   "   ,
0000020   "   a   g   e   "   :   1   7   }
0000031

$ curl -H 'Accept: application/x-msgpack' http://localhost:4743/People/Alice 2>/dev/null | od -c
0000000 222 245   A   l   i   c   e 021
0000010

ところでMessagePackObjectとかつけるの面倒だなーMapモードでいいんだけどなーということもあるかもしれません。そんな時はContractlessStandardResolverを使用するとよいでしょう。

services.AddMvc(options =>
{
    options.FormatterMappings.SetMediaTypeMappingForFormat("msgpack", "application/x-msgpack");
    options.OutputFormatters.Add(new MessagePackOutputFormatter(ContractlessStandardResolver.Instance));
    options.InputFormatters.Add(new MessagePackInputFormatter(ContractlessStandardResolver.Instance));
});
public class Person
{
    public string Name { get; }
    public int Age { get; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}
$ curl -H 'Accept: application/x-msgpack' http://localhost:4743/People/Alice 2>/dev/null | od -c
0000000 202 244   N   a   m   e 245   A   l   i   c   e 243   A   g   e
0000020 021
0000021

このように簡単にMessagePackで返せるようになるので使えそうなときは積極的に使っていきたいですね。

余談: MvcOptions.RespectBrowserAcceptHeader とは何か

ASP.NET MVC CoreのMvcOptionsにはフォーマッターに関連したRespectBrowserAcceptHeader プロパティという設定が存在します。この設定は何をするものかというと「ブラウザのようにAcceptに*/*といったワイルドカードのメディアタイプを乗せて来た場合、デフォルトとするかどうか」です。

例えばそれぞれの設定時には以下のようになります。

つまりfalseの場合には*/*text/*のようなものを見つけると、Acceptで指定されたものを全て無視するのと同じになります。基本的にワイルドカードを指定してくるのはブラウザぐらいなのでブラウザのAcceptに敬意を払うかどうかということです。

このプロパティはデフォルトは false となっていますので、もしブラウザのようなAcceptも受け入れてその通りに判断してほしいというときは true を設定してください。