歷經一段時間摸索歷練,確立「新増修改用EF/ORM,查詢一律用Dapper」的最高指導原則,Dapper的簡潔、效能與彈性無可挑剔,一切看似完美,直到我膝蓋中了一箭…

無意間發現,使用Dapper+ODP.NET無法寫入Unicode字元

跟Oracle Unicode問題奮戰超過10年,以為妖孽已被降伏,用OracleDbType.NVarChar2應該就萬無一失,甚至要在CommandText中用N'…'也不是問題,萬萬沒想到Oracle Unicode問題今天又跑出來咬我屁股。

用以下範例重現問題:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using Oracle.ManagedDataAccess.Client;
 
namespace OraUnicodeTest
{
    public class Program
    {
        static string csOra = "…略";
        static string csSql = "…略";
 
        static void Main(string[] args)
        {
            using (var cn = new OracleConnection(csOra))
            {
                cn.Execute("UPDATE JEFFTEST SET NAME=:NAME WHERE SEQNO=:SEQNO",
                    new
                    {
                        SEQNO = 1,
                        NAME = "牛牪犇" + DateTime.Now.Millisecond
                    });
            }
            
            using (var cn = new SqlConnection(csSql))
            {
                cn.Execute("UPDATE JEFFTEST SET NAME=@NAME WHERE SEQNO=@SEQNO",
                    new
                    {
                        SEQNO = 1,
                        NAME = "牛牪犇" + DateTime.Now.Millisecond
                    });
            }
        }
    }
}

用標準cn.Execute("UPDATE … SET Col=:V1 WHERE …", new { V1 })寫法同時更新Oracle及SQL寫入Unicode文字,在SQL Server一切正常,在Oracle六頭牛跑了三頭!

SQL Server

Oracle

追進Dapper原始碼,Dapper為求跨資料庫,故只能依賴IDbCommand等通用介面,使用CreateParameter產生參數物件,參數型別也必須採通用的System.Data.DbType列舉。至於字串,除非以new DbString() { Value="…", IsAnsi = true }指定System.Data.DbType.AnsiString,字串一律視為System.Data.DbType.String,支援Unicode字元。

由此推論ODP.NET在處理Parameter型別DbType.String時,理應對應成絕對支援Unicode字串的OracleDbType.NVarChar2,卻誤對應成OracleDbType.VarChar2。爬文更發現,不只Dapper,一些使用DbType.String的跨平台程式庫,遇上ODP.NET時也紛紛中箭落馬,例如:NHibernate

用以下實驗驗證ODP.NET處理DbType.String參數有問題:

cn.Open();
var cmd = cn.CreateCommand();
var p = cmd.CreateParameter();
p.ParameterName = "N";
p.DbType = System.Data.DbType.String;
//p.OracleDbType = OracleDbType.Varchar2;
//p.OracleDbType = OracleDbType.NVarchar2;
p.Value = "牛牪犇" + DateTime.Now.Millisecond;
cmd.CommandText = "UPDATE JEFFTEST SET NAME=:N WHERE SEQNO=2";
cmd.Parameters.Add(p);
cmd.ExecuteNonQuery();

實測結果:OracleParameter設定DbType = DbType.String或設定OracleDbType = OracleDbType.Varchar2i時「犇」字都無法正確寫入;必須OracleDbType = OracleDbType.NVarchar2才會正常。

依照System.Data.DbType的設計,DbType.AnsiString對應OracleDbType.Varchar2,DbType.String對應OracleDbType.NVarchar2才合理, 怎麼都覺得是ODP.NET的錯。但為什麼這個錯誤沒有引發大量災情?猜想與它需要以下條件同時成立才會發生有關:

  1. Oracle資料庫未採AL32UTF8編碼
    新建立的資料庫多採UTF8編碼,VARCHAR2即可存入Unicode,因此DbType.String對應成OracleDbType.Varchar2也沒差。本次處理的Oracle環境為求與老系統相容,還在使用ZHT16MSWIN950編碼。
  2. OracleParameter參數型別未指定OracleDbType,而是指定DbType
    OracleParameter同時具備OracleDbType及DbType,都可以設定參數型別。直接使用ODP.NET時,我們多半會指定OracleDbType明確選用NVarchar2或Varchar2,只有Dapper、NHibernate這類必須跨資料庫的程式庫,才會使用與資料庫種類無關的DbType。
  3. 寫入內容剛好有ANSI/BIG5難字
    Dapper寫入NVarchar2的做法已應用在不少地方,這次碰巧寫入資料帶有BIG5難字,問題才爆出來。

這問題挺嚴重,寫Unicode變空白或亂碼誰都不能接受,但要為此放棄Dapper?研判這是ODP.NET的Bug,但感覺被困擾的人不多,Oracle不會積極處理,難道就只能束手無策嗎?

不,誰都別想惹他媽的程式老魔人,誰都別想~ (註:有人提問,補上小河馬典故

判斷是ODP.NET的Bug,但錯誤不普及,短期被修復的可能性不大。但不修正,Dapper無法正確更新Oracle NVARCHAR2,等同廢了一條腿,怎麼辦?

該是駭客登場的時候了,換上墨鏡跟黑色長大衣,打開JustDecomplie反組譯Oracle.ManagedDataAccess.Client.dll鎖定問題根源。

在DbType屬性的set段找到邏輯,當設定OracleParameter.DbType時,背後會同步修改m_oraDbType屬性,而什麼DbType要對應到什麼OracleDbType,由一個內部靜態類別,OraDb_DbTypeTable,的陣列資料:int[] dbTypeToOracleTypeMapping決定。

再追進OraDb_DbTypeTable,靜態建構式裡以Hard-Coding方式指定哪一種DbType列舉要對應成哪一種OracleDbType。先查出各列舉對整數:

(int)OracleDbType.NVarchar2 = 119
(int)OracleDbType.Varchar2 = 126
(int)System.Data.DbType.String = 16

dbTypeToOracleDbTypeMapping[16]=126,罪證確鑿!DbType.String被對應成OracleDbType.Varchar2,是造成Unicode字元無法寫入DB的元兇!

找到根源就好辦,在駭客眼裡,類別屬性欄位哪有分什麼public、internal、private,System.Relection拿出來,想怎麼讀就怎麼讀,愛怎麼改就怎麼改。

我寫了以下修正方法覆寫dbTypeToOracleDbTypeMapping將DbType.String改指向OracleDbType.NVarchar2,修正後ODP.NET + Dapper無法寫入Unicode問題就煙消雲散了。(註:FixOdpNetDbTypStringMapping請放在Glabal.asax.cs或程序、靜態類別啟動過程,整個Process執行一次即可)

static void FixOdpNetDbTypeStringMapping()
{
    Assembly asm = typeof(OracleConnection).Assembly;
    Type tOraDb_DbTypeTable = asm.GetType("Oracle.ManagedDataAccess.Client.OraDb_DbTypeTable");
    var fldDbTypeMapping = tOraDb_DbTypeTable.GetField("dbTypeToOracleDbTypeMapping", 
        BindingFlags.Static | BindingFlags.NonPublic);
    int[] mappings = (int[])fldDbTypeMapping.GetValue(null);
    mappings[(int)System.Data.DbType.String] = (int)OracleDbType.NVarchar2;
    fldDbTypeMapping.SetValue(null, mappings);
}

解決了一個心腹大患,Hacking樂無窮~


Comments

Be the first to post a comment

Post a comment