在寫完Object 672後,軟體的一個致命問題暴露出來,如果伺服器和用戶端都在內網環境下,即雙方都通過NAT來接觸外網,那麼此時用戶端是無法直接和伺服器交流的。
解決方案可以是:
1:把伺服器部署在不存在NAT的公網環境下。
2:使用常見的NAT穿透方法比如UDP打洞,或者STUN協議,但是這些方法都需要另一個已知的部署在公網環境下的伺服器。
3:就是這篇文章主要討論的方案,即不需要部署任何公網環境下的伺服器,通過路由器支援的UPnP協議來把內網的介面綁定到公網介面上。
UPnP的一大優勢就是不會像UDP打洞那樣,內網介面不需要先向外部介面發送UDP包來把綁定的公網介面告訴NAT,而且對於對稱NAT,UDP打洞是無效的。而UPnP一旦設定成功後,內網介面完全以綁定的公網介面暴露在公網中。
示範程式的運行是這樣的:
具體過程:
1. 輸出使用者Host Name和內網IP地址。
2. 通過UPnP把內網IP地址,內部連接埠號碼綁定到一個外部連接埠號碼上。
3. 通過HTTP從外部網站擷取公網IP地址。
4. 在內網中建立TCP Socket伺服器。
5. 建立另一個TCP Socket用戶端,然後嘗試串連上面擷取的公網IP和UPnP綁定的外部連接埠。
6. 如果一切沒有問題的話,此時會成功串連到伺服器,並收到回應!
在.NET環境下使用Windows的UPnP組件需要現在工程中引用:NATUPnP 1.0 Type Library,這是一個COM類庫。
下面開始逐句分析原始碼,原始碼均擬使用者已加入下列命名空間:
using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions; //提取IP時的正則
using System.Threading.Tasks; //Task
using System.IO; //讀取伺服器資訊用到StreamReader
using NATUPNPLib; //Windows UPnP COM組件
首先輸出本機(也就是內網介面資訊),這個很簡單了:
//擷取Host Name
var name = Dns.GetHostName();
Console.WriteLine("使用者:" + name);
//從當前Host Name解析IP地址,篩選IPv4地址是原生內網IP地址。
var ipv4 = Dns.GetHostEntry(name).AddressList.Where(i => i.AddressFamily == AddressFamily.InterNetwork).FirstOrDefault();
Console.WriteLine("內網IP:" + ipv4);
接下來就是設定UPnP了,首先需要初始化UPnPNAT類型(他是一個介面,只不過通過CoClass特性把執行導向UPnPNATClass類型),接著通過UPnPNAT的StaticPortMappingCollection來添加或者刪除UPnP綁定。注意在沒有路由器或者路由器的UPnP不開啟的情況下,StaticPortMappingCollection屬性可能會返回null。
代碼如下:
Console.WriteLine("設定UPnP");
//UPnP綁定資訊
var eport = 8733;
var iport = 8733;
var description = "Mgen測試";
//建立COM類型
var upnpnat = new UPnPNAT();
var mappings = upnpnat.StaticPortMappingCollection;
//錯誤判斷
if (mappings == null)
{
Console.WriteLine("沒有檢測到路由器,或者路由器不支援UPnP功能。");
return;
}
//添加之前的ipv4變數(內網IP),內部連接埠,和外部連接埠
mappings.Add(eport, "TCP", iport, ipv4.ToString(), true, description);
Console.WriteLine("外部連接埠:{0}", eport);
Console.WriteLine("內部連接埠:{0}", iport);
如果成功後,你應該可以在路由器的UPnP選項中看到這些資料:
設定好UPnP後,開始擷取外網IP地址,可以通過這個網址(http://checkip.dyndns.org/)。此時只需要發送一個HTTP GET請求,然後把返回的HTML中的IP地址提取出來就可以了,我們用正則來提取IP地址。
代碼如下:
//外網IP變數
string eip;
//正則
var regex = @"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b";
using (var webclient = new WebClient())
{
var rawRes = webclient.DownloadString("http://checkip.dyndns.org/");
eip = Regex.Match(rawRes, regex).Value;
}
Console.WriteLine("外網IP:" + eip);
OK,這個時候(如果一切順利的話),一切準備工作都做好了。我們有了:內網IP,內部連接埠,外網IP,外部連接埠。那麼就可以做一個TCP串連做測試了。
直接建立一個TCP服務端,代表在NAT下的伺服器,注意連接埠號碼要綁定到UPnP設定時的內部連接埠。
代碼:
//在NAT下的伺服器
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//綁定內網IP和內部連接埠
socket.Bind(new IPEndPoint(ipv4, iport));
socket.Listen(1);
//在另一個線程中運行用戶端Socket
Task.Run(() =>
{
Task.Delay(1000).Wait();
ClientSocket(eip, eport);
});
//成功串連
var client = socket.Accept();
//伺服器向用戶端發送資訊
client.Send(Encoding.Unicode.GetBytes("=== 歡迎來到Mgen的伺服器!===" + Environment.NewLine));
Console.ReadKey(false);
上面的ClientSocket方法就是用戶端的Socket串連執行,注意TCP協議是不保留資料邊界的,因此伺服器在發送訊息時,後面加了個分行符號(Environment.NewLine),然後在用戶端接受資料時,使用Socket –> NetworkStream –> StreamReader的嵌套組合,最後由StreamReader的ReadLine讀取資料,這樣確保會讀到最後的分行符號。
ClientSocket方法的執行代碼:
//ip參數和port參數是公網的IP地址,和UPnP中的外部連接埠
static void ClientSocket(string ip, int port)
{
try
{
Console.WriteLine("建立用戶端TCP串連");
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.Connect(new IPEndPoint(IPAddress.Parse(ip), port));
using (var ns = new NetworkStream(socket))
using (var sr = new StreamReader(ns, Encoding.Unicode))
{
Console.WriteLine("收到來自伺服器的回應:");
Console.WriteLine(sr.ReadLine());
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
OK。
原始碼下載
下載頁面
注意:連結是微軟SkyDrive頁面,下載時請用瀏覽器直接下載,用某些下載工具可能無法下載
原始碼環境:Microsoft Visual Studio Express 2012 for Windows Desktop
注意:原始碼不包含引用的外部類庫檔案