June 2016

Volume 31 Number 6

ASP.NET - カスタム ミドルウェアを利用した ASP.NET Core アプリにおける 404 の検出と解決

Steve Smith

学校や遊園地で落し物をして、それが「遺失物保管所」で見つかりほっとした経験はありませんか。Web アプリケーションでは、ユーザーが要求したパスをそのサーバーでは処理しておらず、「404 Not Found」応答コードが表示されることがよくあります (見つからないことをユーモラスに示すページもあります)。一般に、探しているページを見つけられるかどうかはユーザーしだいです。おそらく推論を重ねるか、検索エンジンを使用することになるでしょう。ただし、ちょっとしたミドルウェアを利用して「遺失物保管所」を ASP.NET Core アプリケーションに追加することで、リソースを探しているユーザーを手助けできます。

ミドルウェアとは

ASP.NET Core ドキュメントでは、ミドルウェアが「要求と応答を処理するアプリケーション パイプラインを構成するコンポーネント」と定義されています。最もシンプルなミドルウェアは要求のデリゲートで、次のようにラムダ式として表現します。

app.Run(async context => {
  await context.Response.WriteAsync(“Hello world”);
});

アプリケーションで構成されているのがこのミドルウェア 1 つだけだとすれば、すべての要求に対して「Hello world」が返されます。この例ではミドルウェアの次の要素を参照していない (この要素の実行後に何も定義していない) ので、パイプラインは終了します。ただし、この要素がパイプラインの最後になるというだけで、他のミドルウェアに「ラップ」できないわけではありません。たとえば、次のように上記の応答にヘッダーを付加するミドルウェアを追加してもかまいません。

app.Use(async (context, next) =>
{
  context.Response.Headers.Add("Author", "Steve Smith");
  await next.Invoke();
});
app.Run(async context =>
{
  await context.Response.WriteAsync("Hello world ");
});

ここでは app.Use の呼び出しが app.Run の呼び出しをラップし、next.Invoke を使用して呼び出しています。独自のミドルウェアを作成するときは、パイプライン内の次のミドルウェアの前後いずれか、または両方で処理を実行するかどうかを選択できます。また、次を呼び出さないことでパイプラインを終了することもできます。今回は、このしくみを利用して、404 解決ミドルウェアを作成する方法に紹介します。

MVC Core 既定のテンプレートを使用している場合、初期 Startup ファイルには、そのような低レベルのデリゲートベースのミドルウェア コードはありません。お勧めは、ミドルウェアを独自のクラスにカプセル化して、Startup から呼び出せる (UseMiddlewareName という) 拡張メソッドを用意する方法です。次の呼び出しに示すように、組み込み ASP.NET ミドルウェアはこの表記法に従います。

if (env.IsDevelopment())
{
  app.UseDeveloperExceptionPage();
}
app.UseStaticFiles()
app.UseMvc();

ミドルウェアの順序は重要です。上記のコードでは、UseDeveloperExceptionPage (開発環境でアプリケーションを実行する場合のみ構成) の呼び出しは、エラーが生じる可能性のある他のすべてのミドルウェアをラップする必要があります (そのため、ミドルウェアよりも前に追加します)。

独自のクラスを使用

ミドルウェアのすべてのラムダ式と細々とした実装によって Startup クラスが乱雑になることは望ましくありません。組み込みミドルウェアと同じように、1 行のコードでミドルウェアをパイプラインに追加することを考えます。また、依存関係挿入 (DI) を使用して挿入するサービスがミドルウェアに必要になると予想していますが、これについてはミドルウェアを独自のクラスにリファクタリングすれば簡単に実現できます (ASP.NET Core の DI の詳細については、5 月号のコラム (msdn.com/magazine/mt703433) をご覧ください)。

今回は Visual Studio を使用するので、ミドルウェアを追加するには、[新しい項目の追加] を選択し、[ミドルウェア クラス] テンプレートを選びます。図 1 に、このテンプレートで生成される既定のコンテンツを示します。これには、UseMiddleware を使ってパイプラインにミドルウェアを追加する拡張メソッドを含めています。

図 1 ミドルウェア クラス テンプレート

