Java. Мое решение для поиска изменений между двумя объектами. ChangeChecker
Вступление
Во время работы над аддоном для Jakarta-валидации мне пришлось писать логику по проверке изменений в модели по собственной аннотации CheckExistingByConstraintAndUnmodifiableAttributes.
Долго разглядывал получившейся код, и в голову пришла светлая (наверное) идея: почему бы не вынести все это в полноценный настраиваемый класс?
Для чего это решение
Как уже было сказано, решение предназначено для поиска и получения подробной информации о различиях (далее буду называть «дельтой») между двумя объектами.
Скажем, нам нужно проверить изменения по конкретным полям, которых может не быть в equals, и получить информацию о различиях отдельно для каждого поля. Допустим, как раз в рамках проверки определенных (не всех) полей на неизменяемость для моделек. И полной информации об ошибке, если изменения есть.
Вот в подобных кейсах мое решение — ChangeChecker — и можно использовать.
Поговорим о реализации идеи. Два объекта.
Я не буду сильно вдаваться в детали реализации (опять-таки, детали можно будет посмотреть в репозитории) и постараюсь сконцентрироваться на «спецификации».
ChangeChecker
Реализации этого интерфейса, собственно, и проделывают всю работу по поиску «дельты» между объектами. Про реализации — чуть позже, ну, а пока выглядит он вот так.
Скрытый текст
/**
* Interface for finding differences between two objects.
* @param - type of objects
* @see ValueChangesCheckerResult
*
* @author Ihar Smolka
*/
public interface ChangesChecker {
/**
* Find differences between two objects.
* @param oldObj - old object
* @param newObj - new object
* @return finding result
*/
ValueChangesCheckerResult getResult(T oldObj, T newObj);
}
Все просто: на вход поступают два объекта одинакового типа, на выходе — получаем подробный результат сопоставления по двум объектам.
Как выглядит результат.
Скрытый текст
/**
* Result for check two objects.
* @see com.ismolka.validation.utils.change.ChangesChecker
*
* @param differenceMap - difference map
* @param equalsResult - equals result
* @author Ihar Smolka
*/
public record ValueChangesCheckerResult(
Map differenceMap,
boolean equalsResult
) implements Difference, CheckerResult {
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ValueChangesCheckerResult that = (ValueChangesCheckerResult) o;
return equalsResult == that.equalsResult && Objects.equals(differenceMap, that.differenceMap);
}
@Override
public int hashCode() {
return Objects.hash(differenceMap, equalsResult);
}
@Override
public T unwrap(Class type) {
if (type.isAssignableFrom(ValueChangesCheckerResult.class)) {
return type.cast(this);
}
throw new ClassCastException(String.format("Cannot unwrap ValueChangesCheckerResult to %s", type));
}
@Override
public CheckerResultNavigator navigator() {
return new DefaultCheckerResultNavigator(this);
}
}
И связанный интерфейс Difference.
Скрытый текст
/**
* Difference interface
*
* @author Ihar Smolka
*/
public interface Difference {
/**
* for unwrapping a difference
*
* @param type - toType
* @return unwrapped difference
* @param - type
*/
TYPE unwrap(Class type);
}
Difference по смыслу близок к «интерфейсам-маркерам», т.к. он помечает все классы, касающиеся информации о «дельте». Если бы не метод unwrap, предназначенный для более «красивого» приведения Difference-объекта к конкретной реализации — можно было бы считать его таковым.
differenceMap — необходимо для хранения развернутой информации по различиям между двумя объектами. Здесь название поля/путь к полю маппится на определенный Difference. Это позволяет хранить сложную структуру «дельты» с вложениями самых разных видов (и результатам по Map, и по Collection, и прочее).
equalsResult — думаю, смысл понятен. Говорит, есть ли «дельта» у объектов.
ValueDifference
Выглядит так.
Скрытый текст
/**
* Difference between two values.
*
* @param valueFieldPath - attribute path from the root class.
* @param valueFieldRootClass - attribute root class.
* @param valueFieldDeclaringClass - attribute declaring class.
* @param valueClass - value class.
* @param oldValue - old value.
* @param newValue - new value.
* @param - value type.
*
* @author Ihar Smolka
*/
public record ValueDifference(String valueFieldPath,
Class> valueFieldRootClass,
Class> valueFieldDeclaringClass,
Class valueClass,
F oldValue,
F newValue) implements Difference {
@Override
public T unwrap(Class type) {
if (type.isAssignableFrom(ValueDifference.class)) {
return type.cast(this);
}
throw new ClassCastException(String.format("Cannot unwrap AttributeDifference to %s", type));
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ValueDifference> that = (ValueDifference>) o;
return Objects.equals(valueFieldPath, that.valueFieldPath) && Objects.equals(valueFieldRootClass, that.valueFieldRootClass) && Objects.equals(valueClass, that.valueClass) && Objects.equals(oldValue, that.oldValue) && Objects.equals(newValue, that.newValue);
}
@Override
public int hashCode() {
return Objects.hash(valueFieldPath, valueFieldRootClass, valueClass, oldValue, newValue);
}
}
Это класс для хранения базовой информации о двух различающихся объектах. Тут мы видим oldObject и newObject (смысл очевиден), их класс, а так же остальную мета-информацию, которая может оказаться полезной в рамках сопоставления объектов, как атрибутов определенного класса.
ValueCheckDescriptorBuilder
Основное содержимое такое.
Скрытый текст
/**
* Builder for {@link ValueCheckDescriptor}.
* @see ValueCheckDescriptor
*
* @param - value type
* @author Ihar Smolka
*/
public class ValueCheckDescriptorBuilder {
Class> sourceClass;
Class targetClass;
String attribute;
Set equalsFields;
Method equalsMethodReflection;
BiPredicate biEqualsMethod;
ChangesChecker changesChecker;
...
}
Служит для того, чтобы описывать, как именно будет проходить проверка двух атрибутов.
sourceClass — класс, в котором атрибут определен.
targetClass — класс атрибута.
attribute — название атрибута/путь.
equalsFields — внутренние поля для сопоставления по equals. Может работать совместно с установленным changesChecker, но с equalsMethodReflection и biEqualsMethod несовместимо.
equalsMethodReflection — экземпляр Method. Может пригодиться, когда передаем какой-то «кастомный equals» по рефлексии.
biEqualsMethod — BiPredicate, по которому будут сопоставляться объекты. Можно просунуть, например, Objects.equals (хотя это бессмысленно, т.к. Objects.equals вызовется в случае, если другие способы сопоставления не обозначены).
changesChecker — можно передавать для проверки какой-то вложенный ChangeChecker. Как это используется — можно будет понять по ходу статьи.
И ключевое.
DefaultValueChangesCheckerBuilder
Выглядит вот так и определяет настройки для проверки двух объектов.
Скрытый текст
/**
* Builder for {@link ValueCheckDescriptor}.
* @see DefaultValueChangesChecker
*
* @param - value type
* @author Ihar Smolka
*/
public class DefaultValueChangesCheckerBuilder {
Class targetClass;
Set> attributesCheckDescriptors;
boolean stopOnFirstDiff;
Method globalEqualsMethodReflection;
BiPredicate globalBiEqualsMethod;
Set globalEqualsFields;
...
}
targetClass — класс объектов.
attributesCheckDescriptors — описываются «сложные» чеки по атрибутам, используя предыдущий класс. Совместимо с globalEqualsFields, несовместимо с globalEqualsMethodReflection и globalBiEqualsMethod.
stopOnFirstDiff — останавливать ли проверку на первом различии.
globalEqualsFields — по каким атрибутам будет простой equals. По смыслу тоже самое, что и equalsFields в предыдущем классе, только работает уже «над» переданными ValueCheckDescriptor.
Примеры использования в виде тестов.
Скрытый текст
@Test
public void test_innerObject() {
ChangeTestObject oldTestObj = new ChangeTestObject();
ChangeTestObject newTestObj = new ChangeTestObject();
oldTestObj.setInnerObject(new ChangeTestInnerObject(OLD_VAL_STR));
newTestObj.setInnerObject(new ChangeTestInnerObject(NEW_VAL_STR));
CheckerResult result = DefaultValueChangesCheckerBuilder.builder(ChangeTestObject.class)
.addAttributeToCheck(
ValueCheckDescriptorBuilder.builder(ChangeTestObject.class, ChangeTestInnerObject.class)
.attribute("innerObject")
.addEqualsField("valueFromObject")
.build()
)
.build().getResult(oldTestObj, newTestObj);
ValueDifference> valueDifference = result.navigator().getDifference("innerObject.valueFromObject").unwrap(ValueDifference.class);
String oldValueFromCheckResult = (String) valueDifference.oldValue();
String newValueFromCheckResult = (String) valueDifference.newValue();
Assertions.assertEquals(oldValueFromCheckResult, oldTestObj.getInnerObject().getValueFromObject());
Assertions.assertEquals(newValueFromCheckResult, newTestObj.getInnerObject().getValueFromObject());
}
Скрытый текст
@Test
public void test_innerObjectWithoutValueDescriptor() {
ChangeTestObject oldTestObj = new ChangeTestObject();
ChangeTestObject newTestObj = new ChangeTestObject();
oldTestObj.setInnerObject(new ChangeTestInnerObject(OLD_VAL_STR));
newTestObj.setInnerObject(new ChangeTestInnerObject(NEW_VAL_STR));
CheckerResult result = DefaultValueChangesCheckerBuilder.builder(ChangeTestObject.class)
.addGlobalEqualsField("innerObject.valueFromObject")
.build().getResult(oldTestObj, newTestObj);
ValueDifference> valueDifference = result.navigator().getDifference("innerObject.valueFromObject").unwrap(ValueDifference.class);
String oldValueFromCheckResult = (String) valueDifference.oldValue();
String newValueFromCheckResult = (String) valueDifference.newValue();
Assertions.assertEquals(oldValueFromCheckResult, oldTestObj.getInnerObject().getValueFromObject());
Assertions.assertEquals(newValueFromCheckResult, newTestObj.getInnerObject().getValueFromObject());
}
Продолжаем разговор. Две коллекции/массива.
CollectionChangesChecker
Для сравнения двух коллекций есть интерфейс CollectionChangesChecker, расширяющий базовый ChangesChecker.
Скрытый текст
/**
* Interface for check differences between two collections.
* @see CollectionChangesCheckerResult
*
* @param - collection value type
*
* @author Ihar Smolka
*/
public interface CollectionChangesChecker extends ChangesChecker {
/**
* Find difference between two collections.
*
* @param oldCollection - old collection
* @param newCollection - new collection
* @return {@link CollectionChangesCheckerResult}
*/
CollectionChangesCheckerResult getResult(Collection oldCollection, Collection newCollection);
/**
* Find difference between two arrays
*
* @param oldArray - old array
* @param newArray - new array
* @return {@link CollectionChangesCheckerResult}
*/
CollectionChangesCheckerResult getResult(T[] oldArray, T[] newArray);
}
Как видим, появилось еще два метода — getResult по коллекциям и по массивам (в реализации массивы просто оборачиваются в List и проходят через getResult с коллекциями).
Возвращают они CollectionChangesCheckerResult.
CollectionChangesCheckerResult
Скрытый текст
/**
* Result for check two collections.
*
* @param collectionClass - collection value class.
* @param collectionDifferenceMap - collection difference.
* @param equalsResult - equals result
* @param - type of collection values
*
* @author Ihar Smolka
*/
public record CollectionChangesCheckerResult(
Class collectionClass,
Map>> collectionDifferenceMap,
boolean equalsResult) implements Difference, CheckerResult {
...
}
Скрытый текст
/**
* Possible modifying operations for {@link java.util.Collection}.
*
* @author Ihar Smolka
*/
public enum CollectionOperation {
/**
* Add element
*/
ADD,
/**
* Remove element
*/
REMOVE,
/**
* Update element
*/
UPDATE
}
Скрытый текст
/**
* Difference between two elements of {@link java.util.Collection}.
*
* @param diffBetweenElementsFields - difference between elements.
* @param elementFromOldCollection - element from old collection.
* @param elementFromNewCollection - element from new collection.
* @param elementFromOldCollectionIndex - index of element from old collection.
* @param elementFromNewCollectionIndex - index of element from new collection.
* @param - type of collection elements.
*
* @author Ihar Smolka
*/
public record CollectionElementDifference(
Map diffBetweenElementsFields,
F elementFromOldCollection,
F elementFromNewCollection,
Integer elementFromOldCollectionIndex,
Integer elementFromNewCollectionIndex
) implements Difference {
...
}
Как видим, в этот раз хранящая «дельту» информация представлена в виде мапы, в которой операция по изменению коллекции сопоставлена с множеством изменений этого типа.
Ну, а CollectionElementDifference содержит информацию про то, какие элементы из каких коллекций различаются, на каких индексах и какие именно между ними различия. Для операции UPDATE оба элемента должны быть заполнены. Для ADD будет отсутствовать старый элемент, для REMOVE — соответственно, новый.
DefaultCollectionChangesCheckerBuilder
Скрытый текст
**
* Builder for {@link DefaultCollectionChangesChecker}
*
* @param - type of collection elements.
*
* @author Ihar Smolka
*/
public class DefaultCollectionChangesCheckerBuilder {
Class collectionGenericClass;
Set> attributesCheckDescriptors;
boolean stopOnFirstDiff;
Set forOperations;
Set fieldsForMatching;
Method globalEqualsMethodReflection;
BiPredicate globalBiEqualsMethod;
Set globalEqualsFields;
...
}
В принципе, все почти аналогично DefaultValueChangesCheckerBuilder, поговорим о различиях.
fieldsForMatching — по каким полям будут сопоставляться объекты в рамках коллекций. Т.е., если эти поля у двух элементов в разных коллекциях совпадают — то они будут сопоставляться друг с другом, и если «дельта» между ними есть — тогда это UPDATE элемента в коллекции. Если это не определено — в качестве такого «ключа» будет выступать индекс в коллекции.
forOperations — для каких операций мы получаем «дельту». По умолчанию для всех.
collectionGenericClass — экземпляры какого класса коллекция в себе держит.
Пример использования в виде теста.
Скрытый текст
@Test
public void test_collection() {
String key = "ID_IN_COLLECTION";
ChangeTestObject oldTestObj = new ChangeTestObject();
ChangeTestObject newTestObj = new ChangeTestObject();
ChangeTestObjectCollection oldCollectionObj = new ChangeTestObjectCollection(key, OLD_VAL_STR);
ChangeTestObjectCollection newCollectionObj = new ChangeTestObjectCollection(key, NEW_VAL_STR);
oldTestObj.setCollection(List.of(oldCollectionObj));
newTestObj.setCollection(List.of(newCollectionObj));
CheckerResult result = DefaultValueChangesCheckerBuilder.builder(ChangeTestObject.class)
.addAttributeToCheck(
ValueCheckDescriptorBuilder.builder(ChangeTestObject.class, ChangeTestObjectCollection.class)
.attribute("collection")
.changesChecker(
DefaultCollectionChangesCheckerBuilder.builder(ChangeTestObjectCollection.class)
.addGlobalEqualsField("valueFromCollection")
.addFieldForMatching("key")
.build()
).build()
).build().getResult(oldTestObj, newTestObj);
CollectionElementDifference difference = result.navigator().getDifferenceForCollection("collection", ChangeTestObjectCollection.class).stream().findFirst().orElseThrow(() -> new RuntimeException("Result for collection is not present"));
Assertions.assertEquals(difference.elementFromOldCollection().getValueFromCollection(), oldCollectionObj.getValueFromCollection());
Assertions.assertEquals(difference.elementFromNewCollection().getValueFromCollection(), newCollectionObj.getValueFromCollection());
}
Разговор приближается к концу. Две мапы.
MapChangesChecker
На то у нас есть следующий интерфейс.
Скрытый текст
/**
* Interface for check differences between two maps.
* @see MapChangesCheckerResult
*
* @param - key type
* @param - value type
*
* @author Ihar Smolka
*/
public interface MapChangesChecker extends ChangesChecker {
/**
* Find difference between two maps.
*
* @param oldMap - old map
* @param newMap - new map
* @return difference result
*/
MapChangesCheckerResult getResult(Map oldMap, Map newMap);
}
K описывает класс ключа, V - соответственно, класс значения для мап.
MapChangesCheckerResult
Скрытый текст
/**
* Result for check two maps.
* @see MapElementDifference
*
* @param keyClass - key class
* @param valueClass - value class
* @param mapDifference - map difference
* @param equalsResult - equals result
* @param - key type
* @param - value type
*
* @author Ihar Smolka
*/
public record MapChangesCheckerResult(
Class keyClass,
Class valueClass,
Map>> mapDifference,
boolean equalsResult
) implements Difference, CheckerResult {
...
}
Скрытый текст
/**
* Possible modifying operations for {@link java.util.Map}.
*
* @author Ihar Smolka
*/
public enum MapOperation {
/**
* Add element
*/
PUT,
/**
* Remove element
*/
REMOVE,
/**
* Update element
*/
UPDATE
}
Скрытый текст
/**
* Difference between two elements of {@link Map}.
*
* @param diffBetweenElementsFields - difference between elements
* @param elementFromOldMap - element from the old map
* @param elementFromNewMap - element from tht new map
* @param key - map key with difference
* @param - key type
* @param - value type
*
* @author Ihar Smolka
*/
public record MapElementDifference(
Map diffBetweenElementsFields,
V elementFromOldMap,
V elementFromNewMap,
K key
) implements Difference {
...
}
В целом, похоже на CollectionChangesCheckerResult, только теперь здесь присутствуют классы ключа и значения. Ну и мапа с «дельтой» держит чуть другую информацию — подробно останавливаться на ней вряд ли имеет смысл, все должно быть понятно уже без лишних слов.
DefaultMapChangesCheckerBuilder
Скрытый текст
/**
* Builder for {@link DefaultMapChangesChecker}
*
* @param - key type
* @param - value type
*/
public class DefaultMapChangesCheckerBuilder {
Class keyClass;
Class valueClass;
Set forOperations;
Set> attributesCheckDescriptors;
boolean stopOnFirstDiff;
Method globalEqualsMethodReflection;
BiPredicate globalBiEqualsMethod;
Set globalEqualsFields;
...
}
Опять-таки, думаю, здесь все понятно без лишних слов, т.к. очень похоже на предыдущие билдеры.
По традиции.
Скрытый текст
@Test
public void test_map() {
String key = "ID_IN_MAP";
ChangeTestObject oldTestObj = new ChangeTestObject();
ChangeTestObject newTestObj = new ChangeTestObject();
ChangeTestObjectMap oldMapObj = new ChangeTestObjectMap(OLD_VAL_STR);
ChangeTestObjectMap newMapObj = new ChangeTestObjectMap(NEW_VAL_STR);
oldTestObj.setMap(Map.of(key, oldMapObj));
newTestObj.setMap(Map.of(key, newMapObj));
CheckerResult result = DefaultValueChangesCheckerBuilder.builder(ChangeTestObject.class)
.addAttributeToCheck(
ValueCheckDescriptorBuilder.builder(ChangeTestObject.class, ChangeTestObjectMap.class)
.attribute("map")
.changesChecker(
DefaultMapChangesCheckerBuilder.builder(String.class, ChangeTestObjectMap.class)
.addGlobalEqualsField("valueFromMap")
.build()
).build()
).build().getResult(oldTestObj, newTestObj);
MapElementDifference difference = result.navigator().getDifferenceForMap("map", String.class, ChangeTestObjectMap.class).stream().findFirst().orElseThrow(() -> new RuntimeException("Result for map is not present"));
Assertions.assertEquals(difference.elementFromOldMap().getValueFromMap(), oldMapObj.getValueFromMap());
Assertions.assertEquals(difference.elementFromNewMap().getValueFromMap(), newMapObj.getValueFromMap());
}
Разговор почти окончен. Навигация по результату.
На мой взгляд, пользуясь этими инструментами можно относительно легко получить «дельту» по объектами любой (ну или практически любой) структуры.
Вопрос теперь в том, как нам удобно «навигировать» по полученному бардаку полученной дельте. На помощь приходит следующий интерфейс.
Скрытый текст
/**
* Interface for navigation in {@link com.ismolka.validation.utils.change.CheckerResult}.
* @see com.ismolka.validation.utils.change.CheckerResult
*
* @author Ihar Smolka
*/
public interface CheckerResultNavigator {
/**
* Get difference for {@link java.util.Map}
*
* @param fieldPath - attribute path with difference.
* @param keyClass - key class.
* @param valueClass - value class.
* @param operations - return for {@link MapOperation}.
* @return {@link Set} of {@link MapElementDifference} - if differences are there and 'null' - if aren't.
* @param - key type.
* @param - value type.
*/
Set> getDifferenceForMap(String fieldPath, Class keyClass, Class valueClass, MapOperation... operations);
/**
* Get difference for {@link java.util.Collection}
*
* @param fieldPath - attribute path with difference.
* @param forClass - class of collection values.
* @param operations - return for {@link CollectionOperation}.
* @return {@link Set} of {@link CollectionElementDifference} - if differences are there and 'null' - if aren't.
* @param - value type
*/
Set> getDifferenceForCollection(String fieldPath, Class forClass, CollectionOperation... operations);
/**
* Get difference for {@link java.util.Map}
*
* @param keyClass - key class.
* @param valueClass - value class.
* @param operations - return for {@link MapOperation}.
* @return {@link Set} of {@link MapElementDifference} - if differences are there and 'null' - if aren't.
* @param - key type.
* @param - value type.
*/
Set> getDifferenceForMap(Class keyClass, Class valueClass, MapOperation... operations);
/**
* Get difference for {@link java.util.Collection}
*
* @param forClass - class of collection values.
* @param operations - return for {@link CollectionOperation}.
* @return {@link Set} of {@link CollectionElementDifference} - if differences are there and 'null' - if aren't.
* @param - value type
*/
Set> getDifferenceForCollection(Class forClass, CollectionOperation... operations);
/**
* Get difference for attribute.
*
* @param fieldPath - attribute path with difference.
* @return {@link Difference} - if differences are there and 'null' - if aren't.
*/
Difference getDifference(String fieldPath);
/**
* Get difference.
*
* @return {@link Difference}
*/
Difference getDifference();
}
И каждый класс результата проверки отдаст нам по методу navigator () дефолтную реализацию этого интерфейса.
Через навигатор мы можем продираться через множество вложений и получать интересующую нас «дельту». Ну или null, если таковой не найдено.
Для «распаковки дельт» из коллекций и мап нужно использовать соответствующие методы getDifferenceForMap и getDifferenceForCollection (если интересует конкретная операция/операции — передаем в конце методов).
При навигации следует учитывать, что если, скажем, где-то на середине нашего «пути» будет какая-то коллекция или мапа — навигатор вернет ошибку. Подобное должно быть только в конце пути. Поэтому когда нам, скажем, надо получить «коллекцию в коллекции» — получаем «дельты» первой коллекции, дальше дергаем навигаторы уже у этих «дельт».
Как все это выглядит — можно увидеть по примерам в тестах.
Конец разговора.
С решением можно ознакомиться во все том же репозитории с аддоном для валидации, в пакете com.ismolka.validation.utils.change.
Код еще не до конца приведен в божеский вид и, скорее всего, еще будет мелкий рефакторинг (как минимум).
Интересует ваше мнение. Насколько нужная штука, насколько хорошее решение, замечания и рекомендации по коду тоже приветствуются.
Всем дзякую!