Варианты использования конфигурации в ASP.NET Core

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Для получения конфигурации приложения обычно используют метод доступа по ключевому слову (ключ-значение). Но это бывает не всегда удобно т.к. иногда требуется использовать готовые объекты в коде с уже установленными значениями, причем с возможностью обновления значений без перезагрузки приложения. В данном примере предлагается шаблон использования конфигурации в качестве промежуточного слоя для ASP.NET Core приложений.

Предварительно рекомендуется ознакомиться с материалом: Metanit — Конфигурация, Как работает конфигурация в .NET Core.

Постановка задачи


Необходимо реализовать ASP NET Core приложение с возможностью обновления конфигурации в формате JSON во время работы. Во время обновления конфигурации текущие работающие сессии должны продолжать работать с предыдущим вариантов конфигурации. После обновления конфигурации, используемые объекты должны быть обновлены/заменены новыми.

Конфигурация должна быть десериализована, не должно быть прямого доступа к объектам IConfiguration из контроллеров. Считываемые значения должны проходить проверку на корректность, при отсутствии как таковых заменяться значениями по умолчанию. Реализация должна работать в Docker контейнере.

Классическая работа с конфигурацией


GitHub: ConfigurationTemplate_1

Проект основан на шаблоне ASP NET Core MVC. Для работы с файлами конфигурации JSON используется провайдер конфигурации JsonConfigurationProvider. Для добавления возможности перезагрузки конфигурации приложения, во время работы, добавим параметр: «reloadOnChange: true».

В файле Startup.cs заменим:

public Startup(IConfiguration configuration)
 {
   Configuration = configuration;
 }

На

public Startup(IConfiguration configuration)
 {         
   var builder = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
   configuration = builder.Build();
   Configuration = configuration;
  }

.AddJsonFile — добавляет JSON файл, reloadOnChange:true указывает на то, что при изменение параметров файла конфигурации, они будут перезагружены без необходимости перезагружать приложение.

Содержимое файла appsettings.json:

