ASP.NET MVC 5 のルーティングを WinForms の Form 切替で理解する

みなさんこんにちは!ヒロポンです!!

今回は ASP.NET 生存ガイド連載・第3回 の本論記事。WinForms 業務SE がガチで圧倒されるやつ!!の話。

ASP.NET MVC 5 のプロジェクトを開いて App_Start/RouteConfig.cs を覗いた瞬間に、「これ何?? Form 切替どこ??」「MapRoute ってメニュー項目みたいなもん??」って固まったこと、ないっすか??

俺も最初に流通系SIer時代に RouteConfig.cs 見た時は同じだった。**WinForms の Form 切替(メニュー → Form.Show())で全部書いてきた頭からすると、「URL ってメニューの代わり?」「ハードコードでいいんじゃ?」みたいな素朴な疑問が浮かぶ。3週間くらい混乱した末に「これ Form 切替の HTTP 版じゃん」**って気付いた瞬間に、ルーティングがいい感じに腹落ちしました。

連載第1回(Form ↔ Razor View 対応)で View、第2回(Controller = Form_Load 拡張版)で Controller を押さえたので、本記事は **「URL → Controller → Action の流れ」**を扱います。連載の「HTML レイヤー / ASP.NET レイヤー分離思考」が、第2回で「Controller / View / Service レイヤー分離」に進化したのと同じ構造で、第3回は 「URL レイヤー / Controller レイヤー / Action レイヤー」のリクエスト解決経路の3層分離 に再拡張します。

この記事では VS2019 / .NET Framework 4.7.2 / C# 7.3 / ASP.NET MVC 5 環境で、**ルーティングの基本(デフォルトルート / 属性ルート / ルート制約)**と、WinForms Form 切替 ↔ Routing の対応マップミニマム検証を、コード6本でまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の話も書いてる。

3行で結論:

  • URL の解決は3層: ①ルートテーブル探索 → ②Controller 解決 → ③Action 解決(WinForms のメニュー → Form.Show() の HTTP 版)
  • デフォルトルート1行で大半は解決{controller}/{action}/{id} パターン)、属性ルートは複雑な URL でだけ使う
  • **詰まったら「URL レイヤーか、Controller レイヤーか、Action レイヤーか」**で原因切り分け → 最小 Action でミニマム検証
目次

著者の体験 — RouteConfig.cs を見て圧倒された話

正直に書いておきます。流通系SIer時代に初めて RouteConfig.cs を見た時、「これ全部書かないとダメなん??」って圧倒されたんですよね。WinForms の menuStrip1.Click → otherForm.Show() で動いてた頭からすると、URL ↔ Action の対応を1つずつ書く感覚が分からなかった。

転機は2つ:

  1. デフォルトルート1行({controller}/{action}/{id})で大半が解決すると気付いた瞬間
  2. 「URL レイヤー / Controller レイヤー / Action レイヤー」の3層で考えると原因切り分けが半分終わると分かった瞬間

例えば「/Customer/Detail/3 が動かない」時は:

  • URL レイヤー → ルートテーブルで Customer/Detail/3 がマッチするか
  • Controller レイヤー → CustomerController クラスが存在するか
  • Action レイヤー → Detail(int id) Action が存在して引数 3 を受け取れるか

の3段階で見ると、原因がどこにあるか分かりやすい。**WinForms の Form 切替経験者にとって、ルーティングは「Form 切替の HTTP 版」**と言えるんですよね。

対応マップ — WinForms Form 切替 ↔ MVC Routing の8観点

WinForms と MVC の対応関係を、8観点でまとめたのがこんな感じ:

WinForms 概念 ASP.NET MVC 5 ルーティング 違いのポイント
MainForm のメニュー項目 ルートテーブル(RouteConfig.cs) メニュー = 静的、ルート = URL パターンで動的
menuStrip1.Click → otherForm.Show() URL /Customer/Index → CustomerController.Index() UI イベント → HTTP リクエスト
Form の名前で識別 コントローラ名 + アクション名で識別 物理的な Form インスタンス vs URL パターン
Form のコンストラクタ引数 URL クエリ文字列 / ルート値 / Action 引数 値渡しの仕組みが違う
this.Tag プロパティで状態渡し TempData / Session で状態渡し サーバ側 HTTP リクエスト跨ぎ
Form.Show() vs Form.ShowDialog() RedirectToAction vs PartialView UI ブロッキングの概念差
Form_Load → 初期化処理 Action メソッド本体 同期的に実行される起点
MdiParent / MdiChild Layout.cshtml + RenderBody UI 構造の親子関係

ここから順に、コード対比で見ていきます。

定石1: デフォルトルート(Convention-based)の仕組み

ASP.NET MVC 5 のテンプレートが生成する App_Start/RouteConfig.cs のデフォルトルートはこんな感じ:

// ✅ 定石1: デフォルトルート({controller}/{action}/{id})
using System.Web.Mvc;
using System.Web.Routing;

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}

ポイント:

  1. {controller}/{action}/{id} パターンで URL の3階層を解析
  2. デフォルト値: controller=Home / action=Index / id=オプション
  3. / にアクセス → Home/Index/(id なし) に解釈される
  4. /Customer → Customer/Index/
  5. /Customer/Detail/3 → Customer/Detail/3

これだけで業務系の CRUD 5割は解決する。「/Customer/Index」「/Order/Edit/123」のような単純な URL は、デフォルトルート1行で吸収できるんですよね。

ん?じゃあ全部このルートで済むんじゃない??って思うかもだけど、業務系の現実では「URL を綺麗にしたい(/customers/123 のように REST 風)」「複雑な階層(/api/v1/orders/{orderId}/items/{itemId})」が出てくるので、その時に属性ルートを併用します。

定石2: 属性ルート(Attribute-based)— URL を綺麗にしたい時

Action に直接 [Route(...)] 属性を付けて URL を指定するパターン:

// ✅ 定石2: 属性ルート([Route] で Action に直接指定)
[RoutePrefix("customer")]   // Controller 全体のプレフィックス
public class CustomerController : Controller
{
    [Route("")]   // /customer に対応
    public ActionResult Index() { /* ... */ }

    [Route("{id:int}")]   // /customer/123 (id は int 制約)
    public ActionResult Detail(int id) { /* ... */ }

    [Route("search/{keyword}")]   // /customer/search/abc
    public ActionResult Search(string keyword) { /* ... */ }

    [HttpPost]
    [Route("create")]   // POST /customer/create
    public ActionResult Create(CustomerVm model) { /* ... */ }
}

属性ルートを使うには、RouteConfig.csroutes.MapMvcAttributeRoutes(); を呼ぶ1行が要ります(デフォルトテンプレートには含まれてないので注意):

public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

    // ↓ この1行で [Route(...)] 属性が有効になる
    routes.MapMvcAttributeRoutes();

    routes.MapRoute(
        name: "Default",
        url: "{controller}/{action}/{id}",
        defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
    );
}

属性ルートのメリット:

  1. URL が Action のすぐ隣にあるので、URL ↔ Action の対応が読みやすい
  2. ルート制約({id:int} 等)を URL の中に書ける
  3. **REST 風 URL(/api/v1/orders/{orderId}/items/{itemId})**が綺麗に書ける

業務系の判断軸: デフォルトルート寄せが基本、属性ルートは **「URL を綺麗にしたい」「REST 風 API」「複雑な階層」**の場面だけに絞ると、ルート定義が散らばらずに保守性が良くなります。

定石3: ルート制約({id:int} 等)で型違いを弾く

{id:int} のようなルート制約で、URL の型違いをルートテーブル段階で弾くパターン:

// ✅ 定石3: ルート制約で型違いを弾く
public class OrderController : Controller
{
    [Route("order/{id:int}")]   // id は int のみ受け付ける
    public ActionResult Detail(int id) { /* ... */ }

    [Route("order/code/{code:alpha:length(8)}")]   // 英字8桁の code のみ
    public ActionResult ByCode(string code) { /* ... */ }

