Проблемы понимания MVC в ASP.NET MVC и не только

в 7:30, , рубрики: .net, adr, ASP, ASP.NET, asp.net mvc, mvc, архитектура приложений, Веб-разработка, паттерны, Проектирование и рефакторинг, проектирование сайтов, Разработка веб-сайтов

Случалось ли вам делать рефакторинг "толстых" контроллеров? Приходилось ли создавать многоэтажные модели представлений? Добавлять в представление данные и переписывать при этом код контроллера? Казалось ли вам, что что-то идёт неправильно?

Причина в том, что многие MVC-фреймворки не вполне следуют шаблону MVC, а люди, использующие их, сами того не замечая, ещё больше отклоняются от него. Казалось бы, он довольно прост, и описан в Википедии, но раз за разом возникают проблемы его понимания.

Взгляните на это классическое действие "толстого" контроллера:

public ActionResult AddComment(int articleId, string content)
{
    // бизнес-логика
    var article = ArticleRepository.Instance.GetById(articleId);

    if (article == null)
        return View("ArticleNotFound");

    var comment = new Comment
    {
        ArticleId = articleId,
        Content = content
    };

    CommentRepository.Instance.Add(comment);

    //формирование модели представления
    var viewModel = new ArticleViewModel
    {
        Article = article,
        Comments = CommentRepository.Instance.GetByArticleId(articleId)
    };

    return View("Article", viewModel);
}

И на этот шаблон представления:

@Model ArticleViewModel
<header><h1>@Model.Article.Title</h1></header>
<article>@Model.Article.Content</article>
<ul>
    @foreach (var comment in Model.Comments)
    {
        <li>@comment.Content</li>
    }
</ul>

Какие здесь есть проблемы?

  • Конечно, бизнес логика в контроллере. Многие с ней умеют бороться, и выносят её с переменным успехом в сервисы предметной области. Здесь обычно людей останавливает проблема понимания термина Model в MVC.
  • Зависимость кода контроллера от того, что кроме самой статьи мы хотим отобразить на её странице. Представление беспечно: оно полностью уповает на то, что контроллер передаст ему все нужные данные. Это следствие непонимания термина View.

И такие ситуации возникают не только в проектах на ASP.NET MVC, а почти на любых MVC-фреймворках. Чтобы бороться с ними, нужно их понять.

Проблемы понимания MVC

Model — не схема БД, не сущность и не ORM, а модель предметной области

Очень часто моделью называют классы, отображающиеся на сущности схемы БД. Однако в рамках MVC этот термин имеет другой смысл. Model — это модель предметной области, содержащая всю бизнес-логику приложения. В том числе сущности, различные сервисы, репозитории, фабрики и многое другое. Модель хранит состояние и может его изменять в зависимости от действий над ней.

Часть функционала модели (или весь) может быть даже реализована с помощью внешних сервисов, но с точки зрения контроллера и представления это не важно: модель предоставляет им API: классы данных и сервисы, имеющие методы для каждого действия, которое мы можем совершить с моделью. Модель может существовать в отрыве от нашего веб-приложения, и поэтому её обычно стоит реализовывать в виде одного или нескольких отдельных проектов.

В контексте нашего примера стоит создать класс, предоставляющий возможность управления статьями (в частности, добавление комментария) и, желательно, соответствующий интерфейс:

public interface IArticleService
{
    // Другие методы

    // Об успешности операции мы можем узнавать либо из исключений,
    // которые может выбросить этот метод, либо из возвращаемого им значения.
    void AddComment(string commentContent);
}

View — не анемичный шаблон, а активный инструмент представления

В веб-разработке часто под View понимается шаблон со специальной разметкой, куда должны быть подставлены данные, предоставленные ему контроллером. При этом он сам не обращается к модели предметной области и не получает оттуда данные. Это приводит к тому, что контроллер должен подготовить для него данные в специальном виде (это вынуждает его нарушать принцип единственной ответственности). А если нужно добавить какую-то информацию в шаблон, приходится менять свойства модели представления и код контроллера или провайдера модели представления (если таковой используется).

Модель представления при этом является обособленным классом, и наполняется не связанными по смыслу свойствами, совокупности которых нельзя даже дать связного названия, кроме как %PageName%ViewModel (ещё один плохой признак).

Но в описании MVC говорится, что представление имеет связь с моделью, и может запрашивать у неё данные, а некоторых случаях даже менять состояние модели! (Но от последнего, по моему мнению, по возможности лучше отказаться.)

