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

Json.NET 日期型別時區問題之終極解法

$
0
0

一直以來常被 JSON 日期序列化時區問題困擾,問題主要發生於從資料庫查詢日期欄位,轉為 .NET DateTime 型別時其 Kind 屬性為 Unspecified,而以 DateTime.Now、DateTime.Today 取得的日期物件,Kind 則為 Local,二者不一致可能導致前端出現 8 小時時差。為解決問題,先前想到的做法是先宣告 JsonConvert.DefaultSettings DateTimeZoneHandling = DateTimeZoneHandling.Utc,將 DateTime 統一轉為 "yyyy-MM-ddTHH:mm:ssZ",至於資料庫查詢取得的 DateTime 則使用自製的 FixUnspecifiedDateKind() 方法將 Unspecified 改為 Local。(關於 JSON 時差問題的更多說明,可參考先前文章:Json.NET日期序列化的時區問題EF日期欄位之JSON序列化時區問題

不過,在實際用了一陣子,感覺  FixUnspecifiedDateKind() 並不能算理想做法。EF 可攔截 ObjectMaterialized 每次查詢後自動轉換還算省事,但使用 Dapper 或 IDbCommand 查詢就得每次記得手工補上轉換才不會出錯,讓我萌生改進的念頭。經過一番研究測試,想到兩個更好的解法。

解法一 改用 JsonConvert.DefaultSettings DateTimeZoneHandling = DateTimeZoneHandling.Local

如此,資料庫讀取的 DateTimeKind.Unspecified 日期在 SerializeObject 會自動被視為本地時間,符合我們的期望。而不管 Unspecified、Local、UTC,都一律轉成 yyyy-MM-ddTHH:mm:ss+08:00,前端 DataReviver 統一處理即可。如下範例,dateUnspecified 視同 dateLocal(黃底文字所示),避免資料庫讀取時間被誤為 UTC 提早 8 小時的問題。

解法二,智慧型 DateReviver 函式

在上圖中,我發現一件過去忽略的事:Unspecified、Local、UTC 三種 DateTime 的 Json.NET 轉換結果是有區別的,分別為 "yyyy-MM-ddTHH:mm:ss"'、"yyyy-MM-ddTHH:mm:ss+08:00" 及 "yyyy-MM-ddTHH:mm:ssZ"。利用這項差異,我們可以改寫 DateReviver 函式聰明地將不同 DateTimeKind 時間轉成正確時間,範例如下:

function dateReviver(key, value) {
var a;
if (typeof value === 'string') {
//UTC
        a = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value);
if (a) {
returnnew Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6]));
        }
//Unspecified
        a = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)$/.exec(value);
if (a) {
returnnew Date(+a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6]);
        }
//with Timezone
        a = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)([+-])(\d{2}):(\d{2})$/.exec(value);
if (a) {
var dir = a[7] == "+" ? -1 : 1;
returnnew Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4] + dir * a[8], +a[5] + dir * a[9], +a[6]));
        }
    }
return value;
}
 
console.log(JSON.parse('"2017-08-08T00:00:00Z"', dateReviver));
console.log(JSON.parse('"2017-08-08T08:00:00"', dateReviver));
console.log(JSON.parse('"2017-08-08T08:00:00+08:00"', dateReviver));

 

經過評估,DateReviver 自動依日期 JSON 字串格式決定時區的做法,只需調整 JavaScript 端程式就好,尤其在 Web API 來自第三方無法配合修改的惡劣環境也能存活,特封為 JSON 時區問題之奧林匹克指定解法。


SQL 資料轉 INSERT 語法-使用 Visual Studio

$
0
0

將資料表內容轉成一連串 INSERT 語法,是蠻好用的跨伺服器搬資料表招式,之前我最愛用的工具是 SQL Dumper,但昨天聽到不幸消息:官方網站人去樓空,連註冊的 DNS 網域都已棄守。

重新尋找替代方案,找到三種做法:

  1. SSMS 內建 Generate Script 功能


    接著透過 Wizard 介面指定資料表並設定只輸出資料(Types of data to script: Data only)



    由於當初設計可搬移整個 DB,故介面跟選項有點繁瑣,另外無法自訂查詢條件是一大缺點。
  2. SSMS Tools Pack
    SQL Server Management Studio 的外掛套件,有不少方便的輔助功能,包含 Insert Statement Generator,功能挺強大,為付費軟體。
  3. SQL Server Data Tools in VS2015+
    SSDT 是整合在 Visual Studio 2015+ 內的資料庫工具,可用於建置 SQL Server 關聯式資料庫、Azure SQL Database、Integration Services 封裝、Analysis Services 資料模型以及 Reporting Services 報表。SSDT 的資料表檢視工具有產生 INSERT 指令功能,這裡簡短示範:

在 Visual Studio 開啟 Server Explorer 視窗,在 Data Connections 清單建立資料庫連線,選擇要匯出資料的資料表透過右鍵選單選取「Show Table Data」:

資料檢視區上方工具列有兩個卷軸圖示,左邊的是產生 Script 並編輯,右邊則是直接匯出成檔案:

點選 Script 即可產生逐筆資料的 INSERT INTO 指令囉~(Script 還包含停用及啟用自動跳號,蠻貼心的)

支援簡單的 Filter 查詢條件,預設只顯示前 1000 筆,實務上可依需求調整。

經簡單試用,SSDT 的 INSERT Script 產生功能雖不及 SQL Dumper 簡潔方便,亦不失為可用的替代方案。

古董點陣印表機套表列印經驗

$
0
0

工作多年,第一次遇到用撞針式印表機套表印單據的需求。身為具有30 年個人電腦使用資歷,MS-DOS 3.1 年代下海的老人,當然摸過用過還買過點陣式印表機,但都已 2017 年,便宜的黑白雷射印表機 2500 元就有,作業系統也來到 Windows 10,再回頭使用 20 年高齡的點陣式印表機,就是很新奇的體驗。

題外話:講到點陣式印表機,就不免想起學生時代痴心妄想 DIY 的土砲光學掃瞄器-搖捍介面接光敏電阻綁在印字頭,寫 BASICA 程式控制紙張捲動、印字頭橫移並同步讀取光線強度數值,理論上就能掃瞄 A4 紙上每一區塊的明暗。不幸地,實驗失敗了(廢話!)一是光敏電阻感應面積大如紅豆,精細度比 Minecraft 還糟,二則沒有放大電路,敏感度奇差無比,結果我造了一台完美的「物理式亂數產生器」,但有想法動手做的樂趣,無價!

咳,回到正題(跳一下)。套表方式計劃採用 Reporting Service 報表,算準欄位位置及尺寸,產生報表轉成 PDF 用點陣印表機印到連續報表紙形式的空白單據上即完成。理論上可行,但沒印出來誰也沒把握。業務單位遙遠暫時摸不到實機,為了開發測試多方打聽徵召,沒借到 EPSON LQ 2090 同型機器,倒是從倉庫挖出一台塵封多年,高齡 20 歲的古董 Fujitsu 136 欄印表機。(讓我想起電影「超級戰艦」裡密蘇里號博物館重返戰場的情節…)

古董印表機的型號是 Fujitsu DL6400 Pro,內建明體、楷書、黑體三種中文字型,唰唰唰瞬間就能噴出一整行中文字,跟我當年用過的 EPSON 80 欄小機器靠倚天中文軋軋軋老半天才印一行,檔次完全不同,是 NBA 對上國中校隊的區別。

【使用手冊大驚奇】

這麼老的機種,居然在富士通台灣網站還能下載到中文使用手冊,打開 ZIP 檔見到二十幾個 PDF 檔嚇我一跳,每個 PDF 只有一頁,為手冊某兩頁的掃瞄影像(有某種珍貴史料的 fu),有的直擺有的橫放,有的上下顛倒,連合併校正成一個 PDF 的功夫都省了,十分奇妙~

參考手冊我發現清朝年間「不用 LED 面板也能操作四層式選單」的絕妙做法:按下設定鍵,印表機先印出一列八個選項,使用者按面板鍵左右移動印字頭,停在想執行功能上按 Enter 鍵進入第二層選單,印表機接著印出第二層選項… 酷!。

【驅動程式】

富士通中文網站的 DL6400 Pro 驅動程式只從 Windows 95 /NT 4 到 Windows XP,而日本官網居然有給 Windows 10 的驅動程式, 為 20 年古董機型更新驅動程式的情操真是太偉大了,我感動到都快哭了。

但很不幸,安裝 Windows 10 驅動程式印出的測試頁,純文字部分 OK,但圖形部分一片錯亂,猜想是手邊這台內建中文字型機種跟英文機型的差異造成。

爬文得到幾點心得:

  • 各廠牌點陣式印表機如找不到驅動程式,幾乎都可用 EPSON LQ 系列驅動程式替代,其中最通用的是 LQ 1000。
  • Window 7 拿掉預設內建的 EPSON LQ 1000 驅動程式,但可在驅動程式選擇頁面按「Windows Update」把它找回來。
    (更新過程等超久,估計超過五分鐘)
  • 試了EPSON LQ Series 1 (136)、EPSON LQ Series 2 (136)、EPSON LQ 1000C 都可正常列印測試頁,用 EPSON LQ 2090C 或 EPSON LQ 2090 則不OK。

【自訂紙張尺寸】

