Для упрощения я подготовил и также выложил на GitHub специальное тестовое решение – AspNet5ModularApp. В основном, я буду опираться на него в этой статье, но также буду касаться некоторых приемов и идей, которые использовал в Платформусе (отчасти, в надежде получить по ним какие-либо замечания).
Решение основных задач
Если опустить специфические именно для разработки CMS вопросы, основной задачей для меня было (и до сих пор остается, кстати) грамотная модульная архитектура проекта.
Я решил, что основное веб-приложение не должно иметь в себе никаких контроллеров, представлений или ресурсов (скриптов, стилей, изображений и прочего), не должно знать о данных или способах их хранения, но зато должно знать о каталоге с расширениями (набором сборок). В таком случае, единственная его задача – найти, загрузить и проинициализировать все расширения.
Расширения, в свою очередь, должны реализовать некий общий интерфейс и могут содержать, по сути, все что угодно. (Но, т. к. практически каждое расширение в моем случае будет работать с данными, я ввел дополнительный уровень абстракции, описывающий этот механизм. Благодаря этому, все расширения могут работать с данными унифицировано и в едином контексте, что достаточно важно. Я подробно опишу это ниже.)
В большинстве случаев, расширения должны содержать в себе контроллеры и представления, поэтому первым делом я попытался заставить работать контроллер и представление из динамически загруженной сборки (т. е. из сборки, на которую нет явной ссылки в project.json основного веб-приложения и которая загружается из каталога с расширениями уже после его старта).
С контроллерами проблем не возникло. Достаточно просто реализовать интерфейс IAssemblyProvider, скопировать сборки из DefaultAssemblyProvider и добавить к ним те сборки, которые загружены динамически из каталога с расширениями (см. AspNet5ModularApp.ExtensionAssemblyProvider), а затем просто зарегистрировать новую реализацию в ConfigureServices (см. AspNet5ModularApp.Startup). Единственный момент: по умолчанию, MVC ищет контроллеры в сборках, которые ссылаются на что-то вроде Microsoft.AspNet.Mvc. Т. к. в моем случае почти все проекты ссылаются на эту сборку (это плохо, но ниже я описал, почему пока что это так), поиск происходит во всех из них, что, наверняка, негативно влияет на быстродействие, поэтому, в любом случае, это необходимо исправить (хотя, конечно, в нашем тестовом решении каких-либо проблем с производительностью не наблюдается).
С представлениями пришлось немного повозиться. Насколько я сейчас знаю, можно либо сделать представления ресурсами (добавив, например, строку «resource»: «Views/**» в project.json), либо использовать предварительную компиляцию представлений (добавив класс RazorPreCompilation, наследуемый от RazorPreCompileModule). Мне больше нравится второй вариант, т. к. в таком случае можно, во-первых, использовать представления, типизированные собственными типами (я имею в виду типы, определенные внутри сборки, которая содержит само представление – в случае с представлениями-ресурсами такие типы не будут найдены во время компиляции в рантайме, хотя, насколько я понял из ответа на свой вопрос на GitHub ASP.NET, эту проблему также возможно решить), а во-вторых, они просто не требуют компиляции в рантайме и поэтому загружаются быстрее при первом обращении. Ну и все ошибки в представлениях также становятся очевидными уже на этапе компиляции.
Основное веб-приложение нашего тестового решения AspNet5ModularApp одновременно поддерживает оба этих варианта, соответствующее поведение задается при помощи функций AddPrecompiledRazorViews и AddRazorOptions (см. Startup.cs).
Если для работы предварительно скомпилированных представлений достаточно просто установить содержащие их сборки, то с представлениями-ресурсами необходимо реализовать интерфейс IFileProvider (см. AspNet5ModularApp.CompositeFileProvider) и в качестве источников файлов указать основное веб-приложение (по умолчанию это именно так) и, дополнительно, все динамически загруженные сборки, содержащие представления. Кстати, ресурсами могут быть не только представления, но и скрипты, стили, изображения и так далее. После того, как содержащие их сборки будут добавлены в CompositeFileProvider, они будут доступны по тем путям, по которым они расположены в своих сборках.
ExtensionA иллюстрирует первый вариант (с представлениями-ресурсами), а ExtensionB – второй (с предварительно скомпилированными представлениями).
Тут необходимо также рассказать о паре проблем, решения которым я пока не нашел.
Во-первых, различные части ASP.NET 5 используют различные версии сборок System.*, поэтому, если в одном проекте подключить, например, Microsoft.AspNet.Mvc, а в другом попытаться подключить System.Runtime, то можно получить ошибку, что используются разные версии одной и той же библиотеки. На данный момент AspNet5ModularApp построено на базе 8-й беты ASP.NET 5, поэтому я думаю, что к релизу это будет исправлено. Пока же я просто прописал Microsoft.AspNet.Mvc во всех проектах (кроме тех, которые работают с Entity Framework – там достаточно самого Entity Framework), чтобы получить одинаковый набор зависимостей. Согласен, это очень плохо, но это позволило не тратить время на мелочи.
Вторая (более серьезная, пожалуй) проблема заключается в том, что я копирую сборки расширений в каталог с расширениями и, если сборка использует что-то, чего не использует основное веб-приложение, я вынужден скопировать туда также соответствующие зависимости. Например, мне пришлось поместить в каталог с расширениями System.Reflection.dll и System.Reflection.TypeExtensions.dll (иначе я получаю исключение при попытке загрузить сборки, имеющие указанные выше зависимости). Но хуже всего то, что я так и не смог подобрать такой набор сборок, какой бы позволил заработать EntityFramework.Sqlite. Соответственно, мне пришлось включить явную ссылку на EntityFramework.Sqlite в project.json главного приложения (а я писал выше, что не хочу, чтобы оно вообще знало о данных, не говоря уже о конкретной реализации), что меня очень раздражает. (Кстати, все будет нормально, если зарегистрировать лежащие в .dnx сборки в GAC, но, как мне кажется, это неправильно.)
Далее, я начал разбираться с данными и их хранением. Я хотел, чтобы расширения могли работать с различными источниками данных, и чтобы для определения конкретной реализации достаточно было просто скопировав необходимые сборки в каталог с расширениями.
В проекте AspNet5ModularApp.Models.Abstractions я определил базовый интерфейс для модели – IEntity. В проекте AspNet5ModularApp.Data.Abstractions я определил 3 базовых интерфейса – IStorageContext, IStorage и IRepository. Их назначение лучше всего иллюстрирует проект AspNet5ModularApp.Data.EF.Sqlite, который содержит реализации этих интерфейсов для работы с базой данных Sqlite с помощью Entity Framework 7. Также, этот проект определяет интерфейс IModelRegistrar, позволяющий расширениям регистрировать их модели в едином контексте (см. AspNet5ModularApp.Data.EF.Sqlite.StorageContext.OnModelCreating).
Общий принцип работы следующий. Расширение может состоять из нескольких проектов: проект с контроллерами и представлениями, проект с моделями, проект с абстракциями репозиториев для работы с источником данных (по одному на каждую модель) и проект с реализациями этих абстракций (по одному проекту на каждый источник данных). Проект с контроллерами и представлениями знает о моделях и абстракциях репозиториев, но не знает об их реализациях для конкретных источников данных. Таким образом, в конструкторе контроллера можно попросить встроенный в ASP.NET 5 DI предоставить доступный экземпляр IStorage и, далее, запросить из него доступную реализацию некого репозитория по его интерфейсу. (Само собой, чтобы встроенный DI нашел доступную реализацию IStorage, ему необходимо о ней рассказать, что и делается в AspNet5ModularApp.ExtensionB.ExtensionB.ConfigureServices. Чтобы не делать это в каждом расширении, в Платформусе я вынес общий функционал в отдельное расширение – Barebone.)
Результат
Что получилось в результате. Если закрыть глаза на те проблемы с зависимостями, о которых я писал и которые, скорее всего, будут вскоре решены, то, на мой взгляд, я нашел ответы на все свои вопросы. Я могу расширять возможности приложения просто копируя сборки в каталог с расширениями, расширения могут иметь строго типизированные представления и единый контекст для работы с источником данных. Не представляет труда сделать возможным добавление и удаление расширения прямо в процессе работы приложения, если это необходимо. Также можно значительно повысить производительность, добавив кеширование в механизмы поиска типов.
Буду рад комментариям и замечаниям, также буду рад разъяснить те моменты, которые мне не удалось хорошо изложить в статье.
Ссылки и благодарности
Ссылка на AspNet5ModularApp.
Я хотел бы поблагодарить пользователя GitHub github.com/leo9223, который очень помог мне, показав вот этот проект. Этот проект, в свою очередь, помог мне разобраться с представлениями-ресурсами, хотя я так и не смог заставить его работать. Сейчас я уже знаю, почему. Создавая EmbeddedFileProvider из сборок с расширениями там не были указаны базовые пространства имен, поэтому представления не могли быть найдены. Я обнаружил подсказку тут. Также мне помогли ответы на мои вопросы на GitHub разработчикам ASP.NET, спасибо им за терпение и внимание.
Комментарии (9)
Weageoo
20.11.2015 16:40Как по мне, так интерфейс(ы) доступа к данным должно реализовать основное приложение (ядро), а расширение уже должно использовать его. Но если хочется логику работы данных отделить, то да, нужно выделить специальный тип расширений — Data Providers. Хотя по сути оно будет одно — как у вас и вышло. Поэтому скорее это не расширение, а просто отдельная либа, которая реализует логику доступа к данным, и которую достаточно загрузить статически, вместе с запуском ядра.
Вообще для реализации системы расширений (плагинов) в .NET есть такая штука, как MEF2 (Microsoft.Composition).DmitrySikorsky
20.11.2015 17:47Я считаю, что неправильно складывать и контроллеры, и классы, взаимодействующие с хранилищем, в один проект. В крупном проекте это приведет к путанице и значительно усложнит командную работу. Также мне нравится, что даже если на самом низком уровне нет поддержки определенного хранилища, можно легко ее добавить, взяв за основу (в нашем упрощенном случае) AspNet5ModularApp.Data.EF.Sqlite и просто добавив еще один проект, не затрагивая ничего больше. (Например, в Платформусе я использую и SQLite, и MS SQL Server.)
Согласен, что работа с данными это часть функций ядра приложения. По сути, AspNet5ModularApp.Models.Abstractions, AspNet5ModularApp.Data.Abstractions и AspNet5ModularApp.Data.EF.Sqlite и есть частью ядра, которую используют все расширения, которым необходима работа с данными.
Что касается MEF – мне очень понравилась эта штука, я как раз построил предыдущий Платформус на ее базе. В новой версии использовать ее не стал. Насколько я понял, она не поддерживает dnxcore50 (по крайней мере, не поддерживала на тот момент, когда я с этим разбирался), а для меня это крайне важно. Кроме того, в ASP.NET 5 уже встроен DI, работать с которым удобно.
Weageoo
20.11.2015 17:25System.ComponentModel.Composition.* which has shipped with .NET 4.0 and higher and Silverlight 4. This provides the standard extension model that has been used in Visual Studio.
System.Compostion.*_ is a lightweight version of MEF, which has been optimized for static composition scenarios and provides faster compositions. It is also the only version of MEF that is as a portable class library and can be used on phone, store, desktop and web applications.
Кроме того, MEF — это не типичный IoC-контейнер. Некоторые IoC-контейнеры даже интегрируются с MEF (Autofac, Unity). Кто-то успешно юзает MEF для реализации системы, подобной той, что описывает автор статьи. Да, в контексте ASP NET 5 не пробовал, не знаю. Вот попробую и расскажу.DmitrySikorsky
20.11.2015 17:52По сути, MEF делает за вас поиск сборок, извлечение из них типов и создание экземпляров объектов, поэтому это действительно больше, чем просто DI. Но все-таки для создания модульного веб-приложения этого недостаточно. Необходимо определиться, как подключать контроллеры и представления, как работать с данными и так далее. Так что это всего лишь инструмент, а не решение. Думаю, если задействовать его (MEF) в нашем тестовом примере, он не слишком изменится архитектурно.
Razaz
20.11.2015 17:53Если бы вы заглянули в код, то поняли бы что MEF для vNext бесполезен, так как вы оперируете не только сборками, а еще и пакетами, причем пакет может быть в виде исходного кода, тогда на него можно натравить Рослин. Бриджи делаются по определенным причинам. И для веба по большей части не нужны(ну кроме случаев когда кто то решил нарисовать весь свой код на MEF И прибил это гврздями). Ну и статическая композиция тут ни к месту :)
Так как мы говорим именно в контексте vNext — то вот что предлагается использовать авторами.
А по вашей ссылке на DNF:
MEF can be used for third-party plugin extensibility, or it can bring the benefits of a loosely-coupled plugin-like architecture to regular applications.
Razaz
20.11.2015 18:05Этот «успешный пример» кстати тихий ужас:
[Export("Plugin1", typeof(IController))] [PartCreationPolicy(CreationPolicy.NonShared)]
В каждом контроллере такое прописать?
А как быть с Unit of Work?
Если брать текущий стек то вот здесь на Autofac просто неистовая магия сделана. Особенно в плане модульности.
Razaz
Могу еще порекомендовать глянуть тут Orchard2
А за статью спасибо. То же сейчас исследую этот вопрос.