2019年9月6日 星期五

建置DataSnap Client Server (3之3)


緣起

在本系列的前面兩篇,第一篇裡頭介紹了使用Delphi製作原生執行檔、二進位資料進行傳輸,完成Client/Server的資料庫程式組,第二篇則是介紹了製作原生執行檔、RESTful格式製作Client/Server程式組,跟第一篇相較,資料內容從二進位改為JSON,傳輸的協定從原生的DataSnap傳輸,改為了HTTP,已經可以通過絕大多數防火牆的阻擋了。
在這第三篇裡面,沿用第二篇的傳輸協定、資料格式,但不再使用原生執行檔,改為製作出ISAPIdll,搭配WindowsIIS來運作,這次我們在Client/Server整組程式完成以後,再用Apache JMeter來實測同一連線、同一資料庫、同一電腦上的原生程式/IIS + ISAPI來驗證兩種Server作法的效能差異。
在本篇的範例中,差異比第二篇和第一篇的差異更小,Client端的使用,差異上只有一個設定要改,Server端和資料庫的連線功能,則是可以完全不改,直接適用。所以最近的幾個專案,我都是先製作了原生執行檔的Server,再作一個IIS ISAPI專案,這樣一來,要逐步偵錯的話,就可以從原生執行檔的專案中設定中斷點,省去了ISAPI專案偵錯的許多複雜步驟。

FireDAC搭配DataSnap RESTful格式製作Client ISAPI Server程式


建立空殼DataSnap WebBroker Application (ISAPI)專案

首先我們建立一個DataSnap WebBroker Application專案,以下用圖片來展示:










當我們選擇專案類型(Project Type)ISAPI dynamic link library之後,原本的六個步驟就變成四個步驟了:
第三步驟直接就選擇Server features了,如果我們製作的是原生執行檔,第三步驟則是要選擇專案執行檔類型(VCL Application/FireMonkey Application/Console Application)了,在ISAPI的專案中,Server features只預設勾選了Server Method class,我依循前兩篇的作法,多勾一個Server Module選項:
 
最後一個步驟跟前兩篇文章設定相同: Server Method是我們要實作各種DataSnap開放給Client使用的API所宣告的地方,因為我們要在這裡連接到SQL Server,所以我選擇TDSServerModule。但選擇TDataModule或者TDSServerModule差別不大,都是要在裡面放置與資料庫Server連線用的TFDConnection等連線元件,所以在三篇文章中,我都是直接就選了TDSServerModule

到這個步驟,點選Finish以後,專案就會建立完成,在Delphi環境裡面,會有三個檔案,分別是專案主畫面、ServerMethodsUnit1.pasServerContainerUnit1.pas,我把專案儲存為TestDSWebBrokerAPI,所以右上角的專案內容,看起來就會變成下圖所示這樣:

這個時候如果點選ServerMethodUni1.pas,切到Design畫面,會看見視窗上什麼元件也沒有:










ServerContainerUnit1.pas當中,Design畫面則是自動被放了一些元件上去,可以看的出來,WebBroker的專案中,ServerContainerUnit裡面比第一篇文章的範例少了DSTCPServerTransport1 跟 DSHTTPService1這兩個元件,本篇的ServerContainerUnit1:











第一篇文章的ServerConatinerUnit1:
















在本篇文章當中,就沒有主畫面了。


在後面這兩篇的範例中,由於使用的是WebBroker應用程式專案(應用程式與ISAPI都一樣),就比第一篇文章的範例多了一個名為WebModuleUnit1的單元檔,其畫面中只有一個DSHTTPWebDispatcher元件,基本上我沒對這些元件作任何調整,都是預設的設定值。











設定IIS,讓製作出來的dll能掛上去使用



這篇原廠的說明文件裡面,一步一步的說明了如何設定IIS,好讓ISAPI可以掛上去使用,所以我不再多加贅述,直接把我們的範例設定方法介紹給大家看:
1.    建立 ISAPI dll 要放置的目錄,並設定權限給 IIS 使用者
我在C碟底下建立了 DSISAPI這目錄,並在檔案總管裡面設定它的權限:

























資料夾的權限,要加入IIS_IUSRS這個使用者,不然IIS沒辦法存取到資料夾,以及放在資料夾裡面的檔案。
設定虛擬目錄,讓它連接到剛建立的這目錄:
 




















設定好虛擬目錄後,接著要設定專案的輸出目錄(output directory),讓DLL產出在剛剛建立好的這個虛擬目錄裡面:

 
接著就把專案編譯一下,在C:\DSISAPI這目錄裡面就生出了TestDSWebBrokerAPI.dll,我們就可以從瀏覽器直接瀏覽內容了,因為我們在建置專案的時候,有產生Sample Methods,所以還沒寫任何程式碼之前,也有EchoStringReverseString這兩個範例方法可以用,以下,我就用FireFox來瀏覽新產生的這個ISAPI裡面的ReverseString方法:
 
可以看見專案的DLL已經有反應了,我們先來看一下當中的 URL 吧。在原廠的文件中,有提到要存取專案中的API,要結合 Host, Port, DSContext, RESTContext 這幾個屬性的設定值:



http://伺服器Host設定:port號碼/DSContext設定值/RESTContent設定值/ServerModuleUnit的類別名稱/方法名稱/參數1/參數2.......
上面這個寫法,是原生執行檔的作法,但我們現在改用ISAPI
http://localhost/DSAPI/TestDSWebBrokerAPI.dll/datasnap/rest/TServerMethods1/ReverseString/Test
HostPort改為直接用IIS的設定,只是需要加入兩個元素: 虛擬目錄路徑跟DLL名字,所以就成了上面這個 URL
從截圖可以看得出來,執行結果是以JSON編碼的,這樣就能穿透防火牆了,接著,我們就先來製作Client端,再進行功能擴增的作業吧。

 建立DataSnap WebBroker Client程式

首先建立一個空的VCL Application專案,我把它命名為TestDSWebBrokerClient,跟二進位格式的DataSnap客戶端不同的是,我們Client主畫面上放置的元件,改為一個TDSRESTConnection元件,設定好它的Host, Port, Context, RESRTContent屬性即可:
這當中的UrlPath,就得填進我們在IIS裡面建立的虛擬目錄跟 DLL 的檔案名稱,在本範例中,虛擬目錄的名字是 DSISAPI (跟實體目錄不同名喔,請留意),檔案名稱則是TestDSWebBrokerAPI.dll,所以 URLPath 要填入 DSAPI/TestDSWebBrokerAPI.dll
在第二篇文章裡面,因為server端是原生exe檔,所以需要先把製作好的TestDSWebBrokerServer從檔案總管裡面執行起來,才能從Client更新API內容。但本篇是使用ISAPI,所以只需要確定IIS是在執行狀態,就可以切換到Client專案,到畫面上用滑鼠右鍵點擊DSRESTConnection1元件,點選其中的Generate DataSnap client class項目:















接下來的所有步驟,都跟第二篇文章當中的Client作法相同,我不更改文字,如果您還沒有很熟悉第二篇的作法,可以就著這些描述再溫習一下:
點選這個項目後,就會建立出一個新的單元檔,當中會宣告與Server連線的新Class,這是開發環境直接跟已經執行起來的Server程式索取資料,即時產生的Class,我們就可以用這個Class直接呼叫Server端的Method了。


因為在第一篇介紹當中,我們已經介紹過基本的方法,所以在這裡我們直接進入SQL指令的實作了,我們在Server端加入資料庫連線,提供兩個方法作為範例,一個是讓Client端傳SQL指令進行查詢,然後Server端把查詢到的資料筆數轉為字串回傳。另一個則是讓Client端傳 select SQL指令,讓Server把查詢到的資料以Table的形式回傳到Client端,讓Client端可以透過MemoryTable讀取資料內容。

擴增Server端功能- getRecordCount

第一篇文章當中,我提到了先使用MySQL做個簡單的DemoDB,作為範例之用,我把MySQL資料庫放在10.10.10.1的IP上頭,設定好一個名為DemoDB的資料庫,並且設定好一個使用者帳號,名為DemoDB,讓它可以接受10.10.10.1連線,並且在上頭新增了四筆資料:

















接著,在Server端的ServerMethodUnit1裡頭,我加了TFDConnection元件,把它的驅動程式設定為MySQL,另在DataModule裡面加入TFDPhysMySQLDriverLink, TFDSchemaAdapter, TFDQuery, TFDStanStorageBinLink這幾個元件,加入以後,畫面如下圖所示:











接著開始設定。首先,把FDConnection設定好,如下畫面中所示:


















接著,把FDQuery1的Connection屬性設定為FDConnection1,這樣一來,FDQuery1的SQL指令就可以透過FDConnection1來執行,送到MySQL伺服器去,而查詢的結果,也會經由FDConnection1回傳到FDQuery1裡面來。

FDQuery1的SchemaAdapter屬性,則用下拉選單設定為FDSchemaAdapter1,這是為了等下要轉換成其他格式回傳給Client之用。

設定好了,我們就可以新增getRecordCount這個新方法了,這步驟就純粹是程式碼編輯囉,先在TServerMethods1類別裡面新增這行宣告:
function getRecordCount(SQLCmd: String): string;

然後按下Ctrl+Shift+C,讓Delphi開發環境自動幫我們建立對應的程式碼,也就是一個空殼的方法實作:
function TServerMethods1.getRecordCount(SQLCmd: String): string;
begin
end;
做好空殼方法之後,我們先重新編譯TestDSWebBrokerServer,並且從檔案總管執行它,再重複前面提到過的『再到畫面上用滑鼠右鍵點擊DSRESTConnection1元件,點選其中的Generate DataSnap client class項目』,執行完成後,剛剛Server新增的這個函式就會被新增到Client端的ClientClassesUnit1.Pas裡面了:

function TServerMethods1Client.getRecordCount(SQLCmd: string; const ARequestFilter: string): string;
begin
  if FgetRecordCountCommand = nil then
  begin
    FgetRecordCountCommand := FConnection.CreateCommand;
    FgetRecordCountCommand.RequestType := 'GET';
    FgetRecordCountCommand.Text := 'TServerMethods1.getRecordCount';
    FgetRecordCountCommand.Prepare(TServerMethods1_getRecordCount);
  end;
  FgetRecordCountCommand.Parameters[0].Value.SetWideString(SQLCmd);
  FgetRecordCountCommand.Execute(ARequestFilter);
  Result := FgetRecordCountCommand.Parameters[1].Value.GetWideString;
end;

這些自動產生的程式碼我們不用處理,接著回頭關掉Server程式、來實作程式碼吧,這些程式碼跟第一篇文章的範例都一樣。
在Server端,我們的getRecordCount有個參數,就是要執行的SQL指令,所以完整的實作如下:
function TServerMethods1.getRecordCount(SQLCmd: String): string;
begin
   self.FDQuery1.Active := False;
   try
      self.FDQuery1.SQL.Text := SQLCmd;
      self.FDQuery1.Active := True;
      Result := IntToStr(self.FDQuery1.RecordCount);
   finally
      self.FDQuery1.Active := False;
   end;
end;

直接執行傳來的SQL指令,執行後,把FDQuery1.RecordCount轉為字串回傳,執行的畫面如下:


Client端的實作,則是放在按鈕 btnGetRecordCount 的Event Handler裡面:
procedure TForm2.btnGetRecordCountClick(Sender: TObject);
var
   Server: TObject;
   retStr: String;
begin
   try
      Server := TServerMethods1Client.Create(DSRestConnection1);
      retStr := TServerMethods1Client(Server)
          .getRecordCount('select * from testRec');
      ShowMessage(retStr);
   finally
      Server.Free;
   end;
end;
所以執行的畫面,就如上面的截圖,出現了4, 因為我只在資料庫裡面建立了四筆資料,所以 select * from testRec 查詢執行後,取得了四筆資料,回傳4這個字串。


擴增Server端功能- StreamGet

接著,我們要把查詢到的資料回傳給Client,由於回傳的資料我們要用Serialization(序列化)處理後,直接回傳到Client,所以就直接回傳TStream,Client端接到了資料,再把Serialization資料轉回為放在記憶體裡面的TTable(為了處理這樣的需求,FireDAC元件當中新增了TFDMemTable這個元件用來處理這樣的資料)。
首先仍然要先做出Server端的空殼方法:
function StreamGetSQL(SqlCmd: String): TStream;

然後重新編譯Server程式、從檔案總管執行它、再重複Client端的步驟『再到畫面上用滑鼠右鍵點擊DSRESTConnection1元件,點選其中的Generate DataSnap client class項目』,執行完成後,剛剛Server新增的這個函式就會被新增到Client端的ClientClassUnit1.Pas裡面了:
function TServerMethods1Client.StreamGetSQL(SQLCmd: string; const ARequestFilter: string): TStream;
begin
  if FStreamGetSQLCommand = nil then
  begin
    FStreamGetSQLCommand := FConnection.CreateCommand;
    FStreamGetSQLCommand.RequestType := 'GET';
    FStreamGetSQLCommand.Text := 'TServerMethods1.StreamGetSQL';
    FStreamGetSQLCommand.Prepare(TServerMethods1_StreamGetSQL);
  end;
  FStreamGetSQLCommand.Parameters[0].Value.SetWideString(SQLCmd);
  FStreamGetSQLCommand.Execute(ARequestFilter);
  Result := FStreamGetSQLCommand.Parameters[1].Value.GetStream(FInstanceOwner);
end;
Client端需要的元件跟第一篇範例一樣,包含了TFDSchemaAdapter, TFDMemTable, TFDGUIxWaitCursor, TFDStanStorageBinLink, TFDTableAdapter 這些元件,這些元件各有其特殊的用途,我們來看一下:
    FDSchemaAdapter1: TFDSchemaAdapter; => 把Stream資料轉為物件
    FDMemTable1: TFDMemTable; => 儲存轉換完成的Table資料
    FDGUIxWaitCursor1: TFDGUIxWaitCursor; => 等待 Server 端連線完成的等待作業
    FDStanStorageBinLink1: TFDStanStorageBinLink; => FDSchemaAdapter轉換二進位資料時,用來處理資料格式的物件。
    FDTableAdapter1: TFDTableAdapter; => 連結 FDSchemaAdapter1 跟 FDMemTable的中介角色,它裡面的DatSTableName必須設定為回傳的TableName,回傳的Table才能正確的被儲存到FDMemTable1裡面去。

這些基本設定完成後,就可以把Client端按鈕的Event Handler寫上去了:
procedure TForm2.btnGetQueryClick(Sender: TObject);
var
   Server: TObject;
   LMemStream: TMemoryStream;
begin
   Server := TServerMethods1Client.Create(DSRestConnection1);
   LMemStream := CopyStream(TServerMethods1Client(Server).StreamGetSQL('select * from testRec'));
   try
      if LMemStream <> nil then begin
         LMemStream.Position := 0;
         self.FDSchemaAdapter1.LoadFromStream(LMemStream,
             TFDStorageFormat.sfBinary);

         self.FDMemTable1.First;
         ShowMessage(self.FDMemTable1.FieldByName('name').AsString);
      end;
   finally
      LMemStream.Free;
      Server.Free;
   end;
end;
這裡面的CopyStream,是Delphi範例裡面的程式碼,我覺得用起來沒問題,所以就Copy來繼續用了。
上面這段程式,是我抓了第一筆資料裡面的name欄位來顯示的,想不起來資料長怎樣嗎?我們回頭看一下MySQL裡面建好的資料:
















第一筆資料的name是Apple,所以執行結果也會顯示Apple:

取回的FDMemTable1資料是完整的,所以呼叫Next,就可以依序取得其他各筆的資料。要記得:FDTableAdapter1.DatSTableName得要回傳跟 SQL指令裡面的Table名字一致,才不會在執行過程中發生錯誤喔。


本篇當中,我新增了一個檔案上傳的功能,命名為postFileStream,第一個參數是要上傳的檔案,第二個參數則是要上傳的TStreamServer端直接接下這個TStream,把接到的Stream儲存到FileStream裡面就行了。
 

function TServerMethods1.postFileStream(Filename: String;
  FileStrm: TStream): String;
var
   filePath, fullFileName: String;
   LMemStream: TMemoryStream;
   newFileStrm: TFileStream;
begin
   fullFileName := System.IOUtils.Tpath.Combine('C:\DSISAPI', Filename);
   filePath := ExtractFilePath(fullFileName);

   if not DirectoryExists(filePath) then begin
      ForceDirectories(filePath);
   end;

   if FileExists(fullFileName) then begin
      DeleteFile(fullFileName);
   end;

   LMemStream := CopyStream(FileStrm);
   newFileStrm := TFileStream.Create(fullFileName, fmCreate);
   try
      LMemStream.Position := 0;
      newFileStrm.CopyFrom(LMemStream, LMemStream.Size);
      LMemStream.SetSize(0);
   finally
      newFileStrm.Free;
      LMemStream.Free;
   end;

   Result := 'file received';
end;

但是在ClientGenerate DataSnap client classes之後,Client端的功能裡頭,我發現有一個問題,就是在Client端的Destroy方法實作程式碼裡面,會造成Access Violation:
destructor TServerMethods1Client.Destroy;
begin
  FEchoStringCommand.DisposeOf;
  FReverseStringCommand.DisposeOf;
  FgetRecordCountCommand.DisposeOf;
  FStreamGetSQLCommand.DisposeOf;
  FStreamGetSQLCommand_Cache.DisposeOf;
  FpostFileStreamCommand.Parameters.RemoveParameters; //加入這一行,就可以避免AccessViolation .
  FpostFileStreamCommand.DisposeOf;
  inherited;
end;

DataSnap Server的效能比較

在多層式架構中,資料庫伺服器、應用程式伺服器,是當中兩項關鍵的節點。整體服務的效能,還牽涉到兩個服務器之間的網路效能、應用程式伺服器與客戶端的網路效能,以及客戶端的應用程式效能三大部分。
回頭想想,絕大多數的服務供應商,都有自己的資料庫伺服器,而應用程式伺服器與資料庫伺服器之間的網路,也不會因為提供多少客戶而有所變化,應用程式伺服器與客戶端之間的網路效能,可以透過更換ISP而改善。
因此,我們最該努力的,就是把應用程式伺服器的效能提升到極致,這樣一來,整體服務的品質就能提的更高。為了單純測試應用程式伺服器,這次的測試中,我們使用同一個資料庫伺服器、同一個網路、同一個應用程式伺服器,唯一的變因,則是應用程式伺服器當中,使用IIS與原生執行檔提供RESTful資料給Client,這樣就能單純比較出IIS與原生執行檔的效能差異了。
比較工具: Apache JMeter Windows
設定條件: 一共 450Thread,每個Thread10筆查詢。
案例一: 原生執行程式,結果如下面的截圖:

用原生執行檔,每秒處理量是 48.4個要求,但還有33.3%的錯誤率。

案例二: 用相同的設定,改為ISAPI,結果如下面的截圖:

 

每秒處理了83.8個要求,錯誤率為0.
所以,有個被優化過的WebServer作後盾,整個服務的效能提昇、錯誤率下降,比原生執行檔好的多了。

 總結本篇

在這一系列的介紹中,都是以DataSnap作為應用程式伺服器,只是有三個作法,所以分成了三篇介紹:
        DataSnap 原生執行檔,以二進位傳輸資料
        DataSnap 原生執行檔,以RESTful資料傳輸
        DataSnap ISAPI,以RESTful資料傳輸
這三篇文章都是在介紹DataSnap的作法,以三層式的架構與這十多年來WebServer作為應用程式伺服器的比較,我以往也常自己從底層開始製作自己需要的API,用LAMP架構製作的PHP Server端效率也不錯,又免去了Windows各種隱性成本。
但考量到大多數的Delphi開發人員並不熟悉這麼多不同的平台,而且從頭寫起也並不是很方便,所以遇到跟DataSnap相關的專案,也正好讓我從不同的角度回頭檢視了一下DataSnap的技術與作法。
以純Delphi程式人員的角度來看,DataSnapRAD Studio都是不錯的選擇,可以讓我們從頭到尾用Delphi就能夠把服務建置起來。而透過本篇最後的資訊分享,更讓大家可以看的出,借重現有的商用WebServer (IISApache),更可以大大提昇服務的可靠度跟效率。
尤其在Delphi XE 10之後,Linux也已經成為Delphi支援的作業系統之一,因此,製作出Windows DataSnapApache外掛之外,更可以製作出LinuxDataSnapApache外掛,如此一來,LAMP的架構中,也能有Delphi的角色了。
本篇文章的範例程式在此,請大家多多指教。