單據為連續報表紙格式,寬度介於 80 欄與 136 欄報表紙之間,高度很扁不到 10 公分,由於不符合任何現成紙張尺寸,必須自訂紙張尺寸。做法是在「控制台 / 裝置與印表機」點選印表機後選「列印伺服器內容」,按「建立新格式」後輸入寬高上下左右邊界按「儲存格式」,之後自訂紙張尺寸就會出現在紙張格式清單中。

實測再發現另一個問題,EPSON LQ Series 驅動程式雖然列印大致正常,但在列印到自訂紙張,即使紙張上下邊界已設為 0,印表機會假設紙張上下各有約 1.27 公分的區域無法列印,改用 LQ 1000C 驅動程式才克服。

再談集保罕用字集與 BIG5 造字區

$
0
0

同事遇到集保罕集問題,我試著解釋個中奧妙時冒出一堆「集保罕字的X」「Unicode標準字的X」「看起來一樣但編碼不同」把同事薰得七葷八素,感覺都快吐了… 嗯,寫篇文章細說從頭吧。

很久很久以前,在 Unicode 還沒一統天下之前,BIG5是台灣地區的主流中文編碼,其中定義 13,053 個常用字與次常用字與 441 個符號。問題來了,有些日常生活會用到的字(最常見是人名)沒被包含在這一萬三千字裡,所以早些年水牛伯還活躍於政壇時不時可在使用 BIG5 的新聞網站看到「游錫方方土」,還有「陶吉吉」大家應該也不陌生 XD (關於 BIG5 與 Unicode 編碼,之前曾在中文編碼解析工具介紹文提過,維基百科則有更完整說明,有興趣深入可以一讀)

當今解決 BIG5 缺字問題最有效做法是回歸王道-改用 Unicode,Unicode 可正確處理七萬個漢字,幾乎不會再有缺字困擾。但對現有系統或資料庫,更換資料編碼是動搖國本的大事,只能在繼續使用 BIG5 前題下克服缺字問題,於是造字區成了唯一救贖。

BIG5 當初在制定時,編碼範圍保留了三段造字區:FA40-FEFE 785字 + 8E40-A0FE 2983字 + 8140-8DFE 2041字, 共可再自訂 5809 字 。

圖片來源:CNS11643 中文全字庫-認識全字庫-中文碼介紹

集保公司所推出的集保罕用字集(下載位置:其他類別/華康中文罕用字型)就是透過造字解決 BIG5 缺字問題。

把鏡頭拉近一點看個實例,集保罕用字集下載檔中有個 Map_code.txt,裡面有所有造字的內碼對照表,我們就拿三條魚的「鱻」字當範例:

罕用字集中造了鱻字,BIG5 碼為 8742,落於 8140-8DFE 造字區,是一個合法的 BIG5 字元,也能被轉換成 Unicode,UCS2 編碼(固定用兩個 Byte 表示一個字元的編碼系統)為 F268。但 Windows 內建的新細明體、標楷體、正黑體並沒有為 UCS2 編碼 F268 繪製字型,因此不另外安裝專屬字型就看不到造字區字元。上面圖示所用電腦裝了華康罕用字型,所以才看得到綠底標示位置的「鱻」字,一般電腦看到的會是空白。回到 Unicode 端,Unicode 的編碼系統可容納七萬個漢字,當然也有「鱻」字(不然大家讀這篇文章時不會看到它),而它的 UCS2 碼是上圖中的 9C7B。

中文編碼解析工具分析一下會更清楚。如下圖,我們輸入兩個鱻字,中間夾一個空白,第一個為集保造字(黃底),第二個為 Unicode 內建字(粉紅底),二者的 BIG5 編碼分別為 8742 與 3F(Unicode 鱻對應不到有效 BIG5 編碼故變成問號,ASCII 碼為 3F),其 UCS-2 則分別為 F268 及 9C7B,印證前一段的說明。

這裡先簡單做個總結:在 Windows 要處理罕用字有兩種選擇:1) 使用 Unicode 版字元 2) 要求使用環境部署罕用字型,使用造字版本字元。當你的系統身處使用 BIG5 造字處理缺字問題的環境,就必須面對明明同一個字卻有兩種編碼的狀況。

在已安裝罕用字集的電腦可同時看到 Unicode 版與造字版字元,實務上二者看起來有差異,如下圖所示,網頁同時放入兩種版本的鱻(造字版輸入法不易選取,故我用表示之),字型都指定為「細明體」,使用瀏覽器檢視時可看左邊的造字版字型比 Unicode 版高,二者明顯不同。

為什麼同樣是細明體,卻感覺是不同的字體?安裝罕用字型後,會有以下 EUDC(End User Defined Characters ) Registry 指定支援造字區的專用字型,以告知軟體在字型遇到造字區字元時該使用何種專屬字型替代。以上圖為例,左邊來自華康字型(細明體.tte),右邊來自系統原本的細明體 ,故字高及樣式有所差異。(實測發現不同軟體處理原則有別,顯示結果可能不同)

介紹完集保罕用字原理與特性,來談談設計系統應如何因應。我建議-除非系統被限制必須維持 BIG5 編碼,系統從資料庫、檔案格式到程式 UI 一律改用 Unicode 才是王道,採行統一標準,才不會冒出一堆轉換需求。如此要面對的問題只剩:「萬一使用者裝了罕用字集在 UI 輸入造字區字元怎麼辦?」,解決之道要在使用者可能輸入罕用字的地方加上檢查(依經驗大概只有姓名地址),阻止使用者輸入造字區字元或是偷偷將其置換成 Unicode 版本,前面提到的 Map_code.txt 已提供足夠資訊要寫偵測或轉換程式不難。確保進入系統的永遠只有 Unicode 標準字,就不會傷了皇城內的和氣囉~

C# 連線 HTTPS 網站發生驗證失敗導致基礎連接已關閉

$
0
0

某台透過 .NET WebClient 物件爬網頁抓資料排程忽然出現:

基礎連接已關閉: 傳送時發生未預期的錯誤。 ---> System.IO.IOException: 驗證失敗,因為遠端群體已經關閉傳輸資料流。
The underlying connection was closed: An unexpected error occurred on a send. ---> System.IO.IOException: Authentication failed because the remote party has closed the transport stream

有趣的是,上回同一排程就發生過類似狀況,原本早上還執行得好好的,近中午時開始出錯。二次出錯的時點幾乎相同,推測是該網站的固定換版上線時間。

對照測試,使用 IE 或 Chrome 開啟網頁正常,只有透過 WebClient 取回 HTML 內容時才出錯,在本機測試也發生相同錯誤。印出 Exception 內容如下:

ERROR=System.Net.WebException: 基礎連接已關閉: 傳送時發生未預期的錯誤。 ---> System.IO.IOException: 驗證失敗,因為遠端群體已經關閉傳輸資料流。
   於 System.Net.Security.SslState.StartReadFrame(Byte[] buffer, Int32 readBytes, AsyncProtocolRequest asyncRequest)
   於 System.Net.Security.SslState.StartReceiveBlob(Byte[] buffer, AsyncProtocolRequest asyncRequest)
   於 System.Net.Security.SslState.CheckCompletionBeforeNextReceive(ProtocolToken message, AsyncProtocolRequest asyncRequest)
   於 System.Net.Security.SslState.StartSendBlob(Byte[] incoming, Int32 count, AsyncProtocolRequest asyncRequest)
   於 System.Net.Security.SslState.ForceAuthentication(Boolean receiveFirst, Byte[] buffer, AsyncProtocolRequest asyncRequest)
   於 System.Net.Security.SslState.ProcessAuthentication(LazyAsyncResult lazyResult)
   於 System.Net.TlsStream.CallProcessAuthentication(Object state)
   於 System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
   於 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
   於 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state)
   於 System.Net.TlsStream.ProcessAuthentication(LazyAsyncResult result)
   於 System.Net.TlsStream.Write(Byte[] buffer, Int32 offset, Int32 size)
   於 System.Net.PooledStream.Write(Byte[] buffer, Int32 offset, Int32 size)
   於 System.Net.ConnectStream.WriteHeaders(Boolean async)
   --- 內部例外狀況堆疊追蹤的結尾 ---
   於 System.Net.WebClient.DownloadDataInternal(Uri address, WebRequest& request)
   於 System.Net.WebClient.DownloadString(Uri address)
   於 System.Net.WebClient.DownloadString(String address)

錯誤訊息明確指向 SSL,爬文後恍然大悟,原來跟上回研究過的 TLS 1.0 停用議題有關,研判對方網站調整系統停用了較不安全的 TLS 1.0,依上回心得,.NET 客戶端使用 WebClient、WCF 以 HTTPS 連線遠端主機,也會涉及 TLS 1.0/1.1/1.2 版本議題,不同版本 .NET 的處理方式不同:

  • .NET 4.6內建支援且預設使用 TLS 1.2
  • .NET 4.5內建支援,但需透過 ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 設為預設協定
  • .NET 4本身不支援,但安裝 .NET 4.5 後即可使用 TLS 1.2,指定 TLS 1.2 的寫法為 ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072;

我的程式是 .NET 4.5,在調整 ServicePointManager.SecurityProtocol 設定擴大 SSL/TLS 版本範圍後,問題排除:(若改程式不方便,亦有修改 Registry 的解法,請參考前文

staticvoid Main(string[] args)
        {
try
            {
                WebClient wc = new WebClient();
//REF: https://stackoverflow.com/a/39534068/288936
                ServicePointManager.SecurityProtocol = 
                    SecurityProtocolType.Ssl3 | SecurityProtocolType.Tls | 
                    SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12;
string res = wc.DownloadString("httqs://some-server/test");
                Console.WriteLine(res);
            }
catch (Exception ex)
            {
                Console.WriteLine("ERROR=" + ex.ToString());
            }
            Console.ReadLine();
        }

TLS 1.0 已被視為不安全,近期應會被各大網站陸續停用,使用 .NET 讀取網站資料遲早要對類似問題,宜多加留意。

TIPS-調整 SQL Agent 作業記錄筆數限制

$
0
0

查詢 SQL Agent 排程執行問題時,發現一個狀況:使用 Log File Viewer 查詢作業記錄(Job History Log),大部分排程的執行歷程都是空的(如下圖),只有少數幾個排程有內容:

研究後學到一件事-SQL Agent Job History 有預設筆數限制,預設值為所有排程作業總共 1000 筆,每項排程作業最多 100 筆。在我們的排程作業中,有幾個排程每兩分鐘或五分鐘就執行一次,很快把 1000 筆配額用光光,因而產生作業記錄只剩下高頻率排程的記錄,超過一天的記錄全部被擠掉消失無蹤。

因此,當 SQL Agent 的排程數量多、步驟複雜或頻率較高,為避免出事無記錄可追,就應調整設定。查到一篇不錯的文章:Check your SQL Agent history settings before it’s too late!,其中有計算公式:

每項作業最大歷程記錄筆數 = 執行次數 * (最大步驟數 + 1)
歷程記錄資料量 = 記錄筆數 * 1.5KB

還有一種務實做法是把上限拉大或取消上限,實際跑一陣子再由 msdb.dbo.sysjobhistory 實測筆數大小,進而依保留期間決定上限及要準備空間,可以估算得更精確。

總之,使用 SQL Agent 排程記得事先規劃記錄空間,不要等遇到事故才發現行車記錄器記憶卡被蓋掉,可就欲哭無淚了。

【延伸閱讀】

筆記-T-SQL 分頁查詢並傳回總筆數

$
0
0

資料庫查詢結果要做分頁,我較常用的做法是拉到 C# 端將物件陣列或 List<T> 存入 MemoryCache,用 .Length 可以取總筆數,用 Skip(pageSize  * (pageNo - 1)).Take(pageSize) 取回指定頁數資料,換頁或排序時從 MemoryCache 讀取以求迅速並減輕資料庫伺服器負擔,遇到變更查詢條件或按查詢鈕時再重新查詢資料庫。

最近遇到的案例,因使用者較多、單筆資料量也大,擔心 Cache 消耗過多記憶體,決定改用 T-SQL 實做分頁同時取得總筆數,過去少有機會練習,試作之餘寫篇筆記備忘。

我找到較簡潔的做法是組裝查詢條件先轉成 CTE,用 Count(1) OVER () 計算總筆數放在每筆資料第一欄(重複資料會浪費空間,但既然會做分頁筆數不會太多,耗損可忽略),再使用 OFFSET + FETCH NEXT 子句實現類似 LINQ Skip() 與 Take() 的效果,程式範例如下:參考來源

DECLARE @pageSize INT, @pageNo INT;
SET @pageSize = 25;
SET @pageNo = 3;
;WITH T
AS (
SELECT *
FROM Production.Product
WHERE ListPrice > 10
    )
SELECT TotalCount = COUNT(1) OVER (), T.*
FROM T
ORDERBY ProductNumber OFFSET(@pageNo - 1) * @pageSize ROWS
 
FETCHNEXT @pageSize ROWSONLY;

2017-08-24 補充:回應網友 Ken 提問:CTE 並非絕對必要,寫成 CTE 的好處是形成標準範本,應用於不同場合時只需置換 CTE 內的查詢語法,其他部分不動,更容易寫成共用程式模組。

拿 AdventureWorks 資料庫 Production.Product 練兵,執行可得總筆數 291 筆,每頁 25 筆取第 3 頁,查得 25 筆:

關於 OFFSET 與 FETCH 的詳細介紹,可參考 德瑞克:SQL Server 學習筆記- SQL Server 2012 :分頁處理:認識 OFFSET 和 FETCH 子句

OFFSET 跟 FETCH 是 SQL 2012 才加入的新指令,如果你的資料庫還停在滿清時代 SQL 2005 或 SQL 2008,就只能回歸使用 ROW_NUMBER() 配合 BETWEEN 分頁,但配合 CTE 使用,查詢稍稍複雜,違和程度尚在可忍受範圍。 (但如果不用 CTE,而是同樣查詢條件 Copy and Paste 就會讓人想吐了…)程式範圍如下:參考來源

DECLARE @pageSize INT, @pageNo INT;
SET @pageSize = 25;
SET @pageNo = 3;
;WITH T
AS (
SELECT ROW_NUMBER() OVER (ORDERBY ProductNumber) AS RowNo,*
FROM Production.Product
WHERE ListPrice > 10
    ),
T2 AS (
SELECTCOUNT(1) TotalCount FROM T
)
SELECT *
FROM T2, T
WHERE RowNo BETWEEN (@pageNo - 1) * @pageSize  + 1 
AND @pageNo * @pageSize;

執行結果與 OFFSET + FETCH 版本相同:

小技巧-使用匿名型別快速捏出指定JSON格式

$
0
0

同事有個小需求,已知城市、區域及郵遞區號要產生如下規格的 JSON 餵到前端:

{
"rows": {
"row": [
      {
"City": "台北市",
"Area": "文山區",
"ZIP": "116"
      }
    ]
  }
}

先前介紹過 JObject 結合 dynamic 的花式玩法可以快速達成目標:

staticvoid TestJObject(string city, string area, string zip)
        {
            dynamic root = new JObject();
            root.rows = new JObject();
            dynamic row = new JObject();
            row.City = city;
            row.Area = area;
            row.ZIP = zip;
            root.rows.row = new JArray(row);
            Console.WriteLine(JsonConvert.SerializeObject(root, Formatting.Indented));
        }

不過,我認為這個案例用 JObject 有點殺雞用牛刀,用 C# 匿名型別可以更輕鬆搞定,就順手寫了範例。從同事驚嘆的反應,我猜應該有些朋友沒想過匿名型別可以這様玩,看來這技巧有分享的價值,那就野人獻曝一下好了。

程式碼說破就不值一文錢,new { PropName = PropValue… } 直接宣告匿名物件,new [] { } 可宣告匿名物件陣列,將物件用 JsonConvert.SerializeObject() 轉成 JSON,大功告成!

staticvoid TestAnonyType(string city, string area, string zip)
        {
            var root = new
            {
                rows = new
                {
                    row = new[]
                    {
new
                        {
                            City = city,
                            Area = area,
                            ZIP = zip
                        }
                    }
                }
            };
            Console.WriteLine(JsonConvert.SerializeObject(root, Formatting.Indented));
        }
    }

實測兩種寫法結果一致:

staticvoid Main(string[] args)
        {
            TestJObject("台北市", "文山區", "116");
            TestAnonyType("台北市", "文山區", "116");
            Console.ReadLine();
        }

 

雜耍表演完畢,下台一鞠躬~


ViewBag dynamic 特性導致無法使用 LINQ 語法

$
0
0

寫 ASP.NET MVC CSHTML 時,我很習慣用 ViewBag 將變數從 Controller 傳到 View 端,只是簡單傳遞幾個字串、數值,為此大費周章宣告 Model 型別有點殺雞用牛刀。我們都知道 ViewBag 是一個 dynamic 型別,而 dynamic 型別的屬性、方法也會被視為 dynamic,編譯階段不檢查,執行階段見真章。

不過,最近學到一件事:一旦函式參數傳入 dynamic,其傳回值也會被視為 dynamic,而此時將無法使用 Lambda 運算式

來看下面這個例子。我計算透過 ViewBag.DateString 傳遞 "2017/08/26"格式字串 View,在 CSHTML 裡我用變數 dateStr 接入此字串,試著查 Length,跑 Split('/').First() 都沒問題。再來我寫了一個簡單函式 - MySplit,輸入 string,傳回 Split('/') 後的 string[]。將 dateStr 當參數傳入 MySpit,取傳回 string[] 的 Length OK,但想對 string[] 做 Any(o => o.Length > 3) 卻產生錯誤:

Error  CS1977  Cannot use a lambda expression as an argument to a dynamically dispatched operation without first casting it to a delegate or expression tree type. 無法將 Lambda 運算式當做動態分派作業的引數,而未先將它轉型為委派或運算式樹狀架構型別

依照錯誤訊息指示,(string[])MySplit(dateStr) 將其強轉型後問題即告排除。至此我才發現,原來不只 dynamic 的屬性會被視為 dynamic,連一般的函式方法,只要傳入 dynamic 傳回結果也會被視為 dynamic。這應該跟參數有 dynamic 時 .NET 會改用複雜機制動態觸發函式有關,詳情可參考前文:方法多載(Method Overloading)與 dynamic

我們用 DateTime.ParseExact 測試,傳入 dateStr,在傳回的 DateTime 上寫 DarkthreadYear 都可以編輯成功(當然,執行階段必爆無夷),而由 Visual Studio 的顯示也可確認它被視為 dynamic。

