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

在單元測試專案使用 dynamic 出現 CSharpArgumentInfo.Create 錯誤

$
0
0

在自動測試專案加入使用 dynamic 型別的測試方法後,Visual Studio 2017 傳回編譯錯誤:

Missing compiler required member 'Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo.Create'

在 Microsoft Connect 查到相似錯誤回報,案例集中在微軟單位測試及 NUnit 測試專案(NUnit 可透過更新版本解決),推測為單元測試專案預設未參照 Microsoft.CSharp.dll,而它是使用 dynamic 的必要參照。

問題在為單元測試專案手動加入參照後排除(如下圖),特此筆記。


【茶包射手日記】Visual Studio 編譯自動帶入相依 DLL 問題

$
0
0

同事報案,在 Visual Studio 從私有 NuGet 伺服器安裝我寫的共用元件,該元件參照了 Managed ODP.NET 但沒在 NuGet Package 宣告相依性,理論上不會一併安裝 Managed ODP.NET NuGet Package,但同事發現建置後 bin 目錄卻神奇地出現 Oracle.ManagedDataAccess.dll。試著在我的電腦演練相同操作,bin 目錄並不會出現 Oracle.ManagedDataAccess.dll!很明顯這又是我不了解的「魔法」,啟動調查,試著找出 Visual Studio 自動帶出相關 DLL 的原理。

起初我懐疑有某個聰明的外掛套件從中幫忙,自動補齊該共用元件需要的第三方程式庫,Visual Studio 改用安全模式也是同樣結果,不在場證明 GET,無保請回。

接著我懐疑跟註冊 GAC 有關,推測同事的 Managed ODP.NET 有註冊 GAC,Visual Studio 能成功找到 DLL 寫入 bin,我的沒註冊 GAC 不知去哪裡找 DLL,補不了檔案。

不料,檢查 GAC C:\Windows\assembly\GAC_MSIL\Oracle.ManagedDataAccess 資料夾的結果出乎意料,我的機器有同事沒有,意味我有註冊 GAC 而同事沒有,跟前面的推論完全相反啊啊啊~(啪!我聽到清脆的打臉聲,Orz)

別怕,拎杯機靈勝過尚書大人,立刻找到合理新解釋:因為我的 Managed ODP.NET 已註冊 GAC,所以不需要複製到 bin!(咳,「事前信心十足大膽預測,事後又能娓娓道來為何失準」是「偽專家」的必要技能,我真不愧是見過大風大浪的老屁股呀)

但這裡有個問題:如果同事沒有註冊 Managed ODP.NET,Visual Studio 是怎麼找到 Oracle.ManagedDataAccess.dll 放進 bin 裡?

這類疑難雜症,交給茶包一哥 Process Monitor就對了!

開啟 Process Monitor 監控專案建置過程的 Registry 與檔案存取,我學到一件事:建置作業由 MSBuild.exe 負責,它會先搜尋共用元件所在目錄看 Oracle.ManagedDataAccess.dll 有沒有跟它放在一起(在本例為 NuGet Packages 目錄,但我沒有包進去),若沒找到 MSBuild 會接著尋找 HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\.NETFramework\v4.0.30319\AssemblyFoldersEx\ Regitry,同事在該機碼下有個 Oracle.ManagedDataAccess 指向 Oracle.ManagedDataAccess.dll,MSBuild 就靠著它找到 DLL 並複製到 bin 目錄下。

[HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\.NETFramework\v4.0.30319\AssemblyFoldersEx\Oracle.ManagedDataAccess]
@="E:\\Oracle\\odp.net\\managed\\common\\Oracle.ManagedDataAccess.dll"

(我的電腦在 AssemblyFoldersEx 也有機碼指向 Managed ODP.NET,但名稱不同,叫 odp.net.managed,如下圖)

補充:AssemblyFoldersEx 是 Visual Studio/MSBuild 尋找第三方元件的依據,參考:如何在32/64bit環境讓Visual Studio加入參考時可在.NET頁籤瀏覽自己的元件

