En el desarrollo de apliacciones, es común la necesidad de copiar datos entre objetos, especialmente cuando se manejan DTOs (Data Transfer Objects) o entidades de bases de datos. Idealmente, herramientas como org.springframework.beans.BeanUtils.copyProperties simplifican esta tarea al copiar propiedades con nombres idénticos. Sin embargo, surge un desafío particular cuando los objetos comparten la misma estructura lógica, pero los nombres de sus propiedades difieren. En estos escanarios, las utilidades estándar no son suficientes y se requiere un enfoque más flexible.
Consideremos un ejemplo para ilustrar el problema. Tenemos dos clases que representan usuarios, una para el sistema de origen y otra para el sistema de destino, con nombres de campo ligeramente diferentes:
Definición de Clases: Origen y Destino
// Archivo: com/ejemplo/modelos/UsuarioOrigen.java
package com.ejemplo.modelos;
import java.util.Date;
/**
* Representa un usuario en el sistema original (fuente).
*/
public class UsuarioOrigen {
private Long idOrigen;
private String nombreOrigen;
private String contrasena;
private Date fechaNacimientoOrigen;
private Boolean saludOrigen;
// Constructores, getters y setters (omitidos para brevedad)
// Se asume que existen para todos los campos.
public Long getIdOrigen() { return idOrigen; }
public void setIdOrigen(Long idOrigen) { this.idOrigen = idOrigen; }
public String getNombreOrigen() { return nombreOrigen; }
public void setNombreOrigen(String nombreOrigen) { this.nombreOrigen = nombreOrigen; }
public String getContrasena() { return contrasena; }
public void setContrasena(String contrasena) { this.contrasena = contrasena; }
public Date getFechaNacimientoOrigen() { return fechaNacimientoOrigen; }
public void setFechaNacimientoOrigen(Date fechaNacimientoOrigen) { this.fechaNacimientoOrigen = fechaNacimientoOrigen; }
public Boolean getSaludOrigen() { return saludOrigen; }
public void setSaludOrigen(Boolean saludOrigen) { this.saludOrigen = saludOrigen; }
@Override
public String toString() {
return "UsuarioOrigen{" +
"idOrigen=" + idOrigen +
", nombreOrigen='" + nombreOrigen + '\'' +
", contrasena='" + contrasena + '\'' +
", fechaNacimientoOrigen=" + fechaNacimientoOrigen +
", saludOrigen=" + saludOrigen +
'}';
}
}
// Archivo: com/ejemplo/modelos/UsuarioDestino.java
package com.ejemplo.modelos;
import java.util.Date;
/**
* Representa un usuario en el sistema de destino.
*/
public class UsuarioDestino {
private Long idDestino;
private String nombreDestino;
private String contrasena; // El nombre del campo es idéntico
private Date fechaNacimientoDestino;
private Boolean saludDestino;
// Constructores, getters y setters (omitidos para brevedad)
// Se asume que existen para todos los campos.
public Long getIdDestino() { return idDestino; }
public void setIdDestino(Long idDestino) { this.idDestino = idDestino; }
public String getNombreDestino() { return nombreDestino; }
public void setNombreDestino(String nombreDestino) { this.nombreDestino = nombreDestino; }
public String getContrasena() { return contrasena; }
public void setContrasena(String contrasena) { this.contrasena = contrasena; }
public Date getFechaNacimientoDestino() { return fechaNacimientoDestino; }
public void setFechaNacimientoDestino(Date fechaNacimientoDestino) { this.fechaNacimientoDestino = fechaNacimientoDestino; }
public Boolean getSaludDestino() { return saludDestino; }
public void setSaludDestino(Boolean saludDestino) { this.saludDestino = saludDestino; }
@Override
public String toString() {
return "UsuarioDestino{" +
"idDestino=" + idDestino +
", nombreDestino='" + nombreDestino + '\'' +
", contrasena='" + contrasena + '\'' +
", fechaNacimientoDestino=" + fechaNacimientoDestino +
", saludDestino=" + saludDestino +
'}';
}
}
En este escenario, métodos como BeanUtils.copyProperties no copiarían idOrigen a idDestino, ni nombreOrigen a nombreDestino, ya que los nombres no coinciden. Escribir manualmente target.setXxx(source.getXxx()) para cada propiedad es tedioso y propenso a errores, especialmente con muchos campos o en proyectos grandes.
Solución: Reflexión y Anotaciones Personalizadas
Una estrategia eficiente para resolver este problema es combinar la reflexión de Java con anotaciones personalizadas. La reflexión nos permite inspeccionar y manipular objetos en tiempo de ejecución, mientras que las anotaciones proporcionan metadatos para guiar el proceso de mapeo. Al igual que librerías como FastJSON utilizan @JSONField(name="xxx") para serializar campos con nombres diferentes, podemos definir una anotación similar para el mapeo entre objetos Java.
- La Anotación
@PropiedadMapeada
Comenzaremos definiendo una anotación que se aplicará a los campos de nuestras clases para indicar sus correspondencias.
// Archivo: com/ejemplo/utilidades/PropiedadMapeada.java
package com.ejemplo.utilidades;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Anotación para mapear propiedades entre objetos cuando sus nombres de campo son diferentes.
* Aplica a campos de clase para especificar el nombre del campo correspondiente
* en el objeto fuente o destino.
*/
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface PropiedadMapeada {
/**
* Especifica el nombre del campo en el objeto *destino*
* cuando esta anotación se aplica a un campo en el objeto *fuente*.
* @return El nombre del campo destino.
*/
String campoDestino() default "";
/**
* Especifica el nombre del campo en el objeto *fuente*
* cuando esta anotación se aplica a un campo en el objeto *destino*.
* @return El nombre del campo fuente.
*/
String campoFuente() default "";
}
Esta anotación ofrece dos parámetros: campoDestino para usar en el objeto fuente y campoFuente para usar en el objeto destino.
- Modificación de las Clases de Modelo
Ahora, aplicamos la anotación a los campos relevantes en nuestras clases UsuarioOrigen y UsuarioDestino.
// Archivo: com/ejemplo/modelos/UsuarioOrigen.java (Modificado)
package com.ejemplo.modelos;
import com.ejemplo.utilidades.PropiedadMapeada; // Importar la anotación
import java.util.Date;
public class UsuarioOrigen {
@PropiedadMapeada(campoDestino = "idDestino")
private Long idOrigen;
@PropiedadMapeada(campoDestino = "nombreDestino")
private String nombreOrigen;
private String contrasena; // El nombre es idéntico en destino, no necesita anotación aquí
@PropiedadMapeada(campoDestino = "fechaNacimientoDestino")
private Date fechaNacimientoOrigen;
@PropiedadMapeada(campoDestino = "saludDestino")
private Boolean saludOrigen;
// ... getters y setters existentes ...
}
// Archivo: com/ejemplo/modelos/UsuarioDestino.java (Modificado)
package com.ejemplo.modelos;
import com.ejemplo.utilidades.PropiedadMapeada; // Importar la anotación
import java.util.Date;
public class UsuarioDestino {
private Long idDestino;
private String nombreDestino;
private String contrasena; // El nombre es idéntico, no necesita anotación aquí
@PropiedadMapeada(campoFuente = "fechaNacimientoOrigen")
private Date fechaNacimientoDestino;
@PropiedadMapeada(campoFuente = "saludOrigen")
private Boolean saludDestino;
// ... getters y setters existentes ...
}
Observa cómo en UsuarioOrigen usamos campoDestino para indicar a qué campo del objeto destino corresponde. En UsuarioDestino, usamos campoFuente para especificar de qué campo del objeto origen debe tomar su valor. Los campos con nombres idénticos (contrasena) no requieren anotación.
- La Clase de Utilidad
UtilidadesObjetos
La lógica central para realizar la copia de propiedades residirá en una clase de utilidad que empleará la reflexión para leer y escribir campos, utilizando nuestra anotación para resolver los nombres.
// Archivo: com/ejemplo/utilidades/UtilidadesObjetos.java
package com.ejemplo.utilidades;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.beans.IntrospectionException;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
/**
* Utilidad para copiar propiedades entre objetos, soportando mapeo de campos
* con nombres diferentes mediante la anotación {@link PropiedadMapeada}.
*/
public class UtilidadesObjetos {
private static final Logger log = LoggerFactory.getLogger(UtilidadesObjetos.class);
/**
* Copia las propiedades de un objeto fuente a un objeto destino.
* Utiliza la anotación {@link PropiedadMapeada} para resolver nombres de campos dispares.
*
* @param objetoFuente El objeto del cual se copiarán las propiedades.
* Sus campos pueden usar {@link PropiedadMapeada#campoDestino()}.
* @param objetoDestino El objeto al cual se asignarán las propiedades.
* Sus campos pueden usar {@link PropiedadMapeada#campoFuente()}.
*/
public static void copiarPropiedades(Object objetoFuente, Object objetoDestino) {
if (objetoFuente == null || objetoDestino == null) {
log.warn("Objeto fuente o destino es nulo. No se realizará la copia.");
return;
}
Map<String, Object> valoresCamposFuente = new HashMap<>();
// 1. Recopilar valores y mapeos de campos del objeto fuente.
recolectarValoresCampos(objetoFuente, valoresCamposFuente, objetoFuente.getClass());
// 2. Asignar valores a los campos del objeto destino.
establecerValoresCampos(objetoDestino, valoresCamposFuente, objetoDestino.getClass());
}
/**
* Recolecta los nombres de campo (mapeados o originales) y sus valores del objeto fuente,
* considerando la jerarquía de clases (superclases).
*
* @param objetoFuente El objeto actual del cual extraer los campos.
* @param mapaValores Mapa para almacenar los nombres de campo mapeados y sus valores.
* @param claseActual La clase actual que se está procesando (puede ser una superclase).
*/
private static void recolectarValoresCampos(Object objetoFuente, Map<String, Object> mapaValores, Class<?> claseActual) {
if (claseActual == null || Object.class.equals(claseActual)) {
return; // Detener la recursión cuando se llega a Object.class
}
Field[] campos = claseActual.getDeclaredFields();
for (Field campo : campos) {
try {
// Obtener el descriptor de propiedad para acceder al getter.
PropertyDescriptor descriptor = new PropertyDescriptor(campo.getName(), claseActual);
Method metodoLectura = descriptor.getReadMethod();
if (metodoLectura == null) {
log.debug("El campo '{}' en la clase '{}' no tiene un método getter. Se omitirá.", campo.getName(), claseActual.getSimpleName());
continue;
}
Object valor = metodoLectura.invoke(objetoFuente);
PropiedadMapeada anotacion = campo.getAnnotation(PropiedadMapeada.class);
String nombreCampoMapeado;
// Si hay una anotación con campoDestino definido, usarlo como clave.
// De lo contrario, usar el nombre original del campo.
if (anotacion != null && !anotacion.campoDestino().isEmpty()) {
nombreCampoMapeado = anotacion.campoDestino();
} else {
nombreCampoMapeado = campo.getName();
}
mapaValores.put(nombreCampoMapeado, valor);
} catch (IntrospectionException e) {
log.warn("Error de introspección para el campo '{}' en la clase '{}'. Es posible que falten métodos getter/setter válidos. Causa: {}", campo.getName(), claseActual.getSimpleName(), e.getMessage());
} catch (IllegalAccessException | InvocationTargetException e) {
log.error("Error al acceder o invocar el método getter para el campo '{}' en la clase '{}'. Causa: {}", campo.getName(), claseActual.getSimpleName(), e.getMessage());
}
}
// Llamada recursiva para procesar los campos de la superclase.
recolectarValoresCampos(objetoFuente, mapaValores, claseActual.getSuperclass());
}
/**
* Establece los valores de los campos en el objeto destino,
* utilizando los valores recolectados y la jerarquía de clases.
*
* @param objetoDestino El objeto destino donde se establecerán los campos.
* @param mapaValoresFuente Mapa que contiene los nombres de campo mapeados y sus valores del objeto fuente.
* @param claseActual La clase actual que se está procesando (puede ser una superclase).
*/
private static void establecerValoresCampos(Object objetoDestino, Map<String, Object> mapaValoresFuente, Class<?> claseActual) {
if (claseActual == null || Object.class.equals(claseActual)) {
return; // Detener la recursión
}
Field[] campos = claseActual.getDeclaredFields();
for (Field campo : campos) {
try {
// Obtener el descriptor de propiedad para acceder al setter.
PropertyDescriptor descriptor = new PropertyDescriptor(campo.getName(), claseActual);
Method metodoEscritura = descriptor.getWriteMethod();
if (metodoEscritura == null) {
log.debug("El campo '{}' en la clase '{}' no tiene un método setter. Se omitirá.", campo.getName(), claseActual.getSimpleName());
continue;
}
PropiedadMapeada anotacion = campo.getAnnotation(PropiedadMapeada.class);
String nombreCampoBusqueda;
// Si hay una anotación con campoFuente definido, usarlo para buscar el valor.
// De lo contrario, usar el nombre original del campo de destino.
if (anotacion != null && !anotacion.campoFuente().isEmpty()) {
nombreCampoBusqueda = anotacion.campoFuente();
} else {
nombreCampoBusqueda = campo.getName();
}
Object valor = mapaValoresFuente.get(nombreCampoBusqueda);
// Solo invocar el setter si se encontró un valor correspondiente.
if (valor != null) {
metodoEscritura.invoke(objetoDestino, valor);
} else {
log.trace("No se encontró valor en el mapa fuente para el campo de destino '{}' (buscando con clave '{}').", campo.getName(), nombreCampoBusqueda);
}
} catch (IntrospectionException e) {
log.warn("Error de introspección para el campo '{}' en la clase '{}'. Es posible que falten métodos getter/setter válidos. Causa: {}", campo.getName(), claseActual.getSimpleName(), e.getMessage());
} catch (IllegalAccessException | InvocationTargetException e) {
log.error("Error al acceder o invocar el método setter para el campo '{}' en la clase '{}'. Causa: {}", campo.getName(), claseActual.getSimpleName(), e.getMessage());
}
}
// Llamada recursiva para procesar los campos de la superclase.
establecerValoresCampos(objetoDestino, mapaValoresFuente, claseActual.getSuperclass());
}
}
La clase UtilidadesObjetos consta del método principal copiarPropiedades, que a su vez llama a dos métodos auxiliares recursivos:
recolectarValoresCampos: Itera sobre los campos del objeto fuente (y sus superclases), obteniendo el valor de cada campo y almacenándolo en un mapa. La clave del mapa es el nombre del campo destino especificado en@PropiedadMapeada(campoDestino=...)o el nombre original del campo si no hay anotación.establecerValoresCampos: Itera sobre los campos del objeto destino (y sus superclases), busca el valor correspondiente en el mapa generado previamente, utilizando el nombre del campo fuente especificado en@PropiedadMapeada(campoFuente=...)o el nombre original del campo. Una vez encontrado el valor, lo asigna al campo del objeto destino.
Ejemplo de Uso
Para verificar el funcionamiento de nuestra utilidad, creamos una clase de prueba.
// Archivo: com/ejemplo/test/EjemploMapeoObjetos.java
package com.ejemplo.test;
import com.ejemplo.modelos.UsuarioDestino;
import com.ejemplo.modelos.UsuarioOrigen;
import com.ejemplo.utilidades.UtilidadesObjetos;
import java.util.Date;
/**
* Clase de ejemplo para demostrar el uso de UtilidadesObjetos.
*/
public class EjemploMapeoObjetos {
public static void main(String[] args) {
// 1. Crear un objeto fuente y asignar valores
UsuarioOrigen usuarioFuente = new UsuarioOrigen();
usuarioFuente.setIdOrigen(1001L);
usuarioFuente.setNombreOrigen("Ana María");
usuarioFuente.setContrasena("claveSegura123");
usuarioFuente.setFechaNacimientoOrigen(new Date()); // Fecha actual
usuarioFuente.setSaludOrigen(true);
System.out.println("Objeto Fuente: " + usuarioFuente);
// 2. Crear un objeto destino vacío
UsuarioDestino usuarioDestino = new UsuarioDestino();
// 3. Realizar la copia de propiedades
UtilidadesObjetos.copiarPropiedades(usuarioFuente, usuarioDestino);
// 4. Imprimir el objeto destino para verificar los resultados
System.out.println("Objeto Destino Copiado: " + usuarioDestino);
}
}
Al ejecutar EjemploMapeoObjetos, la salida será similar a:
Objeto Fuente: UsuarioOrigen{idOrigen=1001, nombreOrigen='Ana María', contrasena='claveSegura123', fechaNacimientoOrigen=Fri Sep 03 13:40:52 CST 2021, saludOrigen=true}
Objeto Destino Copiado: UsuarioDestino{idDestino=1001, nombreDestino='Ana María', contrasena='claveSegura123', fechaNacimientoDestino=Fri Sep 03 13:40:52 CST 2021, saludDestino=true}
Esto confirma que los valores de idOrigen y nombreOrigen se copiaron correctamente a idDestino y nombreDestino, respectivamente, y que los campos con nombres idénticos (contrasena) también se transfirieron sin problemas.
Consideraciones Adicionales
- El método
copiarPropiedadesmaneja la herencia, lo que significa que las propiedades definidas en superclases también se copiarán, siempre que tengan sus respectivos métodos *getter* y *setter*. - La clase
java.beans.PropertyDescriptores fundamental para esta implementación, ya que facilita la obtención programática de los métodos *getter* y *setter* de los campos de un bean, evitando la construcción manual de nombres de métodos mediante concatenación de cadenas. - La reflexión permite acceder a campos privados mediante
Class.getDeclaredFields(), mientras queClass.getFields()solo recupera campos públicos (incluidos los heredados). Nuestro enfoque utilizagetDeclaredFields()para procesar todos los campos definidos en cada clase de la jerarquía. - Es importante manejar adecuadamente las excepciones de reflexión (
IntrospectionException,IllegalAccessException,InvocationTargetException) para asegurar la robustez de la utilidad. Se ha añadido un registro de advertencias y errores para facilitar la depuración. - Para campos no mapeados explícitamente, la utilidad asume que los nombres de los campos son idénticos en ambos objetos.