而在這個案例中,更簡單的解法是將 var dateStr 改成 string dateStr,多打兩個字元,問題消失殆盡!

了解到這個特性,我決定養成一個習慣,不再用 var 宣告變數承接 ViewBag 傳遞過來的資料,而要明確宣告變數型別。如此在編輯階段可全程享受 Visual Studio 的強型別檢查與 Intellisense 支援,也避免衍生 Lambda 運算式碰壁的困擾,而依據先前研究,參數為 dynamic 時,.NET 將改用較複雜的動態機制處理函式呼叫,使用強型別也將有助於提高效能,一舉多得,何樂不為?

dynamic 參數之效能損耗實測

$
0
0

依據前篇文章:參數傳入 dynamic 會讓函式傳回值也變成 dynamic,導致無法使用 LINQ Lambda 運算式。文末提到,依據方法多載(Method Overloading)與 dynamic一文的研究心得,.NET 呼叫函式時若遇到參數為 dynamic 時,將改用System.Runtime.CompilerServices、System.CSharp.RuntimeBinder 命名空間物件與方法間接觸發,程序曲拆繁瑣許多。由此推測,參數傳入 dynamic 型別肯定會產生效能損耗,好奇心驅使之下,索性寫幾行程式實測親見為憑。

我設計測試程式如下,執行 100 萬次 "2017/08/26".Split('/').Length,連跑 10 回合測量執行時間,Test1() 與 Test2() 只差在 "2017/08/26" 宣告為 string 還是 dynamic:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
 
namespace TestDynamic
{
class Program
    {
staticvoid Main(string[] args)
        {
for (var i = 0; i < 10; i++)
            {
                Test1();
                Test2();
            }
            Console.ReadLine();
        }
 
constint TEST_COUNT = 1000000;
 
staticvoid Test1()
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            var str = "2017/08/26";
long c = 0;
for (var i = 0; i< TEST_COUNT; i++)
            {
                c += str.Split('/').Length;
            }
            sw.Stop();
            Console.WriteLine($"Strong Typed: {sw.ElapsedMilliseconds:n0}ms");
        }
staticvoid Test2()
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            dynamic str = "2017/08/26";
long c = 0;
for (var i = 0; i < TEST_COUNT; i++)
            {
                c += str.Split('/').Length;
            }
            sw.Stop();
            Console.WriteLine($"dynamic: {sw.ElapsedMilliseconds:n0}ms");
        }
    }
}

用 ildasm 反組譯工具先比較二者編譯結果的差異。

Test1 寫成 string str = "2017/08/26",MSIL 程式碼單刀直入,直接了當:

Test2 使用 dynamic str = "2017/08/26",其餘部分與 Test1 完全相同,但因為這一點差異,MSIL 程式碼截然不同,步驟多了 N 倍:

實測數據排除前面暖機階段,使用 dynamic 速度比明確宣告型別慢了約一倍。

慢一倍聽起來很恐怖,但不要忘記這畢竟是跑 100 萬次差不到 1 秒的奈米級差異,實務上對效能的影響幾可被忽略。但改幾個字元能讓程式變快又可回歸強型別檢查及 Intellisense 等諸多優勢,實在沒理由不做。在能用強型別取代 dynamic 的場合請明確宣告型別,尤其 CSHTML 使用 var 宣告變數承接 ViewBag 參數是不自覺使用 dynamic 的常見陷阱,應極力避免。 

Reporting Service 報表 List 區塊使用多資料表

$
0
0

Reporting Service RDLC 報表設計進階議題一枚。

先說情境,假設有技能專長與擅長語言兩個資料表,其中有每個人的資料,想在 RDLC 報表採以下形式呈現:先印出姓名,接著以表格形式分別列出技能清單與語言清單:

這類需求,最直覺有效的做法是使用子報表!很不幸,同事嘗試用子報表解決卻踼到鐵板:明細資料總筆數約 2000 筆,拆成 500 個子報表,產生報表耗時七分鐘,志玲姐姐都護完一生了報表還出不來,想當然爾被使用者狠狠打槍!

查了文獻,有文章指出包太多 SubReport 註定快不起來:(但資料都在記憶體, 500 個子報表慢到七八分鐘讓人意外)

總而言之,得想想繞路的方法。我優先想到的武器是 List,以使用者分群,在方格中放入兩個資料表格,一個顯示技能,一個顯示語言,像這樣:

不幸地,再踼到 List 限制的鐵板!List 資料區只能套用單一資料來源:

Because the List contains a grouping level, you can use the List data region only with a single dataset. 參考

最後,想到一個很可恥卻有用的方法-把兩個資料表合併成一個,額外加入 IsSkill 及 IsLang 布林欄位區隔是那一種資料(或用TableSrc string 識別也成),把兩種資料合併在同一個資料表中:

接著,在 Table1 套用 「IsSkill = True」Filter、Table2 套用 「IsLang = true」Filter,就成功在一個 List 顯示兩種資料!(灑花)

最後補上一些實作細節。

首先,將兩個 DataTable 合併成一個應該難不倒大家,但若資料來源是物件陣列,要將多個物件陣列合併成一個 DataTable 就要靠點技巧。分享一則密技:將物件陣列先轉成 JSON 字串,再用 Json.NET JsonConvert.DeserializeObject<DataTable>() 就能瞬間轉成 DataTable。我將合併邏輯寫成 DataTable 型別的擴充方式,合併資料來源則 IEnumerable<object> 與 DataTable 通吃,合併時還要傳入segName 方便加入 IsXXX 欄位。

publicstaticvoid MergeData(this DataTable table, IEnumerable<object> data, string segName)
{
    var json = JsonConvert.SerializeObject(data);
    var t = JsonConvert.DeserializeObject<DataTable>(json);
    table.MergeData(t, segName);
}
 
publicstaticvoid MergeData(this DataTable table, DataTable toAdd, string segName)
{
    var segFldName = $"Is{segName}";
    toAdd.Columns.Add(segFldName, typeof(bool));
foreach (DataColumn c in toAdd.Columns)
    {
if (!table.Columns.Contains(c.ColumnName))
            table.Columns.Add(c.ColumnName, c.DataType);
    }
foreach (DataRow row in toAdd.Rows)
    {
        row[segFldName] = true;
        var newRow = table.NewRow();
foreach (DataColumn c in toAdd.Columns)
            newRow[c.ColumnName] = row[c.ColumnName];
        table.Rows.Add(newRow);
    }
}

資料來源範例如下:

publicclass Skill
{
publicstring UserId { get; set; }
publicstring SkillName { get; set; }
publicint Level { get; set; }
public Skill(string userId, string skillName, int level)
    {
        UserId = userId;
        SkillName = skillName;
        Level = level;
    }
}
 
publicclass Language
{
publicstring UserId { get; set; }
publicstring Lang { get; set; }
publicint Level { get; set; }
public Language(string userId, string lang, int level)
    {
        UserId = userId;
        Lang = lang;
        Level = level;
    }
}
 
publicstaticclass SkillDataStore
{
publicstatic List<Skill> GetSkillData()
    {
returnnew List<Skill>()
        {
new Skill("Jeffrey", "爆破", 5),
new Skill("Jeffrey", "嘴砲", 4),
new Skill("Jeffrey", "嘲諷", 3),
new Skill("Darkthread", "發廢文", 5),
        };
    }
 
publicstatic List<Language> GetLangData()
    {
returnnew List<Language>()
        {
new Language("Jeffrey", "C#", 4),
new Language("Jeffrey", "JavaScript", 3),
new Language("Darkthread", "T-SQL", 3),
new Language("Darkthread", "PL/SQL", 2)
        };
    }
}

處理資料時,先建立空白 DataTable,再使用 MergeData() 合併 List<Skill> 及  List<Language>:

    DataTable t = new DataTable("Data");
    t.MergeData(Models.SkillDataStore.GetSkillData(), "Skill");
    t.MergeData(Models.SkillDataStore.GetLangData(), "Lang");
    rptViewer.LocalReport.DataSources.Add(
new Microsoft.Reporting.WebForms.ReportDataSource("DataSet1", t));

由於 DataSet 是動態組裝的 DataTable,沒有現成的資料模型範本,設計報表時看不到可用欄位無法拖拉設定。

有兩種解決方法,第一種是手動修改 RDLC XML 加上欄位:

<DataSets>
<DataSetName="DataSet1">
<Query>
<DataSourceName>RDLCTestModels</DataSourceName>
<CommandText>/* Local Query */</CommandText>
</Query>
<Fields>
<FieldName="UserId">
<DataField>UserId</DataField>
<rd:TypeName>System.String</rd:TypeName>
</Field>
<FieldName="SkillName">
<DataField>SkillName</DataField>
<rd:TypeName>System.String</rd:TypeName>
</Field>
<FieldName="Lang">
<DataField>Lang</DataField>
<rd:TypeName>System.String</rd:TypeName>
</Field>
<FieldName="Level">
<DataField>Level</DataField>
<rd:TypeName>System.Int32</rd:TypeName>
</Field>
<FieldName="IsSkill">
<DataField>IsSkill</DataField>
<rd:TypeName>System.Boolean</rd:TypeName>
</Field>
<FieldName="IsLang">
<DataField>IsLang</DataField>
<rd:TypeName>System.Boolean</rd:TypeName>
</Field>
</Fields>
<rd:DataSetInfo>
<rd:DataSetName>DynamicDataTable</rd:DataSetName>
<rd:TableName>Data</rd:TableName>
</rd:DataSetInfo>
</DataSet>
</DataSets>

