擴充方法是很常用的技巧, 之前在使用 Entity Framework Core 的時候, 擴充了一個 IQueryable<T>
的擴充方法 WhereIf()
, 後來想說這個方法也適用於其他衍生自 IEnumerable<T>
的型別, 且 IQueryable<T>
繼承了 IEnumerable<T>
, 所以把 WhereIf()
方法改成 IEnumerable<T>
的擴充方法以求更廣的適用範圍, 沒想到一切都不一樣了, 要是當時沒及時發現就引爆了一個效能核彈了.
雖然標題是寫 Entity Framework Core, 但
IQueryable<T>
本來就是設計給”資料查詢”的情境來說, 其他 ORM 八九不離十會遇到一樣的現象.
問題描述
以下面的程式碼為範例來說明:
1 | var adults = _dbContext.Set<Person>() // DbSet<Person> |
我們知道在 ToList()
被呼叫前的行為都只是在組合查詢條件, 不會真正去查資料, 基於這個前提如一開始描述擴充一個 IEnumerable<T>
的擴充方法 WhereIf(...)
來方便使用, 如下:
1 | public static class EnumerableExtensions |
雖然執行結果沒錯, 但這樣做會有個很嚴重的問題, 以上面的例子來說, 當 WhereIf(...)
被呼叫時會從資料來源查詢資料, 以 Oracle 來說就是執行了 SELECT * FROM PERSON
的查詢將 Person 資料表的 所有資料 搜尋出來後才在應用程式中做後續的篩選和處理.
為什麼會有這個現象?
其實從 IQeueryable<T>
和 IEnumerable<T>
的用途與差別大概就能推測出會有這樣的結果了, 不過出於好奇還是稍微實驗一下看會不會有更明確的答案.
實驗程式碼如下: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
38public static IEnumerable<T> WhereIf<T>(this IEnumerable<T> query, Func<T, bool> predicate, bool shouldAppendWhere)
{
if (shouldAppendWhere)
{
return query.Where(predicate);
}
return query;
}
public class MyQueryable<T> : IQueryable<T>
{
public Type ElementType => typeof(T);
public Expression Expression => default;
public IQueryProvider Provider => default;
public IEnumerator<T> GetEnumerator()
{
throw new NotImplementedException("a");
}
IEnumerator IEnumerable.GetEnumerator()
{
throw new NotImplementedException("b");
}
}
IEnumerable<int> query1 = new MyQueryable<int>().WhereIf(r => r > 10, true);
IEnumerable<int> query2 = new MyQueryable<int>().WhereIf(r => r > 10, false);
"Correct here.".Dump();
// NotImplementedException("a")
query1.Dump();
// NotImplementedException("b")
query2.Dump();
經過實驗, 可以看到有加其他條件和直接轉型的情境會分別呼叫到兩個不同的 GetEnumerator()
方法, 翻了一下 Entity Framework Core 的原始碼可以發現 InternalDbSetCreateEntityQueryable()
方法且回傳一個 EntityQueryable<T>
型別的物件, 接著從 EntityQueryable
也就是說, 只要觸發 GetEnumerator() 方法的呼叫, 就會引發資料查詢.
這部分這樣推測是比較粗糙的作法, 嚴格來說應該是要找到真的去執行的程式碼才能證實, 但因為實測已經知道結果了, 加上懶得在家建立完整的環境追蹤, 所以就沒堅持要找到最底層.
解決方案
方案一 : 同時擴充 IQueryable<T>
和 IEnumerable<T>
如下範例: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
30public static class QueryableExtensions
{
public static IQueryable<T> WhereIf<T>(this IQueryable<T> query, Expression<Func<T, bool>> predicate, bool shouldAppendWhere)
{
if (shouldAppendWhere)
{
return query.Where(predicate);
}
return query;
}
}
public static class EnumerableExtensions
{
public static IEnumerable<T> WhereIf<T>(this IEnumerable<T> query, Func<T, bool> predicate, bool shouldAppendWhere)
{
if (shouldAppendWhere)
{
return query.Where(predicate);
}
return query;
}
}
var adults = _dbContext.Set<Person>()
.WhereIf(r => r.Gender == condition.Gender.Value, condition.Gender.HasValue) // Optional filter
.Where(r => r.Age >= 18)
.ToList();
雖然同時擴充 IQueryable<T>
和 IEnumerable<T>
可以解決問題, 但這有幾個缺點:
- 因為
DbSet<T>
實作了IQueryable<T>
, 而IQueryable<T>
繼承了IEnumerable<T>
, 所以編譯時優先使用IQueryable<T>
的擴充方法, 雖然符合預期, 但完全依賴於編譯時的優先順序, 非常隱晦. - 考慮到搭配選擇性引數 (Optional Arguments) 使用時, 很容易在無意間因為一點小改動而導致使用了
IEnumerable<T>
的擴充方法而沒發現. - 萬一維護過程將
IQueryable<T>
的擴充方法移除或重新命名, 呼叫端還是可以正確編譯的, 但會變成呼叫了IEnumerable<T>
的擴充方法, 而且很難意識到這個問題.
這是個有用且支援範圍廣但需要謹慎維護的解決方案, 適合非常嚴謹的團隊.
事實上, dotnet runtime 就是同時做了
IEnumerable<T>
版本的Where()
擴充方法 和IQueryable<T>
版本的Where()
擴充方法
方案二 : 只擴充 IQueryable<T>
如下範例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17public static class QueryableExtensions
{
public static IQueryable<T> WhereIf<T>(this IQueryable<T> query, Expression<Func<T, bool>> predicate, bool shouldAppendWhere)
{
if (shouldAppendWhere)
{
return query.Where(predicate);
}
return query;
}
}
var adults = _dbContext.Set<Person>()
.WhereIf(r => r.Gender == condition.Gender.Value, condition.Gender.HasValue) // Optional filter
.Where(r => r.Age >= 18)
.ToList();
這樣做不容易出錯, 但也有一個缺點 - 對於衍生自 IEnumerable<T>
的型別不友善.
以下面範例來說, 需要透過 AsQueryable()
方法轉型後才能使用.1
2
3
4
5
6var dataSource = new List<Person>();
var adults = dataSource
.AsQueryable()
.WhereIf(r => r.Gender == condition.Gender.Value, condition.Gender.HasValue) // Optional filter
.Where(r => r.Age >= 18)
.ToList();
是個支援範圍比較窄, 但是維護風險較低的做法, 適合無法保證謹慎維護的專案.
結論
整體來說, 如果可能會被任何需要 ORM 的用戶端程式呼叫到, 優先避免做 IEnumerable<T>
的擴充方法, 真的需要的話要考慮如果會被 IQueryable<T>
的衍生類別呼叫到就必須連同 IQueryable<T>
的擴充方法一起做.
還好在確認 ORM 產生的 SQL Script 內容時就即時發現, 不然等到上 production 才爆發就真的很難找到這麼隱晦的問題來源了.