Effective C# 原则41:选择DataSet而不是自定义的数据结构

JerryXia 发表于 , 阅读 (582)

因为两个原则,把DataSet的名声搞的不好。首先就是使用XML序列化的DataSet与其它的非.Net代码进行交互时不方便。如果在Web服务的API中使用DataSet时,在与其它没有使用.Net框架的系统进行交互时会相当困难。其次,它是一个很一般的容器。你可以通过欺骗.Net框架里的一些安全类型来错误DataSet。但在现代软件系统中,DataSet还可以解决很多常规的问题。如果你明白它的优势,避免它的缺点,你就可以扩展这个类型了。

DataSet类设计出来是为了离线使用一些存储在相关数据库里的数据。你已经知道它是用来存储DataTable的,而DataTable就是一个与数据库里的结构在行和列上进行匹配的内存表。或许你已经看到过一些关于DataSet支持在内部的表中建立关系的例子。甚至还有可能,你已经见过在DataSet里验证它所包含的数据,进行数据约束的例子。

但不仅仅是这些,DataSet还支持AcceptChanges 和RejectChanges 方法来进行事务处理,而且它们可以做为DiffGrams存储,也就是包含曾经修改过的数据。多个DataSet还可以通过合并成为一个常规的存储库。DataSet还支持视图,这就是说你可以通过标准的查询来检测数据里的部份内容。而且视图是可以建立在多个表上的。

然而,有些人想开发自己的存储结构,而不用DataSet。因DataSet是一个太一般的容器,这会在性能上有所损失。一个DataSet并不是一个强类型的存储容器,其实存储在里面的对象是一个字典。而且在里的表中的列也是字典。存储在里的元素都是以System.Object的引用形式存在。这使得我们要这样写代码:

int val = (int)MyDataSet.Tables["table1"].
  Rows[0]["total"];

以C#强类型的观点来看,这样的结构是很麻烦的。如果你错误使用table1 或者total的类型,你就会得到一个运行时错误。访问里面的数据元素要进行强制转化。而这样的麻烦事情是与你访问里面的元素的次数成正比的,与其这样,我们还真想要一个类型化的解决方法。那就让我们来试着写一个DataSet吧,基于这一点,我们想要的是:

int val = MyDataSet.table1.Rows[ 0 ].total;

当你看明白了类型化的DataSet内部的C#实现时,就会知道这是完美的。它封装了已经存在的DataSet,而且在弱类型的访问基础上添加了强类型访问。你的用户还是可以用弱类型API。但这并不是最好的。

与它同时存在的,我会告诉你我们放弃了多少东西。我会告诉你DataSet类里面的一些功能是如何实现的,也就是在我们自己创建的自定义集合中要使用的。你可能会觉得这很困难,或者你觉得我们根本用上不同DataSet的所有功能,所以,代码并不会很长。OK,很好,我会写很长的代码。

假设你要创建一个集合,用于存储地址。每一个独立的元素必须支持数据绑定,所以你我创建一个具有下面公共属性的结构:

public struct AddressRecord
{
  private string _street;
  public string Street
  {
    get { return _street; }
    set { _street = value; }
  }

  private string _city;
  public string City
  {
    get { return _city; }
    set { _city = value; }
  }

  private string _state;
  public string State
  {
    get { return _state; }
    set { _state = value; }
  }

  private string _zip;
  public string Zip
  {
    get { return _zip; }
    set { _zip = value; }
  }
}

下面,你要创建这个集合。因为我们要类型安全的集合,所以我们要从CollectionsBase派生:

public class AddressList : CollectionBase
{
}

CollectionBase 支持IList 接口,所以你可以使用它来进行数据绑定。现在,你就发现了你的第一个问题:如果地址为空,你的所有数据绑定行就失败了。而这在DataSet里是不会发生的。数据绑定是由基于反射的迟后绑定代码组成的。控件使用反射来加载列表里的第一个元素,然后使用反射来决定它的类型以及这个类型上的所有成员属性。这就是为什么DataGrid可以知道什么列要添加。它会在集合中的第一个元素上发现所有的公共属性,然后显示他们。当集合为空时,这就不能工作了。你有两种可能来解决这个问题。第一个方法有点丑,但是一个简单的方法:那就是不充许有空列表存在。第二个好一些,但要花点时间:那就是实现ITypedList 接口。ITypedList 接口提供了两个方法来描述集合中的类型。GetListName 返回一个可读的字符串来描述这个列表。GetItemProperties 则返回PropertyDescriptors 列表,这是用于描述每个属性的,它要格式化在表格里的:

public class AddressList : CollectionBase
{
  public string GetListName(PropertyDescriptor[] listAccessors)
  {
    return "AddressList";
  }

  public PropertyDescriptorCollection GetItemProperties(PropertyDescriptor[] listAccessors)
  {
    Type t = typeof(AddressRecord);
    return TypeDescriptor.GetProperties(t);
  }
}

这稍微好一点了,现在你你已经有一个集合可以支持简单的数据绑定了。尽管,你失去了很多功能。下一步就是要实现数据对事务的支持。如果你使用过DataSet,你的用户可以通过按Esc键来取消DataGrid中一行上所有的修改。例如,一个用户可能输入了错误的城市,按了Esc,这时就要原来的值恢复过来。DataGrid同样还支持错误提示。你可以添加一个ColumnChanged 事件来处理实际列上的验证原则。例如,州的区号必须是两个字母的缩写。使用框架里的DataSet,可以这样写代码:

ds.Tables["Addresses"].ColumnChanged +=new DataColumnChangeEventHandler(ds_ColumnChanged);

