65.9K
CodeProject 正在变化。 阅读更多。
Home

异步数据访问

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.69/5 (40投票s)

2003年7月10日

18分钟阅读

viewsIcon

140228

downloadIcon

1248

如何为同步方法调用添加异步支持。

引言

我感觉很蠢。上周在巴塞罗那,我在一家露天咖啡馆等我的小吃。微软 Tech Ed 的第一天令人筋疲力尽。我到处奔波,把免费的可乐罐、火星棒、甜甜圈和饼干塞满了我的背包。我确定我真的瘦了一些,因为我整天都背着那些食物。所以,在最后一节课结束后,我疲惫又烦躁,还在抱怨服务员。她忘了我要点什么,让我等了太久,而且似乎更乐意和她经过的半个家人聊天。她关注的顾客,正如我猜的那样,是她另一半的家人和我,几乎不在她的关心范围之内。

但突然我意识到她实际上同时在做很多事情。我想象她是一个超负荷工作的数据库服务器,只是内存更小,头发更多,在许多请求之间切换。而我只是这个不耐烦地等待请求被服务的渺小线程。如果我当时有其他事情做,这也不会是什么大问题。我可以忙着讲笑话,同时又没真正听别人讲笑话,因为我的会更好笑。那样的话,我才不会那么在意服务员花这么长时间来做她的工作,如果她还能完成的话。

但我是一个人。没有人和我在一起。(不,请等一下,我能解释。)这个线程只是在等待。它什么都没做。它恨死那个服务器了。

然后我突然想到,这正是我一生都在做的事情。不是作为露天咖啡馆的顾客,而是作为一个程序员。我总是编程让我的线程等待服务器。生活本可以多么有趣,如果我的线程能做其他事情呢!如果我们从此拒绝等待数据库服务器给出答案,是不是会更好?我的线程可以邀请其他线程。它们可以互相讲笑话!它们会让服务器按照自己的节奏提供数据。

我为自己总是创造孤独的线程,等待服务器响应,而它们本可以如此快乐,它们的人生却在流逝,我感到很愚蠢。我一直是个冷血的程序员,我从未意识到。所以我决定再次提升自己。从 Tech Ed 回来后,我立即开始着手这项工作。

创建新方法

我手上已经有一个数据访问类。我从微软的网站上拿到了它,当时它被称为数据访问应用程序块(DAAB)。今年早些时候,我改进了它,隐藏了 SQL Server 依赖项,添加了异常处理,改变了填充表的方式,并增加了对更新的支持等等。至少我能做到的就是让它真正可用。现在我要拿那个更新后的类,为其中每一个普通的(同步)调用添加异步方法调用。如果你以前没见过 DAAB,无论是我改进的版本还是微软那个未完成的版本,都别担心。我不会讨论同步方法中的代码。你可以用空方法模仿我所做的步骤。这无关紧要。如果你愿意,可以随时查看微软关于 DAAB 的理念。(只要确保你之后使用我的代码,即使我根本不称之为模式或实践!)

委托类型

微软的 DAAB(以及我的类)包含九个重载的方法,用于与数据库交互。举个例子,我在片段 1 中包含了我的 `FillDataTable` 方法的重载。

public static void FillDataTable(ref DataTable dt,
      string connectionString, CommandType commandType,
      string commandText)
 
public static void FillDataTable(ref DataTable dt,
      string connectionString, CommandType commandType,
      string commandText, params IDataParameter[] commandParameters)
 
public static void FillDataTable(ref DataTable dt,
      string connectionString, string spName,
      params object[] parameterValues)
 
public static void FillDataTable(ref DataTable dt,
      IDbConnection connection, CommandType commandType,
      string commandText)
 
public static void FillDataTable(ref DataTable dt,
      IDbConnection connection,
      CommandType commandType, string commandText,
      params IDataParameter[] commandParameters)
 
public static void FillDataTable(ref DataTable dt,
      IDbConnection connection,
      string spName, params object[] parameterValues)
 
