Papyrus模型校验扩展:OCL/Java规则与CI门禁实战

1. 先想清楚:Papyrus 的校验扩展到底在解决什么问题

做模型驱动开发的朋友大概都有过这种体验:图画得漂漂亮亮,导出时才发现某个类没有命名、某个端口的方向接反了、某个需求到实现的追踪链断了。这类问题靠肉眼在几百个元素的模型里找,基本等于大海捞针。Papyrus 作为一款基于 EMF 的开源建模平台,本身带了一套模型校验能力,但真正落到项目里,内置那几十条规则往往只够覆盖"语法层面"的东西,业务语义和团队规范一条都管不了。所以"基于 Papyrus 做模型校验的扩展",说白了就是把你所在团队的那套建模约定,变成平台能自动执行、能报错、能拦住提交的规则。

这个事情的适用人群其实很集中:一是负责建模规范落地的架构师,二是要给建模工具做定制开发的技术人员,三是想把模型质量卡进持续集成流水线的工程负责人。如果你只是偶尔画画图,那内置校验够用;但只要你管的模型规模超过几十个文件、参与建模的人超过三个,扩展校验基本是早晚要补的一课。下面我按"为什么这么设计—机制怎么跑—规则怎么写—怎么接进流水线—哪些坑别踩"的顺序,把我自己在几个项目里趟出来的路径完整讲一遍,代码和配置都能直接对照着改。

2. 校验扩展的整体设计与路径选型

2.1 内置校验覆盖不到的那部分规则

先说清楚边界。Papyrus 内置的校验大多集中在元模型层面:元素是否违反 UML 的约束、引用是否悬空、某些必须的关联是否缺失。这些规则的问题在于它们只认"语言规范",不认"项目规范"。举个我实际遇到的场景:一个车载控制系统的模型,团队约定所有Block类型的元素必须带ASIL等级属性,所有Port必须显式声明方向,任何跨层的Connector不允许跳过中间层直连。这三条规则在 UML 语义里完全合法,内置校验一条都不会报,但它们恰恰是评审时最容易被挑出来的问题。

再往下还有一类更隐蔽的需求:跨模型的一致性。比如需求模型里的每一条功能需求,必须在架构模型里有对应的实现元素;接口模型里定义的信号,必须能在总线上找到发送方。这种"模型之间对账"的规则,靠人盯是不可能的,必须写成机器可执行的约束。所以校验扩展的核心价值,不是"多几条规则",而是把隐性的团队知识固化成可重复执行的质量门禁。

2.2 三条实现路径:OCL、Java 约束、外部脚本

一旦确定要扩展,第一个岔路口就是用什么方式表达规则。我见过团队在这上面纠结很久,其实三条路各有明确适用面,选错了后期维护成本会翻倍。

路径表达方式适合的规则类型主要代价
OCL 约束声明式表达式单元素或小范围导航内的结构判断,比如"名字非空且唯一"复杂逻辑写不动,调试困难,跨模型导航能力弱
Java 约束命令式代码需要遍历、聚合、跨资源查找、调用外部服务的规则代码量大,需要维护编译与部署
外部脚本 + 批量导出独立进程超大规模模型、需要和历史数据比对、团队已有 Python 工具链无法实时反馈,只能做批处理

我的经验是:能用 OCL 表达的尽量用 OCL,因为改一行表达式比改一个类快得多,而且规则写在plugin.xml里,非开发人员也能看懂;一旦规则涉及"遍历整个模型收集所有元素再做统计"或者"要读外部配置文件",立刻转 Java,别硬扛。外部脚本这条路我只在一种情况下推荐:模型已经大到单机校验跑不动,必须拆到离线批处理去做。绝大多数项目用前两条就够。

2.3 我把扩展拆成五层的原因

一个能长期维护的校验扩展,不该是一堆散落的规则类。我在项目里固定按五层来组织,这个分层不是为了好看,是为了让后面加规则、改规则、关规则都不用动别人的代码。

