Quantcast
Channel: Darkthread
Viewing all 428 articles
Browse latest View live

【茶包射手日記】打不開的PDF檔與檔案格式鑑定

$
0
0

接獲報案,某套表程式忽然故障導致產出的PDF檔案無法開啟。

檢視查檔案內容如下,二進位內容當然看不懂,但由表頭判斷一定不是 PDF。檔案一開始的「俵」跟「遄」字元經 Google 在網際網路上發現不少兄弟姐妹,大致可知這是 Office 相關格式,但試著將副檔名改成 .doc、.docx、.rtf 都無法開啟。

爬文找到一個神奇的小工具-Marco Pontello's TrID,它搜羅整理了 7886 種檔案格式特徵,能掃瞄檔案內容推測檔案類型。到網站下載工具包含主程式 trid.exe 以及 檔案特徵資料庫 triddefs.trd,下個指令一秒就知結果:

如上圖所示,鑑定結果檔案很可能是 .wps 檔(Microsoft Works的文件檔),使用 Word 2010 開啟舊檔,切換檔案類型為「Works 6-9 文件(*.wps)」,果然順利開啟檔案。

到此,案情可定調為「原本產生 PDF 的套表程式,不知何故產出檔案格式變成 WPS」,這… 未免也太懸疑?

同事深入探訪後找出原因:套表程式原本搭 Word 2007 執行,前些時候配合另一套古老系統需求在同台主機安裝了 Word 2003。由於舊版軟體不知道新版軟體的存在,先裝新版再裝舊版可能造成共用元件被不當覆寫。而問題發生時點跟安裝 Word 2003 時間大致吻合,推測是元件覆寫導致檔案格式參數錯亂,才讓 PDF 變成 WPS。

問題在重新安裝 Word 2007 後排除,Case Closed,收工。


核武級ODP.NET版本暴力破解工具

$
0
0

最近又遇到 ODP.NET 版本問題。(警告:本文涉及邪門歪道雞鳴狗盜之技,正義魔人與衛道人士請自行迴避)

古老 ASP.NET 網站參照 ODP.NET 9207 版,移到 x64 平台必須改用新版 ODP.NET,而 ODP.NET 存在版號從 9.2 10.1 降回 2.102 的鬼問題,新版號比舊版號數字小在某些情況下會讓「bindingRedirect 大絕」破功。很無奈,必須調整參照版號才能解決,重新取原始檔編譯太麻煩,於是我找到 ildasm 反組譯成 MSIL 程式碼,修改參數版號再 ilasm 編譯回 DLL 的奧步妙招,後來更進一步寫了自動化懶人工具

多年後再遇老茶包,遇上小麻煩。由於古老網站採用 .NET 2.0,發現在 Windows 8.1 平台上就算用 .NET 2.0 SDK ilasm 編譯出的 DLL 還是 .NET 4 版本,得翻出老機器用 VS2008 開發環境處理才能產出 .NET 2.0 版本。另一方面,網站為 Web Site Project 使用部署專案編譯成一個資料夾一顆 DLL,換句話說,總共有數十個 DLL 要處理。沒耐性的我又動起歪腦筋,索性不要 ildasm / ilasm 了,直接將 DLL 裡的 ODP.NET 版號資料改掉更快。

於是,花十分鐘寫了以下工具,用二進位資料比對的方法 1 秒鐘內把數十個 DLL 的 ODP.NET 版號從 9.2.0.700 換成 2.112.3.0!與之前一個個 DLL ildasm/ilasm 的繁瑣過程相比,威力直逼核武等級呀~

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
 
namespace OdpNet9207RefFixer
{
class Program
    {
staticvoid Main(string[] args)
        {
foreach (var file in Directory.GetFiles("x:\\src-bin", "*.dll"))
            {
byte[] buff = File.ReadAllBytes(file);
                var idx = 0;
                var foundCount = 0;
while (idx < buff.Length - 8)
                {
if (buff[idx] == 9 && buff[idx + 1] == 0 &&
                        buff[idx + 2] == 2 && buff[idx + 3] == 0 &&
                        buff[idx + 4] == 0 && buff[idx + 5] == 0 &&
                        buff[idx + 6] == 0xbc && buff[idx + 7] == 0x02
                        )
                    {
                        buff[idx] = 2;
                        buff[idx + 2] = 112;
                        buff[idx + 4] = 3;
                        buff[idx + 6] = 0;
                        buff[idx + 7] = 0;
                        foundCount++;
                    }
                    idx++;
                }
if (foundCount == 1)
                {
                    File.WriteAllBytes(file.Replace("src-bin\\", "fixed-bin\\"), buff);
                }
            }
        }
    }
}

這個做法的風險在於誤判,若好死不死 DLL 裡程式碼或資源的二進位資料剛好組成 0x090002000000bc02 就會被錯換,因此我加了一道保險,若在 DLL 中發現一處以上吻合,就不進行置換。所幸 9.2.0.700 這串內容夠特殊,一般程式內容撞上的機率不高,故不再投資心力閃避,而是補上事後使用 JustDecompile 檢查參照版號做為第二道防線,降低誤改風險。

就這樣,原本預估要花個把小時處理的 ODP.NET 參照修改問題,花了幾分鐘便搞定。

奧步雖然可恥但有用~ XD

用 TypeScript await 讓操作確認流程回歸直覺

$
0
0

上回提到 TypeScript 2.1 讓 ES5 平台也能支援 async、await,形同 JavaScript 非同步程式的一場革命,衝著這點大家都該認真考慮改用 TypeScript。但 async、await 當真如此神奇?想想,上回漏講一個 await 殺手級應用案例,說服力有點弱,趕緊補上。

大家小時侯都有寫過這種需求:網頁進行更新、刪除操作前跳出對話框請使用者三思,回答「取消」可以反悔取消,回答「確定」才正式執行。在那個古老而純真的年代,只要寫一行就搞定:

if (confirm("下好離手,您確定要洗頭?")) { …倒洗髮精… }

等待 window.confirm() 傳回結果,依傳回值 true 或 false 決定後面流程,程式邏輯動線清楚明瞭。

但 confirm() 有幾個缺點,第一是畫面配置、文字、按鈕樣式由瀏覽器控器無法客製;第二點是等待使用者輸入的當下 JavaScript 執行緒將完全凍結,以 JavaScript 驅動的網頁元素互動或是 AJAX 連線都陷入失效狀態;第三,一旦呼叫 confirm 後我們就失去主導權,因此不可能實現「逾時未回應視為取消」,若使用者不回應,網頁只能地老天荒被卡著。

用個實例示範:

<!DOCTYPEhtml>
<html>
<body>
<button>重新倒數</button>
<divclass="cnt-down"></div>
<scriptsrc="Scripts/jquery-3.1.1.js"></script>
<script>
var countDown = 100;
var hnd = setInterval(function () {
if (countDown == 0) {
                alert("時間到!");
                clearInterval(hnd);
            }
else
                $(".cnt-down").text(countDown--);
        }, 1000);
        $("button").click(function () {
if (confirm("確定要重新開始倒數?"))
                countDown = 100;
        });
</script>
</body>
</html>

如以下展示,confirm 彈出「確定要重新開始倒數?」後,故意等幾秒才按取消,這段期間由 setInterval 驅動的倒數是停止的,直到按下取消才繼續。至於要做到「使用者五秒沒回應就取消 confirm」?黑洗謀摳零A代擠。

要克服上述缺點,就只能走上用 HTML 元素打造對話框(或使用 jQuery confirmKendo UI等現成程式庫)的路。在非同步模式下想要依使用者按不同鈕執行不同邏輯,需仰賴 jQuery Deferred 或 Promise,並將確認及取消邏輯分別寫在 done()/fail() 或 then()/catch():(參考:使用自訂確認對話框取代window.confirm

<!DOCTYPEhtml>
<html>
<head>
<title>Confirm Example</title>
<metacharset="utf-8"/>
</head>
<body>
<button>重新倒數</button>
<divclass="cnt-down"></div>
 
<divclass='dialog'style='display:none'>
<divclass="my-cnfrm-diag"style='border: 1px solid blue; padding: 12px;'>
<divclass='m'></div><br/>
<inputtype='button'value='是'/>
<inputtype='button'value='否'/>
</div>
</div>
 
<scriptsrc="Scripts/jquery-3.1.1.js"></script>
<script src="Scripts/jquery.blockUI.js"></script>
<script>
function myConfirm(msg) {
var df = $.Deferred(); //建立Deferred物件
 
//使用BlockUI顯示對話框
            $.blockUI({
                message: $(".dialog").html(),
                css: { width: "50%" }
            });
//關閉對話框並傳回結果
function close(result) {
                $.unblockUI(); //將對話框移除
                clearTimeout(hnd); //取消自動關閉排程
if (result) df.resolve(); //使用者按下是
else df.reject(); //使用者按下否
            }
 
//若使用者未回應,五秒後自動關閉
var hnd = setTimeout(function () {
                close(false);
            }, 5000);
 
var $div = $(".my-cnfrm-diag");
            $div.find(".m").text(msg); //設定顯示訊息
 
//加上按鈕事件
            $div.on("click", "input", function () {
                close(this.value == "是");
            });
 
//傳回Promise
return df.promise();
        }
</script>
<script>
var countDown = 100;
var hnd = setInterval(function () {
if (countDown == 0) {
                alert("時間到!");
                clearInterval(hnd);
            }
else
                $(".cnt-down").text(countDown--);
        }, 1000);
 
        $("button").click(function () {
            myConfirm("確定要重新開始倒數?")
                .done(function () {
                    countDown = 100;
                });
        });
</script>
</body>
</html>

示範如下,顯示確認對話框的同時,數字仍會繼續倒數,第一次帶出對話框時,故意等五秒不操作,可以看到對話框被逾時機制自動關閉,直接認定取消;而第二次按下「是」時會觸發 jQuery Promise.done() 所設定的邏輯,將 countDown 重設回 100。

一切都符合需求,但確認後的執行動作必須寫在 myConfirm(…).done(function() { … }) 裡,不如過往直覺,要是能寫成 if (myConfirm(…)) { … } 就更完美了!

讓 await 登場實現我們的願望吧!

將程式搬進 TypeScript,TypeScript 是 JavaScript 的超集合,JavaScript 程式貼進 TypeScript 裡不用修改也能運作。myConfirm 部分先不動,先在最後一段動點手腳:

function myConfirm(msg) {
var df = $.Deferred(); //建立Deferred物件
 
//使用BlockUI顯示對話框
            $.blockUI({
                message: $(".dialog").html(),
                css: { width: "50%" }
            });
//關閉對話框並傳回結果
function close(result) {
                $.unblockUI(); //將對話框移除
                clearTimeout(hnd); //取消自動關閉排程
df.resolve(result);//傳回true/false
            }
 
//若使用者未回應,五秒後自動關閉
var hnd = setTimeout(function () {
                close(false);
            }, 5000);
 
var $div = $(".my-cnfrm-diag");
            $div.find(".m").text(msg); //設定顯示訊息
 
//加上按鈕事件
            $div.on("click", "input", function () {
                close(this.value == "是");
            });
 
//傳回Promise
return df.promise();
        }
 
var countDown = 100;
var hnd = setInterval(() => {
if (countDown == 0) {
        alert("時間到!");
        clearInterval(hnd);
    }
else
        $(".cnt-down").text(countDown--);
}, 1000);
 
$("button").click(async () => {
if (await myConfirm("確定要重新開始倒數?")) {
        countDown = 100;
    }
});

button click 事件稍做修改,在匿名函式 () => { … } 前方加上 async 修飾,以便在其中使用 await。而 await myConfirm(…) 將等待 Promise Resolve 或 Reject 才繼續執行,讓邏輯回歸 if (confirm(…)) { … } 般的單純直覺。

還有一個地方要小調,await myConfirm() 傳回結果將等於 Resolve() 傳回值,若遇上 Reject() 會得到 undefined 並產生 "Uncaught (in promise)" 錯誤,故 myConfirm 裡原本 if (result) df.resolve() else df.reject() 寫法改為一律 Resolve(),再依傳回值 true/false 區分使用者按是或按否。小事一椿,改成 df.resolve(result) 就搞定。

從單純的 if (confirm(…)) { … } 演進好用但寫法不直覺的 myConfirm(…).done(function() { … }),回歸 if (await myConfirm(…)) { … } 的清楚流程 ,大師兄回來了,謝謝 TypeScript await!

Visual Studio 擴充套件自動停用問題

$
0
0

最近發生 SCSS無法自動編譯的狀況,查看 Extension and Updates,問題出在 Web Compiler被停用。

手動重新啟用後一切功能如常,原以為是一時系統秀逗,但接連發生好幾次才感覺不對勁。觀察發現,即時手動啟用,重開 Visual Studio 再開,Web Compiler 又是停用狀態。進一步檢查,則發現另一套件 Bundler & Minifier 也有相同狀況。

爬文找到一篇發生在 Web Essentials 的類似案例,似乎是擴件套件 Cache 錯亂重覆造成的。照著作者做法用 devenv /log 開啟 Visual Studio Log 模式,在 %APPDATA%\Microsoft\VisualStudio\14.0\ActivityLog.xml 記錄中找到一堆 ID 相同導致套件無法載入的錯誤訊息:

2931 ERROR Extension will not be loaded because an extension with the same ID '148ffa77-d70a-407f-892b-9ee542346862' is already loaded at C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\1KE1MKUT.YWG\...
          C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\AVOVISND.T3V\   Extension Manager 2017/01/01 09:12:06.894
2932 ERROR Extension will not be loaded because an extension with the same ID '148ffa77-d70a-407f-892b-9ee542346862' is already loaded at C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\1KE1MKUT.YWG\...
          C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\CY10ZHN0.GEN\   Extension Manager 2017/01/01 09:12:06.894
2933 ERROR Extension will not be loaded because an extension with the same ID 'a0ae318b-4f07-4f71-93cb-f21d3f03c6d3' is already loaded at C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\01YIXUDP.4S4\...
          C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\DGLLFOVT.UKJ\   Extension Manager 2017/01/01 09:12:06.894
2934 ERROR Extension will not be loaded because an extension with the same ID '148ffa77-d70a-407f-892b-9ee542346862' is already loaded at C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\1KE1MKUT.YWG\...
          C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\LW0SFC1S.34X\   Extension Manager 2017/01/01 09:12:06.894
2935 ERROR Extension will not be loaded because an extension with the same ID 'a0ae318b-4f07-4f71-93cb-f21d3f03c6d3' is already loaded at C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\01YIXUDP.4S4\...
          C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\NDMEKS5M.CYT\   Extension Manager 2017/01/01 09:12:06.894
2936 ERROR Extension will not be loaded because an extension with the same ID 'a0ae318b-4f07-4f71-93cb-f21d3f03c6d3' is already loaded at C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\01YIXUDP.4S4\...
          C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\R24FBB2G.B1A\   Extension Manager 2017/01/01 09:12:06.894
2937 ERROR Extension will not be loaded because an extension with the same ID 'a0ae318b-4f07-4f71-93cb-f21d3f03c6d3' is already loaded at C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\01YIXUDP.4S4\...
          C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\UHHDU3FN.P1K\   Extension Manager 2017/01/01 09:12:06.894
2938 ERROR Extension will not be loaded because an extension with the same ID '148ffa77-d70a-407f-892b-9ee542346862' is already loaded at C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\1KE1MKUT.YWG\...
          C:\USERS\jeffrey\APPDATA\LOCAL\MICROSOFT\VISUALSTUDIO\14.0\EXTENSIONS\WCSBI30Y.CVD\   Extension Manager

檢查各資料夾內容分別為:

*1KE1MKUT.YWG\ WebCompiler
*01YIXUDP.4S4\ Bundler and Minifier
AVOVISND.T3V\ Bundler and Minifier
CY10ZHN0.GEN\ WebCompiler
DGLLFOVT.UKJ\ Bundler and Minifier
LW0SFC1S.34X\ WebCompiler
NDMEKS5M.CYT\ Bundler and Minifier
R24FBB2G.B1A\ Bundler and Minifier
UHHDU3FN.P1K\ Bundler and Minifier
WCSBI30Y.CVD\ WebCompiler

由此推論我遇到文章所說的狀況-系統存在多個重複的 WebCompiler 與 Bundler and Minifier 資料夾,Visual Studio 啟動時檢查有誤導致擴充套件被強制停用。依照文章裡的做法,連續移除套件直到它從清單消失(在我的案例 Bundler 有六份,Web Compiler 有四份,故連移了十次才移完),再重新安裝一次,問題排除。

後來進一步發現擴件套件資料夾重複應是常見問題,已有善心人士寫好找出重複擴充套件並自動刪除的潛盾機小工具,可以瞬間排除問題,如果懶得反覆手動移除重安裝可考慮使用工具修正問題。

onbeforeunload 事件不再支援自訂訊息

$
0
0

要防止使用者網頁輸入資料時誤按超連結或回上頁鍵,來不及儲存(送出)就離開,有個古老技巧是攔截 onbeforeunload 事件,使用 return "…" 傳回提醒文字,讓使用者有機會反悔,選擇停留在原頁面。(參考:如何避免使用者在特定網頁表單在未經送出時意外離開

這招用了多年,今天在寫某個設定網頁時卻發現 Chrome 沒有顯示我在 onbeforeunload 傳回的提醒文字,而是出現「系統可能不會儲存你所做的變更」字樣。

原以為是寫法有誤不符 Chrome 要求,爬文後發現背後有故事:

Chrome 從 51 版(2016 年 4 月)起取消 onbeforeunload 對話框顯示自訂訊號功能,理由是防止自訂訊息被用於詐騙用途。

規格討論裡提到當代瀏覽器的 onbeforeunlod 訊息有兩大用途:

  1. 防止使用者不慎遺失編輯中的資料
  2. 詐騙使用者
    (來個惡搞範例好了 :P)

目標是保留第一種用途並防止第二種情境發生,不開放自訂訊息是最直接有效的做法。瀏覽器顯示「離開網頁可能遺失資料」之類的萬用訊息,明確度及提醒效果當然比不上自訂訊息,但還是能多少發揮提醒作用,這算是為了安全所做的折衷。討論過程不乏反對的意見,但 Firefox 在更早之前就已這麼做,提供強而有力的助攻。

借用微軟的 onbeforeunload 範例,我測試了 IE11、Edge、Firefox 及 Opera,目前只剩 IE 與 Edge 還支援 onbeforeunload 自訂訊息:


Firefox

Opera

IE11

Edge

結論

當代瀏覽器已取消 onbeforeunload 自訂訊息功能,改以通用文字提醒資料可能遺失,設計網頁時應留意此一改變。

ODP.NET 發行者原則檔經驗一則

$
0
0

之前處理過一個鳥問題,使用 ODP.NET 12.1 連線 Oracle Server 10.2.0.4 時無法參與分散式交易,傳回「Unable to enlist in a distributed transaction /無法列於分散式交易中」錯誤。依網路討論 Server 升級到 10.2.0.5 以上可解決,當時決定將資料庫移至另一台 Oracle Server 11.2 成功脫逃,安全下莊。

半年後鳥問題捲土重來,一樣是 ODP.NET 12.1 連 Oracle 10.2.0.4 無法分散式交易,但這回資料庫沒得搬也很難升級,只能乖乖面對。

確認其他機器曾有 ODP.NET 11.2.0.3 成功與 Oracle 10.2.0.4 建立分散式交易,便在同台主機裝了 ODAC 11.2.0.3,程式換用 ODP.NET 11.2.0.3 後錯誤訊息卻完全沒有改變,揮棒落空,心中茫茫然…

招喚茶包一哥-Process Monitor,幸運挖到關鍵線索:

被 ODP.NET 版本惡整經驗豐富,對於發行者原則檔(Publisher Policy)倒也略懂略懂,在讀取上述 Registry 後,之後程式存取的都是 ODP.NET 2.112.0 DLL 檔案,由此識別出這是安裝 Oracle Client 12.1.0 加入的版本強制導向。

在 product\12.1.10\client32\odp.net\PublisherPolicy\2.x 可以找到多個發行者原則檔,分別將 2.102、2.111、2.112、2.121 導向 2.121 版。

Policy.2.112.Oracle.DataAccess.config 內容如下,這解釋為何我們已明確改用 2.112.0.3,仍被導向 2.121.1.0, 導致無法與舊版 Oracle Server 完成分散式交易。

<configuration>
<runtime>
<assemblyBindingxmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentityname="Oracle.DataAccess"publicKeyToken="89B483F429C47342"/>
<bindingRedirectoldVersion="2.112.0.0-2.112.9999.9999"newVersion="2.121.1.0"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

從組件管理 GUI 將 2.112 導向 2.121 的原則檔刪除,問題排除!

CSHTML 匿名型別資料繫結技巧一則

$
0
0

情境如下, 在 ASP.NET MVC 用一小段程式顯示部門下拉清單,資料來自資料庫,因欄位較多且命名不直覺,我將由資料庫取得的集合轉成匿名型別 Select(o => new { DeptId = o.DI, DeptName = o.DN },再以 Razor 語法 @foreach (var dept in ViewBag.Depts) { <option value="@dept.DeptId">@dept.DeptName</option> } 轉成下拉選單選項。程式碼範例如下:(2017-01-11補充:若只是要產生下拉選單,使用 @Html.DropDownList() 更省事,詳見文末補充)
HomeController.cs

publicclass HomeController : Controller
    {
public ActionResult Index()
        {
            ViewBag.Depts = DataHelper.GetDepts()
                .Select(o => new { DeptId = o.DI, DeptName = o.DN })
                .ToList();
return View();
        }
    }

Index.cshtml

@{
    Layout = null;
}
 
<!DOCTYPEhtml>
 
<html>
<head>
<metaname="viewport"content="width=device-width"/>
<title>MVC Test</title>
</head>
<body>
<div>
<select>
            @foreach (var d in ViewBag.Depts)
            {
<optionvalue="@d.DeptId">@d.DeptName</option>
            }
</select>
</div>
</body>
</html>

看似正常,執行時卻遇到錯誤,資料繫結(Data Binding)抱怨不認得匿名型別的 DeptId 屬性。

這才想到,我踩了匿名型別的紅線:參考

您無法將欄位、屬性、事件或方法的傳回類型,宣告為具有匿名類型。 同樣地,您無法將方法、屬性、建構函式或索引子的型式參數宣告為具有匿名類型。 若要以方法引數的形式來傳遞匿名類型或含有匿名類型的集合,您可以將參數宣告為物件(object)。 但是這樣做將失去強式類型的目的。如果您必須在方法界限外儲存或傳遞查詢結果,請考慮使用一般具名結構或類別來取代匿名類型。

匿名型別在 CSHTML 被當成 object 型別,無法滿足資料繫結的強型別需求。改用 @Newtonsoft.Json.JsonConvert.SerializeObject(ViewBag.Depts) 測試,驗證資料已正確傳到前端,其中差別在於 Json.NET 靠 Reflection 解析欄位可以正確讀取屬性,而資料繫結需要強型別。

有幾種解法:第一種是從 JavaScript 下手,既然資料能正確轉為 JSON,便可再轉為 JavaScript 物件陣列,用 Angular、Knockout 等 MVVM 框架可輕易繫結成下拉選單。[參考]

若想在伺服器端處理,第二種做法是放棄匿名型別,乖乖宣告一個 DeptInfo 之類的具名型別,其中定義 DeptId、DeptName 屬性做為 CSHTML 與 Controller 間的共通規格,這是最守規矩的正統解法。

如果你像我一樣崇尚簡潔勝過嚴謹,討厭為了一丁點限制搞出一堆只用一次的雞肋型別,可以參考看看我找到的第三種做法。(如果你也愛用 Dapper、Tuple,那我們應該是一國的)

在 Stackoverflow 找到一則相關討論,提到以前介紹過的既然要動態就動個痛快 - ExpandoObject,可以兼顧動態及強型別繫結要求。關鍵在於將匿名型別轉成 ExpandoObject,為求簡便,轉換程序可寫成擴充方法,再透過 .Select(o => new { DeptId = o.DI, DeptName = o.DN }.ToExpando()) 將匿名型別物件轉成 ExpandoObject 即可。ToExpando() 有個值得偷學的技巧:RouteValueDictionary 可將任何物件轉成 IDictionary<string, object>,再用 foreach + Add 方式將屬性複製到 ExpandObject 上,比 Reflection 寫法簡潔很多,但要記住 ExpandoObject 與 dynamic 背後仍是走 Reflection,留意可能的效能代價。

publicclass HomeController : Controller
    {
public ActionResult Index()
        {
            ViewBag.Depts = DataHelper.GetDepts()
                .Select(o => new { DeptId = o.DI, DeptName = o.DN }.ToExpando())
                .ToList();
return View();
        }
    }
 
publicstaticclass ExpandoExtensions
    {
//http://stackoverflow.com/a/5670899/4335757
publicstatic ExpandoObject ToExpando(thisobject anonymousObject)
        {
            IDictionary<string, object> anonymousDictionary = 
new RouteValueDictionary(anonymousObject);
            IDictionary<string, object> expando = new ExpandoObject();
foreach (var item in anonymousDictionary)
                expando.Add(item);
return (ExpandoObject)expando;
        }
    }

就這樣,匿名型別也可以在 CSHTML foreach 做資料繫結囉~

最後補充一點,匿名型別真的不能在不同方法間傳遞嗎?倒也未必,如下例,善用 dynamic 就可以克服:

不過,以上範例並不能解決本次案例遇到的狀況。CSHTML 雖然支援 dynamic 資料繫結(例如 ViewBag 本身就是 dynamic) ,但 foreach 時不適用,猜想與 foreach 情境的資料繫結實作方式有關,這部分就交給 ExpandoObject 搞定囉~

2017-01-11 補充

感謝 Dino 大大補充更簡便的做法,如果 foreach 的目的是要展開成 Text/Value 性質選項,有個 SelectList,可以透過 dataValueField、dataTextField 參數指定屬性名稱,轉成具有 Text、Value 屬性物件的集合:

@foreach (var dept innew SelectList(ViewBag.Depts, "DeptId", "DeptName"))
{
<option value="@dept.Value">@dept.Text</option>
}

順便補上 mrkt 針對 MVC 下拉選單處理的一系列深入探討,如果只是要做 DropDownList,用 foreach 其實有點繞路,正統的最精簡寫法是-@Html.DropDownList("field", new SelectList(ViewBag.Depts, "DeptId", "DeptName");。

IE 內嵌 IFrame 之 IE 相容模式組合問題

$
0
0

先前研究 IE 內嵌 IFrame 相容模式規則時,得到一個結論

透過IFrame內嵌網頁會沿用父網頁的文件模式,透過X-UA-Compatible亦無法改變

前幾天同事回報一個黑天鵝案例:IE8 相容模式網頁內嵌 IFrame,裡面再內嵌一個 IFrame,依先前理解,兩個 IFrame 都應沿用 IE8 相容模式。實測卻發現,只要內層 IFrame 沒宣告 X-UA-Compatible,網頁會處於比 IE8 還舊的相容模式。

這引發我的好奇,莫非先前結論有誤?

工作網站還有很多需要依賴 IE 相容模式再戰十年,這些眉角不徹底弄懂,將來少不了一堆見鬼的情境。於是我設計了以下實驗:

共有 Parent.aspx、Frame.aspx、SubFrame.aspx 三個網頁。Parent.aspx 用 IFrame 內嵌 Frame.aspx,Frame.aspx 再用 IFrame 內嵌 SubFrame.aspx,三個網頁可以各自控制 X-UA-Compatible 宣告,用一小段程式偵測 X-UA-Compatible 宣告及文件模式:(寫在 ietool.js 供三個網頁共用)

var meta = document.getElementsByTagName("meta");
var ieMeta = meta.length ? meta[0].getAttribute("content").split('=')[1] : "?";
var ieMode = document.documentMode;
document.getElementById("result").innerHTML = 
"META=" + ieMeta + ",MODE=" + ieMode;

Parent.aspx、Frame.aspx 如下,經由 QueryString 參數控制 X-UA-Compatible,並用 IFrame 內嵌下一層網頁:

<%@Page Language="C#"%>
<!DOCTYPEhtml>
<html>
<head>
<%
    var p = Request["p"];
if (!string.IsNullOrEmpty(p))
    {
%>
<metahttp-equiv="X-UA-Compatible"content="IE=<%=p%>"/>
<%
    }
%>
<style>body { font-size: 9pt; }</style>
</head>
<body>
<divstyle="width:520px">
<h2>Parent</h2>
<divid="result"></div>
<scriptsrc="ietool.js"></script>
<br/>
<iframesrc="frame.aspx<%=Request.Url.Query%>"style="width: 300px; height: 200px">
</iframe>
</div>
</body>

SubFrame.aspx 則多了一小段程式碼,將 Parent.aspx、Frame.aspx 與自己的 X-UA-Compatible 宣告及 IE 文件模式寫入 localStorage。

<%@Page Language="C#"%>
<!DOCTYPEhtml>
<html>
<head>
<%
    var p = Request["s"];
if (!string.IsNullOrEmpty(p))
    {
%>
<metahttp-equiv="X-UA-Compatible"content="IE=<%=p%>"/>
<%
    }
%>
<style>body { font-size: 9pt; }</style>
</head>
<body>
<h2>Sub IFrame</h2>
<divid="result"></div>
<scriptsrc="ietool.js"></script>
<script>
var parentRes = parent.parent.document.getElementById("result").innerHTML;
var frameRes = parent.document.getElementById("result").innerHTML;
var subFrameRes = document.getElementById("result").innerHTML;
var param = location.search.split("=")[4];
        localStorage[param] = parentRes + "," + frameRes + "," + subFrameRes;
</script>
</body>

最後寫一個 TestRunner.html,三個網頁的 X-UA-Compatible 各有 Edge、10、9、8、7、5 及不指定七種選項,7x7x7 共有 343 種組合,透過程式試遍所有組合,將結果轉成 CSV 做成 Excel 報表。

<html>
<head>
<metahttp-equiv="X-UA-Compatible"content="IE=Edge"/>
</head>
<body>
<buttononclick="run()">Test</button><buttononclick="genReport()">Report</button>
<preid="rpt">
</pre>
<script>
function run() {
var list = ["Edge","10","9","8","7","5", ""];
var jobs = [];
for (var i = 0; i < list.length; i++) {
for (var j = 0; j < list.length; j++) {
for (var k = 0; k < list.length; k++) {
                    jobs.push("./parent.aspx?p=" + list[i] + "&f=" + list[j] + "&s=" + list[k] + 
"&n=" + (i + 1) + "-" + (j + 1) + "-" + (k + 1));
                }
            }
        }
        localStorage.clear();
var hnd = setInterval(function() {
if (jobs.length == 0) {
                clearInterval(hnd);
return;
            }
var win = window.open(jobs.pop());
            setTimeout(function() {
                win.close();
            }, 2000);
        }, 250);
    }
function genReport() {
var keys = Object.keys(localStorage);
        keys.sort();
var rpt = [];
for (var i = 0; i < keys.length; i++) {
var p = localStorage[keys[i]].split(',');
for (var j = 0; j < p.length; j++)
                p[j] = p[j].split('=')[1];
            rpt.push([p[0],p[2],p[4],p[1],p[3],p[5]].join(","));
        }
        document.getElementById("rpt").innerHTML = rpt.join("\n");
    }
</script>
<div>
</div>
</body>
</html>

測試過程發現: 當 Parent 為 IE8 相容模式,出現內外 IFrame 相容模式不同的狀況。重看MSDN文件的說明:

As of IE9 mode, webpages cannot display multiple document modes. For example, consider a standards-based webpage that contains a frame element that displays content in quirks mode. IE9 mode displays the child frame in standards mode (because the parent document is in standards mode). Starting with Internet Explorer 10, however, child frames can emulate quirks mode. For more info, see IEBlog: HTML5 Quirks mode in IE10. For best results, however, use document modes consistently.

IE9起,檢視網頁只允許存在一種文件模式,故IFrame網頁必須沿用父網頁的IE標準模式。IE10做了點改善,允許IFrame網頁啟用Quirks相容。

我有一個新體會,原以我以為文件說的 IE9 指的是實體瀏覽器的版本 9,但實際上用 IE11 模擬 IE9 模式就適用。結果出爐,符合新版推測:

當 Parent.aspx 為 Edge、IE=10、IE=9 時,Frame.aspx 與 SubFrame.aspx 不管 X-UA-Compatible 怎麼設定,其 IE 模式永遠與 Parent.aspx 一致。

 

當 Parent.aspx 是 IE=8、IE=7、IE=5 時,Frame.aspx、SubFrame.aspx 可隨 X-UA-Compatible 切換成不同 IE 模式,三者的 IE 模式可各自獨立,但 IE=Edge、10、9 一律降至 IE=8,當未指定 X-UA-Compatible 時,則取 IE=7。

由此新發現,先前的結論應修正為:

當父網頁為 IE11、IE10、IE9 模式,透過IFrame內嵌網頁會沿用父網頁的文件模式,透過X-UA-Compatible亦無法改變。
當父網頁為 IE8、IE7、IE5 模式,IFrame 內嵌的網頁可透過 X-UA-Compatible 切換與父網頁不同的 IE 模式,但 Edge、IE10、IE9 會降為 IE8。


Hi, XBOX ONE!

$
0
0

去年因為一些事,對人生無常頗有感慨,頓時發現自己的人生嚴肅到有些乏味,老在計較每一分每一秒時間的投資報酬率。小木頭恰巧是鮮明的對比,小小年紀就有七八十歲老先生的豁達(如此「早熟」令人堪憂呀),功課考試什麼的最討厭了,腦海老繞著看到的好玩電腦遊戲打轉。兩個不良示範掛在天平兩端也不是辦法,弄條管道擴大交集,鼓勵二者向中央靠攏,而身為阿宅想到的是-買台遊戲機。吸引嚴肅老頭有點玩心,當成貪玩小鬼努力唸書的誘餌,就這麼辦吧!

在 PS4 與 XBOX 間猶豫許久,最後選了 XBOX ONE。會被網友綁架的線上遊戲是無底洞,不碰為妙;太空戰士、古墓奇兵等史詩巨作耗神耗時,升學在即沒本錢沈迷;能全家同樂的體感遊戲或趣味小品,是我心中家用遊戲機的最大發揮,聽說 KINECT 2.0 比前一代精確靈敏,表現比 XBOX 360、PS4 好。PS4 在 VR 方面比較成熟是一項優勢,但我對於會把人封在自己小天地的 3C 科技有排斥感… 總之,最後就決定是 XBOX ONE 啦!(當然,XBOX 的微軟基因應該也是雀屏中選的非理性因素啦!)

上網挑來挑去,找到挺優惠的同綁包:XBOX ONE + KINECT + HALO 最後一戰:士官長合集。自己笨手笨腳,估計不是玩 HALO 這類講求耳聰目明手腳敏捷的第一人稱射擊遊戲的料,但鑑於它是 XBOX 獨家經典,威名顯赫,不想空留「平生不識陳近南,便稱英雄也枉然」之憾,看看豬走路也好,就它吧!

就不開箱了,簡單整理入手一個月的心得:

  1. KINECT 蠻厲害的,肢體動作捕捉精準度比想像高,而且還支援人臉辨識,從電視前方走過就會被它認出來,螢幕會出現「Hi, Jeffrey!」,頗有親切感 XD 
  2. 基本上 XBOX ONE 向下支援許多 XBOX 360 遊戲,但基於 KINECT 版本不同,無法向下相容 XBOX 360 的經典體感遊戲如全民運動會,讓人頗失望。
  3. HALO 我只玩到這裡而已。說過我不是玩第一人稱射擊的料,手腳反應奇慢,推搖桿精確度極差,螢幕看久了頭還會暈。
    指揮官,對不起讓你失望了,這是我的退伍申請…
  4. Cortana,是妳?
    在 HALO 裡見到久仰大名的 Cortana,原來長這樣…

    微軟在 Windows Phone、Microsoft Band(智慧手環)、Windows 10 的語音助理也叫 Cortana(小娜),典故便來自 HALO 這款經典遊戲。原本是戰艦的人工智慧輔助系統,後來轉移到士官長的戰鬥裝裡跟主角一起出生入死。(咦?把太空船等級的系統搬進個人移動裝備,供電、散熱跟記憶體容量真的不會有問題嗎?)
  5. 朗朗上口多年的「解除XX的成就」,玩了 XBOX 才首次真實體驗,我「解除了真的在 XBOX 上解除成就的成就」~
  6. 有 KINECT 卻玩不了全民運動會,找到口碑不錯的水果忍者加減填補空缺。

    好玩!但運動強度直逼跑五分速,玩沒幾分鐘就喘了~
  7. 開放世界遊戲是我的菜,沒有固定路線或邊界,沒有急迫逼人的倒數壓迫感,系統模擬出近乎真實的世界,其中的物體或角色會因你的行為有對應的反應,跟寫程式很像,任由你發揮創意嘗試各種行動觀察結果,愛怎麼玩就怎麼玩,有趣極了。
    趁著耶誕節打折,入手開放世界遊戲的經典大作-俠盗獵車手 GTA 5!

    遊戲我玩得超爛,射擊準星亂飄老打不中,開車一路蛇行四處亂撞,每次調頭方向盤肯定打反,撞車卡住倒車兩分鐘還在原地打轉,為「缺乏玩動作遊戲天分」做出最佳詮釋。主線第一件任務:跟車開到指定地點,我就失敗重試不下五十次,我想起無限接關卻過不了關的湯姆克魯斯,把 GTA 玩成明日邊界,我是史上第一人吧?

    但很奇妙,主線任務打不過一點都不影響遊戲樂趣,開放世界允許你用自己喜歡的方式與步調探索,嘗試各種有創意的死法玩法,超 級 好 玩!

XBOX 發揮了它的影響力,為了可以摸到手把,小木頭眼中閃過亮光,抱起課本…

最後,在 XBOX 上寫程式看來不難,用 C# 寫 UWP 就能搞定,在 XBOX 跑程式開啟開發者模式就能部署程式上去測試,然後地表最強的開發工具 Visual Studio 再次不辱盛名,能遠端逐行偵錯程式… 呸呸呸,怎麼又繞回程式(拍額頭),下回再說。

筆記-Scott Hanselman 的 2017 .NET 開發者須知

$
0
0

Scott Hanselman 前兩天有篇文章-What .NET Developers ought to know to start in 2017,我的工作(甚至生活)跟 .NET 息息相關,重量級人物的觀點自然不容錯過,整理筆記如下:

前言

  • Scott 之前整理過類似的 .NET 須知,結果被大家拿來當作面試時折磨新人的刑具…
  • 清單很長,但並不是每則都必須搞懂弄通,應視自己所需以及學習習慣取捨,有些知道名詞即可,有些應該深入了解。
  • https://dot.net是 .NET 技術資源的新入口,首頁有個線上 C# 編譯器可以玩玩。

必備知識

  • 新的 .NET 架構,分成 .NET Framework、.NET Core、Xamarin 三種 Runtime,底層是 .NET Standard…
    延伸閱讀:.NET Standard 2.0 是什麼?可以吃嗎?
  • .NET Framework - 用於 Windows 平台
  • .NET Core – 可通行於 Windows、Linux、Mac
  • Mono for Xamarin – 整合 .NET 與手機原生 API,可開發 iOS 及 Android App
  • 主要語言:C#、F#、VB.NET
  • 如何開始?
  • Frameworks
    指可使用的 API 集合,例如:.NET 4.6 Framework、.NET Standard 等,有時會以 TFM 表示
  • Platforms 平台
    如 Windows、Linux、Mac、Android、iOS 等,還會進一步區分 x86、x64。
  • TFMs (Target Framework Moniker)
    用來表示平台版本的簡稱,例如 net462(.NET 4.6.2)、net35(.NET 3.5)、uap(Universal Windows Platform),指定 TPM 決定可以使用的 API 範圍。
  • NuGet
    .NET 愈來愈依賴透過 NuGet 下載必要程式庫及核心元件,許多東西不再預先安裝於本機,而是新起專案時才下載,這年頭 .NET 開發者不會 NuGet 恐怕混不下去。
    延伸閱讀:還在揮汗徒手安裝程式庫? 試試NuGet
  • Assembly 組件
    .NET 程式編譯後的產出,多以 DLL、EXE 方式存在,是部署、版本管理、重複利用以及權限控管的基本單位。
    .NET Core 的編譯結果則是一個 NuGet Package,包含組件以及額外的 Metadata。
  • .NET Framework vs. .NET Core
    .NET Framework 聚焦於 Windows 平台(桌機、平板、手機、XBOX),.NET Core 則可跨平台。

應該知道

  • CLR 
    Common Language Runtime (CLR),執行 .NET Framework 的虛擬機器元件(for Windows)
  • CoreCLR
    .NET Core 用的 Runtime
  • Mono
    Xamarin 及 Linux 系統用的 .NET Runtime
  • CoreFX
    .NET Core 的 .NET 類別程式庫,部分程式碼與 Mono 共用。
  • Roslyn
    C# 與 Visual Basic 編譯器,有開放讀、寫、分析程式碼的 API  可供延伸應用。
  • GC
    .NET 使用記憶體回收機制,免除開發者自行管理記憶體的負擔。延伸閱讀:Fundamentals of garbage collection (GC).
  • "Managed Code"
    指使用 .NET 語言開發的程式,相對另一種是 Unmanaged Code,指用 C/C++/VB/Delphi 寫的程式、ActiveX、COM+元件。
  • IL
    .NET 編譯結果非機器碼,而是一種中間語言(Intermediate Language),執行時才由 Runtime JIT 編譯成機器語言。
    Scott 的比喻:C# 是蘋果,IL 是蘋果醬、JIT 及 CLR 再將它磨成磨成蘋果汁。
  • JIT
    Just in Time Compiler,即時將 IL 編譯成機器語言。
  • .NET Framework 的儲存位置在 C:\Windows\Microsoft.NET,而 .NET Core 在 C:\Program Files\dotnet,在 Mac 則為 /usr/local/share。但 .NET Core 允許把 Runtime 包進程式的 Package 一起部署,如此客戶端不需事先安裝 .NET Core Runtime,只要 xcopy 就可以部署(xcopy-deployable or bin-deployable),這種做法稱為:Self-Contained Application,反之則稱為 Shared Framework Apps。
  • async and await
    async 與 await 指令可解決執行耗時動作(例如查詢資料庫,呼叫 Web API)程式卡住的問題。
  • Portable Class Libraries
    一種允許跨平台使用的「最大公約數」性質的共用程式庫,未來建議改用.NET Standard。
  • .NET Core
    .NET runtime、一組 Framework Libraries以及一組 SDK 工具以及語言編譯器組成,這一切可由.NET Core SDK取得。
    'dotnet' 程式可用於啟動 .NET Core 程式,它會選取並執行適當的 Runtime,提供組件載入原則並啟動程式,SDK 工具也是用相同方式啟動。

錦上添花

使用 FileChangeMonitor 實現檔案資料快取自動更新

$
0
0

有個開發老鳥專屬的「成功經驗」陷阱:遇到難題,想出一套簡單有效解法,或許有些小缺點,但造成的麻煩在可忍受範圍,於是 日後再遇到同樣狀況,一律照方煎藥,數十年如一日。

但技術會革新、元件會改進,善用一些新特性,小缺點其實可以化為無形。可怕的地方在於:如果每次都能順利解決問題,就不會圖謀改進,直到有天發現洋人船堅砲利,才知自己已成滿清… 老鳥想一直寫程式又不想被時代淘汰,就得提高警覺。有個超簡單的實踐方法-對自己機車一點。當有人反應不方便時,別一句「就多一個動作會死嗎?」頂回去,改成問自己:「連這個動作都省不掉?嫩!」,對自己GY一點才能撐久一點。以下算是個實例:

專案有時會遇到上傳檔案更新資料的機制,每天由排程將當天的資料寫到固定資料夾,網站執行時解析檔案轉為必要的資料格式。由於檔案每天只更新一次,每次重新讀檔解析太沒效率,解析完將資料寫入 Cache 並設定當日有效,隔日再用到時 Cache 已逾時再讀取新資料,如此兼顧效能與資料即時性,看似挺完美。但有個問題,若營運過程發現檔案有誤重新上傳,此時 Cache 仍有效,網站將繼續涗用舊資料。因此得多設計清除 Cache 的 API,而中途更新檔案的 SOP 要改成:1) 上傳檔案 2) 呼叫清除 Cache API。

以下是實作範例:

DataHelper.cs

publicclass ProductItem
{
publicstring Name;
publicdecimal Price;
}
 
conststring CACHE_KEY = "PriceData";
 
publicstatic List<ProductItem> GetPriceData()
{
//實務上可寫成共用函式GetCachableData,參考:https://goo.gl/K0IeTb
//這裡為示範原理,直接操作MemoryCache
    var cache = MemoryCache.Default;
lock (cache)
    {
if (cache[CACHE_KEY] == null)
        {
string filePath = HostingEnvironment.MapPath("~/App_Data/Price.txt");
//將文字資料轉為物件陣列
            List<ProductItem> list = System.IO.File.ReadAllLines(filePath)
                .Select(o =>
                {
                    var p = o.Split(' ');
returnnew ProductItem()
                    {
                        Name = p[0],
                        Price = decimal.Parse(p[1])
                    };
                }).ToList();
//第一筆塞入Cache產生時間
            list.Insert(0, new ProductItem() { Name = DateTime.Now.ToString("HH:mm:ss") });
 
            cache.Add(CACHE_KEY, list, new CacheItemPolicy()
            {
                AbsoluteExpiration = DateTime.Today.AddDays(1)
            });
        }
return cache[CACHE_KEY] as List<ProductItem>;
    }
}
 
publicstaticvoid ClearPriceData()
{
    MemoryCache.Default.Remove(CACHE_KEY);
}        

HomeController.cs

public ActionResult ShowDailyPrice()
{
return View(DataHelper.GetPriceDataEx());
}
 
public ActionResult ClearDailyPrice()
{
    DataHelper.ClearPriceData();
return Content("OK");
}

ShowDailyPrice.cshtml

@model List<MyMvc.Models.DataHelper.ProductItem>
@{
    Layout = null;
}
 
<!DOCTYPEhtml>
 
<html>
<head>
<metaname="viewport"content="width=device-width"/>
<title>ShowDailyPrice</title>
<style>
        table { width: 200px; font-size: 11pt; }
        td,th { text-align: center; padding: 6px; }
        tr:nth-child(even) { background-color: #eee; }
        tr.hdr { background-color: #0094ff; color: white; }
        .name { width: 70%; }
        .prz { width: 30%; text-align: right; }
</style>
</head>
<body>
<div>
<table>
<trclass="hdr"><th>品名</th><th>價格</th></tr>
            @foreach (MyMvc.Models.DataHelper.ProductItem prod in Model.Skip(1))
            {
<tr>
<tdclass="name">@prod.Name</td>
<tdclass="prz">@string.Format("{0:n1}", prod.Price)</td>
</tr>
            }
</table>
</div>
<br/>
<small>
快取產生時間:@Model.First().Name
</small>
</body>
</html>

這套寫法我用在很多專案,由於中途更新檔案頻率不高,SOP 多一個動作大家覺得還好。但如果 GY 一點:手動清 Cache 的動作真的不能省嗎?這都搞不定,你有臉說自己是資深程式設計師?

其實,都用了 MemoryCache 做檔案快取卻要手動清 Cache 真的有點 Low。在存入 Cache 時,CacheItemPolicy除了設定保存期限、移除事件外,還可以指定 ChangeMonitor 物件,跟檔案、資料庫建立相依關係,在資料異動時自動清除快取。.NET 提供了幾個現成實作元件,包含:CacheEntryChangeMonitor(綁定另一個 Cache 項目,當其被移除時一併移除)、SqlChangeMonitor(利用 SQL Server 的 SqlDependency在某個 DB 查詢結果改變時自動移除)以及 HostFileChangeMonitor(FileChangeMonitor 是抽象類別,HostFileChangeMonitor 是它的實作,偵測到檔案或資料夾異動時可自動移除快取),而我們的案例即可藉由 HostFileChangeMonitor 實現重傳檔案時自動清除快取,省去手動清除的多餘步驟。

寫法很簡單,CacheItemPolicy.ChangeMonitors.Add(new HostFileChangeMonitor(string[] 檔案或路徑)) 就大功告成!

publicstatic List<ProductItem> GetPriceData()
{
//實務上可寫成共用函式GetCachableData,參考:https://goo.gl/K0IeTb
//這裡為示範原理,直接操作MemoryCache
    var cache = MemoryCache.Default;
lock (cache)
    {
if (cache[CACHE_KEY] == null)
        {
string filePath = HostingEnvironment.MapPath("~/App_Data/Price.txt");
//將文字資料轉為物件陣列
            List<ProductItem> list = System.IO.File.ReadAllLines(filePath)
                .Select(o =>
                {
                    var p = o.Split(' ');
returnnew ProductItem()
                    {
                        Name = p[0],
                        Price = decimal.Parse(p[1])
                    };
                }).ToList();
//第一筆塞入Cache產生時間
            list.Insert(0, new ProductItem() { Name = DateTime.Now.ToString("HH:mm:ss") });
 
            CacheItemPolicy policy = new CacheItemPolicy()
            {
                AbsoluteExpiration = DateTime.Today.AddDays(1)
            };
//指定檔案異動時自動移除Cache内容
            policy.ChangeMonitors.Add(new HostFileChangeMonitor(
//HostFileChangeMonitor接受IList<string>,此處用Split小技巧將單一或多檔名轉成IList
                filePath.Split('\n')));
            cache.Add(CACHE_KEY, list, policy);
        }
return cache[CACHE_KEY] as List<ProductItem>;
    }
}

實際展示如下,下方的檔案快取時間可用於驗證資料是否來自快取,先重新整理兩次時間未變,代表使用的是快取中的資料;修改檔案後儲存,重新整理網頁價格數字跟快取時間立即更新!

就醬,老狗又學會了新把戲。

【茶包射手日記】Windows 沒有足夠資訊可以確認這個憑證

$
0
0

某台持續爬網頁抓資料的排程忽然出現 The underlying connection was closed: Could not establish trust relationship for the SSL/TLS secure channel 訊息,推測為 SSL 憑證失效引起。

連至該主機使用瀏覽器檢視,果真憑證顯示異常:

錯誤訊息如下:

Windows does not have enough information to verify this certificate.
Windows 沒有足夠資訊可以確認這個憑證。

對照其他主機看到的憑證則很正常。

爬文得知此類問題出在缺少受信任的根憑證授權單位(Trusted Root Certification Authorities)或中繼憑證授權單位(Intermediate Certification Authorities)憑證資料造成。實際檢查問題主機,VeriSign CA 憑證 OK,但少了 Symantec Class 3 Secure Server CA - G4 中繼 CA 憑證。

最後,由其他主機匯出 Symantec 中繼 CA 憑證安裝在問題主機後順利排除問題!

問題已解,留下幾個疑點:

  1. 缺憑證問題多發生於第一次連網站。早上還正常,近中午才出現缺少中繼 CA 憑證錯誤有些令人費解。 (新憑證有效起始日期頗近,出錯時間又接近整點,不排除正巧遇上站方換憑證的可能性)
  2. 依我的理解,SSL 協商過程網站有責任附上中繼 CA 憑證供瀏覽器驗證信任鏈,為何需要手動補安裝?

因無法重現問題反覆檢驗,先歸入 X 檔案。

貼文後沒多久,Aska 回饋一則相似案例:因 SHA1 升級 SHA2 換 SSL 憑證(2017 起瀏覽器們將開始撲殺 SHA1 SSL 憑證),中繼 CA 由 Verisign Class 3 Secure Server CA - G3換成 Symantec Class 3 Secure Server CA - G4,客戶端也發生缺少中繼 CA 憑證的狀況,推測本案應為相似情境。

Autofac筆記6-Resolve時依參數傳回不同型別

$
0
0

好久沒寫 Autofac 筆記,記錄一則最近遇到的小需求。系統中針對介面(例如:IBlah)實作了多個型別,Resolve<IBlah>() 時希望透過參數指定傳回不同型別。

依據官方文件,實現這類需求的最簡單做法是使用 Named Service(具名服務)或 Keyed Service(鍵值對應服務), Register<T>() 後不使用 As<IBlah>(),而改用 Named<IBlah>("服務名稱") 或 Keyed<IBlah>(列舉值) 註冊。之後呼叫端改用 ResolveNamed<IBlah>("服務名稱") 或 ResolveKeyed<IBlah>(列舉值) 即可取得不同執行個體(Instance)。如此我們可為 IBlah 註冊多種型別,彼此以服務名稱字串或列舉值區隔,Resolve 時透過服務名稱字串或列舉值可取得指定型別的執行個體。Named 與 Keyed 功能相同,差異在於使用字串或列舉當參數,我傾向使用列舉以充分享受強型別的優勢。

講了一堆,其實用起來蠻簡單的,來段程式範例大家就明白了:

using Autofac;
using System;
 
namespace AutofacLab
{
publicinterface ISensor
    {
string Detect();
    }
publicclass TemperatureeSensor : ISensor
    {
publicstring Detect()
        {
return"It's hot";
        }
    }
publicclass SoundSensor: ISensor
    {
publicstring Detect()
        {
return"It's noisy";
        }
    }
 
publicenum SensorType
    {
        Temperature,
        Sound
    }
 
class Program
    {
staticvoid _Main(string[] args)
        {
            ContainerBuilder builder = new ContainerBuilder();
//http://docs.autofac.org/en/latest/advanced/keyed-services.html
            builder.RegisterType<SoundSensor>().Keyed<ISensor>(SensorType.Sound);
            builder.Register((c) =>
            {
returnnew TemperatureeSensor();
            }).Keyed<ISensor>(SensorType.Temperature);
            IContainer container = builder.Build();
 
            var ss = container.ResolveKeyed<ISensor>(SensorType.Sound);
            Console.WriteLine(ss.Detect());
            var ts = container.ResolveKeyed<ISensor>(SensorType.Temperature);
            Console.WriteLine(ts.Detect());
            Console.Read();
 
        }
    }
}

在 Chrome/Edge 網頁用 IE 開啟超連結

$
0
0

這是 IE Only 網站親衛隊才有的困擾。

許多內部系統年代久遠,寫於全天下瀏覽器只有一種(IE)的時代(2004 年 IE 市佔高達 95% [參考]),寫成 IE Only 也是很合理的事。但你我都知道,時代不同了,滿天都是飛機啊,滿街都是電腦啊,HTML5 世代 IE 早已不是最好的瀏覽器選擇。望著公司那堆 IE Only 的生財工具營運系統網站,即使它們遲早要汰換,但也不是說翻就翻?有些規模數十人月的大專案,問君能有幾副肝,恰似鞭炮爆不完?

所以囉,繼續再跟 IE Only 網站和平共處十年,是每一位內部系統開發維護人員要有的心理建設。但尷尬的是-不少新網站改用 HTML5 新技術、新框架打造,JavaScript 角色日益吃重,而 IE 在這方面的效能表現明顯不如 Chrome 或 Edge,因此我們會常常明示暗示使用者改用 Chrome 開啟網頁以享受順暢的操作體驗,但一遇到要切換回還沒翻新的 IE Only 網頁功能就糗了,只能很心虛地跟使用者說「要用兩個功能記得要另外開 IE,不要直接在 Chrome 點哦,啾咪~」,想當然,使用者當場白眼都翻到後腦杓去了。

因此我常被問到「能不能從 Chrome 用 IE 開網頁?」,雖然腦中閃過幾個點子:寫個 Chrome Plugin?在使用者機器裝個常駐內應程式接收 Web API 啟動 IE?衡量部署及後續客服難度後,我的答案一直都是「辦不到」,直到這兩天… 我想到一個好點子:

影片

原理是借用 Windows 的自訂 URI Schema 功能,我定義一個 iehttp://… URI Schema,並透過 Shell Open 方式呼叫 iexplore.exe 開啟該超連結。其中有個特殊需求,%1 接收字串參數中的 iehttp 要換成 http,用了點 DOS 指令技巧,用 cmd /v /c 執行程式,將 %1 存入變數,再對變數(在 cmd 要加 /v,變數 %var_name% 要改成 !var_name!)進行置換(語法為 !var_name:find_str=replace_str!),處理完畢傳給 iexplore.exe 開啟網頁。接著,將要用 IE 開啟的連結由 http: 改成 iehttp:,大功告成!(實務上應加入自動偵測,遇到非 IE 瀏覽器開啟網頁時,再將 IE Only 連結的 URL 改掉)

附上 iehttp URI Schema 註冊機碼如下:(若作業系統為 32 位元,Program Files (x86) 請改為 Program Files)

Windows Registry Editor Version 5.00
 
[HKEY_CLASSES_ROOT\iehttp]
@="URL:Open with IE Protocol"
"URL Protocol"=""
 
[HKEY_CLASSES_ROOT\iehttp\shell]
 
[HKEY_CLASSES_ROOT\iehttp\shell\open]
 
[HKEY_CLASSES_ROOT\iehttp\shell\open\command]
@="cmd /V /C \"set URL=%1&& set URL=!URL:iehttp=http!&&cmd /c \"\"C:\\Program Files (x86)\\Internet Explorer\\iexplore.exe\"\" !URL!\""

提醒,跨瀏覽器開啟方式還是有些小缺點,雙方網頁即使同網域也無法共用 Session、Cookie,彼此的 DOM 也完全不相通,開啟過程會閃一下 DOS 視窗(可靠另寫小程式取代 cmd /c,請自行衡量是否需要)… 但對我來說,已能滿足不少單純的 Chrome / IE 併用需求,前進了一大步(灑花轉圈)~

另外附上完整操作示範影片

生態池日記-豆娘羽化觀察

$
0
0

【警告】照片裡挺可愛的豆娘,小時候住水裡叫做水蠆(發音同「菜」),長得不太稱頭(其實是醜到讓人不蘇湖),文章內有水蠆的「近距離寫真」,請大家視身心狀態自行評估,看完以上照片就關掉網頁,只留下美好印象也是不錯選擇。

【背景知識】蜻蜓豆娘都屬於蜻蛉目,主要差異在於停止時翅膀平放展開或收合。二者的稚蟲都叫做水蠆,生活在池塘或溪流,以昆蟲、小魚或蝌蚪為食。延伸閱讀

防雷緩衝區起點

 

 

 

 

 

 

 

 

防雷緩衝區終點

願意把捲軸拉到這裡的同學,已展現對自然生態的好奇與熱愛,我們進入正題。

前些日子在水蘊草上看到一隻怪蟲,對水生昆蟲生態沒啥研究的我,搞不清楚它是什麼妖魔鬼怪,只覺得樣子挺噁心,拍完照片沒多久小蟲迅速游進水草叢間,之後一個多月,沒再發現其身影。

直到今天,醜醜蟲再度現身,身體呈深咖啡色爬出水面趴在葉子上做日光浴。難得有生物靜止不動自願當麻豆,趕緊拿出相機拍照,拍了幾張想撥葉子調角度,不慎讓牠滑進水裡,但牠沒有潛下去的意思,游到池邊被我補捉到一張完整全身照。沒多久,牠爬上另一片葉子繼續曬太陽。

   

觀察到完整蟲型,認真爬文,這才發現原來牠是豆娘稚蟲-「水蠆」來著,而爬出水面意味著牠要羽化了:參考

水蠆經過多次蛻皮,成長到終齡稚蟲,可以看到胸部會有明顯的翅芽,這是與其他齡期稚蟲的一個簡單的區別方法。通常蜻蜓的終齡稚蟲會在羽化前幾個小時爬出水面附近的地方,如枯枝、植物葉子、石塊、圍牆、橋墩等物體都可供蜻蜓穩定攀爬以進行羽化,據目前所知大多數蜻蜓於夜晚羽化,少部分蜻蜓成員在清晨羽化。剛羽化的成蟲通常體色很淡,翅膀很薄且有強烈金屬反光,等過一天之後,成蟲體色就會加深,翅膀也會變硬且沒有明顯反光,未成熟的成蟲要經一些日子才會變成熟。

生態池水面沒有可以向上攀爬的地方,我很有效率地來水桶、樹枝、吊繩,迅速打造出「頂級專屬羽化架」,資料寫羽化前數小時會爬出水面,而羽化時間多在夜晚或清晨,心想還有得等,搞不好像上次拍紋白蝶羽化得熬夜才能目睹。

誰知,去吃個午餐回來一看,豆娘早已現身,我錯過了蛻皮而出的黃金時刻!啊啊啊啊~

只拍到醜醜的空殼… Orz

8分鐘後

20分鐘後

30分鐘後

一小時後,哇,這身形也太修長了吧?

豆娘拍了拍翅膀,飛上螃蟹蘭繼續曬翅膀,我則留下這張畢業照,祝牠一生平安~

PS:小蟲小草拍多了,有想買單眼百微鏡頭的衝動,哈!


徹底移除已簽入TFS的項目

$
0
0

保留完整版本變更歷程是版控系統的核心精神之一,檔案項目一旦簽入,就算使用者要求刪除,項目從清單上消失,仍可透過歷史記錄還原每一個曾簽入的版本。

實務上,偶爾會發生不慎誤將不該簽入內容丟上版控的狀況(例如:誤簽入個資或機密敏感內容),此時版控對保留完整軌跡的堅持變成缺點,不管刪除或 Rollback 都無法防止他人透過歷史記錄還原內容。

非常狀況只能用非常手段,在 TFS 上遇此種狀況,tf.exe 工具有個 destroy 指令可以解決問題。

語法範例如下:

tf destroy $/src/path/filename.ext /collection:"httq://tfs-server:8080/tfs/collectionName"

執行者必須具備 Team Foundation Administrators 管理者群組身分,確認刪除後檔案便會從 TFS 移除,從 Changeset、Pending Change、Merge History、Branch History 徹底消失,不留半點痕跡。參考

在 destroy 前,建議先依 TFS 標準做法將該項目刪除。否則可能出現類似下圖的奇特狀態,tfs-destroy-test.txt 已從 Source Controller Explorer 消失,但 Solution Explorer 裡它仍存在且有藍色鎖頭,按右鍵 View History 也能查詢,但查不到任何簽入簽出記錄。

若 destroy 時其他人更動過該檔案而處於 Pending Change(暫止變更)狀況,將出現如下警示:

此時可加上 /preview 參數,tf.exe 將不執行動作,只顯示檔案被哪些人列為 Pending Change。優雅的做法是通知相關人員自行 Undo 後再 destroy;粗暴一點直接刪除也成,該項目將從眾人的 Pending Change 清單消失,可能讓當事人一頭霧水以為撞鬼。

最後提醒,destroy 非正常操作,只能視為修補錯誤簽入動作的迫不得已手段,並有遺失修改歷程、破壞資料一致性的副作用,只能由管理者執行,但遇到不計代價必須抹除資料的情境,算是唯一解法。

小試 IIS 的簡易 DoS 防護-動態 IP 限制

$
0
0

這幾天,DDoS 攻擊事件在台灣鬧得沸沸揚揚。

DoS 攻擊可約略分為頻寬消耗型(找一大群鄉民擠在餐廳門口)及資源消耗型(召喚服務生過來點菜連點兩鐘頭,或一口氣點兩百盤紅燒獅子頭),從網站管理者的角度,對頻寬消耗型攻擊完全無能為力,只能靠 ISP 或網管單位防禦;但對於資源消耗型攻擊,倒是有些因應對策。(例如:把十分鐘還沒點完菜的客人攆出去或把點兩百盤獅子頭的客人打成豬頭)

我知道有一些 ASP.NET 設定就與 DoS 防護有關,例如:每次搞檔案上傳時都要手動調大的 MaxRequestLength參數,選用偏小的 4MB 預設值,用意就在防止惡意程式透過 POST Request 傳送大量資料耗盡頻寬、記憶體或執行緒。而這陣子詢問度很高則是另一項 IIS功能-「動態 IP 限制」(在 IIS 7 時代為額外模組需透過 Web PI 安裝,IIS 8 起改為內建,詳情可參考小朱的文章), 過去曾聽過有此功能,但沒實際玩過,把握這個機會弄個小 Lab 觀察驗證也好。

以 IIS 8 為例,先從 IIS 管理員開啟「IP位址及網域限制」:

點選「編輯動態限制設定…」即可叫出設定畫面:

動態 IP 限制可從兩個方向下手:限制同一 IP 同時開啟連線數或限制同一 IP 在一段期間內發送的最大要求數量, 一旦超過上限,IIS 便直接回傳錯誤,不再耗費頻寬或 CPU、記憶體處理要求。

預設建議值為每個 IP 同時連線數不超過 5 條,每 0.2 秒要求數不得超過 20 個,超過這個數字 IIS 就暫時不處理來自該 IP 的請求,直接傳回 HTTP 錯誤以節省資源。

為印證效果,我設計了以下兩個實驗:

【實驗1】

用 IFrame 同時開啟超過五個網頁,網頁故意超過 1 秒傳回結果,迫使瀏覽器同時開啟多條連線,以突破同時要求數上限。

TestSimuLimit.html (開啟七個 IFrame 載入 SlowTask.aspx)

<!DOCTYPEhtml>
<html>
<head>
<metacharset="utf-8"/>
<style>
        iframe { 
            display: block; margin: 3px; width: 600px; height: 50px;
        }
</style>
</head>
<body>
<script>
for (var i = 0; i < 7; i++) {
            document.write('<iframe src="SlowTask.aspx?t=' + Math.random() + '"></iframe>');
        }
</script>
</body>
</html>

SlowTask.aspx (延遲 1 秒傳回 Guid)

<%@ Page Language="C#" %>
<%
    System.Threading.Thread.Sleep(1000);
    Response.ContentType = "text/plain";
    Response.Write(Guid.NewGuid());
    Response.End();
%>

測試結果:前五個 IFrame 顯示正常,後兩個傳回 403.501。註:
501: Dynamic IP Restrictions rejected the request due to too many concurrent requests
502: Dynamic IP Restrictions rejected the request due to too many requests over time

【實驗2】

以 setInterval 方式連續以 $.get() 方式發出 30 個 GET 存取,藉由改變時間間隔調整單位時間內發出要求數,例如:間隔 10ms 等於每 200ms 發出 20 個 AJAX 要求。

TestRPSLimit.html

<!DOCTYPEhtml>
<html>
<head>
<metacharset="utf-8"/>
<style>
        li {
            display: inline-block; margin: 3px; padding: 3px;
            border: 1px solid gray;
        }
        .warn {
            background-color: sandybrown;
        }
</style>
</head>
<body>
    Interval = <inputtype="text"value="11"size="2"/> ms
<buttonid="btnRun">Test</button>
<ulid="ulResults"></ul>
<scriptsrc="https://code.jquery.com/jquery-3.1.1.min.js"></script>
<script>
var MAX_COUNT = 30;
var ul = $("ul");
var count = 0;
function test() {
            ul.empty();
            count = MAX_COUNT;
var hnd = setInterval(function () {
                $.get("OK.txt?t=" + Math.random()).done(function () {
                    ul.append("<li>Succ</li>");
                }).fail(function () {
                    ul.append("<li class='warn'>Fail</li>");
                });
                count--;
if (count <= 0) clearInterval(hnd);
            }, parseInt($("input").val()));
        }
        $("button").click(test);
</script>
</body>
</html>

間隔 11ms,相當於 18.2 Requests / 200ms,未突破上限,所有 $.get() 都成功。

間隔 9ms,約 22.2 Requests / 200 ms,我們觀察到前 20 次成功,接著有三次失敗(由 F12 開發者工具可知為 HTTP 403),滿 200ms 後重新計算,後面 7 次也成功。

間隔 7ms,約 28.6 Requests / 200 ms,前 20 次成功,接著 8 次失敗,滿 200ms 重新計算,後面又成功。

【結論】

透過上述兩個實驗,我們驗證 IIS 的動態 IP 限制確實能降低單一 IP 同時或連續發出大量請求拖垮系統的風險,雖然功能與強韌度無法與專業 DDoS 防禦設備相提並論,但仍具有基本的防護效力(甚至對付「網頁搶票 F5 Combo攻擊」也多少有效果,哈),值得一試。

實務設定時,同時要求數及連續要求數的拿捏是門學問,較嚴謹的做法是觀察平日負載下單一 IP 可能產生的要求數,以判定超過多少需視為異常。設定上限數字的畫面有個「僅啟用記錄模式」選項,此時就能派上用場,在記錄模式下,IIS 遇到超過上限時仍會以 HTTP 200 正常傳回結果,但 IIS Log 的 sc-substatus 會註記 501/502 做為區別,以便觀察誤判比率。有個比較麻煩的問題,是以來源 IP 做為判定依據,來自同一 NAT、防火牆、代理伺服器後方的使用者會共用同一 IP,有可能因誤判造成困擾,此點在是高流量網站更明顯。針對大型網站或重要服務,恐怕還是得靠專業 DoS 防護設備較有保障。

使用非同步處理提升資料庫更新速度

$
0
0

來自同事的資料庫程式效能調校案例一則。

情境為一支同步來源及目的資料表的排程,先一次取回來源及目的資料表,逐一檢查資料是否已存在目的資料表,若不存在即執行Insert,若存在則執行 Update 更新欄位。因 Insert/Update 之前需進行特定轉換,故難以改寫為 Stored Procedure。排程有執行過慢問題,處理四萬筆資料耗時近 27 分鐘。

程式示意如下:

foreach (var src in srcList)
{
try
    {
        var target = findExistingData(src);
if (target == null)
        {
            AddTargetToDB(src);
        }
else
        {
            UpdateTargetToDB(target, src);
        }
    }
catch (Exception e)
    {
        LogError(e);
    }
}

同事加入多執行緒平行處理,改寫為 Parallel.ForEach 版本如下,很神奇地把時間縮短到 5 分鐘內完成!

var count = 0;
Parallel.ForEach(srcList, () => 0, (src, state, subtotal) =>
{
try
    {
        var target = FindExistingData(src);
if (target == null)
        {
return AddTargetToDB(src);
        }
else
        {
return UpdateTargetToDB(target, src);
        }
    }
catch (Exception e)
    {
        LogError(e);
return 0;
    }
},
rowsEffected =>
{
    Interlocked.Add(ref count, rowsEffected);
});

加入平行處理可加速在預期之內,高達五倍的效能提升卻讓我大吃一驚!我原本預期,四萬次 Insert 或 Update 操作大批進入應該在資料庫端也會形成瓶頸,例如:若 Insert 或 Update 涉及 Unique Index,資料庫端需依賴鎖定機制防止資料重複,即使同時送入多個執行指令,進了資料庫還是得排隊執行。

仔細分析,此案例靠多核平行運算能產生的效益有限,效能提升主要來自節省網路傳輪的等待時間。為此,我設計了一個實驗:建主一個包含 12 個欄位的資料表,4 個 VARCHAR(16)、4 個 INT、4 個 DATETIME,使用以下程式測試用 foreach 及 Parallel.ForEach 分別執行 1024, 2048, 4096, 8192 筆資料的新增與更新並記錄時間,Parallel.ForEach 部分則加入同時執行的最大執行緒數目統計:

using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Dapper;
using System.Diagnostics;
using System.Threading;
 
namespace BatchInsert
{
class Program
    {
staticstring cs = "連線字串";
staticstring truncCommand = @"TRUNCATE TABLE Sample";
staticstring insertCommand = @"
INSERT INTO Sample (T1,T2,T3,T4,N1,N2,N3,N4,D1,D2,D3,D4) 
VALUES (@T1,@T2,@T3,@T4,@N1,@N2,@N3,@N4,@D1,@D2,@D3,@D4)";
staticstring updateCommand = @"
UPDATE [dbo].[Sample]
   SET [T2] = @T2, [T3] = @T3, [T4] = @T4
      ,[N1] = @N1, [N2] = @N2, [N3] = @N3, [N4] = @N4
      ,[D1] = @D1, [D2] = @D2, [D3] = @D3, [D4] = @D4
 WHERE T1 = @T1";
staticvoid Main(string[] args)
        {
            Test(1024);
            Test(2048);
            Test(4096);
            Test(8192);
            Console.Read();
        }
 
staticvoid Test(int count)
        {
            List<DynamicParameters> data = new List<DynamicParameters>();
for (var i = 0; i < count; i++)
            {
                var d = new DynamicParameters();
                d.Add("T1", $"A{i:0000}", System.Data.DbType.String);
                d.Add("T2", $"B{i:0000}", System.Data.DbType.String);
                d.Add("T3", $"C{i:0000}", System.Data.DbType.String);
                d.Add("T4", $"D{i:0000}", System.Data.DbType.String);
                d.Add("N1", i, System.Data.DbType.Int32);
                d.Add("N2", i, System.Data.DbType.Int32);
                d.Add("N3", i, System.Data.DbType.Int32);
                d.Add("N4", i, System.Data.DbType.Int32);
                d.Add("D1", DateTime.Today.AddDays(i));
                d.Add("D2", DateTime.Today.AddDays(i));
                d.Add("D3", DateTime.Today.AddDays(i));
                d.Add("D4", DateTime.Today.AddDays(i));
                data.Add(d);
            }
            TestDbExecute(data, true, false);
            TestDbExecute(data, true, true);
            TestDbExecute(data, false, false);
            TestDbExecute(data, false, true);
        }
 
 
staticobject sync = newobject();
 
staticvoid TestDbExecute(List<DynamicParameters> data, 
bool insert, bool parallel)
        {
string cmdText = insert ? insertCommand : updateCommand;
using (SqlConnection cn = new SqlConnection(cs))
            {
                Stopwatch sw = new Stopwatch();
                cn.Execute(truncCommand);
                sw.Start();
if (!parallel)
                {
foreach (var d in data)
                    {
                        cn.Execute(cmdText, d);
                    }
                }
else
                {
int threadCount = 0;
int maxThreadCount = 0;
                    Parallel.ForEach(data, (d) =>
                    {
lock (sync)
                        {
                            threadCount++;
if (threadCount > maxThreadCount)
                                maxThreadCount = threadCount;
                        }
using (var cnx = new SqlConnection(cs))
                        {
                            cnx.ExecuteReader(cmdText, d);
                        }
 
                        Interlocked.Decrement(ref threadCount);
                    });
                    Console.WriteLine("[MaxThreads={0}]", maxThreadCount);
                }
                sw.Stop();
                Console.Write("{0} {1}  {2}: {3:n0}ms\n",
                    data.Count, parallel ? "Parallel" : "Loop", 
                    insert ? "Insert": "Update", sw.ElapsedMilliseconds);
            }
        }
    }
}

找了一台內網的遠端 SQL 資料庫進行測試,從 1024 到 8192 四種筆數,使用 Parallel.ForEach 都節省近一半時間,成效卓著:

1024 Loop Insert: 8,372ms
[MaxThreads=10]
1024 Parallel Insert: 4,668ms
1024 Loop Update: 8,737ms
[MaxThreads=11]
1024 Parallel Update: 4,620ms

2048 Loop Insert: 16,665ms
[MaxThreads=14]
2048 Parallel Insert: 8,358ms
2048 Loop Update: 16,545ms
[MaxThreads=12]
2048 Parallel Update: 8,538ms

4096 Loop Insert: 36,444ms
[MaxThreads=22]
4096 Parallel Insert: 17,925ms
4096 Loop Update: 33,724ms
[MaxThreads=22]
4096 Parallel Update: 17,427ms

8192 Loop Insert: 67,885ms
[MaxThreads=31]
8192 Parallel Insert: 35,011ms
8192 Loop Update: 65,761ms
[MaxThreads=27]
8192 Parallel Update: 34,819ms

接著我改連本機資料庫執行相同測試,這一回加速效果很不明顯,甚至出現 Parallel.ForEach 比 foreach 迴圈還慢的狀況:

1024 Loop  Insert: 5,073ms
[MaxThreads=10]
1024 Parallel Insert: 4,772ms
1024 Loop Update: 4,342ms
[MaxThreads=10]
1024 Parallel Update: 4,457ms

2048 Loop Insert: 8,144ms
[MaxThreads=11]
2048 Parallel Insert: 8,672ms
2048 Loop Update: 8,540ms
[MaxThreads=12]
2048 Parallel Update: 8,659ms

4096 Loop Insert: 17,477ms
[MaxThreads=22]
4096 Parallel Insert: 17,860ms
4096 Loop  Update: 18,089ms
[MaxThreads=22]
4096 Parallel Update: 17,629ms

8192 Loop Insert: 33,393ms
[MaxThreads=30]
8192 Parallel Insert: 35,364ms
8192 Loop Update: 35,869ms
[MaxThreads=39]
8192 Parallel  Update: 36,817ms

比較上述兩組結果,Parallel.ForEach 更新遠端資料庫的時間與更新本端資料庫的時間相近,逼近資料庫的極限,可解釋為藉由平行處理排除網站傳輸因素後,遠端資料庫的效能表現趨近本機資料庫。平行處理的加速效應只出現在連線遠端資料庫,用在本機資料庫反而有負面影響,也能研判效能提升主要來自節省網路傳輸等待時間。

【結論】

在對遠端執行大量批次更新時,使用 Parallel.ForEach 確實能藉著忽略網路傳輸等待縮短總執行時間,在網路傳輸愈慢的環境效益愈明顯。既然效能提升來自避免等待,改用 ExecuteNonQueryAsync 應該也能產生類似效果,但程式寫法比 Parallel.ForEach 曲折些。這類做法本質偏向暴力破解,形同對資料庫的壓力測試,若條件許可,可考慮改用 BULK INSERTTVP等更有效率的策略。

2017-02-10 補充:有不少網友提到 MERGE。依理而論,大量資料處理直接在 Procedure 做掉才是王道,即便不用 MERGE,在 Procedure 裡跑迴圈用 IF 實現也比拉回 C# 處理寫回效率高很多(網路傳輸及連線成本全免)。這個案例在 AddTargetToDb() 與UpdateTargeteToDb() 中有段欄位運算挺複雜,不易用 T-SQL 實現,才決定寫成排程程式(也算是逃避難題啦)。遇到資料量更大的情境(例如:百萬筆)就沒得選擇了,絞盡腦汁也得把同步運算邏輯轉成 T-SQL(或SQLCLR),寫成 Procedure 是唯一解。另一方面,將資料拉回 C# 端比對處理後再寫回 DB,缺乏 Transaction 保護就必須承受讀取到寫入這段期間資料被其他來源修改的風險,但耗時數分鐘的遠端作業加上 Transaction 的成本不容小覤。在本案中無此風險才得以使用此法,否則將戰場移回 DB 端才是較可接受的解決方案。在此感謝網友 ChingYuan, Shih、otaku119、LienFa Huang 的回饋與補充。

【小插曲】

程式改寫 Parallel 版時,由於非同步執行進度不易掌握,使透過統計 ExecuteNoQuery() 傳回受影響筆數方式確認 Insert/Update 筆數無誤。原本預期不管是新增或修改,每次變更筆數都應該為 1,萬萬沒想到統計總數卻超過總資料筆數,貌似改為平行處理後執行結果不同,引發驚慌。

深入調查才發現:目的資料表掛有 Trigger, 在特定情況會連動其他資料表的資料,造成更新一筆但受影響筆數大於 1(要加上 Trigger 所異動的資料筆數)。 最後修改程式,改由受影響筆數 >0 判定是否執行成功,計數則一律+1,化解一場虛驚。

用 100 行 C# 打造 IP 所屬國家快速查詢功能

$
0
0

講到由 IP 地址查詢所屬國家,解決方案有兩種:第一種是直接呼叫線上查詢 API(付費或免費),再不然就要下載 IP 區段資料庫自寫查詢程式。考量應用場合不一定有 Internet 連線能力,加上擔心線上 API 無法滿足 IIS Log 等超大量 IP 解析的效能要求,選擇取回資料檔自幹。(其實是因為這題目大小難易適中,十分適合練功,一時手癢難耐,就…)

爬文找到一些 IP 國別對應資料來源:

最後決定選用 software77 的資料,看中它每日更新以及號稱 99.95% 的準確率。(各家資料格式大同小異,要更換來源並非難事,微調匯入邏輯即可)

由網站取回 IpToContry.csv 格式如下,前方有一大段註解直接略過即可。資料部分共有七欄,第1、2欄為 IP 區段的起始位址及結束位址(IP 位址不使用 aaa.bbb.ccc.ddd 字串格式,而是將四個 Byte 轉換為整數),第 5 欄為國別代碼,第 7 欄有國家名稱。

一般處理 IP 區段查詢,最常見做法是轉進資料庫後使用 SQL 查詢。評估資料筆數大約 17 萬筆,轉成物件陣列放進記憶體進行查詢對 C# 及當代電腦硬體是一碟小菜,不依賴資料庫的輕巧小程式更貼近我的開發哲學,用一個小類別打死才帥。

先不費太多腦力,用最直覺的 C# LINQ 來解:定義一個範圍物件 IPRange,屬性包含起始位址、結束位址、國別代碼、國家名稱。將資料檔轉為 List<IPRange>,查詢用 .SingleOrDefault(o => ip >= o.Start && ip <= o.End) 就能輕鬆達成, 50 行搞定:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
 
namespace IP2C.Net
{
publicclass RookieIPCountryFinder
    {
publicclass IPRange
        {
publicuint Start;
publicuint End;
publicstring CnCode;
publicstring CnName;
        }
 
        List<IPRange> Ranges = new List<IPRange>();
 
public RookieIPCountryFinder(string path)
        {
foreach (var line in File.ReadAllLines(path))
            {
if (line.StartsWith("#")) continue;
//"87597056","87599103","ripencc","1338422400","ES","ESP","Spain"
                    var p = line.Split(',').Select(o => o.Trim('"')).ToArray();
                    Ranges.Add(new IPRange()
                    {
                        Start = uint.Parse(p[0]),
                        End = uint.Parse(p[1]),
                        CnCode = p[4],
                        CnName = p[6]
                    });
                }
        }
publicuint GetIPAddrDec(string ipAddr)
        {
byte[] b = IPAddress.Parse(ipAddr).GetAddressBytes();
            Array.Reverse(b);
return BitConverter.ToUInt32(b, 0);
        }
 
publicstring GetCountryCode(string ipAddr)
        {
uint ip = GetIPAddrDec(ipAddr);
            var range = Ranges.SingleOrDefault(o => ip >= o.Start && ip <= o.End);;
if (range == null)
return"--";
else
return range.CnCode;
        }
    }
}

為了驗證結果,從 https://www.randomlists.com/ip-addresses取得 1024 筆隨機網址,以 http://www.ip2c.org/168.95.1.1方式查出國別,做好 1024 筆測試資料。以單元測試執行批次查詢與 ip2c.org 查詢結果進行比對,驗證結果是否一致。

測試前先觀察 ip2c.org API 執行速度方便比較,經實測一次耗時約 950 – 975ms。

執行單元測試,隨機 1024 筆資料查詢結果與 ip2c.org 查詢結果一致(綠燈),總查詢時間約 4.5 秒,換算每次查詢約 4.5ms,比呼叫 API 快 200 倍。

不過,LINQ 查詢固然直覺方便,若你以為它會像 SQL WHERE 查詢一樣有效率就錯了,恭喜跌入效能陷阱。如果講求效能,得換一顆更專業的查詢引擎。從已排序陣列找出指定數字落點,二分搜尋法是我心中的首選,原以為得捲袖子自已寫,卻發現 Array.BinarySearch在 .NET 已內建 ,哈里路亞!

配合二分搜尋,匯入資料結構也要調整,我的做法是將開始位址及結束位址轉成數字陣列,使用 Dictionary 對應國別碼。有個問題是範圍與範圍間可能存在未定義的空隙,發現範圍不連續時要補上一段開始、結束範圍指向未定義國別(國別碼填入"—"),才能精準回報未定義。另外,資料中有五個區段被重複定義指向兩個不同國家(實務可能發生,參見 FAQ),處理時也需排除。

查詢核心以 Array.BinarySearch 找出 IP 位址在已排序陣列的相對位置,若在陣列裡找不到該數字,BinarySearch 會傳回最接近位置的補數,轉換後可找到所屬範圍的位址。BinarySearch 版本範例如下,加上簡單的防錯,100 行搞定:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
 
namespace IP2C.Net
{
publicclass IPCountryFinder
    {
        Dictionary<string, string> CountryNames = new Dictionary<string, string>();
        Dictionary<uint, string> IP2CN = new Dictionary<uint, string>();
uint[] IPRanges;
publicstring DupDataInfo = string.Empty;
 
public IPCountryFinder(string path)
        {
if (!File.Exists(path)) thrownew ArgumentException($"{path} not found!");
string dupInfo = null;
            StringBuilder dupData = new StringBuilder();
uint lastRangeEnd = 0;
string unknownCode = "--";
            CountryNames.Add(unknownCode, "Unknown");
int count = 0;
try
            {
foreach (var line in File.ReadAllLines(path))
                {
if (line.StartsWith("#")) continue;
try
                    {
//"87597056","87599103","ripencc","1338422400","ES","ESP","Spain"
                        var p = line.Split(',').Select(o => o.Trim('"')).ToArray();
                        var st = uint.Parse(p[0]);
                        var ed = uint.Parse(p[1]);
                        var cn = p[4];
 
//range gap found
if (lastRangeEnd > 0 && st > lastRangeEnd)
                        {
//padding unknown range
                            IP2CN.Add(lastRangeEnd, unknownCode);
                            IP2CN.Add(st - 1, unknownCode);
                            count += 2;
                        }
 
                        dupInfo = $"{st}-{ed}-{cn}";
                        IP2CN.Add(st, cn);
                        IP2CN.Add(ed, cn);
                        lastRangeEnd = ed + 1;
if (!CountryNames.ContainsKey(cn))
                            CountryNames.Add(cn, p[6]);
                    }
catch (ArgumentException aex)
                    {
                        dupData.AppendLine($"Duplicated {dupInfo}: {aex.Message}");
                    }
                }
                IPRanges = IP2CN.Select(o => o.Key).OrderBy(o => o).ToArray();
            }
catch (Exception ex)
            {
thrownew ApplicationException($"CSV parsing error: {ex.Message}");
            }
            DupDataInfo = dupData.ToString();
        }
publicuint GetIPAddrDec(string ipAddr)
        {
byte[] b = IPAddress.Parse(ipAddr).GetAddressBytes();
            Array.Reverse(b);
return BitConverter.ToUInt32(b, 0);
        }
 
publicstring GetCountryCode(string ipAddr)
        {
uint ip = GetIPAddrDec(ipAddr);
int idx = Array.BinarySearch(IPRanges, ip);
if (idx < 0)
            {
int idxNearest = ~idx;
if (idxNearest > 0) idxNearest--;
                idx = idxNearest;
            }
return IP2CN[IPRanges[idx]];
        }
 
publicstring ConvertCountryCodeToName(string cnCode)
        {
if (CountryNames.ContainsKey(cnCode))
return CountryNames[cnCode];
return cnCode;
        }
 
publicstring GetCountryName(string ipAddr)
        {
return ConvertCountryCodeToName(GetCountryCode(ipAddr));
        }
    }
}

以相同資料重新測試 BinarySearch 引擎版本。

薑!薑!薑!薑~ 1024 筆只花了 9ms!!平均每一筆耗時 0.01 ms,比 LINQ 查詢版快了450 倍,比呼叫 API 快了 9 萬倍!速度快到嚇我一大跳,雖然大部分是 Array.BinarySearch 的功勞,但我很滿意。

完整程式及單元測試已放上 Github,有興趣玩玩的同學請自取。

呼口號時間:

C# 真棒,.NET 好威呀!

【茶包射手筆記】在 View 使用 SELECT * 的風險

$
0
0

分享同事踩到的 SELECT * 地雷一枚。

大家應該在程式設計準則都看過這條-「避免使用 SELECT * FROM Table,應以 SELECT Col1, Col2… 明確列舉欄位…」。

如此建議必有其考量:第一個理由顯而易見,正向表列必要欄位,可避免在網路傳送用不到的資料浪費頻寬,並能減少客戶端、伺服器端處理多餘資料的資源損耗。再者,查詢欄位多寡也可能影響效能,SELECT * 時為牽就非必要欄位,資料庫可能改用較無效率的索引,不利效能最佳化。還有一種極端情境,若所需欄位都存在於 Non-Clustered Index,即便使用的索引相同,SELECT * 迫使資料庫逐筆再 Key Lookup 取回完整資料列,拖累執行計劃並衍生非必要 IO。

另外,未正向列舉欄位在 Schema 變動時容易造成問題。例如:INSERT INTO TableA SELECT * FROM TableB,一旦 TableA 或 TableB 欄位增減或調換順序就會出錯。應寫成 INSERT INTO TableA (C1,C2,C3) SELECT C1,C2,C3 FROM TableB 才嚴謹。

以上均為我已知效能問題或嚴謹性疏失,但這次的案例讓我有些意外,是一個 View 使用 SELECT * 導致 Schema 一變更就爆炸的個案!

使用以下案例重現問題:

有一個具備 ID、NAME 兩個欄位的資料表 TBL9527,另外有 View VW9527,內容為 SELECT * FROM TBL9527。塞入一筆資料並查詢 VW9527,結果符合預期:

修改 TBL9527,在 ID 與 NAME 欄位間插入一個 TEAM 欄位。

猜猜怎麼了?SELECT * FROM TBL9527 結果正常,但 VW9527 查詢結果的 NAME 對應成 TEAM,内容為 NULL!

要修正問題,需執行 EXEC sp_RefreshView'VW9527' 更新 View 的 Metadata。

咦~ 在 View 使用 SELECT * 的朋友愛自意哦!

【參考資料】

Viewing all 428 articles
Browse latest View live