AssemblyFoldersEx 機碼可解釋為何同事沒註冊 GAC 也可以找到 Managed ODP.NET DLL,下一步是驗證 GAC 是否與 DLL 會不會複製到 bin 有關?用 gacutil /u Oracle.ManagedDataAccess.dll 將其從 GAC 移除,再測試我的電腦 bin 還是沒出現 Oracle.ManagedDataAccess.dll,代表「有註冊 GAC 所以不用複製到 bin」的推測又錯了…(啪!啪!)

最後,老老實實比對同事跟我的 Process Monitor 記錄,同事的狀況是從 AssemblyFoldersEx 查到路徑找到檔案就複製到 bin 下(如下圖),我則是 AssemblyFoldersEx 找到路徑尋獲 DLL 檔,又繼續去 GAC \Windows\assembly\GAC_MSIL\Oracle.ManagedDataAccess 尋找 DLL 檔,重點是都有找到檔案,但就是沒將檔案複製到 bin。

這讓我有個想法:莫非問題出在版本不符?檢查共用元件參照的 Managed ODP.NET 版本為 4.121.2.0,同事 AssemblyFoldersEx\Oracle.ManagedDataAccess Registry 指向的版本也是 4.121.2.0,
而我的 AssemblyFoldersEx 與 GAC 指向的版本則是 4.121.1.0,並不是共用元件要求的版本。

BINGO!

將 AssemblyFoldersEx 指向位置的版本換成 4.121.2.0,我的電腦編譯後 bin 就出現 Oracle.ManagedDataAccess.dll 了,全案宣告偵破!

歸納本案調查心得:

  1. Visual Studio 建置背後靠 MSBuild 完成
  2. MSBuild 與 Visual Studio 會藉由 SOFTWARE\Microsoft\.NETFramework\v4.0.30319\AssemblyFoldersEx\ Registry 尋找第三方程式庫
  3. 若發現某 DLL 參照其他程式庫,MSBuild 會試著依 DLL 所在目錄、AssemblyFoldersEx、GAC 順序找尋所參照 DLL 並複製到 bin 目錄下
  4. 找尋 DLL 時版本必須完全一致,否則視同沒找到,既不複製也不會有錯誤訊息

【後話】

MSBuild 解析參照組件過程感覺有很多學問,想找篇深入探討文件解惑。在 MSDN 部落格上看到一篇序文如獲至寶,作者提到計劃寫完一系列共六篇文章深入剖析 MSBuild 及 Visual Studio 如何解析尋找 Assembly,讓我滿心期待一探究竟,很遺憾,作者 2010 年 5 月發願後就沒了下文,徒留幾篇敲碗留言… Orz

筆記:C# 6.0 自動實作屬性初始化與運算式主體定義

$
0
0

專案裡有個在父類別宣告 virtual List<string> MyProp { get; } = new List<string>(); ,接著在子類別 override MyProp, Visual Studio 2017 自動帶出 List<string> MyProp => base.MyProp; 。(術語為 Expression Body Definition 運算式主體定義)

子類別要傳回 "Prod1","Prod2" ,我差一點就接改成 List<string> MyProp  => "Prod1,Prod2".Split(',').ToList();,仔細想想不對,我的本意是要給 MyProp 屬性初始值,沿用 => 寫法變成每次產生新字串陣列,意義不同,差點搞錯。為此趕緊寫篇筆記壓壓驚!

來個範例:

using System;
 
publicclass Blah
{
    private Guid _prop1 = Guid.NewGuid();
public Guid Prop1
    {
get
        {
return _prop1;
        }
    }
public Guid Prop2 { get; } = Guid.NewGuid();
 
public Guid Prop3
    {
get
        {
return Guid.NewGuid();
        }
    }
public Guid Prop4 => Guid.NewGuid();
 
}
 
static void Main(string[] args)
{
    var blah = new Blah();
    Console.WriteLine($"Prop1={blah.Prop1},Prop2={blah.Prop2}");
    Console.WriteLine($"Prop1={blah.Prop1},Prop2={blah.Prop2}");
    Console.WriteLine($"Prop3={blah.Prop3},Prop4={blah.Prop4}");
    Console.WriteLine($"Prop3={blah.Prop3},Prop4={blah.Prop4}");
    Console.Read();
}

在以上範例中,Prop1 與 Prop2 意義相同,都是物件建立時給予屬性初始值,屬性值固定不變;Prop3 與 Prop4 則偏向動態性質,每次呼叫時都重新產生新值。其中 Prop2 與 Prop4 是 C# 6.0 起支援的新寫法,較傳統寫法簡潔。

實測結果如下,Prop1/Prop2 兩次呼叫結果相同,Prop3/Prop3 每次讀取都會拿到新的 GUID。

Prop1=9a818bfa-7789-4dc3-8eab-a1c526cdf6c3,Prop2=d09ae5a4-01e8-41fa-a888-b508c974e463
Prop1=9a818bfa-7789-4dc3-8eab-a1c526cdf6c3,Prop2=d09ae5a4-01e8-41fa-a888-b508c974e463
Prop3=76e8b7ac-0830-4715-9d78-6840e7d59b3e,Prop4=922858c2-294c-4178-8a74-bd7bef385eb2
Prop3=1af010c8-a926-4e17-ad77-83d481227171,Prop4=03a8ea07-cc09-42fc-b37d-77c9d6c087d6

【延伸閱讀】

擴充方法參數傳入 dynamic 型別出錯

$
0
0

呼叫擴充方法時傳入 dynamic 型別參數,發生以下錯誤:

'Blah' has no applicable method named 'ExtMethod' but appears to have an extension method by that name. Extension methods cannot be dynamically dispatched. Consider casting the dynamic arguments or calling the extension method without the extension method syntax.

使用以下範例可重現錯誤,ExtMethod 為擴充方法,傳入字串參數時正常,改傳 dynamic 型別即出現上述錯誤:

想起先前遇過類似狀況:【茶包射手日記】CSHTML ViewBag無法使用擴充方法,因 ViewBag 為 dynamic 型別無法使用擴充方法,解法很簡單,將 dynamic 轉型就好。在本案例,也是改寫成 b.ExtMethod((string)d) 就能解決問題。

這讓我聯想到另一個狀況,撇開擴充方法不論,多載(Overloading)方法會依參數型別呼叫不同方法,遇上參數型別是 dynamic 會發生什麼事?

依據 C# 程式規格,dynamic 本質上可視為多了部分特異功能的 object:

dynamic is considered identical to object except in the following respects:

  • Operations on expressions of type dynamic can be dynamically bound (Dynamic binding).
  • Type inference (Type inference) will prefer dynamic over object if both are candidates.

Because of this equivalence, the following holds:

  • There is an implicit identity conversion between object and dynamic, and between constructed types that are the same when replacing dynamic with object
  • Implicit and explicit conversions to and from object also apply to and from dynamic.
  • Method signatures that are the same when replacing dynamic with object are considered the same signature
  • The type dynamic is indistinguishable from object at run-time.
  • An expression of the type dynamic is referred to as a dynamic expression.

簡單來說,面多載抉擇時,把 dynamic 想成 object 就對了~

程式範例-使用 Json.NET 將 Key/Value 陣列轉為物件屬性

$
0
0

專案遇到的需求:程式接收來自外界的 JSON 資料,物件之各屬性內容以 KeyValuePair<string, string> 陣列儲存,序列化結果如下:

{
"modType": [
    {
"Key": "I",
"Value": "獨立模組"
    },
    {
"Key": "J",
"Value": "聯合模組"
    }
  ],
 
"source": [
    {
"Key": "I",
"Value": "內部"
    },
    {
"Key": "E",
"Value": "外部"
    }
  ],
 
"statusOption": [
    {
"Key": "0",
"Value": "停止"
    },
    {
"Key": "1",
"Value": "運轉"
    },
    {
"Key": "2",
"Value": "暫停"
    }
  ]
}

