控えめなViewStateによるハイパフォーマンスASP.NET Web Forms開発
- 2012-06-02
今どきのウェブ開発は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も、これはこれで面白い仕組みだし、まっとうに進む道もあったとは思うんですが、舵取りにしくったと思います。結果的にこの閉塞感漂う現状と、明らかにリソース割かれてない感があるので、未来はないですね、残念ながら。しかし、最後の徒花としてやれるだけはやるのさぁ。