しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

インストール時に MachineKey を自動生成する NuGet パッケージを作った

大昔に紹介した MachineKey をオンラインで生成できるサイトが、いつの間にかドメインごと消滅していてとても不便だったので、代わりになるものとして MachineKey を自動的に生成し、Web.config に設定まで行う NuGet パッケージを作りました。

このパッケージをインストールすると、自動的に Web.config に machineKey 要素が追加されます。今のところ AES256 と HMACSHA256 で固定にしています。

MachineKey に関してもう少し知りたいという場合には、下の記事を参考にしてください。


1 つのアプリケーションを複数の仮想マシンで動作させる場合、問題となることが多いです。

例としてパッケージマネージャーコンソールから、MachineKeyGenerator をインストールしてみました。

インストールするとテキストファイルも追加されますが、これは NuGet の仕様でパッケージに content か lib が含まれていないと install.ps1 / uninstall.ps1 が実行されないので、仕方なく入れています。

Web.config を開くと machineKey 要素が追加されているのが確認できます。本当は 1 行で追加されますが、分かりにくいので改行してあります。

パッケージをアンインストールすると、MachineKey も同時に削除するようにしています。*1

既に MachineKey を設定済み、もしくは NuGet パッケージの復元を行った場合には処理をスキップするようにしています。一度設定した MachineKey が変わることはありません。

実際に NuGet パッケージの復元を行いましたが、MachineKey は変わっていません。

PowerShell 力は相変わらずですが、少しコードを整理した後に GitHub にでも置いておこうかと思います。

追記

GitHub にスクリプト一式を置いておきました。build.cmd を叩けば nupkg が生成されます。

*1:この仕様は後で変更する可能性ありそう

ASP.NET MVC の Display Mode を使っている場合には Vary HTTP ヘッダーを出力するべきだった

ASP.NET MVC の Display Mode を使えば、ビューを用意するだけで PC 版とスマートフォン版のページを同じ URL で公開することができます。

既に何回か紹介しているので、Display Mode については以下の記事を参考にしてください。

機能としては上の記事の通りにすれば問題ないですが、Google のドキュメントに動的な配信を行う場合には Vary HTTP ヘッダーを使って、User-Agent で異なるビューが返されることを伝えるべきとありました。

動的な配信の場合、ページをリクエストするユーザー エージェントに応じて、同じ URL で異なる HTML(および CSS)が配信されます。

この設定では、PC 用ユーザー エージェントのクロール時にはモバイル コンテンツが隠されているため、モバイル用ユーザー エージェント向けにサイトの HTML が変更されることはすぐにはわからない状態になっています。サーバーからヒント送信し、スマートフォン用 Googlebot がページをクロールしてモバイル コンテンツを検出するようリクエストすることをおすすめします。このヒントは Vary HTTP ヘッダーを使用して実装します。

モバイルファースト インデックスに関するおすすめの方法 | Google 検索セントラル  |  ドキュメント  |  Google for Developers

適切にヒントをクローラーに与えていない場合には、検出されるまでに時間がかかることがありそうです。

Vary HTTP ヘッダーを出力する

結局のところ Vary HTTP ヘッダーで User-Agent を返せば良いだけなんですが、IIS の圧縮モジュールを使っている場合には簡単にはいきません。モジュールに既知の不具合があるからです。

IIS で gzip 圧縮が有効になっている場合、強制的に Vary HTTP ヘッダーの値が Accept-Encoding に上書きされてしまいます。残念ながら直る気配がありません。

この時は gzip 圧縮をオフにして対応しましたが、それだと効率が悪化するので URL Rewrite を使いました。

<system.webServer>
  <rewrite>
    <outboundRules>
      <rule name="Append Vary" preCondition="IsHTML">
        <match serverVariable="RESPONSE_Vary" pattern="^.*$" />
        <action type="Rewrite" value="{R:0}, User-Agent" />
      </rule>
      <preConditions>
        <preCondition name="IsHTML">
          <add input="{RESPONSE_CONTENT_TYPE}" pattern="^text/html" />
        </preCondition>
      </preConditions>
    </outboundRules>
  </rewrite>
</system.webServer>

URL Rewrite Module はサーバー変数を書き換えると、レスポンスヘッダーにも反映されます。

この定義を Web.config に張り付けるだけで、HTML を返した時だけ Vary HTTP ヘッダーに User-Agent が追加されます。実際にブラウザからアクセスして確認してみました。

Content-Encoding は gzip のままで、Vary には Accept-Encoding と User-Agent が追加されています。

URL Rewrite の Outbound Rule 処理は HTTP 圧縮が行われた後に実行されるので、影響を受けずに HTTP ヘッダーを操作することが出来る、というからくりでした。

おまけ:Vary HTTP ヘッダーが存在しない場合

ついでに圧縮を無効にした場合の挙動も一緒に確認しておきました。

HTTP 圧縮モジュールが Vary を出力しないため、User-Agent のみ出力されています。理想的な挙動です。

ASP.NET MVC 5 アプリケーションの開発を始める時のテンプレート的なものを作った話

昔 ASP.NET MVC 5 で開発を始める上で、自分が定型的に追加している設定をまとめた記事を書きました。

完全に自分用として書いた記事なので、頻繁に読むようになってしまいました。

ぶっちゃけ、自分で MVC 5 アプリケーションを作り始める時に毎回参照しているのも馬鹿らしくなってきたので、GitHub にて色々と設定を追加したテンプレート的な MVC 5 プロジェクトを公開しました。

テンプレートと言いつつ、VSIX 形式にまでは出来ていないので参考にするとかコピペ元にするとか、そういった使い方をしようかと思ってます。

標準のテンプレートとの違い

具体的にこのテンプレートで、どのような設定を追加で行っているかまとめておきます。

  • 最新の ASP.NET MVC 5.2.3 にアップデート
  • RazorViewEngine のみを有効化
  • 認証クッキー名を .ASPXAUTH から auth に変更
  • セッションクッキー名を ASP.NET_SessionId から session に変更
  • CSRF トークンのクッキー名を __RequestVerificationToken から token に変更
  • Attribute Routing のみを使うように
  • URL ルーティングで生成される URL を小文字に
  • レスポンスヘッダに ASP.NET / ASP.NET MVC のバージョン、X-Powered-By を出力しない
  • クライアントサイド検証を無効化
  • エラー画面の表示を IIS 側の設定に統一
  • HTML ヘルパーを少し追加

カスタム HTTP レスポンスヘッダは地味に非表示にしろと言われるので、最初から非表示にしました。

追加した HTML ヘルパー

バリデーションエラーを表示する ValidationSummary ヘルパーの拡張性が全くなく、実際にデザインを当てる場合に不便しかなかったので、部分ビューを使って自由に表示できるようにしています。

ヘルパー名が微妙ですけど、良いのが思いついたらたぶん変更します。

@using (Html.BeginForm())
{
    @Html.ValidationSummaryView("_ErrorView")
    
    @Html.TextBoxFor(m => m.Name)

    <button type="submit">Submit</button>
}

引数にレンダリングする部分ビュー名を指定しておけば、エラーがある場合にはそのビューを使って表示してくれます。エラーがない場合はレンダリング自体を行いません。

エラー表示用のビューはモデルとして ModelError を取ります。

@model IEnumerable<ModelError>

<p>エラーがあります</p>
<ul>
    @foreach (var error in Model)
    {
        <li>@error.ErrorMessage</li>
    }
</ul>

このヘルパーを使ってエラー表示を行った場合には、以下のようになります。

最近行っている開発では、HTML ヘルパー自体をカスタマイズすることが多すぎるので、ある程度の自由度を持たせて楽できるようにしたいですね。

テンプレートは暇なときに更新していきたいと思います。

ASP.NET の HTTP エラー画面の表示処理を IIS 側に統一する

以前に ASP.NET の customErrors と IIS の httpErrors の違いについて書きました。

結局、この時には customErrors と httpErrors の両方を Web.config に追加するという結論に達しましたが、やはり二つを同時に設定するのは手間なので IIS 側に統一してみました。

