SOAP - это просто

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


Как ни странно, но SOAP (Simple Object Access Protocol, кросс-платформенная, кросс-языковая технология запуска объектов) - это действительно просто, хотя когда я только начинал с ним работать на Delphi, никак не мог понять с какой стороны к нему подступиться. В действительности, при проектировании SOAP приложений необходимо выполнять совсем немного условий, после чего все будет прекрасно работать, и эти простые условия я и постараюсь тут рассмотреть.

Прежде всего, а зачем нужен этот самый SOAP? Основных плюса два: SOAP является public стандартом межпрограммного взаимодействия, и клиенту не надо ничего знать о сервере - ни о его языке, ни о платформе; SOAP интерфейсы являются самодокументирующимися, т.е. сервер обязан предоставить клиенту подробное описание интерфейса, его функций, входных и выходных параметров.

Основное условие при программировании SOAP: сервер должен быть stateless, т.е. результат выполнения запроса не должен зависеть от предыдущих команд, полученных сервером. Это означает, что все параметры сессии должны храниться на клиенте и передаваться серверу в составе запроса (если необходимо). Этим обеспечивается высокая устойчивость и масштабируемость системы (клиент может быть переключен на другой сервер, даже не подозревая об этом), хотя ряд вкусностей обычной двухзвенки становится недоступным:

Замечание: чтобы запустить SOAP приложение, прежде всего нужен Web-сервер, если его у вас нет - дальше можете не читать. Для отладки можно воспользоваться WebAppDebugger из меню Tools, тогда серверную часть надо создавать как WebAppDebugger Executable (смиже, о работе с WebAppDebugger - см. документацию к Delphi)
Примечание от Ivan Babikov: можно найти в каталоге Indy IdHTTPWebBrokerBridge.pas, научиться делать SOAP executable и читать дальше

Простое SOAP-приложение

Подобные примеры рассмотрены в любой литературе, посвященной разработке SOAP на Delphi.
Запустите Delphi и выберите в меню File | New | Other..., перейдите на закладку WebServices и выберите Soap Server Application.

Вам будет предложено на выбор 5 вариантов:

Выберите CGI Stand-alone Executable, как наиболее простой для отладки формат, потом приложение можно будет легко преобразовать в любой другой. Вся хитрость в том, что если вся логика приложения сосредоточена в написанных Вами модулях, то Вы просто создаете новое приложение нужного типа, подключаете к ниму свои модули, и оно работает!

После того, как Вы нажмете ОК, будет сгенерировано новое приложение, содержащее WebModule с тремя компонентами:

Сохраните созданное приложение, это будет скелет нашего сервера.

Примечание: если заменить THTTPSoapDispatcher и THTTPSoapPascalInvoker компонентами, поддерживающими другой транспортный механизм, отличный от HTTP, то можно заставить работать приложение, например, с обменом по e-mail.

Странность: позднее я заметил, что WebModule, создаваемый для WebAppDebugger, немного отличается от остальных вариантов приложений строками

uses WebReq;
 
initialization
  WebRequestHandler.WebModuleClass := TWebModule2;

в чем тут дело я не разбирался, но без них приложние под WebAppDebugger-ом не работает.


Займемся наполнением его логикой. Поскольку и серверу и клиенту потребуются описания структур передаваемых данных и интерфейсов, то лучше их вынести в отдельный модуль, а всю серверную реализацию - в другой. Для этого создайте два модуля (File | New | Unit) и сохраните один из них под именем CentimeterInchIntf.pas, а другой - CentimeterInchImpl.pas. Внутри CentimeterInchIntf.pas наберите следующее:

  unit CentimeterInchIntf;
  interface
  uses
    Types;
 
  type
    ICmInch = interface(IInvokable)
      ['{C53E42A9-8488-4521-BCB4-60863FF09E83}']
      function Cm2Inch(Inch: Double): Double; stdcall;
      function Inch2Cm(Cm: Double): Double; stdcall;
    end;
 
  implementation
  uses
    InvokeRegistry;
 
  initialization
    InvRegistry.RegisterInterface(TypeInfo(ICmInch));
  end.

Таким образом мы определили интерфейс ICmInch, предоставляющий две функции: преобразования сантиметров в дюймы и дюймов в сантиметры, и зарегистрировали его в InvokeRegistry.

Примечание: строку ['{C53E42A9-8488-4521-BCB4-60863FF09E83}'] - GUID нашего сервера, не надо копировать из этого примера, а надо сгенерировать в редакторе Delphi нажатием Ctrl-Shift-G


Разберемся с реализацией. В CentimeterInchImpl.pas определим потомка TInvokableClass, реализующего наш интерфейс ICmInch:

  unit CentimeterInchImpl;
  interface
  uses
    CentimeterInchIntf, InvokeRegistry;
 
  type
    TCmInch = class(TInvokableClass, ICmInch)
    public
      function Cm2Inch(Inch: Double): Double; stdcall;
      function Inch2Cm(Cm: Double): Double; stdcall;
    end;
 
  implementation
  const
    CmPerInch = 2.54;
 
  function TCmInch.Cm2Inch(Inch: Double): Double;
  begin
    Result := Inch / CmPerInch
  end;
 
  function TCmInch.Inch2Cm(Cm: Double): Double;
  begin
    Result := Cm * CmPerInch
  end;
 
  initialization
    InvRegistry.RegisterInvokableClass(TCmInch)
  end.

Как видите, в TCmInch мы реализовали обе функции интерфейса ICmInch, и также зарегистрировали наш новый invokable class в InvokeRegistry (вообще, все что будет передаваться по сети надо в нем регистрировать, за исключением скалярных типов).

Скомпилируем наше приложение и поместим полученный EXE-файл в каталог, откуда Web-сервер может запускать скрипты, например, /cgi-bin/, и обратимся к нему из браузера:
    http://localhost/cgi-bin/MyWebService.exe/wsdl
В случае WebAppDebugger путь будет выглядеть так:
    http://localhost:1024/MyWebService.exe/wsdl
Обратите внимание на дополнительный PathInfo в конце нашего URL. Должно получиться примерно следующее:

WebService Listing

Port Type

Namespace URI

Documentation

WSDL

IWSDLPublish

urn:WSDLPub-IWSDLPublish

 

WSDL for IWSDLPublish

ICmInch

urn:CentimeterInchIntf-ICmInch

 

WSDL for ICmInch

Если нажать на ссылку WSDL for ICmInch, то мы получим полное WSDL описание нашего интерфейса.


Клиент

Создайте новое приложение (обычного типа), укажите в секции uses наш интерфейсный модуль CentimeterInchIntf, поместите на главную форму две кнопки, два поля ввода и компонент THTTPRIO с палитры WebServices.

У компонента HTTPRIO1 в свойстве WSDLLocation укажите путь к WSDL вашего сервиса (например, http://localhost/cgi-bin/MyWebService.exe/wsdl/ICmInch), затем из выпадающего списка у свойства Service выберите ICmInchService, а у Port - ICmInchPort (если выпадающие списки пустые, значит что-то не работает...). После этого в обработчиках кнопок OnClick напишите:

  procedure TForm1.btnCm2InchClick(Sender: TObject);
  var
    Cm: Double;
  begin
    Cm := StrToFloatDef(edCm.Text,0);
    edInch.Text := FloatToStr((HTTPRIO1 as ICmInch).Cm2Inch(Cm))
  end;
 
  procedure TForm1.btnInch2CmClick(Sender: TObject);
  var
    Inch: Double;
  begin
    Inch := StrToFloatDef(edInch.Text,0);
    edCm.Text := FloatToStr((HTTPRIO1 as ICmInch).Inch2Cm(Inch))
  end;

Теперь после компиляции и запуска приложения можно преобразовывать сантиметры в дюймы и наоборот.

Q: А что делать, если SOAP сервер написан кем-то другим и у нас нет интерфейсного модуля?
A: Тогда надо воспользоваться Web Service Importer, который находится в меню File | New | Other..., на закладке WebServices. Этот мастер сгенерирует интерфейсный модуль по WSDL сервиса.

Передача сложных типов

Наш компонент THTTPSOAPPascalInvoker уже знает как пересылать скалярные типы и динамические массивы (последние надо предварительно зарегистрировать в InvokeRegistry, см. ниже), однако для пересылки сложных типов, таких как static array, interface, record, set или class, необходимо сначала описать их как потомков класса TRemotable, обладающего RunTime Type Information (RTTI). Например, если мы хотим объявить класс, возвращающий курс валюты и ее наименование, то наш интерфейсный модуль будет выглядеть так:

  unit CurrencyIntf;
  interface
  uses
    InvokeRegistry;
 
  type
    TCurrency = class(TRemotable)
    private
      FExchangeRate: double;
      FName: string;
    public
      property ExchangeRate: Double read FExchangeRate write FExchangeRate;
      property Name: string read FName write FName;
    end;
 
  TCurrencyArray = array of TCurrency;
 
  implementation
  initialization
    RemClassRegistry.RegisterXSClass(TCurrency);
    RemClassRegistry.RegisterXSInfo(TypeInfo(TCurrencyArray));
  end.

Здесь мы дополнительно объявили динамический массив TCurrencyArray, на случай если захотим его передавать (обратите внимание на различие в командах регистрации класса и массива).
На самом деле полный синтаксис команды регистрации класса несколько шире, желающие могут прочитать о нем в документации к Delphi:

RemClassRegistry.RegisterXSClass(TXSDateTime, XMLSchemaNameSpace, 'dateTime', True);

Замечание: если имеется тип, который в WSDL документе является скалярным, но не имеет прямого соответствия в Object Pascal (например, DateTime) то в качестве базового класса следует использовать TRemotableXS, который объявляет два метода XSToNative и NativeToXS для преобразования строкового представления в Object Pascal и обратно (эти методы надо, естественно, реализовать).
В составе Delphi поставляется модуль XSBuiltIns, в котором уже реализовано много полезных функций (однако в версии 6.0 там были ошибки в обработке даты, если национальные настройки в системе были не английские).

Возникает интересный вопрос с созданием-уничтожением объектов, передаваемых в качестве параметров. Вот что об этом говорится в документации к TRemotable:
"Со стороны сервера потомки TRemotable, являющиеся входными параметрами, автоматически создаются при распаковке (unmarshal) вызова метода, и автоматически уничтожаются после упаковки (marshal) выходных параметров для передачи клиенту.
Потомки TRemotable, созданные внутри метода, вызванного через invokable interface, автоматически уничтожаются после того как их значение упаковано (marshal) для передачи клиенту.
Клиент, вызывающий invokable interface, отвечает за создание объектов, используемых как входные параметры и за уничтожение всех потомков TRemotable, которые он создал, а также полученных в результате вызова метода."

Передача Dataset-а

Здесь все совсем просто. Находясь в проекте Soap Server Application, выберите в меню File | New | Other..., перейдите на закладку WebServices и выберите Soap Server Data Module. Дальнейшее не отличается от разработки обычного MIDAS приложения, с двумя особенностями: сервер обязан быть stateless - получил запрос, ответил и забыл (например, CGI модуль в буквальном смысле завершается после каждого вызова), и иметь не более одного SoapDataModule.
Поместите на полученный модуль компоненты доступа к данным (например, TClientDataset), установите у них все необходимые для работы свойства. Поместите TDataSetProvider, соедините его с компонентом доступа к данным.

Скомпиллируйте приложение и положите его туда, где оно может быть запущено Web-сервером (почему-то мне не удалось запустить его под WebAppDebugger, вероятно я использовал не тот WebModule, см. замечание выше).

В клиентском приложении поместите на форму TSoapConnection и TClientDataset, в SoapConnection.URL укажите путь к интерфейсу вашего сервера:

http://localhost/cgi-bin/CGIProject1.exe/soap/ISOAPDataMod42

можно использовать конкретный интерфейс SoapDataModule, а можно и более общий - IAppServer. В TClientDataset.RemoteServer укажите на TSoapConnection. Теперь, поставив TClientDataset.Active:=true, получим наши данные на клиента.

Если для отрытия датасета на сервере ему требуются какие-то параметры, то удобно будет вместо установки Active:=true использовать запрос DataRequest (напомню, что для SOAP приложений все параметры должны передаваться в составе единого запроса, т.е. нельзя сначала установить параметры, а потом запросить данные, т.к. с большой вероятностью второй запрос уйдет к другому экземпляру сервера). Это выглядит так.
На клиенте:

  procedure TForm1.Button1Click(Sender: TObject);
  begin
    Screen.Cursor:=crSQLWait;
    try
    BiolifeCDS.Data := BiolifeCDS.DataRequest(NeedOrderNum);
    finally
    Screen.Cursor:=crDefault;
    end;
  end;

На сервере:

  function TSOAPDataMod42.dspBiolifeDataRequest(Sender: TObject;
    Input: OleVariant): OleVariant;
  begin
    with (Sender as TDataSetProvider)  do
    begin
      Query1.ParamByName('Num').AsInteger:=Input;
      Query1.Open;
      Result := Data;
    end;
  end;

т.е. серверу можно передать любые данные (передаваемый тип - OleVariant) и получить назад данные опять же любого типа, например datapacket, как в приведенном случае.

Если вы изменили данные на клиенте и хотите их сохранить на сервер, то есть несколько способов это сделать. Самый простой - установить TDataSetProvider.ResolveToDataSet:=false и вызвать у TClientDataset метод ApplyUpdates. Запросы обновления пусть формирует сам TDataSetProvider, а контроль (довольно слабый, однако) за формированием этих запросов можно осуществлять с помощью свойств TField.ProviderFlags.

Другой способ: установить TDataSetProvider.ResolveToDataSet:=true, однако в этом случае в событии TDataSetProvider.OnBeforeApplyUpdates придется открыть связанный датасет, так чтобы в него была загружена та запись, которую собираетесь изменять. Зато теперь можно задействовать методы датасета BeforeInsert-BeforePost.

И последний вариант: воспользоваться своим собственным методом, добавленным к интерфейсу, как это ранее было проделано с Cm2Inch, и передавать ему ClientDataset.Delta, или набор инструкций для обновления, или то что подсказывает Ваша фантазия разработчика. Например:

  procedure TForm1.SendButtonClick(Sender: TObject);
  var
    X: ISOAPDataMod42;
  begin
    Screen.Cursor:=crSQLWait;
    try
    X:=httprio1 as ISOAPDataMod42;
    cdsErrors.Data:=X.SaveChanges(BiolifeCDS.Delta);
    if cdsErrors.Active and (cdsErrors.RecordCount>0) 
      then raise Exception.Create('Ошибка!')
      else BiolifeCDS.MergeChangeLog;
    finally
    Screen.Cursor:=crDefault;
    end;
  end;
 
  function TSOAPDataMod42.SaveChanges(Input: OleVariant): OleVariant;
  var
    ErrorCount: integer;
  begin
    cdsTmp.Data:=Input;
    //... здесь какая-нибудь предобработка полученных данных ...
    cdsTmp.Data:=dspBiolife.ApplyUpdates(cdsTmp.Data,0,ErrorCount);
    if ErrorCount<>0 then begin
      //... и тут можно что-то сделать, например Rollback транзакции
      end;
    Result := cdsTmp.Data;
  end;

В данном примере метод SaveChanges принимает датапакет типа Delta и возвращает таблицу ошибок, возникших при обновлении.

Для любопытных: формат передаваемого пакета данных описан на community.borland.com, однако в реальности он передается в виде бинарного (base64Binary) пакета в том же формате, что и файл (*.cds), описания этого формата мне найти не удалось.
Чтобы посмотреть, как реально выглядят пакеты, передаваемые по сети, можно воспользоваться программой tcpTrace, или логом WebAppDebugger-а.

Работа с мастер-деталь

Тут тоже все просто, но - несколько необычно, поскольку деталь должна передаваться как вложенный в мастер-таблицу датасет, поскольку обычная мастер-деталь связка для TClientDataSet невозможна. Делается это так.

В Soap Server Data Module помещаются датасеты для мастер таблицы и для детали и, как обычно, связываются через TDataSource. Туда же помещается один TDatasetProvider, который связывается с мастер-таблицей. Сервер компилируется и кладется туда, где может быть запущен Web-сервером.

На форму клиента кладется TSoapConnection и два TClientDataSet, у первого из которых (это будет мастер) устанавливаются свойства RemoteServer (указывает на TSoapConnection) и ProviderName (указывает на TDatasetProvider нашего сервера). Далее, у первого датасета создаются persistent поля: выберите Add All Fields в редакторе полей, последним в списке добавленных будет поле типа TDataSetField, имеющее имя нашей деталь-таблицы на сервере.

У второго TClientDataSet (это будет наша деталь) установите единственное свойство: DataSetField (выберите из списка название для TDataSetField первого датасета). Теперь, если соединить наши TClientDataSet-ы с гридами, то мы увидим наши данные - отдельно мастер таблицу и деталь.

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

Переход к другому типу приложений

Если вы уже наигрались с WebAppDebugger-ом и CGI, то можете замахнуться на "так сказать, Шекспира", модуль ISAPI/NSAPI.

Вообще, перейти от одного вида приложения к другому (скажем, от WebAppDebugger к CGI) крайне просто. Создаем новое приложение нужного нам типа, добавляем в него все наши модули и новое приложение работает! Если вы конечно не помещали никакого кода в автоматически генерируемые модули, чего лично я не рекомендую делать.

Еще следует помнить об упоминавшихся ранее отличиях WebModule для WebAppDebugger и CGI, а также о разнице в работе CGI и ISAPI приложений: в первом случае создается по экземпляру приложения на коннект (т.е. с каждым приложением работает один юзер), во втором - приложение одно, но на каждый коннект внутри приложения создается отдельный поток, что требует аккуратного обращения с общими данными, а в остальном - никаких проблем .

Примечание: SOAP сервера, скомпилированные на Delphi 7 (и, вероятно, выше) чувсвительны к Locale сервера, на котором они работают - именно опираясь на нее они преобразуют русские буквы из Win1251 в UTF8. Т.е. в национальных настройках операционной системы сервера необходимо выставить страну "Россия", иначе вместо русских букв получите крякозюбры...
или добавьте в код инициализации любого модуля строку

   SetThreadLocale($419);

что заставит ваш модуль использовать именно русскую кодировку по умолчанию.

И еще пара замечаний:
- для нормальной работы SOAP приложений, необходимо зарегистрировать stdvcl40.dll с помощью tregsvr.exe или regsvr32.exe, DLL должна лежать в каталоге, прописанном в PATH;
- если вы устанавливаете SOAP клиента на Win2003, то в Свойствах системы необходимо отключить Data Execution Prevention для вашей программы, иначе особенности реализации TRIO в Delphi до версии 2005 приводят к Access Violation:

Konstantin Beliaev, 2003-2006


В качестве дополнительной литературы советую посмотреть:

  1. BizSnap chapter of Kylix Developer's Guide (особенно части 4-6)
  2. InterBase in a Multi-tier World
  3. Проектирование ISAPI приложений для работы с базами данных
  4. ... и конечно же - RTFM, правда с поиском в этих разделах почему-то большие проблемы, но информация в хелпах есть и достаточно подробная,
  5. а также - те демо-приложения, которые идут с Delphi

Пожелания по поводу развития данной статьи приветствуются на: KonstB@newmail.ru