{
  "AppSettings": {
    "Parameter1": "Parameter1 ABC",
    "Parameter2": "Parameter2 ABC"  
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

Контроллеры приложения вместо прямого обращения к конфигурации будут использовать сервис: ServiceABC. ServiceABC – класс который первоначальные значения берет из файла конфигурации. В данном примере класс ServiceABC содержит только одно свойство Title.

Содержимое файла ServiceABC.cs:

public class ServiceABC
{
  public string Title;
  public ServiceABC(string title)
  {
     Title = title;
  }
  public ServiceABC()
  { }
}

Для использования ServiceABC необходимо его добавить в качестве сервиса middleware в приложение. Добавим сервис как AddTransient, который создается каждый раз при обращении к нему, с помощью выражения:
services.AddTransient<IYourService>(o => new YourService(param));
Отлично подходит для легких сервисов, не потребляющих память и ресурсы. Чтение параметров конфигурации в Startup.cs осуществляется с помощью IConfiguration, где используется строка запроса с указанием полного пути расположения значения, пример: AppSettings:Parameter1.

В файле Startup.cs добавим:

public void ConfigureServices(IServiceCollection services)
{
  //Считывание параметра "Parameter1" для инициализации сервиса ServiceABC
  var settingsParameter1 = Configuration["AppSettings:Parameter1"];
  //Добавление сервиса "Parameter1"            
  services.AddScoped(s=> new ServiceABC(settingsParameter1));
  //next
  services.AddControllersWithViews();
}

Пример использования сервиса ServiceABC в контроллере, значение Parameter1 будет отображаться на html странице.

Для использования сервиса в контроллерах добавим его в конструктор, файл HomeController.cs

public class HomeController : Controller
{
  private readonly ILogger<HomeController> _logger;
  private readonly ServiceABC _serviceABC;
  public HomeController(ILogger<HomeController> logger, ServiceABC serviceABC)
    {
      _logger = logger;
      _serviceABC = serviceABC;
    }
  public IActionResult Index()
    {
      return View(_serviceABC);
    }

Добавим видимость сервиса ServiceABC в файл _ViewImports.cshtml

@using ConfigurationTemplate_1.Services

Изменим Index.cshtml для отображение параметра Parameter1 на странице.

@model ServiceABC
@{
    ViewData["Title"] = "Home Page";
}
    <div class="text-center">
        <h1>Десериализация конфигурации в ASP.NET Core</h1>
        <h4>Классическая работа с конфигурацией</h4>
    </div>
<div>        
    <p>Сервис ServiceABC, отображение Заголовка
        из параметра Parameter1 = @Model.Title</p>
</div>

Запустим приложение:



Итог


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

Использование IConfiguration как Singleton


GitHub: ConfigurationTemplate_2

Второй вариант заключается в помещение IConfiguration(как Singleton) в сервисы. В результате IConfiguration может вызываться из контроллеров и других сервисов. При использовании AddSingleton сервис создается один раз и при использовании приложения обращение идет к одному и тому же экземпляру. Использовать этот способ нужно особенно осторожно, так как возможны утечки памяти и проблемы с многопоточностью.

Заменим код из предыдущего примера в Startup.cs на новый, где
services.AddSingleton<IConfiguration>(Configuration);
добавляет IConfiguration как Singleton в сервисы.

public void ConfigureServices(IServiceCollection services)
{
  //Доступ к IConfiguration из других контроллеров и сервисов
  services.AddSingleton<IConfiguration>(Configuration);
  //Добавление сервиса "ServiceABC"                          
  services.AddScoped<ServiceABC>();
  //next
  services.AddControllersWithViews();
}

Изменим конструктор сервиса ServiceABC для принятия IConfiguration

public class ServiceABC
{        
  private readonly IConfiguration _configuration;
  public string Title => _configuration["AppSettings:Parameter1"];        
  public ServiceABC(IConfiguration Configuration)
    {
      _configuration = Configuration;
    }
  public ServiceABC()
    { }
}

Как и в предыдущем варианте добавим сервис в конструктор и добавим ссылку на пространство имен
Для использования сервиса в контроллерах добавим его в конструктор, файл HomeController.cs

public class HomeController : Controller
{
  private readonly ILogger<HomeController> _logger;
  private readonly ServiceABC _serviceABC;
  public HomeController(ILogger<HomeController> logger, ServiceABC serviceABC)
    {
      _logger = logger;
      _serviceABC = serviceABC;
    }
  public IActionResult Index()
    {
      return View(_serviceABC);
    }

Добавим видимость сервиса ServiceABC в файл _ViewImports.cshtml:

@using ConfigurationTemplate_2.Services;

Изменим Index.cshtml для отображения параметра Parameter1 на странице.

@model ServiceABC
@{
    ViewData["Title"] = "Home Page";
}
<div class="text-center">
    <h1>Десериализация конфигурации в ASP.NET Core</h1>
    <h4>Использование IConfiguration как Singleton</h4>
</div>
<div>
    <p>
        Сервис ServiceABC, отображение Заголовка
        из параметра Parameter1 = @Model.Title
    </p>
</div>


Запустим приложение:



Сервис ServiceABC добавленный в контейнер с помощью AddScoped означает, что экземпляр класса будет создаваться при каждом запросе страницы. В результате экземпляр класса ServiceABC будет создаваться при каждом http запросе вместе с перезагрузкой конфигурации IConfiguration, и новые изменения в appsettings.json будут применяться.
Таким образом, если во время работы приложения изменить параметр Parameter1 на «NEW!!! Parameter1 ABC», то при следующем обращение к начальной странице отобразится новое значение параметра.

Обновим страницу после изменения файла appsettings.json:



Итог


Неудобством такого подхода является ручное чтение каждого параметра. И если добавить валидацию параметров, то проверка будет выполняться не после изменения в файле appsettings.json а каждый раз при использование ServiceABC, что является лишним действием. В лучшем варианте валидация параметров должна выполняться только один раз после каждого изменения файла.

Десериализация конфигурации с валидацией (вариант IOptions)


GitHub: ConfigurationTemplate_3
Почитать про Options по ссылке.

В этом варианте необходимость использования ServiceABC отпадает. Вместо него используется класс AppSettings, который содержит параметры из конфигурационного файла и объект ClientConfig. Объект ClientConfig после изменения конфигурации требуется инициализировать, т.к. в контроллерах используется готовый объект.
ClientConfig это некий класс, взаимодействующий с внешними системами, код которого нельзя изменять. Если выполнить только десериализацию данных класса AppSettings, то ClientConfig будет в состояние null. Поэтому необходимо подписаться на событие чтения конфигурации, и в обработчике инициализировать объект ClientConfig.

Для передачи конфигурации не в виде пар ключ-значение, а как объекты определенных классов, будем использовать интерфейс IOptions. Дополнительно IOptions в отличие от ConfigurationManager позволяет десерилизовать отдельные секции. Для создания объекта ClientConfig потребуется использовать IPostConfigureOptions, который выполняется после обработки всех конфигурации. IPostConfigureOptions будет выполняться каждый раз после чтения конфигурации, самым последним.

Создадим ClientConfig.cs:

public class ClientConfig
{
  private string _parameter1;
  private string _parameter2;
  public string Value => _parameter1 + " " + _parameter2;
  public ClientConfig(ClientConfigOptions configOptions)
    {
      _parameter1 = configOptions.Parameter1;
      _parameter2 = configOptions.Parameter2;
    }
}

В качестве конструктора будет принимать параметры в виде объекта ClientConfigOptions:

public class ClientConfigOptions
{
  public string Parameter1;
  public string Parameter2;
} 

Создадим класс настроек AppSettings, и определим в нем метод ClientConfigBuild(), который создаст объект ClientConfig.

Файл AppSettings.cs:

public class AppSettings
{        
  public string Parameter1 { get; set; }
  public string Parameter2 { get; set; }        
  public ClientConfig clientConfig;
  public void ClientConfigBuild()
    {
      clientConfig = new ClientConfig(new ClientConfigOptions()
        {
          Parameter1 = this.Parameter1,
          Parameter2 = this.Parameter2
        }
        );
      }
}

Создадим обработчик конфигурации, который будет отрабатываться последним. Для этого он должен быть унаследован от IPostConfigureOptions. Вызываемый последним метод PostConfigure выполнит ClientConfigBuild(), который как раз и создаст ClientConfig.

Файл ConfigureAppSettingsOptions.cs:

public class ConfigureAppSettingsOptions: IPostConfigureOptions<AppSettings>
{
  public ConfigureAppSettingsOptions()
    { }
  public void PostConfigure(string name, AppSettings options)
    {            
      options.ClientConfigBuild();
    }
}

Теперь осталось внести изменения только в Startup.cs, изменения коснутся только функции ConfigureServices(IServiceCollection services).

Сначала прочитаем секцию AppSettings в appsettings.json

// configure strongly typed settings objects
var appSettingsSection = Configuration.GetSection("AppSettings");
services.Configure<AppSettings>(appSettingsSection);

Далее, для каждого запроса будет создаваться копия AppSettings для возможности вызова постобработки:

services.AddScoped(sp => sp.GetService<IOptionsSnapshot<AppSettings>>().Value);

Добавим в качестве сервиса, постобработку класса AppSettings:

services.AddSingleton<IPostConfigureOptions<AppSettings>, ConfigureAppSettingsOptions>();

Добавленный код в Startup.cs

public void ConfigureServices(IServiceCollection services)
{
  // configure strongly typed settings objects
  var appSettingsSection = Configuration.GetSection("AppSettings");
  services.Configure<AppSettings>(appSettingsSection);
  services.AddScoped(sp => sp.GetService<IOptionsSnapshot<AppSettings>>().Value);                                    
  services.AddSingleton<IPostConfigureOptions<AppSettings>, ConfigureAppSettingsOptions>();            
  //next
  services.AddControllersWithViews();
}

Для получения доступа к конфигурации, из контроллера достаточно будет просто внедрить AppSettings.

Файл HomeController.cs:

public class HomeController : Controller
{
  private readonly ILogger<HomeController> _logger;
  private readonly AppSettings _appSettings;
  public HomeController(ILogger<HomeController> logger, AppSettings appSettings)
    {
      _logger = logger;
      _appSettings = appSettings;
    }

Изменим Index.cshtml для вывода параметра Value объекта СlientConfig

@model AppSettings
@{
    ViewData["Title"] = "Home Page";
}
<div class="text-center">
    <h1>Десериализация конфигурации в ASP.NET Core</h1>
    <h4>Десериализация конфигурации с валидацией (вариант IOptions)</h4>
</div>
<div>
    <p>
        Класс ClientConfig, отображение Заголовка
         = @Model.clientConfig.Value
    </p>
</div>

Запустим приложение:



Если во время работы приложения изменить параметр Parameter1 на «NEW!!! Parameter1 ABC» и Parameter2 на «NEW!!! Parameter2 ABC», то при следующем обращении к начальной странице отобразится новое свойства Value:



Итог


Данный подход позволяет десериализовать все значения конфигурации без ручного перебирания параметров. Каждый запрос http работает со своим экземпляром AppSettings и СlientConfig, что исключает ситуацию возникновения коллизий. IPostConfigureOptions гарантирует выполнение в последнюю очередь, когда все параметры будут перечитаны. Недостатком решения является постоянное создание экземпляра СlientConfig для каждого запроса, что является непрактичным т.к. по сути СlientConfig должен пересоздаваться только после изменения конфигурации.

Десериализация конфигурации с валидацией (без использования IOptions)


GitHub: ConfigurationTemplate_4

Использование подхода с использованием IPostConfigureOptions приводит к созданию объекта ClientConfig каждый раз при получении запроса от клиента. Это недостаточно рационально т.к. каждый запрос работает с начальным состоянием ClientConfig, которое меняется только при изменение конфигурационного файла appsettings.json. Для этого откажемся от IPostConfigureOptions и создадим обработчик конфигурации который будет вызваться только при изменении appsettings.json, в результате ClientConfig будет создаваться только один раз, и далее на каждый запрос будет отдаваться уже созданный экземпляр ClientConfig.

Создадим класс SingletonAppSettings конфигурации(Singleton) с которого будет создаваться экземпляр настроек для каждого запроса.

Файл SingletonAppSettings.cs:

public class SingletonAppSettings
{
  public AppSettings appSettings;  
  private static readonly Lazy<SingletonAppSettings> lazy = new Lazy<SingletonAppSettings>(() => new SingletonAppSettings());
  private SingletonAppSettings()
    { }
  public static SingletonAppSettings Instance => lazy.Value;
}

Вернемся в класс Startup и добавим ссылку на интерфейс IServiceCollection.
Он будет использоваться в методе обработки конфигурации

public IServiceCollection Services { get; set; }

Изменим ConfigureServices(IServiceCollection services) и передадим ссылку на IServiceCollection.

Файл Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
  Services = services;
  //Считаем секцию AppSettings из конфигурации
  var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>();
  appSettings.ClientConfigBuild();

Создадим Singleton конфигурации, и добавим его в коллекцию сервисов:

SingletonAppSettings singletonAppSettings = SingletonAppSettings.Instance;
singletonAppSettings.appSettings = appSettings;
services.AddSingleton(singletonAppSettings);     

Добавим объект AppSettings как Scoped, при каждом запросе будет создаваться копия от Singleton:

services.AddScoped(sp => sp.GetService<SingletonAppSettings>().appSettings);

Полностью ConfigureServices(IServiceCollection services):

public void ConfigureServices(IServiceCollection services)
{
  Services = services;
  //Считаем секцию AppSettings из конфигурации
  var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>();
  appSettings.ClientConfigBuild();
  SingletonAppSettings singletonAppSettings = SingletonAppSettings.Instance;
  singletonAppSettings.appSettings = appSettings;
  services.AddSingleton(singletonAppSettings);             
  services.AddScoped(sp => sp.GetService<SingletonAppSettings>().appSettings);
  //next
  services.AddControllersWithViews();
}

Теперь добавить обработчик для конфигурации в Configure(IApplicationBuilder app, IWebHostEnvironment env). Для отслеживания изменения в файле appsettings.json используется токен. OnChange – вызываемая функция при изменении файла. Обработчик конфигурации onChange():

ChangeToken.OnChange(() => Configuration.GetReloadToken(), onChange);

Вначале читаем файл appsettings.json и десериализуем класс AppSettings. Затем из коллекции сервисов получаем ссылку на Singleton, который хранит объект AppSettings, и заменяем его новым.

private void onChange()
{                        
  var newAppSettings = Configuration.GetSection("AppSettings").Get<AppSettings>();
  newAppSettings.ClientConfigBuild();
  var serviceAppSettings = Services.BuildServiceProvider().GetService<SingletonAppSettings>();
  serviceAppSettings.appSettings = newAppSettings;
  Console.WriteLine($"AppSettings has been changed! {DateTime.Now}");
}

В контроллер HomeController внедрим ссылку на AppSettings, как в предыдущем варианте (ConfigurationTemplate_3)
Файл HomeController.cs:

public class HomeController : Controller
{
  private readonly ILogger<HomeController> _logger;
  private readonly AppSettings _appSettings;
  public HomeController(ILogger<HomeController> logger, AppSettings appSettings)
    {
      _logger = logger;
      _appSettings = appSettings;
    }

Изменим Index.cshtml для вывода параметра Value объекта СlientConfig:

@model AppSettings
@{
    ViewData["Title"] = "Home Page";
}
<div class="text-center">
    <h1>Десериализация конфигурации в ASP.NET Core</h1>
    <h4>Десериализация конфигурации с валидацией (без использования IOptions)</h4>
</div>
<div>
    <p>
        Класс ClientConfig, отображение Заголовка
        = @Model.clientConfig.Value
    </p>
</div>


Запустим приложение:



Выбрав режим запуска как консольное приложение, в окне приложения можно увидеть сообщение о срабатывания события изменения файла конфигурации:



И новые значения:



Итог


Данный вариант лучше использования IPostConfigureOptions т.к. позволяет строить объект только после изменения файла конфигурации, а не при каждом запросе. В результате достигается уменьшение времени на ответ сервера. После срабатывания токена, состояние токена сбрасывается.

Добавление значений по умолчанию и валидация конфигурации


GitHub: ConfigurationTemplate_5

В предыдущих примерах при отсутствии файла appsettings.json приложение выбросит исключение, поэтому сделаем файл конфигурации опциональным и добавим настройки по умолчанию. При публикации приложения проекта, созданного из шаблона в Visula Studio, файл appsettings.json будет располагаться в одной и той же папке вместе со всеми бинарными файлами, что неудобно при развертывание в Docker. Файл appsettings.json перенесем в папку config/:

.AddJsonFile("config/appsettings.json")

Для возможности запуска приложения без appsettings.json изменим параметр optional на true, который в данном случае означает, что наличие appsettings.json является необязательным.

Файл Startup.cs:

public Startup(IConfiguration configuration)
{
  var builder = new ConfigurationBuilder()
     .AddJsonFile("config/appsettings.json", optional: true, reloadOnChange: true);
  configuration = builder.Build();
  Configuration = configuration;
}

Добавим в public void ConfigureServices(IServiceCollection services) к строке десериализации конфигурации случай обработки отсутствия файла appsettings.json:

 var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings();

Добавим валидацию конфигурации, на основе интерфейса IValidatableObject. При отсутствующих параметрах конфигурации, будет применяться значение по умолчанию.

Наследуем класс AppSettings от IValidatableObject и реализуем метод:

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)

Файл AppSettings.cs:

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
  List<ValidationResult> errors = new List<ValidationResult>();
  if (string.IsNullOrWhiteSpace(this.Parameter1))
    {
      errors.Add(new ValidationResult("Не указан параметр Parameter1. Задано " +
        "значение по умолчанию DefaultParameter1 ABC"));
      this.Parameter1 = "DefaultParameter1 ABC";
    }
    if (string.IsNullOrWhiteSpace(this.Parameter2))
    {
      errors.Add(new ValidationResult("Не указан параметр Parameter2. Задано " +
        "значение по умолчанию DefaultParameter2 ABC"));
      this.Parameter2 = "DefaultParameter2 ABC";
    }
    return errors;
}

Добавим метод вызова проверки конфигурации для вызова из класса Startup
Файл Startup.cs:

private void ValidateAppSettings(AppSettings appSettings)
{
  var resultsValidation = new List<ValidationResult>();
  var context = new ValidationContext(appSettings);
  if (!Validator.TryValidateObject(appSettings, context, resultsValidation, true))
    {
      resultsValidation.ForEach(
        error => Console.WriteLine($"Проверка конфигурации: {error.ErrorMessage}"));
      }
    }

Добавим вызов метода валидации конфигурации в ConfigureServices(IServiceCollection services). Если файла appsettings.json отсутствует, то требуется инициализировать объект AppSettings со значениями по умолчанию.

Файл Startup.cs:

var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings();

Проверка параметров. В случае использования значения по умолчанию в консоль будет выведено сообщение с указанием параметра.

 //Validate            
this.ValidateAppSettings(appSettings);            
appSettings.ClientConfigBuild();

Изменим проверку конфигурации в onChange()

private void onChange()
{                        
  var newAppSettings = Configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings();
  //Validate            
  this.ValidateAppSettings(newAppSettings);            
  newAppSettings.ClientConfigBuild();
  var serviceAppSettings = Services.BuildServiceProvider().GetService<SingletonAppSettings>();
  serviceAppSettings.appSettings = newAppSettings;
  Console.WriteLine($"AppSettings has been changed! {DateTime.Now}");
}

Если из файла appsettings.json удалить ключ Parameter1, то после сохранения файла в окне консольного приложения появится сообщение об отсутствие параметра:



Итог


Изменение пути для расположения конфигураций в папке config является хорошим решением т.к. позволяет не смешивать все файлы в одну кучу. Папка config определяется только для хранения конфигурационных файлов. Упростили задачу развертывания и конфигурирования приложения для администраторов, благодаря валидации конфигурации. Если добавить вывод ошибок конфигурации в лог, то администратор в случае указания неправильных параметров, получит точную информацию о проблеме, а не как в последнее время программисты на любое исключение стали писать: «Что -то пошло не так».

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

Все шаблоны конфигураций доступны по ссылке.

Литература:

  1. Корректный ASP.NET Core
  2. METANIT — Конфигурация. Основы конфигурации
  3. Singleton Design Pattern C# .net core
  4. Reloading configuration in .NET core
  5. Reloading strongly typed Options on file changes in ASP.NET Core RC2
  6. Конфигурация ASP.NET Core приложения через IOptions
  7. METANIT — Передача конфигурации через IOptions
  8. Конфигурация ASP.NET Core приложения через IOptions
  9. METANIT — Самовалидация модели
Источник: https://habr.com/ru/post/517306/


Интересные статьи

Интересные статьи

Перевод статьи Тима Деттмерса, кандидата наук из Вашингтонского университета, специалиста по глубокому обучению и обработке естественного языка Глубокое обучение (ГО) – область с п...
В нашем блоге мы часто говорим об устройстве различных сетевых протоколов. Сегодня расскажем об ONPC, который расширяет возможности Wi-Fi-сетей. Под катом о том, как он работает. ...
Привет, Хабровчани! Сегодня вы ознакомитесь со статьей, в которой будет рассказано, как создать бота, используя C# на .NET Core, и о том, как его завести на удаленном сервере. Статья буд...
Как-то у нас исторически сложилось, что Менеджеры сидят в Битрикс КП, а Разработчики в Jira. Менеджеры привыкли ставить и решать задачи через КП, Разработчики — через Джиру.
На сегодняшний день у сервиса «Битрикс24» нет сотен гигабит трафика, нет огромного парка серверов (хотя и существующих, конечно, немало). Но для многих клиентов он является основным инструментом ...