3.4 处理错误和异常

给用户看到友好的错误报告,其重要性绝不低于提高DSL语法的表现力。因为DSL是一种应用范围受限的语言,错误消息也应该适应其应用范围,用领域语言去表达。DSL环境内的错误和异常报告要有章可循,不能误导用户和造成困惑。报告中要清楚说明系统所处的确切状况。这种设计思路叫做领域驱动的异常报告,详见3.4.1节。我们还会讲到DSL用户可能面对的两种主要类型的错误状态。以上几点要求共同构成了错误和异常处理策略的三大支柱,是DSL设计者不可忽略的参考视角,如图3-8所示。

enter image description here

图3-8 DSL错误和异常状态处理策略的三大支柱

根据DSL的内部、外部之分,以及实现语言的区别,错误状态的呈现方式也有所不同。表3-4总结了有关错误、异常的待解问题,还有你身为DSL设计者的责任。

表3-4 关于DSL错误和异常,你应该知道的一些事

待解问题 DSL设计者的解决之道
你需要清楚声明DSL内的异常状态 异常状态也是领域抽象。应当一直使用领域语言来表述过程中可能发生的任何异常。详见3.4.1节
当用户输错了方法名、对象名或者其他语言成分,你需要处理因此产生的错误 具体策略取决于所用的实现语言。详见3.4.2节
当系统进入无效的业务状态,你需要处理因此出现的异常。例如,当与银行的通信中断时,若有人试图转账一笔资金,会发生什么事呢 在报告此类异常的时候,请务必以用户可以理解的语言提供所有相关详情。详见3.4.3节

下面我们就详细探讨这几个问题。

3.4.1 给异常命名

给DSL里的异常状态命名的时候,我们应该采用领域用户的用语描述那种情况。异常不一定是设施出了毛病,可能只是业务用例中的一条分支。命名的重点是通过领域语汇将情况呈现出来。下面的例子来自一个对交易双方账户进行结算的系统:

  1. val fromBalance = fromAccount.getSecurityBalance
  2. if (fromBalance <= tradeQuantity)
  3. throw new SettlementFailedException(
  4. " Insufficient security balance in " +
  5. "account " + counterpartyAccount.getName +
  6. " for settlement completion")
  7. settle(...)

系统遇到了异常状况;当卖家的证券余额不足的时候,结算将失败。事件的状态已经通过异常名SettlementFailedException表达出来,其措辞也符合现实中结算系统的一般用语。当用户看到这个异常,他立即就会明白当前的情况。而且,他还会在异常附带的消息里看到详细的失败原因。

3.4.2 处理输入错误

不管DSL语言多么自然,用户还是会写错,这是人类本性。如果所用编程语言是像Scala和Java这样的静态类型语言,编译器会在错误发生时立即给你提醒。只要你违反了语言类型系统的规则,编译器就会像警察一样来警示你,如图3-9所示。

enter image description here

图3-9 编译器就像警察一样发挥警示作用

如果用户对实现DSL的宿主语言有足够的了解,编译器报告的错误消息将很有帮助。现代IDE都带有代码助手和自动补全功能,有利于防范输入错误。可是,如果没有这些帮助又该怎么办呢?

1. 当类型系统不可用时

像Ruby和Groovy那样的动态类型语言没有编译器帮忙找出类型错误。在这些语言里,类型错误大多要经过语言的方法分发流水线处理之后作为运行时错误呈现出来。即使没有编译时的错误检查,也不妨碍设计得当的DSL发挥动态语言的优势来达到目的,比如利用methodMissing特性来设置友好的错误处理程序,向DSL用户传达纠正错误所需的信息。

对于使用动态语言来设计DSL而言,采用methodMissing是非常有用的技巧。下面的Ruby示例就为方便用户理解运行时异常补充了足够的上下文信息:

  1. class Trade
  2. //...
  3. def method_missing(method, *args, &block)
  4. raise NoMethodError, <<ERRORINFO
  5. method: #{method}
  6. args: #{args.inspect}
  7. on: #{self.to_yaml}
  8. ERRORINFO
  9. end
  10. //...
  11. end

如果用户输入了Trade对象中不存在的方法名,Ruby将默认抛出一个NoMethodError。而上面的代码片段实现了method_missing来充当定制的错误处理程序,可以向用户提供更多上下文信息。(至于在Groovy中利用methodMissing合成新方法的例子,请翻阅2.2.2节。)

2. 语法解析器的功用

对于外部DSL,解析器在解析输入脚本的过程中需要确保报告输入字符串发生错误的准确行号和位置。错误报告的友好程度非常依赖于生成DSL语法解析器所采用的技术。在错误报告方面,ANTLR生成的自顶向下型解析器比YACC的自底向上型解析器支持性更好。在第7章讨论通过解析器生成器设计外部DSL的时候,我们会详细解说这部分内容。

好了,对于注定要出现的用户输入错误,你已经知道该怎样对付了。那么,如果业务状态出了错误,该怎么办呢?

3.4.3 处理异常的业务状态

DSL应该有能力精确报告异常状态,且按照3.4.1节所说的“领域驱动的异常报告”方式进行。比报告异常更重要的是处理异常。DSL运行期间可能抛出的所有领域异常,都应该有相应的处理程序,包括实施各种清理动作、释放资源和回滚事务。

怎样处理和报告异常才算合适,这也要看你选择什么样的策略来集成DSL脚本。像3.2.1节中那种依靠ScriptEngine的集成策略,一般在报告异常方面表现不好。下面的例子来自我们之前讨论在Java应用程序中内嵌Groovy脚本的部分,我们看看它是怎么报告异常的:

  1. ScriptEngineManager factory = new ScriptEngineManager();
  2. ScriptEngine engine = factory.getEngineByName("groovy");
  3. try {
  4. List<?> orders = (List<?>)
  5. engine.eval(new InputStreamReader(
  6. new BufferedInputStream(
  7. new SequenceInputStream(
  8. new FileInputStream("ClientOrder.groovy"),
  9. new FileInputStream("order.dsl")))));
  10. } catch (javax.script.ScriptException screx) {
  11. // 具体的处理 ❶ 处理异常
  12. }

示例并没有在Groovy代码内显式处理异常,我们只是假设脚本的调用者会去处理❶。javax.script.ScriptException类带有getFileName()getLineNumber()等方法,有助于找到发生异常的确切位置。凡是源自DSL内部的异常都必须谨慎处理,而且你需要向用户提供充分的上下文信息——这是重要的处理原则。然而,当DSL代码运行在ScriptEngine的沙盒里面时,处理异常的时候所需的上下文信息不一定直观。这个缺点再次说明,集成DSL应该优先选择语言专门提供的方式,只在不得已时选择Java脚本引擎方案。

DSL的设计宗旨是领域内的可读性和表现力,因而我们必须时刻考虑到领域用户。与此同时,我们也要留心DSL对应用设计的性能有何影响。应该如何折中呢?