Первая версия ASP.NET MVC появилась еще в 2009 году а первый перезапуск платформы (ASP.NET Core) начал поставляться с прошлого лета. На протяжении этого времени структура проекта по умолчанию осталась почти неизменной: папки для контроллеров, представлений (views) и часто для моделей (или, возможно, ViewModels). Такой подход называется Tech folders. После создания нового проекта ASP.NET Core MVC организационная структура папок имеет следующий вид:


В чем проблема со структурой папок по умолчанию?


Большие веб-приложения требуют лучшей организации чем маленькие. Когда есть большой проект, организационная структура папок, которую используется по умолчанию в ASP.NET MVC (и Core MVC), перестает работать на вас.

Tech folders имеет свои преимущества, вот некоторые из них:

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

Когда стартует новый проект, Tech folders работает достаточно хорошо, пока не большой функционал и нет много файлов. Как только проект начинает расти, становится довольно трудно искать нужный контроллер или View в большом количестве файлов.

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

Основной недостаток заключается в том, что группа файлов, организованная по типу а не по цели (features). И этим файлам не хватает связности (high cohesion). В типичном проекте ASP.NET MVC, контроллер будет связан с одним или более View (в папке, которая соответствует имени контроллера). Контроллер имеет связь с моделями (и / или ViewModels). Models / ViewModels будут использоваться в View и т.д. Для того, чтобы сделать изменения, придется искать нужные файлы по всему проекту.

Простой пример


Рассмотрим простой проект, в задачу которого входит управление четырьмя слабо связанными компонентами: User, Customer, Client и Payment. Организационная структура папок по умолчанию для этого проекта будет выглядеть примерно так:


Для того, чтобы добавить новое поле в модель Client, отразить его на View и добавить некоторые проверки перед сохранением, потребуется переместиться в папку Models, найти подходящую модель, затем перейти в Controllers и найти ClientController, дальше в папку Views. Даже только с четырьмя контроллерами можно заметить, что нужно делать много навигации по проекту. В основном проект включает в себя гораздо больше папок.

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

Это может быть реализовано несколькими путями. Мы можем использовать Areas, но по моему мнению они не решают главной проблемы, или создать свою собственную структура для папок с features.

Feature Folders в ASP.NET Core MVC


В последнее время большой популярностью пользуется новый подход в организации структуры папок для крупных проектов который называется Feature Folders. Это особенно актуально для команд используемых подход Vertical slice.

При организации проекта по features, создается, как правило, корневая папка (например, Features), в которой вы будете иметь вложенные папки для каждой из features. Это очень похоже на то, как организованы Areas. Однако, каждая папка с feature, будет включать в себя все необходимые контроллеры, View, ViewModel и т.д. В большинстве случаев в результате мы получим папку с, возможно, от 5 до 15 файлов, которые есть все тесно связаны друг с другом. Все содержимое папки feature легко держать в фокусе в Solution Explorer. Пример этой организации:


Преимущества использования Feature Folders:

  • в отличие от Areas, нам не нужны дополнительные роуты
  • уменьшается время на навигацию и поиск файлов по проекту
  • можно легко масштабировать и изменять независимо от других features
  • позволяет держать меньше открытых папок в Solution Explorer
  • дает понимание того, что именно делает приложение и какие файлы для этого нужны
  • дает нам возможность повторного использования feature в других проектах, путем простого копирования папки
  • в системе контроля версий можно посмотреть все изменения, которые касаются конкретной feature
  • повышает связность файлов

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


Для того чтобы реализовать такую организацию папок нужно иметь кастомную реализацию интерфейсов IViewLocationExpander и IControllerModelConvention. По конвеншену ожидается, что контроллер находится в namespace с названием «Features» и для следующего элемента в иерархии namespace после «Features», должно быть имя конкретного feature. Пример реализации IControllerModelConvention для поиска контроллеров:

FeatureConvention
public class FeatureConvention : IControllerModelConvention
{
    public void Apply(ControllerModel controller) 
    {
        controller.Properties.Add("feature", GetFeatureName(controller.ControllerType));
    }

    private static string GetFeatureName(TypeInfo controllerType) 
    {
        var tokens = controllerType.FullName.Split('.');
	if (tokens.All(t => t != "Features"))
	    return "";
        var featureName = tokens
	    .SkipWhile(t => !t.Equals("features", StringComparison.CurrentCultureIgnoreCase))
	    .Skip(1)
	    .Take(1)
	    .FirstOrDefault();

        return featureName;
    }
}


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

FeatureFoldersRazorViewEngine
public class FeatureFoldersRazorViewEngine : IViewLocationExpander
{
      public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, 
            IEnumerable<string> viewLocations) 
      {
            if (context == null) 
            {
	      throw new ArgumentNullException(nameof(context));
	}

	if (viewLocations == null) 
            {
	      throw new ArgumentNullException(nameof(viewLocations));
            }

	var controllerActionDescriptor = context.ActionContext.ActionDescriptor as ControllerActionDescriptor;
	if (controllerActionDescriptor == null) 
            {
	      throw new NullReferenceException("ControllerActionDescriptor cannot be null.");
	}

	string featureName = controllerActionDescriptor.Properties["feature"] as string;
	foreach (var location in viewLocations) 
            {
	      yield return location.Replace("{3}", featureName);
	}
      }

      public void PopulateValues(ViewLocationExpanderContext context) { }
}



Осталось только использовать реализации интерфейсов и добавить некоторые параметры в Startup классе:

Startup.cs
public void ConfigureServices(IServiceCollection services) 
{
      services.AddMvc(o => o.Conventions.Add(new FeatureConvention()))
            .AddRazorOptions(options =>
	{
	      // {0} - Action Name
	      // {1} - Controller Name
	      // {2} - Feature Name
	      // Replace normal view location entirely
	      options.ViewLocationFormats.Clear();
	      options.ViewLocationFormats.Add("/Features/{2}/{1}/{0}.cshtml");
	      options.ViewLocationFormats.Add("/Features/{2}/{0}.cshtml");
	      options.ViewLocationFormats.Add("/Features/Shared/{0}.cshtml");
	      options.ViewLocationExpanders.Add(new FeatureFoldersRazorViewEngine());
	});
}



А как насчет моделей?


Тут нужно сделать исключение для моей предыдущей структуры. В реальном мире, ваша модель предметной области будет гораздо сложнее. Традиционная трехслойная архитектура (data, business logic, presentation) до сих пор является одним из наиболее важных концепций для структурирования программного обеспечения. Важно понимать, что ASP.NET MVC не дает никакой встроеной поддержки для «моделей». ASP.NET MVC ориентирован на слой представления и не должен покрывать ответственность от других слоев. По этой причине, мы должны переместить файлы моделей (Client.cs, ClientAddress.cs, Customer.cs, Payment.cs, User.cs) в отдельную библиотеку.

Summary


Feature folders обеспечивает меньшую связанность (low coupling) и одновременно группирует связной код вместе (high cohesion). С этим подходом гораздо легче поддерживать структуру папок, что позволяет нам оставаться сосредоточенными и продуктивными при написании кода, а не тратить время на поиск файлов в разных папках. Единственным недостатком является то, что по умолчанию ASP.NET MVC не поддерживает структуру Feature folders, следовательно вам нужно настраивать ее вручную.

Спасибо за внимание!
Поделиться с друзьями
-->

