Menos boilerplate en Java, o generación de código en tiempo de compilación con annotations
— Quisiera actualizar mi conexión a la red de 28.8 kilobaudios a una línea T1 de fibra óptica. Tiene usted un servidor con routeador compatible con mi configuración LAN Ethernet Token Ring?
— ... Puede darme dinero?
Vamos por partes
- Menos boilerplate en Java: un objetivo muy noble.
- Generación de código: lo que haremos es que Java arme clases con cierta funcionalidad de forma genérica por nosotros.
- En tiempo de compilación: el código que generemos va a estar en archivos .java, iguales a los que escribimos al programar siempre, con tipado estático.
- Annotations: para marcar dónde y respecto de qué generar el código.
Esta solución se inspira en la forma de trabajar de MapStruct.
Otras soluciones: tradeoffs
- Usar runtime reflection
- Hacer modificación del AST a la Lombok o Spoon
Generar archivos .java en compilación es más robusto ya que podemos ver el código generado y usamos el compilador para asegurarnos que nuestra implementación no rompe.
Sin embargo, es menos flexible: por ejemplo, no podemos editar clases existentes, solo crear nuevas (aunque sí podemos referenciarlas de antemano).
Otra opción es escribir el boilerplate a mano, que es menos mantenible, y por supuesto, mucho más aburrido.
Show me the code!
Tenemos como requisito de ejemplo poder mergear dos objetos de la misma clase, o sea: dados dos objetos, devolver uno que contenga todos los campos no nulos de ambos. Veamos esta value class:
public class Country {
private final String name;
private final Long inhabitantCount;
// constructor, getters, builder...
}
Si googleamos "java merge objects" vamos a encontrar soluciones con runtime reflection que pueden ser poco performantes, poco extensibles/customizables y poco robustas. Y escribir el boilerplate para mergear objetos... Por algo todos buscan soluciones con reflection. Pero hagamos algo mejor!
Implementemos, entonces, un merger autogenerado en tiempo de compilación. Para eso necesitamos ver dos cosas:
- Cómo queremos que el developer/usuario nos use para
Country?
Algo simple es que el usuario anote una interfaz suya con nuestra annotation, a la cual le generaremos su implementación. Para que ande en tiempo de compilación, también necesitamos que a su vez extienda de una interfaz con el método merge. Veamos:
// user's interface
@Merger
public interface CountryMerger extends IMerger<Country> { }
- Qué vamos a necesitar dentro de nuestra solución?
- La interfaz
IMerger<T> - Una annotation custom, que llamamos
@Merger - Un processor con toda la lógica, usando la Java Mirror API y JavaPoet
Interfaz
public interface IMerger<T> {
T merge(T one, T other);
}
Annotation
@Target(ElementType.TYPE) // usable on classes and interfaces
@Retention(RetentionPolicy.SOURCE) // erase after compiling
public @interface Merger { }
Annotation processor
Nota: para resumir el ejemplo, asumo que tu implementación de IMerger<T> tiene un <T> con Builder y getters.
Además, en este caso validamos que no estemos usando un T con atributos primitivos, que nunca son null y por lo tanto no son mergeables.
@SupportedAnnotationTypes("my.package.Merger") // your annotation's FQCN
@SupportedSourceVersion(SourceVersion.RELEASE_17) // your java source version
public class MergerProcessor extends AbstractProcessor {
private Types typeUtils;
private Elements elementUtils;
private Filer filer;
private Messager messager;
public MergerProcessor() {
super();
}
@Override
public synchronized void init(ProcessingEnvironment processingEnv) { // get some processor utils
super.init(processingEnv);
typeUtils = processingEnv.getTypeUtils();
elementUtils = processingEnv.getElementUtils();
filer = processingEnv.getFiler();
messager = processingEnv.getMessager();
}
@Override
public boolean process(Set<? extends TypeElement> annotationSet, RoundEnvironment roundEnvironment) {
for (var annotation : annotationSet) {
var annotatedClasses = roundEnvironment.getElementsAnnotatedWith(annotation);
annotatedClasses.forEach(clazz -> {
// get necessary stuff from annotated class using Java Mirror API
var packageName = elementUtils.getPackageOf(clazz).toString();
var annotatedClass = clazz.asType(); // CountryMerger
var genericFillableType = typeUtils.directSupertypes(annotatedClass).stream().filter(
it -> it.getKind() == TypeKind.DECLARED && it.toString().toLowerCase().contains("merger"))
.findFirst().get(); // Merger<Country>
var fillingErasureType = typeUtils.erasure(genericFillableType); // Merger
var innerGenericType = ((DeclaredType) genericFillableType).getTypeArguments().get(0); // Country
// build method and class using JavaPoet and obtained data from Java Mirror API
var methodBuilder = MethodSpec.methodBuilder("merge")
.addAnnotation(Override.class)
.addModifiers(Modifier.PUBLIC)
.returns(TypeName.get(innerGenericType))
.addParameter(TypeName.get(innerGenericType), "one")
.addParameter(TypeName.get(innerGenericType), "other")
.addStatement("var builder = one.toBuilder()");
var fields = typeUtils.asElement(innerGenericType).getEnclosedElements();
ElementFilter.fieldsIn(fields).forEach(field -> {
if (field.asType().getKind().isPrimitive()) {
this.messager.printMessage(Diagnostic.Kind.ERROR, "Illegal type: field '" + field.getSimpleName() + "' cannot be of primitive type.");
}
var isOrGet = (field.asType().getKind().equals(TypeKind.BOOLEAN) ? "is" : "get");
methodBuilder.addStatement("builder." + field.toString() + "($T.ofNullable(one." + isOrGet
+ StringUtils.capitalize(field.toString()) + "()).orElseGet(other::" + isOrGet
+ StringUtils.capitalize(field.toString()) + "))", Optional.class);
});
methodBuilder.addStatement("return builder.build()");
// build class
var classPoem = TypeSpec.classBuilder(clazz.getSimpleName().toString() + "Impl")
.addAnnotation(Component.class) // annotation for spring DI, optional
.addModifiers(Modifier.PUBLIC)
.addSuperinterface(ParameterizedTypeName.get(annotatedClass))
.addMethod(methodBuilder.build())
.build();
// write class
var fileObject = JavaFile.builder(packageName, classPoem);
try {
fileObject.build().writeTo(filer);
} catch (IOException e) {
messager.printMessage(Diagnostic.Kind.ERROR, "Oops");
}
});
}
return true;
}
}
Resultado
Ahora compilamos el proyecto, y vemos que se generó una clase nueva:
@Component
public class CountryMergerImpl implements CountryMerger {
public Country merge(Country one, Country other) {
var builder = one.toBuilder();
builder.name(Optional.ofNullable(one.getName()).orElseGet(other::getName));
builder.inhabitantCount(Optional.ofNullable(one.getInhabitantCount()).orElseGet(other::getInhabitantCount));
return builder.build();
}
}
Esta clase CountryMergerImpl puede ser inyectada como Bean o creada con new.
Este ejemplo es de una clase con dos atributos, pero imaginen todo el boilerplate ahorrado en merges para un proyecto con muchas clases de muchos atributos.
Customizando el merger
Ahora imaginemos que queremos excluir ciertos campos a la hora de generar el merger, o que queremos definir más de una forma de mergear objetos. Para eso basta con agregar los parámetros a nuestra annotation e implementar la lógica con un if o strategy en el Processor.
La annotation quedaría así:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Merger {
String[] excludedFields() default "";
MergeStrategy strategy() default MergeStrategy.FIRST_NOT_NULL;
}
enum MergeStrategy {
FIRST_NOT_NULL,
CONCAT,
...
}
Dejo como ejercicio para el lector implementar algo más sobre @Merger si quisiera, teniendo como base el Processor.
Incluso se podrían generar reglas por atributo definiendo otra annotation para los fields de una clase @Merger y procesando ambas en un Processor.