company_banner

Готовим ASP.NET Core: подробнее про работу с модульным фреймворком

    Мы продолжаем нашу колонку по теме ASP.NET Core очередной публикацией от Дмитрия Сикорского ( DmitrySikorsky) — руководителя компании «Юбрейнианс» из Украины. В этот раз Дмитрий продолжает рассказ о своем опыте разработки модульного кроссплатформенного фреймворка на базе ASP.NET Core. Предыдущие статьи из колонки всегда можно прочитать по ссылке #aspnetcolumn — Владимир Юнев
    В предыдущей статье я уже рассказывал об ExtCore — небольшом фреймворке для разработки модульных и расширяемых приложений на ASP.NET Core. В этой статье я постараюсь более подробно остановится на процессе разработки приложения на его основе.

    Основное приложение


    Первым делом создадим новый пустой проект на ASP.NET Core 1.0:



    В результате мы получим готовый к использованию проект. Осталось только удалить файл Project_Readme.html. Теперь наш обозреватель решений должен выглядеть примерно следующим образом:



    aspnetcolumngithubСовет! Вы можете попробовать все самостоятельно или загрузив исходный код из GitHub https://github.com/ExtCore/ExtCore-Sample.
    Чтобы подключить к нашему проекту фреймворк ExtCore необходимо добавить ссылки на NuGet-пакеты ExtCore.Infrastructure и ExtCore.WebApplication в project.json. Также, т. к. в этом примере мы будем работать с базой данных, добавим туда ссылки и на компоненты расширения ExtCore.Data: (ExtCore.Data, ExtCore.Data.Abstractions, ExtCore.Data.EntityFramework.Sqlite, ExtCore.Data.Models.Abstractions). (Еще нам понадобятся ссылки на привычные для MVC-приложений пакеты, вроде Microsoft.AspNet.Mvc.) В итоге наш project.json должен иметь следующий вид:

    {
      "commands": {
        "web": "Microsoft.AspNet.Server.Kestrel"
      },
      "dependencies": {
        "EntityFramework.Sqlite": "7.0.0-rc1-final",
        "ExtCore.Data": "1.0.0-alpha7",
        "ExtCore.Data.Abstractions": "1.0.0-alpha7",
        "ExtCore.Data.EntityFramework.Sqlite": "1.0.0-alpha7",
        "ExtCore.Data.Models.Abstractions": "1.0.0-alpha7",
        "ExtCore.Infrastructure": "1.0.0-alpha7",
        "ExtCore.WebApplication": "1.0.0-alpha7",
        "Microsoft.AspNet.Diagnostics": "1.0.0-rc1-final",
        "Microsoft.AspNet.Diagnostics.Entity": "7.0.0-rc1-final",
        "Microsoft.AspNet.Mvc": "6.0.0-rc1-final",
        "Microsoft.AspNet.Mvc.TagHelpers": "6.0.0-rc1-final",
        "Microsoft.AspNet.Server.Kestrel": "1.0.0-rc1-final",
        "Microsoft.Extensions.Configuration.Abstractions": "1.0.0-rc1-final",
        "Microsoft.Extensions.Configuration.Json": "1.0.0-rc1-final",
        "Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0-rc1-final"
      },
      "exclude": [
        "wwwroot"
      ],
      "frameworks": {
        "dnx451": { },
        "dnxcore50": { }
      },
      "publishExclude": [
        "**.user",
        "**.vspscc"
      ],
      "version": "1.0.0-*",
      "webroot": "wwwroot"
    }
    

    Теперь осталось лишь унаследовать класс Startup от ExtCore.WebApplication.Startup:

    public class Startup : ExtCore.WebApplication.Startup
    {
      public Startup(IHostingEnvironment hostingEnvironment, IApplicationEnvironment applicationEnvironment, IAssemblyLoaderContainer assemblyLoaderContainer, IAssemblyLoadContextAccessor assemblyLoadContextAccessor, ILibraryManager libraryManager)
        : base(hostingEnvironment, applicationEnvironment, assemblyLoaderContainer, assemblyLoadContextAccessor, libraryManager)
      {
        IConfigurationBuilder configurationBuilder = new ConfigurationBuilder()
          .AddJsonFile("config.json");
    
        this.configurationRoot = configurationBuilder.Build();
      }
    
      public override void ConfigureServices(IServiceCollection services)
      {
        base.ConfigureServices(services);
      }
    
      public override void Configure(IApplicationBuilder applicationBuilder, IHostingEnvironment hostingEnvironment)
      {
        if (hostingEnvironment.IsEnvironment("Development"))
        {
          applicationBuilder.UseBrowserLink();
          applicationBuilder.UseDeveloperExceptionPage();
          applicationBuilder.UseDatabaseErrorPage();
        }
    
        else
        {
          applicationBuilder.UseExceptionHandler("/");
        }
    
        base.Configure(applicationBuilder, hostingEnvironment);
      }
    }
    

    В конструкторе класса Startup мы инициализируем переменную configurationRoot, определенную в базовом классе ExtCore.WebApplication.Startup. Это необходимо для предоставления фреймворку ExtCore доступа к параметрам конфигурации (в нашем случае единственным источником параметров конфигурации является файл config.json). Например, расширение ExtCore.Data таким образом получает параметр Data:DefaultConnection:ConnectionString (строку подключения к базе данных). Также можно конфигурировать и другие расширения (в т. ч. свои собственные).

    Давайте создадим файл config.json в корне проекта:

    {
      "Data": {
        "DefaultConnection": {
          "ConnectionString": "Data Source=../db.sqlite"
        }
      },
      "Extensions": {
        "Path": "artifacts\\bin\\Extensions"
      }
    }
    

    Параметр Extensions:Path определяет путь, по которому в файловой системе расположена папка с расширениями (относительно корня приложения).

    Вот и все, на этом моменте мы можем собрать и запустить наше приложение. Мы получим ошибку 404 и это будет правильно, т. к. у нас пока что нет ни маршрутов, ни контроллеров.

    Расширения


    Теперь давайте создадим 2 расширения. Первое расширение (ExtensionA) на своей единственной странице (главной странице нашего приложения) будет просто отображать список всех доступных расширений. Также в нем мы протестируем использование статического контента в виде ресурсов на примере CSS-файла. Второе расширение (ExtensionB) будет отображать записи, описанные моделью, из базы данных. Все просто.

    Расширение ExtensionA


    Создадим еще один проект WebApplication.ExtensionA (обратите внимание, что на этот раз это библиотека классов):


    Чтобы удобно разделять проекты в решении, относящиеся к различным расширениям, переместим наш проект в папку решения с названием ExtensionA, предварительно ее создав.
    Первым делом опять отредактируем project.json. Добавим ссылку на ExtCore.Infrastructure (содержит описание интерфейса IExtension; кроме того, ExtCore загружает и использует только те сборки, которые имеют ссылку на этот пакет) и на Microsoft.AspNet.Mvc. В этом расширении мы будем использовать представление и CSS-файл, добавленные в виде ресурсов (детальнее я описал это в предыдущей статье, на которую есть ссылка выше), поэтому необходимо также добавить соответствующую запись. Вот что должно получится:

    {
      "dependencies": {
        "ExtCore.Infrastructure": "1.0.0-alpha7",
        "Microsoft.AspNet.Mvc": "6.0.0-rc1-final"
      },
      "frameworks": {
        "dnx451": { },
        "dnxcore50": { }
      },
      "resource": [ "Styles/**", "Views/**" ],
      "version": "1.0.0-*"
    }
    

    Далее, реализуем интерфейс IExtension:

    public class ExtensionA : IExtension
    {
      private IConfigurationRoot configurationRoot;
    
      public string Name
      {
        get
        {
          return "Extension A";
        }
      }
    
      public void SetConfigurationRoot(IConfigurationRoot configurationRoot)
      {
        this.configurationRoot = configurationRoot;
      }
    
      public void ConfigureServices(IServiceCollection services)
      {
      }
    
      public void Configure(IApplicationBuilder applicationBuilder)
      {
      }
    
      public void RegisterRoutes(IRouteBuilder routeBuilder)
      {
        routeBuilder.MapRoute(name: "Extension A", template: "", defaults: new { controller = "ExtensionA", action = "Index" });
      }
    }
    

    В методе RegisterRoutes мы добавляем маршрут для главной страницы нашего приложения.
    Теперь добавим контроллер с единственным методом Index, который будет передавать представлению набор имен всех загруженных ExtCore расширений, для получения которых используется класс ExtensionManager:

    public class ExtensionAController : Controller
    {
      public ActionResult Index()
      {
        return this.View(ExtensionManager.Extensions.Select(e => e.Name));
      }
    }
    

    В свою очередь, представление отображает этот набор следующим образом:

    <ul>
      @foreach (var item in this.Model)
      {
        <li>@item</li>
      }
    </ul>
    

    Последнее, что необходимо сделать, это добавить файл стилей typography.css в папку Styles. Выше, в файле project.json, мы указали, что все содержимое папок Styles и Views будет добавлено в сборку в виде ресурсов. ExtCore обнаружит эти ресурсы и сделает возможным их использование аналогичным использованию физических файлов способом. Т. е. мы сможем подключить наш CSS-файл в любом расширении таким образом:

    <link href="Styles.typography.css" rel="stylesheet" />
    

    Следует лишь иметь в виду, что древовидная структура файловой системы превращается в «плоскую» структуру текстовых имен (регистр имеет значение!).

    Наше расширение ExtensionA готово. Чтобы протестировать его работу достаточно либо добавить ссылку на него в project.json основного приложения, либо собрать его в виде dll-файла и скопировать его в папку с расширениями (мы ранее указали ее в config.json).

    Расширение ExtensionB


    Здесь нам понадобится целых 4 новых проекта: WebApplication.ExtensionB, WebApplication.ExtensionB.Data.Abstractions, WebApplication.ExtensionB.Data.EntityFramework.Sqlite и WebApplication.ExtensionB.Data.Models. Как и в первом расширении, сгруппируем их в папке решения (с названием ExtensionB).

    WebApplication.ExtensionB
    В этом проекте мы разместим реализацию интерфейса IExtension, контроллер, модели видов и представления.
    Реализация интерфейса IExtension аналогична таковой из предыдущего расширения. Перейдем сразу к контроллеру:

    public class ExtensionBController : Controller
    {
      private IStorage storage;
    
      public ExtensionBController(IStorage storage)
      {
        this.storage = storage;
      }
    
      public ActionResult Index()
      {
        return this.View(new IndexViewModelBuilder().Build(this.storage.GetRepository<IItemRepository>().All()));
      }
    }
    

    Т. к. в этом расширении нам необходимо получать некие записи из базы данных, воспользуемся для этого возможностями расширения ExtCore.Data. В конструкторе контроллера запросим у встроенного в ASP.NET DI доступную реализацию интерфейса IStorage (которую раннее обнаружило и зарегистрировало расширение ExtCore.Data). Далее, запросим уже собственную реализацию собственного интерфейса IItemRepository для конкретного хранилища (в нашем случае, это база данных SQLite) и вызовем метод All для получения всех записей. Далее, преобразуем модели из базы данных в модели видов для отображения в представлении.
    Вместо использование представлений в виде ресурсов, в этом расширении мы будем использовать предварительно скомпилированные представления. Для этого необходимо добавить класс RazorPreCompilation в папку /Compiler/PreProcess:

    public class RazorPreCompilation : RazorPreCompileModule
    {
      protected override bool EnablePreCompilation(BeforeCompileContext context) => true;
    }
    

    Это даст нам возможность использовать собственные (т. е. объявленные внутри нашего расширения) классы для моделей видов. (Более подробно о предварительно скомпилированных представлениях см. в предыдущей статье.)
    WebApplication.ExtensionB.Data.Abstractions
    Этот проект содержит интерфейс единственного репозитория для работы с моделями типа Item (см. ниже):

    public interface IItemRepository : IRepository
    {
      IEnumerable<Item> All();
    }
    

    В нашем примере интерфейс описывает всего лишь один метод для получения всех записей.
    WebApplication.ExtensionB.Data.EntityFramework.Sqlite
    В этом проекте мы реализуем интерфейс IItemRepository для конкретного хранилища — базы данных SQLite:

    public class ItemRepository : RepositoryBase<Item>, IItemRepository
    {
      public IEnumerable<Item> All()
      {
        return this.dbSet.OrderBy(i => i.Name);
      }
    }
    

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

    public class ModelRegistrar : IModelRegistrar
    {
      public void RegisterModels(ModelBuilder modelbuilder)
      {
        modelbuilder.Entity<Item>(etb =>
        {
          etb.HasKey(e => e.Id);
          etb.Property(e => e.Id);
          etb.ForSqliteToTable("Items");
        }
        );
      }
    }
    

    WebApplication.ExtensionB.Data.Models
    В этом проекте мы описываем нашу единственную модель — Item:

    public class Item : IEntity
    {
      public int Id { get; set; }
      public string Name { get; set; }
    }
    

    Каждая модель должна реализовать интерфейс ExtCore.Data.Models.Abstractions.IEntity.
    Протестируем работу нашего нового расширения точно так, как мы это делали с ExtensionA.

    Запуск и тестирование


    Наше приложение с двумя расширениями готово. Запустив его, мы должны увидеть нечто подобное:



    Выводы


    В настоящий момент мы (я и несколько заинтересовавшихся ребят) активно развиваем этот проект и на нем уже основано несколько других. Будем очень рады идеям, советам и критике. Спасибо!

    Ссылка на исходники: https://github.com/ExtCore/ExtCore-Sample.

    Авторам


    Друзья, если вам интересно поддержать колонку своим собственным материалом, то прошу написать мне на vyunev@microsoft.com для того чтобы обсудить все детали. Мы разыскиваем авторов, которые могут интересно рассказать про ASP.NET и другие темы.

    Об авторе


    Сикорский Дмитрий Александрович
    Компания «Юбрейнианс» (http://ubrainians.com/)
    Владелец, руководитель
    DmitrySikorsky
    Microsoft
    407.50
    Microsoft — мировой лидер в области ПО и ИТ-услуг
    Share post

    Similar posts

    Comments 11

      0
      Прикольный у вас фреймворк) напоминает initial commit какой-нибудь библиотеки.
      Честно, все эти плагинные системы с подгрузкой типов из dll, смотрятся в наше время не очень.
      Все будет жутко тормозить и возвращать на 10 лет назад с медленной загрузкой страниц и тому подобными вещами.
      У вас, как я понял CMS и без этого сложно, но у вас слишком шаблонный и стандартный путь, пройденный тысячами людей, зачем его идти в 1001 раз и зачем миру очередная CMS? Первая CMS на .NET, запускаемая в Linux?
      Раз уж вы работаете в этой области, то хотелось бы увидеть какой-то свежий взгляд на стандартные вещи, а не банальные репозитории к RDBMS и MVC-контроллеры: что-то типа масштабируемой NoSQL базы, которая в JSON хранит какие-то элементы и данные, а какой-нибудь изоморфный JS-фреймворк это рендерит (можно и на клиенте) и показывает и все это запускается одим файлом, тем более, что у вас на начальном этапе такой маневр есть, а у многих CMS он уже слишком далеко позади. Понятно, что ваша компания зарабатывает на разработке ПО, а не пишет для души, но… может быть…
      Я, конечно, CMS не занимаюсь, и могу ошибаться в возможности таких манипуляций, но реально хочется чего-то свеженького!
      Сайты ваши понравились.
        0
        Спасибо за комментарий! CMS делал не исходя из желания сделать "что-то свеженькое", а из-за необходимости именно в таком инструменте для работы. На предыдущей версии есть множество проектов, сейчас решил переписать, сделать модульно и кроссплатформенно, раз уж такая возможность (я имею в виду кроссплатформенность) появилась на моей любимой платформе .NET. Может быть это старомодно, но да, мне нравится, что ASP.NET-приложение работает на Linux и Mac. Даже без стороннего веб-сервера.
        Что касается фреймворка — он, кстати, позволяет загружать не только dll, но и другие штуки, вроде NuGet-пакетов или просто проектов в исходниках. Было бы интересно взглянуть, как можно было бы решить задачу иначе. Не видел пока что ничего подходящего. На счет скорости — "жуткого торможения" не наблюдаю, но без сомнения, некое падение производительности у модульного проекта по отношению к "цельному" конечно же будет. Но это цена, которую придется заплатить за гибкость и удобство. В общем, буду благодарен, если приведете больше конкретики. Спасибо!
        +1
        Что-то я ничего не понял. Зачем нужен ExtCore.WebApplication.Startup когда и так есть Startup класс?
        Зачем представления оформлять в виде расширений ExtensionA и ExtensionB?
        Хотель бы прочитать какое-то обоснование такой структуре.
        Из предыдущей статьи тоже непонятно. Там только как-то фрагментарно описано что надо делать вот так и так. А почему — непонятно.
          0
          На счет ExtCore.WebApplication.Startup. Этот класс необходим, чтобы отнаследовавшись от него получить весь необходимый функционал (поиск, подготовка расширений и прочее). Имена одинаковые для удобства (т. к. своим классом вы дополняете базовый). В своем классе вы можете не переопределять функции ConfigureServices и Configure, если добавить туда нечего. Т. е. это просто базовый класс.
          На счет структуры расширений. Да, как-то я упустил этот момент. Исхожу из следующих идей. Чтобы проекты одного расширения не выглядели разрознено, логично, чтобы названия проектов начинались названием расширения. Если расширение большое (как в случае с CMS), такие куски, как фронтенд и бекенд, лучше разделять на отдельные проекты. Так с ними удобнее работать, особенно в команде. Также, при необходимости можно подключить только фронтенд и не подключать бекенд. Что касается данных. Модели удобно выносить в отдельный проект, т. к. в больших расширениях (да и вообще, приложениях) ссылки на них могут быть нужны в различных проектах. Кроме того, иногда проекту нужны только описания моделей и не нужна работа с базой. Мне больше нравится, когда добавив ссылку на проект с моделями я не получу ничего лишнего (вроде реализации работы с БД). Абстракции репозиториев лежат также в отдельном проекте, т. к. именно через них производится вся работа с данными, без знания о конкретных реализациях для конкретных хранилищ. Ну и, соответственно, конкретные реализации лежат каждая в своем проекте, чтобы можно было использовать независимо ту или иную реализацию для того или иного хранилища. Также можно в любой момент добавить проек-реализацию нового хранилища и ничего не придется переделывать благодаря такой структуре.
          Т. е. если вам необходимо подключить расширение, вы подключаете основной проект и выбранную конкретную реализацию репозиториев (например, для SQLite, если расширение вообще работает с хранилищем). Возможно, эту идею хорошо проиллюстрирует эта ссылка.
          Буду рад идеям по улучшению этой структуры.
            0
            Благодарю за ответ. Становится понятнее. И хорошо было бы начать первую статью с подобного объяснения.
            А еще ДО него описать ЦЕЛЬ — что вы хотите построить.
            Ибо конечная цель определяет средства. И читателям неплохо бы сообщить вашу цель с самого начала. Тогда мы поймем почему вы делаете так и эдак.
            И картинка весьма помогает. Например
            clean architechture
            Моя идея: выделять сценарии использования — use cases — в отдельный проект. Я по возможности так делаю.
            Получается структура похоже как на картинке.
            Модель -> Репозетроий -> Сценарий -> Контроллер
            Преимущество: одни и теже сценарии можно использовать из разных контроллеров и даже из разных приложений (web, mobil app).
            На сценарии навешивать статистику, логи и т.д.
            А контроллеры — очень тонкие, только готовят данные для представлений.
            Я как-то писал пост в своем блоге про очень простую структуру MVC. Самый базовый минимум
            http://blog.chudinov.net/how-to-create-a-minimal-asp-net-mvc-application/
              0
              Насчет выделения цели — согласен, это правильная мысль. И картинки тоже. Все приходит с опытом.
              Насчет сценариев использования. Насколько я понимаю, это нечто вроде сервисного слоя? Т. е. контроллер обращается к сервису по какому-то методу предметной области, тот обращается к репозиториям (одному или нескольким), те, в свою очередь, уже к ORM и слою хранилища или источника данных. Правильно я понял? Если так, то я не использовал сервисный слой, чтобы не добавлять эту еще одну прослойку там, где в ней нет потребности. А объемные специфические задачи предметной области я обычно переношу на некие классы-хелперы, менеджеры и так далее.
                0
                Вот здесь это хорошо описано chsakell.com/2015/02/15/asp-net-mvc-solution-architecture-best-practices
                  0
                  Да. Но я об этом и говорю, взгляните на слой сервисов в этом примере. Он практически полностью состоит из методов вроде:
                  public Category GetCategory(int id)
                  {
                      var category = categorysRepository.GetById(id);
                      return category;
                  }

                  Т. е. в большинстве случаев, где нет какой-то действительно сложной предметной области, слой сервисов это, как по мне, лишняя прослойка, идентичная слою единицы работы с репозиториями по функциональности, но находящаяся над ним. Еще одна параллельная иерархия интерфейсов/реализаций.
                    0
                    Да!
                    Я в некоторых не сложных проектах упускаю слой сервисов. Но если сложная модель данных то в сервисах оперирую репозиториями чтоб получить нужное представление.
                    Это еще одна прослойка, но тогда контроллеры получаются очень тонкими.
                      0
                      Ну в общем да, на любителя. Мне нравится для достижения состояния "тонких контроллеров" использовать модели видов и их билдеры/мапперы. Сложные модели видов разбивать на более простые и вызывать билдеры по цепочке. Тогда методы в контроллерах тоже в одну строку.
                  0
                  Да всё поняли правильно.
                  Хелпер-классы и менеджеры заменяют слой сервисов. Тоже сойдёт.

          Only users with full accounts can post comments. Log in, please.