【问题标题】:.NET stream capabilities - is the CanXXX test safe?.NET 流功能 - CanXXX 测试安全吗?
【发布时间】:2009-07-31 06:04:23
【问题描述】:

.NET 中有一种相当常见的模式用于测试类的功能。这里我将使用 Stream 类作为示例,但问题适用于所有使用此模式的类。

该模式是提供一个名为 CanXXX 的布尔属性,以指示该类上的能力 XXX 可用。例如,Stream 类具有 CanRead、CanWrite 和 CanSeek 属性,以指示可以调用 Read、Write 和 Seek 方法。如果属性值为 false,则调用相应的方法将导致抛出 NotSupportedException。

来自有关流类的 MSDN 文档:

根据底层数据源或存储库,流可能仅支持其中一些功能。应用程序可以使用 CanRead、CanWrite 和 CanSeek 属性查询流的功能。

CanRead 属性的文档:

在派生类中重写时,获取一个值,指示当前流是否支持读取。

如果从 Stream 派生的类不支持读取,则调用 Read、ReadByte 和 BeginRead 方法会引发 NotSupportedException。

我看到很多代码都是这样写的:

if (stream.CanRead)
{
    stream.Read(…)
}

请注意,没有同步代码可以以任何方式锁定流对象——其他线程可能正在访问它或它引用的对象。也没有捕获 NotSupportedException 的代码。

MSDN 文档没有说明属性值不能随时间改变。事实上,当流关闭时,CanSeek 属性变为 false,展示了这些属性的动态特性。因此,没有合同保证上述代码 sn-p 中对 Read() 的调用不会引发 NotSupportedException。

我预计会有很多代码存在这个潜在问题。我想知道那些发现这个问题的人是如何解决这个问题的。这里适合哪些设计模式?

我也很感谢 cmets 关于这种模式的有效性(CanXXX、XXX() 对)。对我来说,至少在 Stream 类的情况下,这代表了一个试图做太多事情的类/接口,应该分成更基本的部分。缺乏严格的记录合同使得测试变得不可能,实施变得更加困难!

【问题讨论】:

  • 我相信这是对当前有效问题的出色展示。 +1

标签: .net design-patterns stream reliability


【解决方案1】:

好的,这是另一个尝试,希望比我的其他答案更有用...

很遗憾,MSDN 没有提供任何关于 CanRead/CanWrite/CanSeek 随时​​间变化的具体保证。我认为可以合理地假设如果一个流是可读的,它将继续可读,直到它被关闭 - 其他属性也是如此

在某些情况下,我认为流变为以后可搜索是合理的 - 例如,它可能会缓冲它读取的所有内容,直到它到达底层数据的末尾,然后允许搜索之后在其中让客户重新读取数据。不过,我认为适配器忽略这种可能性是合理的。

这应该可以处理除最病态的病例之外的所有病例。 (流几乎旨在造成破坏!)将这些要求添加到现有文档中在理论上是一个突破性的变化,尽管我怀疑 99.9% 的实现已经遵守它。不过,在Connect 上可能值得建议。

现在,关于是否使用“基于能力”的 API(如 Stream)和基于接口的 API 之间的讨论......我看到的根本问题是 .NET 不提供能力指定一个变量必须是对多个接口的实现的引用。比如我不会写:

public static Foo ReadFoo(IReadable & ISeekable stream)
{
}

如果它确实允许这样做,这可能是合理的 - 但如果没有这样做,您最终会遇到潜在接口的爆炸式增长:

IReadable
IWritable
ISeekable
IReadWritable
IReadSeekable
IWriteSeekable
IReadWriteSeekable

我认为这比目前的情况更混乱 - 尽管我认为我支持除了现有的 Stream 类之外仅 IReadableIWritable 的想法。这将使客户更容易以声明方式表达他们的需求。

使用Code Contracts,API可以声明他们提供什么以及他们需要什么,诚然:

public Stream OpenForReading(string name)
{
    Contract.Ensures(Contract.Result<Stream>().CanRead);

    ...
}

