Вступление

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

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

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

Всем дзякую!

Комментарии (15)


  1. Iceberk
    02.09.2024 19:50
    +3

    Это не то же самое что Javers? https://javers.org/


    1. igorsmolkako Автор
      02.09.2024 19:50
      +2

      Похоже)

      Чорт, не знал про эту штуку.

      Оказывается, велосипед или близко к этому... Печаль)

      Но по крайней мере попытка интересная вышла. Да и, мб, функциональность местами все же будет различаться. Не знаком c Javers, пускай уж знатоки рассудят.


  1. LeshaRB
    02.09.2024 19:50
    +2

    У меня была задача записывать аудит изменений в БД
    Выбор был между Javers и Envers

    Javers как-то резво развивался, плюс автор отвечал
    Плюс поддержка монги

    Выбрал его


    1. igorsmolkako Автор
      02.09.2024 19:50

      Раз вы пользовались... как по вашему, отличается функциональность моего "велосипеда" от Javers? И в какую сторону, если есть отличия, в лучшую или худшую?)


      1. LeshaRB
        02.09.2024 19:50

        Javers имеет 1.4k звёзд на гитхабе
        И несколько лет развития

        Согласитесь глупый вопрос


        1. igorsmolkako Автор
          02.09.2024 19:50

          Это понятно, что масштабы несопоставимы. Это вообще полноценный фреймворк (а не написанная за неделю либа), как я понимаю, в котором куча вещей помимо чекинга изменений.

          Но если только в разрезе функции проверки изменений и использования? Ограничиваясь чисто этим узким функционалом.


          1. LeshaRB
            02.09.2024 19:50
            +2

            Понимаете, то что вы решили написать сами что-то своё.это вам плюс.
            Но то что изобрели велосипед не промониторив мир, это минус

            Например вы прочитали книгу старую книгу Beginning Java EE 7
            И решите писать свое. DI

            Согласитесь будет смешно.
            Рядом гиганты spring, quarkus, micronaut
            И куча light библиотек

            Как нет проект , попробовать, я согласен, опыт
            Но не для прода.

            Тем более ушли времена надеюсь, когда для Джаспера ,мне приходилось подключать itext монстра)


            1. igorsmolkako Автор
              02.09.2024 19:50
              +1

              Не, ну промониторить-то я пытался, только на глаза тот же Javers так и не попался) Видать, плохо упрашивал Гугл)

              А так был искренне убежден, что делаю что-то новое, хоть и пока "на коленке") Эх, суровая реальность)

              Cейчас хочу определиться с возможным дальнейшим планом развития. Может, есть все же что-то, какая-то важная мелочь/мелочи, которых не хватает в готовых решениях подобного плана (несмотря на их бОльший масштаб - порой у разрабов до мелочей не доходят руки, даже до важных :)) и которые у меня имеются/которые буду готов реализовать. Например, в том же Javers, видимо, для "дельты" по коллекциями какое-то время нельзя было получить старый и новый объекты (https://github.com/javers/javers/issues/1094). Что-то в этом роде.

              Если их добавить - то как такая "light"-либа, думаю, будет вполне себе и даже в мавен централ залить будет не стыдно.


              1. bulatenkom
                02.09.2024 19:50
                +1

                Вместо гугла, уже есть более современные средства поиска. Тот же чатгпт, сходу выдал несколько готовых решений:

                • Java Object Diff (https://github.com/SQiShER/java-object-diff)

                • Gson/JsonDiff

                • Javers

                • Apache Commons Lang (EqualsBuilder и DiffBuilder)

                Помимо, собственно поиска, можно закинуть такому чату ваше решение и попросить его сравнить, найти отличия, составить отчёт/сводку.


                1. igorsmolkako Автор
                  02.09.2024 19:50
                  +1

                  Пока все руки не доходили до этого чуда)

                  Первое вроде видел, но мне как-то не очень понравилось. Второе, судя по всему, не совсем про Java-объекты. Про третье уже говорили, четвертое тоже выглядит интересно, нужно будет ознакомиться. Спасибо)


  1. tuxi
    02.09.2024 19:50
    +1

    [мысли вслух] можно черезвычайно гибкие компараторы делать


  1. moonster
    02.09.2024 19:50
    +3

    Ну вот. ) Хотел поделиться аналогичным опытом, но смысла уже нет - вы меня опередили. И это хорошо. Тот самый код я не имею возможности опубликовать ни по техническим, ни по юридическим причинам, мне бы пришлось его пересочинить.

    Десять лет назад я создал очень похожий инструмент. Javers тогда уже существовал, но его версия была меньше чем 1.0.0, то есть не production ready.

    Изначально назначение у инструмента было другое - обеспечение конкурентной работы.

    Объект не сохранялся в БД напрямую. Вместо этого под распределенной блокировкой вычислялся дифф между исходной и измененной версией объекта, и далее дифф применялся к актуальной версии объекта, которая и сохранялась в БД. Сохранялись в БД и диффы, которые в дальнейшем можно было использовать при расследовании разного рода инцидентов, а так же для восстановления исторических версий объекта, которые могли быть нужны для разного рода миграций.

    Таким образом, несколько юзеров могли одновременно отредактировать разные поля, одновременно сохранить объект, и ничьи изменения не терялись в результате гонки.

    Опытный разработчик может возразить, что правильный способ обеспечения конкурентной работы - это команды, а не вот это вот всё поверх CRUD'а, и я с ним соглашусь.


    1. igorsmolkako Автор
      02.09.2024 19:50

      Спасибо) Интересная штука, как бы то ни было.


  1. Sigest
    02.09.2024 19:50
    +2

    Наверное каждый разработчик в своей жизни пишет велосипед, а потом…хоба…и обнаруживает на рынке что-то готовое, намного функциональнее. У меня тоже так было. Писал в свое время для себя аналог Jooq или QueryDSL, но как потом оказалось эти инструменты уже существовали. Ну и ладно, сказал я себе. Зато хороший опыт написания фреймворка с нуля. Выбрал я, естественно, готовые решения


    1. igorsmolkako Автор
      02.09.2024 19:50

      Есть такое)