Доводилось ли Вам реализовывать объёмный сетевой обмен посредством TCP- или HTTP-протокола? Насколько, в случае такого опыта, Вы были удовлетворены сопровождаемостью конечного решения? Утвердительный ответ на первый вопрос (пусть даже и без «объёмистости» обмена) и недовольство гибкостью получившейся реализации позволяют рекомендовать эту статью как содержащую один из способов избавления от такого несчастья.

Ценность публикации, как представляется автору, также в том, что иллюстрируется всё не на простейшем учебном и малосвязанном с реальностью примере, а на небольшой части реального решения из настолько же взаправдашнего мобильного приложения, ранее уже упоминавшегося в другой статье.

Нужно отметить, что в программном коде статьи используется Indy, однако, хотя это и может показаться странным в материале, посвящённом сетевому взаимодействию, как такового знания этой библиотеки от читателя не потребуется, ибо смысл – в знакомстве с более абстрактными, высокоуровневыми приёмами при реализации своего протокола – речь в большей степени о проектировании.

Постановка задачи на пальцах


Мобильное приложение, одна из функций которого – под названием синхронизация – и легла в основу статьи, представляет собой, говоря общо, список покупок: пользователь, создав перечень товаров, идёт с ним в магазин сам, либо поручает это дело другому человеку (или группе людей), но тогда, во втором случае, требуется передать этот список сначала на сервер (как централизованное хранилище), а затем уже на целевые мобильные устройства – как раз в эти моменты и появляется необходимость задействовать сеть; стоит подчеркнуть, что синхронизация является двусторонней, т. е. правка, сделанная любым участником (не обязательно автором), отразится и у всех других. В качестве наглядного примера, рассмотрим гипотетический случай, когда два амбициозных агронома-любителя решили осуществить одну из своих идей, для чего им понадобилось предварительно приобрести необходимый инструментарий:
Действие Автор списка Второй участник
синхронизации
Создание списка
  1. Ведро
  2. Лейка
  3. Манка 0,5 кг
Добавление участника
и последующая
синхронизация
  1. Ведро
  2. Лейка
  3. Манка 0,5 кг
  1. Ведро
  2. Лейка
  3. Манка 0,5 кг
Правка содержимого
  1. Ведро
  2. Лейка
  3. Манка 0,5 кг
  4. Грабли
  1. Ведро 2 шт
  2. Лейка
  3. Манка 1 кг
Синхронизация
  1. Ведро 2 шт
  2. Лейка
  3. Манка 1 кг
  4. Грабли
  1. Ведро 2 шт
  2. Лейка
  3. Манка 1 кг
  4. Грабли
Визуально, на устройстве, весь процесс синхронизации представлен анимированным индикатором и кнопкой отмены:

Процесс синхронизации на устройстве

Формализация


Таблица с примером выше – всего лишь набросок, самое поверхностное описание того, что должно происходить при синхронизации, поэтому его ни в коем разе нельзя использовать в качестве серьёзного ТЗ, а уж тем более писать код на таком шатком основании. Требуется полноценный протокол – детальное, поэтапное и исчерпывающее описание шагов по взаимодействию – кто, что и для чего пересылает по сети; по модели OSI он будет находиться на прикладном уровне (или, говоря иначе, уровне приложения). В качестве такого примера приводится небольшая часть реального документа, содержащая около 10% от всех действий (ось времени направлена вниз):
Клиент Данные Сервер
Определение списков для синхронизации
Синхронизация справочника товаров
Синхронизация пользователей
Синхронизация списков:
1. Добавление на сервер
2. Добавление на клиента
3. Обмен изменениями
Передача списков, требующих обмена изменениями; первый уровень иерархии.
  1. ID списка
  2. Хеш списка
  3. Хеш его потомков:
    • Пользователей
    • Элементов
Анализ хешей:
Уведомление о совпадении хешей. 1. Все совпадают – конец синхронизации.
Конец синхронизации.
Требование передать:
  1. Изменённые на клиенте поля списка – в случае несовпадения его хеша.
  2. Подробности по прямым потомкам – в случае отличия хеша потомков.

2. Хотя бы один не совпадает.
Передача затребованных данных. Второй уровень иерархии.
  1. Изменённые поля списка (если требуется)
  2. Детализация по его пользователям (если требуется):
    • ID
    • Хеш (если не удалён)
    • Добавлен (только для своего авторства)?
    • Удалён (только для своего авторства)?
  3. Детализация по его элементам (если требуется):
    • ID
    • Хеш (если не удалён)
    • Хеш его потомков – чата (если не удалён).
    • Удалён?
Для дальнейшего понимания не требуется вникать во все нюансы приведённого фрагмента протокола – главное уяснить, что есть действия на клиентской стороне (левый столбец), есть данные (средний столбец), полученные в результате выполнения действий одной из сторон, которые, собственно говоря, и нужно передать по сети, и есть серверная сторона (правый столбец), выполняющая некий анализ и прочую работу.

Решение в лоб


Транспорт


