Quantcast
Channel: 黑暗執行緒
Viewing all 2311 articles
Browse latest View live

小技巧 - 運用 Form target 模擬 AJAX 表單傳送效果

$
0
0

先定義我所謂的「AJAX 表單傳送」:意指撰寫 JavaScript 蒐集 HTML 表單欄位,再藉由 XHR 傳送到伺服器端,取代傳統的 Postback。其優點包含送單時畫面不閃動、沒有重新載入網頁的延遲以及傳輸運算成本、表單處理失敗使用者輸入狀態不變方便修改重送... 等等,近年來 AJAX 表單已成網頁操作設計主流。相較之下,傳統 Postback 方式(指用<form action="…">配合<input type="submit">或<button>按鈕送單的做法)雖然使用者體驗略遜一籌,但寫法簡單,在一些要求不高,殺雞不必用牛刀的場合,仍是省時省力的好選擇。

另一種我會繼續使用 Postback 的情境發生於修現有網站的 Bug,總不能看不習慣就怒把 Postback 表單都翻寫成 AJAX 版,叫你割痔瘡卻搞成切盲腸,母湯喔母湯~

依據實務經驗,Postback 表單有個棘手問題是送單出錯時得恢復欄位原本輸入狀態以便使用者修正重試,使用 ASP.NET WebForm 的 ViewState 可以解決部分問題,但遇到有 JavaScript 參與互動時,要完整還原的難度便提高許多。我有時會使出 Postback 時傳回 <script>alert(msg);history.go(-1);</script> 退回前一頁的技巧,大部部情況下瀏覽器會神奇地保留原本的輸入內容,但遇到由 JavaScript 控制的部分還是很容易破功。舉個例了:

假設網頁有兩個<intput type="text">,一個手動輸入,另一個由 JavaScript 填入:

<html><body><form action="post1.aspx" method="post"><input type="text" name="userId" value="" /><input type="text" name="seed" value="" /><button>Submit Form</button></form><script>
document.querySelector("input[name=seed]").value = Math.random();</script></body></html>

伺服器模擬送單出錯,傳回一段 JavaScript,alert 錯誤訊息後用 history.go(-1) 退回上一頁:

<%@Page Language="C#"%><script runat="server">
void Page_Load(object sender, EventArgs e)
{
	Response.Write("<script>alert('Something wrong!');history.go(-1);</" + "script>");
	Response.End();
}</script>

如以下展示,退回上一頁時,手動輸入的內容還在,但亂數欄位已重新產生。

如果不想改寫 $.post() 抓欄位呼叫 post1.aspx,其實還有一招取巧做法 - 利用 <form> 的 target 將傳回結果嵌入 <iframe>,傳回結果以 JavaScript 呼叫 parent 的函式或直接修改 parent 的 DOM。如此,輸入頁面的所有欄位狀態不因送出表單而變動,能簡單實現類似 AJAX 送單的效果。

做法是在頁面放置一個 <iframe>,取名 name="result",並透過 style="display:none" 隱形,而 <form> 則加上 target="result" 指定將 Postback 結果顯示於 <iframe>:

<html><body><iframe name="result" style="display:none"></iframe><form action="post2.aspx" target="result" method="post"><input type="text" name="userId" value="" /><input type="text" name="seed" value="" /><button>Submit Form</button><div id="msg" style="color:red"></div></form><script>
document.querySelector("input[name=seed").value = Math.random();
function showMessage(msg) {
	document.querySelector("#msg").innerHTML = msg;
}</script></body></html>

Post.aspx 小做修改,傳回結果改傳一小段 JavaScript 程式,將呼叫 parent 預先寫好的函式顯示執行結果:

<%@Page Language="C#"%><script runat="server">
void Page_Load(object sender, EventArgs e)
{
	Response.Write("<script>parent.showMessage('Something wrong! - " + Guid.NewGuid() + "');</" 
		+ "script>");
	Response.End();
}</script>

如下所示,不必大費周章就能輕鬆用 Postback 實現類似 AJAX 送單的效果囉!


程式範例:byte[] 不落地壓縮 ZIP 檔

$
0
0

.NET 4.5 起加入 ZipArchive、ZipFile 等列類別,自此不用額外安裝第三方程式庫就能製作 ZIP 檔。微軟官方文件則有篇範例文章,操作說明:壓縮與解壓縮檔案 - Microsoft Docs,介紹如何使用 System.IO.Compression 的一系列類別壓縮及解壓縮檔案。

我遇到一個需求,要將使用者在網站查詢的結果,以一筆資料一個檔案形式匯出,再集結壓縮成單一 ZIP 檔方便使用者下載。爬文找到的範例多以檔案形式處理為主,而我想省掉將資料寫成檔案再壓縮的步驟,但直接將記憶體 byte[] 壓成 ZIP(也是 byte[])的完整範例不多,索性將摸索成果整理成筆記:如何將記憶體中的 byte[] 直接壓成 ZIP 保存於記憶體?如此,直接 Response.BinaryWrite() 即可下載,全程資料不落地(不寫暫存檔),有利減少IO、提升效能。(註:前題是處理資料量不構成伺服器記憶體的壓力)

完整程式範例如下:

    class Program
    {
        static void Main(string[] args)
        {
            var src = new Dictionary<string, byte[]>()
            {
                ["name.txt"] = Encoding.UTF8.GetBytes("Jeffrey"),
                ["score.txt"] = Encoding.UTF8.GetBytes("32767")
            };
            var zip = ZipHelper.ZipData(src);
            System.IO.File.WriteAllBytes("test.zip", zip);
            var res = ZipHelper.UnzipData(zip);
            foreach (var fileName in res.Keys)
            {
                Console.WriteLine($"FileName={fileName}");
                Console.WriteLine($"Content={Encoding.UTF8.GetString(res[fileName])}");
            }
            Console.Read();
        }
    }

    public class ZipHelper
    {
        public static byte[] ZipData(Dictionary<string, byte[]> data)
        {
            using (var zipStream = new MemoryStream())
            {
                using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Update))
                {
                    foreach (var fileName in data.Keys)
                    {
                        var entry = zipArchive.CreateEntry(fileName);
                        using (var entryStream = entry.Open())
                        {
                            var buff = data[fileName];
                            entryStream.Write(buff, 0, buff.Length);
                        }
                    }
                }
                return zipStream.ToArray();
            }
        }

        public static Dictionary<string, byte[]> UnzipData(byte[] zip)
        {
            var dict = new Dictionary<string, byte[]>();
            using (var msZip = new MemoryStream(zip))
            {
                using (var archive = new ZipArchive(msZip, ZipArchiveMode.Read))
                {
                    archive.Entries.ToList().ForEach(entry =>
                    {
                        //e.FullName可取得完整路徑
                        if (string.IsNullOrEmpty(entry.Name)) return;
                        using (var entryStream = entry.Open())
                        {
                            using (var msEntry = new MemoryStream())
                            {
                                entryStream.CopyTo(msEntry);
                                dict.Add(entry.Name, msEntry.ToArray());
                            }
                        }
                    });
                }
            }
            return dict;
        }
    }

實測將 Dictionary<string, byte[]> 壓成 ZIP,解壓後內容還原無誤:

將壓縮結果另存 ZIP 檔,使用 7-Zip 可正常檢視解壓,測試成功!

前端小筆記-Progressive Web App (PWA)

$
0
0

抓了開源專案 MiniBlog.Core 回來玩,想在其中套用 Form target 模擬 AJAX 表單傳送技巧時踢到鐵板,開啟 F12 偵錯工具後驚呼:天吶,這世界又變了!

新時代的 Postback 不再是單純送出一個 Request 拿回 HTTP 200,而是像這樣子:

如上圖,表單送出行為被拆成三個動作,並出現關鍵字 ServiceWorker。

另外,我發現 js、css 也變成由 ServiceWorker 載入。

