Ran Wei/MDE 系列
English
基于 Eclipse Epsilon 的模型驱动工程 — Ran Wei

什么是模型驱动工程?

认识模型驱动工程的核心概念(模型、元模型、符合性与模型管理),并在浏览器中运行你的第一个 Epsilon 程序。

3 小时3 个时段9 个可运行示例Epsilon Playground

完成后你能够

  • 解释什么是模型,并区分描述性模型与规约性模型。
  • 把元模型描述为建模语言的抽象语法,并说明模型“符合”元模型的含义。
  • 区分抽象语法与具体语法,以及建模语言与编程语言。
  • 说出四类核心模型管理任务以及各自对应的 Epsilon 语言。
  • 在 Epsilon Playground 中打开、运行并修改示例。

开始之前

具备任意一种语言的编程经验,并使用较新的 Chrome、Edge 或 Firefox 浏览器。无需建模背景,也无需安装软件。

下载本模块全部示例(zip)

目录

学习计划

3 小时

时间为估计值,包含运行示例的时间。进度保存在当前浏览器,中英文版本共享。

时段 160 分钟
模型与元模型
时段 260 分钟
语言与模型管理
时段 360 分钟
练习
1

为什么需要模型驱动工程?

软件项目很少因为缺少代码而失败。真正的麻烦在于,构建系统的人逐渐弄不清系统究竟应该是什么样子:需求写在一份文档里,设计写在另一份文档里,而代码与两者渐行渐远。每次修改都要在好几个地方手工完成,迟早会出现前后不一致。

模型驱动工程(model-driven engineering,MDE)用两个思想来应对这个问题。第一个是抽象:用专为该问题设计的语言描述系统,略去与当前问题无关的细节。第二个是自动化:让这些描述足够精确,使工具能够处理它们。这样,工具就可以检查和分析这些描述,并从中产出其他制品,例如代码、文档或测试数据。这些描述称为模型(model)。在模型驱动工程中,模型是首要的工程制品,而不是摆在“真正的”工作旁边的示意图。

一个小例子就能说明其收益。JHipster 是一个面向 Web 应用的代码生成器。你用它的文本语言 JDL 来描述应用:有哪些实体(在 Kolovos 文章的例子中是一个博客应用,包含博客、文章和标签;该例基于 Matt Raible 的示例),每个实体有哪些属性,实体之间如何关联,以及若干配置选项,例如使用哪种数据库。根据一份一屏就能放下的描述,JHipster 会生成一个可以运行的完整应用:数据库表、带 Web API 的服务器端代码,以及基于浏览器的用户界面。修改模型后重新生成,比手工修改所有这些层要快得多;而且各层彼此保持一致,因为它们都出自同一个来源。(改编自 Dimitris Kolovos,Minimal JHipster JDL Monolith Example,2021。)

凡是规模大、生命周期长或安全攸关的系统,都能看到同样的思想。在汽车和航空航天行业,工程师把控制软件设计成 Simulink 模型,再从中生成代码。系统工程师用 SysML 描述整个系统,本站的 SysML v2 和 MBSE 系列对此有详细介绍。许多团队还会为某一项具体工作定义小型的领域特定语言,JDL 就是一例。

核心思想

在模型驱动工程中,模型不是会逐渐过时的文档,而是供工具读取、检查和转换的输入,因此保持模型正确会带来直接的回报。

本模块介绍基本术语,并让你初步体验相关工具。第 2 至 4 节解释什么是模型、元模型和建模语言。第 5 节借助 Eclipse Epsilon 介绍工具对模型执行的四类核心任务。第 6 节说明如何在浏览器中的 Epsilon Playground 里使用这些示例。

2

模型

模型究竟是什么?Kolovos 的定义有意宽泛:模型是“a description of a system of interest”(对所关注系统的描述)。这个系统可以是任何你想推理的对象:一栋建筑、一个工资系统、一辆汽车的定速巡航,或者本模块贯穿始终的防盗报警器。描述的形式可以是一幅图、一个结构化的文本文件,或者工具中的一组对象。

模型按其构建目的分为两类:

  • 描述性模型(descriptive model)描述一个已经存在的系统。现有建筑的平面图是描述性的;团队为了熟悉遗留代码库而从中逆向得到的类图也是描述性的。
  • 规约性模型(prescriptive model)描述一个尚不存在的系统,并规定它应当是什么样子。建筑师为一栋尚待建造的房子绘制的设计图是规约性的;你即将编写的软件的设计也是规约性的。

规约性模型在完整性和精确性上差别很大。Kolovos 把它们放在一个连续的尺度上:

  • 作为草图的模型只记录一次非正式讨论所需的信息,例如在白板上随手画的图。
  • 作为蓝图的模型记录了足够的信息,工程师可以据此构建系统,但仍有一些决定留给人来做。只列出类及其关系、却不说明操作具体做什么的 UML 类图就是蓝图。
  • 作为程序的模型细致而准确,工具几乎无需人工帮助,就能据此生成实现或验证系统的性质。用来生成控制代码的 Simulink 模型就是这种意义上的程序。

模型在这个尺度上越靠后,工具能用它做的事情就越多。模型驱动工程的大部分工作,就是把模型从蓝图推向程序,从而把实现中繁琐而容易出错的部分自动化。

每个模型也都是片面的:它只保留与其目的相关的方面,略去其余部分。这不是缺陷,而正是建立模型的意义所在。

类比

同一座城市的地铁图和街道图描述的是同一个地方。地铁图扭曲了距离、省略了所有街道,所以不能用来步行认路,却非常适合规划乘车路线。两者都不是这座城市唯一的模型:各自适合各自的用途。

(改编自 Dimitris Kolovos,Model-Driven Engineering Terminology,2022。)

3

元模型与符合性

模型总是用某种语言写成的,而语言需要规则:模型可以包含哪些种类的元素,这些元素有哪些性质,又可以怎样相互连接。在模型驱动工程中,这些规则由元模型(metamodel)来描述。Kolovos 把元模型定义为“an object-oriented specification of the abstract syntax of a language”(对一种语言的抽象语法的面向对象规约)(Model-Driven Engineering Terminology,2022)。一种语言的抽象语法(abstract syntax)指它的概念以及概念之间的关系,而与模型如何绘制或书写无关。(Kolovos 认为这两个术语可以互换。)元模型本身也用元建模语言来编写,最常用的是 Eclipse 建模框架(EMF)中的 Ecore,以及对象管理组织(OMG)的标准 MOF。

本系列始终使用同一种语言:一种描述组件与连接器架构的小型语言。一个架构包含若干组件;每个组件有输入端口和输出端口;连接器把信号从一个输出端口传送到一个输入端口。贯穿全系列的示例受到 Dimitris Kolovos 在 Metamodelling with ChatGPT(2022)中借助 ChatGPT 构建的组件与端口元模型的启发。他的版本包含组件、连接和带类型的端口;我们的版本把输入端口和输出端口分开,并让架构拥有其组成部分。第 2 模块会一步步构建出我们的版本。

下面第一个示例给出了用 Emfatic 编写的元模型。Emfatic 是 Ecore 的一种文本记法,你将在第 2 模块学习如何编写它。现在,只需把它当作一组类声明来阅读:

  • class Component { … } 声明一种模型元素。abstract class Port 声明一种不能直接创建的元素,class InPort extends Port {} 声明一种更具体的端口。
  • attr String name; 声明一个属性,用来保存一个普通的值。
  • val Component[*] components; 声明一个包含(containment)引用:架构拥有它的组件,[*] 表示数量不限。
  • ref OutPort source; 声明一个普通引用:连接器指向某个组件所拥有的端口。

模型用 Flexmi 编写,这是一种简洁的、基于 XML 的 EMF 模型记法。每个 XML 元素都会在模型中创建一个元素:<architecture> 创建一个 Architecture,<component> 创建一个 Component,<inPort> 创建一个 InPort。name="OrGate" 这样的 XML 属性用来设置该元素的属性。这些模型元素(model element)是元模型所定义类型的实例。如果一个模型的每个元素都是这些类型之一的实例,并且只使用其类型所声明的特征,我们就说这个模型符合(conform to)它的元模型。

程序是一段用 EOL 写的简短查询,EOL 是 Epsilon 的基础语言。请阅读这三个文件,点击在 Playground 中打开并运行示例,再把你看到的结果与下面的实际输出进行比较。

示例第一个模型、元模型与查询在 Playground 中打开 ↗
components.emf
@namespace(uri="components", prefix="components")
package components;

// An architecture is a set of components wired together by connectors
class Architecture {
  attr String name;
  val Component[*] components;
  val Connector[*] connectors;
}

class Component {
  attr String name;
  val Port[*] ports;
}

abstract class Port {
  attr String name;
}

class InPort extends Port {}

class OutPort extends Port {}

// A connector carries a signal from an output port to an input port
class Connector {
  ref OutPort source;
  ref InPort target;
}
alarm.flexmi
<?nsuri components?>
<architecture name="Alarm">
  <!-- The siren sounds when (door OR window) AND armed -->
  <component name="OrGate">
    <inPort name="door"/>
    <inPort name="window"/>
    <outPort name="open"/>
  </component>
  <component name="AndGate">
    <inPort name="open"/>
    <inPort name="armed"/>
    <outPort name="sound"/>
  </component>
  <component name="Siren">
    <inPort name="sound"/>
  </component>
  <connector source="Alarm.OrGate.open" target="Alarm.AndGate.open"/>
  <connector source="Alarm.AndGate.sound" target="Alarm.Siren.sound"/>
</architecture>
tour.eol
// Print every component with the names of its ports
for (c in Component.all) {
  (c.name + ": " + c.ports.collect(p | p.name).concat(", ")).println();
}
以下输出由构建本页时的 Epsilon 2.8.0 实际运行得到。Playground 中的提示措辞可能有所不同,并且不显示关于模型的警告。
输出
OrGate: door, window, open
AndGate: open, armed, sound
Siren: sound