Комментарии (23)


  1. Odrin
    08.02.2017 10:04
    +2

    Я бы добавил, что для действительно большого проекта может быть полезным еще большее ветвление по подпапкам:
    /feature/models/
    /feature/controllers/
    /feature/views/

    И models в mvc проекте все же нужны — отдавать клиенту dto, а не объекты бизнес логики, будет хорошим решением.


    1. perfectdaemon
      08.02.2017 10:29
      +4

      Такое ветвление уже имеет имя — Areas


  1. 0x1000000
    08.02.2017 10:16

    В MVC уже есть (были?) так называемые Areas, которые и служат для решения описанных в статье проблем.


    1. mayorovp
      08.02.2017 10:26
      +3

      Области (Areas) обладали еще и своей конфигурацией. Создавать по области на фичу — замаяться можно.


      Тут же приведено решение, когда конфигурация для всего проекта общая, только файлы перегруппированы.


      1. kakutuzov
        08.02.2017 13:26

        В areas Так же можно оставить конфигурацию по умолчанию и она будет использовать общую конфигурацию. странное это решение с features. И плюс в «увеличении связанности» не такой уж и плюс.


        1. MRomaV
          08.02.2017 13:27

          нє «увеличении связанности» а «увеличении связности»


        1. mayorovp
          08.02.2017 13:29

          А что если понадобится создать новую область со своей конфигурацией, в которой будет несколько фич? :)


  1. GreenBee
    08.02.2017 13:02
    -1

    Фактически сделано следующее:
    — Переименована папка Views в Features
    — Для каждого контроллера все что с ним связано перенесено в его папку в папке Views


  1. Sybe
    08.02.2017 13:27
    +3

    Поделитесь опытом о расположении в проекте объектов, связанных с DI-контейнером (например, профили Autofac), объектов, связанных с Automapper (профили), различных инфраструктурных вещей (ActionFilters, ModelBinders, Helpers, etc). Интересует структура и иерархия директорий.


  1. uskembayev
    08.02.2017 13:27
    +1

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


    1. Flaksirus
      08.02.2017 17:34

      Для aspnet.core есть ExtCore


    1. mayorovp
      08.02.2017 18:09

      В старом ASP.NET MVC это делалось вот так:


      <Target Name="GetModuleContent" DependsOnTargets="PipelineTransformPhase" Outputs="@(_Content)">
          <PropertyGroup>
              <ModuleName Condition="'$(ModuleName)'==''">$(AssemblyName)</ModuleName>
          </PropertyGroup>
          <ItemGroup>
              <_Content Remove="@(_Content)" />
              <_Content Include="@(FilesForPackagingFromProject)" Condition="'%(FilesForPackagingFromProject.FromTarget)' == 'CollectFilesFromContent'">
                  <DestinationRelativePath>Areas\$(ModuleName)\%(FilesForPackagingFromProject.DestinationRelativePath)</DestinationRelativePath>
              </_Content>
              <_Content Include="@(FilesForPackagingFromProject)" Condition="'%(FilesForPackagingFromProject.FromTarget)' != 'CollectFilesFromContent'">
                  <DestinationRelativePath>%(FilesForPackagingFromProject.DestinationRelativePath)</DestinationRelativePath>
              </_Content>
          </ItemGroup>
      </Target>
      
      <Target Name="CopyChildContent" BeforeTargets="CopyAllFilesToSingleFolderForMsdeploy">
          <ItemGroup>
              <_ChildContent Remove="@(_ChildContent)" />
          </ItemGroup>
      
          <MSBuild Projects="@(ProjectReference)" Targets="GetModuleContent" RebaseOutputs="true" Condition="'%(ProjectReference.CopyContent)'=='true'">
              <Output TaskParameter="TargetOutputs" ItemName="_ChildContent" />
          </MSBuild>
      
          <ItemGroup>
              <FilesForPackagingFromProject Include="@(_ChildContent)" Exclude="@(FilesForPackagingFromProject)" />
          </ItemGroup>
      </Target>

      Ну и для отладки в IIS надо еще виртуальный путь настроить.


  1. Sellec
    09.02.2017 10:57

    Я правильно понимаю, что описанные в статье вещи в старом MVC реализовывались именно через IControllerFactory (самостоятельный поиск класса контроллера и создание экземпляра) и собственный ResourceProvider с регистрацией в HostingEnvironment.RegisterVirtualPathProvider?


    1. mayorovp
      09.02.2017 14:08

      ResourceProvider не нужен, достаточно ControllerFactory.


      1. Sellec
        10.02.2017 09:05

        А MVC найдет шаблон Index.cshtml в папке /Modules/Help/Views/ для контроллера Help автоматом, если в соседней /Modules/Admin/Views есть свой Index.cshtml?


        1. mayorovp
          10.02.2017 09:44

          Нет. За это отвечает интерфейс IViewEngine. Надо унаследоваться от RazorViewEngine и перегрузить методы FindPartialView и FindView.


          А замена VirtualPathProvider для этих целей — из пушки по воробьям. Костылем.


          1. Sellec
            11.02.2017 11:36

            В VirtualPathProvider можно реализовать работу с кешем представления, как если, например, изменили вручную представление и надо его заново перекомпилировать. RazorViewEngine это позволит сделать?


            1. mayorovp
              11.02.2017 12:26

              Э… что-то я не понял как вы собрались перекомпилировать представление из VirtualPathProvider.


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


              1. Sellec
                11.02.2017 17:59

                Да, согласен.
                Просто есть такой класс как CacheDependency и его можно использовать, чтобы уведомить об изменении представления (например, я редактирую представления «на лету» на продакшене или во время отладки).
                А еще есть сильный минус в RazorViewEngine на первый взгляд. Сейчас на скорую руку попробовал использовать и сразу натолкнулся на проблему. На этапе разработки сайт представляет собой набор библиотек плюс основное приложение. БОльшая часть представлений во время компиляции копируется в конечную папку в bin в основное приложение. Но мне-то надо редактировать исходный вариант представления в рантайме. То есть мне надо редактировать не файл Projects/SiteMain/bin/Debug/Views/login.cshtml, а файл Projects/SiteAuthLibrary/Views/login.cshtml. А при попытке выдать путь выше каталога приложения я получаю ошибку, что нельзя указать такой путь.
                Конечно, может быть, есть варианты обхода такого поведения, но на данный момент это основное, что останавливает)


                1. mayorovp
                  11.02.2017 18:55

                  Я все еще не понимаю зачем вам иметь доступ к CacheDependency. ASP.NET сама создает этот класс в дефолтном провайдере — и сама же утилизирует его в билд-менеджере… Перекомпиляция измененных представлений вообще-то работает "из коробки" (пока вы не заменили VirtualPathProvider)!


                  Что же для путей к представлениям, хранящимся в других библиотеках — тут я согласен. VirtualPathProvider — один из возможных вариантов их подключения. Но я бы советовал вам подключить вручную виртуальный каталог в IIS — это позволит находиться в библиотеках еще и статике. Кроме того, это уберет из проекта отладочный код, который не нужен в релизе.


                  Ну и файлы из bin я бы посоветовал перенести в Areas :)


                  1. Sellec
                    12.02.2017 12:29

                    Для девелоперской машины все решил через автоматическое создание Junctions в папке основного приложения. Но вылезла очередная проблема — нет метода для поиска Layout, если он лежит не в папке с представлением. Вот это уже печалька.


  1. questpc
    14.02.2017 11:20

    Именно такая организация проекта когда views / models и прочее каждого модуля находится в отдельном поддереве (подкаталоге) и принята по-умолчанию в Django. Там правда это еще вытекает из самой организации модулей для Python, которые есть подкаталог с файлом __init__.py.

    До этого работал с Laravel, там по-умолчанию группировалось все controller в одном подкаталоге, все model в другом — приходилось чаще скакать по папкам. Так как все-таки в пределах одного модуля модель / контроллер / представление больше взаимосвязанны чем разные модели и контроллеры между собою.


  1. mrxten
    15.02.2017 09:36
    -2

    Мы пошли по иному пути. Все стандартные папки оставили как есть (Controllers, Views, etc), а все остальное (DAL, DTO, Tools, etc) вынесли в отдельные проекты.