О Lombok

2020-08-30

На одном проекте, который, ой, уже как почти год идёт, мы не уломали технарей от заказчика на Kotlin. На наши попытки рассказать, чем Kotlin хорош, нам говорили: ну вот же, берёте Lombok, и будет так же лаконично. Так мы узнали, что такое Lombok, Project Lombok.

На самом деле, Lombok — это остров в Индонезии. Рядом с островом Java. Там ещё рядом есть город Jakarta. Ну вы поняли, да?

Индонезия

Lombok — это библиотека кодогенерации. В Maven достаточно просто добавить зависимость, и вот у вас уже работает плугин, который творит магию. В Gradle Lombok нужно подключить либо как плугин, либо как специальную annotationProcessor зависимость.

Что за код генерирует Lombok? Ну вот простенький пример.

import lombok.Data;

@Data
public class Location {
    private double latitude;
    private double longitude;
}

Аннотация @Data добавляет нам геттеры, сеттеры, toString(), equals(), hashCode() и конструктор для «обязательных» аргументов. В данном случае и latitude, и longitude являются double, у которых есть дефолтное значение 0.0, и они не объявлены final. С точки зрения Lombok это «необязательные» аргументы, поэтому создался конструктор без аргументов.

Location location = new Location();
location.setLatitude(1.23);
location.setLongitude(4.56);

location.getLatitude();
location.getLongitude();

location.toString();
// Location(latitude=1.23, longitude=4.56)
location.hashCode();
// -819911996

Location anotherLocation = new Location();
location.equals(anotherLocation);
// false

Получился просто JavaBean.

Аналогичный data class на Kotlin будет выглядеть так:

data class Location(
    var latitude: Double = 0.0,
    var longitude: Double = 0.0
)

Чтобы можно было изменять свойства класса, они объявлены как var. Чтобы можно было вызывать конструктор без аргументов, добавлены значения по умолчанию. Не вполне котлиновый подход.

val location = Location()
location.latitude = 1.23
location.longitude = 4.56

location.latitude
location.longitude

println(location)
// Location(latitude=1.23, longitude=4.56)
println(location.hashCode())
// 1091536083

val anotherLocation = Location()
location == anotherLocation
// false

Но мы ведь все за иммутабельные данные? Поэтому отходим от JavaBean и используем аннотацию @Value.

import lombok.Value;

@Value
public class Location {
    double latitude;
    double longitude;
}

@Value делает все поля класса private final, поэтому явно писать private не нужно. Нам сгенерируют геттеры, toString(), equals() и hashCode(). Сеттеров не будет, иммутабельный класс же. И будет конструктор со всеми аргументами, который создаёт наш объект раз и навсегда.

Location location = new Location(1.23, 4.56);

location.getLatitude();
location.getLongitude();

location.toString();
// Location(latitude=1.23, longitude=4.56)
location.hashCode();
// -819911996

Location anotherLocation = new Location(1.23, 4.56);
location.equals(anotherLocation);
// true

Если, после Котлина, вам не нравится слово new, можно попробовать добавить @AllArgsConstructor(staticName="of"). @Value уже неявно добавляет эту аннотацию, только без этого аргумента. Тогда можно будет так:

Location location = Location.of(1.23, 4.56);

Так вот, навешивая всё больше и больше аннотаций, и меняется поведение Lombok.

Иммутабельное значение уже похоже на поведение типичных дата классов Котлина.

data class Location(
    val latitude: Double,
    val longitude: Double
)

Конструктор без new. Возможность только читать свойства.

val location = Location(1.23, 4.56)

location.latitude
location.longitude

println(location)
// Location(latitude=1.23, longitude=4.56)
println(location.hashCode())
// 1091536083

val anotherLocation = Location(1.23, 4.56)
location == anotherLocation
// true

А что если не все свойства обязательны? Как конструировать такие объекты?

В Java нам либо придётся создавать больше конструкторов, в которых можно запутаться, особенно, если типы полей одинаковые. Либо создавать больше фабричных методов. Либо воспользоваться паттерном Builder.

В Lombok есть аннотация @Builder.

@Value
@Builder
public class Location {
    double latitude;
    double longitude;
    Double radius;
}

Теперь объекты можно конструировать через билдер. При этом не обязательно указывать все свойства.

Location location = Location.builder()
    .latitude(1.23)
    .longitude(4.56)
    .build();

location.toString();
// Location(latitude=1.23, longitude=4.56, radius=null)

Location radiusLocation = Location.builder()
    .latitude(1.23)
    .longitude(4.56)
    .radius(5.0)
    .build();

radiusLocation.toString();
// Location(latitude=1.23, longitude=4.56, radius=5.0)

А в Kotlin билдеры просто не нужны. Потому что там есть именованные аргументы и аргументы по умолчанию.

data class Location(
    val latitude: Double,
    val longitude: Double,
    val radius: Double? = null
)

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

val location = Location(
    latitude = 1.23, longitude = 4.56
)

println(location)
// Location(latitude=1.23, longitude=4.56, radius=null)

val radiusLocation = Location(
    latitude = 1.23, longitude = 4.56,
    radius = 5.0
)

println(radiusLocation)
// Location(latitude=1.23, longitude=4.56, radius=5.0) 

А что, если нам нужно «поменять» иммутабельный класс? Ну чтобы не указывать заново 100500 свойств, а лишь поменять парочку при копировании.

В Lombok можно получить билдер из объекта. Нужно добавить toBuilder.

@Value
@Builder(toBuilder = true)
public class Location {
    double latitude;
    double longitude;
    Double radius;
}

Теперь у объекта есть метод toBuilder().

