坚如磐石的质量






4.88/5 (36投票s)
通过拥有永不破坏内部状态的组件,让您的生活更轻松。
引言
最近我一直在谈论软件开发中的质量,而这并不是一件容易弄清楚的事情。
首先,许多经理说质量是按时交付产品。我当然同意,当我们有最后期限时,不遵守它们会降低工作的整体质量。但产品交付后,其质量与交付所花费的时间无关。一个新的人,第一次看到产品(在我们这里是软件)时,可能会将质量视为这些因素的总和:
- 该程序完成了它需要做的事情;
- 程序不崩溃;
- 程序易于使用;
- 它有一个好看的界面。
但是,如果这个人是继续开发此类软件的开发人员,或者只是修复其中发现的错误,那么质量可能就是这些因素的总和:
- 程序几乎没有错误(所以新开发人员只专注于新事物,而不是追逐这些错误的根源);
- 程序易于阅读(方法和变量名称良好,有用的注释,遵循标准等);
- 程序具有良好的结构,便于扩展(如果您只想要添加一个带有单个按钮的窗体,您不需要更改配置文件并创建 5 个不同的类);
- 而且,即使程序员一开始没有意识到这一点,他将使用的所有方法都不会破坏状态,并且在出现问题时会立即抛出异常。
SOLID
现在有很多文章在讨论 SOLID,其中许多文章将 SOLID 呈现为真正的 OOP。一个主要论点是,通过使用 SOLID(或者我应该说,通过尊重 SOLID 原则?),程序更容易维护和演进。那么,这就是质量,对吧?
好吧,我不会在这篇文章中讨论 SOLID,因为有很多文章已经这样做了(我已经谈到了 L,即 Liskov 替换原则,在这里),但我将要说的是,通过尊重 SOLID 原则,我们只是在尊重我刚才列出的第三项。毕竟,使用 SOLID,我们仍然可能会有错误(第 1 项),仍然可能会有难看的函数名,即使它们是真正松散耦合的(第 2 项),我们仍然可能会有不验证输入参数的方法,将无效值填充到列表和其他对象中,并在稍后被其他方法使用时抛出异常(第 4 项)。
所以,我在这里结束关于 SOLID 的讨论。从现在开始,我将专注于第四项。
坚如磐石的质量 - 永不破坏状态
坚如磐石的质量与 SOLID 原则无关,也不是许多词语的组合。它真正意味着极其抗状态破坏。
从一方面来看,这个想法很简单,但从另一方面来看,它非常棘手。大多数开发人员立即明白内部对象状态不应被破坏,因为破坏这种状态会导致稍后出现错误,并使查找错误源变得更加困难,但大多数开发人员仍然认为验证输入是浪费时间(他们自己的和 CPU 的时间)。
更糟的是,他们认为即使他们当前的实际代码没有破坏数据,如果他们不采取任何措施来保护数据不被其他地方破坏,一切都是正常的。最常见的论点是:
“它不是在工作吗?”
“嘿,如果有人调用了这样的无效调用,那就是他们的错误,而不是我的!”
“质量保证团队负责进行正确的测试并确保有 bug 的代码不会进入生产环境。”
而且,如果你相信这些论点是正确的,我应该问:我们为什么要有这些可见性修饰符(public
、protected
、internal
和 private
)?
如果我们得到一个正常工作的应用程序,并且应用了正确的可见性,我们可以将所有内容更改为 public
,代码将继续正常工作(好吧,只要没有反射期望搜索非公共成员,但那是一个边缘情况,也可以很容易纠正)。我的意思是:通过将所有内容都设为 public
,代码仍然可以正常工作,因为它将继续访问正确的函数和属性,而不是直接访问内部字段。但如果我们这样做,我们将使代码更容易出现错误,因为此类代码的用户可能会开始直接访问字段,从而破坏它们或读取尚未初始化的值。
那么,你怎么看?“代码正常工作”是衡量质量的好标准吗?
在我看来不是。对我来说,正常工作的代码是应该提供的最低要求,但要获得质量,必须保证正常工作的代码即使在未来新的单元调用该代码时会产生各种糟糕的调用,也能正常运行。在这种情况下,坚如磐石的组件必须抛出异常,导致新代码崩溃,但组件本身不应处于损坏的状态。也就是说,无论新代码做多么“愚蠢”的事情,已测试过的正常工作的模块将保持功能正常。
这在服务器应用程序中很容易看到。如果新页面传递 null
值或对共享组件进行其他错误调用,这些页面可能会崩溃,但使用旧页面的用户不会收到由这些共享组件内部结构中的垃圾数据引起的异常。所以,这些共享组件就是坚如磐石的。
如何实现坚如磐石的质量
坚如磐石的质量要求组件保证它们永远不会处于损坏的状态,无论是什么原因导致这种损坏状态。因此,我将提出一些实现这一目标的指南。
重要的是要注意,当我说永远不会处于损坏的状态时,有一些需要考虑的事情:
- 我说“永远不会处于损坏的状态”而不是“永远不破坏状态”,是因为两者的含义不同。“永远不破坏状态”仅仅意味着当前代码永远不会做坏事来创建损坏的状态,但它仍然可能允许别人向其中放入无效状态(例如,让一个字段是
public
或返回一个可变的且不可观察的内部集合); - 永远是一个很强的词。我们无法真正提供这种保证。在 .NET 中,可以使用反射来访问和修改内部状态。同样,我们也无法解决可能的硬件问题,但我们应该尽最大努力避免所有我们可以控制的情况;
- 性能关键代码是大多数规则的例外。一些坚如磐石的保证会影响性能,所以如果你正在编写内核驱动程序,你可能不会应用这些原则。
因此,忽略不可能的情况,让我们来理解为了提供坚如磐石的质量保证需要做什么:
第一部分 - 封装
我们从封装开始。如前所述,直接更改状态(甚至直接读取)可能会成为一个问题,因此字段永远不能是 public
,并且它们应该始终通过方法或属性(内部使用 get
/set
方法)来访问。
例如,如果我们稍后决定应用惰性加载,调用 get 方法的外部代码将继续正常工作,因为它将使用新的惰性加载技术。
由于所有状态更改都通过方法进行,因此可以验证无效状态的更改,抛出异常并保持状态不变。因此,这是另一个关键点:在进行实际状态更改之前,始终验证输入(以及可能的状态组合)。
谁来应用这些规则?
我们可以说,这些基本规则在 .NET 基础库中得到了很好的遵守。例如,如果一个列表只有一个元素,我们就不能在第 8 位插入一个元素,即使它的内部数组(其容量)大小为 32。我们永远不会通过访问无效的数组索引来破坏内存,而不能接受 null
值的函数会抛出 NullArgumentException
,然后再执行任何工作。
不过,确实有一些 public static readonly
字段,如果想要改变内部行为,这些字段可能会被认为有问题,但 static readonly
字段只在声明它们的类首次需要时才初始化,因此静态构造函数可以使用任何逻辑来初始化它们,这就已经实现了惰性加载的效果。
第二部分 - 线程安全/线程所有权
即使有所有输入验证,大多数 .NET 基础类仍然可能被破坏,因为它们根本不具备线程安全性,所以一个线程可能读取到一个中间状态,而另一个线程正在进行更改,甚至可能两个(或更多)线程同时更改状态,最终导致一个状态不是任何线程的完整状态。这可以被认为是“按设计”的,因为不具备线程安全性并避免线程检查会使代码更快,并且对象仍然可以被多个线程访问,前提是用户知道他们在做什么并进行适当的锁定。
但是,并非所有组件都共享这种想法。例如,WPF 依赖项属性始终进行线程检查。即使用户知道如何进行锁定,另一个线程也无法直接更改依赖项属性。进行此类更改的唯一方法是请求组件的所有者线程进行更改(通过使用 Dispatcher
实现)。因此,我们可以说依赖项属性(以及 WPF 组件总体上)比 BCL 组件实现了更好的坚如磐石性。
这就为我们带来了坚如磐石的组件必须是线程安全的规则。它们是否因为进行锁定、它们是不可变的还是只能由所有者线程使用而线程安全,对规则来说并不重要。这可能对组件的预期使用仍然很重要,因为如果组件预期由单个线程使用,则锁定比线程检查慢,而如果组件可以被许多并发读取者访问,则线程检查可能是一个问题。
关于不可变对象,重要的是要注意,使用惰性加载的“只读”对象仍然是可变的。由同一线程执行的对同一属性的两次调用将返回相同的结果,但初始状态将是“未加载”,并且加载过程是对状态的更改,因此这种加载也必须以某种方式具备线程安全性。
第三部分 - 所有状态转换都必须以“单步”从一个有效状态转到另一个有效状态
这种情况通常在数据库操作中看到,但它并不局限于数据库对象,我们可以说它仍然与封装有关。
在数据库操作中,我们通过使用事务来实现“单步”(也称为原子)行为。在我们的代码中,我们仍然可以进行许多单独的更改(例如,在两个不同的操作中更新两个不同的记录),但结果将被视为“全部或无”,因为我们将提交或回滚整个工作。
在处理内存对象时,我们也可以在不同方面遇到这种情况。因此,让我们看看其中的一些:
-
分配许多对象并设置字段。
我们应该先进行所有分配,然后在最后才设置新分配对象的状态字段,这是一个常见做法。
例如,看看这两种情况:
_x = new X(); _y = new Y();
并且
X x = new X(); Y y = new Y(); _x = x; _y = y;
我故意使用 X 和 Y 作为名称。X 和 Y 做什么并不重要。但想象它们可以是大型对象,具有复杂的逻辑,并且它们的分配可能会抛出异常(
OutOfMemoryException
总是可能的)。在第一个示例中,如果
new Y()
抛出异常,我们已经丢失了 _x 的先前值。在第二种情况下,如果new Y()
抛出异常,_x 和 _y 的值将保持不变,分配的 X 将在稍后被回收,因为不再有对其的引用。但是,如果 X 和 Y 是可处置的,我们可以改进代码,使其如下所示:
X x = new X(); Y y; try { y = new Y(); } catch { x.Dispose(); throw; } if (_x != null) _x.Dispose(); if (_y != null) _y.Dispose(); _x = x; _y = y;
注意:此代码还考虑了
Dispose()
永远不会抛出异常,但如果Dispose()
抛出异常,那么就有问题了。 -
必须同时设置的不同属性
我们可以独立读取属性的事实有时会让我们养成允许它们独立设置的不良习惯,而实际上并非总是如此。
在任何情况下,如果两个或多个属性必须全部设置或都不设置,我们都应该通过调用单个方法来执行这种状态转换。考虑到我们已经使代码具有线程安全性,内部有多少条指令来完成工作并不重要,重要的是调用者无法设置第一个属性而从不设置其他强制性属性。
与数据库情况相比,我们可以将其比作有折扣的情况,那么必须给出折扣的原因。因此,仅设置折扣是不允许的,但是执行此操作的方法将同时接收两个参数,能够验证两者,如果一切正常,将同时更新两个属性。
-
许多、许多更改伴随一次验证
在某些情况下,我们有一个可能被更改的大型对象层次结构。我们确实想添加许多项,删除许多其他项,等等,但验证必须在最后进行,因为中间的任何验证都可能失败。
许多常见模式包括诸如
DisableValidations
/EnableValidations
之类的方法,或者诸如using(DisableValidations()) { /* some code */ }
之类的方法。但这样做对于共享组件仍然有问题。如果您进行了许多更改,并且在验证时,验证失败了怎么办?或者即使EnableValidations
(或Dispose
)从未被调用怎么办?更好的解决方案,可以保证坚如磐石的质量,是创建一个单独的更改集合。在该集合中,您可以执行所有您想要的操作,但只记录您打算执行的操作。然后,通过一个方法(如
Apply
),您可以提供该集合。然后,所有验证都可以在应用任何更改之前完成,如果一切正常,所有更改都将应用。
第四部分 - 所有结果必须是不可变的、副本或其他坚如磐石的组件。
通常说我们不应该直接返回集合,而应该始终使用某种抽象。许多人然后使用漂亮的解释,如“我们应该针对接口编程,而不是针对实现”,但他们实际上并没有解释直接返回列表有什么问题。
所以,让我们解释一下问题:如果你直接返回一个可变列表,任何人都可以添加、删除和替换项目而无需任何验证。因此,用户可能会添加 null
值、重复值以及任何其他对你的组件无效的值。这已经非常成问题了,但还有其他问题,比如:如果你决定从 List<T>
更改为 HashSet<T>
会怎样?更改 public
方法的返回类型是破坏性更改。这个特定的更改(从一个类到另一个类的返回类型)是说“针对接口编程,而不是针对实现”的原因。List<T>
和 HashSet<T>
是实现。例如,IEnumerable<T>
是两者都实现的接口。
但是坚如磐石的质量与“针对接口编程”的想法不同。事实上,将 List<T>
或 HashSet<T>
转换为 IEnumerable<T>
仍然是危险的。用户总是可以将收到的对象转换回其原始类型并修改它们,因此他们将能够破坏状态。而且,对于坚如磐石的质量来说,你是否针对接口或实现编程并不重要。你可以返回特定类型的集合,只要这些集合进行正确的验证。也就是说,你可以针对实现编程,仍然拥有坚如磐石的质量(我们可能没有相同的整体质量,但坚如磐石的质量与保证状态有关,而不是与抽象有关)。
试图将问题归结为对坚如磐石的质量真正重要的事情,结果必须要么:
- 是副本。这可能在性能上效率不高,但有一个保证,即使结果被修改,内部引用的对象也会保持良好。我不建议这样做,因为它可能会让人产生错觉,认为你可以修改结果来修改原始内容,这是一个问题,但这不会影响坚如磐石的质量。而且,重要的是要提供深度副本,因为返回一个列表的副本,其中每个项目都是一个可变引用,仍然是一个问题;
- 是不可变的。在这种情况下,我们有原始类型和字符串的结果(这是最常见的结果),我们可以拥有自己的不可变类型。大多数值类型是不可变的,即使这不是强制性的,但值类型始终是一个副本,所以它们通常符合上一项(但如果一个结构体包含引用,那么该引用必须是不可变的或具有坚如磐石的质量);
- 也是坚如磐石的。如果你的结果是对另一个坚如磐石组件的引用,该组件知道如何以尊重实际组件规则的方式修改状态,那么一切都会好起来。例如,你可以返回一个可修改的
ColumnsCollection
,如果该集合能够进行正确的验证并使用正确的更新过程,即使在这种情况下我们是针对实现(ColumnsCollection
)而不是针对接口编程。
异常抵抗
即使之前的规则已经使组件坚如磐石,仍然有一些东西可能会出错。线程中止和一般的异步异常。但首先,让我们只关注线程中止。对于那些只能由所有者线程访问的组件,通常不是问题,因为如果它们的调用线程被中止,没有其他线程会访问它们。也就是说,线程可能会被中止并留下具有不一致状态的组件。只有当它们具有使用这些状态的 finalizer 方法时,才会成为问题。如果没有,没有人会看到它们被破坏。
对于可以被多个线程访问的组件,即使使用所有正确的锁定,它们仍然可能处于不一致的状态。这是因为线程中止可能发生在任何 IL 指令处,所以例如在设置两个已分配值的字段时,可能只有第一个字段被设置。可以通过将代码放入 finally 块来解决这种情况(因为即使发生 Abort()
也会执行 finally 块),但事实是:你永远无法免受线程中止的侵害。
在这种情况下,原因与 .NET 库本身有关。它们不是 Abort()
安全的,除了极少数组件,因此任何依赖于非 Abort()
安全库的代码在面对线程中止时都可能失败。
如果你真的想了解更多关于这方面的信息,我建议阅读我写的这两篇关于这个问题的文章:
Using Keyword Is Not Abort Safe
其他异步异常
线程中止会生成异步异常,并且是其中最常见的,但还有其他异步异常。一般来说,你调用的任何方法可能还没有被即时编译,为了即时编译它,必须分配内存(并且可能会抛出 OutOfMemoryException
)或者调用堆栈已满,并且可能会抛出 StackOverflowException
。
有一些方法可以尝试保护你的代码免受它们的影响(CER - 受约束的执行区域正是为此而设计的),但我真的不认为这是创建坚如磐石组件所必需的,原因在于它们极其罕见,而且当它们发生时,应用程序通常会立即崩溃(事实上,我从未在实际情况下遇到过此类异常,只有在强制它们发生时才遇到)。而且,即使你保护了你的组件免受它们的影响,.NET 本身也没有免受此类异常的保护。
结论
坚如磐石的质量是一个直接与组件内部状态相关的概念,它保证一旦经过测试且没有 bug,这些组件就可以被许多模块共享,而不会有一个模块通过不正确地使用组件来危害其他模块。只有当组件接收到无效的输入参数,或者它们确实依赖于外部因素(如网络)不起作用时,坚如磐石的组件才会抛出异常,但绝不会因为先前的调用破坏了组件。
这个概念不是其他原则的直接结果,也不打算取代它们。我们不会通过使用这个概念来避免单元测试的需要,但如果应用得当,这个概念将使单元测试更容易,因为可以保证给定组件在被另一个模块访问时或在之前的调用提供了无效参数时不会响应不正确。它无助于记录应用程序或解决业务问题,但它将通过在接收到无效参数时立即抛出异常来帮助你识别任何未来有 bug 的代码。
在坚如磐石的意大利面条式代码中碰壁
我知道结论通常在最后,但我决定在结论之后添加这个主题,因为它与坚如磐石的质量的含义无关,而是关于当你尝试将其应用于已存在的意大利面条式代码时可能面临的问题。
我知道许多开发人员害怕触碰任何旧的意大利面条式代码。你发现一个 bug,你修复它,然后,因为这个修复,又出现了其他 bug。也许这样的代码返回了一个负值,而概念上应该是正值,但有些地方已经减去了这个负值(使其变为正值),所以纠正这样一个方法将不可避免地导致与错误结果一起工作的代码停止工作。
如果你开始将坚如磐石的质量原则应用于旧的意大利面条式组件,情况也会如此。你添加了一个验证,现在原本工作的代码(但使整个系统不稳定)将根本不起作用,这是决定性的时刻。当发生这种情况时,经理们会发疯,他们说新版本是一个回归,比以前的 bug 要多得多,而且所有这些修改都应该被回滚。
好吧,我不能说所有经理都完全是这样的,但这确实是一个风险。当将普通组件转换为坚如磐石组件时,所有错误都会变得更加明显。行动不会完成,而不是完成一个动作并使系统不稳定(这可能不会立即影响所有人,并且可以通过重置服务器或应用程序来解决)。当然,服务器不会变得不稳定,但由于这个新验证,这个动作根本无法完成,所以很有可能有人会生气。
新的错误可能很容易解决,因为它们将发生在调用中的无效参数的确切位置,但直到调用被纠正,该操作将不再完成。所以正常的观点是:之前事情有时工作,有时失败。现在“系统从未工作”。
当然这是错误的。整个系统继续运行,并且不允许有问题的操作完成以保护系统的其余部分,但这并不是客户和经理通常看到的。
所以,如果你是添加新验证的开发人员,并且你遇到了这个问题,那么,我应该说,在进行此类修改时,你应该清楚可能存在的问题,但你也应该强调接收此类异常的优势,以便所有错误都可以得到纠正,而不是生活在“有时有效,有时无效”的状态。
我想说,根据我的经验,有很多情况,所有开发人员都在“追逐一个特定的 bug”,而我只是在使组件更加坚如磐石。在许多这些情况下,我在(做我自己的测试时)发现了许多错误并纠正了它们,有效地纠正了每个人都在追逐的那个 bug,而没有真正知道是哪个错误是真正的原因。然而,如果其他地方(很少使用)开始出现错误,我就会被指责为“引入了新 bug”的人。但最终,我总是能够解决那个“新 bug”,它一直都在,并证明“一次不工作”比“那个动作完成了,现在整个系统不稳定”要好。
一个新的结论
因此,为了给出一个包含所有主题的结论:坚如磐石的质量非常有用,但像大多数原则一样,它应该从一开始就存在。将现有项目更改为应用新原则总是可能的,但总会有棘手的时候。在这种情况下,我认为最好的解决方案是良好的沟通,如果可能的话,有一个良好的测试团队来尝试测试所有可能因更改而失败的情况。我仍然认为应用坚如磐石的质量非常有用,因为任何现有的 bug 都会变成一个固定在特定位置的常数,而不是变成“有时在随机位置失败”。与任何原则一样,由你决定何时应用它,以及它是否能在你的案例中带来任何好处。
版本历史
- 2013年7月3日。格式化了一些文本,并添加了主题“在坚如磐石的意大利面条式代码中碰壁”;
- 2013年7月2日。初始版本。