2007-12-27

深入 Microsoft.VisualBasic.Strings.StrConv 簡繁轉換

昨天又遇到一個簡繁轉換的需求, 雖然這個問題以前已經處理過了, 但是以前是用自己建立的 b52gb 和 gb2b5 的對應表來完成這個需求(VB6 的話就用 StrConv 方法來達成), 在 .NET 環境中, Microsoft.VisualBasic.dll 裡也有提供 Strings.StrConv 方法, 而且用法和原來的 VB6 幾乎是如出一轍, 可是昨天在使用 StrConv 的時候卻意外發現了一些奇怪的現象, 特別深入研究了一下, 順便記錄下來!

先來觀察 Strings.StrConv 方法的簽名:

public static string StrConv(string str, VbStrConv Conversion, [Optional, DefaultParameterValue(0)] int LocaleID)

第三個參數和 MSDN 上的文件有點不同, 上面的簽名是從 Reflector 中摘出來的, 也是這篇文章要記錄的重點, 先來看一些範例:

    a1 = Strings.StrConv("书樂う반", VbStrConv.TraditionalChinese, 0x0404);    // a1 = "?樂??"
    a2 = Strings.StrConv("书樂う반", VbStrConv.SimplifiedChinese, 0x0404);     // a2 = "????"

    b1 = Strings.StrConv("书樂う반", VbStrConv.TraditionalChinese, 0x0804);    // b1 = "書樂う?"
    b2 = Strings.StrConv("书樂う반", VbStrConv.SimplifiedChinese, 0x0804);     // b2 = "书乐う?"

    c1 = Strings.StrConv("书樂う반", VbStrConv.TraditionalChinese, 0x0412);    // c1 = "?樂う반"
    c2 = Strings.StrConv("书樂う반", VbStrConv.SimplifiedChinese, 0x0412);     // c2 = "??う반"

    d1 = Strings.StrConv("书樂う반", VbStrConv.TraditionalChinese, 0x0009);    // d1 = "書樂う반"
    d2 = Strings.StrConv("书樂う반", VbStrConv.SimplifiedChinese, 0x0009);     // d2 = "书乐う반"

上面 8 個範例的第一個參數摻雜了簡中、繁中、日文和韓文, 第二個參數區分了轉簡體和轉繁體, 第三個參數是 localeID 的部分, 分別包含了 zh-TW (0x0404), zh-CN (0x0840), ko-KR (0x0412), en (0x0009), 讓我們來仔細觀察一下結果, 一切的玄機都在第三個 localeID 參數身上. 我們先假設第三個參數 localeID 是用來表示來源字串的字集, 所以如果這個假設成立的話..., 來看看結果:

  1. a1: 嗯, 一切如預期的結果, 第一步應該先將 "书樂う반" 轉成符合 zh-TW (0x0404) 的字集, 所以結果是 "?樂??", 然後再根據第二個參數 VbStrConv.TraditionalChinese 結果變成了 "?樂??", 正確!
  2. a2: 第一步同上, 然後再根據第二個參數 VbStrConv.SimplifiedChinese 結果應該要變成 "?乐??", 可是 a2 的結果卻得到了 "????", 不如預期!
  3. b1: 第一步應該先將 "书樂う반" 轉成符合 zh-CN 的字集, 所以結果是 "书樂う?", (簡體字集是有包含繁體形態 "樂" 這個字的), 第二個參數 VbStrConv.TraditionalChinese, 所以結果變成 "書樂う?", 正確!
  4. b2: 正確!
  5. c1: 韓文字集不太了解, 從結果推測韓文的漢字集如果沒有 "书" 這個字的話, 結果應該算是正確的!
  6. c2: 從 c1 的結果本來預期應該得到 "?乐う반", 可是結果卻是 "??う반", 不如預期!
  7. d1: 咦!!! 怎麼會這樣, 完全不如預期, 竟然得到如此漂亮的結果, 本來預期是 4 個 "?" 的!!!
  8. d2: 一樣得到令人搞不清楚為什麼美麗結果!!!

