随着Linq的普及,大家都对实现自己的Linq provider很感兴趣。VB Team的 Kevin 写了两篇相关的文章,我觉得很有帮助。现在尝试翻译了第一篇,希望大家能够从中有所收获。
原文是How to implement IQueryable (Part 1)
随着Orcas(Visual Studio 2008的code name)的推出,微软计划为我们经常用到的一些数据应用的场合提供Linq的支持。比如:DLinq用于SQL Server,Xlinq则用于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,首先要做的就是实现IQueryable和IQueryProvider这两个接口。因为我们要操作的是文件对象(FileInfo),所以要实现IQueryable(Of FileInfo),代码如下:
Imports System.IO
PublicClass WDSQueryObject
Implements IQueryable(Of FileInfo), IQueryProvider
EndClass
当你在Visual Studio里敲出(估计没有人真的会敲)或是Paste出上面的这些代码,IDE会提示你要实现这两个接口,有几个方法你必须要实现。下面我一一介绍:
注意:
l 完整的代码在末尾的给出的链接里能找到,下载下来并调试运行一下,收获更大。
l 我的代码基于Orcas Beta2。IQueryable接口在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已经包括了调用者的信息(比如:Select,Where等等),我们将用到这些信息来处理剩下的内容。在这个文件系统的例子里,“Where”子句是比较有意思的,我们先来谈谈它。Where是Queryable中定义的一个扩展方法,它的签名如下:
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>。第二层是两个参数:一个是作为source的WDSQueryObject,还有一个是作为predicate的lambda表达式。
下面是上面代码中“' 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 Search的SQL语句,请参考后面给出的关于WDS的链接。这里要说明的是,在第二次调用CreateQuery时才会处理Linq中的Select部分。从上面的Expression tree中我们可以看出,Where的第一个参数(是个常量表达式)是指向我们的WDSQueryObject的一个引用。这个参数也就是实现IQueryable.Expression的返回值。第二个参数是需要被我们转换成SQL语句的lambda表达式,这个也就是我们实现Iqueryable的核心。我们要做的就是,把Linq表达式转换成一组指令用于从制定的数据原理取得数据。在我的代码里,是由方法ExpandExpression具体负责这一转换过程的。它遍历整个expression tree并把它展开转换成相对应的SQL语句。方法ExpandExpression返回的时候,m_query里就包含了与Linq中where条件相对应的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
在处理Where的expression tree的过程中,第一个被调用的方法是ExpandBinary。ExpandBinary又会调用ConcatBinary,ConcatBinary通过合适的操作符来把左右两边连到一块儿(在这儿是“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')"
在我的下一篇博客里,会讲到GetEnumerator和Select。
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:
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