控えめなViewStateによるハイパフォーマンスASP.NET Web Forms開発

今どきのウェブ開発はMVCだよねー、な昨今を皆様どうお過ごしでしょうか。そんな中であっても、Web Formsでモバイル向けにハイパフォーマンスサイトを作らなきゃいけない時だってあるんです。さて、そんなWeb Fromsですが、とりあえずの敵はViewStateです。ViewStateをどのように活かし、どのように殺害するか、そこに全てがかかっています。幾つかの典型的なシチュエーションを取り出して、ViewStateを抹消していきましょう。

ViewStateMode = "Disabled"

下準備として、ViewStateModeをDisabledにします。ViewStateModeは.NET Framework 4から入った新機能で、「ようやく」ViewStateのオン・オフをルート階層から切り替えることが出来るようになりました。それまではEnableViewStateのみで、falseにすると、その階層の下のViewStateが全てオフになってしまうという使いにくいものでした。全部OFFで済ませられるほど世の中は甘くなく、Web Formsでは部分的にONにする必要性があります。ViewStateModeとEnableViewStateの両方が記述されているとEnableViewStateのほうが優先されて害悪となります。というわけで、.NET Framework 4以降ならばViewStateModeのみを使いましょう。

<%@ Master Language="C#" AutoEventWireup="true" CodeBehind="Site1.master.cs" Inherits="WebApplication3.Site1" %>

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>ViewState殺害教</title>
</head>
<body>
    <asp:ContentPlaceHolder ID="BodyPlaceHolder" runat="server" ViewStateMode="Disabled">
    </asp:ContentPlaceHolder>
</body>
</html>

まずは、マスターページのContentPlaceHolderに対して、ViewStateModeをDisabledにしましょう。こうすることで全ページが強制的にデフォでViewStateオフが働きます。また、ページ単位でDisabledにしてしまうと、各Pageの this.ViewState["hogehoge"] も無効になってしまって不便なので(PageのViewStateは便利なinput hiddenみたいなものですし、闇雲にすべてをオフにせず、便利なものは便利に使うのが大事です)、マスターページのContentPlaceHolderに仕込むのが最良だと私は考えています。

テキストボックスやドロップダウンリストから値を取り出す

そんなわけで、ViewStateを丸ごとオフにした状態からデータを取り出してみませう。

<%@ Page Title="" Language="C#" MasterPageFile="~/Site1.Master" AutoEventWireup="true"
    CodeBehind="WebForm1.aspx.cs" Inherits="WebApplication3.WebForm1" %>

<asp:Content ContentPlaceHolderID="BodyPlaceHolder" runat="server">
    <form runat="server">
        <asp:TextBox runat="server" ID="ToaruTextBox" />
        <asp:DropDownList runat="server" ID="ToaruDropDownList" />
        <asp:Button runat="server" Text="ただのボタン" OnClick="Button_Click" />
    </form>
</asp:Content>

こんなどうでもいい画面があるとして、コードビハインド側は

public partial class WebForm1 : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (IsPostBack) return;

        ToaruTextBox.ToolTip = "ほげほげ";
        var items = Enumerable.Range(1, 10)
            .Select(x => new ListItem
            {
                Text = x + "点",
                Value = x.ToString(),
                Selected = x == 5
            })
            .ToArray();
        ToaruDropDownList.Items.AddRange(items);
    }

    protected void Button_Click(object sender, EventArgs e)
    {
        try
        {
            Response.Write("TextBox.Text:" + ToaruTextBox.Text + "<br />");
            Response.Write("TextBox.ToolTip:" + ToaruTextBox.ToolTip + "<br />");
            Response.Write("DropDownList:" + ToaruDropDownList.SelectedValue);
            Response.End();
        }
        catch (System.Threading.ThreadAbortException) { }
    }
}

こんな感じとします。ところでどーでもいーんですが、DropDownListに値を突っ込むときはLINQでListItemの配列を作って、それをAddRangeで流し込むほうがDataSourceに入れてDataBindするよりも楽です。というのも、DataBindだとSelectedを指定するのが非常に難しいというか不可能に近いようなので。こういう色々と中途半端なとこがWeb Formsは嫌ですね。

さて、このボタンを押した実行結果は、「TextBox.Text:あいうえお」「TextBox.ToolTip:」「DropDownList:」になります。TextBox.Textは取り出せてるけど、ToolTipは取り出せてない。DropDownListも全滅。つまるところ、ViewStateをオフにしていると、復元できる(イベントで取り出せる)プロパティと、そうでないプロパティがあります。挙動としては、Web Formsが復元出来るものは自動で復元してくれて、復元できないものは復元してくれない、といった感じです。そして、それだと困るわけです。ToolTipはどうでもいいのですが、DropDownListの値とか取れないと困る。ViewStateがオンなら、全部取得できているのに!やっぱりViewStateはオンにしよう!ではなくて、何とかしましょう。

「復元出来るものは自動で復元してくれる」というけれど、その情報はどこにあるのでしょう。これは、別にWeb Formsだからって特殊なわけでもなんでもなく、Request.Formにあります。

public static class NameValueCollectionExtensions
{
    public static IEnumerable<KeyValuePair<string, string>> AsEnumerable(this NameValueCollection collection)
    {
        return collection.Keys.Cast<string>().Select(x => new KeyValuePair<string, string>(x, collection[x]));
    }
}

Button_Clickのところでブレークポイント張って、Request.Formの中身を覗きましょう。AsEnumerableは独自拡張メソッドです。Request.FormはNameValueCollectionというゴミに格納されていて、Keysは見れるけどValuesが見れないというクソ仕様なので、そこはLINQで何とかしましょうというか、これは多用するのでNameValueCollectionへの拡張メソッドとして定義しておくと捗りますというか、ないと死ぬレベル。

そんなわけで、Formに普通に格納されていることが分かりました。そうそう、ViewStateをオフにしてるはずなのに__VIEWSTATEに値が入ってるぞ!とお怒りかもですが、ほんの少し入ってくるのは仕様なので、そこは我慢しましょう。大した量じゃないので。

では、どうすれば値を取得できるのか、というと

protected void Button_Click(object sender, EventArgs e)
{
    try
    {
        Response.Write("TextBox.Text:" + Request.Form[ToaruTextBox.UniqueID] + "<br />");
        Response.Write("DropDownList:" + Request.Form[ToaruDropDownList.UniqueID]);
        Response.End();
    }
    catch (System.Threading.ThreadAbortException) { }
}

こうです。コントロールのUniqueIDをFormに渡せば良いわけですね。ToolTipとかいうどうでもいいものは取れないので、そういう、HTMLのinputに存在しないものに関しては、PageのViewStateに保存しておけば良いでしょう。私としては、Web Forms使うならinput hiddenよりもthis.ViewState["hoge"]を使うべきだと思います。せっかくある道具ならば、嫌々ながらも有効活用したほうが良いでしょう。

リピーターとチェックボックス

お次は、繰り返しなシチュエーションを考えてみましょう。繰り返しといったらRepeater一択です。それ以外は存在しません。BulletedListすら存在しません。書き出すHTMLを完全にコントロールできるものはRepeater以外存在しません。Web Formsとはなんだったのか。ともかく、Repeaterです。

<asp:Content ContentPlaceHolderID="BodyPlaceHolder" runat="server">
    <form runat="server">
        <asp:Repeater runat="server" ID="ToaruRepeater">
            <ItemTemplate>
                <input runat="server" id="ToaruCheckBox" type="checkbox" value="<%# Container.DataItem %>" />
                チェック 値:<%# Container.DataItem %>
                <br />
            </ItemTemplate>
        </asp:Repeater>
        <asp:Button runat="server" OnClick="Button_Click" Text="ぼたん" />
    </form>
</asp:Content>
public partial class WebForm1 : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (IsPostBack) return;

        ToaruRepeater.DataSource = Enumerable.Range(1, 10);
        DataBind();
    }

    protected void Button_Click(object sender, EventArgs e)
    {
        try
        {
            // CheckされたCheckBoxの値をどう取り出す?
            Response.End();
        }
        catch (System.Threading.ThreadAbortException) { }
    }
}

こんな、まあ簡単な画面があるとします。チェックされたCheckBoxの値をどうやって取り出しましょうか?そうそう、ちなみにですがasp:CheckBoxはvalueが指定できないというゴミ仕様なのでやめておきましょう(Web Formsってそんなのばっか、もうやだよ……。ちなみにコード側のInputAttributesで渡すことは一応できます、一応)。

例によってRequest.Formから取り出すことになるので、まずはデバッガで値を見てやります。

チェックしたチェックボックスの値、ToaruCheckBox,3とToaruCheckBox,8が確認できます。では、どうやって取り出してやろうか。Repeaterの中のコントロールなのでUniqueIDを使うことはできません。ただ、コントロール階層順に$で連結されてる、という法則は見えるわけなので、単純に$でSplitして文字列一致で見てやりましょうか。