爬文得知原來是專案引用了 WebEssentials.AspNetCore.PWA套件(參考:Introduction to PWA in ASP.NET Core Application – Beginner's Guide to Mobile Web Development ),MiniBlog.Core 恰巧就是 WebEssentials 作者的大作,吃自己的狗食是一定要滴。

事到如今,只好花點時間了解什麼是 PWA - Progress Web Application。

以下是我參考的資料:

歸納重點如下:

  1. PWA 是 Google 2015 提出的概念,希望讓 Web Application 可以在各種網路狀況、不同手機 OS 下均能順利運作,並可被安裝到桌面、離線使用、支援推播... 等。
  2. 設計要點:
    * Progress - 漸進式,運行環境支援度愈高提供的服務愈多,在簡陋環境亦可優雅降級提供基本功能。
    * Responsivve - RWD,自動適應各種螢幕尺寸
    * App-like - 模仿 Native App 風格及資料更新方式(Service Worker、快取)
    * Fresh – 使用 Service Worker API 自動更新(不依賴App Store/Google Play)
    * Safe – 全面 HTTPS
    * Discoverable - 透過 manifest 進行 SEO
    * Re-engageable - 透過推播與使用者互動
    * Installable – 可 Add To Home 將 Web App 裝到手機桌面,並可列於應用程式清單,不需(也不能)經由 App 商店下載。
    * Linkable - 可透過 URL 分享
  3. Service Worker 是 PWA 可以離線使用的關鍵,Service Worker 在瀏覽器背後運行,有自己的生命週期,與網頁獨立。
  4. SPA 存在首次載入過慢、JS 過大及不利 SEO 優化等問題,PWA 則可克服這些問題。
  5. PWA 將不常變動內容(App Shell)與動態內容(Content)分開,AppShell 下載後將 Content 透過 Service Worker 儲存在本地資料庫作為 Cache,即使網路中斷仍可繼續使用。而使用者進入網頁後馬上看到完整 Shell,之後再填入內容,相較於畫面空白一陣子再一次顯示,即使等待時間相近,也會有效降低使用者的煩躁感。
  6. PWA 講的是整個網站的設計哲學,網站當然不可能加一個 NuGet 套件就變 PWA。WebEssentials.AspNetCore.PWA 套件主要提供:強制轉 HTTPS、Web App Manifest(讓網站可被加成手機桌面 App)、Service Worker JS 等三項功能。
  7. Service Worker 在 localhost 以外的網站限定 HTTPS 才能啟用,啟用後會接管原本的 JS/CSS/Form 等 HTTP Request 傳送。 目前除了 IE 全系列、iOS 11.2-(含)、Opera Mini 外,主流瀏覽器都已支援(參考:Can I use Service Worker... Support tables for HTML5, CSS3, etc)。
  8. 使用 Chrome F12 可以查看 Serivce Worker 啟用狀態:


    實測 Unregister Service Worker 後重整網頁,JS/CSS 等即恢後使用傳統方式接收。(網頁載入後會再次 Register)

  9. 一般要使用 Service Worker,需攔截 install, activate, fetch 等事件加入處理邏輯,WebEssentials.AspNetCore.PWA 透過 ServiceWorkerTagHelperComponent在 <body> 尾端注入 <script>'serviceWorker'in navigator&&navigator.serviceWorker.register('/serviceworker')</script> 加載 /serviceworker,其中已實作好 Service Worker 相關事件:

又見識了新東西,呼~ (到底還有多少新東西要學呢?)

ASP.NET Core 值得學嗎?

$
0
0

連發了幾篇 ASP.NET Core 文章,果不其然接到各方詢問:

喵的媽呀,微軟又推新東西了?」
「WebForm 玩完了嗎?」
「我 ASP.NET MVC 還沒開始玩耶,是不是不用學了?」

先簡單答覆以上疑問:

是的,ASP.NET Core是下一代的 ASP.NET,能跨平台執行,預期是未來的主流。它是兩年前推出的新東西沒錯,但做資訊這行一天到晚學新東西剛好而已好嗎?你要是體驗過前端框架「放煙火式的生命週期」,這根本不算什麼。

至於 WebForm,再戰十年或二十年應該不是問題。大型企業或組織求穩重於求新,系統愈大愈複雜,革新速度愈慢,但可預期也不會再挹注資源擴大發展,相關工作機會註定愈來愈少,由於不再有新鮮肝投入這塊領域,將演變成留守老鳥們靠寫很快或領很少或娶了老闆女兒角逐稀有維護職缺的場面。(補充參考:丞相,起風了!從ASP.NET 5的變革談起)

如果你正要或正在學 ASP.NET MVC 5,請繼續學好學滿,相關知識技巧在 ASP.NET Core 絕大部分都能沿用。企業若無強烈的跨平台需求,ASP.NET MVC 5 的 Windows 及第三方程式庫支援較完整成熟,穩定性及技術資源勝過仍在起步的 ASP.NET Core,仍是現階段開發網站的好選擇(依據官方文件,ASP.NET 與 ASP.NET Core 為可替換選項,開發人員可視自身需求擇一使用),請安服用。

回到正題,如果 ASP.NET MVC 5 仍是現役主力,那 ASP.NET Core 值學習嗎? 看我最近寫了不少 ASP.NET Core 筆記,想當然爾是投贊成票的,個人觀點如下:

跨平台優勢

二十幾年的 Coding 人生,C# 是我用過最成熟最順手的程式語言,搭配地表最強的 Visual Studio IDE,簡直削鐵如泥。可惜早年它被封印在 Windows 裡,錯失與 Java 競爭主流開發語言霸主的先機,直到 .NET Core 終於正式跨平台,雖然晚了十幾年,但總算讓我等到了。
跨平台有什麼好處? 有選擇就是爽!


依據 Netcraft 的統計,2018 七月全球前 100 萬網站使用的網站伺服器 Apache 佔 35.2%,nginx 佔 24.9%(市佔持續擴大中),Microsoft 佔 9.4 %。各家作業系統、網站伺服器的成本、效能、穩定性、管理方便性各有優劣,各有愛好者。身為網站開發人員,ASP.NET Core 跟 Apache、Nginx、IIS 都能搭,甚至丟到 NAS Docker 跑也成,不必為了網站主機限制跟客戶戰作業系統戰伺服器,光想到嘴角就上揚。 (謎:是以前有多常被打搶?) 
想像一下,跟錙銖必較的老闆報告網站用 Linux 主機、 VPS 或 Cloud 就能跑,租金省一半,老闆開心你加薪。

效能優勢

ASP.NET Core 採用輕巧的 Kestrel Web Server 處理核心 HTTP 通訊(甚至可抽換成更效能取向的伺服器以適應極端情境),一般會配合 Nginx、Apache、IIS 等反向代理伺服器(Reverse Proxy Server)補足安全、負載平衡、靜態內容快取、壓縮、HTTP 認證等需求。ASP.NET 受限於 IIS,功能豐富但較笨重,在一些評測(12)中 ASP.NET Core 的效能數字(RPS,Request Per Second)至少嬴過 ASP.NET on IIS 3-4 倍。
當然純用 Kestrel 對比 IIS,多少帶有「徒手跑步 vs 武裝跑步」相比的差偏,實際情境 Kestrel 搭配反向代理伺服器後差距應會縮小一些,但不可否認,當你不計代價極想擠出效能時,ASP.NET Core 更能超越巔峰。

趨勢 趨勢 趨勢

ASP.NET MVC 5 仍是檯面上的主流選項,但若無意外未來 ASP.NET Core 將是王道。舉兩條線索:


ASP.NET 5 is dead - Introducing ASP.NET Core 1.0 and .NET Core 1.0 - Scott Hanselman
ASP.NET MVC 5 的下一代不是 ASP.NET MVC 6,而是 ASP.NET Core 1.0,非常令人困惑的命名,但 Scott 他們盡力了。ASP.NET MVC NuGet Package目前最新版本為 5.2.6;而 ASP.NET Core 這兩年從 1.0 躍升到 2.1,3.0 預計在今年下半年釋出預覽並於 2019 推出正式版,處於急速抽高的青春期。

比較 EF Core 與 EF6 - Microsoft Docs
官方文件提到 EF6 仍是受支援的產品,未來仍會看到 Bug 修正及小幅改善。EF Core 的 API 與 EF6 相近,但核心已重寫故未繼承 EF6 所有功能,成熟度也不及 EF6,但未來將會加入一些 EF6 沒有的新功能(替代鍵、批次更新、LINQ 查詢混用用戶端及資料庫端運算) 。

由此推論,微軟仍會繼續支援 ASP.NET / EF6,但新功能將會在 ASP.NET Core 跟 EF Core 出現。

Open Source 萬歲

.NET Core / ASP.NET Core 完全開源,開發社群的每一份子都可以回報問題、提供建議、協助修 Bug、新增功能,讓平台更貼近自己的需求。即便意見最終未被接受,還有一招大絕,那裡用不爽就改到爽,你功力的極限的就是系統功能與效能的極限 :P (呃,這樣以後不能跟老闆說「這是平台限制沒辦法了」... Orz)
 

結論

小結我的看法:如果你未來五到十年還打算靠 ASP.NET 吃飯,ASP.NET MVC 一定要學,寫 WebForm 工作機會將變得很少,具備 MVC 技能才有本錢跟年輕小夥子們搶飯碗,很高比例的 MVC 知識搬到 ASP.NET Core 仍受用,毫不猶豫投資下去就對了。至於 ASP.NET Core,我個人認為雖然已經 3.0 在即,但其穩定性及成熟度尚待更多實戰驗證,還有第三方元件支援度尚未完全跟上來的問題,是進行大規模商轉前要考量的風險,但時間會消除這些疑慮。我建議現在就可提早接觸,試著在小型新專案上練習,應是不錯的入水角度。現在累積實力,待未來市場接受度變高,對於提供競爭力大有助益,不妨提早投資。

程式範例 - 使用 C# 寄送圖文並茂郵件

$
0
0

在 Outlook 寫信時,直接在文字穿插圖片是再自然也不過的事(如下圖),但是用 C# 程式走 SMTP 寄信,夾帶附檔的經驗很多,直接在內文內嵌圖檔倒是沒試過。

很快在 Stackoverflow 查到範例,照方煎藥,就寄出像上面圖文並茂的信件了:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mail;
using System.Net.Mime;
using System.Text;
using System.Threading.Tasks;

namespace EmbImgMail
{
    class Program
    {
        static void Main(string[] args)
        {
            var mail = new MailMessage();
            mail.IsBodyHtml = true;

            //建立連結資源
            var res = new LinkedResource("netcore3.png");
            res.ContentId = Guid.NewGuid().ToString();
            //使用<img src="cid:..."方式引用內嵌圖片
            var htmlBody = $@"<div>.NET Core 3 架構圖如下:</div><div><img src='cid:{res.ContentId}'/></div>";
            //建立AlternativeView
            var altView = AlternateView.CreateAlternateViewFromString(
                htmlBody, null, MediaTypeNames.Text.Html);
            //將圖檔資源加入AlternativeView
            altView.LinkedResources.Add(res);
            //將AlternativeView加入MailMessage
            mail.AlternateViews.Add(altView);
            //設定寄件人收件人主旨
            mail.To.Add("jeffrey@mail.com");
            mail.From = new MailAddress("jeffrey@mail.com");
            mail.Subject = "內嵌圖檔測試";
            //送出郵件
            SmtpClient smtp = new SmtpClient("relayServerIp");
            smtp.Send(mail);
        }
    }
}

補充,AlternativeView 源自 RFC2046規範,理論上主流郵件軟體及網路信箱應該都支援。

突破 32 位元 .NET 程式 2GB 記憶體上限

$
0
0

同事分享了一記讓 32 位元 .NET 程式突破 2GB 記憶體上限的密技,讓我不禁獻上了膝蓋,當然要轉分享一下。

.NET 編譯成 32 位元與 64 位元最大的差異在於可用記憶體上限,32 位元的記憶體定址上限為 4GB,其中 2GB 配置給作業系統核心模式,應用程式為使用者模式只有 2GB 可用,實際執行需再扣除 Runtime 本身耗用的記憶體,依經驗只能用到 1.6GB 左右。所以若無特殊限制,程式最好編譯成 AnyCPU 或 x64 以充分享用記憶體。但實務上 .NET 程式一旦引用了 32 位元 Unmanaged 元件,就毫無選擇只能以 32 位元執行。

Windows 有個 /3GB 啟動參數,可調整只配置 1GB 給核心模式,留下 3GB 給應用程式使用,但 /3GB 的設定步驟繁瑣,要部署大量客戶端很有麻煩。Visual C++ 有個 EDITBIN 命令列工具,可修改 OBJ/DLL/EXE 檔案旗標,其中有個 /LARGEADDRESSAWARE 參數可針對特定 EXE 開放 3GB 模式,突破 1.6GB 上限。

以下是個簡單的測試程式,透過不斷產生 1M 長度字串消耗記憶體直接 OutOfMemoryException,並用以前介紹過的記憶體用量觀察函式測量佔用的 Managed Heap 記憶體:

using System;
using System.Collections.Generic;

namespace _32BitAppTest
{
    public class Program
    {
        static void Main(string[] args)
        {
            try
            {
                CreateBigData();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.ToString()); 
            }
            Console.WriteLine("Press any key for exit... ");
            Console.ReadKey();
        }

        static void DumpMemSize(int count)
        {
            //強制回收記憶體清出空間,以充分利用所有記憶體 
            var memSz = GC.GetTotalMemory(true) / 1024 / 1024;
            Console.WriteLine(
                $"Managed Heap={memSz}MB, Count={count}");
        }

        static void CreateBigData()
        {
            var dic = new Dictionary<long, string>();
            var src = new string(' ', 1024 * 1024 - 1);
            for (int i = 0; i < 4096; i++)
            {
                dic.Add(i, src + (i % 10));
                if (i % 32 == 0)
                     DumpMemSize(i);                
            }
        }
    }
}

實測結果,大約用到 1.5GB 左右出現 OutOfMemoryException,跟一般認知的 2GB 上限相近。

用 EDITBIN /LARGEADDRESSAWARE 32BitAppTest.exe 開光後,同一支 32 位元 .NET程式便能吃到 3GB 記憶體,神奇吧!

如果要每次編譯後自動修改,可加在專案 Post-Build Event,以下是適用 VS2015/VS2017 的寫法:

使用以下腳本則可適用多個 VS 版本:參考

IF  EXIST  "%VS140COMNTOOLS%"  CALL  "%VS140COMNTOOLS%vsvars32.bat"
IF  EXIST  "%VS120COMNTOOLS%"  CALL  "%VS120COMNTOOLS%vsvars32.bat"
IF  EXIST  "%VS110COMNTOOLS%"  CALL  "%VS110COMNTOOLS%vsvars32.bat"
IF  EXIST  "%VS100COMNTOOLS%"  CALL  "%VS100COMNTOOLS%vsvars32.bat"
editbin.exe /LARGEADDRESSAWARE $(TargetPath)

補充,EDITBIN 為 Visual C++ 附屬工具,Visual Studio 記得要安裝 Visual C++ 才有的用。

【同場加映】

C/C++ Build Tools 有另一件工具 - DUMPBIN,可檢查 EXE 是否已設定 LARGEADDRESSAWARE 旗標,如下圖:

在 ASP.NET MVC Response.End() 不會中斷執行

$
0
0

同事回報了一起奇怪狀況,追查之後又學到新東西。在我的觀念裡,Response.End() 時會立即中斷執行,有時還會觸發討厭的 ThreadAbortException。但在以下的 ASP.NET MVC 範例中,CheckAuth() 在查不到 Cookie 時會導向 /Login 並呼叫 Response.End() 結束執行,結果沒有,程式繼續往下跑,在試圖修改 Response.ContentType 時觸發 HTTP Header 送出後無法修改 ContentType 的錯誤:(修改 ContentType 需求來自 JsonNetResult )

Response.End() 不會中斷執行?這大大違背我的認知,莫非 WebForm 與 ASP.NET MVC 的行為不同?真相在原始碼裡,Use the source, Luke!

追進 HttpResponse 原始碼,很快有了答案:

        /// <devdoc>
        ///    <para>Sends all currently buffered output to the client then closes the
        ///       socket connection.</para>
        /// </devdoc>
        public void End() {
            if (_context.IsInCancellablePeriod) {
                AbortCurrentThread();
            }
            else {
                // when cannot abort execution, flush and supress further output
                _endRequiresObservation = true;

                if (!_flushing) { // ignore Reponse.End while flushing (in OnPreSendHeaders)
                    Flush();
                    _ended = true;

                    if (_context.ApplicationInstance != null) {
                        _context.ApplicationInstance.CompleteRequest();
                    }
                }
            }
        }

