Friday, May 25, 2012

История интеграции ASP.NET WebForms и MVC

В этом посте хочу рассказать об использовании ASP.NET MVC в уже работающем WebForms приложении. Прежде чем начать делать что-то подобное, надо ответить на вопрос «Зачем?».

Кончено, зачастую нами движет желание попробовать что-то новое в работе — испробовать какую-то новую блестящую технологию, которая в сто раз лучше старой. Это субъективный фактор, за который цепляются все остальные. Загоревшись подобной идеей, мы начинаем собирать факты «за» и стараемся не обращать внимание на «против». Хочу лишний раз предостеречь от необдуманных шагов и тем более от сценария «всё старое выкидываем и переписываем заново».

Объективно веб-формы имеют своё право на жизнь и обладают рядом преимуществ, например с ними очень быстро можно сделать веб-форму, но, скажем, реализация AJAX через UpdatePanel выглядит как надругательство над всем разумом. Поэтому первое, что было решено: постепенно менять все места, где используется AJAX на MVC и разрабатывать новый функционал тоже на MVC. Отлаженные веб-формы было решено не трогать, но подвергнуть рефакторингу.

Разработчики, использующие ASP.NET MVC стараются использовать лучшие практики программирования, и основная прелесть этой технологии как раз в том, что она подталкивает разработчика делать это изначально: разделять логику частей приложения, инвертировать управление, писать юнит-тесты. Всё это, конечно, можно делать и в веб-формах, однако, в  тех приложениях, что попадались мне на глаза ничего этого сделано не было, поэтому пришлось немало потрудиться, чтобы внедрить в них элементы MVC Framework: использовать роутинг, изолировать работу с сессией, контекстом, сервисами через их интерфейсы , а разрешать зависимости при помощи IoC контейнера ( в нашем случае использовался Unity).

Уже довольно много написано о том, как скрестить MVC и WebForms. Напишу о своём опыте: что же всё-таки было сделано, а главное — как.


1. Роутинг

Все страницы приложения связаны ссылками. Первое, что необходимо сделать — избавиться от всех захардкоженных ссылок на веб-формах и создать единый механизм роутинга. Для того, чтобы заставить механизм роутинга MVC вызывать страницы aspx, необходимо создать кастомный IRouteHandler.

public class AspxPageRouteHandler<T>: IRouteHandler where T : IHttpHandler
{
 private readonly string _virtualPath;

 public AspxPageRouteHandler(string virtualPath)
 {
  _virtualPath = virtualPath;
 }

 public IHttpHandler GetHttpHandler(RequestContext requestContext)
 {
  var page = BuildManager.CreateInstanceFromVirtualPath(_virtualPath, typeof(Page)) as Page;
  if (page != null)
  {
    // Да, это не очень круто, но придётся сделать синглтон из контейнера
     DependencyResolver.Current.BuildUp(page.GetType(), page);
     return page;
  }
  return null;
 }
}

Теперь можно прописывать роутинг в Global.asax. Я вынес это в отдельный класс-хелпер, занимающийся роутингом.

public const string ORDER_PAGE_ROUTE_NAME = "ORDER_PAGE_ROUTE_NAME";

public static void RegisterRoutes(RouteCollection routes)
{
    // .. Прописываем сначала MVC роутинг
    routes.Add(ORDER_PAGE_ROUTE_NAME, CreateRouteToAspx("orders/newOrder"));
    // .. other routes
    // Default route
    routes.MapRoute(
         "Default",
          "{controller}/{action}/{id}",
          new { controller = "Home", action = "Index", id = UrlParameter.Optional });
}

private static Route CreateRouteToAspx(string urlPath) where T : IHttpHandler
{
    var noControllerDefaults = new RouteValueDictionary { { "controller", null }, { "action", null } };
    return new Route(urlPath, noControllerDefaults, new AspxPageRouteHandler(string.Format("~/{0}.aspx", urlPath )));
}

Это позволит использовать механизм роутинга только в одну сторону: определение хендлера по URL. Для того чтобы генерить URL во View, можно написать следующий хелпер:

public static string GetOrderFormLink(this UrlHelper helper)
{
    return helper.RouteUrl(ORDER_PAGE_ROUTE_NAME, new RouteValueDictionary { { "action", "Index" } });
}

Мой совет использовать такие хелперы везде, даже в MVC представлениях, вместо генерирования адреса по имени контроллера и акшена, правда с введением типизированной генерации путей, проблема хардкоженных имён несколько сглаживается.

2. Dependency Injection

Следующий шаг — прикручивание IoC контейнера для инъектирования зависимостей страницы от внешних сервисов. Необходимо учесть, что решение использовать контейнер должно быть обдумано и обосновано. Стоит это делать если планируется тестировать страницы и контроллеры, а также если реализаций сервисов будет несколько.

К сожалению, внедрить зависимости через конструктор не получится, но можно создать кастомный httpHandler и инъектировать зависимости в свойства после создания страницы:

public class PageHandlerFactory : PageHandlerFactory
{
    public override IHttpHandler GetHandler(HttpContext context, string requestType, string virtualPath, string path)
    {
        var page = base.GetHandler(context, requestType, virtualPath, path) as Page;
        if (page == null)
            return null;

        DependencyResolver.Current.BuildUp(page.GetType(), page);
        return page;
    }
}

2.1 IocPage

Будет полезно создать базовый Page-класс, поддерживающий IoC.

public abstract class BaseWebPage : Page
{
    [Dependency]
    public ISomeService CommonService { get; set; }

    [Dependency]
    public IHttpContext PageContext { get; set; }
    // ..
}

2.2 IocControl

Вместе с ним, базовый Control-класс, поддерживающий IoC.

public class IocControl : UserControl
{
    protected override void FrameworkInitialize()
    {
        base.FrameworkInitialize();
        DependencyResolver.Current.BuildUp(GetType(), this);
    }
}

3. Tests

Юнит-тестов для WebForm так и не было сделано, однако, есть способы, позволяющие их писать. Мне показалось довольно бессмысленным покрывать этими тестами уже существующие веб-формы. Несмотря на это, благодаря внедрению IoC, упростилось создание авто-тестов для интерфейса (напрмиер, с помощью Selenium), поскольку довольно просто заменить весь слой доступа к данным (Data Access Layer) на стабы или фейки.


No comments:

Post a Comment