Перед реализацией протокола необходимо определиться с транспортом – протоколом того же, либо нижележащего уровня, ответственным за физическую пересылку данных; очевидных альтернатив две – HTTP и TCP (UDP, по вполне понятной причине – негарантированности доставки, здесь применяться не может). В конечном итоге выбор пал на второй вариант из-за двух причин: TCP, в силу своей бинарной природы, даёт полную свободу над всеми пересылаемыми данными и, на том же основании, имеет более высокую производительность, что в мобильном проекте идёт не на последнем месте.

Первый вариант кода


Выбрав транспорт, рассмотрим условную реализацию протокола на примере клиентской стороны, взяв за основу TIdTCPClient (на сервере никаких принципиальных отличий не возникнет – там лишь поменяется компонент на TIdTCPServer). Сейчас и далее всё будет показываться на небольшой части из только что приведённого фрагмента:
3. Обмен изменениями
Передача списков, требующих обмена изменениями; первый уровень иерархии.
  1. ID списка
  2. Хеш списка
  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 (размытые прочие добавятся по мере надобности):

Новый модуль 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. ID списка
  2. Хеш списка
  3. Хеш его потомков:
    • Пользователей
    • Элементов
Анализ хешей:
Уведомление о совпадении хешей. 1. Все совпадают – конец синхронизации.
Конец синхронизации.
Код пакета тоже прост и выделен в новый модуль Net.Packet:

Новый модуль 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 предназначен для идентификации конкретных пакетов-наследников и позволяет убедиться, что получен в точности ожидаемый.

После обрисовки абстрактного пакета, определим пару тех, что непосредственно станут использоваться в приведённом выше описании протокола и содержать полезные данные, для чего объявим новый модуль:

Новый модуль Sync.Packets

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:

Новые модули 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:

Новый модуль 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 необходимых всем его наследникам метода для работы с транзакциями (с тривиальным кодом) и поле для физического соединения с БД – интересного мало, поэтому сразу рассмотрим реализацию клиентского и серверного фасадов, которые уже привнесут специфические для каждой из сторон методы:

Новые модули Sync.DB.Client и Sync.DB.Server

Клиентский фасад:

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 модуля (отмечены цветом):

Модуль Net.Packet, а также Sync.DB.Client и Sync.DB.Server

Благодаря этому, переход с Indy на нечто иное сейчас потребует изменить лишь 2 метода у TPacketSend и Receive, а замена FireDAC у одной из сторон (или вообще отказ от базы данных как хранилища) отразится только на методах фасада, абсолютно не требуя правки самого протокола.

Как ни печально, но текущее решение, даже со всеми его плюсами, ещё не полностью подходит для практического применения, т. к. реальная жизнь обязывает учитывать прочие важные нюансы – для примера: со временем любое ПО меняется, улучшается, дорабатывается, что в полной мере относится и к его сетевой подсистеме, а значит станут появляться клиенты с отличиями в протоколе (далеко не все пользователи регулярно и с радостью обновляют свои приложения); реакция сервера может быть двоякой – либо отказывать в обслуживании клиентам с несвежим протоколом, либо поддерживать одновременную работу с различными его версиями. Ответ на этот и некоторые другие вопросы может быть дан во второй части статьи – в случае, если появится заинтересованность в поднятой теме (выразить которую предлагается личным сообщением, либо в комментарии).

Комментарии (5)


  1. Zakyann
    29.08.2017 11:11

    Спасибо за статью. Рекомендую посмотреть в сторону UniGUI


    1. SergeyPyankov Автор
      29.08.2017 11:18

      Не могли бы Вы уточнить, почему упомянули UniGUI? Её связь со статьёй довольно слаба — разве что эта библиотека использует в качестве транспорта HTTP, озвученный здесь в таком же качестве.


  1. Zakyann
    30.08.2017 13:22

    Мобильное приложение, одна из функций которого – под названием синхронизация – и легла в основу статьи, представляет собой, говоря общо, список покупок: пользователь, создав перечень товаров, идёт с ним в магазин сам, либо поручает это дело другому человеку (или группе людей), но тогда, во втором случае, требуется передать этот список сначала на сервер (как централизованное хранилище), а затем уже на целевые мобильные устройства – как раз в эти моменты и появляется необходимость задействовать сеть; стоит подчеркнуть, что синхронизация является двусторонней, т. е. правка, сделанная любым участником (не обязательно автором), отразится и у всех других.


    Выбрасываем все протоколы, всю обвязку, делаем базу-сервер + несколько форм (мобил, десктоп). Обновление сервер > клиент либо по таймеру, либо по каким-то событиям, либо по кнопке. Обновление клиент > сервер сделают сами компоненты. День работы с минимумом кода.


    1. SergeyPyankov Автор
      30.08.2017 14:29

      Насколько понимаю, Вы предлагаете полностью веб-решение, когда приложению (в том числе мобильному) отводится всего лишь роль контейнера для браузера; для несложных случаев — почему бы нет, но у синхронизации (да и всего проекта в целом) требования довольно разнообразны и специфичны (например, работа над списками без доступа к сети).


      1. Zakyann
        30.08.2017 18:32

        Я предлагаю для описанного, процитированного, случая.

        например, работа над списками без доступа к сети.

        localhost в помощь. На самом деле — очень удобная библиотека, посмотрите, может так статься, и скорее всего, описанные в статье протоколы вообще будут не нужны.