原來 Reponse.End() 時會依據 IsCancellablePeriod 屬性決定是否中斷執行緒。由 IsCancellablePeriod 關鍵字追到 Response.End()在Webform和ASP.NET MVC下的表现差异 - 空葫芦 - 博客园,證實了 Reponse.End() 在 WebForm 與 MVC 的行為不同。有趣的是,在該文發現保哥也追過這個問題,IsCancellablePeriod 取決於  _timeoutState 屬性值,在 WebForm 下其值為 1 (IsCancellablePeriod = true),在 ASP.NET MVC 下為 0 (IsCancellablePeriod = false),故 Response.End() 在 WebForm 下會執行 AbortCurrentThread() 在 MVC 則是 Flush() 並執行 ApplicationInstance.CompeteRequest()。

如此即可解釋 Response.End() 後會繼續執行且無法修改 ContentType。(因為在 End() 中已 CompleteRequest() )

既知原因,來看如何解決。最粗暴但有效的解法是自己模擬 AbortCurrentThead() 中止執行,HttpResponse.AbortCurrentThread() 原始碼是呼叫 Thread.CurrentThread.Abort(new HttpAppication.CancelModuleException(false));,所以我們將程式碼修改如下即可搞定。
(此舉如同 Reponse.End() 會有觸發 ThreadAbortException 的副作用,參考:ThreadAbortException When Response.End() - 黑暗執行緒)

        void CheckAuth()
        {
            //模擬Cookie檢查
            if (Request.Cookies["AuthCookie"]?.Value != "X")
            {
                Response.Redirect("/Login");
                Response.End();
                Thread.CurrentThread.Abort();
            }
        }

另一個思考方向是 CheckAuth() 改傳回 bool,呼叫時改寫成 if (CheckAuth()) { …認證成功作業... } else { return Content(null); },但如此一來,所有用到 CheckAuth() 的 Action 都要多一層 if,噁心又麻煩,不優。

而依此案例的認證需求,倒是可以回歸 ASP.NET 內建的表單驗證。CodeProject 有篇文章可以參考:A Beginner's Tutorial on Custom Forms Authentication in ASP.NET MVC Application - CodeProject,先設定 web.config

<authentication mode="Forms"><forms loginUrl="~/Login" timeout="2880" /></authentication>

/Login 認證身分成功後呼叫 FormsAuthentication.SetAuthCookie(username, false); 連自訂認證 Cookie 的功夫都免了。

如果認證邏輯再複雜,則可考量實作 IAuthenticationFilter 實現自訂認證。例如:[ASP.NET MVC]使用IAuthenticationFilter,IAuthorizationFilter實作Form表單登入認證&授權 - 分享是一種學習 - 點部落

追進原始碼,學到新東西,敲開勳~

【茶包射手日記】SQLite 資料庫出錯消失

$
0
0

最近啟動了部落格轉移計劃,打算把我的部落格從 ASP.NET + SQL 移到 ASP.NET Core + SQLite。前陣子試出 Ubuntu + SQLite + Dapper令我信心大增,後續進展也挺順利,寫了匯入程式,從 SQL 匯出物件轉 JSON,開始將 JSON 資料轉換成新平台 Model,再用 Entity Framework 寫入 SQLite 資料庫。

不料,批次匯入時發現每次一轉到某篇文章時固定出錯,最可怕的是,出錯後整個資料庫檔案會消失無蹤。

頓如五雷轟頂!喵的,這還得了,一出錯整個 SQLite 資料庫檔案消失,網站全部內容化為烏有,如果這是 SQLite .NET Core 的 Bug,誰敢用?原本寄以厚望的架構這下要作廢了,心中猶如萬頭羚羊狂奔…

冷靜了一下,想想不對。SQLite 也算當今迷你資料庫主流,應用廣泛,不應存在如此嚴重 Bug,即使有也很快會被抓到並修復。

於是再反覆測試,發現一絲曙光。

SQLite 出錯的當下,桌面右下角剛好彈出 Windows Defender 警示:

啊!是防毒軟體搞鬼,SQLite 資料庫被誤判含有病毒,檔案消失原因是被防毒軟體隔離!由 Windows Defender 掃瞄記錄證實這點,blog.db 被判定包含 JS/ShellCode.gen 病毒遭到移除。

案情急轉之下,SQLite 無罪,仍是值得信任的好夥伴,我大大鬆了一口氣,接下來便是找出到底是哪篇文章造成誤判,避開即可。

有趣的是,批次匯入作業出錯都是在試圖寫入 Distributed Transaction With MS OLEDB Provider For Oracle 時,所以是這篇文章觸發防毒警報導致資料庫被刪,但這篇跟病毒有個毛關係?不得已,我使用愚公移山法反覆測試不同組合,最後找出問題跟另一篇包含木馬程式的 有趣的木馬解剖文章有關。資料庫檔同時存在這兩篇文章會觸發 Windows Defender 誤判,單獨寫入其中任何一篇沒問題,只要別同時存在就沒事。

有趣的木馬解剖由於內含解說用的木馬程式範例,過去已有多次被防毒軟體誤抓坐黑牢的記錄(木馬解剖一點都不有趣呀),為此也改過 CLSID,調過程式碼樣本,但跟另一篇文章組合而被誤判倒是頭一遭。

一怒之下,將程式碼部分都換成圖檔,徹底解決誤判風險,事件落幕。


程式範例 - 正式台測試台 JSON + Dapper 資料搬移術

$
0
0

野人現曝,分享最近在寫的正式、測試台間的小規模資料搬移法。

情境是正式台跟測試台各有自己的資料庫,想將正式台某幾筆資料匯出,備份保存或是匯入測試台資料庫模擬測試;或是反過來,資料先在測試台輸入驗證無誤後要上線,希望將將輸入好的資料直接匯入正式台,省去在正式台重新登打的工夫。

這類情境用 Entity Framework 不難實現的,這篇介紹則介紹不用 EF 的做法。匯出匯入的前題是要有強型別的 Entity 型別,你可以手工宣告,也可借用 Visual Studio 強大的「貼上 JSON 做為類別」、「貼上 XML 做為類別」功能快速產生(參考:Visual Studio 的選擇性貼上,貼上Json作為類別 - 50懶 - 點部落)。

匯出時先用 .Query<EntityType>("SELECT * FROM Table") 產生 EntityType[],JSON 序列化後供使用者下載保存或匯入到另一套系統。實務上可透過壓縮提高傳輸與儲存效率(參考:程式範例:byte[] 不落地壓縮 ZIP 檔 - 黑暗執行緒)。

在匯入端則是先將 JSON 反序列化為 EntityType[],再來我選擇用 Reflection 自動產生 INSERT INTO 指令,再用 Dapper .Execute(InsertScript, EntityType[]) 一次塞入多筆資料,一行搞定。

程式範例如下:

       public class Player
        {
            public int SeqNo { get; set; }
            public string Id { get; set; }
            public string Name { get; set; }
            public DateTime RegDate { get; set; }
            public int Score { get; set; }
        }


        static void Main(string[] args)
        {
            string json = null;
            //模擬由伺服器A匯出Player[],再轉成JSON
            using (var cn = new SqlConnection(cs))
            {
                var data = cn.Query<Player>(
                    @"SELECT Id,Name,RegDate,Score FROM Players").ToArray();
                json = JsonConvert.SerializeObject(data);
            }
            //補充:實務應用時可使用ZIP壓縮技術
            //http://blog.darkthread.net/post-2018-08-14-zip-byte-array.aspx


            //模擬上傳JSON到伺服器B,還原後INSERT進資料庫
            var restored = JsonConvert.DeserializeObject<Player[]>(json);
            var insertScript = GenerateInserScript(restored[0], "Players", "SeqNo".Split(','));
            Console.WriteLine(insertScript);
            using (var cn = new SqlConnection(cs))
            {
                //傳入Insert Script跟物件陣列,完成資料匯入
                cn.Execute(insertScript, restored);
            }
        }


        /// <summary>
        /// 使用Reflection產生SQL INSERT腳本
        /// </summary>
        /// <param name="sample">樣本型別</param>
        /// <param name="tableName">資料表名稱</param>
        /// <param name="ignoredColNames">忽略欄位名稱</param>
        /// <returns></returns>
        static string GenerateInserScript(object sample, string tableName, 
            string[] ignoredColNames = null)
        {
            var sb = new StringBuilder();
            //此處假設tableName由開發人員決定,不開放使用者輸入,否則要防範SQL Injection
            sb.AppendLine("INSERT INTO " + tableName);

            string[] props = sample.GetType()
                .GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .Select(o => o.Name).ToArray();
            props = props.Where(o =>
                !o.StartsWith("_") && //排除_開頭及ignoredColNames列舉欄位名
                (ignoredColNames == null || !ignoredColNames.Contains(o)))
                .ToArray();
            sb.AppendLine($"({string.Join(", ", props)})");
            sb.AppendLine("VALUES");
            sb.AppendLine($"({string.Join(", ", props.Select(o => "@" + o).ToArray())});");
            return sb.ToString();
        }