public static void FillDataTable(ref DataTable dt,
      IDbTransaction transaction, CommandType commandType,
      string commandText)
 
public static void FillDataTable(ref DataTable dt,
      IDbTransaction transaction, CommandType commandType,
      string commandText, params IDataParameter[] commandParameters)
 
public static void FillDataTable(ref DataTable dt,
      IDbTransaction transaction, string spName,
      params object[] parameterValues)

片段 1

你可以看到 `FillDataTable` 的九个版本在填充具有数据的 `DataTable` 对象方面各有略微不同的方法。有的使用连接字符串,有的使用连接对象,甚至事务对象。有的让客户端选择命令类型和命令文本,带或不带参数变量,而有的只需要存储过程的名称,可选地带几个参数常量。但它们都有一个共同点,那就是它们会开始等待数据库服务器返回完整的表。使用这些方法的客户端显然必须无事可做,只能等待,有时几乎是无限地等待,等待一个可能过热的服务器完成它的工作。(同时,希望它不会与亲戚们喋喋不休。)多么可怜的一批线程啊。

如果你希望你的线程有自己的生活,做一些聪明的事情,或者与其他线程社交等等,你需要你的数据访问方法的异步版本。你应该做的第一件事是为你的每一个方法和每一个重载(如果有的话)创建一个委托类型。委托是某个其他实体的官方代表,实际上负责委托所做的一切。就像我 Tech Ed 的徽章上写的那样,我是我公司的代表,所以我玩得很开心,而我公司则负责了我背包里所有不见了的食物。你创建的委托将会有趣地接受你的客户端线程交给它们参数,而你原来的数据访问方法将负责完成实际工作。

创建委托类型

  1. 复制你数据访问方法的签名(而不是主体)。
  2. 将访问修饰符(例如,在我这里是 `public static`)更改为 `private delegate`。
  3. 通过添加 `Delegate` 或其他你喜欢的名称来更改名称。
  4. 哦,别忘了末尾的分号。
private delegate void FillDataTableDelegate1(ref DataTable dt,
      string connectionString, CommandType commandType,
      string commandText);
 
private delegate void FillDataTableDelegate2(ref DataTable dt,
      string connectionString, CommandType commandType,
      string commandText, params IDataParameter[] commandParameters);
 
private delegate void FillDataTableDelegate3(ref DataTable dt,
      string connectionString, string spName,
      params object[] parameterValues);
 
private delegate void FillDataTableDelegate4(ref DataTable dt,
      IDbConnection connection, CommandType commandType,
      string commandText);
 
private delegate void FillDataTableDelegate5(ref DataTable dt,
      IDbConnection connection, CommandType commandType,
      string commandText, params IDataParameter[] commandParameters);
 
private delegate void FillDataTableDelegate6(ref DataTable dt,
      IDbConnection connection, string spName,
      params object[] parameterValues);
 
private delegate void FillDataTableDelegate7(ref DataTable dt,
      IDbTransaction transaction, CommandType commandType,
      string commandText);
 
private delegate void FillDataTableDelegate8(ref DataTable dt,
      IDbTransaction transaction, CommandType commandType,
      string commandText, params IDataParameter[] commandParameters);
 
private delegate void FillDataTableDelegate9(ref DataTable dt,
      IDbTransaction transaction, string spName,
      params object[] parameterValues);

片段 2

如果你有同一个方法的多个重载,像我一样,你必须为每个重载创建一个委托类型并给它一个唯一的名称。我通过添加数字来做到这一点,正如你在片段 2 中看到的。是的,我知道,这看起来很奇怪。但它们是私有的。少管闲事。委托类型不能有相同的名称但签名不同。因为它们实际上是编译成类的。创建编译器的那些人没有费心解决这个问题。也许 Anders Hejlsberg 只需要休息一天。我不知道。问微软。

Begin 方法

