前陣子有讀者問起如何用 PowerShell Invoke-WebRequest 呼叫 WCF?我大概知道原理,只要組合出符合 WCF 要求的 XML,直接用 POSTMan/WebClient/HttpClient/Invoke-WebRequest 呼叫 WCF 不是難事,但囉嗦的 SOAP XML 有礙健康,既然用不到就別深究了。

造化弄人,手邊專案眼看也要整合 WCF,由於只用到一個 WCF 方法,不是很想為此加入參照在專案生出一堆自動產生程式碼跟噁心 XML 設定,給了我挑戰用 WebClient 搞定 WCF 的動機。另一方面,掌握此技巧也有助於養成徒手測試 WCF 能力,一舉兩得還算值得,便趁連假自主訓練健體強身。

首先要弄個 WCF 服務來測試驗證,這才發現 Visual Studio 2019 預設不支援 WCF 需另外安裝(參考: Installing WCF In Visual Studio 2019 ),基於某種潔癖、偏見或心靈創傷之類的不明心理因素,我不想平日寫 MVC、WebAPI 及 .NET Core 的開發環境被玷污,沒在 VS2019 安裝 WCF 元件建立新專案,而是抓現成的 WCF 範例專案回來改。在 CodeProject 找到一篇 Implementing a Basic Hello World WCF Service,打算用它當基礎修改。

HelloWorldService 範例原本只有一個 String GetMessage(String name) 方法,我想測試傳入 enum 跟結果傳回自訂型別以涵蓋常見情境,於是修改 IHelloWorldService.cs 及 HelloWorldService.cs 如下。

IHelloWorldService.cs 加入 List<Product> QueryProductsByCatetory(Categories catg) 並宣告 public enum Categories 及 public class Product (正規做法此二者應自成獨立 .cs 檔,示範用途為求單純化就塞在 IHelloWorldService.cs 吧):

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.ServiceModel;
using System.Runtime.Serialization;

namespace MyWCFServices
{
    [ServiceContract]
    interface IHelloWorldService
    {
        [OperationContract]
        String GetMessage(String name);
        [OperationContract]
        List<Product> QueryProductsByCatetory(Categories catg);
    }

    public enum Categories
    {
        Unknown,
        Keyboard,
        Mouse,
        Set,
        Part
    }
    [DataContract]
    public class Product
    {
        [DataMember]
        public string ModelId { get; set; }
        [DataMember]
        public Categories Category { get; set; }
        [DataMember]
        public string Name { get; set; }
        [DataMember]
        public decimal Price { get; set; }
        [DataMember]
        public int StockQty { get; set; }
        public Product(string modelId, Categories catg, string name, decimal prz, int stkQty)
        {
            ModelId = modelId;
            Category = catg;
            Name = name;
            Price = prz;
            StockQty = stkQty;
        }
    }
}

HelloWorldService.cs 加上簡單的實作:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace MyWCFServices
{
    public class HelloWorldService : IHelloWorldService
    {
        public String GetMessage(String name)
        {
            return "Hello world from " + name + "!";
        }

        static List<Product> stock = new List<Product>()
        {
            new Product("K100", Categories.Keyboard, "經典茶軸鍵盤", 1200, 100),
            new Product("K250", Categories.Keyboard, "電競七彩背光鍵盤", 2500, 200),
            new Product("M30", Categories.Mouse, "藍牙簡報滑鼠", 990, 150)
        };


        public List<Product> QueryProductsByCatetory(Categories catg)
        {
            return stock.Where(o => o.Category == catg).ToList();
        }
    }
}

用標準「Add Service Reference」做法寫了 Client 程式,測試無誤:

開啟 Fiddler 想觀察 XML 傳輸內容,我有點傻眼:

我之前寫 WCF 探勘時很習慣擷取封包觀察 XML,但這回抓到的訊息本體被加密了。

檢查範例專案 web.config 發現端倪:

<services>
	<service name="MyWCFServices.HelloWorldService" behaviorConfiguration="MyServiceTypeBehaviors">
		<endpoint address="" binding="wsHttpBinding" contract="MyWCFServices.IHelloWorldService"/>
		<endpoint contract="IMetadataExchange" binding="mexHttpBinding" address="mex"/>
	</service>
</services>

依據之前的整理 - WCF 預設 Binding 介紹,我理解到:之前玩 WCF 測試我幾乎都是用 BasicHttpBinding,預設加密模式為 None;而 CodeProject 的範例專案用的是 wsHttpBinding,預設為 Message 加密,但可以透過設定調整加上 <binding><security mode="None" /></binding> 停用加密(伺服器端跟客戶端都要調整)。

下圖為加密與否的傳輸形態比較。加密時一次 QueryProductsByCatetory() 會產生四次往返(黃底部分);設定 <security mode="None" /> 後只剩一次,傳輸量變小只需 961 Bytes:

停用加密後,從 Fiddler 取得一次呼叫的 Request 與 Response XML 內容如下:

Request

<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing">
	<s:Header>
		<a:Action s:mustUnderstand="1">http://tempuri.org/IHelloWorldService/QueryProductsByCatetory</a:Action>
		<a:MessageID>urn:uuid:47f2ab1a-be57-4ea6-a0d4-8477bd33f259</a:MessageID>
		<a:ReplyTo>
			<a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address>
		</a:ReplyTo>
		<a:To s:mustUnderstand="1">http://192.168.50.7/HelloWorld/HelloWorldService.svc</a:To>
	</s:Header>
	<s:Body>
		<QueryProductsByCatetory xmlns="http://tempuri.org/">
			<catg>Keyboard</catg>
		</QueryProductsByCatetory>
	</s:Body>
</s:Envelope>

Response

<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing">
	<s:Header>
		<a:Action s:mustUnderstand="1">http://tempuri.org/IHelloWorldService/QueryProductsByCatetoryResponse</a:Action>
		<a:RelatesTo>urn:uuid:47f2ab1a-be57-4ea6-a0d4-8477bd33f259</a:RelatesTo>
	</s:Header>
	<s:Body>
		<QueryProductsByCatetoryResponse xmlns="http://tempuri.org/">
			<QueryProductsByCatetoryResult xmlns:b="http://schemas.datacontract.org/2004/07/MyWCFServices" xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
				<b:Product>
					<b:Category>Keyboard</b:Category>
					<b:ModelId>K100</b:ModelId>
					<b:Name>經典茶軸鍵盤</b:Name>
					<b:Price>1200</b:Price>
					<b:StockQty>100</b:StockQty>
				</b:Product>
				<b:Product>
					<b:Category>Keyboard</b:Category>
					<b:ModelId>K250</b:ModelId>
					<b:Name>電競七彩背光鍵盤</b:Name>
					<b:Price>2500</b:Price>
					<b:StockQty>200</b:StockQty>
				</b:Product>
			</QueryProductsByCatetoryResult>
		</QueryProductsByCatetoryResponse>
	</s:Body>
</s:Envelope>

掌握了 XML 格式,要改用 WebClient 或 PowerShell Invoke-WebRequest 直接呼叫 WCF 方法便不再是難事。以下為 C# 範例:

static void Main(string[] args)
{
    var xdReq = XDocument.Parse(@"<s:Envelope xmlns:s=""http://www.w3.org/2003/05/soap-envelope"" 
xmlns:a=""http://www.w3.org/2005/08/addressing"">
<s:Header>
<a:Action s:mustUnderstand=""1"">http://tempuri.org/IHelloWorldService/QueryProductsByCatetory</a:Action>
<a:MessageID>urn:uuid:47f2ab1a-be57-4ea6-a0d4-8477bd33f259</a:MessageID>
<a:ReplyTo>
	<a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address>
</a:ReplyTo>
<a:To s:mustUnderstand=""1"">http://192.168.50.7/HelloWorld/HelloWorldService.svc</a:To>
</s:Header>
<s:Body>
<QueryProductsByCatetory xmlns=""http://tempuri.org/"">
	<catg>Keyboard</catg>
</QueryProductsByCatetory>
</s:Body>
</s:Envelope>");
    XNamespace ns = "http://tempuri.org/";
    XNamespace ns_s = "http://www.w3.org/2003/05/soap-envelope";
    XNamespace ns_a = "http://www.w3.org/2005/08/addressing";
    var url = "http://192.168.50.7/HelloWorld/HelloWorldService.svc";
    xdReq.Descendants(ns_a + "To").First().Value = url;
    xdReq.Descendants(ns + "QueryProductsByCatetory")
        .First().Element(ns + "catg").Value = "Mouse";
    var wc = new WebClient();
    wc.Encoding = Encoding.UTF8;
    wc.Headers.Add(HttpRequestHeader.ContentType, "application/soap+xml; charset=utf-8");
    var resp = wc.UploadString(url, xdReq.ToString());
    var xdResp = XDocument.Parse(resp);
    XNamespace ns_b = "http://schemas.datacontract.org/2004/07/MyWCFServices";
    xdResp.Descendants(ns + "QueryProductsByCatetoryResult")
        .First().Elements().ToList()
        .ForEach(p =>
        {
            var propNames = "ModelId,Category,Name,Price,StockQty".Split(',');
            foreach (var propName in propNames) {
                Console.WriteLine($"{propName}: {p.Element(ns_b + propName).Value}");
            }
            Console.WriteLine("====");
        });
}

執行結果:

程式並無深奧之處,純粹考驗對 XDocument 物件操作的熟練度,並需要懂 XML Namespace 是怎麼一回事。透過這種做法,就能做到不事先 Add Service Reference,動態串接 WCF 取得結果。但因為涉及 XML 繁瑣操作程式碼精簡不到哪裡去,加上只適用 None 或 Transport (HTTPS) 加密情境(手工處理 Message 加解密的複雜度太吃力,不划算),跟標準做法相比有沒有比較簡單,就見仁見智囉。

Tips of how to use pure WebClient to call WCF method without add service reference in design time.


Comments

# by Ike

我後來用了 VS 裡的 WcfTestClient 工具,來取得 POST 用的 XML,不用擷取封包真是太好了

Post a comment