private void ds_ColumnChanged(object sender, DataColumnChangeEventArgs e)
{
  if (e.Column.ColumnName == "State")
  {
    string newVal = e.ProposedValue.ToString();
    if (newVal.Length != 2)
    {
      e.Row.SetColumnError(e.Column, "State abbreviation must be two letters");
      e.Row.RowError = "Error on State";
    }
    else
    {
      e.Row.SetColumnError(e.Column, "");
      e.Row.RowError = "";
    }
  }
}

为了在我们自己定义的集合上也实现这样的概念,我们很要做点工作。你要修改你的AddressRecord 结构来支持两个新的接口,IEditableObject 和IDataErrorInfo。IEditableObject 为你的类型提供了对事务的支持。IDataErrorInfo 提供了常规的错误处理。为了支持事务,你必须修改你的数据存储来提供你自己的回滚功能。你可能在多个列上有错误,因此你的存储必须包含一个包含了每个列的错误集合。这是一个为AddressRecord做的更新的列表:

public class AddressRecord : IEditableObject, IDataErrorInfo
{
    private struct AddressRecordData
    {
      public string street;
      public string city;
      public string state;
      public string zip;
    }

    private AddressRecordData permanentRecord;
    private AddressRecordData tempRecord;

    private bool _inEdit = false;
    private IList _container;

    private Hashtable errors = new Hashtable();

    public AddressRecord(AddressList container)
    {
      _container = container;
    }

    public string Street
    {
      get
      {
        return (_inEdit) ? tempRecord.street : permanentRecord.street;
      }
      set
      {
        if ( value.Length == 0 )
          errors[ "Street" ] = "Street cannot be empty";
        else
        {
          errors.Remove( "Street" );
        }
        if ( _inEdit )
          tempRecord.street = value;
        else
        {
          permanentRecord.street = value;
          int index = _container.IndexOf( this );
          _container[ index ] = this;
        }
      }
    }

    public string City
    {
      get
      {
        return (_inEdit) ? tempRecord.city : permanentRecord.city; 
      }
      set
      {
        if (value.Length == 0)
          errors["City"] = "City cannot be empty";
        else
        {
          errors.Remove("City");
        }
        if (_inEdit)
          tempRecord.city = value;
        else
        {
          permanentRecord.city = value;
          int index = _container.IndexOf(this);
          _container[index] = this;
        }
      }
    }

    public string State
    {
      get
      {
        return (_inEdit) ? tempRecord.state : permanentRecord.state;
      }
      set
      {
        if ( value.Length == 0 )
          errors[ "State" ] = "City cannot be empty";
        else
        {
          errors.Remove( "State" );
        }
        if ( _inEdit )
          tempRecord.state = value;
        else
        {
          permanentRecord.state = value;
          int index = _container.IndexOf( this );
          _container[ index ] = this;
        }
      }
    }

    public string Zip
    {
      get
      {
        return ( _inEdit ) ? tempRecord.zip :
          permanentRecord.zip;
      }
      set
      {
        if ( value.Length == 0 )
          errors["Zip"] = "Zip cannot be empty";
        else
        {
          errors.Remove ( "Zip" );
        }
        if ( _inEdit )
          tempRecord.zip = value;
        else
        {
          permanentRecord.zip = value;
          int index = _container.IndexOf( this );
          _container[ index ] = this;
        }
      }
    }
    public void BeginEdit( )
    {
      if ( ( ! _inEdit ) && ( errors.Count == 0 ) )
        tempRecord = permanentRecord;
      _inEdit = true;
    }

    public void EndEdit( )
    {
      // Can't end editing if there are errors:
      if ( errors.Count > 0 )
        return;

      if ( _inEdit )
        permanentRecord = tempRecord;
      _inEdit = false;
    }

    public void CancelEdit( )
    {
      errors.Clear( ); 
      _inEdit = false;
    }

    public string this[string columnName]
    {
      get
      {
        string val = errors[ columnName ] as string;
        if ( val != null )
          return val;
        else
          return null;
      }
    }

    public string Error
    {
      get
      {
        if ( errors.Count > 0 )
        {
          System.Text.StringBuilder errString = new
            System.Text.StringBuilder();
          foreach ( string s in errors.Keys )
          {
            errString.Append( s );
            errString.Append( ", " );
          }
          errString.Append( "Have errors" );
          return errString.ToString( );
        }
        else
          return "";
      }
    }
  }

花了几页的代码来支持一些已经在DataSet里实现的了的功能。实际上,这还不能像DataSet那样恰当的工作。例如,交互式的添加一个新记录到集合中,以及支持事务所要求的BeginEdit, CancelEdit, 和EndEdit等。 你要在CancelEdit 调用时检测一个新的对象而不是一个已经修改了的对象。CancelEdit 必须从集合上移除这个新的对象,该对象应该是上次调用BeginEdit时创建的。对于AddressRecord 来说,还有很多修改要完成,而且一对事件还要添加到AddressList 类上。

最后,就是这个IBindingList接口。这个接口至少包含了20个方法和属性,用于控件查询列表上的功能描述。你必须为只读列表实现IBindingList 或者交互排序,或者支持搜索。在你取得内容之前就陷于层次关系和导航关系中了。我也不准备为上面所有的代码添加任何例子了。

几页过后,再问问你自己,还准备创建你自己的特殊集合吗?或者你想使用一个DataSet吗?除非你的集合是一个基于某些算法,对性能要求严格的集合,或者必须有轻便的格式,就要使用自己的DataSet,特别是类型化的DataSet。这将花去你大量的时间,是的,你可以争辩说DataSet并不是一个基于面向对象设计的最好的例子。类型化的DataSet甚至会破坏更多的规则。但,使用它所产生的代码开发效率,比起自己手写更优美的代码所花的时间,这只是其中一小部份。

添加新评论