第一层是约束定义层,也就是每条规则的 ID、名称、严重级别、提示信息,全在plugin.xml里声明,规则 ID 用命名空间前缀保证全局唯一。第二层是约束实现层,Java 类或 OCL 表达式,只负责判断"过还是不过",不关心什么时候被调用。第三层是作用域层,也就是客户端选择器,决定这条规则对哪些元素生效——这一层是性能的关键,后面会细说。第四层是绑定层,把实现和作用域配到一起。第五层是执行与呈现层,界面上的"验证模型"菜单、批量校验、问题视图里的标记、CI 里的报告,全都算这一层。

之所以要这么切,是因为这五层的变更频率差别巨大:规则实现可能一周改三次,作用域半年调一次,绑定关系基本不动。混在一起写,改一条规则就要重新理解整个文件,接手的人会很痛苦。

3. 机制拆解:校验框架到底是怎么把规则跑起来的

3.1 约束、提供者、绑定:三个最容易混淆的概念

很多人第一次配扩展点会卡住,根源是没分清这三个东西。约束(Constraint)是规则的原子单位,它有唯一 ID、语言类型、严重级别和执行模式。提供者(Constraint Provider)是一组约束的容器,它声明这些约束作用在哪个元模型包上——比如你写namespaceUri指向 UML 元模型,这些规则就只会对 UML 元素生效。绑定(Binding)是连接器,它把某个约束挂到某个客户端上下文上,只有绑定成功的约束才会真正参与校验。

这个设计的意图很明确:约束可以复用,同一条"名称非空"的规则,既可以在"编辑时实时校验"这个上下文里用,也可以在"提交前全量校验"这个上下文里用,只是绑定不同。理解了这一点,后面写配置就不会乱。

3.2 客户端选择器决定"谁来触发",也决定性能

客户端选择器(Client Selector)是整套机制里最容易被忽略、也最容易出事的部分。它本质上是一个过滤器,每次框架要判断"这个元素该不该跑这条规则"时都会问它一次。

如果你不配选择器,框架的默认行为是"所有元素都参与",听起来省事,实际后果是打开一个稍大的模型,每点一下元素都触发一轮全量校验,界面直接卡死。我在一个两百多个元素的模型上试过,不加选择器,实时校验的响应时间从毫秒级涨到好几秒,鼠标点下去有明显延迟。

正确的做法是写一个继承自框架基类的选择器,在里面做两件事:一是按元模型类型过滤,比如只对Class和Interface生效;二是按资源扩展名过滤,只处理.uml、.sysml这类业务模型文件,把.notation、.di这类纯图形文件排除掉。后一条尤其重要,图形文件里的元素和语义模型是一一对应的,重复校验等于把所有规则跑了两遍。

public class SemanticModelSelector extends AbstractModelConstraintClientSelector { private static final Set<String> MODEL_EXTENSIONS = Set.of("uml", "sysml", "profile"); @Override public boolean selects(IValidationContext ctx) { EObject target = ctx.getTarget(); if (target == null || target.eResource() == null) { return false; } String ext = target.eResource().getURI().fileExtension(); return ext != null && MODEL_EXTENSIONS.contains(ext.toLowerCase()); } }

这段代码看着简单,但把"作用范围"和"规则实现"彻底解耦了。以后要新增一类模型文件的支持,只改这一个集合,不用碰任何规则代码。

3.3 严重级别与消息模板:问题最终怎么落到问题视图里

约束的严重级别只有几种取值,但选哪个直接影响团队的使用体验。我的原则是:违反语言规范或导致模型不可用的,一律 ERROR;违反团队约定但不影响工具处理的,用 WARNING;纯提示性的风格建议,用 INFO。这个划分不是为了严谨,是为了让问题视图里的信息有优先级——如果所有规则都是 ERROR,大家很快就对红色标记脱敏了。

消息模板里可以用占位符引用参数,比如{0}会被替换成你在约束代码里传入的第一个参数。这个细节值得用好:不要写"名称重复",要写"元素 {0} 的名称 {1} 与同层级元素冲突",报错时直接告诉人是谁和谁冲突,省掉一次手工排查。我在一个项目里把消息模板全部改成带元素名的格式后,评审会上关于"这条报错到底指哪"的争论基本消失了。

4. 手把手实现:一个可用的校验扩展

4.1 工程骨架与依赖准备

先说环境。Papyrus 的版本迭代比较快,扩展开发和平台版本强绑定,所以第一步永远是确认你目标平台的版本号,然后找对应的建模 SDK。工程类型选插件工程(Plugin Project),不要选普通的 Java 工程,因为扩展点注册依赖插件清单。

依赖上至少需要这几项:EMF 核心、EMF 校验框架、UML/SysML 元模型、Papyrus 的建模基础设施。如果你的规则要读 Papyrus 的偏好设置,还得加上对应的服务包。这里有个实操建议:用目标平台(Target Platform)文件管理依赖,不要在工程里手工加 jar。我早期图省事直接引 jar,结果换机器编译就报找不到类,排查了大半天,最后发现是版本错位。

MANIFEST.MF里的依赖建议显式声明并标注版本区间,比如Require-Bundle: org.eclipse.emf.validation;bundle-version="[1.8.0,2.0.0)"。区间上限卡死一点,能避免平台升级后 API 变了但编译期没发现、运行期才崩的情况。

4.2 plugin.xml 三处扩展点的逐项配置

配置是整件事里最容易出错的环节,我把三处扩展点按顺序列出来,每一处说明它解决什么问题。

第一处:声明约束提供者。这里定义规则本身,包括 ID、语言、级别、执行模式和消息。

<extension point="org.eclipse.emf.validation.constraintProviders"> <constraintProvider cache="true"> <package namespaceUri="http://www.eclipse.org/uml2/5.0.0/UML"/> <constraints categories="com.example.papyrus.validation.category"> <constraint id="com.example.papyrus.validation.block.asilRequired" lang="Java" class="com.example.papyrus.validation.BlockAsilConstraint" severity="ERROR" mode="Batch" isEnabledByDefault="true" name="Block 必须声明 ASIL 等级"> <message>元素 {0} 缺少 ASIL 等级属性</message> <description>所有 Block 类型元素必须带有 ASIL 等级,取值来自项目统一枚举。</description> </constraint> </constraints> </constraintProvider> </extension>

namespaceUri必须和你所用版本的元模型 URI 完全一致,差一个字符规则就不会生效。cache="true"表示约束实例缓存复用,对无状态规则一定要开,否则每次校验都新建对象,批量校验时开销很明显。mode的取值决定这条规则参与实时校验还是批量校验:遍历整个模型的规则一律设为Batch,因为实时触发会卡界面;只看单个元素自身属性的规则可以设成实时,反馈更快。

第二处:声明客户端上下文。也就是给作用域起个名字,并挂上选择器。

<extension point="org.eclipse.emf.validation.constraintBindings"> <clientContext id="com.example.papyrus.validation.semanticContext"> <selector class="com.example.papyrus.validation.SemanticModelSelector"/> </clientContext> <binding context="com.example.papyrus.validation.semanticContext"> <constraint ref="com.example.papyrus.validation.block.asilRequired"/> <constraint ref="com.example.papyrus.validation.port.directionRequired"/> </binding> </extension>

注意<binding>里的ref必须和第一处的id完全对应,这是最常见的错误来源。我见过不止一次,ID 拼错一个字母,规则静悄悄不跑,也不报任何错。

第三处:声明规则分类。分类决定了这些规则在偏好的校验配置页面里归在哪个树下,也决定了用户能不能整组关闭。

<extension point="org.eclipse.emf.validation.constraintProviders"> <categories> <category id="com.example.papyrus.validation.category" name="项目建模规范"/> </categories> </extension>

分类这个环节很多人省掉,结果所有规则混在默认分类里,后面想按规则集做开关就没法做了。

4.3 Java 约束的实现细节

约束类继承框架提供的抽象基类,实现校验方法。核心逻辑就三件事:拿到目标元素、做判断、返回状态。

