.NET面試題系列目錄
名言警句
"理解IQueryable的最簡單方式就是,把它看作一個查詢,在執行的時候,將會生成結果序列。" - Jon Skeet
LINQ to Object和LINQ to SQL有何區別?
LINQ to SQL可以將查詢表達式轉換為SQL語句,然后在數據庫中執行。相比LINQ to Object,則是將查詢表達式直接轉化為Enumerable的一系列方法,最終在C#內部執行。LINQ to Object的數據源總是實現IEnumerable<T>(所以不如叫做LINQ to IEnumerable<T>),相對的,LINQ to SQL的數據源總是實現IQueryable<T>並使用Queryable的擴展方法。
將查詢表達式轉換為SQL語句並不保證一定可以成功。
IQueryable
理解IQueryable的最簡單方式就是,把它看作一個查詢,在執行的時候,將會生成結果序列。
IQueryable是一個繼承了IEnumerable接口的另一個接口。
Queryable是一個靜態類型,它集合了許多擴展方法,擴展的目標是IQueryable和IEnumerable。它令IQueryable和IEnumerable一樣,擁有強大的查詢能力。
AsQueryable方法將IEnumerable<T>轉換為IQueryable<T>。
var seq = Enumerable.Range(0, 9).ToList(); IEnumerable<int> seq2 = seq.Where(o => o > 5); IQueryable<int> seq3 = seq.Where(o => o > 4).AsQueryable();
模擬接口實現一個簡單的LINQ to SQL
下面試圖實現一個非常簡單的查詢提供器(即LINQ to xxx),其可以將簡單的where lambda表達式轉換為SQL,功能非常有限。在LINQ to SQL中lambda表達式首先被轉化為表達式樹,然后再轉換為SQL語句。
我們試圖實現一個可以將where這個lambda表達式翻譯為SQL語句的查詢提供器。
准備工作
首先在本地建立一個數據庫,然后建立一個簡單的表。之后,再插入若干測試數據。用於測試的實體為:
public class Staff { public int Id { get; set; } public string Name { get; set; } public string Sex { get; set; } }
由於VS版本是逆天的2010,且沒有EF,我采用了比較原始的方法,即建立一個mdf格式的本地數據庫。大家可以使用EF或其他方式。
public class DbHelper : IDisposable { private SqlConnection _conn; public bool Connect() { _conn = new SqlConnection { ConnectionString = "Data Source=.\\SQLEXPRESS;" + "AttachDbFilename=Your DB Path" + "Integrated Security=True;Connect Timeout=30;User Instance=True" }; _conn.Open(); return true; } public void ExecuteSql(string sql) { SqlCommand cmd = new SqlCommand(sql, _conn); cmd.ExecuteNonQuery(); } public List<Staff> GetEmployees(string sql) { List<Staff> employees = new List<Staff>(); SqlCommand cmd = new SqlCommand(sql, _conn); SqlDataReader sdr = cmd.ExecuteReader(); while (sdr.Read()) { employees.Add(new Staff{ Id = sdr.GetInt32(0), Name = sdr.GetString(1), Sex = sdr.GetString(2) }); } return employees; } public void Dispose() { _conn.Close(); _conn = null; } }
這個非常簡陋的DbHelper擁有連接數據庫,簡單執行sql語句(不需要返回值,用於DDL或delete語句)和通過執行Sql語句,返回若干實體的功能(用於select語句)。
public static List<Staff> Employees; static void Main(string[] args) { using (DbHelper db = new DbHelper()) { db.Connect(); //db.ExecuteSql("CREATE TABLE Staff ( Id int, Name nvarchar(10), Sex nvarchar(1))"); db.ExecuteSql("DELETE FROM Staff"); db.ExecuteSql("INSERT INTO Staff VALUES (1, 'Frank','M')"); db.ExecuteSql("INSERT INTO Staff VALUES (2, 'Mary','F')"); Employees = db.GetEmployees("SELECT * FROM Staff"); } Console.ReadKey(); }
在主函數中我們執行建表(只有第一次才需要),刪除記錄,並插入兩行新紀錄的工作。最后,我們選出新紀錄並存在List中,這樣我們的准備工作就做完了。我們的目標是解析where表達式,將其轉換為SQL,然后調用ExecuteSql方法返回數據,和通過直接調用where進行比較。
實現IQueryable<T>
首先我們自建一個類別FrankQueryable,繼承IQueryable<T>。因為IQueryable<T>繼承了IEnumerable<T>,所以我們一樣要實現GetEnumerator方法。只有當表達式需要被計算時,才會調用GetEnumerator方法(例如純Select就不會)。另外,IQueryable<T>還有三個屬性:
- Expression:這個很好理解,就是要處理的表達式
- Type
- IQueryProvider:你自己的IQueryProvider。在構造函數中,需要傳入自己的IQueryProvider實現自己的邏輯。
public class FrankQueryable<T> : IQueryable<T> { public IEnumerator<T> GetEnumerator() { throw new NotImplementedException(); } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } public Expression Expression { get; private set; } public Type ElementType { get; private set; } public IQueryProvider Provider { get; private set; } public FrankQueryable() { } }
我們需要實現構造函數和GetEnumerator方法。
實現IQueryProvider
構建一個自己的查詢提供器需要繼承IQueryable<T>。查詢提供器將會做如下事情:
- 調用CreateQuery建立一個查詢,但不計算。只在需要的時候才進行計算。
- 如果需要執行表達式的計算(例如調用了ToList),此時調用GetEnumerator,觸發Execute的執行,從而計算表達式。我們需要把自己的邏輯寫在Execute方法中。並在GetEnumerator中進行調用。
我們要自己寫一個簡單的查詢提供器,所以我們要寫一個IQueryProvider,然后在構造函數中傳入。我們再次新建一個類型,繼承IQueryProvider,此時我們又需要實現四個方法。其中非泛型版本的兩個方法可以暫時不用理會。
public class FrankQueryProvider : IQueryProvider { public IQueryable CreateQuery(Expression expression) { throw new NotImplementedException(); } public IQueryable<TElement> CreateQuery<TElement>(Expression expression) { throw new NotImplementedException(); } public object Execute(Expression expression) { throw new NotImplementedException(); } public TResult Execute<TResult>(Expression expression) { throw new NotImplementedException(); } }
此時FrankQueryable類型的構造函數可以將屬性賦成適合的值,它變成這樣了:
public FrankQueryable(Expression expression, FrankQueryProvider provider) { Expression = expression; ElementType = typeof(T); Provider = provider; }
其中CreateQuery方法的實現很簡單。
public IQueryable<TElement> CreateQuery<TElement>(Expression expression) { Console.WriteLine("Going to CreateQuery"); return new FrankQueryable<TElement>(this, expression); }
然后,我們可以實現FrankQueryable的GetEnumerator方法,它的目的在於呼叫其配套的provider中的Execute方法,從而令我們自己的邏輯得以執行(我們已經在構造函數中傳入了自己的provider):
public IEnumerator<T> GetEnumerator() { Console.WriteLine("Begin to iterate."); var result = Provider.Execute<List<T>>(Expression); foreach (var item in result) { Console.WriteLine(item); yield return item; } }
另外為方便起見,我們加入一個無參數的構造函數,其會先調用有參的構造函數,然后再執行它自己,將表達式設為一個默認值:
public FrankQueryable() : this(new FrankQueryProvider(), null) { //this is T Expression = Expression.Constant(this); }
最后就是FrankQueryProvider的Execute方法了,它的實現需要我們自己手動解析表達式。所以我們可以建立一個ExpressionTreeToSql類,並在Execute方法中進行調用。
public TResult Execute<TResult>(Expression expression) { string sql = ""; //通過某種方式獲得sql(謎之代碼) //ExpressionTreeToSql Console.WriteLine(sql); using (DbHelper db = new DbHelper()) { db.Connect(); dynamic ret = db.GetEmployees(sql); return (TResult) ret; } }
假設我們獲得了正確的SQL語句,那么接下來的事情當然就是連接數據庫獲得結果了。這個已經是現成的了,那么當然最后也是最關鍵的一步就是解析表達式獲得SQL語句了。
注意,CreateQuery每次都產生新的表達式對象,不管相同的表達式是否已經存在,這構成了對表達式進行緩存的動機。
測試IQueryable的運行流程
在進行解析之前,假設我們先把SQL語句寫死,那么我們將會獲得正確的輸出:
public TResult Execute<TResult>(Expression expression) { string sql = "select * from staff where Name = 'Frank'"; Console.WriteLine(sql); using (DbHelper db = new DbHelper()) { db.Connect(); dynamic ret = db.GetEmployees(sql); return (TResult) ret; } }
主程序:
static void Main(string[] args) { using (DbHelper db = new DbHelper()) { db.Connect(); //db.ExecuteSql("CREATE TABLE Staff ( Id int, Name nvarchar(10), Sex nvarchar(1))"); db.ExecuteSql("DELETE FROM Staff"); db.ExecuteSql("INSERT INTO Staff VALUES (1, 'Frank','M')"); db.ExecuteSql("INSERT INTO Staff VALUES (2, 'Mary','F')"); Employees = db.GetEmployees("SELECT * FROM Staff"); } var aa = new FrankQueryable<Staff>(); //Try to translate lambda expression (where) var bb = aa.Where(t => t.Name == "Frank"); Console.WriteLine("Going to compute the expression."); var cc = bb.ToList(); Console.WriteLine("cc has {0} members.", cc.Count); Console.WriteLine("Id is {0}, and sex is {1}", cc[0].Id, cc[0].Sex); Console.ReadKey(); }
此時我們發現,程序的行為將按照我們的查詢提供器來走,而不是默認的IQueryable。(默認的提供器不會打印任何東西)我們的打印結果是:
Going to CreateQuery Going to compute the expression. Begin to iterate. select * from staff where Name = 'Frank' FrankORM.Staff cc has 1 members. Id is 1, and sex is M
當程序運行到
var bb = aa.Where(t => t.Name == "Frank");
這里時,會先調用泛型的CreateQuery方法(因為aa對象的類型是FrankQueryable<T>所以我們會進入自己的查詢提供器,而Where是Queryable的擴展方法所以FrankQueryable自動擁有),然后輸出Going to CreateQuery。然后,因為此時並不計算表達式,所以不會緊接着就進入Execute方法。之后主程序繼續運行,打印Going to compute the expression.
之后,在主程序的下一行,由於我們調用了ToList方法,此時必須要計算表達式了,故程序開始進行迭代,調用GetEnumerator方法,打印Begin to iterate,然后調用Execute方法,仍然是使用我們自己的查詢提供器的邏輯,執行SQL,輸出正確的值。
通過這次測試,我們了解到了整個IQueryable的工作流程。由於Queryable那一大堆擴展方法,我們可以輕而易舉的獲得強大的查詢能力。那么現在當然就是把SQL解析出來,填上整個流程最后的一塊拼圖。
我們將解析方法放入ExpressionTreeToSql類中,並將其命名為VisitExpression。這個類是自己寫ORM必不可少的,有時也通稱為ExpressionVisitor類。
解析Where lambda表達式:第一步
我們的輸入是一個lambda表達式,它是長這樣的:
var bb = aa.Where(t => t.Name == "Frank");
我們的目標則是這樣的:
Select * from Staff where Name = ‘Frank’
其中Staff,Name和Frank是我們需要從外界獲得的,其他則都是語法固定搭配。所以我們需要一個解析表達式的方法,它接受一個表達式作為輸入,然后輸出一個字符串。通過表達式我們可以獲得Name和Frank這兩個值。而我們還需要知道目標實體類的類型名稱Staff,所以我們的解析方法還需要接受一個泛型T。
另外,由於我們的解析方法很有可能是遞歸的(因為要解析表達式樹),我們的輸出還需要用ref加以修飾。所以這個解析方法的簽名為:
public static void VisitExpression<T>(T enumerable, Expression expression, ref string sql)
獲得Select * from Staff這一步是比較容易的:
public static string GenerateSelectHeader<T>(T type) { var typeName = type.GetType().Name.Replace("\"", ""); return string.Format("select * from {0} ", typeName); }
我們的解析方法首先要加上:
public static void VisitExpression<T>(T enumerable, Expression expression, ref string sql) { if (sql == String.Empty) sql = GenerateSelectHeader(enumerable); }
當然這里我們也默認設定是選取實體所有的列了。如果是選取一部分,則還需要解析select表達式。
回到Execute方法,現在謎之代碼也就浮出水面了,它不過是:
ExpressionTreeToSql.VisitExpression(new Staff(), expression, ref sql);
解析Where lambda表達式:第二步
解析的第二步就是where這個表達式了。首先我們要知道它的NodeType(即類型,Type是表達式最終計算結果值的類型)。通過設置斷點,我們看到類型是Call類型,所以我們需要將表達式轉為MethodCallExpression(否則我們將無法獲得任何細節內容,這對於所有類型的表達式都一樣)。
現在我們獲得了where這個方法名。
switch (expression.NodeType) { case ExpressionType.Call: MethodCallExpression method = expression as MethodCallExpression; if (method != null) { sql += method.Method.Name; } break; default: throw new NotSupportedException(string.Format("This kind of expression is not supported, {0}", expression.NodeType)); }
現在我們可以運行程序了,當然,結果sql是錯誤的,我們的解析還沒結束,通過設置斷點檢查表達式的各個變量,我們發現Argument[1]是表達式本身,於是我們通過遞歸繼續解析這個表達式:
我們可以根據每次拋出的異常得知我們下一個表達式的種類是什么。通過異常發現,下一個表達式是一個Quote類型的表達式。它對應的表達式類型是Unary(即一元表達式)。一元表達式中唯一有用的東西就是Operand,於是我們繼續解析:
case ExpressionType.Quote: UnaryExpression expUnary = expression as UnaryExpression; if (expUnary != null) { VisitExpression(enumerable, expUnary.Operand, ref sql); } break;
下一個表達式:t=>t.Name==”Frank”,顯然是一個lambda表達式。它有用的地方就是它的Body(t.Name==”Frank”):
case ExpressionType.Lambda: LambdaExpression expLambda = expression as LambdaExpression; if (expLambda != null) { VisitExpression(enumerable, expLambda.Body, ref sql); } break;
最后,我們終於來到了終點。這回是一個Equal類型的表達式,它的左邊是t.Name,右邊則是“Frank”,都是我們需要的值:
case ExpressionType.Equal: BinaryExpression expBinary = expression as BinaryExpression; if (expBinary != null) { var left = expBinary.Left; var right = expBinary.Right; sql += " " + left.ToString().Split('.')[1] + " = '" + right.ToString().Replace("\"", "") + "'"; } break;
將這些case合起來,一個簡陋的LINQ to SQL解釋器就做好了。此時我們將寫死的SQL去掉,程序應當得到正確的輸出:
public TResult Execute<TResult>(Expression expression) { string sql = ""; ExpressionTreeToSql.VisitExpression(new Staff(), expression, ref sql); Console.WriteLine(sql); using (DbHelper db = new DbHelper()) { db.Connect(); dynamic ret = db.GetEmployees(sql); return (TResult) ret; } }
可以看到,where lambda表達式被轉化為一個復雜的表達式樹。通過手動解析表達式樹,我們可以植入自己的邏輯,從而實現LINQ to SQL不能實現的功能。
當然,例子只是最最基本的情況,如果表達式樹變得復雜,生成出的sql很可能是錯的。
進行簡單的擴展
我們來看看下面這個情況,我們增加一個where表達式:
using (DbHelper db = new DbHelper()) { db.Connect(); //db.ExecuteSql("CREATE TABLE Staff ( Id int, Name nvarchar(10), Sex nvarchar(1))"); db.ExecuteSql("DELETE FROM Staff"); db.ExecuteSql("INSERT INTO Staff VALUES (1, 'Frank','M')"); db.ExecuteSql("INSERT INTO Staff VALUES (2, 'Mary','F')"); db.ExecuteSql("INSERT INTO Staff VALUES (1, 'Roy','M')"); Employees = db.GetEmployees("SELECT * FROM Staff"); } var test = Employees.Where(t => t.Sex == "M").Where(t => t.Name == "Frank"); var aa = new FrankQueryable<Staff>(); //Try to translate lambda expression (where) var bb = aa.Where(t => t.Sex == "M") .Where(t => t.Name == "Frank");
此時我們用IQueryable<T>可以得出正確的結果(test只有1筆輸出),但使用自己的查詢提供器,獲得的SQL卻是錯誤的(第一個Sex = M不見了)。我們發現,問題出在我們解析MethodCallExpression那里。
當只有一個where表達式時,表達式樹是這樣的:
所以我們在解析MethodCallExpression時,直接跳過了argument[0](實際上它是一個常量表達式),而現在我們似乎不能跳過它了,因為現在的表達式樹中,argument[0]是:{value(FrankORM.FrankQueryable`1[FrankORM.Staff]).Where(t => (t.Sex == "M"))}
它包含了有用的信息,所以我們不能跳過它了,我們要解析所有的argument,並使用and進行連接:
case ExpressionType.Call: MethodCallExpression exp = expression as MethodCallExpression; if (exp != null) { if(!sql.Contains(exp.Method.Name)) sql += exp.Method.Name; foreach (var arg in exp.Arguments) { VisitExpression(enumerable, arg, ref sql); } sql += " and "; } break;
此時再運行程序,發生異常。系統提示我們沒有關於constant表達式的解析,對於constant表達式,我們什么都不用做。
case ExpressionType.Constant: break;
使用上面的代碼,再解析一次,我們就得到了一條看上去比較正確的SQL:
select * from Staff Where Sex = 'M' and Name = 'Frank' Sex = 'M' and Name = 'Frank' and
結尾and多出現了一次,這是因為我們每次解析都在最后加上了and。簡單的去掉and,程序就會輸出正確的結果。
這次表達式樹是這樣的:
當然,這個擴展的代碼質量已經非常差了,各種湊數。不過,我在這里就僅以此為例,解釋下如何擴展並為表達式樹解析增加更多的功能,使之可以應付更多類型的表達式。
IQueryable與 IEnumerable的異同?
首先IQueryable<T>是解析一棵樹,IEnumerable<T>則是使用委托。前者的手動實現上面已經講解了(最基本的情況),而后者你完全可以用泛型委托來實現。
IQueryable<T>繼承自IEnumerable<T>,所以對於數據遍歷來說,它們沒有區別。兩者都具有延遲執行的效果。但是IQueryable的優勢是它有表達式樹,所有對於IQueryable<T>的過濾,排序等操作,都會先緩存到表達式樹中,只有當真正發生遍歷的時候,才會將表達式樹由IQueryProvider執行獲取數據操作。
而使用IEnumerable<T>,所有對於IEnumerable<T>的過濾,排序等操作,都是在內存中發生的。也就是說數據已經從數據庫中獲取到了內存中,在內存中進行過濾和排序操作。
當數據源不在本地時,因為IEnumerable<T>查詢必須在本地執行,所以執行查詢前我們必須把所有的數據加載到本地。而且大部分時候,加載的數據有大量的數據是我們不需要的無效數據,但是我們卻不得不傳輸更多的數據,做更多的無用功。而IQueryable<T>卻總能只提供你所需要的數據,大大減少了傳輸的數據量。
IQueryable總結
- 理解IQueryable的最簡單方式就是,把它看作一個查詢,在執行的時候,將會生成結果序列。
- 繼承IQueryable<T>意味着獲得強大的查詢能力,這是因為自動獲得了Queryable的一大堆擴展方法。
- 當對一個IQueryable<T>的查詢進行解析時,首先會訪問IQueryable<T>的QueryProvider,然后訪問CreateQuery<T>方法,並將輸入的查詢表達式傳入,構建查詢。
- 一個查詢進行執行,就是開始遍歷IQueryable的過程,其會調用Execute方法並傳遞表達式樹。
- 不是所有的表達式樹都可以翻譯成SQL。例如ToUpper就不行。
- 自己寫一個ORM意味着要自己寫一個QueryProvider,自定義Execute方法來解析表達式樹。所以,你必須要有一個解析表達式樹的類,通常大家都叫它ExpressionVisitor。
- 通常使用遞歸的方式解析表達式樹,這是因為表達式樹的任意結點(包括葉結點)都是表達式樹。
- CreateQuery每次都產生新的表達式對象,不管相同的表達式是否已經存在,這構成了對表達式進行緩存的動機。
ORM和經典的Datatable的優劣比較
好處:
- 提供面向對象和強類型,慣用OO語言的程序員會很快上手。
- 隱藏了數據訪問細節,使得干掉整個DAL成為可能。在三層架構中BL要去調用DAL來獲得數據,而現在BL可以直接通過lambda表達式等各種方式獲得數據,不再需要DAL。
- 將程序員從對SQL語句的拼接(尤其是insert)中解放出來,它既容易錯,又很難發現錯誤。現在插入的對象都是強類型的,就猶如插入一個List一樣。
- 以相同的語法操作各種不同的數據庫(例如oracle, SQL server等)
- 與經典的DataReader相比,當數據表的某欄的數據類型發生改變時,DataReader就會發生錯誤(傳統的方式是使用DataReader.Read方法一行行讀取數據,然后通過GetString,GetInt32等方法獲得每一列的數據)。而且錯誤在運行時才會發生。ORM則會在編譯時就會發生錯誤,而且只需要更改對象屬性的類型就不會發生問題。
缺點:
- 有些復雜的SQL或者SQL內置的方法不能通過ORM翻譯。
- 自動產生的SQL語句有時的性能較低,這跟產生的機理有關。對於不熟悉ORM的程序員,可能會導致編寫出的程序性能低劣。
- 難以替代Store procedure。
ORM的核心是DbContext。它可以看成是一個數據庫的副本,我們只需要訪問它的方法就可以實現對數據庫的CRUD。
擴展閱讀
表達式樹上手指南:
http://www.cnblogs.com/Ninputer/archive/2009/09/08/expression_tree3.html
對表達式樹緩存以進一步提高性能:
http://blog.zhaojie.me/2009/03/expression-cache-1.html
自己實現的LINQ TO 博客園:
http://www.cnblogs.com/jesse2013/p/expressiontree-part1.html
帶有GIF的IQueryable講解:
http://www.cnblogs.com/zhaopei/p/5792623.html