September 2017
Volume 32 Number 9
ASP.NET Core - Razor ページを使った簡単な ASP.NET MVC アプリ
Razor ページは、ASP.NET Core 2.0 の新機能です。ASP.NET Core アプリ内のコードを整理するシンプルな方法を提供し、実装ロジックとビュー モデルを、ビューの実装コードに近い形に保ちます。ASP.NET Core アプリ開発を簡単に開始できる方法も用意されています。ただし、経験豊富な .NET 開発者の場合も Razor ページを検討の対象外にすべきではありません。Razor ページを使用すると、規模が大きく複雑な ASP.NET Core アプリでも適切に整理できるようになります。
モデル - ビュー - コントローラー (MVC: Model-View-Controller) パターンは、マイクロソフトが 2009 年から ASP.NET アプリ開発を対象にサポートしている成熟した UI パターンです。この UI パターンには、アプリ開発者が懸念事項を分離できる多数のメリットが備わってるため、結果として保守が容易なソフトウェアになります。残念ながら、既定のプロジェクト テンプレートで実装されるような MVC では、多数のファイルとフォルダーが必要になることが多く、その結果、開発時の手間が増える可能性があります。アプリの規模が拡大すると特に顕著になります。2016 年 9 月号のコラムでは、この問題に対処する 1 つの方法として、機能スライスを使用する方法について説明しました (msdn.com/magazine/mt763233)。Razor ページは、これと同じ問題に対処する新たな方法を提供するもので、特に考え方がページベースのシナリオに向いています。この方法は、ほぼ静的なビューだけを使用する場合、つまり POST/Redirect/GET 以外は実行する必要のない単純なフォームのみを使用する場合に特に役立ちます。このようなシナリオを Razor ページは得意とします。Razor ページを使用すると、MVC アプリで必要になる多くの表記規則が回避されます。
Razor ページの開始
Razor ページの使用を開始するには、Visual Studio で ASP.NET Core 2.0 を使用して新しい ASP.NET Core Web アプリを作成し、Razor ページ テンプレートを選択します (図 1 参照)。
図 1 ASP.NET Core 2.0 Web アプリと Razor ページ テンプレート
dotnet コマンド ライン インターフェイス (CLI) で以下のコマンドを使用しても同じことを実現できます。
dotnet new razor
バージョン 2.0 以上の .NET Core SDK を実行していることを確認する必要があります。これを確認するには、次のコマンドを使用します。
dotnet --version
どちらの場合でも、生成されたプロジェクトを調べると、そのプロジェクトに新しいフォルダー Pages が含まれているのがわかります (図 2 参照)。
図 2 Razor ページ プロジェクト テンプレートの編成
このテンプレートに、MVC プロジェクトに通常関連付けられる 2 つのフォルダーが存在しないことがわかります。つまり、Controllers と Views がありません。Razor ページは Pages フォルダーにアプリの全ページを保持します。開発者は Pages ルート フォルダー内でフォルダーを自由に使用して、アプリにとって合理的な方法でページを整理できます。一緒に変更される傾向がある要素をまとめてグループ化することには生産性を向上するメリットがありますが、Razor ページを使用すると、こうした生産性向上のメリットと、MVC パターンのコード品質機能を同時に利用できます。
Pages はバージョン 2 の ASP.NET Core MVC に含まれていることに注意してください。任意の ASP.NET Core MVC アプリに Pages のサポートを追加するには、Pages フォルダーを追加して、このフォルダーに Razor ページ ファイルを追加します。
Razor ページは、このフォルダー構造をルーティング要求の表記規則として使用します。標準 MVC アプリの既定のページは、"/" のほか、"/Home/" および "/Home/Index" に配置できますが、Razor ページを使用するアプリの既定のインデックス ページは "/" と "/Index" に対応します。 サブフォルダーを使用すると、非常に直感的にアプリのさまざまなセクションを作成でき、ルートも同様に直感的なものになります。各フォルダーには、そのルート ページとして動作する Index.cshtml ファイルを含めることができます。
個々のページを確認すると、新しいページ ディレクティブである @page が見つかります。これは Razor ページでは必須です。このディレクティブはページ ファイルの先頭に配置しなければなりません。また、ページ ファイルは .cshtml 拡張子を使用する必要があります。Razor ページは、Razor ベースのビュー ファイルと外観や動作が非常に似ており、非常にシンプルなページには次のように HTML しか含まれないことがあります。
@page
<h1>Hello World</h1>
Razor ページが真価を発揮するのは、UI 細部のカプセル化とグループ化です。Razor ページでは、インラインまたは個別のクラス ベースのページ モデルをサポートします。このモデルを使って、ページの表示や操作を行うデータ要素を表現します。また、ハンドラーもサポートしているため、個別のコントローラーやアクション メソッドは必要ありません。これらの機能のおかげで、Web アプリの特定ページを操作するうえで必要な個別のフォルダーとファイルの数が大幅に減っています。図 3 では、標準の MVC ベース アプローチと Razor ページ アプローチで必要なフォルダーとファイルを比較しています。
図 3 MVC フォルダーおよびファイルと Razor ページ
ASP.NET Core MVC アプリのコンテキストで Razor ページの例を示すために、シンプルなサンプル プロジェクトを使用します。
サンプル プロジェクト
若干複雑で、機能領域が一部異なるプロジェクトをシミュレーションするために、前回の機能スライスのコラムで使用したサンプルを再利用することにします。このサンプルでは、Ninjas や Ninja Swords に加え、Pirates、Plants、Zombies など、さまざまな種類のエンティティの表示と管理を行います。このアプリは、カジュアル ゲームと連携し、ゲーム内のコンストラクトの管理も支援します。標準の MVC 編成アプローチを使用すると、これらの種類のコンストラクトごとにコントローラー、ビュー、ビューモデルなどを保持する、さまざまなフォルダーを用意することになる可能性が高くなります。Razor ページの場合は、アプリの URL 構造に対応する単純なフォルダー階層を作成することができます。
今回のアプリは、Pages の下にサンプル ホームページと 4 つの異なるセクションを設け、セクションごとに固有のサブフォルダーを配置します。このフォルダー構造は非常にすっきりしていて、Pages フォルダーのルートにはホームページ (Index.cshtml) と一部のサポート ファイルしかありません。他のセクションはそれぞれの固有フォルダーに格納しています (図 4 参照)。
図 4 Razor ページのフォルダー編成
たいていの場合、シンプルなページでは個別のページ モデルを必要としません。たとえば、/Ninjas/Swords/Index.cshtml に表示される Ninja Swords の一覧では、インライン変数だけを使用しています (図 5 参照)。
図 5 インライン変数の使用
@page
@{
var swords = new List<string>()
{
"Katana",
"Ninjago"
};
}
<h2>Ninja Swords</h2>
<ul>
@foreach (var item in swords)
{
<li>@item</li>
}
</ul>
<a asp-page="/Ninjas/Index">Ninja List</a>
Razor ブロックで宣言した変数は、このページのスコープ内になります。@functions ブロックを利用して機能とクラスを宣言する方法は後ほど示します。このページの一番下にあるリンクで asp-page タグ ヘルパーを使用しています。これらのタグ ヘルパーはそれぞれのルートを使ってページを参照します。また、絶対パスと相対パスをサポートしています。この例の "/Ninjas/Index” は、"../Index" と記述することもでき、".." とだけ記述することさえ可能です。このように記述しても、Ninjas フォルダー内の同じ Index.cshtml Razor ページにルーティングされることになります。また、<form> 要素で asp-page タグ ヘルパーを使用して、フォームの送信先を指定することもできます。asp-page タグ ヘルパーは強力な ASP.NET Core ルーティング サポートを土台に構築されているため、単純な相対 URL だけでなく、多くの URL 生成シナリオをサポートします。
ページ モデル
Razor ページでは厳密に型指定されるページ モデルをサポートすることができます。(厳密に型指定される MVC ビューのように) @model ディレクティブを使用して、Razor ページのモデルを指定します。図 6 に示すように、Razor ページ ファイル内でモデルを定義できます。
図 6 モデルの定義
@page
@using WithRazorPages.Core.Interfaces;
@using WithRazorPages.Core.Model;
@model IndexModel
@functions
{
public class IndexModel : PageModel
{
private readonly IRepository<Zombie> _zombieRepository;
public IndexModel(IRepository<Zombie> zombieRepository)
{
_zombieRepository = zombieRepository;
}
// additional code omitted
}
}
また、ページ モデルは、<ページ名>.cshtml.cs という名前の個別の分離コード ファイル内で定義することもできます。Visual Studio では、この表記規則に従っているファイルは対応するページ ファイルにリンクされるため、これらのファイル間を簡単に移動できます。図 6 の @functions ブロックに示しているコードと同じコードを個別のファイルに配置することもできます。
ページ モデルを保存するための両アプローチには長所も短所もあります。ページ モデル ロジックを Razor ページ自体に配置すると、ファイル数が少なくなり、柔軟性のあるランタイム コンパイルが可能になります。そのため、アプリ全体を配置しないでページのロジックを更新できます。その一方で、Razor ページ内でページ モデルを定義すると、コンパイル エラーが実行時まで発見されない可能性があります。Visual Studio を使用すれば、開いている Razor ファイルのエラーが表示されます (実際にコンパイルする必要はありません)。「dotnet build」コマンドを実行する場合は Razor ページがコンパイルされず、これらのファイルに潜む可能性のあるエラーの情報が提供されません。
個別のページ モデル クラスを使用すると、懸念事項の分離が少し進みます。Razor ページはデータの表示を目的とするテンプレートのみに集中でき、ページのデータ構造および対応するハンドラーの処理は個別のページ モデルに任せることができます。個別の分離ページ モデルを使用すると、コンパイル時にエラーを確認できるメリットもあります。また、インライン ページ モデルよりも単体テストが簡単になります。突き詰めていくと、Razor ページでは、モデルを使用しない、インライン モデルを使用する、または個別のページ モデルを使用するという、3 つの方法のいずれかを選ぶことができます。
ルーティング、モデル バインド、ハンドラー
一般的にコントローラー クラス内で行う MVC の重要な 2 つの機能として、ルーティングとモデル バインドがあります。ほとんどの ASP.NET Core MVC アプリでは、次のような構文で属性を使用してルート、HTTP 動詞、およびルート パラメーターを定義します。
[HttpGet("{id}")]
public Task<IActionResult> GetById(int id)
前述のように、Razor ページのルート パスは表記規則に基づき、/Pages フォルダー階層内のページの位置に対応します。ただし、ルート パラメーターをサポートするには、そのルート パラメーターを @page ディレクティブに追加します。Razor ページでは、サポートする HTTP 動詞を属性を使用して指定するのではなく、On<Verb> の名前付け規則に従ったハンドラーを使用します。このとき、<Verb> は Get や Post などの HTTP 動詞です。Razor ページ ハンドラーは、MVC コントローラー アクションと動作が非常に似ており、モデル バインドを使用して、定義する任意のパラメーターを設定します。図 7 には、レコードの詳細を表示するためにルート パラメーター、依存関係の挿入、およびハンドラーを使用するサンプルの Razor ページを示しています。
図 7 特定のレコード ID の詳細を表示する Details.cshtml
public async Task OnGetAsync()
{
Ninjas = _ninjaRepository.List()
.Select(n => new NinjaViewModel { Id = n.Id, Name = n.Name }).ToList();
}
public async Task<IActionResult> OnPostAddAsync()
{
var entity = new Ninja()
{
Name = "Random Ninja"
};
_ ninjaRepository.Add(entity);
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
var entityToDelete = _ninjaRepository.GetById(id);
_ ninjaRepository.Delete(entityToDelete);
return RedirectToPage();
}
@page "{id:int}"
@using WithRazorPages.Core.Interfaces;
@using WithRazorPages.Core.Model;
@inject IRepository<Ninja> _repository
@functions {
public Ninja Ninja { get; set; }
public IActionResult OnGet(int id)
{
Ninja = _repository.GetById(id);
// A void handler (no return) is equivalent to return Page()
return Page();
}
}
<h2>Ninja: @Ninja.Name</h2>
<div>
Id: @Ninja.Id
</div>
<div>
<a asp-page="..">Ninja List</a>
</div>
ページでは複数のハンドラーをサポートできるため、OnGet や OnPost などを定義できます。Razor ページでは、フォームで特に便利な新しいモデル バインド属性 [BindProperty] も導入されています。この属性を (明示的に PageModel を使用しても、しなくても) Razor ページのプロパティに適用することで、ページへの GET 以外の要求に対するデータ バインドを選択できます。これにより、asp-for や asp-validation-for のようなタグ ヘルパーで、指定済みのプロパティを操作できるようになります。また、プロパティをメソッド パラメーターとして指定する必要なく、バインドされたプロパティをハンドラーで操作できるようにもなります。[BindProperty] 属性はコントローラーでも動作します。
図 8 に、ユーザーが新しいレコードをアプリに追加できるようにする Razor ページを示しています。
図 8 新しい Plant を追加する New.cshtml
@page
@using WithRazorPages.Core.Interfaces;
@using WithRazorPages.Core.Model;
@inject IRepository<Plant> _repository
@functions {
[BindProperty]
public Plant Plant { get; set; }
public IActionResult OnPost()
{
if(!ModelState.IsValid) return Page();
_repository.Add(Plant);
return RedirectToPage("./Index");
}
}
<h1>New Plant</h1>
<form method="post" class="form-horizontal">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="Plant.Name" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="Plant.Name" class="form-control" />
<span asp-validation-for="Plant.Name" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</div>
</form>
<div>
<a asp-page="./Index">Plant List</a>
</div>
同じ HTTP 動詞を使用する複数の操作をサポートするページがあるのは非常に一般的なことです。たとえば、サンプルのメイン ページでは、(既定の GET 動作として) エンティティの一覧表示をサポートすると同時に、エントリを削除したり、新しいエントリを追加したりする機能も (両方とも POST 要求として) サポートしています。Razor ページでは、名前付きハンドラーを使用することで、このシナリオをサポートします (図 9 参照)。名前付きハンドラーは、HTTP 動詞の後ろに (ただし、Async サフィックスがある場合はその前に) 名前を含めます。PageModel 基本型は、アクションの結果が返されたときに使用できるヘルパー メソッドをいくつか提供するという点で、基本的なコントローラーの型に似ています。新しいレコードを追加するなどの更新を実行する場合は、一般的に、成功したら操作直後にユーザーをリダイレクトすることをお勧めします。これにより、ブラウザーの更新によってサーバーへの呼び出しが重複してトリガーされ、重複したレコードが作成されたり、もっと厄介な事態になったりするという問題が起こらなくなります。引数を指定せずに RedirectToPage を使用することで、現在の Razor ページの既定の GET ハンドラーにリダイレクトできます。
図 9 名前付きハンドラー
public async Task OnGetAsync()
{
Ninjas = _ninjaRepository.List()
.Select(n => new NinjaViewModel { Id = n.Id, Name = n.Name }).ToList();
}
public async Task<IActionResult> OnPostAddAsync()
{
var entity = new Ninja()
{
Name = "Random Ninja"
};
_ ninjaRepository.Add(entity);
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
var entityToDelete = _ninjaRepository.GetById(id);
_ ninjaRepository.Delete(entityToDelete);
return RedirectToPage();
}
asp-page-handler タグ ヘルパーを使用して名前付きハンドラーを指定することができます。このヘルパーは、フォーム、リンク、またはボタンに適用します。
<a asp-page-handler="Handler">Link Text</a>
<button type="submit" asp-page-handler="delete" asp-route-id="@id">Delete</button>
この asp-page-handler タグは、ルーティングを使用して URL を生成します。既定では、ハンドラー名と asp-route-parameter 属性はクエリ文字列値として適用されます。前述のコードの Delete ボタンでは次のような URL が生成されます。
Ninjas?handler=delete&id=1
ハンドラーを URL の一部にする場合は、次のように @page ディレクティブを使用することでこの動作を指定できます。
@page "{handler?}/{id?}"
このルートが指定されていると、Delete ボタンでは次のようなリンクが生成されます。
Ninjas/Delete/1
フィルター
フィルターは、ASP.NET Core MVC のもう 1 つの強力な機能です (2016 年 8 月号のコラム (msdn.microsoft.com/mt767699) でフィルターについて説明しています)。個別のファイルでページ モデルを使用している場合は、Razor ページで属性ベースのフィルターを使用できます。たとえば、ページ モデル クラスにフィルター属性を配置します。それ以外の場合でも、アプリの MVC を構成するときにグローバル フィルターを指定できます。フィルターの最も一般的な用途の 1 つは、アプリ内で承認ポリシーを指定することです。次のように、フォルダー ベースとページ ベースの承認ポリシーをグローバルに構成できます。
services.AddMvc()
.AddRazorPagesOptions(options =>
{
options.Conventions.AuthorizeFolder("/Account/Manage");
options.Conventions.AuthorizePage("/Account/Logout");
options.Conventions.AllowAnonymousToPage("/Account/Login");
});
Razor ページでは、アクション フィルターを除いて、すべての既存の種類のフィルターを適用できます。アクション フィルターは、コントローラー内のアクション メソッドにのみ適用します。Razor ページには新しいページ フィルターも導入されています。これは、IPageFilter (または IAsyncPageFilter) を使用して表現します。このフィルターを使用すると、特定のページ ハンドラーが選択された後に実行されるコードや、ハンドラー メソッドが実行される前/後に実行されるコードを追加できます。要求の処理に使用するハンドラーを変更するには、First メソッドを使用します。たとえば、次のようになります。
public void OnPageHandlerSelected(PageHandlerSelectedContext context)
{
context.HandlerMethod =
context.ActionDescriptor.HandlerMethods.First(m => m.Name == "Add");
}
ハンドラーが選択されてから、モデル バインドが実行され、モデル バインドの後に、ページ フィルターの OnPageHandlerExecuting メソッドが呼び出されます。このメソッドでは、ハンドラーから利用できるすべてのモデル バインド済みデータに対するアクセスと操作を行うことができ、ハンドラーの呼び出しを省略することができます。その後、ハンドラーが実行されたら (アクションの結果が実行される前に)、OnPageHandlerExecuted メソッドが呼び出されます。
概念的に、ページ フィルターはアクション フィルターとよく似ています。アクション フィルターは、アクションが実行される前後に実行されます。
フィルターの 1 つである ValidateAntiforgeryToken は、Razor ページではまったく必要ありません。このフィルターは、クロスサイト リクエスト フォージェリ (CSRF または XSRF) 攻撃を防御するために使用しますが、この保護は Razor ページに自動的に組み込まれます。
アーキテクチャ パターン
Razor ページは ASP.NET Core MVC に同梱され、ルーティング、モデル バインド、フィルターなど、ASP.NET Core MVC の多くの組み込み機能を利用します。Razor ページは、マイクロソフトが 2010 年に Web Matrix と共にリリースした Web ページ機能とやや名前が似ています。ただし、この Web ページ機能は初心者の Web 開発者を主なターゲットにしていました (また、大半の経験豊富な開発者にとってはほとんど興味を引かれるものではありませんでした)。一方、Razor ページの場合は強力なアーキテクチャ設計に加えて親しみやすさも備わっています。
アーキテクチャ的に、Razor ページはコントローラーが存在しないため、モデル ビュー コントローラー (MVC) パターンを踏襲するものではありません。どちらかといえば、多くのネイティブ アプリ開発者にとって親しみやすいモデル-ビュー-ビューモデル (MVVM: Model-View-ViewModel) パターンを踏襲しています。また、Razor ページは Page Controller パターンの 1 例と考えることもできます。Martin Fowler は、このパターンを「Web サイトの特定のページまたはアクションに対する要求を処理するオブジェクトであり、そのオブジェクトはページ自体の場合もあれば、そのページに対応する個別のオブジェクトの場合もある」と説明しています。 当然、ASP.NET Web フォームに取り組んだことがある開発者なら、Page Controller パターンにもなじみがあるでしょう。最初の ASP.NET ページもこのパターンで動作していました。
ASP.NET Web フォームとは異なり、Razor ページは ASP.NET Core でビルドされ、疎結合、懸念事項の分離、および SOLID の原則をサポートします。(個別の PageModel クラスが使用されている場合は) 単体テストも簡単に行うことができ、すっきりしていて保守しやすいエンタープライズ アプリの基盤にすることができます。Razor ページを、単なる愛好家プログラマ向けの「補助」機能であるかのように簡単に見限るべきではありません。Razor ページを真剣に検討する必要があります。Razor ページを (単独で、または従来のコントローラー ページおよびビュー ページと組み合わせて) 使用して、特定の機能を操作する際に相互移動が必要なフォルダー数を削減することで、ASP.NET Core アプリの設計を改善できるかどうかを考えてみましょう。
移行
Razor ページは MVC パターンを踏襲していませんが、既存の ASP.NET Core MVC コントローラーおよびビューとほぼ互換性があるため、たいていの場合、その切り替えは非常に簡単です。Razor ページを使用するために既存のコントローラー/ビュー ベースのページを移行するには、次の手順に従います。
- Razor ビュー ファイルを /Pages フォルダー内の適切な場所にコピーします。
- ビューに @page ディレクティブを追加します。GET のみのビューである場合は、これで完了です。
- <ビュー名>.cshtml.cs という名前の PageModel ファイルを追加し、Razor ページのフォルダーに配置します。
- ビューに ViewModel がある場合は、それを PageModel ファイルにコピーします。
- ビューに関連付けられているすべてのアクションを、そのコントローラーから PageModel クラスにコピーします。
- アクションの名前を変更して Razor ページのハンドラー構文を使用します ("OnGet" など)。
- ビュー ヘルパー メソッドへの参照をページ メソッドに置き換えます。
- コンストラクターの依存関係の挿入コードをコントローラーから PageModel にコピーします。
- ビューにコードを渡すモデルを PageModel の [BindProperty] プロパティに置き換えます。
- ビュー モデル オブジェクトを受け取るアクション メソッド パラメーターも [BindProperty] プロパティに置き換えます。
適切にファクタリングされた MVC アプリは一般的に、ビュー、コントローラー、ビューモデル、およびバインド モデルのファイルを分離し、プロジェクトの個別フォルダーにそれぞれを配置します。Razor ページを使用すると、これらの概念を単一フォルダー内のいくつかのリンク ファイルに統合できます。同時に、コードでは懸念事項の分離を守ることができます。
多くの場合、これらの手順を逆順に実行することで、Razor ページの実装からコントローラー/ビュー ベースのアプローチに移行できます。この手順に従えば、大半のシンプルな MVC ベースのアクションとビューは動作することでしょう。もっと複雑なアプリでは、追加の手順とトラブルシューティングが必要になる可能性があります。
次のステップ
サンプルには、NinjaPiratePlantZombie 管理アプリの 4 つのバージョンを、各データ型の追加と表示のサポートと一緒にすべて含めています。サンプルでは、MVC、MVC とエリア、MVC と機能スライス、および Razor ページを使用して、個別の機能領域を複数含むアプリを整理する方法を示しています。これらさまざまな方法を試して、開発している ASP.NET Core アプリではどの方法が最も適切に機能するかを確認してください。このサンプルの最新のソース コードは bit.ly/2eJ01cS (英語) で入手できます。
Steve Smithは、独立系のトレーナー、指導者で、コンサルタントでもあります。Microsoft MVP を 14 回受賞し、複数のマイクロソフト製品チームと密接に連携しています。チームで ASP.NET Core への移行を検討している場合や、もっと優れたコーディング手法の採用を考えている場合は、ardalis.com または Twitter (@ardalis、英語のみ) までご連絡ください。
この記事のレビューに協力してくれたマイクロソフト技術スタッフの Ryan Nowak に心より感謝いたします。