註:閱讀本文章前有個先修課程,需知道.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值應該一樣,事實不然:
publicstaticstring 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>
publicstaticvoid 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物件,避免自行建構
publicstatic BBDPEntities CreateDbContext()
{
//正式應用時,設定檔之連線字串應加密
//在此進行讀取設定並解密以建構DbContext,細節待日後介紹
var ent = new BBDPEntities();
ObjectContext objCtx = ((IObjectContextAdapter)ent).ObjectContext;
//由資料庫讀得資料後,進行DateTimeKind修正
objCtx.ObjectMaterialized += (sender, e) =>
{
Converter.FixUnspecifiedDateKind(e.Entity);
};
return ent;
}