元々の Web.config はこのように記述していました。これをベースに変更します。

<system.web>
  <customErrors mode="On">
    <error statusCode="404" redirect="/home/error"/>
  </customErrors>
</system.web>
<system.webServer>
  <httpErrors errorMode="Custom">
    <remove statusCode="404" />
    <error statusCode="404" path="/home/error" responseMode="Redirect" />
  </httpErrors>
</system.webServer>

今回は customErrors での処理を行いたくないので、出来るだけ使わないように設定します。

<system.web>
  <customErrors mode="RemoteOnly" />
</system.web>

mode は適当な感じですが、開発中はエラーが見えた方が良いので RemoteOnly にします。ステータスコードによってエラーページのリダイレクトを行う設定は、要素ごと削除しました。

このままだと ASP.NET 側でエラーが発生した場合には IIS の設定がスキップされて、いつものカスタムエラーページが表示されてしまいますが、httpErrors の existingResponse を Replace に設定すると、関係なく IIS の設定に従って画面を表示することが出来ます。

<system.webServer>
  <httpErrors errorMode="Custom" existingResponse="Replace">
    <remove statusCode="404" />
    <error statusCode="404" path="/home/error" responseMode="ExecuteURL" />
  </httpErrors>
</system.webServer>

responseMode は ExecuteURL に設定してあります。この場合にはサーバー側で path に指定した URL を実行してくれるので、リダイレクトすることなくエラー画面を返すことが出来るようになります。

設定の詳細については IIS 公式サイトのリファレンスを参照しておいてください。

Adding HTTP Errors <error> | Microsoft Learn

これでカスタムなエラーページを URL そのままで表示できるようになりました。ASP.NET と IIS で発生した 404 エラーは両方この設定通りに処理が行われます。

しかし、このままだと 200 が返ってしまうので、ステータスコードを 404 に変更してビューを返します。

public class HomeController : Controller
{
    public ActionResult Error()
    {
        Response.StatusCode = 404;

        return View();
    }
}

余談になりますが、responseMode が Redirect を設定している場合には、TrySkipIisCustomErrors を true に設定しないと無限ループになります。

全て設定が終わったので、実際に開発者ツールを使って確認してみました。

URL はアクセスしたそのままで、カスタムエラー画面と 404 が返っていることが確認出来ました。

ASP.NET MVC で HTTP 401 を返した時にログインページへリダイレクトさせない方法

割と有名な ASP.NET のフォーム認証モジュールのおせっかい機能として、HTTP ステータスコードで 401 を返すと自動的にログインページへの 302 リダイレクトに変換するというのがあります。

認証が必要な Web サイトを作る場合には割と便利なんですが、API を MVC でサクッと作りたい場合には厄介です。API としては 401 を返したいのに、勝手にログインページへのリダイレクトになるからです。

f:id:shiba-yan:20150612140747p:plain

ちなみに ASP.NET Web API の Nightly 版でも同じ問題がありましたが、製品版ではアプリケーション設定に特殊なキーを追加することで回避可能になりました。

最初から Web API で作っとけよという感じですが、今回久しぶりにはまったので書きます。

ASP.NET 4.5 から HttpResponse に SuppressFormsAuthenticationRedirect というそのまんまな名前のプロパティが追加されています。

HttpResponse.SuppressFormsAuthenticationRedirect プロパティ (System.Web)

このプロパティを true にすればリダイレクトは回避出来るんですが、このプロパティに値をセットするタイミングで少し悩みました。

最初は OnActionExecuting をオーバーライドして設定すればいいだろと思って試しました。

public class ApiController : Controller
{
    protected override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        Response.SuppressFormsAuthenticationRedirect = true;

        base.OnActionExecuting(filterContext);
    }

    [Authorize]
    public ActionResult Auth()
    {
        return Json(true, JsonRequestBehavior.AllowGet);
    }
}

ところが実際に試すとリダイレクトされたままです。

少し考えると、そもそも Authorize 属性を付けた場合には OnActionExecuting が実行される前に処理が打ち切られてしまうので、そもそも呼び出されないことに気が付きました。

