2024年12月3日 星期二

在應用程式中整合 Google 帳號登入功能

 

從登入到社群帳號登入

從 Delphi 開始出現的1990年代中期開始,各種應用程式就有不同的方式提供使用者登入的功能,包含自己建立的帳號系統、Email與密碼、Windows AD等等。要透過當年不斷更迭的資料庫元件,例如 BDE,ADO,對Delphi的開發人員都算不上問題。

到了2000年初期,各種Email信箱的登入與驗證多了起來,2007-2017之間,Facebook, IG, LINE的社群登入,以OAuth方式的協定,讓Delphi中內建了支援TOAuth的 Client元件,需要 SSL,TLS等等安全驗證元件,都需要不同平台的OpenSSL Library,在那個 Delphi 開始復甦,卻還讓大家難以掌握的年代裡,就比較頭痛。

但Embarcadero從2017年開始,就開始提供跨平台可以直接使用的TNetHTTPClient以及TRestClient元件,讓開發人員可以減少一些因為Indy元件不容易處理OpenSSL函式庫的不便,但仍然有很多開發上的煩燥之處。(請別誤以為筆者這是在說Indy不行,畢竟筆者也是Indy團隊成員之一,TIdDNSServer是我在2004年完成的,Indy仍然是很棒的元件喔) 

時序到了2018之後,登入帳號這件事情,在Google跟Microsoft、Apple的努力之下,從手機、平板、電腦瀏覽器登入社群軟體、GMail,微軟帳號、Apple的Game Center不再每次都需要輸入帳號密碼,從輸入帳密,變成了2層式驗證(2FA),或是多層式身分驗證(MFA),註冊與登入這件事情越來越麻煩,但操作上越來越簡單,只有第一次需要點一下想要用來登入的帳號,輸入一次密碼,並在手機或平板上進行一次驗證。第二次之後的登入,都只需要點擊帳號就可以完成這個程序了。

用戶端的簡化,程式端的複雜

用戶端的操作過程可以越來越簡單,但卻苦了開發人員。尤其這幾年幾乎所有的流程跟API都Web化,對於應用程式,尤其是Windows端的應用程式來說,要整合Web API,再從Web抓到所需的資料作為後續在應用程式中使用,真的很複雜。

在Delphi的全球社群,以及Embarcadero的開發團隊中,對於這幾年在網路中的各種新式身分驗證標準,不斷的更新,以對於RFC 7520 JOSE(JSON Object Signing and Encryption)為例,官方在Delphi 12.2的系列研討會裡面,就介紹了Delphi的JOSE函式庫,在GitHub上面,也有越來越多的函式庫,例如FB4D (FireBase For Delphi),都是Embarcadero全球各地的前輩跟MVP們努力的成果。

站在巨人的肩膀上,我們也能有巨人般的步伐。

FB4D提供了很完整的API,讓我們可以在Delphi的程式中,不論您使用的是VCL或者FireMonkey,都可以快速的使用FireBase系列的API,讓使用者用自己的Google帳號註冊或登入我們開發的應用程式了。

可惜的是,這系列的API當中,沒有提供用戶直接以瀏覽器選擇自己帳號登入的選項,所以筆者把這一段簡單的補足給想要這麼做的開發人員。

讓用戶直接透過瀏覽器,以Google帳號登入吧

不多說,直接來看圖。

這個畫面,相信大家一定不陌生,這是在各種網頁應用程式或者手機遊戲當中,常見的『以Google帳號』登入選項被點擊之後,常會出現的畫面,此時,只需要輸入用戶的Google帳號,再輸入密碼之後,如果有設定要使用2FA的話,就會出現以下的畫面。

接著,如果使用者完成了2FA或者MFA的步驟,我們的程式就會接收到登入完成的通知,就可以繼續進入程式功能了,在筆者這次的範例中,只會從Google帳號端取得用戶的簡單資訊,將它顯示在畫面上:

如果這是註冊程序,就能把抓到的這些資訊寫入註冊的資料表中,如果已完成註冊,則可以透過抓到的這些資訊,識別使用者的身分,並在應用程式中打開對應的授權功能了。
 

原本要做到2FA或者MFA,對中小型的系統服務提供者來說,都是很麻煩的事情。但透過了Firebase這個工具的幫助,可以幫我們把Google帳號、微軟帳號、Facebook、IG等常用的社群帳號一次整合完成,而且介面也是用戶們熟悉的介面,不用我們另外費心,身為懶惰的開發人員,這個懶怎麼可以不偷?

一步一步完成整合

要完成這功能,首先得從Firebase開始。

請先進入到Firebase控制台,建立您的Firebase專案。如果您已經建立過Firebase專案,也可以從Firebase控制台直接點擊該專案。


在上圖的頁面中,左側有個Authentication,就是用來啟用Firebase身分驗證服務的。請點擊該選項,進入Authentication設定頁,如下圖所示:


這個頁面的設定項目中,請選擇右邊主要內容頁籤的『登入方式』,點選『新增供應商』,把Google新增進去,點選新增供應商的時候,會有『電子郵件/密碼』、Google、Microsoft、Apple GameCenter、Facebook等多個項目可以選擇,本文要介紹的是Googl登入的功能,所以請直接點選Google進入下個頁面。

Google登入的供應商設定頁面中,有兩個可以展開的部分,第一部分『將外部專案的用戶端ID新增至許可清單(選用)』可以完全不用理會,因為本文的設定,是要在Windows應用程式的環境中使用這個登入方法,而第一部分的設定是對於手機App的設定,所以我們可以先不理會它,哪天要製作App的時候再來面對~

 

設定Redirect URL(重新導向網址)

前述的設定都完成後,得要到GCP的API控制台設定Redirect URL,這個網址會讓用戶用來登入帳號的瀏覽器在完成登入程序之後,進行重新導向,一般在網頁應用程式中,會把這個網址設定為該網頁應用程式中的另一個專門用來驗證登入結果的網址。

而在手機應用程式中,可以在App中自訂一個只有該App專用的URL,例如myapp://loginprocedureOK/,在登入程序完成後,瀏覽器重新導向這個網址之後,手機應用程式就可以接收到這個訊息,進行登入成功或登入失敗的後續處理。

那在Windows應用程式中怎麼辦?簡單,我們自己在Windows應用程式裡面放一個WebServer不就好了?我們用的是Delphi,我們有TIdHTTPServer,並不是要做完整的WebServer,只要接收一個網址,這就只是一片小蛋糕而已。

在上面的這個設定中,筆者把登入程序完成後的重新導向網址設定為http://localhost:5877/passAuth,這個網址可以隨您的喜歡設定,只要是localhost,使用1024以上的Port,後面的路徑可以隨我們自訂,接下來的篇幅中以Delphi搭配TIdHTTPServer把這個Request承接下來即可。

Delphi程式開始

在本文當中,筆者使用的是Delphi 12.2 (Athen),請新增一個專案,看你自己喜歡VCL或者FMX都可以,本文當中的做法是用瀏覽器,要用VCL或者FMX都行,因為筆者都試過了,哪種框架都可以運作。

在這個專案中,筆者只簡單的在Form裡面放了三個TEdit,分別用來顯示登入後回傳的Name、Issuer(回傳Token的組織名稱)、使用者的Id(Sub Id),再多加一個TImage,用來顯示用戶設定在Google帳號中的圖片。

在上圖的Form當中,當然還有一些TLabel,用來作為提示或者文字,分別是Edit前的描述文字,以及右方用來顯示登入成功與否的文字。

在Form裡面,除了視覺化元件之外,還有四個隱形元件,分別是TRestClient,TRestRequest,TIdHTTPServer以及TTimer。TRestClient跟TRestRequest,是當成HTTP Client元件使用的,細節可以參考筆者幾年前的另一篇文章,本文中不再贅述。

之所以用TRestClient跟TRestRequest作為HTTP Client元件,在本文開頭的第三段裡,筆者已經提到過,只是當時筆者提到的是TNetHTTPClient,而TRestClient跟TRestRequet都是同一系列的元件。

除了這個Form之外,我們還需要另一個用來讓用戶們登入的Form,裡面只需要放一個WebBrowser元件,如下圖所示:

上圖非靜止畫面~這個畫面中,筆者放了一個TWebBrowser,並將它設定為AlignClient,所以看起來整個Form就是空的。

程式分成兩個Form,一個是FormMain,一個是FormLogin。在FormMain當中,Form建立時,先把FLogin指派為false,當FormMain被Activate的時候,檢查Login狀態,如果Login並非true,就呼叫FormLogin.tryLogin,把FormLogin用ShowModal顯示在FormMain的中央,讓使用者登入,我們看一下這幾段程式碼:

procedure TFormMain.FormActivate(Sender: TObject);
begin
   if not self.login then begin
      self.IdHTTPServer1.Active := True;
      UnitLogin.FormLogin.tryLogin;
   end
   else if self.IdHTTPServer1.Active then begin
      self.IdHTTPServer1.Active := False;
   end;
end;
procedure TFormLogin.tryLogin;
var
   targetURL: String;
begin
   targetURL := 'https://accounts.google.com/o/oauth2/auth?
                client_id=' +myClientId + '&redirect_uri=' + 
                myRedirectURI +
                '&response_type=code&scope=profile email';

   self.WebBrowser1.Navigate(targetURL);
   self.ShowModal;
end;

在上面這段程式碼裡面,可以看到從FormMain要呼叫FormLogin.tryLogin之前,先把IdHTTPServer1.Active改為True,啟動了本機上的WebServer,這個元件的設定只把DefaultPort改為5877,其餘都是預設值。

在FormLogin.tryLogin裡面,則只是讓WebBrowser1瀏覽Google的網址,當中的myClientId,就是前文中提到登入方式的Google畫面中『Web SDK設定->網路用戶端ID』,請直接從該畫面中複製該字串下來即可。

當中的myRedirectURL,就是在前面提到的GCP API控制台裡面設定的重新導向URL,筆者把這個字串設定為http://localhost:5877/passAuth,待會登入完成了,無論成功或失敗,瀏覽器元件都會自動重新導向到本機的WebServer這個網址,正好可以從IdHTTPServer1接收到重新導向的內容。

執行的畫面如下:

如果用戶是第一次使用這個Windows應用程式,就會依序顯示上面這兩個畫面,如果用戶已經使用過這個應用程式,而且已經授權登入,就會直接完成登入過程,進入主畫面。

對於用戶來說,使用過程就很順暢。

接著我們來看一下重新導向的程式碼接收到什麼資訊:

procedure TFormMain.IdHTTPServer1CommandGet(AContext:
    TIdContext; ARequestInfo: TIdHTTPRequestInfo;
    AResponseInfo: TIdHTTPResponseInfo);
var
   AuthCode: string;
begin
   if ARequestInfo.Document = '/passAuth' then begin
      AuthCode := ARequestInfo.Params.Values['code'];

      if AuthCode <> '' then begin
         AResponseInfo.ContentText :=
             '<html><body>Login successful! You can close this
              window.</body></html>';
         AResponseInfo.ContentType := 'text/html';

         RetrieveUserToken(AuthCode);
      end
      else begin
         AResponseInfo.ContentText :=
             '<html><body>Error: No authorization code
              found.</body></html>';
         AResponseInfo.ContentType := 'text/html';
      end;
   end
   else begin
      AResponseInfo.ResponseNo := 404;
      AResponseInfo.ContentText := 'Not Found';
   end;
end;
procedure TFormMain.RetrieveUserToken(const AuthCode: string);
var
   tmpJSONObj: TJSONObject;
   AccessToken: String;
   theUser: TFirebaseUser;
begin
   self.RESTRequest1.Method := TRESTRequestMethod.rmPOST;
   self.RESTClient1.BaseURL := 'https://oauth2.googleapis.com/
        token';
   self.RESTClient1.ContentType := 'application/x-www-form-
        urlencoded';

   self.RESTRequest1.AddParameter('code', AuthCode,
        pkGETorPOST);
   self.RESTRequest1.AddParameter('client_id', myClientId,
        pkGETorPOST);
   self.RESTRequest1.AddParameter('client_secret',
        myClientSecret, pkGETorPOST);
   self.RESTRequest1.AddParameter('redirect_uri',
        myRedirectURI, pkGETorPOST);
   self.RESTRequest1.AddParameter('grant_type',
        'authorization_code', pkGETorPOST);

   self.RESTRequest1.Execute;
   if Assigned(self.RESTRequest1.Response.JSONValue) then
   begin
      tmpJSONObj := self.RESTRequest1.Response.JSONValue as
                    TJSONObject;
      AccessToken := tmpJSONObj.GetValue<string>
                    ('access_token', '');
      currentUserIdToken := tmpJSONObj.GetValue<string>
                    ('id_token', '');

      self.FJWT := TJWTv2.Create(currentUserIdToken);

      self.EditUserName.Text := self.FJWT.Claims.userName;
      self.EditIssuer.Text := self.FJWT.Claims.Issuer;
      self.EditSub.Text := self.FJWT.Claims.Subject;
      self.userPicURL := self.FJWT.Claims.userPhotoURL;

      self.Memo1.Lines.Add(currentUserIdToken);

      self.login := True;
      self.TextLoginStatus.Text := 'Login OK!';
      self.TimerRefresh.Enabled := True;
   end
   else begin
      self.Memo1.Lines.Add('Login not work, token not
           retrieved.');
   end;
end;

在這段程式碼當中,筆者自己寫了一個元件:TJWTv2,把回傳的Token解析為TJOSE當中的TJWT類別格式,但TJWT當中只有簡單的幾個欄位,在Google回傳的JWT當中,還有不少延伸的欄位,所以筆者擴充了這個Class,在IdHTTPServer1.OnCommandGet事件被觸發的時候,我們把Google回傳的第一階Access Token傳到oauth2 API取得JWT,便把取得的JWT解析出來。

在TJWTv2當中,筆者擴充了Claims的欄位,新增userName, userPhotoURL, userEmail, userEmailVerified, userGivenName, userFamilyName等幾個欄位。也就順便把userName, Issuer, Subject, 以及userPhotURL指派給FormMain裡面的幾個對應欄位作為顯示。

在FormMain.userPicURL的setter當中,筆者直接封裝了下載圖片回來塞進TImage的程式邏輯,在此也分享給大家:

procedure TFormMain.SetuserPicURL(const Value: String);
var
   webClient: TNetHTTPClient;
   ms: TMemoryStream;
   resp: IHTTPResponse;
begin
   FuserPicURL := Value;

   if (Pos('http://', Value) = 1) or
      (Pos('https://', Value) = 1) then begin
      webClient := TNetHTTPClient.Create(nil);
      try
         ms := TMemoryStream.Create;
         resp := webClient.Get(Value, ms);
         if (resp.StatusCode = 200) and (ms.Size > 0) then
         begin
            ms.Position := 0;
            self.Image1.Bitmap.LoadFromStream(ms);
         end;
      finally
         webClient.Free;
         ms.Free;
      end;
   end;
end;

封裝是Delphi當中很常見的做法,但考量到可能有些讀者對於OOP還不熟悉,所以順便提一下,希望對大家有幫助。

執行後,畫面就如下圖:

在Google登入的回傳資料中,Subject就是使用者的ID,這個ID是跨應用程式的,如果我們有多個FireBase專案,在FireBase用Email跟帳號登入時,會回傳localId,同一用戶在每個FireBase專案的localId會不同,但Subject在不同的FireBase專案中則會是一致的。

希望這篇文章對大家有幫助,說不定過一陣子筆者會把這個專案錄成影片跟大家分享,畢竟看著影片一步步操作跟看文章還是有些不一樣。

以下先分享TJWTv2的原始碼,需要使用的話,請記得需要先下載TJOSE函式庫喔,或者下載FB4D,當中也有TJSOE函式庫。

{******************************************************************************}
{                                                                              }
{  JWTv2, Decode from JWT                                                      }
{  Copyright (c) 2024-2025 Dennies Chang                                       }
{  dennies@ms4.hinet.net, dennies226@gmail.com                                 }
{                                                                              }
{******************************************************************************}
{                                                                              }
{  Licensed under the Apache License, Version 2.0 (the "License");             }
{  you may not use this file except in compliance with the License.            }
{  You may obtain a copy of the License at                                     }
{                                                                              }
{      http://www.apache.org/licenses/LICENSE-2.0                              }
{                                                                              }
{  Unless required by applicable law or agreed to in writing, software         }
{  distributed under the License is distributed on an "AS IS" BASIS,           }
{  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.    }
{  See the License for the specific language governing permissions and         }
{  limitations under the License.                                              }
{                                                                              }
{******************************************************************************}
unit UnitJWTv2;

interface

uses System.SysUtils,
   System.StrUtils,
   System.DateUtils,
   System.Rtti,
   System.JSON,
   System.Generics.Collections,

   JOSE.Types.JSON,
   JOSE.Types.Bytes,
   JOSE.Core.Base,
   JOSE.Core.JWA,
   JOSE.Core.JWK,
   JOSE.Core.JWT,
   IdGlobal, IdCoderMIME, System.NetEncoding;

type
   TJWTClaimsExtend = class(TJWTClaims)
   private
      FuserEmail: String;
      FuserEmailVerified: Boolean;
      FuserLastName: String;
      FatHash: String;
      FAZP: String;
      FuserPhotoURL: String;
      FuserGivenName: String;
      FuserName: String;
      procedure SetuserEmail(const Value: String);
      procedure SetuserEmailVerified(const Value: Boolean);
      procedure SetatHash(const Value: String);
      procedure SetAZP(const Value: String);
      procedure SetuserGivenName(const Value: String);
      procedure SetuserLastName(const Value: String);
      procedure SetuserName(const Value: String);
      procedure SetuserPhotoURL(const Value: String);
   public
      property userEmail: String read FuserEmail write SetuserEmail;
      property userEmailVerified: Boolean read FuserEmailVerified
          write SetuserEmailVerified;
      property userName: String read FuserName write SetuserName;
      property userGivenName: String read FuserGivenName write SetuserGivenName;
      property userFamilyName: String read FuserLastName write SetuserLastName;
      property userPhotoURL: String read FuserPhotoURL write SetuserPhotoURL;
      property atHash: String read FatHash write SetatHash;
      property AZP: String read FAZP write SetAZP;
   end;

   TJWTv2 = class(TJWT)
   protected
      FClaims: TJWTClaimsExtend;

      procedure parseJWTString(sourceJWT: String);
   public
      constructor Create(strJWT: String); overload;

      property Claims: TJWTClaimsExtend read FClaims;
   end;

implementation

{ TJWTv2 }

constructor TJWTv2.Create(strJWT: String);
begin
   inherited Create(TJWTHeader, TJWTClaimsExtend);
   self.FClaims := TJWTClaimsExtend.Create;

   self.parseJWTString(strJWT);
end;

procedure TJWTv2.parseJWTString(sourceJWT: String);
var
   srcJWT, srcHeader, srcPayload, tmpStr: String;
   jsonHeader, jsonPayload: TJSONObject;
   bValue: Boolean;
begin
   srcJWT := sourceJWT;

   srcHeader := IdGlobal.fetch(srcJWT, '.');
   srcHeader := System.NetEncoding.TBase64Encoding.Base64String.Decode
       (srcHeader);

   jsonHeader := TJSONValue.ParseJSONValue(srcHeader) as TJSONObject;
   if jsonHeader.TryGetValue<String>('typ', tmpStr) then begin
      self.FHeader.HeaderType := tmpStr;
   end;

   if jsonHeader.TryGetValue<String>('alg', tmpStr) then begin
      self.FHeader.Algorithm := tmpStr;
   end;

   if jsonHeader.TryGetValue<String>('kid', tmpStr) then begin
      self.FHeader.KeyID := tmpStr;
   end;
   jsonHeader.Free;

   srcPayload := IdGlobal.fetch(srcJWT, '.');
   srcPayload := System.NetEncoding.TBase64Encoding.Base64String.Decode
       (srcPayload);
   jsonPayload := TJSONValue.ParseJSONValue(srcPayload) as TJSONObject;
   if jsonPayload.TryGetValue<String>('iss', tmpStr) then begin
      self.FClaims.Issuer := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('aud', tmpStr) then begin
      self.FClaims.Audience := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('sub', tmpStr) then begin
      self.FClaims.Subject := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('exp', tmpStr) then begin
      self.FClaims.Expiration := UnixToDateTime(StrToInt64(tmpStr));
   end;

   if jsonPayload.TryGetValue<String>('iat', tmpStr) then begin
      self.FClaims.IssuedAt := UnixToDateTime(StrToInt64(tmpStr));
   end;

   if jsonPayload.TryGetValue<String>('nbf', tmpStr) then begin
      self.FClaims.NotBefore := UnixToDateTime(StrToInt64(tmpStr));
   end;

   if jsonPayload.TryGetValue<String>('jti', tmpStr) then begin
      self.FClaims.JWTId := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('email', tmpStr) then begin
      self.FClaims.userEmail := tmpStr;
   end;

   if jsonPayload.TryGetValue<Boolean>('email_verified', bValue) then begin
      self.FClaims.userEmailVerified := bValue;
   end;

   if jsonPayload.TryGetValue<String>('picture', tmpStr) then begin
      self.FClaims.userPhotoURL := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('azp', tmpStr) then begin
      self.FClaims.azp := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('at_hash', tmpStr) then begin
      self.FClaims.atHash := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('given_name', tmpStr) then begin
      self.FClaims.userGivenName := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('family_name', tmpStr) then begin
      self.FClaims.userFamilyName := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('name', tmpStr) then begin
      self.FClaims.userName := tmpStr;
   end;
end;

{ TJWTClaimsExtend }

procedure TJWTClaimsExtend.SetatHash(const Value: String);
begin
   FatHash := Value;
end;

procedure TJWTClaimsExtend.SetAZP(const Value: String);
begin
   FAZP := Value;
end;

procedure TJWTClaimsExtend.SetuserEmail(const Value: String);
begin
   FuserEmail := Value;
end;

procedure TJWTClaimsExtend.SetuserEmailVerified(const Value: Boolean);
begin
   FuserEmailVerified := Value;
end;

procedure TJWTClaimsExtend.SetuserGivenName(const Value: String);
begin
   FuserGivenName := Value;
end;

procedure TJWTClaimsExtend.SetuserLastName(const Value: String);
begin
   FuserLastName := Value;
end;

procedure TJWTClaimsExtend.SetuserName(const Value: String);
begin
   FuserName := Value;
end;

procedure TJWTClaimsExtend.SetuserPhotoURL(const Value: String);
begin
   FuserPhotoURL := Value;
end;

end.

 

 

 



2023年7月28日 星期五

Delphi 開發 Linux 圖形介面程式: 以 Ubuntu 22.04 LTS 為遠端環境

Delphi + Linux

從 2002-2005,Delphi 還在 Borland 的那個年代, Borland 就已經開始著手想讓 Delphi 可以開發跨平台的應用程式了。在那個年代裡,Borland 以 Object Pascal 作為基礎語言,開發了 Kylix 這個整合開發環境,提供給開發人員可以開發 Linux 應用程式的工具。

然而好景不常,Kylix 3 在 2005 年之後,就沒有更新的版本,而 Delphi、C++ Builder 也在 2009 獨立出來由 CodeGear 維護與開發後續的版本,之後又歷經了 Embacadero, Idera 兩次的變革,直到 Delphi 10,才又提供了 Linux 開發的功能。

一開始 Delphi 10 提供 Linux 開發功能,僅限於 Linux 64bit 純文字模式,無法支援使用者圖形介面 (GUI),但 Delphi 的社群在全球仍然都很強大,隔不到一年,就有歐洲的團隊開發出了 FMXLinx 的套件,可以支援 GUI 程式的開發了。可惜的是,目前 Delphi 對於 Linux 的支援仍僅支援 64bit (AMD 或者 Intel 64), ARM 64 仍舊是不支援的。

但 Delphi 對於 MacOS 應用程式的支援較廣、較深,可以支援 Intel Chip, 也支援 Apple Chip 的機種,但 Apple Chip 的機種就等同於 ARM 64,所以 Delphi 支援 ARM 64 的 Compiler 理應不是什麼問題,如果 Delphi 能製作出 ARM 64 的 Linux 應用程式,那麼 Delphi 也就可以用來開發樹莓派或者其他 IoT 開發版使用的應用程式才對,我相信這一天不會太遠了,且讓我們拭目以待。

Linux 的環境多元

Linux 從 1990 年代首次發佈之後,全球各種社群、商業團體對它都有各自的愛好,所以也如同 Unix 的發展,有著各種不同面向的分支出現,有興趣的朋友可以參考以下的圖片,圖片是從這篇網路文章找到的。


目前在全球的使用者中,使用 Ubuntu 作為個人工作電腦的作業系統應該是所有 Linux 分支當中最多的,當然 Red Hat, CentOS, Fedora, Debian 也都不少,但 Ubuntu 應該還是比例最高的,佔了全球 Linux 使用率的 33.9% (資料來源: Enterprise Apps Today)。

所以,我們就用 Ubuntu 22.04 LTS 這個版本作為介紹的對象,這是筆者在 2023/7/24 下載 Ubuntu 的時候,Ubuntu 最新的官方穩定版本。

在 Linux 的作業系統生態中,安裝程式的工具軟體分為 apt/apt-get 跟 yum 兩大陣營,Debian 系列常使用 yum,CentOS, Ubuntu系列常使用 apt/apt-get 系列,兩個陣營安裝同樣的軟體套件時,套件的名稱或多或少會有點不太一樣,所以我先挑選了最多使用者的 Ubuntu,先涵蓋最多使用者的情境。

Embarcadero 的官方文件

Embacadero 的官方網站上,有提供 Linux 系統的開發環境設定(請點擊這裡),當中的大多數步驟都正確,但在網頁中的『準備你的 Linux 開發環境: Preparing Your Linux Development Environment』當中,所提到的安裝步驟,可能是適合 Ubuntu 20.04 的,在我安裝 Ubuntu 22.04 LTS 的時候,還是遇到了不少問題。

我安裝完成所有步驟之後,建立了一個多平台裝置應用程式,不做任何異動,只用 Delphi 初始建立的空專案來跑,就出現了 cannot find -lgcc_s 的錯誤,後來還出現過 cannot find -lz 等其他錯誤。

後來我發現 Linux 上面的連結程式 ld,除了基礎安裝的程式,也需要使用到 python 的函式庫,可是 Delphi PAServer 22.0 預設會認為系統上安裝的是 python 3.7, 但 Ubuntu 22.04 預設安裝的是 python 3.10, 所以 PAServer 22.0 尋找的函式庫檔案連結不存在,在 Delphi 裡面怎麼按 F9, 或者 Ctrl + F9 都沒有用。

Ubuntu 22.04 的逃生路徑

所以筆者直接整理了從乾淨的 Ubuntu 22.04 剛安裝好,到可以直接使用 PAServer 22.0 從 Delphi 連線過來讓 GUI 順利執行起來的所有安裝指令分享給大家,Delphi 上面的設定仍然請參考 Embacadero 的官方文件喔,但仍然有點需要注意的小地方待會再分享,以下就是所有指令:

1. sudo apt update && sudo apt upgrade
2. sudo apt install -y openssh-server
3. sudo systemctl restart ssh.service

1: 先把 Ubuntu apt 的軟體包更新、升級到最新.
2: 安裝 ssh server, 讓我們可以用 FileZilla, SSH terminal 連到 Ubuntu
3. 啟動 ssh server.

4. sudo apt install -y libgl1-mesa-glx libglu1-mesa libgtk-3-common libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 g++ linux-libc-dev zlib1g zlib1g-dev

4: 安裝所有需要的函式庫, Embarcadero 官方文件寫的是 libgstreamer1.0, 但 Ubuntu 22.04 的 apt 用的是 1.0-0, 所以這兩個都需要調整一下名字.

5. ls /usr/lib/x86_64-linux-gnu/libpython3.10.so.1

5: 確認 python3.10.so.1 存在於上述路徑

把 LinuxPAServer22.0.tar.gz 從 Delphi 開發機上傳到 Ubuntu 裡,我自己是把它放在使用者 dennieschang 目錄的桌面上, 路徑為 /home/dennieschang/Desktop/LinuxPAServer22.0.tar.gz

請把這一行的 dennieschang 換成您 Ubuntu 系統登入的使用者帳號名稱

6. cd /home/dennieschang/Desktop
7. tar xzvf LinuxPAServer22.0.tar.gz

6: 切換到桌面
7: 把 LinuxPAServer22.0.tar.gz 解壓縮, 此時會解出一個名為PAServer-22.0 的目錄

8. unlink /home/dennieschang/Desktop/PAServer-22.0/lldb/lib/libpython3.so 

8: 把 PAServer 22.0 裡面預設要連結的 libpython3.so 解除連結

9. sudo ln -s /usr/lib/x86_64-linux-gnu/libpython3.10.so.1 /home/dennieschang/Desktop/PAServer-22.0/lldb/lib/libpython3.so

    9: 把系統中 /usr/lib/x86_64-linux-gnu/libpython3.10.so.1 連結為 /home/dennieschang/Desktop/PAServer-22.0/lldb/lib/libpthon3.so

到了這裡,請在 Ubuntu 桌面上打開一個 Terminal (終端機),切換到 /home/dennieschang/Desktop/PAServer-22.0,然後執行 sudo ./paserver

按下 Enter 之後,可能會詢問您 root 的密碼,輸入 root 密碼後,則會請您設定 PAServer 的驗證密碼 (由您自行設定).

只有第一次要使用 sudo ./paserver,第一次如果沒有透過 sudo 來執行,Delphi 在透過 Connection Profile 連線到 Linux 的時候,可能會出現 DS Authentication Error 的錯誤訊息。

但用了第一次 sudo ./paserver 之後,之後就不用再 sudo 了,用您登入的帳號來執行 paserver 就行,而此時 Delphi 透過 Linux profile 傳送到 Linux 的應用程式,會被儲存在 /home/dennieschang/PAServer/scratch-files/您Windows上Profile名稱/ 這個目錄中,之後就無須再透過 Delphi,可以直接從 Ubuntu 執行這個應用程式了.

Parallels Desktop 注意事項

如果您使用的開發機是 Apple Chip 的 Mac 電腦,例如 M1 或者 M2 晶片系列。像筆者使用的是 Macbook Pro 2022 M2,目前是 2023 7月,我過去十多年慣用的 VMWare Fusion 還沒有 M1/M2 上面完整支援 Windows 11 Arm 的版本,所以我只好又買了一套 Parallels Desktop 18 來跑 Windows 11 Arm (不用想 Windows 10 Arm 了, 看起來微軟沒有要做這個了),然後在 Windows 11 Arm 上面安裝 Delphi Alexandria (仍然是 X86 版本喔),Windows 11 Arm 上面可以執行 Windows 32 的應用程式,除了驅動程式之外都可以。

Parallels Desktop 有一個壞處,就是預設會讓使用者把 MacOS 上面的桌面、文件等等預設的資料夾分享在 Parallels Desktop 安裝的 Windows 裡面。這個功能乍聽之下很貼心,為什麼會是壞處呢?

因為這樣的分享,在 Windows 11 Arm 裡面,會讓原本 C:\ProgramData\Users\我的使用者\Documents 這樣的路徑,變成 //Mac/home/dennieschang/Documents

而這樣的路徑,在 Windows 命令模式下,會被認為是『參數』,而非路徑,所以 ld 會找不到函式庫。

所以,如果您也是使用 Parallels Desktop 來跑 Windows 11 Arm + Delphi 的話,請記得在 Parallels Desktop 的設定當中,取消讓 Windows 使用 MacOS 的文件,取消後,再重新從 Linux 系統中下載一次 SDK 即可.

如果您也正在處理用 Delphi 開發 Linux 的環境, 提供上述的流程與指令給您參考.

2022年7月26日 星期二

Encrypt in Delphi, Decrypt in Laravel

Delphi provided cross-platform application ability from Delphi XE2, that happened in 2011 or 2012. With the feature, all the applications created by Delphi needs to communicate with client applications or server applications created by various IDE or technologies. RESTful API is the most frequently channel.

Although Delphi deliver package bundled Indy from Delphi 2005, and Indy did provided wonderful internet protocol capability, Indy did not provide the capability for encryption and decryption. The security protocol of Indy comes with OpenSSL, which is almost best security library in the world from early 1990 era.Even OpenSSL is so great, it's still difficult to deliver OpenSSL to various operating systems. For example, iOS, Linux. 

For solving the issue about deliver SSL/TLS secure channel to iOS/Linux, Embarcadero created NET package to provide secure channel feature in FireMonkey framework since XE5 or XE6. Developers can use NET package for RESTful API communication without any overheads. What we developers need to do is to create application, package it with the request by Apple/Linux platform, no more extra library issues happen.

Howerver, it's not so easy for encryption and decryption. In FireMonkey, all secure channel features depend on the platform native solutions. In macOS, Delphi used mac safari libraries for SSL/TLS. In iOS, Delphi used iOS native framework for SSL/TLS. And Delphi used Windows/Linux native API in Windows/Linux platform. What can we do to provide encrypt/decrypt feature?

I used to work with Delphi, Xcode, Android studio for creating client application, and used to create server side applications with PHP, and Laravel/PHPStorm in recent years. I do know that Laravel framework provide strong capability, and Apache will provide standard SSL/TLS channel, but I still wish to encrypt some sensitive content in the secure channel, and I believe some of you do think so.

And I tried to pick a popular symmetric encryption, AES is my choice. As we know, all encryption support various key length and block process mode. My choice is AES-256-CBC, which means AES with 256 bits length key and CBC mode.

For this choice, I need to solve 2 issues: 

  1. Picking a encryption/decryption library which supports Delphi, and no overhead in delivery package.
  2. The created cipher should be decrypted by Laravel.

Encryption package for Delphi

I studied information security in graduated school, and my master thesis is about CA and personal certificate management in 1998. Encryption is not big deal for me. It's a huge deal for me to create all the encryption algorithms by myself. Thank God, we have so many giants on the internet, and we need to stand on giant's shoulder, rather than create wheel from zero.

For this thought, I searched the key word "AES-256-CBC" from github in recent weeks, and so many relative projects pop-up, including LockBox-2, LockBox-3, and Delphi Encryption Compendium, all 3 projects can be found in Delphi GetIt package manager GUI.

After testing LockBox-3 and Delphi Encryption Compendium. I chose Delphi Encryption Compendium (I will used DEC for it in later article) as my further test candidate because I cannot get enough example or document for LockBox-3, and I got enough sample projects in DEC for FireMonkey with Console/GUI.

As we know, Key, initial vector (iv for short in later article), and padding byte are 3 fundmental factors for symmetric encryption. And 4th factor might be the encoding format for cipher. I checked that basic encryption/decryption feature works, and then I tried to modify the parameter/format/iv generation in the DEC sample project.

First of all, the encoding format. The DEC sample project requires HEX format key and iv, and HEX is the only one format it supports. The sample project allowed us to use any custom padding byte, it's very flexible for developer, and it's good for DEC.

But it's very hard to understand for developers who are not familiar to encryption/decryption.

I had chosen Laravel as server side application, hence I just try to make the communication between DEC and Laravel at this moment.

The profile of encrypt decrypt, encryptstring, decryptstring in Laravel

With XDebug and PHPStorm, I created a test project to trace into encrypt, encryption, decrypt, decryptstring function. Both of the encrypt functions invoke the openssl_encrypt function, and both of the decrypt functions invoke the openssl_decrypt function.

The 2 functions encrypt and encryptstring are the basically identical, encryptstirng will pack the string as a JSON Payload before invoke encrypt, and the reverse sequence happened in decryptstring and decrypt.

When you invoke encrypt/encryptstring, a random iv will be created, and the length of the iv will be different with the encrypt algorithm and key length. For example, AES-256-CBC will generate 16 bytes iv, and the OpenSSL library function will generate the 16 bytes iv randomly, encode it, combine in a JSON payload, and return it.

The result will be the following JSON payload:

{

    "iv":"KSMkCBozRhR3Lxl3MF4OHg==",

    "value":"Cw025jAKF0TLcjxIJ30Vzg==",

    "mac":"e414202d1ae168315bea8d5d68a4e8bc1de22c299f1dd50940e1c8419565d710",

    "tag":""

}

In the above JSON payload, there are 4 elements:

iv: generated by OpenSSL library, and encoded by base64 format.

value: the cipher, encoded by base64 format.

mac: SHA256.hmac(iv + value).base64String // just psudo code, generate hmac by (iv+value), and encoded it with base64.

tag: empty string. 

If the payload does not match above structure, decryption will be failed. Another important factor is padding byte, padding byte MUST following a rule. The "mac" field in above structure is stand for Message Authentic Code, which is generated by SHA256 hmac, the source date is iv contact value, take above data as example, the source string is: 

Cw025jAKF0TLcjxIJ30Vzg==KSMkCBozRhR3Lxl3MF4OHg==

SHA256 hmac requires a key to protect the source, and we will use the encrypt key as the key again. Howerver, DEC does not provide hmac function, hence we need to use the System.Hash.THashSHA2 in FireMonkey. it's a class function of THashSHA2 class, you need to invoke it as following way:

    hMacSrcString := base64IV + base64Cipher;
    hMacResult := THashSHA2.GetHMAC(hMacSrcString,
          TFormat_Base64.Decode(base64Key));

Particular Padding Byte in OpenSSL

In all symmetric encryption algorithms, padding is an important issue. Including DES, 3DES, RC4, RC5, AES, padding is an issue which could not be ignored.

That's caused by the encryption algorithms used block calculation with various block process modes (ECB, CBC, OFB, CFB), all data will be divided into blocks as same size, calculated with key to generate cipher blocks, and process these cipher blocks as completed cipher.

The padding issue will happen in last block, which might be shorter or equal to the block size. If it's shorter, padding byte will be padded in the tail, to make sure the last block size matches the defined block size.

Taking AES-256-CBC as example, the key size is 256 bits (32 bytes), and both iv and block size are 16 bytes. If last block length is less than 16 bytes, we need to pad it to 16 bytes with padding byte.

I mentioned that DEC opens all parameters for developers to config, it's flexible. As we need to make DEC production be decrypted by Laravel decryptstring, we need to figure out how openssl_decrypt handle the padding issue.

I tried every combination from DEC and Laravel, and one conclusion was found: the padding byte value will change with the last block length. If we need to pad 1 byte for the last block, the padding byte should be 0x1, please refer the following table:

Plain Data length
Padding size
Padding Byte value
1 byte or N* 16 + 1 bytes15 byte0xF
2 bytes or N* 16 + 2 bytes14 bytes0xE
3 bytes or N* 16 + 3 bytes13 bytes0xD
4 bytes or N* 16 + 4 bytes12 bytes0xC
5 bytes or N* 16 + 5 bytes11 bytes0xB
6 bytes or N* 16 + 6 bytes10 bytes0xA
7 bytes or N* 16 + 7 bytes9 bytes0x9
8 bytes or N* 16 + 8 bytes8 bytes0x8
9 bytes or N* 16 + 9 bytes7 bytes0x7
10 bytes or N* 16 + 10 bytes6 bytes0x6
11 bytes or N* 16 + 11 bytes5 bytes0x5
12 bytes or N* 16 + 12 bytes4 bytes0x4
13 bytes or N* 16 + 13 bytes3 bytes0x3
14 bytes or N* 16 + 14 bytes2 bytes0x2
15 bytes or N* 16 + 15 bytes1 bytes0x1

If you did not follow the rule in above table, the openssl_decrypt will return failure.

Conclusion

It's very frequently to use Delphi client to communicate with other server technologies. I share this article to make sure every Delphi developer not take time to find a FireMonkey way to do so.

Laravel or any other server side technologies adopt OpenSSL very frequently, so if you check this article out, and use the sample with your Delphi project, it will be very useful.

The sample code is uploaded to github, you can use it as free, I shared the project with Apache License 2.0.


用 Delphi 做 AES 加密, 在 Laravel 裡面解密

從 2011 年開始,Delphi XE2 提供了跨平台的開發能力之後,Delphi 需要跟各種平台溝通,就是日常會發生的事情。雖然 Delphi 從 Delphi 2005 開始,已經把 Indy 作為與 Delphi 或者 RAD Studio 一起預設安裝的網路套件,但對於加密、解密的能力,Indy 仍舊是使用外部函式庫來提供的,而且全世界很多平台對於加解密都是使用 OpenSSL 來製作的。

OpenSSL 是很優秀的函式庫,Indy 也是,但跨平台的時候,要把 OpenSSL 佈建到不同平台上就是一個很辛苦的事情,例如要把 OpenSSL 佈建到 iOS 上面,就不是一件簡單的事情。為了這個不簡單,Delphi 從 XE5 還是 XE6 的版本開始,自己在 FireMonkey 上面開發了一個 NET 套件,當中支援了 HTTPS 的各種加密模式 (SSLv2, SSLv3, TLS 1.0-1.3),通通都從 Delphi 的層級重寫起,這樣在佈建的時候就不用苦惱。但加密演算法就沒有這麼單純了!

我自己的慣用工具,Client 端是 Delphi, Xcode, Android Studio, Server 端這幾年則是 Laravel 7.x-8.x, 主要是以 PHP 為主。概念上是以 Laravel 做 API Server, 跟 Client 端的程式做溝通。雖然 Laravel 透過 HTTPS 也能提供一定的安全性,但我還是想把傳輸過程中一些比較敏感的資料額外加密一下。

而這幾年比較流行、又相對安全一點的加密演算法,就是 AES 了,我自己選擇的是 AES-256-CBC 這個組合,也就是 AES 加密法,金鑰用 256 bits,以 CBC 模式處理多重區塊加密。

因此,就有兩個要面對的課題:1. Client 端要找到能支援這個組合的套件,而且最好是免費的。2. 加密的結果要能夠讓 Laravel 順利解密。

支援 FireMonkey 的加密套件

對於碩士時研究加密演算法與 CA 的我來說,看懂各種加密演算法、參數與使用的方式不會造成困擾,但要我把每一種加密演算法從 OpenSSL 裡面分離出來重製一次,就很煩了。這個網際網路的年代裡,網路上有很多巨人,我們只要能站在巨人的肩膀上,很多事情就可以不用重頭做起。

最近在 GitHub 裡面,我用 AES-256-CBC 跟 Delphi 作為關鍵字,找到了很多的套件,包含 LockBox-2, LockBox-3, Delphi Encryption Compendium 這幾組可以從 Delphi 的 GitIt 套件管理員當中取得的工具。

測試了LockBox-3跟 Delphi Encryption Compendium 這兩組之後,我選擇了 Delphi Encryption Compendium (後文中簡稱 DEC) 來做測試,主要是因為 LockBox-3 的範例不多,而 DEC 支援 FireMonkey 之外,也提供了各種平台的 Console 跟 GUI 範例程式。

在加密工具的使用上,Key, IV, Padding 是三個基本又最重要的元素,第四個元素則是原文跟密文的編碼格式。確認 DEC 本身的加密、解密功能沒問題之後,我就開始著手改寫 DEC 範例程式中的各種功能。

首先是編碼格式,在 DEC 範例程式中,只提供了 HEX 格式的 Key 跟 IV, 也提供開發人員自訂的 Padding Byte (範例程式中稱為 FillerByte),這是一個靈活度超大的工具,但對於只熟悉 Delphi,不熟悉加密解密的開發人員來說,不啻於無字天書。 

但既然我的目標平台是 Laravel,那當然是先追求 DEC 跟 Laravel 之間的互通了。

Laravel 的 encrypt, encryptstring, decrypt, decryptstring 特性

透過 PHPStorm 的 Debug 功能,以 XDebug 進行 break point 的設定之後,我把 Laravel 的 encrypt, encryptstring 深入了解了一下,發現這兩個 function 最後都是呼叫了 OpenSSL 函式庫裡面的 openssl_encrypt 函式,差異只在於是否把加密結果作為物件對待,但我其實只需要對字串加密,所以直接針對 encryptstring 跟 decryptstring 進行了解。

在呼叫了這個 encryptstring 函式之後,會先隨機建立 Initial Vector(後文中簡稱 iv),iv的長度會根據 AES 金鑰的長度而有差異,256 bits 的 AES 加密演算法,其 iv 的長度是 16 bytes,而 OpenSSL 函式庫會隨機產生這 16 bytes 的 iv,並將之回傳,在 Laravel 當中,加密作業完成後,會把 iv, 加密結果這兩個 base64 的編碼字串連結起來,再用 SHA256 d搭配 AES 的 Key 製作 hmac 驗證碼,最終會把這三個值跟一個名為 tag 的空字串做成一個 JSON 格式的物件,再把這個物件做一次 base64 編碼,JSON 物件的格式如下:

{

    "iv":"KSMkCBozRhR3Lxl3MF4OHg==",

    "value":"Cw025jAKF0TLcjxIJ30Vzg==",

    "mac":"e414202d1ae168315bea8d5d68a4e8bc1de22c299f1dd50940e1c8419565d710",

    "tag":""

}

如果沒有做成這樣的結構,且 padding 字元沒有依照特定的規則,openssl_decrypt 在進行解密的時候就會發生解密失敗的問題。

上面提到的這個 mac, 是 Message Authentic Code 的縮寫,在 DEC 套件裡面並沒有提供,但在 FireMonkey 裡面的 System.Hash.THashSHA2 裡面有提供,只是做法有點特別,需要直接呼叫該類別的類別方法 GetHMAC:

    hMacSrcString := base64IV + base64Cipher;
    hMacResult := THashSHA2.GetHMAC(hMacSrcString,
          TFormat_Base64.Decode(base64Key));

特別的 Padding Byte (補位元)

在所有的加密演算法當中,幾乎所有的對稱金鑰加密演算法(Symmetric Encryption Algorithm) 都需要處理補位位元的問題。從早期美國限制出口的 DES, 3DES, 到廣泛使用在各種網路資料交換的 RC4, RC5, 目前最流行的 AES, 全都有這個問題需要處理。

這個問題的由來,是因為這些加密演算法的計算基準都是以單一區塊進行加密,再透過幾種不同的區塊處理模式 (ECB, CBC, OFB, CFB......) 把這些密文區塊做各種不同的處理之後,製作出原始資料的完整密文。而這些區塊的加密,在各種演算法中都有獨特的位元處理,因此需要每個區塊有固定的大小。

Padding的問題,會發生在原始資料的最後一個區塊。以 AES-256-CBC 這個組合來看,它使用 AES 加密演算法,金鑰長度是 256 bits,也就是 32 bytes,以CBC模式來處理每個加密區塊。這個組合的區塊長度是 16 bytes,所以當原始資料的最後一個區塊長度不到 16 bytes 時,就要把原始資料補足到 16 bytes。

前面提過 DEC 開放了所有的參數讓開發人員可以自行設定,這是非常靈活開放的函式庫做法,但要跟 Laravel 所使用的 OpenSSL 當中的 openssl_decrypt 函式能完整互通,就得先知道 openssl_encrypt 對這問題的處理模式才行。

我做了許多測試,發現 openssl_encrypt 所建立的 AES-256-CBC 密文,補位用的資料會隨著最後這個區塊資料的長度改變,所以如果單純的使用同一個位元資料來做補位,怎麼樣都不可能跟 Laravel 的 decryptstring 共通的。我找出了當中的規則,並且已經一一測試過,完全沒有問題了,規則如下表:

原始資料長度需補位長度必須使用的補位位元內容
1 byte 或者 N* 16 + 1 bytes15 byte0xF
2 bytes 或者 N* 16 + 2 bytes14 bytes0xE
3 bytes 或者 N* 16 + 3 bytes13 bytes0xD
4 bytes 或者 N* 16 + 4 bytes12 bytes0xC
5 bytes 或者 N* 16 + 5 bytes11 bytes0xB
6 bytes 或者 N* 16 + 6 bytes10 bytes0xA
7 bytes 或者 N* 16 + 7 bytes9 bytes0x9
8 bytes 或者 N* 16 + 8 bytes8 bytes0x8
9 bytes 或者 N* 16 + 9 bytes7 bytes0x7
10 bytes 或者 N* 16 + 10 bytes6 bytes0x6
11 bytes 或者 N* 16 + 11 bytes5 bytes0x5
12 bytes 或者 N* 16 + 12 bytes4 bytes0x4
13 bytes 或者 N* 16 + 13 bytes3 bytes0x3
14 bytes 或者 N* 16 + 14 bytes2 bytes0x2
15 bytes 或者 N* 16 + 15 bytes1 bytes0x1

如果補位規則沒有依照上面這個表格所列,openssl_decrypt 的解密會回報錯誤。

結語

Delphi 在各種作業系統中需要與其他各式各樣的 Server 端程式溝通已經是日常事務了,本篇的分享主要是希望讓大家在使用 Laravel 作為 Server 端程式,或者是需要使用 openssl 的 openssl_decript 在 Server 端解密時,可以順利的使用 Delphi 在 Client 端程式迅速的完成程式開發,希望大家會喜歡,這個專案我也放在 GitHub 上面了,請放心使用,如果有遇到什麼問題,也歡迎一起討論或者回覆給我。


2022年7月13日 星期三

Delphi 是否還應該保留對Goto/Label, With 的支援?

 Pascal 時代的遺產

熟悉或者使用了 Delphi 一小段時間的開發人員,都知道 Delphi 的語言是 Object Pascal,而 Object Pascal 是從傳統的 Pascal 演進而來的,這個歷史可能得回溯到 1993-1995 之間,Turbo Pascal 6, Turbo Pascal 7, 以及 Delphi 誕生的那個時代,提到那個年代,就不可避免的必須提到 Anders Hejlsberg 這位大神的貢獻,在 Borland,他改進了 Turbo Pascal 6.0 到 Delphi 7 之間的所有記憶體管理功能,之後轉戰微軟創造了 C# 程式語言與 .NET 平台,最近這幾年又創造了 TypeScript,如果過去三十年當中必須選擇一位我最崇拜的技術人員,非 Anders Hejlsberg 莫屬。

嘮叨了一下簡單的歷史,在 Pascal 時代延續到 Delphi 的 Object Pascal 語法,到目前的 Dephi 11 Alexandria,即使 Delphi 已經加入對於 Windows/macOS/iOS/Android/Linux 各種平台的支援,也加入了 Parallel (平行處理)  的功能,讓多核心的系統能真正發揮最大效能,但 Pascal 時代的遺產還是一直存在。

雖然對於傳統 Pascal 的語法支援可以讓大多數熟悉傳統 Pascal 的開發人員更快上手 Delphi,但是這些語法在新支援的功能中還是有些風險的。讓我們一起來看一下當中的問題。

絕對不要使用的語法:GOTO/LABEL

從 1998 年開始,筆者在攻讀博士的時候,也開始在學校兼課,在所有的程式開發課程中,只要開始講到 LOOP, Branch, Function, 都不免會講到這個最古老的語法:GOTO + LABEL,但筆者也一定會跟課堂的所有學生說『這個語法,禁用!在我課堂上的所有作業中,出現GOTO語法,一律 0 分』

GOTO語法在筆者唸高中剛學 BASIC 語言的時候,是不得不使用的功能,畢竟當時的作業系統是單工的DOS,也沒有多核心、多線程、網路或其他週邊可能來干擾,用 GOTO 讓程式碼得到迴圈的功能無可厚非。

但一旦引入了副程式的概念之後,不管是在 BASIC 裡面的 sub, 還是 Pascal 裡面的 procedure/function 或者 C 的 function, 更可怕的是在 OOP 當中的 Method,都有維護、管理變數、屬性生命週期的議題需要注意,即使不用 GOTO 這個惡魔語法,一個不慎都可能讓整個程式 crash,如果有傻傻搞不清楚的開發人員或者開發學員,在 function A 裡面設定了個 LABEL,在另一個 function B 裡面直接 GOTO 到該 LABEL,下場就是程式直接巴比Q了。

現在還有更嚴重的情況,就是在 Class A 的 Method 當中寫了 Label,但從 Class B 的某個 Event Handler 用了 Goto 跳過去,如果這時候 Class A 根本不曾被建立出來,整個程式也是直接 GG..... 或者是在匿名函式當中做了 Label, 從其他 Method 中 Goto, 執行中的程式也是直接 Bye 了.

如果是簡單的工具程式,幫大家做做簡單的計算機或者文字編輯就算了,但如果是排版、圖片處理的應用程式,在輸入了很多資料,還沒存檔之前,突然直接 crash,絕對會讓使用者直接問候開發人員或者提供應用程式的人家裡的祖宗十八代。

所以,修過筆者開課的所有同學都會牢牢的記住『不可以用GOTO,不然一律0分』這個鐵律,也請大家絕對不要用它。只有組合語言是個例外,因為不用 jumper,就沒得玩了!

不建議使用的語法: With

在傳統 Pascal 當中,Record 跟 Packed Record 常被用來作為自訂型別的結構,由於當中可以把多種型別組合成一個新的型別,在還沒有OOP的那個年代,這個做法已經接近現代OOP的屬性概念(當然當時還沒演進到資料封裝的能力、也還不能包含Method的程式碼在 record 裡面,這些功能都在 Delphi 10.4, 11 的這幾個版本當中被加入了)。

為了方便程式碼的撰寫,傳統 Pascal 提供了 With 這個語法,透過 With,開發人員可以不用把完整的變數名稱每次都 key 出來,省下了不少的打字時間。但是有一長就有一短,在With 當中可以一次標明多個變數,如果這幾個變數當中,出現了同名的屬性,在編譯的時候可能會有語焉不詳(ambigution)的情況發生,而在後續的維護中,也可能讓接手的ㄎ發人員弄錯該程式碼的原意,這是很糟糕的情況。

結論

綜合以上的說明,筆者的想法是『GOTO/LABEL』應該要從Delphi 的 Object Pascal 語法中移除,連出現都不該出現。而 With 語法則是建議編譯時出現提示警告,再過一兩個版本就不支援。希望 Embarcadero 能盡早為了 Delphi 的產出程式更穩定而做出這個修改。

您也有想要建議 Embarcadero 移除掉的傳統 Pascal 語法嗎? 歡迎在底下留言,筆者在參加 Embarcadero MVP 會議的時候會幫大家提出的。

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的角色了。
本篇文章的範例程式在此,請大家多多指教。