下载本文的PDF版本 PDF

异步世界中的调试
MICHAEL DONAT, SILICON CHALK

当您无法保证顺序执行时,难以追踪的错误可能会出现。正确的工具和技术可以提供帮助。

寻呼机、手机、智能家电和Web服务——这些产品和服务在我们的世界中几乎无处不在,并正在刺激一种新型软件的诞生:应用程序必须处理来自各种来源的输入,提供实时响应,提供强大的安全性——并在提供积极用户体验的同时完成所有这些。作为回应,一种新的应用程序编程风格正在兴起,这种风格基于多线程控制和数据的异步交换,并导致应用程序在本质上更加复杂。

但是,我们以前都处理过复杂且具有挑战性的软件——优化编译器、电子表格、文字处理、文本渲染、航空公司预订和太空探测器图像增强。我们现代的异步世界有什么不同,使得这种特定类型的软件如此难以开发?在Silicon Chalk,我们将这种差异称为涌现行为——即由组件之间的交互产生的行为(在隔离使用组件时未观察到的特性)。

传统上,软件行为是指令顺序执行的结果。行为的转变很容易识别和理解,因为我们有一个可以参考的序列,就像地图一样。然而,在异步世界中,软件行为不太具体,是多个指令序列并行运行并相互通信的结果。单个部分的转变仍然像以前一样清晰,但系统整体的转变变得更加复杂,因为系统状态空间是其组件状态的乘积。更糟糕的是,我们没有地图来帮助我们。

涌现行为带来了一系列挑战。您如何理解异步代码?虽然经验丰富的程序员擅长在脑海中执行代码,但只有极少数人有能力在脑海中执行多个代码流并跟踪它们的交互。事实上,我们还没有遇到这样的人——如果遇到,我们会立即聘用他们!

从测试的角度来看,涌现行为为错误创造了一些难以触及的隐藏场所。主要的挑战是设计策略,将错误从诸如竞争条件、脆弱时期导致崩溃的调用或与多个客户端交错通信的混乱等问题区域中清除出来,仅举几例。

似乎一种全新的难以推理的错误来源还不够糟糕,使用足以准确诊断和修复故障的工具来确定性地重现异常行为也非常困难。当然,我们之前也面临过日益增长的复杂性。我们已经从查看核心转储,到原始调试器,再到当前的符号调试器、内存使用工具(例如,Purify和BoundsChecker)、性能分析工具(例如,gprof和Quan-tify)等等,这些工具提供了越来越复杂的机制来观察、中断甚至修改代码的执行。与过去一样,我们创建更大、更复杂代码的能力已经超过了用于管理和理解该代码的现有工具。很少有工具能够处理大量线程和跨线程、进程和机器的复杂控制流,更不用说由此产生的涌现行为。

在我们自己的调试工作中,我们依赖于大量使用断言 [1] 以及检查应用程序测试运行生成的日志文件。因为我们预计很少看到错误,并且知道它们很难重现,所以我们需要在错误首次发生时捕获和检查错误的能力。

从维护的角度来看,涌现行为意味着在您体验到整个系统的行为之前,无法成功地在实现方案之间做出选择。

在开发和交付分布式和实时应用程序多年之后,我们痛苦地认识到这些问题不容忽视。我们将讨论我们的一些经验,并提供一种新的视角来看待异步应用程序的调试、测试和维护过程。我们还将研究现有工具的缺点,介绍我们发现有用的一些机制,并观察工具和方法可能演变的方式,以帮助应对这些挑战。

起初

在专注于异步应用程序的测试和调试之前,我们必须提及一些设计原则。虽然一直以来,在创建良好的设计上花费时间会使软件更易于测试、调试和维护,但在我们的异步世界中,这一点更为重要。

大多数人发现对涌现行为进行建模具有挑战性。重要的是通过使设计的这些方面更加明确来创建参考点。

一种有用的技术是为系统中各种典型的任务或活动创建序列图。序列图明确地表示了多个执行流,因此它们为提出诸如“如果事件B在事件A之前发生会怎样?”之类的问题提供了理想的媒介——即使图表可能显示它们以相反的顺序发生。基于对事物应该如何发生的狭隘视野来设计系统非常容易。序列图使人们更容易开始理解可能发生的事情。我们已经看到许多情况,在这种情况下,通过这种方法在设计时检测并处理了系统中相对严重的潜在缺陷。

