实现一个返回Task的方法时的合同协议

7
当实现返回Task的方法时,是否有MS“最佳实践”或合同约定来处理抛出的异常?这在编写单元测试时会遇到,我想知道是否应该测试/处理此情况(我认识到答案可能是“防御性编程”,但我不希望那是答案)。
例如:
1. 方法必须始终返回一个包含抛出异常的Task。 2. 方法必须始终返回一个Task,除非该方法提供了无效的参数(即ArgumentException)。 3. 方法必须始终返回一个Task,除非开发人员偏离主题并随心所欲(开个玩笑)。
Task Foo1Async(string id){
  if(id == null){
    throw new ArgumentNullException();
   }

  // do stuff
}

Task Foo2Async(string id){
  if(id == null){
    var source = new TaskCompletionSource<bool>();
    source.SetException(new ArgumentNullException());
    return source.Task;
  }

  // do stuff
}

Task Bar(string id){
  // argument checking
  if(id == null) throw new ArgumentNullException("id")    

  try{
    return this.SomeService.GetAsync(id).ContinueWith(t => {
       // checking for Fault state here
       // pass exception through.
    })
  }catch(Exception ex){
    // handling more Fault state here.
    // defensive code.
    // return Task with Exception.
    var source = new TaskCompletionSource<bool>();
    source.SetException(ex);
    return source.Task;
  }
}

有趣的问题。我从未见过这样的指南,所以我倾向于说4:方法可能会抛出异常,也可能返回一个包含该异常的任务,调用者应将这些情况视为等效。 - user743382
@hvd 防御性编程建议调用者处理两种情况,但我内心的极简主义者不想处理以多种方式表达的异常。例外是参数检查(有点自相矛盾)。 - Kevin
同时,抛出异常的方式不应该取决于方法是否使用了“async”实现,因为这是对调用者不可见的实现细节。如果您同意这一点,那么这两种情况应该被视为等效的,或者您就不应该同步地抛出异常。 - user743382
3个回答

6

我最近问了一个类似的问题:

处理异步方法同步部分的异常

如果方法有一个async签名,无论你从同步或异步部分抛出异常都没关系。在两种情况下,异常都将存储在Task中。唯一的区别是,前者会立即完成(故障)Task对象。

如果方法没有async签名,则可能在调用方的堆栈帧上抛出异常。

我认为,在任何情况下,调用方不应该做出任何假设,即无论异常是从同步还是异步部分抛出,或者方法是否具有async签名。

如果您真的需要知道任务是否已同步完成,可以始终检查其Task.Completed/Faulted/Cancelled状态或Task.Exception属性,而不必等待:

try
{
    var task = Foo1Async(id);
    // check if completed synchronously with any error 
    // other than OperationCanceledException
    if (task.IsFaulted) 
    {
        // you have three options here:

        // 1) Inspect task.Exception

        // 2) re-throw with await, if the caller is an async method 
        await task;

        // 3) re-throw by checking task.Result 
        // or calling task.Wait(), the latter works for both Task<T> and Task 
    }
}
catch (Exception e)
{
    // handle exceptions from synchronous part of Foo1Async,
    // if it doesn't have `async` signature 
    Debug.Print(e.ToString())
    throw;
}

然而,通常情况下,你应该只需等待结果,而不必关心任务是同步完成还是异步完成,以及哪一部分可能会出现异常。任何异常都将在调用者上下文中重新抛出:
try
{
    var result = await Foo1Async(id); 
}
catch (Exception ex)
{
    // handle it
    Debug.Print(ex.ToString());
}

这对单元测试也适用,只要async方法返回一个Task(据我所知,单元测试引擎不支持async void方法,这很有道理:没有Task来跟踪和等待)。
回到你的代码,我会这样写:
Task Foo1Async(string id){
  if(id == null) {
    throw new ArgumentNullException();
   }

  // do stuff
}

Task Foo2Async(string id) {
  if(id == null){
    throw new ArgumentNullException();
   }

  // do stuff
}

Task Bar(string id) {
  // argument checking
  if(id == null) throw new ArgumentNullException("id")    
  return this.SomeService.GetAsync(id);
}

让调用Foo1AsyncFoo2AsyncBar的人处理异常,而不是手动捕获和传播它们。


5
我知道Jon Skeet喜欢在单独的同步方法中执行前置条件样式检查,以便它们可以直接抛出。
然而,我的观点是“无所谓”。考虑Eric Lippert的异常分类。我们都同意外部异常应该放在返回的Task上(而不是直接在调用者的堆栈帧上抛出)。应完全避免令人困惑的异常。唯一需要考虑的异常类型是愚蠢的异常(例如参数异常)。
我的观点是,无论它们如何抛出,都不重要,因为您不应编写捕获它们的生产代码。只有您的单元测试应该捕获ArgumentException和类似异常,如果您使用await,那么它们何时抛出并不重要。

在OP的情况下,Bar内部由this.SomeService.GetAsync()引发的异常是否被视为外生的(我认为是这样)?那么它应该像那里所做的那样用TCS.SetException包装吗? - noseratio - open to work
是的,那是外部的。我会使用async/await实现Bar,它将在底层使用TCS.SetException - Stephen Cleary
如果您能展示Bar在同时使用TaskCompletionSourceasync/await的情况下会是什么样子,我将不胜感激。我感觉自己错过了一些显而易见的东西。谢谢! - noseratio - open to work
“TCS”方法是OP在他的问题中所写的。 “async” / “await”方法将是if(id == null)throw new ArgumentNullException(“id”); await this.SomeService.GetAsync(id);(忽略“只返回GetAsync任务”的优化)。 - Stephen Cleary
1
啊,所以它要么是TCS,要么像这样使用async/await http://pastebin.com/ncXH3T9X? - noseratio - open to work
1
@Noseratio:是的,那就是我想说的。 - Stephen Cleary

3
当方法返回任务(Task)时,通常是因为它们是异步方法。在这些情况下,与任何其他方法一样,在同步部分引发异常,在异步部分将其存储在返回的“Task”(通过调用“async”方法或匿名委托自动完成)中。
因此,在像“Foo1Async”这样的简单情况下,例如无效的参数,只需抛出异常即可。在涉及异步操作的更复杂情况下,请在返回的任务上设置异常,例如“Foo2Async”。
本答案假定您指的是未标记为“async”的“Task”返回方法。对于标记有“async”的方法,您无法控制任务的创建,并且任何异常都会自动存储在该任务中(因此问题是无关紧要的)。

2
+1。同意。像参数验证这样的东西没有等待的理由。如果你不能立即抛出异常,那就将其放入任务中。 - TomTom
我已经使用Bar(string):Task更新了示例。Bar依赖于返回Task的某些服务。在示例中,异常/故障状态在两个区域(try块和ContinueWith)中得到处理。 - Kevin
@Noseratio,示例中的方法是异步的,但没有标记为async,这在您删除不必要的async修饰符时非常常见。 - i3arnon
@l3arnon,我的观点是调用者不应该做出任何关于方法是否具有“async”签名的假设。我认为在Bar内部以@Kevin的方式使用TaskCompletionSource传播异常并不是一个好主意,我会简单地执行return this.SomeService.GetAsync(id) - noseratio - open to work
@Noseratio,这不是关于调用者的问题,而是关于如何实现方法本身的问题。对于Bar,返回SomeService.GetAsync(id)正是我所说的。同步异常被抛出,异步异常在Task中。 - i3arnon
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接