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

от автора

Вступление

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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *