URL 編解碼問題

嗯,這看起來只是小問題,可是....

可是這小問題有些細節,若不整理一下,恐怕沒多久就會忘掉大半。

寫 JavaScript 時曾用過 encodeURIComponent,寫 .NET 程式時,印象中都是用 HttpUtility.UrlEncode()。最近寫 web API 時突然想到,如果需要一個 URL 編碼函式,它接受一個 URL 字串(裡面包含查詢參數),並且傳回適當編碼過的、伺服器端可正常處理的 URL 字串,已知的現成函式可以做到嗎?我的意思是,就像我們在瀏覽器的網址列輸入一整串完整 URL,按 Enter 之後,瀏覽器會把我們輸入的文字經過適當編碼之後再傳送給伺服器。這應該很簡單吧?

比如底下這個 URL:
http://whotest.com/a b/c?phone=+886&name=M. Tsai&msg=say:'hello?!'

Chrome 和 IE 實際送出的 HTTP 請求都會轉成:

http://whotest.com/a%20b/c?phone=+886&name=M.%20Tsai&msg=say:'hello?!'

為了盡量涵蓋各種狀況和夠多的特殊字元(如需要完整資訊請看 RFC 3986),我在測試網址中的路徑部分加了空白字元("a b"),在查詢參數的部分則加了 空白字元、加號「+」小數點「.」、冒號「:」、問號「?」、驚嘆號「!」、單引號「'」等等。

.NET Framework 提供了以下幾個方法供我們選擇(有漏掉嗎?):
  1. HttpUtility.UrlEncode()
  2. HttpUtility.UrlPathEncode()
  3. Uri.EscapeUriString()
  4. Uri.EscapeDataString()

編號 2 號的 HttpUtility.UrlPathEncode() 在官方文件裡面的說明文字就只一行:

Do not use; intended only for browser compatibility. Use UrlEncode.

直接告訴我們別用它了,所以可用選項剩下三個。其中 HttpUtility 類別屬於 System.Web.dll 組件,Uri 類別則是隸屬於基礎的 System.dll 組件。

有人老早寫了測試程式,還整理了很詳細的測試結果和對照表格。但我還是自己實驗了一下,再根據實際的觀察來兜出那個我想要的 URL 編碼函式。

先看實驗的部分:

static void Main(string[] args)
{
    string input = "http://whotest.com/a b/c?phone=+886&name=M. Tsai&msg=say:'hello?!'";
    Console.WriteLine("input: \n{0}", input);

    string encoded = System.Web.HttpUtility.UrlEncode(input);
    string decoded =  System.Web.HttpUtility.UrlDecode(encoded);
    Console.WriteLine(new string('=', 70));
    Console.WriteLine("HttpUtility.UrlEncode/UrlDecode: \n{0}\n{1}", encoded, decoded);
    
    encoded = Uri.EscapeUriString(input);
    decoded = Uri.UnescapeDataString(encoded);                      
    Console.WriteLine(new string ('=', 70));
    Console.WriteLine("Uri.EscapeUriString/UnescapeDataString: \n{0}\n{1}", encoded, decoded);
    
    encoded = Uri.EscapeDataString(input);
    decoded = Uri.UnescapeDataString(encoded);
    Console.WriteLine(new string('=', 70));
    Console.WriteLine("Uri.EscapeDataString/UnescapeDataString: \n{0}\n{1}", encoded, decoded);
}

解碼暫且忽略,目前我只關心編碼的結果。如下圖:


圖中三處黃色標示的文字就是三種方法編碼出來的結果。結果沒有一個是我要的,因為...
  • HttpUtility.UrlEncode() 連網址的路徑部分都編碼了,例如 http:// 變成 http%3a%2f%2f,是無效的網址。
  • Uri.EscapeUriString()  不會弄壞網址的路徑部分,對於空白字元的處理也沒問題(編碼成 "%20",可是查詢字串的部分就漏掉很多符號,例如 '+' 號,這個不處理是不行的。
  • Uri.EscapeDataString() 的結果跟 HttpUtility.UrlEncode() 幾乎一樣,只有兩個差別:
    (1) Uri.EscapeDataString() 在編碼時採用大寫 16 進制字元,例如 %3A。HttpUtility.UrlEncode() 則是小寫。根據 RFC 文件,使用 % 十六進位編碼時,大小寫視為相同,但為求一致性,建議採用大寫。
    (2) Uri.EscapeDataString() 碰到空白字元時會轉成 %20,HttpUtility.UrlEncode() 則會轉成 '+' 號。
至於 HttpUtility.UrlEncode() 碰到空白字元會轉成 '+' 號,這是 OK 的。在 application/x-www-form-encoded 類型的文件裡,空白字元可以編碼成 '+' 號(據說定義在 RFC 2396 裡面,我沒去查 :p)。換言之,URL 查詢字串中的空白字元既可以編碼成 "%20",亦可編碼成 "+" 號。

(迷之音:有沒有這麼複雜啊 Orz)

這些細微差異,如果有一份對照表會更清楚。先前提過的那篇文章裡面就有整理對照表:Don't use .NET System.Uri.UnescapeDataString in URL Decoding

自己寫編解碼函式

這是常見問題,我想應該很多人早就有自己的解法了。底下是我為自己寫的工具函式,包含編碼和解碼。

/// <summary>
/// Encoding a URL. Basically the Path Component is encoded with Uri.EscapeUriString, and the Query Component is encoded with Uri.EscapeDataString.
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
public static string UrlEncode(string input)
{
    var aUri = new Uri(input, true);  // 'true' means don't encode it, or else the space characters will be double encoded!

    // Parse query string to a name-value collection. The first '?' is removed and remained '?' characters will be encoded.
    var queryParams = SplitToKeyValuePairs(aUri.Query.TrimStart('?'), '&', '=');  // Do NOT use HttpUtility.ParseQueryString(aUri.Query) because it does encode.

    // Rebuilding and encoding query string.
    var sb = new StringBuilder();
    foreach (var item in queryParams)
    {
        sb.AppendFormat("{0}={1}&", Uri.EscapeDataString(item.Key), Uri.EscapeDataString(item.Value));
    }
    sb.Remove(sb.Length - 1, 1);  // Remove last '&'
    string result = String.Format("{0}?{1}", Uri.EscapeUriString(aUri.GetLeftPart(UriPartial.Path)), sb.ToString());
    return result;
}

/// <summary>
/// Decoding a URL.
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
public static string UrlDecode(string input)
{
    if (input == null)
    {
        throw new ArgumentNullException("input");
    }
    // Since Uri.UnescapeDataString() does not decode plus sign ('+') to space character, we do it manually. 
    // Yes, System.Web.HttpUtility.UrlDecode() can do this, I just don't want to involve System.Web.dll here.
    return Uri.UnescapeDataString(input.Replace('+', ' '));
}

程式碼裡面已經有註解(菜英文,請包涵),就不細說了。僅提一下重點:
  • UrlEncode() 函式負責編碼,採取的策略是以 Uri.EscapeUriString() 來編碼路徑的部分,並且用 Uri.EscapeDataString() 來編碼查詢字串的部分。
  • UrlDecode() 函式負責解碼,其中有針對 "+" 號做額外處理。其實這部分只要用 System.Web.HttpUtility.UrlDecode() 一行就能解決,只是我不想在這裡引用 System.Web 組件而已。
另外,我的 UrlEncode() 裡面呼叫了另一個函式 SplitToKeyValuePairs() 把查詢字串分解成一個 key-values 串列,這個函式的原始碼沒有列出來(後面有完整原始碼的下載網址)。

單元測試

為了避免這兩個函式出什麼大亂子,我寫了點單元測試。程式碼如下:

[TestClass]
public class StrHelperUnitTest
{
    [TestMethod]
    public void TestUrlEncode()
    {
        string input = "http://xyz.com/test?tel=+1732123456&name=M. Tsai";
        string expected = "http://xyz.com/test?tel=%2B1732123456&name=M.%20Tsai";
        string encoded = StrHelper.UrlEncode(input);

        Assert.AreEqual(encoded, expected, true);

        // decode
        string decoded = StrHelper.UrlDecode(encoded);
        Assert.AreEqual(decoded, input, true);

        // test decoding '+' to space characters.
        decoded = StrHelper.UrlDecode("How+are+you");
        Assert.AreEqual(decoded, "How are you", true);

        // test double encoding then decoding.
        encoded = StrHelper.UrlEncode(StrHelper.UrlEncode(input));
        decoded = StrHelper.UrlDecode(StrHelper.UrlDecode(encoded));
        Assert.AreEqual(decoded, input);
    }
}

只是簡單地測試一下編碼和解碼的基本功能,以及空白字元轉 '+' 號(前面提過)和重複編碼(double encoding)的狀況,以確保兩次編碼之後再做兩次解碼,仍能夠還原成初始的 URL。當然啦,實際寫程式時,能夠避免二次編碼/解碼是最好。

我把這兩個函式以及單元測試都放到最近建立的 Yalib 專案裡,類別全名是 Yalib.StrHelper。若有興趣試試,可下載 NuGet 套件,亦可至 GitHub 取得完整原始碼

參考資料

Post Comments

技術提供:Blogger.