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


目次