委托类型负责接收你的调用并将它们转换为对原始方法的异步调用。现在我们需要一种方法将它们暴露给你的客户端线程,但你的客户端将不会直接使用它们。你将提供包装器供它们使用。(就像我被一个徽章、登录账户和一件 T 恤包装起来,以便微软能在 Tech Ed 上处理我一样。)

public static IAsyncResult BeginFillDataTable
      (ref DataTable dt, string connectionString, CommandType commandType,
           string commandText, AsyncCallback ac, Object state,
           params IDataParameter[] commandParameters)
{
      FillDataTableDelegate2 d = new FillDataTableDelegate2(FillDataTable);
      IAsyncResult result = d.BeginInvoke(ref dt, connectionString,
           commandType, commandText, commandParameters, ac, state);
      return result;
}

片段 3

创建 Begin 包装器

  1. 对于每个原始数据访问方法,创建一个新方法,但有一个区别:它的返回类型应该是 `IAsyncResult`,并且它应该有两个额外的参数,类型分别为 `AsyncCallback` 和 `Object`。你可以自己决定放在哪里。(请注意,在片段 3 的示例中,我只列出了九个重载中的第二个,`commandParameters` 参数仍然是列表中的最后一个参数,就像在原始数据访问方法中一样。这是因为 `params` 关键字之后不允许有其他参数。)
  2. 将数据访问方法的异步版本前缀加上 `Begin` 是一个好习惯。你也可以使用其他名称,比如 Donut,但这可能会让你的客户不那么容易理解。
  3. 这个新方法应该做的第一件事是创建一个新的委托。显然,你应该使用你专门为这个方法重载创建的委托类型。(如果你没有重载,你就能轻松地弄清楚是哪一个,因为每个方法只有一个委托类型。)在括号中,提供原始数据访问方法的名称。请注意,尽管 `FillDataTable` 有九个不同的重载,但编译器能够弄清楚你指的是哪一个,因为它将具有与委托类型本身相同的签名。(Anders Hejlsberg 一定为此感到自豪。)
  4. 该方法应该做的第二件事是调用委托,使用 `BeginInvoke` 方法,并提供与你从客户端收到的相同参数。委托上的 `BeginInvoke` 方法是由编译器在后台自动生成的,它基于你创建委托类型时提供的参数。(请注意,它会自动在末尾附加 `AsyncCallback` 和 `Object` 类型的两个额外参数,而我们由于 `params` 关键字无法做到这一点。编译器生成的 `BeginInvoke` 方法不使用 `params` 参数。它只接受一维数组,而不是可变数量的参数。我相信 Anders Hejlsberg 有他的理由。)
  5. `BeginInvoke` 方法的结果是一个 `IAsyncResult` 类型的对象。你应该将这个对象作为你的包装器方法的返回值。完成之后,你就完成了这一部分。

你现在拥有的包装器方法是客户端线程调用的那个。它创建一个新的委托,然后告诉它使用相同的参数调用你的原始数据访问方法,但它会在新线程上、在自己的时间里完成。`IAsyncResult` 对象只是一个引用,表示委托正在做什么。有了这个对象,你可以偶尔检查一下,看看它是否完成了。也就是说,如果你想的话。你的客户端线程也可以完全忽略结果对象,继续和其他线程讲笑话,或者做家务,或者其他什么,直到委托回电通知它已完成,你就可以拿到你的小吃,或者你的 `DataTable`。这就是 `AsyncCallback` 参数的作用。它是为了回电给客户端。但我稍后会告诉你。

End 方法

当异步方法调用完成请求后,就是客户端线程收取小吃或 `DataTable` 的时候了。你需要编写一个新方法,以便客户端可以获取它想要的东西。

public static void EndFillDataTable(ref DataTable dt, IAsyncResult result)
{
      ...
      if ((((AsyncResult)result).AsyncDelegate).GetType() ==
           typeof(FillDataTableDelegate2))
      {
           FillDataTableDelegate2 d = (FillDataTableDelegate2)
                 ((AsyncResult)result).AsyncDelegate;
           d.EndInvoke(ref dt, result);
      }
      ...
}