public class BlockAsilConstraint extends AbstractModelConstraint { private static final String ASIL_STEREOTYPE = "ASIL"; private static final String ASIL_PROPERTY = "level"; @Override public IStatus validate(IValidationContext ctx) { EObject target = ctx.getTarget(); // 只处理带有 Block 构造型的元素 EObject stereotype = findAppliedStereotype(target, ASIL_STEREOTYPE); if (stereotype == null) { return ctx.createFailureStatus(nameOf(target)); } Object level = readSlot(stereotype, ASIL_PROPERTY); if (level == null || String.valueOf(level).isBlank()) { return ctx.createFailureStatus(nameOf(target)); } return ctx.createSuccessStatus(); } }

几个实操细节值得强调。第一,失败状态里的参数就是消息模板的占位符,传入什么,用户就看到什么,所以这里传入元素名是最合适的。第二,不要在约束里抛异常,框架对异常的处理不统一,抛出去很可能表现成"这条规则没报错"而不是"报错",排查时会走很大弯路;遇到无法判断的情况,直接返回成功状态跳过。第三,约束类要保持无状态,因为cache="true"时实例会被复用,任何实例字段都可能被并发访问污染。这个坑我踩过,在一个约束里缓存了遍历结果,实时校验和批量校验同时跑的时候出现了脏数据,最后定位到是共享字段。

还有一点:跨模型查找这种耗时操作,务必在约束内部做局部缓存,但缓存要挂在IValidationContext或者用弱引用挂在资源上,别挂实例字段。

4.4 同一规则用 OCL 表达时的写法与取舍

同一个"Block 必须有 ASIL"的规则,换成 OCL 写法大概是这个样子:

context UML::Class inv AsilRequired: self.extension_ASIL->notEmpty() and self.extension_ASIL.level->notEmpty()

写起来确实短,但有几个现实约束要提前想清楚。OCL 的导航能力受限于元模型和构造型,一旦你要访问"同一命名空间下的其他元素"或者"通过引用跨文件查找",表达式会迅速变成一个嵌套很深的select组合,可读性和性能都崩。另外 OCL 规则在调试时几乎没有断点能力,出问题只能靠二分法删条件试。

我的判断标准很粗暴:能在一行内写完导航链的用 OCL,需要循环或聚合的一律用 Java。比如"名称在同类元素中唯一"这种需要收集同级元素的规则,表面看是单元素判断,实际要遍历容器,用 Java 写反而更清晰。

4.5 编译部署与第一次校验验证

写完代码,验证顺序建议固定成三步,别跳。第一步,启动一个运行中的建模平台实例(Runtime 实例),导入一个故意构造的"坏模型"——缺属性、名字重复、引用悬空全放进去。第二步,右键模型文件选校验,看问题视图里有没有出现你配置的消息。第三步,把坏模型的每个问题逐个修掉,再跑一次,确认问题全部消失。

第三步经常被跳过,但它是验证"误报率"的唯一手段。我见过一个规则写完只测了报错场景,上线后把正确模型也全标红了,原因是判断逻辑写成了"属性不存在则报错",而实际上属性是存在但类型不匹配。修掉一个错再跑一遍,比看十遍代码有效。

5. 走出界面:批量校验、报告与门禁接入

5.1 无界面批处理的调用骨架

界面上的校验按钮只适合个人自查,团队真正需要的是在提交前自动跑一遍。EMF 校验框架提供了批量校验器,可以在无界面环境下跑完整套规则。

public class HeadlessValidator { public ValidationReport run(Collection<EObject> roots) { BatchValidator validator = new BatchValidator(); CollectingListener listener = new CollectingListener(); validator.addValidationListener(listener); for (EObject root : roots) { validator.validate(root); } return listener.report(); } }

关键点在监听器上:批量校验的结果不会自动落到问题视图,必须自己实现监听器把每条失败状态收集起来。监听器拿到的是约束 ID、目标元素、消息文本这三样信息,够用了。如果模型分散在多个资源里,记得先把所有根元素收集齐,再统一跑,而不是每个文件单独跑一遍——文件之间有关联规则的场景下,单独跑会漏掉跨文件的问题。

5.2 结果落成报告与阈值判定

跑出结果之后,最直接的做法是输出一份结构化报告,比如 XML 或 JSON,把文件路径、元素名、规则 ID、严重级别、消息全写进去。然后在流水线里加一个判定步骤:ERROR 级别的条目数超过阈值就失败。

阈值怎么定是个经验活。我的建议是分阶段:新项目上来就设成零容忍;存量项目先清零严重级别,再把 WARNING 设一个逐月下降的指标。一上来就要求全部清零,团队会直接把校验关掉,反而达不到目的。

另外报告里一定要包含规则 ID 而不是只有消息文本,否则统计时没法按规则聚合,也看不出哪条规则误报最多。

5.3 规则分级与开关的工程化做法

规则上线之后一定会出现"某条规则在某个子项目里不适用"的情况。这时候有两种处理方式:一种是改规则代码加条件分支,另一种是在配置层面做开关。前者是灾难的开始——几条规则里塞满了针对特定项目的判断,半年后没人敢动。

我的做法是把规则开关做成配置项,读一个版本受控的配置文件,里面按规则 ID 和项目标识列出生效状态。校验执行前加载配置,不生效的规则直接跳过。这样特殊需求的适配不用改代码,配置本身还能进代码评审,谁关了什么规则一目了然。

6. 踩坑实录与排查速查

6.1 规则不触发:按这个顺序怀疑六件事

规则写完不报错,是最折磨人的问题,因为没有任何提示。我总结了一个排查顺序,按命中率从高到低排:

第一,namespaceUri不匹配。去元模型注册表里核对实际的 URI,复制粘贴比手打可靠。

第二,绑定里的ref与实际 ID 不一致。这个只能靠肉眼比对,建议在写 ID 时统一用"包名 + 规则名"的固定格式,减少手误空间。

第三,选择器返回了 false。在selects方法里临时打一行日志,看目标元素到底进没进来。很多时候是资源扩展名判断漏了,比如模型文件实际是.notation而非.uml。

第四,约束类没有被加载。插件依赖缺失时,类加载失败会被静默吞掉。检查MANIFEST.MF里是否引了校验框架和元模型包。

第五,模式设置不对。设成Batch的规则在实时校验里就是不跑,这是设计如此,不是 bug。

第六,插件缓存过期。运行时实例启动时如果没带上新插件,改动就看不到。清一次运行配置或者重启实例基本能解决。

6.2 性能与误报:两条最容易翻车的线

性能问题的典型症状是"模型一大就卡"。除了前面说的选择器过滤,还有一个常见原因是规则里做了全模型遍历。比如"检查所有接口名不重复",如果每条规则在每次校验时都从头遍历一遍根元素的所有子元素,元素数上去之后复杂度是指数级的。优化方式是先做一次遍历建立索引,把结果挂在上下文里供后续规则复用,而不是每条规则各扫一遍。

误报问题更隐蔽,也更伤团队信任。最常见的误报来源是"没区分元素的真实类型"——比如你把规则定在Class上,但模型里用了Interface,构造型逻辑判断走空分支,结果报了一个"缺属性"。解决方式是所有类型判断都走元模型查询而不是字符串比较,字符串比较在继承关系面前非常脆弱。

6.3 常见问题速查表

现象最可能的原因处理方式
规则完全不报ID 或 ref 拼写不一致逐字符比对两处配置
报错位置飘到别的元素上消息参数对应的不是目标元素检查失败状态传入的参数
实时校验卡顿明显未配选择器或规则设成实时全量遍历加选择器,把遍历类规则改成批量模式
批量校验比界面校验少几条根元素没收集全,跨文件关联被切断收集全部资源根元素后统一执行
改完代码行为没变运行时实例加载了旧版本插件清理运行配置并重启实例
存在异常但结果为空约束内部抛了异常被框架吞掉约束内不做无保护的类型强转
换机器编译不过手工引 jar 导致版本错位统一用目标平台文件管理依赖

7. 最后分享几个我自己的做法

规则数量不怕多,怕的是没分类。我在项目里固定给每条规则打两个标签:所属规范章节、适用模型层级。前者让人能顺着规范文档找到对应规则,后者让人知道这条规则主要拦哪类问题。加了标签之后,新同事看问题视图基本能自己判断该找谁确认,不用每次都来问。

另外,不要把校验做成"一次性交付"。规则和模型是同步演进的,模型里新增一类元素,规则就得跟着补。我在每个迭代结束前会留半天,专门把这一轮评审里反复出现的人工检查项转成规则。这个习惯坚持下来,规则的命中率会越来越高,误报会越来越少,因为它长在真实问题上,而不是拍脑袋想出来的。

最后一个建议:扩展校验这件事,先跑通一条规则的全链路,再批量铺。一条规则从配置、实现、绑定、验证到接入流水线,全走通之后你会发现后面的规则都是同一个模板换个判断逻辑。反过来,一上来写二十条,配置里有个共用的问题,你会一条一条排查到怀疑人生。