Start Debugging

Уход от BinaryFormatter после его удаления в современном .NET

Реализация BinaryFormatter была удалена в .NET 9 и по-прежнему выбрасывает PlatformNotSupportedException в .NET 10 и .NET 11: как выбрать сериализатор на замену, читать уже сохранённые NRBF-блобы через NrbfDecoder и что ломается в WinForms, WPF и ResX.

Сервис, который сериализует собственные типы в собственное хранилище, уходит от BinaryFormatter за один-три дня. Кодовая база, где NRBF-полезные нагрузки пересекли границу, которую вы не контролируете (очередь, общий столбец базы данных, десктопный клиент со своим графиком выпусков), потребует недель, потому что сложность не в замене сериализатора, а в вытеснении старых полезных нагрузок. Встроенная реализация была удалена в .NET 9 Preview 6 и остаётся удалённой: в .NET 9, .NET 10 и .NET 11 preview методы BinaryFormatter.Serialize и BinaryFormatter.Deserialize выбрасывают PlatformNotSupportedException для любого типа проекта, а старое свойство MSBuild EnableUnsafeBinaryFormatterSerialization само по себе уже ничего не возвращает. Это руководство написано для .NET 10.0.11 (GA) с замечаниями по .NET 11 SDK (preview 7, август 2026), System.Formats.Nrbf 10.0.11 и System.Runtime.Serialization.Formatters 10.0.11.

Почему это не опционально

Что ломается

ОбластьИзменениеСерьёзность
BinaryFormatter.Serialize / DeserializeВыбрасывает PlatformNotSupportedException при каждом вызове, во всех типах проектоввысокая
EnableUnsafeBinaryFormatterSerializationСамо по себе больше не достаточно; нужен ещё пакет совместимостивысокая
Сохранённые NRBF-блобыНичто во фреймворке больше их не десериализуетвысокая
SoapFormatter, NetDataContractSerializerУдалены или отнесены к опасным сериализаторам; это не цель миграциивысокая
Буфер обмена и перетаскивание в WinForms/WPFПолный цикл проходит только список встроенных типов. DataFormats.Serializable и пользовательские форматы падают на всём остальномвысокая
Дизайнер WinForms / ResXСериализация пользовательского типа во время разработки требует TypeConverterсредняя
Exception(SerializationInfo, StreamingContext)Помечен устаревшим как SYSLIB0051; старая сериализация исключений — балластсредняя
MSB3825 в MSBuildПредупреждение о ресурсах в двоичном формате; подавляется через GenerateResourceWarnOnBinaryFormatterUseнизкая
SettingsPropertyValue.PropertyValueИмеет тип object, поэтому пользовательские настройки System.Configuration с собственными типами нельзя мигрировать без слома APIвысокая

Подготовительный список

