За последнее десятилетие значительно повысилась доступность Интернета. Поэтому и число приложений, которые работают в связке клиент-сервер, тоже выросло в разы. Но что делать, если доступ в сеть есть, но не всегда? Именно с таким требованием от заказчика мы столкнулись в одном из проектов. Всех, кому интересно разработанное нами решение, прошу под кат.
Итак, чуть подробнее о задаче. Есть N географически удаленных офисов. В каждом офисе работает несколько сотрудников, обрабатывая данные. Данные могут пересекаться между офисами. В офисе большую часть времени может отсутствовать доступ в Интернет. Соответственно, может возникать ситуация, когда один и тот же объект был изменен двумя сотрудниками. Так как изменения являются равнозначными и нельзя применить одно из них и отклонить другое, появляется еще одно требование – механизм разрешения конфликтов. Ну и в качестве вишенки на торте – в роли рабочих станций в офисах используются относительно старые ноутбуки с малым объемом оперативной памяти.
Прежде, чем отважно кинуться на решение проблемы, мы, конечно же, попытались выяснить, не дешевле ли будет решить вопрос с доступностью сети, чем искать программное решение. Но здесь заказчик был непреклонен и настаивал на своих внутренних причинах к такому решению.
Оставив организационный путь решения, мы перешли к техническому. Многое в дальнейшем зависело от выбора СУБД для локальной БД. Первым выбором стал SQL CE, но спустя некоторое время из-за больших проблем с производительностью итоговый выбор пал на MySql.
Если отбросить решения, найденные на просторах codeproject’a, то альтернатив было 3:
Вариант с репликацией не подходит по многим критериям: начиная от требований по визуальному разрешению конфликтов и заканчивая различными СУБД на клиентах и сервере (хотя, конечно, это решаемо).
MS Sync Framework внушал большие надежды после прочтения списка фич. После первого знакомства надежд стало на порядок меньше. Основной проблемой стало, то, что он слабо ориентирован на работу на уровне доменных объектов (а именно так пользователю наиболее удобно работать – разрешать конфликт для сущностей, с которыми он работает в приложении, а не для отдельных их кусков, которые соответствуют отдельным таблицам).
Поэтому мы отправились на встречу приключениям по дороге к пункту 3!
Итак, конечная архитектура включала в себя клиент, реализованный на WPF, и сервер, реализованный в виде WCF-сервиса.
Для каждой сущности в доменой структуре приложения был заведен класс, унаследованный от базового класса Entity:
Каждый объект в системе имеет три представления:
Изначально, на стороне сервиса для каждой сущности реализовано два метода:
В ходе тестирования было решено добавить третий метод – GetCount, который бы возвращал количество измененных после указанной даты объектов. Метод Get, соответственно, получит параметры для получения N объектов со смещением M. Было это сделано из-за того, что при плохом соединении с сервером часто вылетал таймаут соединения. Можно бы было просто увеличить таймаут, но вариант с постраничным получением данных кажется более правильным.
Кроме методов для обработки сущностей также на сервере присутствуют следующие методы:
Для решения вопроса с разрешением конфликтов в системе была реализованная следующая модель редактирования.
В приложении каждый редактор может использоваться в двух вариантах:
Отличия между этими вариантами заключаются в форме, на которой происходит редактирование – при редактировании сущности форма содержит редактор и кнопки сохранения/отмены (некоторые формы также могут иметь дополнительные кнопки, но это не влияет на суть дела), при сохранении такой формы происходит сохранение объекта в БД и закрытие диалога. При разрешении конфликтов на форме отображается одновременно два редактора (для локального объекта и для объекта, полученного с сервера), управление состоянием объекта (удален/не удален), кроме того, внутри редактора необходимо визуально выделять отличающиеся поля, а при закрытии окна не нужно сохранять изменения в БД.
Для этого в приложении каждый редактор был оформлен в виде отдельного элемента управления. Этот элемент управления содержал в себе только логику для редактирования (вызов дочерних редакторов, обновление состояния экрана и т.п.). Интерфейс этого редактора предоставлял два метода для установки / получения редактируемого объекта (PE).
Для удобства использования был введен его generic-вариант:
Далее он оборачивался в форму, которая включала логику по трансляции PE в доменный объект и дальнейшее сохранению PE в БД.
Диалог разрешения конфликтов одновременное отображает два редактора, в каждый из которых соответственно установлены либо PE локальной сущности, либо PE серверной сущности.
Для некоторых редакторов у пользователей была возможность изменить не только поля самого объекта, но и поля дочерних объектов (например, редактирование дочернего списка). Соответственно, в результате работы формы разрешения конфликтов изменить свое состояние мог не только объект, для которого была вызвана форма, но и объекты дочерних коллекций. Поэтому все объекты дочерних коллекций в представлении на формах имели следующий общий класс:
Где ChildEditorPE – PE для редактора этой сущности
Таким образом, после закрытия формы разрешения конфликтов мы проходим по коллекциям редактируемого объекта (с помощью рефлексии) и обновляем все объекты, редакторы которых имеют состояние, отличное от Normal. В результате мы имели измененное состояние объекта и идентификаторы объектов, которые были изменены во время редактирования основного объекта.
Кроме того, во время работы формы разрешения конфликтов мы по таймеру в параллельном потоке вызываем метод сервиса Noop для поддержания нашей сессии.
Логика подсветки состояния элемента форм строится на предположении, что редакторы локального объекта и объекта, полученного с сервера, имеют идентичную структуру, а также на условии ограниченного числа используемых типов элементов управления (т.е. если необходимо отображать различия для слайдера, то необходимо добавить код, который будет проверять равенство текущих значений, а также устанавливать стили для состояний).
Для каждого элемента редактора локального объекта ищется соответствующий элемент в редакторе объекта, полученного с сервера. Далее проверяется наличие установленного биндинга на соответствующее поле (например, Text для TextBox или ItemsSource для DataGrid) – это еще одно допущение, все поля в редакторах, влияющих на состояние объекта связаны с моделью через Binding. Далее получаем значения полей в элементе, принадлежащему редактору локального объекта, и в элементе, принадлежащему редактору объекту, полученного с сервера. На основе сравнения этих значений устанавливаем необходимый стиль.
Вот несколько примеров формы разрешения конфликтов
Элементы на форме имеют красный цвет фона в случае, если значение в локальной версии объекта отличается от значения в объекте, полученного с сервера.
Списки имеют зеленый фон если количество объектов и ихсостав совпадает в локальной версии и в версии, полученной с сервера. Элементы в списке так же меняют цвет фона в зависимости от своего состояния.
Первая версия синхронизации включала в себя следующие шаги:
В процессе возникла необходимость восстанавливать локальную базу пользователя в случае не удачной синхронизации (например, пропала связь с сервером, или пользователь отменил синхронизацию). Первый рассмотренный вариант – обернуть все в транзакцию. Но так как в процессе синхронизации у нас происходит обращение к базе из различных потоков (взаимодействие с сервером осуществляется в одном потоке, работа форм в другом (а формы при отображении объекта в теории могут обратиться к БД за какими-то справочными данными из БД), то этот вариант отпал. Поэтому процесс синхронизации дополнился двумя шагами – в начале мы создаем бэкап локальной базы, а в случае ошибки / отмены – восстанавливаем базу из ранее созданного бэкапа.
Следующей проблемой, с которой мы столкнулись, стал таймаут операции при сохранении изменений на сервере – в случае большого количества изменений сущностей одного типа операция по их сохранению могла выполняться дольше, чем позволял установленный таймаут. Простым решением было бы увеличить таймаут. Другим решением могло быть введение постраничного сохранения (по аналогии с получением данных). Но мы решили пойти по альтернативному пути, который позволил нам решить вопрос с транзакционностью и на серверной стороне (чтобы была возможность применить либо все изменения из сессии синхронизации, либо не применять ни одного).
Все изменения, полученные в методах Save, транслировались в JSON-объект, который сохранялся в специальную таблицу. Это объект содержал информацию о сессии, которой он принадлежит, типе сущности, которой соответствует, и состоянию соответствующей DTO.
После окончания основной части синхронизации (получения объектов, разрешения конфликтов и отправки изменений на сервер) клиент вызывал метод сервиса CommitChanges, который, в свою очередь, отправлял сообщение Windows-сервису, который уже производил обратную распаковку изменений из промежуточной таблицы в DTO, трансляцию объектов DTO в доменные сущности и их сохранение.
Выделение этой операции в отдельный win-сервис было сделано из-за того, что операция по сохранению может занимать достаточно длительное время. Клиент же в это время периодически вызывает метод сервера GetCommitStatus для того, чтобы проверить статус операции.
Вполне вероятно, что в свое время мы изобрели велосипед. Также мы понимаем, что итоговое решение не лишено недостатков. Но в итоге мы получили устойчивый и работоспособный вариант решения синхронизации оффлайн частей системы. Но все же для будущих проектов (и как ни странно, запросы с подобными требованиями продолжают регулярно поступать) мы бы были рады узнать другие варианты решения, а также улучшить нашу модель, поэтому будем рады комментариям и замечаниями.
Недостатки:
Плюшки:
Частично перечисленные недостатки можно устранить:
Исходные данные
Итак, чуть подробнее о задаче. Есть N географически удаленных офисов. В каждом офисе работает несколько сотрудников, обрабатывая данные. Данные могут пересекаться между офисами. В офисе большую часть времени может отсутствовать доступ в Интернет. Соответственно, может возникать ситуация, когда один и тот же объект был изменен двумя сотрудниками. Так как изменения являются равнозначными и нельзя применить одно из них и отклонить другое, появляется еще одно требование – механизм разрешения конфликтов. Ну и в качестве вишенки на торте – в роли рабочих станций в офисах используются относительно старые ноутбуки с малым объемом оперативной памяти.
Прежде, чем отважно кинуться на решение проблемы, мы, конечно же, попытались выяснить, не дешевле ли будет решить вопрос с доступностью сети, чем искать программное решение. Но здесь заказчик был непреклонен и настаивал на своих внутренних причинах к такому решению.
Оставив организационный путь решения, мы перешли к техническому. Многое в дальнейшем зависело от выбора СУБД для локальной БД. Первым выбором стал SQL CE, но спустя некоторое время из-за больших проблем с производительностью итоговый выбор пал на MySql.
Если отбросить решения, найденные на просторах codeproject’a, то альтернатив было 3:
- Репликация БД
- MS Sync Framework
- Свой
велосипедвариант
Вариант с репликацией не подходит по многим критериям: начиная от требований по визуальному разрешению конфликтов и заканчивая различными СУБД на клиентах и сервере (хотя, конечно, это решаемо).
MS Sync Framework внушал большие надежды после прочтения списка фич. После первого знакомства надежд стало на порядок меньше. Основной проблемой стало, то, что он слабо ориентирован на работу на уровне доменных объектов (а именно так пользователю наиболее удобно работать – разрешать конфликт для сущностей, с которыми он работает в приложении, а не для отдельных их кусков, которые соответствуют отдельным таблицам).
Поэтому мы отправились на встречу приключениям по дороге к пункту 3!
Программная модель
Итак, конечная архитектура включала в себя клиент, реализованный на WPF, и сервер, реализованный в виде WCF-сервиса.
Для каждой сущности в доменой структуре приложения был заведен класс, унаследованный от базового класса Entity:
public class Entity
{
public virtual Guid ID { get; set; }
public virtual DateTime Timestamp { get; set; }
public virtual EEntityState State { get; set; }
public virtual bool IsDeleted { get; set; }
}
public enum EEntityState
{
Normal,
Created,
Modified,
Deleted
}
- ID – идентификатор объекта, использование Guid позволяет решить проблему присвоения идентификаторов при создании новых объектов на различных клиентах системы.
- Timestamp – время последнего изменения объекта. Данное поле обновляется ТОЛЬКО на сервере при синхронизации изменений пользователя. На основе этого поля сервер отправляет клиенту объекты, которые были изменены с момента предыдущей синхронизации.
- State – состояние объекта. Данное поле обновляется ТОЛЬКО на клиенте. На основе этого поля клиент выбирает объекты, которые были изменены (созданы, отредактированы или удалены) с момента последней синхронизации.
- IsDeleted – объекты в системе не удаляются (еще одно требование заказчика). Поэтому вместо фактического удаления объект помечается как удаленный и не участвует в дальнейших выборках при работе клиента.
Каждый объект в системе имеет три представления:
- в виде доменной сущности
- в виде DTO (Data Transfer Object) – в этом представлении объект передается при синхронизации между клиентом и сервером. В таком объекте присутствуют только простые поля. Все ссылки/коллекции ссылок заменены на идентификаторы объектов, на которые указывала ссылка.
- в виде PE (Presentation Entity) – в этом представлении объект отображается на форме.
Серверная часть
Изначально, на стороне сервиса для каждой сущности реализовано два метода:
- Get – получить объекты, измененные после указанной даты;
- Save – сохранить изменения для переданных объектов
В ходе тестирования было решено добавить третий метод – GetCount, который бы возвращал количество измененных после указанной даты объектов. Метод Get, соответственно, получит параметры для получения N объектов со смещением M. Было это сделано из-за того, что при плохом соединении с сервером часто вылетал таймаут соединения. Можно бы было просто увеличить таймаут, но вариант с постраничным получением данных кажется более правильным.
Кроме методов для обработки сущностей также на сервере присутствуют следующие методы:
- BeginSynchronization – инициирует начало синхронизации. В этот момент происходит проверка возможности синхронизации (в один момент времени может синхронизироваться только один клиент; сессия считается активной, если она а) не завершена, б) была активна в течение последних 20 минут). В случае успеха создается новая сессия.
- EndSynchronization – завершение синхронизации. Данная операция закрывает текущую сессию синхронизации, а клиент получает дату синхронизации (которую он использует при последующих синхронизациях).
- Noop – операция поддержания соединения. Данная операция используется в момент разрешения конфликтов на клиентской стороне, чтобы не оборвалось соединение по таймауту, а также для обновления статуса текущей сессии синхронизации.
Разрешение конфликтов
Для решения вопроса с разрешением конфликтов в системе была реализованная следующая модель редактирования.
В приложении каждый редактор может использоваться в двух вариантах:
- непосредственное редактирование сущности;
- разрешение конфликта при синхронизации.
Отличия между этими вариантами заключаются в форме, на которой происходит редактирование – при редактировании сущности форма содержит редактор и кнопки сохранения/отмены (некоторые формы также могут иметь дополнительные кнопки, но это не влияет на суть дела), при сохранении такой формы происходит сохранение объекта в БД и закрытие диалога. При разрешении конфликтов на форме отображается одновременно два редактора (для локального объекта и для объекта, полученного с сервера), управление состоянием объекта (удален/не удален), кроме того, внутри редактора необходимо визуально выделять отличающиеся поля, а при закрытии окна не нужно сохранять изменения в БД.
Для этого в приложении каждый редактор был оформлен в виде отдельного элемента управления. Этот элемент управления содержал в себе только логику для редактирования (вызов дочерних редакторов, обновление состояния экрана и т.п.). Интерфейс этого редактора предоставлял два метода для установки / получения редактируемого объекта (PE).
public interface IEditor
{
event Action EditedObjectChanged;
IWindow Window { get; set; }
ObjectValidationResult Validate();
void SetEditedObject(PresentationEntity pe);
PresentationEntity GetEditedObject();
}
Для удобства использования был введен его generic-вариант:
public interface IEditor<TPE> : IEditor
where TPE: PresentationEntity
{
TPE DisplayObject { get; set; }
}
Далее он оборачивался в форму, которая включала логику по трансляции PE в доменный объект и дальнейшее сохранению PE в БД.
Диалог разрешения конфликтов одновременное отображает два редактора, в каждый из которых соответственно установлены либо PE локальной сущности, либо PE серверной сущности.
Для некоторых редакторов у пользователей была возможность изменить не только поля самого объекта, но и поля дочерних объектов (например, редактирование дочернего списка). Соответственно, в результате работы формы разрешения конфликтов изменить свое состояние мог не только объект, для которого была вызвана форма, но и объекты дочерних коллекций. Поэтому все объекты дочерних коллекций в представлении на формах имели следующий общий класс:
public class ChildListItemPE : PresentationsEntity
{
public Guid ObjectID { get; set; }
public ChildEditorPE EditorPE { get; set; }
}
Где ChildEditorPE – PE для редактора этой сущности
public class ChildEditorPE : ValidatablePresentationEntity
{
public Guid ObjectID { get; set; }
public EEntityState State { get; set; }
}
Таким образом, после закрытия формы разрешения конфликтов мы проходим по коллекциям редактируемого объекта (с помощью рефлексии) и обновляем все объекты, редакторы которых имеют состояние, отличное от Normal. В результате мы имели измененное состояние объекта и идентификаторы объектов, которые были изменены во время редактирования основного объекта.
Кроме того, во время работы формы разрешения конфликтов мы по таймеру в параллельном потоке вызываем метод сервиса Noop для поддержания нашей сессии.
Логика подсветки состояния элемента форм строится на предположении, что редакторы локального объекта и объекта, полученного с сервера, имеют идентичную структуру, а также на условии ограниченного числа используемых типов элементов управления (т.е. если необходимо отображать различия для слайдера, то необходимо добавить код, который будет проверять равенство текущих значений, а также устанавливать стили для состояний).
Для каждого элемента редактора локального объекта ищется соответствующий элемент в редакторе объекта, полученного с сервера. Далее проверяется наличие установленного биндинга на соответствующее поле (например, Text для TextBox или ItemsSource для DataGrid) – это еще одно допущение, все поля в редакторах, влияющих на состояние объекта связаны с моделью через Binding. Далее получаем значения полей в элементе, принадлежащему редактору локального объекта, и в элементе, принадлежащему редактору объекту, полученного с сервера. На основе сравнения этих значений устанавливаем необходимый стиль.
Вот несколько примеров формы разрешения конфликтов
Элементы на форме имеют красный цвет фона в случае, если значение в локальной версии объекта отличается от значения в объекте, полученного с сервера.
Списки имеют зеленый фон если количество объектов и ихсостав совпадает в локальной версии и в версии, полученной с сервера. Элементы в списке так же меняют цвет фона в зависимости от своего состояния.
Синхронизация
Первая версия синхронизации включала в себя следующие шаги:
- начало синхронизации – клиент инициализирует соединение с сервером;
- получение от сервера списка измененных объектов (т.е. объектов, которые изменили другие пользователи);
- получение списка локальных изменённых объектов (т.е. объектов, которые изменил текущий пользователь);
- объекты, которые были изменены только на сервере, сразу же сохраняются в локальную БД;
- объекты, которые были изменены только локально, добавляются в коллекцию объектов для отправки на сервер;
- объекты, которые были изменены и локально, и на сервере, помечаются для разрешения конфликтов:
o В разрешении конфликтов участвует локальная копия объекта, и объект, созданный на основе полученной DTO. При этом для объекта, созданного на основе DTO, дополнительно обрабатываются ссылки на другие объекты и коллекции (чтобы они указывали не на локальные объекты, а на объекты, полученные с сервера); - отправляем информацию об измененных локальных сущностях на сервер;
- запускаем процесс сохранения;
- завершаем процесс синхронизации.
В процессе возникла необходимость восстанавливать локальную базу пользователя в случае не удачной синхронизации (например, пропала связь с сервером, или пользователь отменил синхронизацию). Первый рассмотренный вариант – обернуть все в транзакцию. Но так как в процессе синхронизации у нас происходит обращение к базе из различных потоков (взаимодействие с сервером осуществляется в одном потоке, работа форм в другом (а формы при отображении объекта в теории могут обратиться к БД за какими-то справочными данными из БД), то этот вариант отпал. Поэтому процесс синхронизации дополнился двумя шагами – в начале мы создаем бэкап локальной базы, а в случае ошибки / отмены – восстанавливаем базу из ранее созданного бэкапа.
Следующей проблемой, с которой мы столкнулись, стал таймаут операции при сохранении изменений на сервере – в случае большого количества изменений сущностей одного типа операция по их сохранению могла выполняться дольше, чем позволял установленный таймаут. Простым решением было бы увеличить таймаут. Другим решением могло быть введение постраничного сохранения (по аналогии с получением данных). Но мы решили пойти по альтернативному пути, который позволил нам решить вопрос с транзакционностью и на серверной стороне (чтобы была возможность применить либо все изменения из сессии синхронизации, либо не применять ни одного).
Все изменения, полученные в методах Save, транслировались в JSON-объект, который сохранялся в специальную таблицу. Это объект содержал информацию о сессии, которой он принадлежит, типе сущности, которой соответствует, и состоянию соответствующей DTO.
public class IntermediateItem
{
public virtual Guid SessionId { get; set; }
public virtual string DTOType { get; set; }
public virtual string Content { get; set; }
}
После окончания основной части синхронизации (получения объектов, разрешения конфликтов и отправки изменений на сервер) клиент вызывал метод сервиса CommitChanges, который, в свою очередь, отправлял сообщение Windows-сервису, который уже производил обратную распаковку изменений из промежуточной таблицы в DTO, трансляцию объектов DTO в доменные сущности и их сохранение.
Выделение этой операции в отдельный win-сервис было сделано из-за того, что операция по сохранению может занимать достаточно длительное время. Клиент же в это время периодически вызывает метод сервера GetCommitStatus для того, чтобы проверить статус операции.
Вместо заключения
Вполне вероятно, что в свое время мы изобрели велосипед. Также мы понимаем, что итоговое решение не лишено недостатков. Но в итоге мы получили устойчивый и работоспособный вариант решения синхронизации оффлайн частей системы. Но все же для будущих проектов (и как ни странно, запросы с подобными требованиями продолжают регулярно поступать) мы бы были рады узнать другие варианты решения, а также улучшить нашу модель, поэтому будем рады комментариям и замечаниями.
Недостатки:
- одна синхронизация в один момент времени – как видно из описанного процесса, одновременно может синхронизироваться только один пользователь. Это доставляет определенные неудобства при первичной синхронизации (т.е. когда пользователь не имеет локальной БД). Такая сессия обычно занимает 2-3 часа (в зависимости от скорости доступа к серверу и быстродействия компьютера). Но с учетом того, что количество пользователей системы меньше 20, а частота появления новых пользователей стремится к нулю, это не доставляет проблем. Средняя длительность сессии синхронизации составляет 20-30 минут, а с учетом разной географии офисов (в том числе и разных часовых поясов) пересечения по синхронизации возникают не часто;
- добавление сущностей в систему требует большого числа вспомогательных объектов (собственно это не то, чтобы недостаток конкретного решения, а недостаток всех паттернов – их избыточность);
- необходимость следить за корректным обновлением состояния объектов;
- в случае если клиент «отвалился» от синхронизации и не закрыл свою сессию, то следующий клиент сможет подключиться только после истечения таймаута на «не активность» предыдущей сессии (20 минут).
Плюшки:
- транзакционность операции синхронизации (как на стороне сервера, так и на стороне клиента);
- разрешение конфликтов с визуализацией различий;
- ну и главное – заказчик доволен!
Частично перечисленные недостатки можно устранить:
- например, рутинные операции при добавлении новых сущностей можно по большей части автоматизировать с помощью кодогенерации;
- обновление состояния объектов можно реализовать на уровне репозитория;
- для отслеживания оборванных сессий можно ввести дополнительный метод сервиса, который вызывается через короткие промежутки времени (например, через 15 секунд) и обновляет статус сессии синхронизации. В таком случае можно уменьшить таймаут «не активности» с 20 минут, до упомянутых 15 секунд. Конечно, это увеличит трафик и нагрузку на сервер, но т.к. количество пользователей не велико и значительно меняться не будет, то этот вариант вполне жизнеспособен.