Если следовать этому правилу, для передачи данных в представление не понадобится модель представления с большим количеством свойств. Скорее всего её вообще не будет, или это будет идентификатор необходимой сущности или сама сущность в крайнем случае. Остальные данные, связанные с ней, представление должно получить само через предоставленные через внедрение зависимости сервисы, принадлежащие Model.

Если многим представлениям приходится схожим образом обрабатывать данные перед показом, могут быть созданы другие классы, помогающие им в этом. Это могут быть не только "Helper"-классы. И, хоть они и не будут шаблонами разметки, они все равно будут относиться к слою View.

Controller — не место для бизнес-логики и подготовки данных для отображения

Контроллер, а в частности его действие (в веб-разработке) является конечной точкой маршрутизации запроса. Всё, что должно сделать действие, это:

  1. Выполнить валидацию запроса (опционально)
  2. Оповестить модель (если это необходимо), и получить ответ (тоже не всегда обязательно)
  3. Отобразить представление, передав ему только действительно важные данные от модели

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

Если придерживаться этого, контроллеры будут оставаться действительно тонкими, и вам не придется для этого делать уловки вроде %PageName%ViewModelProvider, чтобы собрать нужные данные для представления.

Внедрение зависимостей в ASP.NET MVC

Хорошо. Но для того, чтобы следовать всему этому, нужно внедрить зависимости в контроллеры и представления. Как это сделать в ASP.NET MVC? Широко распространена практика внедрения в контроллер через свойства. Эту возможность предоставляют многие библиотеки вроде Autofac или Ninject, но есть и другие варианты, которые следует знать.

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

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

  1. Используя Autofac или другой инструмент внедрения зависимостей с поддержкой этой функции.
  2. Написав свою собственную фабрику контроллеров, и использовав внутри неё любой инструмент для разрешения зависимостей.

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

В основной линейке фреймворков ASP.NET MVC нет возможности внедрить зависимости в конечный класс представления, так как этот класс не существует до тех пор, пока оно в первый раз не потребуется. В этот момент на лету будет разобран код шаблона, и создастся наследник класса WebViewPage c перегруженным методом Execute из одного из предков — WebPageExecutingBase, содержащим сложный код генерации ответа по шаблону.
Но есть возможность отнаследовать этот класс не от WebViewPage, а от собственного класса с помощью директивы @inherits в начале кода шаблона:

(новый класс-предок представления, файл можно разместить рядом с шаблоном)

using System.Web.Mvc;

namespace ASP
{
    // здесь я упростил параметр типа ArticleViewModel
    // до int-идентификатора статьи
    public abstract class ArticlePageBase: WebViewPage<int>
    {
        // Autofac не требует дополнительных атрибутов для внедрения,
        // но другие инструменты могут требовать.

        // Источник, из которого представление получит статью
        public IArticleRepository ArticleRepository { get; set; }
        // Источник, из которого представление получит комментарии к ней
        public ICommentRepository CommentRepository { get; set; }
        // Рекомендательный сервис, выдающий список статей,
        // имеющих сходство с текущей
        public IArticleRecomendationService RecomendationService { get; set; }
    }
}

(Razor-шаблон представления)

@inherits ArticlePageBase
@{
    // получение нужных данных из предоставленных сервисов
    var article = ArticleRepository.GetById(Model);
    var comments = CommentRepository.GetByArticleId(Model);
    var recommendedArticles = RecomendationService.GetRecomendations(Model);
}
@*Вывод полученных данных*@

К сожалению, здесь невозможно сделать внедрение через конструктор, так как генерируемый наследник не будет его использовать, что вызовет исключение во время выполнения. Но есть возможность внедрить зависимости через свойства. Это может сделать Autofac и, возможно, другие инструменты.

Логику получения и подготовки данных для отображения, если она слишком сложна, можно написать, перегрузив метод InitializePage предка представления, и сохранив полученные данные в защищённые поля, которые можно использовать в шаблоне:

public abstract class ArticlePageBase: WebViewPage<int>
{
    public IArticleRepository ArticleRepository { get; set; }

    // Свойство, используемое в шаблоне для отображения данных
    protected Article Article;

    protected override void InitializePage()
    {
        Article = ArticleRepository.GetById(Model);
    }
}

@inherits ArticlePageBase
<h1>@Article.Title</h1>

