1 июня 2009 г.

Сравнение двух способов внесения зависимостей в проектах c ASP.NET MVC приложением

Первый способ внесения зависимостей я показал в прошлый раз. Сейчас хочу показать второй способ и сравнить оба подхода. На этот раз мы будем использовать модель push.

Решаем ту же самую задачу. Мы будем использовать стандартный механизм ASP.NET MVC, который называется фабрика контроллеров. Только прописывать все контроллеры мы, конечно, не станем.

   1:  // Файл - Global.asax.cs
   2:   
   3:  public class MvcApplication : NinjectHttpApplication
   4:  {
   5:      protected override void RegisterRoutes(RouteCollection routes)
   6:      {
   7:          // регистрируем маршруты
   8:      }
   9:   
  10:      protected override IKernel CreateKernel()
  11:      {
  12:          var module = new InlineModule(m => m.Bind<IProductRepository>().To<ProductRepository>());
  13:          var kernel = new StandardKernel(module, new AutoControllerModule(Assembly.GetExecutingAssembly()));
  14:          return kernel;
  15:      }
  16:  }

В строке 12 мы выставляем зависимости для наших объектов инфраструктуры, сервисов и т.д. Код:

   12:  ... new AutoControllerModule(Assembly.GetExecutingAssembly())

автоматически собирает все контроллеры, которые есть в нашем ASP.NET MVC приложении.

Внесение зависимостей в контроллер продуктов будет реализовано через конструктор:

   1:  public class ProductController
   2:  {
   3:   
   4:      private readonly IProductRepository productRepository;
   5:   
   6:      public ProductController(IProductRepository productRepository)
   7:      {
   8:          this.productRepository = productRepository;
   9:      }
  10:   
  11:      public ActionResult ProductDetails(int productId)
  12:      {
  13:          Product product = productRepository.FindById(productId);
  14:          return View(product);
  15:      }
  16:  }

Как это тестировать? Первый вариант:

   1:  public class TestFixture 
   2:  {
   3:      protected readonly Mock<IProductRepository> mockProductRepository = new Mock<IProductRepository>();    
   4:  }
   5:   
   6:  public ProductControllerTests : TestFixture
   7:  {
   8:      [Fact]
   9:      public void FindProductByIdAndAddToViewModel()
  10:      {
  11:          mockProductRepository.Setup(m => m.FindById(1)).Returns(new Product {Id = 1});
  12:   
  13:          var controller = new ProductController(mockProductRepository.Object);
  14:          var result = (ViewResult)controller.ProductDetails(1);
  15:          var model = (Product) result.ViewData.Model;
  16:   
  17:          Assert.Equal(1, model.Id);
  18:      }
  19:  }

У этого варианта есть недостатки:

  1. Нам приходится перечислять все зависимости явно, создавая mock-объекты и передавая их в конструктор (или в свойство объекта, зависит от типа внесения зависимости).
  2. Если контроллер имеет несколько зависимостей, то перечислять в конструкторе придется все, либо передавать null. При изменении количества зависимостей, придется менять все вызовы этого конструктора.
  3. Если придется менять способ внесения зависимостей, например, использовать field injection, то на это уйдет много сил.

В нашем проекте мы этим вариантом не пользуемся из-за его неповоротливости. Вот более легкое и гибкое решение:

   1:  public class TestFixture 
   2:  {
   3:      private   IKernel kernel;
   4:      protected readonly Mock<IProductRepository> mockProductRepository = new Mock<IProductRepository>();    
   5:   
   6:      public TestFixture()
   7:      {
   8:          var module = new InlineModule(m => m.Bind<IProductRepository>().ToConstant(mockProductRepository.Object));
   9:          kernel = new StandardKernel(module, new AutoControllerModule(typeof (ProductController).Assembly));
  10:      }
  11:   
  12:      public TController CreateController<TController>() where TController : Controller
  13:      {
  14:          return kernel.Get<TController>();
  15:      }
  16:  }
  17:   
  18:  public ProductControllerTests : TestFixture
  19:  {
  20:      [Fact]
  21:      public void FindProductByIdAndAddToViewModel()
  22:      {
  23:          mockProductRepository.Setup(m => m.FindById(1)).Returns(new Product {Id = 1});
  24:   
  25:          var controller = CreateController<ProductController>();
  26:          var result = (ViewResult)controller.ProductDetails(1);
  27:          var model = (Product) result.ViewData.Model;
  28:   
  29:          Assert.Equal(1, model.Id);
  30:      }
  31:  }

С тестированием контроллера все понятно, но как нам протестировать само хранилище ProductRepository? ProductRepository использует IDataContext для доступа к данным. Раньше его реализация выглядела так:

   1:  public ProductRepository
   2:  {
   3:      public ProductEntity FindById(decimal id)
   4:      {
   5:          using (var context = Builder.Create<IDataContext>())
   6:          {
   7:              return context.Query<ProductEntity>()
   8:                      .Where(e => e.Id == id)
   9:                      .FirstOrDefault();
  10:          }
  11:      }
  12:  }

Куда теперь нам вынести создание IDataContext? Конечно, можно вносить зависимость в коструктор, но тогда придется еще писать деструктор, чтобы гарантировано уничтожать реализацию объекта IDataContext. А что если не все методы ProductRepository требуют доступ к базе? Зачем нам создавать объект доступа к базе в конструкторе, только потому что мы выбирали модел push для внесения зависимостей? По этим причинам мы оставили объект Builder в качестве обертки для IoC (в нашем случае - это Ninject)

Ну да ладно, с разработкой самого ASP.NET MVC проекта все понятно. Теперь шагнем немного дальше. У нас есть проект инфраструктуры. Там определены объекты для работы с Excel, почтой и т.д. Они используются в Web проекте, поэтому реализации интерфейсов IMailSender, IExcelConverter и других подставляют с помощью Ninject во время выполнения веб-приложения. Зависимости выставляются в Global.asax.cs, как было показано ранее на примере IProductRepository.

Встала обычная задача - автоматизировать действия приложения. Нужно каждую ночь запускать консольное приложение, которое в зависимости от добавленных продуктов за день рассылает письма пользователям.

Если бы наша система внесения зависимостей работала через Builder, то консольное приложение приняло бы вид:

   1:  public class App
   2:  {
   3:      public static void Main()
   4:      {
   5:          InitDependencies();
   6:   
   7:          // логика работы, вызов объектов консольного приложения, инфраструктуры и сервисов
   8:      }
   9:   
  10:      private static void InitDependencies()
  11:      {
  12:          var module = new InlineModule(m => m.Bind<IProductRepository>().To<ProductRepository>(),
  13:                            m => m.Bind<IMailService>().To<MailService>());
  14:          var kernel = new StandardKernel(module);
  15:          Builder.Init(kernel);
  16:      }
  17:  }

Такой способ дает единый механизм внесения зависимостей в любое приложение, которое мы создаем в нашем проекте (консольные приложение, сервис, ASP.NET приложение).

Но сейчас мы выбрали модель push. У нас все зависимости выставляются в конструкторах или через свойства. Теперь нет объекта Builder. Что делать?

Как обычно начнем с самого очевидного варианта:

   1:  public class App
   2:  {
   3:      public static IKernel kernel;
   4:   
   5:      public static void Main()
   6:      {
   7:          InitDependencies();
   8:   
   9:          // логика работы, вызов объектов консольного приложения, инфраструктуры и сервисов
  10:   
  11:          // создаем все объекты с помощью kernel
  12:          var productRepository = kernel.Get<IProductRepository>();
  13:      }
  14:   
  15:      private static void InitDependencies()
  16:      {
  17:          var module = new InlineModule(m => m.Bind<IProductRepository>().To<ProductRepository>(),
  18:                            m => m.Bind<IMailService>().To<MailService>());
  19:          kernel = new StandardKernel(module);
  20:      }
  21:  }

Этот вариант не подходит, т.к. мы привязываем свое консольное приложение к Ninject намертво. Вызовы kernel в коде обязательно выйдут боком. Поэтому и здесь мы выбираем реализацию с объетом Builder.

Лично мне нравится использовать единый механизм в виде объекта Builder, но сейчас в нашем проекте мы перешли на смешанный способ - фабрика контроллеров + Builder. Это связано с желанием минимизировать код в ASP.NET MVC приложении. Какой способ выберете вы зависит от вашего конкретного случая.

Спасибо Сане Зайцеву за продуктивное парное программирование во время решения этой проблемы.

5 комментариев:

  1. А Ninject вы используете который версии 2?

    Как то он очень стал на autofac похож :)

    ОтветитьУдалить
  2. @Andrey
    Ninject взяли прямо из http://ninject.googlecode.com/svn/trunk, на данный момент версия 1.5.0.158

    ОтветитьУдалить
  3. Ага, я немного напутал и все таки это старая версия. Сейчас разработка и последние исходники на github.

    Есть тема для следующего поста в блог: Почему ninject? с чем сравнивали? какие плюсы и минусы :)

    ОтветитьУдалить
  4. @Andrey
    Ninject выбрали по тому, что он очень простой в изучении, имеет понятный API и поддерживает ASP.NET MVC.
    Сравнивали серьезно только с StuctureMap. StuctureMap имеет довольно сложную структуру, хотя я не думаю, что есть какие-то препятствия на переход с Ninject на любой другой IoC контейнер.

    Из минусов можно отметить отсутствие настройки через *.config. Хотя для меня это плюс, потому что файл конфигурации не компилируется и можно ошибиться при указании зависимостей, особенно если приходится писать довольно много XML-кода.

    ОтветитьУдалить
  5. @Andrey
    вот это: http://github.com/enkari/ninject/tree/master ?

    ОтветитьУдалить