Denis Gladkikh

outcoldman

My personal blog about software development

  • 08 Sep 2010
  • .NET, C#, CodePlex, XDT, XML-Document-Transform, Config Transformation Tool
  • 0 comments

Пару недель назад я писал про небольшую утилиту Config Transformation Tool, которую я создал на базе задачи трансформирования web.config файла. В тот момент у меня сразу же возникла идея, что было бы неплохо еще иметь возможность указывать места в файле-трансформере, вместо которых можно было бы подставлять значения при помощи этой утилиты. И вот, сегодня я готов объявить, что мне удалось решить эту задачу. Сначала хотелось бы поблагодарить AlexBar, за то, что он порекомендовал посмотреть глубже при помощи .Net Reflector в недры библиотеки Microsoft.WebApplication.Build.Tasks.Dll, и отыскать там класс Microsoft.Web.Publishing.Tasks.XmlTransformation, который умеет выполнять XML-Document-Transform для строк. Мне это очень сильно упростило реализацию. Чтобы утилита смогла поддерживать параметры мне предстояло решить две задачи: (а) уметь пробегаться по файлу и подставлять значения вместо параметров, (б) уметь парсить командную строку на предмет передачи параметров со значениями.

ParametersTask

Итак, сначала опишу, как я решал первую задачу. Синтаксис для параметров я решил сделать таким: {Имя_параметра:значение_по_умолчанию}, где значение по умолчанию это необязательный параметр. Правила для замены следующие:

  1. если указан параметр со значением, то использовать это значение;
  2. если параметр не указан, но указано значение по умолчанию, то подставлять его;
  3. если параметр не указан, и значение по умолчанию так же не указано, то оставить все как есть.

