2020-04-07
zelder
2020-04-08
07/04
2020

Конфигурация маршрутов в ASP.NET Core MVC.


Для чего
Наглядно попробовать создать свой "сервис", поработать с настройкой.
Опробовать инъекцию версий для своего сервиса (да и вообще инъекцией).
Работа с файлом конфигурации.

Задача
Привязать маршруты к действиям контроллеров с использованием уже реализованного Middleware (EndpointMiddlware).
Реализовать несколько версий привязок: из кода, из файла конфигурации.
Для простых сайтов может быть достаточно подцепить к атрибуту действия: [Route("myawesomeurl")]
Но в нашем случае это не покроет дальнейшей организации сайта.
При этом, реализация своего Middleware излишняя.

Требования
ASP.NET Core MVC 3.1
Реализация

Начало
Изначально (из шаблона) при конфигурации у нас есть "общая" привязка:
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
Нам необходимо в ней добавить свой сервис, который будет подсовывать наши маршруты из удобного для нас места (реализуем несколько версий, по доставанию из разных мест 😉).
Создадим свой сервис и настроем его.


Сервис
Класс сервиса RouteBuilder.
Суть сервиса простая, берем на вход данные о маршрутах и подсовываем их в "стандартный" Middlware.
/// <summary>
/// Сервис привязки маршрутов.
/// </summary>
public class RouteBuilder
{
private IRouteRepository _repo;
public RouteBuilder(IRouteRepository repo)
{
this._repo = repo;
}
public void InitRoutes(IEndpointRouteBuilder endpoints)
{
var routes = _repo.Get();
foreach (var route in routes)
{
endpoints.MapControllerRoute(
name: route.Name,
pattern: route.Pattern,
defaults: new { controller = route.Controller, action = route.Action });
}
}
}

Интерфейс реализации репозитория маршрутов IRouteRepository:
Этот интерфейс будет подменен инъекцией с необходимой версией реализации.
public interface IRouteRepository
{
IEnumerable<RouteEndpoint> Get();
//ValueTask<IEnumerable<RouteEndpoint>> GetAsync();
}

Модель данных, которую ожидает сервис от репозитория:
public class RouteEndpoint
{
public String Name;
public String Pattern;
public String Controller;
public String Action;
}

Теперь, подключим наш сервис
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
services.AddSingleton<RouteBuilder>(); // подключаем наш сервис
}

И, внедрим его (где-то в методе Configure в Startup):
app.UseEndpoints(endpoints =>
{
// своя привязка до
var routeBuilder = app.ApplicationServices.GetRequiredService<RouteBuilder>();
routeBuilder.InitRoutes(endpoints);
// после оставляем "общую" на все случаи жизни (если забудем прописать в своей реализации)
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});

Если сейчас запустить приложение, то будет исключение: Невозможно собрать один из сервисов.
Наш сервис требует хоть какую реализацию IRouteRepository. Необходимо реализовать и внедрить ее.


Версия 1. Хардкод.
Первая версия простая и для наглядности (хардкод).
public class RouteRepository_V1 : IRouteRepository
{
public RouteRepository_V1() { }
public IEnumerable<RouteEndpoint> Get()
{
var list = new List<RouteEndpoint>();
list.Add(new RouteEndpoint() { Name = "about", Pattern = "about", Controller = "Home", Action = "About" });
list.Add(new RouteEndpoint() { Name = "privacy", Pattern = "privacy", Controller = "Home", Action = "Privacy" });
return list;
}
}

Инъекция
Необходимо настроить нашу реализацию репозитория.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
services.AddSingleton<IRouteRepository, RouteRepository_V1>(); // подключаем
services.AddSingleton<RouteBuilder>();
}

Собственно, вот и все.
Дальше другие варианты реализации для примера.

Версия 2. Файл конфигурации.
Очевидно, хардкод нас не устраивает. Решили сложить все маршруты в файле конфигурации appsettings.json:
{
  ..
  
  "Routes": {
    "Items": [
      { "Name": "about", "Pattern": "about", "Controller": "Home", "Action": "About" },
      { "Name": "privacy", "Pattern": "privacy", "Controller": "Home", "Action": "Privacy" }
    ]
  }
}

