Вступление
Во время работы над аддоном для Jakarta-валидации мне пришлось писать логику по проверке изменений в модели по собственной аннотации CheckExistingByConstraintAndUnmodifiableAttributes.
Долго разглядывал получившейся код, и в голову пришла светлая (наверное) идея: почему бы не вынести все это в полноценный настраиваемый класс?
Для чего это решение
Как уже было сказано, решение предназначено для поиска и получения подробной информации о различиях (далее буду называть "дельтой") между двумя объектами.
Скажем, нам нужно проверить изменения по конкретным полям, которых может не быть в equals, и получить информацию о различиях отдельно для каждого поля. Допустим, как раз в рамках проверки определенных (не всех) полей на неизменяемость для моделек. И полной информации об ошибке, если изменения есть.
Вот в подобных кейсах мое решение - ChangeChecker - и можно использовать.
Поговорим о реализации идеи. Два объекта.
Я не буду сильно вдаваться в детали реализации (опять-таки, детали можно будет посмотреть в репозитории) и постараюсь сконцентрироваться на "спецификации".
ChangeChecker
Реализации этого интерфейса, собственно, и проделывают всю работу по поиску "дельты" между объектами. Про реализации - чуть позже, ну а пока выглядит он вот так.
Скрытый текст
/** * Interface for finding differences between two objects. * @param <T> - type of objects * @see ValueChangesCheckerResult * * @author Ihar Smolka */ public interface ChangesChecker<T> { /** * 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<String, Difference> 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 extends Difference> T unwrap(Class<T> 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 */ <TYPE extends Difference> TYPE unwrap(Class<TYPE> 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 <F> - value type. * * @author Ihar Smolka */ public record ValueDifference<F>(String valueFieldPath, Class<?> valueFieldRootClass, Class<?> valueFieldDeclaringClass, Class<F> valueClass, F oldValue, F newValue) implements Difference { @Override public <T extends Difference> T unwrap(Class<T> 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 <Q> - value type * @author Ihar Smolka */ public class ValueCheckDescriptorBuilder<Q> { Class<?> sourceClass; Class<Q> targetClass; String attribute; Set<String> equalsFields; Method equalsMethodReflection; BiPredicate<Q, Q> biEqualsMethod; ChangesChecker<Q> 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 <T> - value type * @author Ihar Smolka */ public class DefaultValueChangesCheckerBuilder<T> { Class<T> targetClass; Set<ValueCheckDescriptor<?>> attributesCheckDescriptors; boolean stopOnFirstDiff; Method globalEqualsMethodReflection; BiPredicate<T, T> globalBiEqualsMethod; Set<String> 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 <T> - collection value type * * @author Ihar Smolka */ public interface CollectionChangesChecker<T> extends ChangesChecker<T> { /** * Find difference between two collections. * * @param oldCollection - old collection * @param newCollection - new collection * @return {@link CollectionChangesCheckerResult} */ CollectionChangesCheckerResult<T> getResult(Collection<T> oldCollection, Collection<T> newCollection); /** * Find difference between two arrays * * @param oldArray - old array * @param newArray - new array * @return {@link CollectionChangesCheckerResult} */ CollectionChangesCheckerResult<T> 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 <F> - type of collection values * * @author Ihar Smolka */ public record CollectionChangesCheckerResult<F>( Class<F> collectionClass, Map<CollectionOperation, Set<CollectionElementDifference<F>>> 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 <F> - type of collection elements. * * @author Ihar Smolka */ public record CollectionElementDifference<F>( Map<String, Difference> diffBetweenElementsFields, F elementFromOldCollection, F elementFromNewCollection, Integer elementFromOldCollectionIndex, Integer elementFromNewCollectionIndex ) implements Difference { ... }
Как видим, в этот раз хранящая "дельту" информация представлена в виде мапы, в которой операция по изменению коллекции сопоставлена с множеством изменений этого типа.
Ну а CollectionElementDifference содержит информацию про то, какие элементы из каких коллекций различаются, на каких индексах и какие именно между ними различия. Для операции UPDATE оба элемента должны быть заполнены. Для ADD будет отсутствовать старый элемент, для REMOVE - соответственно, новый.
DefaultCollectionChangesCheckerBuilder
Скрытый текст
** * Builder for {@link DefaultCollectionChangesChecker} * * @param <T> - type of collection elements. * * @author Ihar Smolka */ public class DefaultCollectionChangesCheckerBuilder<T> { Class<T> collectionGenericClass; Set<ValueCheckDescriptor<?>> attributesCheckDescriptors; boolean stopOnFirstDiff; Set<CollectionOperation> forOperations; Set<String> fieldsForMatching; Method globalEqualsMethodReflection; BiPredicate<T, T> globalBiEqualsMethod; Set<String> 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<ChangeTestObjectCollection> 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 <K> - key type * @param <V> - value type * * @author Ihar Smolka */ public interface MapChangesChecker<K, V> extends ChangesChecker<V> { /** * Find difference between two maps. * * @param oldMap - old map * @param newMap - new map * @return difference result */ MapChangesCheckerResult<K, V> getResult(Map<K, V> oldMap, Map<K, V> 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 <K> - key type * @param <V> - value type * * @author Ihar Smolka */ public record MapChangesCheckerResult<K, V>( Class<K> keyClass, Class<V> valueClass, Map<MapOperation, Set<MapElementDifference<K, V>>> 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 <K> - key type * @param <V> - value type * * @author Ihar Smolka */ public record MapElementDifference<K, V>( Map<String, Difference> diffBetweenElementsFields, V elementFromOldMap, V elementFromNewMap, K key ) implements Difference { ... }
В целом, похоже на CollectionChangesCheckerResult, только теперь здесь присутствуют классы ключа и значения. Ну и мапа с "дельтой" держит чуть другую информацию - подробно останавливаться на ней вряд ли имеет смысл, все должно быть понятно уже без лишних слов.
DefaultMapChangesCheckerBuilder
Скрытый текст
/** * Builder for {@link DefaultMapChangesChecker} * * @param <K> - key type * @param <V> - value type */ public class DefaultMapChangesCheckerBuilder<K, V> { Class<K> keyClass; Class<V> valueClass; Set<MapOperation> forOperations; Set<ValueCheckDescriptor<?>> attributesCheckDescriptors; boolean stopOnFirstDiff; Method globalEqualsMethodReflection; BiPredicate<V, V> globalBiEqualsMethod; Set<String> 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<String, ChangeTestObjectMap> 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 <K> - key type. * @param <V> - value type. */ <K, V> Set<MapElementDifference<K, V>> getDifferenceForMap(String fieldPath, Class<K> keyClass, Class<V> 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 <T> - value type */ <T> Set<CollectionElementDifference<T>> getDifferenceForCollection(String fieldPath, Class<T> 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 <K> - key type. * @param <V> - value type. */ <K, V> Set<MapElementDifference<K, V>> getDifferenceForMap(Class<K> keyClass, Class<V> 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 <T> - value type */ <T> Set<CollectionElementDifference<T>> getDifferenceForCollection(Class<T> 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.
Код еще не до конца приведен в божеский вид и, скорее всего, еще будет мелкий рефакторинг (как минимум).
Интересует ваше мнение. Насколько нужная штука, насколько хорошее решение, замечания и рекомендации по коду тоже приветствуются.
Всем дзякую!
