Несмотря на то, что использовать механизм сериализации при программировании на C# достаточно просто и удобно, есть моменты, которые стоит учитывать. О том, на какие грабли можно наступить, работая с сериализацией, о примерах кода, в котором эти грабли припрятаны, а также о том, как PVS-Studio поможет вам избежать шишек на лбу, и будет эта статья.
Данная статья будет особенно полезна разработчикам, только начинающим знакомиться с механизмом сериализации. Более опытные программисты также могут почерпнуть для себя что-то интересное, или просто убедиться, что даже профессионалы порой допускают ошибки.
Тем не менее, подразумевается, что читатель уже знаком с механизмом сериализации.
При чём тут PVS-Studio? В релизе 6.05 были добавлены 6 диагностических правил, обнаруживающих подозрительный код, связанный с использованием механизма сериализации. Эти диагностики в основном ищут проблемные места, связанные с атрибутом [Serializable] или реализацией интерфейса ISerializable.
Примечание.
Стоит понимать, что описанные в статье утверждения актуальны для некоторых сериализаторов, например - BinaryFormatter и SoapFormatter, а для других, например, собственноручно написанного сериализатора, поведение может отличаться. Например, отсутствие атрибута [Serializable] у класса может не помешать проводить его сериализацию и десериализацию собственным сериализатором.
Кстати, если вы работаете с сериализацией, советую загрузить пробную версию анализатора и проверить свой код на наличие подозрительных мест.
Реализация типом интерфейса ISerializable позволяет управлять сериализацией, выбирая, какие члены нужно сериализовать, какие - нет, какие значение нужно записывать при сериализации членов и т.п.
Интерфейс ISerializable содержит объявление одного метода - GetObjectData, который будет вызван при сериализации объекта. Но в паре с этим методом обязательно должен быть реализован конструктор, который будет вызываться при десериализации объекта. Так как интерфейс не может обязать вас реализовать в своем типе какой-то конструктор, эта задача ложится на плечи программиста, занимающегося реализацией сериализуемого типа. Конструктор сериализации имеет следующую сигнатуру:
Ctor(SerializationInfo, StreamingContext)
Без наличия данного конструктора сериализация объекта пройдёт успешно (при условии корректной реализации метода GetObjectData), но восстановить (десериализовать) его не удастся - будет сгенерировано исключение типа SerializationException.
Посмотрим на пример подобного кода из проекта Glimpse:
[Serializable]
internal class SerializableTestObject : ISerializable
{
public string TestProperty { get; set; }
public void GetObjectData(SerializationInfo info,
StreamingContext context)
{
info.AddValue("TestProperty", this.TestProperty);
}
}
Предупреждение PVS-Studio: V3094 Possible exception when deserializing. The SerializableTestObject(SerializationInfo, StreamingContext) constructor is missing. Glimpse.Test.AspNet SessionModelConverterShould.cs 111
Сериализация экземпляра данного класса пройдёт успешно, а вот при десериализации возникнет исключение, так как нет соответствующего конструктора. Скорее всего, это не ошибка (исходя из названия класса и файла), но в качестве иллюстрации описанной ситуации - то, что нужно.
Конструктор сериализации для данного класса мог бы выглядеть так:
protected SerializableTestObject(SerializationInfo info,
StreamingContext context)
{
TestProperty = info.GetString(nameof(TestProperty));
}
При разработке типа, реализующего интерфейс ISerializable, важно правильно определить модификатор доступа для конструктора сериализации. Здесь возможны несколько случаев:
Наибольший интерес представляет первый из приведённых выше вариантов, так как он таит в себе наибольшую опасность. Кратко остановимся и на втором пункте, а третий рассматривать не будем - объявить член с модификатором protected в структуре не даст компилятор (ошибка компиляции), если такой член объявляется в запечатанном классе - компилятор выдаст предупреждение.
Это наиболее опасный случай из ситуаций неправильного применения модификаторов доступа к конструкторам сериализации. Если тип является незапечатанным, подразумевается, что у него могут быть наследники. Однако если конструктор сериализации имеет модификатор доступа private, он не сможет быть вызван из дочернего класса.
В таком случае у разработчика дочернего класса есть 2 выхода - отказаться от использования данного родительского класса или вручную десериализовать члены базового класса. Стоит отметить, что второй случай навряд ли можно считать решением проблемы:
Поэтому, разрабатывая незапечатанный сериализуемый класс, обращайте внимание на то, какой модификатор доступа имеет конструктор сериализации.
При анализе проектов удалось найти несколько таких, в которых описанное выше правило не соблюдалось.
NHibernate
[Serializable]
public class ConnectionManager : ISerializable,
IDeserializationCallback
{
....
private ConnectionManager(SerializationInfo info,
StreamingContext context)
{
....
}
....
}
Предупреждение PVS-Studio: V3103 A private Ctor(SerializationInfo, StreamingContext) constructor in unsealed type will not be accessible when deserializing derived types. NHibernate ConnectionManager.cs 276
Roslyn
[Serializable]
private class TestDiagnostic : Diagnostic, ISerializable
{
....
private TestDiagnostic (SerializationInfo info,
StreamingContext context)
{
....
}
....
}
Предупреждение PVS-Studio: V3103 A private TestDiagnostic(SerializationInfo, StreamingContext) constructor in unsealed type will not be accessible when deserializing derived types. DiagnosticAnalyzerTests.cs 100
В обоих примерах, приведённых выше, у конструктора сериализации следовало установить модификатор доступа protected, чтобы дочерние классы могли вызвать его при десериализации.
Это совет хорошего стиля программирования. Объявление конструктора сериализации с модификатором public или internal не приведёт к ошибке, но смысла в этом нет - данный конструктор не должен вызываться извне, а сериализатору без разницы, какой модификатор доступа имеет конструктор.
При проверке open source проектов встретились несколько таких, в которых это правило не соблюдалось.
MSBuild
[Serializable]
private sealed class FileState : ISerializable
{
....
internal SystemState(SerializationInfo info,
StreamingContext context)
{
....
}
....
}
Предупреждение PVS-Studio: V3103 The Ctor(SerializationInfo, StreamingContext) constructor should be used for deserialization. Making it internal is not recommended. Consider making it private. Microsoft.Build.Tasks SystemState.cs 218
[Serializable]
private sealed class FileState : ISerializable
{
....
internal FileState(SerializationInfo info, StreamingContext context)
{
....
}
....
}
Предупреждение PVS-Studio: V3103 The Ctor(SerializationInfo, StreamingContext) constructor should be used for deserialization. Making it internal is not recommended. Consider making it private. Microsoft.Build.Tasks SystemState.cs 139
В обоих случаях для конструктора сериализации следовало установить модификатор доступа private, так оба класса, приведённых выше, являются запечатанными.
NHibernate
[Serializable]
public class StatefulPersistenceContext : IPersistenceContext,
ISerializable,
IDeserializationCallback
{
....
internal StatefulPersistenceContext(SerializationInfo info,
StreamingContext context)
{
....
}
....
}
Предупреждение PVS-Studio: V3103 The Ctor(SerializationInfo, StreamingContext) constructor should be used for deserialization. Making it internal is not recommended. Consider making it protected. NHibernate StatefulPersistenceContext.cs 1478
[Serializable]
public class Configuration : ISerializable
{
....
public Configuration(SerializationInfo info,
StreamingContext context)
{
....
}
....
}
Предупреждение PVS-Studio: V3103 The Ctor(SerializationInfo, StreamingContext) constructor should be used for deserialization. Making it public is not recommended. Consider making it protected. NHibernate Configuration.cs 84
Ввиду того, что оба класса являются незапечатанными, для конструкторов сериализации следовало установить модификатор доступа protected.
Правило простое - если вы разрабатываете незапечатанный класс, реализующий интерфейс ISerializable, объявите метод GetObjectData с модификатором virtual. Это позволит дочерним классам корректно производить сериализацию объекта при использовании полиморфизма.
Чтобы лучше понять суть проблемы, предлагаю рассмотреть несколько примеров.
Допустим, у нас есть следующие объявления родительского и дочернего классов.
[Serializable]
class Base : ISerializable
{
....
public void GetObjectData(SerializationInfo info,
StreamingContext context)
{
....
}
}
[Serializable]
sealed class Derived : Base
{
....
public new void GetObjectData(SerializationInfo info,
StreamingContext context)
{
....
}
}
Предположим, что имеется метод сериализации и десереализации объекта следующего вида:
void Foo(BinaryFormatter bf, MemoryStream ms)
{
Base obj = new Derived();
bf.Serialize(ms, obj);
ms.Seek(0, SeekOrigin.Begin);
Derived derObj = (Derived)bf.Deserialize(ms);
}
В таком случае сериализация выполнится неправильно из-за того, что будет вызван метод GetObjectData не дочернего, а родительского класса. Следовательно, члены дочернего класса не будут сериализованы. Если при десериализации из объекта типа SerializationInfo будут извлекаться значения членов, добавляемых в методе GetObjectData дочернего класса, будет сгенерировано исключение, так как объект типа SerializationInfo не будет содержать запрашиваемых ключей.
Для исправления ошибки в родительском классе к методу GetObjectData необходимо добавить модификатор virtual, в производном - override.
Если в родительском классе присутствует только явная реализация интерфейса ISerializable, добавить к ней модификатор virtual вы не сможете. Однако оставив всё, как есть, вы рискуете усложнить жизнь разработчикам дочерних классов.
Рассмотрим пример реализации родительского и дочернего классов:
[Serializable]
class Base : ISerializable
{
....
void ISerializable.GetObjectData(SerializationInfo info,
StreamingContext context)
{
....
}
}
[Serializable]
sealed class Derived : Base, ISerializable
{
....
public void GetObjectData(SerializationInfo info,
StreamingContext context)
{
....
}
}
В таком случае из дочернего класса будет невозможно обратиться к методу GetObjectData родительского класса. И если в базовом методе сериализуются приватные члены, обратиться к ним из дочернего класса также не удастся, а значит и не удастся провести корректную сериализацию. Для исправления ошибки помимо явной реализации в базовый класс необходимо добавить неявную реализацию виртуального метода GetObjectData. Тогда исправленный код может выглядеть так:
[Serializable]
class Base : ISerializable
{
....
void ISerializable.GetObjectData(SerializationInfo info,
StreamingContext context)
{
GetObjectData(info, context);
}
public virtual void GetObjectData(SerializationInfo info,
StreamingContext context)
{
....
}
}
[Serializable]
sealed class Derived : Base
{
....
public override void GetObjectData(SerializationInfo info,
StreamingContext context)
{
....
base.GetObjectData(info, context);
}
}
Или же, если не подразумевается наследование данного класса, следует сделать его запечатанным, добавив к объявлению класса модификатор sealed.
Roslyn
[Serializable]
private class TestDiagnostic : Diagnostic, ISerializable
{
private readonly string _kind;
....
private readonly string _message;
....
void ISerializable.GetObjectData(SerializationInfo info,
StreamingContext context)
{
info.AddValue("id", _descriptor.Id);
info.AddValue("kind", _kind);
info.AddValue("message", _message);
info.AddValue("location", _location, typeof(Location));
info.AddValue("severity", _severity, typeof(DiagnosticSeverity));
info.AddValue("defaultSeverity", _descriptor.DefaultSeverity,
typeof(DiagnosticSeverity));
info.AddValue("arguments", _arguments, typeof(object[]));
}
....
}
Предупреждение PVS-Studio: V3104 'GetObjectData' implementation in unsealed type 'TestDiagnostic' is not virtual, incorrect serialization of derived type is possible. CSharpCompilerSemanticTest DiagnosticAnalyzerTests.cs 112
Класс TestDiagnostic является незапечатанным (хоть и приватным, так что унаследоваться от него в рамках того же класса возможно), но при этом он имеет только явную реализацию интерфейса ISerializable, в которой, ко всему прочему, сериализуются приватные члены. Это означает одно - разработчик дочернего класса никаким образом не сможет сериализовать необходимые члены: метод GetObjectData будет ему недоступен, а обратиться к членам напрямую не позволит модификатор доступа.
Правильнее было бы вынести весь код сериализации, приведённый выше, в виртуальный метод GetObjectData, на который сослаться из явной реализации интерфейса:
void ISerializable.GetObjectData(SerializationInfo info,
StreamingContext context)
{
GetObjectData(info, context);
}
public virtual void GetObjectData(SerializationInfo info,
StreamingContext context)
{
info.AddValue("id", _descriptor.Id);
info.AddValue("kind", _kind);
info.AddValue("message", _message);
info.AddValue("location", _location, typeof(Location));
info.AddValue("severity", _severity, typeof(DiagnosticSeverity));
info.AddValue("defaultSeverity", _descriptor.DefaultSeverity,
typeof(DiagnosticSeverity));
info.AddValue("arguments", _arguments, typeof(object[]));
}
Это условие является обязательным для корректной сериализации объекта вне зависимости от того, происходит ли автоматическая сериализация (когда тип декорирован атрибутом [Serializable] и при этом не реализует интерфейс ISerializable) или сериализация осуществляется вручную (реализован ISerializable).
В противном случае, если при сериализации встретится член, не декорированный атрибутом [Serializable], будет сгенерировано исключение типа SerializationException.
Если требуется сериализовать объект без учёта членов, имеющих несереализуемый тип, возможны несколько подходов:
Стоит обратить внимание на тот факт, что атрибут [NonSerialized] применим только к полям. Таким образом, вы не сможете запретить сериализацию свойства, но, если оно будет иметь несериализуемый тип - получите исключение. Например, при попытке сериализации класса SerializedClass, определение которого приведено ниже:
sealed class NonSerializedType { }
[Serializable]
sealed class SerializedClass
{
private Int32 value;
public NonSerializedType NSProp { get; set; }
}
Обойти эту ситуацию можно, реализовав свойство через поле, декорированное атрибутом [NonSerialized]:
[Serializable]
sealed class SerializedClass
{
private Int32 value;
[NonSerialized]
private NonSerializedType nsField;
public NonSerializedType NSProp
{
get { return nsField; }
set { nsField = value; }
}
}
Подобные ошибки, когда сериализуемый тип имеет члены несереализуемых типов, не декорированные атрибутом [NonSerialized], обнаруживает диагностическое правило V3097 статического анализатора кода PVS-Studio.
Напоминаю, что данное предупреждение не обязательно свидетельствует о наличии ошибки - всё зависит от используемого сериализатора.
Рассмотрим несколько примеров кода, в которых описанное условие было нарушено.
Subtext
public class BlogUrlHelper
{
....
}
[Serializable]
public class AkismetSpamService : ICommentSpamService
{
....
readonly BlogUrlHelper _urlHelper;
....
}
Предупреждение PVS-Studio: V3097 Possible exception: the 'AkismetSpamService' type marked by [Serializable] contains non-serializable members not marked by [NonSerialized]. Subtext.Framework AkismetSpamService.cs 31
Тип BlogUrlHelper поля _urlHelper не является сериализуемым, поэтому при попытке сериализации экземпляра класса AkismetSpamService некоторыми сериализаторами, будет сгенерировано исключение типа SerializationException. Решать проблему нужно, отталкиваясь от ситуации. Если используются сериализаторы типа BinaryFormatter или SoapFormatter - необходимо либо декорировать поле атрибутом [NonSerialized], либо декорировать атрибутом [Serializable] тип BlogUrlHepler. Если используются другие сериализаторы, не требующие наличия атрибута [Serializable] у сериализуемых полей, можно не забивать голову.
NHibernate
public class Organisation
{
....
}
[Serializable]
public class ResponsibleLegalPerson
{
....
private Organisation organisation;
....
}
Предупреждение PVS-Studio: V3097 Possible exception: the 'ResponsibleLegalPerson' type marked by [Serializable] contains non-serializable members not marked by [NonSerialized]. NHibernate.Test ResponsibleLegalPerson.cs 9
Ситуация аналогична описанной выше - или пан, или пропал. Всё зависит от используемого сериализатора.
Данный совет относится скорее к тем, кто только начинает работать с сериализацией. Управляя сериализацией вручную, посредством реализации интерфейса ISerializable, легко забыть декорировать тип атрибутом [Serializable], что потенциально приводит к генерации исключения типа SerializationException. Такие сериализаторы, как BinaryFormatter, требуют наличия данного атрибута.
SharpDevelop
Интересные примеры данной ошибки встретились в проекте SharpDevelop.
public class SearchPatternException : Exception, ISerializable
{
....
protected SearchPatternException(SerializationInfo info,
StreamingContext context)
: base(info, context)
{
}
}
Предупреждение PVS-Studio: V3096 Possible exception when serializing 'SearchPatternException' type. [Serializable] attribute is missing. ICSharpCode.AvalonEdit ISearchStrategy.cs 80
public class DecompilerException : Exception, ISerializable
{
....
protected DecompilerException(SerializationInfo info,
StreamingContext context)
: base(info, context)
{
}
}
Предупреждение PVS-Studio: V3096 Possible exception when serializing 'DecompilerException' type. [Serializable] attribute is missing. ICSharpCode.Decompiler DecompilerException.cs 28
Для передачи объекта исключения между доменами приложений происходит его сериализация и десериализация. Соответственно, собственные типы исключений должны быть сериализуемыми. В приведённых выше примерах типы SearchPatternException и DecompilerException наследуются от Exception и реализуют конструкторы сериализации, но при этом не декорированы атрибутом [Serializable], а значит, что при попытке сериализации объектов данных типов (например, для передачи между доменами) будет сгенерировано исключение типа SerializationException. Таким образом, например, генерируя исключение в другом домене приложений, в текущем вы перехватите не сгенерированное исключение, а SerializationException.
Реализуя интерфейс ISerializable и определяя метод GetObjectData, вы берёте на себя ответственность за то, какие члены типа будут сериализованы и какие значения в них будут записаны. В этом случае для разработчиков открывается большой простор в управлении сериализацией: в качестве сериализуемого значения, ассоциированного с членом (а если быть более честным - с любой строкой) вы можете записать действительное значение сериализованного объекта, результат работы какого-либо метода, константное или литеральное значение - всё, что захотите.
Однако в данном случае на плечи разработчика ложится большая ответственность, потому что необходимо не забыть никакой член, подлежащий сериализации, даже если он находится в базовом классе. Все мы люди, так что иногда некоторые члены всё же забываются.
Для диагностирования таких ситуаций в статическом анализаторе кода PVS-Studio предусмотрено диагностическое правило V3099. Предлагаю ознакомиться с некоторыми примерами кода, обнаруженных данным правилом.
SharpDevelop
[Serializable]
public abstract class XshdElement
{
public int LineNumber { get; set; }
public int ColumnNumber { get; set; }
public abstract object AcceptVisitor(IXshdVisitor visitor);
}
[Serializable]
public class XshdColor : XshdElement, ISerializable
{
....
public virtual void GetObjectData(SerializationInfo info,
StreamingContext context)
{
if (info == null)
throw new ArgumentNullException("info");
info.AddValue("Name", this.Name);
info.AddValue("Foreground", this.Foreground);
info.AddValue("Background", this.Background);
info.AddValue("HasUnderline", this.Underline.HasValue);
if (this.Underline.HasValue)
info.AddValue("Underline", this.Underline.Value);
info.AddValue("HasWeight", this.FontWeight.HasValue);
if (this.FontWeight.HasValue)
info.AddValue("Weight", this.FontWeight
.Value
.ToOpenTypeWeight());
info.AddValue("HasStyle", this.FontStyle.HasValue);
if (this.FontStyle.HasValue)
info.AddValue("Style", this.FontStyle.Value.ToString());
info.AddValue("ExampleText", this.ExampleText);
}
}
Предупреждение PVS-Studio: V3099 Not all the members of 'XshdColor' type are serialized inside 'GetObjectData' method: LineNumber, ColumnNumber. ICSharpCode.AvalonEdit XshdColor.cs 101
В этом коде нет проблем, описанных ранее, таких как неправильные модификаторы доступа у конструктора сериализации, отсутствие атрибута [Serializable] или модификатора virtual у метода GetObjectData.
Увы, ошибка здесь всё равно есть. В методе GetObjectData не учитываются свойства базового класса, а значит, при сериализации часть данных будет потеряна. В итоге, при десериализации будет восстановлен объект с другим состоянием.
В данном случае решением проблемы будет ручное добавление необходимых значений, например, таким образом:
info.AddValue(nameof(LineNumber), LineNumber);
info.AddValue(nameof(ColumnNumber), ColumnNumber);
Если бы базовый класс также реализовывал интерфейс ISerializable, решение было бы более элегантным - вызовом в производном методе GetObjectData базового.
NHibernate
[Serializable]
public sealed class SessionImpl : AbstractSessionImpl,
IEventSource,
ISerializable,
IDeserializationCallback
{
....
void ISerializable.GetObjectData(SerializationInfo info,
StreamingContext context)
{
log.Debug("writting session to serializer");
if (!connectionManager.IsReadyForSerialization)
{
throw new InvalidOperationException("Cannot serialize a Session
while connected");
}
info.AddValue("factory", Factory, typeof(SessionFactoryImpl));
info.AddValue("persistenceContext", persistenceContext,
typeof(StatefulPersistenceContext));
info.AddValue("actionQueue", actionQueue, typeof(ActionQueue));
info.AddValue("timestamp", timestamp);
info.AddValue("flushMode", flushMode);
info.AddValue("cacheMode", cacheMode);
info.AddValue("interceptor", interceptor, typeof(IInterceptor));
info.AddValue("enabledFilters", enabledFilters,
typeof(IDictionary<string, IFilter>));
info.AddValue("enabledFilterNames", enabledFilterNames,
typeof(List<string>));
info.AddValue("connectionManager", connectionManager,
typeof(ConnectionManager));
}
....
private string fetchProfile;
....
}
Предупреждение PVS-Studio: V3099 Not all the members of 'SessionImpl' type are serialized inside 'GetObjectData' method: fetchProfile. NHibernate SessionImpl.cs 141
На этот раз забыли сериализовать поле текущего класса (fetchProfile). Как видно из определения, оно не декорировано атрибутом [NonSerialized] (в отличии от других полей, не сериализуемых в методе GetObjectData).
В данном проекте нашлось ещё два подобных места:
С подобными ошибками связана интересная особенность - они либо приводят к генерации исключения, либо к трудноуловимым логическим ошибкам.
Исключение будет сгенерировано в том случае, если в конструкторе сериализации попытаются получить значение того поля, которое не было добавлено (обратятся по отсутствующему ключу). Если же про член забыли совсем (и в методе GetObjectData, и в конструкторе сериализации), будет происходить порча состояния объекта.
Кратко обобщив всю информацию, приведённую выше, можно сформулировать несколько советов и правил:
Надеюсь, вы узнали что-то новое из статьи и стали большим экспертом в вопросах сериализации. Придерживаясь приведённых выше советов и правил, вы сможете тратить меньше времени на отладку, облегчить жизнь себе и другим разработчикам, работающим с вашими классами. А анализатор PVS-Studio ещё больше облегчит жизнь, позволяя выявлять подобные ошибки сразу после их появления.