透過 Reflection 產生的 INSERT 語法如下,欄位名稱前面加上@就變成變數,剛好讓 Dapper 會欄位名稱自動對應,比想像中簡單很多吧! 這裡只設計可排除特定欄位(例如:自動跳號欄位)的設計,實務若有需要,可加入客製化邏輯。

以上是小量資料匯出匯入功能的簡單示範,我們下次見。

【茶包射手日記】web.config 設定鬼故事

$
0
0

同事報案,某網站委託 OP 上線出現異常,連至本機查看詳細錯誤訊息,ASP.NET 回報看不懂 </location> 元素。

上圖的 <location path="Area51">...</location> 是本次新增設定,是應用先前介紹過的 Windows 驗證網站設定部分匿名存取技巧,用 <location> 針對部分路徑做不同設定的做法過去用得很多,不該有錯,更何況同一 web.config 的前一段就活生生是另一個 <location> 相安無事,惟獨新加的 <location> 有問題。

用記事本反覆檢查 web.config,兩個人四隻眼睛看到出血,逐字母檢查一遍又一遍,就是看不出哪裡有問題:

做了幾個測試:

  1. 將 <location path="Area51"> 改成 <location path="Shit" >,錯誤仍指向同段 <location>
  2. 將 <location path="Area51"> 該段刪除,網站正常
  3. 將 <location path="Area51"> 與前段 <location> 交換位置,錯誤仍指向 Area51 這則
  4. 將 <location path="Area51"> 該段刪除,另外重新輸入完全相同內容,網站正常

同事高呼「見鬼了」,老射手則嘟嚷:「不對,有妖氣!」。

請同事將 web.config 傳回本機用 Notepad++ 開啟,薑! 薑! 薑! 薑~ 答案揭曉,<location path="Area51"> 這段前方的縮排空白,其實是特殊字元:

這就能解釋為什麼只有這段 <location path="Area51"> 會出錯,刪掉正常,手工重打一次也正常。至於這些看似空白的字元從何而來,問了同事。原來是這段設定是透過 HipChat 交談追加的(如下圖示意),OP 直接複製對話內容貼上,未察覺縮排前方的空白其實是特殊字元。

而這些奇怪字元是什麼呢? 它們是 EN SPACE (&ensp;),UTF8 字碼為 0xe2 0x80 0x82:

以前只學過 &nbsp;(ASC 160) 其實還有 &ensp;、&emsp;,都屬於不會被合併的空白,而寬度分別為一個標準空白鍵字元、半個中文字寬及一個中文字寬(參考:HTML字元符號 &Nbsp; &Ensp; &Emsp; 的差異 - ShunNien's Blog)。

進一步爬文,在 Unicode 字元還有很多看起來像空白但不是空白的特殊字元。(參考:HTML Unicode UTF-8)

【心得】

  1. 下回遇到看不出任何異樣卻回報有錯的 config、XML、程式碼,不要只用眼睛檢查,應先以工具排除假空白及隱形字元陷阱。
  2. 想讓討厭的程式設計師同事發瘋,不用紮稻草人下降頭,偷偷在他的程式碼裡加點料就可以了。

2018 苗栗山城星光馬

$
0
0

六七八月休了三個月避暑,2018 下半年第一場由苗栗山城星光馬掀開序幕。

會場設在苗栗縣立體育場,下午四點半起跑,氣溫是嚇人的 35 度。

主持人報告本屆全馬大約有八百多人(這種天氣下場,堪稱八百壯士,對於跑步肯定是真愛),比賽也算小而美。

起跑時,起跑拱門正前方有一大片烏雲,可惜老沒飄到賽道這一帶,透過薄雲的陽光仍帶有殺氣,熱呀~

大會封了整條單向車道,寬敞好跑又不必與車爭道,缺點是風景單調了點,全馬同樣路線得跑兩趟,枯燥度加倍。水站補給中規中矩,水、運動飲料、汽水、小糕點、檸檬片、西瓜、香蕉... 應有盡有,供應也很充足,服務同學很熱情,活力十足,再加分。

雖然封了單向車道但沒全面交管,因此起跑沒多久便偶爾要等紅燈過馬路。我志不在成績,對於此種安排倒也不為意,反倒覺得能減少其他用路人的困擾,減少抱怨,是好事一椿。

穿阿媽裝的大哥,背上貼了【加油!! 阿媽跑得比你快】從我旁邊呼嘯而過... 嗚~

這兩年少跑中台灣場次,碰到久違的滾鐵圈大哥,依照往例,當然又被無情碾壓了~ 話說當年連在葡萄馬山路都被他海放,這回在平地相逄就更不用說了 Orz

跑著跑著,夕陽西下,才開始進入所謂的星光馬。

新港大橋有一段無路燈,大會在馬路兩側灑上小螢光棒,黑夜裡跑在其間很有 fu,我幻想著自己正在夜訪電影阿凡達裡的潘朵拉星球。:P

第一次造訪苗栗高鐵站。(忽然一驚! 我居然到現在還沒坐過高鐵)

第一次造訪客家圓樓,又一個因跑馬才有緣一睹的地標。

來張夜景,應該是新東大橋吧。

32 公里,真正的馬拉松考驗從這裡才開始,但拎杯已經累惹...

最後打起精神小拼一下保住 530,回終點已是晚上十點,會場溫度計顯示還有 31 度,我的完賽時間是 5:27:05,總排還在前 50%,可見戰況之慘烈。

賽後有客家發粿、長壽麵當伴手禮,簡章提到完賽有冰品可吃,滿心期待會有挫冰吃到飽,沒想到只有一支紅豆粿冰棒,還限一人一枝,有點小失望。但整體而言,仍是場令人滿意的好賽事。

久未跑外地馬,順道安排全家到苗栗民宿小住一晚。上山的路超窄,僅容一車通行兩側還是水溝,但環境清幽景觀也美,值得。

隔日返家前順路去了明德水庫的日新島晃晃,一家子都很宅的好處是哪都沒去過,去哪都新鮮,哈!

最後補上奬牌照,為久違的外地馬畫下完美的句點。

 

UrlEncode() 與空白變加號問題

$
0
0

在 ASP.NET Core 遇到轉換網址中文及特殊字元的需求,由於 .NET Core 不適合再用 System.Web.HttpUtility,爬文查到有個替代品 - System.Net.WebUtility.UrlEncode,開開心心上路卻踢到鐵板。

問題出在 System.Net.WebUtility.UrlEncode() 會將空白字元轉成加號,而 IIS7+ 預設禁止在網址使用加號,否則必須修改 allowDoubleEscaping 設定,但如此將增加風險。(延伸參考:IIS 7+禁止URL路徑使用加號代表空白 - 黑暗執行緒)

既然遇到,順手蒐集整理資料,看看 .NET 裡 UrlEncode 該怎麼寫才好。

  1. 如果是寫 .NET Framework,最普遍的做法是用 System.Web.HttpUtility.UrlEncode(),而官方文件提到 UrlEncode() 會將空白換成+,用於傳送表單時的 application/x-www-form-urlencoded 編碼 OK,但當成 URL 網址就有問題,文件建議改用 UrlPathEncode(),但 UrlPathEncode() 方法又說「這方法過時了別用,請改用 UrlEncode()!」(Do not use; intended only for browser compatibility. Use UrlEncode(String).) 這...  (第一次看到兩個 API 互踢皮球,很妙)
  2. 在 ASP.NET Core 不建議再用 System.Web,System.Net.WebUtility.UrlEncode()可為替代,但它跟 HttpUtility.UrlEncode() 一樣,空白會被轉成 +,而且沒有 UrlPathEncode() 這種版本。
  3. .NET Core 還有個套件 System.Text.Encodings.Web,提供 HtmlEncode、UrlEncode、JavaScriptEncoder 等編碼功能,可透過 System.Text.Encodings.Web.UrlEncoder.Default.Encode("...") 轉換,空白會轉成 %20,可用。但缺點是它限定 .NET Core 平台,且只為了一個函式要加裝套件似乎有點搞剛。
  4. 最後,我想起 System.Uri 有個 EscapeDataString()也可以用。System.Uri 本身是處理 URL 的專家,轉換結果用在 URL 肯定沒問題。 加上 System.Uri 為 .NET 核心型別,不管 .NET Framework 或 .NET Core 均可直接引用,評估之後是最方便的解法。
    註:Uri.EscapeDataString() 之外還有個 Uri.EscapeUriString(),二者差不多就是 JavaScript encodeURIComponent 與 encodeURI 的區別。(EscapeDataString = encodeURIComponent, EscapeUriString,詳情可參考這篇:JavaScript 網址編碼函式 escape encodeURI encodeURIComponent 的不同 @ Vexed's Blog )

【結論】

未來要將中文跟特殊字元做 UrlEncode 編碼串進 URL,JavaScript 端用 encodeURICompoent,.NET 端則一律改用 Uri.EscapeDataiString() 就對了。

ASP.NET Core View 中文變 & # x4E2D; & # x6587;

$
0
0

發現 ASP.NET Core 有個特性造成困擾。

我們都知道在 cshtml 以 @textFromServerSide 嵌入字串時,預設會被 HtmlEncode 以防止 Cross-Site Scripting 攻擊,如要將字串視為 HTML 標籤處理需額外呼叫 Html.Raw()。但在 ASP.NET Core 裡,結果跟我原本想像不同,例如以下 cshtml,表面上看來正常,中文正常顯示:

@{
    Layout = null;
}<html><body><div>
        Chinese Text = @("<b>中文測試</b>")</div></body></html>

檢視網頁原始碼卻讓我大吃一驚,中文字元都被轉成 &#xhhhh; 形式! 若以 UTF-8 編碼計算,每個中文字元由 3 Bytes 變成 8 Bytes 增肥近三倍,而更令人困擾的一點,原始碼裡的中文字消失讓網頁偵錯難度驟升好幾個等級。

爬文找到解法,Prevent Cross-Site Scripting (XSS) in ASP.NET Core - Microsoft Docs文件提到 ASP.NET Core 的 TagHelper 及 HtmlHelper 預設會將所有非拉丁字元都當成特殊符號進行編碼,但此一設計對中文、西里爾文(斯拉夫語族)網頁開發者很不友善。所幸這個行為可透過設定調整,前幾天談 UrlEncode時提到 .NET Core 新推出的 System.Text.Encodings.Web.HthmlEncoder 就用在這裡。而修改方法為在 Startup.cs ConfigureServices() 加入以下程式片段,將 ASP.NET Core cshtml 透過 DI 取得的 HtmlEcoder 換成我們的自訂版本:

services.AddSingleton<HtmlEncoder>(HtmlEncoder.Create(allowedRanges: new[] { UnicodeRanges.BasicLatin,     UnicodeRanges.CjkUnifiedIdeographs }));

自訂版 HtmlEcoder 將基本拉丁字元與中日韓字元納入允許範圍不做轉碼。

經過這番手腳,網頁原始碼總算清爽多了,阿彌陀佛~

CODE - WebClient 下載檔案自動取得檔名

$
0
0

透過 WebClient.DownloadFile() 或 DownloadData() 對 .NET 老鳥而言是雕蟲小技(參考:CODE-使用C#程式從網站下載檔案 ),但此種寫法檔名需自行指定。若下載對象非靜態檔案,伺服器端程式會透過 Content-Disposition Response Header 傳回檔名供客戶端參考,WebClient 是否能由 Response Header 自動取得檔名呢?

答案是可以! 程式範例如下:

static string DownloadFile(string url, string saveFolder)
{
    using (var wc = new WebClient())
    {
        using (var stream = wc.OpenRead(url))
        {
            //若伺服器未提供檔名,預設以下載時間產生檔名
            var fn = DateTime.Now.ToString("yyyyMMddHHmmss") + ".data";
            var cd = wc.ResponseHeaders["content-disposition"];
            if (!string.IsNullOrEmpty(cd)) {
                var p = Regex.Split(cd, "filename=");
                if (p.Length == 2) fn = p.Last();
            }
            using (var file = File.Create(Path.Combine(saveFolder, fn)))
            {
                stream.CopyTo(file);
            }
            return fn;
        }
    }
}

突破 32 位元 .NET 程式 2GB 記憶體上限

$
0
0

同事分享了一記讓 32 位元 .NET 程式突破 2GB 記憶體上限的密技,讓我不禁獻上了膝蓋,當然要轉分享一下。

.NET 編譯成 32 位元與 64 位元最大的差異在於可用記憶體上限,32 位元的記憶體定址上限為 4GB,其中 2GB 配置給作業系統核心模式,應用程式為使用者模式只有 2GB 可用,實際執行需再扣除 Runtime 本身耗用的記憶體,依經驗只能用到 1.6GB 左右。所以若無特殊限制,程式最好編譯成 AnyCPU 或 x64 以充分享用記憶體。但實務上 .NET 程式一旦引用了 32 位元 Unmanaged 元件,就毫無選擇只能以 32 位元執行。

Windows 有個 /3GB 啟動參數,可調整只配置 1GB 給核心模式,留下 3GB 給應用程式使用,但 /3GB 的設定步驟繁瑣,要部署大量客戶端很有麻煩。Visual C++ 有個 EDITBIN 命令列工具,可修改 OBJ/DLL/EXE 檔案旗標,其中有個 /LARGEADDRESSAWARE 參數可針對特定 EXE 開放 3GB 模式,突破 1.6GB 上限。

以下是個簡單的測試程式,透過不斷產生 1M 長度字串消耗記憶體直接 OutOfMemoryException,並用以前介紹過的記憶體用量觀察函式測量佔用的 Managed Heap 記憶體:

using System;
using System.Collections.Generic;

namespace _32BitAppTest
{
    public class Program
    {
        static void Main(string[] args)
        {
            try
            {
                CreateBigData();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.ToString()); 
            }
            Console.WriteLine("Press any key for exit... ");
            Console.ReadKey();
        }

        static void DumpMemSize(int count)
        {
            //強制回收記憶體清出空間,以充分利用所有記憶體 
            var memSz = GC.GetTotalMemory(true) / 1024 / 1024;
            Console.WriteLine(
                $"Managed Heap={memSz}MB, Count={count}");
        }

        static void CreateBigData()
        {
            var dic = new Dictionary<long, string>();
            var src = new string(' ', 1024 * 1024 - 1);
            for (int i = 0; i < 4096; i++)
            {
                dic.Add(i, src + (i % 10));
                if (i % 32 == 0)
                     DumpMemSize(i);                
            }
        }
    }
}

實測結果,大約用到 1.5GB 左右出現 OutOfMemoryException,跟一般認知的 2GB 上限相近。

用 EDITBIN /LARGEADDRESSAWARE 32BitAppTest.exe 開光後,同一支 32 位元 .NET程式便能吃到 3GB 記憶體,神奇吧!

如果要每次編譯後自動修改,可加在專案 Post-Build Event,以下是適用 VS2015/VS2017 的寫法:

使用以下腳本則可適用多個 VS 版本:參考

IF  EXIST  "%VS140COMNTOOLS%"  CALL  "%VS140COMNTOOLS%vsvars32.bat"
IF  EXIST  "%VS120COMNTOOLS%"  CALL  "%VS120COMNTOOLS%vsvars32.bat"
IF  EXIST  "%VS110COMNTOOLS%"  CALL  "%VS110COMNTOOLS%vsvars32.bat"
IF  EXIST  "%VS100COMNTOOLS%"  CALL  "%VS100COMNTOOLS%vsvars32.bat"
editbin.exe /LARGEADDRESSAWARE $(TargetPath)

補充,EDITBIN 為 Visual C++ 附屬工具,Visual Studio 記得要安裝 Visual C++ 才有的用。

【同場加映】

C/C++ Build Tools 有另一件工具 - DUMPBIN,可檢查 EXE 是否已設定 LARGEADDRESSAWARE 旗標,如下圖:


在 ASP.NET MVC Response.End() 不會中斷執行

$
0
0

同事回報了一起奇怪狀況,追查之後又學到新東西。在我的觀念裡,Response.End() 時會立即中斷執行,有時還會觸發討厭的 ThreadAbortException。但在以下的 ASP.NET MVC 範例中,CheckAuth() 在查不到 Cookie 時會導向 /Login 並呼叫 Response.End() 結束執行,結果沒有,程式繼續往下跑,在試圖修改 Response.ContentType 時觸發 HTTP Header 送出後無法修改 ContentType 的錯誤:(修改 ContentType 需求來自 JsonNetResult )

Response.End() 不會中斷執行?這大大違背我的認知,莫非 WebForm 與 ASP.NET MVC 的行為不同?真相在原始碼裡,Use the source, Luke!

追進 HttpResponse 原始碼,很快有了答案:

        /// <devdoc>
        ///    <para>Sends all currently buffered output to the client then closes the
        ///       socket connection.</para>
        /// </devdoc>
        public void End() {
            if (_context.IsInCancellablePeriod) {
                AbortCurrentThread();
            }
            else {
                // when cannot abort execution, flush and supress further output
                _endRequiresObservation = true;

                if (!_flushing) { // ignore Reponse.End while flushing (in OnPreSendHeaders)
                    Flush();
                    _ended = true;

                    if (_context.ApplicationInstance != null) {
                        _context.ApplicationInstance.CompleteRequest();
                    }
                }
            }
        }

原來 Reponse.End() 時會依據 IsCancellablePeriod 屬性決定是否中斷執行緒。由 IsCancellablePeriod 關鍵字追到 Response.End()在Webform和ASP.NET MVC下的表现差异 - 空葫芦 - 博客园,證實了 Reponse.End() 在 WebForm 與 MVC 的行為不同。有趣的是,在該文發現保哥也追過這個問題,IsCancellablePeriod 取決於  _timeoutState 屬性值,在 WebForm 下其值為 1 (IsCancellablePeriod = true),在 ASP.NET MVC 下為 0 (IsCancellablePeriod = false),故 Response.End() 在 WebForm 下會執行 AbortCurrentThread() 在 MVC 則是 Flush() 並執行 ApplicationInstance.CompeteRequest()。

如此即可解釋 Response.End() 後會繼續執行且無法修改 ContentType。(因為在 End() 中已 CompleteRequest() )

既知原因,來看如何解決。最粗暴但有效的解法是自己模擬 AbortCurrentThead() 中止執行,HttpResponse.AbortCurrentThread() 原始碼是呼叫 Thread.CurrentThread.Abort(new HttpAppication.CancelModuleException(false));,所以我們將程式碼修改如下即可搞定。
(此舉如同 Reponse.End() 會有觸發 ThreadAbortException 的副作用,參考:ThreadAbortException When Response.End() - 黑暗執行緒)

        void CheckAuth()
        {
            //模擬Cookie檢查
            if (Request.Cookies["AuthCookie"]?.Value != "X")
            {
                Response.Redirect("/Login");
                Response.End();
                Thread.CurrentThread.Abort();
            }
        }

另一個思考方向是 CheckAuth() 改傳回 bool,呼叫時改寫成 if (CheckAuth()) { …認證成功作業... } else { return Content(null); },但如此一來,所有用到 CheckAuth() 的 Action 都要多一層 if,噁心又麻煩,不優。

而依此案例的認證需求,倒是可以回歸 ASP.NET 內建的表單驗證。CodeProject 有篇文章可以參考:A Beginner's Tutorial on Custom Forms Authentication in ASP.NET MVC Application - CodeProject,先設定 web.config

<authentication mode="Forms"><forms loginUrl="~/Login" timeout="2880" /></authentication>

/Login 認證身分成功後呼叫 FormsAuthentication.SetAuthCookie(username, false); 連自訂認證 Cookie 的功夫都免了。

如果認證邏輯再複雜,則可考量實作 IAuthenticationFilter 實現自訂認證。例如:[ASP.NET MVC]使用IAuthenticationFilter,IAuthorizationFilter實作Form表單登入認證&授權 - 分享是一種學習 - 點部落

追進原始碼,學到新東西,敲開勳~

【茶包射手日記】SQLite 資料庫出錯消失

$
0
0

最近啟動了部落格轉移計劃,打算把我的部落格從 ASP.NET + SQL 移到 ASP.NET Core + SQLite。前陣子試出 Ubuntu + SQLite + Dapper令我信心大增,後續進展也挺順利,寫了匯入程式,從 SQL 匯出物件轉 JSON,開始將 JSON 資料轉換成新平台 Model,再用 Entity Framework 寫入 SQLite 資料庫。

不料,批次匯入時發現每次一轉到某篇文章時固定出錯,最可怕的是,出錯後整個資料庫檔案會消失無蹤。

頓如五雷轟頂!喵的,這還得了,一出錯整個 SQLite 資料庫檔案消失,網站全部內容化為烏有,如果這是 SQLite .NET Core 的 Bug,誰敢用?原本寄以厚望的架構這下要作廢了,心中猶如萬頭羚羊狂奔…

冷靜了一下,想想不對。SQLite 也算當今迷你資料庫主流,應用廣泛,不應存在如此嚴重 Bug,即使有也很快會被抓到並修復。

於是再反覆測試,發現一絲曙光。

SQLite 出錯的當下,桌面右下角剛好彈出 Windows Defender 警示:

啊!是防毒軟體搞鬼,SQLite 資料庫被誤判含有病毒,檔案消失原因是被防毒軟體隔離!由 Windows Defender 掃瞄記錄證實這點,blog.db 被判定包含 JS/ShellCode.gen 病毒遭到移除。

案情急轉之下,SQLite 無罪,仍是值得信任的好夥伴,我大大鬆了一口氣,接下來便是找出到底是哪篇文章造成誤判,避開即可。

有趣的是,批次匯入作業出錯都是在試圖寫入 Distributed Transaction With MS OLEDB Provider For Oracle 時,所以是這篇文章觸發防毒警報導致資料庫被刪,但這篇跟病毒有個毛關係?不得已,我使用愚公移山法反覆測試不同組合,最後找出問題跟另一篇包含木馬程式的 有趣的木馬解剖文章有關。資料庫檔同時存在這兩篇文章會觸發 Windows Defender 誤判,單獨寫入其中任何一篇沒問題,只要別同時存在就沒事。

有趣的木馬解剖由於內含解說用的木馬程式範例,過去已有多次被防毒軟體誤抓坐黑牢的記錄(木馬解剖一點都不有趣呀),為此也改過 CLSID,調過程式碼樣本,但跟另一篇文章組合而被誤判倒是頭一遭。

一怒之下,將程式碼部分都換成圖檔,徹底解決誤判風險,事件落幕。

程式範例 - 正式台測試台 JSON + Dapper 資料搬移術

$
0
0

野人獻曝,分享最近在寫的正式、測試台間的小規模資料搬移法。

情境是正式台跟測試台各有自己的資料庫,想將正式台某幾筆資料匯出,備份保存或是匯入測試台資料庫模擬測試;或是反過來,資料先在測試台輸入驗證無誤後要上線,希望將將輸入好的資料直接匯入正式台,省去在正式台重新登打的工夫。

這類情境用 Entity Framework 不難實現的,這篇介紹則介紹不用 EF 的做法。匯出匯入的前題是要有強型別的 Entity 型別,你可以手工宣告,也可借用 Visual Studio 強大的「貼上 JSON 做為類別」、「貼上 XML 做為類別」功能快速產生(參考:Visual Studio 的選擇性貼上,貼上Json作為類別 - 50懶 - 點部落)。

匯出時先用 .Query<EntityType>("SELECT * FROM Table") 產生 EntityType[],JSON 序列化後供使用者下載保存或匯入到另一套系統。實務上可透過壓縮提高傳輸與儲存效率(參考:程式範例:byte[] 不落地壓縮 ZIP 檔 - 黑暗執行緒)。

在匯入端則是先將 JSON 反序列化為 EntityType[],再來我選擇用 Reflection 自動產生 INSERT INTO 指令,再用 Dapper .Execute(InsertScript, EntityType[]) 一次塞入多筆資料,一行搞定。

程式範例如下:

       public class Player
        {
            public int SeqNo { get; set; }
            public string Id { get; set; }
            public string Name { get; set; }
            public DateTime RegDate { get; set; }
            public int Score { get; set; }
        }


        static void Main(string[] args)
        {
            string json = null;
            //模擬由伺服器A匯出Player[],再轉成JSON
            using (var cn = new SqlConnection(cs))
            {
                var data = cn.Query<Player>(
                    @"SELECT Id,Name,RegDate,Score FROM Players").ToArray();
                json = JsonConvert.SerializeObject(data);
            }
            //補充:實務應用時可使用ZIP壓縮技術
            //http://blog.darkthread.net/post-2018-08-14-zip-byte-array.aspx


            //模擬上傳JSON到伺服器B,還原後INSERT進資料庫
            var restored = JsonConvert.DeserializeObject<Player[]>(json);
            var insertScript = GenerateInserScript(restored[0], "Players", "SeqNo".Split(','));
            Console.WriteLine(insertScript);
            using (var cn = new SqlConnection(cs))
            {
                //傳入Insert Script跟物件陣列,完成資料匯入
                cn.Execute(insertScript, restored);
            }
        }


        /// <summary>
        /// 使用Reflection產生SQL INSERT腳本
        /// </summary>
        /// <param name="sample">樣本型別</param>
        /// <param name="tableName">資料表名稱</param>
        /// <param name="ignoredColNames">忽略欄位名稱</param>
        /// <returns></returns>
        static string GenerateInserScript(object sample, string tableName, 
            string[] ignoredColNames = null)
        {
            var sb = new StringBuilder();
            //此處假設tableName由開發人員決定,不開放使用者輸入,否則要防範SQL Injection
            sb.AppendLine("INSERT INTO " + tableName);

            string[] props = sample.GetType()
                .GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .Select(o => o.Name).ToArray();
            props = props.Where(o =>
                !o.StartsWith("_") && //排除_開頭及ignoredColNames列舉欄位名
                (ignoredColNames == null || !ignoredColNames.Contains(o)))
                .ToArray();
            sb.AppendLine($"({string.Join(", ", props)})");
            sb.AppendLine("VALUES");
            sb.AppendLine($"({string.Join(", ", props.Select(o => "@" + o).ToArray())});");
            return sb.ToString();
        }

透過 Reflection 產生的 INSERT 語法如下,欄位名稱前面加上@就變成變數,剛好讓 Dapper 依欄位名稱自動對映,比想像中簡單很多吧! 這裡只設計可排除特定欄位(例如:自動跳號欄位)的設計,實務若有需要,可加入客製化邏輯。

以上是小量資料匯出匯入功能的簡單示範,我們下次見。

【茶包射手日記】web.config 設定鬼故事

$
0
0

同事報案,某網站委託 OP 上線出現異常,連至本機查看詳細錯誤訊息,ASP.NET 回報看不懂 </location> 元素。

上圖的 <location path="Area51">...</location> 是本次新增設定,是應用先前介紹過的 Windows 驗證網站設定部分匿名存取技巧,用 <location> 針對部分路徑做不同設定的做法過去用得很多,不該有錯,更何況同一 web.config 的前一段就活生生是另一個 <location> 相安無事,惟獨新加的 <location> 有問題。

用記事本反覆檢查 web.config,兩個人四隻眼睛看到出血,逐字母檢查一遍又一遍,就是看不出哪裡有問題:

做了幾個測試:

  1. 將 <location path="Area51"> 改成 <location path="Shit" >,錯誤仍指向同段 <location>
  2. 將 <location path="Area51"> 該段刪除,網站正常
  3. 將 <location path="Area51"> 與前段 <location> 交換位置,錯誤仍指向 Area51 這則
  4. 將 <location path="Area51"> 該段刪除,另外重新輸入完全相同內容,網站正常

同事高呼「見鬼了」,老射手則嘟嚷:「不對,有妖氣!」。

請同事將 web.config 傳回本機用 Notepad++ 開啟,薑! 薑! 薑! 薑~ 答案揭曉,<location path="Area51"> 這段前方的縮排空白,其實是特殊字元:

這就能解釋為什麼只有這段 <location path="Area51"> 會出錯,刪掉正常,手工重打一次也正常。至於這些看似空白的字元從何而來,問了同事。原來是這段設定是透過 HipChat 交談追加的(如下圖示意),OP 直接複製對話內容貼上,未察覺縮排前方的空白其實是特殊字元。

而這些奇怪字元是什麼呢? 它們是 EN SPACE (&ensp;),UTF8 字碼為 0xe2 0x80 0x82:

以前只學過 &nbsp;(ASC 160) 其實還有 &ensp;、&emsp;,都屬於不會被合併的空白,而寬度分別為一個標準空白鍵字元、半個中文字寬及一個中文字寬(參考:HTML字元符號 &Nbsp; &Ensp; &Emsp; 的差異 - ShunNien's Blog)。

進一步爬文,在 Unicode 字元還有很多看起來像空白但不是空白的特殊字元。(參考:HTML Unicode UTF-8)

【心得】

  1. 下回遇到看不出任何異樣卻回報有錯的 config、XML、程式碼,不要只用眼睛檢查,應先以工具排除假空白及隱形字元陷阱。
  2. 想讓討厭的程式設計師同事發瘋,不用紮稻草人下降頭,偷偷在他的程式碼裡加點料就可以了。

2018 苗栗山城星光馬

$
0
0

六七八月休了三個月避暑,2018 下半年第一場由苗栗山城星光馬掀開序幕。

會場設在苗栗縣立體育場,下午四點半起跑,氣溫是嚇人的 35 度。

主持人報告本屆全馬大約有八百多人(這種天氣下場,堪稱八百壯士,對於跑步肯定是真愛),比賽也算小而美。

起跑時,起跑拱門正前方有一大片烏雲,可惜老沒飄到賽道這一帶,透過薄雲的陽光仍帶有殺氣,熱呀~

大會封了整條單向車道,寬敞好跑又不必與車爭道,缺點是風景單調了點,全馬同樣路線得跑兩趟,枯燥度加倍。水站補給中規中矩,水、運動飲料、汽水、小糕點、檸檬片、西瓜、香蕉... 應有盡有,供應也很充足,服務同學很熱情,活力十足,再加分。

雖然封了單向車道但沒全面交管,因此起跑沒多久便偶爾要等紅燈過馬路。我志不在成績,對於此種安排倒也不為意,反倒覺得能減少其他用路人的困擾,減少抱怨,是好事一椿。

穿阿媽裝的大哥,背上貼了【加油!! 阿媽跑得比你快】從我旁邊呼嘯而過... 嗚~

這兩年少跑中台灣場次,碰到久違的滾鐵圈大哥,依照往例,當然又被無情碾壓了~ 話說當年連在葡萄馬山路都被他海放,這回在平地相逄就更不用說了 Orz

跑著跑著,夕陽西下,才開始進入所謂的星光馬。

新港大橋有一段無路燈,大會在馬路兩側灑上小螢光棒,黑夜裡跑在其間很有 fu,我幻想著自己正在夜訪電影阿凡達裡的潘朵拉星球。:P

第一次造訪苗栗高鐵站。(忽然一驚! 我居然到現在還沒坐過高鐵)

第一次造訪客家圓樓,又一個因跑馬才有緣一睹的地標。

來張夜景,應該是新東大橋吧。

32 公里,真正的馬拉松考驗從這裡才開始,但拎杯已經累惹...

最後打起精神小拼一下保住 530,回終點已是晚上十點,會場溫度計顯示還有 31 度,我的完賽時間是 5:27:05,總排還在前 50%,可見戰況之慘烈。

賽後有客家發粿、長壽麵當伴手禮,簡章提到完賽有冰品可吃,滿心期待會有挫冰吃到飽,沒想到只有一支紅豆粿冰棒,還限一人一枝,有點小失望。但整體而言,仍是場令人滿意的好賽事。

久未跑外地馬,順道安排全家到苗栗民宿小住一晚。上山的路超窄,僅容一車通行兩側還是水溝,但環境清幽景觀也美,值得。

隔日返家前順路去了明德水庫的日新島晃晃,一家子都很宅的好處是哪都沒去過,去哪都新鮮,哈!

最後補上奬牌照,為久違的外地馬畫下完美的句點。

 

Viewing all 2311 articles
Browse latest View live