или прямо в шаблоне в блоке C#-кода (что на мой взгляд даже лучше, если нет необходимости писать дополнительные методы для преобразования данных модели в подходящий формат):

@inherits ArticlePageBase
@{
    // получение нужных данных из предоставленных сервисов
    var article = ArticleRepository.Get(Model);
    var comments = CommentRepository.GetByArticleId(Model);
    var recommendedArticles = RecomendationService.GetRecomendations(Model);
}
<h1>@ArticleService.Get(Model).Name</h1>

<header><h1>@article.Title</h1></header>
<article>@article.Content</article>
<ul>
    @foreach (var comment in comments)
    {
        <li>@comment.Content</li>
    }
</ul>
<ul>
    @foreach (var recommendedArticle in recommendedArticles)
    {
        // Вывод ссылки на рекомендуемую статью
    }
</ul>

Так же в базовом классе этого представления можно описать несколько защищенных методов для обработки данных, которые можно потом использовать в шаблоне.

Внедрение зависимостей во View в ASP.NET MVC Core

Движок Razor в ASP.NET MVC Core позволяет внедрить зависимость с помощью директивы @inject в начале шаблона. На самом деле происходит всё то же создание свойства, но уже не в предке представления, а в самом его классе, генерируемом движком:

@model int
@inject IArticleRepository ArticleRepository
@{
    var article = ArticleRepository.GetById(Model)
}
<h1>@article.Title</h1>

Но для этого необходимо, чтобы этот интерфейс был зарегистрирован в методе ConfigureServices класса Startup:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    // Некоторые действия
    services.AddTransient<IArticleRepository, ArticleRepository>();
    // Некоторые действия
}

После всего этого

Используя эти техники, можно упростить код действия контроллера до следующего:

public ActionResult AddComment(int articleId, string content)
{
    // Вместо исключений можно анализировать
    // возвращаемое сервисом значение. Это не принципиально.
    try
    {
        ArticleService.AddComment(articleId, content);
    }
    catch (ArticleNotFoundException e)
    {
        return View("ArticleNotFound");
    }
    return View("Article", articleId);
}

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

Альтернатива MVC — ADR

Сам шаблон MVC в контексте веб-разработки тоже имеет некоторые недостатки. Обработчики запросов объединяются в виде методов-действий внутри одного класса-контроллера. Это ведёт к тому, что для того, чтобы совершить одно действие, нужно проинициализировать весь контроллер, внедрив в него зависимости, необходимые для всех его действий. Это тоже нарушение SRP. Так же часто возникает проблема правильной группировки действий по контроллерам.

Пол М. Джонс предложил альтернативный шаблон — ADR (реализованный на PHP), некое подобие которого можно встретить в других библиотеках. У этого шаблона есть преимущества перед MVC:

  • Замена классов-контроллеров с методами-действиями на отдельные классы-действия. Это дает несколько плюсов:
    • Нет необходимости внедрять все зависимости, необходимые для целой группы действий, достаточно лишь тех, которые нужны для конкретного действия.
    • Нет дилеммы, как сгруппировать действия в контроллеры: по иерархии страниц или по смыслу, так как действия — отдельные классы. Можно организовать произвольное дерево папок с такими классами.
    • Нет необходимости использовать Reflection-механизмы для поиска нужного метода и подстановки параметров. Все данные можно передавать через конструктор наряду с зависимостями. Таким образом у действия остается только один метод — "Исполнить", что приводит все действия в соответствие одному интерфейсу.
  • Responder более гибок, чем View в широко известных фреймворках: в зависимости от ситуации он может выбирать, какой шаблон использовать, или вообще ответить в виде XML или JSON или любом другом формате.

Остается ждать или попробовать самим реализовать ADR-фреймворк на .NET.

Всем хорошего кода и тонких контроллеров!

Ссылки

MVC на Википедии

Принцип единственной ответственности (SRP) на Википедии

Внедрение зависимости в контроллер через конструктор с помощью Autofac

Внедрение зависимости в контроллер через конструктор с помощью Castle Windsor

Создание собственной фабрики контроллеров

Внедрение зависимости в представление через свойства с помощью Autofac

Внедрение зависимости в представление в ASP.NET MVC Core

Paul M. Jones об ADR

Статья про ADR на Хабрахабре

ADR на Википедии

Автор: Николай Лебедев

Источник

Поделиться

* - обязательные к заполнению поля