При работе с веб-проектами в Visual Studio существует одна неприятная мелочь — при использовании в процессе разработки нескольких бранчей, каждый из которых должен использовать свою копию окружения (например базу данных, или какие то внешние сервисы), возникает проблема с конфигурационными файлами в момент отладки — IISExpress использует только основной web.config в папке проекта, где обычно всякие connection strings содержат значения по умолчанию и где нет никаких специфичных для бранча настроек, и никаких трансформаций при запуске к нему не применяется. Можно конечно принудительно либо автоматически, либо вручную, применять трансформации к web.config, но во-первых измененный файл будет постоянно висеть в pending changes, что создает риск коммита нежелательных изменений, которые потом попадут в другие бранчи, а во-вторых это создает массу неудобств при его редактировании, поскольку перед коммитом каких-либо изменений в конфигурационном файле такие трансформации придется вручную убирать.
Рассмотрим как этого избежать.
Решение довольно простое — необходимо перехватывать чтение конфигурационного файла процессом IISExpress, и вместо исходного файла подсунуть другой, временный файл в котором внесены соответствующие исправления, и который не добавлен в Source Control. Список исправлений которые необходимо применить в зависимости от того из какой папки запускается проект можно указывать, к примеру, в простом xml файле.
Для этого понадобятся:
Фоновая утилита следящая за созданием новых процессов, 32 и 64-битные dll c хуками, 32 и 64-битные exe запускаемые фоновой утилитой и загружающие соответствующую dll с хуком в процесс соответствующей разрядности.
Фоновая утилита следит за процессами через WMI используя класс ManagementEventWatcher и запрос к __InstanceOperationEvent с фильтрацией по типу объекта Win32_Process и требуемым именам процессов. Получение события ____InstanceCreationEvent означает что был создан процесс, информацию о котором можно получить из EventArrivedEventArgs.NewEvent. В данном случае необходим только ProcessId.
processWatcher.Query.QueryString = @"SELECT * FROM __InstanceOperationEvent WITHIN 1" +
"WHERE TargetInstance ISA 'Win32_Process' AND (" +
string.Join(" OR ", processNames.Select(x => "TargetInstance.Name = '" + x + ".exe'")) + ")";
processWatcher.EventArrived += (sender, e) =>
{
if (e.NewEvent.ClassPath.ClassName == "__InstanceCreationEvent")
{
var processId = (uint)((ManagementBaseObject)e.NewEvent["TargetInstance"])
.Properties["ProcessId"].Value;
// ... Do smth useful
}
};
Алгоритм загрузки dll в чужой процесс стандартный — в чужом процессе через VirtualAllocEx выделяется память под путь к dll и создается поток путем передачи адреса LoadLibrary в CreateRemoteThread. Если код инициализации хуков находится в DllMain, то никаких дополнительных действий не понадобится. Но фоновая утилита самостоятельно внедрять dll и в 32 и в 64-битные процессы одновременно не сможет. Теоретически конечно вызов CreateRemoteThread из 64-битного процесса может создать поток в 32-битном процессе, но в данном случае в качестве функции для потока используется LoadLibrary. А максимально простым способом через GetProcAddress можно получить ее адрес только для той же разрядности что и текущий процесс. У kernel32.dll fixed base address, поэтому для разных процессов одной разрядности адрес функции совпадает. В теории конечно можно бы было вручную разбирать PE-заголовки и не использовать дополнительные процесы для внедрения, но это сложнее.
Перехватывать надо конечно же функцию CreateFileW. Сначала я весь код написал полностью на C#, но на практике с перехватом некоторых слишком фундаментальных функций вроде этой возникают ошибки с loader lock и им подобные, когда managed код вызывается, например, из DllMain каких-либо сторонних библиотек подгружаемых процессом. Поэтому пришлось установку хуков и фильтрацию вызовов требующих обработки вынести в native dll на C, которая в свою очередь загружает managed dll и вызывает managed код оттуда только когда CreateFileW вызывается для .config файлов. Для установки хуков я использовал проверенную временем стороннюю библиотеку MinHook, про нее в интернете много информации и останавливаться на ее описании не буду. Возможно, у кого то возникнет вопрос — 'а не проще ли было все полностью сделать на C и не создавать кучу .net сборок', возможно да, но это скучно.
Логика фильтрации должна проверять что файл существует, является файлом а не директорией, содержит в имени web.config, не расположен в папке windows\microsoft.net\… (системные конфиги нас не интересуют). Если все эти условия выполняются то передаем HANDLE, полученный от вызова исходной системной функции CreateFileW перехватчиком, а также все параметры CreateFileW в managed обработчик, который по этому HANDLE прочтет содержимое. Для простоты лучше использовать оригинальный HANDLE чтобы не делать защиту от рекурсии, к которой приведет чтение того же самого файла каким-нибудь File.ReadAllText, поскольку сработает этот же хук. Далее, в полученном содержимом заменяются все необходимые строки и измененное содержимое записывается во временный файл у которого имя не соответствует вышеописанным критериям фильтрации (опять же чтобы не попасть в рекурсию). Вызываем CreateFileW для этого временного файла с теми же параметрами с которыми был открыт web.config и полученный HANDLE возвращаем из перехватчика CreateFileW. Исходный HANDLE уже не нужен и его следует закрыть.
HANDLE WINAPI _CreateFileW(
LPCWSTR lpFileName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile)
{
DWORD attributes = GetFileAttributesW(lpFileName);
HANDLE result = CreateFileWOriginal(lpFileName,
dwDesiredAccess,
dwShareMode,
lpSecurityAttributes,
dwCreationDisposition,
dwFlagsAndAttributes,
hTemplateFile);
HANDLE newFile = NULL;
if (attributes != INVALID_FILE_ATTRIBUTES &&
(attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 &&
(StrStrI(lpFileName, L"web.config") != NULL || StrStrI(lpFileName, L"app.config") != NULL) &&
StrStrI(lpFileName, L"Windows") == NULL)
{
fileHandler(result, &newFile, dwDesiredAccess, dwShareMode, lpSecurityAttributes,
dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile);
}
if (newFile != NULL)
{
CloseHandle(result);
result = newFile;
}
return result;
}
public static void GetUpdatedConfigF(IntPtr handle, IntPtr newHandleAddress,
uint access, uint share, IntPtr securityAttributes, uint creationDisposition,
uint flagsAndAttributes, IntPtr templateFile)
{
try
{
if (config == null)
return;
var path = new StringBuilder(260);
if (GetFinalPathNameByHandle(handle, path, (uint)path.Capacity, 0) == 0)
return;
var matchedSection = config.FirstOrDefault(x =>
path.ToString().IndexOf(x.Branch, StringComparison.OrdinalIgnoreCase) >= 0);
if (matchedSection == null)
return;
var size = GetFileSize(handle, IntPtr.Zero);
if (size == 0)
return;
var buffer = new byte[size];
uint bytesRead;
if (!ReadFile(handle, buffer, (uint)buffer.Length, out bytesRead, IntPtr.Zero))
return;
var content = Encoding.UTF8.GetString(buffer);
foreach (var replacement in matchedSection.Replacements)
content = content.Replace(replacement.Find, replacement.ReplaceWith);
var tempFile = Path.GetTempFileName();
MoveFileEx(tempFile, null, 4);
File.WriteAllText(tempFile, content);
var newHandle = CreateFileW(tempFile, access, share, securityAttributes,
creationDisposition, flagsAndAttributes, templateFile);
Marshal.WriteIntPtr(newHandleAddress, newHandle);
}
catch
{
}
}
Native dll с хуком вызывает обработчик из managed dll через LoadLibrary и GetProcAddress. Для этого необходимо экспортировать статический метод как обычную dll функцию. Это делается немного шаманским способом через дизассемблирование ildasm-ом, добавление специальных опций к методу в il-коде и ассемблирование обратно в dll. Об этом тоже в интернете много статей, повторяться не буду, их легко найти поискав например ".vtentry". В исходном коде присутствует простейшая утилита обрабатывающая таким образом сборки.
Помимо IISExpress схожая проблема актуальна и для wcf-сервисов запускаемых через wcfsvchost. Правда в этом случае применение трансформаций работает нормально, но чтобы все было единообразно и чтобы не клонировать лишних файлов с трансформациями и не переключать ничего в Configuration Manager-е, рассмотрим и этот случай. Здесь есть некоторые отличия — wcfsvchost читает конфигурацию сразу при старте процесса, а WMI событие приходит слишком поздно, и хук устанавливается позже чем надо. Но путь к файлу конфигурации передается через командную строку, поэтому внедряться следует в parent-процесс и перехватывать CreateProcessW.
Parent-процесс в данном случае devenv.exe, т.е. Visual Studio. В этом случае, перед тем как вызвать исходную системную функцию CreateProcessW, в managed обработчик передаем строку параметров с которыми создается процесс и адрес массива куда запишется исправленная строка. В обработчике строка разбивается на параметры путем вызова CommandLineToArgvW, далее среди них определяется путь к файлу конфигурации, а затем аналогично создается временный файл с исправленным содержимым и путь в параметрах подменяется на него.
BOOL WINAPI _CreateProcessW(LPCWSTR lpApplicationName,
LPWSTR lpCommandLine,
LPSECURITY_ATTRIBUTES lpProcessAttributes,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
BOOL bInheritHandles,
DWORD dwCreationFlags,
LPVOID lpEnvironment,
LPCWSTR lpCurrentDirectory,
LPSTARTUPINFOW lpStartupInfo,
LPPROCESS_INFORMATION lpProcessInformation)
{
BOOL result;
LPWSTR buffer = NULL;
if (lpCommandLine != NULL && StrStrI(lpCommandLine, L".config") != NULL)
{
buffer = (LPWSTR)malloc(BUFFER_SIZE);
memset(buffer, 0, BUFFER_SIZE);
processHandler(lpCommandLine, buffer);
lpCommandLine = buffer;
}
result = CreateProcessWOriginal(lpApplicationName, lpCommandLine,
lpProcessAttributes, lpThreadAttributes, bInheritHandles, dwCreationFlags,
lpEnvironment, lpCurrentDirectory, lpStartupInfo, lpProcessInformation);
if (buffer != NULL)
free(buffer);
return result;
}
public static void GetUpdatedConfigP(IntPtr commandLine, IntPtr newCommandLine)
{
var commandLineText = Marshal.PtrToStringUni(commandLine);
try
{
int numArgs;
var argArray = CommandLineToArgvW(commandLineText, out numArgs);
if (argArray != IntPtr.Zero)
{
var pointerArray = new IntPtr[numArgs];
Marshal.Copy(argArray, pointerArray, 0, numArgs);
var arguments = pointerArray.Select(x => Marshal.PtrToStringUni(x)).ToArray();
var configFile = arguments.FirstOrDefault(x =>
x.EndsWith(".config", StringComparison.OrdinalIgnoreCase));
var matchedSection = config.FirstOrDefault(x => configFile.ToString()
.IndexOf(x.Branch, StringComparison.OrdinalIgnoreCase) >= 0);
if (matchedSection != null && configFile != null &&
configFile.StartsWith("/config:", StringComparison.OrdinalIgnoreCase) &&
commandLineText.IndexOf("wcfsvchost", StringComparison.OrdinalIgnoreCase) >= 0)
{
configFile = configFile.Substring("/config:".Length);
var content = File.ReadAllText(configFile);
foreach (var replacement in matchedSection.Replacements)
content = content.Replace(replacement.Find, replacement.ReplaceWith);
var tempFile = Path.GetTempFileName();
MoveFileEx(tempFile, null, 4);
File.WriteAllText(tempFile, content);
commandLineText = commandLineText.Replace(configFile, tempFile);
}
}
}
catch
{
}
Marshal.Copy(commandLineText.ToCharArray(), 0, newCommandLine, commandLineText.Length);
}
> Код к статье (перфекционистов просьба не возмущаться — это минимально рабочий вариант сделанный наспех, без обработки ошибок и со многими допущениями)
Комментарии (11)
ZerGer
14.02.2017 11:12+1Я решал эту задачу используя штатный Web.config Transformation:
1) Добавляем /Web.config в .gitignore
2) Создаем конфигурации для требуемых вариантов сборки:
- /App_Config/Web.config
- /App_Config/Web.Release.config
- /App_Config/Web.Stage.config
3) Добавляем небольшой код в файл проекта:
<PropertyGroup> <SiteConfigDir>$(MSBuildProjectDirectory)\App_Config</SiteConfigDir> <SiteRootDir>$(MSBuildProjectDirectory)</SiteRootDir> </PropertyGroup> <Target Name="ProcessConfigFiles"> <TransformXml Condition="Exists('$(SiteConfigDir)\Web.$(Configuration).config')" Source="$(SiteConfigDir)\Web.config" Transform="$(SiteConfigDir)\Web.$(Configuration).config" Destination="$(SiteRootDir)\Web.config" StackTrace="true" /> <Copy Condition="!Exists('$(SiteConfigDir)\Web.$(Configuration).config')" SourceFiles="$(SiteConfigDir)\Web.config" DestinationFolder="$(SiteRootDir)" OverwriteReadOnlyFiles="true" /> </Target> <Target Name="AfterBuild"> <CallTarget Targets="ProcessConfigFiles" /> </Target>
Pros:
- \Web.config не попадает в pending changes
- все варианты настроек хранятся в git.
Cons:
- нужно не забывать, что правки требуется вносить в /App_Config/Web.config, а не в /Web.config.
- после изменения конфигурации нужно пересобрать проект
pokryshkin
14.02.2017 14:59Дополню: я еще выношу appSettings во внешний файл (через атрибут file), а версия для продакшена в git вообще не существует (т.к. там хранятся приватные ключи для различных сервисов).
ZerGer
14.02.2017 15:54Ну, в общем случае настройка окружения не ограничивается только appSettings. Как минимум, еще будет system.net\mailSettings, connectionStrings, system.web\customErrors, содержимое system.serviceModel, настройки логов (nlog и/или system.diagnostic). Так что держать все это в одном файле, как мне кажется, проще.
OfftopЕсли честно, то приведенный выше пример я давно не использую. Сейчас предпочитаю хранить в appSettings один параметр DeployMode, на основе которого производится конфигурация IoC-контейнера. Получается более гибко, плюс строгая типизация не даст что-нибудь забыть.
pokryshkin
14.02.2017 17:05Как правило «секретное» хранится в appSettings и mailsettings, которые и можно вынести во внешние файлы, чтобы случайно или специально не засветить их где-то. Все остальное да, трансформациями и через контроль версий.
mayorovp
14.02.2017 17:16А как же connectionStrings?
pokryshkin
14.02.2017 17:19а что в нем секретного? Если команда маленькая, то все разработчики и так имеют доступ к боевой БД, если большая, то ИМХО они даже имея connectionString с логином и паролем не должны иметь доступ к базе.
chumakov-ilya
А можно просто создать по конфигурации на бранч и включить трансформацию конфигов при каждой сборке проекта (не путать с трансформацией при деплое). Например, так:
Web.Base.Config + Web.$(Configuration).config = Web.Config
То, что собранный
Web.Config
будет попадать в pending changes — не имеет никакого значения, поскольку он все равно перезапишется при следующем билде соответственно выбранной конфигурации. Оригинал хранится вWeb.Base.Config
и правится редко. На билд-сервере трансформация тоже будет работать.Einherjar
Будут пляски с бубном с nuget пакетами к примеру, которые что то пишут в конфиг в момент установки, т.к. писать они будут не в оригинал Web.Base.Config а в генерируемый Web.Config, который перезапишется при первом билде стерев эти изменения. Ну это конечно сравнительно редко необходимо, и действительно можно пренебречь и вручную все скопировать ради простоты решения, особенно если ответственный за установку и обновление пакетов знает что на это следует обращать внимание, но тем не менее.
chumakov-ilya
Будут. Но на практике, основной проблемой, с которой мы сталкивались, оказалось — приучить коллег проверять, у каждого ли проекта в солюшене выставлена правильная конфигурация в Configuration Manager. И не забывать добавлять наши кастомные конфигурации для новых проектов, что, впрочем, легко диагностировалось по падению сборки на билд-сервере.
mayorovp
Наоборот удобно — в web.config не попадает всякий мусор. А нужные вещи можно и перенести вручную...
mayorovp
Чтобы собранный web.config не попадал в pending changes — его надо добавлять в .gitignore (или анологичный список файлов для других VCS)!