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

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,提供 HtmlEncoder、UrlEncoder、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.EscapeDataString() 就對了。


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))
            {
                Match m = Regex.Match(cd, "filename[*]=(?<es>[^;]+)");
                if (m.Success)
                {
                    fn = DecodeRF5987(m.Groups["es"].Value);
                }
                else
                {
                    m = Regex.Match(cd, "filename=[\"]*(?<f>[^\";]+)[\"]*");
                    if (m.Success)
                    {
                        fn = m.Groups["f"].Value;
                        //如伺服器會傳回UrlEncode()格式檔名,視需要加入
                        if (fn.Contains("%")) fn = Uri.UnescapeDataString(fn);
                    }
                }
            }
            using (var file = File.Create(Path.Combine(saveFolder, fn)))
            {
                stream.CopyTo(file);
            }
            return fn;
        }
    }
}
//REF: https://github.com/grumpydev/RFC5987-Decoder/blob/master/RFC5987/RFC5987.cs
private static IEnumerable<byte> GetDecodedBytes(string encData)
{
    var encChars = encData.ToCharArray();
    for (int i = 0; i < encChars.Length; i++)
    {
        if (encChars[i] == '%')
        {
            var hexString = new string(encChars, i + 1, 2);

            i += 2;

            int characterValue;
            if (int.TryParse(hexString, NumberStyles.HexNumber,
                CultureInfo.InvariantCulture, out characterValue))
            {
                yield return (byte)characterValue;
            }
        }
        else
        {
            yield return (byte)encChars[i];
        }
    }
}
static string DecodeRF5987(string encStr)
{
    Match m = Regex.Match(encStr, "^(?<e>.+)'(?<l>.*)'(?<d>[^;]+)$");
    if (m.Success)
    {
        //TODO: 此處未包含伺服器傳回資料有誤之容錯處理
        var enc = Encoding.GetEncoding(m.Groups["e"].Value);
        return enc.GetString(GetDecodedBytes(m.Groups["d"].Value).ToArray());
    }
    return encStr;
}

2018-09-17 更新:Content-Disposition Header 可能包含多值(且會帶有雙引號),原本 Regex.Split() 寫法錯抓機率甚高,例如:Content-Disposition: attachement; filename="…"; name="fieldName"; ,更進一步,伺服器端還可能依據 RFC5987,以 filename*=UTF-8''%c2%a3%20and%20rates.pdf 編碼規則提供 Unicode 檔名,故調整以 Regex.Match 取 filename* 或 filename,並加入 RFC5987 解碼。(註: 程式碼未經廣泛驗證,大家如發現有誤請不吝指正)
感謝網友 Slash 提醒。

黑暗執行緒又搬家囉~

$
0
0

部落格又搬家囉,來不及參與本站過去的新同學應該不知"又"搬家的又字而來,先來段白頭宮女話當年好了...

我從 2004 年 1 月開始寫部落格,如今已堂堂邁入第 14 個年頭。想當年部落格興起,我也趕流行在 PCHome 個人新聞台開了個小站,寫了幾篇廢文瑣事雜文,但跟大部分的人一樣,興頭一過便放著長草。一年後家裡再添一口,再興起繼續發廢文寫點東西的念頭,無奈那陣子個人新聞台系統極度不穩,被內容消失與資料庫錯誤搞到怒火攻心,心一橫便決定搬到有富爸爸 Google 撐腰的 Blogger,這是第一次搬家。

