Mis buenas prácticas – Java

1. Pattern.compile() en lugar de String.replaceAll() dentro de un bucle

public String replaceAll(String regex, String replacement) {
    return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}

El Pattern siempre tiene que compilarse primero. Si el mismo Pattern se ejecuta a menudo con strings cortos, el rendimiento es claramente mejor si se reutiliza el Pattern ya compilado.

2. StringBuilder en lugar de + dentro de un bucle

Dentro de un bucle, new StringBuilder() rinde más que +.

3. Usar bien equals() y hashCode()

A veces se incluyen todos los campos en equals() o hashCode(). Es útil para comprobar si una entidad ha cambiado, pero es incorrecto. El propósito de equals() no es comprobar si una entidad se ha modificado, sino determinar si, aunque la instancia del objeto no sea la misma, se trata igualmente de la misma entidad. Es decir: ¡usa solo la clave primaria!

Esto hace falta en cualquier tipo de colección. Si llamas a .contains() sobre una List, Java usará el método equals. Los HashMap necesitan además hashCode(). Ambos deberían sobrescribirse juntos.

Una trampa habitual son las entidades sin guardar y, por tanto, sin ID. Ten cuidado y persístelas pronto para evitar problemas.

Para comparar:

El contrato de estos métodos debe respetarse siempre:

4. Trampas con DateTime al mezclar sistemas operativos

Algunos sistemas operativos tienen un reloj interno con nanosegundos y otros con milisegundos. Por eso, ZonedDateTime.now() no funciona igual en ambas plataformas y rompe equals(). ¡En su lugar, ajusta SIEMPRE la precisión!

/**
 * truncating the precision, because it relies on the internal clock of linux/macOs/windows.
 * windows and older macOS versions use only milliseconds, whereas linux and new macOS versions use microsecond precision.
 */
public static ZonedDateTime getNow() {
    return ZonedDateTime.now().truncatedTo(DateTimeUtil.PRECISION);
}

Las siguientes anotaciones de JPA también dependen de ZonedDateTime.now() y nunca deberían usarse tal cual:

5. No declarar tipos de datos complejos en línea

Crea siempre clases propias para los tipos de datos complejos en lugar de anidar Pair, List, etc.

Mal:

// unlesbar
public Triple<List<Pair<List<List<ChatMessage>>, ZonedDateTime>>, Language, ChatRoom> getChatMessagesGroupedByDay();

Mejor:

public class FTOChatMessageCollection {

    private List<FTOChatMessageCollectionPerDay> messagesPerDay;
    private Language language;
    private ChatRoom chatRoom;

    // constructor, getter & setter ...
}
public class FTOChatMessageCollectionPerDay {

    private List<FTOChatMessage> messages;
    private ZonedDateTime day;

    // constructor, getter & setter ...
}

Ventajas:

Excepciones:

¡Para relaciones sencillas, Pair es genial! Por ejemplo:

Pair<User, Training> pair = this.addCurrentUserToTraining(training);

6. Caché, pero bien hecha (guía de configuración mínima)

La caché (del inglés cache, pronunciado /kæʃ/, como «cash») no es, en principio, más que un Map que guarda entradas. Se ha prescindido de cosas más complejas, como la invalidación por tiempo o similares.

¡No siempre hace falta una librería si basta con un simple Map! Muchos desarrolladores no lo entienden y, por miedo, recurren a librerías por sistema. Lo más importante al cachear es invalidar en el momento adecuado.

Trampa n.º 1: fugas de memoria

Las fugas de memoria (memory leaks) se producen con mucha facilidad al cachear. En cuanto te olvidas de sobrescribir correctamente equals() y hashCode(), ya tienes un HashMap con fugas de memoria. Un método que siempre es seguro es crear a mano las claves del Map. Lo ideal sería incluir únicamente el ID de una entidad; de lo contrario, el Map ya tiene fugas con solo cambiar un campo. Y es lo correcto, porque aunque cambie el nombre de un usuario, sigue siendo el mismo usuario. Para comparar: 3. Usar bien equals() y hashCode()

Trampa n.º 2: thread safety

Lo ideal es que la caché use un ConcurrentHashMap, que ofrece operaciones atómicas y thread-safe. El Map solo se modifica mediante una única llamada, computeIfAbsent(), y así se consigue la thread safety:

private ConcurrentMap<String, T> cache = new ConcurrentHashMap<>();

protected T getOrCompute(final String key, final Supplier<T> computeFunction) {
    // thread safe, because `computeIfAbsent` is atomic, so no need for `synchronized` here.
    // if you extend this to more than 1 map operation, you should synchronize it
    return this.getCacheMap().computeIfAbsent(key, k -> computeFunction.get());
}

Si el código fuera más largo y hubiera varias llamadas, habría que hacer synchronized todo el método.

7. Serialización: ¿sigue haciendo falta?

Mi pregunta: Existe esta regla de Sonar llamada Fields in a “Serializable” class should either be transient or serializable. Salta cuando añades a tu clase un campo no serializable. La regla dice:

Por ejemplo, bajo carga, la mayoría de los frameworks de aplicaciones J2EE vuelcan objetos a disco, y un objeto supuestamente Serializable con miembros de datos no transient y no serializables podría provocar caídas del programa y abrir la puerta a atacantes.

A mí esto me suena desfasado. En Effective Java 3rd Edition se afirma que la serialización no es relevante cuando se desarrolla software nuevo. Además, Removing Serialization from Java Is a 'Long-Term Goal' at Oracle. El propio arquitecto jefe de Java, Mark Reinhold, afirmó:

«La serialización fue un error horrible en 1997».

La pregunta de si el framework Spring puede volcar objetos a disco ya se planteó aquí:

Así que pregunto:

¿Hay servidores de aplicaciones o frameworks modernos, o la propia implementación de J2EE (sobre todo los componentes de Java que interactúan con CDI, EJB y JPA), que vuelquen objetos a disco bajo carga elevada?

¿O esa afirmación está desfasada?

Respuesta:

En resumen: Si no estás implementando una aplicación web distribuible con estado, no activas la persistencia de sesiones y solo usas cachés en memoria, es posible que no necesites Serializable en ninguna parte.

Explicación:

Para la replicación de sesiones, la serialización de Java sigue siendo imprescindible en la especificación de Servlet más reciente: https://jakarta.ee/specifications/servlet/5.0/jakarta-servlet-spec-5.0.html#distributed-environments

Un caso de uso habitual: tienes varios servidores detrás de un balanceador de carga e inicias sesión en uno de ellos, con lo que recibes una cookie JSESSIONID. Si el balanceador te envía a otro servidor en la siguiente petición, tus atributos de inicio de sesión y demás atributos de ámbito de sesión se serializan y se replican en el nuevo servidor, de modo que tu JSESSIONID sigue siendo válido allí.

Otro caso de uso en el que necesitas serialización para tus atributos de ámbito de sesión es cuando el servidor de aplicaciones decide volcar sesiones a disco. En Tomcat 8, por ejemplo, esto se puede configurar para que se active cuando hay demasiadas sesiones para mantenerlas en memoria, o cuando se reinicia el servidor y quieres conservar las sesiones. Consulta https://tomcat.apache.org/tomcat-9.0-doc/config/manager.html

Todo servidor de aplicaciones compatible con Servlet 5.0 debe admitir por defecto objetos Serializable en las sesiones HTTP para este tipo de casos de uso.

En algunos servidores, esto se puede ajustar o sustituir, por ejemplo, con serialización JSON mediante Jackson o algo similar. Consulta, por ejemplo, https://stackoverflow.com/questions/46459707/how-to-use-jackson-instead-of-jdkserializationredisserializer-in-spring

Otro caso de uso habitual es el cacheo. Sirve para mantener los objetos accesibles lo más rápido posible, idealmente en memoria, aunque a veces se descargan temporalmente a disco o a un servidor de caché externo.
Existe una API estándar para ello (JSR-107) que decidió expresamente no exigir objetos Serializable, pero la mayoría de las implementaciones siguen usando por defecto la serialización de Java para la parte de descarga.

También aquí puedes ajustarlas para que admitan otros mecanismos de serialización, por ejemplo en ehcache: https://www.ehcache.org/documentation/3.8/serializers-copiers.html

8. Hibernate: estrategia de fetching

Teniendo en cuenta EAGER fetching is a code smell, escrito por un sabelotodo de lo más carismático que es colaborador de Hibernate, es preferible LAZY como FetchType. Aunque algunos tipos de datos no tengan sentido sin una clave foránea, estos tipos sí se consultan por separado (getCount(), tablas de datos, etc.).