public class MyMiddleware
{
  private readonly RequestDelegate _next;
  public MyMiddleware(RequestDelegate next)
  {
    _next = next;
  }
  public Task Invoke(HttpContext httpContext)
  {
    return _next(httpContext);
  }
}
// Extension method used to add the middleware to the HTTP request pipeline.
public static class MyMiddlewareExtensions
{
  public static IApplicationBuilder UseMyMiddleware(this IApplicationBuilder builder)
  {
    return builder.UseMiddleware<MyMiddleware>();
  }
}

通常、async を Invoke メソッド シグネチャに追加します。そこで、そのメソッド本体を次のように変更します。

await _next(httpContext);

その結果、この呼び出しは非同期に行われます。

ミドルウェア クラスを別途作成したら、デリゲートのロジックを Invoke メソッドに移動します。次に、Configure 内の呼び出しを UseMyMiddleware 拡張メソッドの呼び出しに置き換えます。この時点でこのアプリケーションを実行すると、以前と同じようにミドルウェアが動作するのがわかります。アプリケーションを一連の UseSomeMiddleware ステートメントで構成すると、Configure クラスが非常にわかりやすくなります。

404 Not Found 応答の検出と記録

ASP.NET アプリケーションでは、どのハンドラーにも一致しない要求が行われた場合、その応答の StatusCode には 404 が設定されます。そこで、(_next を呼び出した後に) この応答コードをチェックし、その要求の詳細を記録する簡単なミドルウェアを作成することができます。

await _next(httpContext);
if (httpContext.Response.StatusCode == 404)
{
  _requestTracker.Record(httpContext.Request.Path);
}

今回は、特定のパスで発生した 404 の数を追跡します。その中から最も起こりやすい 404 を解決することで、解決処理の効果を最大限に高めることを考えます。そのためには、404 要求のパスに応じて 404 要求のインスタンスを記録するサービス RequestTracker を作成します。RequestTracker は DI を使用して今回のミドルウェアに渡します (図 2 参照)。

図 2 RequestTracker をミドルウェアに渡す依存関係の挿入

public class NotFoundMiddleware
{
  private readonly RequestDelegate _next;
  private readonly RequestTracker _requestTracker;
  private readonly ILogger _logger;
  public NotFoundMiddleware(RequestDelegate next,
    ILoggerFactory loggerFactory,
    RequestTracker requestTracker)
  {
    _next = next;
    _requestTracker = requestTracker;
    _logger = loggerFactory.CreateLogger<NotFoundMiddleware>();
  }
}

NotFoundMiddleware をパイプラインに追加するには、UseNotFoundMiddleware 拡張メソッドを呼び出します。ただし、これはサービス コンテナーで構成済みのカスタム サービスを利用することになるため、そのサービスを必ず登録しておく必要があります。IServiceCollection に AddNotFoundMiddleware という拡張メソッドを作成し、このメソッドを Startup の ConfigureServices で呼び出します。

public static IServiceCollection AddNotFoundMiddleware(
  this IServiceCollection services)
{
  services.AddSingleton<INotFoundRequestRepository,
    InMemoryNotFoundRequestRepository>();
  return services.AddSingleton<RequestTracker>();
}

今回の場合、AddNotFoundMiddleware メソッドにより、RequestTracker のインスタンスをサービス コンテナーのシングルトンとして構成するため、RequestTracker を作成した時点で NotFoundMiddleware に挿入できるようになります。また、INotFoundRequestRepository のインメモリ実装も結び付けます。RequestTracker は、この INotFoundRequestRepository を使用してデータを永続化します。

存在しない同じパスに対して多くの要求が同時に行われる場合があります。そのため、図 3 のコードは簡単なロックを使用することで、NotFoundRequest の重複したインスタンスが追加されないようにし、さらにカウントが正しくインクリメントされるようにしています。

図 3 RequestTracker

public class RequestTracker
{
  private readonly INotFoundRequestRepository _repo;
  private static object _lock = new object();
  public RequestTracker(INotFoundRequestRepository repo)
  {
    _repo = repo;
  }
  public void Record(string path)
  {
    lock(_lock)
    {
      var request = _repo.GetByPath(path);
      if (request != null)
      {
        request.IncrementCount();
      }
      else
      {
        request = new NotFoundRequest(path);
        request.IncrementCount();
        _repo.Add(request);
      }
    }
  }
  public IEnumerable<NotFoundRequest> ListRequests()
  {
    return _repo.List();
  }
  // Other methods
}

