随着Linq的普及,大家都对实现自己的Linq provider很感兴趣。VB Team Kevin 了两篇相关的文章,我觉得很有帮助。现在尝试翻译了第一篇,希望大家能够从中有所收获。


原文是
How to implement IQueryable (Part 1)


随着
OrcasVisual Studio 2008code name)的推出,微软计划为我们经常用到的一些数据应用的场合提供Linq的支持。比如:DLinq用于SQL ServerXlinq则用于XML相关的处理。事实上还有其他数不清的数据读取的场合,用户也希望能用上Linq这个方便快捷的工具。多数情况下,我们只需要把数据放到CLR Collection里,用Linq基本的支持足以。举个例子来说:要是你想找到我的文档里的所有新放进来的.exe文件,可以用下边这句:

Dim newExe = From fileName In Directory.GetFiles( _

My.Computer.FileSystem.SpecialDirectories.MyDocuments, _

"*.exe", SearchOption.AllDirectories) _

Where (New FileInfo(fileName)).CreationTime > #6/30/2007# _

Select fileName

很简单,是吧?

这样看起来很好,唯一的缺点是没有涉及到我们这篇文章的主题实现IQueryable。下边还是看看怎么通过实现IQueryable达到我们的目的,也就是为Linq提供我们自已的Linq Provider

在当今的开发领域,已经存在很多的程序接口和对象模型提供对各式各样的数据的读取和操作。比如:Windows Desktop Search (关于它的详细内容参见http://www.microsoft.com/windows/desktopsearch/default.mspx)

它就提供了一个OLE DB Provider让你能查询系统已经建立好的各种文件信息的索引。那么,我们可不可以不用写SQL语句而是使用Linq来访问这些内容呢?答案是:能,但我们要实现Linq Provider。对相关背景知识不太了解而又有兴趣的朋友来说,本文末尾的参考资源很有必要,不妨一看。

写一个自己的Linq provider,首先要做的就是实现IQueryableIQueryProvider这两个接口。因为我们要操作的是文件对象(FileInfo),所以要实现IQueryable(Of FileInfo),代码如下:

Imports System.IO

PublicClass WDSQueryObject

    Implements IQueryable(Of FileInfo), IQueryProvider

EndClass

当你在Visual Studio里敲出(估计没有人真的会敲)或是Paste出上面的这些代码,IDE会提示你要实现这两个接口,有几个方法你必须要实现。下面我一一介绍:

注意:
    l 完整的代码在末尾的给出的链接里能找到,下载下来并调试运行一下,收获更大。

  l 我的代码基于Orcas Beta2IQueryable接口在Beta1版本后有过重构。


CreateQuery

IQueryProvider接口里面定义了两个CreateQuery方法. 一个返回泛型的 IQueryable(Of TElement)。另外一个返回非泛型的 Iqueryable。大多数情况下你可能只需要在非泛型的里面调用泛型的那个。就象下面这样:

    Public Function CreateQuery(ByVal expression As Expression) As IQueryable Implements IQueryProvider.CreateQuery

        Return CreateQuery1(Of FileInfo)(expression)

    End Function

对一个简单查询来说,每一个“Where”子句中的过滤条件都会调用一次CreatQuery,每一个“Select”会调用一次CreatQuery。像下面这一句:

        Dim r = From file In index _

                Where file.Name Like"%.exe" _

          Select file.FullName

表达式file.Name Like"%.exe"file.FullName分别会调用一次CreateQuery方法。下面是我实现处理这两个语句的代码的框架:

    Public Function CreateQuery1(Of TElement)(ByVal expression As Expression) As IQueryable(Of TElement) Implements IQueryProvider.CreateQuery

        Dim querySource As IQueryable(Of TElement) = Nothing

        Dim nodeType = expression.NodeType

        Select Case nodeType

            Case ExpressionType.Call

                Dim m As MethodCallExpression = expression

                Dim methodName = m.Method.Name

                Select Case methodName

                    Case "Select"

               ' insert Select processing code

                    Case "Where"

               ' insert Where processing code                   

CaseElse

                        Throw New NotSupportedException("Queries using '" & methodName & "' are not supported for this collection.")

                End Select

            Case Else

                Throw New NotSupportedException("Creating a query from an expression of type '" & nodeType & "' is supported.")

        End Select

        Return querySource

    End Function

你可能注意到了我们在CreateQuery里得到的expression已经包括了调用者的信息(比如:SelectWhere等等),我们将用到这些信息来处理剩下的内容。在这个文件系统的例子里,“Where”子句是比较有意思的,我们先来谈谈它。WhereQueryable中定义的一个扩展方法,它的签名如下:

    PublicSharedFunction Where(Of TSource)( _

ByVal source As IQueryable(Of TSource), _

ByVal predicate As Expression(Of System.Func(Of TSource, Boolean)) _

) As System.Linq.IQueryable(Of TSource)

你要是察看它的expression tree里的详细信息(下图),你会发现关于上面签名的信息被处理过了。按层次展开下面的expression tree,就能得到上面签名的对应结构。第一层:方法名是Where<FileInfo>,返回类型是IQueryable<FileInfo>第二层是两个参数:一个是作为sourceWDSQueryObject,还有一个是作为predicatelambda表达式。





下面是上面代码中
' insert Where processing code部分的内容,我来详细介绍一下:

m_query = New StringBuilder()

m_funclets = New List(Of KeyValuePair(OfString, Func(OfString)))()

Dim lambda As LambdaExpression = CType(m.Arguments(1), UnaryExpression).Operand

ExpandExpression(lambda.Body)

m_query.Insert(0, "SELECT System.ItemPathDisplay FROM SystemIndex WHERE NOT CONTAINS(System.ItemType, 'folder') AND (")

m_query.Append(")")

querySource = Me

想必大家都了解,上面的SELECT那一句是用来拼接SQL字符串用的。关于如何写用于Windows Desktop SearchSQL语句,请参考后面给出的关于WDS的链接。这里要说明的是,在第二次调用CreateQuery时才会处理Linq中的Select部分。从上面的Expression tree中我们可以看出,Where的第一个参数(是个常量表达式)是指向我们的WDSQueryObject的一个引用。这个参数也就是实现IQueryable.Expression的返回值。第二个参数是需要被我们转换成SQL语句的lambda表达式,这个也就是我们实现Iqueryable的核心。我们要做的就是,把Linq表达式转换成一组指令用于从制定的数据原理取得数据。在我的代码里,是由方法ExpandExpression具体负责这一转换过程的。它遍历整个expression tree并把它展开转换成相对应的SQL语句。方法ExpandExpression返回的时候,m_query里就包含了与Linqwhere条件相对应的SQL语句。然后调用

m_query.Insert(0, "SELECT System.ItemPathDisplay FROM SystemIndex WHERE NOT CONTAINS(System.ItemType, 'folder') AND (")

m_query.Append(")")

就构造出了完整的SQL句子。

下面是ExpandExpression的代码:

    Private Sub ExpandExpression(ByVal e As Expression)

        Select Case e.NodeType

            Case ExpressionType.And

                ExpandBinary(e, "AND")

            Case ExpressionType.Equal

                ExpandBinary(e, "=")

            Case ExpressionType.GreaterThan

                ExpandBinary(e, ">")

            Case ExpressionType.GreaterThanOrEqual

                ExpandBinary(e, ">=")

            Case ExpressionType.LessThan

                ExpandBinary(e, "<")

            Case ExpressionType.LessThanOrEqual

                ExpandBinary(e, "<=")

            Case ExpressionType.NotEqual

                ExpandBinary(e, "!=")

            Case ExpressionType.Not

                ExpandUnary(e, "NOT")

            Case ExpressionType.Or

                ExpandBinary(e, "OR")

            Case ExpressionType.Call

                ExpandCall(e)

            Case ExpressionType.MemberAccess

                ExpandMemberAccess(e)

            Case ExpressionType.Constant

                ExpandConstant(e)

            Case Else

                Throw New NotSupportedException("Expressions of type '" & e.NodeType.ToString() & "' are not supported.")

        End Select

    End Sub

我们通过判断expression tree的节点类型,对希望能够支持的操作,调用了相应的处理方法。虽然在这儿我们没有支持所有的表达式类型,可最常用的基本都包括了(凑合够用了)。下面我们看看一个简单的Linq 语句是怎样得到处理的。如下:

        Dim index AsNew WDSQueryObject

        Dim cutoffDate = #6/28/2007#

        Dim r = From file In index _

                Where file.CreationTime > cutoffDate And _

                file.Name Like"%.exe" _

                Select file.FullName

在处理Whereexpression tree的过程中,第一个被调用的方法是ExpandBinaryExpandBinary又会调用ConcatBinaryConcatBinary通过合适的操作符来把左右两边连到一块儿(在这儿是“AND”)

    Private Sub ExpandBinary(ByVal b As BinaryExpression, ByVal op AsString)

        ConcatBinary(b.Left, b.Right, op)

    End Sub

    Private Sub ConcatBinary(ByVal left As Expression, ByVal right As Expression, ByVal op AsString)

        ExpandExpression(left)

        m_query.Append(" ")

        m_query.Append(op)

        m_query.Append(" ")

        ExpandExpression(right)

    End Sub

处理And语句左边部分会再次调用ConcatBinary(这次是处理>),接着会调用ExpandMemberAccess,如下:

    Private Sub ExpandMemberAccess(ByVal m As MemberExpression)

        Dim member = m.Member

        Dim e = m.Expression

        Select Case e.NodeType

            Case ExpressionType.Parameter

                ' Parameter processing code

            Case ExpressionType.Constant

                ' Constant processing code

            Case Else

                Throw New NotSupportedException("Accessing member '" & member.Name & "' is not supported in this context.")

        End Select

    End Sub

先来看看代码中的Parameter processing code。此处,Parameter就是整个查询中用到的迭代器,也就是指From file In index中的file我们要做的就是把FileInfo 类型的属性的名称(如:file.CreationTime)转换成.net中对应的windows文件系统的属性名称。如下:

    PrivateFunction GetAttributeName(ByVal m As MemberInfo) AsString

        Dim name AsString

        Dim memberName = m.Name

        Select Case memberName

            Case "CreationTime"

                name = "System.DateCreated"

            Case "Name"

                name = "System.FileName"

            Case Else

                Throw New NotSupportedException("Using the property '" & memberName & "' in filter expressions is not supported.")

        End Select

        Return name

    End Function

跟前面一样,目前我们对属性的支持并不完整,但这并不妨碍我们的理解和简单的使用。完整的支持请参考本文末尾的链接。

下面介绍一下Constant processing code’.  在这儿我们把对变量cutoffDate的访问翻译SQL语言。 如下:

Dim valueName = "[value" & m_funclets.Count & "]"

Dim valueFunc As Func(OfString) = Nothing

Dim memberType = member.MemberType

If m.Type IsGetType(String) OrElse m.Type IsGetType(Date) Then

m_query.Append("'")

m_query.Append(valueName)

m_query.Append("'")

Else

m_query.Append(valueName)

EndIf

Dim funclet As Func(OfString) = Nothing

SelectCase memberType

Case MemberTypes.Field

Dim f As FieldInfo = member

Dim c As ConstantExpression = e

If m.Type IsGetType(Date) Then

funclet = Function() CDate(f.GetValue(c.Value)).ToString("yyyy-MM-dd")

Else

funclet = Function() CStr(f.GetValue(c.Value))

EndIf

CaseElse

           Throw New NotSupportedException("Accessing member of type'" & memberType & "' is not supported.")

EndSelect

m_funclets.Add(New KeyValuePair(OfString, Func(OfString))(valueName, funclet))

看到上面的代码,很多朋友会问里面那个funclet是什么东东?用来做啥?这个就涉及到了Linq架构中一个很重要的特点延迟执行。换句话说,在我们建立一个query时,我们只是定义了它,并没有运行它(废话)。而有很多信息只有运行时才能被获知比如说cutoffDate的值#6/28/2007#这就是说我们没有办法在执行query这个query拿到#6/28/2007#并查询底层的数据源)。因此,我们想存储关于如何得到cutoffDate的值的信息,而不是它的具体的值。我在这所做的就是,在查询字符串中放了一个占位符([value*]),并建了一个函数,让这个函数在可以拿到查询结果的时候返回cutoffDate的值。

我在这儿用到了lambda表达式,当你想创建一个inline函数或匿名代理的时候,用lambda表达式很方便。它同时会自动创建一个closure类来保存我在当前block里读到的所有变量的信息。比如上面:当代码进入“Case” 后,就会生成一个新的closure类,变量 ‘f’ 和‘c’的值都会存到里面。编译器会自动把对这些局部变量的读取转换成对相应的closure类的字段的读取。执行的query时候,就会执行上面的funclet来替换占位符[value*]这要就能在运行query时拿到变量的值(而不是在query被创建的时候)。你可能会注意到cutoffDate的MemberAccessExpression,同样是一个已被提升过的局部变量。这也就是为什么它的成员类型Field,正因为是在query中用到cutoffDate ,他的值其实是存到了closure 中的一个字段里。

关于Closures类的相关内容请参考:http://blogs.msdn.com/vbteam/search.aspx?q=closure+&p=1

接下来谈谈“file.Name Like"%.exe"”。你可能会奇怪为什么我们在ExpandCall里处理这一部分,而不是ExpandBinary。事实上,VB编译器把一些的二元操作符直接转换成对VB运行库中相应方法的调用。这给VB添加了一些CLR没有的功能。比如:LikeString (由VB中的Like操作生成) 和CompareString (由VB中的字符串比较的表达式例如““a” = “A” ”生成)。下面是我实现的ExpandCall中处理LikeString的一段:

    Private Sub ExpandCall(ByVal m As MethodCallExpression, OptionalByVal op AsString = "")

        Dim methodName = m.Method.Name

        Select Case methodName

            Case "LikeString"

                ConcatBinary(m.Arguments(0), m.Arguments(1), "LIKE")

            Case Else

                Throw New NotSupportedException("Using method '" & methodName & "' in a filter expression is not supported.")

        End Select

    End Sub

处理“Where”语句所做的最后一件事就是处理常量字符串值"%.exe"。这一步很简单,值得在此一提的是,一些数据类型默认的转换操作不一定适用于你的数据源。比如下面:WDS就要求日期必须是指定的格式。

    Private Sub ExpandConstant(ByVal c As ConstantExpression)

        Dim value = c.Value

        If value.GetType() IsGetType(String) Then

            m_query.Append("'")

            m_query.Append(CStr(value))

            m_query.Append("'")

        ElseIf value.GetType() IsGetType(Date) Then

            m_query.Append("'")

            m_query.Append(CDate(value).ToString("yyyy-MM-dd"))

            m_query.Append("'")

        Else

            m_query.Append(value.ToString())

        End If

    End Sub

处理完“Where语句后,最终我们得到的传给WDS的字符串如下:

"SELECT System.ItemPathDisplay FROM SystemIndex WHERE NOT CONTAINS(System.ItemType, 'folder') AND (System.DateCreated > '[value0]' AND System.FileName LIKE '%.exe')"

在我的下一篇博客里,会讲到GetEnumeratorSelect

Resources

Full source code for this project:

http://hresult.members.winisp.net/FileSystemQuery.zip

Bart De Smet’s excellent blog on Implementing IQueryable for Linq to LDAP:

http://community.bartdesmet.net/blogs/bart/archive/2007/04/05/the-iqueryable-tales-linq-to-ldap-part-0.aspx

Fabrice Marguerie’s blog in implementing Linq to Amazon:

http://weblogs.asp.net/fmarguerie/archive/2006/06/26/Introducing-Linq-to-Amazon.aspx

Catherine Heller’s blog on Windows Desktop (Vista) Search:

http://blogs.msdn.com/cheller/archive/2006/06/21/642220.aspx

List of query attributes supported by the Windows filesystem

http://msdn2.microsoft.com/en-us/library/aa830600.aspx


 另外大家也可以参考我们team 的blog里的一些资源 http://blog.joycode.com/vbcti/category/1465.aspx