這到底是怎麼一回事? 是假設錯誤嗎? 可是還有什麼別的可能嗎? 為了解開這個謎團, 於是又祭出了殺手工具 "Reflector", 仔細觀察了 Microsoft.VisualBasic.dll 內的程式碼, 終於了解箇中奧秘!

先來看一下 StrConv 方法反向工程之後的一小部分程式碼(還沒到重點, 所以只節錄最後幾行),

image

再來追進 vbLCMapString 看一看, 也是看下半部就行了:

image

橘黃色是和 Encoding 相關的程式碼, 綠色紅色底線的部分是 Win32 API 用來處理字碼轉換的函式, 綠色底線的函式有一個後綴字 A, 而且輸入的參數是 byte[], 而紅色底線部分的函式則沒有後綴字, 輸入的參數是 string.

看到這兒, 答案已經呼之欲出了, 之所以結果會不如預期都是因為 encoding.GetBytes() 和 encoding.GetString() 這兩個方法給弄砸的, 如果可以跳過它們直接叫用底下畫紅線的 UnsafeNativeMethods.LCMapString 的話, 就不會有那些討厭的問號產生了, 那要怎麼樣才能避過那段我們不想要的程式碼呢? 看一下那個底下有畫虛線的部分 "encoding.IsSingleByte", 嗯! 沒錯, 這就是為什麼 d1, d2 的結果這麼令人驚訝的原因了, 因為 en 的 Encoding 就是 SingleByte 所以會直接跳過 Unicode 和 MBCS 互轉的部分, 而直接進行 Unicode 的轉碼, 於是得到美麗的答案, 整個過程分析完畢!

雖然已經知道整個來龍去脈, 但是如果能再了解一下那個神奇的 Win32API: LCMapString 的話, 想必觀念又可以再更清楚一些. 所以我們再來看看 LCMapString 的重點吧! 嗯~~重點在哪兒咧? 以此篇文章的需求 "簡繁轉換" 來看的話, 只有第二個參數 dwMapFlags 值得我們注意, 打開 MSDN 的文件, 透過索引找到 LCMapString 的章節, 我們可以看到以下的內容,

image

針對 Windows NT 4.0 以後的作業系統, Microsoft 已經早就幫程式設計師們準備好了一個現成的系統函式來達成簡繁轉換的工作了(唉! 為什麼沒有早點知道!), 看你是要轉簡體 (LCMAP_SIMPLIFIED_CHINESE), 或是轉繁體 (LCMAP_TRADITIONAL_CHINESE), 只要給個參數, 一切就搞定了, 就是這麼簡單!

 

結論

如果您的需求和我一樣, 只是想把文字內容的簡繁部分轉換, 並不是想轉成 big5 或 gb, 整個輸出入都是 unicode, 而且也不想破壞其他非簡繁文字部分的話, 那麼結論就是照著本篇文章的一開始的 d1, d2 範例呼叫 VB 的 Strings.StrConv 帶上 0x0009 或是其他 SingleByte 字集的 localeID 當成第三個參數就可以啦!!!

如果不想引入 Microsoft.VisualBasic.dll (別問為什麼, 純屬個人偏好) 又想要做到相同的效果, 做法也很簡單, 請參考以下的範例程式碼!!!

public static class ChineseStringUtility
{
    internal const int LOCALE_SYSTEM_DEFAULT = 0x0800;
    internal const int LCMAP_SIMPLIFIED_CHINESE = 0x02000000;
    internal const int LCMAP_TRADITIONAL_CHINESE = 0x04000000;

    [DllImport("kernel32", CharSet = CharSet.Auto, SetLastError = true)]
    internal static extern int LCMapString(int Locale, int dwMapFlags, string lpSrcStr, int cchSrc, [Out] string lpDestStr, int cchDest);

