.NET面試題系列[14] - LINQ to SQL與IQueryable


.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>還有三個屬性:

  1. Expression:這個很好理解,就是要處理的表達式
  2. Type
  3. 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>。查詢提供器將會做如下事情:

  1. 調用CreateQuery建立一個查詢,但不計算。只在需要的時候才進行計算。
  2. 如果需要執行表達式的計算(例如調用了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總結

  1. 理解IQueryable的最簡單方式就是,把它看作一個查詢,在執行的時候,將會生成結果序列。
  2. 繼承IQueryable<T>意味着獲得強大的查詢能力,這是因為自動獲得了Queryable的一大堆擴展方法。
  3. 當對一個IQueryable<T>的查詢進行解析時,首先會訪問IQueryable<T>的QueryProvider,然后訪問CreateQuery<T>方法,並將輸入的查詢表達式傳入,構建查詢。
  4. 一個查詢進行執行,就是開始遍歷IQueryable的過程,其會調用Execute方法並傳遞表達式樹。
  5. 不是所有的表達式樹都可以翻譯成SQL。例如ToUpper就不行。
  6. 自己寫一個ORM意味着要自己寫一個QueryProvider,自定義Execute方法來解析表達式樹。所以,你必須要有一個解析表達式樹的類,通常大家都叫它ExpressionVisitor。
  7. 通常使用遞歸的方式解析表達式樹,這是因為表達式樹的任意結點(包括葉結點)都是表達式樹。
  8. CreateQuery每次都產生新的表達式對象,不管相同的表達式是否已經存在,這構成了對表達式進行緩存的動機。

ORM和經典的Datatable的優劣比較

好處:

  1. 提供面向對象和強類型,慣用OO語言的程序員會很快上手。
  2. 隱藏了數據訪問細節,使得干掉整個DAL成為可能。在三層架構中BL要去調用DAL來獲得數據,而現在BL可以直接通過lambda表達式等各種方式獲得數據,不再需要DAL。
  3. 將程序員從對SQL語句的拼接(尤其是insert)中解放出來,它既容易錯,又很難發現錯誤。現在插入的對象都是強類型的,就猶如插入一個List一樣。
  4. 以相同的語法操作各種不同的數據庫(例如oracle, SQL server等)
  5. 與經典的DataReader相比,當數據表的某欄的數據類型發生改變時,DataReader就會發生錯誤(傳統的方式是使用DataReader.Read方法一行行讀取數據,然后通過GetString,GetInt32等方法獲得每一列的數據)。而且錯誤在運行時才會發生。ORM則會在編譯時就會發生錯誤,而且只需要更改對象屬性的類型就不會發生問題。

缺點:

  1. 有些復雜的SQL或者SQL內置的方法不能通過ORM翻譯。
  2. 自動產生的SQL語句有時的性能較低,這跟產生的機理有關。對於不熟悉ORM的程序員,可能會導致編寫出的程序性能低劣。
  3. 難以替代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 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM