为什么需要模型驱动工程?
软件项目很少因为缺少代码而失败。真正的麻烦在于,构建系统的人逐渐弄不清系统究竟应该是什么样子:需求写在一份文档里,设计写在另一份文档里,而代码与两者渐行渐远。每次修改都要在好几个地方手工完成,迟早会出现前后不一致。
模型驱动工程(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 里使用这些示例。
模型
模型究竟是什么?Kolovos 的定义有意宽泛:模型是“a description of a system of interest”(对所关注系统的描述)。这个系统可以是任何你想推理的对象:一栋建筑、一个工资系统、一辆汽车的定速巡航,或者本模块贯穿始终的防盗报警器。描述的形式可以是一幅图、一个结构化的文本文件,或者工具中的一组对象。
模型按其构建目的分为两类:
- 描述性模型(descriptive model)描述一个已经存在的系统。现有建筑的平面图是描述性的;团队为了熟悉遗留代码库而从中逆向得到的类图也是描述性的。
- 规约性模型(prescriptive model)描述一个尚不存在的系统,并规定它应当是什么样子。建筑师为一栋尚待建造的房子绘制的设计图是规约性的;你即将编写的软件的设计也是规约性的。
规约性模型在完整性和精确性上差别很大。Kolovos 把它们放在一个连续的尺度上:
- 作为草图的模型只记录一次非正式讨论所需的信息,例如在白板上随手画的图。
- 作为蓝图的模型记录了足够的信息,工程师可以据此构建系统,但仍有一些决定留给人来做。只列出类及其关系、却不说明操作具体做什么的 UML 类图就是蓝图。
- 作为程序的模型细致而准确,工具几乎无需人工帮助,就能据此生成实现或验证系统的性质。用来生成控制代码的 Simulink 模型就是这种意义上的程序。
模型在这个尺度上越靠后,工具能用它做的事情就越多。模型驱动工程的大部分工作,就是把模型从蓝图推向程序,从而把实现中繁琐而容易出错的部分自动化。
每个模型也都是片面的:它只保留与其目的相关的方面,略去其余部分。这不是缺陷,而正是建立模型的意义所在。
同一座城市的地铁图和街道图描述的是同一个地方。地铁图扭曲了距离、省略了所有街道,所以不能用来步行认路,却非常适合规划乘车路线。两者都不是这座城市唯一的模型:各自适合各自的用途。
(改编自 Dimitris Kolovos,Model-Driven Engineering Terminology,2022。)
元模型与符合性
模型总是用某种语言写成的,而语言需要规则:模型可以包含哪些种类的元素,这些元素有哪些性质,又可以怎样相互连接。在模型驱动工程中,这些规则由元模型(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 中打开并运行示例,再把你看到的结果与下面的实际输出进行比较。
@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;
}
<?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>
// Print every component with the names of its ports
for (c in Component.all) {
(c.name + ": " + c.ports.collect(p | p.name).concat(", ")).println();
}
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。
<?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
// Print every component with the names of its ports
for (c in Component.all) {
(c.name + ": " + c.ports.collect(p | p.name).concat(", ")).println();
}
@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;
}
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 端口,因为它的目标从未被设置。
抽象语法与具体语法
元模型规定了报警器模型包含什么:组件、端口、连接器以及它们的名称,却完全没有规定模型的外观。那是具体语法(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。)
用 Epsilon 进行模型管理
一旦模型精确到工具能够读取,就可以把围绕模型的工作自动化。模型驱动工程把这称为模型管理(model management),其核心是四类任务:
- 查询:从模型中提取信息,例如哪些组件没有输出端口。
- 验证:检查良构模型必须遵守、而元模型本身又无法表达的规则。
- 模型到文本转换:从模型生成文本,例如代码、文档或报告。
- 模型到模型转换:从一个模型产出另一种语言的模型。
其他任务建立在这四类任务之上:比较两个模型、合并模型,以及在元模型变化时迁移模型。第 9 模块会介绍 Epsilon 中用于这些任务的语言。
Eclipse Epsilon 是一族用于模型管理的语言。每种语言针对一类任务,而它们都建立在同一种基础语言 EOL(Epsilon Object Language)之上,因此你在一种语言中学到的表达式可以用于其他所有语言。
| 任务 | Epsilon 语言 | 讲授模块 |
|---|---|---|
| 查询 | EOL | 第 4 模块 |
| 验证 | EVL | 第 5 模块 |
| 模型到文本转换 | EGL、EGX | 第 6 模块 |
| 模型到模型转换 | ETL | 第 7 模块 |
你已经见过一个查询。下面三个示例预览验证和两类转换。暂时不必在意语法的每个细节:后续每个模块都会系统讲授一种语言。
第一个示例是用 EVL 编写的验证程序。它的模型是报警器的一个变体,其中有一处接线错误:第二个连接器把与门的输出接回了它自己的 armed 输入。
// 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"
}
}
<?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
@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;
}
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 程序的控制台是空的:这条错误和这些警告会以注释的形式标注在模型图上。
下一个示例从原来那个连线正确的报警器生成文本。
<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
<?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>
@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;
}
<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 背后的思想,只是规模小得多:输出由模型派生而来,所以模型变化时,你只需重新生成输出,而不必手工修改它。
最后一个示例把报警器转换为另一种语言的模型:一个普通的有向图,其元模型显示在程序下方。
// 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();
}
@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
<?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>
@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;
}
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 会用由组件生成的节点填充这个图。
实际输出把目标模型打印成一棵树:一个图、三个节点和两条边。端口不见了,因为图语言用不到它们。模型到模型转换让每项任务都能使用最适合它的语言:图算法需要的是节点和边,而不是端口。
使用 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,阅读时在两者之间切换。
练习
描述性还是规约性? 判断下列模型是描述性的还是规约性的;对于规约性模型,再说明它属于草图、蓝图还是程序。
- 建筑师为一栋尚未建造的房子绘制的平面图。
- 从现有代码库逆向得到、帮助新开发者熟悉系统的类图。
- 用来生成汽车定速巡航代码的 Simulink 模型。
- 团队讨论一项新功能该如何工作时在白板上画的图,拍照后就擦掉了。
查看解答
- 规约性,蓝图。它描述一栋尚不存在的房子,细节足以让施工人员据此施工,但仍有许多细节需要他们自行决定。
- 描述性。它记录一个已经存在的系统。草图—蓝图—程序这一尺度适用于规约性模型,因此不用它来定位这个模型。
- 规约性,程序。它足够完整和精确,工具可以据此生成代码。
- 规约性,草图。它关于一项尚不存在的功能,只记录了一次非正式讨论所需的信息。
模型是描述性还是规约性,取决于建模的目的,而不是它的外观:同样是平面图,它既可以是描述性的,也可以是规约性的,取决于房子是否已经存在。
找出用错的术语。 下列说法中哪些正确使用了模型驱动工程的术语?请改写用错的说法。
- “我们的模型实现了 UML 元模型。”
- “
alarm.flexmi符合components.emf。” - “
Port是报警器模型中的一个模型元素。” - “元模型定义了组件语言的抽象语法。”
查看解答
- 用错了。模型“符合”元模型;“实现”描述的是编程语言中类与接口之间的关系。应改为:“我们的模型符合 UML 元模型。”
- 正确。
- 用错了。
Port是元模型中的一个类型(抽象类)。报警器模型的元素是实例,例如组件OrGate或其输入端口door。应改为:“door是报警器模型中的一个模型元素,其类型是InPort。” - 正确。
查询:没有输出端口的组件。 在 Playground 中打开本模块的第一个示例,修改程序,使其打印所有没有输出端口的组件名称。提示:组合使用 select、exists 和 isTypeOf(OutPort)。
查看解答
先选出没有任何输出端口的组件,再收集它们的名称:
// 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
<?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>
@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;
}
Sequence {"Siren"}
只有 Siren 符合条件:它接收信号,但不产生信号。第 4 模块会深入讲解 select、exists 和 collect。
修复模型。 在 Playground 中打开示例“不符合元模型的模型”,点击模型面板上的图示按钮。找出只有一个箭头的连接器,修复模型使两个连接器都有两个箭头,再次运行程序,并与原来的输出比较。
查看解答
最后一个连接器引用了一个不存在的端口:Alarm.Siren.sond 应为 Alarm.Siren.sound。改正名称后,Flexmi 能够解析该引用。在 Playground 的图中,第二个连接器现在有一个指向警报器 sound 端口的箭头;在构建中,实际输出里的警告也随之消失:
<?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
// Print every component with the names of its ports
for (c in Component.all) {
(c.name + ": " + c.ports.collect(p | p.name).concat(", ")).println();
}
@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;
}
OrGate: door, window, open
AndGate: open, armed, sound
Siren: sound
程序本身的输出没有变化,因为程序根本不读取连接器。这正是符合性问题重要的原因:程序可以在有缺陷的模型上运行,并给出看似合理的结果。
区分外部输入。 示例“验证:检查连线”中的 Connected 建议性检查对 door 和 window 发出了警告,但这两个输入来自架构外部的传感器,本来就不应该被连接。请修改元模型,使模型能够标明哪些输入是外部输入;在模型中把 door、window 和 armed 标为外部输入;再添加守卫条件,使该检查跳过外部输入。以第一个示例中连线正确的报警器作为模型。
查看解答
为 InPort 添加一个布尔属性,在模型中设置它,并用它作为该检查的守卫条件:
@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;
}
<?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>
// 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"
}
}
All constraints are satisfied.
现在该检查只作用于内部输入,而内部输入都已连接,因此所有检查都通过了。请注意修改的顺序:规则需要的信息模型无法表达,所以先修改元模型,再修改模型和程序。模型驱动工程中的许多工作都遵循这个方向。
第二个架构。 使用同一个元模型,用 Flexmi 编写一个恒温器模型:带输出端口 reading 的 Sensor、带输入 raw 和输出 smoothed 的 Filter,以及带输入 value 的 Display,按此顺序连接。在你的模型上运行第一个示例的查询。
查看解答
<?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
// Print every component with the names of its ports
for (c in Component.all) {
(c.name + ": " + c.ports.collect(p | p.name).concat(", ")).println();
}
@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;
}
Sensor: reading
Filter: raw, smoothed
Display: value
引用使用以架构名称开头的完全限定名,例如 Thermostat.Sensor.reading。同一个查询无需修改就能用于新模型,因为它是针对元模型编写的,而不是针对某一个具体模型。
自测
alarm.flexmi 与 components.emf 之间的关系?Alarm.Siren.sond。构建在 Epsilon 2.8.0 上运行它时会发生什么?延伸阅读
- Dimitris Kolovos,Model-Driven Engineering Terminology(2022):本模块术语的来源,并讨论了这些术语常见的误用。
- Dimitris Kolovos,Metamodelling with ChatGPT(2022):启发本系列贯穿示例的元模型,第 2 模块以它为基础。
- Dimitris Kolovos,Minimal JHipster JDL Monolith Example(2021):从一个简短的文本模型生成完整的 Web 应用。
- Dimitris Kolovos,公开讲座(视频列表)。
- Epsilon Playground 文档与 Epsilon 文档。
- Marco Brambilla、Jordi Cabot、Manuel Wimmer,Model-Driven Software Engineering in Practice,第 2 版(Morgan & Claypool,2017):系统介绍该领域的专著。