вторник, 10 мая 2011 г.

DynamicXml: “динамическая” оболочка для работы с XML данными

Я уже однажды писал о том, что, несмотря на мою любовь к статической типизации, в некоторых сценариях преимущества от той свободы, которую дает динамическая типизация, может превосходить связанные с ней недостатки. В прошлый раз шла речь о Dynamic LINQ, а в этот раз речь пойдет об использовании новой возможности C# 4.0 под названием dynamic, для работы с такими исходно слаботипизированными данными, как XML.

ПРИМЕЧАНИЕ
Исходный код библиотеки DynamicXml, рассматриваемой в данной статье, доступен на
github.

Введение

Начиная с версии 4.0, в языке C# появилась поддержка динамического программирования, благодаря новому статическому типу под названием dynamic. По сути, применение этого ключевого слова говорит компилятору сгенерировать весь необходимый код, чтобы процесс привязки (binding) и диспетчеризации вызовов (dispatch operations) производился во время выполнения, вместо определения всех этих характеристик во время компиляции. При этом компилятор генерирует весь необходимый код с помощью библиотеки DLR – Dynamic Language Runtime (*), которая была изначально создана при проектировании языка программирования Iron Python и впоследствии вошла в состав .Net Framework 4.0, как основа для реализации динамических языков программирования, а также для взаимодействия между ними.

Несмотря на появление ключевого слова dynamic язык программирования C# остался в своей основе статически типизированным; вам все еще нужно явно указать, что решение о том, что именно будет происходить с этим кодом, откладывается до времени выполнения. Кроме того, никто не предполагает, что этой возможностью будут пользоваться ежедневно; эта функция предназначена, прежде всего, для взаимодействия с другими динамически типизированными языками, как Iron Python, Iron Ruby, а также для взаимодействия со слаботипизированным окружением, таким как VSTO (Visual Studio Tools for Office) и другими СОМ API. Еще одним классическим примером использования dynamic является создание «динамических» оболочек над объектами. Весьма известным примером является создание оболочки для доступа к закрытым или защищенным членам класса (**); другим не менее известным примером является создание динамической оболочки для доступа к XML данным. Вот именно на реализации второй возможности мы здесь и остановимся.

Простой пример чтения XML данных

Итак, давайте предположим, что у нас есть строка, в которой содержатся следующие данные (***):

<books>
  <book>
    <title>Mortal Engines</title>
    <author name=""Philip Reeve"" />
  </book>
  <book>
    <title>The Talisman</title>
    <author name=""Stephen King"" />
    <author name=""Peter Straub"" />
  </book>
</books>

И нашей задачей является написание простенького кода, который будет читать и обрабатывать эти данные. Конечно же, в некоторых случаях разумнее десериализировать все это добро в некоторый объект бизнес-логики (в данном случае в список сущностей типа Book) с помощью класса XmlSerializer и манипулировать уже этим бизнес-объектом, однако во многих случаях значительно лучше подойдет более легковесное решение, например, на основе LINQ 2 XML.

Если предположить, что приведенная выше строка содержится в переменной с именем books, то для получения названия для получения некоторых данных можно воспользоваться весьма простым кодом:

var element = XElement.Parse(books); 
string firstBooksTitle =
    element.Element("book").Element("title").Value;
Assert.That(firstBooksTitle, Is.EqualTo("Mortal Engines"));

string firstBooksAuthor =
    element.Element("book").Element("author").Attribute("name").Value;
Assert.That(firstBooksAuthor, Is.EqualTo("Philip Reeve"));

string secondBooksTitle =
    element.Elements().ElementAt(1).Element("title").Value;
Assert.That(secondBooksTitle, Is.EqualTo("The Talisman"));           

Я совершенно ничего не имею против явного использования XElement, более того, этот вариант достаточно простой и элегантный, но тем не менее этот код не лишен недостатков. Во-первых, он достаточно многословен, а во-вторых, он не совсем честен по одной отношению к обработке ошибок: если в переменной books не будет элемента с именем book или элемента с именем title мы получим NullReferenceException. Так что этот код нужно доработать напильником, что несколько усложнит его чтение, понимание и сопровождение.

// Получаем Dynamic Wrapper над объектом XElement
dynamic dynamicElement = // ...

// Получаем автора первой книги
string firstBooksTitle = dynamicElement.book.title;
Assert.That(firstBooksTitle, Is.EqualTo("Mortal Engines"));
 
// С помощью индексатора, принимающего строку, получаем доступ к атрибуту элемента
string firstBooksAuthor = dynamicElement.book.author["name"];
Assert.That(firstBooksAuthor, Is.EqualTo("Philip Reeve"));
 
// С помощью индексатора, принимающего целое число, получаем доступ ко второй книге
string secondBooksTitle = dynamicElement.book[1].title;
Assert.That(secondBooksTitle, Is.EqualTo("The Talisman"));

Нам все еще нужно использовать индексатор для доступа к значениям атрибутов, поскольку приходится разделять доступ к элементу от доступа к атрибуту, но поскольку, как мы увидим позднее, все полностью в наших руках, то мы можем принять другой решение и реализовать доступ к атрибуту с помощью другого синтаксиса. Тем не менее, полученный синтаксис является более простым и понятным, нежели код с непосредственным использованием LINQ 2 XML и нам осталось ответить на один простой вопрос: что же такое должно скрываться за комментарием “получаем Dynamic Wrapper над объектом XElement”, чтобы подобная уличная магия была возможна.

Создание «динамической» оболочки для чтения XML данных

Наиболее простым способом создания динамической оболочки, которая при этом будет обладать достаточно широкой функциональностью, является использование класса DynamicObject из пространства имен System.Dynamic. Данный класс содержит несколько виртуальных функций вида TryXXX, которые позволяют «перехватывать» все основные действия с вашим динамическим объектом, которые будут происходить с ним во время выполнения, включая вызовы методов, обращение к свойствам, преобразование типов и многие другие.

Таким образом, все, что нам нужно сделать, это создать класс наследник от DynamicObject, который бы принимал в качестве параметра конструктора объект XElement и переопределял ряд вспомогательных методов:

/// <summary>
/// "Динамическая оболочка" вокруг XElement
/// </summary>
public class DynamicXElementReader : DynamicObject
{
    private readonly XElement element;

    private DynamicXElementReader(XElement element)
    {
        this.element = element;
    }

    public static dynamic CreateInstance(XElement element)
    {
        Contract.Requires(element != null);
        Contract.Ensures(Contract.Result<object>() != null);

        return new DynamicXElementReader(element);
    }
}

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

Теперь давайте вернемся к реализации класса DynamicXElementReader. Вначале немного теории: любое обращение к свойству или методу класса наследника от DynamicObject происходит в два этапа: вначале выполняется поиск соответствующего метода или свойства с одноименным именем в этом самом наследнике, а затем уже вызывается соответствующий метод, в котором можно обработать отсутствие данного члена динамически. Поскольку никакая обертка никогда не предоставит абсолютно всю мыслимую и немыслимую функциональность (да в большинстве случаев это и не нужно), то нужно обеспечить получение из обертки нижележащего XElement. Кроме того, как мы видели в предыдущем примере, нам нужно сделать два индексатора: один должен принимать int и возвращать подэлемент, а второй – принимать строку (или, как мы увидим позднее XName) и возвращать атрибут.

public class DynamicXElementReader : DynamicObject
{
    /// <summary>
    /// Возвращает true, если текущий элемент содержит родительский узел.
    /// </summary>
    /// <remarks> 
    /// Атрибут Pure позволяет использовать этот метод в предусловиях любых методов
    /// </remarks>
    [Pure]
    public bool HasParent()
    {
        return element.Parent != null;
    }

    public dynamic this[XName name]
    {
        get
        {
            Contract.Requires(name != null);

            XAttribute attribute = element.Attribute(name);

            if (attribute == null)
                throw new InvalidOperationException(
                    "Attribute not found. Name: " + name.LocalName);

            return attribute.AsDynamic();
        }
    }

    public dynamic this[int idx]
    {
        get
        {

            Contract.Requires(idx >= 0, "Index should be greater or equals to 0");
            Contract.Requires(idx == 0 || HasParent(),
                "For non-zero index we should have parent element");

            // Доступ по нулевому индексу означает доступ к самому текущему элементу
            if (idx == 0)
                return this;

            // Доступ по ненулевому индексу означает поиск "брата" текущего элемента.
            // Для этого необходимо, чтобы текущий элемент содержал родительский узел
            var parent = element.Parent;
            Contract.Assume(parent != null);

            XElement subElement = parent.Elements().ElementAt(idx);

            // subElement не может быть равен null, поскольку метод ElementAt генерирует
            // исключение, если подэлемента с указанным индексом не существует.
            // Однако статический анализатор об этом не знает, поэтому мы подсказываем
            // ему об этом с помощью метода Contract.Assume
            Contract.Assume(subElement != null);

            return CreateInstance(subElement);
        }
    }

    public XElement XElement { get { return element; } }

}

Первый индексатор, принимает XName в качестве параметра и предназначен для получения атрибута текущего элемента по его имени. Типом возвращаемого значения также является dynamic, а реальное возвращаемое значение получается в результате вызова метода расширения AsDynamic для объекта XAttribute. В принципе, никто не мешает в качестве типа возвращаемого значения использовать тип XAttribute, однако в этом случае, для получения непосредственного значения атрибута придется дополнительно обратиться к свойству Value, полученного значения, либо воспользоваться явным приведением типа. В целом же, реализация динамической оболочки для атрибутов значительно проще, и реализована аналогичным образом.

Теперь давайте перейдем к реализации двух главных (для этого класса) виртуальных методов класса DynamicObject: метода TryGetMember – который отвечает за доступ к свойству или полю вида dynamicObject.Member, а также метода TryConvert – который вызывается при неявном преобразовании типа из динамического типизированного объекта к статически типизированному, string value = dynamicObject.

public class DynamicXElementReader : DynamicObject
{
    /// <summary>
    /// Этот метод вызывается в случае использования оболочки в выражении вида:
    /// SomeType variable = dynamicElement;
    /// </summary>
    public override sealed bool TryConvert(ConvertBinder binder, out object result)
    {
        // При попытке преобразования оболочки к XElement возвращаем
        // нижележащий xml-элемент
        if (binder.ReturnType == typeof(XElement))
        {
            result = element;
            return true;
        }
 
        // В противном случае получаем значение текущего  элемента
        // и преобразовываем это значение к типу запрошенного результата
        string underlyingValue = element.Value;
        result = Convert.ChangeType(underlyingValue, binder.ReturnType,
            CultureInfo.InvariantCulture);
       
        return true;
    }
 
    /// <summary>
    /// Этот метод вызывается при доступе к члену или свойству
    /// </summary>
    public override bool TryGetMember(GetMemberBinder binder, out object result)
    {
        string binderName = binder.Name;
        Contract.Assume(binderName != null);
 
        // Если соответствующий подэлемент с указанным именем существует,
        // то создаем для него динамическую оболочку
        XElement subelement = element.Element(binderName);
        if (subelement != null)
        {
            result = CreateInstance(subelement);
            return true;
        }
 
        // В противном случае вызываем базову версию метода, что приводит к ошибке
        // времени выполнения
        return base.TryGetMember(binder, out result);
    }
 
}

Как уже было сказано выше, метод TryConvert вызывается при любой попытке преобразования xml элемента или одного из его подэлементов к указанному типу. Поскольку мы легко можем получить значение текущего xml- лемента, то все, что нужно для реализации этого метода – это вызвать ChangeType класса Convert; единственным исключением является тип XElement, который обрабатывается отдельно и позволяет получить нижележащий XElement напрямую.

Метод TryGetMember также достаточно простой: вначале мы получаем имя члена, к которому пользовательский код пытается получить доступ, а затем пробуем найти элемент с этим именем. Если указанный элемент найден, мы создаем динамическую оболочку и возвращаем его через выходной параметр result. В противном случае мы вызываем базовую версию, что приводит к исключению времени выполнения, в котором будет сказано, что запрошенный член не найден.

Все это позволяет использовать оболочку следующим образом:

// Получаем доступ к первому подэлементу с именем bookXElement 
string firstBooksElement = dynamicElement.book;
Console.WriteLine("First books element: {0}", firstBooksElement);
 
