Web application configuration management for different target environments

Рано или поздно в жизненном цикле любого приложения становятся актуальными задачи развертывания. Это может быть развертывание как на выделенной машине в пределах офиса для того, чтобы передать приложение на тестирование, так и на группе удаленных хостов в облаке, образующих веб-ферму. Где бы не хостилось приложение, встает вопрос об управлении конфигурационными настройками для разных окружений. Эта проблема и одно из ее возможных решений – как раз и будут темой сегодняшнего поста.

Содержание:

  1. Классический способ “в лоб” управления конфигурацией.
  2. Где хранить конфигурационные настройки веб приложения для разных окружений?
  3. Лирическое отступление на тему хороших манер при работе с конфигурационными файлами.
  4. Как использовать ASP.NET web.config transformations для генерации конфигурационных файлов для разных окружений (dev, staging, production).
  5. Использование ASP.NET web.config transformations для любого XML файла, а не только для web.config.
  6. Как используя ASP.NET web.config transformations, иметь возможность компилировать приложение на машине без установленного Visual Studio и ASP.NET, а лишь с .NET Framework 4.0.
  7. Пример использования ASP.NET web.config transformation синтаксиса.
  8. Автоматический выбор конфигурационного файла для целевого окружения, и подготовка deployment package посредством собственного скрипта, написанного на Python.

Application target environments

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

  • local environment
  • development/QA environment
  • staging environment
  • production environment

Есть конечно и более специфичные, но сегодня этих будет достаточно. Local environment можно было бы и не выделять как отдельную полноценную среду, обычно разработчик вручную осуществляет настройку приложения на локальной машине, о деплойменте же как о таковом речи вообще может не идти. IIS application может быть отображен напрямую на папку, где находятся исходники проекта, в web.config по сути находятся настройки для локального окружения. Но если не относится к local environment как к частному случаю, то можно унифицировать процесс развертывания, что есть хорошо.

Configuration places

После того как мы определились с целевыми средами, нужно понять что должно или может меняться в зависимости от среды, а соответственно, что мы хотим конфигурировать. Это могут быть строки подключения к источнику данных, адреса, учетные записи и пароли для внешних сервисов, настройки безопасности, например timeout авторизационного cookie, настройки логгирования, уровень логгирования, получатели лог событий, обработка ошибок и отображение собтвенных страниц с ошибками, настройки сторонних модулей (например, Glimpse).

Web.config and config files good manners

Стандартной практикой является размещение конфигурационных настроек в web.config. Здесь хочется сделать небольшое лирическое отступление, и поговорить о хороших манерах работы с web.config. Мы выносим конфигурационные настройки в web.config или любой другой конфигурационный файл, для того чтобы иметь возможность на лету изменять их без необходимости перекомпиляции всего приложения. Говоря технически, при изменении web.config IIS создает новый AppDomain, загружает туда сборки приложения, и новые запросы обрабатываются уже новой версией приложения в новом AppDomain.

Конфигурационный файл должен содержать только то, что нужно менять в зависимости от окружения (строки подключения), или же то, что меняется часто или на лету в процессе работы приложения (настройки логгирования). Не нужно засорять конфигурационный файл, а тем более XML файл, тем что никогда не будет менятся, или же будет менятся редко. Если что-то меняется редко, реже чем происходит цикл развертывания приложения, то это что-то можно вполне конфигурировать посредством кода и менять в коде, как и любой другой функциональный аспект. Если что-то не меняется никогда, то уносим это что-то в код. Для наглядности можно привести пример с http modules. Если мне не надо включать\отключать некий модуль на лету, то я предпочитаю конфигурировать модуль в коде, а не в конфигурационном файле. Вот как это может выглядеть.

Унифицированная инициализация всех сконфигурированных модулей в Global.asax:

public class MvcApplication : HttpApplication
{
 // ...
 public override void Init()
 {
  base.Init();
  foreach (var httpModule in ServiceLocator.Get<IEnumerable<IHttpModule>>())
  {
   httpModule.Init(this);
  }
 }
 // ...

Регистрация модулей наряду с другими сервисами в контейнере:

protected override void Load(ContainerBuilder builder)
{
 // ...
 builder.RegisterType<ErrorHandlingModule>().AsImplementedInterfaces().SingleInstance();
 builder.RegisterType<RequestLoggingModule>().AsImplementedInterfaces().SingleInstance();
 // ...
} 

public class ErrorHandlingModule : IHttpModule
{
 // ...
}

Если же модулю нужны конфигурационные настройки, то:

public class RequestLoggingModule : IHttpModule
{
 // ...
 public RequestLoggingModule(ILoggingConfig loggingConfig)
 {
  _loggingConfig = loggingConfig;
 }
 // ...
}

Если вы не хотите, чтобы Вас, как разработчиков, постоянно просили исправлять параметры в конфигурационных файлах - делайте конфигурационный файл таковым, чтобы он был интуитивно понятен, понятен не только разработчику, а и другим участникам процесса разработки: QA, release manager, support team. В практической плоскости это может значить не использовать самописные навороченные config sections, а предпочесть словарь ключей <appSettings>. При использовании Castle.DictionaryAdapter в качестве провайдера конфигурационных параметров, это может выглядеть следующим образом.

<appSettings> 
... 
 <add key="Smtp_Host" value="..."/>
 <add key="Smtp_Port" value="..."/>
 <add key="Smtp_UserName" value="..."/>
 <add key="Smtp_Password" value="..."/>
 <add key="Smtp_EnableSsl" value="..."/> 
... 
</appSettings> 

Интерфейс для Castle configuration adapter:

public interface ISmtpEmailConfig
{
 [Key("Smtp_Host")]
 string Host { get; set; }
 [Key("Smtp_Port")]
 int Port { get; set; }
 [Key("Smtp_UserName")]
 string UserName { get; set; }
 [Key("Smtp_Password")]
 string Password { get; set; }
 [Key("Smtp_EnableSsl")]
 bool EnableSsl { get; set; }
}

Dependency injection:

public SmtpEmailSender(ISmtpEmailConfig config)
{
 _config = config;
}

Магией инстанциирования прокси класса, и привязки параметров рулит Castle.DictionaryAdapter. Если интересно, то почитать здесь и здесь.

Any.config

Несмотря на то, что web.config’а либо app.config’а вполне хватает для хранения конфигурационных настроек, может возникнуть необходимость завести отдельный конфигурационный файл. Например, NLog.config – для хранения настроек логгирования при использовании NLog. Либо приложение интегрируется с неким legacy компонентом, который конфигурируется исключительно посредством отдельного файла. Либо Вы хотите располагать одним файлом и применять его для разных приложения, таким образом уйти от необходимости поддержки нескольких однотипных файлов.

Classic configuration management

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

Файлы Web.config и NLog.config содержат конфигурационные параметры актуальные для окружения на локальной машине разработчика, то есть ссылаются на локальные внешние сервисы, содержат учетные записи и пароли локальные для текущего домена, в незашифрованном виде, возможно выключенные настройки безопасности, логгирование на Debug уровне. При деплойменте, создается билд, подготавливается deployment package, загружается на целевой хост, развертывается. Если это в первый раз, то существующие конфигурационные файлы берутся за основу, и необходимые настройки вручную меняются. Если уже есть предыдущая версия приложения, то перезаписывается всё кроме конфигурационных файлов, а содержимое этих файлов мерджится вручную.

Минусы этого подхода очевидны:

  • Большой объем ручной работы.
  • Невозможность быстрого развертывания приложения (скажем, до 5 минут).
  • Необходимость помнить и не перезатереть существующие конфигурационные файлы при развертывании.
  • Необходимость помнить что является конфигурационным файлом а что нет, и где они находятся. Речь идет о случае, когда кроме web.config у Вас есть еще другие конфигурационные файлы.
  • Конфигурационные настройки для разных окружений хранятся непонятно где. Возможно, только в самих конфигурационных файлах на целевых хостах, возможно, где-то в заначке у того, кто занимается деплойментом. Если приложение в production окружении случайно перезатерлось вместе с конфигурационными файлами, а заначка пропала, то настройки восстановить будет очень сложно.

Configuration management considerations

Подумаем над тем, как могла бы выглядеть оптимальная управления конфигурационными параметрами и развертыванием. Рассмотрим следующие вопросы и компромиссы.

Keep config settings in version control system

На вопрос что хранить, а что не хранить в системе контроля версия – мой ответ обычно таков: “Хранить все, что относится к проекту”. Конфигурационные настройки, особенно для production окружения, жизненно важны для корректного функционирования приложения. Поэтому у меня нет никаких аргументов в пользу того, чтобы не хранить их в системе контроля версий. Если Вас волнуют вопросы безопасности, то можно зашифровать наиболее критические данные, либо же весь файл.

Duplication vs diff

Если конфигурационные файлы для разных окружений отличаются не сильно, и нужно изменять только несколько параметров, то желательно не дублировать содержимое файлов, а описывать только diff. Например, в Visual Studio 2010 в ASP.NET приложениях есть функция: web.config transformation. Вы можете описать базовый web.config файл, а также файлы с преобразованиями для разных окружений (Web.Debug.config, Web.Release.config).

Web.config files typically include settings that have to be different depending on which environment the application is running in. For example, you might have to change a database connection string or disable debugging when you deploy a Web.config file. For Web application projects, ASP.NET provides tools that automate the process of changing (transforming) Web.config files when they are deployed. For each environment that you want to deploy to, you create a transform file that specifies only the differences between the original Web.config file and the deployed Web.config file for that environment.

Однако, если вы следуете принципу “Хранить в конфиге только тот минимум, который меняется”, то возможно все эти транформации будут излишни для Вас и только добавят головной боли. В таком случае, самый простой вариант – продублировать и поддерживать несколько конфигурационных файлов для разных окружений. В этом случае при добавлении нового параметра, его нужно будет не забыть продублировать во всех файлах. В качестве утешения может служить тот факт, что окружений обычно не так уж и много (development, staging, production).

Если же вы хотите возпользовать функцией трансформации конфигурационных файлов, то стоит учесть, что из коробки она доступна только для ASP.NET приложений, и только если вы пользуетесь “Build Deployment Package” функцией, которую предлагает Visual Studio. Дальше в статье я покажу подход, который позволяет использовать config transformations для любого xml файла, будь то web.config, app.config, любой другой any.config, либо вообще файл, не имеющий отношения к конфигурации.

Automate as much as possible

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

Solution

И так, перейдем к самой интересной части и посмотрим возможное решение на практике. Дано:

  1. Visual Studio 2010, .NET 4.0, ASP.NET MVC.
  2. Два конфигурационных файла: Web.config и NLog.config.
  3. Четыре целевых среды развертывания: local, development, staging, production.

Compilation and configuration transformations

В данном решении, я хочу показать использование web.config transformation возможности за пределами “Build Deployment Package” функции. Изначальное состояние структуры таково.

Удалим файлы Web.Debug.config и Web.Release.config, и добавим Web.dev.build.config, Web.staging.build.config и Web.production.build.config, которые будут содержать трасформации для целевых окружений.

Для того чтобы эти файлы отображались в виде дерева с Web.config в качестве родителя, нужно внести правки вручную в “.csproj” файл проекта.

<Content Include="Web.config">
 <SubType>Designer</SubType>
</Content>
<Content Include="Web.dev.build.config">
 <DependentUpon>Web.config</DependentUpon>
</Content>
<Content Include="Web.staging.build.config">
 <DependentUpon>Web.config</DependentUpon>
</Content>
<Content Include="Web.production.build.config">
 <DependentUpon>Web.config</DependentUpon>
</Content>

По умолчанию в ASP.NET приложении, трансформации, описанные в том или ином файле (Web.Debug.config, Web.Release.config) применяются в зависимости от выбранного Build Configuration (Debug, Release), и результат трансформации перезаписывается в исходный web.config.

Изменим эту схему таким образом, чтобы во время компиляции, отрабатывали все транформации, описанные в наших *.build.config файлах, а в результате создавались новые файлы: *.env.config.

Для этого нам нужно импортировать MSBuild task, который отвечает за трансформацию конфигурационных файлов: “TransformXml”, который описан в сборке “Microsoft.Web.Publishing.Tasks.dll”. Далее, нам нужно вызвать эту задачу указав ей исходный конфигурационный файл, файл с трансформациями, и целевой файл с результатом. Также мы хотим, чтобы эта задача происходила автоматически во время компиляции. Вместо того, чтобы использовать Post-build event, воспользуемся предопределенным MSBuild target: “AfterBuild”. Вот как будет выглядеть “.csproj” файл.

<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v10.0\WebApplications\Microsoft.WebApplication.targets" />
<UsingTask TaskName="TransformXml" AssemblyFile="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v10.0\Web\Microsoft.Web.Publishing.Tasks.dll" />
<Target Name="AfterBuild">
 <TransformXml Source="$(ProjectDir)\Web.Config" Transform="$(ProjectDir)\Web.dev.build.config" Destination="$(ProjectDir)\Web.dev.env.config" StackTrace="true" />
 <TransformXml Source="$(ProjectDir)\Web.Config" Transform="$(ProjectDir)\Web.staging.build.config" Destination="$(ProjectDir)\Web.staging.env.config" StackTrace="true" />
 <TransformXml Source="$(ProjectDir)\Web.Config" Transform="$(ProjectDir)\Web.production.build.config" Destination="$(ProjectDir)\Web.production.env.config" StackTrace="true" />
</Target>

В результате компиляции получим желаемые файлы в папке проекта.

Проанализируем какие зависимости на внешние сборки имееются:

  • В первой строке находится import файла: “Microsoft.WebApplication.targets”. Этот import присуствует по умолчанию в проекте типа web application. Путь: “$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v10.0\WebApplications”.
  • В второй строке находится ссылка на “TransformXml” из сборки “Microsoft.Web.Publishing.Tasks.dll”. Путь: “$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v10.0\Web”.

В обеих случаях пути ссылаются на папку с msbuild дополнениями для Visual Studio 2010. На моей локальной машине это –“C:\Program Files (x86)\MSBuild\Microsoft\VisualStudio\v10.0\”. Это значит, что приложение не будет компилироваться на машине, где не установлена Visual Studio 2010, и возможно ASP.NET. Мы же хотим сделать так, что приложение компилировалось без проблем на любой машине, где есть .NET Framework 4.0.

Первой идеей было скопировать файлы “Microsoft.WebApplication.targets” и “Microsoft.Web.Publishing.Tasks.dll” в некую папку, который находится в репозитории (речь идет о системе контроля версий) наряду с проектом, и также изменить ".csproj" файл, чтобы он ссылался на эти файлы по новому пути.

<Import Project="$(ProjectDir)\..\ms_build_extensions\Microsoft.WebApplication.targets" />
<UsingTask TaskName="TransformXml" AssemblyFile="$(ProjectDir)\..\ms_build_extensions\Microsoft.Web.Publishing.Tasks.dll" />

Папка “ms_build_extensions” могла бы содержать следующие файлы.

Однако на ручное изменение директивы Import в первой строке Visual Studio реагирует предложением о конвертации приложения.

Если продолжить, то в результате исходная Import директива будет воссоздана заново.

В результате, правильным решением будет переопределить переменную “$(MSBuildExtensionsPath32)” следующим образом.

<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
 <PropertyGroup>
  <!-- ... -->
  <MSBuildExtensionsPath32>..\..\ms_build_extensions</MSBuildExtensionsPath32>
  <!-- ... -->
 </PropertyGroup>

Относительные пути в данном случае работают, а вот применение переменных типа $(ProjectDir) - нет. Внутренняя структура папки “ms_build_extensions” должна в этом случае соотвествовать содержимому папки, которая была задана переменной “$(MSBuildExtensionsPath32)” до переопределения. Из содержимого я оставил лишь нужные мне “$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v10.0\WebApplications” и “$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v10.0\Web”. В целом папка ms_build_extensions занимает 649KB, что позволяет нам хранить ее в системе контроля версий безо всяких колебаний.

Это все что нужно для того, чтобы в результате компиляции на выходе создавались нужные конфигурационные файлы. Если мы хотим, чтобы сгенерированные файлы игнорировались системой контроля версий, то это делается очень легко (на примере .gitignore файла):

# Generated configuration files for different environments
*.env.config

Configuration transformation syntax

В данной статье я не хочу углублятся в синтаксис и содержимое файлов трансформации. Детально со всеми возможностями синтаксиса можно ознакомится здесь.

И все же, в качестве примера рассмотрим преобразование исходного web.config файла, в котором надо изменить значение параметра appSettings, заменить connection string, и включить customErrors mode=”On” для staging окружения. Другие конфигурационные настройки трогать не надо. Исходный web.config файл будет выглядеть следующим образом.

<?xml version="1.0"?>
<configuration>
 <appSettings>
  <add key="myKey" value="myValue"/>
  <add key="Security_SwitchToSecureConnection" value="false"/>
 </appSettings>

 <connectionStrings>
  <add name="myConnectionSettings" connectionString="Data Source=localhost;Initial Catalog=dev_db;Integrated Security=True"/>
 </connectionStrings>

 <system.web>
  <customErrors mode="Off" defaultRedirect="~/Content/static/generic_error.htm">
  </customErrors>

  <sessionState mode="InProc" timeout="10" cookieless="true"></sessionState>
 </system.web>
</configuration>

Файл с преобразованиями “Web.staging.build.config” будет выглядеть следующим образом.

<?xml version="1.0"?>
<configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
 <appSettings>
  <add key="Security_SwitchToSecureConnection" value="true" xdt:Transform="Replace" xdt:Locator="Match(key)"  />
 </appSettings>

 <connectionStrings>
  <add name="myConnectionSettings" 
connectionString="Data Source=stg.host.net;Initial Catalog=stg_db;UserId=myUsername;Password=myPassword" 
xdt:Transform="SetAttributes" 
xdt:Locator="Match(name)"/>
 </connectionStrings>

 <system.web>
  <customErrors mode="On" xdt:Transform="SetAttributes" ></customErrors>    
 </system.web>
</configuration>

В результате после компиляции получим файл “Web.staging.env.config” с примененными преобразованиями.

<?xml version="1.0"?>
<configuration>
 <appSettings>
  <add key="myKey" value="myValue"/>
  <add key="Security_SwitchToSecureConnection" value="true"/>
 </appSettings>

 <connectionStrings>
  <add name="myConnectionSettings" connectionString="Data Source=stg.host.net;Initial Catalog=stg_db;UserId=myUsername;Password=myPassword"/>
 </connectionStrings>

 <system.web>
  <customErrors mode="On" defaultRedirect="~/Content/static/generic_error.htm">
  </customErrors>

