2017年6月9日 星期五

使用 TRESTClient 與 TRESTRequest 作為 HTTP Client 之一 (POST 字串參數)

在 Delphi XE 推出以前的年代,Delphi的發展方向是筆直朝向資料庫連結Windows 應用程式這個目標不斷前進的,從Delphi 1開始,到Delphi 7,Delphi奠定了VB Killer的外號,主要依靠的就是與資料庫的連接功能超越其他開發工具,而且超越的距離不只一個世代。

在 .NET開始發展,Delphi 8, Delphi .NET 不斷延遲的時候,與資料庫連接功能的方便性,仍然讓許多ERP廠商、軟硬體廠商持續愛用 Delphi.

直到 Web 開發與 App 開發超越了 Windows 應用程式的需求,VC, VB, Delphi 也開始隨著這波潮流,漸漸不再像 1990年代那麼廣受愛戴了。

在1990到 2010年之間,Delphi的網路連線功能,主要是藉由第三方元件來提供的,其中知名度最高,全球使用人數也最多的,應該就是 Indy 這套元件了。

這套元件在 2000 年前,叫做 WinShose,從第八版之後才改名為Indy,全球投入這套元件開發的開發人員,前後超過40人,從最基礎的 TCP/IP 功能到各種協定的Client與Server 端元件,筆者從中得益非常多,也開發了當中的DNS Server元件,對通訊協定的深入了解,Indy團隊可說是我不可或缺的師長。

隨著Delphi 工具走入了多平台開發的領域,Indy的侷限性也在這兩三年凸顯了出來,主要是在各個作業系統上面對於SSL與加密功能的支援無法緊密結合到作業系統內建的功能所致。

由於這個局限性,Delphi XE6開始,REST Client系列元件漸漸開始成為 Delphi 團隊的重點開發項目之一,所以我們從 Delphi XE6, Delphi XE7 之後的版本,可以發現到,使用 TRESTClient, TRESTRequest, TRESTResponse 系列組合的應用程式越來越多了,原廠也不斷鼓勵大家使用這套元件來提供 REST API 的連線功能。

REST API 的基礎是 HTTP 協定,大多以 HTTP 的 POST 方法把 JSON 編碼形式的參數傳遞到 Server,而 Server 再以 JSON 形式的參數回傳。

有時作法也會稍有變化,例如以 POST 方法把 Web-Form 編碼形式的參數傳遞給 Server,Server 再以 JSON 形式把資料回傳。

形式不一而足,但相同的是 HTTP 協定,最常用的也是以 POST 方法把參數傳給 Server 端。

今天要跟大家分享的主題,則是如何『使用 TRESTClient 與 TRESTRequest 作為 HTTP Client』。

前面已經提到過,在沒有 TRESTClient 整組元件以前,我們通常用的是 Indy 系列的元件來提供網路傳輸的功能,而現在有了 TRESTClient 整組元件,我們在行動平台上面就可以不需要另外配置函式庫,也能夠直接使用 https 與 server 連線了,在勒索病毒氾濫的今天,使用 https 會讓使用者比較安心。

POST作業說明

在 HTTP 的 POST 作業當中,參數跟 GET 作業一樣,Client端需要以 name=value&name2=value2 這種形式進行字串連接,再傳送到 Server 端去。Get 跟 POST的差異,在於 Get 方法是把所有參數當做 URL 的一部分,發送 HTTP GET 指令的時候,參數連同 URL  一起傳送。

而 POST 作業則是發送完 POST 指令後,把所有的參數與資料隨之傳送。依照 HTTP 型定的規範,GET 作業的 URL 是無法加密的,而且長度也有限制。因此,當需要傳遞的資料比較多,或者有機敏性,透過 HTTPS 傳送,就是最直接,也最方便,更是目前最通用的資料保護方法。

透過 POST 傳遞的參數,除了字串以外,還常常包含了檔案傳遞。我們很常看到在網頁上面以按鈕提供使用者選擇要上傳的檔案,也常看見提供以拖拉的方式把檔案上傳到遠端系統,尤其網頁郵件系統最常見到這種作法。

過去以 TIdHTTP 元件的 POST 方法發送參數時,呼叫方式如下:
var
    httpClient : TIdHTTP;
    url, params, httpResultStr : string;
begin
    url := 'http://mytestURL.com/test.php';
    params := 'name=我的名字&test=測試';

    httpClient := TIdHTTP.Create(self);
    try
         httpResultStr := httpClient.Post(url, params);
         showMessage(httpResultStr);
    finally
        httpClient.Free;
    end;
end;
  
這樣就可以把 params 字串的眾參數傳到 server 去了。理論上是這樣沒錯,但事情並沒有這麼簡單,在 HTTP 協定當中要傳參數給 Server,如果這些字串包含了特殊字元,則必須要先經過編碼,而編碼,是我們一生都需要與之對抗的繁複程序。

在 HTTP GET 方法當中,所有的參數除了要以 name=value 對每一個參數做描述,以及需要用 & 來連接各組參數,所有的 value 都需要以 url encode 來擺脫 URL 保留字元的糾纏。name 是否需要編碼呢?筆者建議,name 就乖乖的用英文吧,可以省下很多問題,以及處理這些可避免的問題所需要的時間!

那麼同樣的功能,以 TRESTClient 跟 TRESTRequest 要怎麼達成呢? 也很容易,作法如下:
1. 在 form 裡面放上 TRESTClient 跟 TRESTRequest 元件各一。
2. 把要傳遞的參數加到 TRESTRequest 實體的 params 屬性裡面去,這個屬性的型別是 TArray,所以可以存放多組參數。
3. 設定 TRESTClient 要傳送參數的URL,注意,URL 是設定在TRESTClient 哦!
4. 設定 TRESTRequest 要使用的傳輸方法,要設定為POST(因為我們正在介紹的是POST方法,請按照您的需求調整)
5. 呼叫 TRESTRequest 實體的 execute 方法,就可以把資料送去 server 了。

寫成 Delphi 的程式碼,會像以下這樣:
self.RESTClient1.BaseURL :=
          'http://我的網址/acceptNewCard.php';
self.RESTRequest1.Params.Clear;
self.RESTRequest1.Method := rmPOST;

self.RESTRequest1.AddParameter('test', self.EditCardNo.Text);
self.RESTRequest1.AddParameter('name', self.EditName.Text);

是不是很容易呢?的確很容易,裡頭的問題我們等下再深入探討,先來看 server 端要怎麼接收這些個參數,我們用 PHP 當範例,需要用 C#,JSP的讀者朋友們請自行轉譯喔⋯⋯

PHP Server 端接收 POST 參數的方法
從 1994 年開始,筆者就陸續撰文說明 HTTP POST 方法如何接參數,包含了CGI 用C,perl等語言實作,也包含 ISAPI 以 Delphi 實作,近幾年比較流行的是 PHP,JSP,C#,但 PHP 程式碼讀起來比較簡潔易懂,所以我就選擇 PHP 來做範例了。

在 PHP 裡面,透過GET 跟 POST 方法傳遞的參數,會被分別存放在 $_GET 跟 $_POST 這兩個陣列變數裡面,如果要偷懶,不想區分 GET 或 POST 方法,也可以從 $_REQUEST 這個變數試著讀取,當中有些安全性考量,最好勤勞一點,把它們區分開來。

以剛剛的例子來看,我們傳了一個名為 name,以及一個名為 test 的字串,用的是 POST 方法,所以我們得用以下兩個變數來存取這兩個字串:
  • $_POST['name']  這個變數可以取得 Client 端發送出來的 name  
  • $_POST['test']  這個變數可以取得 Client 端發送出來的 test
所以在 server 端,我們可以這樣寫,來抓到這兩個資料:
$name = $_POST["name"];
$test = $_POST["test"];

這樣寫會不會出問題呢? 答案是不會!如果使用者不輸入中文的話!

中文資料的編碼處理 

Delphi的開發人員絕大多數都是英美語系的人,我推測因此對於亞洲語系的文字顯示與傳輸比較沒有辦法完整的測試,但對於我們以中文為母語的人來說,從電腦誕生的那個年代,中文的顯示在每個作業系統、每種通訊協定的設計都比英文協定來的困難。

以上面的例子來看,如果我們直接拿這個例子來測試,筆者寫的範例程式,執行傳輸資料時,Server 所抓到的文字並不是正確的中文字,如下圖所示:

可以看得出來,傳到 server 的時候,server 是讀不到資訊的。這是怎麼回事呢?筆者屬於不認輸的好奇寶寶,使出了渾身解數,終於解決了這個問題。

寫過 Web 程式的讀者們一定可以立刻推測出來,這絕對是文字編碼出問題了,然而,是什麼地方出問題?可能出問題的點我列出來跟大家分享:
  • HTTP Client 的 charset 設定錯了
  • HTTP Request 裡面的文字編碼出問題
檢查的方向也是從這兩個關鍵點出發,第一點的檢測很容易,從Object Inspector檢查一下 RESTRequest1的設定:

AcceptCharset 確定是 UTF-8,沒錯,所以設定不是問題。

接著,就要從 Client 端發出去的資料下手了。有讀者或許會問『你怎麼不懷疑Server端程式寫錯了?』這個問題很好,之所以排除了這個問題,是因為同一個 Server 端的 PHP 程式,我用了 Postman 做過比對測試,回傳的結果是正確的,因此判定是 Client 端程式的問題。

接著筆者從 TRESTRequest.AddParameter 的各種多載形式來嘗試,AddParameter 這個方法有以下幾種多載的形式:
  • procedure AddParameter(const AName, AValue: string); overload;
  • procedure AddParameter(const AName: string; AJsonObject: TJSONObject; AFreeJson: boolean = true); overload;
  • procedure AddParameter(const AName, AValue: string; const AKind: TRESTRequestParameterKind); overload;
三種形式我都測試過,從 AddParameter 的執行中 trace 進去看各個可能性,由於 TRESTRequest 的參數中,Get 跟 Post 的加入方法是混用的,在程式碼裡面編碼又會有點不同。

在 REST.Client.pas 裡面,我曾經懷疑過編碼錯誤,所以也在執行階段對各個變數都進行觀察,最後,找到了原因與解法,至於過程,就不多說了,花了我兩天咧。

原因:編碼錯誤

用HTTP傳遞中文的時候,務必用UTF-8編碼,但一定要記得,中文字在作業系統中,都是UCS32編碼,這個現象在Windows裡面如此,在Android裡面如此,在iOS跟Mac我不確定,但處理方法是一樣的。

直接以 AddParameter('name', '中文測試'); 把參數加進 TRESTRequest 的時候,REST.Client.pas 的程式碼是把 '中文測試' 這個字串直接抓 Ord 的資料來做編碼的,然而,這個作法,是錯的!!!!!!!!

在 HTTP 傳遞 UTF-8 資料的時候,我們要傳遞的是 UTF-8 文字的二進位資料,但直接把 '中文測試' 這個字串直接拿來轉成二進位? 當時編碼並不是 UTF-8 啊,當然怎麼編碼送到 server 都是錯的!!!!

解法:AddParameter之前先做 UTF-8 轉換

這個解法,筆者第一天就已經想到,只是很想像以前改 Indy 程式一樣,直接改好 REST.Client.pas 之後,回饋給原廠使用,所以花了不少時間找方法,最後發現這個方法不用動到 REST.Client.pas,又能正確處理,就直接這麼跟大家分享了,寫成 Delphi 程式碼如下:
var
     nameStr : String;
begin
   ...
   nameStr := TIdURI.ParamsEncode(nameStr, IndyTextEncoding_UTF8);
   self.RESTRequest1.AddParameter('name', nameStr,     
             TRESTRequestParameterKind.pkGETorPOST,    
             [TRESTRequestParameterOption.poDoNotEncode]);
   ...
end;
在把字串透過 AddParameter 加入參數陣列之前,我先把字串做個 UTF-8 轉換,在這裡用的是 TIdURI 的類別方法 ParamsEncode,這個方法只有兩個參數,第一個參數是字串內容,第二個參數則是要文字編碼的種類,在這裡我選擇了 UTF8,寫法就是上面範例程式的第一行。

接著,在呼叫 AddParameter 的時候,我使用了多載形式當中的第三種,要求 AddParameter 處理資料的時候不要再動我的編碼,因為我已經處理好了。

這麼修改過之後,在各個作業系統當中,執行結果都是正確的,上面兩個圖以 Windows 作業系統為例,現在我們拿 Android 截圖來做為例子:


讀者可以看到右邊的截圖裡面,server 回傳的資料已經是正確的中文字了。

最後,我把 PHP 程式碼也附上來給大家參考:
<?php
date_default_timezone_set("Asia/Taipei");
header('Content-Type: charset=utf-8');

include("public/DBClassPDO.php");
$objDBPDO = new DBClassPDO();

$cardNum = $_POST["cardNum"];
$floorIdx = $_POST["floorIdx"];
$name = $_POST["name"];
$houseNum = $_POST["houseNum"];

$params = array();
$params['name'] = $name;
$params['cardno'] = $cardNum;
$params['houseNum'] = $houseNum;
$params['floorIdx'] = $floorIdx;
$params['picFilename'] = $fileSaveName;
$params['created'] = 0;

$result["resultCode"] = "0";
$result["result"] = "成功";
$result["sqlcmd"] = $name;

$jsonStr = json_encode($result);
echo $jsonStr;
?>
這是最基本的傳遞字串,下次再給大家示範怎麼傳檔案,透過 TRESTRequest來做也是很簡單的,TRESTRequest 跟 TRESTClient 的確是取代 TIdHTTP 的好工具。