Перед тем как решать данную задачу я так же подумал о том, что RegEx или простыми string.Replace тут лучше не увлекаться, так как, если параметров будет много, то такая задача может выполняться очень долго. Потому я решил обойтись одним проходом по строке-трансформере, и, соответственно, за этот один проход подставить значения параметров. Так же я подумал о том, что в строке-трансформере могут применяться символы ‘{‘, ‘}’ не только для моих параметров, а для того, чтобы моя утилита их игнорировала нужно использовать комбинации “\}”, “\{“, ну и, соответственно, для самого символа ‘\’ так же используем комбинацию “\\”. Итак, класс ParametersTask имеет одно поле _parameters с типом IDictionary<string, string>, где ключи – это имена параметров, а значения – значения этих параметров. Основной метод ApplyParameters:

public string ApplyParameters(string sourceString)
{    StringBuilder result = new StringBuilder();
     int index = 0;
     char[] source = sourceString.ToCharArray();
     bool fParameterRead = false;
     StringBuilder parameter = new StringBuilder();
     while (index < source.Length)
    {        // If parameter read, read it and replace it
        if (fParameterRead && source[index] == '}')
        {
            var s = parameter.ToString();            int colonIndex = parameter.ToString().IndexOf(':');
 
            var parameterName = colonIndex > 0 ? s.Substring(0, colonIndex) : s;            var parameterDefaultValue = colonIndex > 0 ? s.Substring(colonIndex + 1, s.Length - colonIndex - 1) : null;
             string parameterValue = null;
            if (_parameters != null && _parameters.ContainsKey(parameterName))
                parameterValue = _parameters[parameterName];
             // Put "value" or "default value" or "string which was here"
            result.Append(parameterValue ?? parameterDefaultValue ?? "{" + parameter + "}");
             fParameterRead = false;
            index++;            continue; 
        }        if (source[index] == '{')
        {            fParameterRead = true;
            parameter = new StringBuilder();
            index++;
        }        // Check is this escape \{ \} \\
        else if (source[index] == '\\')
        {
            var nextIndex = index + 1;            if (nextIndex < source.Length)
            {
                var nextChar = source[nextIndex];                if (nextChar == '}' || nextChar == '{' || nextChar == '\\')
                {
                    index++;
                }
            }
        }
         if (fParameterRead)
            parameter.Append(source[index]);        else
            result.Append(source[index]);
 
        index++;
    }
     return  result.ToString();
}

Основная идея метода в том, что в цикле мы читаем либо параметр, либо просто содержание. В методе первый if на то, что это окончание параметра, второй if на то, что это начало параметра. Следующий пропускает специальные комбинации “\{”, “\}” или “\\”. Это, конечно, не полноценный “нисходящий разбор”, но, вроде выглядит ничего, и он отлично отрабатывает на следующих тестах:

[Test]public void ApplyParameters_Sample()
{    const string ExpectedResult =
        @"
<value key=""Value CustomParameter1"" value=""False"" />
<value key=""Test2"" value=""Value CustomParameter2"" />
<value key=""Test3"" value=""False"" />";
     const string Source =
        @"
<value key=""{CustomParameter1:Default value}"" value=""{TrueValueParameter:True}"" />
<value key=""Test2"" value=""{CustomParameter2:Default value of CustomParameter2}"" />
<value key=""Test3"" value=""{TrueValueParameter:True}"" />";
     ParametersTask task = new ParametersTask();
     task.AddParameters(new Dictionary<string, string>
                        {                            {"CustomParameter1", "Value CustomParameter1"},
                            {"TrueValueParameter", "False"},
                            {"CustomParameter2", "Value CustomParameter2"}
                        });
    var result = task.ApplyParameters(Source);
    Assert.AreEqual(ExpectedResult, result);
}
 
[Test]public void WithoutParameters()
{    const string Source =
        @"
<value key=""{CustomParameter1}"" value=""{TrueValueParameter}"" />
<value key=""Test2"" value=""{CustomParameter2}"" />
<value key=""Test3"" value=""{TrueValueParameter}"" />";
     ParametersTask task = new ParametersTask();
    var result = task.ApplyParameters(Source);
    Assert.AreEqual(Source, result);
}
 
[Test]public void WithoutParameters_But_With_Default_Values()
{    const string ExpectedResult =
        @"
<value key=""Default value"" value=""True"" />
<value key=""Test2"" value=""Default value of CustomParameter2"" />
<value key=""Test3"" value=""False"" />";
     const string Source =
        @"
<value key=""{CustomParameter1:Default value}"" value=""{TrueValueParameter:True}"" />
<value key=""Test2"" value=""{CustomParameter2:Default value of CustomParameter2}"" />
<value key=""Test3"" value=""{TrueValueParameter:False}"" />";
     ParametersTask task = new ParametersTask();
    var result = task.ApplyParameters(Source);
    Assert.AreEqual(ExpectedResult, result);
}
 
[Test]public void Apply_With_Double_Colon_In_Definition()
{    const string ExpectedResult =
        @"
<value key=""Default:value"" value=""Val"" />";
     const string Source =
        @"
<value key=""{Parameter1:Default:value}"" value=""Val"" />";
     ParametersTask task = new ParametersTask();
    var result = task.ApplyParameters(Source);
    Assert.AreEqual(ExpectedResult, result);
}
 
[Test]public void Apply_With_Escaped_Brackets()
{    const string ExpectedResult =
        @"
<value key=""Default:value"" value=""{TestParameter:Test}"" />";
     const string Source =
        @"
<value key=""{Parameter1:Default:value}"" value=""\{TestParameter:Test\}"" />";
     ParametersTask task = new ParametersTask();
    var result = task.ApplyParameters(Source);
    Assert.AreEqual(ExpectedResult, result);
}
 
[Test]public void Apply_With_Escaped_Brackets_In_Default_Value()
{    const string ExpectedResult =
        @"
<value key=""Defa{ultva}lue"" value=""{TestParameter:Test}"" />";
     const string Source =
        @"
<value key=""{Parameter1:Defa\{ultva\}lue}"" value=""\{TestParameter:Test\}"" />";
     ParametersTask task = new ParametersTask();
    var result = task.ApplyParameters(Source);
    Assert.AreEqual(ExpectedResult, result);
}
 
[Test]public void Apply_With_Parameter_At_End_Of_String()
{    const string ExpectedResult =
        @"
<value key=""Defa{ultva}lue"" value=""Test";
     const string Source =
        @"
<value key=""{Parameter1:Defa\{ultva\}lue}"" value=""{TestParameter:Test}";
     ParametersTask task = new ParametersTask();
    var result = task.ApplyParameters(Source);
    Assert.AreEqual(ExpectedResult, result);
}
 
[Test]public void Apply_With_Parameter_At_Start_Of_String()
{    const string ExpectedResult =
        @"Defa{ultva}lue"" value=""{TestParameter:Test}"" />";
     const string Source =
        @"{Parameter1:Defa\{ultva\}lue}"" value=""\{TestParameter:Test\}"" />";
     ParametersTask task = new ParametersTask();
    var result = task.ApplyParameters(Source);
    Assert.AreEqual(ExpectedResult, result);
}

ParametersParser

Вторая задача – это уметь распарсить параметры из командной строки. Реализовать эту функциональность я решил способом, который используется в MsBuild.exe, ну или очень похожим на него. Параметры должны быть разделены точкой с запятой ‘;’, имя параметра и значение должно разделять двоеточие ‘:’, если значение параметра включает в себя пробелы или точку запятой, то это значение лучше заключить в кавычки, и так же есть возможность использовать ‘\”’, ‘\\’. Реализация ниже:

/// <summary>
/// Parse string of parameters 
/// </summary>
public static class ParametersParser
{    private readonly static ILog Log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); 
     /// <summary>
    /// Parse string of parameters <paramref name="parametersString"/> separated by semi ';'.
    /// Value should be separated from name by colon ':'. 
    /// If value has spaces or semi you can use quotes for value. 
    /// You can escape symbols '\' and '"' with \.
    /// </summary>
    /// <param name="parametersString">String of parameters</param>
    /// <returns>Dicrionary of parameters, where keys are names and values are values of parameters. 
    /// Can be null if <paramref name="parametersString"/> is empty or null.</returns>
    public static IDictionary<string, string> ReadParameters(string parametersString)
    {        if (string.IsNullOrWhiteSpace(parametersString)) return null;
         Dictionary<string, string> parameters = new Dictionary<string, string>();
 
        var source = parametersString.ToCharArray();
         int index = 0;
         bool fParameterNameRead = true;
        bool fForceParameterValueRead = false;
         StringBuilder parameterName = new StringBuilder();
        StringBuilder parameterValue = new StringBuilder();
         while (index < source.Length)
        {            if (fParameterNameRead && source[index] == ':')
            {                fParameterNameRead = false;
                index++;
                 if (index < source.Length && source[index] == '"')
                {                    fForceParameterValueRead = true;
                    index++;
                }
                 continue;
            }
             if ((!fForceParameterValueRead && source[index] == ';')
                || (fForceParameterValueRead && source[index] == '"' && ((index + 1) == source.Length || source[index + 1] == ';')))
            {
                AddParameter(parameters, parameterName, parameterValue);
                index++;                if (fForceParameterValueRead)
                    index++;
                parameterName.Clear();
                parameterValue.Clear();                fParameterNameRead = true;
                fForceParameterValueRead = false;
                continue;
            }
             // Check is this escape \{ \} \\
            if (source[index] == '\\')
            {
                var nextIndex = index + 1;                if (nextIndex < source.Length)
                {
                    var nextChar = source[nextIndex];                    if (nextChar == '"' || nextChar == '\\')
                    {
                        index++;
                    }
                }
            }
             if (fParameterNameRead)
            {
                parameterName.Append(source[index]);
            }            else
            {
                parameterValue.Append(source[index]);
            }
 
            index++;
        }
 
        AddParameter(parameters, parameterName, parameterValue);
         if (Log.IsDebugEnabled)
        {            foreach (var parameter in parameters)
            {                Log.DebugFormat("Parameter Name: '{0}', Value: '{1}'", parameter.Key, parameter.Value);
            }
        }
         return parameters;
    }
     private static void AddParameter(Dictionary<string, string> parameters, StringBuilder parameterName, StringBuilder parameterValue)
    {
        var name = parameterName.ToString();        if (!string.IsNullOrWhiteSpace(name))
        {            if (parameters.ContainsKey(name))
                parameters.Remove(name);
            parameters.Add(name, parameterValue.ToString());
        }
    }
}

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