見つからない要求の表示

404 を記録できるようになったので、そのデータを表示する方法が必要です。これを行うには、小さなミドルウェア コンポーネントをもう 1 つ作成します。このコンポーネントは、記録した「見つからない要求」を発生回数の降順ですべて並べたページを表示します。ここでは現在の要求が特定のパスに一致するかどうかをチェックし、そのパスに一致しない要求はすべて無視 (パス スルー) します。パスが一致する場合は、NotFound 要求を発生回数の降順で格納した表を含むページを返します。そこから、ユーザーは個別の要求に解決したパスを割り当てることができます。その後の要求では 404 を返す代わりにそのパスを使用します。

図 4 は、特定のパスをチェックし、クエリ文字列値に応じて同じパスを使用して更新する NotFoundPageMiddleware を示しています。セキュリティ上の理由から、NotFoundPageMiddleware パスへのアクセスは管理者ユーザーに制限します。

図 4 NotFoundPageMiddleware

public async Task Invoke(HttpContext httpContext)
{
  if (!httpContext.Request.Path.StartsWithSegments("/fix404s"))
  {
    await _next(httpContext);
    return;
  }
  if (httpContext.Request.Query.Keys.Contains("path") &&
      httpContext.Request.Query.Keys.Contains("fixedpath"))
  {
    var request = _requestTracker.GetRequest(httpContext.Request.Query["path"]);
    request.SetCorrectedPath(httpContext.Request.Query["fixedpath"]);
    _requestTracker.UpdateRequest(request);
  }
  Render404List(httpContext);
}

コードから分かるように、パス /fix404s をリッスンするようハードコーディングしています。このパスは構成可能にする方が適切です。そうすれば、さまざまなアプリケーションで希望するパスを指定できます。レンダリングした要求のリストには、解決したパスを用意しているかどうかにかかわらず、404 の記録数が多いものから順番にすべての要求を表示します。ミドルウェアを強化してなんらかのフィルタリングを可能にするのは難しくありません。ほかにも興味深い機能として、情報をさらに詳しく記録できるようにしてもかまいません。それにより、発生頻度の高いリダイレクトや、最近 7 日間で最も多い 404 などを表示できます。ただし、その方法については読者の皆さん (またはオープン ソース コミュニティ) に課題として残しておきます。

図 5 は、レンダリングしたページのようすを示しています。

Fix 404s ページ
図 5 Fix 404s ページ

オプションの追加

さまざまなアプリケーション内で Fix 404s ページに異なるパスを指定できるようにしましょう。最善なのは、Option クラスを作成し、DI を使用してそれをミドルウェアに渡す方法です。今回のミドルウェアの場合は、NotFoundMiddlewareOptions クラスを作成します。このクラスには、fix404s を既定値とする Path プロパティを含めます。IOptions<T> インターフェイスを使用してこのクラスを NotFoundPageMiddleware に渡し、ローカル フィールドをこの型の Value プロパティに設定します。その後、鍵となる /fix404s の文字列参照を更新します。

if (!httpContext.Request.Path.StartsWithSegments(_options.Path))

404 の解決

NotFoundRequest に一致し、CorrectedUrl をもつ要求が送られてきたら、NotFoundMiddleware ではその CorrectedUrl を使用して要求を解決します。そのためには、要求の path プロパティを更新するだけです。

string path = httpContext.Request.Path;
string correctedPath = _requestTracker.GetRequest(path)?.CorrectedPath;
if(correctedPath != null)
{
  httpContext.Request.Path = correctedPath; // Rewrite the path
}
await _next(httpContext);

これを実装することで、どの解決済み URL も、その要求が解決後のパスにリダイレクトされるかのように機能します。その後、要求パイプラインは動作を続け、書き換えられたパスが使用されるようになります。これが目的の動作になることもあれば、そうでない場合もあります。まず、検索エンジンのリストは、複数の URL でインデックス化される重複コンテンツの影響を受ける場合があります。今回のアプローチでは、多数の URL すべてが基になる同じアプリケーション パスにマップされる可能性があります。そのため、永続的なリダイレクト (ステータス コード 301) を使用して 404 を解決する方が適切な場合がよくあります。

リダイレクトを送信するように変更するのであれば、ミドルウェアはこれで終わりです。301 を返すだけならば、パイプラインの残りについてはまったく実行する必要がありません。

if(correctedPath != null)
{
  httpContext.Response. Redirect(httpContext.Request.PathBase + correctedPath +
    httpContext.Request.QueryString, permanent: true);
  return;
}
await _next(httpContext);

解決したパスを設定する場合は、リダイレクトの無限ループに陥らないよう注意します。

理想的には、NotFoundMiddleware でパスの書き換えと固定リダイレクトの両方をサポートします。これを実装するには、NotFoundMiddlewareOptions を使用して、すべての要求に対してパスの書き換えまたは固定リダイレクトを設定するか、NotFoundRequest パスの CorrectedPath を変更して、使用するパスとメカニズムの両方を CorrectedPath に含めるようにします。今回は、この動作をサポートするように NotFoundMiddlewareOptions クラスを更新し、既に NotFoundPageMiddleware に対して行っているように IOptions<NotFoundMiddleOptions> を NotFoundMiddleware に渡します。これらのオプションを使用すると、リダイレクトと書き換えのロジックは次のようになります。

if(correctedPath != null)
{
  if (_options.FixPathBehavior == FixPathBehavior.Redirect)
  {
    httpContext.Response.Redirect(correctedPath, permanent: true);
    return;
  }
  if(_options.FixPathBehavior == FixPathBehavior.Rewrite)
  {
    httpContext.Request.Path = correctedPath; // Rewrite the path
  }
}

この時点で、NotFoundMiddlewareOptions クラスには 2 つのプロパティがあります。そのうちの 1 つは列挙値です。

public enum FixPathBehavior
{
  Redirect,
  Rewrite
}
public class NotFoundMiddlewareOptions
{
  public string Path { get; set; } = "/fix404s";
  public FixPathBehavior FixPathBehavior { get; set; } 
    = FixPathBehavior.Redirect;
}

ミドルウェアの構成

NotFoundMiddlewareOptions をミドルウェア用にセットアップしたら、Startup で構成する際にこれらの NotFoundMiddlewareOptions のインスタンスをミドルウェアに渡します。または、NotFoundMiddlewareOptions を構成にバインドすることもできます。ASP.NET の構成は非常に柔軟性が高く、環境変数や設定ファイルにバインドしたり、プログラムで構成することもできます。構成が設定される場所に関わらず、NotFoundMiddlewareOptions は 1 行のコードで構成にバインドできます。

services.Configure<NotFoundMiddlewareOptions>(
  Configuration.GetSection("NotFoundMiddleware"));

これを適切に行ったら、appsettings.json (今回の例で使用している構成) を次のように更新することで NotFoundMiddleware の動作を構成します。

"NotFoundMiddleware": {
  "FixPathBehavior": "Redirect",
  "Path": "/fix404s"
}

設定ファイルの文字列ベースの JSON 値から FixPathBehavior の列挙値への変換はフレームワークによって自動的に行われます。

保存

これまでのところ、すべて適切に動作しています。しかし、残念ながら 404 のリストと解決したパスはインメモリのコレクションに格納された状態です。つまり、アプリケーションを再起動するたびに、データがすべて失われます。アプリケーションでは 404 のカウントを定期的にリセットするのが適切な場合もあります。そうすることで、現時点で最もよく発生している 404 を把握できます。しかし、せっかく設定した解決済みのパスが失われるのは望ましくありません。

さいわい、保存に抽象化を利用できるように RequestTracker を構成したので (INotFoundRequestRepository)、データベースへの結果の保存は、Entity Framework Core (EF) を使用して非常に簡単にサポートできます。さらに、アプリケーションごとに EF を使用するか、インメモリ構成を使用するかも簡単に選択できるようになります (試してみてください)。そのためには、ヘルパー メソッドを別途用意します。