另一种非常有用的技术是在可能的情况下识别并显式编码状态机 [2]。无论它们如何编写,组件都会根据它们接收的通信从一个状态移动到另一个状态。当代码在没有指导的情况下演变时,这些状态决策通常会作为看似不相关的if语句的集合分布在整个代码中。在没有指导结构的情况下,与代码对应的状态机变得过于复杂且容易出错,最终代码将无法维护。

从一开始就将组件视为状态机,可以沿着更容易理解的路径构建它们的演变。它提供了一个中心位置,封装了行为决策。而且,与序列图一样,状态机非常适合考虑假设情景,例如“如果事件A在机器处于状态S1时到达会怎样?”

我们作为软件开发一部分成功应用的第三种技术是积极使用断言。断言在几个方面很有用。首先,它们允许程序员记录他们对代码将在何种情况下执行的假设。因为断言是编程环境的一部分,所以它们既简洁又精确,并且有强烈的动机使这种形式的文档与代码本身保持一致——也就是说,除非你这样做,否则代码将不会运行。

其次,断言比没有断言的情况更早地捕获错误行为。在没有断言的情况下,即使代码中固有的基本假设不再成立,代码仍然会愉快地继续执行。这些假设的范围可以从您取消引用指针时指针是非空的固有假设,到关于各种状态值之间关系的更复杂的假设。无论如何,当实际注意到故障时(可能是因为您收到了访问冲突),它通常可能与最初违反假设的时间相去甚远。由于复杂的组件交互,可能几乎无法重建何时以及如何违反了假设。

提出这些示例的主要目的是引起人们对尽早解决问题的重要性的关注。如果您正在创建异步应用程序,请寻找有助于突出涌现行为的设计技术。如果应用程序的大部分可以表示为状态机,那么将它们与应用程序的实际编码分开模拟可能很有用。请记住,最糟糕的错误将难以确定性地重现;异步应用程序中这类错误会比您习惯的要多得多。根据我们的经验,预先设置的机制将有助于分析之后出现的问题(现代等同于读取核心转储),并将有助于更快地隔离这些类型的错误。

测试

我们目前正在开发的一个应用程序组件是“目录服务”,它将数据与其他机器上的对应服务同步。同步算法旨在在可能较差的无线网络上具有容错能力,同时也要力求高效。在接下来的讨论中,我们将使用这个例子。

在创建了一个出色的异步软件(例如我们的目录服务)之后,并且希望通过良好的设计预先捕获了许多问题之后,您仍然需要测试您的软件,并确保它真正按照需求所说的去做。测试有很多不同的种类——单元测试、系统测试、压力测试、性能测试等等。本讨论将考察执行测试的技术,而不是不同种类测试的优点。我们将只关注

当我们测试我们的目录服务时,我们执行通常的手动测试——人工驱动的测试刺激和视觉评估——但我们也使用脚本来生成测试刺激,并通过检查日志文件来评估响应。

由于应用程序已变得功能丰富,手动测试实在太不规则、缓慢且昂贵。我们需要一个自动化测试基础设施。

