[Из песочницы] Как приготовить DTO?

За последние полтора месяца мне довелось поработать над backend-ом трех проектов. В каждом требовалось подготовить классы для взаимодействия с удаленным сервисом посредством обмена XML-документами; в частности, требовалось подготовить DTO-классы для каждого из типов сообщений. Для каждого из сервисов шло описание от разработчиков, с примерами XML-документов, так что работа была относительно простая: взять пример, прогнать через утилиту xsd, получить класс, поправить типы, добавить недостающие поля\свойства, протестировать. Операции рутинные, после десятка классов думать уже особо не требовалось, так что в голове начали скапливаться мысли, как ускорить процесс разработки либо улучшить выходной результат. Получилось и то, и другое.

TLDR

Для готовки берем DTO-полуфабрикат из xsd, добавляем обертки для типов-примитивов, добавляем сахару (implicit operators), кладем в микроволновку на 30 минут, получаем удобный объект передачи данных.


Сперва загоним образец XML-документа в утилиту xsd. DTO-объект после выхода из xsd.exe выглядит приблизительно так:

// Информация о производителе: версия xsd
[System.SerializableAttribute]
[XmlTypeAttribute(AnonymousType = true)]
public class DtoProduct
{
        private string productidfield;
        private string productnamefield;
        private string productpricefield;
        /*...*/

        [XmlElement]
        public string productid
        {
                get{return this.productidfield;}
                set{this.productidfield = value;}
        }

        [XmlElement]
        public string productname
        {
                get{return this.productnamefield;}
                set{this.productnamefield = value;}
        }

        [XmlElement]
        public string productprice
        {
                get{return this.productpricefield;}
                set{this.productpricefield = value;}
        }       
}


Помимо проблем со стилем (лечится Решарпером и\или Ctrl+H), перед нами проблема с типами: для price лучше подойдет decimal, для Id — long. За правильное указание типов пользователи наших DTO скажут нам спасибо, либо хотя бы не будут желать нам сгореть в аду. Внесем правки, заодно приведя имена к корпоративному стандарту.

[System.SerializableAttribute]
[XmlTypeAttribute(AnonymousType = true)]
public class DtoProduct
{       
        private decimal _productPrice;
        
        //...

        [XmlElement("productprice")]
        public decimal ProductPrice
        {
                get{return _productPrice;}
                set{_productPrice = value;}
        }       
}


Уже лучше, но появляются проблемы с (де)сериализацией: у удаленного сервиса может быть свой формат передачи данных. Особенно характерно это для дат и десятичных значений. Плюс, иногда встречаются специфические типы (guid, code, mail).

Другая проблема: значения по умолчанию. Если значение строкового свойства null — свойство не сериализуется (читай, если свойству не присваивали значения — свойство не сериализуется). Double, int, bool — это типы значений (Value Types), и они не могут принимать значение null; в результате int свойства сериализуются значением по умолчанию (читай, если int свойству не присвоили значение, сериализуется 0). Скорее всего, вреда это не принесет, но это не описываемое в коде поведение, которого хотелось бы избежать.

Итак, мы приходим к необходимости создания правил (де)сериализации базовых типов. В качестве примера рассмотрим Money (decimal), который сериализуется как «d.dd» (разделитель «точка», два знака после разделителя). Создадим класс XmlMoneyWrapper, отнаследуем его от IXmlSerializable.

public class XmlMoneyWrapper : IXmlSerializable
{
        public decimal Value { get; set; } // Тут хранится передаваемое значение

        public override string ToString()
        {               
                return Value.ToString("0.00", CultureInfo.InvariantCulture);
        }       

        #region IXmlSerializable Members
        public XmlSchema GetSchema() { return null; }
        public void ReadXml(XmlReader reader) 
        {
                string value = reader.ReadString();
                // TODO change to TryParse?
                try
                {
                        Value = Decimal.Parse(value,
                                new NumberFormatInfo
                                {
                                        NumberDecimalSeparator = "."
                                });
                }
                catch (Exception exc)
                {
                        String err = String.Format("Can't deserialize string {0} to decimal. Expected number decimal separator is dot \".\"", value);
                        throw new SerializationException(err, exc);
                }
                reader.Read();
        }
        public void WriteXml(XmlWriter writer)
        {
                writer.WriteString(ToString()); 
        }
        #endregion
}


И поменяем наш DTO:

[System.SerializableAttribute]
[XmlTypeAttribute(AnonymousType = true)]
public class DtoProduct
{
        private XmlMoneyWrapper _productPrice;

        //...

        [XmlElement("productprice")]
        public XmlMoneyWrapper ProductPrice // Можно заменить на автосвойство, упростив код
        {
                get { return _productPrice; }
                set { _productPrice = value; }
        }
}


Мы сделали Nullable свойство, которое инициируется как null; мы избавили пользователя DTO от необходимости задумываться о формате сериализации. Однако, работа с DTO усложнилась. Теперь проверку if(product.ProductPrice > 10.00) придется заменять на if(product.ProductPrice.Value > 10.00).

Вывод: нужно добавить пару ложек сахара перегрузить операторы неявного приведения типов.

public static implicit operator XmlMoneyWrapper(decimal arg) // decimal to XmlMoneyWrapper
{
        XmlMoneyWrapper res = new XmlMoneyWrapper { Value = arg };
        return res;
}

public static implicit operator decimal (XmlMoneyWrapper arg) // XmlMoneyWrapper to decimal 
{
        return arg.Value;
}


Теперь пользователь вновь может использовать код вида if(product.ProductPrice > 10.00). При этом, в комментарий класса (и коммита) стоит внести предупреждение о неявных приведениях. Кроме того, использующие наш DTO коллеги могут не помнить про implicit operators, так что стоит добавить пример использования. Ведь наша цель не повыпендриваться недавно изученной фичей?

К сожалению, некоторые типы не совместимы с неявным приведением. Например, строка ограниченной длины: в интерфейсе перегрузки

public static implicit operator XmlLimitedStringWrapper(string arg)


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

[System.SerializableAttribute]
[XmlTypeAttribute(AnonymousType = true)]
public class DtoProduct
{       
        private readonly XmlLimitedStringWrapper _productName = new XmlLimitedStringWrapper(16); // Create string with maxLength = 16.
        // ...
        
        // Max symbols: 16.
        [XmlElement("productname")]
        public string ProductName
        {
                get{return _productName = _productName.Value;}
                set{_productName.Value = value;}
        }       
}


В результате этих манипуляций сырой DTO-полуфабрикат превращается в достаточно удобный продукт. Вся логика форматирования скрыта от пользователя, пользователь в своем коде может использовать привычные базовые типы. Создание DTO классов (по ощущениям) занимает чуть меньше времени, чем раньше. За счет Nullable оберток может немного снизиться траффик. На разработку всех XmlPrimitiveTypeWrapper типов уходит порядка одного дня (с модульным тестированием). В следующих проектах можно брать готовые обертки, благо изменяются они не сильно.

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

© Habrahabr.ru