Шаги миграции

  1. Инвентаризуйте каждую границу полезной нагрузки, а не каждое место вызова. Сгруппируйте использования BinaryFormatter по тому, куда уходят байты: только в память (помощник глубокого клонирования), кеш внутри процесса, долговременное хранилище (столбец базы данных, блоб, файл на диске) и межпроцессное взаимодействие (буфер обмена, очередь, RPC в стиле remoting). Использования в памяти и внутри процесса меняются одним коммитом. Долговременные и межпроцессные требуют окна перехода формата. Зафиксируйте замкнутый набор типов, доходящих до каждой границы.

    Проверка: каждое совпадение из grep выше отнесено ровно к одной из четырёх групп, а у каждой долговременной границы есть названный ответственный и названный список сериализуемых типов.

  2. Выберите сериализатор на замену для каждой границы. Прямой замены нет, и выбирать один и тот же везде не обязательно. Официальное сравнение сводится к следующему: System.Text.Json, когда полезная нагрузка может быть текстовой и типы можно снабдить атрибутами (единственный вариант в списке с полноценной поддержкой AOT и генерацией исходного кода); DataContractSerializer, когда типы менять вообще нельзя, потому что это единственный рекомендованный сериализатор, уважающий [Serializable] и ISerializable; MessagePack for C# или protobuf-net, когда нагрузка должна остаться компактной двоичной.

    Проверка: рядом с каждой границей из шага 1 записан один сериализатор и однострочное обоснование. Если обоснование звучит как “он был по умолчанию”, вернитесь назад.

  3. Сначала замените использования в памяти и внутри процесса. Это бесплатный выигрыш, сокращающий площадь для сложных шагов. Типу [Serializable], переезжающему на System.Text.Json, нужно явное согласие на всё, что раньше было неявным: поля не сериализуются, пока вы не попросите, приватные члены требуют собственного контракта, а сам [Serializable] не значит ничего.

    // .NET 10.0.11, C# 14
    using System.Text.Json;
    using System.Text.Json.Serialization;
    
    [JsonSourceGenerationOptions(IncludeFields = true)]
    [JsonSerializable(typeof(CartSnapshot))]
    internal partial class CartContext : JsonSerializerContext;
    
    public sealed class CartSnapshot
    {
        public int Version;                 // a field, so IncludeFields is required
        public string? CouponCode { get; set; }
        public List<int> LineItemIds { get; set; } = [];
    }
    
    byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(snapshot, CartContext.Default.CartSnapshot);
    CartSnapshot? back = JsonSerializer.Deserialize(bytes, CartContext.Default.CartSnapshot);

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

  4. Добавьте путь двойного чтения на каждой долговременной границе. Именно этот шаг позволяет выкатиться. NrbfDecoder.StartsWithPayloadHeader сообщает, являются ли только что прочитанные байты старым NRBF, и если да, вы их декодируете, заново сериализуете новым сериализатором и записываете обратно. Чтения переносят корпус лениво; записи с первого дня идут только в новом формате.

    // .NET 10.0.11, System.Formats.Nrbf 10.0.11
    using System.Formats.Nrbf;
    
    internal static CartSnapshot Load(string path)
    {
        byte[] raw = File.ReadAllBytes(path);
    
        if (!NrbfDecoder.StartsWithPayloadHeader(raw))
        {
            return JsonSerializer.Deserialize(raw, CartContext.Default.CartSnapshot)!;
        }
    
        CartSnapshot upgraded = ReadLegacy(raw);
        File.WriteAllBytes(path, JsonSerializer.SerializeToUtf8Bytes(upgraded, CartContext.Default.CartSnapshot));
        return upgraded;
    }

    Проверка: тест, который записывает настоящий продакшн-образец NRBF во временный файл, вызывает Load, проверяет значения, а затем проверяет, что второй Load больше не идёт по старой ветке.

  5. Реализуйте ReadLegacy через NrbfDecoder, по одному типу за раз. NrbfDecoder декодирует; он никогда не создаёт ваши типы, никогда не загружает сборку и никогда не рекурсирует. Конструирование выполняете вы, и именно поэтому он безопасен на недоверенном входе. ClassRecord отдаёт члены по имени через типизированные аксессоры, а TypeNameMatches сравнивает имена типов, игнорируя идентичность сборки, так что переадресация типов и смена версии сборки вас не ломают.

    // .NET 10.0.11, System.Formats.Nrbf 10.0.11
    using System.Formats.Nrbf;
    
    private static CartSnapshot ReadLegacy(byte[] raw)
    {
        using MemoryStream stream = new(raw);
        ClassRecord root = NrbfDecoder.DecodeClassRecord(stream);
    
        if (!root.TypeNameMatches(typeof(CartSnapshot)))
        {
            throw new InvalidDataException($"Unexpected payload type '{root.TypeName.AssemblyQualifiedName}'.");
        }
    
        SZArrayRecord<int> ids = (SZArrayRecord<int>)root.GetArrayRecord(nameof(CartSnapshot.LineItemIds))!;
        if (ids.Length > 10_000)
        {
            throw new InvalidDataException("Line item array exceeds the sane limit.");
        }
    
        return new CartSnapshot
        {
            Version = root.HasMember(nameof(CartSnapshot.Version)) ? root.GetInt32(nameof(CartSnapshot.Version)) : 1,
            CouponCode = root.GetString(nameof(CartSnapshot.CouponCode)),
            LineItemIds = [.. ids.GetArray()],
        };
    }

    HasMember — это аварийный выход для версионирования: поле, добавленное или переименованное между записью полезной нагрузки и сегодняшним днём, даёт false, а не исключение. Проверка длины перед GetArray не опциональна, потому что NRBF позволяет враждебной нагрузке дёшево пообещать два миллиарда null.

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

  6. Если типы действительно нельзя менять, используйте DataContractSerializer вместо шагов 3-5. Это единственный рекомендованный вариант, уважающий модель программирования [Serializable] и ISerializable, так что типы остаются нетронутыми. Подвох в том, что известные типы нужно указывать заранее, включая приватные, а некоторые распространённые типы (в частности DateTimeOffset) не входят в список разрешённых по умолчанию. PreserveObjectReferences возвращает поведение с идентичностью объектов и циклами, которое BinaryFormatter давал бесплатно.

    // .NET 10.0.11
    using System.Runtime.Serialization;
    
    DataContractSerializer serializer = new(
        typeof(CartSnapshot),
        new DataContractSerializerSettings
        {
            KnownTypes = [typeof(PercentageCoupon), typeof(FixedAmountCoupon), typeof(DateTimeOffset)],
            PreserveObjectReferences = true,
        });

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

    Проверка: тест полного цикла по всему замыканию известных типов, включая граф с намеренным циклом, который проходит при PreserveObjectReferences = true.

  7. Разбирайтесь с WinForms и WPF отдельно. С .NET 9 оба фреймворка внутренне используют подмножество NRBF для буфера обмена, перетаскивания и ресурсов времени разработки, но только для встроенного списка: примитивы, string, decimal, TimeSpan, DateTime, nint, nuint, PointF, RectangleF, плюс Bitmap и ImageListStreamer в WinForms, а также массивы и списки из них. Всё остальное откатывается к BinaryFormatter и падает. Предписанное решение для буфера обмена и перетаскивания — самостоятельно класть в буфер string или byte[], обычно JSON, и разбирать его на принимающей стороне. Для сериализации пользовательского типа через дизайнер и ResX зарегистрируйте TypeConverter, чтобы дизайнер использовал его вместо отката к BinaryFormatter.

    Проверка: ручное копирование-вставка и перетаскивание между двумя запущенными экземплярами приложения для каждого пользовательского формата, плюс полный цикл в дизайнере (открыть форму, сохранить, открыть заново) без MSB3825 и без исключения во время выполнения.

  8. Только после этого решайте насчёт пакета совместимости. Если сторонняя зависимость вызывает BinaryFormatter внутри себя и ждать её исправления нельзя, установите System.Runtime.Serialization.Formatters только в проекте приложения. Пакет не меняет идентичность типа BinaryFormatter, поэтому библиотеки в графе подхватывают рабочую реализацию без пересборки.

    <!-- .NET 10.0.11. Unsupported, and a temporary measure. -->
    <PropertyGroup>
      <TargetFramework>net10.0</TargetFramework>
      <EnableUnsafeBinaryFormatterSerialization>true</EnableUnsafeBinaryFormatterSerialization>
    </PropertyGroup>
    
    <ItemGroup>
      <PackageReference Include="System.Runtime.Serialization.Formatters" Version="10.0.11" />
    </ItemGroup>

    Для ResX есть второй барьер: дополнительно установите переключатель AppContext System.Resources.Extensions.UseBinaryFormatter в true.

    Проверка: ссылка на пакет существует ровно в одном файле проекта, и есть датированная задача сопровождения, называющая зависимость, которая к этому вынудила.

Проверьте миграцию

Откат

Изменение кода обратимо, изменение данных — нет. Как только шаг 4 перезаписал блоб в новом формате, старые байты исчезли, и откат на сборку, понимающую только NRBF, их не прочитает. Два следствия, которые стоит запланировать: храните байты прежнего формата в течение всего окна отката (пишите обновлённую нагрузку в новый столбец или под новый ключ, а не поверх, и удаляйте старую только после закрытия окна), и оставьте путь чтения старого формата через NrbfDecoder в коде минимум на один релиз после того, как счётчик миграции достигнет нуля. Если вы выкатываете пакет совместимости как мост, откат тривиален, но угроза безопасности реальна всё время, пока он развёрнут, поэтому проставьте дату в задаче сопровождения.

Подводные камни, о которых стоит знать заранее

[Serializable] ничего не значит для System.Text.Json. Типы, которые проходили полный цикл через BinaryFormatter с приватными полями и без публичного конструктора, под JSON молча дадут {}. Сбой — это не исключение, а пустой вывод, поэтому тест полного цикла из шага 3 обязан сравнивать приватное состояние.

Идентичность объектов исчезает. BinaryFormatter сохранял ссылки и справлялся с циклами. System.Text.Json требует ReferenceHandler.Preserve, DataContractSerializer требует PreserveObjectReferences = true, и если пропустить оба, общий дочерний объект после полного цикла молча превращается в два объекта. Там, где старый код полагался на равенство по ссылке после десериализации, это допущение теперь неверно.

NrbfDecoder — декодер, а не эмулятор BinaryFormatter. Его поведение намеренно не совпадает с поведением BinaryFormatter, поэтому успешное декодирование нельзя считать доказательством того, что вызов BinaryFormatter был бы безопасен. Он также не поддерживает массивы с ненулевым начальным индексом, которые .NET Framework мог записывать в NRBF-нагрузки, а .NET никогда не читал.

Некоторые библиотеки нельзя мигрировать в принципе. SettingsPropertyValue.PropertyValue имеет тип object, поэтому файл настроек System.Configuration мог содержать буквально что угодно. Замкнутого набора типов для декодирования нет, а значит, нет и пути через NrbfDecoder без слома API. Именно из-за таких типов инвентаризация из шага 1 идёт первой.

Сериализация исключений — отдельное устаревание. SYSLIB0051 покрывает конструктор Exception(SerializationInfo, StreamingContext) и остальную поддержку старой сериализации. Ваши пользовательские исключения, скорее всего, всё ещё несут этот конструктор; удалять его безопасно, когда ничто больше не гоняет исключения через форматтер, и это хороший grep для того же прохода.

Межверсионная конверсия должна выполняться там, где реализация ещё есть. Если вы одновременно уходите с .NET Framework, напишите одноразовый инструмент конверсии блобов, пока у вас ещё есть среда выполнения с рабочим BinaryFormatter, либо используйте System.Formats.Nrbf, который нацелен в том числе на .NET Standard 2.0 и .NET Framework именно для того, чтобы сторона декодирования могла работать где угодно.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад