На сайте OpenJDK появился новый исследовательский документ, в котором описывается идея введения в язык новой улучшенной сериализации взамен старой.

Сериализация в Java существует с версии 1.1, то есть практически с момента её рождения. С одной стороны, сериализация является очень удобным механизмом, который позволяет быстро и просто сделать любой класс сериализуемым посредством наследования этого класса от интерфейса java.io.Serializable. Возможно даже, эта простота стала одной из ключевых причин, почему Java набрала такую огромную популярность в мире, ведь она позволила быстро и эффективно писать сетевые приложения.

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

Что не так с сериализацией в Java? Перечислим наиболее серьёзные проблемы:

  • Сериализация (и десериализация) происходит в обход языковых механизмов. Она игнорирует модификаторы доступа полей (private, protected) и создаёт объекты, не используя конструкторы, а значит игнорирует инварианты, которые могут присутствовать в этих конструкторах. Такую уязвимость может использовать злоумышленник, подменив данные на невалидные, и они успешно «проглотятся» при десериализации.
  • При написании сериализуемых классов никак не помогает компилятор и не обнаруживает ошибки. Например, вы не можете статически гарантировать, что все поля сериализуемого класса сами являются сериализуемыми. Или можете опечататься в имени методов readObject, writeObject, readResolve и т.д., и тогда эти методы просто не будут использоваться во время сериализации.
  • Сериализация не поддерживает нормального механизма версионирования, поэтому очень сложно изменять сериализуемые классы так, чтобы они оставались совместимыми с их старыми версиями.
  • Сериализация сильно завязана на потоковое кодирование/декодирование, а значит поменять формат кодирования на отличный от стандартного очень сложно. Кроме того, стандартный формат не является ни компактным, ни эффективным и ни человекочитаемым.

Фундаментальная ошибка существующей сериализации в Java заключается в том, что она пытается быть слишком «невидимой» для программиста. Он просто наследуется от java.io.Serializable и получает некую неявную магию, которая выполняется виртуальной машиной.
Наоборот, программист должен явно писать конструкции, отвечающие за конструирование и деконструирование объектов. Эти конструкции должны быть на уровне языка и должны быть написаны посредством статического доступа к полям, а не рефлексии.

Другая ошибка сериализации в том, что она пытается делать слишком много. Она ставит себе задачу уметь сериализовать любой произвольный граф объектов (который может содержать циклы) и десериализовать его обратно, не ломая его состояние.

Это ошибку можно исправить, если упростить задачу и делать сериализацию не графа объектов, а дерева данных, в котором не будет понятия identity (как в JSON).

Как сделать сериализацию, которая бы естественно вписывалась в объектную модель, использовала конструкторы при десериализации, была отделена от формата кодирования и поддерживала версионирование? Для этого на помощь приходят аннотации и ещё не вошедшая в Java возможность языка: паттерн-матчинг. Например:

public class Range {
    int lo;
    int hi;
  
    private Range(int lo, int hi) {
        if (lo > hi)
            throw new IllegalArgumentException(String.format("(%d,%d)",
                                                             lo, hi));
        this.lo = lo;
        this.hi = hi;
    }
 
    @Serializer
    public pattern Range(int lo, int hi) { 
        lo = this.lo;
        hi = this.hi;
    }
    
    @Deserializer
    public static Range make(int lo, int hi) {
        return new Range(lo, hi);
    }
}

В этом примере объявлен класс Range, который готов к сериализации посредством двух специальных членов класса: сериализатора и десериализатора помеченных аннотациями @Serializer и @Deserializer. Сериализатор реализован через деконструктор паттерна, а десериализатор – через статический метод, в котором вызывается конструктор. Таким образом, при десериализации неминуемо проверяется инвариант hi >= lo, указанный в конструкторе.
В таком подходе нет никакой магии, и используются обычные аннотации, поэтому сериализацию может делать любой фреймворк, а не только сама платформа Java. Это значит, что формат кодирования может быть также абсолютно любым (бинарный, XML, JSON, YAML и т.д.).

Так как сериализаторы и десериализаторы – это обычные методы, то программист имеет большую свободу в их реализации. Например, он может выбрать репрезентацию объекта, отличной от той, как представлен объект в памяти. К примеру, LinkedList можно будет сериализовать не в цепочку ссылок, а в один непрерывный массив, что сделает представление более простым, эффективным и компактным.

Версионирование в таком подходе реализуется с помощью специального поля version у аннотаций @Serializer и @Deserializer:

class C {
    int a;
    int b;
    int c;
   
    @Deserializer(version = 3)
    public C(int a, int b, int c) { 
        this a = a;
        this.b = b;
        this.c = c;
    }
    
    @Deserializer(version = 2)
    public C(int a, int b) { 
        this(a, b, 0);
    }

    @Deserializer(version = 1)
    public C(int a) { 
        this(a, 0, 0);
    }
    
    @Serializer(version = 3)
    public pattern C(int a, int b, int c) {
        a = this.a;
        b = this.b;
        c = this.c;
    }
}

В этом примере будет вызван один из трёх десериализаторов в зависимости от версии.
Что делать, если мы не хотим, чтобы сериализаторы и десериализаторы были доступны кому-то кроме как для целей сериализации? Для этого мы можем сделать их приватными. Однако в таком случае конкретный фреймворк сериализации не сможет получить к ним доступ через рефлексию, если такой код находится внутри модуля, в котором пакет не открыт для глубокого рефлективного доступа. Для такого случая предлагается ввести в язык ещё одну новую конструкцию: открытые члены классов. Например:

class Foo {
    private final InternalState is;

    public Foo(ExternalState es) {
        this(new InternalState(es));
    }
    
    @Deserializer
    private open Foo(InternalState is) { 
        this.is = is;
    }
    
    @Serializer
    private open pattern serialize(InternalState is) { 
        is = this.is;
    }
}

Здесь сериализаторы и десериализаторы помечены ключевым словом open, что делает их открытыми для setAccessible.

Таким образом, новый подход фундаментально отличается от старого: в нём классы проектируются как сериализуемые, а не отдаются платформе как есть. Это требует дополнительных усилий, но делает сериализацию более предсказуемой, безопасной и независимой от формата кодирования и фреймворка сериализации.