Location location = Location.builder()
    .latitude(1.23)
    .longitude(4.56)
    .build();

location.toString();
// Location(latitude=1.23, longitude=4.56, radius=null)

Location radiusLocation = location.toBuilder()
    .radius(5.0)
    .build();

radiusLocation.toString();
// Location(latitude=1.23, longitude=4.56, radius=5.0)

В Kotlin ничего добавлять не нужно. У дата классов уже есть метод copy() с теми же аргументами, что у конструктора, только всеми необязательными.

val location = Location(
    latitude = 1.23, longitude = 4.56
)

println(location)
// Location(latitude=1.23, longitude=4.56, radius=null)

val radiusLocation = location.copy(
    radius = 5.0
)

println(radiusLocation)
// Location(latitude=1.23, longitude=4.56, radius=5.0)

Билдеры, это, конечно, хорошо. Если у вас Java, а не Kotlin. Но @Value класс с @Builder — это не JavaBean. От этого возникают проблемы с десериализацией.

С сериализацией проблем нет. У нас есть геттеры. А значит, сериализатор может узнать имена и значения свойств.

А вот для десериализации нам нужно создать объект по сериализованному описанию. Конструктор у нас без аргументов. Сеттеров нет. Где-то там есть билдер, но про него десериализатору ещё нужно сообщить.

Полностью обвешенный аннотациями для Jackson класс выглядит так:

@Value
@Builder(builderClassName = "Builder", toBuilder = true)
@JsonDeserialize(builder = Location.Builder.class)
public class Location {
    double latitude;
    double longitude;

    @JsonPOJOBuilder(withPrefix = "")
    public static final class Builder {
    }
}

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

ObjectMapper mapper = new ObjectMapper();

Location location = Location.builder()
    .latitude(1.23).longitude(4.56)
    .build();

String json = mapper.writeValueAsString(location);
// {"latitude":1.23,"longitude":4.56}

Location fromJson = mapper.readValue(json, Location.class);
location.equals(fromJson);
// true

Либо можно попросить Lombok сгенерировать публичный конструктор с аннотацией @ConstructorProperties. Эта аннотация сохраняет имена параметров конструктора. И Джексон может воспользоваться этим конструктором, чтобы создать объект.

Нужно снова добавить @AllArgsConstructor:

@Value
@Builder
@AllArgsConstructor
public class Location {
    double latitude;
    double longitude;
}

Но также создать файл lombok.config в корне проекта. С таким содержимым:

lombok.addLombokGeneratedAnnotation = true
lombok.anyConstructor.addConstructorProperties = true

Расширение jackson-lombok, кстати, устарело.

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

Котлиновые дата классы тоже Джексону из коробки не по зубам. Но есть стабильный официальный поддерживаемый jackson-module-kotlin. Он работает.

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.fasterxml.jackson.module.kotlin.registerKotlinModule

//...

val mapper = ObjectMapper().registerKotlinModule()

val location = Location(
    latitude = 1.23, longitude = 4.56
)

val json = mapper.writeValueAsString(location)
// {"latitude":1.23,"longitude":4.56,"radius":null}

val fromJson: Location = mapper.readValue(json)
println(location == fromJson)
// true

registerKotlinModule() и readValue() — это extension функции. Ещё одна приятная фича Котлина. А readValue() ещё и inline reified функция, благодаря чему можно не указывать тип, который мы пытаемся прочесть из JSON. Он будет выведен из типа переменной, которой здесь присваивается значение.

У Lombok есть проблемы не только с Jackson. Это — кодогенерация. И даже не столько кодогенерация, потому что нового кода-то и не создаётся, сколько вмешательство в процесс компиляции. Модификация AST, судя по тому, что гуглится.

А кодогенерация в Java так и не стала естественным этапом сборки, как, например, в Go. Раз в коде нет ни геттеров, ни сеттеров, ни билдеров, ни прочих нагенерированных методов, ни IDE, ни прочие инструменты их не увидят.

Без соответствующего плугина, работать с Lombok в IDE практически невозможно. А к IDEA плугин поддерживается не авторами Lombok, не JetBrains, а неким частным лицом с русской фамилией, проживающим в Германии. И за этот год этот плугин уже несколько раз отваливался, при обновлении IDEA. Причём в последний раз это, вроде как, было по вине JetBrains.

И не только IDE плохо. Всякие инструменты покрытия кода тестами, статистические анализаторы и прочие тоже спотыкаются о Lombok. Поэтому Lombok приходится разломбочивать. То есть генерировать тот самый отсутствующий код, который (не) создаёт Lombok.

С Kotlin таких проблем нет. Это отдельный язык со своим отдельным компилятором из исходного кода в JVM байткод. С полноценными плугинами для Maven, Gradle и IDE.

Что ещё мы используем от Lombok? Ну, например, аннотацию @Slf4j.

@Slf4j
public class Main {

    public static void main(final String[] args) {
        log.info("Hello, World!");
    }

}

Lombok добавляет нам в класс статическое свойство log.

В Kotlin можно добавить синтаксического сахара с помощью extension функций. Но оставить явное создание логера.

fun Any.logger(): Logger = LoggerFactory.getLogger(this.javaClass)

class Main {

    private val log = logger()

    fun main() {
        log.info("Hello, World!")
    }

}

Lombok не нужен, если есть Kotlin.

Lombok очень странно встраивается в работу компилятора. Он требует сильно больших танцев с бубнами, чтобы все сопутствующие инструменты заработали. Kotlin надёжнее, стабильнее и удобнее.

Вот этими глюками плугинов и десериализаторов и буду пугать заказчика в следующий раз, если меня будут отговаривать от Kotlin, размахивая перед носом Lombok.