protected void Button_Click(object sender, EventArgs e)
{
    try
    {
        var checkedValues = Request.Form.AsEnumerable()
            .Where(x => x.Key.Split('$').Last() == "ToaruCheckBox")
            .Select(x => x.Value);

        // Checked:3, Checked:8
        foreach (var item in checkedValues)
        {
            Response.Write("Checked:" + item + "<br />");
        }
        Response.End();
    }
    catch (System.Threading.ThreadAbortException) { }
}

はい、これで完璧です!なお、ASP.NETのID生成ルールはそれなりに変更が聞くので、詳しくはプログラミングMicrosoft ASP.NET 4 (マイクロソフト公式解説書)でも読めばいいでしょう。

ボタンとCommandArgument

ボタンはOnClickではなくOnCommandを使うと、CommandArgument(とCommandName)を渡せて便利です。簡単な例としては

<asp:Content ContentPlaceHolderID="BodyPlaceHolder" runat="server">
    <form runat="server">
        <asp:Button runat="server" OnCommand="Button_Command" CommandArgument="<%# (int)Fruit.Grape %>" Text="ぶどう!" />
        <asp:Button runat="server" OnCommand="Button_Command" CommandArgument="<%# (int)Fruit.Apple %>" Text="りんご!" />
        <asp:Button runat="server" OnCommand="Button_Command" CommandArgument="<%# (int)Fruit.Orange %>" Text="みかん!" />
    </form>
</asp:Content>
public partial class WebForm1 : System.Web.UI.Page
{
    public enum Fruit
    {
        Grape,
        Apple,
        Orange
    }
    protected void Page_Load(object sender, EventArgs e)
    {
        if (IsPostBack) return;

        DataBind();
    }

    protected void Button_Command(object sender, CommandEventArgs e)
    {
        Response.Write("Clicked:" + (Fruit)int.Parse((string)e.CommandArgument));
    }
}

注意しなければならないのは、aspx上でCommandArgumentに渡すと文字列になるので、enumを渡すときはintにキャストしておくことと、イベント側のCommandEventArgsに渡ってくるときは文字列なのでintにParseしなければならないこと、です。クソ面倒くさいですね、もう少し気が利いてもいいと思うんですが、まあWeb Formsなのでしょうがないと思っておきましょう。

さて、ViewStateがオンならばこれでいいのですが、オフの場合はe.CommandArgumentは常に空文字列になってしまいます。何故か、というと、CommandArgumentはViewStateに乗ってやってくるからです。さて、どうしましょう、というと、解決策は部分的にオンにすることです。

<asp:Content ContentPlaceHolderID="BodyPlaceHolder" runat="server">
    <form runat="server">
        <asp:Button runat="server" ViewStateMode="Enabled" OnCommand="Button_Command" CommandArgument="<%# (int)Fruit.Grape %>" Text="ぶどう!" />
        <asp:Button runat="server" ViewStateMode="Enabled" OnCommand="Button_Command" CommandArgument="<%# (int)Fruit.Apple %>" Text="りんご!" />
        <asp:Button runat="server" ViewStateMode="Enabled" OnCommand="Button_Command" CommandArgument="<%# (int)Fruit.Orange %>" Text="みかん!" />
    </form>
</asp:Content>

ButtonのViewStateModeだけを"Enabled"にすることで、最小限のViewStateで最大限の成果を発揮することができます。ViewStateを嫌うならば、そもそもCommandなんて使わない!になるでしょうけれど、そこまでやってしまうと勿体ない。縁あってWeb Formsを使うわけなのですから、「控えめに」「隠し味」として、ViewStateを有効活用していきましょう。

リピーターとボタン

最後に、リピーターの中にボタンが仕込まれているパターンを見てみましょう。

<asp:Content ContentPlaceHolderID="BodyPlaceHolder" runat="server">
    <form runat="server">
        <asp:Repeater runat="server" ID="ToaruRepeater" ViewStateMode="Enabled">
            <ItemTemplate>
                <%# Container.DataItem %>:<asp:Button runat="server" OnClick="Button_Click" Text="ぼたん!" /><br />
            </ItemTemplate>
        </asp:Repeater>
    </form>
</asp:Content>
public partial class WebForm1 : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (IsPostBack) return;

        ToaruRepeater.DataSource = Enumerable.Range(1, 10).Select(x => new string(Enumerable.Repeat('a', 10000).ToArray()));
        DataBind();
    }

    protected void Button_Click(object sender, EventArgs e)
    {
        try
        {
            Response.Write("Clicked!");
            Response.End();
        }
        catch (System.Threading.ThreadAbortException) { }
    }
}

a(x100000):ボタン という表示結果が得られます。ボタンをクリックするとClicked!と実行されて欲しいわけですが、ViewStateがオフだとうんともすんとも言いません。何故か、というと、イベントの選択もまたViewStateに乗ってくるからです。解決策はRepeaterのViewStateModeをEnabledにすること、です。

しかし、単純にRepeaterのViewStateModeをEnabledにしただけだと、それ以下の全てのViewStateがオンになってしまいます。どういうことかというと、ViewStateを見てみると、この場合の結果は「133656文字」もあります!どれだけデカいんだよ!なぜかというとaaaaaa...(x10000) x 10がViewStateに乗っかってしまったからです。ただたんにボタンクリックできればいいだけなのに!じゃあ、どうするか、というと、ViewStateのオンオフを入れ子にしてRepeaterだけをオンにします。

<asp:Content ContentPlaceHolderID="BodyPlaceHolder" runat="server">
    <form runat="server">
    <asp:Repeater runat="server" ID="ToaruRepeater" ViewStateMode="Enabled">
        <ItemTemplate>
            <asp:PlaceHolder runat="server" ViewStateMode="Disabled">
                <%# Container.DataItem %>:<asp:Button runat="server" OnClick="Button_Click" Text="ぼたん!" /><br />
            </asp:PlaceHolder>
        </ItemTemplate>
    </asp:Repeater>
    </form>
</asp:Content>

これで解決。クソ面倒くさくて回りっくどくてイライラしますが、"そういうもの"だと思うしかないです。

Web Formsの良さをスポイルしてまでWeb Forms使いたい?

使いたくないです。それでもやらなきゃいけない時はあるんですDataSet死ね。

(ところで関係なく)async event

Visual Studio 2012 RC出ました!neue cc - Visual Studio 11の非同期(”C#, ASP.NET, Web Forms, MVC”)で非同期系について特集しましたが、Web FormsではRegisterAsyncTask(new PageAsyncTask)しなきゃならなくて面倒くさい死ねという感じでしたが、ようやくイベントにasyncをつけるだけでよくなりました!

protected async void Button_Click(object sender, EventArgs e)
{
    await Task.Delay(TimeSpan.FromSeconds(3));
    try
    {
        Response.Write("hoge");
        Response.End();
    }
    catch (System.Threading.ThreadAbortException) { }
}

正式リリース前に対応してくれて本当に良かった。これでWeb Formsでももう少し戦える……、戦いたくないけど。

まとめ

世の中にはUnobtrusive JavaScriptという言葉がありますが、そのように、私としてもUnobtrusive ViewStateを唱えたい。控えめに。とにかく控えめに。ほとんどないも同然なぐらいに。隠し味として使うのが、一番良いわけです。今までのWeb Formsはデフォルトオンで化学調味料をドバドバと投げ込んでいました。そんなものは食えたものじゃあありません。化学調味料を使うなら、超絶控えめに、ほんの少しでいいんです。そうすれば、革命的に美味しくなるのですから。それが正しい化学調味料の使い方。

そして、理想を言えば化学調味料はゼロがいいんですけれどね。ゼロにしたければWeb Formsはやめましょう。それ以外の答えはない。Web Formsを使う以上は正しく向き合うことが大事。

ViewStateをドバドバ使うことがWeb Formsの良さだというのも半分は事実ですが、半分はNOですね。ていうか馬鹿でしょ。そういう発想に未来はないし脳みそイカれてると思いますよ。DataSetを褒め称えていた狂った時代の発想なので、腐った部位はとっとと切り落としましょう。

さて、そうして控えめにしたWeb Formsですけれど、これはこれでそれなりに良いとは思います。最小限にしたとしても、依然としてパーツ配置してOnClickでほいほい、という楽ちんさは健在ですし、カスタムコントロールによるモジュール化というのは悪くない。絶賛はしませんが、悪くないとは思います、そこまで悪しざまにいうものでもない。でも、まあ、もはや時代じゃあないでしょうね。もう、さようなら。

と、まあ割とキツめにWeb Forms(やDataSet)にあたるのは日々苦しめられているからなので、罵詈雑言多いのは勘弁してね!別に現状がダメでも未来に良くなるとかならいいんですが、明らかに未来がないので、なんというかもうねえ、という。Web Formsも、これはこれで面白い仕組みだし、まっとうに進む道もあったとは思うんですが、舵取りにしくったと思います。結果的にこの閉塞感漂う現状と、明らかにリソース割かれてない感があるので、未来はないですね、残念ながら。しかし、最後の徒花としてやれるだけはやるのさぁ。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive