Scrivi codice Java senza grassi con Project Lombok
Pubblicato: 2022-03-11Ci sono una serie di strumenti e librerie che non riesco a immaginare di scrivere codice Java senza di questi tempi. Tradizionalmente, cose come Google Guava o Joda Time (almeno per l'era precedente a Java 8) sono tra le dipendenze che finisco per inserire nei miei progetti la maggior parte del tempo, indipendentemente dal dominio specifico a portata di mano.
Lombok merita sicuramente il suo posto anche nelle mie build POM o Gradle, sebbene non sia una tipica utility di libreria/framework. Lombok è in circolazione da un po' di tempo ormai (rilasciato per la prima volta nel 2009) e da allora è maturato molto. Tuttavia, ho sempre pensato che meritasse più attenzione: è un modo straordinario di affrontare la verbosità naturale di Java.
In questo post, esploreremo cosa rende Lombok uno strumento così utile.
Java ha molte cose utili oltre alla JVM stessa, che è un software straordinario. Java è maturo e performante e la comunità e l'ecosistema che lo circonda sono enormi e vivaci.
Tuttavia, come linguaggio di programmazione, Java ha alcune idiosincrasie e scelte di progettazione che possono renderlo piuttosto prolisso. Aggiungi alcuni costrutti e modelli di classe che gli sviluppatori Java hanno spesso bisogno di usare e spesso finiamo con molte righe di codice che apportano poco o nessun valore reale oltre al rispetto di una serie di vincoli o convenzioni del framework.
È qui che entra in gioco Lombok. Ci consente di ridurre drasticamente la quantità di codice "boilerplate" che dobbiamo scrivere. I creatori di Lombok sono una coppia di ragazzi molto intelligenti e hanno sicuramente un gusto per l'umorismo: non puoi perderti questa introduzione che hanno fatto in una conferenza passata!
Vediamo come Lombok fa la sua magia e alcuni esempi di utilizzo.
Come funziona Lombok
Lombok agisce come un processore di annotazioni che "aggiunge" codice alle tue classi in fase di compilazione. L'elaborazione delle annotazioni è una funzionalità aggiunta al compilatore Java alla versione 5. L'idea è che gli utenti possano inserire processori di annotazioni (scritti da se stessi o tramite dipendenze di terze parti, come Lombok) nel percorso di classe build. Quindi, mentre il processo di compilazione è in corso, ogni volta che il compilatore trova un'annotazione, in qualche modo chiede: "Ehi, qualcuno nel percorso di classe è interessato a questa @Annotazione?." Per quei processori che alzano la mano, il compilatore trasferisce loro il controllo insieme al contesto di compilazione per, beh... elaborare.
Forse il caso più comune per i processori di annotazione è generare nuovi file sorgente o eseguire una sorta di controllo in fase di compilazione.
Lombok in realtà non rientra in queste categorie: Quello che fa è modificare le strutture dei dati del compilatore usate per rappresentare il codice; cioè, il suo albero sintattico astratto (AST). Modificando l'AST del compilatore, Lombok altera indirettamente la generazione finale del bytecode stesso.
Questo approccio insolito e piuttosto invadente ha tradizionalmente portato Lombok a essere visto come una specie di hack. Anche se io stesso sarei d'accordo con questa caratterizzazione in una certa misura, piuttosto che vederla nel cattivo senso della parola, vedrei Lombok come "un'alternativa intelligente, tecnicamente meritoria e originale".
Tuttavia, ci sono sviluppatori che lo considerano un hack e non usano Lombok per questo motivo. Questo è comprensibile, ma secondo la mia esperienza, i vantaggi in termini di produttività di Lombok superano qualsiasi di queste preoccupazioni. Lo uso felicemente per progetti di produzione ormai da molti anni.
Prima di entrare nei dettagli, vorrei riassumere i due motivi per cui stimo particolarmente l'uso di Lombok nei miei progetti:
- Lombok aiuta a mantenere il mio codice pulito, conciso e al punto. Trovo che le mie classi annotate di Lombok siano molto espressive e generalmente trovo che il codice annotato sia piuttosto rivelatore di intenzioni, anche se non tutti su Internet sono necessariamente d'accordo.
- Quando inizio un progetto e penso a un modello di dominio, tendo a iniziare scrivendo classi che sono in gran parte un lavoro in corso e che cambio in modo iterativo mentre penso ulteriormente e le perfeziono. In queste prime fasi, Lombok mi aiuta a muovermi più velocemente non dovendo spostarmi o trasformare il codice standard che genera per me.
Modello di fagioli e metodi di oggetti comuni
Molti degli strumenti e dei framework Java che utilizziamo si basano sul Bean Pattern. I Java Bean sono classi serializzabili che hanno un costruttore zero-args predefinito (e possibilmente altre versioni) ed espongono il loro stato tramite getter e setter, tipicamente supportati da campi privati. Ne scriviamo molti, ad esempio quando lavoriamo con JPA o framework di serializzazione come JAXB o Jackson.
Considera questo bean utente che contiene fino a cinque attributi (proprietà), per i quali vorremmo avere un costruttore aggiuntivo per tutti gli attributi, una rappresentazione di stringhe significativa e definire uguaglianza/hashing in termini di campo email:
public class User implements Serializable { private String email; private String firstName; private String lastName; private Instant registrationTs; private boolean payingCustomer; // Empty constructor implementation: ~3 lines. // Utility constructor for all attributes: ~7 lines. // Getters/setters: ~38 lines. // equals() and hashCode() as per email: ~23 lines. // toString() for all attributes: ~3 lines. // Relevant: 5 lines; Boilerplate: 74 lines => 93% meaningless code :( }
Per brevità qui, invece di includere l'effettiva implementazione di tutti i metodi, ho invece fornito solo commenti che elencano i metodi e il numero di righe di codice che hanno preso le implementazioni effettive. Quel codice standard avrebbe totalizzato più del 90% del codice per questa classe!
Inoltre, se in seguito volessi, ad esempio, cambiare email
in indirizzo e- emailAddress
o fare in modo che la registrationTs
sia una Date
anziché un Instant
, allora dovrei dedicare tempo (con l'aiuto del mio IDE per alcuni casi, è vero) per cambiare cose come ottenere /imposta nomi e tipi di metodi, modifica il mio costruttore di utilità e così via. Ancora una volta, tempo inestimabile per qualcosa che non apporta alcun valore commerciale pratico al mio codice.
Vediamo come Lombok può aiutare qui:
import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; @Getter @Setter @NoArgsConstructor @AllArgsConstructor @ToString @EqualsAndHashCode(of = {"email"}) public class User { private String email; private String firstName; private String lastName; private Instant registrationTs; private boolean payingCustomer; }
Ecco! Ho appena aggiunto un mucchio di annotazioni lombok.*
e ho ottenuto proprio quello che volevo. L'elenco sopra è esattamente tutto il codice che devo scrivere per questo. Lombok si sta agganciando al mio processo di compilazione e ha generato tutto per me (vedi lo screenshot qui sotto del mio IDE).
Come si nota, l'ispettore NetBeans (e ciò accadrà indipendentemente dall'IDE) rileva il bytecode della classe compilato, comprese le aggiunte apportate da Lombok al processo. Quello che è successo qui è abbastanza semplice:
- Usando
@Getter
e@Setter
ho incaricato Lombok di generare getter e setter per tutti gli attributi. Questo perché ho usato le annotazioni a livello di classe. Se avessi voluto specificare selettivamente cosa generare per quali attributi, avrei potuto annotare i campi stessi. - Grazie a
@NoArgsConstructor
e@AllArgsConstructor
, ho ottenuto un costruttore vuoto predefinito per la mia classe e uno aggiuntivo per tutti gli attributi. - L'annotazione
@ToString
genera automaticamente un pratico metodotoString()
, che mostra per impostazione predefinita tutti gli attributi di classe preceduti dal loro nome. - Infine, per avere la coppia di metodi
equals()
ehashCode()
definiti in termini di campo email ho usato@EqualsAndHashCode
e l'ho parametrizzato con l'elenco dei campi rilevanti (solo l'email in questo caso).
Personalizzazione delle annotazioni Lombok
Usiamo ora alcune personalizzazioni di Lombok seguendo questo stesso esempio:
- Vorrei ridurre la visibilità del costruttore predefinito. Poiché ne ho bisogno solo per motivi di conformità del bean, mi aspetto che i consumatori della classe chiami solo il costruttore che accetta tutti i campi. Per imporre ciò, sto personalizzando il costruttore generato con
AccessLevel.PACKAGE
. - Voglio assicurarmi che ai miei campi non vengano mai assegnati valori nulli, né tramite il costruttore né tramite i metodi setter. È sufficiente annotare gli attributi di classe con
@NonNull
; Lombok genererà controlli nulli generandoNullPointerException
quando appropriato nei metodi costruttore e setter. - Aggiungerò un attributo
password
, ma non voglio che venga mostrato quando chiamotoString()
per motivi di sicurezza. Ciò si ottiene tramite l'argomento esclude di@ToString
. - Sto bene esporre pubblicamente lo stato tramite getter, ma preferirei limitare la mutabilità esterna. Quindi lascio
@Getter
così com'è, ma di nuovo utilizzandoAccessLevel.PROTECTED
per@Setter
. - Forse vorrei forzare qualche vincolo sul campo
email
in modo che, se viene modificato, venga eseguita una sorta di controllo. Per questo, implemento da solo il metodosetEmail()
. Lombok ometterà semplicemente la generazione per un metodo già esistente.
Ecco come apparirà la classe User:
import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.NonNull; import lombok.Setter; import lombok.ToString; @Getter @Setter(AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PACKAGE) @AllArgsConstructor @ToString(exclude = {"password"}) @EqualsAndHashCode(of = {"email"}) public class User { private @NonNull String email; private @NonNull byte[] password; private @NonNull String firstName; private @NonNull String lastName; private @NonNull Instant registrationTs; private boolean payingCustomer; protected void setEmail(String email) { // Check for null (=> NullPointerException) // and valid email code (=> IllegalArgumentException) this.email = email; } }
Nota che, per alcune annotazioni, stiamo specificando gli attributi di classe come semplici stringhe. Nessun problema, perché Lombok genererà un errore di compilazione se, ad esempio, digitiamo in modo errato o ci riferiamo a un campo inesistente. Con Lombok, siamo al sicuro.
Inoltre, proprio come per il metodo setEmail()
, Lombok andrà bene e non genererà nulla per un metodo che il programmatore ha già implementato. Questo vale per tutti i metodi e costruttori.
Strutture di dati immutabili
Un altro caso d'uso in cui Lombok eccelle è la creazione di strutture di dati immutabili. Questi sono generalmente indicati come "tipi di valore". Alcuni linguaggi hanno il supporto integrato per questi e c'è anche una proposta per incorporarlo nelle future versioni di Java.
Supponiamo di voler modellare una risposta a un'azione di accesso dell'utente. Questo è il tipo di oggetto che vorremmo semplicemente istanziare e restituire ad altri livelli dell'applicazione (ad esempio, per essere serializzato JSON come corpo di una risposta HTTP). Tale LoginResponse non dovrebbe essere affatto mutevole e Lombok può aiutare a descriverlo in modo succinto. Certo, ci sono molti altri casi d'uso per strutture di dati immutabili (sono multithreading e compatibili con la cache, tra le altre qualità), ma atteniamoci a questo semplice esempio:
import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.ToString; import lombok.experimental.Wither; @Getter @RequiredArgsConstructor @ToString @EqualsAndHashCode public final class LoginResponse { private final long userId; private final @NonNull String authToken; private final @NonNull Instant loginTs; @Wither private final @NonNull Instant tokenExpiryTs; }
Degno di nota qui:

- È stata introdotta un'annotazione
@RequiredArgsConstructor
. Con un nome appropriato, ciò che fa è generare un costruttore per tutti i campi finali che non sono già stati inizializzati. - Nei casi in cui desideriamo riutilizzare un LoginResonse precedentemente emesso (immaginare, ad esempio, un'operazione di "refresh token"), non vogliamo certamente modificare la nostra istanza esistente, ma piuttosto, vogliamo generarne una nuova basata su di essa . Guarda come l'annotazione
@Wither
ci aiuta qui: dice a Lombok di generare unwithTokenExpiryTs(Instant tokenExpiryTs)
che crea una nuova istanza di LoginResponse con tutti i valori dell'istanza with'ed, tranne quello nuovo che stiamo specificando. Vorresti questo comportamento per tutti i campi? Aggiungi@Wither
alla dichiarazione di classe.
@Dati e @Valore
Entrambi i casi d'uso discussi finora sono così comuni che Lombok fornisce un paio di annotazioni per renderli ancora più brevi: Annotare una classe con @Data
farà sì che Lombok si comporti proprio come se fosse stato annotato con @Getter
+ @Setter
+ @ToString
+ @EqualsAndHashCode
+ @RequiredArgsConstructor
. Allo stesso modo, l'utilizzo @Value
trasformerà la tua classe in una immutabile (e definitiva), anche questa come se fosse stata annotata con l'elenco sopra.
Modello del costruttore
Tornando al nostro esempio Utente, se vogliamo creare una nuova istanza, dovremo utilizzare un costruttore con un massimo di sei argomenti. Questo è già un numero piuttosto grande, che peggiorerà ulteriormente se aggiungiamo ulteriori attributi alla classe. Supponiamo inoltre di voler impostare alcuni valori predefiniti per i campi lastName
e payingCustomer
.
Lombok implementa una funzionalità @Builder
molto potente, che ci consente di utilizzare un Builder Pattern per creare nuove istanze. Aggiungiamolo alla nostra classe Utente:
import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.NonNull; import lombok.Setter; import lombok.ToString; @Getter @Setter(AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PACKAGE) @AllArgsConstructor @ToString(exclude = {"password"}) @EqualsAndHashCode(of = {"email"}) @Builder public class User { private @NonNull String email; private @NonNull byte[] password; private @NonNull String firstName; private @NonNull String lastName = ""; private @NonNull Instant registrationTs; private boolean payingCustomer = false; }
Ora siamo in grado di creare fluidamente nuovi utenti in questo modo:
User user = User .builder() .email("[email protected]") .password("secret".getBytes(StandardCharsets.UTF_8)) .firstName("Miguel") .registrationTs(Instant.now()) .build();
È facile immaginare quanto sia conveniente questo costrutto man mano che le nostre classi crescono.
Delega/Composizione
Se vuoi seguire la regola molto sana di "composizione del favore sull'ereditarietà", è qualcosa con cui Java non aiuta davvero, per quanto riguarda la verbosità. Se vuoi comporre oggetti, in genere devi scrivere chiamate di metodi deleganti dappertutto.
Lombok propone una soluzione per questo tramite @Delegate
. Diamo un'occhiata a un esempio.
Immagina di voler introdurre un nuovo concetto di ContactInformation
. Queste sono alcune informazioni che il nostro User
ha e potremmo volere che abbiano anche altre classi. Possiamo quindi modellarlo tramite un'interfaccia come questa:
public interface HasContactInformation { String getEmail(); String getFirstName(); String getLastName(); }
Vorremmo quindi introdurre una nuova classe ContactInformation
usando Lombok:
import lombok.Data; @Data public class ContactInformation implements HasContactInformation { private String email; private String firstName; private String lastName; }
E infine, potremmo refactoring User
per comporre con ContactInformation
e utilizzare Lombok per generare tutte le chiamate di delega necessarie per abbinare il contratto di interfaccia:
import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.NonNull; import lombok.Setter; import lombok.ToString; import lombok.experimental.Delegate; @Getter @Setter(AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PACKAGE) @AllArgsConstructor @ToString(exclude = {"password"}) @EqualsAndHashCode(of = {"contactInformation"}) public class User implements HasContactInformation { @Getter(AccessLevel.NONE) @Delegate(types = {HasContactInformation.class}) private final ContactInformation contactInformation = new ContactInformation(); private @NonNull byte[] password; private @NonNull Instant registrationTs; private boolean payingCustomer = false; }
Nota come non avevo bisogno di scrivere implementazioni per i metodi di HasContactInformation
: questo è qualcosa che stiamo dicendo a Lombok di fare, delegando le chiamate alla nostra istanza ContactInformation
.
Inoltre, poiché non voglio che l'istanza delegata sia accessibile dall'esterno, la personalizzo con un @Getter(AccessLevel.NONE)
, impedendo in modo efficace la generazione di getter.
Eccezioni controllate
Come tutti sappiamo, Java distingue tra eccezioni controllate e non controllate. Questa è una fonte tradizionale di polemiche e critiche al linguaggio poiché la gestione delle eccezioni a volte ci ostacola troppo, specialmente quando si ha a che fare con API progettate per generare eccezioni controllate e quindi costringere noi sviluppatori a catturarle o dichiarare i nostri metodi per lanciali.
Considera questo esempio:
public class UserService { public URL buildUsersApiUrl() { try { return new URL("https://apiserver.com/users"); } catch (MalformedURLException ex) { // Malformed? Really? throw new RuntimeException(ex); } } }
Questo è un modello così comune: sappiamo certamente che il nostro URL è ben formato, tuttavia, poiché il costruttore di URL
genera un'eccezione verificata, siamo costretti a catturarlo o dichiarare il nostro metodo per lanciarlo e inviare i chiamanti nella stessa situazione. Il wrapping di queste eccezioni verificate all'interno di una RuntimeException
è una pratica molto estesa. E questo diventa ancora peggio se il numero di eccezioni controllate che dobbiamo gestire aumenta man mano che codifichiamo.
Quindi questo è esattamente a cosa @SneakyThrows
di Lombok, avvolgerà tutte le eccezioni verificate soggette a essere lanciate nel nostro metodo in una non controllata e ci libererà dal problema:
import lombok.SneakyThrows; public class UserService { @SneakyThrows public URL buildUsersApiUrl() { return new URL("https://apiserver.com/users"); } }
Registrazione
Con quale frequenza aggiungi istanze di logger alle tue classi in questo modo? (campione SLF4J)
private static final Logger LOG = LoggerFactory.getLogger(UserService.class);
Ho intenzione di indovinare un bel po'. Sapendo questo, i creatori di Lombok hanno implementato un'annotazione che crea un'istanza logger con un nome personalizzabile (impostazione predefinita per log), supportando i framework di logging più comuni sulla piattaforma Java. Proprio così (di nuovo, basato su SLF4J):
import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @Slf4j public class UserService { @SneakyThrows public URL buildUsersApiUrl() { log.debug("Building users API URL"); return new URL("https://apiserver.com/users"); } }
Annotazione del codice generato
Se usiamo Lombok per generare codice, potrebbe sembrare che perderemmo la possibilità di annotare quei metodi poiché in realtà non li stiamo scrivendo. Ma questo non è proprio vero. Piuttosto, Lombok ci permette di dirgli come vorremmo che il codice generato fosse annotato, usando una notazione alquanto particolare, a dire il vero.
Considera questo esempio, mirato all'uso di un framework di iniezione delle dipendenze: abbiamo una classe UserService
che usa l'iniezione del costruttore per ottenere i riferimenti a UserRepository
e UserApiClient
.
package com.mgl.toptal.lombok; import javax.inject.Inject; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor(onConstructor = @__(@Inject)) public class UserService { private final UserRepository userRepository; private final UserApiClient userApiClient; // Instead of: // // @Inject // public UserService(UserRepository userRepository, // UserApiClient userApiClient) { // this.userRepository = userRepository; // this.userApiClient = userApiClient; // } }
L'esempio sopra mostra come annotare un costruttore generato. Lombok ci consente di fare la stessa cosa anche per i metodi e i parametri generati.
Imparare di più
L'utilizzo di Lombok spiegato in questo post si concentra su quelle funzionalità che ho trovato personalmente più utili nel corso degli anni. Tuttavia, ci sono molte altre funzionalità e personalizzazioni disponibili.
La documentazione di Lombok è molto istruttiva e completa. Hanno pagine dedicate per ogni singola caratteristica (annotazione) con spiegazioni ed esempi molto dettagliati. Se trovi questo post interessante, ti incoraggio ad approfondire lombok e la sua documentazione per saperne di più.
Il sito del progetto documenta come utilizzare Lombok in diversi ambienti di programmazione. In breve, sono supportati gli IDE più diffusi (Eclipse, NetBeans e IntelliJ). Io stesso passo regolarmente da uno all'altro in base al progetto e uso Lombok su tutti loro in modo impeccabile.
Delombok!
Delombok fa parte della "toolchain Lombok" e può tornare molto utile. Quello che fa è fondamentalmente generare il codice sorgente Java per il tuo codice annotato Lombok, eseguendo le stesse operazioni che fa il bytecode generato da Lombok.
Questa è un'ottima opzione per le persone che stanno pensando di adottare Lombok ma non sono ancora del tutto sicure. Puoi iniziare a usarlo liberamente e non ci sarà alcun "blocco del fornitore". Nel caso in cui tu o il tuo team vi pentirete in seguito della scelta, potete sempre usare delombok per generare il codice sorgente corrispondente che potrete poi usare senza che rimanga alcuna dipendenza da Lombok.
Delombok è anche un ottimo strumento per imparare esattamente cosa farà Lombok. Esistono modi molto semplici per inserirlo nel processo di compilazione.
Alternative
Esistono molti strumenti nel mondo Java che utilizzano in modo simile i processori di annotazione per arricchire o modificare il codice in fase di compilazione, come Immutables o Google Auto Value. Questi (e altri, di sicuro!) si sovrappongono a Lombok per quanto riguarda le funzionalità. Mi piace particolarmente l'approccio di Immutables e l'ho usato anche in alcuni progetti.
Vale anche la pena notare che ci sono altri ottimi strumenti che forniscono funzionalità simili per il "miglioramento del bytecode", come Byte Buddy o Javassist. Questi in genere funzionano in fase di esecuzione, tuttavia, e comprendono un mondo a parte oltre lo scopo di questo post.
Java conciso
Esistono numerosi linguaggi moderni mirati alla JVM che forniscono approcci di progettazione più idiomatici, o addirittura a livello linguistico, che aiutano ad affrontare alcuni degli stessi problemi. Sicuramente Groovy, Scala e Kotlin sono dei bei esempi. Ma se stai lavorando su un progetto solo Java, allora Lombok è un ottimo strumento per aiutare i tuoi programmi a essere più concisi, espressivi e gestibili.