或者先跑程式取得 DataTable 再匯出 XSD 也成,我選擇手工修改 RDLC XML 了事。

就醬,再度靠著奧步驚險過關~(煙)

Dictionary 多執行緒存取衝突吃光 CPU

$
0
0

這是一個老鳥失足,程式沒寫好吃光 CPU 的故事。開始前推薦大家兩篇先修知識:

接獲通報,某主機在離峰時段出現 CPU 維持 50% 高檔狀況,來源則是某個 ASP.NET AppPool Process。依照 SOP,先擷取 Memory Dump 後再重啟 AppPool。(提醒:建立 Dump 檔前需確認 AppPool 是 32 位元還是 64 位元,若為 32 位元需改用 32 位元版 TaskManager,32 位元版 TaskManager 位於 C:\Windows\SysWOW64\taskmgr.exe,執行後 Process 名稱應為「Task Manager (32bit)」。)

取得 DMP 檔後著手分析,上回體驗過 DebugDiag Tools 威力後就回不去了。一般案件調查,出動 DebugDiag Tools 足矣, DebugDiag Analysis Report 點幾下滑鼠報告出爐,簡單明瞭直搗黃龍,至於 WinDbg,就留待密室殺人等級玄案再上場。而本次案例,更讓我對 DebugDiag Analysis Report 的分析能力讚嘆不已。

分析報告如上,發現兩個問題, Thread 26 跟 OracleInternal.SelfTuning.OracleTuner.DoScan()/Sleep() 有關,研判是 Oracle 監控機制,而 Sleep 不會是 CPU 飆高原因,在此忽略。把焦點放在 Thread 20,DebugDiag 給的說明是:

Multiple threads enumerating through a collection is intrinsically not a thread-safe procedure. If the dictionary object accessed by these threads is declared as static then the threads can go in an infinite loop while trying to enumerate the dictionary if one of the threads writes to the dictionary while the other threads are reading\enumerating through the same dictionary. You may also experience High CPU during this stage. For more details refer to High CPU in .NET app using a static Generic.Dictionary
意思是多執行緒存取 static 集合時,若其中一條執行緒試圖寫入 Dictionary,而其他執行緒正好要讀取或列舉同一個 Dictionary,有可能導致無窮迴圈吃光CPU,結尾並附上 Debugger Lady Tess 的部落格文章詳解。

報告裡列出耗用最多 CPU 時間的兩條 Thread 是 20 跟 21:

Top 5 Threads by CPU time
Note - Times include both user mode and kernel mode for each thread
Thread ID: 21
    Total CPU Time: 1 day(s) 02:54:20.718        Entry Point for Thread: clr!Thread::intermediateThreadProc
Thread ID: 20
    Total CPU Time: 1 day(s) 02:54:20.390        Entry Point for Thread: clr!Thread::intermediateThreadProc
Thread ID: 14
    Total CPU Time: 00:00:01.468        Entry Point for Thread: clr!GCThreadStub
Thread ID: 12
    Total CPU Time: 00:00:01.343        Entry Point for Thread: clr!GCThreadStub
Thread ID: 13
    Total CPU Time: 00:00:00.999        Entry Point for Thread: clr!GCThreadStub

而 Thread 20、21 的 Callstack 指向同一程式片段,也明確吻合 Tess 文章中所說的 Dictionary.FindEntry:

Thread 20:
mscorlib_ni!System.Collections.Generic.Dictionary`2[[System.Int32, mscorlib],[System.__Canon, mscorlib]].FindEntry(Int32)+4a
mscorlib_ni!System.Collections.Generic.Dictionary`2[[System.Int32, mscorlib],[System.__Canon, mscorlib]].ContainsKey(Int32)+a
BLAH.ViewModels.UIText.GetInstance(System.Globalization.CultureInfo)+5f
BLAH.ViewModels.UIText.GetInstance(Int32)+51
BLAH.ViewModels.UIText.GetInstance(BLAH.LangOption)+3a
BLAH.Client.ClientAgent.GiveFreestyle(System.String, System.String, System.String, System.String, System.String, System.String)+19ac
BLAHWeb.Controllers.ClientController+<>c__DisplayClass4_0.b__0()+67

Thread 21:
mscorlib_ni!System.Collections.Generic.Dictionary`2[[System.Int32, mscorlib],[System.__Canon, mscorlib]].Insert(Int32, System.__Canon, Boolean)+131
mscorlib_ni!System.Collections.Generic.Dictionary`2[[System.Int32, mscorlib],[System.__Canon, mscorlib]].Add(Int32, System.__Canon)+10
BLAH.ViewModels.UIText.GetInstance(System.Globalization.CultureInfo)+a7
BLAH.ViewModels.UIText.GetInstance(Int32)+51
BLAH.ViewModels.UIText.GetInstance(BLAH.LangOption)+3a
BLAH.Client.ClientAgent.GiveFreestyle(System.String, System.String, System.String, System.String, System.String, System.String)+1806
BLAHWeb.Controllers.ClientController+<>c__DisplayClass4_0.b__0()+67

引用文章的說法:What is happening here, and causing the high CPU is that the FindEntry method walks through the dictionary, trying to find the key.  If multiple threads are doing this at the same time, especially if the dictionary is modified in the meantime you may end up in an infinite loop in FindEntry causing the high CPU behavior and the process may hang. CPU 飆高發生在 FindEntry 方法遍巡 Dictionary 比對 Key 值的過程,當有多個 Thread 同時 FindEntry,若此時有 Thread 修改 Dictionary 內容,就可能讓 FindEntry 陷入無窮迴圈吃光 CPU。

兇手為以下這段程式碼,dict 為 static Dictionary 物件存放各語系專屬物件,GetInstance() 檢查是否已存在該語系物件,若存在則直接回傳,不存在再當場建立。由 Callstack 來看,Thread 20 是 ContainsKey 背後觸發 FindEntry,Thread 21 則是 Add 背後觸發 Insert,符合「Thread 查詢時另一 Thread 試圖變更 Dictionary 內容」情境,結局是二者都陷入無窮迴圈,四核主機被兩個 Thread 各吃掉一整核 CPU 25%,50% 運算能力陷入空轉。(想起之前也研究過 Dictionary Thread-Safe 議題,當時未發現還有無窮迴圈這種結局)

publicstatic UIText GetInstance(global::System.Globalization.CultureInfo culture)
        {
int lcid = culture.LCID;
if (!dict.ContainsKey(lcid)) 
            {
                dict.Add(lcid, new UIText(culture));
            }
return dict[lcid];
        }

