Данная задача включает две подзадачи:
1) когда пользователь уходит с формы редактирования, необходимо понимать, действительно ли он произвёл изменения, чтобы не задавать вопрос на подтверждение впустую и не перезаписывать идентичные данные;
2) если редактированию подвергается непосредственно исходная сущность, а не её копия, то в случае отмены необходимо сохранять возможность отката к исходным значениям.
В статье мы рассмотрим обобщённый и очень лаконичный [размером в несколько строк кода!] подход к решению подобного рода задач, основанный на использовании библиотеки Replication Framework.
Рассмотрим пример приложения. Пусть дан список сущностей, среди которых пользователь может выбрать любую и нажать на кнопку редактирования [в режиме оригинала либо копии].
В режиме редактирования оригинала при изменении сущности в диалоговом окне соответствующие значения немедленно обновляются и в главном, чего не происходит в режиме копии.
После подтверждения изменений выводится список всех найденных различий, если поднят флаг Show detailed changes, либо просто выводится сообщение об обнаружении хотя бы одного отличия [в реальных ситуациях иногда достаточно и такого поведения].
При отмене используются старые значения.
Теперь взглянем на код метода, который отвечает за данное поведение.
private void Edit<T>(T sourceEntry, bool useCopy, bool showChanges, ReplicationProfile replicationProfile)
{
var cache = new ReconstructionCache();
var sourceSnapshot = sourceEntry.CreateSnapshot(cache, replicationProfile);
var editableEntry = useCopy ? sourceSnapshot.ReplicateGraph() : sourceEntry;
if (GetView(editableEntry).ShowDialog() == true)
{
var resultSnapshot = editableEntry.CreateSnapshot(null, replicationProfile);
var changes = sourceSnapshot.Juxtapose(resultSnapshot)
.Where(j => j.State != Etalon.State.Identical);
if (changes.Any())
{
MessageBox.Show(showChanges
? changes.Aggregate("", (x, y) => x + y + Environment.NewLine)
: "Any changes has been detected!");
UpdateSourceData(editableEntry);
UpdateUserInterface();
}
else MessageBox.Show("There are no any changes.");
}
else if (!useCopy) sourceSnapshot.ReconstructGraph(cache);
}
public class Person : INotifyPropertyChanged
{
private int _id;
private string _name;
private string _birthday;
private string _phone;
private string _mail;
public event PropertyChangedEventHandler PropertyChanged = (o, e) => { };
private void Set<T>(ref T target, T value, [CallerMemberName]string caller = "")
{
if (Equals(target, value)) return;
target = value;
PropertyChanged(this, new PropertyChangedEventArgs(caller));
}
public int Id
{
get => _id;
set => Set(ref _id, value);
}
public string Name
{
get => _name;
set => Set(ref _name, value);
}
public string Birthday
{
get => _birthday;
set => Set(ref _birthday, value);
}
public string Phone
{
get => _phone;
set => Set(ref _phone, value);
}
public string Mail
{
get => _mail;
set => Set(ref _mail, value);
}
}
private static readonly ReplicationProfile PersonRepicationProfile = new ReplicationProfile
{
MemberProviders = new List<MemberProvider>
{
new CoreMemberProviderForKeyValuePair(),
new CoreMemberProvider(BindingFlags.Public | BindingFlags.Instance, Member.CanReadWrite),
}
};
Как видим, метод достаточно обобщённый и его можно испольовать для сущностей других типов.
Теперь обратим внимание на ключевые моменты. Работа библиотеки Replication Framework основана на использовании мгновенных снимков объектов в произвольные моменты времени, то есть с помощью метода-расширения Snapshot можно запросто сделать хронику мутаций [историю изменений] произвольного объекта или графа.
var cache = new ReconstructionCache();
var sourceSnapshot = sourceEntry.CreateSnapshot(cache, replicationProfile);
...
var resultSnapshot = editableEntry.CreateSnapshot(null, replicationProfile);
Далее можно сопоставить два снимка, чтобы выявить различия в состоянии графа между двумя любыми контрольными точками.
var changes = sourceSnapshot.Juxtapose(resultSnapshot)
.Where(j => j.State != Etalon.State.Identical);
С помощью вызова метода ReplicateGraph можно воссоздать новую копию графа идентичную той, что зафиксирована на снимке, а с помощью ReconstructGraph при наличии кэша репликации совершить реконструкцию графа, то есть вернуть старый экземпляр к прежнему состоянию.
var editableEntry = useCopy ? sourceSnapshot.ReplicateGraph() : sourceEntry;
var cache = new ReconstructionCache();
var sourceSnapshot = sourceEntry.CreateSnapshot(cache, replicationProfile);
...
else if (!useCopy) sourceSnapshot.ReconstructGraph(cache);
Более подробную информацию об использовании библиотеки вы можете найти в предыдущих публикациях:
1) Replication Framework • глубинное копирование и обобщённое сравнение связных графов объектов
2) Обобщённое копирование связных графов объектов в C# и нюансы их сериализации
Библиотека является бесплатной для некоммерческих и учебных проектов, а на Nuget доступна пробная версия, которая функциональна до конца лета. Для получения лицензионной версии с неограниченным сроком действия и доступа к исходным кодам необходимо отправить запрос на адрес makeman@tut.by.
За внешней простотой использования и хорошей функциональностью библиотеки кроется большая и кропотливая работа по её созданию и отладке, поэтому любая материальная поддержка и покупка коммерческой лицензии очень приветсвуются!
Вдохновения тебе, читатель!
Комментарии (21)
geekmetwice
22.07.2017 05:06Если он вносит какие-либо корректировки в данные, то при обработке формы хорошим тоном является запрос-подтверждение перед окончательным применением внесённых правок.
Первый раз этот бред слышу. Появилась форма, отредактировал, нажал ОК — значит хотим сохранить. Cancel — отмена, что тут ещё подтверждать??
Заголовок тоже весьма закрученный вокруг простой идеи: «форма редактирования и откат изменений».
Автор, ты просто наворотил мельницу вокруг тривиальнейшего шаблона: перед редактированием создаём копию объекта, редактируем, применяем изменения.
Объект Person… наворотить геттеров-сеттеров — много ума не надо, студенты уже так сделали мильён раз и это ни разу не интересно. Вот как редактировать, когда объекты недоступны? Дали тебе бинарь с классами — что хочешь, то и делай!
Наверное, этот «Replication Framework» где-то и нужен, но в данной задаче это полная фигня.Makeman
22.07.2017 09:33Под подтверждением подразумевается нажатие кнопки Ок.
Соль в том, что не нужно самому реализовывать рутинную логику копирования и сравнения сущностей, поскольку всю эту работу берёт на себя фреймворк. В случае единичной сущности, как в примере, выигрыш в коде не столь очевиден, но когда в программе сущностей много, разница становится заметна — практически всю логику по копированию можно заменить парой обобщённых методов.hVostt
23.07.2017 09:19Если реализуется INotifyPropertyChanged, как показано в примерах, то ничего сравнивать уже не надо, мы итак знаем, что было изменено. А для копирования можно взять тот же AutoMapper, который уже давно вырос в серьёзный и проверенный временем тул. Не вижу причин использовать фреймворк, по крайне в этой задаче совершенно точно он не нужен, даже с большой натяжкой. Его использование только всё усложнит.
Makeman
23.07.2017 11:02По INotifyPropertyChanged, пускай изначальное значение свойства было abc, потом пользователь отредактировал его на abcd, после чего на другое значение и так n раз, но в конечном счёте решил вернуть abc и нажал Ok. В итоге нотификация сработала, но значение осталось прежним, не изменилось. Конечно, можно запоминать исходные данные и потом выполнять сравнение, но это же тот самый подход от которого мы хотим отойти…
Да, можно использовать AutoMapper, различные сериализаторы, библиотеку Compare net objects, но Replication Framework, если разобраться детально, во многих отношениях стремится обобщить их функциональность и решать более широкий круг проблем.
Не совсем ясно, в чём именно состоит усложнение, если использовать библиотеку для задачи из публикации. Конечно, нужно изучить и понять новый api, но он ведь не настолько обширный и сложный.
Или всё-таки что-то показалось не достаточно понятным?hVostt
23.07.2017 11:41Мне вот что непонятно. Если мне нужно знать изменения объекта, то я реализую INotifyPropertyChanged. И как минимум два варианта есть, узнать конечные изменения:
1. Объект хранит начальное состояние.
2. Объект, который много раз менялся, я присваиваю с помощью AutoMapper объекту, реализующему INotifyPropertyChanged. Т.е. получу абсолютно то же самое, что делает фреймворк, один в один.
В целом, использование сравнения по всем полям для выяснения изменений является более накладной процедурой, чем просто отслеживать эти изменения. Хранить кучу копий в памяти, чтобы хранить граф изменений? Вы про Event Sourcing слышали? Он решает именно эту задачу и очень экономно, без создания избыточных копий, только здесь фреймворк мало чем поможет.
Усложнение состоит в том, что вместо того, чтобы решать конечную задачу близкими к решению способами: нужны изменения, значит надо их отслеживать/регистрировать, решаются сложными путями, через создание копий и последующим их сравнением.
Вот ещё пример решения: прокси. Имеем один инстанс-оригинал, и сколько угодно прокси, которые хранят ссылку на оригинал, таким образом дифференс всегда известен, в любой момент времени, без дополнительной операции сравнения, которая к тому же должна быть атомарной, иначе в многопоточной среде могут быть неприятные артефакты.
И процедура вычисления изменений в бизнес-логике может быть гораздо сложнее, чем сравнить все поля. Например, один крыжик отключает группу полей совсем, и сравнивать их не надо вообще. А некоторые изменения просто не принимаются, потому что всё равно не будут сохранены (поля только для чтения).
Хотя сам по себе фреймворк имеет право на жизнь. Но не в данном примере точно. Пример нужно подобрать более уместный.Makeman
23.07.2017 12:45Конкретно в примере INotifyPropertyChanged был введен, чтобы немедленно отображать изменения в гриде при редактировании оригинальной сущности в диалоге (демонстрационная цель). Конечно, если разработчику доступна реализация этого интерфейса, то для контроля изменений он может завязаться на событийную модель, это его выбор. И, действительно, для ряда задач это будет наиболее оптимальным решением, например, при обновления состояния интерфейса или слежениии за изменением определённых свойств. Но не всегда реализация этого интерфейса доступна, поэтому приходится применять другие решения.
RF позволяет довольно гибко сопоставлять снимки объектов, и не только по всем полям. Результатом операции сопоставления является IEnumerable, что позволяет реализовывать даже весьма экзотические сценарии (прекращать сравнение при достижении n различий, пропускать ненужное, вычислять «похожесть» снимков, например, количество одинаковых полей делить на общее, сопоставлять объекты разных типов). Конечно, если логика сравнения бизнес-сущностей в определённом случае очень специфическая, то, возможно, имеет смысл реализовать её вручную, но ведь это тоже не самый распространённый сценарий.
Снимки полностью изолированы от исходного объекта, поэтому при сравнении состояний с многопоточностью проблем нету.
Задачу, поставленную в публикации, библиотека решает успешно с приемлемой для данного случая производительностью и минимумом строк кода, поэтому подход вполне практичен.hVostt
23.07.2017 18:57Хорошо, по вопросу применимости к обсуждаемому примеру наши взгляды расходятся.
Больше всего меня смущает вот что (из вашей документации): «но иногда полезно иметь возможность вернуть [откатить] граф и входящие в него объекты к какому-то определённому состоянию зафиксированному ранее» — вот бы увидеть пример, когда это бывает полезно, хранить граф 1000 объектов == 1000 изменений. И как я буду откатываться, куда и зачем? К 467-му изменению назад? Какой-нибудь действительно практический и живой пример очень не помешал.
Спасибо.Makeman
23.07.2017 21:38Ну, если даже взглянуть на пример из статьи, то в нём редактирование сущности в диалоговом окне можно осуществить двумя способами (флаг Create copy): с созданием новой копии объекта и без его копирования с непосредственным редактированием оригинала. Так вот, если изменить оригинал, то правки сразу отразятся в главном окне, но если нажать Cancel, то при скрытии диалога произойдёт откат (реконструкция) до состояния, зафиксированного на снимке.
Хотя здесь операции подвергается единичная сущность в общем случае библиотека поддерживает целые графы с произвольной структурой (допустимы множественные и циклические ссылки на объекты) и любым адекватным числом объектов (в пределах памяти доступной приложению и размеру стека вызовов).
Данная функциональность позволяет вести хронологию изменений графа и сопоставлять снимки в произвольных комбинациях. Например, делать что-то вроде бэкапов или кнопки Undo. Что касается вопроса про граф из 1000 объектов, поясню. Пусть дан такой граф, в какой-то момент делаем его снимок и явным образом помещаем исходные объекты в ReplicationCache (он удерживает экземпляры от сборки мусора и хранит ссылку-идентификатор каждого экземпляра). Далее с графом может происходить что угодно: изменяться структура, добавляться и удаляться объекты, — но когда у нашего снимка будет вызван метод ReconstructGraph, то все объекты из ReplicationCache вернутся к состоянию на момент создания снимка и образуют исходный граф из тысячи объектов.
Создание снимка — это своего рода промежуточная сериализация в Json-подобную структуру данных из словарей, но с которой значительно более удобно работать, чем с жёсткой Json-строкой. Но при необходимости снимки можно преобразовывать в строку и восстанавливать без искажений.
Благодарю за интерес! Если возникают вопросы, то свободно их задавайте.hVostt
24.07.2017 18:18Спасибо, про работу реконструкции теперь ясно. Только ещё небольшое уточнение.
По поводу «1000 объектов» я имел в виду не 1000 объектов в самом графе объекта, а 1000 копий. Мне показалось, что ReplicationCache хранит полную копию графа при каждом снимке, это означает, что 1000 снимков (после каждого изменения) образует 1000 копий, а это очень много, ведь нам нужны лишь изменения, чтобы откатиться.
Как например делается Undo, с помощью паттерна Команда. Команда хранит представляет собой операцию и данные (т.е. изменения). Не полное состояние объекта, а только информацию, которая изменяет объект. Например, команда изменения ФИО хранит только ФИО, а не всё +100500 полей и целый граф объекта. Откат этого действия, вернёт только предыдущее значение ФИО. Какая-то более универсальная команда может просто хранить только изменившиеся поля. Это мне понятно. Я так делал.
В чём смысл хранить для этого снимки? Если говорить по Undo? Может вы из своей практики приведёте пример, где действительно понадобилось хранить кучу исторических копий? Не считая примера с сохранением записи и всего одной копией. Интересует, когда это может понадобится больше одной и зачем? Вы ведь не просто так реализовали свой фреймворк? Основываясь на каком-то опыте.
Буду благодарен пояснения, если я вас ещё не утомил :)Makeman
24.07.2017 22:54Хорошо :) расскажу подробнее про историю создания.
Насчёт тысячи снимков, да, каждый снимок хранит полную копию состояния графа, а не отдельные изменения. Но стоит отметить, что снимки можно запросто сериализовать и сохранять в файлы, после чего заново восстанавливать. То есть не обязательно держать их всегда в памяти.
На самом деле, в моей практике не возникало необходимости хранить кучу изменений, но и такую возможность исключить полностью нельзя. Фремворк же возник в результате решения несколько других задач, а ведение хронологии и реконструкция графов явились своего рода побочными эффектами.
1) Часто на практике приходилось копировать и сравнивать объекты, писать довольно утомительную логику, где из-за копи-пэйста закрадывались малозаметные ошибки, поэтому начали возникать мысли всё это дело обобщить. А однажды появилась необходимость сравнить два огромных графа с настройками (там происходила миграция форматов в новой версии программы и нужно было удостовериться, что все опции смигрировались успешно), в результате чего был разработан своеобразный рекурсивный алгоритм сравнения на основе рефлексии.
2) В какой-то момент начали то и дело всплывать ограничения, накладываемые многими сериализатооами на графы, например, далеко не все могут обработать корректно множественные и циклические ссылки, иногда искажают информацию о типах, каким-то не достаёт гибкости. Захотелось написать такой сериализатор, который съест практически любой граф и восстановит его без искажений, идентично оригиналу, при этом позволит настраивать весь процесс…
Долго вынашивались эти идеи, поскольку очень много нюансов присуще процессам копирования, сравнения и [де]сериализации. Но в конце концов возникла мысль ввести промежуточный слой и абстракцию снимка. Были сомнения насчёт целесообразности такого шага, поскольку любые прослойки стоит вводить осторожно, но когда началась реализация, то стало ясно, что это отличное решение, которое очень упрощает код, делает его красивым и позволяет реализовать новую необычную функциональности в виде хронологии и реконструкции. То есть получалось очень обобщённое решение, которое может запросто копировать сложные графы, работать с их состоянием, прекрасно сериализовать и десериализовать, а также сравнивать состояния.
Другими словами, в обычных сериализаторах логика получения состояния объекта и записи его в файл очень связная, иногда зависимая от формата сериализации, а в RF состояние — это отдельная абстракциях, с которой можно заметно эффективнее работать.
Примерно так и возник фреймворк. Если появятся ещё вопросы, то буду рад ответить! ;)
hVostt
25.07.2017 00:39Теперь картина сложилась, спасибо :)
Насчёт сериализатора, если класс не содержит публичного конструктора без параметров, ваш фреймворк может десериализовать объект? Это самое важно, если честно, так как мы используем контракты для сериализации и десериализации, классы должны реализовать специальный конструктор (наподобие Exception(SerializationInfo..)). А конструктор без параметров для целей сериализации это нарушение инкапсуляции, чего бы мы не хотели.
Насчёт изменений. Хранить все изменения это круто, сейчас мы активно используем Event Sourcing и храним все изменения всех данных с начала времён, поэтому нам интересно хранить только изменения, а не изменённые копии, это слишком накладно и медленно. Мощный механизм сравнения графов может быть действительно в этом случае полезным, так как можно упростить задачу.
Теперь понятно, что кеш снимков это побочный эффект, который наверное как-то где-то можно применить, но пока непонятно где и как :) Всё же Undo реализуется на командах, так как это именно откат действий, а не изменённых данных по своей сути.Makeman
25.07.2017 01:19В текущей реализации по умолчанию библиотека использует класс Activator и конструктор без параметров для создания новых копий объектов, однако есть возможность переопределять это поведение в профилях репликации, в том числе использовать параметризированные конструкторы для конкретных типов (например, так сейчас работают Regex и StringBuilder).
Вообще стандартные Data Contract сериализаторы от Майкрасофт используют метод FormatterServices.GetUninitializedObject(Type), который создаёт непроинициализированный объект и вовсе не вызывает конструктор, но этот метод недоступен в публичном API для портабельных сборок, только в полном dot net.
Если заинтересуетесь деталями реализации, то спрашивайте, можете также отправить запрос на адрес makeman@tut.by для получения ознакомительного доступа к коду проекта. :)
pawlo16
За такие диалоговые окна хочется начать бить уже сейчас и больно. Редактировать табличные данные в чём то по мимо ячеек таблицы — грех
Makeman
Действительно, обычно удобнее редактировать данные напрямую в таблице, но не всегда такая возможность доступна в реальных приложениях, особенно это касается, например, мобильных, где бывает не очень уместен DataGrid.
В примере для статьи диалоговое окно добавлено в демонстрационных целях. :-)
MonkAlex
Если таблица отображает сущность и нужна валидность сохраненной сущности — то редактирование ячейки потребует отдельного действия для сохранения, а все изменения будут временными и это будет менее прозрачно для пользователя.
zed220
Это не так. В таблице может быть не полный набор полей, например.
zenkz
Это дело вкуса. Мне вот редактирование внутри табицы кажется неудобным. Хорошо иметь это опционально для ввода большого объёма данных или массового обновления, но в большинстве случаев формы будут удобнее.
Очень нравится вариант, когда активная ячейка превращается в форму ввода.
d-stream
pawlo16
в таком случае treeview с колонками либо detailed row для активной строки таблицы (что в разы хуже).
d-stream
Ни то ни другое совершенно не увяжется в ситуации, когда к примеру фигурирует фото (несколько) персоны и кучка иных атрибутов…
А вот по сути картотека с многостраничными «досье» (грид и карточки) — очень даже ничего.