Исправление сериализации объектов Kotlin раз и навсегда

Некоторое время назад я прочитал статью, описывающую проблему с объектами Kotlin и сериализацией с использованием встроенных методов Java. Автор предлагает удивительное решение, включающее добавление метода readResolve к каждому объекту, реализующего java.io.Serializable, вместо использования проверок экземпляров.

Несмотря на то, что этот подход кажется наиболее правильным, поддержка его может оказаться настоящим кошмаром. Учитывая, что здесь, в Bumble - материнской компании, работающей с приложениями Badoo и Bumble - мы не используем Serializable нигде, кроме Bundle, мы решили сохранить is для каждого пункта выражения when и полностью забыть об этой проблеме. Однако в глубине души я просто не мог понять, почему компилятор Kotlin не генерирует сам по себе readResolve для целей JVM. Казалось, эта работа больше подходит для инструмента генерации кода, чем для человека.

Эта проблема возникла, когда я исследовал плагины компилятора Kotlin, и вот как ее можно элегантно решить. Если Kotlin не генерирует эти функции сам по себе, давайте поможем ему в этом! В этой статье рассматривается реализация плагина компилятора для этой цели.

Планировать заранее

Прежде всего, давайте подробнее рассмотрим код, который мы собираемся сгенерировать:

Плагин должен генерировать readResolve функцию для каждого объекта, расширяющего java.io.Serializable, если этот класс не содержит ни одной. Функция имеет нулевые параметры и тип возвращаемого значения Any?. В теле мы возвращаем одноэлементный экземпляр объекта.

Нам не нужно, чтобы этот метод был виден в IDE в коде Kotlin или Java; на самом деле, предпочтительно, чтобы он был скрытым. Таким образом, мы можем реализовать генерацию на сервере компилятора. Результирующий метод будет существовать внутри JAR, но на него нельзя будет ссылаться в компиляторе или редакторе.

Настройка

Теперь пришло время настроить сборку плагина компилятора с Gradle и проверить, что он подключается к компилятору в отдельном модуле интеграции.

Плагин имеет зависимость времени компиляции от компилятора; во время выполнения Kotlin уже предоставляет все классы. К счастью, JetBrains публикует отдельную версию компилятора, которую мы можем здесь использовать.

Точкой входа в плагин является подкласс ComponentRegistrar, который создается при запуске компиляции и позволяет регистрировать пользовательские части плагина.

Регистратор создается с помощью ServiceLoader, поэтому для jar плагина требуется файл ресурсов с именем класса реализации в папке META-INF/services. Если вы не хотите управлять этими файлами самостоятельно, AutoService Google сгенерирует их для вас.

Теперь мы можем подключить плагин к модулю интеграции:

Эта конфигурация gradle добавит в проект плагин компилятора и все его зависимости, поэтому он будет загружен с выполнением задачи gradle. Если мы сейчас попытаемся скомпилировать какой-либо класс в процессе интеграции, он должен напечатать в консоли «Works».

После того, как плагин настроен, мы переходим к фактической генерации кода. Kotlin в настоящее время поддерживает три разные платформы, из которых нас интересует только JVM (просто потому, что java.io.Serializable существует только там). Генерация этого бэкэнда в его текущем состоянии поддерживается с помощью ExpressionCodegenExtension, который нам и нужно реализовать.

Это расширение применяется к каждому классу, с которым компилятор сталкивается на этапе генерации байт-кода. Он позволяет вам изменять ссылки на функции / свойства и создавать некоторые синтетические части класса. Последняя функция - это именно то, что нам нужно, чтобы добавить readResolve.

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

Возможные точки расширения определены как подкласс ProjectExtensionDescriptor<T>. Они предоставляют функцию registerExtension для добавления ваших собственных экземпляров расширения. Для генерации байт-кода нам нужно будет использовать только ExpressionCodegenExtension, но внутри компилятора есть гораздо больше открытых возможностей.

Последний шаг - подключить это расширение к ComponentRegistrar.

Теперь вы можете добавить несколько классов в модуль integration-test и проверить, что он печатает при компиляции!

Генерация байт-кода

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

Проверка состоит из трех этапов:

  1. Подтвердите, что мы имеем дело с объектом
  2. Проверить объект реализует Serializable любым способом
  3. Убедитесь, что readResolve еще не реализован.

Для первого шага в Kotlin уже есть полезный метод проверки того, является ли класс объектом, поэтому мы можем перейти к следующему.

Мы просматриваем иерархию классов и выясняем, являются ли какие-либо из встречающихся нам интерфейсов java.io.Serializable. Обратите внимание, что нам нужно проверить родителей как суперклассов, так и интерфейсов.

Последняя часть - выяснить, переопределен ли уже наш сериализуемый класс readResolve:

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

Как только все эти условия проверены, мы можем приступить к созданию метода. Компилятор Kotlin использует ASM для манипуляций с байт-кодом и предоставляет уже подготовленный ClassBuilder через объект codegen.

Мы настроили новый метод для открытого ClassBuilder с общедоступными и синтетическими модификаторами, поэтому он не отображается ни в Java, ни в классах Kotlin в IDE. Мы также предоставляем лямбда-выражение для генерации тела функции.

Байт-код объекта Example выше дает нам желаемый байт-код для передачи тела:

GETSTATIC Example.INSTANCE : LExample;
ARETURN

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

Тестирование

Команда компиляторов Kotlin тестирует свои плагины разными способами. Однако многие из них слишком сложны для самостоятельной настройки (например, проверка инструкций байт-кода), поэтому я перейду к более высокому уровню тестирования. Во-первых, мы протестируем сам компилятор, проверив вывод с помощью отражения Java. А во второй части используется наш модуль интеграции.

Для прямого тестирования компилятора я использую kotlin-compile-testing. Эта удивительная библиотека позволяет тестировать встроенные фрагменты или файлы в каталоге ресурсов с множеством настроек.

Тест предоставляет компилятору фрагмент кода в виде виртуального файла и проверяет полученный класс на наличие функции readResolve с использованием отражения Java.

У нас уже есть настроенный модуль интеграции, поэтому мы можем добавить туда тесты. Осталось только выбрать свой любимый тестовый фреймворк и проверить экземпляр объекта после сериализации:

Вот и все!

Заключение

Плагины компилятора Kotlin - идеальный инструмент для генерации кода и метапрограммирования. Несмотря на начальную кривую обучения, возможности, которые открывает эта платформа, огромны, и я призываю всех попробовать. Если вы хотите начать прямо сейчас, я рекомендую попробовать Arrow Meta, они проделали потрясающую работу по созданию платформы для разработки плагинов прямо сейчас.

Однако следует сказать, что есть некоторые скрытые подводные камни, которые я не затронул, например, отсутствие надлежащей документации по всему, что связано с компилятором или постоянное изменение API между основными версиями. Надеюсь, что в какой-то момент мы получим больше официальной поддержки по Kotlin 1.4.

Полный код этого плагина доступен на Github. Он также опубликован в репозитории плагинов gradle, чтобы вы могли попробовать их в своих проектах. Если вы хотите узнать больше о плагинах для Kotlin / Android / компилятора, не стесняйтесь подписываться на меня в Twitter!

Наша команда Bumble и Badoo Android растет! Не стесняйтесь обращаться к нам и узнавать больше об этом здесь, если вы хотите присоединиться к нам 🙂