Доводилось ли Вам реализовывать объёмный сетевой обмен посредством TCP- или HTTP-протокола? Насколько, в случае такого опыта, Вы были удовлетворены сопровождаемостью конечного решения? Утвердительный ответ на первый вопрос (пусть даже и без «объёмистости» обмена) и недовольство гибкостью получившейся реализации позволяют рекомендовать эту статью как содержащую один из способов избавления от такого несчастья.
Ценность публикации, как представляется автору, также в том, что иллюстрируется всё не на простейшем учебном и малосвязанном с реальностью примере, а на небольшой части реального решения из настолько же взаправдашнего мобильного приложения, ранее уже упоминавшегося в другой статье.
Нужно отметить, что в программном коде статьи используется Indy, однако, хотя это и может показаться странным в материале, посвящённом сетевому взаимодействию, как такового знания этой библиотеки от читателя не потребуется, ибо смысл – в знакомстве с более абстрактными, высокоуровневыми приёмами при реализации своего протокола – речь в большей степени о проектировании.
Мобильное приложение, одна из функций которого – под названием синхронизация – и легла в основу статьи, представляет собой, говоря общо, список покупок: пользователь, создав перечень товаров, идёт с ним в магазин сам, либо поручает это дело другому человеку (или группе людей), но тогда, во втором случае, требуется передать этот список сначала на сервер (как централизованное хранилище), а затем уже на целевые мобильные устройства – как раз в эти моменты и появляется необходимость задействовать сеть; стоит подчеркнуть, что синхронизация является двусторонней, т. е. правка, сделанная любым участником (не обязательно автором), отразится и у всех других. В качестве наглядного примера, рассмотрим гипотетический случай, когда два амбициозных агронома-любителя решили осуществить одну из своих идей, для чего им понадобилось предварительно приобрести необходимый инструментарий:
Визуально, на устройстве, весь процесс синхронизации представлен анимированным индикатором и кнопкой отмены:
Таблица с примером выше – всего лишь набросок, самое поверхностное описание того, что должно происходить при синхронизации, поэтому его ни в коем разе нельзя использовать в качестве серьёзного ТЗ, а уж тем более писать код на таком шатком основании. Требуется полноценный протокол – детальное, поэтапное и исчерпывающее описание шагов по взаимодействию – кто, что и для чего пересылает по сети; по модели OSI он будет находиться на прикладном уровне (или, говоря иначе, уровне приложения). В качестве такого примера приводится небольшая часть реального документа, содержащая около 10% от всех действий (ось времени направлена вниз):
Для дальнейшего понимания не требуется вникать во все нюансы приведённого фрагмента протокола – главное уяснить, что есть действия на клиентской стороне (левый столбец), есть данные (средний столбец), полученные в результате выполнения действий одной из сторон, которые, собственно говоря, и нужно передать по сети, и есть серверная сторона (правый столбец), выполняющая некий анализ и прочую работу.
Перед реализацией протокола необходимо определиться с транспортом – протоколом того же, либо нижележащего уровня, ответственным за физическую пересылку данных; очевидных альтернатив две – HTTP и TCP (UDP, по вполне понятной причине – негарантированности доставки, здесь применяться не может). В конечном итоге выбор пал на второй вариант из-за двух причин: TCP, в силу своей бинарной природы, даёт полную свободу над всеми пересылаемыми данными и, на том же основании, имеет более высокую производительность, что в мобильном проекте идёт не на последнем месте.
Выбрав транспорт, рассмотрим условную реализацию протокола на примере клиентской стороны, взяв за основу
Пусть имеется простейшая форма с тремя компонентами:
Приводимый ниже обработчик нажатия на кнопку содержит сильные упрощения и, как следствие, некоторые ошибки и компилироваться не будет, но суть начального, незамысловато-очевидного подхода передаёт:
Необходимо вновь подчеркнуть, что приведённый кусочек значительно облегчен по сравнению с оригинальным кодом из приложения – поэтому на самом деле это событие, для полного протокола, разрастётся до многих тысяч строк. Будь объём не таким (до нескольких сотен строк), то вполне допустимо просто-напросто разбить его на методы или локальные процедуры по основным этапам и остановиться на этом, но не в данном случае – масштаб привносит серьёзные проблемы:
Дальнейшее повествование предложит пути устранения этих недостатков.
Основная идея, помогающая бороться с объёмом и проистекающей из этого сложностью, не нова – это введение абстракций, отражающих «предметную область»: в данном случае таковой является сетевое взаимодействие, поэтому первым будет введено такое программное обобщение, как протокол; его реализация, как нетрудно догадаться, станет основываться на классах, которые в свою очередь сгруппируются по модулям, и первым из них будет
Так как термин протокол уже использовался, то для избежания путаницы та таблица, что выше, станет называться описанием протокола. Также нужно заметить, что пока все модули не делятся на клиентские и серверные – они являются безотносительными к стороне обмена, общими.
Первоначально протокол описывается довольно простым кодом:
Ключевой метод
Если теперь переписать самый первый вариант кода, то он станет весьма компактным (в нём
Такое видоизменение, конечно же, пока не решает ни одну из обозначенных проблем – это достигнется другими средствами.
Вторая абстракция, которая станет использоваться уже? при реализации протокола, – это пакет данных (далее просто пакет) – именно на него ляжет ответственность за манипуляции с сетью. Если взглянуть на приводившийся фрагмент описания, то ему соответствуют 2 пакета (выделены цветом; первый из них отправляется клиентом, второй – сервером):
Код пакета тоже прост и выделен в новый модуль
Главными у пакета выступают 2 метода:
После обрисовки абстрактного пакета, определим пару тех, что непосредственно станут использоваться в приведённом выше описании протокола и содержать полезные данные, для чего объявим новый модуль:
Как можно заметить, ни эти 2 пакета, ни их предок,
Чтобы продемонстрировать, каким способом протокол использует пакеты, нужно ввести ещё 2 модуля – в этот раз делящиеся на клиентский и серверный, в отличие от всех предыдущих, – это
По их названию очевидно, реализацию какой из сторон они представляют.
И парный модуль:
Предыдущие разделы лишь подготовили почву, создали каркас для решения обозначенных в самом начале проблем – теперь возможно приступить и к ним, начав с доступа к данным.
Только что, при реализации протоколов обеих сторон, встречался следующий код:
а также
Для замещения приведённых комментариев реальным кодом, предлагается применить такой шаблон проектирования, как фасад: вместо манипуляций с данными напрямую, на протокол ляжет лишь задача по вызову его высокоуровневых методов, реализующих все сколь угодно сложные и объёмные действия по общению с БД; для этого создадим модуль
Единственный объявленный здесь класс
Клиентский фасад:
И серверный:
Если читателю, на примере клиентского фасада, кажется, что метод
Хотелось бы остановиться на одном нюансе: оба фасада импортируют модуль
После введения фасада, все методы протокола примут уже финальную, стабильную форму:
Для завершения всей воздвигнутой конструкции, осталось вбить последний, но при этом весьма важный, гвоздь – научить пакет передавать полезную информацию, однако задачу эту удобно поделить на две составляющие (на примере отправки):
Делать упаковку можно по-разному: в некий бинарный вид, в XML, в JSON и т. д. Поскольку мобильные устройства не обладают богатыми ресурсами, был избран именно последний, JSON-вариант, требующий меньших вычислительных затрат на обработку (по сравнению с XML); для реализации выбранного пути добавим 2 метода в
Их реализация не приведена, ибо возможны 2 пути: методы объявляются защищёнными и виртуальными и все пакеты-наследники индивидуально, в зависимости от добавленных в них свойств с данными, выполняют упаковку в JSON и распаковку из него, либо второй вариант – методы остаются приватными (как здесь) и содержат код по автоматическому преобразованию в JSON, что полностью избавляет отпрысков от «логистических» забот. Первый вариант допустим для случаев, когда количество пакетов и их сложность невелики (до десятка штук, со свойствами простейших типов), но если счёт идёт на бо?льшие величины – в проекте автора их 32, а сложность весьма высока, как к примеру у
то без автоматизации процесса упаковки обходиться уже крайне опрометчиво. В частности, может быть задействована RTTI, позволяющая выделить нужные свойства пакетов и манипулировать их значениями, но тема эта выходит за рамки статьи, посему какой-либо код показан не будет.
Видится полезным привести возможное JSON-представление ранее объявленного пакета
Физическая же транспортировка упакованного выполняется весьма просто – нужно лишь дополнить пару основных методов
Насколько же эффективно предложенное решение справляется с поставленными в начале проблемами? Если теперь взглянуть на код протокола, скажем клиентского, то его методы оперируют терминами из описания протокола, что позволяет довольно чётко и быстро находить соответствие между ними, а значит дальнейшее сопровождение потребует меньших усилий. Зависимость от сетевой библиотеки и данных локализована и вынесена в 3 модуля (отмечены цветом):
Благодаря этому, переход с Indy на нечто иное сейчас потребует изменить лишь 2 метода у
Как ни печально, но текущее решение, даже со всеми его плюсами, ещё не полностью подходит для практического применения, т. к. реальная жизнь обязывает учитывать прочие важные нюансы – для примера: со временем любое ПО меняется, улучшается, дорабатывается, что в полной мере относится и к его сетевой подсистеме, а значит станут появляться клиенты с отличиями в протоколе (далеко не все пользователи регулярно и с радостью обновляют свои приложения); реакция сервера может быть двоякой – либо отказывать в обслуживании клиентам с несвежим протоколом, либо поддерживать одновременную работу с различными его версиями. Ответ на этот и некоторые другие вопросы может быть дан во второй части статьи – в случае, если появится заинтересованность в поднятой теме (выразить которую предлагается личным сообщением, либо в комментарии).
Ценность публикации, как представляется автору, также в том, что иллюстрируется всё не на простейшем учебном и малосвязанном с реальностью примере, а на небольшой части реального решения из настолько же взаправдашнего мобильного приложения, ранее уже упоминавшегося в другой статье.
Нужно отметить, что в программном коде статьи используется Indy, однако, хотя это и может показаться странным в материале, посвящённом сетевому взаимодействию, как такового знания этой библиотеки от читателя не потребуется, ибо смысл – в знакомстве с более абстрактными, высокоуровневыми приёмами при реализации своего протокола – речь в большей степени о проектировании.
Постановка задачи на пальцах
Мобильное приложение, одна из функций которого – под названием синхронизация – и легла в основу статьи, представляет собой, говоря общо, список покупок: пользователь, создав перечень товаров, идёт с ним в магазин сам, либо поручает это дело другому человеку (или группе людей), но тогда, во втором случае, требуется передать этот список сначала на сервер (как централизованное хранилище), а затем уже на целевые мобильные устройства – как раз в эти моменты и появляется необходимость задействовать сеть; стоит подчеркнуть, что синхронизация является двусторонней, т. е. правка, сделанная любым участником (не обязательно автором), отразится и у всех других. В качестве наглядного примера, рассмотрим гипотетический случай, когда два амбициозных агронома-любителя решили осуществить одну из своих идей, для чего им понадобилось предварительно приобрести необходимый инструментарий:
Действие | Автор списка | Второй участник синхронизации |
---|---|---|
Создание списка |
|
|
Добавление участника и последующая синхронизация |
|
|
Правка содержимого |
|
|
Синхронизация |
|
|
Формализация
Таблица с примером выше – всего лишь набросок, самое поверхностное описание того, что должно происходить при синхронизации, поэтому его ни в коем разе нельзя использовать в качестве серьёзного ТЗ, а уж тем более писать код на таком шатком основании. Требуется полноценный протокол – детальное, поэтапное и исчерпывающее описание шагов по взаимодействию – кто, что и для чего пересылает по сети; по модели OSI он будет находиться на прикладном уровне (или, говоря иначе, уровне приложения). В качестве такого примера приводится небольшая часть реального документа, содержащая около 10% от всех действий (ось времени направлена вниз):
Клиент | Данные | Сервер |
---|---|---|
Определение списков для синхронизации | ||
… | ||
Синхронизация справочника товаров | ||
… | ||
Синхронизация пользователей | ||
… | ||
Синхронизация списков: | ||
1. Добавление на сервер | ||
… | ||
2. Добавление на клиента | ||
… | ||
3. Обмен изменениями | ||
Передача списков, требующих обмена изменениями; первый уровень иерархии. |
|
|
Анализ хешей: | ||
Уведомление о совпадении хешей. | 1. Все совпадают – конец синхронизации. | |
Конец синхронизации. | ||
Требование передать:
|
2. Хотя бы один не совпадает. | |
Передача затребованных данных. Второй уровень иерархии. |
|
|
… |
Решение в лоб
Транспорт
Перед реализацией протокола необходимо определиться с транспортом – протоколом того же, либо нижележащего уровня, ответственным за физическую пересылку данных; очевидных альтернатив две – HTTP и TCP (UDP, по вполне понятной причине – негарантированности доставки, здесь применяться не может). В конечном итоге выбор пал на второй вариант из-за двух причин: TCP, в силу своей бинарной природы, даёт полную свободу над всеми пересылаемыми данными и, на том же основании, имеет более высокую производительность, что в мобильном проекте идёт не на последнем месте.
Первый вариант кода
Выбрав транспорт, рассмотрим условную реализацию протокола на примере клиентской стороны, взяв за основу
TIdTCPClient
(на сервере никаких принципиальных отличий не возникнет – там лишь поменяется компонент на TIdTCPServer
). Сейчас и далее всё будет показываться на небольшой части из только что приведённого фрагмента:… | ||
3. Обмен изменениями | ||
Передача списков, требующих обмена изменениями; первый уровень иерархии. |
|
|
Анализ хешей: | ||
Уведомление о совпадении хешей. | 1. Все совпадают – конец синхронизации. | |
Конец синхронизации. | ||
… |
TForm1 = class(TForm)
TCPClient: TIdTCPClient;
ButtonSync: TButton;
StoredProcLists: TFDStoredProc;
procedure ButtonSyncClick(Sender: TObject);
end;
Приводимый ниже обработчик нажатия на кнопку содержит сильные упрощения и, как следствие, некоторые ошибки и компилироваться не будет, но суть начального, незамысловато-очевидного подхода передаёт:
procedure TForm1.ButtonSyncClick(Sender: TObject);
var
Handler: TIdIOHandler;
begin
TCPClient.Connect;
Handler := TCPClient.IOHandler;
// Определение списков для синхронизации
...
// Синхронизация справочника товаров
...
// Синхронизация пользователей
...
// Синхронизация списков:
// 1. Добавление на сервер
...
// 2. Добавление на клиента
...
// 3. Обмен изменениями
// Передача списков, требующих обмена изменениями.
StoredProcLists.Open;
Handler.Write(StoredProcLists.RecordCount);
while not StoredProcLists.Eof do
begin
Handler.Write( StoredProcLists.FieldByName('ListID').AsInteger );
Handler.Write( Length(StoredProcLists.FieldByName('ListHash').AsBytes) );
Handler.Write( StoredProcLists.FieldByName('ListHash').AsBytes );
Handler.Write( Length(StoredProcLists.FieldByName('ListUsersHash').AsBytes) );
Handler.Write( StoredProcLists.FieldByName('ListUsersHash').AsBytes );
Handler.Write( Length(StoredProcLists.FieldByName('ListItemsHash').AsBytes) );
Handler.Write( StoredProcLists.FieldByName('ListItemsHash').AsBytes );
StoredProcLists.Next;
end;
StoredProcLists.Close;
// Реакция на ответ сервера.
if Handler.ReadByte = 1 then // Конец синхронизации?
... // Да - прерываем выполнение.
else
... // Нет - дальнейшие шаги протокола.
TCPClient.Disconnect;
end;
Необходимо вновь подчеркнуть, что приведённый кусочек значительно облегчен по сравнению с оригинальным кодом из приложения – поэтому на самом деле это событие, для полного протокола, разрастётся до многих тысяч строк. Будь объём не таким (до нескольких сотен строк), то вполне допустимо просто-напросто разбить его на методы или локальные процедуры по основным этапам и остановиться на этом, но не в данном случае – масштаб привносит серьёзные проблемы:
- Замыливание, размытие структуры протокола, его логических этапов и передаваемых данных, что чрезвычайно затруднит доработки в будущем.
- Привязка к конкретной сетевой библиотеке (Indy): в случае её смены, потребуется серьёзная работа по скрупулёзному прочёсыванию всего объёма кода, которая чревата ошибками.
- Аналогичная привязка не просто к источнику данных (БД), но и на компоненты доступа к ним (FireDAC) – это влечёт ту же проблему.
Дальнейшее повествование предложит пути устранения этих недостатков.
Первое приближение
Протокол
Основная идея, помогающая бороться с объёмом и проистекающей из этого сложностью, не нова – это введение абстракций, отражающих «предметную область»: в данном случае таковой является сетевое взаимодействие, поэтому первым будет введено такое программное обобщение, как протокол; его реализация, как нетрудно догадаться, станет основываться на классах, которые в свою очередь сгруппируются по модулям, и первым из них будет
Net.Protocol
(размытые прочие добавятся по мере надобности):Так как термин протокол уже использовался, то для избежания путаницы та таблица, что выше, станет называться описанием протокола. Также нужно заметить, что пока все модули не делятся на клиентские и серверные – они являются безотносительными к стороне обмена, общими.
Первоначально протокол описывается довольно простым кодом:
unit Net.Protocol;
interface
uses
IdIOHandler;
type
TNetTransport = TIdIOHandler;
TNetProtocol = class abstract
protected
FTransport: TNetTransport;
public
constructor Create(const Transport: TNetTransport);
procedure RunExchange; virtual; abstract;
end;
implementation
constructor TNetProtocol.Create(const Transport: TNetTransport);
begin
FTransport := Transport;
end;
end.
Ключевой метод
RunExchange
предназначен для запуска сетевого обмена, т. е. всех тех шагов, что присутствуют в описании протокола. Конструктор же параметром принимает объект, отвечающий за непосредственно физическую доставку, – тот самый транспорт, в качестве которого, как было указано ранее, выступает TCP, представленный в данном случае компонентами Indy.Если теперь переписать самый первый вариант кода, то он станет весьма компактным (в нём
TClientProtocol
является наследником TNetProtocol
):procedure TForm1.ButtonSyncClick(Sender: TObject);
var
Protocol: TClientProtocol;
begin
TCPClient.Connect;
Protocol := TClientProtocol.Create(TCPClient.IOHandler);
try
Protocol.RunExchange;
finally
Protocol.Free;
end;
TCPClient.Disconnect;
end;
Такое видоизменение, конечно же, пока не решает ни одну из обозначенных проблем – это достигнется другими средствами.
Пакет
Вторая абстракция, которая станет использоваться уже? при реализации протокола, – это пакет данных (далее просто пакет) – именно на него ляжет ответственность за манипуляции с сетью. Если взглянуть на приводившийся фрагмент описания, то ему соответствуют 2 пакета (выделены цветом; первый из них отправляется клиентом, второй – сервером):
… | ||
3. Обмен изменениями | ||
Передача списков, требующих обмена изменениями; первый уровень иерархии. |
|
|
Анализ хешей: | ||
Уведомление о совпадении хешей. | 1. Все совпадают – конец синхронизации. | |
Конец синхронизации. | ||
… |
Net.Packet
:unit Net.Packet;
interface
uses
Net.Protocol;
type
TPacket = class abstract
public
type
TPacketKind = UInt16;
protected
FTransport: TNetTransport;
function Kind: TPacketKind; virtual; abstract;
public
constructor Create(const Transport: TNetTransport);
procedure Send;
procedure Receive;
end;
implementation
constructor TPacket.Create(const Transport: TNetTransport);
begin
FTransport := Transport;
end;
procedure TPacket.Send;
begin
FTransport.Write(Kind);
end;
procedure TPacket.Receive;
var
ActualKind: TPacketKind;
begin
ActualKind := FTransport.ReadUInt16;
if Kind <> ActualKind then
// Реакция на неожидаемый пакет.
...
end;
end.
Главными у пакета выступают 2 метода:
Send
– его использует отправитель, и Receive
– вызывается принимающей данные стороной; транспорт конструктор получает от протокола. Метод Kind
предназначен для идентификации конкретных пакетов-наследников и позволяет убедиться, что получен в точности ожидаемый.После обрисовки абстрактного пакета, определим пару тех, что непосредственно станут использоваться в приведённом выше описании протокола и содержать полезные данные, для чего объявим новый модуль:
unit Sync.Packets;
interface
uses
System.Generics.Collections,
Net.Packet;
type
TListHashesPacket = class(TPacket)
private
const
PacketKind = 1;
public
type
THashes = class
strict private
FHash: string;
FItemsHash: string;
FUsersHash: string;
public
property Hash: string read FHash write FHash;
property UsersHash: string read FUsersHash write FUsersHash;
property ItemsHash: string read FItemsHash write FItemsHash;
end;
TListHashes = TObjectDictionary<Integer, THashes>; // Ключ словаря - ID списка.
private
FHashes: TListHashes;
protected
function Kind: TPacket.TPacketKind; override;
public
property Hashes: TListHashes read FHashes write FHashes;
end;
TListHashesResponsePacket = class(TPacket)
private
const
PacketKind = 2;
private
FHashesMatched: Boolean;
protected
function Kind: TPacket.TPacketKind; override;
public
property HashesMatched: Boolean read FHashesMatched write FHashesMatched;
end;
// Прочие пакеты протокола.
...
implementation
function TListHashesPacket.Kind: TPacket.TPacketKind;
begin
Result := PacketKind;
end;
function TListHashesResponsePacket.Kind: TPacket.TPacketKind;
begin
Result := PacketKind;
end;
end.
Как можно заметить, ни эти 2 пакета, ни их предок,
TPacket
, не содержат кода, выполняющего отправку и приём данных, хранящихся в свойствах (Hashes
и HashesMatched
в данном случае), однако показ способа это обеспечить – дело ближайшего будущего, а пока предположим, что неким чудесным образом всё работает.Реализация протокола
Чтобы продемонстрировать, каким способом протокол использует пакеты, нужно ввести ещё 2 модуля – в этот раз делящиеся на клиентский и серверный, в отличие от всех предыдущих, – это
Sync.Protocol.Client
и Sync.Protocol.Server
:По их названию очевидно, реализацию какой из сторон они представляют.
unit Sync.Protocol.Client;
interface
uses
Net.Protocol;
type
TClientProtocol = class(TNetProtocol)
private
procedure SendListHashes;
function ListHashesMatched: Boolean;
...
public
procedure RunExchange; override;
end;
implementation
uses
Sync.Packets;
procedure TClientProtocol.RunExchange;
begin
inherited;
...
// 3. Обмен изменениями
SendListHashes;
if ListHashesMatched then // Конец синхронизации?
... // Да - прерываем выполнение.
else
... // Нет - дальнейшие шаги протокола.
end;
procedure TClientProtocol.SendListHashes;
var
ListHashesPacket: TListHashesPacket;
begin
ListHashesPacket := TListHashesPacket.Create(FTransport);
try
// Заполнение ListHashesPacket.Hashes данными из БД.
...
ListHashesPacket.Send;
finally
ListHashesPacket.Free;
end;
end;
function TClientProtocol.ListHashesMatched: Boolean;
var
ListHashesResponsePacket: TListHashesResponsePacket;
begin
ListHashesResponsePacket := TListHashesResponsePacket.Create(FTransport);
try
ListHashesResponsePacket.Receive;
Result := ListHashesResponsePacket.HashesMatched;
finally
ListHashesResponsePacket.Free;
end;
end;
end.
И парный модуль:
unit Sync.Protocol.Server;
interface
uses
Net.Protocol;
type
TServerProtocol = class(TNetProtocol)
private
function ListHashesMatched: Boolean;
...
public
procedure RunExchange; override;
end;
implementation
uses
Sync.Packets;
procedure TServerProtocol.RunExchange;
begin
inherited;
...
// 3. Обмен изменениями
if ListHashesMatched then // Конец синхронизации?
... // Да - прерываем выполнение.
else
... // Нет - дальнейшие шаги протокола.
end;
function TServerProtocol.ListHashesMatched: Boolean;
var
ClientListHashesPacket: TListHashesPacket;
ListHashesResponsePacket: TListHashesResponsePacket;
begin
ClientListHashesPacket := TListHashesPacket.Create(FTransport);
try
ClientListHashesPacket.Receive;
ListHashesResponsePacket := TListHashesResponsePacket.Create(FTransport);
try
// Сравнение ClientListHashesPacket.Hashes с хешами в БД,
// заполнение ListHashesResponsePacket.HashesMatched.
...
ListHashesResponsePacket.Send;
Result := ListHashesResponsePacket.HashesMatched;
finally
ListHashesResponsePacket.Free;
end;
finally
ClientListHashesPacket.Free;
end;
end;
end.
Финальный вариант
Предыдущие разделы лишь подготовили почву, создали каркас для решения обозначенных в самом начале проблем – теперь возможно приступить и к ним, начав с доступа к данным.
Данные
Только что, при реализации протоколов обеих сторон, встречался следующий код:
// Заполнение ListHashesPacket.Hashes данными из БД.
...
ListHashesPacket.Send;
а также
// Сравнение ClientListHashesPacket.Hashes с хешами в БД,
// заполнение ListHashesResponsePacket.HashesMatched.
...
ListHashesResponsePacket.Send;
Для замещения приведённых комментариев реальным кодом, предлагается применить такой шаблон проектирования, как фасад: вместо манипуляций с данными напрямую, на протокол ляжет лишь задача по вызову его высокоуровневых методов, реализующих все сколь угодно сложные и объёмные действия по общению с БД; для этого создадим модуль
Sync.DB
:unit Sync.DB;
interface
uses
FireDAC.Comp.Client;
type
TDBFacade = class abstract
protected
FConnection: TFDConnection;
public
constructor Create;
destructor Destroy; override;
procedure StartTransaction;
procedure CommitTransaction;
procedure RollbackTransaction;
end;
implementation
constructor TDBFacade.Create;
begin
FConnection := TFDConnection.Create(nil);
end;
destructor TDBFacade.Destroy;
begin
FConnection.Free;
inherited;
end;
procedure TDBFacade.StartTransaction;
begin
FConnection.StartTransaction;
end;
procedure TDBFacade.CommitTransaction;
begin
FConnection.Commit;
end;
procedure TDBFacade.RollbackTransaction;
begin
FConnection.Rollback;
end;
end.
Единственный объявленный здесь класс
TDBFacade
содержит 3 необходимых всем его наследникам метода для работы с транзакциями (с тривиальным кодом) и поле для физического соединения с БД – интересного мало, поэтому сразу рассмотрим реализацию клиентского и серверного фасадов, которые уже привнесут специфические для каждой из сторон методы:Клиентский фасад:
unit Sync.DB.Client;
interface
uses
Sync.DB, Sync.Packets;
type
TClientDBFacade = class(TDBFacade)
public
procedure CalcListHashes(const Hashes: TListHashesPacket.TListHashes);
...
end;
implementation
uses
FireDAC.Comp.Client;
procedure TClientDBFacade.CalcListHashes(const Hashes: TListHashesPacket.TListHashes);
var
StoredProcHashes: TFDStoredProc;
begin
StoredProcHashes := TFDStoredProc.Create(nil);
try
// Инициализация StoredProcHashes.
...
StoredProcHashes.Open;
while not StoredProcHashes.Eof do
begin
// Заполнение Hashes.
...
StoredProcHashes.Next;
end;
finally
StoredProcHashes.Free;
end;
end;
end.
И серверный:
unit Sync.DB.Server;
interface
uses
Sync.DB, Sync.Packets;
type
TServerDBFacade = class(TDBFacade)
public
function CompareListHashes(const ClientHashes: TListHashesPacket.TListHashes): Boolean;
...
end;
implementation
uses
FireDAC.Comp.Client;
function TServerDBFacade.CompareListHashes(const ClientHashes: TListHashesPacket.TListHashes): Boolean;
var
StoredProcHashes: TFDStoredProc;
begin
Result := True;
StoredProcHashes := TFDStoredProc.Create(nil);
try
// Инициализация StoredProcHashes.
...
StoredProcHashes.Open; // Получение серверных хешей.
while not StoredProcHashes.Eof do
begin
Result := Result and {Текущий серверный хеш совпадает с клиентским из ClientHashes?};
StoredProcHashes.Next;
end;
finally
StoredProcHashes.Free;
end;
end;
end.
Если читателю, на примере клиентского фасада, кажется, что метод
CalcListHashes
довольно прост и выносить в него из протокола всю работу с БД смысла почти нет, то рекомендуется сравнить представленное здесь сильное упрощение среальным кодом из приложения.
procedure TClientSyncDBFacade.CalcListHashes(const Hashes: TListHashesPacket.THashesCollection);
var
Lists: TList<TLocalListID>;
procedure PrepareListsToHashing;
begin
PrepareStoredProcedureToWork(SyncPrepareListsToHashingProcedure);
FStoredProcedure.Open;
while not FStoredProcedure.Eof do
begin
Lists.Add( FStoredProcedure['LIST_ID'] );
FStoredProcedure.Next;
end;
end;
procedure CalcTotalChildHashes;
var
ListID: TLocalListID;
TotalUsersHash, TotalItemsHash: TMD5Hash;
begin
for ListID in Lists do
begin
PrepareStoredProcedureToWork(SyncSelectListUsersForHashingProcedure);
FStoredProcedure.ParamByName('LIST_ID').Value := ListID;
TotalUsersHash := CalcTotalHashAsBytes( FStoredProcedure, ['USER_AS_STRING'] );
PrepareStoredProcedureToWork(SyncSelectListItemAndItemMessagesHashProcedure);
FStoredProcedure.ParamByName('LIST_ID').Value := ListID;
TotalItemsHash := CalcTotalHashAsBytes( FStoredProcedure, ['ITEM_HASH', 'ITEM_MESSAGES_HASH'] );
PrepareStoredProcedureToWork(SyncAddTotalListHashesProcedure);
FStoredProcedure.ParamByName('LIST_ID').Value := ListID;
FStoredProcedure.ParamByName('TOTAL_USERS_HASH').AsHash := TotalUsersHash;
FStoredProcedure.ParamByName('TOTAL_ITEMS_HASH').AsHash := TotalItemsHash;
FStoredProcedure.ExecProc;
end;
end;
procedure FillHashes;
var
ListHashes: TListHashesPacket.THashes;
begin
PrepareStoredProcedureToWork(SyncSelectListHashesProcedure);
FStoredProcedure.Open;
while not FStoredProcedure.Eof do
begin
ListHashes := TListHashesPacket.THashes.Create;
try
ListHashes.Hash := HashToString( FStoredProcedure.FieldByName('LIST_HASH').AsHash );
ListHashes.UsersHash := HashToString( FStoredProcedure.FieldByName('LIST_USERS_HASH').AsHash );
ListHashes.ItemsHash := HashToString( FStoredProcedure.FieldByName('LIST_ITEMS_HASH').AsHash );
except
ListHashes.DisposeOf;
raise;
end;
Hashes.Add( FStoredProcedure.FieldByName('LIST_GLOBAL_ID').AsUUID, ListHashes );
FStoredProcedure.Next;
end;
end;
begin
Lists := TList<TLocalListID>.Create;
try
PrepareListsToHashing;
CalcRecordHashes(TListHashes);
CalcRecordHashes(TListItemHashes);
CalcRecordHashes(TListItemMessagesHashes);
CalcTotalChildHashes;
FillHashes;
finally
Lists.DisposeOf;
end;
end;
Хотелось бы остановиться на одном нюансе: оба фасада импортируют модуль
Sync.Packets
и затем используют объявленные в нём пакеты – это создаёт сильное сцепление между ними, что в общем случае весьма нежелательно, т. к. фасад и пакеты предназначены для применения протоколом и знать друг о друге им совершенно незачем. Будь приложение крупным, над которым бы работало множество разработчиков, то сцепление просто необходимо было уменьшать, заменяя пакето-специфичные типы в методах фасада на другие, более общие, например «абстрактный перечень списков», но за всё это пришлось бы расплачиваться возросшей сложностью; текущий же компромисс вполне адекватно распределяет риск с учётом небольшого масштаба проекта.Окончательный вид протокола
После введения фасада, все методы протокола примут уже финальную, стабильную форму:
unit Sync.Protocol.Client;
interface
uses
Net.Protocol, Sync.DB.Client;
type
TClientProtocol = class(TNetProtocol)
private
FDBFacade: TClientDBFacade;
procedure SendListHashes;
...
public
procedure RunExchange; override;
end;
implementation
uses
Sync.Packets;
procedure TClientProtocol.RunExchange;
begin
inherited;
FDBFacade.StartTransaction;
try
...
// 3. Обмен изменениями
SendListHashes;
if ListHashesMatched then // Конец синхронизации?
... // Да - прерываем выполнение.
else
... // Нет - дальнейшие шаги протокола.
FDBFacade.CommitTransaction;
except
FDBFacade.RollbackTransaction;
raise;
end;
end;
procedure TClientProtocol.SendListHashes;
var
ListHashesPacket: TListHashesPacket;
begin
ListHashesPacket := TListHashesPacket.Create(FTransport);
try
FDBFacade.CalcListHashes(ListHashesPacket.Hashes);
ListHashesPacket.Send;
finally
ListHashesPacket.Free;
end;
end;
...
end.
unit Sync.Protocol.Server;
interface
uses
Net.Protocol, Sync.DB.Server;
type
TServerProtocol = class(TNetProtocol)
private
FDBFacade: TServerDBFacade;
function ListHashesMatched: Boolean;
...
public
procedure RunExchange; override;
end;
implementation
uses
Sync.Packets;
procedure TServerProtocol.RunExchange;
begin
inherited;
FDBFacade.StartTransaction;
try
...
// 3. Обмен изменениями
if ListHashesMatched then // Конец синхронизации?
... // Да - прерываем выполнение.
else
... // Нет - дальнейшие шаги протокола.
FDBFacade.CommitTransaction;
except
FDBFacade.RollbackTransaction;
raise;
end;
end;
function TServerProtocol.ListHashesMatched: Boolean;
var
ClientListHashesPacket: TListHashesPacket;
ListHashesResponsePacket: TListHashesResponsePacket;
begin
ClientListHashesPacket := TListHashesPacket.Create(FTransport);
try
ClientListHashesPacket.Receive;
ListHashesResponsePacket := TListHashesResponsePacket.Create(FTransport);
try
ListHashesResponsePacket.HashesMatched := FDBFacade.CompareListHashes(ClientListHashesPacket.Hashes);
ListHashesResponsePacket.Send;
Result := ListHashesResponsePacket.HashesMatched;
finally
ListHashesResponsePacket.Free;
end;
finally
ClientListHashesPacket.Free;
end;
end;
end.
Доработка пакета
Для завершения всей воздвигнутой конструкции, осталось вбить последний, но при этом весьма важный, гвоздь – научить пакет передавать полезную информацию, однако задачу эту удобно поделить на две составляющие (на примере отправки):
- сначала необходимо упаковать (сериализовать) данные в вид, подходящий к передаче по сети;
- затем следует выполнить физическую отправку упакованного.
Делать упаковку можно по-разному: в некий бинарный вид, в XML, в JSON и т. д. Поскольку мобильные устройства не обладают богатыми ресурсами, был избран именно последний, JSON-вариант, требующий меньших вычислительных затрат на обработку (по сравнению с XML); для реализации выбранного пути добавим 2 метода в
TPacket
:unit Net.Packet;
interface
uses
Net.Protocol, System.JSON;
type
TPacket = class abstract
...
private
function PackToJSON: TJSONObject;
procedure UnpackFromJSON(const JSON: TJSONObject);
...
end;
Их реализация не приведена, ибо возможны 2 пути: методы объявляются защищёнными и виртуальными и все пакеты-наследники индивидуально, в зависимости от добавленных в них свойств с данными, выполняют упаковку в JSON и распаковку из него, либо второй вариант – методы остаются приватными (как здесь) и содержат код по автоматическому преобразованию в JSON, что полностью избавляет отпрысков от «логистических» забот. Первый вариант допустим для случаев, когда количество пакетов и их сложность невелики (до десятка штук, со свойствами простейших типов), но если счёт идёт на бо?льшие величины – в проекте автора их 32, а сложность весьма высока, как к примеру у
такого пакета,
TListPacket = class(TStreamPacket)
public
type
TPhoto = class(TPackableObject)
strict private
FSortOrder: Int16;
FItemMessageID: TItemMessageID;
public
property ItemMessageID: TItemMessageID read FItemMessageID write FItemMessageID;
property SortOrder: Int16 read FSortOrder write FSortOrder;
end;
TPhotos = TStandardPacket.TPackableObjectDictionary<TMessagePhotoID, TPhoto>;
TMessage = class(TPackableObject)
strict private
FAuthor: TUserID;
FAddDate: TDateTime;
FText: string;
FListItemID: TListItemID;
public
property ListItemID: TListItemID read FListItemID write FListItemID;
property Author: TUserID read FAuthor write FAuthor;
property AddDate: TDateTime read FAddDate write FAddDate;
property Text: string read FText write FText;
end;
TMessages = TStandardPacket.TPackableObjectDictionary<TItemMessageID, TMessage>;
TListDescendant = class(TPackableObject)
strict private
FListID: TListID;
public
property ListID: TListID read FListID write FListID;
end;
TItem = class(TListDescendant)
strict private
FAddDate: TDateTime;
FAmount: TAmount;
FEstimatedPrice: Currency;
FExactPrice: Currency;
FStandardGoods: TID;
FInTrash: Boolean;
FUnitOfMeasurement: TID;
FStrikeoutDate: TDateTime;
FCustomGoods: TGoodsID;
public
property StandardGoods: TID read FStandardGoods write FStandardGoods;
property CustomGoods: TGoodsID read FCustomGoods write FCustomGoods;
property Amount: TAmount read FAmount write FAmount;
property UnitOfMeasurement: TID read FUnitOfMeasurement write FUnitOfMeasurement;
property EstimatedPrice: Currency read FEstimatedPrice write FEstimatedPrice;
property ExactPrice: Currency read FExactPrice write FExactPrice;
property AddDate: TDateTime read FAddDate write FAddDate;
property StrikeoutDate: TDateTime read FStrikeoutDate write FStrikeoutDate;
property InTrash: Boolean read FInTrash write FInTrash;
end;
TItems = TStandardPacket.TPackableObjectDictionary<TListItemID, TItem>;
TUser = class(TListDescendant)
strict private
FUserID: TUserID;
public
property UserID: TUserID read FUserID write FUserID;
end;
TUsers = TStandardPacket.TPackableObjectList<TUser>;
TList = class(TPackableObject)
strict private
FName: string;
FAuthor: TUserID;
FAddDate: TDateTime;
FDeadline: TDate;
FInTrash: Boolean;
public
property Author: TUserID read FAuthor write FAuthor;
property Name: string read FName write FName;
property AddDate: TDateTime read FAddDate write FAddDate;
property Deadline: TDate read FDeadline write FDeadline;
property InTrash: Boolean read FInTrash write FInTrash;
end;
TLists = TStandardPacket.TPackableObjectDictionary<TListID, TList>;
private
FLists: TLists;
FMessages: TMessages;
FItems: TItems;
FUsers: TUsers;
FPhotos: TPhotos;
public
property Lists: TLists read FLists write FLists;
property Users: TUsers read FUsers write FUsers;
property Items: TItems read FItems write FItems;
property Messages: TMessages read FMessages write FMessages;
property Photos: TPhotos read FPhotos write SetPhotos;
end;
то без автоматизации процесса упаковки обходиться уже крайне опрометчиво. В частности, может быть задействована RTTI, позволяющая выделить нужные свойства пакетов и манипулировать их значениями, но тема эта выходит за рамки статьи, посему какой-либо код показан не будет.
Видится полезным привести возможное JSON-представление ранее объявленного пакета
TListHashesPacket
, чтобы помочь читателю окончательно понять соответствие между исходным классом и его сериализованным видом:{
16:
{
Hash: "d0860029f1400147deef86d3246d29a4",
UsersHash: "77febf816dac209a22880c313ffae6ad",
ItemsHash: "1679091c5a880faf6fb5e6087eb1b2dc"
},
38:
{
Hash: "81c8061686c10875781a2b37c398c6ab",
UsersHash: "d3556bff1785e082b1508bb4e611c012",
ItemsHash: "0e3a37aa85a14e359df74fa77eded3f6"
}
}
Физическая же транспортировка упакованного выполняется весьма просто – нужно лишь дополнить пару основных методов
TPacket
:unit Net.Packet;
interface
...
implementation
uses
System.SysUtils, IdGlobal;
...
procedure TPacket.Send;
var
DataLength: Integer;
RawData: TBytes;
JSON: TJSONObject;
begin
FTransport.Write(Kind);
JSON := PackToJSON;
try
SetLength(RawData, JSON.EstimatedByteSize);
DataLength := JSON.ToBytes( RawData, Low(RawData) );
FTransport.Write(DataLength);
FTransport.Write( TIdBytes(RawData), DataLength );
finally
JSON.Free;
end;
end;
procedure TPacket.Receive;
var
ActualKind: TPacketKind;
DataLength: Integer;
RawData: TBytes;
JSON: TJSONObject;
begin
ActualKind := FTransport.ReadUInt16;
if Kind <> ActualKind then
// Реакция на неожидаемый пакет.
...
DataLength := FTransport.ReadInt32;
FTransport.ReadBytes( TIdBytes(RawData), DataLength, False );
JSON := TJSONObject.Create;
try
JSON.Parse(RawData, 0);
UnpackFromJSON(JSON);
finally
JSON.Free;
end;
end;
...
end.
Заключение
Насколько же эффективно предложенное решение справляется с поставленными в начале проблемами? Если теперь взглянуть на код протокола, скажем клиентского, то его методы оперируют терминами из описания протокола, что позволяет довольно чётко и быстро находить соответствие между ними, а значит дальнейшее сопровождение потребует меньших усилий. Зависимость от сетевой библиотеки и данных локализована и вынесена в 3 модуля (отмечены цветом):
Благодаря этому, переход с Indy на нечто иное сейчас потребует изменить лишь 2 метода у
TPacket
– Send
и Receive
, а замена FireDAC у одной из сторон (или вообще отказ от базы данных как хранилища) отразится только на методах фасада, абсолютно не требуя правки самого протокола.Как ни печально, но текущее решение, даже со всеми его плюсами, ещё не полностью подходит для практического применения, т. к. реальная жизнь обязывает учитывать прочие важные нюансы – для примера: со временем любое ПО меняется, улучшается, дорабатывается, что в полной мере относится и к его сетевой подсистеме, а значит станут появляться клиенты с отличиями в протоколе (далеко не все пользователи регулярно и с радостью обновляют свои приложения); реакция сервера может быть двоякой – либо отказывать в обслуживании клиентам с несвежим протоколом, либо поддерживать одновременную работу с различными его версиями. Ответ на этот и некоторые другие вопросы может быть дан во второй части статьи – в случае, если появится заинтересованность в поднятой теме (выразить которую предлагается личным сообщением, либо в комментарии).
Комментарии (5)
Zakyann
30.08.2017 13:22Мобильное приложение, одна из функций которого – под названием синхронизация – и легла в основу статьи, представляет собой, говоря общо, список покупок: пользователь, создав перечень товаров, идёт с ним в магазин сам, либо поручает это дело другому человеку (или группе людей), но тогда, во втором случае, требуется передать этот список сначала на сервер (как централизованное хранилище), а затем уже на целевые мобильные устройства – как раз в эти моменты и появляется необходимость задействовать сеть; стоит подчеркнуть, что синхронизация является двусторонней, т. е. правка, сделанная любым участником (не обязательно автором), отразится и у всех других.
Выбрасываем все протоколы, всю обвязку, делаем базу-сервер + несколько форм (мобил, десктоп). Обновление сервер > клиент либо по таймеру, либо по каким-то событиям, либо по кнопке. Обновление клиент > сервер сделают сами компоненты. День работы с минимумом кода.SergeyPyankov Автор
30.08.2017 14:29Насколько понимаю, Вы предлагаете полностью веб-решение, когда приложению (в том числе мобильному) отводится всего лишь роль контейнера для браузера; для несложных случаев — почему бы нет, но у синхронизации (да и всего проекта в целом) требования довольно разнообразны и специфичны (например, работа над списками без доступа к сети).
Zakyann
30.08.2017 18:32Я предлагаю для описанного, процитированного, случая.
например, работа над списками без доступа к сети.
localhost в помощь. На самом деле — очень удобная библиотека, посмотрите, может так статься, и скорее всего, описанные в статье протоколы вообще будут не нужны.
Zakyann
Спасибо за статью. Рекомендую посмотреть в сторону UniGUI
SergeyPyankov Автор
Не могли бы Вы уточнить, почему упомянули UniGUI? Её связь со статьёй довольно слаба — разве что эта библиотека использует в качестве транспорта HTTP, озвученный здесь в таком же качестве.