EF を使用して NotFoundRequests を保存および取得するためにまず必要なのは、DbContext です。アプリケーションが構成している可能性がある DbContext を利用するつもりはないため、NotFoundMiddleware 専用に作成します。

public class NotFoundMiddlewareDbContext : DbContext
{
  public DbSet<NotFoundRequest> NotFoundRequests { get; set; }
  protected override void OnModelCreating(ModelBuilder modelBuilder)
  {
    base.OnModelCreating(modelBuilder);
    modelBuilder.Entity<NotFoundRequest>().HasKey(r => r.Path);
  }
}

DbContext を用意したら、リポジトリ インターフェイスを実装します。EfNotFoundRequestRepository を作成します。これは、そのコンストラクター内で NotFoundMiddlewareDbContext のインスタンスを要求し、取得したインスタンスをプライベート フィールド _dbContext に代入します。たとえば次のように、個々のメソッドの実装は単純です。

public IEnumerable<NotFoundRequest> List()
{
  return _dbContext.NotFoundRequests.AsEnumerable();
}
public void Update(NotFoundRequest notFoundRequest)
{
  _dbContext.Entry(notFoundRequest).State = EntityState.Modified;
  _dbContext.SaveChanges();
}

この時点で、残っているのはアプリケーションのサービス コンテナー内の DbContext と EF リポジトリを関連付けることだけです。これは新しい拡張メソッド内で行います (元の拡張メソッドは名前を変更し、InMemory バージョンであることがわかるようにします)。

public static IServiceCollection AddNotFoundMiddlewareEntityFramework(
  this IServiceCollection services, string connectionString)
{
    services.AddEntityFramework()
      .AddSqlServer()
      .AddDbContext<NotFoundMiddlewareDbContext>(options =>
        options.UseSqlServer(connectionString));
  services.AddSingleton<INotFoundRequestRepository,
    EfNotFoundRequestRepository>();
  return services.AddSingleton<RequestTracker>();
}

接続文字列は、NotFoundMiddlewareOptions に格納しないで、渡すことにしました。EF を使用する ASP.NET アプリケーションでは、ConfigureServices メソッドで接続文字列が既に EF に提供されることが多いためです。必要に応じて、services.AddNotFoundMiddlewareEntityFramework(connectionString) を呼び出すときに同じ変数を使用できます。

このミドルウェアの EF バージョンが使用できるようになる前に新しいアプリケーションで行う必要がある最後の作業は、データベースのテーブル構造が適切に構成されるように移行を実行することです。そのためにはミドルウェアの DbContext を指定する必要があります。(今回の) アプリケーションには独自の DbContext が既に存在するためです。このプロジェクトのルートから実行するコマンドは次のようになります。

dotnet ef database update --context NotFoundMiddlewareContext

データベース プロバイダーに関するエラーが表示される場合は、Startup で services.AddNotFoundMiddlewareEntityFramework を呼び出していることを確認します。

次のステップ

今回示したサンプルは問題なく動作し、インメモリ実装と、EF を使用して Not Found 要求数と解決済みのパスをデータベースに保存する実装を両方とも組み込んでいます。404 のリストと解決済みのパスを追加する機能は管理者のみがアクセスできるように保護します。最後に、現在の EF 実装にはキャッシュ ロジックが含まれていません。そのため、アプリケーションへの要求ごとにデータベース クエリが作成されます。パフォーマンス上の理由から、CachedRepository パターンを使用してキャッシュを追加します。

今回のサンプルを更新したソース コードは、bit.ly/1VUcY0J (英語) から入手できます。


Steve Smith は、独立系のトレーナー、指導者、およびコンサルタントであり、ASP.NET の MVP でもあります。彼は、公式の ASP.NET Core ドキュメント (docs.asp.net、英語) に多くの記事を寄稿しており、チームが ASP.NET Core をすばやく理解できるようになることを支援しています。連絡先は、ardalis.com (英語) です。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの Chris Ross に心より感謝いたします。
Chris Ross は、マイクロソフトの ASP.NET チームの開発者です。現在、彼の頭の中はミドルウェアのことばかりです。