修正方法很簡單,用 lock 將 ContainsKey()、Add() 到讀取內容包起來,限定同一時間只有一個 Thread 執行這段程式,杜絕存取衝突:(另一解法是改用 ConcurrentDictionary,改動幅度略大,詳情請參考前文

publicstatic UIText GetInstance(global::System.Globalization.CultureInfo culture)
        {
lock (dict)
            {
int lcid = culture.LCID;
if (!dict.ContainsKey(lcid))
                {
                    dict.Add(lcid, new UIText(culture));
                }
return dict[lcid];
            }
        }

事後想從 IIS Log 找到出事的 Request。分析報表有 Thread 的起始時間(Create Time),原本要以此時點定位:

Thread 20 - System ID 3492
Entry point clr!Thread::intermediateThreadProc
Create time 2017/9/2 下午 07:33:00

但想想不對,IIS Thread 建立後會放在 Pool 重複使用,其建立時間不等於導致無窮迴圈 Request 的時間,而更重要的一點,當 Request 陷入無窮迴圈,永遠沒有完成的一天,在 IIS Log 不會留下任何記錄。(Request 處理完成後才會寫 IIS Log,不然哪來的執行時間長度?)

說起來,這是一個多執行緒程式開發的低級錯誤,一旦宣告 static 就該確認所有存取動作在多執行緒環境不會出錯,尤其 ASP.NET Request 是不折不扣的多執行緒呼叫來源,要時時提高警覺。犯一次錯,學一次教訓,下回要加倍注意。

【延伸閱讀】集合物件的多執行緒存取注意事項

小筆記-避免 ThreadAbortException 的Response.End() 替代寫法

$
0
0

一個古老問題,在 ASP.NET 呼叫 Response.End() 會觸發 ThreadAbortException,假警報常會干擾偵錯與問題追查,之前寫過文章但沒整理完整的替代方案,今天補上筆記。

使用以下程式重現問題,WebForm 網頁包含一枚按鈕,按下時透過 AJAX 呼叫同一程式,Page_Load() 事件遇 Request["m"] == "ajax" 時 Response.Write() 傳回 Guid 並以 Response.End() 中止程式,避免傳回 HTML 內容:

<%@ Page Language="C#" %>
<scriptrunat="server">
void Page_Load(object sender, EventArgs e)
{
if (Request["m"]=="ajax") 
    {
        returnGuid();
    }
}
void returnGuid()
{
    Response.Write(Guid.NewGuid().ToString());
    Response.End();
}
</script>
<html>
<body>
<button type="button">Get GUID</button>
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script>
        $("button").click(function() {
            $.post("TestRespEnd.aspx", { m: "ajax" }).done(function(res) {
                alert(res);
            });
        });
</script>
</body>
</html>

如下圖所示,即使程式可正確執行,當使用 Visual Studio偵錯時 Response.End() 會觸發 ThreadAbortException 造成中斷。若 Response.End() 被 try catch包覆,則會進入 catch 流程。

前文所提,官方建議解法是改用 CompleteRequest() 但沒有交待細節。參考 stackoverflow 討論,找到一則完整處理範例,步驟是先 Response.Flush() 將先前 Response.Write() 寫入內容傳回客戶端,接著設定 Response.SuppressContent = true 防止再傳回其他內容,最後使用 CompleteRequest() 略過 ASP.NET Pipeline 其他步驟直接跳至 EndRequest() 事件。程式範例中的 NoExceptionResponseEnd() 方法使用 HttpContext.Current.Response,可置於程式庫共用,不限定要寫進 WebForm .aspx.cs,使用時將 Response.End() 改成 NoExceptionResponseEnd() 並補上中止執行邏輯。

void Page_Load(object sender, EventArgs e)
    {
if (Request["m"]=="ajax")
        {
            returnGuid();
//Response.End()會停止Thread,不必煩惱後方還有邏輯
//若確定函式己中止Response,要防止後方程式繼續執行
return; 
        }
    }
 
void returnGuid()
    {
        Response.Write(Guid.NewGuid().ToString());
        NoExceptionResponseEnd();
    }
 
publicvoid NoExceptionResponseEnd()
    {
//https://stackoverflow.com/a/22363396/288936
//將Buffer中的內容送出
        HttpContext.Current.Response.Flush();
//忽視之後透過Response.Write輸出的內容
        HttpContext.Current.Response.SuppressContent = true;
//忽略之後ASP.NET Pipeline的處理步驟,直接跳關到EndRequest
        HttpContext.Current.ApplicationInstance.CompleteRequest(); 
    }

不過有一點要特別留意:NoExceptionResponseEnd() 與 Resonse.End() 最大的差異在於 Response.End() 會中止目前的執行緒,故 Response.End() 之後的程式碼一定不會被執行;NoExceptionResponseEnd() 則不然,我們必須自行中止程式。在本例中可藉由 return 退出避免執行 Page_Load() 下半段程式,如果忘了 return 而後面又試圖輸出 Response 就會出錯(如下圖),更麻煩的一種狀況是 Response.End() 改用 NoExceptionResponseEnd() 後忘了自行中止程式,執行了原本不該執行更新資料庫或寫檔案動作,有可能破壞商業邏輯,故換掉 Reponse.End() 時務必要謹慎。

另外有一種狀況是 Response.End() 寫在外部函式裡,在特定條件下才會中止 Response,原本函式使用 Reponse.End() 呼叫端不需煩惱是否繼續執行下去(反正Response.End()後程式就停了),改用 NoExceptionResponseEnd() 後則要加上判斷,一個簡單做法是依Response.SuppressContent 決定是否繼續。

if (Request["m"]=="ajax")
        {
            returnGuid();
//如果不確定函式內部是否己中止Response
//可透過Response.SuppressContent簡易判斷
if (Response.SuppressContent)
return; 
        }

簡單總結:

  • 使用 Response.Flush() + 設定 Response.SuppressContent + CompleteRequest() 可以模擬 Response.End() 並避免觸發 ThreadAbortException。
  • Reponse.End() 與替代做法最大的差異是 Response.End() 會中止執行緒,我們不用費心後面的程式碼還要不要執行。改掉 Response.End() 時務必謹慎檢查,避免觸發原本不該執行的程式邏輯。
  • 若外部函式原本在某些情況下會 Response.End(),呼叫端可檢查 Response.SuppressContent 簡易判斷決定是否繼續。

小技巧-當集合型別不支援 LINQ 擴充方法

$
0
0

用慣 LINQ 後不太能忍受回頭用 foreach 處理集合物件,List<T>、IEnumerable<T> 及物件陣列可直接 Where()、Select() ,基本上涵蓋大部分應用情境,但有些時候還是會遇到一些不支援 LINQ 擴充方法的集合物件。這篇筆記將介紹透過簡單轉換讓集合支援 LINQ 的小技巧。(別像我以前傻傻先 var list = new List<T> 再 foreach 跑 list.Add(…) )

舉兩個我遇過的例子。

有字串 A12+A34+B99-C56+A87,打算用 Regex.Matches 挑出 A 起首的數字代碼,用 Select().ToArray() 轉成字串陣列 "12","34","87"。

有 app.config 如下,我想透過 System.Configuration.ConfigurationManager.AppSettings 將 d:SvcIp d:SvcPort 設定用 ToDictionary() 轉成 Dictionary<string, string>。

<?xmlversion="1.0"encoding="utf-8" ?>
<configuration>
<appSettings>
<addkey="d:SvcIp"value="192.168.1.87"/>
<addkey="d:SvcPort"value="80"/>
<addkey="Interval"value="5000"/>
</appSettings>
</configuration>

依直覺寫出以下程式碼,但無法編譯,理由是 MatchCollection 跟 NameValueCollection 兩種集合型別不適用 LINQ 擴充方法。

.Select()是 IEnumerable<TSource> 的擴充方法,而 MatchCollection 只實作了 ICollection 與 IEnumerable 介面,故無法直接使用 Select、Where、ToList 等 LINQ 擴充方法:

.Cast<T>()是 IEnumerable 的擴充方法,可將 IEnumerable 轉換成 IEnumerable<TResult>,如此即可大方使用 LINQ 方法加工。

NameValueCollection 是另一種案例,它繼承自 NameObjectCollectionBase,NameObjectCollectionBase 雖然實作了 IEnumerable,但其 GetEnumerator() 傳回 IEnumerator 只能取回 Name 字串,充其量 Cast<string> 跟 AllKeys 相比多此一舉,故我們改對 AllKeys(型別為 string[],陣列型別可使用 LINQ 擴充方法)進行 Select 產生 Key Value 物件,再依原本構想進行 Where() 篩選與 ToDictionary() 轉換。

補充一點,appSettings 不接受同一 key 值設定多次,遇重複 key 時以最後一次 value 為準,故 appSettings 設定永遠是一對一。但 NameValueCollections 本身允許同一 key 值對應多組 value,透過 Get("key")取得 "value1,value2,value3" 以逗號分隔的字串,GetValues("key")則傳回 "value1", "value2", "value3" 字串陣列,如要轉換成 Dictionary<string, string>需考量此一狀況。

最後再來個練習,DataTable.Rows 的型別為 DataRowCollection,其父類別 InternalDataCollectionBase 實作了 ICollection, IEnumerable,跟 MatchCollection 狀況相同,解法也一樣,Cast<DataRow> 後可使用 Select(),若想套用我很愛的 ForEach() 方法,則用 ToList() 轉型成 List<DataRow> 即可如願~ 〔註: ForEach() 非 IEnumerable<T> 的擴充方法,為 List<T> 的專屬方法〕

大型物件 Json.NET 序列化經驗一則

$
0
0

附檔管理模組裡採用 JSON 格式保存暫存物件,將附檔物件序列化暫存成檔案,稍後寫入資料庫時再還原取出資料,直覺又方便。不料因附檔物件內含檔案內容(byte[])體積龐大,在處理極端案例時踢到記憶體不足的鐵板。

批次作業程式為 32 位元模式,依經驗記憶體上限約 1.8 GB,一開始很直覺地將資料用 JsonConvert.SerialObject() 轉成 JSON 字串再用 File.WriteAllText() 寫成檔案,之後用 File.ReadAllText() 讀取 JSON 字串,再以 JsonConvert.DeserializeObject<T>() 還原回物件:

  • 寫入
    File.WriteAllText(tempFileName, JsonConvert.SerializeObject(attFile));
  • 讀取
    var attFile = JsonConvert.DeserializeObject<AttachmentFile>(File.ReadAllText(filePath, Encoding.UTF8));

這個寫法處理 50 MB 大小的檔案不成問題,但在處理一個 86MB 檔案時(轉為 JSON 約 116MB) 冒出 OutOfMemoryException 錯誤。實測單獨讀檔並 JsonConvert.DeserializeObject() 可過關,加上其他執行邏輯消耗更多記憶體就爆了。

最簡單的解法是將程式改為 64bit 模式,在我的 16GB 機器上可用記憶空間可放大二、三倍以上。但靠加大記憶體空間鋸箭,在正式環境多人使用時效能堪慮,設法減少非必要的記憶體用量才是治本之道。

依 JSON.NET 官方建議,改用 Stream 方式可節省記憶體: (參考: Performance Tips)

To minimize memory usage and the number of objects allocated, Json.NET supports serializing and deserializing directly to a stream. Reading or writing JSON a piece at a time, instead of having the entire JSON string loaded into memory, is especially important when working with JSON documents greater than 85kb in size to avoid the JSON string ending up in the large object heap.

將讀取程式修改如下,順利通過 116MB JSON 測試:

using (var streamReader = new StreamReader(filePath))
{
using (var reader = new JsonTextReader(streamReader))
    {
        var serializer = new JsonSerializer();
return serializer.Deserialize<AttachmentFile>(reader);
    }
}

好景不常,不久更上層樓,遇上 212MB 檔案,換成序列化為字串再寫檔的做法爆了,也得修改:

using (StreamWriter sw = new StreamWriter(tempFileName))
{
using (JsonTextWriter writer = new JsonTextWriter(sw))
    {
       var serializer = new Newtonsoft.Json.JsonSerializer();
       serializer.Serialize(writer, attFile);
    }
}

心得: 處理大型資料物件 JSON 轉換,改用 Stream 方式讀寫檔案較節省記憶體,提升效能。

【後記】雖然改走 Stream 通過 200MB 大檔的序列化反序列化考驗,究竟我還是回頭檢討了超大附檔的必要性,最後透過 PDF 解析度調整將檔案縮小到 40MB 以下,別逼系統超越顛峰,以免翻車墜崖。


使用 DebugDiag Tools 分析 ASP.NET 站台記憶體耗盡問題

$
0
0

同事報案,某測試站台不定期會發生 OutOfMemeoryException 記憶體不足錯誤,接獲通報立刻趕往事故現場,問題網站已吃掉 1.8GB 記憶體,差不多是 32 位元模式可用記憶體的上限。廢話不多說,開啟 32 位元工具管理員(C:\Windows\SysWOW64\TaskMgr.exe 參考) 擷取 Memory Dump 檔。

從工具箱搬出 CPU/Memory 茶包分析的小型戰術核武 - DebugDiag 2 Tools,之前處理的都是 CPU 滿載案例,記憶體用盡分析倒是頭一遭。選用 DotNetMemoryAnalysis - Managed Memory Analysis。(另一個選項 MemoryAnalysis – Memory analysis including LeakTrack and heap reporting 主要用於連取多個 Dump 檔抓漏,而本案例重點在找出記憶體被誰用掉)

1.8GB 檔案不小,看了一陣子進度條,取得 DebugDiag Analysis 分析報告:

身為記憶體問題生手,只能約略看出大概,問題有三:(第四點測試台開 debug 天經地義,可忽略)

  1. Dump 檔載入了 1023 個動態組件
    分析報表貼心附上 Debugger Laddy, Tess 的文章,指出有可能與 XML 元件 Memory Leak 有關。
    -NET Memory Leak- XslCompiledTransform and “leaked” dynamic assemblies – If broken it is, fix it
    -NET Memory Leak- XmlSerializing your way to a Memory Leak – If broken it is, fix it you should
  2. 有 75721 個物件等待 Finalization
  3. Process 中有超過 3 個 AppDomains

關於第三點,我在 AppDomain 統計表找到明顯異常,從 9/14 晚上 11 點起,每隔幾秒就冒出一個 Domain,載入 18 個組件,組件大小 74MB,數量高達 1022 個,數字似乎與第 1 點的 1023 個動態組件呼應。

將資料用 Excel 重排,確認 1022 個 AppDomain 的執行時間從 9/14 23:28:04 到 9/15 00:57:49。

一般 ASP.NET 程式很少會另建 AppDomain 跑程式,對照 IIS Log,很幸運是測試台又在冷門時段 Log 很乾淨,發現有個 RDLC 報表匯出 PDF 的排程從 23:28 左右開始執行,大約送出了 2500 次 Request,於 00:54:58 起出現 HTTP 500,時間相當吻合。(推測原因就是記憶體用盡)

依據文件(Expression Evaluation in Local Mode – Brian Hartman's Report Viewer Blog),RDLC 會在沙箱 AppDomain 中執行,而文末提到了 VS2010 版 ReportViewer 曾存在 AppDomain Leak Bug(但後來已修復),AppDomain 爆增極有可能是 RDLC 報表引起的。網站使用的版本 ReportViewer 2012 (ver 11),推測已無文章中所提  Bug,但我想起上回研究 RDLC 子報表效能時學到「子報表會另起 Instance 執行」,同事補上同一組程式在正式環境執行未傳出記憶體問題,進一步檢查發現問題主機的報表版本有使用子報表,而正式台已改用 List 奧步版,如此看來子報表極有可能就是嫌犯。將測試台 RDLC 也換成非子報表版本後有一段時間未再觀察記憶體爆表狀況,看來子報表是凶手的嫌疑極高,若之後有新發現,再做追蹤報導。

看完 DebugDiag Tools 在本案例強大的火力展示,讚嘆之餘,照慣例又到了呼口號時間:

DebugDiag Tools 好威呀!

【茶包射手日記】新增檔案後卻無權限修改

$
0
0

一個詭異的狀況。

為了在測試網站測網頁,我透過該主機的網路分享建立一個 HTML 檔案,測完一回,調整程式想存檔時 Notepad++ 彈出以下訊息:

這是 Notepad++ 寫入檔案存取被拒的貼心 SOP,當寫入本機檔案遇到權限不足,改用管理者身分多可克服;但本案例檔案來自網路分享資料夾,切換成本機管理者也無濟於事。但這個訊息指出一項事實 - 我沒權限覆寫剛剛才建立的檔案!新增檔案後卻不能修改是什麼奇妙的 NTFS 設定?

反覆檢查網路分享跟 NTFS 的權限設定,甚至改成 Everyone 完全控制,還是無法寫入。

更扯的事情來了,反覆測試,有幾次不知怎麼忽然可以成功寫入,但多測幾次又存取被拒,讓我懷疑問題不在 NTFS 權限設定,而是有其他因素造成,一個常見原因是被檔案被其他程式開啟鎖定造成無法更新,當訊息不明確時容易跟權限不足搞混。

終於,我找到問題來源 - 是檔案總管的預覽功能搞鬼!

原來,要編輯檔案時我習慣在檔案總管先點 Test.html 再按右鍵呼叫 Notepad++ 編輯檔案,因為預覽功能開啟,預覽視窗會讀取 Test.html 並顯示網頁內容,還一併鎖定 Test.html 不允許其他程式修改,造成我遇到的無權限寫入現象。此時只要改選其他檔案或空白處讓 Test.html 從預覽視窗消失,就能正常覆寫了。

將同樣的操作拉回本機資料夾演練,發現預覽功能並不會防礙檔案寫入,而且還會偵測到檔案被修改重新產生預覽畫面,故此一狀況似乎只發生在網路磁碟機情境。不過,未來如遇到 PDF、Excel、Word 等可預覽檔案出現無權修改的詭異狀況,我會優先檢查是不是預覽功能作祟。

【答客問】ClosedXML 日期資料解析測試

$
0
0

網友 Danny 在舊文留言提問關於 NPOI 讀取 Excel 日期,"2017/9/23"被轉成"23-九月-2017"的問題,我已棄用 NPOI 投向新歡 ClosedXML多年,沒打算再花時間研究,於是題目改成: 面對相同文件,ClosedXML 能否順利過關?

取得 Danny 提供的測試樣本,挺有趣的,共有四欄(F、G、N、AS)包含日期資料,第一列有欄名分別為A6、A7、A14與A45:

四欄儲存格格式各有千秋,A6 設成自訂 yyyy/mm/dd:

A7 設為文字,其中包含 1953.9.12、1968/1/13、1953/09/12 甚至 1953912 ,格式很混亂:

A14、A45 設為日期,取格式 2012/3/14:

測試程式如下,為因應 Excel 中五花八門的日期格式,我寫了一個 ParseDateValue() 接收 IXLCell 進行解析。若 cell.DataType == XLCellValues.DateTime 就直接回傳 cell.GetDateTime() 並將儲存格改為藍字;若否,則取得 cell.GetString() 再以 DateTime.TryParseExact() 配合預先定義的格式嘗試解析。(註: yyyyMd 格式存在爭議,例如: 2012111 可能是 1/11 也可能是 11/1,視為無效)

class Program
    {
staticvoid Main(string[] args)
        {
            var path = "D:\\日期格式.xlsx";
using (var wb = new XLWorkbook(path))
            {
                var ws = wb.Worksheets.First();
                var r = 2;
while (!ws.Row(r).Cell("F").IsEmpty())
                {
                    var row = ws.Row(r);
                    var a6 = ParseDateValue(row.Cell("F"));
                    var a7 = ParseDateValue(row.Cell("G"));
                    var a14 = ParseDateValue(row.Cell("N"));
                    var a45 = ParseDateValue(row.Cell("AS"));
                    Console.WriteLine(
$"A6:{a6:yyyy-MM-dd} A7:{a7:yyyy-MM-dd} A14:{a14:yyyy-MM-dd} A45:{a45:yyyy-MM-dd}");
                    r++;
                }
                Console.ReadLine();
using (FileStream fs = new FileStream("d:\\Output.xlsx", FileMode.Create))
                {
                    wb.SaveAs(fs);
                }
            }
        }
 
static DateTime? ParseDateValue(IXLCell cell)
        {
if (cell.DataType == XLCellValues.DateTime)
            {
                cell.Style.Font.FontColor = XLColor.Blue;
return cell.GetDateTime();
            }
            var dateString = cell.GetString();
            DateTime d;
            var dateFormats = "yyyy/M/d,yyyy/M/d,yyyy/MM/dd,yyyy.M.d".Split(',');
foreach (var fmt in dateFormats)
            {
if (DateTime.TryParseExact(dateString, fmt, null,
                    DateTimeStyles.None, out d))
                {
                    Debug.WriteLine($"Custom Format: {fmt} for {dateString}");
return d;
                }
            }
            Debug.WriteLine($"無法識別:{dateString}");
returnnull;
        }
    }

執行結果如下:

 

除 1953912 之外,其餘日期值均被正確解讀。A3 欄儲存格採自訂格式 yyyy/mm/dd、A14 及 A45 欄採日期格式,如上圖所示,ClosedXML 均視為 XLCellValues.DateTime 故變成藍字,唯一的例外是 A14 欄第一筆 1985/7/26,原因是它前方加了單引號(如下圖所示)將其宣告為字串,故要改由 DateTime.TryParseExact() 解析。

至於 A7 儲存格格式為文字,需取回字串再自行轉換。

實驗完畢,證實 ClosedXML 可正確解讀日期格式不一的 Excel 文件,我則學到透過 DataType 屬性偵測資料型別的技巧。若想進一步 DataType 與 DateFormat/NumberFormat 的應用,可參考 ClosedXML 官方範例: Data Types · ClosedXML-ClosedXML Wiki · GitHub

Coding4Fun-試聽 16 進位字串表示的 MP3 內容

$
0
0

鹹蝦專案(利用閒暇經營的 Side Project)遇到的需求,先前把 MP3 音效資料整進 SQL 資料表轉成 IMAGE 資料型別,查詢起來像這樣:

如果我想試聽這段聲音該怎麼辦? 網路上可以找到一些 T-SQL 範例,將 SQL 裡的二進位資料匯出成檔案。不過這樣子每次試聽的步驟有點麻煩: 用 SELECT 取得某一筆 IMAGE 內容 -> 以內容及檔名為參數呼叫 Stored Procedure -> 在檔案總管點選檔案試聽。

我心中理想的操作方式是: SELECT 選出數筆內容 -> 選出想試聽的內容貼到「試聽工具」輸入區 -> 試聽工具自動播放 MP3 內容。像這個樣子:

程式透過 jQuery paste 事件偵測 <textarea> 被貼上新內容,自動將 16 進位字串送到後端解析成 byte[] 存入 Cache,之後由前端 <audio>取回 MP3 內容播放,一氣喝成,連我自己都覺得帥氣~ 特發文一篇紀念。

<%@ Page Language="C#" %>
<%@ Import Namespace="System.Runtime.Caching" %>
<scriptrunat="server">
protectedvoid Page_Load(object sender, EventArgs e)
    {
var mode = Request["mode"];
if (mode == "parse")
        {
var hex = Request["hex"];
try
            {
if (string.IsNullOrEmpty(hex) || hex.IndexOf("0x") != 0)
                {
thrownew ApplicationException("Invalid hex data");
                }
                hex = hex.Substring(2); //remove 0x
var data = newbyte[hex.Length / 2];
for (var i = 0; i < data.Length; i++)
                {
                    data[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
                }
var token = Guid.NewGuid().ToString();
                MemoryCache.Default.Add(token, data, new CacheItemPolicy()
                {
                    AbsoluteExpiration = DateTime.Now.AddSeconds(30)
                });
                Response.Write($"OK:{token}&len={data.Length}");
            }
catch (Exception ex)
            {
                Response.Write($"Error: {ex.Message}");
            }
            Response.End();
        }
elseif (mode == "download")
        {
var data = MemoryCache.Default[Request["token"]] asbyte[];
            Response.ContentType = "audio/mpeg";
            Response.BinaryWrite(data);
            Response.End();
        }
    }
</script>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Play MP3 hex data</title>
</head>
<body>
<div>
<button>Play</button>
<audio controls="true" autoplay="true"></audio>
</div>
<textarea rows="10" cols="80"></textarea>
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script>
        $("button").click(function() {
            $.post("PlayMP3Hex.aspx", { mode: "parse", hex: $("textarea").val() })
                .done(function(res) {
if (res.indexOf("OK:") === 0) {
                        $("audio").attr("src",
"PlayMP3Hex.aspx?mode=download&_t=" +
                            Math.random() +
"&token=" +
                            res.substr(3));
                    } else {
                        alert(res);
                    }
                });
        });
//貼上內容後0.1秒自動解析試聽
        $("textarea").bind("paste", function() {
            setTimeout(function() {
                    $("button").click();
                },
                100);
        });
</script>
</body>
</html>

【茶包射手日記】分離式冷氣出風無冷度故障經驗

$
0
0

小木頭跑來報案,房間冷氣完全不冷。

前陣子蝦皮免運時失心瘋採購一批儀器,把居家度量衡全面電子化(左起 PH 酸鹼計,土壤濕度酸鹼日照三用錶,電子游標卡尺,紅外線測溫槍),這回輪測溫槍派上用場了。

冷氣機出風強弱、運轉模式、溫度調整等操作一切正常,唯一有問題是即使溫度調到 20 度,出風口溫度還是 30 度,跟室溫相同,毫無冷度可言。先做好基本功,洗了濾網(就像作業系統有問題要先做 Windows Update是一樣道理),無效。

翻箱倒櫃找出保證書,摸摸鼻子上網報修順便爬文,看到好多用沒幾年花五千一萬修理的鬼故事,頓時心涼了半截... 但在某篇文章看到關鍵字心生一計,找到配電箱關電重開機,它竟然就好了就好了就好了。不禁感嘆,「3R大絕」真是工作除錯居家生活不可或缺的 3C 技巧啊!

好景不常,隔日小木頭又上門報案說冷氣不涼。故技重施,再次恢復正常。但依多年茶包射手的直覺,心頭有不祥預感,果不其然,一個多小時後接獲第三度報案,自此關電重開密技完全失靈。玩票性質的故障排除人員乖乖閃開,交給專業的來。

等到上班日,原廠來電約時間前來維修。第一次來的技師先生拿著遙控器按了上下上下左右左右BA幾下(依女王轉述,猜想是某種偵錯密技讓冷氣機顯示故障代碼),觀察冷氣運轉狀態又從陽台去摸摸室外機,判定室內機沒問題,可能是冷媒洩漏或室外機機板故障,但室外機作需要裝配,需另約工班處理。隔天約好時間來了另一位技師先生,工具箱、零件、梯子裝配齊全,感覺前一天是先遣偵察兵,今天輪裝甲兵登場。做完簡單檢測,技術爬出去拆開室外機檢查,猜測是室外室感溫器故障。更換零件後,冷氣立即恢復正常,收費兩千大洋。(光是能從樓梯間氣窗爬出去這招就值了,修冷氣這行高個子跟胖子還做不來哩)

室外機的玄機要當蜘蛛人才能實地觀察,我好奇又懶得動手,爬文找到室外機內部照片(一起小強入侵造成故障的案例,另外還找到壁虎肇事燒壞兩台室外機機板的新聞),不過倒沒找到感溫器位置的照片。

室內機感溫器故障不冷比較容易理解,室溫感測錯誤就不會啟動壓縮機。但,室外機為何也需要感溫器? 又為何室外感溫器故障會導致「冷氣出風如常但沒有冷度」?

爬文找到一些線索

定頻冷氣室內機有二個感溫器 : 1.室溫感溫器  2. 化霜感溫器
定頻冷氣室外機沒有感溫器

變頻冷氣室內機有二個感溫器 : 1.室溫感溫器  2. 化霜感溫器
變頻冷氣室外機有五個感溫器 : 1. 壓縮機吐出溫感溫器 2.凝縮器感溫器 3. 室外溫感溫器 4. 高壓管(細管)感溫器 5. 低壓管(粗管)感溫器

在另一篇變頻空調機簡易檢修文則解釋了室外機感溫器的用途

  1. 室外環境溫度感測器(安裝在室外機冷凝器,由塑膠件支撐)
    A)室外溫度過低或過高時系統自動保護
    B)製冷或制熱時用於控制室外送風機風速
  2. 室外盤管溫度感測器(安裝在室外機散熱器管路)
    A)制熱時用於室外機除霜
    B)製冷或制熱時用於過熱保護或防凍結保護
  3. 室外壓縮機排氣溫度感測器(安裝在室外壓縮機排氣管)
    A)壓縮機排氣管溫度過高時系統自動進行保護
    B)在變頻空調機中用於控制電子膨脹閥開啟度以及壓縮機運轉頻率的升降。

