Java. Мое решение для поиска изменений между двумя объектами. ChangeChecker

5ac02b6ab17f13f975571f516a55e8cf.png

Вступление

Во время работы над аддоном для 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.

Код еще не до конца приведен в божеский вид и, скорее всего, еще будет мелкий рефакторинг (как минимум).

Интересует ваше мнение. Насколько нужная штука, насколько хорошее решение, замечания и рекомендации по коду тоже приветствуются.

Всем дзякую!

© Habrahabr.ru