(翻译)Entity Framework技巧系列之四 - Tip 13 – 15
提示13. 附加一个实体的简单方式
问题:
在早先的一些提示中,我们讨论了使用Attach来加载一个处于未改变(unchanged)状态的东西到ObjectContext从而避免进行查询的开销。
如果性能是你的目标,Attach就是要选择的武器。
不幸的是我们的API不能适应99%的情况,即每个类型仅有一个实体集(entity set)的情况。Entity Framework支持单类型多实体集(Multiple Entity Sets per Type)或称MEST,且API反映了这一点,要求你提供你要附加的实体集(EntitySet)的名称。
即,像这样:
1 ctx.Attach("Orders", order);
如果你像我一样,可能你也会发反感在代码里硬编码入字符串。它们容易出错且这类东西会污染你的代码,这实质上是一个"小问题"。
.NET 4.0中的解决方案
在.NET 4.0中通过每一个EntitySet返回ObjectSet<T>而不是ObjectQuery<T>这个强类型的属性修复了这个问题。ObjectSet<T>有Add, Delete及Attach方法直接处理这个问题,所以你可以写如下这样的代码:
1 ctx.Order.Attach(order);
没有一个字符串出现!
这种解决方案是理想的,你附加需要的实体集,且无论你是否有MEST,它都工作。
.NET 3.5中的解决方案
.NET 3.5中应该怎么办呢?
我的观点是,我们应该提供一个泛型版本的Attach,即如下这样:
1 void AttachToDefaultSet<T>(T Entity);
这个方法会检查T中存在多少个EntitySet,如果只有一个,其将附加这个实体到那个实体集。然而如果多于一个其将抛出异常。
虽然这种方法没有扩展方法的力量,但它也很容易写。
以下是你需要做的:
-
得到<T>类型的EntityType
-
得到这个EntityType可能属于的EntitySet类型的列表。EntityType可能为派生类型(像Car),并且实际上属于一个父类型集合(像Vehicles)。
-
遍历EntityContainer,对每一个EntityContainer的EntitySet查找一个匹配。
-
如果找到一个则进行附加,否则抛出异常。
下面让我们来完成:
但首先要注意,这只是示例级质量的代码,我是一个项目经理而非程序员,所以使用这些代码的风险自担:)
首先我们添加一个扩展方法到MetadataWorkspace来获取一个CLR类型(O-Space)对应的概念模型(C-Space)EntityType。
1 public static EntityType GetCSpaceEntityType<T>( 2 this MetadataWorkspace workspace 3 ) 4 { 5 if (workspace == null) 6 throw new ArgumentNullException("workspace"); 7 // Make sure the assembly for "T" is loaded 8 workspace.LoadFromAssembly(typeof(T).Assembly); 9 // Try to get the ospace type and if that is found 10 // look for the cspace type too. 11 EntityType ospaceEntityType = null; 12 StructuralType cspaceEntityType = null; 13 if (workspace.TryGetItem<EntityType>( 14 typeof(T).FullName, 15 DataSpace.OSpace, 16 out ospaceEntityType)) 17 { 18 if (workspace.TryGetEdmSpaceType( 19 ospaceEntityType, 20 out cspaceEntityType)) 21 { 22 return cspaceEntityType as EntityType; 23 } 24 } 25 return null; 26 }
由于你可能在<T>的元数据加载前调用这段代码,代码第一行保证了<T>这个程序集被加载,如果程序集已被加载其不会执行任何操作。
下一步我们添加一个方法得到一个我们需要匹配的所有类型的枚举,即,包括当前类型在内的父类型的层级:
1 public static IEnumerable<EntityType> GetHierarchy( 2 this EntityType entityType) 3 { 4 if (entityType == null) 5 throw new ArgumentNullException("entityType"); 6 while (entityType != null) 7 { 8 yield return entityType; 9 entityType = entityType.BaseType as EntityType; 10 } 11 }
最后,我们可以开始完成AttachToDefaultSet方法:
1 public static void AttachToDefaultSet<T>( 2 this ObjectContext ctx, 3 T entity) 4 { 5 if (ctx== null) throw new ArgumentNullException("ctx"); 6 if (entity == null) throw new ArgumentNullException("entity"); 7 8 9 MetadataWorkspace wkspace = ctx.MetadataWorkspace; 10 EntitySet set = wkspace 11 .GetEntitySets(wkspace.GetCSpaceEntityType<T>()) 12 .Single(); 13 14 ctx.AttachTo(set.Name, entity); 15 }
这里使用了标准的.Single()方法,如果不是恰好存在一个对应EntityType的可能的实体集其将抛出一个异常。
使用这个实现,我们可以将前文的代码使用下面这种方式重写:
1 Product p = newProduct { ID = 1, Name = "Chocolate Fish" } ctx.AttachToDefaultSet(p);
当然,除非你使用MEST...但你可能不使用!
附加说明
虽然这段代码可以很好的工作,但其确实没有进行过任何优化。
或许缓存对应一个CLR类型的可能的集合的名字是有意义的,这样当你进行Attach的时候就无需再进行相同的检查,这就当留给你的练习了!
提示索引
是的,这里有一个本系列剩余提示的索引。
提示14. 怎样缓存Entity Framework引用数据
场景:
为了使应用程序可以工作,缓存常用的引用数据是非常有意义的。
引用数据的好例子包括像States, Countries, Departments等事物。
通常你想要这些数据随时可用,以方便的进行填充下拉列表等操作。
什么地方将引用数据缓存在手边的一个好例子是让新客户注册的页面,表单的一部分收集用户的地址,包括他们的州。在这个例子中你需要引用数据做两件事:
1. 构建表单中选择州的下拉列表。
2. 将州指定到最终的用户记录。
你怎样使用Entity Framework来支持这类场景呢?
解决方案:
当设计解决方案时我们需要记住两个关键点。
1. 一个实体同一时间只能被附加到一个ObjectContext,至少.NET 3.5 SP1中是这样。
2. 你可能由许多线程同时使用缓存的引用数据(读ObjectContexts)。
本质上这两点相互矛盾。
解决方案是当我们由缓存读取时拷贝实体,这样附加拷贝将不会影响任何其它线程。
如果这是一个webform解决方案,我们可能要写这样的代码:
1 var customer = new Customer{ 2 Firstname = txtFirstname.Text, 3 Surname = txtSurname.Text, 4 Email = txtEmail.Text, 5 Street = txtStreet.Text, 6 City = txtCity.Text, 7 State = statesCache.GetSingle( 8 s => s.ID = Int32.Parse(ddState.SelectedValue) 9 ), 10 Zip = txtZip.Text 11 } 12 ctx.AddToCustomers(customer); 13 ctx.SaveChanges();
但是这有一个大问题。当你添加customer到ObjectContext时,拷贝的State也是added状态。如果我们这样做,Entity Framework会认为其需要将State插入到数据库。而这不是我们想要的。
所以我们需要通过使用 AttachTo(...) 告诉Entity Framework State这个拷贝的已经存在于数据库中:
1 var state = statesCache.GetSingle( 2 s => s.ID = Int32.Parse(ddState.SelectedValue) 3 ); 4 // See Tip 13 to avoid specifying the EntitySet 5 // as a string 6 ctx.AttachTo("States", s);
然后我们可以继续构建customer:
1 var customer = new Customer{ 2 Firstname = txtFirstname.Text, 3 Surname = txtSurname.Text, 4 Email = txtEmail.Text, 5 Street = txtStreet.Text, 6 City = txtCity.Text, 7 State = state, 8 Zip = txtZip.Text 9 } 10 ctx.SaveChanges();
如果你足够警惕,你可能已经发现我没有再次调用 AddToCustomers(...) 。
为什么呢?嗯,当你构建一个到已存在于context (State = state)中的关系时,这个customer会自动被添加。
现在,在 SaveChanges() 被调用时,只有Customer被存储到数据库。State根本不会被持久化,因为Entity Framework认为其不曾改变。
有趣的是,我们可以利用State不被支持化的事实作为我们的条件。
因为,State的主键属性是Entity Framework构建关系时唯一需要知道的,即使其它属性都错了也没有关系,主键属性实际上是我们拷贝时唯一需要的。
这样,我们的拷贝代码可以非常简单:
1 public State Clone(State state) 2 { 3 return new State {ID = state.ID}; 4 }
或者使用如下lambda表达式:
1 var cloner = (State s) => new State {ID = s.ID};
只要我们不想要修改拷贝,这些就是所有我们实际需要的。
现在我们知道了需要什么,编写一个非常简单的提供缓存与"只读拷贝"服务的泛型类就很容易了。
1 public class CloningCache<T> where T : class 2 { 3 private List<T> _innerData; 4 private Func<T, T> _cloner; 5 public CloningCache(IEnumerable<T> source, Func<T, T> cloner) 6 { 7 _innerData = source.ToList(); 8 _cloner = cloner; 9 } 10 public T GetSingle(Func<T, bool> predicate) 11 { 12 lock (_innerData) 13 { 14 return _innerData 15 .Where(predicate) 16 .Select(s => _cloner(s)) 17 .Single(); 18 } 19 } 20 }
注意, GetSingle(...) 方法拷贝它找到的结果。
另外使用这个拷贝缓存非常简单:
1 var statesCache = new CloningCache<State>( 2 ctx.States, 3 (State s) => new State {ID = s.ID} 4 );
构造函数的第一个参数是要缓存的数据(即数据库中所有的States),第二个参数是怎样实现拷贝,我们需要跨多个ObjectContext安全的使用这个缓存。
一旦你初始化了这个缓存(大概是在Global.asax中),无论什么情况下你需要直接访问引用数据,你都可以在一个静态变量中情况此缓存。
如果哪里讲的不清楚或你有什么问题,请告诉我。
提示15. 怎样避免加载非必须的属性
更新:对之前需要Original值做了一系列重要的更正。
问题:
想象如果你查询博客随笔:
1 var myPosts = from post in ctx.Posts 2 orderby post.Created descending 3 select post;
仅仅这样你就可以输出博客标题等等。
1 foreach(var post in myPosts) 2 { 3 Console.WriteLine("{0} on {1}", post.Title, post.Created); 4 }
这样你做了一大些无用的工作来加载你实际上不需要的属性。
只读解决方案:
对于只读场景,解决方案很容易。
你只需进行投影操作:
1 var myPosts = from post in ctx.Posts 2 orderby post.Created descending 3 select new {post.Title, post.Created};
这样你就避免了加载你实际上不需要的属性。
对于有许多属性或存在映射到数据库中一个blob列的属性的实体,例如像Body之类映射到一个nvarchar(max)列的属性,这尤其重要。
读写解决方案:
但如果你需要修改实体该怎么办呢?
此处投影不是一个好方案,因为除非你获取一个完整的对象,否则你将不会得到任何对象服务,这意味着将无法更新。
嗯…
一如往常,得到一个解决方案的关键的就是理解Entity Framework的工作方式。
当更新一个实体,Entity Framework将更新以如下格式发送到数据库(伪代码):
1 UPDATE [Table] 2 SET 3 ModifiedProperty1 = NewValue1, 4 ModifiedProperty2 = NewValue2, 5 ... 6 ModifiedPropertyN = NewValueN 7 WHERE 8 KeyProperty = KeyValue AND 9 ModifiedProperty1 = OriginalValue1 AND 10 ModifiedProperty2 = OriginalValue2 AND 11 ... 12 ModifiedPropertyN = OriginalValueN
注意没有修改过的属性不会出现在更新命令的任何地方。
重大发现:这意味着你只需要知道主键属性的原始值即可。*
带着这些发现,我们可以进行下面这样的尝试:
-
仅投射出我们需要读写的列
-
由投影伪造一个实体,忽略我们不关心的列。
-
Attach(...)那个"部分正确"的实体
-
对实体做所需的更改
-
SaveChanges(...)
这样我们就可以在不实例化我们不感兴趣的属性的情况下更改我们的实体。
下面是一些可以完成上述工作的代码:
1 // Project just the columns we need 2 var myPosts = from post in ctx.Posts 3 orderby post.Created descending 4 select new {post.ID, post.Title}; 5 // Fabricate new Entities in memory. 6 // Notice the use of AsEnumerable() to separate the in db query 7 // from the LINQ to Objects construction of Post entities. 8 var fabricatedPosts = from p in myPosts.AsEnumerable() 9 select new Post{ID = p.ID, Title = post.Title}; 10 // Now we attach the posts 11 // And call a method to modify the Title 12 foreach(var p in fabricatedPosts) 13 { 14 ctx.AttachTo("Posts", p); 15 p.Title = ChangeTitle(p.Title); 16 } 17 ctx.SaveChanges();
注意我们只检索了ID属性(主键)和Title属性(我们要修改的东西),但我们仍成功地进行了更新。
TA DA!
*警告/并发问题
如果你使用存储过程更新实体,这个提示中的内容不适用。
如果你考虑存储过程工作的方式你可以明白为什么。当使用存储过程进行更新时,所有当前值(及部分原始值)被映射到参数,而不管它们是被修改过。这基本意味着你不得不获得所有原始值:(
另外有些时候你需要告诉Entity Framework一些其它的原始值,因为没有它们更新不会成功:
-
并发属性:并发属性的原始值被包含在更新语句中,来保证你只可以在明确知道当前数据库版本的情况下更新数据库。所以没有正确的原始值更新不会成功。
-
EntityFramework的EntityKey值:你也需要知道0..1关系的原始值,即使不准备改变此关系。例如如果一个订单有一个客户,你将需要知道CustomerReference.EntityKey,然后你可以使用这个已确立的关系初始化一个新的Order。当你使用FK属性(在即将到来的.NET 4.0中)时,这个问题将不复存在。
C-Side映射条件引用的属性:C-Side映射条件的值用于算出应用了哪个映射,所以没有正确的原始值就不会确立正确的更新映射。大部分人不会使用这个特性。