若依此資料結構,JavaScript 前端 MVVM 繫結特定項目時要寫成 model.source[1].Value 不夠直覺,希望改成 model.source.E。JavaScript 要將 Key/Value 陣列轉成物件屬性不是難事,jQuery.each() 一行可以搞定:
var obj={};$.map(model.source,function(item){ obj[item.Key]=item.Value;});

不過,傳送繁瑣 JSON 資料到前端再簡化,自然不如在 C# 端直接轉換優雅,而這對 Json.NET 來說是小菜一碟。

程式範例附於下方,簡單說明原理:先將 JSON 字串用 JObject.Parse() 反序列化成 JObject 物件,透過 JObject.Properties() 可逐一取得象徵各屬性的 JProperty 物件,JProperty.Name 為屬性名稱,JProperty.Value 則為屬性值,在本案例為 Key/Value 物件組成的陣列。透過 p.Value as JArray 轉為 JArray 後可 foreach 取得陣列元素。陣列元件可視為包含 Key/Value 兩個屬性的 JObject,可透過 item["Key"]/item["Value"] 取值。我們為每個屬性建立一顆專屬 JObject,將 Key/Value 陣列以 propObj.Add(propName, propValue) 轉成 propObj 的一個個屬性值,藉以取代原有的 JArray,就完成了置換。其中有個小眉角,由於 Key 可能包含不合法的屬性名稱字元或格式(例如「.」字元或以數字起首),因此要藉由 Regex 取代及修正(數字起首時在前方加上「_」)。

staticvoid Main(string[] args)
{
    var json = System.IO.File.ReadAllText("data.json");
//將JSON轉為JObject
    JObject jo = JObject.Parse(json);
//逐一轉換各屬性
foreach (var p in jo.Properties())
    {
//原本Key/Value陣列方式表達選項?容
        JArray a = p.Value as JArray;
//準備一個新物件以屬性儲放選項
        JObject propObj = new JObject();
//將{ "Key":"..", "Value":"..."}視為JObject
foreach (JObject item in a)
        {
string propName = (string)item["Key"]; //取出Key
//將.換成_,數字起首時前方加_,避免產生無效屬性名
            propName =
                Regex.Replace(
//TODO:如有其他字元再擴充
                    Regex.Replace(propName, "[-.]", "_"),
"^[0-9]", //若以數字起始前方加_
                    m => "_" + m.Value);
//取出Value
string propValue = (string)item["Value"];
//新增成屬性
            propObj.Add(propName, propValue);
        }
 
        jo[p.Name] = propObj;
    }
 
    Console.WriteLine(JsonConvert.SerializeObject(jo, Formatting.Indented));
    Console.Read();
}

轉換結果如下,成功!

{
"modType": {
"I": "獨立模組",
"J": "聯合模組"
  },
"source": {
"I": "內部",
"E": "外部"
  },
"statusOption": {
"_0": "停止",
"_1": "運轉",
"_2": "暫停"
  }
}

以上寫法展示完 JObject/JProperty/JArray 的概念與應用方式,接著我們來抄捷徑:

JObject jo = JObject.Parse(json);
foreach (var p in jo.Properties())
{
    p.Value =
        JObject.FromObject(
        (p.Value as JArray).Cast<JObject>()
        .ToDictionary(
            o => {
string propName = (string)o["Key"];
                propName =
                    Regex.Replace(
                        Regex.Replace(propName, "[-.]", "_"),
"^[0-9]",
                        m => "_" + m.Value);
return propName;
            }, 
            o => (string)o["Value"]));
}

JArray 經由 ToDictionary() 轉成 Dictionary<string, string>,呼叫 JObject.FromObject() 就直接轉成 JObject,收工!

後話:Json.NET 並不是轉換效能最好的 JSON 程式庫,但其完整性、成熟度與應用彈性實在沒話說,只想學一套 JSON 程式庫,選它就對了!

貼文後經網友提醒,補上之前寫過的另一篇介紹:使用dynamic簡化Json.NET JObject操作兩帖併服,藥效加倍~ :P

方法多載(Method Overloading)與 dynamic

$
0
0

方法多載(Overloading)是指多個名稱相同但參數個數或型別不同的方法,編譯器依傳入參數的個數、型別與順序決定使用哪一個方法。概念上多載讓方法變得更彈性,能接受不同參數組合,符合更多應用情境。舉個常見的例子,Convert.ToByte() 可傳入 int, short, string, float, double, decimal, char… 等輸入值,將其轉成 byte,傳入 string 時還能指定 16 進位(fromBase)或 IFormatProvider。

我有個根深蒂固的觀念-多載解析都發生在編譯期間,編譯器依參數將函式指標指向同名方法的其中一個。上回在談擴充方法參數傳入 dynamic 型別出錯時提到:「當 dynamic 遇上多載介面解析,一律視為 object 就對了」,但事後想想覺得怪,「多載解析都是在編譯期間完成」不適用 dynamic 型別吧?dynamic 要到執行期間才知確實型別,編譯期間要如何判斷該套用哪個多載實作?廢話不多說,做個實驗便知:有個 OverloadingMethod 共有接收 int 或 string 兩個多載版本,測試時第一次傳入數字、第二次傳入字串、第三次將數字轉型成 dynamic。

class Program
{
staticvoid Main(string[] args)
    {
        OverloadingMethod(12345);
        OverloadingMethod("ABCDEF");
        OverloadingMethod((dynamic)12345);
        Console.Read();
    }
 
staticvoid OverloadingMethod(int i)
    {
        Console.WriteLine($"int version: {i}");
    }
 
staticvoid OverloadingMethod(string s)
    {
        Console.WriteLine($"string version: {s}");
    }
 
}

編譯後以 ildasm 解譯回 MSIL,答案揭曉!如下圖所示,前兩次分別呼叫 int 及 string 版多載方法(黃底部分),第三次則又臭又長,透過 System.Runtime.CompilerServices、System.CSharp.RuntimeBinder 命名空間的物件與方法隔水加熱完成。

改用 LINQPad 的 IL 檢視比較簡潔,原則上類似 Reflection,第三次呼叫,方法名稱"OverloadingMethod"變成字串變數,以 Invoke 方式執行。

所以,結論是多載原則上是在編譯期間決定實際呼叫方法沒錯,但遇上 dynamic 只能留到執行期間再決定。最後,以 C# in Depth- Overloading的這段說明為本議題劃上句點:

At compile time, the compiler works out which one it's going to call, based on the compile time types of the arguments and the target of the method call. (I'm assuming you're not using dynamic here, which complicates things somewhat.)

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.Utc

如此,資料庫讀取的 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(幸好還找得到有 Print Port LPT1: 的電腦),內建明體、楷書、黑體三種中文字型,唰唰唰瞬間就能噴出一整行中文字,跟我當年用過的 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 驅動程式才克服。

2017-08-17 更新:FB 貼文後收到網友 Kevin Yang 回饋(特此感謝),透過印表機內容設定「裝置設定/可安裝的選項/Printable」改為 Old OS 可使用最大列印範圍。

另外,感謝網友貓老大補充另一則小技巧:套印非標準高度的連續報表紙,數「孔數」最準,每 3 孔 1 英吋。

再談集保罕用字集與 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 版與造字版字元,實務上二者看起來有差異,如下圖所示,網頁同時放入兩種版本的鱻(造字版輸入法不易選取,故我用&#62056;表示之),字型都指定為「細明體」,使用瀏覽器檢視時可看左邊的造字版字型比 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;

拿 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% 運算能力陷入空轉。

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 執行這段程式,杜絕存取衝突:

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> 的專屬方法〕

Viewing all 2311 articles
Browse latest View live