片段 4

创建 End 包装器

  1. 对于每个原始数据访问方法,你还需要创建一个包装器方法来返回结果。通常的做法是将此方法前缀为 `End`。作为返回类型,这个 `End` 方法的类型应该与原始数据访问方法相同。在我的例子中是 `void`,但对于其他方法,如 `ExecuteReader`,它可以是 `IDataReader` 或 `XmlReader` 或 `int` 或任何其他类型。该方法至少会有一个参数,用于客户端从 `Begin` 方法收到的 `IAsyncResult` 对象。这并不难理解,因为可能有十个不同的委托同时处理检索结果。这个方法需要知道它应该解析的是哪个委托。(否则客户端可能会得到别人的小吃,而在巴塞罗那这可能意味着蜗牛!)
  2. 在我片段 4 的示例中,你可以看到还有一个 `DataTable` 参数。这是因为在我的原始数据访问方法中,`DataTable` `dt` 被用作一个按引用传递的参数。我没有将其变成函数结果。(我这样做的原因很好。相信我。)所以,我们在这里采用相同的方法,不再多想。
  3. 在这个包装器方法中,你必须通过将 `IAsyncResult` 对象的 `AsyncDelegate` 属性强制转换为正确的委托类型来检索原始委托。
  4. 现在你有了原始委托,你可以通过调用 `EndInvoke` 来询问它一直在做什么。`EndInvoke` 方法是编译器自动创建的,正如你在图 1 中看到的,它是基于我们在委托类型定义中使用的参数。由于我们在该定义中包含了一个按引用传递的类型(当然是 `DataTable`),编译器非常明智地推断出我们可能希望在请求结束时得到它。所以它不仅包含在 `BeginInvoke` 的参数列表中,也包含在 `EndInvoke` 中。

在创建 `End` 方法时,有一件事困扰了我,而且我必须吐露出来,那就是如何处理这种情况下的九个不同重载。如果你不使用重载,可以跳过这部分,但你会错过看到我痛苦的乐趣。

结果是我无法像创建 `BeginFillDataTable` 那样创建九个 `EndFillDataTable` 的重载。原因在于在这种情况下,参数列表都相同!我很快就放弃了通过使用数字或其他后缀字符来区分九个版本的想法。(我确定这在我简历上不好看。)所以我诅咒,我踢了我的电脑,我烧毁了我的 MCSD 图书。最后,我在同一个方法中创建了九个 `if...else` 分支,每个分支都检查委托的实际类型。(为了让你免受同样的痛苦,我在示例中只包含了一个分支。)它仍然不好看,但至少这样可以把丑陋的东西隐藏起来。客户端不会在意 `EndFillDataTable` 只有一个重载,而 `BeginFillDataTable` 有九个重载。我可能会把我背包里剩下的一些可乐罐和火星棒寄给 Anders Hejlsberg,以换取更好的解决方案。

使用新方法

你现在可以开始使用你的新异步方法了。有两种方法可以处理这些方法,所以你的客户有选择:一种好的,一种坏的。在客户向 `Begin` 方法提交请求后,他们可以定期与委托联系以获取任何结果。这是糟糕的选择。他们也可以继续做他们正在做的事情,直到迟早委托出现并带来结果。这是好的选择。我将首先向你展示后者,你可能想跳过前者,因为它仍然有点愚蠢。而且后者代码甚至可能被认为有点粗鲁。

使用回调方法

如果你希望你的客户端在任务完成后收到通知,你应该编写一个回调方法(是的,又一个方法),但这这次是在客户端。它将是一个获取结果的方法。

private void FillDataTableCallback(IAsyncResult result)
{
      DataTable dt = null;
      SqlHelper.EndFillDataTable(ref dt, result);
}

片段 5

创建回调方法

  1. 编写一个新方法,只有一个参数,用于包含委托的 `IAsyncResult` 对象。你可以选择任何你喜欢的名称,但建议使用 `Callback` 后缀。
  2. 在这个方法中,调用你之前编写的 `End` 方法,并为其提供 `IAsyncResult` 对象,如果需要,还提供你必须从该方法检索的任何其他 `ref` 参数。在其他情况下,你的 `End` 方法可能会作为函数结果返回一个值,就像我的 `ExecuteReader` 的情况一样。这取决于你的原始数据访问方法是什么样的。狗吃了我的水晶球,所以我无法为你决定。
  3. 你也可以使用这个新的回调方法来做任何其他额外的客户端端结果处理,比如准备部分用户界面或填充一些业务对象。

现在你就可以开始调用你的异步数据访问方法了。别担心;你已经完成了大部分工作。从这里开始只需要几行代码。

DataTable dt = new DataTable();
valuelist = new Object[1];
valuelist[0] = 2;
AsyncCallback callback = new AsyncCallback(FillDataTableCallback);
IAsyncResult result = SqlHelper.BeginFillDataTable(ref dt, connString,
      "TestFillDataTable", callback, null, valuelist);

片段 6

编写调用代码

  1. 在调用代码中,你需要准备将要提供给 `Begin` 方法的值,就像我在我的示例中创建新的 `DataTable` 和 `valuelist` 一样。
  2. 然后你应该创建一个新的 `AsyncCallback` 委托。它将是一个指向你刚才编写的回调方法的委托。这个委托将确保在你的异步调用完成后,你的回调方法会被调用。
  3. 现在你可以进行调用了。使用 `Begin` 方法开始调用,提供你本来会提供给你原始(同步)数据访问方法的所有参数。但你也将包含回调委托和可选的状态对象。对于状态对象,你可以传递任何你喜欢的东西。我总是使用 `null`,因为我只是一个穷人,没有什么别的东西可以提供。

你完成了!调用 `Begin` 方法后,你的调用代码可以继续处理其他需要处理的事项。调用完成后,你的回调方法将自动被调用,你的客户端将能够处理结果。如果你愿意,你可以同时向服务器发出很多调用。你的客户端可以在结果送达时随时使用它们,前提是它能处理比我在巴塞罗那处理的更多的送达。当然,你的客户端也可以将结果打包到一个背包里,在方便的时候使用。只要确保它们不会太重,让你的客户端一直背着!

我在这里给出的例子使用的是连接字符串。请小心提供连接对象。你不应该在不同的异步调用中使用同一个连接对象!ADO.NET 一次只能有一个 `DataReader` 在连接上工作。当你尝试重用一个已经被另一个线程使用的连接时,你可能会以非常不可预测的速率遇到错误。(并记住,几乎所有东西都在底层使用 `DataReader`...)

不使用回调方法

我其实不想给你看这个。如果你这样做了,你会很烦人。我只想告诉你如何不进行异步调用。如果你真的这样设计你的客户端代码,你会反复询问委托是否有任何更新。它们不会喜欢你这样做。(服务员也不喜欢我这样做。)委托甚至可能因此变慢,你甚至会更晚拿到你的小吃。别说我没警告你!

DataTable dt = new DataTable();
valuelist = new Object[1];
valuelist[0] = 2;
IAsyncResult result = SqlHelper.BeginFillDataTable(ref dt, connString,
      "TestFillDataTable3", null, null, valuelist);
while (! result.IsCompleted)
{
      Thread.Sleep(100);
}
SqlHelper.EndFillDataTable(ref dt, result);

片段 7

在这种情况下,我不希望你记住,你不使用回调方法。你只是将 `null` 传递给 `Begin` 方法的 `AsyncCallback` 参数。(这就像说“别给我打电话,我会给你打电话”。难怪委托们讨厌这样。)这意味着你的客户端代码不会在结果准备好时收到通知。相反,你的客户端代码使用你从 `Begin` 方法返回的 `IAsyncResult` 对象来不断地询问它,直到它说任务已完成。在此之前,客户端代码可以像我的例子一样等待和睡眠。在 `IsCompleted` 返回 `True` 后,调用 `End` 方法。

