控えめな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も、これはこれで面白い仕組みだし、まっとうに進む道もあったとは思うんですが、舵取りにしくったと思います。結果的にこの閉塞感漂う現状と、明らかにリソース割かれてない感があるので、未来はないですね、残念ながら。しかし、最後の徒花としてやれるだけはやるのさぁ。

Comment (2)

noname : (06/04 11:30)

今回はDataSetさんは無関係なのに嫌われ過ぎわろた

つとむ : (06/06 02:18)

WebFormsやDataTableをけちょんけちょんにしてしまってTwitterでも少しヒートアップしてるような感じもしましたが、いかいろ考えさせられました。
自分も可能であればDataTableなど使わずに複合型で解決したいです。ただ現状はDataTableに依存しています。オブジェクトの変更追跡、ロールバック、リレーションの管理などをしたい場合が多々あるからです。
複合型のコレクション(Collection)をメンバーに持つ複合型(Customer)などよくあるパターンですが、こういったリレーショナルなエンティティに対してどのように変更追跡を行っていますか?
POCOを諦め、INotifyPropertyChangedなどを実装したりコレクションを通知型に変えたりしてDataTableのようにエンティティ内部に追跡機能を書いたりしているのでしょうか。
でもそうしてしまうとなんか再発明な感じもしますし、外部で追跡管理するきれいな実装アイデアが思いつきませんでした。自前パターンはMSのサイトに例があったりしましたが。
ロールバックやリレーションの管理も同様です。
なので、そういう場合は泣く泣くDataTableを使用しています。
DataTableをけちょんけちょんにするのは全然構わないのですが、DataTableをこうすれば捨てられるよ的なエントリーも上げてもらえると本当に死ね!と心から思えるのですが。
時間があれば、上記のような場合どのように解決しているの教えていただけると本当にうれしいです。

Name
WebSite(option)
Comment

Trackback(1) | http://neue.cc/2012/06/02_375.html/trackback

.NET Clips : (06/05 10:34)

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

素敵なエントリーの登録ありがとうございます - .NET Clipsからのトラックバック…

Search/Archive

Category

Profile


Yoshifumi Kawai
Microsoft MVP for .NET(C#)

April 2011
|
March 2017

Twitter:@neuecc
GitHub:neuecc
ils@neue.cc