    public static string ToSimplified(string source)
    {
        String target = new String(' ', source.Length);
        int ret = LCMapString(LOCALE_SYSTEM_DEFAULT, LCMAP_SIMPLIFIED_CHINESE, source, source.Length, target, source.Length);
        return target;
    }

    public static string ToTraditional(string source)
    {
        String target = new String(' ', source.Length);
        int ret = LCMapString(LOCALE_SYSTEM_DEFAULT, LCMAP_TRADITIONAL_CHINESE, source, source.Length, target, source.Length);
        return target;
    }
}

2007-12-19

建立 Localized JavaScript

從前在 asp 時代就有這樣子的需求, 那時候是用 Excel 表製作字串對應表, 再透過程式產生各種不同的 JavaScript 檔, 最後再根據需求動態引入不同的 JavaScript 來達成, 其實這個解決方案也還算不錯, 因為 Excel 可以由程式設計師以外的人來進行相關的翻譯工作, 一切 OK 之後, 再透過轉檔程式產生一堆的 JavaScript 即可!

到了 ASP.NET 時代, 透過 System.Web.Extensions.dll 的 ScriptResourceAttribue 將整個動作整合的更好了(但是要做的事情其實差不了多少, 只是比較美而已), 實作的步驟如下:

  1. 建立一個 Class Library Project, 取名為 LocalizingScriptResources, 並刪除那個多餘的 Class1.cs.
  2. 加入 System.Web.dll 和 System.Web.Extensions.dll 參考.
  3. 新增一個 MyResource.resx 資源檔, 並作以下的編輯:
     image
  4. 新增一個 MyScript.js, 將 Build Action 設為 Embedded Resource, 然後寫一個簡單的 function 在裡面, 其中值得注意的是 String1 和 String2 的前綴字 MyNamespace.MyType, 等一下會再遇到.
    image
  5. 為了讓等會兒的範例更清楚, 再補上一個 MyResource.zh-TW.resx, 這次內容用中文的:
    image
  6. 接下來的動作是一切的關鍵, 做錯的話等會兒就看不到效果了. 打開 Properties 底下的 AssemblyInfo.cs, 在 using 底下加入以下兩行:
    [assembly: WebResource("LocalizingScriptResources.MyScript.js", "text/javascript")]
    [assembly: ScriptResource("LocalizingScriptResources.MyScript.js", "LocalizingScriptResources.MyResource", "MyNamespace.MyType")]
    • 第一行的 WebResourceAttribute 是將 MyScript 嵌入 dll 中, 注意第一個參數的名字必須是 Namespace.DotSplittedFolderName.FileName, 因為範例的 MyScript.js 是放在根目錄, 所以名字是 LocalizingScriptResources.MyScript.js, 如果把檔案放在 \F1\F2\MyScript.js 的話, 那名字就必須改成 LocalizingScriptResources.F1.F2.MyScript.js, 目錄的分隔符號要換成 "."
    • 第二行的 ScripteResourceAttribute, 參數一就是和第一行相同的名字, 參數二是 Resource 檔的名字, 命名規則和第一個參數類似, 但是不包含語系的後綴字, 也不包含最後的 .resx 附檔名, 參數三則是要在 JavaScript 中使用名型別名稱, 也就是步驟 5 的 MyNamespace.MyType, 可以取自己想要的名字, 但兩邊要對應.
    • 在這兒先 Build 看看吧, 應該要成功的, 把不小心犯的錯找出來, 順便休息一下!
  7. 接下來準備實驗啦, 加入一個新的 WebSite Project, 並將 LocalizingScriptResources 加入為專案參考, 打開 Default.aspx, 加入一個 ScriptManager, 並在裡面加入一個 Script 參考, 然後再寫一個 pageLoad 方法去呼叫 MyScript.js 裡面的 function.
    <form id="form1" runat="server">
    <div>
        <asp:ScriptManager ID="ScriptManager1" runat="server">
            <Scripts>
                <asp:ScriptReference Assembly="LocalizingScriptResources" Name="LocalizingScriptResources.MyScript.js" />
            </Scripts>
        </asp:ScriptManager>
    </div>
    <script type="text/javascript">
    function pageLoad()
    {
        LocalizedMethod();
    }
    </script>
    </form>
  8. 接下來可以打開 Browser 看看結果, 如果想切換語系的話, 可以在 IE 的 網際網路選項 -> 語言 -> 語言喜好設定 裡調整語言的優先順序, 然後再次瀏覽網頁, 應該就可以了.
  9. 如果想強制切換語系檔的話, 可以直接修改 @Page 的 UICulture 屬性, 試試 en-US, zh-TW 或其他的 Culture name 體驗一下.
    <%@ Page UICulture="zh-TW" Language="C#" AutoEventWireup="true"  CodeFile="Default.aspx.cs" Inherits="_Default" %>