public class ApiController : Controller
{
    protected override void Initialize(System.Web.Routing.RequestContext requestContext)
    {
        requestContext.HttpContext.Response.SuppressFormsAuthenticationRedirect = true;

        base.Initialize(requestContext);
    }

    [Authorize]
    public ActionResult Auth()
    {
        return Json(true, JsonRequestBehavior.AllowGet);
    }
}

必ず呼び出されるメソッドを考えた結果、今回は Initialize をオーバーライドすることにしました。

f:id:shiba-yan:20150612140759p:plain

これで 401 を返してもリダイレクトされることが無くなりました。

なぞなぞ認証を ASP.NET MVC で実装して遊んでみた

特に意味はないのですが、はてなの各サービスで使えるようになっているなぞなぞ認証を、ASP.NET MVC の機能を使って実装してみました。なぞなぞ認証の詳細ははてなキーワードを見てください。

単純になぞなぞに回答して、それが正解であればページを表示できる機能です。最近は新機能の紹介的なことばかりやってきて疲れてきたので、たまにはこんな実装をしてみるのもいいでしょう。

ASP.NET MVC で実装するので MVC 5 で追加された IAuthenticationFilter を使ってみようと思ったのですが、なぞなぞ認証の場合はユーザーの特定は出来ないので ActionFilter として素直に実装しました。

実装して試した結果

今回はまず実装したらどのような挙動になるのかを紹介しておきます。実際にルートに対してなぞなぞ認証を有効にした場合、専用のフォームが表示されるようになります。

この答えは Azure を使っている方なら誰でも分かると思います。当然ながら答えは kosmosebi ですね。

正しい答えを入力して送信ボタンを押すと、実際のページが表示されるようになります。

これがなぞなぞ認証の大雑把な流れになります。

実際には一度正解した場合には再度入力することの無いように、クッキーに何らかの情報を入れておくべきだと思ったので、適当にクッキーに保存するような処理を実装しました。

コードの解説など

なぞなぞ認証は ActionFilter として実装するので、継承した RiddleAuthenticationAttribute というクラスを作成しました。実装としては簡単で、問題文と答えをコンストラクタで受け取って、ユーザーから答えが入力された場合にはチェックしているだけです。

初回アクセスや、回答が間違っていた場合にはフォームを強制的に表示するようにしています。

public class RiddleAuthenticationAttribute : ActionFilterAttribute
{
    public RiddleAuthenticationAttribute(string question, string answer)
    {
        _question = question;
        _answer = answer;

        var hash = SHA1.Create().ComputeHash(Encoding.UTF8.GetBytes(_question + ":" + _answer));

        _riddleHash = BitConverter.ToString(hash).Replace("-", "").ToLower();
    }

    private readonly string _question;
    private readonly string _answer;

    private readonly string _riddleHash;

    private const char Separator = '|';
    private const string RiddleCookieName = "riddle";

    private const string DefaultAnswerName = "riddle-answer";
    private const string DefaultViewName = "_RiddleForm";

    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        // 既に回答済みか確認
        var cookie = filterContext.HttpContext.Request.Cookies[RiddleCookieName] ?? new HttpCookie(RiddleCookieName);

        var values = (cookie.Value ?? "").Split(new[] { Separator }, StringSplitOptions.RemoveEmptyEntries);

        if (values.Contains(_riddleHash))
        {
            return;
        }

        // 回答が入力されたか確認
        var postAnswer = filterContext.HttpContext.Request.Form[DefaultAnswerName];

        if (postAnswer == _answer)
        {
            cookie.Value = string.Join(Separator.ToString(), values.Concat(new[] { _riddleHash }));

            filterContext.HttpContext.Response.SetCookie(cookie);

            return;
        }

        // なぞなぞ認証フォームを表示
        filterContext.Result = new ViewResult
        {
            ViewData = new ViewDataDictionary
            {
                { "Question", _question }
            },
            ViewName = DefaultViewName
        };
    }
}

多少、フォームの項目は決め打ちが多いですが、眠たいのと遊びなのでこのように実装しました。

