Достаточно продолжительное время при разработке веб-сайтов для своих клиентов я использовал собственную несложную CMS (Платформус). Написана она была на ASP.NET+MVC и имела закрытый исходный код. С появлением первой беты новой ASP.NET 5 я решил переписать свою систему на этой технологии, чтобы сделать ее кроссплатформенной и, в конечном итоге, выложить на GitHub. Т. к. технология очень новая, информации по этому вопросу практически не было, поэтому решение некоторых проблем удалось найти либо случайно, либо в процессе изучения исходных кодов самой ASP.NET 5.
Для упрощения я подготовил и также выложил на 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, спасибо им за терпение и внимание.
Для упрощения я подготовил и также выложил на 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, спасибо им за терпение и внимание.