/// <summary>
/// Check simple parameters command line
/// </summary>
[Test]public void Sample()
{    const string parametersLine = "Parameter1:Value1;Parameter2:121.232";
 
    var parameters = ParametersParser.ReadParameters(parametersLine);
     Assert.AreEqual("Value1", parameters["Parameter1"]);
    Assert.AreEqual("121.232", parameters["Parameter2"]);
}
 /// <summary>
/// Check parameters command line when one of parameter has semi in value string
/// </summary>
[Test]public void String_With_Semicolon_In_Value()
{    const string parametersLine = "Parameter1:Value1;Parameter2:\"121;232\"";
 
    var parameters = ParametersParser.ReadParameters(parametersLine);
     Assert.AreEqual("Value1", parameters["Parameter1"]);
    Assert.AreEqual("121;232", parameters["Parameter2"]);
}
 /// <summary>
/// Check that if command line has semicon at end parameters will be loaded
/// </summary>
[Test]public void String_With_Semicolon_At_End()
{    const string parametersLine = "Parameter1:Value1;Parameter2:\"121.232\";";
 
    var parameters = ParametersParser.ReadParameters(parametersLine);
     Assert.AreEqual("Value1", parameters["Parameter1"]);
    Assert.AreEqual("121.232", parameters["Parameter2"]);
}
 /// <summary>
