О 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.