// Получаем заголовок первой книги и преобразуем его к строке
string firstBooksTitle = dynamicElement.book.title;
Console.WriteLine("First books title: {0}", firstBooksTitle);
 
// Получаем количество строк первой книге и преобразуем его к int
int firstBooksPageCount = dynamicElement.book.pages;
Console.WriteLine("First books page count: {0}", firstBooksPageCount);

Результат выполнения этого кода:

First books element: <book>
<title>Mortal Engines</title>
<author name="Philip Reeve" />
<pages>347</pages>
</book>

First books title: Mortal Engines
First books page count: 347
First books author: Philip Reeve
Second books title: The Talisman

Создание «динамической» оболочки для создания/изменения XML данных

Причиной создания двух классов, одного – ответственного за чтение данных, а второго – за создание и изменение, обусловлено тем, что в реализации метода TryGetMember мы заранее не можем узнать, для чего происходит обращение к нижележащему члену. Ведь если это обращение происходит для чтения данных, а указанного элемента нет в исходных XML данных, то наиболее логичным поведением является генерация исключения, в котором будет сказано, что элемент с указанным именем не найден. Именно так себя и ведет приведенная выше реализация в классе DynamicXElementReader. Однако нам нужно совершенно иное поведение при создании/изменении XML данных: в этом случае, вместо генерации исключения нам нужно создать пустой элемент с указанным именем; ведь вполне логично предположить, что в создаваемом элементе может и не быть (а точнее, скорее всего не будет) элемента с указанным именем.

Таким образом, к приведенному выше классу DynamicXElementReader, предназначенному только для чтения, мы добавляем еще один – DynamicXElementWriter, задачей которого будет создание и изменение XML данных. Однако поскольку у двух этих классов есть достаточно много общего, например, реализация метода TryConvert, а также некоторые вспомогательные методы, типа HasParent, то реальный код, содержит еще один вспомогательный класс DynamixXElementBase, устраняющий дублирование кода и упрощающий реализацию его наследников. Однако поскольку анализировать код с дополнительным базовым классом несколько сложнее, то здесь я его показывать не буду.

Основное отличие в динамической оболочке, предназначенной для создания/изменения XML данных, заключается в наличии сеттеров у двух индексаторов: один – для изменения значения атрибутов, а второй – для добавления дополнительных под элементов. Вторым отличием является наличие двух дополнительных нединамических методов: SetValue и SetAttributeValue, которые служат для изменения значения текущего элемента и его атрибутов.

public class DynamicXElementWriter : DynamicObject
{
    // Код, отвечающий за создание экземпляра аналогичен
 
    /// <summary>
    /// Изменение значения текущего элемента
    /// </summary>
    public void SetValue(object value)
    {
        Contract.Requires(value != null);
 
        element.SetValue(value);
    }
 
    /// <summary>
    /// Изменение атрибута текущего элемента
    /// </summary>
    public void SetAttributeValue(XName name, object value)
    {
        Contract.Requires(name != null);
        Contract.Requires(value != null);
 
        element.SetAttributeValue(name, value);
    }
 
    /// <summary>
    /// Идексатор для доступа к атрибутом текущего элемента
    /// </summary>
    public dynamic this[XName name]
    {
        get
        {
            // Реализация геттера абсолютно аналогична
        }
 
        set
        {
            // А в сеттере нам всего лишь нужно вызвать метод
            // XElement.SetAttributeValue, и он зделает всю работу за нас
            element.SetAttributeValue(name, value);
        }
 
    }
 
