距离 EF Core 第一次发布已经过了很久很久,然而,缺失的 Many To Many 映射却迟迟没有加入 Roadmap,并且在之后的一段时间内也很难看到 EF Core 团队对这一功能的态度发生变化。虽然说可以通过手动加入中间表的方式来用两个 One To Many 映射来替代,但是这无疑是非常影响流畅的编程体验。对此,本文将通过对 DbContext 的定义略作修改,来降低这种不便。
原文来自 Many-to-many relationships in EF Core 2.0 – Part 3: Hiding as ICollection ,有删改 原作者保留一切权利
0x00 限制 在开始之前,我必须明确有两个限制不能被忽略:
EF Core 对任何没有映射的属性是一无所知的,这意味着对实体的查询依然要根据实体定义的关系来写。
中间表并不会消失,它仍会存在于应用程序中并且映射到数据库,只不过是在与实体交互时并不使用它。
解决这两个限制需要对 EF Core 的核心代码做出修改,在 issue 1368 被解决之前我不期望这些限制会消失。
0x01 最初的模型 根据官方的文档以及许多博客中所写的内容,定义一个 Many To Many 关系需要用中间表来建立两个 One To Many 映射,用最为简单明了的例子来说,那就是博客的 Post 和 Tag 的关系了。假设有以下理想实体类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class Post { public int Id { get ; set ; } public string Title { get ; set ; } public ICollection<Tag> Tags { get ; } = new List<Tag>(); } public class Tag { public int Id { get ; set ; } public string Text { get ; set ; } public ICollection<Post> Posts { get ; } = new List<Post>(); }
之所以说这是一个理想模型,是因为 EF Core 并不支持直接定义这种关系,实际上这种关系隐含了一个中间表,这主要是关系型数据库的问题,它的数据结构就是这样定义的。
所以,实际上的实体类应该是这样定义的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class Post { public int PostId { get ; set ; } public string Title { get ; set ; } public ICollection<PostTag> PostsTags { get ; } = new List<PostTag>(); } public class Tag { public int TagId { get ; set ; } public string Text { get ; set ; } public ICollection<PostTag> PostsTags { get ; } = new List<PostTag>(); } public class PostTag { [Key ] [Required ] public Guid Id { get ; set ; } [Required ] public Post Post { get ; set ; } [Required ] public Tag Tag { get ; set ; } }
中间表的属性我添加了 RequiredAttribute
特性,这会使 EF Core 自动建立外键和约束。
这样一来,关系映射出来确实是没错,但是每次要查询时,都要写 Posts.Include().ThenInclude().PostsTags.Tags
,非常麻烦,那么能不能把 Tags 作为公共属性导出,用一个 get 访问器来简化访问呢?
我们自然会想到这个问题,那答案当然是可以。
0x02 用 IEnumerable 来隐藏 为了将 Tags 属性导出,需要一个新的属性,于是实体类的定义会变成这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class Post { public int PostId { get ; set ; } public string Title { get ; set ; } public ICollection<PostTag> PostTags { get ; } = new List<PostTag>(); [NotMapped ] public IEnumerable<Tag> Tags => PostTags.Select(e => e.Tag); } public class Tag { public int TagId { get ; set ; } public string Text { get ; set ; } public ICollection<PostTag> PostTags { get ; } = new List<PostTag>(); [NotMapped ] public IEnumerable<Post> Posts => PostTags.Select(e => e.Post); }
现在,进行多对多的查询会变得略微简单一些:
1 2 3 4 5 6 7 8 9 10 11 12 13 var posts = context.Posts .Include(r => r.PostsTags) .ThenInclude(r => r.Tag) .ToList(); foreach (var post in posts){ Console.WriteLine($" Post {post.Title} " ); foreach (var tag in post.Tags) { Console.WriteLine($" Tag {tag.Text} " ); } }
但是如果想要添加数据,依然是非常复杂,需要手动向中间表插入数据,这该怎么办呢?
0x03 用 ICollection 来隐藏 为了能包装对另一个表的添加、删除操作,我们需要公开的这个属性是一个 ICollection
类型,以便它能够成为中间表的一个代理 (Proxy)。
于是,这有一个这种集合的可选实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 public class JoinCollectionFacade <T , TJoin > : ICollection <T >{ private readonly ICollection<TJoin> _collection; private readonly Func<TJoin, T> _selector; private readonly Func<T, TJoin> _creator; public JoinCollectionFacade ( ICollection<TJoin> collection, Func<TJoin, T> selector, Func<T, TJoin> creator ) { _collection = collection; _selector = selector; _creator = creator; } public IEnumerator<T> GetEnumerator () => _collection.Select(e => _selector(e)).GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public void Add (T item ) => _collection.Add(_creator(item)); public void Clear () => _collection.Clear(); public bool Contains (T item ) => _collection.Any(e => Equals(_selector(e), item)); public void CopyTo (T[] array, int arrayIndex ) => this .ToList().CopyTo(array, arrayIndex); public bool Remove (T item ) => _collection.Remove( _collection.FirstOrDefault(e => Equals(_selector(e), item))); public int Count => _collection.Count; public bool IsReadOnly => _collection.IsReadOnly; }
译注:这个实现其实是有问题的,如果对集合进行 LINQ 查询,比如 ToList()
,会导致在 void CopyTo(T[] array, int arrayIndex)
函数中的堆栈溢出,不过接下来将会修正这个错误。
这个想法是比较简单的,大体上看来是把对实体类的操作转换成了对集合的操作,并且在需要时使用 selector
代理从联合实体中提取出目标实体。同样地,在添加时使用 creator
代理会用目标实体创建一个新的联合实体。
接下来需要对实体类的定义做出改变,要在实体类的构造函数中初始化这个代理的集合。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 public class Post { public Post () => Tags = new JoinCollectionFacade<Tag, PostTag>( PostsTags, pt => pt.Tag, t => new PostsTag { Post = this , Tag = t }); public int Id { get ; set ; } public string Title { get ; set ; } private ICollection<PostsTag> PostsTags { get ; } = new List<PostTag>(); [NotMapped ] public ICollection<Tag> Tags { get ; } } public class Tag { public Tag () => Posts = new JoinCollectionFacade<Post, PostTag>( PostsTags, pt => pt.Post, p => new PostsTag { Post = p, Tag = this }); public int Id { get ; set ; } public string Text { get ; set ; } private ICollection<PostTag> PostsTags { get ; } = new List<PostTag>(); [NotMapped ] public ICollection<Post> Posts { get ; } }
在用 ICollection 代替了 IEnumerable 之后,就能在集合上进行更多的操作,查询代码也相应地可以这样来写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 var tags = new []{ new Tag { Text = "Golden" }, new Tag { Text = "Pineapple" } }; var posts = new []{ new Post { Title = "Best Boutiques on the Eastside" }, new Post { Title = "Avoiding over-priced Hipster joints" } }; posts[0 ].Tags.Add(tags[0 ]); posts[0 ].Tags.Add(tags[1 ]); posts[1 ].Tags.Add(tags[0 ]); posts[1 ].Tags.Add(tags[1 ]); context.AddRange(tags); context.AddRange(posts); context.SaveChanges();
1 2 3 4 5 6 7 8 9 10 11 12 foreach (var post in posts){ var oldTag = post.Tags.FirstOrDefault(e => e.Text == "Pineapple" ); if (oldTag != null ) { post.Tags.Remove(oldTag); post.Tags.Add(newTag1); } post.Tags.Add(newTag2); } context.SaveChanges();
注意:
在填充数据时,可以直接向 Post 的 Tags 集合中添加了实体
在查找或搜索时,可以直接搜索 Tags 并且从中移除已有的实体,而不经过中间表
0x04 为了更加抽象 对以上代码,其实可以进一步优化结构而增加复用度。
首先定义一个中间表实体的接口:
1 2 3 4 public interface IJoinEntity <TEntity >{ TEntity Navigation { get ; set ; } }
任何一个中间表都需要实现两次这个接口,为了两边映射的两个表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class PostTag : IJoinEntity <Post >, IJoinEntity <Tag >{ public Post Post { get ; set ; } Post IJoinEntity<Post>.Navigation { get => Post; set => Post = value ; } public Tag Tag { get ; set ; } Tag IJoinEntity<Tag>.Navigation { get => Tag; set => Tag = value ; } }
现在,可以用更强关系的泛型来重写 JoinCollectionFacade 集合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 public class JoinCollectionFacade <TEntity , TOtherEntity , TJoinEntity > : ICollection <TEntity > where TJoinEntity : IJoinEntity <TEntity >, IJoinEntity <TOtherEntity >, new () { private readonly TOtherEntity _ownerEntity; private readonly ICollection<TJoinEntity> _collection; public JoinCollectionFacade ( TOtherEntity ownerEntity, ICollection<TJoinEntity> collection ) { _ownerEntity = ownerEntity; _collection = collection; } public IEnumerator<TEntity> GetEnumerator () => _collection.Select(e => ((IJoinEntity<TEntity>)e).Navigation).GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public void Add (TEntity item ) { var entity = new TJoinEntity(); ((IJoinEntity<TEntity>)entity).Navigation = item; ((IJoinEntity<TOtherEntity>)entity).Navigation = _ownerEntity; _collection.Add(entity); } public void Clear () => _collection.Clear(); public bool Contains (TEntity item ) => _collection.Any(e => Equals(item, e)); public void CopyTo (TEntity[] array, int arrayIndex ) => _collection .Select(je => ((IJoinEntity<TEntity>)je).Navigation) .ToList() .CopyTo(array, arrayIndex); public bool Remove (TEntity item ) => _collection.Remove( _collection.FirstOrDefault(e => Equals(item, e))); public int Count => _collection.Count; public bool IsReadOnly => _collection.IsReadOnly; private static bool Equals (TEntity item, TJoinEntity e ) => Equals(((IJoinEntity<TEntity>)e).Navigation, item); }
新的实现方式的最大的优点是,不再需要指定目标实体并创建中间实体,所以现在实体类的定义是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public class Post { public Post () => Tags = new JoinCollectionFacade<Tag, Post, PostTag>(this , PostsTags); public int Id { get ; set ; } public string Title { get ; set ; } private ICollection<PostTag> PostsTags { get ; } = new List<PostTag>(); [NotMapped ] public ICollection<Tag> Tags { get ; } } public class Tag { public Tag () => Posts = new JoinCollectionFacade<Post, Tag, PostTag>(this , PostsTags); public int Id { get ; set ; } public string Text { get ; set ; } private ICollection<PostTag> PostsTags { get ; } = new List<PostTag>(); [NotMapped ] public IEnumerable<Post> Posts { get ; } }
之前在 0x03 中所写的任何查询代码都不需要改变,一切正常运行~