Fiddler 又不能攔到 localhost 了

剛把作業系統換成 Vista 的時候, 就曾經發生 Fiddler 不能攔到 localhost 的 Request 的問題, 後來在網路上找到的答案是說只要在 localhost 後面加個 "." 就可以了, 輕輕鬆鬆就解決了!

可是今天再度使用 Fiddler 的時候, 相同的技巧卻失效了, 畫面上出現的是 Exception Text: 無法連線,因為目標電腦拒絕連線。 ::1:64155, 有圖為證:

image

奇妙的是如果把 "localhost." 換成 "127.0.0.1." 就又可以了, 怎麼可能會有這種事情, 打開 cmd.exe 試著 ping localhost 看看, 果然發現了問題, 如圖, 回應的內容是 "回覆自 ::1: time<1ms":

image

要怎麼解決這個問題咧? 最快的治標方法是用記事本打開 C:\Windows\System32\drivers\etc 目錄底下的 hosts 檔案, 把其中一行 "::1 localhost" 前面加個 "#" 註解掉就行了!

2007-12-18

列舉子資料夾的方法(.NET)

簡單, 就 Directory.GetDirectories() 就好啦, 但是如果要列舉的對象有著為數眾多(比如說有好幾萬)的子資料夾或是檔案的話, 那可有得等了, 而且還要有充足的記憶體供它使用, 因為它的回傳值是 string[], 所以如果有類似的需求的話, 只能另想它法囉!

當下想到的就是 WIN32 API 的 FindFirstFile, FindNextFile 函式, 可是要在 .NET 環境中使用的話, 還是稍稍封裝一下比較好, 所以第一步當然是要準備那些 DllImport 的宣告, 和最煩人的 WIN32_FIND_DATA 資料結構囉, 自己參考文件照樣重新製作一個當然也是可以, 可是如果有現成的話該多好!

先用 Reflector 參考一下 Directory.GetDirectories 是怎麼寫的, 它既然要列舉資料夾, 應該也一定會用到那些 API 才對, 果然! 相關的宣告如下:

[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr FindFirstFile(string fileName, [In, Out] WIN32_FIND_DATA data);

[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern bool FindNextFile(IntPtr hndFindFile, [In, Out, MarshalAs(UnmanagedType.LPStruct)] WIN32_FIND_DATA lpFindFileData);

[DllImport("kernel32.dll")]
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
private static extern bool FindClose(IntPtr handle);
[Serializable, StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto), BestFitMapping(false)]
private class WIN32_FIND_DATA
{
    internal int dwFileAttributes;
    internal int ftCreationTime_dwLowDateTime;
    internal int ftCreationTime_dwHighDateTime;
    internal int ftLastAccessTime_dwLowDateTime;
    internal int ftLastAccessTime_dwHighDateTime;
    internal int ftLastWriteTime_dwLowDateTime;
    internal int ftLastWriteTime_dwHighDateTime;
    internal int nFileSizeHigh;
    internal int nFileSizeLow;
    internal int dwReserved0;
    internal int dwReserved1;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
    internal string cFileName;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)]
    internal string cAlternateFileName;
}

