ASP.NET MVC で「戻る」リンク(もしくは Html ヘルパーの書き方)

ご無沙汰しております。クロスワープの大鷲です。

私は今、ASP.NET MVC で、とある社内向け業務アプリを書いています。
その開発上、今回は「すべてのページには双方向のナビゲーション リンクを置く」という方針を立てました。
つまり、ページ A から B へ飛んだら、必ず、B から A へ戻るリンクも作るということです。

大抵の場合、普通に ActionLink メソッドでリンクを作れば事足りますが、それでは足りない場合もあります。
たとえば以下の図のように、画面 B と C からそれぞれ D へ行くことが可能な時、D に置く「戻る」リンクは、B と C のどちらへ戻るべきでしょうか。

f:id:cw_owashi:20150619161851p:plain

一律に決めてしまうのもアリですが、今回は「来たほうから戻る」という仕様にしました。ブラウザーの「戻る」ボタンと同じです。*1
となると、「どちらから来たのか」という情報を、どうにかして渡さなければなりません。
この情報をどうやって渡すかということに、今回、かなり悩みました。
結果的には、あまりカッコよくない方法で妥協することになってしまいました。

ActionFilterAttribute → 挫折

とにかく「最後に表示したページの URL」が必要だと考えましたので、「すべての画面遷移に割り込んで、ページの URL を記録する何か」を作ろうと考えました。
そのために最初に目を付けたのが、ActionFilterAttribute です。
この属性を継承して、メソッドをオーバーライドしてやれば、コントローラーのアクション メソッドが呼ばれる度に、オーバーライドしたメソッドが呼ばれます。
あとはこれを、GlobalFiltes に仕込んでやれば、すべての画面遷移に割り込むことが可能になります。

というわけで属性クラスを作りました。
ページ遷移する度に現在のページの URL をスタックに積んでいって、「戻る」リンクはスタックを Pop して…というイメージから、LocationStackAttribute と名付けました。
スタックのトップだけでなく、何段階か飛ばして前の方の URL に一足飛びに戻ることも想定しました。前のページに戻る機能だけでなく、パンくずリストにも応用できるな…と考えていました。

さらに気合を入れて、特定の場合だけは URL を記録しないという例外を指定する属性も作りました。こちらは IgnoreLocationStackAttribute という名前だったと思います。*2
そうすると、これらの属性の兼ね合いを考えなければいけません。
ActionFilterAttribute の通例に倣うと、グローバル < コントローラー < アクション という優先度になります。
例えば、LocationStack を GlobalFilters に入れている時は、IgnoreLocationStack がコントローラー クラスやアクション メソッドについていたら、そのコントローラーやアクションについては URL を記録しません。
しかし、LocationStack が アクション メソッドについていて、IgnoreLocationStack がコントローラー クラスについていたら、この場合はアクションの方が優先されるので URL を記録するということになります。

この調査の過程で分かったのは、ActionFilterAttribute には、標準でそのような優先度機能が備わっているということでした。
同じ属性を、グローバル、コントローラー、アクションにつけると、アクションにつけた属性のメソッドしか呼ばれないことがわかったのです。
しかし、違う型の属性ではダメなようで、IgnoreLocationStack を LocationStack から派生させても、両方のメソッドが呼ばれてしまいました。

さて、LocationStack 属性の OnActionExecuted メソッドで、今表示したページの URL はわかりました。
あとはそれをどうやって記録するか。
グローバルな静的変数を試したり、TempData に入れてみたりと、いろいろ試しました。

が、結局、この案は没となりました。
理由は、ブラウザーの「戻る」ボタンで前のページに戻った際に、ActionFilterAttribute が働かないということがわかったためです。
ページ A を開いた段階で、スタックには A の URL が積まれます。
そこから B に遷移すると、B の URL が積まれます。
ページ B 内で「戻る」リンクを生成するには、スタックの上から 2 番目にある A の URL を取り出せばよいわけです。
そして、戻るリンクをクリックしたら*3、スタックトップの B を取り除く。最初の構想はそうでした。

1. ページ A を開くと、スタックには A が積まれる

# スタック
1 A

2. ページ B を開くと、スタックには B が積まれる

# スタック
1 B
2 A

3. ページ B 内では、スタックの 2 番目の URL を取得して「戻る」リンクを生成する

# スタック
1 B
2 A ← これを使う

4. 「戻る」リンクをクリックしたら、スタックの頭を Pop する。

# スタック
1 A

ここで、B から A に、ブラウザーの戻るボタンで戻り、再び B を開くとどうなるでしょうか。
ステップ 1 ~ 3 は上記の図と同じです。

4. ページ A に、ブラウザーの戻るボタンで戻ると、ActionFilterAttribute は働かないので、スタックはそのまま

# スタック
1 B
2 A

5. 再度 B に移動すると、B の URL が積まれる

# スタック
1 B
2 B
3 A

6. ページ B 内では、スタックの 2 番目の URL を取得して「戻る」リンクを生成する

# スタック
1 B
2 B ← これを使う…ダメだ!
3 A

というわけで、ActionFilterAttribute を使うという案はあえなく却下されたわけです。
何かいい方法がないものでしょうか…

前のページの URL と言えば…

そう、Referer*4 です。
ASP.NET においては、HttpRequest.UrlReferrer プロパティで取得できます。

しかし、これはブラウザーが HTTP ヘッダーに Referer をつけるかどうかに依存します。
セキュリティソフトによっては Referer が取得できない場合もあると聞きます。

ですが、今作っているのは、社内向けツールなので、あまり多様な環境を考慮する必要がありません。社内で使っているセキュリティソフトもわかっています。
なりふり構わないという点では、JavaScript で history.back() を使うという手もあるのですが、今回は Referer を使うことにしました。

Html ヘルパーを作ろう

ActionLink メソッドに倣って、Razor View 内で、

@Html.ReferrerLink("戻る")

というような形で使いたいな、と思い、Html ヘルパーを作ることにしました。
ActionLink を真似るので、引数のパターンも同じようにしましょう。こんな感じです。

IHtmlString ReferrerLink(this HtmlHelper htmlHelper, string linkText);
IHtmlString ReferrerLink(this HtmlHelper htmlHelper, string linkText, object htmlAttributes);
IHtmlString ReferrerLink(this HtmlHelper htmlHelper, string linkText, IDictionary<string, object> htmlAttributes);

一番下のパターンだけを実装し、上の 2 パターンは一番下のパターンを呼び出すようにします。
このあたりの書き方は、ASP.NET MVC のソースが公開されていますので、それを参考にしました。
ActionLink のソースは LinkExtensions.cs にあります。
そこから呼び出されている HtmlHelper.GenerateLink メソッドのソースは HtmlHelper.cs にあります(GenerateLink でページ内検索してください)。

ActionLink のソースですと、一番上の htmlAttributes を取らないバージョンは、一番下のバージョンに new RouteValueDictionary() を渡しているのがわかります。
受け取る側は IDictionary<string, object> なので、空のディクショナリを渡してやれば何でもいいわけです。
が、今回は、(object)null として、2 番目のメソッドを呼ぶようにしました。
ASP.NET MVC のソースの別の箇所でそのようにしていた部分があり*5、私がそれで最初にこのパターンを覚えてしまったからです。

2 番目の、htmlAttributes を object で受けるバージョンから、一番下のを呼び出すためには、HtmlHelper.AnonymousObjectToHtmlAttributes メソッドを使います。
このメソッドに null を渡すと、ちゃんと空のディクショナリに変換してくれます。

そして、実際のタグ生成処理を行う一番下のバージョンでは、TagBuilder クラスを使います。

この、属性の変換と TagBuilder の使い方は、Html ヘルパーを作る上での決まりごとのようなものなので、覚えておいて損はありません。*6

というわけで、最終的に出来上がったソースがこちらです。

public static class ReferrerLinkExtensions
{
  public static IHtmlString ReferrerLink(
    this HtmlHelper htmlHelper,
    string linkText,
    IDictionary<string, object> htmlAttributes)
  {
    var referrer =
      htmlHelper.ViewContext.HttpContext.Request.UrlReferrer;

    if (referrer == null)
    {
        return MvcHtmlString.Empty;
    }

    string referrerString = referrer.ToString();
    if (string.IsNullOrEmpty(referrerString))
    {
        return MvcHtmlString.Empty;
    }

    var tagBuilder = new TagBuilder("a");
    tagBuilder.SetInnerText(linkText);
    tagBuilder.MergeAttributes(htmlAttributes);
    tagBuilder.MergeAttribute("href", referrerString, true);

    string tagString = tagBuilder.ToString();
    return MvcHtmlString.Create(tagString);
  }

  public static IHtmlString ReferrerLink(
    this HtmlHelper htmlHelper,
    string linkText,
    object htmlAttributes)
  {
    var dic = HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes);
    return ReferrerLink(htmlHelper, linkText, dic);
  }

  public static IHtmlString ReferrerLink(
    this HtmlHelper htmlHelper,
    string linkText)
  {
    return ReferrerLink(htmlHelper, linkText, (object)null);
  }
}

当初は内部で ActionLink を流用しようと思ったのですが、そのためにはコントローラーやアクションの名前が必要だったので、タグ全体を自前で組み立てることにしました。

まとめ

というわけで、ActionFilterAttribute の使い方と罠、それから、Html ヘルパーの作り方をお届けしました。
機会があったら参考にしてみてください。*7

*1:だったらブラウザーのボタンで戻らせればいいじゃないかという意見には一理あります

*2:もうそのソースは残っていないのでわからないのです

*3:他のリンクではなく「戻る」リンクであることをどのように識別するかも課題の一つでした

*4:スペルミスにあらず

*5:InputExtensions.cs とか

*6:次期 ASP.NET MVC 6 では、TagHelper という新しい機能に取って代わられそうですが…

*7:そして、もっといい方法を思いついたらぜひ教えてください