Архитектура ASP.NET MVC 5

124

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

История создания MVC

Термин модель-представление-контроллер (model-view-controller) используется с конца 70-х гг. прошлого столетия. Этот шаблон происходит из проекта Smalltalk, выполнявшегося в Xerox PARC, где он был задуман как способ организации ранних приложений с графическим пользовательским интерфейсом. Некоторые нюансы первоначального шаблона MVC были связаны с концепциями, специфичными для Smalltalk, такими как экраны и инструменты, но более широкие понятия по-прежнему применимы к приложениям - и особенно хорошо они подходят для веб-приложений.

Взаимодействие с приложением MVC осуществляется в соответствии с естественным циклом действий пользователя и обновлений представления, при котором предполагается, что представление не содержит информации о состоянии. Это прекрасно сочетается с запросами и ответами HTTP, которые лежат в основе веб-приложения.

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

Появление платформы Ruby on Rails привело к возобновлению всеобщего интереса к MVC, и он остается шаблоном реализации архитектуры MVC. С тех пор появилось много других инфраструктур MVC, и все они демонстрировали преимущества архитектуры MVC - разумеется, это относится и к ASP.NET MVC.

Особенности архитектурного шаблона MVC

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

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

Модели определяются также и тем, за что они не отвечают: модели не имеют дела с визуализацией пользовательских интерфейсов или обработкой запросов, т.к. это ответственность представлений и контроллеров.

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

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

Модель предметной области

Наиболее важной частью приложения MVC является модель предметной области. Эта модель создается за счет определения реальных объектов, операций и правил, существующих в данной отрасли или роде деятельности, которые должно поддерживать приложение; такая совокупность и называется предметной областью.

После этого создается программное представление предметной области - модель предметной области. Для целей ASP.NET MVC Framework модель предметной области - это набор типов C# (классов, структур и т.д.), которые все вместе называются типами предметной области. Операции из предметной области представляются методами, определенными в типах предметной области, а правила предметной области выражаются логикой, заключенной внутри этих методов или путем применения атрибутов C#.

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

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

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

Реализация MVC в ASP.NET

В инфраструктуре MVC контроллеры - это классы C#, обычно производные от класса System.Web.Mvc.Controller. Каждый открытый метод в классе, производном от Controller, является методом действия который посредством системы маршрутизации ASP.NET ассоциируется с конфигурируемым URL. Когда запрос отправляется по URL, связанному с методом действия, операторы в классе контроллера выполняются, чтобы провести некоторую операцию над моделью предметной области и затем выбрать представление для отображения клиенту.

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

Взаимодействия в приложении MVC

В инфраструктуре ASP.NET MVC Framework используется механизм визуализации - компонент, отвечающий за обработку представления с целью генерации ответа для браузера. В ранних версиях MVC применялся стандартный механизм визуализации ASP.NET, который обрабатывал ASPX-страницы с использованием модернизированной версии синтаксиса разметки Web Forms. В MVC 3 появился механизм визуализации Razor, усовершенствованный в версии MVC 4 (и оставшийся неизменным в версии MVC 5), в котором применяется совершенно другой синтаксис.

Среда Visual Studio обеспечивает поддержку средства IntelliSense для механизма Razor, упрощая внедрение и ответ на данные представления, передаваемые контроллером.

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

Сравнение MVC с другими шаблонами

Разумеется, MVC - далеко не единственный архитектурный шаблон программного обеспечения. Существует множество других шаблонов подобного рода, и некоторые из них являются или, во всяком случае, были чрезвычайно популярными. О шаблоне MVC можно узнать много интересного, сравнивая его с альтернативами. В последующих разделах будут кратко описаны разные подходы к структурированию приложения и приведено их сравнение с MVC. Некоторые шаблоны являются близкими вариациями на тему MVC, в то время как другие совершенно отличаются.

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

Шаблон интеллектуального пользовательского интерфейса

Одним из наиболее распространенных шаблонов проектирования является интеллектуальный пользовательский интерфейс (Smart UI). Большинству программистов на том или ином этапе своей профессиональной деятельности приходилось создавать приложение с интеллектуальным пользовательским интерфейсом. Если вы использовали Windows Forms, WPF или ASP.NET Web Forms, то сказанное относится и к вам.

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

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

Шаблон интеллектуального пользовательского интерфейса

Интеллектуальные пользовательские интерфейсы идеальны для простых проектов т.к. позволяют добиться неплохих результатов достаточно быстро (по сравнению с разработкой MVC, которая требует определенной предварительной подготовки и начальных затрат).

Интеллектуальные пользовательские интерфейсы также подходят для построения прототипов пользовательских интерфейсов. Их инструменты визуального конструирования могут оказаться действительно удобными хотя я всегда считал визуальный конструктор WPF в Visual Studio неуклюжим и непредсказуемым. Если вы обсуждаете с заказчиком требования к внешнему виду и потоку интерфейса, то какой-нибудь инструмент интеллектуального пользовательского интерфейса может стать быстрым и удобным способом для генерации и проверки различных идей.

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

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

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

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

Приложения с интеллектуальным пользовательским интерфейсом быстро и легко разрабатывать. Создатели компонентов и инструментов проектирования приложили немало усилий, чтобы сделать процесс разработки приятным занятием, поэтому даже самый неопытный программист за считанные часы может построить нечто, профессионально выглядящее и достаточно функциональное.

Наибольшая слабость приложений с интеллектуальным пользовательским интерфейсом - неэффективность сопровождения - не проявляется при мелких объемах разработки. Если вы создаете несложный инструмент для небольшой аудитории, то приложение с интеллектуальным пользовательским интерфейсом может оказаться идеальным решением. Просто при этом не гарантируется увеличение сложности, поддерживаемое приложением MVC.

Архитектура "модель-представление"

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

Улучшить эту ситуацию можно за счет применения архитектуры "модель-представление", которая выносит бизнес-логику в отдельную модель предметной области. При этом все данные, процессы и правила концентрируются в одной части приложения, как показано на рисунке ниже:

Архитектура модель-представление

Архитектура "модель-представление" - это значительное усовершенствование по сравнению с монолитным шаблоном интеллектуального пользовательского интерфейса.

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

Классическая трехуровневая архитектура

Чтобы решить проблемы, присущие архитектуре "модель-представление", трехслойная или трехуровневая модель отделяет код сохранения данных от модели предметной области и помещает его в новый компонент, который называется уровнем доступа к данным (data access layer - DAL). Эта архитектура показана на рисунке ниже:

Трехуровневая архитектура

Трехуровневая архитектура наиболее широко используемый шаблон для бизнес-приложений. Она не накладывает никаких ограничений на способ реализации пользовательского интерфейса и обеспечивает хорошее разделение ответственности, не будучи при этом излишне сложной.

Кроме того, ценой некоторых усилий уровень DAL может быть создан так, чтобы модульное тестирование проводилось сравнительно просто. Можно заметить очевидное сходство между классическим трехуровневым приложением и архитектурой MVC. Различие состоит в том, что когда уровень пользовательского интерфейса непосредственно связан с инфраструктурой графического пользовательского интерфейса типа "щелчок-событие" (такой как WPF или ASP.NET Web Forms), выполнение автоматизированных модульных тестов становится практически невозможным. А поскольку часть пользовательского интерфейса трехуровневого приложения может быть сложной, останется много кода, не поддающегося серьезному тестированию.

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

Создание слабо связанных компонентов

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

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

Понять этот подход поможет простой пример. Если бы мы создавали компонент по имени MyEmailSender, предназначенный для отправки сообщений электронной почты, то реализовали бы интерфейс под названием IEmailSender, в котором определены все открытые функции, необходимые для отправки электронной почты.

Тогда любой другой компонент приложения, которому требуется отправить сообщение электронной почты - например, вспомогательный класс сброса пароля PasswordResetHelper - может отправить сообщение, обращаясь только к методам этого интерфейса. Как видно на рисунке ниже, никакой прямой зависимости между PasswordResetHelper и MyEmailSender не существует.

Использование интерфейсов для разъединения компонентов

За счет ввода интерфейса IEmailSender мы гарантируем отсутствие какой-либо прямой зависимости между PasswordResetHelper и MyEmailSender. Компонент MyEmailSender можно было бы заменить другим поставщиком электронной почты или даже использовать имитированную реализацию в целях тестирования, не нуждаясь во внесении изменений в PasswordResetHelper.

Использование внедрения зависимостей

Интерфейсы помогают разъединять компоненты, но мы по-прежнему сталкиваемся с проблемой: язык C# не предоставляет встроенного способа для легкого создания объектов, которые реализуют интерфейсы, кроме создания экземпляра конкретного компонента с помощью ключевого слова new. В результате получается код, подобный приведенному ниже:

public interface IEmailSender {
        void SendEmail();
}
public class MyEmailSender : IEmailSender {
        public void SendEmail() { }
}

public class PasswordResetHelper
{
        public void ResetPassword()
        {
            IEmailSender mySender = new MyEmailSender();
            // ...вызов методов интерфейса для конфигурировании настроек электронной почты... 
            mySender.SendEmail();
        }
}

Это нарушает цель, заключающуюся в наличии возможности замены MyEmailSender без изменения вспомогательного класса PasswordResetHelper, и означает, что пройдена только часть пути к созданию слабо связанных компонентов. Класс PasswordResetHelper конфигурирует и отправляет сообщения электронной почты через интерфейс IEmailSender, но чтобы создать объект, который реализует этот интерфейс ему нужно создать экземпляр MyEmailSender. Мы еще больше ухудшили ситуацию, поскольку теперь PasswordResetHelper зависит от класса MyEmailSender и от интерфейса IEmailSender:

В конце концов, компоненты становятся тесно связанными

Нам требуется способ получения объектов, которые реализуют данный интерфейс без необходимости в непосредственном создании объекта. Решение этой проблемы называется внедрением зависимостей (dependency injection — DI) или инверсией управления (inversion of control — IoC).

DI — это шаблон проектирования, дополняющий процесс слабой связи. Приведенное далее описание внедрения зависимостей может показаться не совсем понятным, но поверьте — это важная концепция, которая является центральной для эффективной разработки приложений MVC и может вызывать немало путаницы.

Разрыв и объявление зависимостей

Шаблон DI состоит из двух частей. Для начала мы удаляем из компонента — в этом случае PasswordResetHelper — любые зависимости от конкретных классов. Это делается путем создания конструктора класса, который принимает реализации требуемых интерфейсов в качестве аргументов, как показано ниже:

public class PasswordResetHelper
{
        private IEmailSender emailSender;
        public PasswordResetHelper(IEmailSender emailSenderParam)
        {
            emailSender = emailSenderParam;
        }

        public void ResetPassword()
        {
            // ...вызов методов интерфейса для конфигурировании настроек электронной почты... 
            emailSender.SendEmail();
        }
}

Говорят, что конструктор класса PasswordResetHelper теперь объявляет зависимость от интерфейса IEmailSender, т.е. он не может быть создан и использоваться, пока не получит объект, который реализует интерфейс IEmailSender. В объявлении своей зависимости класс PasswordResetHelper больше не содержит каких-либо сведений о классе MyEmailSender, а зависит только от интерфейса IEmailSender. Выражаясь кратко, класс PasswordResetHelper больше ничего не знает и не заботится о том, как реализован интерфейс IEmailSender.

Внедрение зависимостей

Второй частью шаблона DI является внедрение зависимостей, объявленных классом PasswordResetHelper, когда создаются его экземпляры, отсюда и термин - внедрение зависимостей.

Все это на самом деле означает, что нужно решить, какой класс, реализующий интерфейс IEmailSender, будет использоваться, создать объект этого класса и затем передать готовый объект в качестве аргумента конструктору PasswordResetHelper.

Класс PasswordResetHelper объявляет свои зависимости в собственном конструкторе. Такой подход называется внедрением через конструктор. Зависимости можно было бы также объявить через открытое свойство - этот способ известен как внедрение через установщик.

Зависимости внедряются в PasswordResetHelper во время выполнения. Другими словами, экземпляр некоторого класса, реализующего интерфейс IEmailSender, будет создан и передан конструктору класса PasswordResetHelper во время создания его экземпляра. На этапе компиляции больше не существует никакой зависимости между PasswordResetHelper и любым классом, реализующим интерфейсы, от которых он зависит.

Поскольку с зависимостями приходится иметь дело во время выполнения, при запуске приложения можно решить, какие реализации интерфейсов должны применяться. Доступен выбор из множества поставщиков электронной почты или внедрение специальной имитированной реализации, предназначенной для тестирования. Внедрение зависимостей позволило обеспечить наличие отношений, к которым мы стремились.

Использование контейнера внедрения зависимостей

Проблема с зависимостями решена, но как создать экземпляр конкретной реализации интерфейса, не создавая зависимости в других частях приложения? Другими словами, по-прежнему необходимо иметь где-то в коде приложения операторы следующего вида:

IEmailSender sender = new MyEmailSender();
helper = new PasswordResetHelper(sender);

Ответ заключается в использовании контейнера внедрения зависимостей, который также называют контейнером инверсии управления (inversion of control - IoC). Он представляет собой компонент, действующий в качестве посредника между зависимостями, которые объявляет класс вроде PasswordResetHelper, и классами, которые могут применяться для распознавания этих зависимостей, такими как MyEmailSender.

Мы регистрируем в контейнере DI набор интерфейсов или абстрактных типов, которые использует приложение, и указываем, экземпляры каких классов реализации должны быть созданы для удовлетворения зависимостей. Таким образом, нам понадобится зарегистрировать в контейнере интерфейс IEmailSender и указать, что экземпляр класса MyEmailSender должен создаваться во всех случаях, когда требуется реализация IEmailSender.

Когда внутри приложения нужен объект PasswordResetHelper, его создание запрашивается у контейнера DI. Контейнеру DI известно, что класс PasswordResetHelper объявляет зависимость от интерфейса IEmailSender и что в качестве реализации этого интерфейса указан класс MyEmailSender. Контейнер DI объединяет эти две порции информации, создает объект MyEmailSender и затем применяет его как аргумент при создании объекта PasswordResetHelper, с которым впоследствии можно работать в приложении.

Важно отметить, что мы больше не создаем объекты в приложении самостоятельно с помощью ключевого слова new. Вместо этого мы обращаемся к контейнеру DI и запрашиваем необходимые объекты. Привыкание к такому подходу может занять некоторое время у новичков в DI, но, как вы увидите, инфраструктура MVC Framework предлагает ряд средств, помогающих упростить данный процесс.

Контейнер DI не придется создавать самостоятельно - доступно несколько прекрасных реализаций с открытым исходным кодом и свободно лицензируемых реализаций. Одной из них является реализация Ninject.

Роль контейнера DI может казаться простой и тривиальной, но на самом деле это не так. Хороший контейнер DI, такой как Ninject, обладает рядом удобных функциональных возможностей:

Распознавание цепочки зависимостей

В случае запроса компонента, который имеет собственные зависимости (например, параметры конструктора), контейнер удовлетворит и эти зависимости. Таким образом, если конструктор класса MyEmailSender требует реализацию интерфейса INetworkTransport, то контейнер DI создаст экземпляр стандартной реализации этого интерфейса, передаст ее конструктору MyEmailSender и возвратит результат в виде стандартной реализации IEmailSender.

Управление жизненным циклом объектов

Если компонент запрашивается более одного раза, должен ли каждый раз выдаваться один и тот же или совершенно новый экземпляр? Хороший контейнер DI позволяет конфигурировать жизненный цикл компонента, предоставляя возможность выбора из заранее определенных вариантов: единственный экземпляр (один и тот же экземпляр во всех случаях), кратковременный экземпляр (новый экземпляр в каждом случае), экземпляр на поток, экземпляр на HTTP-запрос, экземпляр из пула и т.д.

Конфигурирование значений параметров конструктора

Если реализация интерфейса INetworkTransport требует, к примеру, строку по имени serverName, должна существовать возможность установки ее значения в конфигурации контейнера DI. Это грубая, но простая система конфигурирования, которая избавляет код от необходимости передавать строки подключения, адреса серверов и т.п.

Написание собственного контейнера DI - великолепный способ понять, каким образом C# и .NET обрабатывает типы и рефлексию, и я рекомендую заняться таким проектом в дождливые выходные. Однако не поддавайтесь соблазну развертывать свой код в реальном проекте. Создание надежного, устойчивого и высокопроизводительного контейнера DI сопряжено с трудностями и для применения вы должны отдать предпочтение испытанному и протестированному пакету. Мне нравится Ninject, но есть множество других доступных пакетов, поэтому вы наверняка найдете такой, который окажется подходящим для вашего стиля разработки.

Пройди тесты
Лучший чат для C# программистов