有了這些線索,鍵盤柯南登場:兇手是「室外壓縮機排氣溫度感測器」(指)

我推測的理由是: 若感溫故障導致 1A/2A/2B/3A 屬異常主機應會收到訊號,可由遙控器或顯示面板得知狀態;而 1B 控制室外機送風不致影響冷度;至於 3B 是變頻省電關鍵,由室外機排氣溫度與設定溫度差異控制壓縮機功率高低。假設以下情境,感溫器故障但仍能傳回訊號,只是送回的是錯誤數據,例如: 10 度。程式判斷此溫度比設定的 20 度還要低,壓縮機不轉也夠冷,導致冷氣機未發現感溫故障正常出風,並產生自己正把室外10 度低溫(其實是30度)源源不絕往室內輸送的錯覺。這是我想到冷氣機未偵測到感溫器故障又毫無冷度的一個好解釋,不過一切全是猜想,就當成某個資訊領域茶包射手在空中比畫兩下,就以為自己解開了冷氣機不冷之謎的妄想吧~ 哈。(如果有專業人士路過,再請不吝開示)

總之,冷氣終於有冷氣的樣子了,讚! (實測發現,目標溫度設定 26 度時,室內機出風口內部最低可測得 14 度低溫)

Viewing all 428 articles
Browse latest View live