/// Check that value of parameter can contain escaped quotes
/// </summary>
[Test]public void String_With_Values_With_Quotes()
{    const string parametersLine = @"Parameter1:Value1;Parameter2:""12\""1.2\""32"";";
 
    var parameters = ParametersParser.ReadParameters(parametersLine);
     Assert.AreEqual("Value1", parameters["Parameter1"]);
    Assert.AreEqual("12\"1.2\"32", parameters["Parameter2"]);
}

Результат

В итоге теперь можно при помощи утилиты проработать такой пример. Исходный файл (s.config):

<?xml version="1.0"?>
 <configuration>
     <custom>
        <groups>
            <group name="TestGroup1">
                <values>
                    <value key="Test1" value="False" />
                    <value key="Test2" value="600" />
                </values>
            </group>
             <group name="TestGroup2">
                <values>
                    <value key="Test3" value="C:\Test\" />
                </values>
            </group>
         </groups>
    </custom>
</configuration>

Теперь файл трансформации (t.config), в нем описано два параметра, один Parameter1, значение которого указывать необязательно, и параметр с именем Test3Value:

<?xml version="1.0"?>
<configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
    <custom>
        <groups>
            <group name="TestGroup1">
                <values>
                    <value key="Test2" value="601" xdt:Transform="Replace"  xdt:Locator="Match(key)" />
                    <value key="Test1" value="{Parameter1:True5665}" xdt:Transform="Replace"  xdt:Locator="Match(key)" />
                </values>
            </group>
            <group name="TestGroup2">
                <values>
                    <value key="Test3" value="{Test3Value}" xdt:Transform="Replace"  xdt:Locator="Match(key)" />
                </values>
            </group>
        </groups>
    </custom>
</configuration>

Вызываем утилиту:

ctt s:s.config t:t.config d:d.config p:Parameter1:True;Test3Value:"c:\Program Files\Test"

Как и следовало ожидать получаем d.config:

<?xml version="1.0"?>
<configuration>
  <custom>
    <groups>
      <group name="TestGroup1">
        <values>
          <value key="Test1" value="True" />
          <value key="Test2" value="601" />
        </values>
      </group>
      <group name="TestGroup2">
        <values>
          <value key="Test3" value="c:\Program Files\Test" />
        </values>
      </group>
    </groups>
  </custom>
</configuration>

К аргументам утилиты еще добавил fpt – этот параметр следует применять, когда в файле-трансформере находятся параметры со значениями по умолчанию, а в командной строке вызова утилиты значения параметров не указываете, тогда под действием этого параметра утилиты пробежится и все-таки проставит значения по-умолчанию. Без параметра утилита не вызывает задача подстановки параметров, если в командной строке параметры со значениями не указаны.

Итог

Скорее всего есть очень много проблемных мест и недочетов, если видите что-то, либо знаете коварный тест, где точно не проработает – отпишитесь, пожалуйста, в комментариях, постараюсь все допилить. Ну и буду рад любым замечаниям и предложениям. Исходники и собранную версию можно скачать на сайте проекта на CodePlex: http://ctt.codeplex.com, последняя версия Config Transformation Tool v1.1.

Comments