我不认为我需要告诉你,这种方法完全违背了异步调用的初衷。它让你的客户端代码像进行简单同步调用时一样愚蠢。这就是为什么我不想让你这样做。忘了它。抹去你过去六十秒内读到的任何东西,撕掉这一页!

示例

我将给你另一个例子,将我所说的一切结合起来。这次我将使用 `ExecuteReader` 方法,并带有事务对象和简单的 SQL 字符串作为命令文本。

这是数据访问代码

//The original synchronous method
//(further details aren’t needed, just imagine it is your own method)
public static IDataReader ExecuteReader(IDbTransaction transaction,
      CommandType commandType, string commandText) { ... }
 
//The new private delegate type for the original method (same signature)
private delegate IDataReader ExecuteReaderDelegate(IDbTransaction
      transaction, CommandType commandType, string commandText);
 
//The Begin method that will start the asynchronous call using the delegate
public static IAsyncResult BeginExecuteReader
      (IDbTransaction transaction, CommandType commandType,
      string commandText, AsyncCallback ac, Object state)
{
      //create a new delegate and have it point to the synchronous method
      ExecuteReaderDelegate d = new ExecuteReaderDelegate(ExecuteReader);
      //invoke the delegate and give it the callback method from the client
      IAsyncResult result = d.BeginInvoke(transaction, commandType,
           commandText, ac, state);
      //return result object in case the client wants to check for progress
      return result;
}
 
//The End method that will end the asynchronous call
public static IDataReader EndExecuteReader(IAsyncResult result)
{
      //cast the delegate object in IAsyncResult to our own type
      ExecuteReaderDelegate d = (ExecuteReaderDelegate)
           ((AsyncResult)result).AsyncDelegate;
      //ask the delegate for any results and return them to the client
      return d.EndInvoke(result);
}

片段 8

这是客户端代码

//The callback method that will process the results when they’re available
private void ExecuteReaderCallback(IAsyncResult result)
{
      //get the results from the data access code
      IDataReader dr = SqlHelper.EndExecuteReader(result);
      //do something useful with it
      while (dr.Read()) { ... };
      dr.Close();
}
 
//Part of the client code calling the asynchronous method
//Imagine this is all part of some transaction 
...
      //create a delegate for our callback method
      AsyncCallback callback = new AsyncCallback(ExecuteReaderCallback);
      //start working on it
      SqlHelper.BeginExecuteReader(transaction, CommandType.Text,
           "SELECT * FROM Test", callback, null);
...

片段 9

结论

起初,我认为异步方法调用的设计模式有点令人望而生畏,但实际上并不难理解。它只是需要很多连接才能实现一个简单的机制。我的数据访问方法的新异步版本每个只需要不到二十行额外的代码。诚然,连接有时会有点令人困惑,但一旦你掌握了窍门,它真的并不难。而且新方法与原始方法并不冲突。我现在已经能够让客户端在同步和异步数据访问调用之间进行选择。只需要付出一点努力,我现在感觉聪明多了。我甚至可能有一天会回到巴塞罗那的 tapas 餐厅。而且我不会再 bothered by non-responsiveness of the waitress,因为我会确保我在等待我的 tapas 的同时有很多其他事情要做。而且我再也不会感到愚蠢了。如果我和 Anders Hejlsberg 一起吃 tapas,在亲吻他走过的地面之后,我会感谢他为 .NET 世界带来了委托。

下载

我改进的数据访问应用程序块的完整源代码,包括所有方法的异步版本,可从我的网站获取。欢迎对任何我可以做得更好的地方发表评论。只是不要寄花。我讨厌花。

© . All rights reserved.