註:閱讀本文章前有個先修課程,需知道.NET DateTimeKind如何影響Json.NET序列化結果,不熟悉的同學可以看補充教材補充教材2

依先前研究心得:將JsonSerializerSettings指定DateTimeZoneHandling.Utc可以避免DateTimeKind.Local被轉成「yyyy-MM-ddTHH:mm:ss.fffffff+08:00」,統一採用「yyyy-MM-ddTHH:mm:ss.fffffffZ」UTC時間格式方便前端處理,但遇上DateTimeKind.Unspecified,Json.NET轉換會出現時差(以台灣時區為例,會多八小時)。實務上碰到被標成Unspecified的台灣時間,我習慣用DateTime.ToUniversalTime()轉成UTC時間,JsonConvert.SerializeObject()的轉換結果才會正確。

平時取用DateTime.Now或DateTime.Today都有正確的DateTimeKind,較常遇到的狀況多發生在從資料庫讀取DATE型別。如以下Entity Framework實驗,建立一個Blah Entity物件,時間取DateTime.Now,以EF方式新増到資料庫,再查詢取回同一筆資料,表面上寫入與讀取的UpdateTime值應該一樣,事實不然:

        public static string TestJson()
        {
            Blah toAdd = null;
            string code = "JEFF";
            using (var ctx = DataHelper.CreateDbContext())
            {
                ctx.Database.ExecuteSqlCommand(
                    "delete from blah where code={0}", 
                    code);
                toAdd = new Blah()
                {
                    Code = code,
                    UpdateTime = DateTime.Now
                };
                ctx.Blah.Add(toAdd);
                ctx.SaveChanges();
            }
            Blah fromDb = null;
            using (var ctx = DataHelper.CreateDbContext())
            {
                fromDb = ctx.Blah.Single(o => o.Code == code);
            }
            JsonConvert.DefaultSettings = () => new JsonSerializerSettings()
            {
                DateTimeZoneHandling = DateTimeZoneHandling.Utc
            };
            return JsonConvert.SerializeObject(new 
            {
                Now = DateTime.Now,
                NewItem = toAdd,
                FromDb = fromDb
            }, Formatting.Indented);
        }

結果為:

{
  "Now": "2015-11-19T03:30:22.4362365Z",
  "NewItem": {
    "Code": "JEFF",
    "UpdateTime": "2015-11-19T03:30:22.2832178Z"
  },
  "FromDb": {
    "Code": "JEFF",
    "UpdateTime": "2015-11-19T11:30:22.283Z"
  }
}

可以發現由資料庫讀出的Blah.UpdateTime除了精確度不同,經JSON序列化會多8小時,就是前面所說「DateTimeKind.Unspecified時間的JsonConvert.SerializeObject()轉換誤差」,解決之道是UpdateTime = UpdateTime.ToUniversalTime(),或使用DateTime.SpecifyKind()將UpdateTime.Kind校正為Local。

每次從DB讀取資料必須逐一轉換日期資料轉JSON才不會爆炸,系統正確性全看開發者的紀律(和記性)有點危險。另一種解法是用程式偵測找出DateTime或DateTime?型別屬性,遇到DateTimeKind.Unspecified就自動轉成Local,程式不難,用Reflection就可實現:

        static Dictionary<Type, List<PropertyInfo>> DatePropsCache = 
            new Dictionary<Type, List<PropertyInfo>>(); 
        /// <summary>
        /// 掃瞄資料物件的DateTime型別屬性,將DateTimeKind.Unspecified改為DateTimeKind.Local
        /// </summary>
        /// <param name="entity">資料</param>
        public static void FixUnspecifiedDateKind(object entity)
        {
            if (entity != null)
            {
                Type type = entity.GetType();
                //以Reflection找出所有DateTime或DateTime?型別屬性
                //每個型別只要做一次,使用Cache省去多餘運算
                List<PropertyInfo> dateProps = DatePropsCache.ContainsKey(type) ?
                    DatePropsCache[type] : null;
                if (dateProps == null)
                {
                    lock (DatePropsCache)
                    {
                        //找出所有DateTime及DateTime?屬性的PropertyInfo
                        DatePropsCache[type] = type.GetProperties()
                            .Where(o =>
                                o.PropertyType == typeof(DateTime) ||
                                o.PropertyType == typeof(DateTime?)
                            ).ToList();
 
                    }
                    dateProps = DatePropsCache[type];
                }
                foreach (var dateProp in dateProps)
                {
                    //取得目前屬性值
                    object curVal = dateProp.GetValue(entity);
                    if (curVal != null)
                    {
                        DateTime dt = dateProp.PropertyType.IsGenericType ? 
                            ((DateTime?)curVal).Value : (DateTime)curVal;
                        //若DateTimeKind為Unspecified,改設定為Local
                        if (dt.Kind == DateTimeKind.Unspecified)
                            dateProp.SetValue(entity, 
                                DateTime.SpecifyKind(dt, DateTimeKind.Local));
                    }
                }
            }
        }

而原來的程式要加上FixUnspecifiedDateKind(fromDb):

            Converter.FixUnspecifiedDateKind(fromDb);
            return JsonConvert.SerializeObject(new 
            {
                Now = DateTime.Now,
                NewItem = toAdd,
                FromDb = fromDb
            }, Formatting.Indented);

經過修正後JSON序列化結果符合預期。

{
  "Now": "2015-11-19T03:55:25.4274737Z",
  "NewItem": {
    "Code": "JEFF",
    "UpdateTime": "2015-11-19T03:55:25.4174634Z"
  },
  "FromDb": {
    "Code": "JEFF",
    "UpdateTime": "2015-11-19T03:55:25.417Z"
  }
}

但是,即使有自動修正,每次由DB取回資料都要加上FixUnspecifiedDateKind(),還是很容易有疏漏,從源頭下手會是更理想的做法。這裡介紹一招: ObjectContext.ObjectMaterialized 事件,會在從資料來源讀取資料產生物件後觸發,趁此時對 e.Entity 動手腳,外界永遠會拿到加工整形過的Entity。上回提過「建立DbContext應使用統一共用函式」,就是加入FixUnspecifiedDateKind()邏輯的好地方,如此可確保所有DbContext丟回的日期資料都經過修正,就不必再煩惱資料庫造成的JSON序列化時差囉!(這個例子也說明了「為什麼該用統一的DbContext建立函式,不要自己new一個DbContext」)

        //使用統一的靜態函式建立DbContext物件,避免自行建構
        public static BBDPEntities CreateDbContext()
        {
            //正式應用時,設定檔之連線字串應加密
            //在此進行讀取設定並解密以建構DbContext,細節待日後介紹
            var ent = new BBDPEntities();
            ObjectContext objCtx = ((IObjectContextAdapter)ent).ObjectContext;
            //由資料庫讀得資料後,進行DateTimeKind修正
            objCtx.ObjectMaterialized += (sender, e) =>
            {
                Converter.FixUnspecifiedDateKind(e.Entity);
            };
            return ent;
        }

2017-08-08 補充更簡便的解法


Comments

Be the first to post a comment

Post a comment