Доводилось ли Вам реализовывать объёмный сетевой обмен посредством 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 у одной из сторон (или вообще отказ от базы данных как хранилища) отразится только на методах фасада, абсолютно не требуя правки самого протокола.Как ни печально, но текущее решение, даже со всеми его плюсами, ещё не полностью подходит для практического применения, т. к. реальная жизнь обязывает учитывать прочие важные нюансы – для примера: со временем любое ПО меняется, улучшается, дорабатывается, что в полной мере относится и к его сетевой подсистеме, а значит станут появляться клиенты с отличиями в протоколе (далеко не все пользователи регулярно и с радостью обновляют свои приложения); реакция сервера может быть двоякой – либо отказывать в обслуживании клиентам с несвежим протоколом, либо поддерживать одновременную работу с различными его версиями. Ответ на этот и некоторые другие вопросы может быть дан во второй части статьи – в случае, если появится заинтересованность в поднятой теме (выразить которую предлагается личным сообщением, либо в комментарии).