この属性を使った例は以下のようになります。単純にアクションへ追加するだけです。

public class HomeController : Controller
{
    [RiddleAuthentication("Azure 界の抱かれたい男 No.1 とは?", "kosmosebi")]
    public ActionResult Index()
    {
        return View();
    }
}

ちなみになぞなぞ認証のフォームは以下のような形です。特にいうこともないシンプルなフォームです。

<h2>なぞなぞ認証</h2>

@using (Html.BeginForm())
{
    <div class="form-group">
        <label>@ViewBag.Question</label>
        <input type="text" name="riddle-answer" class="form-control" />
    </div>
    <button type="submit" class="btn btn-primary">送信</button>
}

実際に使うかと言われると微妙ですが、ActionFilter を使う例としては分かりやすいサンプルになったのではないかと思います。IAuthenticationFilter は Basic 認証の例をどこかでアップしておきたいと思います。

IIS 10.0 と ASP.NET 4.6 で HTTP/2 のサーバープッシュが使えるようになっていたので試した

IIS 10.0 で HTTP/2 のサーバープッシュに対応しているか分からないと書きましたが、実際には ASP.NET からサーバープッシュを簡単に行えるようになっていました。

まずは HTTP/2 のサーバープッシュについての参考になる記事を紹介しておきます。

初めてのHTTP/2サーバプッシュ | GREE Engineering
HTTP/2 入門 - Yahoo! JAPAN Tech Blog

要するに、リクエストされたページに必要なリソースを予めクライアントに送信しておくための仕様ですね。ページをパースしてからリソースを取りに行くのと異なり、既にダウンロード済みとなるので、素早く表示することが出来るというからくりです。

ASP.NET 4.6 での対応

ASP.NET 4.6 の API を軽く調べてみると、HttpResponse に PushPromise というメソッドが追加されていることに気が付きました。これは ASP.NET 4.6 の HTTP/2 対応らしいです。

HttpResponse.PushPromise Method (System.Web) | Microsoft Learn

HttpResponse クラスと HttpResponseBase クラスの両方に実装されているので、Web Forms と MVC の両方で問題なく使えるようになっています。

簡単に試したいので、Web Forms を使ってサンプルコードを書いてみました。1 行ですけど。

public partial class Default : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        Response.PushPromise("~/iisstart.png");
    }
}

PushPromise メソッドの引数は仮想パスじゃないとエラーになります。

サーバープッシュ無し

まずは PushPromise メソッドの呼び出しをコメントアウトして試したところ、iistart.png のリクエストでは TTFB が大きな時間を占めていることが分かります。

サーバーからデータを受信し始めるまで時間がかかってます。

サーバープッシュ有り

次に PushPromise メソッドの呼び出しを有効にして試したところ、TTFB がほぼ無くなりました。

Request Sent に時間がかかるようになっていますが、これは純粋にプッシュされた画像のダウンロードに時間がかかっているのかもしれません。

Chrome で HTTP/2 のフレームを確認してみたところ、ちゃんとプッシュされていることが確認出来ます。

レンダリングがブロックされる JavaScript や CSS をプッシュした方が効果が大きい気がします。

ASP.NET MVC から使う

本来なら透過的に静的なリソースをプッシュするような HTTP Module を実装するのが良いと思いましたが、ちょっと手間なので MVC の URL ヘルパーとして実装してみました。

public static string PushContent(this UrlHelper urlHelper, string contentPath)
{
    var context = urlHelper.RequestContext.HttpContext;

    context.Response.PushPromise(contentPath);

    return urlHelper.Content(contentPath);
}

実装としては PushPromise を呼び出す単純なヘルパーです。使い方は Url.Content と同じですが、プッシュしたいコンテンツの時に使うようにするだけで恩恵に与れます。

ブラウザで確認すると、TTFB はとても短くなっているのでプッシュされていることが確認出来ます。

今回は手を抜いて全コンテンツに対してプッシュを使ってみましたが、サーバープッシュを使うとキャッシュが効かなくなるので、CDN で配信可能なものに関しては有効にしない方が良い気がしました。

Firefox でも試してみると、プッシュが効いているからかタイミングすら表示されなかったです。

コンテンツを事前に送り込むというのは、キャッシュされないということを考えると、思っていたよりも使いどころが難しい機能だと感じました。

ASP.NET Web API を使って画像を Motion JPEG で配信する

Raspberry Pi 2 が Azure Blob に保存した画像を Motion JPEG として配信してみたくなったので、まずはローカルに保存している画像を Motion JPEG として返す Web API を作って試してみました。

Motion JPEG - Wikipedia

H.264 で圧縮しても良い気がしますが、エンコードしながら HTTP で出力する難易度がとても高そうだったので、今回はとても簡単な Motion JPEG です。

Web API に用意されている PushStreamContent を使うと、サーバーからのプッシュが簡単に実装できるので、これを利用して JPEG データを定期的に送信することで動画っぽく見せます。

http://blogs.msdn.com/b/henrikn/archive/2012/04/23/using-cookies-with-asp-net-web-api.aspx

中の人がブログで簡単な使い方を紹介してくれています。

要するに PushStreamContent を Content として返すと、レスポンスのストリームを明示的に閉じるか、HTTP 自体が閉じられるまで自由に書き込みできるという話です。動画をバッファリングして配信したり、Server-Sent Events の実装もこれを使えば簡単でしょう。

ちなみに Motion JPEG として配信するための HTTP レスポンスについては、以下の Wiki に書いてあったので参考に実装しました。

[Wiki] Motion JPEG | en.code-bude.net

まずは、ローカルに保存してある画像を出力するだけの API を作ってみました。

public class StreamController : ApiController
{
    private readonly byte[] _newLine = Encoding.UTF8.GetBytes("\r\n");
    private readonly string _boundary = "0123456789";

    public HttpResponseMessage Get()
    {
        var response = Request.CreateResponse();

        response.Content = new PushStreamContent(async (stream, content, context) =>
        {
            foreach (var file in Directory.GetFiles(@"C:\Users\shibayan\Documents\snapshot", "*.jpg"))
            {
                var fileInfo = new FileInfo(file);

                // ヘッダー書き込み
                var header = string.Format("--{0}\r\nContent-Type: image/jpeg\r\nContent-Length: {1}\r\n\r\n", _boundary, fileInfo.Length);
                var headerData = Encoding.UTF8.GetBytes(header);

                await stream.WriteAsync(headerData, 0, headerData.Length);

                // JPEG データ書き込み
                await fileInfo.OpenRead().CopyToAsync(stream);

                // 最後の改行書き込み
                await stream.WriteAsync(_newLine, 0, _newLine.Length);

                // 30fps で頑張ってみるつもり
                await Task.Delay(1000 / 30);
            }
        });

        // PushStreamContent の mediaType に指定すると検証エラーになったのでここで追加
        response.Content.Headers.TryAddWithoutValidation("Content-Type", "multipart/x-mixed-replace;boundary=" + _boundary);

        return response;
    }
}

いつも通り手抜きな感じですが、とりあえずこれで動作を確認してみます。

残念と言うか当然という感じではありますが、世間的には multipart/x-mixed-replace は全く流行らなかったらしく、ブラウザとしては Firefox しか対応していないようです。

問題なく再生出来ました。正確には素早く JPEG を差し替えているだけですが。

他には VLC Media Player を使って、ネットワークストリームとして開くことで再生出来ました。

本来の目的である、Azure Blob に保存している画像を Motion JPEG として配信することに関しては、JPEG を読み込む元を変更するぐらいで対応出来ると思います。

ASP.NET Web API でバージョニングを実現する方法を調べた

一般的に Web API のバージョニングを実現する方法として、URL にバージョンを含める方法とクエリパラメータや HTTP ヘッダーでバージョンを指定する方法の 2 種類が使われています。

例を挙げると Twitter や Instagram の API は URL でバージョンを指定します。

https://api.twitter.com/1.1/statuses/user_timeline.json

https://api.instagram.com/v1/media/popular?client_id=CLIENT-ID

