HTTPハンドラを使用したFrontController(^o^)
いまさらかもしれないが、HTTPハンドラを使用したFrontControllerを実装してみた。
FrontControllerは、http://localhost:49876/Default.aspx?name=Sade&command=Artistというような形式のURLで動的コマンドを生成する。
まずは、ハンドラから。IHttpHandlerインターフェースを実装する。
using System; using System.Web; using System.Collections.Specialized; using System.Reflection; using System.Runtime.Remoting; namespace FrontControllerSample.Web.FrontController { public class FrontHandler : IHttpHandler { public bool IsReusable { get { return true; } } public void ProcessRequest(HttpContext context) { IFrontCommand command = GetCommand(context.Request.Params); command.Execute(context); } private IFrontCommand GetCommand(NameValueCollection parms) { string commandName = string.Format("FrontControllerSample.Web.FrontController.{0}Command", parms["command"]); IFrontCommand command = null; try { ObjectHandle handle = Activator.CreateInstance(Assembly.GetExecutingAssembly().FullName, commandName); command = (IFrontCommand) handle.Unwrap(); } catch (Exception) { command = new UnknownCommand(); } return command; } } }
このハンドラはWeb.configのhttpHandlersセクションで登録する。
<system.web> ... <httpHandlers> <add verb="*" path="Default.aspx" type="FrontControllerSample.Web.FrontController.FrontHandler" /> </httpHandlers> </system.web>
次はコマンド。HttpContextをパラメータで受け取るExecuteメソッドを持つだけのシンプルなインターフェース。
using System; using System.Web; namespace FrontControllerSample.Web.FrontController { public interface IFrontCommand { void Execute(HttpContext context); } }
続いて具象コマンド。リクエストからパラメータを取得して、ビジネスロジックを実行した結果のオブジェクトをHttpContextのItemsに追加して遷移。遷移先のページではHttpContextのItemsからオブジェクトを取得してビューに表示することになる。
using System; using System.Web; using System.Web.Security; using FrontControllerSample.Web.Model; namespace FrontControllerSample.Web.FrontController { public class ArtistCommand : IFrontCommand { public void Execute(HttpContext context) { string name = context.Request.Params["name"]; Artist artist = Artist.FindBy(name); context.Items.Add("artist", artist); context.Server.Transfer("ArtistPage.aspx"); } } public class AlbumCommand : IFrontCommand { public void Execute(HttpContext context) { string title = context.Request.Params["title"]; string artist = context.Request.Params["artist"]; Album album = Album.FindBy(title, artist); context.Items.Add("album", album); context.Server.Transfer("AlbumPage.aspx"); } } }
ハンドラのSpecialCaseで生成されるコマンドはエラーページにエラーメッセージを表示させる。
using System; using System.Web; using System.Web.Security; using FrontControllerSample.Web.Model; namespace FrontControllerSample.Web.FrontController { public class UnknownCommand : IFrontCommand { public void Execute(HttpContext context) { context.Items.Add("message", "不明なコマンドが要求されました。"); context.Server.Transfer("UnknownPage.aspx"); } } }
ここからは、モデルのスタブ実装。これらのクラスは本来であればデータアクセスロジックが記述されたActiveRecordとなるだろう。もしくはTableModuleになるかもしれない。
using System; using System.Collections.Generic; namespace FrontControllerSample.Web.Model { public class Artist { private readonly static List<Artist> artists_ = new List<Artist>(); static Artist() { Artist sade = new Artist("Sade"); Album.AddAlbumTo(sade); artists_.Add(sade); Artist anita = new Artist("Anita Baker"); Album.AddAlbumTo(anita); artists_.Add(anita); } public static Artist FindBy(string name) { return artists_.Find(delegate(Artist artist) { return artist.Name == name; }); } private string name_; private List<Album> albums_ = new List<Album>(); private Artist(string name) { this.name_ = name; } public string Name { get { return name_; } } public void AddAlbum(Album album) { albums_.Add(album); } public List<Album> GetAlbums() { return albums_; } } public class Album { private readonly static List<Album> albums_ = new List<Album>(); static Album() { albums_.Add(new Album("Diamond Life", "Sade", 1984)); albums_.Add(new Album("Promise", "Sade", 1985)); albums_.Add(new Album("Stronger Than Pride", "Sade", 1988)); albums_.Add(new Album("Love Deluxe", "Sade", 1992)); albums_.Add(new Album("Lovers Rock", "Sade", 2000)); albums_.Add(new Album("The Songstress", "Anita Baker", 1983)); albums_.Add(new Album("Rapture", "Anita Baker", 1986)); albums_.Add(new Album("Giving You The Best That I Got", "Anita Baker", 1988)); albums_.Add(new Album("Compositions", "Anita Baker", 1990)); albums_.Add(new Album("Rhythm Of Love", "Anita Baker", 1994)); albums_.Add(new Album("A Night Of Rapture", "Anita Baker", 2004)); albums_.Add(new Album("My Everything", "Anita Baker", 2004)); albums_.Add(new Album("Christmas Fantasy", "Anita Baker", 2005)); } public static Album FindBy(string title, string artist) { return albums_.Find(delegate(Album album) { return album.Title == title && album.Artist == artist; }); } public static void AddAlbumTo(Artist artist) { albums_.ForEach( delegate(Album album) { if (album.Artist == artist.Name) { artist.AddAlbum(album); } } ); } private string title_; private string artist_; private int release_; private Album(string title, string artist, int release) { this.title_ = title; this.artist_ = artist; this.release_ = release; } public string Title { get { return title_; } } public string Artist { get { return artist_; } } public int Release { get { return release_; } } } }
ようやく各ページのコードビハインドコードまでたどり着いた。
using System; using System.Web; using System.Web.UI; using FrontControllerSample.Web.Model; namespace FrontControllerSample.Web { public partial class ArtistPage : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { if (!IsPostBack) PrepareUI(); } private void PrepareUI() { Artist artist = Context.Items["artist"] as Artist; if (artist == null) return; nameLabel.Text = artist.Name; albumsGridView.DataSource = artist.GetAlbums(); albumsGridView.DataBind(); } } public partial class AlbumPage : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { if (!IsPostBack) PrepareUI(); } private void PrepareUI() { Album album = Context.Items["album"] as Album; if (album == null) return; titleLabel.Text = album.Title; artistHyperLink.Text = album.Artist; artistHyperLink.NavigateUrl = string.Format("Default.aspx?name={0}&command=Artist", album.Artist); releaseLabel.Text = album.Release.ToString(); } } }
リクエスト元となるDefaut.aspxのコードビハインドには何もない。
using System; using System.Web; using System.Web.UI; namespace FrontControllerSample.Web { public partial class _Default : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { } } }
さて、ここで問題となるのがテストだ。HTTPハンドラを使用したFrontControllerでは、ハンドラ、コマンド共にASP.NET ランタイムに依存しているのでユニットテストを行うのは大変だ。ここでは思い切ってSeleniumのカスタマーテストでテストを行うことにした。ユニットテストはできないが、テストが全くないよりはましだ。
まずは、ASP.NET開発サーバーを起動するための受け入れテストのベースクラス
using System; using System.Diagnostics; using NUnit.Framework; namespace FrontControllerSample.CustomerTests { public class CustomerTestBase { private Process process_; [TestFixtureSetUp] public void Cassiniの起動() { process_ = new Process(); string path = Environment.CurrentDirectory.Replace(@"FrontControllerSample.CustomerTests\bin\Debug", string.Empty); process_.StartInfo.FileName = @"C:\Windows\Microsoft.NET\Framework\v2.0.50727\WebDev.WebServer.EXE"; process_.StartInfo.Arguments = String.Format("/port:8081 /path:\"{0}FrontControllerSample.Web\"", path); process_.StartInfo.WindowStyle = ProcessWindowStyle.Minimized; process_.Start(); } [TestFixtureTearDown] public void Cassiniの終了() { process_.Kill(); } } }
続いて、FrontControllerが完全に機能していることを確認するためのカスタマーテストクラス
using System; using System.Text; using NUnit.Framework; using NUnit.Framework.SyntaxHelpers; using Selenium; namespace FrontControllerSample.CustomerTests { [TestFixture] public class FrontControllerTest : CustomerTestBase { private ISelenium selenium_; private StringBuilder verificationErrors_; [SetUp] public void Seleniumの開始() { selenium_ = new DefaultSelenium("localhost", 4444, "*iexplore", "http://localhost:4444"); selenium_.Start(); verificationErrors_ = new StringBuilder(); } [TearDown] public void Seleniumの終了() { try { selenium_.Stop(); } catch (Exception) { // Ignore errors if unable to close the browser } Assert.That(verificationErrors_.ToString(), Is.Empty); } [Test] public void アーティスト情報が表示されるべき() { selenium_.Open("http://localhost:8081/Default.aspx?name=Sade&command=Artist"); Assert.That(selenium_.GetTitle(), Is.EqualTo("アーティスト情報")); Assert.That(selenium_.GetText("nameLabel"), Is.EqualTo("Sade")); Assert.That(selenium_.GetText("link=Diamond Life"), Is.EqualTo("Diamond Life")); Assert.That(selenium_.GetText("link=Promise"), Is.EqualTo("Promise")); Assert.That(selenium_.GetText("link=Stronger Than Pride"), Is.EqualTo("Stronger Than Pride")); Assert.That(selenium_.GetText("link=Love Deluxe"), Is.EqualTo("Love Deluxe")); Assert.That(selenium_.GetText("link=Lovers Rock"), Is.EqualTo("Lovers Rock")); } [Test] public void アルバム詳細情報が表示されるべき() { selenium_.Open("http://localhost:8081/Default.aspx?title=Diamond Life&artist=Sade&command=Album"); Assert.That(selenium_.GetTitle(), Is.EqualTo("アルバム情報")); Assert.That(selenium_.GetText("titleLabel"), Is.EqualTo("Diamond Life")); Assert.That(selenium_.GetText("artistHyperLink"), Is.EqualTo("Sade")); Assert.That(selenium_.GetText("releaseLabel"), Is.EqualTo("1984")); } [Test] public void 存在しないコマンド名が渡された場合はエラーページに遷移されるべき() { selenium_.Open("http://localhost:8081/Default.aspx?name=Sade&command=Hoge"); Assert.That(selenium_.GetTitle(), Is.EqualTo("エラーページ")); Assert.That(selenium_.GetText("messageLabel"), Is.EqualTo("不明なコマンドが要求されました。")); } [Test] public void アーティスト情報ページのアルバム一覧のリンクからアルバム詳細情報ページに遷移できるべき() { selenium_.Open("http://localhost:8081/Default.aspx?name=Sade&command=Artist"); selenium_.Click("link=Diamond Life"); selenium_.WaitForPageToLoad("30000"); Assert.That(selenium_.GetTitle(), Is.EqualTo("アルバム情報")); Assert.That(selenium_.GetText("titleLabel"), Is.EqualTo("Diamond Life")); } [Test] public void アルバム詳細情報ページのアルバム詳細情報のリンクからアーティスト情報ページに遷移できるべき() { selenium_.Open("http://localhost:8081/Default.aspx?title=Diamond Life&artist=Sade&command=Album"); selenium_.Click("artistHyperLink"); selenium_.WaitForPageToLoad("30000"); Assert.That(selenium_.GetTitle(), Is.EqualTo("アーティスト情報")); Assert.That(selenium_.GetText("nameLabel"), Is.EqualTo("Sade")); } } }
Selenium Remote Controlを動作させるためのバッチはこうなる。
cd C:\Program Files\selenium-remote-control-0.9.2\selenium-server-0.9.2 java -jar selenium-server.jar -interactive pause