大多数操作系统都支持测试脚本工具,这些工具通常将脚本语言与一些基于UI的刺激生成原语以及基于UI的响应检查原语结合在一起。还提供用于运行脚本以及记录和报告结果的管理功能——其中许多功能非常齐全,甚至可以在一组机器上运行应用程序。有关这些类型工具的信息可以在网上找到,例如,在ApNet (http://www.aptest.com/resources.html#info-misctools)。注意:对于非基于UI的应用程序(例如信号处理),您可能更难找到合适的现成工具。

可以使用诸如jUnit之类的单元测试工具,或者使用临时代码集合(这些代码集合单独使用每个组件)来隔离测试组件。测试刺激只是对被测代码的直接调用,并通过返回值或通过探测数据结构来检查响应。这些测试在集成之前非常有用,但在揭示隐藏在涌现行为中的那种错误方面无效;根据定义,它是一个后集成实体。

这些技术的问题在于,我们只看到了冰山一角。我们希望任何错误都会出现。为了充分解决涌现行为,我们需要更深入地研究多机状态空间的方法。当前的测试工具没有配备评估异步应用程序状态转换的能力。

在异步世界中,水深得多。您在水面附近发现的鱼和以前一样,但现在深处还有许多其他鱼难以发现(而且有些鱼的牙齿非常大!)。我们需要弄清楚如何捕捞大鱼。我们需要检查异步应用程序复杂状态空间的深处。

在测试期间,最大的问题是:一切都按预期发生吗?仅仅通过UI查看结果就足够了吗?在我们的目录服务示例中,应用程序的实例在特定测试期间可能彼此成功通信,但我们如何知道同步算法是否正常运行?在UI级别评估系统响应是不够的;可能刚刚执行的场景奏效了,但不是按预期的方式。换句话说,该场景发现了一个错误,但如果您只是查看UI,则不会看到它。例如,一个目录服务在听到相同数据三次后认为它是同步的,这显然是不正常工作的,但应用程序仍然看起来可以运行。

在处理处理消息流的组件时,很容易忘记询问您为什么要接收这些特定消息。当您查看实例如何相互交互时,您可能会看到已生成项目/值对中的消息,并且它们看起来“语法上”正确,但它们在目录之间“对话”的上下文中没有意义。为了观察到这一点,您必须能够“听到”整个对话,这意味着检查来自所有实例的信息。

为了确定应用程序是否按我们期望的方式运行,我们需要做的不仅仅是查看系统输出。事实上,我们必须做的不仅仅是探测应用程序的某些数据结构。我们必须检查事件序列的跟踪。我们发现这是常态而不是例外。一般来说,我们应该检查组件之间的交互,例如,在全球层面识别事件A是否紧随事件B,事件B是否紧随事件C——即使每个事件都可能由不同机器上的不同组件产生。不幸的是,没有单个组件可以让我们查看以进行此评估。

检测深层错误。 为了在测试期间检测错误,我们发现使用传统上用于调试的工具来检查应用程序是有益的。使用这些工具的挑战在于,大多数调试工具都面向检查和跟踪单个控制线程的活动;我们需要检查控制线程之间的交互。

我们发现对此最有用的机制是跟踪。当然,跟踪工具并不新鲜。事实上,旧的printf调试方法只是通过手动插入跟踪代码来实现跟踪。异步世界在几个方面提高了跟踪的标准

  1. 我们需要能够显式地关联跨多个控制线程的活动,这些线程甚至可能驻留在不同的机器上。
  2. 由于异步应用程序的复杂性,我们不能依赖手动干预来插入跟踪代码;这太费力了。
  3. 由于系统通常实时处理信息,因此跟踪工具本身对性能的影响必须最小。这一点至关重要,因为如果执行时间被改变,跟踪工具可能会隐藏对诸如竞争条件之类的事情敏感的错误。

我们认为针对这些问题中的每一个都有三种通用方法。首先,跨线程控制关联活动的能力通常可以通过我们所说的“拦截技术”来处理。粗略地说,这些技术通过将自身插入组件之间的通信路径中来捕获组件之间的交互。根据组件彼此通信的方式,有许多不同的拦截风格。我们将讨论我们所了解的一些技术,但如果您要构建这种跟踪工具,您仍然需要仔细考虑这一点。

其次,手动插入代码的问题以显而易见的方式解决:我们必须创建允许自动插入跟踪代码的机制,理想情况下,可以根据诸如哪些组件正在通信或正在跟踪的特定交互类型等因素来启用和禁用跟踪。

最后,我们必须注意跟踪系统自身的设计,以最大限度地减少对程序行为的改变。

关于跟踪。 让我们从最后一个问题开始研究跟踪技术。以下是一些关于最大限度地减少跟踪影响的想法

  1. 保持跟踪机制尽可能简单,以便您可以最大限度地减少必须进行的OS调用次数。
  2. 在内存中收集跟踪,以使其尽可能快。
  3. 使用单独的低优先级线程将跟踪内存写入磁盘。
  4. 在生成跟踪输出时,暂时提高进程优先级,以避免使用锁定,这需要更昂贵的OS调用。
  5. 寻找压缩跟踪输出的方法,以最大限度地减少磁盘访问。一种方法是为后续使用的格式字符串编写标记,并将格式字符串和参数分开记录,而不是在收集期间花费时间格式化跟踪输出。

跟踪所有内容可能会破坏我们的结果(通过改变代码执行的timing),并且我们希望出于不同目的关注应用程序的不同方面。跟踪功能对于调试活动也很重要——诊断测试中发现的错误的原因。通常,症状出现在一个地方,但其原因在另一个地方。我们还需要一种以最小的努力重新配置跟踪收集的机制,以便该工具有效。大型代码库和许多独立的代码路径使手动跟踪重新配置不切实际。自动化解决方案是必要的。

如前所述,我们在使用拦截技术捕获相关跟踪信息方面拥有非常好的经验。使用拦截时,您必须首先查看系统中的组件如何交互,然后尝试确定拦截和跟踪这些交互的最佳机制。组件可以通过无数种方式进行交互:函数调用、方法调用、共享内存、锁、信号量、事件队列和网络消息,仅举几例。我们将首先研究一个从我们自身经验中提取的具体示例,然后简要介绍我们知道的其他一些示例——这些示例可能具有广泛的适用性。然而,显示涌现行为的系统往往具有特有的通信机制,而为您的系统创建框架最有趣的方面之一将是弄清楚要捕获哪些交互以及如何自动化捕获过程。

我们工作的应用程序之一是基于COM(微软的组件对象模型系统)。与大多数组件系统一样,COM基于声明式接口规范,并为组件的创建和生命周期管理功能提供基础设施。

幸运的是,COM提供了在通信路径中拦截自身所需的基础知识。在我们的例子中,我们获取系统中所有组件的接口规范,并机械地对其进行后处理,以为这些接口创建新的实现。新实现(我们称之为“跟踪代理”)的目的是跟踪有关调用何时发生、谁发起了调用、谁接收了调用以及参数值的信息,然后将调用原封不动地传递给实际实现。

在运行时,我们可以选择在系统中任何组件的前面插入跟踪代理。修改COM中的组件创建工具可以做到这一点,因此它对应用程序代码本身是完全透明的。 направленные к определенному компоненту, на самом деле отправляются его прокси. 代理跟踪出必要的信息,然后在实际组件上进行相应的调用。

将拦截应用于其他系统有多容易?显然,这完全取决于所使用的通信机制的性质以及您的操作系统提供的工具,但我们肯定知道在许多情况下,这种类型的方法是可行的

  1. 网络。诸如tcpdump (Unix) 和 netmon/netcap (Windows) 之类的工具已经允许人们拦截通过网络连接的组件的通信。但是,如果您构建了自己的应用程序级协议,您可能需要考虑扩展这些工具的功能,甚至编写自己的工具来跟踪包含与交互相关的丰富语义细节的信息。例如,tcpdump 会为您提供用户数据库协议 (UDP) 数据包中字节的转储,但您可以对 tcpdump 日志进行后处理,以按照您的代码的方式解释这些字节并生成更高级别的跟踪。
  2. 对象分发系统。.NET 和 Java 都提供了虚拟机级别的工具来拦截所有方法调用。
  3. 组件系统。COM、CORBA 和 EJB 基于接口规范,这些规范允许构建代理,如前所述。
  4. 面向切面编程。这为识别拦截点和插入代码提供了丰富的词汇。

跟踪包含大量有价值的信息。然而,如果只是手动完成,将小麦与谷壳分开可能会让人感到不知所措。使用诸如 grep 和 sed 之类的工具进行搜索和过滤非常有用,但识别通信模式需要稍微复杂的工具。

我们使用术语“路径表达式”来表达我们有兴趣标记的行为类型。我们可能希望检测有缺陷的交换序列,或者我们可能想要找到特定的序列以便我们可以进一步检查它。路径表达式是描述这些序列的正则表达式。它们与在 grep 等工具中找到的略有不同,因为我们想要忽略任何可能散布在我们跟踪中的无用通信数据包。(换句话说,路径表达式中的每个字符 X 都由前面的 grep-ism 隐式分隔:[^X]*。)

路径表达式用于评估跟踪日志。如果跟踪与路径表达式不匹配,我们就检测到了一个错误。但不仅如此,我们还拥有该错误的上下文,这使我们在理解可能出了什么问题,或者我们如何重现问题以供进一步研究方面取得了很大的领先。

我们的应用程序具有录制功能,允许用户重放之前与软件的交互。此功能是通过存储应用程序的一些实际内部事件来实现的。通过利用这项技术,我们将脚本机制整合到应用程序中。

脚本是用一种非常简单的语言编写的。我们没有许多脚本工具提供的循环或 if-then-else 结构。我们唯一拥有的能力是在宏参数上包含文件、宏、UI原语和字符串连接。然而,这为我们提供了编写极其广泛的交互脚本的方法。

脚本被编译成包含特殊事件的记录文件,我们称之为“Monkey”事件。记录可以通过命令行参数回放,“Monkey”事件被转换为它们预期的UI原语。

还记得我们之前提到的目录服务吗?它允许我们让一台机器向其他机器发送脚本命令,以便一个脚本可以包含一组机器要执行的同步UI操作序列。这确实有助于我们完成诸如自动验证我们最新的构建和重现困难的交互之类的任务。

调试。 程序员出了名的不擅长模拟处理器。了解一些重要的代码将要做什么的唯一方法是执行它并观察会发生什么。通常,人们会看到一些出乎意料且明显错误的事件序列。用于测试的相同跟踪工具可以帮助调试。然而,在异步调试的上下文中,会出现额外的考虑因素。

最重要的一个是可重现性。最难解决的错误是那些不确定且难以重现的错误。尝试通过手动操作UI来重现这些错误是具有挑战性的。脚本化操作UI更有效,因为人们可以精确地编写UI操作序列的脚本,并改变 timing 参数,直到持续观察到该错误。您还可以改变事件发生的顺序,以查看该错误是否与不同组件之间的交错通信有关。一旦您重现了该错误,您就拥有了向前发展的测试资产。该脚本可以在将来重用,以确保在重新引入该错误时可以检测到它。

脚本编写中的假设是脚本工具可以在合适的间隔内持续执行脚本命令。根据几个因素,包括网络速度和所涉及机器上的处理器负载,在单独的机器上同步事件会遇到间隔问题。因此,总会存在一些超出脚本软件能力的小间隔,这将导致某些错误使用此技术间歇性地重现。

对可疑代码的通信状态机近似值进行形式化分析可能有助于理解某些错误——但这是一项非常昂贵的 工作,并且形式化模型与现实之间总是存在差距 [3],这会留下疑问的空间。代码正确性的形式化证明同样站不住脚,至少对于商业级软件而言是这样。

由于总会有一些难以重现的错误,我们的方法是使用应用程序的instrumented版本执行所有测试。这使我们能够在检测到错误后检查日志文件。即使是软件的发布版本也会生成日志文件(日志文件仅记录过去一小时左右的事件,以确保它们使用有限的磁盘空间)。我们将此证明为合理的策略,因为代码故障通常有多种显示自身的方式。在instrumented版本中查找错误与在非instrumented版本中查找错误所需的工作量几乎相同,这很可能是真的。

调试器是必不可少的。不幸的是,对于跟踪多机状态空间的支持不多。仅仅跟踪逻辑执行线程可能非常困难。一旦您重现了该错误,您就会在代码中跟踪,紧追不舍。然后您遇到了一个远程过程调用,或合理的副本,而调试器无法帮助您跟踪到远程机器上的代码——真糟糕!

这个问题有两面性。一方面,人们希望能够透明地跟踪逻辑执行线程,而不是处理远程机器上的其他调试器——以及在处理物理执行上下文等时 RPC(远程过程调用)支持的复杂性。另一方面,人们也希望能够在服务入口点设置断点,并检查回到调用客户端的堆栈帧。

以我们的情况为例,请原谅这里的血腥细节,当您从单线程单元 (STA) 组件向多线程单元 (MTA) 组件发出接口调用时,您可以开始跟踪 STA 组件中的执行,但随后它消失在 COM 基础设施中,您没有简单的方法将其与 MTA 组件中生成的执行序列连接起来。

Visual Studio .NET 支持 Web 服务的逻辑线程跟踪和远程堆栈帧检查。对于不支持这些功能的其他平台/环境,可以考虑部分解决方案。一种这样的解决方案是显式传递机器+线程标识符作为每个远程过程调用的第一个参数。这必须从一开始就成为设计的一部分,因为事后实施很困难。

今天的调试器具有相当好的条件断点功能。然而,在异步系统中,人们正在寻找的“事件”可能是在独立执行上下文中执行的一系列代码。断点上的条件通常仅限于对本地机器上的引用的引用,因此设置适当的断点可能是一个挑战。这是另一种情况,其中后处理跟踪更具吸引力。可以搜索跟踪,然后在“事件”发生附近检查跟踪。

关于调试异步应用程序的一个有趣的事情是,您对调试器的依赖程度降低了。随着这些应用程序变得越来越普遍,开发工具无疑将赶上。与此同时,您应该找到将调试功能直接构建到应用程序中的方法。

我们描述了一种对我们非常有用的技术:基于自动拦截代码的灵活跟踪。我们还考虑过的其他技术包括通过instrumenting锁定代码来检测死锁,以及自动监视队列长度(我们的许多组件都将事件排队,而大量的事件积压通常表明某些地方出了问题)。每当您在系统中发现一类难以追踪的错误时,如果您能弄清楚如何使您的代码检测到该类型的错误,甚至指出其方向,那么可能会有很大的好处。

消除涌现错误

新型设备以及互联网的普及正在为能够实时处理各种输入和输出的软件创造需求。通常,为了响应这一点,应用程序被构造为能够异步通信的组件集合。虽然这种架构有助于满足需求,但它也创建了表现出涌现行为的系统,这种行为在隔离使用组件时无法观察到。

我们讨论了涌现行为的一些陷阱,特别是它如何影响测试和调试。我们还谈到我们在自身开发工作中面临的一些问题,并描述了将调试支持直接构建到应用程序中如何帮助解决这些问题。毫无疑问,您的经验会有所不同,但希望我们为您可能面临的问题以及处理这些问题的方法提供了有益的见解。

致谢

我要感谢我在Silicon Chalk的亲爱的同事以及帮助我撰写本文的审稿人。

注释

  1. 断言是嵌入在应用程序调试版本中的检查,当出现问题的最初迹象时,会弹出一个消息框。
  2. 状态机通过显式识别状态和状态之间可能的转换来描述行为。可见行为是转换序列的结果。
  3. 差距的存在是因为形式化模型始终是某人对代码的解释,反之亦然,这取决于所使用的技术。通过努力,差距可以缩小,但它始终存在。

MICHAEL DONAT 目前是加拿大不列颠哥伦比亚省温哥华市Silicon Chalk的质量保证总监。他拥有不列颠哥伦比亚大学计算机科学博士学位,自1987年至1992年为微软工作以来,一直对软件开发问题感兴趣。

acmqueue

最初发表于 Queue 第 1 卷,第 6 期——
数字图书馆 中评论本文





更多相关文章

Sanjay Sha - 企业应用程序的可靠性
企业可靠性是一门学科,它确保应用程序将以一致、可预测且经济高效的方式交付所需的业务功能,而不会损害诸如可用性、性能和可维护性之类的核心方面。本文介绍了一套企业可以应用的核心原则和工程方法,以帮助他们驾驭企业可靠性的复杂环境并交付高度可靠且经济高效的应用程序。


Robert Guo - MongoDB 的 JavaScript Fuzzer
随着时间的推移,MongoDB 变得更加功能丰富和复杂,开发更复杂的错误查找方法的需求也在增长。三年前,MongDB 将自研的 JavaScript fuzzer 添加到其工具包中,现在它已成为我们最多产的错误查找工具,在两个发布周期内负责检测到近 200 个错误。这些错误涵盖了从分片到存储引擎的各种 MongoDB 组件,症状从死锁到数据不一致不等。fuzzer 作为 CI(持续集成)系统的一部分运行,它经常捕获新提交代码中的错误。


Robert V. Binder, Bruno Legeard, Anne Kramer - 基于模型的测试:它处于什么位置?
您可能听说过MBT(基于模型的测试),但与许多未曾使用过MBT的软件工程专业人士一样,您可能对其他人在使用这种测试设计方法方面的经验感到好奇。从2014年6月中旬至2014年8月初,我们进行了一项调查,旨在了解MBT用户如何看待其效率和效能。2014年MBT用户调查是2012年类似调查的后续,面向所有评估或使用过任何MBT方法的人员开放。这份调查包含32个问题,其中一些问题来自2013年高级自动化测试用户大会上分发的调查问卷。部分问题侧重于MBT的效率和效能,旨在提供管理者最关注的数据。


Terry Coatta, Michael Donat, Jafar Husain - EA 自动化质量保证测试:事件驱动
对于数百万游戏爱好者而言,在 Electronic Arts 担任 QA(质量保证)测试员的职位,一定看似梦寐以求。但从公司角度来看,与 QA 相关的开销可能会显得非常惊人,尤其是在大型多人在线游戏时代。





© 保留所有权利。

© . All rights reserved.