P.S. Друзья, если вы хотите получать подобные новости о Java более быстро и удобно, то подписывайтесь на мой канал в Telegram.

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


  1. vektory79
    12.06.2019 21:35

    А самую большую проблему сериализации так и не указали: уязвимость удалённого выполнения произвольного кода, которую для того-же EJB даже исправить невозможно.


    1. zim32
      12.06.2019 23:50

      Мне всегда было непонятно, как такое происходит. Как сериализация, которая вроде должна лишь хранить состояние объекта, не его поведение, в итоге приводит к уязвимости выполнения кода.


    1. Mishiko
      13.06.2019 00:17

      уязвимость удалённого выполнения произвольного кода
      — а в чем уязвимость? не понял Вашу мысль


      1. DieSlogan
        13.06.2019 09:45

        Существует целое семейство уязвимостей, связанных с сериализацией.
        Грубо говоря, нельзя вызывать бинарную сериализацию/десериализацию на не доверенных данных в бинарных типах, т.к. тут передаются метаданные объекта и атакующий может в эти данные обернуть свой вредоносный код. Такой код называется гаджетом (gadget). Про создание бинарного гаджета см. здесь deadcode.me/blog/2016/09/02/Blind-Java-Deserialization-Commons-Gadgets.html

        Тоже самое касается и JSON/XML сериализации. Как минимум у библиотеки должны быть отключены настройки использования метаданных, в рамках JSON это нотация $types. См. blackhat.com/docs/us-17/thursday/us-17-Munoz-Friday-The-13th-Json-Attacks.pdf


    1. orionll Автор
      13.06.2019 07:00

      Ссылку на уязвимость, будьте добры



      1. naething
        13.06.2019 07:56

        Вот, например:
        github.com/frohoff/ysoserial
        deadcode.me/blog/2016/09/02/Blind-Java-Deserialization-Commons-Gadgets.html#execWait

        Уязвимость в том, что десериализация вызывает конструктор для произвольного класса, имя которого указано в исходном потоке байтов. Если в вашем classpath достаточно классов, которые делают разные полезные вещи в своем конструкторе, то можно соорудить хитрый объект, который при десериализации будет делать, что вам нужно. Такие «полезные» классы называют «гаджетами», и одни из наиболее популярных — из библиотек Apache Commons, которые почти в каждом крупном проекте используются.

        Для заделывания дыр часто применяют черные или белые списки классов, но понятно, что это не дает 100% гарантии. В общем, технология broken by design, что называется.


        1. orionll Автор
          13.06.2019 08:14

          Судя по javadoc'у, эта уязвимость в Apache Commons Collections была исправлена


    1. eirnym
      13.06.2019 22:21

      не будем вслух упоминать о уязвимостях XML (со времён создания XML) как способа сериализации/десериализации данных, и установках парсера, включённых в Java по-умолчанию


  1. Mishiko
    13.06.2019 00:10

    наследуется от java.io.Serializable и получает некую неявную магию

    Она ставит себе задачу уметь сериализовать любой произвольный граф объектов

    — имплементация программистом интерфейса Serializable означает, что он уверен в сериализуемости объектов класса. Тут нет никакой магии. Вы описали класс, добавили где надо модификатор transient и уверены в его сериализуемости, что и подтверждаете имплементацией интерфейса.


    1. orionll Автор
      13.06.2019 07:00

      А если программистов несколько? Вот написал ваш так называемый уверенный программист класс, а потом пришёл другой и добавил в него несериализуемое поле и забыл пометить его transient. Вот и всё, вы словите исключение только в рантайме.
      Что касается магии — это то, как работает сериализация под капотом. Это страшная вещь. Знаете ли вы, что сериализация работает, даже если в вашем классе отсутствует no-arg конструктор? А знаете, как она обходит это? Она генерирует такой конструктор в рантайме с помощью метода sun.reflect.ReflectionFactory.newConstructorForSerialization. Это ли не магия? А то, что сериализация устанавливает final поля после уже конструирования объекта — не магия? А сериализация циклических графов объектов — не магия?


      1. Mishiko
        13.06.2019 12:51
        -1

        а потом пришёл другой и добавил в него

        — ошибки такого типа могут возникать применительно к любому элементу разработки, наиболее часто при маппинге объектов (к этой группе можно отнести и сериализацию/десериализацию). Учитывая, что всем многообразии мапперов, проблема с кривым маппингом сохраняется по сию пору, можно предположить что и радикальное решение проблем сериализации невозможно.

        а потом пришёл другой и добавил в него

        — фактически Вы предлагаете заменить ручной код на ту самую «магию», которой пугаете. Магии станет больше.


  1. samhuawey
    13.06.2019 10:26

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

    И да, бороться с головотяпством лучше всего юнит-тестами и запретом коммитить код, не прошедший тестирование.

    Ну и напоследок — откуда пошла страсть засовывать логику программы в аннотации? Насколько я помню, в том же С++ стремились избавиться от define и прочих чудес препроцессора, в Джаве же упорно стремятся наступить на грабли. И потом получается что код, который прекрасно работает с одной версией Springa крашится с другой потому что что-то там поменяли в аннотациях, а программист поленился прочитать изменения в новой версии библиотеки. Не по-человечески это как-то.


    1. orionll Автор
      13.06.2019 13:25

      А что значит средствами синтаксиса языка? А как её решать по-другому? Синтаксиса здесь специального созданного для сериализации, кстати, никакого и нет. Паттерн-матчинг — это независимая фича, которая так или иначе появится в языке. open тоже не связан с сериализацией как таковой, это просто более тонкая альтернатива opens из модулей. Аннотации Serializer/Deserializer — ну это просто аннотации. Ну разве что там будет дополнительные синтаксические проверки компилятором, но это не синтаксис.


  1. slonopotamus
    13.06.2019 10:43

    Возможность сериализовать граф с циклами — ошибка? Нуок.


  1. Savalek
    13.06.2019 13:09

    Так есть же ещё Externalizable.
    Почти то же самое что вы описали.


    1. orionll Автор
      13.06.2019 13:15

      Абсолютно нет. Деэсктернализация происходит по тому же принципу, как и десериализация (восстановление полей в уже сконструированном объекте). Что в итоге выливается в те же самые проблемы, от которых мы хотим уйти. Плюс экстернализация является потокоориентированной, от чего мы тоже хотим уйти.


  1. tonad
    13.06.2019 13:15

    new Range(lo, hi);

    Интересный подбор названий переменных )

    private open pattern serialize(InternalState is)

    Как минимум 2 ключевых слова… То есть все у кого есть методы open и pattern, получат много «радости» при переходе на новую версию? )


    1. orionll Автор
      13.06.2019 13:18

      lo, hi — стандартное сокращение для low и high. Такое много где можно увидеть.

      Слово pattern будет введено в язык в независимости от появления новой сериализации, когда появится паттерн-матчинг. Слово open пока вызывает больше всего вопросов. Возможно от него и откажутся.


      1. Rhombus
        13.06.2019 14:56

        Слово pattern будет введено в язык в независимости от появления новой сериализации, когда появится паттерн-матчинг.

        Цитату можно?

        Посмотрел cr.openjdk.java.net/~briangoetz/amber/pattern-match.html — там нет ничего про такое ключевое слово.


        1. orionll Автор
          13.06.2019 15:38

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


  1. rombell
    13.06.2019 13:54

    private open pattern serialize(InternalState is)

    или
    private open pattern Foo(InternalState is)

    А то я что-то нить теряю


    1. orionll Автор
      13.06.2019 14:49

      Первое — это деконструктор, помеченный аннотацией @Serializer.
      Второе — конструктор, помеченный аннотацией @Deserializer.
      Деконструктор — это фишка из паттерн-матчинга, обратная конструктору. Конструктор собирает объект из полей, деконструктор — из объекта достаёт поля.


      1. rombell
        13.06.2019 15:02

        Разве в конструкторе теперь не надо писать имя класса Foo?
        Оба варианта предполагались @Serializer, поленился написать.

        А, понял, ввёл в заблуждение новый синтаксис.

        Это не конструктор, это такой метод?

        @Serializer
        public pattern Range(int lo, int hi) {
        lo = this.lo;
        hi = this.hi;
        }


        1. orionll Автор
          13.06.2019 15:45

          Это деконструктор. Он позволит разложить ваш инстанс Range на компоненты. Выглядеть это будет как-то так:

          Range range = new Range(0, 10);
          let Range(lo, hi) = range;
          System.out.println("low=" + lo + ", high=" + hi);
          

          Также вы сможете делать switch:
          switch (range) {
              case Range(lo, hi) -> System.out.println("low=" + lo + ", high=" + hi);
              ...
          }


        1. orionll Автор
          13.06.2019 15:48

          Посмотрите вот этот отличный доклад от lany. Там всё разложено по полочкам.


          1. rombell
            13.06.2019 16:02

            спасибо


  1. TargetSan
    13.06.2019 19:42

    А чем не подходит схема с явным конструктором, принимающим хранилище-источник, и методом serialize, принимающим хранилище-приёмник?


    1. orionll Автор
      13.06.2019 22:54

      Хранилище — это как раз то, отчего хотят уйти (ObjectOutputStream/ObjectInputStream), потому что они слишком завязаны на конкретный формат кодировки. Кроме того, в предложенном вами подходе придётся всегда писать два конструктора (обычный и для десериализации) и дублировать код в них.