Как использовать иммутабельные запросы в WCF?
Есть проект, в котором надо контракты дополнительно к webapi выставить и по wcf. При этом контракты сделаны иммутабельными: нет parameterless конструктора по умолчанию, свойства get-only, всё под функциональный стиль:
public class ExportClientRequest
{
public ExportClientRequest(int clientId)
{
this.ClientId = clientId;
}
public int ClientId { get; }
}
Замечательно сериализуется/десериализуется привычным Json.Net уже даже можно не вешать [JsonConstructor] для подсказок как создавать экземпляр класс.
[Test]
public void JsonSerialize()
{
var request = new ExportClientRequest(42);
var json = JsonConvert.SerializeObject(request);
var deserialized = JsonConvert.DeserializeObject<ExportClientRequest>(json);
Assert.That(request.ClientId, Is.EqualTo(deserialized.ClientId));
}
Однако как только начинаешь работать с WCF начинаются проблемы с сериализацией, там штатно всё это идёт через XmlSerializer, поэтому тесты на де/сериализацию начинают выдавать ошибку за ошибкой:
[Test]
public void XmlSerialize()
{
var request = new ExportClientRequest(42);
var serializer = new XmlSerializer(typeof(ExportClientRequest));
using var writer = new StringWriter();
serializer.Serialize(writer, request);
using var reader = new StringReader(writer.ToString());
var deserialized = (ExportClientRequest)serializer.Deserialize(reader);
Assert.That(request.ClientId, Is.EqualTo(deserialized.ClientId));
}
Сначала оно жалуется на отсутствие безпараметрического конструктора:
System.InvalidOperationException : CompanyName.ProjectName.Contracts.ExportClientRequest cannot be serialized because it does not have a parameterless constructor.
Окей, добавил, навесив [Obsolete] чтобы находить в коде места где будут использовать не по назначению.
Потом тест стал ругаться на private set'тер:
System.InvalidOperationException : Cannot deserialize type 'CompanyName.ProjectName.Contracts.ExportClientRequest' because it contains property 'ClientId' which has no public setter.
(При этом ни protected, ни internal ему не подходит - только публичный).
И я что-то тихо выпадаю в осадок: это вообще реально работать с иммутабельностью в WCF или только "никак", возвращаемся обратно:
public class ExportClientRequest
{
[Obsolete]
internal ExportClientRequest()
{
}
public ExportClientRequest(int clientId)
{
this.ClientId = clientId;
}
public int ClientId { get; set; }
}
Почитал на en so темы, но чего-то утешительного не нашёл, ну например: Why XML-Serializable class need a parameterless constructor -- типа проблема by design. :(
Ответы (3 шт):
Предложу следующее решение.
Реализовать интерфейс IXmlSerializable. Причём явно: тогда методы этого интерфейса не будет видны на экземплярах ExportClientRequest.
Беспараметрический конструктор при этом можно сделать приватным (но он всё же нужен).
Для свойства придётся ввести поле, чтобы задавать его в методе ReadXml.
В итоге внешне класс получается чистым: методы интерфейса не видны (если не знать, что он реализует интерфейс), конструктор не виден. Красота!
Но придётся вручную описывать процесс записи в XmlWriter и чтение из XmlReader. Это огромный недостаток.
public class ExportClientRequest : IXmlSerializable
{
ExportClientRequest() { }
public ExportClientRequest(int clientId)
{
_clientId = clientId;
}
private int _clientId;
public int ClientId => _clientId;
XmlSchema IXmlSerializable.GetSchema()
{
throw new NotImplementedException();
}
void IXmlSerializable.ReadXml(XmlReader reader)
{
reader.ReadToFollowing("ClientId");
_clientId = reader.ReadElementContentAsInt();
}
void IXmlSerializable.WriteXml(XmlWriter writer)
{
writer.WriteElementString(nameof(ClientId), ClientId.ToString());
}
}
- Можно сделать объекты просто ISerializable и реализовать в обьектах дополнительно код для сериализации и конструктор для десериализации. Вот здесь в самом конце такой вариант описан, да и вообще все варианты: https://docs.microsoft.com/ru-ru/dotnet/standard/serialization/serialization-guidelines
- А можно привязать к контракту WCF реализацию IDataContractSurrogate и всю логику по сериализации и десериализации запихнуть в него
Второй вариант позволит прикрутить WCF не затрагивая остальной код, но придется фактически под каждый класс сделать дубликат, который будет конвертироваться туда-сюда.
Зря я поленился написать реальное приложение сначала — а пробовал написать тест с мыслью "а потом уже допишу приложение".
Да, оно тоже не работает, но в exception вендор оставил подсказку, куда двигаться:
2020-11-16 12:54:40,308 [ExportClient, 1] - Unhandled exception on start.System.Runtime.Serialization.InvalidDataContractException: Тип "'CompanyName.ProjectName.Contracts.ExportClientRequest" не может быть сериализован. Пометьте его с помощью атрибута DataContractAttribute, а также пометьте все его члены, которые необходимо сериализовать, с помощью атрибута DataMemberAttribute. Если этот тип является коллекцией, пометьте его атрибутом CollectionDataContractAttribute. Для получения сведений о других поддерживаемых типах см. документацию к Microsoft .NET Framework.
(Вот почему сообщение по-русски, хотя в студии язык английский и русский снесён сто лет назад? Это же боль просто если бы пришлось гуглить пытаясь угадать исходное сообщение на английском)
Итого, вот такой вариант в приложении работает без проблем и вполне меня устраивает:
using System.Runtime.Serialization;
namespace CompanyName.ProjectName.Contracts
{
[DataContract]
public class ExportClientRequest
{
public ExportClientRequest(int clientId)
{
this.ClientId = clientId;
}
[DataMember]
public int ClientId { get; private set; }
}
}
И тогда уже тест переписывается в такую форму:
/// <summary>
/// For WCF.
/// </summary>
[Test]
public void DataContractSerialize()
{
var request = new ExportClientRequest(42);
var serializer = new DataContractSerializer(typeof(ExportClientRequest));
var stream = new MemoryStream();
using var writer = XmlWriter.Create(stream, new XmlWriterSettings());
serializer.WriteObject(writer, request);
writer.Close();
stream.Position = 0;
var reader = XmlReader.Create(stream, new XmlReaderSettings());
var deserialized = (ExportClientRequest)serializer.ReadObject(reader);
Assert.That(request.ClientId, Is.EqualTo(deserialized.ClientId));
}
Очень близко намёк был и в вопросах, которые я читал, просто я его проглядел, не подумал, что мне можно уйти от XmlSerialize:
This is a limitation of
XmlSerializer. Note thatBinaryFormatterandDataContractSerializerdo not require this - they can create an uninitialized object out of the ether and initialize it during deserialization.