次の方法で共有



September 2016

Volume 31 Number 9

ASP.NET Core - ASP.NET Core MVC 向け機能スライス

Steve Smith

大規模 Web アプリケーションを開発するときは、小規模 Web アプリケーションの開発よりも適切な編成が求められます。大規模アプリケーションでは、ASP.NET MVC (および Core MVC) で使われる既定の編成構造がマイナスに働きます。編成アプローチを 2 つのシンプルな手法を使って新しくすれば、成長を続けるアプリケーションに対応していくことできます。

モデル ビュー コントローラー (MVC: Model-View-Controller) パターンは Microsoft ASP.NET 環境でも、成熟した手法になっています。ASP.NET MVC の最初のバージョンは、2009 年にリリースされました。そして、このプラットフォームの最初の完全リブート版の ASP.NET Core MVC がこの初夏にリリースされました。ASP.NET MVC は進化しましたが、このリブート全体を通じて、コントローラー フォルダー、ビュー フォルダー、および多くの場合はモデル フォルダー (また、おそらくはビューモデル フォルダー) といった、既定のプロジェクト構造に変化はありません。実際に、現時点で新しい ASP.NET Core アプリケーションを作成すると、図 1 に示すように、既定のテンプレートによってこれらのフォルダーが作成されるのがわかります。

ASP.NET Core Web アプリケーションの既定のテンプレート構造
図 1 ASP.NET Core Web アプリケーションの既定のテンプレート構造

この編成構造には、多くのメリットがあります。まず、ここ数年の間に ASP.NET MVC プロジェクトに携わったことがあれば、すぐに理解できる「わかりやすさ」があります。また、コントローラーやビューを探している場合、絶好の調査出発点となる「組織性」があります。新しいプロジェクトを開始したばかりのときには、ファイルがあまり多くないので、この編成構造は非常に有効です。しかし、プロジェクトの規模が大きくなるにつれ、このような階層構造に含まれるファイルやフォルダーの数が増え、その中から目的のコントローラーやビューのファイルを見つけるのに手間がかかるようになります。

これを理解するため、コンピューターのファイルをこれと同じ構造に編成したところを想像してください。つまり、さまざまなプロジェクトや作業のフォルダーを個別に用意するのではなく、ディレクトリをファイルの種類だけで編成します。テキスト ドキュメントのフォルダー、PDF のフォルダー、イメージ のフォルダー、スプレッドシートのフォルダーのような感じです。複数の種類のドキュメントを使用する特定のタスクに取り組むことになったら、さまざまなフォルダーを行き来して、数多くのファイルをスクロールして探さなければなりません。各フォルダーに含まれるファイルの中には現在のタスクとは関係のないファイルもたくさんあります。これがまさに、既定の方法で編成された MVC アプリケーションで機能に取り組む方法です。

問題は、目的ではなく種類によって編成されたファイルのグループは、凝集度に欠ける傾向がある点です。凝集度とは、1 つのモジュール内の要素が協調している度合いを表します。一般的な ASP.NET MVC プロジェクトでは、特定のコントローラーが (コントローラー名に対応するフォルダーに含まれる) 1 つ以上の関連するビューを参照します。コントローラーとビューはどちらも、コントローラーの役割に関連する 1 つ以上のビューモデルを参照します。しかし、一般的に、いくつかのビューモデルの型やビューは、複数のコントローラーの型から使用されます (また、ドメイン モデルや永続化モデルは独自の個別プロジェクトに移動されるのが一般的です)。

サンプル プロジェクト

4 つの緩やかに関連するアプリケーション概念を管理するシンプルなプロジェクトがあるとします。 その概念は、Ninjas、Plants、Pirates、および Zombies で。実際のサンプルが行うのは、これらの概念を一覧し、表示し、追加することだけです。ただし、このサンプルには多くのビューが関係し、複雑なものになると想像してください。このプロジェクトの既定の編成構造は、図 2 のようになります。

サンプル プロジェクトの既定の編成構造
図 2 サンプル プロジェクトの既定の編成構造

Pirates の新しい機能に取り組むには、Controllers に移動して PiratesController を探し、Views の Pirates に移動して対応するビュー ファイルを探すことになります。コントローラーが 5 つしかなくても、多くのフォルダーを行き来することになるのがわかります。プロジェクトのルートにもっと多くのフォルダーがあると、通常、さらに厄介なことになります。というのも、Controllers と Views は、アルファベット順で場所が離れているためです (フォルダーを追加すると、おそらく、フォルダーの一覧でこの 2 つのフォルダーの間に挿入されます)。

ファイルを種類別に編成しないとなると、他にはアプリケーションで行う処理別にファイルを編成するアプローチが考えられます。コントローラー用、モデル用、ビュー用などのフォルダーではなく、機能や役割の区分で編成したフォルダーをプロジェクトに用意します。関連するファイルが一緒に保存されるため、アプリケーションの特定の機能に関連するバグや機能に取り組んでいるときに、開くフォルダーの数が少なくなります。これは、さまざまな方法で実現できます。たとえば、組み込みの区分機能を使用する方法があります。また、機能のフォルダー用に独自の表記法を構成する方法もあります。

ASP.NET Core MVC のファイル認識方法

ここで少し、アプリケーションに組み込んで使用する標準的な種類のファイルを ASP.NET Core MVC が扱う方法について説明しましょう。アプリケーションのサーバー側に関係するファイルの多くは、いずれかの .NET 言語で記述されたクラスです。このようなコード ファイルは、コンパイルして、アプリケーションから参照できれば、ディスクのどこにあっても機能します。特に、コントローラー クラス ファイルは、特定のフォルダーに格納する必要はありません。さまざまな種類のモデル クラス (ドメイン モデル、ビュー モデル、永続モデルなど) も同様で、ASP.NET MVC Core プロジェクトとは別のプロジェクトに簡単に含めることができます。アプリケーションのコード ファイルの大半は、自身の好みの方法で、配置や再配置が可能です。

ですが、ビューは異なります。ビューは、コンテンツ ファイルです。アプリケーションのコントローラー クラスとビューの保存場所の相対関係は問題になりません。しかし、それらを検索する場所を、MVC が認識していることが重要です。区分は、既定のビュー フォルダーではないさまざまな場所にあるビューを検索するための、組み込みサポートを提供します。また、MVC がビューの場所を判断する方法をカスタマイズすることもできます。

区分を使用した MVC プロジェクトの編成

区分は、ASP.NET MVC アプリケーションに含まれる個々のモジュールを編成する方法を提供します。各区分には、プロジェクト ルートの表記の規則を模したフォルダー構造があります。そのため、MVC アプリケーションには、同じルート フォルダー構造に加えて、Areas という追加フォルダーが含まれます。Areas フォルダーには、コントローラー用のフォルダーやビュー用のフォルダー (および必要に応じてモデル用やビューモデル用のフォルダー) など、アプリケーションのセクションごとに 1 つのフォルダーが含まれます。

区分は、大規模アプリケーションを、論理的に区別された個別のサブアプリケーションに分割する強力な機能です。たとえば、コントローラーには、区分が異なれば同じ名前を付けることができます。実際、アプリケーション内の各区分に HomeController クラスを用意するのが一般的です。

ASP.NET MVC Core プロジェクトに区分のサポートを追加するには、単純に Areas という新しいルート レベルのフォルダーを作成します。このフォルダーに、区分内に編成するアプリケーションの各要素に対応する新しいフォルダーを作成します。その後、このフォルダー内に、コントローラー用やビュー用の新しいフォルダーを追加します。

したがって、コントローラーのファイルは以下の場所に配置することになります。

/Areas/[area name]/Controllers/[controller name].cs

そのコントローラーが特定の区分に属していることを MVC フレームワークが認識するように、コントローラーには以下のように Area 属性を適用します。

namespace WithAreas.Areas.Ninjas.Controllers
{
  [Area("Ninjas")]
  public class HomeController : Controller

ビューは次の場所に配置します。

/Areas/[area name]/Views/[controller name]/[action name].cshtml

区分に移動したビューへのリンクはすべて、更新する必要があります。タグ ヘルパーを使用している場合は、区分名の指定をタグ ヘルパーの一部に含めることができます。以下に例を示します。

<a asp-area="Ninjas" asp-controller="Home" asp-action="Index">Ninjas</a>

同じ区分に含まれるビュー間のリンクでは、asp-area 属性を省略できます。

アプリケーションで区分をサポートするために必要な最後の作業は、アプリケーションの既定のルーティング規則を更新することです。Startup.cs の Configure メソッドを以下のように更新します。

app.UseMvc(routes =>
{
  // Areas support
  routes.MapRoute(
    name: "areaRoute",
    template: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
  routes.MapRoute(
    name: "default",
    template: "{controller=Home}/{action=Index}/{id?}");
});

たとえば、さまざまな Ninjas、Pirates などを管理するサンプル アプリケーションでは、区分を活用して、図 3 に示すプロジェクト編成構造を実現できます。

区分による ASP.NET Core プロジェクトの編成
図 3 区分による ASP.NET Core プロジェクトの編成

区分機能を使用すると、アプリケーションの論理セクションごとに個別のフォルダーを用意できるため、既定の構造よりも操作がしやすくなります。区分は ASP.NET Core MVC 組み込みの機能なので、必要な設定は最小限で済みます。まだ区分を使用したことがなければ、アプリケーションの関連セクションをグループにまとめて、アプリケーションの他の部分と区別する便利な機能だと覚えてください。

ただし、区分による編成でも、非常に多くのフォルダーが含まれます。Areas フォルダーに含まれる比較的少ない数のファイルを表示するのでも、縦長のスペースが必要になることがわかります。各区分のコントローラーが多くなく、各コントローラーのビューも多くない場合、既定の構造と同じように、こうしたフォルダーのオーバーヘッドによって面倒が増える可能性があります。

さいわい、簡単に独自の構造を作成することができます。

ASP.NET Core MVC の機能フォルダー

既定のフォルダー構造や組み込みの区分機能を使用する以外に、MVC プロジェクトを編成する最も一般的な方法は、機能別のフォルダーを使用する方法です。これは、バーティカル スライス (bit.ly/2abpJ7t、英語) と呼ばれる機能提供手法を採用しているチームに特に適しています。というのも、バーティカル スライスの UI に関する懸念事項の多くは、そのような機能フォルダーの 1 つに存在する可能性があるためです。

ファイルの種類別ではなく、機能別にプロジェクトを編成する場合、通常は (Features などの) ルート フォルダーを作成して、その中に機能別のサブフォルダーを用意します。これは、区分の編成方法に非常によく似ています。ただし、各機能フォルダーの中には、必要なコントローラー、ビュー、およびビューモデルの型をすべて含めます。多くのアプリケーションでは、各機能フォルダーにおそらく 5 ~ 15 個の項目が含まれることになります。フォルダーに含まれる項目はすべて、相互に密接に関連しています。機能フォルダーのコンテンツ全体は、ソリューション エクスプローラーで引き続き表示できます。サンプル プロジェクトのこの編成の例を図 4 に示します。

機能フォルダー編成
図 4 機能フォルダー編成

ルート レベルの Controllers フォルダーと Views フォルダーがなくなっているのがわかります。このアプリケーションのホーム ページは、Home という独自の機能フォルダーになっています。また、_Layout.cshtml などの共有ファイルは、Features フォルダー内の Shared フォルダーに配置されています。このプロジェクト編成構造は、非常に適切にスケーリングされます。そのため、開発者は、アプリケーションの特定のセクションに取り組んでいるときに表示するフォルダーを、非常に少なくできます。

この例では、区分を使用する場合とは異なり、ルート フォルダーを追加する必要がなく、コントローラーの属性も必要ありません (ただし、コントローラー名はこの実装の機能全体で一意にならなければなりません)。この編成をサポートするためには、カスタム IViewLocationExpander と IControllerModelConvention が必要です。この両方を、なんらかのカスタム ViewLocationFormats と一緒に使用して、Startup クラスで MVC を構成します。

特定のコントローラーに関して、それがどの機能に関連しているのかを把握しておくと役立ちます。区分では、属性を使用してこれを実現していますが、機能フォルダーのアプローチでは、表記法を使用します。この表記法では、コントローラーが「Features」という名前空間にあり、名前空間の階層で「Features」の次にくる項目が機能名であると想定します。この名前は、ビューの検索中に利用可能なプロパティに追加されます (図5 参照)。

図 5 FeatureConvention: IControllerModelConvention

{
  public void Apply(ControllerModel controller)
  {
    controller.Properties.Add("feature", 
      GetFeatureName(controller.ControllerType));
  }
  private string GetFeatureName(TypeInfo controllerType)
  {
    string[] tokens = controllerType.FullName.Split('.');
    if (!tokens.Any(t => t == "Features")) return "";
    string featureName = tokens
      .SkipWhile(t => !t.Equals("features",
        StringComparison.CurrentCultureIgnoreCase))
      .Skip(1)
      .Take(1)
      .FirstOrDefault();
    return featureName;
  }
}

Startup で MVC を追加するときに、次の表記の規則を MvcOptions に追加します。

services.AddMvc(o => o.Conventions.Add(new FeatureConvention()));

MVC で使用する通常のビュー検索ロジックを機能ベースの表記法に置き換えるには、MVC で使用する View­LocationFormats の一覧を削除して、独自の一覧に置き換えます。これは、AddMvc 呼び出しの一環として行います (図 6 参照)。

図 6 MVC が使用する通常のビュー検索ロジックを置き換える

services.AddMvc(o => o.Conventions.Add(new FeatureConvention()))
  .AddRazorOptions(options =>
  {
    // {0} - Action Name
    // {1} - Controller Name
    // {2} - Area Name
    // {3} - Feature Name
    // Replace normal view location entirely
    options.ViewLocationFormats.Clear();
    options.ViewLocationFormats.Add("/Features/{3}/{1}/{0}.cshtml");
    options.ViewLocationFormats.Add("/Features/{3}/{0}.cshtml");
    options.ViewLocationFormats.Add("/Features/Shared/{0}.cshtml");
    options.ViewLocationExpanders.Add(new FeatureViewLocationExpander());
  }

既定で、このような書式指定文字列には、アクションのプレースホルダー (“{0}”)、コントローラーのプレースホルダー (“{1}”)、および区分のプレースホルダー (“{2}”) が含まれます。このアプローチでは、機能用に、4 つ目のトークン (“{3}”) を追加します。

使用するビュー検索形式では、機能内の別のコントローラーによって使用される同じ名前のビューをサポートします。たとえば、1 つの機能に複数のコントローラーを含めて、複数のコントローラーに Index メソッドを用意するのは非常に一般的です。これは、コントローラー名を照会して、フォルダー内でビューを検索することによってサポートされます。したがって、NinjasController.Index および SwordsController.Index は、それぞれ /Features/Ninjas/Ninjas/Index.cshtml と /Features/Ninjas/Swords/Index.cshtml のビューを検索します ( 7 参照)。

機能ごとに複数のコントローラーを用意する
7 機能ごとに複数のコントローラーを用意する

ただし、このようにするのは任意です。(機能に含まれるコントローラーが 1 つだけであるといった理由で) ビューを区別する必要がない場合は、ビューを直接機能フォルダーに配置してもかまいません。また、フォルダーではなくファイルのプレフィックスを使用する場合には、書式指定文字列を簡単に調整して、「{3}/{1}」ではなく「{3}{1}」を使用するようして、NinjasIndex.cshtml や SwordsIndex.cshtml のようなビューのファイル名を検索できます。

共有ビューも、機能フォルダーのルートと、Shared サブフォルダーでサポートされます。

IViewLocationExpander インターフェイスによって公開される ExpandViewLocations メソッドは、フレームワークによって使用され、ビューを含むフォルダーを特定します。そのようなフォルダーは、アクションがビューを返すときに検索されます。このアプローチに必要なのは、前に説明した FeatureConvention で指定したコントローラーの機能名で「{3}」トークンを置き換えることだけです。

public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context,
  IEnumerable<string> viewLocations)
{
  // Error checking removed for brevity
  var controllerActionDescriptor =
    context.ActionContext.ActionDescriptor as ControllerActionDescriptor;
  string featureName = controllerActionDescriptor.Properties["feature"] as string;
  foreach (var location in viewLocations)
  {
    yield return location.Replace("{3}", featureName);
  }
}

適切な公開をサポートするには、次のように project.json の publishOptions を更新して、Features フォルダーを含めることも必要です。

"publishOptions": {
  "include": [
    "wwwroot",
    "Views",
    "Areas/**/*.cshtml",
    "Features/**/*.cshtml",
    "appsettings.json",
    "web.config"
  ]
},

Features というフォルダーを使用する新しい表記の規則は、そのフォルダー内でのフォルダーの編成方法を含めて、完全に制御できます。一連の View­LocationFormats (および場合によっては FeatureViewLocationExpander 型の動作) を変更することで、アプリケーションのビューの検出場所を完全に制御できます。コントローラー型は、型が含まれるフォルダーにかかわらず検出されるため、ファイルを認識するのに必要な作業はこれだけです。

機能フォルダーの対照比較

既定の MVC の区分とビューの表記法を機能フォルダーとを比較する場合は、少し変更を加えるだけで試すことができます。ViewLocationFormats を消去するのではなく、以下のように一覧の最初に機能の形式を挿入します (順番は逆になっています)。

options.ViewLocationFormats.Insert(0, "/Features/Shared/{0}.cshtml");
options.ViewLocationFormats.Insert(0, "/Features/{3}/{0}.cshtml");
options.ViewLocationFormats.Insert(0, "/Features/{3}/{1}/{0}.cshtml");

区分と組み合わせた機能をサポートするには、同様にして、AreaViewLocationFormats コレクションを変更します。

options.AreaViewLocationFormats.Insert(0, "/Areas/{2}/Features/Shared/{0}.cshtml");
options.AreaViewLocationFormats.Insert(0, "/Areas/{2}/Features/{3}/{0}.cshtml");
options.AreaViewLocationFormats.Insert(0, "/Areas/{2}/Features/{3}/{1}/{0}.cshtml");

モデルについて

勘の鋭い読者なら、モデル型を機能フォルダー (または区分) に移動していないことに気づいたでしょう。このサンプルでは、個別のビューモデル型を用意していません。使用しているモデルが非常にシンプルだからです。実際のアプリケーションでは、ビューで必要とされる以上の複雑さがドメイン モデルや永続モデルに含まれる場合があります。また、独自の個別のプロジェクトで定義されている可能性があります。MVC アプリケーションでは、特定のビューに必要で、表示 (または、クライアントの API 要求による使用) に最適化されたデータのみを含むビューモデル型を定義する可能性があります。このようなビューモデル型は、間違いなくそれが使用される機能のフォルダーに配置する必要があります (また、このような型が機能間で共有されることはめったにありません)。

まとめ

サンプルには、NinjaPiratePlant­Zombie 管理アプリケーションの 3 つのバージョンを、各データ型の追加と表示のサポートと一緒にすべて含めています。サンプルをダウンロード (または GitHub で表示) して、現在携わっているアプリケーションの状況でそれぞれのアプローチがどのように機能するのかを考えてください。取り組んでいる大規模アプリケーションに区分や機能フォルダーを追加して試験し、ファイルの種類に基づいた最上位フォルダーを使用するのではなく、機能スライスをアプリケーションのフォルダー構造の最上位編成として使用するのが好ましいかを判断してください。

このサンプルのソース コードは bit.ly/29MxsI0 で入手できます。


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


この記事のレビューに協力してくれた技術スタッフの Ryan Nowak に心より感謝いたします。
Ryan Nowak は、マイクロソフトの ASP.Net チームに所属する開発者です。