程式碼和Midas/DataSnap系統安全

資安的重要性和熱門相信是現在所有資訊人員都瞭解並被要求的, 雖然資安在作業系統設定層級是優先中被考量的, 但現在也在程式碼層次被愈來愈重視 .最近我收到許多Delphi朋友和用戶詢問程式碼安全性和系統弱點描掃的相關問題, 這2個重要的問題同時包含程式碼, 應用程式型態和作業系統設定的混合技術, 而且這還牽涉到政府對資安的法令要求. 我不是資安方面的專業, 但我希望在本文中簡單的就程式碼和應用程式型態方面討論如何儘量撰寫安全的程式碼並建設安全的應用程式型態, 讓Delphi和C++Builder的使用者有一些基本的瞭解. 

事實上Delphi和C++Builder在數年前就提供了Code Audits和Code Metrics這2個重要的功能, 能夠幫助程式開發人員在程式碼層級儘量撰寫出安全, 合乎規範的程式碼, 只是我知道使用的人不多, 而且遺憾的是EMBT在這2個功能上也沒有什麼進一步的改善, 多年來仍然只是維持一樣的功能. Code Audits和Code Metrics不足之處是沒有加入Rule-Based Scan, 如果有 Rule-Based Scan那麼我們就可以定義程式碼層級的掃描機制以便及時找出程式碼中不符合 OWASP的危險程式碼. 有關 Rule-Based Scan和 OWASP方面的說明我們在下一次中再討論, 本文先討論Midas/DataSnap伺服器弱點描掃的相關問題. 

最新有數位開發Midas/DataSnap系統的使用者紛紛和我連絡, 說明他們開發的 Midas/DataSnap系統中被用戶進行弱點描掃時版發現了許多的弱點, 但他們不知道如何解決這些資安問題, 因此希望我能夠幫助他們解決問題. 

我大概看了一下這些 Midas/DataSnap系統的弱點描掃報告, 大多是因為不支援TLS 1.2, 或是類似如下的許多弱點報告: 

Vulnerable’ cipher suites accepted by this service via the tlsv1.2 protocol 

TLS_RSA_WITH_3DES_EDE_CBC_SHA (SWEET32) 

 

其實第一點不支援TLS 1.2很容易解決, 因為這些 Midas/DataSnap系統大多是使用Delphi 10.4之前版本開發的, 因此不支援TLS 1.2.要解決這一點只需要昇級到Delphi 10.4.2之後的版本, 再於程式碼中加入使用TLS 1.2即可, 事實上我以前的文章也有說明過.  

procedure TForm1.FormCreate(Sender: TObject); 

var 

  LIOHandleSSL: TIdServerIOHandlerSSLOpenSSL; 

begin 

  FServer := TIdHTTPWebBrokerBridge.Create(Self); 

  LIOHandleSSL := TIdServerIOHandlerSSLOpenSSL.Create(FServer); 

  LIOHandleSSL.SSLOptions.Method := sslvTLSv1_2; 

  LIOHandleSSL.SSLOptions.CertFile := ‘C:\Test\WeakScan\server.crt’; 

  LIOHandleSSL.SSLOptions.RootCertFile := “; 

  LIOHandleSSL.SSLOptions.KeyFile := ‘C:\Test\WeakScan\server.key’; 

  LIOHandleSSL.OnGetPassword := OnGetSSLPassword; 

  FServer.IOHandler := LIOHandleSSL; 

  FServer.OnQuerySSLPort := OnQuerySSLPort; 

end; 

當然如果是Midas/DataSnap 伺服器的型態, 那麼開啟使用加密機制是更好的: 

至於上面 cipher suites的弱點則不是Delphi/C++Builder方面的問題, 而是屬於SSL和作業系統方面的設定問題. 基本上這些弱點是指SSL使用的加密機制有破解的方法, 因此不建議再使用而應該使用加密效果更好的機制. 了解原理之後就孚解決了, 我們只需要把不安全的機制關閉而使用加密效果戈良好的機制即可.  

但要在SSL和作業系統層級設定這些機制是蠻麻煩的, 不過我們可以使用一個非常方便的工具來幫忙. 讀者可以到: 

https://www.nartac.com/Products/IISCrypto

下載 IIS Crypto使用GUI來設定, 例如下面就是筆者使用它關閉TLS 1.0/1.1, 只使用更安全的TLS 1.2: 

另外要關閉不安全的 cipher suites, 只需要點選左上方的 Cipher Suites按鈕, 取消所有被弱點描掃報告提及的不安全 cipher suites, 在設定完成之後記得勾選Reboot, 點選Apply按鈕並重新開機:

重新開機並重新執行 Midas/DataSnap 伺服器, 並且到下面的網站測試您的 Midas/DataSnap 伺服器: 

https://www.ssllabs.com/ssltest/

就可以看到進行完上面的設定之後您的 Midas/DataSnap 伺服器安全評比了. 希望本文簡單討論程式碼, 應用程式型態和作業系統設定的混合技術對您開發安全的 Midas/DataSnap 伺服器有所幫助. 

發表留言

好用的TMarshaller, TMarshal和TPtrWrapper

很多問題不斷地重複出現,但常常也有更好的處理方式,這個現象也是蠻有趣的.

最近有幾位朋友和我說他們在網路論壇上看到一個提問,是關於Delphi要呼叫C的Dll相關問題. 由於這幾位朋友也不知道如何解決網路論壇上的這個提問,因此他們想要問問我的看法也想要我去看看這個論壇上的提問.

我本來想Delphi呼叫C/C++ Dll 的問題在網路上已經有無數的討論了, 因此隨便Google一下也應該可以找到答案, 應該沒有什麼好再去說明的, 因此也沒有放在心上. 後來有一位朋友直接把這個提問的連結發給我請我看看問題出在哪裡,因此我就點進去看了一下,沒想到卻看到了一些有意思的地方,也引出了我想為Delphi和C++Builder的使用者介紹的一些比較少人使用的有用類別,因此才有了這篇文章. 由於這些類別正是解決這個問題的好幫手,所以讓我們先來簡單看一下這個網路上的提問。

基本上這個問題是一位網友詢問他想使用Delphi呼叫C的Dll中如下的函式:

char* __stdcall AABBCCDD(char* sData1,char* InData,char* OutData); 

因此首先他把上述的C函式原型翻譯成大概如下的Delphi函式原型:

procedure _ AABBCCDD (sData1: PChar; InData : PChar; OutData : PChar); stdcall; External 'XXXXXX.dll'; 

然後他使用大概如下的程式碼呼叫Dll中C的函式,把兩個 TEdit元件的資料傳入,並且把TMemo的Text特性作為輸出資料的儲存目的地:

procedure TForm1.btn1Click(Sender: TObject); 
begin 
   _AABBCCDD (PChar(Edit1.Text),PChar(Edit2.Text),PChar(Memo1.Lines.Text)); 
end; 

然而這樣的程式碼並無法正確工作,因此這位網友詢問為什麼?

其實這段程式碼隱藏了許多的問題,大家可以看出來嗎?
這段程式碼的問題有:

  1. 當使用 PChar(Edit1.Text)時, Delphi的Compiler會先取得Edit.Text中的資料, 再把它拷貝到一個Compiler暫時產生的以nil結尾的字串中, 再把指到此字串的第一個字元的Pointer(PChar型態)當成第一個參數傳給_AABBCCDD. PChar(Edit2.Text)也是一樣的動作. 但問題是當離開 btn1Click到_AABBCCDD函式時 Compiler暫時產生的字串在理論上已消失, PChar(Edit1.Text)的Pointer理論上其實已經無效. PChar(Edit2.Text)也是一樣的道理.
  2. PChar(Memo1.Lines.Text)也和上面說明的一樣, 但它有另一個問題. 那就是如果此時Memo1.Lines.Text如果此時是空字串, 那麼 PChar(Memo1.Lines.Text)會直接產生一個nil pointer, 因此到了C的函式時應該會看到Access violation錯誤(如果在C函式中先檢查參數的話, C函式應該會立刻回覆錯誤碼而不會產生此Access violation錯誤):

所以基本上就算 Memo1中有資料而不會產生錯誤,那麼傳過去的資料也不對(因為第一個,第二個參數的字串已消失), C函式也無法藉由第3個參數傳回任何資料(因為C函式將把回傳資料寫入到已消失字串的地方).
那麼應該要怎麼改呢? 其實網路上已經有很多的討論了, 應該可以找到很多答案, 只是要把Delphi字串傳給其他語言使用的話, 特別是如果其他語言會修改字串的內容, 那麼就要很小心, 因為Delphi的字串有參考計數(reference counting)機制, 在Delphi文件中有如下的說明:

Note: PChar is inherently unsafe if used in combination with normal string values. PChar variables are not reference-counted and are not copy-on-written. This may result in corruption of the string values or memory leaks.

所以我的建議是如果要把字串資料傳遞給其他語言使用, 或是其他語言要把資料傳回Delphi, 那麼最好事先動態配置記憶體, 把資料拷貝到此記憶體, 再把記憶體pointer傳給Dll, 如果Dll要把資料回傳給Delphi(比如out參數), 那麼也是在Delphi中先配置記憶體, 並把pointer 傳給Dll 讓Dll 函式在其中寫入資料. 最後再利用Delphi的Compiler可以自動把pointer指到的資料拷貝到字串中的功能完成整個工作.

當然您可以使用GetMemory來動態配置記憶體, 使用完畢之後再呼叫FreeMemory來釋放記憶體,網路上有很多範例,我就不贅述了.但接下來就是本文的重點, 那就是現在我們有更好, 更安全的寫法, 那就是使用TMarshaller, TPtrWrapper和TMarshal這3個好用的類別/記錄.

TMarshaller是一個記錄型態, 在它的定義中有很清楚的說明:

// High-level aid for marshalling arguments to and from OS / native API invocation. 

  // Auto-destruction to ease use. 

  TMarshaller = record 
  ...

/

它正是我們在不同平台/API間傳遞參數和資料的好幫手.

由於TMarshaller是記錄型態, 因此可直接使用, 使用完就直接回收, 所以使用起來非常方便. 現在就讓我們使用這3個類別來實做上述類似的問題. 要動態配置記憶體, 我們可以直接呼叫AllocMem, 使用完畢之後可以呼叫FreeMem, AllocMem會回傳一個封裝pointer的TPtrWrapper物件, 此TPtrWrapper物件可以做為Dll中C函式的Out參數寫入目的地.

class function TMarshal.AllocMem(Size: NativeInt): TPtrWrapper;

接著可以使用TMarshaller的As…..方法把要傳給Dll中C函式的資料拷貝到此TPtrWrapper物件中

    function AsAnsi(const S: string): TPtrWrapper; overload;
    function AsAnsi(S: PWideChar): TPtrWrapper; overload;
    function AsAnsi(const S: string; CodePage: Word): TPtrWrapper; overload;
    function AsAnsi(S: PWideChar; CodePage: Word): TPtrWrapper; overload;
    function AsUtf8(const S: string): TPtrWrapper; overload; inline;
    function AsUtf8(S: PWideChar): TPtrWrapper; overload; inline;
    function AsRaw(const B: TBytes): TPtrWrapper;

例如上面的AsAnsi可以把參數S字串的資料拷貝到一個產生的TPtrWrapper物件中, 因此M.Ansi(Edit1.Text)就可以把Edit1元件中Text特性的文字拷貝到TPtrWrapper物件中.
最後_ AABBCCDD需要的參數型態是PChar, 那麼我們可以呼叫TPtrWrapper的ToPointer方法自動把TPtrWrapper封裝的pointer傳入即可.

function TPtrWrapper.ToPointer: Pointer;

最後要把Dll寫入Out參數的資料讀回Delphi的字串, 我們只需要呼叫TMarshal相關的讀取函式即可輕鬆完成:

  // Copy string from buffer; Len = number of characters to read from Ptr, or -1 to read until null terminator.
    class function ReadStringAsAnsi(Ptr: TPtrWrapper; Len: Integer = -1): string; overload;
    class function ReadStringAsAnsi(CodePage: Word; Ptr: TPtrWrapper; Len: Integer = -1): string; overload;
    class function ReadStringAsUnicode(Ptr: TPtrWrapper; Len: Integer = -1): string;
    class function ReadStringAsUtf8(Ptr: TPtrWrapper; Len: Integer = -1): string;

在上面的函式定義中我們可以看到ReadStringAsXXXX函式可以從參數TPtrWrapper中讀取資料並回傳Delphi的字串,這正是我們需要的, 而ReadStringAsXXXX的參數也正是上面我們傳給Dll中C函式的Out參數..

所以下面就是使用上述3個類別/記錄完成在Delphi和C的Dll間處理資料的程式碼:

001    procedure TForm5.btnCallCDllClick(Sender: TObject); 
002    var 
003      M: TMarshaller; 
004      pOutData : TPtrWrapper; 
005      sOutData : String;
006    begin 
007      try 
008        Memo1.Lines.Clear; 
009        pOutData := TMarshal.AllocMem(2048); 
010        _ AABBCCDD ( M.AsAnsi(Edit1.Text).ToPointer, 
011                     M.AsAnsi(Edit2.Text).ToPointer, 
012                     pOutData.ToPointer ); 
013      finally 
014        sOutData := TMarshal.ReadStringAsAnsi( pOutData, -1); 
015        TMarshal.FreeMem(pOutData); 
016      end; 
017      Memo1.Lines.Add(sOutData); 
018    end;  

在C的這邊我只是寫了一個簡單的把2個傳入的字串資料相加並寫回第3個輸出參數的示範函式.

void NationEcTrans(char* strUrl,char* InData,char* OutData)
{

  String sTemp =  String(strUrl) + " + " + String(InData);
  StrPCopy(OutData , sTemp);
}

下面的畫面顯示了成功傳入資料並從C的Dll取得回傳資料:

TMarshaller, TPtrWrapper和TMarshal有許多非常實用的方法,各位可以參考使用, Cheers!

發表留言

Delphi和C++Builder的路線發展圖 

最近有越來越多的Delphi和C++Builder的客戶和朋友一直在詢問我 RAD Studio最新的路線發展圖, 不過說真的我也不知道. 英巴在發表了下面的路線發展圖之後就沒有後續的更新: 

https://blogs.embarcadero.com/rad-studio-november-2020-roadmap-pm-commentary/

不過在最近舉辦的 DelphiCon 2021 活動中Marco 終於透漏了RAD Studio 的路線發展方向,在Marco的 演講中: 

Keynote: Beyond 10x – The Future of Development with Delphi (DelphiCon 2021 replay) 

說明了RAD Studio 現在, 接下來, 和稍後的發展方向, 我建議有興趣的朋友可以去聽一聽這個演講. 如果 你沒有時間去聽這個演講的話,那們下面兩張圖就是從其中擷取出來的,有興趣的朋友可以自己看一下 RAD Studio接下來發展的方向, 並聆聽Marco的說明. 

在影片中有說明安全港條款, 但是做為參考還是有一定的價值, 也希望對詢問Delphi和C++Builder路線發展的朋友有所幫助.

發表留言

在Android瀏覽器中動態載入檔案和放大內容 

最近連續收到幾位使用Delphi開發Android app的朋友詢問類似的問題,這些問集中在 Android瀏覽器的功能.有數位朋友詢問如何在Android瀏覽器中動態載入HTML檔案,也有一些朋友想詢問如何能夠放大Android瀏覽器的內容。 

由於我本身的測試APP是可以在Android瀏覽器中動態載入HTML檔案,因此我也奇怪為什麼有一些朋友無法進行這樣的工作,因此我去查了一下Android開發人員手冊的說明, 找到了下面的內容: 

The default value is true for apps targeting Build.VERSION_CODES.Q and below, and false when targeting Build.VERSION_CODES.R and above. 

經過確認之後我才知道這些朋友是使用SDK 29以上的版本, 而根據  Build.VERSION_CODES.Q ,29以上的版本是設定為false,不允許WebVew載入檔案, 必須呼叫JWebView介面的JWebSettings的setAllowFileAccess為True才可以解除這個限制. 

但我查了TWebBrowser的設定, 它無法讓程式師取得內部的Android WebView物件, 無法取得JWebBrowser和 JWebSettings介面, 因此無法在客戶端的程式碼中直接改變這個設定。而另外一些朋友詢問的有關如何放大Android瀏覽器內容的問題也是類似的, 開發人員必須要能夠取得JWebBrowser和JWebSettings才可以做到, 因為Delphi的 TWebBrowser元件在內定上並沒有開放這些功能. 

由於我們沒有辦法直接藉由TWebBrowser元件存取JWebBrowser和JWebSettings介面, 因此解決的方法就是只好直接修改 FMX.WebBrowser.Android 程式單元, 下面就是解決這兩個方法的步驟: 

1. 把C:\Program Files (x86)\Embarcadero\Studio\22.0\source\fmx 下的FMX.WebBrowser.Android.pas 檔案拷貝到專案目錄中 
2. 把拷貝來的FMX.WebBrowser.Android.pas加入到專案中 
3. 開啟專案中拷貝來的FMX.WebBrowser.Android.pas並修改如下 

在TAndroidWebBrowserService.Create建構元中: 

  ... 
  FWebView.getSettings.setAllowFileAccessFromFileURLs(True); 
  FWebView.getSettings.setAllowUniversalAccessFromFileURLs(True); 
  FWebView.getSettings.setMediaPlaybackRequiresUserGesture(False); 
  FWebView.getSettings.setAllowFileAccess(true);                                    FWebView.getSettings.setMediaPlaybackRequiresUserGesture(False); 

  //請加入下面的程式碼以便在Android瀏覽器中載入檔案或是放大內容 
  FWebView.getSettings.setAllowFileAccess(true);             //允許載入檔案 
  FWebView.getSettings.setBuiltInZoomControls(True);         //允許放大內容 
  FWebView.getSettings.setDisplayZoomControls(False);        //允許放大內容 

  

  FWebViewContainer := TJRelativeLayout.JavaClass.init(TAndroidHelper.Context); 
  ...

   
4. 重新Build專案 

完成上面步驟之後我請幾位朋友測試得到的結果都是可以在Android瀏覽器中完成上面兩項工作, 因此我想上面加入的程式碼是正確的, 希望上面說明的內容對於有同樣需求的朋友提供幫助. 

發表留言

DataSnap和TLS 1.2

最近有好幾位朋友寫信詢問我有關DataSnap和支援TLS 1.2的問題, 看來很了很多朋友還在使用DataSnap啊. 由於我已經有一段時間沒有使用DataSnap了, 記憶有點模糊, 因此也去查了一下原因順便更新一下對於DataSnap的記憶.

在RAD Studio 1.4.1的Bug修正文件中可以看到下面的說明:

TLS 1.2 support for Datasnap standalone or windows service server

這證明在1.4.1之後的DataSnap已經可以支援TLS 1.2了, 那麼為什麼有許多使用DataSnap的朋友在使用/支援TLS 1.2上仍然有問題呢? 其實原因很簡單, 可能只是這些朋友沒有注意一, 二個小細節.

要讓1.4.1之後的DataSnap支援TLS 1.2, 使用DataSnap的朋友需要完成下面的步驟:

  1. 更新您的OpenSSL到最新的版本. 例如您可以到 fulgan面新您的OpenSSL https://indy.fulgan.com/SSL/.
  2. 在您的DataSnap Sever主表單的OnCreate事件中應該可以類似看到如下的程式碼:
procedure TForm1.FormCreate(Sender: TObject);
var
  LIOHandleSSL: TIdServerIOHandlerSSLOpenSSL;
begin
  FServer := TIdHTTPWebBrokerBridge.Create(Self);
  LIOHandleSSL := TIdServerIOHandlerSSLOpenSSL.Create(FServer);
  LIOHandleSSL.SSLOptions.CertFile := '';
  LIOHandleSSL.SSLOptions.RootCertFile := '';
  LIOHandleSSL.SSLOptions.KeyFile := '';
  LIOHandleSSL.OnGetPassword := OnGetSSLPassword;
  FServer.IOHandler := LIOHandleSSL;
  FServer.OnQuerySSLPort := OnQuerySSLPort;
end;

您需要加入如下一行的程式碼來啟動支援TLS 1.2:

LIOHandleSSL.SSLOptions.Method := sslvTLSv1_2;

這樣應該就可以支援TLS 1.2了, 也祝各位使用順利.

發表留言

程式碼的樂趣2-好玩又要跑的快

程式語言好玩的地方就是會不斷的進化, Delphi雖然這幾年在語言本身改變的比較慢, 但也是有進步並且吸收其他語言的特點並融入Pascal原本較正式的語法結構中, 這次讓我們討論一下目前比較流行的鏈式寫法.

所謂鏈式寫法的意思是指藉由物件導向語言可回傳本身(Self/this)而能夠在一行程式碼中不斷的呼叫方法或是存取特性完成工作, 而不需要像以往要拆分成多行程式碼的寫法, 也許讓我們用一個例子來說明會讓您更容易瞭解. 由於我個人認為鏈式寫法特別適合使用在結構資料和像Pascal語言這種有嚴謹語法架構的應用, 因此就讓我們使用JSON來說明.

假設我們想產生如下的簡易JSON資料, 其中只有JSON物件, JSON陣列和JSON Pair架構形成的資料:

{
     "版本": {
         "RAD Studio 10": "Seattle",
         "功能": [
             "支援大型記憶體模式",
             "支援Clang 32編譯器"
         ]
     },
     "版本": {
         "RAD Studio 10.2": "Tokyo",
         "功能": [
             "Linux 64 (Delphi)",
             "Android原生元件"
         ]
     }
 }

那麼在Delphi中有好幾種方法可以完成這個工作, 在本文中就讓我們使用TJSONObjectBuilder類別.下面就是使用TJSONObjectBuilder來產生如上資料的程式碼:

procedure TForm12.btnJSON1Click(Sender: TObject);
 var
   AWriter : TJsonTextWriter;
   sw : TStringWriter;
   jObjBuilder : TJSONObjectBuilder;
   jPair : TJSONCollectionBuilder.TPairs;
   jElement : TJSONCollectionBuilder.TElements;
 begin
   sw := TStringWriter.Create;
   AWriter := TJsonTextWriter.Create(sw);
   AWriter.Formatting := TJsonFormatting.Indented;
   jObjBuilder := TJSONObjectBuilder.Create(AWriter);
   try
     jObjBuilder.BeginObject;
     jObjBuilder.ParentObject.BeginObject('版本'); 
     jObjBuilder.ParentObject.Add('RAD Studio 10', 'Seattle'); 
     jObjBuilder.ParentObject.BeginArray('功能'); 
     jObjBuilder.ParentArray.Add('支援大型記憶體模式'); 
     jObjBuilder.ParentArray.Add('支援Clang 32編譯器'); 
     jObjBuilder.ParentArray.EndArray; 
     jObjBuilder.ParentObject.EndObject; 
     
     jObjBuilder.ParentObject.BeginObject('版本'); 
     jObjBuilder.ParentObject.Add('RAD Studio 10.2', 'Tokyo'); 
     jObjBuilder.ParentObject.BeginArray('功能'); 
     jObjBuilder.ParentArray.Add('Linux 64 (Delphi)'); 
     jObjBuilder.ParentArray.Add('Android原生元件'); 
     jObjBuilder.ParentArray.EndArray; 
     jObjBuilder.ParentObject.EndObject; 
     jObjBuilder.ParentObject.EndObject; 
     
     mmResult.Text := sw.ToString;
 finally
     jObjBuilder.Free;
     AWriter.Free;
     sw.Free;
   end;
 end;

上面的寫法是正確而且正式的寫法, 我想大多數程式師也是使用這樣的寫法, 即使Delphi的函式庫本身也是這樣寫, 所以沒什麼問題, 但我們可以使用鏈式寫法來改善它, 讓它看起來更具階層架構, 更像JSON本身的資料架構.

仔細看看上的程式碼,

  1. 它先在最外層建立一個JSON物件,
  2. 再於其中為每版Delphi建立一個版本JSON物件,
  3. 在版本JSON物件中再建立JSON Pair,
  4. 建立JSON陣列,
  5. 最後於JSON陣列中建立功能元素.

如果我們檢查上面jObjBuilder.BeginObject方法原型, 可以看到如下的程式碼:

function TJSONObjectBuilder.BeginObject: TJSONCollectionBuilder.TPairs;

BeginObject就回傳TPairs物件, 那我們不是剛好可以使用此回傳TPairs物件完成上面3的步驟嗎?