public void ReadFrom(Stream stream)
{
    Contract.Requires(stream.CanRead);

    ...
}

我不知道静态检查器在这方面能提供多少帮助 - 或者它如何处理流在关闭时确实变得不可读/不可写的事实。

【讨论】:

  • +1。关于Foo ReadFoo(IReadable &amp; ISeekable stream):问题不在于将IReadable &amp; ISeekable传入 方法;只需写Foo ReadFoo&lt;TReadSeekable&gt;(TReadSeekable stream) where TReadSeekable : IReadable, ISeekable。问题是如何从方法中返回这样的流:TReadSeekable OpenForReading(string name) where T : IReadable, ISeekable 不会编译。那就是你需要interface IReadSeekable : IReadable, ISeekable的地方。
  • 关于仅在一段时间后才可搜索的流:在这种情况下,应该有一个CanSeekChanged 事件。它的存在会立即向消费者发出信号,CanSeek可能随时更改,同时使重复轮询变得不必要。
  • 或者,在没有CanSeekChanged 事件的情况下,如果CanSeek 是一个方法而不是一个属性,这将是另一个小提示,即返回的值可能不是恒定的,即可能会改变随时。 (该解释来自.NET Framework Design Guidelines
【解决方案2】:

在不了解对象内部结构的情况下,您必须假设“标志”属性太不稳定,无法在多个线程中修改对象时依赖。

我看到这个问题更常见于只读集合而不是流,但我觉得这是相同设计模式的另一个示例,并且适用相同的论点。

澄清一下,.NET 中的 ICollection 接口具有属性 IsReadOnly,该属性旨在用作集合是否支持修改其内容的方法的指示符。就像流一样,这个属性可以随时更改,并会导致 InvalidOperationException 或 NotSupportedException 被抛出。

围绕这个的讨论通常归结为:

  • 为什么没有 IReadOnlyCollection 接口?
  • NotSupportedException 是否是个好主意。
  • “模式”与不同的具体功能的优缺点。

模式很少是一件好事,因为你不得不处理不止一个“集合”的行为;拥有可以随时切换模式的东西要糟糕得多,因为您的应用程序现在也必须处理多个“组”行为。但是,仅仅因为可以将某些东西分解为更谨慎的功能并不一定意味着您总是应该这样做,尤其是在将其分解时不会降低手头任务的复杂性。

我个人的看法是,你必须选择最接近你认为你所在班级的消费者会理解的心智模型的模式。如果您是唯一的消费者,请选择您最喜欢的型号。在 Stream 和 ICollection 的情况下,我认为对它们进行单一定义更接近于在类似系统中多年开发所建立的心智模型。当您谈论流时,您谈论的是文件流和内存流,而不是它们是否可读或可写。同样,当您谈论集合时,您很少会以“可写性”来提及它们。

我对此的经验法则:始终寻找一种方法将行为分解为更具体的界面,而不是拥有操作“模式”,只要它与简单的心理模型相得益彰。如果很难将单独的行为视为单独的事物,请使用基于模式的模式并非常清楚地记录它。

【讨论】:

  • 这是一个深思熟虑的答案,我同意所写的大部分内容。关于 NotSupportedException,我在这里大喊大叫:stackoverflow.com/questions/410719。我要补充的一件事是,当大众的思维模式明显存在缺陷时,有影响力的开发人员(如 Sun 和微软)有义务教育未洗过的人并将行业推向更好的方向。这似乎不是软件开发的趋势,出于某种原因,我们似乎都接受了被推到我们身上的垃圾。
  • 不幸的是,心智模型是遗留系统中最终的向后兼容要求;我们未洗过的群众不喜欢重新学习以前效果很好的东西,即使新方法更优越!
  • 好的,如果遗留系统不能很好地工作怎么办?如果它确实工作得很好,那么就没有新的更好的做事方式了。
  • 也许我只是一个不讲道理的人——“讲道理的人让自己适应世界;不讲道理的人坚持让世界去适应自己。所以,一切的进步都取决于不讲道理的人” ——选自萧伯纳的《革命家格言》。请注意,您正在提拔一个使世界适应其他人的人-这简直太奇怪了。你对把事情做得更好的反感是什么?那些“不喜欢重新学习东西”的大白痴不应该尝试编写软件或从事任何其他工程学科的工作。
  • 我非常喜欢“让世界适应他人”这句话。在我正在进行的软件工作中,特别是用户界面,我发现尝试使我的系统适应不习惯它的人是主要目标之一。我个人认为,任何产品的用户界面都是该产品最重要的部分,无论是 GUI、命令行还是编程 API。创建用户不必适应的界面将是实现这一目标的巨大成就。
【解决方案3】:

stream.CanRead 只是检查底层流是否有读取的可能性。它没有说明是否可以进行实际读取(例如磁盘错误)。

如果您使用任何 *Reader 类,则无需捕获 NotImplementedException,因为它们都支持阅读。只有 *Writer 会有 CanRead=False 并抛出该异常。如果您知道流支持读取(例如,您使用了 StreamReader),恕我直言,无需进行额外检查。

您仍然需要捕获异常,因为读取期间的任何错误都会抛出异常(例如磁盘错误)。

还要注意,任何未记录为线程安全的代码都不是线程安全的。通常静态成员是线程安全的,但实例成员不是 - 但是,需要检查每个类的文档。

【讨论】:

  • “如果您使用了任何 Reader 类,则无需捕获 NotImplementedException”(我认为您的意思是 NotSupportedExpection)。只处理抽象 Stream 类的框架呢?它唯一知道的是 Stream 接口上的合约——不用等待,它根本没有定义,所以它什么都不知道。因此,无法正确编写框架......这从来都不是线程安全的问题,而是关于 Stream 基类接口的合同*隐含(因为它显然没有记录)。
【解决方案4】:

根据您的问题和所有后续评论,我猜您的问题在于所述合同的明确性和“正确性”。所述合同是 MSDN 在线文档中的内容。

您指出的是,文档中缺少某些内容,迫使人们对合同做出假设。更具体地说,由于没有提及流的可读性属性的波动性,因此可以做出的唯一假设是 可能 NotSupportedException 被抛出,无论什么相应 CanRead 属性的值早于几毫秒(或更多)。

我认为在这种情况下需要继续这个界面的intent,即:

  1. 如果您使用多个线程,所有赌注都将被取消;
  2. 在您调用接口上可能会改变流状态的东西之前,您可以放心地假设CanRead 的值是不变的。

尽管有上述情况,Read* 方法可能可能会抛出 NotSupportedException

相同的参数可以应用于所有其他 Can* 属性。

【讨论】:

  • Eric - 你对界面意图的解释非常好,但我讨厌编程合同就像法律的想法 - 可以基于感知 意图。想象一下,如果软件开发人员无法制定完全保护自己的 EULA - 您会将业务押在暗示接口意图的开发人员身上,还是更愿意拥有一个强大的数学定义接口?在这里,我们可能都可以从 ADA 中学到一些东西,包括花哨的前后条件等等。
【解决方案5】:

我也很感谢 cmets 关于这种模式的有效性(CanXXX、XXX() 对)。

当我看到这种模式的一个实例时,我通常会这样认为:

  1. 无参数CanXXX 成员将始终返回相同的值,除非……

  2. …在存在 CanXXXChanged 事件的情况下,无参数的CanXXX 可能会在该事件发生前后返回不同的值;但它不会在不触发事件的情况下改变。

  3. 参数化的CanXXX(…) 成员可能会为不同的参数返回不同的值;但是对于相同的参数,它很可能返回相同的值。也就是说,CanXXX(constValue) 可能会保持不变。

    我在这里很谨慎:如果stream.CanWriteToDisk(largeConstObject) 现在返回true,那么假设它将来总是返回true 是否合理?可能不会,所以参数化的CanXXX(…) 是否会为相同的参数返回相同的值可能取决于上下文。

  4. 只有在CanXXX 返回true 时,对XXX(…) 的调用才能成功。


话虽如此,我同意Stream 对这种模式的使用有些问题。至少在理论上,如果在实践中可能没有这么多的话。

【讨论】:

    【解决方案6】:

    这听起来更像是一个理论问题,而不是一个实际问题。除了关闭之外,我真的想不出任何情况下流会变得不可读/不可写其他

    可能会有一些极端情况,但我根本不希望它们经常出现。我认为绝大多数代码都不需要担心这一点。

    不过,这是一个有趣的哲学问题。

    编辑:解决 CanRead 等是否有用的问题,我相信它们仍然有用 - 主要用于参数验证。例如,仅仅因为一个方法需要在某个时刻想要读取的流并不意味着它想要在方法开始时立即读取它,但理想情况下应该执行参数验证。这与检查参数是否为 null 并抛出 ArgumentNullException 而不是等待 NullReferenceException 在您第一次取消引用时抛出实际上没有什么不同。

    另外,CanSeek 略有不同:在某些情况下,您的代码可能很好地处理可搜索和不可搜索的流,但在可搜索的情况下效率更高。

    这确实依赖于“可搜索性”等保持一致 - 但正如我所说,这在现实生活中似乎是正确的。


    好的,让我们换个方式试试...

    除非您在内存中读取/查找,并且您已经确保有足够的数据,或者您正在预先分配的缓冲区中写入,否则总是可能会出错。磁盘故障或填满,网络崩溃等。这些事情确实在现实生活中发生,所以你总是需要以一种能够在故障中幸存下来的方式进行编码(或者有意识地选择在没有问题时忽略问题' t真的很重要)。

    如果您的代码在磁盘故障的情况下可以做正确的事情,那么它很有可能在FileStream 从可写变为不可写时幸存下来。

    如果Stream 确实有固定合同,那么他们就必须难以置信地弱 - 您不能使用静态检查来证明您的代码将始终有效。你能做的最好的事情就是证明它在面对失败时做了正确的事。

    我不相信Stream 会很快改变。虽然我当然接受可以更好地记录它,但我不接受它“完全损坏”的想法。如果我们不能在现实生活中实际使用它,它会更多损坏......如果它可能比现在更损坏,那么它在逻辑上还没有完全损坏.

    我的框架存在更大的问题,例如日期/时间 API 的状态相对较差。在最近的几个版本中,它们已经很多变得更好了,但是它们仍然缺少很多(比如)Joda Time 的功能。缺乏内置的不可变集合,语言中对不可变性的支持不佳等 - 这些都是让我实际头疼的真正问题。我宁愿看到它们得到解决,也不愿花时间在Stream 上,在我看来,这似乎是一个有点棘手的理论问题,在现实生活中几乎没有引起任何问题。

    【讨论】:

    • (补充你所说的。)我相信问题变成了:“我们应该使用 CanXXX 属性吗?”如果操作正在读取流并且失败,则必须抛出异常。这不像 TryParse 方法。
    • 如果“理论”是指“数学上正确”,那么是的,这是一个理论问题。对我来说,这非常重要——如果微软不能在一个抽象类上记录合同,那么每个程序怎么会有人反对它呢?你不知道你可以做出什么假设,或者更糟糕的是,其他人做了什么假设。我们都注定要失败吗?
    • 我的意思是“理论上的”,因为“是的,流可以在不关闭的情况下改变它是否可读,但它不会在现实生活中发生。”是的,不幸的是,有很多接口和抽象类的设计不如他们想象的那么好。
    • 现实生活中不会发生...哇。如果它没有发生,那么它不应该被允许发生并被记录下来。你是对的,这个抽象类的设计不如它可能的那么好,因为它很难变得比完全破坏更糟糕。
    • 但是,Jon,接口也被使用,以便其他人可以实现它们。如果我使用的是其他人编写的流类,那么除了合同中明确规定的内容之外,所有赌注都将被取消。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2012-04-26
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多