接下來要就要自己實作一個回傳 IEnumerable 的方法, 每列舉一次只回傳一筆結果, 既可避免等待的問題, 又可以隨時停止, 如果在 .NET 1.x 的時代, 要實作一個 IEnumerable 比較麻煩, 但是 .NET 2.0 因為有 yield 這個新元素可以使用, 所以問題就簡化到一個不可思議的地步, 所以先實作一個內部使用的 internalGetFileDirectoryNames 的方法, 如下:

private const int FILE_ATTRIBUTE_DIRECTORY = 0x10;
private static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1);
private static IEnumerable internalGetFileDirectoryNames(string path, string searchPattern, bool includeFiles, bool includeDirs, SearchOption searchOption)
{
    WIN32_FIND_DATA data = new WIN32_FIND_DATA();
    IntPtr findHandle = FindFirstFile(Path.Combine(path, searchPattern), data);

    if (findHandle == INVALID_HANDLE_VALUE) yield break;

    try
    {
        List<string> dirs = new List<string>();
        do
        {
            bool isDirectory = (data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
            if (data.cFileName != "." && data.cFileName != "..")
            {
                if (isDirectory && searchOption == SearchOption.AllDirectories) dirs.Add(data.cFileName);

                if ((isDirectory && includeDirs) || (!isDirectory && includeFiles))
                {
                    yield return Path.Combine(path, data.cFileName);
                }
            }
        }
        while (FindNextFile(findHandle, data));

        foreach (string dir in dirs)
        {
            foreach (string f in internalGetFileDirectoryNames(Path.Combine(path, dir), searchPattern, includeFiles, includeDirs, searchOption))
                yield return Path.Combine(path, f);
        }
    }
    finally
    {
        bool r = FindClose(findHandle);
        System.Diagnostics.Debug.Assert(r);
    }
}

最後, 模擬原生的 Directory.GetFiles, Directory.GetDirectories, Directory.GetFileSystemEntries 函式簽名, 自己 overload 一下就搞定啦, 以下列出最多屬性的函式宣告:

public static IEnumerable GetFiles(string path, string searchPattern, SearchOption searchOption)
{
    return internalGetFileDirectoryNames(path, searchPattern, true, false, searchOption);
}
public static IEnumerable GetDirectories(string path, string searchPattern, SearchOption searchOption)
{
    return internalGetFileDirectoryNames(path, searchPattern, false, true, searchOption);
}
public static IEnumerable GetFileSystemEntries(string path, string searchPattern, SearchOption searchOption)
{
    return internalGetFileDirectoryNames(path, searchPattern, true, true, searchOption);
}

完成啦! 完整的程式碼在這裡!

2007-12-12

導入 Team Foundation Server

在開發團隊中導入 TFS, 配合 Visual Studio Team Explorer, 除了要習慣那個不太好用, 功能又有點陽春的 Source Control Explorer 之外, 其實過程還順滿平順的(以前使用的 SVN Server 搭配 TortoiseSVN 雖然沒有一個良好的 Item Tracking 機制, 但在 Source Code Control 上還算滿好用的), 而且 TFS 還內建了整合性超高的 Item Tracking 功能, 對軟體開發來說, 實在是一大福音! 雖然有人說可以使用 Bug Tracking System 來達到類似的需求, 但因為維護成本和教育訓練等考量, 到最後還是選擇放棄, 只專心做好 Source Control.

在開發團隊順利導入 TFS 之後, 下一步就是擴展到其他的部門, 讓大家彼此間的溝通能夠更為透明, 而且方便追蹤, 可是如果要其他非 IT 部門使用 Visual Studio 似乎有點太過份, 所以一直以來都是使用 Team Plain 當做其他部門進入 TFS 的入口, 經過簡單的教育訓練之後, 發現接受度還滿不錯的, 終於可以把整個軟體開發生命週期, 從訪談, 產生需求, 風險管理, 工作項目建立, Bug 記錄, QoS 管理等等, 通通都在 TFS 中一起管理.