За последние полтора месяца мне довелось поработать над 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 с новыми классами.

Комментарии (12)


  1. lair
    09.11.2015 13:58

    Double, int, bool — это типы значений (Value Types), и они не могут принимать значение null; в результате int свойства сериализуются значением по умолчанию (читай, если int свойству не присвоили значение, сериализуется 0).

    Хм, а вы правда не знаете про minOccurs="0" и паттерн *Specified, или просто в статье не упомянули?


    1. Oxoron
      09.11.2015 14:15
      +1

      Знаю.
      Но в данном случае они будут усложнять код DTO-класса.
      Сравните:

      [XmlElement]
      public XmlDecimalWrapper NullableDecimal{get;set;}
      

      и
      [XmlElement]
      public decimal NullableDecimal{get;set;}
      
      [XmlIgnore]
      public bool NullableDecimalSpecified{get{ AnyAdditionalLogicHere;}}
      


      При наличии одного-двух полей — можно и IsSpecified использовать; плюс, этот способ приходится использовать с атрибутами.
      При наличии же большого числа Value Type полей мне кажется более удобным использовать оберточные типы, особенно, если они уже есть в готовом виде.


  1. Serg046
    09.11.2015 14:17
    +1

    Почему нельзя использовать nullable-тип из коробки, для которого даже есть красивый синтаксис (int? etc)?


    1. lair
      09.11.2015 14:21

      Потому что XmlSerializer его не поддерживает.


      1. Serg046
        09.11.2015 14:36

        Какой именно случай?
        Сейчас запустил это

        class Program
        {
            static void Main(string[] args)
            {
                var xml1 = XDocument.Parse("<Test><Prop>5</Prop></Test>");
                var test1 = new XmlSerializer(typeof(Test)).Deserialize(xml1.CreateReader()) as Test;
                var xml2 = XDocument.Parse("<Test></Test>");
                var test2 = new XmlSerializer(typeof(Test)).Deserialize(xml2.CreateReader()) as Test;
                Console.WriteLine(test1.Prop.HasValue);
                Console.WriteLine(test2.Prop.HasValue);
            }
        }
        
        public class Test
        {
            public int? Prop { get; set; }
        }

        Вывод:
        true
        false


        1. lair
          09.11.2015 14:37

          А на запись?


          1. Serg046
            09.11.2015 14:46
            +1

            Это у вас игра такая? Я же реально ответа не знал, потому и спросил…
            Это сериализуется нормально

            var test1 = new Test() { Prop = 5 };
            var test2 = new Test();
            var writer1 = new StringWriter();
            var writer2 = new StringWriter();
            new XmlSerializer(typeof(Test)).Serialize(writer1, test1);
            new XmlSerializer(typeof(Test)).Serialize(writer2, test2);
            Console.WriteLine(writer1.ToString());
            Console.WriteLine(writer2.ToString());
            


            1. lair
              09.11.2015 14:47

              Нормально — это как, с пропущенным тегом, или с xsi:nil? У меня просто сейчас нет возможности проверить.


              1. Serg046
                09.11.2015 14:51

                witer1:

                <?xml version="1.0" encoding="utf-16"?>
                <Test xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
                  <Prop>5</Prop>
                </Test>
                

                writer2:
                <?xml version="1.0" encoding="utf-16"?>
                <Test xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
                  <Prop xsi:nil="true" />
                </Test>
                


                1. lair
                  09.11.2015 14:56

                  О, вот это поведение у нас и было. Вместо того, чтобы пропустить тег, как делается в случае со *Specified, он выводит xsi:nil.


  1. jack128
    10.11.2015 12:11
    +1

    К сожалению все эти Wrapper'ы не помогут сохранять примитивы в аттрибуты…


    1. Oxoron
      10.11.2015 13:15
      +1

      Да, с атрибутами приходится использовать *Specified, и красивого способа решить эту проблему я пока не вижу. Разве что, сниппет написать, или над когогенерирующей утилитой задуматься.

      С перечислениями тоже неудобно работать. Писать обертку на каждый enum — раздувать проект, а удобную обобщенную оболочку вроде

      public class EnumWrapper<T> where T : Enum
      

      написать не получится.