Aquí van todos los escenarios ordenados según lo satisfactoria que sea la solución, empezando por el menos satisfactorio:

1. El peor caso: LazyInitializationException

Sobran las palabras. Todos compartimos este dolor.

2. Apaño rápido

public void updateChatRoom(ChatMessage message) {
    // reload here or LazyInitializationException would be thrown
    message = this.chatMessageService.reload(message); // not optimal: second roundtrip to the database - could easily take 100ms!
    // we missed the chance to unproxy without a performance hit.

    ChatRoom chatRoom = message.getChatRoom().getUpdatedAt(DateTimeUtil.getNow());
}

3. Hacer unproxy directamente en el servicio

public ChatMessage getChatMessage(final Long id, boolean withRoom) {
    // assume this is the same service + the same transaction.
    // so we are in the same persistence context + everything will be performed together at the transaction flush.
    // perfect: no additional roundtrip like in example 2.!

    ChatMessage message = this.chatMessageService.getById(id);

    if (withRoom) {
        ChatRoom chatRoom = this.chatMessageService.unproxy(message.getChatRoom());
    }
   
    // we should not call message.setChatRoom() -> this getter should not even exist as Hibernate would 
    // map this relation for us! setting it again would perform an update on our foreign key!!
    // so we have no option to set the ChatRoom back to the message and have to pass it everywhere in a Tuple or something similar
    return ???; 
}

4. Hacer fetch directamente en el servicio (¡haz esto!)

public ChatMessage getChatMessage(final Long id, boolean withRoom) {
    // all aforementioned problems solved
    return this.executeSingleResultQuery((query, cb, root) -> {
            if (withRoom) {
                root.fetch(ChatRoomEntity_.partition).fetch(ChatPartitionEntity_.chatModule);
            }

            return cb.equal(root.get(ChatRoomEntity_.id), id);
        });
}

9. Hibernate Search

Hibernate Search está perfectamente integrado con Hibernate JPA. Detecta cuándo cambian las entidades y se configura fácilmente:

@Entity
@Indexed(interceptor = SearchableChatMessageInterceptor.class) // hier können Einträge von der Indexierung ausgeschlossen werden
@FullTextFilterDef(name = "SearchableChatMessageFilter", impl = SearchableChatMessageFilter.class) // hier wird der Key festgelegt
public class ChatMessageEntity implements Searchable { // das Searchable dient als Marker-Interface

    @Column
    @Field  // dieses Feld wird indexiert
    private String message;

    @ManyToOne(targetEntity = ChatRoomEntity.class, fetch = FetchType.LAZY, optional = false)
    @IndexedEmbedded(includeEmbeddedObjectId = true, targetElement = ChatRoomEntity.class)   // indexiert den Fremschlüssel mit.
                                                                                             // Über dieses Entity wird auch die Message gesucht,
                                                                                             // wie in @FullTextFilterDef -> SearchableChatMessageFilter definiert
    private ChatRoom chatRoom;

    // ...

}

En local, la búsqueda está desactivada:

// DefaultInstanceInitializer.java

// we don't need the global search everywhere, givin an option to override this setting here
protected boolean isBuildSearchIndexes() {
    return !Constants.Mode.isDebug();
}

Búsqueda difusa y comportamiento de búsqueda

if ((needle.split(" ").length > 1 || needle.contains("\"")) && !searchExact) {
  /*
   * Simple Query String. Provides basic functionality like:
   *
   *     boolean (AND using “+”, OR using “|”, NOT using “-“)
   *     prefix (prefix*)
   *     phrase (“some phrase”)
   *     precedence (using parentheses)
   *     fuzzy (fuzy~2)
   *     near operator for phrase queries (“some phrase”~3)
   */
  query = qb.simpleQueryString()
            .onField(field)
            .andFields(moreFields)
            .withAndAsDefaultOperator()
            .matching(needle)
            .createQuery();
} // ...

Lucene

Depurar Lucene con Luke

Nuestra versión de Hibernate Search hay que depurarla con la versión 6.6.0 de Luke. ¡Solo funciona con esa versión!

ElasticSearch

Hibernate Search también es compatible con ElasticSearch (que entonces sustituye a Lucene y ofrece una búsqueda difusa mejor, con una sensación más parecida a Google).