対して Azure の REST API では、クエリパラメータや HTTP ヘッダーでバージョンを指定します。

https://[search service name].search.windows.net/indexes?api-version=2015-02-28

今回は Twitter や Instagram ような URL ベースのバージョニングを、ASP.NET Web API で実現する方法を調べました。思ったよりめんどくさい感じでした。

IHttpControllerSelector を実装して実現

Web API の DefaultHttpControllerSelector はコントローラをクラス名だけでグルーピングしてしまうので、名前空間が異なっていたとしても同じものとして扱われてしまいます。

string controllerName = controllerTypeGroup.Key;

foreach (IGrouping<string, Type> controllerTypesGroupedByNs in controllerTypeGroup.Value)
{
    foreach (Type controllerType in controllerTypesGroupedByNs)
    {
        if (result.Keys.Contains(controllerName))
        {
            duplicateControllers.Add(controllerName);
            break;
        }
        else
        {
            result.TryAdd(controllerName, new HttpControllerDescriptor(_configuration, controllerName, controllerType));
        }
    }
}
ASP.NET MVC / Web API / Web Pages - Source Code

クラス名に V1 とか V2 と付ければ問題無いですが、やはり名前空間でバージョンを分けておきたいので IHttpControllerSelector を実装して対応します。

規約ベースで実装

最初に考えられるのは規約ベースでバージョンを決めてしまう方法だと思います。実際に MSDN Blog でもバージョンを URL ルーティングに追加し、名前空間にマッピングさせる方法が紹介されています。

ASP.NET Web API: Using Namespaces to Version Web APIs - .NET Web Development and Tools Blog - Site Home - MSDN Blogs

規約ベースで書けるので追加の設定など必要なく、名前空間とコントローラを増やすだけで複数バージョンに対応出来ますし、サンプルコードが CodePlex で提供されているので、そのまま使うことが出来そうです。

http://aspnet.codeplex.com/SourceControl/latest#Samples/WebApi/NamespaceControllerSelector/

規約ベースで開発している場合は、こっちを選んだ方がスムーズな気がします。

Attribute Routing で実装

既に ASP.NET Web API で Attribute Routing を使った開発をしている場合には、名前空間でコントローラを分けて RoutePrefix でバージョンを直接指定する方法も考えられます。

namespace SampleWebApi.Controllers.V1
{
    [RoutePrefix("v1/products")]
    public class ProductsController : BaseApiController
    {
        [Route]
        public async Task<IHttpActionResult> GetProduct()
        {
            return Ok();
        }
    }
}

残念ながら DefaultHttpControllerSelector の制限でこのままでは動作しませんし、サンプルコードで用意されている NamespaceControllerSelector を使っても対応出来ないので、自前で同じように実装しました。

重要な部分は SelectController メソッドなので、その部分だけ引っ張り出してきました。

public HttpControllerDescriptor SelectController(HttpRequestMessage request)
{
    var routeData = request.GetRouteData();

    if (routeData == null)
    {
        throw new HttpResponseException(HttpStatusCode.NotFound);
    }

    var subRoute = routeData.GetSubRoutes().FirstOrDefault();

    if (subRoute == null)
    {
        throw new HttpResponseException(HttpStatusCode.NotFound);
    }

    var actions = (HttpActionDescriptor[])subRoute.Route.DataTokens["actions"];

    if (actions.Length == 0)
    {
        throw new HttpResponseException(HttpStatusCode.NotFound);
    }

    return actions[0].ControllerDescriptor;
}

Attribute Routing を使った場合には MS_SubRoutes というキーが RouteData に含まれていて、更にその中に HttpActionDescriptor が入っているので辿っていきます。

最後に HttpControllerDescriptor を返せば、そのコントローラが使われるようになります。

ライブラリを使って実現

自分で IHttpControllerSelector を実装するのもめんどくさい場合には、手軽に公開されているライブラリを使うという方法もあります。

このライブラリを使えば URL と HTTP ヘッダーを使ったバージョニングの両方が使えるようになるみたいです。実際に使い方を紹介している方が居たのでリンクを載せておきます。