  <sessionState mode="InProc" timeout="10" cookieless="true"></sessionState>
 </system.web>
</configuration>

Transformations for Any.config

Ничего не мешает использовать подобные преобразования для любого файла в формате XML. Например файл преобразования для NLog.config файла.

<?xml version="1.0"?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
 <!-- ... -->
</nlog>

Deployment and automatic config file selection

Итак, на выходе имеем для каждого окружения файл преобразований (*.{target_environment}.build.config) и результирующий файл (*.{target_environment}.env.config). Осталось решить задачу выбора необходимого файла в зависимости от целевого окружения и подмены его в качестве обычного (*.config) файла.

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

  1. В зависимости от целевого {target_environment} определить нужный конфигурационный файл (*.{target_environment}.env.config).
  2. Скопировать его в качетве обычного конфигурационного файла: (*.{target_environment}.env.config) –> (*.config).
  3. Удалить все *.env.config.
  4. Удалить все *.build.config.

Ниже приведен отрывок Python кода, который выполняет эти задачи в рамках deployment скрипта.

config_prefix_for_target_env = "{0}.env.config".format(target_env.environment_name)
config_template_for_target_env = '*.' + config_prefix_for_target_env
print("Apply configuration files for '{0}' environment. Config file pattern is: '{1}'".format(
 target_env.environment_name,
 config_template_for_target_env))
env_config_files = [file for file
 in walkfiles(build_package_path_temp)
 if fnmatch.fnmatch(os.path.basename(file), "*.env.config")]
build_config_files = [file for file
 in walkfiles(build_package_path_temp)
 if fnmatch.fnmatch(os.path.basename(file), "*.build.config")]

# iterate through *.evn.config and apply if config match target environment
# then remove all *.evn.config configs
for file in env_config_files:
 if fnmatch.fnmatch(os.path.basename(file), config_template_for_target_env):
  shutil.copyfile(file, file.replace(config_prefix_for_target_env, 'config'))
 os.remove(file)

# remove all *.build.config
for file in build_config_files:
 os.remove(file)

The end

Сегодня был рассмотрен один из возможных вариантов управления конфигурацией веб приложения для разных окружений. При этом удалось уйти от необходимости ручной конфигурации приложения, внести конфигурационные параметры для разных окружений под управление системы контроля версий, сделать возможным применить ASP.NET web.config transformations для генерации конфигурационных файлов для разных окружений, а также иметь возможность компилировать приложение на машине, на которой установлен лишь .NET framework 4.0, и осуществлять сборку и развертывание автоматически при помощи собственного скрипта.

2 comments:

ShurikEv said...

Я для трансформаций использую следующую тулзу http://visualstudiogallery.msdn.microsoft.com/69023d00-a4f9-4a34-a6cd-7e854ba318b5
Так же позволяет автоматизировать трансформации на билд-сервере

Alexey Samoshkin said...

Глянул на "SlowCheetah - XML Transforms" инструмент, о которым Вы говорите.

По поводу отрицательных моментов:
1) Позволяет создавать трансформационные файлы только исходя из текущего набор Build Configuration (Debug, Release, etc.). Если хочется сделать (dev, stg, prod), а затем еще сделать (dev, stg_big_customer, stg_small_customer, prod_big_customer, stg_small_customer), то создавать такой же набор build конфигураций не хотелось бы.

2) Зависимость от SlowCheetah сборки и .targets.
%AppData%\Local\Microsoft\MSBuild\SlowCheetah\v1\SlowCheetah.Transforms.targets
То есть судя по всему для того чтобы запустить билд на другой машине, мне нужно установить там этот инструмент. Но в то же время, это все-таки Visual studio инструмент, да и распространяется он как *.vsix.

3) Ну и самый последний отрицательный момент для меня, это то что нужно ставить third-party инструмент для такой достаточно малой цели.

А вообще, стоит смотреть по ситуации, если инструмент решает задачу с минимальными затратами, и нет весомых аргументов против, то его стоит использовать. В нашем случае, он бы не подошел, так как мы в первую очередь хотим минимизировать внешние зависимости.

Расскажите пожалуйста, как вы используете его на билд сервере без установки на билд машине?

Post a Comment