Создадим модель данных конфигурации маршрутов.
public class RouteConfig
{
public RouteConfigItem[] Items { get; set; }
}
public class RouteConfigItem
{
public String Name { get; set; }
public String Pattern { get; set; }
public String Controller { get; set; }
public String Action { get; set; }
}

Настраиваем нашу секцию из файла конфигурации.
В Startup:
public void ConfigureServices(IServiceCollection services)
{
services.Configure<RouteConfig>(Configuration.GetSection("Routes")); // наша секция из конфига
services.AddControllersWithViews();
services.AddSingleton<IRouteRepository, RouteRepository_V2>(); // уже подцепили версию 2
services.AddSingleton<RouteBuilder>();
}

Реализация RouteRepository_V2:
public class RouteRepository_V2 : IRouteRepository
{
private IOptions<RouteConfig> _config;
public RouteRepository_V2(IOptions<RouteConfig> config)
{
this._config = config;
}
public IEnumerable<RouteEndpoint> Get()
{
var list = new List<RouteEndpoint>();
if (_config == null || _config.Value == null) return list;
foreach (var routeItem in _config.Value.Items)
{
list.Add(new RouteEndpoint() 

Name = routeItem.Name, 
Pattern = routeItem.Pattern, 
Controller = routeItem.Controller, 
Action = routeItem.Action
});
}
return list;
}
}

Не забываем в инъекции обновить версию: 
services.AddSingleton<IRouteRepository, RouteRepository_V2>();

Все готово и работает. Но нас смущает, что секция файла конфигурации фигурирует в общих настройках приложения. Ведь это необходимо только для этого репозитория и только для этой версии.
Удаляем из общих настроек конфигурацию нашей секции, приводим к такому виду:
public void ConfigureServices(IServiceCollection services)
{
//services.Configure<RouteConfig>(Configuration.GetSection("Routes")); УДАЛИЛИ
services.AddControllersWithViews();
services.AddSingleton<IRouteRepository, RouteRepository_V2>();
services.AddSingleton<RouteBuilder>();
}

Теперь, обновим нашу реализацию репозитория. Будем на вход слушать файл конфигурации и сами с ним работать:
public class RouteRepository_V2 : IRouteRepository
{
private RouteConfig _config;
public RouteRepository_V2(IConfiguration configuration)
{
this._config = new RouteConfig();
configuration.GetSection("Routes").Bind(_config); // сами биндим, что нам необходимо
}
public IEnumerable<RouteEndpoint> Get()
{
var list = new List<RouteEndpoint>();
if (_config == null) return list;
foreach (var routeItem in _config.Items)
{
list.Add(new RouteEndpoint() 

Name = routeItem.Name, 
Pattern = routeItem.Pattern, 
Controller = routeItem.Controller, 
Action = routeItem.Action
});
}
return list;
}
}

Все вроде хорошо. Но теперь нам не нравится, что конфигурация маршрутов "мешается" в общем конфиге. Хотим в отдельном Configs/routes.json
{
  "Items": [
    { "Name": "about", "Pattern": "about", "Controller": "Home", "Action": "About" },
    { "Name": "privacy", "Pattern": "privacy", "Controller": "Home", "Action": "Privacy" }
  ]
}

Меняем конструктор репозитория:
public RouteRepository_V2(IWebHostEnvironment env)
{
// считываем интересующий нас файл конфигурации (делаем тут, потому как он только наш и не нужен всему приложению)
var builder = new ConfigurationBuilder()
  .SetBasePath(env.ContentRootPath)
  .AddJsonFile($"Configs/routes.json", optional: true, reloadOnChange: true)
  .AddEnvironmentVariables();
var configuration = builder.Build();
this._config = new RouteConfig();
configuration.Bind(_config);
}


Обращаем внимание, конструкторы во всех вариантах принимают разные аргументы, но ASP.NET Core MVC делает необходимую инъекцию за нас (если все верно настроили).
При этом, сам сервис (RouteBuilder) вообще не интересует это все и он не зависим от этого.

Далее напрашивается версия 3, с доставанием маршрутов из базы - но это уже дело техники.
.