Versioning ASP.Net Web API | Culbertson Exchange

最近は Attribute Routing ばかり使っているので、IHttpControllerSelector を実装して解決しました。名前空間を無視するかどうかを設定で切り替えさせてほしかったですね。

ASP.NET Web API でアップロードされたファイルを扱う方法を知らなかったので調べた

ASP.NET MVC でファイルアップロードを実装する場合は、モデルバインダが HttpPostedFileBase クラスなどに自動的にバインドしてくれていましたが、Web API だとそこまで面倒は見てくれないみたいです。

これまで Web API での実装方法を知らなかったですが、今回ついに観念して調べました。

Sending HTML Form Data in ASP.NET Web API: File Upload and Multipart MIME | The ASP.NET Site

MultipartFormDataStreamProvider クラスを使うとアップロードされたファイルをストレージ上に保存してくれるみたいですね。メモリストリームとして扱い続けるよりも楽な感じがします。

サンプルコードでは App_Data に入れてましたが、個人的に Temp のが良い気がしたので変えました。

public async Task<IHttpActionResult> FileUpload()
{
    // multipart/form-data 以外は 415 を返す
    if (!Request.Content.IsMimeMultipartContent())
    {
        return StatusCode(HttpStatusCode.UnsupportedMediaType);
    }

    // マルチパートデータを一時的に保存する場所を指定
    var rootPath = Path.GetTempPath();
    var provider = new MultipartFormDataStreamProvider(rootPath);

    // 実際に読み込む
    await Request.Content.ReadAsMultipartAsync(provider);

    // ファイルデータを読む
    foreach (var file in provider.FileData)
    {
        // ファイル情報を取得
        var fileInfo = new FileInfo(file.LocalFileName);
    }

    return Ok();
}

アップロード時に渡されたファイル名などの情報は Headers プロパティから参照できます。

MultipartFormDataStreamProvider クラスを使って実際にアップロードしてみると、Temp ディレクトリにユニークなファイル名でアップロードしたファイルが保存されていることが分かりますね。

f:id:shiba-yan:20150319160736p:plain

単純なファイルアップロードが必要な場合はこれで十分だと思います。

他にファイルアップロード時に使えるプロバイダーとしては MultipartFormDataRemoteStreamProvider クラスも用意されています。このプロバイダーは書き込み可能なストリームを返すような実装を用意することで、アップロードされたファイルを直接保存することが出来ます。

実際に Azure Blob へのストリームを返すプロバイダーを実装してみました。

public class AzureBlobRemoteStreamProvider : MultipartFormDataRemoteStreamProvider
{
    public AzureBlobRemoteStreamProvider()
    {
        _storageAccount = CloudStorageAccount.Parse("...");
    }

    private readonly CloudStorageAccount _storageAccount;

    public override RemoteStreamInfo GetRemoteStream(HttpContent parent, HttpContentHeaders headers)
    {
        var fileName = headers.ContentDisposition.FileName.Trim('"');

        var blobClient = _storageAccount.CreateCloudBlobClient();

        var container = blobClient.GetContainerReference("upload");
        var blob = container.GetBlockBlobReference(fileName);

        var stream = blob.OpenWrite();

        return new RemoteStreamInfo(stream, blob.Uri.AbsoluteUri, fileName);
    }
}

アップロードされたファイル名のまま upload というコンテナに保存するだけの簡単なプロバイダーです。

使い方はシンプルで、ReadAsMultipartAsync に渡すプロバイダーを切り替えるだけです。

public async Task<IHttpActionResult> Post()
{
    var provider = new AzureBlobRemoteStreamProvider();

    await Request.Content.ReadAsMultipartAsync(provider);

    foreach (var file in provider.FileData)
    {
        var blobUri = file.Location;
    }

    return Ok();
}

実際にアップロードすると、Blob に書きこまれていることが確認出来ます。

f:id:shiba-yan:20150319162151p:plain

GetRemoteStream メソッドでもうちょっと処理を頑張ると、アップロードしたユーザー別にコンテナを作成して保存するといった処理も実装できると思います。