    /// <summary>
    /// Индексатор для доступа к "брату" текущего элемента по указанному индексу
    /// </summary>
    public dynamic this[int idx]
    {
        get
        {
            // Предусловие аналогично предыдущей реализации
            Contract.Requires(idx >= 0, "Index should be greater or equals to 0");
            Contract.Requires(idx == 0 || HasParent(),
                "For non-zero index we should have parent element");
 
            // Доступ по нулевому индексу означает доступ к текущему элементу
            if (idx == 0)
                return this;
 
            // Доступ по ненулевому индексу означает поиск "брата" текущего элемента.
            // Для этого необходимо, чтобы текущий элемент содержал родительский узел
            var parent = element.Parent;
            Contract.Assume(parent != null);
 
            // Если в данный момент нет "брата" с указанным индексом,
            // добавляем его руками
            XElement subElement = parent.Elements(element.Name).ElementAtOrDefault(idx);
            if (subElement == null)
            {
                XElement sibling = parent.Elements(element.Name).First();
                subElement = new XElement(sibling.Name);
                parent.Add(subElement);
            }
 
            return CreateInstance(subElement);
        }
 
        set
        {
            Contract.Requires(idx >= 0, "Index should be greater or equals to 0");
            Contract.Requires(idx == 0 || HasParent(),
                "For non-zero index we should have parent element");
          
            // Поскольку вся основная логика по добавлению нового подэлемента
            // уже реализована в геттере, то проще всего сеттер реализовать
            // через него
            dynamic d = this[idx];
            d.SetValue(value);
            return;
        }
 
    }
}

Реализация геттеров весьма похожа на предыдущую реализацию, особенно это касается индексатора, принимающего XName и предназначенного для работы с атрибутами. Реализация индексатора, принимающего целое число несколько сложнее, поскольку даже геттер содержит дополнительную логику по созданию дополнительного «брата», если такого элемента еще нет. Реализация же сеттеров в обоих случаях достаточно тривиальна.

Еще одним существенным отличием является реализация метода TryGetMember, а также наличие дополнительного метода TrySetMember, который будет вызываться в случае установки значения xml элемента: dynamicElement.SubElement = value.

/// <summary>
/// Вызывается при доступе к члену или свойству
/// </summary>
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
    string binderName = binder.Name;
    Contract.Assume(binderName != null);
       
    // Получаем подэлемент с указанным именем
    XElement subelement = element.Element(binderName);
 
    // Создаем и добавляем новый элемент, если текущий элемент не содержит
    // под элемент с заданным именем
    if (subelement == null)
    {
        subelement = new XElement(binderName);
        element.Add(subelement);
    }
 
    result = CreateInstance(subelement);
    return true;
}
 
/// <summary>
/// Вызывается при изменения значения члена или свойства
/// </summary>
public override bool TrySetMember(SetMemberBinder binder, object value)
{
    Contract.Assume(binder != null);
    Contract.Assume(!string.IsNullOrEmpty(binder.Name));
    Contract.Assume(value != null);
 
    string binderName = binder.Name;
       
    // Если взываеющий код пытается установить значение свойства, соответствующего
    // имени текущего элемента, изменяем значение текущего элемента;
    // В противном случае вызываем метод XElement.SetElementValue, который
    // сделает всю работу за нас
    if (binderName == element.Name)
        element.SetValue(value);
    else
        element.SetElementValue(binderName, value);
    return true;
}

Основное отличие реализация метода TryGetValue заключается в том, что при доступе к под элементу, которого нет в исходном xml дереве, вместо генерации исключения, будет добавлен элемент с указанным именем. Реализация же метода TrySetMember также не слишком сложная благодаря тому, что всю черную работу делает за нас метод XElement.SetElementValue, которые добавит элемент с нужным именем в случае необходимости .

Выводы

Я нисколько не исключаю того, что приведенная выше реализация содержит ошибки или не совершенна в том или ином вопросе. Однако основной задачей статьи является показать принцип создания динамических оболочек вокруг статически типизированных объектов, а также показать пользу динамического программирования в таком изначально статически типизированном языке программирования, как C#. И хотя приведенная реализация может быть далека от идеала, она весьма неплохо протестирована, и успешно участвует в паре небольших проектов. Кроме того, она находится в свободном доступе на github, и каждый из вас может использоваться ее идеями (а также и реализацией) на свое усмотрение.

Еще раз напоминаю, исходный код библиотеки DynamicXml доступен здесь.

------------------------------

(*) Самое интересное, что DLR – Dynamic Language Runtime, не имеет никакого отношения ко времени выполнения, а является лишь «обычной» библиотекой, хитро манипулирующей деревьями выражений.

(**) Существует несколько примеров, показывающих эту возможность, например, здесь и здесь.

(***) Этот несколько модифицированный пример, который использовал Джон Скит в одном из примеров к своей книге “C# In Depth”, 2nd edition.

Комментариев нет:

Отправить комментарий