在 Blogger 寫了兩年,初衷只是延伸公司處理問題寫 KB 備忘兼分享的習慣,改成直接把筆記寫在 Internet 上罷了,但不時有路過的高手前輩現身指點,有不少意外收獲,倒也讓我樂此不疲,漸漸寫成習慣。2007 僥倖當選微軟 MVP,深感自己肩負社會責任之重大,部落格應走向更專業化經營(謎之聲:先生,您哪位?),而 Blogger 功能擴充不易、備份與管理困難,自己平日就在寫網站時要風有風,說雨是雨,因系統限制坐困愁城很不是滋味。當時靠著 MVP 前輩 Rex Tang 指點,我也仿效去註冊一個 DNS 網域 (darkthrad.net) 租了網路空間,用 Community Server (ASP.NET WebForm + SQL) 架起部落格跟討論區平台。CS 本身功能十分強大完整且為開源專案,遇到不順眼的小地方可以動手改到開心,加上擁有自己的 Domain Name,跟先前寄宿公共平台爽度完全不同呀~ 第二次搬家的心路歷程當時也有寫筆記留念:漫漫搬家路---

Community Server 從 2007 使用至今超過十年,中間幾度翻修。討論區因使用率偏低而廣告留言嚴重收攤,僅保留部落格功能;初期使用的 Captcha 機制 (TrimothyHumphrey's CAPTCHA) 漸漸不敵垃圾留言,2011 年改裝 reCaptcha才好一些,但好景不常,2012 年 reCAPTCHA 威風不再,逼得我最後自己寫了陽春 Captcha,網路上現成的垃圾留言攻擊程式瞄準的都是大廠名牌 Captcha,土砲版從來不是鎖定目標,反而較少成為攻擊對象。(但偶爾還是會中)

後續較大的調整是因應 Google 對網站行動裝置相容性的要求,不支援行動瀏覽的網站搜尋排名會受到影響,為此我惡補 CSS,修改排版 HTML 及樣式,胡亂搞了套偽 RWD 才過關。

坦白說,Community Server 雖已有十年歷史,功能、穩定性都很好,架構概念也很專業,再戰十年應不是問題。但我還是決定更換平台展開第三次搬家,有幾個原因:

  1. 2018 年 7 月起,Chrome 開始將未使用 HTTPS 的網站明顯標示「不安全」,並將調降其在搜尋結果中的排名。我比別人卡認真,我比別人卡打拼,為什麼為什麼被說不安全排名比人爛?
    不幸地,我租用的共享式站台空間無法支援 SSL 憑證。

  2. CS 走的是 WebForm + SQL Server,架構考慮周詳且嚴謹,有層層介面隔離邏輯,網頁配置全面元件化,資料庫存取也幾乎都走 Stored Procedure。可想而知,代價是修改擴充手續繁瑣,加個功能得調兩個介面改三支程式。久久才有修改需求,每回都要先溫習一次架構才能動手。我是 KISS (Keep It Simple, Stupid) 法則的忠實信徒,華麗壯觀的架構向來不是我的菜,另一方面,CS 平台仍跑在 .NET 2.0,平日寫程式已用慣 .NET 4.5 / C# 6.0 的各式新函式與語法糖,繼續維護只准用 .NET 2.0/3.5 太痛苦,砍掉重練是眼前的較佳解。

  3. 共享式網站租用空間(同一個IIS上掛載多名房客的網站)對機器的控制度很有限,資料備份、Log 分析也諸多不便。Azure 虛擬機器可以登入遠端桌面,操作管理完全比照平日開發測試的實體機,親切方便度壓倒性勝出。能百分之百存取控制機器,安裝申請 SSL 憑證,安裝各家網站平台都不是問題,而我腦中有不少觀察網站行為或實驗的怪點子,都要靠獨立機器才能實現。

大方向已定 - 另建新的部落格網站,跑 Azure 虛擬機器上,然後把舊文章通通搬過去。

下一步是選擇新的部落格系統,沒什麼懸念,我很快就選定 Miniblog.Core,理由很簡單:

第一,它是當今檯面上僅有用 ASP.NET Core 寫的部落格平台,ASP.NET Core成為主流在即,藉此強迫自己學習上手,是絕佳的安排。

第二,Miniblog.Core 既稱 Mini,架構自然走單純簡潔風,沒有拆出一堆 Interface、Component、Provider 專案,是我鍾愛的 KISS 風格。而作者 Mads Kristensen是 Visual Studio 神級外掛 WebEssentials 的作者,有什麼比觀摩神人的作品更讓人快速成長呢?(事實也證明,在修改過程中我學到許多新穎觀念與前端技巧,受益良多。)

初步了解 Miniblog.Core,發現它用 XML 保存文章及留言,平時則將所有內容讀入記憶體進行查詢,以個人部落格文章資料量絕對可行。但對於無法用 SQL 工具統計及批次調整,總讓我有些不踏實感,找到在 ASP.NET Core 使用 SQLite + Dapper 的做法後,我決定一鼓作氣將儲存核心由 XML 換成 SQLite,另外陸續調整擴充功能以相容舊版,基本上新部落的雛型就完成了。

利用間暇時間邊做邊學,陸續進行近一個月,新站落成,於 9/21 正式啟用。以下是這次搬家歷程的技術筆記:

  1. Miniblog.Core 是 ASP.NET MVC,要頂替舊站的前題是能將包含 .aspx 的舊 URL 對應到新址,我是用 ASP.NET Core 的 UrlRewriting Middleware ,將有 .aspx 的 URL 統交給 UrlRewriteController,再查表對照轉址。

    var rewrite = new RewriteOptions()
        .AddRewrite("(.+)[.]aspx", "UrlRewrite/Index?path=$1", skipRemainingRules: true);
        app.UseRewriter(rewrite);
  2. 在舊站寫了一支程式,將舊文章依新版 Schema 欄位轉成 JSON,再進行必要的資料對應、轉換。新站匯入時直接將 JSON 反序列化回物件陣列,再用 Entity Framework 塞入資料庫,比預期簡單。時代進步,新工具威力愈來愈強大,做起來比以前輕鬆愉很多。

  3. Miniblog.Core 支援 Live Writer,只需提供網址、帳號、密碼就可以用 Windows Live Writer 或 Open Live Writer貼文,非常神奇。剖析背後靠的是已屬業界標準的 MetaWeblog 介面,Mininblog.Core 借用第三方套件,MetaWeblog,NuGet 即可安裝,實作 IMetaWeblogPorivder 再配合 app.UseMetaWeblog("/metaweblog"),網站就有了離線文章編輯功能。不過,我最終決定隨新站上線,也從此改用 Markdown 寫部落格,找到好用的 Markdown Monster (作者 Rick Strahl是微軟 MVP),能搭配 MetaWeblog 直接發佈,事實上這篇就是我第一篇用 Markdown 寫的部落格文章。關於改用 Markdown 的經驗,之後另外再發文分享。

  4. Miniblog.Core 專案引用了 WebEssentials.AspNetCore.ServiceWorker,網站具備 Progressive Web App功能,背後有 Service Worker 支援離線使用及優化效能,還能被釘選到手機或平台主畫面當成 App 使用,不過目前沒提供任何訂閱、通知等服務就是了。

  5. Miniblog.Core 用了不少先進的前端技巧,例如:<script> preload/async 載入、圖檔延遲載入(捲到可視範圍才真的下載圖檔)... 等等,我如劉姥姥進大觀園,邊改邊開眼界,看到目瞪口呆。另一方面,十年過去我的前端能力也小有長進,這次改版有留意避開影響效能的寫法。原則上,新版部落格的載入速度與流暢度應該有大幅改進。

  6. ASP.NET Core 不再提供內建 OutputCache 機制,WebEssentials.AspNetCore.OutputCaching也是 Mads Kristensen 的作品,Miniblog.Core 吃狗食(Dogfooding)也是很合情合理滴。原本苦惱文章更新時要如何強制清除 Cache,後來找到一個超簡單的做法 - 設定 FileDependencies,修改檔案內容,相依的快取都會被清除,下回就會讀到最新內容。

    services.AddOutputCaching(options =>
    {
        options.Profiles["default"] = new OutputCacheProfile
        {
            Duration = 3600,
            FileDependencies = new string[] { "filename.txt" }
        };
    });
  7. ASP.NET Core 有一套新的 appSettings.json 機制,可在 appSettings..json 加入特定執行環境(例如: 開發、測試、正式)適用的設定,也可透過環境變數、命令列參數指定,蠻靈活的。這部分待未來有更完整心得時再介紹。

  8. ASP.NET Core 的 Dependency Inject 用得很深,深到你想裝不認識都不行。例如:Controller 要取得文章資料、網站設定,做法是在 Startup.cs 中先註冊服務:

    services.AddSingleton<IBlogService, SqliteBlogService>();
    services.Configure<BlogSettings>(Configuration.GetSection("blog"));

    Controller 建構時將要用到的服務當成參數傳入。

    private readonly IBlogService _blog;
    private readonly IOptionsSnapshot<BlogSettings> _settings;
    
    public RobotsController(IBlogService blog, IOptionsSnapshot<BlogSettings> settings)
    {
        _blog = blog;
        _settings = settings;
    }

    在 cshtml 則是寫成 @inject IOptionsSnapshot settings

  9. 原本 Community Server 內建以 SQL 全文檢索為基礎的站內搜尋功能,SQLite 沒有這種東西。一度考慮改用 Lucene.NET實作,但考量實作工程不小,目前使用的 ASP.NET Core 能跨 Windows / Linux 平台,一旦引入 Lucene.NET,未來要搬移到 Linux 得多花功夫研究。評估後決定閃開讓專業的來,交給 Google Custom Search,Google 自訂搜尋引擎

  10. 忘了提本次搬家的重要目標,支援 HTTPS。我跟大部分的個人網站一樣,選擇免費的 Let's Encrypt 憑證,網路上相關的教學文很多,這裡不多贅述。但 Let's Encrypt SSL 憑證有個缺點是三個月要更新一次,原本想自己寫個程式自動更新,但發現已有佛心的開發者寫好了 - IIS 自動安裝Let’s Encrypt 免費 SSL 並到期自動更新,這部分有空再來研究。

總之,新站已經正式啟用囉,如果大家發現運作有問題,請留言或在 Facebook 貼文反映。

【茶包射手日記】空白結尾捉鬼記

$
0
0

紀念抓了個把小時的茶包。

同事回報一個詭異狀況,某個使用者登入時遇到異常,由於是線上系統,只能靠 Log 推敲還原事發經過,但卻有個矛盾之處:Log 顯示使用者只登入過一次,但登入後的初始化作業 Log 卻有兩次記錄。為了找出第二次沒有登入卻觸發初始化的來源,翻箱倒櫃清查過所有相關程式碼,一一推敲被執行的可能性,不料所有嫌犯都亮出完美的不在場證明,除了見鬼,我們想破頭也找不到合理解釋。

當案情陷入膠著,同事倒發現一處可疑,登入初始化作業的 Log 記載的客戶帳號,結尾多了一個空白。這段 Log 我們反覆看了 N 遍,結尾空白八成被人類認知模組給自動過濾了,白繞了一大圈才發現。

有了這條線索,很快就拼湊出我們的推論 - 使用者第二次登入時在帳號結尾多敲了空白,而前後端程式未加攔阻,八成是心想:反正帳號沒輸對 SQL 比對帳號密碼就不會正確,因此未對帳號字數或格式進行檢核。不料,SQL WHERE 比對時會自動略過結尾空白,多了空白結尾的帳號就這麼通過了密碼檢核,但要寫登入 Log 時帳號被當成資料夾名稱,結尾空白導致名稱不合法失效,NLog 忽略寫 Log 錯誤,程式繼續往下執行初始化,直到其他地方因帳號結尾多出空白出錯。登入 Log 只有一筆,初始化卻有兩筆,終於有了合理的解釋。

用以下這段程式驗證 SQL WHERE 比對行為:

如上圖所示,用 WHERE N = "Jeffrey " 可以查得 "Jeffrey",但取回至 C# 比對二者並不相等,形成奇妙的「在 SQL 端相等,但 C# 不相等」的奇特結果,也證明 SQL WHERE 比對會自動忽略結尾空白。而這個行為遇到固定寬度欄位 CHAR(N) 時也很精彩,一樣會造成要 SQL WHERE 找回相等的字串,在 C# 卻不相對的矛盾狀況。

至於結尾空白導致寫 Log 失敗的問題,依據 MSDN 文件空白或句點不宜做為檔案或資料夾結尾:(要硬幹也成,但實在沒必要庸人自擾)

Do not end a file or directory name with a space or a period. Although the underlying file system may support such names, the Windows shell and user interface does not. However, it is acceptable to specify a period as the first character of a name. For example, ".temp".

NLog 預設會忽略 Log 寫入例外以避免因寫 Log 出錯干擾主要作業,在 NLog.config 加上 throwExceptions="true" 可觀察到因路徑名帶有空白結尾出錯的訊息,證實我們的猜測。

又學個經驗:SQL WHERE 忽略結尾空白、沒看到 Log 不一定代表事情沒發生。

我的 Linux 作業系統考察

$
0
0
ASP.NET Core 最迷人之處就在於可以跨出 Windows 象牙塔,在伺服器的選擇上海闊天空,藉著改版部落格我已踏出第一步,接著我想真實挑戰跨平台這件事。 依據 W3Techs 最新調查,統計全世界一千萬個網站(其調查方法可參考這篇),其使用作業系統比例:Unix 佔 68.1%,Windo... Read More

CentOS 安裝筆記

$
0
0
將運作十年的 ASP.NET WebForm 的部落格改寫成 ASP.NET Core,我的下一步是挑戰將部落格搬到 Linux 上執行,才不辜負 .NET Core 可跨平台的能力。考察過 Linux 作業系統,我選擇了 CentOS,主要考量其以穩定性著稱,企業界接受度較高,將來或許有機會靠它混... Read More

【茶包射手日記】換帳號執行程式產生多餘空白

$
0
0
同事回報一枚詭異茶包,某上古神獸級的老 VBScript 排程,在更換執行身分後所輸出的固定欄寬文字檔錯誤,中文欄位右側填補空白數不對,多出三個空白。但讓人無法理解的是:程式完全沒動,換了執行身分權限不對還能理解,執行成功但結果多出空白是什麼鬼? 取回 Log 分析並檢視程式碼,推測與字數計算邏輯有... Read More

在 DOS 批次檔寫入 Windows 事件

$
0
0
介紹最近這兩天學到的好用 DOS 指令。 工作上有些排程我還是習慣靠 DOS 批次檔解決,雖然這年頭 PowerShell 已是 Windows 管理腳本的主流,但批次檔簡單易上手,串接執行檔或 DOS 內建作業,例如:COPY、ROBOCOPY、7Zip...,不花太多腦筋三兩下便搞定。因此批次檔... Read More

VBScript 語系切換技巧

$
0
0
被 VBScript 語系問題咬傷多次,前幾天分享換帳號執行程式產生多餘空白一文後,在臉書專頁與留言區都接到網友回響,雖然晚了十年學會,日後用到的機率也微乎其微,還是寫篇筆記備忘。 先前遇到的狀況是 Windows 語系被設成英文,導致中文字元被解析成問號衍生錯誤。然而,除了依賴作業環境語系正確設定... Read More

使用 curl 批次檔觸發排程作業網頁

$
0
0
在工作上有些系統選擇將定期執行作業的邏輯寫在 ASP.NET 網站,再設定 SQL 排程 (SQLAgent) 或 Windows 排程器 (Task Scheduler) 呼叫它。這麼做的好處是邏輯完全集中在網站專案,不致分散於網站及主控台程式兩處(雖共用程式庫已可大幅減少重複,但仍比單一網站複雜... Read More

【茶包射手日記】Windows 我的圖片中文檔名的祕密

$
0
0
同事報案,測試網頁上傳檔遇到中文檔名上傳變英文的怪事,仔細檢查後有了驚奇發現。檔案來自 Windows 內建範例圖片(C:\Users\Public\Pictures\Sample Pictures),檔名原本是英文,但檔案總管顯示的卻是中文檔名,因此選取檔案上傳時產生「自己正在上傳中文檔名圖檔」的... Read More

.NET 4.7.2 Windows 10 版本要求與版號常識

$
0
0
因安裝軟體需求,要在 Windows Enterprise 上安裝 .NET 4.7.2,卻得到「這個作業系統不支援 .NET Framework 4.7.2」訊息。 呼叫 WinVer 查了版本,版本為 10.0,組建 10240。 查到這裡我有點疑惑,Windows 10 版號不是 1703... Read More

ASP.NET MVC 針對電腦與手機提供不同 OutputCache

$
0
0
OutputCacheAttribute 是改善 ASP.NET MVC 網站效能的利器,只需在 Action 加註 [Output] 並指定 Cache 保留時間,其他什麼都不用做就得到跑一次程式應付數十上百次請求的效能提升,能提升回應速度並降低資源消耗。至於因應不同類型請求快取多份內容,Outp... Read More

【茶包射手筆記】Windows 10 升級後 Hyper-V VM 無法啟動

$
0
0
小筆記一則,前幾天升級 Windows 10 1803,隔了兩天要開啟 Hyper-V VM 出現錯誤。 過去有類似經驗,升級 Windows 前如有使用「儲存」功能的休眠 Hyper-V VM,須先「刪除儲存狀態」,視為不正常關機再啟動。 這次狀況不同,VM 啟動失敗,彈出錯誤: Emulate... Read More

範例教學:使用 ASP.NET MVC 打造 WebAPI 服務

$
0
0
前言 閒聊 - Web API 是否一定要 RESTful? 一文提到我個人偏好用 ASP.NET MVC 寫 WebAPI,讀者 Mark 留言希望能有簡單範例參考。這篇文章將示範用 ASP.NET MVC 從無到有打造一個簡單 WebAPI 服務,提供給初學 ASP.NET MVC 不知如何下手... Read More

ASP.NET Core + Nginx on CentOS 安裝筆記

$
0
0
先前文章已初步驗證 ASP.NET Core 程式可以不經修改直接搬到 Linux 執行,接下來得真的把它配置好才能上戰場。 ASP.NET Core 內建的 Kestrel 伺服器輕巧但功能陽春,實務上需搭配 Reverse Proxy 對外提供服務,在 ASP.NET Core 值得學嗎? 提過... Read More

ASP.NET Core 練功筆記 2 – Ubuntu + SQLite + Dapper

$
0
0

繼續 ASP.NET Core 專案練習,本階段的戰術目標:嘗試在 Linux Ubuntu 16.04 上跑 .NET Core + SQLite + Dapper。

  • Ubuntu 遠端桌面設定
    Ubuntu xrdp 支援使用 Windows 的「連線遠端桌面」程式(RDP Client)登入桌面環境,還可在主機螢幕操作桌面之外另開一個虛擬獨立桌面環境。
    但有個問題是 Ubuntu 13.10 之後 xrdp 不再支援系統預設的 Gnome 和 Unity 桌面,大部分使用者只好改用精緻度與功能較差的 xfac4 代替,網路上有配合 tightvncserver 繞道的解法(參考:xrdp完美实现Windows远程访问Ubuntu 16.04 - 法号阿兴 - 博客园),但我試不出來。最後決定改走遙控鍵盤滑鼠的路,用 RealVNC Viewer連接 Ubuntu 內建的桌面分享功能順利搞定。(參考:How to Remote Access to Ubuntu 16.04 from Windows - UbuntuHandbook)。
    從 Windows 使用 RealVNC Viewer 連接 Ubuntu 會遇到以下安全錯誤,Workaround 是使用 "sudo gsettings set org.gnome.Vino remote-access false" 關閉 Vino 加密:

  • Ubuntu Samba 伺服器設定 
    程式主要還是會用 Windows VS2017 / VSCode 開發,我習慣在 Ubuntu 執行跑 Samba Server 分享目錄, 在 Windows 寫好程式透過網路磁碟機部署檔案,再使用 Putty 或遠端桌面測試,這是我找到較順暢的整合方式。
    參考:在 Ubuntu 11.10 架設 Samba Server 及windows 7上的設定 @ 永˙宗˙看˙視˙界 -- 痞客邦 --
  • 在 Ubuntu 16.04 安裝 .NET SDK
    這部分較簡單,官方文章寫得很詳細。在 -NET Tutorial - Hello World in 10 minutes Tutorial Guid / Linux / Install the .NET SDK / 選好作業系統版本,依據說明步驟操作即可。

    wget -q https://packages.microsoft.com/config/ubuntu/16.04/packages-microsoft-prod.deb
    sudo dpkg -i packages-microsoft-prod.deb
    sudo apt-get install apt-transport-https
    sudo apt-get update
    sudo apt-get install dotnet-sdk-2.1
  • System.Data.SQLite 支援問題
    NuGet 上的 System.Data.Sqlite Package 執行時需要一個與平台相依的 Unmanaged Sqlite.Interop.dll 才能運作,而它只支援 net20/40/45/451/46,Sqlite.Interop.dll 分 x86/x64 只支援 Windows,故加入 .NET Core 專案編譯時將出現警告:'System.Data.SQLite.Core 1.0.108' was restored using '.NETFramework,Version=v4.6.1' instead of the project target framework '.NETCoreApp,Version=v2.1'. This package may not be fully compatible with your project.
    下場是程式在 Windows 測試正常,移到 Ubuntu 執行將出現錯誤: 
    Exception: System.DllNotFoundException: Unable to load shared library 'SQLite.Interop.dll' or one of its dependencies. In order to help diagnose loading problems, consider setting the LD_DEBUG environment variable: libSQLite.Interop.dll: cannot open shared object file: No such file or directory
       at System.Data.SQLite.UnsafeNativeMethods.sqlite3_config_none(SQLiteConfigOpsEnum op)
       at System.Data.SQLite.SQLite3.StaticIsInitialized()
       at System.Data.SQLite.SQLiteLog.Initialize()
       at System.Data.SQLite.SQLiteConnection..ctor(String connectionString, Boolean parseViaFramework)
       at SqliteDapper.Program.InitSQLiteDb() in /home/jeffrey/Labs/dotnet/SqliteDapper/Program.cs:line 51
       at SqliteDapper.Program.Main(String[] args) in /home/jeffrey/Labs/dotnet/SqliteDapper/Program.cs:line 17

    爬文找到編譯 Linux 專屬 libSQLite.Interop.so 取代 SQLite.Interop.dll 的做法:Using System.Data.SQLite under Linux and Mono - Wezeku,但似乎只適用於 Mono,幾經嘗試都不成功。
  • Microsoft.EntityFrameworkCore.Sqlite 突圍
    發現配合 EF 使用的 Microsoft.Data.Sqlite.Core NuGet Package 可在 Ubuntu 執行,其中也實作了 IDbConnection (SqliteConnection),讓我燃起一絲希望,但實測自己建立 Microsoft.Data.Sqlite.SqliteConnection 配合 Dapper 執行 .Query() / .Execute() 時都會彈出錯誤:
    You need to call SQLitePCL.raw.SetProvider().  If you are using a bundle package, this is done by calling SQLitePCL.Batteries.Init().
    靈機一動,先建立 EF DbContext(),再由 DbContext.GetDbConnection() 產生連線物件,搭配 Dapper .Query()/.Execute() 居然就成功了! 高興到差點從椅子上跳起來歡呼,總算沒枉費我跟它奮戰了大半個週末~

在 Ubuntu 跑 SQLite + Dapper 實測成功,算是攻上了小山頭,繼續推進。

小技巧 - 運用 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 可正常檢視解壓,測試成功!

Viewing all 2311 articles
Browse latest View live