Вступление
Во время работы над аддоном для 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.
Код еще не до конца приведен в божеский вид и, скорее всего, еще будет мелкий рефакторинг (как минимум).
Интересует ваше мнение. Насколько нужная штука, насколько хорошее решение, замечания и рекомендации по коду тоже приветствуются.
Всем дзякую!
ссылка на оригинал статьи https://habr.com/ru/articles/840396/
Добавить комментарий