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

もちろん、すべてのカスタマーテストが成功した。