說到延遲加載(Lazy Load), 有些文章或書籍翻譯為懶加載,雖然我不太喜歡這個翻譯,但是這個“懶”字能貼近生活的。很多事情我們懶得去做,如果事情沒有發生,我們就賺到了。
延遲加載,Martin Flower在《企業應用架構模式》中給了這樣一個定義:一個對象,它雖然不包含所需要的所有數據,但是知道怎么獲取這些數據。
為了理解這句話,還是先來舉個場景,在某些時候,從數據庫里得到一條記錄,需要與數據庫建立連接,網絡請求,執行SQL,關閉連接,費了很大的力氣,很大的代價,把所需的數據拿到手,但是悲劇的事情發生了,這個記錄的實際數據從不曾用到,這種情況下,能不能"懶一下",需要使用數據的時候我才去獲取數據。怎么才能做到在需要的時候獲得數據?並通過什么標示來獲得數據呢?
還是來一段代碼,因為在我這個碼農的眼里,“有代碼才有真相”:
class LazyLoadDemo { static void Main(string[] args) { Session session = new Session(); Person person = session.Load<Person>(1); string name = person.Name; } }
這里我用Session這個對象來完成數據庫的操作,Load方法將延遲加載數據,用代碼來解釋延遲加載的定義就是:
1.一個對象,它雖然不包含所需要的所有數據
碼農翻譯:sesson.Load<Person>(1)返回的對象,不包含所有的數據,就意味着此時Load方法沒有向數據發起請求獲得所有數據
2.但是知道怎么獲取這些數據
碼農翻譯:person對象雖然不包含所有數據,但它知道怎樣從數據庫獲得所有的數據,也就是說在訪問person.Name時,person對象才向數據庫發出請求去獲得所有數據,那怎么憑什么標示去獲得呢?不難想到是通過Load方法的參數1,這里一般而言是主鍵,這樣就有了數據的唯一標示。
講到這里,可能有些朋友如果從來接觸過ORM,可能有些困惑:
1.只知道了主鍵的值,不知道主鍵對應的字段怎么查詢?
2.不知道person對應的表怎么查詢?
3.怎么知道Name對應數據的字段
從上述的問題里可能已經看出“對應”這個詞頻繁出現,這正是ORM的核心思想,ORM全稱Object/Relation Mapping,關系對象映射,在關系數據庫的世界里,它有着它自己的語言,比如SQL.在面向對象的世界里,有它自己的語言。比如C#.這樣就造成了不能像面向對象那樣去操作數據庫,需要一個對應來起到了將兩個世界連接起來的橋梁。怎樣起到的橋梁作用?我參照NHibernate的mapping文件跟大家講解:
<?xml version="1.0" encoding="utf-8" ?> <hibernate-mapping xmlns="urn:nhibernate-mapping-2.2"> <class name ="Person" table="Person"> <id name="Id" column ="id"> <generator class ="native"/> </id> <property name ="Name" type="string" column="name"/> </class> </hibernate-mapping>
這個mapping是個XML文件,想必大家都能很好的理解。
1.xml文件class元素,name的值代表了C#類的名字,table的值代表了數據庫表的名字。
碼農翻譯:這樣person對象就知道了向哪張表查詢數據。
2.xml文件Id元素,代表主鍵。name代表在類中的映射,column代表對象的列。
碼農翻譯:這樣person對象就知道了表的主鍵。
3.xml文件property元素,代表非主鍵的元素。對應同理可得。
碼農翻譯:這樣person對象就知道表的其他字段。
我相信對於mapping文件的三個解釋,很好的回答了開始提出的三個問題,仔細想想,person知曉了表的字段,表名(外鍵),幾乎知曉了表的一切當然它能知道怎么樣得到數據,但是新的問題出現了,我們怎么去實現這個延遲加載?怎樣讓person直到訪問屬性的時候才加載呢?我相信看過這個系列第一遍文章的朋友已經有想法,可以采用動態代理
來實現。即開始Load方法返回的只是一個Person類的代理,代理的偽代碼大概這樣:
class PersonProxy:Person { private bool initialized; public override string Id { get { return base.Id; } set { base.Id = value; } } public override string Name { get { if (!initialized) { /*Query*/ } return base.Name; } set { base.Name = value; } } }
有了前面的分析,我相信大家都躍躍欲試了,想自己來實現,那我們還猶豫什么一起來實現吧,這里我就一步一步地來實現一個簡易的版本:
首先,故事的主角之一數據庫,表,是並不可少的,來建數據庫,建表吧!
create database LazyDemo use LazyDemo Go create table Person ( Id int primary key identity(1,1), Name varchar(20) not null, Salary money not null, Description varchar(200) ) Go insert into Person values('碼農1','3000','不懂動態代理') insert into Person values('碼農2','5000','他了解動態代理') insert into Person values('碼農3','8000','他能實現延遲加載')
Ok,讓我們離開關系模型的世界,進入面向對象的世界,先建立一個Person類,並對它進行映射。
public class Person { /*注意一定必須是Virtual,不了解可以看這個系列第一遍文章*/ public virtual string Id { get; set; } public virtual string Name { get; set; } public virtual decimal Salary { get; set; } public virtual string Description { get; set; } }
person的映射person.hbm.xml,並把它嵌入程序集,作為程序集的資源。
<?xml version="1.0" encoding="utf-8" ?> <hibernate-mapping xmlns="urn:nhibernate-mapping-2.2"> <class name ="Person" table="Person"> <id name="Id" column ="Id"> <generator class ="native"/> </id> <property name ="Name" type="string" column="Name"/> <property name ="Salary" type="decimal" column="Salary"/> <property name ="Description" type="string" column="Description"/> </class> </hibernate-mapping>
由於配置文件被嵌入程序集中,所以需要一個類將Xml讀出,並緩存起來,這里的實現保證容器是線程安全的。
public static class MappingContainer { private static IDictionary<Type, XElement> xmls = new Dictionary<Type, XElement>(); private static object syncObj = new object(); public static XElement GetMappingXml(Type type) { if (type == null) { throw new NullReferenceException("type can't be null!"); } if (!xmls.ContainsKey(type)) { AddMappingXml(type, GetMappingXmlForAssembly(type)); } return xmls[type]; } private static void AddMappingXml(Type type, XElement xEle) { if (!xmls.ContainsKey(type)) { lock (syncObj) { if (!xmls.ContainsKey(type)) { xmls.Add(type, xEle); } } } } private static XElement GetMappingXmlForAssembly(Type type) { Stream xmlStream = type.Assembly.GetManifestResourceStream(GetMappingXmlName(type)); if (xmlStream == null) { throw new InvalidOperationException("Entity should have mapping xml embeded the assembly!"); } return XElement.Load(xmlStream); } private static string GetMappingXmlName(Type type) { return type.FullName + ".hbm.xml"; } }
此時,得到xml之后,需要類來解析xml得到相應的表的信息,采用對Type擴展方法讓程序有更好的可讀性。
namespace LazyDemo { public static class MappingXmlParser { public static string GetTableName(this Type type) { return GetXmlEleValue(type, "class", "table"); } public static string GetIdentityPropName(this Type type) { return GetXmlEleValue(type, "id", "name"); } public static string GetIdentityColumnName(this Type type) { return GetXmlEleValue(type, "id", "column"); } public static IDictionary<string, string> GetPropertyMappingDic(this Type type) { IEnumerable<XElement> propElems = from elem in GetMappingXml(type).DescendantsAndSelf("property") select elem; return propElems.ToList().ToDictionary( key => key.Attribute("name").Value, value => value.Attribute("column").Value); } private static XElement GetMappingXml(Type type) { if (type == null) { throw new ArgumentNullException("Entity can't be null!"); } /*xml should validate by xml schema*/ return MappingContainer.GetMappingXml(type); } private static string GetXmlEleValue(Type type, string eleName, string attrName) { var attrs = from attr in GetMappingXml(type).DescendantsAndSelf(eleName).Attributes(attrName) select attr; return attrs.Count() > 0 ? attrs.First().Value : string.Empty; } } }
從Xml中得到了數據表的信息,需要一個類對數據庫進行操作,這里我盡量依賴於抽象,使得數據庫盡量與平台無關,可以在將來的時候進行擴展。
public abstract class AbstractDbContext:IDbContext { protected IDbConnection dbConnection; protected AbstractDbContext(IDbConnection dbConnection) { this.dbConnection = dbConnection; } public DataReaderInfo Query(string sqlStr) { dbConnection.Open(); IDbCommand dbCommand = dbConnection.CreateCommand(); dbCommand.CommandText = sqlStr; return new DataReaderInfo(dbCommand.ExecuteReader(CommandBehavior.CloseConnection),sqlStr); } }
在Sql平台下的參數,只需要簡單基礎抽象類即可,並且數據連接寫在配置文件中。
public class SqlContext:AbstractDbContext,IDbContext { public SqlContext() : base(new SqlConnection(ConfigurationUtil.GetConnectionString())) { } }
現在需要能對Session.Load方法產生代理的方法,這里還是使用Castle來產生代理:
public class ProxyFactoryImpl<TEntity>:IProxyFactory { private ProxyGenerator proxyGenerator; private ISession session; private IdentityInfo identityInfo; public ProxyFactoryImpl(IdentityInfo identityInfo,ISession session) { proxyGenerator = new ProxyGenerator(); this.session = session; this.identityInfo = identityInfo; } public TEntity GetProxy<TEntity>() { if (typeof(TEntity).IsClass) { return (TEntity)proxyGenerator.CreateClassProxy( typeof(TEntity), new Type[] { typeof(ILazyIntroduction) }, new LazyLoadIntercetor(identityInfo, session)); } return (TEntity)proxyGenerator.CreateInterfaceProxyWithoutTarget( typeof(TEntity), new Type[] { typeof(ILazyIntroduction) }, new LazyLoadIntercetor(identityInfo, session)); } }
這里注意:GetProxy<TEntity>()中對CreateClassProxy的調用的第二個參數,特別重要,它的意思是被代理的實體在運行時實現參數所指定的接口,這里我所指定的接口是ILazyIntroduction
public interface ILazyIntroduction { ILazyInitializer LazyInitializer { get; } } public interface ILazyInitializer { bool IsInitialzed { get; } }
這樣我就可以對實體是否初始化進行檢查,因為動態代理在實現時被我做了手腳實現了ILazyIntroduction接口,這里與AOP中的Introduction的通知有點類型,以后有機會再詳細講解。
public class LazyUtil { public static bool IsInitialized(object entity) { return ((ILazyIntroduction)entity).LazyInitializer.IsInitialzed; } }
現在最核心的部分,攔截器要登場,它才是導演,它包括了所有的攔截邏輯,現在讓它閃亮登場吧:
public class LazyLoadIntercetor:IInterceptor,ILazyInitializer { private IdentityInfo identityInfo; private bool initialized; private ISession session; private object instance; public bool IsInitialzed { get { return initialized; } } public LazyLoadIntercetor(IdentityInfo identityInfo, ISession session) { this.identityInfo = identityInfo; this.session = session; } public void Intercept(IInvocation invocation) { if (IsCheckInitialize(invocation)) { invocation.ReturnValue = this; return; } if (!initialized) { if (invocation.Arguments.Length == 0) { if (IsCallIdentity(invocation)) { invocation.ReturnValue = identityInfo.Value; return; } this.instance = GetDataFormDB(invocation); initialized = true; } } invocation.ReturnValue = invocation.Method.Invoke(instance, invocation.Arguments); } private bool IsCheckInitialize(IInvocation invocation) { return invocation.Method.Name.Equals("get_LazyInitializer"); } private bool IsCallIdentity(IInvocation invocation) { return invocation.Method.Name.Equals("get_" + identityInfo.Name); } private object GetDataFormDB(IInvocation invocation) { object instance = session.QueryDirectly(invocation.InvocationTarget.GetType().BaseType, identityInfo.Value); return instance; } private void DirectlyLoadFormDB(IInvocation invocation) { object instance = session.QueryDirectly(invocation.InvocationTarget.GetType().BaseType, identityInfo.Value); invocation.ReturnValue = invocation.Method.Invoke(instance,invocation.Arguments); } }
最重要的還是Intercept方法,這里有幾個條件,簡單地講解下我的思路:
1.IsCheckInitialize即當調用LazyUtil.IsInitialized的方法時,直接訪問this。
2.如果不滿足1,就要判斷數據是否初始化,即是否從數據庫加載,如果沒有並且不是訪問的主鍵,向數據庫查詢,如果是訪問主鍵直接返回。
這樣,往事具備,只欠Session啦:
public class SessionImpl : ISession { public IDbContext DbContext { get; set; } private IProxyFactory proxyFactory; public SessionImpl() { DbContext = new SqlContext(); } public TEntity Load<TEntity>(object id) { IdentityInfo identityInfo = new IdentityInfo(id, typeof(TEntity).GetIdentityPropName()); proxyFactory = new ProxyFactoryImpl<TEntity>(identityInfo, this); return proxyFactory.GetProxy<TEntity>(); } public object QueryDirectly(Type type, object id) { object instance = Activator.CreateInstance(type); SetIdVal(instance, id); SetPropertyVal(id, instance); return instance; } private void SetPropertyVal(object id, object instance) { string sqlText = instance.GetType().ParserSqlText(id); instance.SetPropertyFromDB(DbContext.Query(sqlText)); } private void SetIdVal(object instance, object id) { PropertyInfo idPropertyInfo = instance.GetType().GetProperty(instance.GetType().GetIdentityPropName()); idPropertyInfo.SetValue(instance, id, null); } }
好了,進行簡單的集成測試。
[TestFixture] class TestLazyLoad { private ISession session; private Person person; [SetUp] public void Initialize() { session = new SessionImpl(); person = session.Load<Person>(1); } [Test] public void TestIsInitialzed() { Assert.IsFalse(LazyUtil.IsInitialized(person)); } [Test] public void TestAccessIdentity() { Assert.IsFalse(LazyUtil.IsInitialized(person)); Assert.AreEqual(1, person.Id); Assert.IsFalse(LazyUtil.IsInitialized(person)); } [Test] public void TestAccessOtherProperty() { Assert.IsFalse(LazyUtil.IsInitialized(person)); Assert.AreEqual("碼農1", person.Name); Assert.IsTrue(LazyUtil.IsInitialized(person)); Assert.AreEqual(3000m, person.Salary); } }
這里可以看到TestAccessOtherProperty()想數據庫進行了查詢,發出了Sql,而且,雖然這個方法中訪問了2次屬性,但是還是只執行了一次SQL.
能看到這里,也許你還有的糊塗,那我就把代碼共享出來,執行代碼時不要忘記修改數據庫連接。