    [Route("order/{guid:guid}")]   // GUID 形式のみ
    public ActionResult ByGuid(Guid guid) { /* ... */ }

    [Route("order/{date:datetime}")]   // 日付形式のみ
    public ActionResult ByDate(DateTime date) { /* ... */ }
}

組み込み制約の主なもの:

制約 意味
{id:int} 整数
{id:long} 長整数
{name:alpha} 英字のみ
{name:alphanumeric} 英数字のみ
{id:guid} GUID 形式
{date:datetime} 日付形式
{value:length(8)} 文字数指定
{value:minlength(3)} 最小文字数
{value:range(1,100)} 範囲指定

業務系で**{id:int} を付けないと**、/order/abc が来た時に Action 引数 int id の model binding が失敗して NullReferenceException、もしくは別のルートにフォールバックして意図しない Action が呼ばれる事故が起きる。ID 系パラメータは型制約をセットで書くのが業務系の鉄則っす。

定石4: カスタム階層ルート — REST 風 API のような綺麗な URL

業務系の API で /api/v1/orders/{orderId}/items/{itemId} のような階層 URL を扱うパターン:

// ✅ 定石4: カスタム階層ルート(API 風)
[RoutePrefix("api/v1")]
public class OrdersApiController : Controller
{
    [Route("orders/{orderId:int}/items")]
    public ActionResult ListItems(int orderId) { /* ... */ }

    [Route("orders/{orderId:int}/items/{itemId:int}")]
    public ActionResult GetItem(int orderId, int itemId) { /* ... */ }

    [HttpPost]
    [Route("orders/{orderId:int}/items")]
    public ActionResult AddItem(int orderId, ItemVm model) { /* ... */ }

    [HttpDelete]
    [Route("orders/{orderId:int}/items/{itemId:int}")]
    public ActionResult RemoveItem(int orderId, int itemId) { /* ... */ }
}

ポイント:

  • RoutePrefix で共通プレフィックスを Controller に付ける
  • HTTP メソッド属性([HttpPost] [HttpDelete])と [Route] を併用
  • URL の階層と Action 引数が自然にマッピングされる

業務系の社内 API・モバイルアプリ連携 API で頻出のパターンです。「API は REST 風、画面はデフォルトルート」のように使い分けると、ルート設計の見通しが良くなります。

定石5: Form 切替 ↔ RedirectToAction の対比

WinForms の Form 切替と Action のリダイレクト処理の対比:

// ✅ WinForms 版: Form を切り替える
public partial class CustomerListForm : Form
{
    private void btnNewCustomer_Click(object sender, EventArgs e)
    {
        var form = new CustomerCreateForm();
        form.Show();
        this.Hide();   // 元の Form を隠す
    }
}
// ✅ ASP.NET MVC 版: Action にリダイレクト
public class CustomerController : Controller
{
    public ActionResult Index() { /* 一覧表示 */ }

    public ActionResult Create()
    {
        return View(new CustomerVm());   // 入力フォーム表示
    }

    [HttpPost]
    public ActionResult Create(CustomerVm model)
    {
        _service.Save(model);
        TempData["Message"] = "登録しました";
        return RedirectToAction("Index");   // ← 一覧画面にリダイレクト
        // 引数: actionName, controllerName(省略時は同じ Controller)
    }
}

RedirectToAction の引数の順序を間違えると意図しない URL に飛ぶので、業務系の罠の1つ。第1引数が actionName、第2引数が controllerName の順序を覚えておくのが鉄則っす。

定石6: TempData で状態をリクエスト跨ぎで渡す

WinForms の this.Tag プロパティでの状態渡しに相当する、TempData の使い方:

// ✅ 定石6: TempData でリクエスト跨ぎの状態渡し
[HttpPost]
public ActionResult Save(CustomerVm model)
{
    _service.Save(model);
    TempData["Message"] = "保存しました";   // 次のリクエストまで生存
    TempData["LastSavedId"] = model.Id;
    return RedirectToAction("Index");
}

public ActionResult Index()
{
    if (TempData["Message"] != null)
    {
        ViewBag.Message = TempData["Message"];   // 1度読むと消える
    }
    if (TempData["LastSavedId"] is int savedId)
    {
        ViewBag.LastSavedId = savedId;
    }
    return View(_service.GetAll());
}

ポイント:

  • TempData は次のリクエストまで生存(リダイレクト先で読める)
  • Session より短命(永続的な状態は Session、一時的なメッセージは TempData)
  • 読むと消えるPeek で消さずに読むことも可能)

連載第2回で扱った PRG(Post-Redirect-Get)パターンの延長で、保存 → メッセージ表示 → 一覧画面に戻るフローの定石パターンっす。

ハマりポイント — 実体験ベースの本番事故3点

1. ルート優先順位の理解不足で予期せぬ Action が呼ばれた(半日デバッガで追ってハマった)

属性ルートとデフォルトルートを混在させた時、優先順位を理解せずに意図しない Action が呼ばれて画面が崩れた事件。夕方の運用報告で気づいて半日デバッガで追ってハマった末に、**「属性ルート > Convention-based、具体 > 抽象」**の優先順位ルールを学んだ。それ以来、業務系チームで 「属性ルートを使う時は Controller 単位で全 Action に付ける(混在しない)」 をルール化した。

2. {id:int} 制約なしで customer/abc が int 期待 Action に到達して例外(30分溶かした)

/customer/{id} ルートで idint 期待していたのに、URL に文字列が来て NullReferenceException で詰まった事件。30分溶かした末に、{id:int} のルート制約を入れてルートテーブル段階で弾く形に直した。それ以来、ID 系パラメータは型制約をセットで書くを業務系チーム規約に揃えました。

3. RedirectToAction の引数間違いで別 Controller に飛んだ(夕方の運用報告で気づいた)

RedirectToAction("Customer", "Edit") と書くべきところを RedirectToAction("Edit", "Customer") と引数順を間違えて、Edit Controller の Customer Action を探しに行って 404 になる事件。夕方の運用報告で気づいたRedirectToAction(actionName, controllerName) の引数順を覚えるか、RedirectToAction("Index", new { controller = "Customer" }) のように名前付きで書くと事故りにくい。

著者の現場メモ — 業務系チームでのルーティング規約

流通系SIer時代に過去の RouteConfig.cs と Controller を grep -rnE "MapRoute|\[Route\(|RedirectToAction" . で70箇所近くひっかけたら、属性ルートとデフォルトルートが混在・{id} 制約なし・RedirectToAction 引数順ミスが全部入りだった。後輩と一緒に 3行ルール にまとめた:

  1. デフォルトルート寄せ、属性ルートは API・複雑階層の Controller のみ(混在禁止)
  2. ID 系パラメータは型制約をセットで書く{id:int} {id:guid} 等)
  3. RedirectToAction(actionName, controllerName) の順を死守 or 名前付き引数で書く

このルール化で、ルーティング周りの 404 / 例外 / 意図しない Action 起動が消えた。書き方を1パターンに揃えるだけで保守工数と事故率が両方下がるので、業務系チームにはおすすめのルールっす。

まとめ

状況 WinForms の対応 → ルーティングの答え
メニューから画面遷移 MapRoute でルート定義 / Action にデフォルトルートで到達
シンプルな CRUD デフォルトルート1行 {controller}/{action}/{id}
URL を綺麗にしたい・REST 風 属性ルート [Route("api/v1/...")]
型違いを弾きたい ルート制約 {id:int} {id:guid}
Form を切り替える RedirectToAction(actionName, controllerName)
状態を次画面に渡す TempData["Key"] = value;
大規模システムの分離 Areas(Admin/API/Public)
ルートが効かない時 最小 Action でミニマム検証 → 段階的に複雑化

WinForms 業務SE が ASP.NET ルーティングに移行する時の事故は、「URL/Controller/Action の3層分離思考」「デフォルトルート寄せ」「型制約セット」 の3点で9割消えます。次回(連載第4回)は「ORM 3択 — EF6 / Dapper / ADO.NET の業務SE 視点比較」を扱うので、Action 内部での DB アクセス方針が見えてきます。

よくある質問

Q1. デフォルトルートと属性ルートはどう使い分けますか?

A. 原則は デフォルトルート(Convention-based)でほぼ全部解決、属性ルートは「URL を綺麗にしたい・REST 風 API・複雑な階層」の場面だけに絞るのが業務系の判断軸です。/Customer/Index/3 のような単純な CRUD はデフォルトルート1行で十分で、/api/v1/orders/{orderId}/items/{itemId} のような階層がある時だけ [Route(...)] 属性ルートを Action に直接付ける。最初は混在させず、デフォルトルート寄せから始めるのが学習コストを下げます。

Q2. ルート制約 {id:int} を付けないと何が起きますか?

A. 型違いの値が来た時に Action パラメータの model binding で例外、もしくは想定外の Action にマッチします。例えば /Customer/Detail/{id}idint 期待なのに /Customer/Detail/abc が来ると、id が null になって NullReferenceException が飛んだり、別のルートにフォールバックして意図しない Action が呼ばれたりする。{id:int} {id:guid} {name:alpha} のような組み込み制約を入れておくと、ルートテーブルの段階で弾けるので安全です。

Q3. RedirectToAction の引数を間違えると何が起きますか?

A. 意図しない Action にリダイレクトされます。RedirectToAction("Edit", "Customer")RedirectToAction("Customer", "Edit") と書くと、Customer Controller の Edit Action ではなく、Edit Controller の Customer Action を探しに行って 404 か別の Action が呼ばれる。第1引数が actionName、第2引数が controllerName の順序を覚えておくと事故りにくいです。

Q4. Areas はいつ使うべき?

A. Admin / API / Public のような大規模な画面群を分離したい時に使います。中小規模の業務系画面1〜2画面なら Areas は不要で、Controllers/ フォルダにフラットに並べる方が見通しが良い。Admin 画面が10個以上ある・API と Web 画面を同居させたい・別のレイアウトを当てたい、のような条件が揃った時に Areas 化するのが業務系の判断軸です。

Q5. ルートが効かない時の最短デバッグ手順は?

A. ミニマム検証で行きます。(1)Hello を返すだけの最小 Action を作って /Hello/Index で呼ぶ → ここで動かない時はルート設定 or web.config 問題、(2)動いたら本番のルートを少しずつ近づける、(3)属性ルートを付けてみる、の段階で原因切り分け。最初に複雑な属性ルートで検証すると問題箇所が分散するので、デフォルトルート 1個から始めるのが業務系の鉄則です。

ここまでで ASP.NET ルーティングの基本・属性ルート・制約・Areas は押さえた。次回は ORM 3択を扱うので、Action 内部の DB アクセス方針が見えてきます。隣接トピックも貼っておきます。

関連記事

ASP.NET 生存ガイド・連載目次

本記事は WinForms 業務SE のための ASP.NET 生存ガイド 全10回の 第3回 です。

  • 第1回: WinForms の Form と Razor View の対応関係を業務SE が一日で腹落ちさせる
  • 第2回: Controller は WinForms の Form_Load 拡張版だと理解する — ASP.NET MVC 5 業務SE 入門
  • 第3回(本記事): ルーティング — WinForms の Form 切替との対応 ← イマココ
  • 第4回: ORM 3択 — EF6 / Dapper / ADO.NET の業務SE 視点比較(公開予定)
  • 第5回: DI は業務系で必要か — 入れない派の論点(公開予定)
  • 第6回: 業務イントラの認証 — Windows認証 / Forms認証 / Cookie(公開予定)
  • 第7回: CSS が効かない時のチェックリスト10項目(公開予定)
  • 第8回: IIS デプロイ — オンプレ業務系の現実(公開予定)
  • 第9回: ASP.NET MVC 5 トラブルシューティング・チェックリスト20項目(公開予定)
  • 目次(最終回): WinForms 業務SE のための ASP.NET 生存ガイド・全体目次(公開予定)

以上!

同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!


この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次