Перейти к основному содержимому

Введение в проект Ломбок

· 14 мин. чтения

1. Избегайте повторяющегося кода

Java — отличный язык, но иногда он может быть слишком многословным для общих задач, которые мы должны выполнять в нашем коде, или для соблюдения некоторых практик фреймворка. Зачастую это не приносит реальной пользы бизнес-стороне наших программ, и именно здесь на помощь приходит Ломбок, чтобы сделать нас более продуктивными.

Это работает путем подключения к нашему процессу сборки и автоматического создания байт-кода Java в наших файлах .class в соответствии с рядом аннотаций проекта, которые мы вводим в наш код.

Включить его в наши сборки, какую бы систему мы ни использовали, очень просто. На странице проекта Project Lombok есть подробные инструкции по специфике. Большинство моих проектов основаны на maven, поэтому я просто обычно отбрасываю их зависимость в предоставленной области, и все готово:

<dependencies>
...
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
<scope>provided</scope>
</dependency>
...
</dependencies>

Мы можем проверить самую последнюю доступную версию здесь .

Обратите внимание, что зависимость от Lombok не заставит пользователей наших .jar также зависеть от него, так как это чистая зависимость сборки, а не время выполнения.

2. Геттеры/сеттеры, конструкторы — повторяющиеся

Инкапсуляция свойств объекта с помощью общедоступных методов получения и установки является обычной практикой в мире Java, и многие фреймворки широко полагаются на этот шаблон «Java Bean» (класс с пустым конструктором и методами получения/установки для «свойств»).

Это настолько распространено, что большинство IDE поддерживают автоматическое создание кода для этих шаблонов (и не только). Однако этот код должен жить в наших источниках и поддерживаться при добавлении нового свойства или переименовании поля.

Давайте рассмотрим этот класс, который мы хотим использовать в качестве сущности JPA:

@Entity
public class User implements Serializable {

private @Id Long id; // will be set when persisting

private String firstName;
private String lastName;
private int age;

public User() {
}

public User(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}

// getters and setters: ~30 extra lines of code
}

Это довольно простой класс, но представьте, если бы мы добавили дополнительный код для геттеров и сеттеров. Мы бы пришли к определению, в котором шаблонного кода с нулевым значением было бы больше, чем соответствующей бизнес-информации: «У пользователя есть имя, фамилия и возраст».

Давайте теперь Lombok-ize этого класса:

@Entity
@Getter @Setter @NoArgsConstructor // <--- THIS is it
public class User implements Serializable {

private @Id Long id; // will be set when persisting

private String firstName;
private String lastName;
private int age;

public User(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
}

Добавив аннотации @Getter и @Setter , мы сказали Lombok сгенерировать их для всех полей класса. @NoArgsConstructor приведет к генерации пустого конструктора.

Обратите внимание, что это весь код класса, мы ничего не пропускаем, в отличие от версии выше с комментарием // геттеры и сеттеры . Для класса с тремя релевантными атрибутами это значительная экономия кода!

Если мы дополнительно добавим атрибуты (свойства) в наш класс User , произойдет то же самое; мы применяем аннотации к самому типу, чтобы они по умолчанию учитывали все поля.

Что, если мы хотим улучшить видимость некоторых свойств? Например, если мы хотим, чтобы пакет модификаторов поля id наших сущностей был видимым или защищенным , поскольку ожидается, что они будут прочитаны, но не установлены явно кодом приложения, мы можем просто использовать более тонкий @Setter для этого конкретного поля: ``

private @Id @Setter(AccessLevel.PROTECTED) Long id;

3. Ленивый добытчик

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

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

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

Предположим, что эти данные кэшируются как поле внутри класса. Теперь класс должен убедиться, что любой доступ к этому полю возвращает кэшированные данные. Один из возможных способов реализации такого класса — заставить метод получения получать данные только в том случае, если поле имеет значение null . Мы называем это ленивым добытчиком .

Lombok делает это возможным с помощью ленивого параметра в аннотации @ Getter , которую мы видели выше.

Например, рассмотрим этот простой класс:

public class GetterLazy {

@Getter(lazy = true)
private final Map<String, Long> transactions = getTransactions();

private Map<String, Long> getTransactions() {

final Map<String, Long> cache = new HashMap<>();
List<String> txnRows = readTxnListFromFile();

txnRows.forEach(s -> {
String[] txnIdValueTuple = s.split(DELIMETER);
cache.put(txnIdValueTuple[0], Long.parseLong(txnIdValueTuple[1]));
});

return cache;
}
}

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

Если мы теперь посмотрим на скомпилированный код этого класса, мы увидим метод получения, который обновляет кеш, если он был нулевым , а затем возвращает кешированные данные :

public class GetterLazy {

private final AtomicReference<Object> transactions = new AtomicReference();

public GetterLazy() {
}

//other methods

public Map<String, Long> getTransactions() {
Object value = this.transactions.get();
if (value == null) {
synchronized(this.transactions) {
value = this.transactions.get();
if (value == null) {
Map<String, Long> actualValue = this.readTxnsFromFile();
value = actualValue == null ? this.transactions : actualValue;
this.transactions.set(value);
}
}
}

return (Map)((Map)(value == this.transactions ? null : value));
}
}

Интересно отметить, что Lombok обернул поле данных в AtomicReference . Это обеспечивает атомарные обновления поля транзакций . Метод getTransactions() также обеспечивает чтение файла, если транзакции равны нулю.

Мы не рекомендуем использовать поле транзакций AtomicReference непосредственно внутри класса. Мы рекомендуем использовать метод getTransactions() для доступа к полю.

По этой причине, если мы используем другую аннотацию Lombok, например ToString , в том же классе , она будет использовать getTransactions() вместо прямого доступа к полю.

4. Классы значений/DTO

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

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

public class LoginResult {

private final Instant loginTs;

private final String authToken;
private final Duration tokenValidity;

private final URL tokenRefreshUrl;

// constructor taking every field and checking nulls

// read-only accessor, not necessarily as get*() form
}

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

@RequiredArgsConstructor
@Accessors(fluent = true) @Getter
public class LoginResult {

private final @NonNull Instant loginTs;

private final @NonNull String authToken;
private final @NonNull Duration tokenValidity;

private final @NonNull URL tokenRefreshUrl;

}

Как только мы добавим аннотацию @RequiredArgsConstructor , мы получим конструктор для всех конечных полей в классе, как мы их объявили. Добавление @NonNull к атрибутам заставляет наш конструктор проверять допустимость значений NULL и соответственно выбрасывать исключения NullPointerException . Это также произошло бы, если бы поля были не окончательными, и мы добавили для них @Setter .

Нужна ли нам скучная старая форма get*() для наших свойств? Поскольку в этом примере мы добавили @Accessors(fluent=true) , у «геттеров» будет то же имя метода, что и у свойств; getAuthToken() просто становится authToken() .

Эта «свободная» форма будет применяться к неконечным полям для установщиков атрибутов, а также позволит выполнять цепные вызовы:

// Imagine fields were no longer final now
return new LoginResult()
.loginTs(Instant.now())
.authToken("asdasd")
. // and so on

5. Базовый шаблон Java

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

Мы можем автоматизировать это с помощью других аннотаций уровня класса Lombok:

  • @ToString : сгенерирует метод toString() , включая все атрибуты класса. Нет необходимости писать его самим и поддерживать его по мере того, как мы обогащаем нашу модель данных.
  • @EqualsAndHashCode : по умолчанию будут генерироваться методы equals() и hashCode() с учетом всех соответствующих полей и в соответствии с очень хорошей семантикой .

Эти генераторы поставляются с очень удобными вариантами конфигурации. Например, если наши аннотированные классы являются частью иерархии, мы можем просто использовать параметр callSuper=true , и родительские результаты будут учитываться при создании кода метода.

Чтобы продемонстрировать это, предположим, что наш пример сущности User JPA включает ссылку на события, связанные с этим пользователем:

@OneToMany(mappedBy = "user")
private List<UserEvent> events;

Мы бы не хотели, чтобы весь список событий выгружался всякий раз, когда мы вызываем метод toString() нашего пользователя только потому, что мы использовали аннотацию @ToString . Вместо этого мы можем параметризовать его следующим образом: @ToString(exclude = {“events”}) , и этого не произойдет. Это также полезно, чтобы избежать циклических ссылок, если, например, UserEvent содержит ссылку на User .

Для примера LoginResult мы можем захотеть определить равенство и вычисление хэш-кода только с точки зрения самого токена, а не других конечных атрибутов в нашем классе. Затем мы можем просто написать что-то вроде @EqualsAndHashCode(of = {“authToken”}) .

Если функции из аннотаций, которые мы рассмотрели до сих пор, представляют интерес, мы можем захотеть также изучить аннотации @Data и @Value , поскольку они ведут себя так, как если бы их набор был применен к нашим классам. В конце концов, во многих случаях эти обсуждаемые употребления очень часто объединяются.

5.1. (Не) Использование @EqualsAndHashCode с сущностями JPA

Следует ли нам использовать методы equals() и hashCode() по умолчанию или создавать собственные методы для сущностей JPA — это часто обсуждаемая тема среди разработчиков. Есть несколько подходов , которым мы можем следовать, каждый из которых имеет свои плюсы и минусы.

По умолчанию @EqualsAndHashCode включает все неокончательные свойства класса сущностей. Мы можем попытаться «исправить» это, используя атрибут onlyExplicitlyIncluded @EqualsAndHashCode , чтобы Lombok использовал только первичный ключ сущности. Тем не менее, сгенерированный метод equals() может вызвать некоторые проблемы. Торбен Янссен более подробно объясняет этот сценарий в одном из своих сообщений в блоге .

В общем, нам следует избегать использования Lombok для генерации методов equals() и hashCode() для наших сущностей JPA.

6. Шаблон Строителя

В качестве примера класса конфигурации для клиента REST API можно использовать следующее:

public class ApiClientConfiguration {

private String host;
private int port;
private boolean useHttps;

private long connectTimeout;
private long readTimeout;

private String username;
private String password;

// Whatever other options you may thing.

// Empty constructor? All combinations?

// getters... and setters?
}

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

Вместо этого мы можем указать инструменту сгенерировать шаблон построителя , что избавит нас от необходимости писать дополнительный класс Builder и связанные с ним плавные методы, подобные сеттеру, просто добавив аннотацию @Builder в нашу конфигурацию ApiClientConfiguration:

@Builder
public class ApiClientConfiguration {

// ... everything else remains the same

}

Оставив определение класса, как указано выше (без объявления конструкторов или сеттеров + @Builder ), мы можем в конечном итоге использовать его как:

ApiClientConfiguration config = 
ApiClientConfiguration.builder()
.host("api.server.com")
.port(443)
.useHttps(true)
.connectTimeout(15_000L)
.readTimeout(5_000L)
.username("myusername")
.password("secret")
.build();

7. Бремя проверенных исключений

Многие API-интерфейсы Java разработаны таким образом, что они могут генерировать ряд проверенных исключений; клиентский код вынужден либо перехватывать , либо объявлять throws . Сколько раз мы превращали эти исключения, которые, как мы знаем, не произойдут во что-то подобное?:

public String resourceAsString() {
try (InputStream is = this.getClass().getResourceAsStream("sure_in_my_jar.txt")) {
BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
return br.lines().collect(Collectors.joining("\n"));
} catch (IOException | UnsupportedCharsetException ex) {
// If this ever happens, then its a bug.
throw new RuntimeException(ex); <--- encapsulate into a Runtime ex.
}
}

Если мы хотим избежать этого шаблона кода, потому что компилятор не будет доволен (и мы знаем, что проверяемые ошибки не могут произойти), используйте метко названный @SneakyThrows :

@SneakyThrows
public String resourceAsString() {
try (InputStream is = this.getClass().getResourceAsStream("sure_in_my_jar.txt")) {
BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
return br.lines().collect(Collectors.joining("\n"));
}
}

8. Убедитесь, что наши ресурсы высвобождены

В Java 7 появился блок try-with-resources, чтобы гарантировать, что наши ресурсы удерживаются экземплярами чего-либо, реализующего java.lang . AutoCloseable освобождаются при выходе.

Lombok предоставляет альтернативный и более гибкий способ добиться этого с помощью @Cleanup . Мы можем использовать его для любой локальной переменной, ресурсы которой мы хотим освободить. Им не нужно реализовывать какой-либо конкретный интерфейс, мы просто вызовем метод close() :

@Cleanup InputStream is = this.getClass().getResourceAsStream("res.txt");

Наш метод освобождения имеет другое имя? Нет проблем, мы просто настраиваем аннотацию:

@Cleanup("dispose") JFrame mainFrame = new JFrame("Main Window");

9. Аннотируйте наш класс, чтобы получить регистратор

Многие из нас экономно добавляют операторы ведения журнала в наш код, создавая экземпляр Logger из выбранной нами среды. Допустим, SLF4J:

public class ApiClientConfiguration {

private static Logger LOG = LoggerFactory.getLogger(ApiClientConfiguration.class);

// LOG.debug(), LOG.info(), ...

}

Это настолько распространенный паттерн, что разработчики Lombok упростили его для нас:

@Slf4j // or: @Log @CommonsLog @Log4j @Log4j2 @XSlf4j
public class ApiClientConfiguration {

// log.debug(), log.info(), ...

}

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

10. Пишите потокобезопасные методы

В Java мы можем использовать ключевое слово synchronized для реализации критических секций; однако это не 100% безопасный подход. Другой клиентский код также может синхронизироваться с нашим экземпляром, что может привести к неожиданным взаимоблокировкам.

Вот тут-то и появляется @Synchronized . Мы можем аннотировать наши методы (как экземпляры, так и статические) с его помощью, и мы получим автоматически сгенерированное, закрытое, скрытое поле, которое наша реализация будет использовать для блокировки:

@Synchronized
public /* better than: synchronized */ void putValueInCache(String key, Object value) {
// whatever here will be thread-safe code
}

11. Автоматизируйте композицию объектов

В Java нет конструкций на уровне языка, чтобы сгладить подход «предпочтение наследования композиции». Другие языки имеют встроенные концепции, такие как Traits или Mixins , для достижения этой цели.

@Delegate Lombok очень удобен, когда мы хотим использовать этот шаблон программирования. Рассмотрим пример:

  • Мы хотим , чтобы пользователи и клиенты имели некоторые общие атрибуты для именования и номера телефона.
  • Мы определяем как интерфейс, так и класс адаптера для этих полей.
  • Наши модели реализуют интерфейс и @Delegate для своего адаптера, эффективно объединяя их с нашей контактной информацией.

Во-первых, давайте определим интерфейс:

public interface HasContactInformation {

String getFirstName();
void setFirstName(String firstName);

String getFullName();

String getLastName();
void setLastName(String lastName);

String getPhoneNr();
void setPhoneNr(String phoneNr);

}

Теперь адаптер в качестве класса поддержки :

@Data
public class ContactInformationSupport implements HasContactInformation {

private String firstName;
private String lastName;
private String phoneNr;

@Override
public String getFullName() {
return getFirstName() + " " + getLastName();
}
}

Теперь самое интересное; посмотрите, как легко составить контактную информацию в обоих классах модели:

public class User implements HasContactInformation {

// Whichever other User-specific attributes

@Delegate(types = {HasContactInformation.class})
private final ContactInformationSupport contactInformation =
new ContactInformationSupport();

// User itself will implement all contact information by delegation

}

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

12. Откатить Ломбок назад?

Короткий ответ: совсем нет.

Может возникнуть опасение, что если мы будем использовать Lombok в одном из наших проектов, то позже мы можем захотеть отменить это решение. Потенциальная проблема может заключаться в том, что для него аннотировано большое количество классов. В этом случае мы защищены благодаря инструменту delombok из того же проекта.

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

Это то, что мы можем интегрировать в нашу сборку .

13. Заключение

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

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

Теперь, когда мы можем дать Ломбоку шанс войти в наш набор инструментов для разработки Java, мы можем повысить нашу производительность.

Код примера можно найти в проекте GitHub .