输出中每个组件占一行,按顺序列出其端口的名称。Component.all 给出模型中所有类型为 Component 的元素,因此这段查询从未提到报警器本身。只要模型符合同一个元模型,它都能运行;练习 6 正是请你验证这一点。

注意连接器是如何引用端口的。Alarm.OrGate.open 是一个完全限定名(fully qualified name):它从根元素开始,依次经过包含该端口的各个元素的名称,最后到达端口本身。Flexmi 按名称解析引用,所以两个端口可以同名为 open,只要它们的完整路径不同即可。

陷阱

应当说模型符合它的元模型,或者说模型是元模型的实例。Kolovos 指出,作者们常常说模型“实现”(implement)、“依赖”(depend on)或“使用”(use)它的元模型。这些动词描述的是别的关系,会让读者感到困惑。

如果模型不符合元模型,会发生什么?下一个示例使用相同的元模型和相同的程序,但它的模型在最后一个连接器中有一处拼写错误:目标写成了 Alarm.Siren.sond,而不是 Alarm.Siren.sound。

示例不符合元模型的模型在 Playground 中打开 ↗
alarm.flexmi
<?nsuri components?>
<architecture name="Alarm">
  <!-- The siren sounds when (door OR window) AND armed -->
  <component name="OrGate">
    <inPort name="door"/>
    <inPort name="window"/>
    <outPort name="open"/>
  </component>
  <component name="AndGate">
    <inPort name="open"/>
    <inPort name="armed"/>
    <outPort name="sound"/>
  </component>
  <component name="Siren">
    <inPort name="sound"/>
  </component>
  <connector source="Alarm.OrGate.open" target="Alarm.AndGate.open"/>
  <connector source="Alarm.AndGate.sound" target="Alarm.Siren.sond"/>
</architecture>
其他文件: tour.eol, components.emf
tour.eol
// Print every component with the names of its ports
for (c in Component.all) {
  (c.name + ": " + c.ports.collect(p | p.name).concat(", ")).println();
}
components.emf
@namespace(uri="components", prefix="components")
package components;

// An architecture is a set of components wired together by connectors
class Architecture {
  attr String name;
  val Component[*] components;
  val Connector[*] connectors;
}

class Component {
  attr String name;
  val Port[*] ports;
}

abstract class Port {
  attr String name;
}

class InPort extends Port {}

class OutPort extends Port {}

// A connector carries a signal from an output port to an input port
class Connector {
  ref OutPort source;
  ref InPort target;
}
以下输出由构建本页时的 Epsilon 2.8.0 实际运行得到。Playground 中的提示措辞可能有所不同,并且不显示关于模型的警告。
输出
Model warning (line 18): Could not resolve target Alarm.Siren.sond for reference target (target)
OrGate: door, window, open
AndGate: open, armed, sound
Siren: sound

从上往下阅读实际输出。Flexmi 找不到名为 Alarm.Siren.sond 的元素,于是报告一条警告,指出模型中的行号以及它无法设置的引用。随后它让这个引用保持为空,继续往下执行:模型加载成功,程序照常运行,并打印出与之前完全相同的三行。通往警报器的连接已经断开,但查询本身的输出里看不出任何迹象。

这一点值得记住:程序可以在不符合元模型的模型上运行,并给出看似合理的结果,所以要把每一条警告都当作需要修复的缺陷。Flexmi 有意设计得很宽容,这让模型写起来很快。第 3 模块会说明这种宽容在哪些地方有帮助,又在哪些地方会掩盖错误。

如果你在 Playground 中运行这个示例,根本看不到这条警告:Playground 不报告 Flexmi 无法解析的引用,因此控制台只显示那三行。请改为点击模型面板上的图示按钮。在图中,第一个连接器有两个箭头,分别指向它所连接的两个端口;第二个连接器却只有一个箭头,指向与门的 sound 端口,因为它的目标从未被设置。

4

抽象语法与具体语法

元模型规定了报警器模型包含什么:组件、端口、连接器以及它们的名称,却完全没有规定模型的外观。那是具体语法(concrete syntax)的职责:具体语法是书写或展示某种语言的模型时所用的记法。按照 Kolovos 的说法,一种语言只有一个抽象语法,即它的元模型,却可以有任意多个具体语法:文本的、图形的、表格的、树形的,或者它们的组合。

在本模块中,报警器已经以几种具体形式出现:

  • 你刚才读过的 Flexmi 文本;
  • 图:在 Playground 中打开第一个示例,点击模型面板上的图示按钮,Playground 会把组件、端口和连接器画成方框和连线;
  • 表格:在第 5 节中,一个模板会把模型转换成 HTML 零件清单。

三者展示的是同样的模型元素。更换记法,丝毫不会改变模型所表达的内容。

正因为如此,Kolovos 认为严格来说,语言本身并无文本或图形之分,只有其具体语法才有。称一种语言为文本语言或图形语言,只是一种方便的简称,意思是它默认或主要的具体语法是文本的或图形的。SysML v2 很好地说明了这一点:同一个抽象语法同时拥有文本记法和图形记法(参见本站的 SysML v2 系列)。

那么,建模语言与编程语言的区别在哪里?我们通常称为编程语言的语言(例如 Java)大多是文本的、图灵完备的;而大多数建模语言(例如 UML 和 SysML)是图形的、非图灵完备的。但这一规律有例外:BPEL 是图形的,却是图灵完备的;SQL-92 是文本的,却不是图灵完备的。而且,如果以图灵完备性为标准,“编程语言”就只不过是“图灵完备语言”的另一个名字。Kolovos 提出了一种基于意图的区分:如果人们使用一种语言时,大多并不打算从中得到可执行的东西,而是为了理解领域、探索设计或向他人解释,它就是建模语言;如果人们使用它时,大多打算产出能够运行的东西,它就是编程语言。按照这种观点,同一种语言可以服务于两种目的:你可以写几个 Java 类只为理解一个领域,也可以从 UML 类图生成可执行代码。

(改编自 Dimitris Kolovos,Model-Driven Engineering Terminology,2022。)

5

用 Epsilon 进行模型管理

一旦模型精确到工具能够读取,就可以把围绕模型的工作自动化。模型驱动工程把这称为模型管理(model management),其核心是四类任务:

  • 查询:从模型中提取信息,例如哪些组件没有输出端口。
  • 验证:检查良构模型必须遵守、而元模型本身又无法表达的规则。
  • 模型到文本转换:从模型生成文本,例如代码、文档或报告。
  • 模型到模型转换:从一个模型产出另一种语言的模型。

其他任务建立在这四类任务之上:比较两个模型、合并模型,以及在元模型变化时迁移模型。第 9 模块会介绍 Epsilon 中用于这些任务的语言。

Eclipse Epsilon 是一族用于模型管理的语言。每种语言针对一类任务,而它们都建立在同一种基础语言 EOL(Epsilon Object Language)之上,因此你在一种语言中学到的表达式可以用于其他所有语言。

任务 Epsilon 语言 讲授模块
查询 EOL 第 4 模块
验证 EVL 第 5 模块
模型到文本转换 EGL、EGX 第 6 模块
模型到模型转换 ETL 第 7 模块

你已经见过一个查询。下面三个示例预览验证和两类转换。暂时不必在意语法的每个细节:后续每个模块都会系统讲授一种语言。

第一个示例是用 EVL 编写的验证程序。它的模型是报警器的一个变体,其中有一处接线错误:第二个连接器把与门的输出接回了它自己的 armed 输入。

示例验证:检查连线在 Playground 中打开 ↗
rules.evl
// A connector must join two different components
context Connector {
  constraint NoSelfLoop {
    check: self.source.eContainer() <> self.target.eContainer()
    message: "A connector joins " + self.source.eContainer().name + " to itself"
  }
}

// Every input should be driven by something
context InPort {
  critique Connected {
    check: Connector.all.exists(c | c.target = self)
    message: self.eContainer().name + "." + self.name + " is not connected"
  }
}
alarm.flexmi
<?nsuri components?>
<architecture name="Alarm">
  <!-- The siren sounds when (door OR window) AND armed -->
  <component name="OrGate">
    <inPort name="door"/>
    <inPort name="window"/>
    <outPort name="open"/>
  </component>
  <component name="AndGate">
    <inPort name="open"/>
    <inPort name="armed"/>
    <outPort name="sound"/>
  </component>
  <component name="Siren">
    <inPort name="sound"/>
  </component>
  <connector source="Alarm.OrGate.open" target="Alarm.AndGate.open"/>
  <connector source="Alarm.AndGate.sound" target="Alarm.AndGate.armed"/>
</architecture>
其他文件: components.emf
components.emf
@namespace(uri="components", prefix="components")
package components;

// An architecture is a set of components wired together by connectors
class Architecture {
  attr String name;
  val Component[*] components;
  val Connector[*] connectors;
}

class Component {
  attr String name;
  val Port[*] ports;
}

abstract class Port {
  attr String name;
}

class InPort extends Port {}

class OutPort extends Port {}

// A connector carries a signal from an output port to an input port
class Connector {
  ref OutPort source;
  ref InPort target;
}
以下输出由构建本页时的 Epsilon 2.8.0 实际运行得到。Playground 中的提示措辞可能有所不同,并且不显示关于模型的警告。
输出
Error [NoSelfLoop]: A connector joins AndGate to itself
Warning [Connected]: OrGate.door is not connected
Warning [Connected]: OrGate.window is not connected
Warning [Connected]: Siren.sound is not connected

EVL 程序按规则所适用的类型来组织规则:context Connector 和 context InPort。每条规则都有一个 check,即对该类型的每个元素都必须为真的表达式,以及一个 message,用于报告每个使该表达式为假的元素。约束(constraint)不满足时,EVL 报告错误;建议性检查(critique)不满足时,EVL 报告警告,用于那些值得关注、但并不使模型出错的问题。

实际输出显示了一条错误和三条警告。错误是自环。Siren.sound 不再有连接,因为出错的连接器接到了与门上。OrGate.door 和 OrGate.window 也被标记出来,尽管它们本应由架构外部的传感器驱动:这种语言无法表达这一点,练习 5 会请你解决这个问题。在 Playground 中,EVL 程序的控制台是空的:这条错误和这些警告会以注释的形式标注在模型图上。

下一个示例从原来那个连线正确的报警器生成文本。

示例模型到文本:零件清单在 Playground 中打开 ↗
parts.egl
<h2>[%=Architecture.all.first().name%]: parts list</h2>
<table>
  <tr><th>Component</th><th>Inputs</th><th>Outputs</th></tr>
[% for (c in Component.all) { %]
  <tr><td>[%=c.name%]</td><td>[%=c.ports.select(p | p.isTypeOf(InPort)).size()%]</td><td>[%=c.ports.select(p | p.isTypeOf(OutPort)).size()%]</td></tr>
[% } %]
</table>
其他文件: alarm.flexmi, components.emf
alarm.flexmi
<?nsuri components?>
<architecture name="Alarm">
  <!-- The siren sounds when (door OR window) AND armed -->
  <component name="OrGate">
    <inPort name="door"/>
    <inPort name="window"/>
    <outPort name="open"/>
  </component>
  <component name="AndGate">
    <inPort name="open"/>
    <inPort name="armed"/>
    <outPort name="sound"/>
  </component>
  <component name="Siren">
    <inPort name="sound"/>
  </component>
  <connector source="Alarm.OrGate.open" target="Alarm.AndGate.open"/>
  <connector source="Alarm.AndGate.sound" target="Alarm.Siren.sound"/>
</architecture>
components.emf
@namespace(uri="components", prefix="components")
package components;

// An architecture is a set of components wired together by connectors
class Architecture {
  attr String name;
  val Component[*] components;
  val Connector[*] connectors;
}

class Component {
  attr String name;
  val Port[*] ports;
}

abstract class Port {
  attr String name;
}

class InPort extends Port {}

class OutPort extends Port {}

// A connector carries a signal from an output port to an input port
class Connector {
  ref OutPort source;
  ref InPort target;
}
以下输出由构建本页时的 Epsilon 2.8.0 实际运行得到。Playground 中的提示措辞可能有所不同,并且不显示关于模型的警告。
输出
<h2>Alarm: parts list</h2>
<table>
  <tr><th>Component</th><th>Inputs</th><th>Outputs</th></tr>
  <tr><td>OrGate</td><td>2</td><td>1</td></tr>
  <tr><td>AndGate</td><td>2</td><td>1</td></tr>
  <tr><td>Siren</td><td>1</td><td>0</td></tr>
</table>

EGL 模板是带有动态片段的文本。[% %] 之外的内容原样复制到输出中;[% %] 之内的代码会被执行,这里是一个遍历所有组件的循环;[%= %] 则插入一个表达式的值。因此,这个模板为每个组件生成表格中的一行,并用与第 3 节查询同类的表达式统计其输入端口和输出端口的数量。Playground 会把生成的 HTML 渲染成表格;下面的实际输出显示的是 HTML 本身。

这就是 JHipster 背后的思想,只是规模小得多:输出由模型派生而来,所以模型变化时,你只需重新生成输出,而不必手工修改它。

最后一个示例把报警器转换为另一种语言的模型:一个普通的有向图,其元模型显示在程序下方。

示例模型到模型:从组件到图在 Playground 中打开 ↗
components2graph.etl
// Each architecture becomes a graph
rule Architecture2Graph
  transform a : Source!Architecture
  to g : Target!Graph {
  g.name = a.name;
  g.nodes ::= a.components;
  g.edges ::= a.connectors;
}

// Each component becomes a node
rule Component2Node
  transform c : Source!Component
  to n : Target!Node {
  n.name = c.name;
}

// Each connector becomes an edge between the components it joins
rule Connector2Edge
  transform c : Source!Connector
  to e : Target!Edge {
  e.source ::= c.source.eContainer();
  e.target ::= c.target.eContainer();
}
graph.emf
@namespace(uri="graph", prefix="graph")
package graph;

// A plain directed graph
class Graph {
  attr String name;
  val Node[*] nodes;
  val Edge[*] edges;
}

class Node {
  attr String name;
}

class Edge {
  ref Node source;
  ref Node target;
}
其他文件: alarm.flexmi, components.emf
alarm.flexmi
<?nsuri components?>
<architecture name="Alarm">
  <!-- The siren sounds when (door OR window) AND armed -->
  <component name="OrGate">
    <inPort name="door"/>
    <inPort name="window"/>
    <outPort name="open"/>
  </component>
  <component name="AndGate">
    <inPort name="open"/>
    <inPort name="armed"/>
    <outPort name="sound"/>
  </component>
  <component name="Siren">
    <inPort name="sound"/>
  </component>
  <connector source="Alarm.OrGate.open" target="Alarm.AndGate.open"/>
  <connector source="Alarm.AndGate.sound" target="Alarm.Siren.sound"/>
</architecture>
components.emf
@namespace(uri="components", prefix="components")
package components;

// An architecture is a set of components wired together by connectors
class Architecture {
  attr String name;
  val Component[*] components;
  val Connector[*] connectors;
}

class Component {
  attr String name;
  val Port[*] ports;
}

abstract class Port {
  attr String name;
}

class InPort extends Port {}

class OutPort extends Port {}

// A connector carries a signal from an output port to an input port
class Connector {
  ref OutPort source;
  ref InPort target;
}
以下输出由构建本页时的 Epsilon 2.8.0 实际运行得到。Playground 中的提示措辞可能有所不同,并且不显示关于模型的警告。
输出
Graph name="Alarm"
  Node name="OrGate"
  Node name="AndGate"
  Node name="Siren"
  Edge source->OrGate target->AndGate
  Edge source->AndGate target->Siren

ETL 规则说明源模型中的元素如何变成目标模型中的元素。Source!Component 和 Target!Node 分别指明两个模型中的类型。规则 Component2Node 把每个组件变成一个同名的节点;Connector2Edge 把每个连接器变成一条边,连接它所关联的两个组件对应的节点。运算符 ::= 赋予的是某个元素经转换后的对应元素,由转换该元素的那条规则生成;因此 g.nodes ::= a.components 会用由组件生成的节点填充这个图。

实际输出把目标模型打印成一棵树:一个图、三个节点和两条边。端口不见了,因为图语言用不到它们。模型到模型转换让每项任务都能使用最适合它的语言:图算法需要的是节点和边,而不是端口。

6

使用 Playground

本系列的每个示例都可以在 Epsilon Playground 中打开。Playground 是一个用来编辑和运行 Epsilon 程序的网页,无需安装任何软件。运行程序时,Playground 会把程序、模型和元模型发送到约克大学的服务器,由服务器运行 Epsilon 并返回结果,因此请不要粘贴任何私密内容。点击在 Playground 中打开后,示例的各个文件会载入不同的面板:

  • 程序面板存放 Epsilon 程序(EOL、EVL、EGL、EGX 或 ETL);
  • 模型面板存放 Flexmi 模型,元模型面板存放 Emfatic 元模型,两者都可以切换为图示;
  • 控制台显示程序打印的内容以及所有错误,但不显示关于模型本身的警告,例如 Flexmi 无法解析的引用:这类问题请查看模型图;
  • 视语言而定,其他面板会显示生成的文本、渲染后的 HTML、经过验证的模型,或转换得到的目标模型。

要运行示例,点击程序面板上的 Run(运行)按钮,或者按 Ctrl+S(Mac 上为 Cmd+S)。你可以随意修改任何面板并反复运行:尝试一处修改、观察结果,是最快的学习方式。Playground 的示例菜单按模块列出了本系列的所有示例,你无需回到课程页面就能在示例之间切换。

请记住两点:

  • 你所做的修改都不会被保存。关闭标签页之前,请用 Download(下载)按钮保存你的工作。
  • 本页每个示例下方的输出,是构建页面时由 Epsilon 2.8.0 实际运行得到的。Playground 可能运行较新版本的 Epsilon,部分提示的措辞会有所不同,并且不显示关于模型的警告。如果结果出乎意料,请与实际输出进行比较。
提示

在一个浏览器标签页中打开课程,在另一个标签页中打开 Playground,阅读时在两者之间切换。

7

练习

练习 1★★★概念6 分钟

描述性还是规约性? 判断下列模型是描述性的还是规约性的;对于规约性模型,再说明它属于草图、蓝图还是程序。

  1. 建筑师为一栋尚未建造的房子绘制的平面图。
  2. 从现有代码库逆向得到、帮助新开发者熟悉系统的类图。
  3. 用来生成汽车定速巡航代码的 Simulink 模型。
  4. 团队讨论一项新功能该如何工作时在白板上画的图,拍照后就擦掉了。
查看解答
  1. 规约性,蓝图。它描述一栋尚不存在的房子,细节足以让施工人员据此施工,但仍有许多细节需要他们自行决定。
  2. 描述性。它记录一个已经存在的系统。草图—蓝图—程序这一尺度适用于规约性模型,因此不用它来定位这个模型。
  3. 规约性,程序。它足够完整和精确,工具可以据此生成代码。
  4. 规约性,草图。它关于一项尚不存在的功能,只记录了一次非正式讨论所需的信息。

模型是描述性还是规约性,取决于建模的目的,而不是它的外观:同样是平面图,它既可以是描述性的,也可以是规约性的,取决于房子是否已经存在。

练习 2★★★概念5 分钟

找出用错的术语。 下列说法中哪些正确使用了模型驱动工程的术语?请改写用错的说法。

  1. “我们的模型实现了 UML 元模型。”
  2. “alarm.flexmi 符合 components.emf。”
  3. “Port 是报警器模型中的一个模型元素。”
  4. “元模型定义了组件语言的抽象语法。”
查看解答
  1. 用错了。模型“符合”元模型;“实现”描述的是编程语言中类与接口之间的关系。应改为:“我们的模型符合 UML 元模型。”
  2. 正确。
  3. 用错了。Port 是元模型中的一个类型(抽象类)。报警器模型的元素是实例,例如组件 OrGate 或其输入端口 door。应改为:“door 是报警器模型中的一个模型元素,其类型是 InPort。”
  4. 正确。
练习 3★★★编程8 分钟

查询:没有输出端口的组件。 在 Playground 中打开本模块的第一个示例,修改程序,使其打印所有没有输出端口的组件名称。提示:组合使用 select、exists 和 isTypeOf(OutPort)。

查看解答

先选出没有任何输出端口的组件,再收集它们的名称:

参考解答没有输出端口的组件在 Playground 中打开 ↗
no-outputs.eol
// Components that only receive signals: none of their ports is an output
var sinks = Component.all.select(c | not c.ports.exists(p | p.isTypeOf(OutPort)));
sinks.collect(c | c.name).println();
其他文件: alarm.flexmi, components.emf
alarm.flexmi
<?nsuri components?>
<architecture name="Alarm">
  <!-- The siren sounds when (door OR window) AND armed -->
  <component name="OrGate">
    <inPort name="door"/>
    <inPort name="window"/>
    <outPort name="open"/>
  </component>
  <component name="AndGate">
    <inPort name="open"/>
    <inPort name="armed"/>
    <outPort name="sound"/>
  </component>
  <component name="Siren">
    <inPort name="sound"/>
  </component>
  <connector source="Alarm.OrGate.open" target="Alarm.AndGate.open"/>
  <connector source="Alarm.AndGate.sound" target="Alarm.Siren.sound"/>
</architecture>
components.emf
@namespace(uri="components", prefix="components")
package components;

// An architecture is a set of components wired together by connectors
class Architecture {
  attr String name;
  val Component[*] components;
  val Connector[*] connectors;
}

class Component {
  attr String name;
  val Port[*] ports;
}

abstract class Port {
  attr String name;
}

class InPort extends Port {}

class OutPort extends Port {}

// A connector carries a signal from an output port to an input port
class Connector {
  ref OutPort source;
  ref InPort target;
}
以下输出由构建本页时的 Epsilon 2.8.0 实际运行得到。Playground 中的提示措辞可能有所不同,并且不显示关于模型的警告。
输出
Sequence {"Siren"}

只有 Siren 符合条件:它接收信号,但不产生信号。第 4 模块会深入讲解 select、exists 和 collect。

练习 4★★★编程4 分钟

修复模型。 在 Playground 中打开示例“不符合元模型的模型”,点击模型面板上的图示按钮。找出只有一个箭头的连接器,修复模型使两个连接器都有两个箭头,再次运行程序,并与原来的输出比较。

查看解答

最后一个连接器引用了一个不存在的端口:Alarm.Siren.sond 应为 Alarm.Siren.sound。改正名称后,Flexmi 能够解析该引用。在 Playground 的图中,第二个连接器现在有一个指向警报器 sound 端口的箭头;在构建中,实际输出里的警告也随之消失:

参考解答修复后的模型在 Playground 中打开 ↗
alarm.flexmi
<?nsuri components?>
<architecture name="Alarm">
  <!-- The siren sounds when (door OR window) AND armed -->
  <component name="OrGate">
    <inPort name="door"/>
    <inPort name="window"/>
    <outPort name="open"/>
  </component>
  <component name="AndGate">
    <inPort name="open"/>
    <inPort name="armed"/>
    <outPort name="sound"/>
  </component>
  <component name="Siren">
    <inPort name="sound"/>
  </component>
  <connector source="Alarm.OrGate.open" target="Alarm.AndGate.open"/>
  <connector source="Alarm.AndGate.sound" target="Alarm.Siren.sound"/>
</architecture>
其他文件: tour.eol, components.emf
tour.eol
// Print every component with the names of its ports
for (c in Component.all) {
  (c.name + ": " + c.ports.collect(p | p.name).concat(", ")).println();
}
components.emf
@namespace(uri="components", prefix="components")
package components;

// An architecture is a set of components wired together by connectors
class Architecture {
  attr String name;
  val Component[*] components;
  val Connector[*] connectors;
}

class Component {
  attr String name;
  val Port[*] ports;
}

abstract class Port {
  attr String name;
}

class InPort extends Port {}

class OutPort extends Port {}

// A connector carries a signal from an output port to an input port
class Connector {
  ref OutPort source;
  ref InPort target;
}
以下输出由构建本页时的 Epsilon 2.8.0 实际运行得到。Playground 中的提示措辞可能有所不同,并且不显示关于模型的警告。
输出
OrGate: door, window, open
AndGate: open, armed, sound
Siren: sound

程序本身的输出没有变化,因为程序根本不读取连接器。这正是符合性问题重要的原因:程序可以在有缺陷的模型上运行,并给出看似合理的结果。

练习 5★★★小项目12 分钟

区分外部输入。 示例“验证:检查连线”中的 Connected 建议性检查对 door 和 window 发出了警告,但这两个输入来自架构外部的传感器,本来就不应该被连接。请修改元模型,使模型能够标明哪些输入是外部输入;在模型中把 door、window 和 armed 标为外部输入;再添加守卫条件,使该检查跳过外部输入。以第一个示例中连线正确的报警器作为模型。

查看解答

为 InPort 添加一个布尔属性,在模型中设置它,并用它作为该检查的守卫条件:

参考解答标记外部输入在 Playground 中打开 ↗
components.emf
@namespace(uri="components", prefix="components")
package components;

// An architecture is a set of components wired together by connectors
class Architecture {
  attr String name;
  val Component[*] components;
  val Connector[*] connectors;
}

class Component {
  attr String name;
  val Port[*] ports;
}

abstract class Port {
  attr String name;
}

class InPort extends Port {
  // True for inputs that come from outside the architecture
  attr boolean external;
}

class OutPort extends Port {}

// A connector carries a signal from an output port to an input port
class Connector {
  ref OutPort source;
  ref InPort target;
}
alarm.flexmi
<?nsuri components?>
<architecture name="Alarm">
  <!-- The siren sounds when (door OR window) AND armed -->
  <component name="OrGate">
    <inPort name="door" external="true"/>
    <inPort name="window" external="true"/>
    <outPort name="open"/>
  </component>
  <component name="AndGate">
    <inPort name="open"/>
    <inPort name="armed" external="true"/>
    <outPort name="sound"/>
  </component>
  <component name="Siren">
    <inPort name="sound"/>
  </component>
  <connector source="Alarm.OrGate.open" target="Alarm.AndGate.open"/>
  <connector source="Alarm.AndGate.sound" target="Alarm.Siren.sound"/>
</architecture>
rules.evl
// A connector must join two different components
context Connector {
  constraint NoSelfLoop {
    check: self.source.eContainer() <> self.target.eContainer()
    message: "A connector joins " + self.source.eContainer().name + " to itself"
  }
}

// Every internal input should be driven by something
context InPort {
  critique Connected {
    guard: not self.external
    check: Connector.all.exists(c | c.target = self)
    message: self.eContainer().name + "." + self.name + " is not connected"
  }
}
以下输出由构建本页时的 Epsilon 2.8.0 实际运行得到。Playground 中的提示措辞可能有所不同,并且不显示关于模型的警告。
输出
All constraints are satisfied.

现在该检查只作用于内部输入,而内部输入都已连接,因此所有检查都通过了。请注意修改的顺序:规则需要的信息模型无法表达,所以先修改元模型,再修改模型和程序。模型驱动工程中的许多工作都遵循这个方向。

练习 6★★★小项目10 分钟

第二个架构。 使用同一个元模型,用 Flexmi 编写一个恒温器模型:带输出端口 reading 的 Sensor、带输入 raw 和输出 smoothed 的 Filter,以及带输入 value 的 Display,按此顺序连接。在你的模型上运行第一个示例的查询。

查看解答
参考解答第二个架构:恒温器在 Playground 中打开 ↗
thermostat.flexmi
<?nsuri components?>
<architecture name="Thermostat">
  <!-- A sensor reading is smoothed and then shown on a display -->
  <component name="Sensor">
    <outPort name="reading"/>
  </component>
  <component name="Filter">
    <inPort name="raw"/>
    <outPort name="smoothed"/>
  </component>
  <component name="Display">
    <inPort name="value"/>
  </component>
  <connector source="Thermostat.Sensor.reading" target="Thermostat.Filter.raw"/>
  <connector source="Thermostat.Filter.smoothed" target="Thermostat.Display.value"/>
</architecture>
其他文件: tour.eol, components.emf
tour.eol
// Print every component with the names of its ports
for (c in Component.all) {
  (c.name + ": " + c.ports.collect(p | p.name).concat(", ")).println();
}
components.emf
@namespace(uri="components", prefix="components")
package components;

// An architecture is a set of components wired together by connectors
class Architecture {
  attr String name;
  val Component[*] components;
  val Connector[*] connectors;
}

class Component {
  attr String name;
  val Port[*] ports;
}

abstract class Port {
  attr String name;
}

class InPort extends Port {}

class OutPort extends Port {}

// A connector carries a signal from an output port to an input port
class Connector {
  ref OutPort source;
  ref InPort target;
}
以下输出由构建本页时的 Epsilon 2.8.0 实际运行得到。Playground 中的提示措辞可能有所不同,并且不显示关于模型的警告。
输出
Sensor: reading
Filter: raw, smoothed
Display: value

引用使用以架构名称开头的完全限定名,例如 Thermostat.Sensor.reading。同一个查询无需修改就能用于新模型,因为它是针对元模型编写的,而不是针对某一个具体模型。

8

自测

1
元模型……
2
下列哪种说法正确描述了 alarm.flexmi 与 components.emf 之间的关系?
3
一个团队为一套遗留的工资系统建立模型,以便在替换它之前理解它。这是哪种模型?
4
按照本模块采用的术语,建模语言与编程语言的区别在于什么?
5
在 EVL 中,约束(constraint)与建议性检查(critique)有何区别?
6
你想从模型生成一份 HTML 报告。应使用哪种 Epsilon 语言?
7
在示例“不符合元模型的模型”中,模型引用了 Alarm.Siren.sond。构建在 Epsilon 2.8.0 上运行它时会发生什么?
9

延伸阅读