代码改变世界

Linq to SharePoint,看上去很美

2012-04-12 09:46 by Windie Chai, ... 阅读, ... 评论, 收藏, 编辑

Linq to SharePoint是SharePoint 2010引入的一组新API,在这之前,如果我们想要按照条件过滤SharePoint列表中的数据,只能通过CAML。

但使用CAML并不是件令人身心愉悦的事情,至少我是这么认为的。我觉得在代码中嵌入一块冗长的XML字符串非常破坏美感,我尤其喜欢强类型,所以一直很难接受SPListItem用字符串作为键值去获取Field值的方式,更别提这些值都是Object类型,还得再经过一次转换。

所以我比较喜欢将SPListItem转换成实体类来使用,只不过一直以来的做法都是自己写实体类和转换方法。而Linq to SharePoint则可以自动将列表映射为实体类,并且可以使用Linq语句来进行查询,看上去很美!

那么Linq to SharePoint能不能帮我彻底摆脱CAML呢,趁着重构代码的机会研究了一下,在这里简单总结一下。

前面说过Linq to SharePoint可以自动生成列表的实体类,这是通过一个叫做SPMetal的工具来实现的,具体的用法请查阅这里

SPMetal会根据实际的列名来生成实体类中的属性名,所以如果你的列名是中文的话(譬如你安装了中文版SharePoint),你会得到一份非常诡异且冗长的代码文件。

当然,如果你能接受中英文混排的代码的话,这倒也不是什么问题。

SPMetal生成的属性大多是下面这个样子的:

[ColumnAttribute(Name = "Body", Storage = "_body", FieldType = "Note")]
public string Body
{
  get
  {
    return this._body;
  }
  set
  {
    if ((value != this._body))
    {
       this.OnPropertyChanging("Body", this._body);
       this._body = value;
       this.OnPropertyChanged("Body");
    }
  }
}
protected string _body;

Body属性被附加了一个ColumnAttribute,它的作用是将属性和SharePoint中的某一列关联起来。在它的命名参数中,Name表示的就是SharePoint中的列名,FieldType指列的类型,Storage表示的是实体类中用来存放列值的变量,可以看到这里为它指定的是一个变量,而不是Body属性,也就是说,在初始化这一实体的时候,该列的值会直接赋给_body变量,而不经过Body属性。那么Body属性的set访问器又是用来干什么的呢?实际上它的作用只是为了提供一种更改列值的机制,这一点从它复杂的内部流程也能看出端倪。

如果你只是为了查询方便,并不需要修改和提交数据的话,完全可以使用下面的只读版本:

[Column(Name = "Body", Storage = "_body", FieldType = "Note")]
public string Body
{
  get
  {
    return this._body;
  }
}
protected string _body;

此外,如果列表的包含一些设置为可空值的列的话,它们会被映射成一个Nullable<T>类型,如下所示:

[ColumnAttribute(Name="RatingCount", Storage="_ratingCount", FieldType="Number")]
public System.Nullable<double> RatingCount{
  get {
    return this._ratingCount;
  }
  set {
    if ((value != this._ratingCount)) {
      this.OnPropertyChanging("RatingCount", this._ratingCount);
      this._ratingCount= value;
      this.OnPropertyChanged("RatingCount");
    }
  }
}
private System.Nullable<double> _ratingCount;

虽然可以理解这么做的原因,但是却很难接受这种代码。尤其是在HTML中做绑定时,你不得不针对Nullable属性额外写一些代码来处理它的非空情况。

好在我们可以将属性本身改成非空的类型,然后在get访问器里根据情况返回真实的值或者默认值:

[Column(Name="RatingCount", Storage="_ratingCount", FieldType="Counter")]
public double RatingCount{
  get {
    return this._ratingCount ?? 0;
  }
}
private double? _ratingCount;

但要注意Storage指向的变量还得是Nullable类型,以保存列的真实的值(包括空值);属性的类型虽然可以改为非空类型,但要注意类型一定要和对应的变量相同,因为ColumnAttribute会在初始化时检查属性的实际类型。我曾尝试写过下面这样的属性,结果只收获了一个异常:

[Column(Name="RatingCount", Storage="_ratingCount", FieldType="Counter")]
public int RatingCount{
  get {
    return this._id ? (int)this._id.Value : 0;
  }
}
private double? _ratingCount;

为什么想要这么做呢?因为我实在想不出投票总数为什么会是一个小数?

如果你刚巧需要使用Linq语句查询列表,而且查询条件刚巧也是一个包含可空值的列的话,就不能用上面提到的方法来修改属性的类型了,否则Linq to SharePoint将无法生成CAML,结果也是以异常告终。

如果想让可空值列的映射属性既能在Linq语句里作为条件,又能让调用者方便使用的话,只能像下面这样定义它们,对,是它们:

[ColumnAttribute(Name = "RatingCount", Storage = "_ratingCount", FieldType = "Number")]
public double? RatingCountField
{
  get { return _ratingCount; }
}
protected double? _ratingCount;
public int RatingCount { get { return this._ratingCount.HasValue ? (int)this._ratingCount.Value : 0; } }

平常使用int类型的RatingCount,在Linq语句里查询时使用double?类型的RatingCountField。

坦白说,我很讨厌这样的代码,两个含义相同的属性必然会让其他阅读者感到困惑。

此外,如果列是一个查阅项(譬如Author列),我们可以做到映射这个查阅项的完整字符串(譬如“12;#windstyle\chai”),或者查阅项的ID(譬如“12”),或者查阅项的值(譬如“windstyle\chai”),所做的仅仅是在ColumnAttribute里指定IsLookupId或IsLookupValue(如果要拿到完整字符串,则什么都别指定):

[Column(Name = "Author", Storage = "_authorId", FieldType = "Text", IsLookupId = true)]
public int AuthorId
{
  get { return _authorId; }
}
protected int _authorId;

而且如果指定了IsLookupId,就可以在Linq语句中使用这一属性来做查询了。

以上提到的都是关于列与属性的映射,然而有一些列很难通过简单的映射变成属性,那就需要另外一种机制:自定义映射。

自定义映射需要实体类实现ICustomMapping接口,并实现它的三个成员方法MapFrom、MapTo和Resolve,我们这里只讨论只读实体类的情况,只需实现MapFrom即可:

[CustomMapping(Columns = new string[] { Attachments" })]
public override void MapFrom(object listItem)
{
  var item = listItem as Microsoft.SharePoint.SPListItem;
  this.IsRootPost = item["IsRootPost"].ToString();
  if (this.IsRootPost == "1")
    this.Url = item.Web.Url + "/" + item.Folder.Url;
  else
    this.Url = new Uri(new Uri(item.Web.Url), item.ParentList.DefaultDisplayFormUrl + "?id=" + item.ID).ToString();
}

MapFrom方法包含一个listItem参数,可以通过它来拿到SPListItem的列值,具体能拿到哪些列,需要在修饰MapFrom的CustomMappingAttribute中指定。

在使用Lambda表达式进行查询时,CustomMappingAttribute中指定的列名以及之前属性映射时指定的列名都会成为ViewFields的一员。

但需要注意的是,如果你在Linq语句中使用了通过MapFrom映射而来的属性,那么它将不会出现在CAML的Query语句中,Linq to SharePoint采取的方法是把所有SPlistItem都获取并转换成实体类,然后通过Linq to Objects来进行第二次查询(而普通的映射属性则不存在这个问题)。

这当然是极大的性能隐患,然而在Linq to SharePoint中,类似的性能隐患还不止这一处,而且稍不注意就会中招。

譬如根据ID来查找某一Item,我们通常会写出这样的代码:

var item = list.First(i => i.Id == root.ID);

或者

var item = list.Where(i => i.Id == root.ID).First();

或者

var item = list.Where(i => i.Id == root.ID).Single();

这三行代码看起来没有任何问题,而且最终也会被翻译成一模一样的CAML(我省去了ViewFields):

<View>
  <Query>
    <Where>
      <Eq>
        <FieldRef Name="ID" />
        <Value Type="Counter">16</Value>
      </Eq>
    </Where>
  </Query>
  <RowLimit Paged="TRUE">2147483647</RowLimit>
</View>

注意RowLimit,它的值居然是2147483647,这表示查询会返回列表中的所有条目,并将它们都转换成实体类,然后再使用Linq to Objects来进行查询。

MSDN的这篇文档中的“Additional Performance Considerations”一节虽然明确了哪些方法会导致这种行为,但First和Single这两个方法居然都被标记为Efficient。

那么正确的获取单个条目的查询表达式该怎么写呢?使用Take方法,只有Take方法才会正确的翻译成RowLimit:

this.Root = list.Where(i => i.Id == root.ID).Take(1).Single();

它会被翻译成(同样省去了ViewFields):

<View>
  <Query>
    <Where>
      <Eq>
        <FieldRef Name="ID" />
        <Value Type="Counter">16</Value>
      </Eq>
    </Where>
  </Query>
  <RowLimit Paged="TRUE">1</RowLimit>
</View>

同样的试验还可以参考这里

此外,我们知道Linq可以使用Take和Skip方法来进行数据分页,但在Linq to SharePoint中,Skip方法并不会翻译成CAML的分页语句,它还是会拿到所有条目。

写到这里,基本上已经把我所遇到的所有难以接受的部分都介绍完了,其实前面几条,若不是有严重的代码洁癖的话,也可以不必在意。

最后提到的性能问题才是关键所在,而且你很难通过阅读代码来发现问题所在,微软的官方文档对性能问题的解释也模棱两可,一会儿说性能很棒,一会儿又说可能会导致极差的性能。所以最好还是检查一下每一条查询语句生成的CAML是否有问题(DataContext的Log属性会输出所有翻译后的CAML)。

现在你应该明白标题的含义了。