而TPairs的Add方法又回傳本身的TPairs:

function TJSONCollectionBuilder.TPairs.Add(const AKey, AValue: string): TPairs;

因此我們可以呼叫TPairs的BeginArray方法完成上面4的步驟, 而BeginArray方法又回傳TElements物件, 又剛好可以使用來完成上面5的步驟:

function TJSONCollectionBuilder.TPairs.BeginArray(const AKey: string): TElements;

由於TJSONCollectionBuilder這種使用階層又回傳本身物件的設計方法, 因此我們可以把上面正確但是有點繁瑣的寫法改寫成如下的語法:

001    procedure TForm12.Button3Click(Sender: TObject);
 002    var
 003      AWriter : TJsonTextWriter;
 004      sw : TStringWriter;
 005      jObjBuilder : TJSONObjectBuilder;
 006      jPair : TJSONCollectionBuilder.TPairs;
 007      jElement : TJSONCollectionBuilder.TElements;
 008    begin
 009      sw := TStringWriter.Create;
 010      AWriter := TJsonTextWriter.Create(sw);
 011      AWriter.Formatting := TJsonFormatting.Indented;
 012      jObjBuilder := TJSONObjectBuilder.Create(AWriter);
 013      try
 014        jObjBuilder.BeginObject
 015    
 016          .BeginObject('版本')
 017            .Add('RAD Studio 10', 'Seattle')
 018              .BeginArray('功能')
 019                .Add('支援大型記憶體模式’)
 020                .Add('支援Clang 32編譯器’)
 021              .EndArray
 022          .EndObject
 023    
 024          .BeginObject('版本')
 025            .Add('RAD Studio 10.2', 'Tokyo')
 026            .BeginArray('功能')
 027              .Add('Linux 64 (Delphi)')
 028              .Add(' Android原生元件')
 029            .EndArray
 030          .EndObject
 031    
 032        .EndObject;
 033    
 034        mmResult.Text := sw.ToString;
 035      finally
 036        jObjBuilder.Free;
 037        AWriter.Free;
 038        sw.Free;
 039      end;
 040    end;

仔細看一下上面014~032的程式碼的寫法架構是不是和前面JSON資料本身非常的類似? 而且和Pascal使用的嚴謹架構語法也非常的契合, 看起來非常的舒服又直覺.

但更重要的是第2種寫法會讓你的程式跑起來更快. 為什麼?

這需要一點組合語言的基礎. 簡單說明如下:

  1. 在var中宣告的變數和物件都是儲存在記憶體中
  2. 當使用物件呼叫方法/存取變數時需要將物件移到暫存器(register)中, 由於eax暫存器是最快的所以通常儘可能的是使用(移到)eax
  3. 物件在呼叫方法/存取變數之後再移回原本的記憶體中

掌握了上面3個原則之後讓我解說一下上面寫法1和寫法2的差別.

看看這2行程式碼:

001 jObjBuilder.BeginObject;
002 jObjBuilder.ParentObject.BeginObject('版本');

001使用jObjBuilder呼叫BeginObject, 所以先把jObjBuilder移到eax, 再呼叫BeginObject方法. 由於下一行又是使用jObjBuilder存取ParentObject特性, 因此eax暫時不用再移回jObjBuilder原本的記憶體, 可以立刻使用在002, 而002中的ParentObject又需要使用eax, 所以再次要執行上面3個原則, 所以如果我們看看真正執行的組合語言程式碼, 可以下面的結果:

001    uMainForm.pas.122: jObjBuilder.BeginObject;
002    00663400 8B45EC           mov eax,[ebp-$14]  原則1,2
003    00663403 E88C79FEFF       call TJSONObjectBuilder.BeginObject
004    uMainForm.pas.124: jObjBuilder.ParentObject.BeginObject('版本');
005    00663408 8B45EC           mov eax,[ebp-$14]  原則1,2
006    0066340B E8185FFEFF       call TJSONCollectionBuilder.GetParentObject
007    00663410 8945E0           mov [ebp-$20],eax  原則3
008    00663413 BA24366600       mov edx,$00663624
009    00663418 8B45E0           mov eax,[ebp-$20]
010    0066341B E8D46FFEFF       call TJSONCollectionBuilder.TBaseCollection.BeginObject
011    00663420 8945DC           mov [ebp-$24],eax  原則3 

瞭解了之後我們就可以開始比較了, 看看寫法1加入JSON Pair的方式:

jObjBuilder.ParentObject.Add('RAD Studio 10', 'Seattle');

它的組合語言如下:

001    uMainForm.pas.125: jObjBuilder.ParentObject.Add('RAD Studio 10', 'Seattle');
002    00663423 8B45EC           mov eax,[ebp-$14]  原則1,2
003    00663426 E8FD5EFEFF       call TJSONCollectionBuilder.GetParentObject
004    0066342B 8945D8           mov [ebp-$28],eax  原則3
005    0066342E B938366600       mov ecx,$00663638
006    00663433 BA54366600       mov edx,$00663654
007    00663438 8B45D8           mov eax,[ebp-$28]  原則1,2
008    0066343B E8E076FEFF       call TJSONCollectionBuilder.TBaseCollection.Add
009    00663440 8945D4           mov [ebp-$2c],eax  原則3

而鏈式寫法:

  .BeginObject('版本')     
    .Add('RAD Studio 10', 'Seattle')

它的組合語言如下:

001    0066349A E88176FEFF       call TJSONCollectionBuilder.TBaseCollection.Add
002    0066349F 8945E4           mov [ebp-$1c],eax        原則1,2
003    006634A2 BA2C366600       mov edx,$0066362c
004    006634A7 8B45E4           mov eax,[ebp-$1c]        原則3

你可以看到鏈式寫法的最終組合語言被高度的最佳化, 大量的簡少了mov/call的次數, 所以鏈式寫法的執行速度當然會快一點.

所以如何利用鏈式寫法? 很簡單, 在你的類別的方法儘可能回傳Self/this. 最後讓我們使用一個小範例來說明. 下面是TFunClass, 它定義了數個方法.

TFunClass = class(TObject)
     private
       FSalary : double;
       FTaxRate : double;
     public
       constructor Create(const dSalary, dTaxRate : double);
       function RaiseSalary(const dRate : double) : TFunClass;
       function DeductTax : TFunClass;
       function AmIRich : Boolean;
   end;

我們可以在實作方法時讓方法回傳Self, 這是為了儘可能讓物件停留在eax中而不要移回記憶體:

function TFunClass.RaiseSalary(const dRate: double): TFunClass;
 begin
   FSalary := FSalary * (1 + dRate / 100.0);
   Result := Self;
 end;

現在讓我們使用2種寫法來測試, 第一種是一行呼叫一個方法的正式寫法:

procedure TForm12.Button4Click(Sender: TObject);
 var
   aFunObj : TFunClass;
 begin
   aFunObj := TFunClass.Create(32000.00, 0.2);
   try
     aFunObj.RaiseSalary(20.0);
     aFunObj.DeductTax;
     if (aFunObj.AmIRich) then
       mmResult.Lines.Add('終於達到了')
     else
       mmResult.Lines.Add('尚待努力!');
   finally
     aFunObj.Free;
   end;
 end;

它的組合語言使用了3個move:

001    uMainForm.pas.218: aFunObj.RaiseSalary(20.0);
 002    00663FA2 6800003440       push $40340000
 003    00663FA7 6A00             push $00
 004    00663FA9 8B45F8           mov eax,[ebp-$08]
 005    00663FAC E8EFF3FFFF       call TFunClass.RaiseSalary
 006    uMainForm.pas.219: aFunObj.DeductTax;
 007    00663FB1 8B45F8           mov eax,[ebp-$08]
 008    00663FB4 E8B7F3FFFF       call TFunClass.DeductTax
 009    uMainForm.pas.220: if (aFunObj.AmIRich) then
 010    00663FB9 8B45F8           mov eax,[ebp-$08]
 011       00663FBC E81BF3FFFF       call TFunClass.AmIRich
 …

第2種是鏈式寫法, 在一行中呼叫我們需要的方法, , 直覺又像Pseudocode:

procedure TForm12.Button1Click(Sender: TObject);
 var
   aFunObj : TFunClass;
 begin
   aFunObj := TFunClass.Create(32000.00, 0.2);
   try
     if (aFunObj.RaiseSalary(20.0).DeductTax.AmIRich) then
       mmResult.Lines.Add('終於達到了')
     else
       mmResult.Lines.Add('尚待努力!');
   finally
     aFunObj.Free;
   end;
 end;

它的組合語言只使用了1個move, 成功的完成我們把物件儘量留在eax中的目的而加快執行速度:

001    uMainForm.pas.121: if (aFunObj.RaiseSalary(20.0).DeductTax.AmIRich) then
002    006637E6 6800003440       push $40340000
003    006637EB 6A00             push $00
004    006637ED 8B45F8           mov eax,[ebp-$08]
005    006637F0 E8ABFBFFFF       call TFunClass.RaiseSalary
006    006637F5 E876FBFFFF       call TFunClass.DeductTax
007    006637FA E8DDFAFFFF       call TFunClass.AmIRich

如何? 鏈式寫法產生的組合語言都更直覺, 更容易瞭解和更快.

如果您暫時無法消化上面的內容或是不熟悉組合語言的話, 沒有關係, 您可以稍後再回頭想想. 目前您可以記得的是使用鏈式寫法配合Delphi語法不但可以讓程式碼看起來更具架構性, 更酷, 最重要的是會讓您的程式跑起來更快速, 一舉3得, 那麼何樂而不為呢?

寫到最後又讓我回想起Zen of Assembly Language這本書, 它是我年輕時最喜歡的書籍之一, 我的很多組合語言知識都是從這本書學到的.

思緒在瞬時回到clock cycles之間. Have Fun!

1 則迴響

程式碼的樂趣

在我手機中最常使用的下載APP除了Line之外應該就是BBC Leaning English了, 因為除了可以聽英國腔之外還可以複習一些早已遺忘的文法. 我們日常的工作也是一樣, 除了努力寫程式碼完成工作之外, 有時看看別人的程式碼可以讓我們溫故知新, 也可以增加自身的寫碼水準.

我們在使用某些程式碼或是函式時由於太常使用而成習慣, 因此也把它們視為理所當然, 但其實其中有許多細節值得我們思考, 也考驗我們對程式碼的瞭解程度, 就讓我們看看10.4版中的FreeAndNil這個函式.

在10.4版之前FreeAndNil的原型宣告如下:

procedure FreeAndNil(var Obj); inline;

它的實作如下:

procedure FreeAndNil(var Obj);
 {$IF not Defined(AUTOREFCOUNT)}
 var
   Temp: TObject;
 begin
   Temp := TObject(Obj);
   Pointer(Obj) := nil;
   Temp.Free;
 end;
 {$ELSE}
 begin
   TObject(Obj) := nil;
 end;
 {$ENDIF}

10.4之前的FreeAndNil是可以正常工作的, 但問題是由於它的原型是宣告成未指定的參數型態, 因此我們也可以使用下面的程式碼讓它產生錯誤:

Var
   iValue : Integer;
 begin
   …
   iValue := 293849034;
   FreeAndNil(iValue); //產生存取錯誤
 End;

由於未指定參數型態, 因此Compiler不會檢查傳入的參數是否是TObject和衍生物件, 而它的實作程式碼卻把參數當成TObject及衍生物件釋放, 因此當然出錯.

那麼看看10.4改的原型宣告:

procedure FreeAndNil(const [ref] Obj: TObject); inline;

從10.4宣告的原型, 請各位想想
1. 參數const [ref] Obj: TObject到底是什麼意思?
2. FreeAndNil函式的功能是把傳入參數的物件釋放再把參數指標設定為Nil;但再回頭看看它使用了const 編譯指令, 代表參數obj是不能改變的, 那為什麼FreeAndNil可以把它設定為Nil而不會產生編譯錯誤呢?

也許各位可以停下來想一下, 上面問題的原因和答案.

如果您實在不記得或是無法回答上面2個問題的答案, 也沒問題, 反正Delphi的好處就是提供原始程式碼, 讓我們看看它的實作:

001    procedure FreeAndNil(const [ref] Obj: TObject);
 002    {$IF not Defined(AUTOREFCOUNT)}
 003    var
 004      Temp: TObject;
 005    begin
 006      Temp := Obj;
 007      TObject(Pointer(@Obj)^) := nil;
 008      Temp.Free;
 009    end;
 010    {$ELSE}
 011    begin
 012      Obj := nil;
 013    end;
 014    {$ENDIF}

關鍵答案就是在007行. 先讓我們回答上面的問題1, 在Delphi程式語言中物件參數是passed by reference, 因此正常上當傳遞物件時是不需要加上[ref]編譯指令的, 但如果在物件參數之前加上[ref]指令則代表是傳遞指到物件reference的reference(即C/C++的**ptr), 因此const [ref] Obj: TObject是指一個指到物件reference的reference參數, 而此物件reference是常數(不可改變).

OK, 現在回答問題2, 由於const [ref] Obj: TObject中的Obj是不可改變的, 但FreeAndNil又需要把Obj設定為nil, 那麼怎麼解決這個困境? 答案就是007行, FreeAndNil使用了程式技巧解決這個困境, 007行中的@Obj先取得Obj物件的指標(TObject**), 再用(Pointer(@Obj)把此指標轉為一般的指標(Ptr**), 再使^運算元取得一般的指標的內容(ptr*), 最後外面的TObject()再把一般的指標的內容(ptr*)轉為TObject*, 即物件的reference, 最後把它設它為nil而成功的把數參Obj設定為Nil. 如果您想問為什麼007行不直接使用:

Obj := Nil;

這是因為const [ref] Obj: TObject中的const不允許您改變Obj的內容, Obj是一個常數參數. 因此如果使用Obj := Nil;編譯器會產生錯誤:

[dcc32 Error] E2064 Left side cannot be assigned to

嚴格來說FreeAndNil違反了const [ref] Obj: TObject宣告語義, 因為它的確改變了常數參數的內容, 其實一個比較符合Delphi語義的FreeAndNil應該是如下:

001    procedure FreeAndNilNew(var Obj : TObject);
 002    {$IF not Defined(AUTOREFCOUNT)}
 003    var
 004      Temp: TObject;
 005    begin
 006      Temp := Obj;
 007      Obj := Nil;
 008      Temp.Free;
 009    end;
 010    {$ELSE}
 011    begin
 012      Obj := nil;
 013    end;
 014    {$ENDIF}

var代表參數Obj可以改變(需要設定為Nil), 此版又和10.4版前的原型類似. 但此版的問題是var Obj : TObject代表也是要傳遞一個指到物件reference的reference參數,因此如果你使用下面的程式碼呼叫上面版本的FreeAndNilNew:

procedure TForm12.Button3Click(Sender: TObject);
 var
   ss : TStringStream;
 begin
   ss := TStringStream.Create('A String is created!');
   FreeAndNilNew( ss);
 …

那麼會得到如下的編譯錯誤:

[dcc32 Error] E2033 Types of actual and formal var parameters must be identical

問題3 :為什麼? 如何修正?

其實答案在前面已經說了, 此版的FreeAndNilNew需要一個指到物件reference的reference參數, 而上面是傳入ss, 它是一個指到物件(TStringStream物件)的reference而不是指到物件reference的reference. 因此要修正編譯錯誤, 我們必須使用如下的程式碼:

procedure TForm12.Button3Click(Sender: TObject);
 var
   ss : TStringStream;
 begin
   ss := TStringStream.Create('A String is created!');
   FreeAndNilNew( TObject(ss) );
 …

這樣就可以正確編譯和執行了, 但為什麼? 因為現在傳給FreeAndNilNew的參數是一個指到TStringStream物件的reference(ss), 而TObject(ss)又是一個TObject的reference, 而TObject在做為參數時又是pass by reference, 因此TObject(ss)做為FreeAndNilNew的參數就成為一個正確的指到物件reference的reference參數.

因此10.4在一個有點違反語義但可以使用簡潔語法的FreeAndNil, 因為它可以讓我們使用:

  FreeAndNil(ss);

以及一個語義正確但語法比較麻煩的

FreeAndNilNew( TObject(ss) );

10.4選擇了前者, 當然如果您沒有想過這些細微差異的話, 你可能從沒想過這些問題, 但其中的樂趣就在於這些細節之中, 而這些細節又包含了一些進階的程式碼技巧.

到此這篇文章已經有點長了, 因此也該結束了, 為了提供結尾的樂趣, 您可以想想下面的FreeAndNil2版本又和前面有什麼不同嗎? 差異是什麼?

001    procedure FreeAndNil2([ref] Obj: TObject);
 002    {$IF not Defined(AUTOREFCOUNT)}
 003    var
 004      Temp: TObject;
 005    begin
 006      Temp := Obj;
 007      Obj := nil;
 008      Temp.Free;
 009    end;
 010    {$ELSE}
 011    begin
 012      Obj := nil;
 013    end;
 014    {$ENDIF}

最後如果是使用下面的版本, 又有什麼不同? 執行正確嗎? 為什麼會正確或不正確呢? 這就留給各位動動腦吧.

procedure FreeAndNil3( Obj: TObject);
 {$IF not Defined(AUTOREFCOUNT)}
 var
   Temp: TObject;
 begin
   Temp := Obj;
    Obj := Nil;
   Temp.Free;
 end;
 {$ELSE}
 begin
   Obj := nil;
 end;
 {$ENDIF}

最後再回到BBC, 前幾天有一篇討論下面2句的差異:
I forget to lock the door.
I forget locking the door.

天啊, 我真的已把國中英文文法忘記了, 歲月不饒人記憶力真的衰退了.

發表留言

RAD Studio企業元件包中Winsoft的JSON處理效率

在最近RAD Studio的優惠活動中有贈送企業元件包, 其中包含了Winsoft的元件組. 由於去年曾使用過Winsoft的Camera for Android元件,其效率遠勝過FireMonkey的Camera元件, 使用的非常滿意, 因此在看到Winsoft也有JSON的類別庫時我也很好奇其執行效率如何, 特別是10.4.x的TJSONReader/TJSONWriter已經大幅改善了執行效率.

因此我去Winsoft的網站下載了其JSON元件庫試用版, 來試試它和10.4.x的TJSONReader/TJSONWriter差異. 我沿用上次文章的例子改用Winsoft JSON類別來處理. 相對來說Winsoft的TJsonReader類別在使用上設計的非常簡單和直覺, 我個人覺得設計的很好, 它可以藉由一個repeat迴圈自然的處理整個JSON文件:

procedure TForm11.DoJSONRead;
var
  JsonItem: TJsonItem;
  Prefix: WideString;
begin
  with TJsonReader.Create(Memo1.Lines.Text) do
  try
    repeat
      JsonItem := Read;
      case JsonItem of
        itNumber:
          begin
            if (MemberName = 'latitude') then
            begin
              sl.Add('LineNumber : ' + Row.ToString);
              sl.Add('latitude = ' + NumberValue.ToString);
            end;
            if (MemberName = 'longitude') then
              sl.Add('longitude = ' + NumberValue.ToString);

          end;
      end;
    until JsonItem = itEof;
  finally
    Free;
  end;
end;

在使用相同的JSON文件資料的情形下Winsoft的JSON的類別庫的確比10.4.x的TJSONReader/TJSONWriter更快, 其處理效率達到了令人非常滿意的程度.在下面的執行結果畫面中我再次執行了10.3.3, 10.4.1和Winsoft JSON類別庫的執行結果, 各位可看到是01:682比00:022比00:016.

我不知道RAD Studio贈送的企業元件包有沒有包含Winsoft JSON類別庫的原始程式碼(因為我沒有此企業元件包), 如果沒有的話我也建議有興趣的朋友可以購買它的原始碼版, 如此一來就可以在日後昇級RAD Studio之後繼續使用它了.

1 則迴響

RAD Studio的JSON處理效率

在數月前發表RAD Studio 10.4時看著英巴的slides, 心理知道那些slides是屬於銷售的內容, 那些是屬於行銷的而那些是屬於產品技術的. 在最近幾年英巴年的產品發表中最常看到的就是又有多少品質改善又有多少效率改善, 因此當我看到10.4 slides中有提及到JSON的效率改善就比較注意, 這是因為我本身也需要經常處理大量的JSON文件.

RAD Studio 10.4的slides中提及TJsonReader的速度提昇了50%以上, 因此我很好奇這是真的嗎? 我會好奇是因為在數年前當RAD Studio對JSON開發支援之後主要是以TJSONAncestor為主的類別群來處理JSON資料, 但坦白說RAD Studio的TJSONAncestor類別群其執行效率只是堪用而已, 說不上快. 後來RAD Studio又加入了TJSONReader/TJSONWriter等新類別來提供JSON支援, 但其執行效率也是稍微好一點, 達不到業界中等效率. 但是在RAD Studio的其他類別也開始大量使用JSON處理資料之後, 例如WebClient, REST等, RAD Studio必須要改善JSON的處理效率, 否則其他相關RTL的功能都快不起來.

為了證實10.4處理JSON真的比較快, 我看了一下10.3.x和10.4的相關原始碼, 果然10.3.x的TJsonReader和10.4的TJsonReader類別有所不同, 10.4加入了一個buffer類別TJsonFiler:

RAD Studio 10.3.xRAD Studio 10.4.x
TJsonReader = class(TJsonLineInfo)TJsonReader = class(TJsonFiler)
TJsonLineInfo = class(抽象類別)TJsonFiler = class(TJsonLineInfo)
TJsonLineInfo = class(虛擬類別)

之後我隨手寫了一個使用TJSONReader的小測試程式, 我從網路上產生了一個比較複雜的JSON類別, 再使用TJSONReader來分析和擷取其中的部分資料:

procedure TForm11.DoJSONRead;
var
  ss : TStringStream;
  streamreader: TStreamReader;
  jsonreader: TJSONTextReader;
begin
  ss := TStringStream.Create(Memo1.Lines.Text);
  streamreader := TStreamReader.Create(ss, TEncoding.UTF8);
  try
    jsonreader := TJSONTextReader.Create(streamreader);
    try
      while jsonreader.Read do begin
        case jsonreader.TokenType of
          TJsonToken.Float:
            begin
              if jsonreader.Path.EndsWith('latitude', true) then
              begin
                sl.Add('LineNumber : ' + jsonreader.LineNumber.ToString);
                sl.Add('latitude = ' + jsonreader.Value.AsExtended.ToString);
              end;
              if jsonreader.Path.EndsWith('longitude', true) then
                sl.Add('longitude = ' + jsonreader.Value.AsExtended.ToString);
            end;
        end;
      end;
    finally
      jsonreader.Free;
    end;
  finally
    streamreader.Close;
    streamreader.Free;
    ss.Free;
  end;
end;

分別以10.3.3和10.4編譯執行得到下列的執行結果:

Wow, 從這個測試來看10.4的TJSONReader果然比10.3.3快了許多倍, 令人印象深刻, 看來新加入的TJsonFiler緩衝類別發揮了作用, 讓RAD Studio使用TJSONReader處理JSON讀取的速度到達了業界中上的水準了.

我也順便再檢查了TJsonWriter類別, 也看到10.3.3和10.4.x的差別:

RAD Studio 10.3.xRAD Studio 10.4.x
TJsonWriter= classTJsonWriter= class(TJsonFiler)
TJsonFiler = class(TJsonLineInfo)
TJsonLineInfo = class(虛擬類別)

由此可推10.4.x使用TJsonWriter處理JSON寫入的速度也將快上許多.

如果您的程式/系統有處理大量的JSON資料, 改用10.4.x的TJSONReader/TJSONWriter會讓您值回花費的更改時間. 更不用說如果您使了DataSnsap/RAD Server處理大量的JSON資料, 那更是強烈建議您改用10.4.x的TJSONReader/TJSONWriter, 把舊的TJSONObject, TJSONArray等類別改成使用TJSONReader/TJSONWriter來處理JSON資料吧.

發表留言

英巴新消息更新

從10.4推出以及C++Builder的PM寫了有關C++Builder未來的發展方向後我的確被很多C++Builder的朋友轟炸, 但我也沒法說什麼, 因為那畢竟是英巴的官方聲明. 幾天前英巴又公告了C++Builder最新的發展, 請C++Builder的朋友一定要去看看英巴釋出的資訊:

此外英巴最近又將舉辦一系列有關技術的線上活動, 有興趣的朋友可以參考下面的資訊:

發表留言