はじめに
エンドユーザーを混乱させないようにデータを効果的に表示することは、Webのデータプレゼンテーションアプリケーションを開発する際の主たる目的の1つです。1ページに20件のレコードを表示するなら我慢できますが、10,000件にもなると混乱を招きます。この問題の解決策としては、データを複数のページに分割するという方法が一般的に用いられています。これを「データのページング処理」と言います。
ASP.NETにおけるページング処理
ASP.NETには、ページングをサポートするDataGrid
というコントロールが用意されています。これはページングをサポートする唯一のコントロールです。ただ、イントラネット向けのアプリケーションの場合はDataGrid
ページャコントロールで問題ないのですが、一般向けのアプリケーションの場合には、柔軟なWebアプリケーションを開発するために必要な機能が不足しています。具体的に言うと、DataGrid
コントロールでは、Webデザイナがページャを配置できる位置やその見た目が制限されており、たとえばページャを縦に配置することはできません。
DataGrid
以外のコントロールで、ページングが効果を発揮しそうなものとしては、リピータコントロールがあります。ただ、リピータコントロールでは、Web開発者がデータの表示方法をすばやく設定できるものの、ページング処理は開発者が自ら実装しなくてはなりません。また、データソースやプレゼンテーションに応じて異なるさまざまなコントロールに対して、専用のカスタムのページャをそれぞれ実装するというのは、時間がかかってしまうおそれがあります。特定のプレゼンテーションコントロール専用ではない、汎用的なページャコントロールがあれば、時間を大きく節約できます。
汎用的なページャコントロールに要求される機能
汎用的なページャコントロールの中でも、特に優れたものは、単にデータを複数ページに表示するだけではなく、次のような機能を備えているはずです。
- [先頭]、[前へ]、[次へ]、[最後]の各ボタン、およびページ移動用のボタンが用意されている。
- データに応じた処理が行われる。たとえば、10件のレコードを表示するようにページャが設定されていて、表示データが9件しかない場合は、ページャは表示されないのが望ましい。先頭ページでは、[前へ]ボタンと[先頭]ボタンは表示されないのが望ましい。末尾ページでは、[次へ]ボタンと[最後]ボタンは表示されないのが望ましい。
- プレゼンテーションを担当するコントロールの種類に依存しない。
- 既存および今後のさまざまなデータソースに対応できる。
- プレゼンテーションの設定が簡単で、カスタムアプリケーションに統合できる。
- ページング処理の実行時には他のコントロールに通知する。
- 経験の浅いWebデザイナでも簡単に使える。
- 関連するページングデータに対するプロパティを提供する。
同様の機能を持つ商用のページャもいくつかありますが、それなりに値が張ります。資金が潤沢でないWeb企業としては、カスタムのページャコントロールを作成することが必須です。
カスタムのページャコントロールの作成
ASP.NETでは、独自のWebコントロールを作成する方法として、ユーザーコントロール、複合コントロール、カスタムコントロールという3つが用意されています。3つ目に挙げたカスタムコントロールというのは、少し誤解を招く呼び名です。前述のコントロールはいずれも、実際にはカスタムコントロールです。複合コントロールとカスタムコントロールの違いは、CreateChildControls()
メソッドを使用するかどうかという点です。このメソッドを使用すると、発生したイベントに基づいて、コントロール自身で再描画を行うことができます。この記事では、複合コントロールモデルを使用して汎用的なページャを作成することにします。
ページャコントロールのしくみ
次のシーケンス図は、ページャコントロールのしくみの全体像を示します。
ここで作成するページャコントロールは、プレゼンテーションコントロールの種類に依存しないものの、何らかの方法でデータにアクセスしなくてはなりません。Control
クラスから派生した各クラスには、DataBinding
イベントが備わっています。ページャは、自らをDataBinding
イベントのリスナとして登録することにより、イベントを待機して、データに変更を加えることができます。このイベントは、Control
クラスから派生したすべてのコントロールに備わっているので、これを使用すれば、プレゼンテーションコントロールの種類に依存しないページャコントロールを作成するという目標を達成できます。言い換えると、Control
クラスから派生したコントロール、つまりほぼすべてのWebコントロールにバインドできるということです。
プレゼンテーションコントロールがDataBinding
イベントを発生させると、ページャコントロールはDataSource
プロパティをインターセプトできます。あいにく、すべてのデータバインドクラスが実装しているインターフェイス(たとえばIdataSourceProvider
のようなもの)はありません。また、Control
クラスやWebControl
クラスから派生したコントロールの中にはDataSource
プロパティを持たないものもあるので、Control
クラスへのアップキャストという方法は使えません。代案としては、リフレクションを使用してDataSource
プロパティを直接操作するというのが唯一の方法です。
データソースの把握
イベントハンドラメソッドについて説明する前に、1つ指摘しておきたいのは、イベントリスナとして登録するためには、プレゼンテーションコントロールに対する参照を確立する必要があるということです。ページャコントロールでは、BindToControl
という簡単な文字列プロパティを公開します。Web開発者は、コードかaspxページからこのプロパティを設定することで、DataSource
をプレゼンテーションコントロールにバインドできます。
public string BindToControl { get { if (_bindcontrol == null) throw new NullReferenceException("You must bind to a control " + "through the BindToControl property before you use the pager"); return _bindcontrol; } set{_bindcontrol=value;} }
このメソッドは非常に重要なので、標準のNullReferenceException
ではなく、もっと意味のわかりやすいメッセージをスローするのがよいでしょう。ページャのOnInit
イベントハンドラでは、プレゼンテーションコントロールへの参照を解決するための呼び出しを行います。JITコンパイルされたaspxページが必ずBindToControl
メソッドを設定するようにするために、コンストラクタではなくOnInit
イベントハンドラを使用する必要があります。
protected override void OnInit(EventArgs e) { _boundcontrol = Parent.FindControl(BindToControl); BoundControl.DataBinding += new EventHandler(BoundControl_DataBound); base.OnInit(e); }
プレゼンテーションコントロールを見つけ出すには、ページャのParent
コントロールを検索します。今回の例では、メインのページテンプレートがParent
コントロールに当たります。Parent
プロパティをこのように使用することには、それなりの危険があります。たとえば、ページャを別のコントロール(たとえばTable
コントロール)に埋め込む場合は、Parent
プロパティを呼び出すとTable
コントロールへの参照が返ります。FindControl
メソッドは現在のコントロールコレクションのみを検索するので、プレゼンテーションコントロールは、そのコレクションに含まれていない限りは見つかりません。各コントロールのコントロールコレクションを再帰的に検索し、目的のコントロールが見つかるまで繰り返すという方法の方が安全です。
BoundControl
が見つかったら、ページャをDataBinding
イベントのリスナとして登録します。ページャコントロールはデータソースを操作するので、このイベントハンドラは呼び出しチェインの末尾であることが重要です。プレゼンテーションコントロールがDataBinding
イベントのイベントハンドラを既定どおりOnInit
イベントハンドラで登録している限りは、ページャがデータソースを操作するときに問題は生じません。
プレゼンテーションコントロールのDataSource
プロパティの取得は、DataBound
イベントハンドラで行います。
private void BoundControl_DataBound(object sender,System.EventArgs e) { Type type = sender.GetType(); _datasource = type.GetProperty("DataSource"); if (_datasource == null) throw new NotSupportedException("The Pager control doesn't support controls " + "that don't contain a datasource"); object data = _datasource.GetGetMethod().Invoke(sender,null); BindParent(); }
リフレクションを使用して、DataSource
プロパティのGet
部分を実行するための呼び出しを行い、実際のデータソースへの参照を取得します。
データソースの操作方法の把握
これでデータソースは把握できましたが、ページャはさらに、その操作方法を把握する必要があります。ページャをプレゼンテーションコントロールから独立させるべく多大な努力を費やしているというのに、ここでデータソースに依存するようなしくみにしたら、柔軟性のあるコントロールを構築するという目的が損なわれてしまいます。プラグ可能なアーキテクチャにすれば、.NET標準のものにせよ、独自のものにせよ、あらゆる種類のデータソースをページャコントロールが確実に処理できるようになります。
使用するデザインパターン
堅牢性と拡張性に優れたプラグ可能なアーキテクチャを実現するための最善のソリューションは、GoFのBuilderパターンを使用することです。
IDataSourceAdapterインターフェイス
IDataSourceAdapter
インターフェイスは、ページャがデータ操作のために必要とする最も基本的な要素(プラグ)を定義します。
public interface IDataSourceAdapter { int TotalCount{get;} object GetPagedData(int start,int end); }
TotalCount
プロパティは、データソースに含まれる要素の総数を、データの操作前に返します。また、GetPagedData
メソッドは、元のデータのサブセットを返すことにより、データソースを操作します。たとえば、20個の要素を持つ単純な配列がデータソースで、ページャは1ページにつき10個の要素を表示する場合、このデータのサブセットは、1ページ目は要素0~9、2ページ目は要素10~19です。
DataViewAdapter
DataView
型用のプラグを提供するのはDataViewAdapter
です。
internal class DataViewAdapter:IDataSourceAdapter { private DataView _view; internal DataViewAdapter(DataView view) { _view = view; } public int TotalCount { get{return (_view == null) ? 0 : _view.Table.Rows.Count;} } public object GetPagedData(int start, int end) { DataTable table = _view.Table.Clone(); for (int i = start;i<=end && i<= TotalCount;i++) { table.ImportRow(_view[i-1].Row); } return table; } }
DataViewAdapter
は、IDataSourceAdapter
のGetPagedData
メソッドを実装しています。その中では、元のDataTable
を複製し、元のDataTable
から複製テーブルに対して行をインポートしています。クラスの可視性はあえてinternal
と設定しています。実装の詳細をWeb開発者から隠ぺいし、Builder
クラスを通じて簡単なインターフェイスを提供するためです。
AdapterBuilder抽象クラス
public abstract class AdapterBuilder { private object _source; private void CheckForNull() { if (_source == null) throw new NullReferenceException("You must provide a valid source"); } public virtualobject Source { get { CheckForNull(); return _source;} set { _source = value; CheckForNull(); } } public abstract IDataSourceAdapter Adapter{get;} }
AdapterBuilder
抽象クラスは、IDataSourceAdapter
型を扱いやすくするためのインターフェイスを提供します。IDataSourceAdapter
を直接使用するのではなく、抽象化のレベルを高めることによって、処理前(データのページング前)の指示を実行するための追加的な層が得られます。また、AdapterBuilder
を使用することにより、DataViewAdapter
などの実装本体をページャのユーザーから隠ぺいできます。
public class DataTableAdapterBuilder:AdapterBuilder { private DataViewAdapter _adapter; private DataViewAdapter ViewAdapter { get { if (_adapter == null) { DataTable table = (DataTable)Source; _adapter = new DataViewAdapter(table.DefaultView); } return _adapter; } } public override IDataSourceAdapter Adapter { get{return ViewAdapter;} } } public class DataViewAdapterBuilder:AdapterBuilder { private DataViewAdapter _adapter; private DataViewAdapter ViewAdapter { get { //lazy instantiate if (_adapter == null) { _adapter = new DataViewAdapter((DataView)Source); } return _adapter; } } public override IDataSourceAdapter Adapter { get{return ViewAdapter;} } }
DataView
型とDataTable
型には密接な関係があるため、汎用的なDataAdapter
を用意することも理にかなっているかもしれません。その場合、DataTable
を処理する別のコンストラクタを追加すれば十分です。ただあいにく、ユーザーがDataTable
に対して異なる機能を必要とする場合に、クラス全体を置き換えるか継承する必要が生じてしまいます。同じIDataSourceAdapter
を使用する新しいビルダーを構築することにより、アダプタの実装方法に対するユーザーの自由度が高まります。
AdapterCollection
ページャコントロールでは、適切なビルダーの検索は、タイプセーフなコレクションによって処理します。
public class AdapterCollection:DictionaryBase { private string GetKey(Type key) { return key.FullName; } public AdapterCollection() {} public void Add(Type key,AdapterBuilder value) { Dictionary.Add(GetKey(key),value); } public bool Contains(Type key) { return Dictionary.Contains(GetKey(key)); } public void Remove(Type key) { Dictionary.Remove(GetKey(key)); } public AdapterBuilder this[Type key] { get{return (AdapterBuilder)Dictionary[GetKey(key)];} set{Dictionary[GetKey(key)]=value;} } }
AdapterCollection
はDataSource
の種類に依存しており、これはBoundControl_DataBound
メソッドにちょうど適しています。インデックスキーにはType.FullName
メソッドを使用します。これにより、それぞれの型に対してインデックスキーが一意となります。この結果、所定の型に対応するビルダーが1つだけ含まれるようにするという役割は、AdapterCollection
が担うことになります。ビルダーの検索をBoundControl_DataBound
メソッドに追加すると、次のようになります。
public AdapterCollection Adapters { get{return _adapters;} } private bool HasParentControlCalledDataBinding { get{return _builder != null;} } private void BoundControl_DataBound(object sender,System.EventArgs e) { if (HasParentControlCalledDataBinding) return; Type type = sender.GetType(); _datasource = type.GetProperty("DataSource"); if (_datasource == null) throw new NotSupportedException("The Pager control doesn't support controls " + "that don't contain a datasource"); object data = _datasource.GetGetMethod().Invoke(sender,null); _builder = Adapters[data.GetType()]; if (_builder == null) ''throw new NullReferenceException("There is no adapter installed " + "to handle a datasource of type "+data.GetType());'' _builder.Source = data; BindParent(); }
BoundControl_DataBound
メソッドでは、HasParentControlCalledDataBinding
を使用して、既にビルダーが作成済みかどうかを確認するという処理も行っています。既に作成済みの場合は、適切なビルダーを改めて見つけるという手間は省きます。もちろんこれは、別のDataSource
でユーザーがDataBinding
を呼び出していないということが前提です。そういうことは当然ないはずです。Adapters
テーブルはコンストラクタで初期化しています。
public Pager() { _adapters = new AdapterCollection(); _adapters.Add(typeof(DataTable),new DataTableAdapterBuilder()); _adapters.Add(typeof(DataView),new DataViewAdapterBuilder()); }
最後に実装するメソッドはBindParent
です。この中では、データを操作し、それを返します。
private void BindParent() { _datasource.GetSetMethod().Invoke(BoundControl, new object[] { _builder.Adapter.GetPagedData( StartRow,ResultsToShow*CurrentPage)}); }
このメソッドはかなり単純です。実際のデータ操作はAdapter
が行うからです。完了したら、リフレクションを再度使用しますが、今回はプレゼンテーションコントロールのDataSource
プロパティを設定するための使用です。これでページャコントロールの動作はほぼ完成です。しかし、適切なプレゼンテーションがないことには、あまり役に立ちません。