演化架构和紧急设计: 使用 DSL

使用特定领域语言捕捉惯用域模式

至今,演化构架和紧急设计主要关注技术模式的紧急设计,本期将介绍使用特定领域语言(DSL)捕获 领域惯用模式。系列作者 Neal Ford 用一个例子说明了该方法,显示了这种获取惯用模式的抽象样式的优势。

Neal Ford, 软件架构师, ThoughtWorks Inc.

Neal FordNeal Ford 是一家全球性 IT 咨询公司 ThoughtWorks 的软件架构师和 Meme Wrangler。他的工作还包括设计和开发应用程序、教材、杂志文章、课件和视频/DVD 演示,而且他是各种技术书籍的作者或编辑,包括最近的新书 The Productive Programmer。他主要的工作重心是设计和构建大型企业应用程序。他还是全球开发人员会议上的国际知名演说家。请访问他的 Web 站点



2010 年 7 月 26 日

惯用模式可以是 技术也可以是 领域。技术模式为常用的技术软件问题指出解决方案,例如在应用程序(或应用程序套件)中怎样处理验证、安全和事务数据。前几期主要关注获取技术惯用模式所用的技术,例如元程序设计。域模式关注的是如何抽象常见业务问题。而技术模式几乎出现在所有的软件中,域模式之间的差异与业务之间的差异一样大。然而获取它们有一套丰富的技术,这就是本期以及后续几期将要谈论的话题。

关于本系列

系列旨在从全新的视角来介绍经常讨论但是又难以理解的软件架构和设计概念。通过具体示例,Neal Ford 将帮助您在 演化架构紧急设计的灵活实践中打下坚实的基础。通过将重要的架构和设计决定推迟到最后责任时刻,您可以防止不必要的复杂度降低软件项目的质量。

本文为使用 DSL 技术作为一种抽象样式获取域模式提供动力,DSL 提供多种选择,包括自己命名的模式。Martin Fowler 最近的一本书对 DSL 技术有较为深入的研究(见 参考资料)。在后续几期中,我会使用他的许多模式名,也会将他的示例用于我的例子中,逐步讲述具体技术。

DSL 的动机

为什么我们要费那么多周折创建一个 DSL ?仅仅为了获取一个惯用模式?正如我在 “利用可重用代码,第 2 部分” 中指出的,区分惯用模式的最好方法就是让它看起来与其他代码不一样。这种看得见的不同是最直接的线索,您不需要再看常规 API。同样,使用 DSL 的目的之一是写代码,使这些代码看起来不像源代码而更像您正在尝试解决的问题。如果能够达到这个目标(或者接近这个目标),那您将填补软件项目中的这一空白,为开发人员和业务涉众的沟通架起了桥梁。允许用户阅读您的代码很有必要,因为这消除了将代码转换为语言的需求,这是项很容易出错的工作。让您的代码对于非技术人员是易读的,因为他们了解软件的预期设想,这样你们之间就会有更多的交流。

为激励开发人员使用这种技术,我将借用 Fowler DSL 书中的例子(见 参考资料)。假设我正为一家制作软件控制的暗格(secret compartments,想想 James Bond)的公司工作。公司的一个客户,H. 夫人,想要在她的卧室里装一个暗格。然而我们公司使用 .com 泡沫破碎后留下的 Java™驱动的 toasters 来运行软件。尽管 toasters 比较便宜,但更新其中的软件却很昂贵,因此我需要创建基础暗格代码,然后将其永久地设置在 toasters 上,然后找到一种方法根据每位用户的需求进行配置。您也知道,在现代软件世界中,这是一个很常见的问题:普遍行为不常改变,而配置需要根据个人情况进行改变。

H. 夫人想要一个暗格,打开这个暗格的方法是:首先关闭卧室门,接着打开梳妆台的第二个抽屉,最后打开床头灯。这些活动必须依次进行,如果打乱次序,必须重头开始。您可以将控制暗格的软件想象为一个状态机,如图 1 所示:

图 1. H. 夫人的暗格是状态机
状态机图

基本状态机 API 比较简单。创建一个抽象事件类,可以同时处理状态机内的事件和命令,如清单 1 所示:

清单 1. 状态机的抽象事件
 public class AbstractEvent { 
  private String name, code; 

  public AbstractEvent(String name, String code) { 
    this.name = name; 
    this.code = code; 
  } 
  public String getCode() { return code;} 
  public String getName() { return name;}

可以使用另一个简单类 States对状态机中的状态建模,如清单 2 所示 :

清单 2. 状态机类的开始部分
 public class States { 
  private State content; 
  private List<TransitionBuilder> transitions = new ArrayList<TransitionBuilder>(); 
  private List<Commands> commands = new ArrayList<Commands>(); 

  public States(String name, StateMachineBuilder builder) { 
    super(name, builder); 
    content = new State(name); 
  } 

  State getState() { 
    return content; 
  } 

  public States actions(Commands... identifiers) { 
    builder.definingState(this); 
    commands.addAll(Arrays.asList(identifiers)); 
    return this; 
  } 


  public TransitionBuilder transition(Events identifier) { 
    builder.definingState(this); 
    return new TransitionBuilder(this, identifier); 
  } 

  void addTransition(TransitionBuilder arg) { 
    transitions.add(arg); 
  } 


  void produce() { 
    for (Commands c : commands) 
      content.addAction(c.getCommand()); 
    for (TransitionBuilder t : transitions) 
      t.produce(); 
  } 
 }

清单 1清单 2仅做参考。需要解决的问题是如何表示状态机的配置。这种表示是安装暗格的一种惯用模式。清单 3 展示了状态机基于 Java 的配置:

清单 3. 一个配置选择:Java 代码
 Event doorClosed = new Event("doorClosed", "D1CL"); 
 Event drawerOpened = new Event("drawerOpened", "D2OP"); 
 Event lightOn = new Event("lightOn", "L1ON"); 
 Event doorOpened = new Event("doorOpened", "D1OP"); 
 Event panelClosed = new Event("panelClosed", "PNCL"); 

 Command unlockPanelCmd = new Command("unlockPanel", "PNUL"); 
 Command lockPanelCmd = new Command("lockPanel", "PNLK"); 
 Command lockDoorCmd = new Command("lockDoor", "D1LK"); 
 Command unlockDoorCmd = new Command("unlockDoor", "D1UL"); 

 State idle = new State("idle"); 
 State activeState = new State("active"); 
 State waitingForLightState = new State("waitingForLight"); 
 State waitingForDrawerState = new State("waitingForDrawer"); 
 State unlockedPanelState = new State("unlockedPanel"); 

 StateMachine machine = new StateMachine(idle); 

 idle.addTransition(doorClosed, activeState); 
 idle.addAction(unlockDoorCmd); 
 idle.addAction(lockPanelCmd); 

 activeState.addTransition(drawerOpened, waitingForLightState); 
 activeState.addTransition(lightOn, waitingForDrawerState); 

 waitingForLightState.addTransition(lightOn, unlockedPanelState); 

 waitingForDrawerState.addTransition(drawerOpened, unlockedPanelState); 

 unlockedPanelState.addAction(unlockPanelCmd); 
 unlockedPanelState.addAction(lockDoorCmd); 
 unlockedPanelState.addTransition(panelClosed, idle); 

 machine.addResetEvents(doorOpened);

清单 3显示了使用 Java 进行状态机配置的几个问题。首先,阅读这些 Java 代码并不能明确知道这就是状态机配置,和多数 Java API 一样,这只是一堆没有差别的代码。第二,冗长且重复。为状态机的每部分设置更多的状态和转换时,变量名重复使用,所有这些重复使代码难于阅读。第三,代码不能满足最初目标 —— 无需重新编译就可配置暗格。

事实上,在 Java 世界几乎看不到这种代码了,现在流行使用 XML 编写配置代码。用 XML 编写配置很简单,如清单 4 所示:

清单 4. 用 XML 编写的状态机配置
 <stateMachine start = "idle"> 
    <event name="doorClosed" code="D1CL"/> 
    <event name="drawerOpened" code="D2OP"/> 
    <event name="lightOn" code="L1ON"/> 
    <event name="doorOpened" code="D1OP"/> 
    <event name="panelClosed" code="PNCL"/> 

    <command name="unlockPanel" code="PNUL"/> 
    <command name="lockPanel" code="PNLK"/> 
    <command name="lockDoor" code="D1LK"/> 
    <command name="unlockDoor" code="D1UL"/> 

  <state name="idle"> 
    <transition event="doorClosed" target="active"/> 
    <action command="unlockDoor"/> 
    <action command="lockPanel"/> 
  </state> 

  <state name="active"> 
    <transition event="drawerOpened" target="waitingForLight"/> 
    <transition event="lightOn" target="waitingForDrawer"/> 
  </state> 

  <state name="waitingForLight"> 
    <transition event="lightOn" target="unlockedPanel"/> 
  </state> 

  <state name="waitingForDrawer"> 
    <transition event="drawerOpened" target="unlockedPanel"/> 
  </state> 

  <state name="unlockedPanel"> 
    <action command="unlockPanel"/> 
    <action command="lockDoor"/>    
    <transition event="panelClosed" target="idle"/> 
   </state> 

  <resetEvent name = "doorOpened"/> 
 </stateMachine>

清单 4中的代码相比 Java 版本有几个优势。第一,延迟绑定,这意味着可以修改代码并将其放进 toaster,可以使用 XML 解析器阅读配置。第二,对于这个特定问题,这段代码是更富于表现力,因为 XML 包含容器(containership)概念:States 将它们的配置包含为子元素。这有助于删除 Java 版本中令人讨厌的冗余。第三,代码本质上是声明式的。通常,如果您只是进行声明而不需要 ifwhile语法,声明式代码更易于阅读。

暂时退后一步,先理解其含义。外化配置在现代 Java 世界中是一种很常见的模式,我们不再认为它是独特实体。实际上这也是每个 Java 框架的特征。配置是一个惯用模式,我们需要捕获方式,使其区别于周围框架的一般行为,并将其分离出来。使用 XML 进行配置,我是使用外部 DSL 编写代码的(句法 [syntax] 是 XML,语法 [grammar] 是由 XML 相关模式定义的),因此不需要重新编译框架代码对其进行转换。

我们没有必要因为 XML 的优势,总是使用 XML。可以考虑以下配置代码,如清单 5 所示:

清单 5. 定制语法(custom-grammar)的状态机配置
 events 
  doorClosed  D1CL 
  drawerOpened  D2OP 
  lightOn     L1ON 
  doorOpened  D1OP 
  panelClosed PNCL 
 end 

 resetEvents 
  doorOpened 
 end 

 commands 
  unlockPanel PNUL 
  lockPanel   PNLK 
  lockDoor    D1LK 
  unlockDoor  D1UL 
 end 

 state idle 
  actions {unlockDoor lockPanel} 
  doorClosed => active 
 end 

 state active 
  drawerOpened => waitingForLight 
  lightOn    => waitingForDrawer 
 end 

 state waitingForLight 
  lightOn => unlockedPanel 
 end 

 state waitingForDrawer 
  drawerOpened => unlockedPanel 
 end 

 state unlockedPanel 
  actions {unlockPanel lockDoor} 
  panelClosed => idle 
 end

XML 版本有的优势,它也有:是声明式的,有容器概念,并且是简明的。同时它也超越了 XML 和 Java 版本,因为它很少有 噪音字符(例如 <>),尽管这对技术实现是必需的,但是影响可读性。

此版配置代码是一个用 ANTLR 编写的定制外部 DSL,也是一个开源工具,它使得用自定义语言编写变得很容易(见 参考资料)。曾经在大学时候不喜欢编译器(包括诸如 Lex 和 YACC 之类的经典工具)课程的人,将很高兴知道这些工具已经变得好多了。这个例子来自 Fowler 的书中,他说构建 XML 版本和构建定制语言版本所用时间相同。

清单 6 中的是用 Ruby 写的另一种可选版本 :

清单 6. JRuby 中的状态机配置
 event :doorClosed, "D1CL"
 event :drawerOpened, "D2OP"
 event :lightOn, "L1ON"
 event :doorOpened, "D1OP"
 event :panelClosed, "PNCL"

 command :unlockPanel, "PNUL"
 command :lockPanel, "PNLK"
 command :lockDoor, "D1LK"
 command :unlockDoor, "D1UL"

 resetEvents :doorOpened 

 state :idle do 
  actions :unlockDoor, :lockPanel 
  transitions :doorClosed => :active 
 end 

 state :active do 
  transitions :drawerOpened => :waitingForLight, 
              :lightOn => :waitingForDrawer 
 end 

 state :waitingForLight do 
  transitions :lightOn => :unlockedPanel 
 end 

 state :waitingForDrawer do 
  transitions :drawerOpened => :unlockedPanel 
 end 

 state :unlockedPanel do 
  actions :unlockPanel, :lockDoor 
  transitions :panelClosed => :idle 
 end

这是一个很好的 内部DSL 例子:DSL 使用基础语言的语法,这意味这个 DSL 必须是符合语法的 Ruby 代码。(因为它是用 Ruby 编写的,可以使用 JRuby 运行,就是说,您的 toaster 所需的全是 JRuby JAR 文件。)

清单 6同定制语言有许多相同的优点。注意,大量使用 Ruby 块充当容器,这能给您同 XML 和定制语言版本一样的容器语义。它比定制语言使用更少的噪音字符(noise characters)。例如,在 Ruby 中 前缀表明一个符号,在本例中基本上是用作标识符的不变字符串。

使用 Ruby 实现这类 DSL 相当简单,如清单 7 所示:

清单 7. JRuby DSL 的部分类定义
 class StateMachineBuilder 
  attr_reader :machine, :events, :states, :commands 

  def initialize 
    @events = {} 
    @states = {} 
    @state_blocks = {} 
    @commands = {} 
  end 

  def event name, code 
    @events[name] = Event.new(name.to_s, code) 
  end 

  def state name, &block 
    @states[name] = State.new(name.to_s) 
    @state_blocks[name] = block 
    @start_state ||= @states[name] 
  end 

  def command name, code 
    @commands[name] = Command.new(name.to_s, code) 
  end

Ruby 语法比较灵活,这使它适用于此类 DSL。例如,声明一个事件时,不会强制包含一个圆括弧作为方法调用的一部分。在这个版本中,不需要编写自己的语言或者用尖括弧妨碍自己。这更能说明为什么这个方法在 Ruby 世界是如此流行。


DSL 特征

DSL 为捕获惯用模式提供了很好的可供选择的语法。正如 Martin Fowler 所定义的,DSL 有 5 个主要特征。

计算机编程语言

要成为一个 DSL,这个语言必须是一个计算机编程语言。如果没有这一限制,容易引起 “滑坡”,您遇到的所有事物都有可能是一个 DSL。如果您定义 DSL 术语太广泛,所有的上下文会话都可能是 DSL。例如,我有些同事是板球迷, 当我同他们在一起时,他们总是不停的谈论板球,尽管他们是用英语,我也不明白他们在说什么。我缺乏适当的上下文,以至于我不能明白他们所用的单词。然而,我们可以使用 DSL 术语谈论板球和其他运动。但是如果没有范围定义,很难将其缩小到可用约束范围内 —因此 Fowler 坚持将其限制在计算机编程语言范围之内。

语言天性

Fowler 关于 DSL 的第二条准则是,它应该有 “语言天性”,这意味您的 DSL 对于非程序员至少是隐约可读的。语言天性包含多种格式,在后续几期中,我将向您展示其中的一些,我将继续探索 DSL —— 作为一种捕获惯用模式的方法 —— 的引用。

领域焦点

要成为一个合适的 DSL,该语言必须只关注一个特定的问题领域,尝试创建 DSL 的风险之一是使其太宽泛。DSL 是一个抽象机制,创建太宽泛的抽象会降低它的优势。

有限的表现力

限制表现力也是 DSL 的一个特点。很少能找到一个 DSL 含有诸如循环和判定的控制结构。DSL 应该特别关注它正在尝试描述的领域,而且也只能关注该领域。因此,相当多的 DSL 是声明式的,而不是指令式的。

非图灵完整的(Turing complete)

前面两个标准暗示了这一特征,但是在这里,我将正式确认它。您的 DSL 应当不是图灵完整的(见 参考资料)。事实上,人们认为在 DSL 中一个反模式将意外地变成图灵完整的。例如,经典的 UNIX®sendmail配置文件就是意外图灵完整的。您可以在 sendmail配置文件中写一个操作系统,如果您愿意,而且又有很多时间的话。

意外地变成图灵完整的是惊人的简单。一些熟悉的基础设施工具可以意外地进行这种转变 —例如,XSLT。确定一种语言是否是 DSL,有时候取决于其上下文。使用 XSLT 来将一种版本的本文转换成另一个版本的文本时,您就是将它作为 DSL 使用的。如果您使用 XSTL 解决汉诺塔问题,您是将它作为一种图灵完整语言使用的(并且您可能会找到一种新的爱好)。


结束语

这一期为使用 DSL 作为一种获取惯用模式的提取机制奠定了基础。DSL 在这方面做得很不错,因为它们很容易与常规 API 区分开,更倾向于声明式的,并改善了项目中开发人员与非开发人员之间的信息交流和反馈。下一期,我将探索多种构建 DSL 的技术。在后续几期中,我将逐一介绍几种可用于寻求发现和设计代码的 DSL 技术。

参考资料

学习

  • The Productive Programmer(Neal Ford,O'Reilly Media,2008 年):Neal Ford 最近撰写的这本书对本系列中的很多观点作了详细的阐述。
  • ANTLR:ANTLR 是一个功能强大的开放源码工具,用于构建语言和语法。
  • Domain Specific Languages(Martin Fowler,Addison-Wesley,2010 年):Fowler 的新书的测试版。
  • 图灵完整:阅读 Wikipedia 文章了解相关概念。
  • 技术书店浏览关于这些主题和其他主题的文章。
  • developerWorks Java 技术专区:这里有数百篇关于 Java 编程各个方面的文章。

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=502113
ArticleTitle=演化架构和紧急设计: 使用 DSL
publish-date=07262010