# 插件开发 开发

插件是平台开放的一种扩展方式,通过插件可以为平台添加新的功能,或者修改现有的功能。一个插件可以包含 Java/Groovy 代码、种子数据(领域模型、表单、菜单、动态逻辑等 CSV)以及前端组件。

# 插件管理

插件是以 .myp 结尾的文件。菜单位置:开发定制 > 动态插件。

动态插件列表

列表显示已导入的插件的名称、版本号、描述、是否启用、系统配置、依赖插件等信息。PlatformAdapter 是平台内置插件,不要禁用。

“系统配置”列对应插件的 isSystem 属性,只是一个标记,当前版本的平台源码中没有据此对插件做任何特殊处理,可以忽略。

# 导入插件

点击 Import dynamic plugin,在弹出的面板中上传插件文件后点右上角的 提交:

导入插件

面板标题和字段名当前显示为英文。上传区的提示文字写着“然后点击下方按钮上传”,但下方并没有按钮:把文件拖进上传区或点击上传区选择文件即可,最后点右上角的 提交。这些是当前版本的界面问题。

字段 说明
Plugin file 插件文件(.myp)
Ignore MD5 Check 导入插件中的种子数据时忽略 MD5 校验:打开后,即使 CSV 文件没有变化也会重新导入
Overwrite Conflict 打开后强制覆盖:版本号不比已有插件高也会覆盖,并且种子数据不做冲突检测,CSV 中的数据会覆盖界面上改过的数据。这只对实际导入的 CSV 文件有效:内容没有变化(MD5 相同)的 CSV 文件默认整个跳过,需要同时打开 Ignore MD5 Check 才会重新导入。插件文件名中包含 SNAPSHOT 时,不论是否打开都按打开处理

# 版本与覆盖规则

  • 每个插件名称只保留一条记录,导入同名插件会更新这条记录,不会并存多个版本。
  • 导入的版本号高于已有版本时,覆盖已有插件并自动启用。
  • 导入的版本号等于或低于已有版本时,默认静默忽略,界面上没有提示,只在后端日志中记录一条警告。以下两种情况例外,会强制覆盖:
    • 插件文件名中包含 SNAPSHOT;
    • 导入时打开了 Overwrite Conflict。
  • 上面两种情况下,插件中的种子数据也会强制覆盖、不做冲突检测:只要某个 CSV 文件的内容有变化(或者导入时打开了 Ignore MD5 Check),界面上改过的数据就会被这个 CSV 覆盖;内容没有变化的 CSV 文件仍按 MD5 整个跳过。所以文件名带 SNAPSHOT 的插件只适合在开发环境中使用。

所以发布插件的新版本时,一定要提高 build.gradle 中的 version。

# 启用与禁用

在列表中勾选插件后点 启用插件 或 禁用插件。启用、禁用后平台会重新加载插件,不需要重启。

# 插件运行时说明

  • 平台中启用的所有插件共享同一套运行时环境,插件之间可以直接调用。
  • 插件通过 tech.muyan:api 库调用平台的能力,见 平台 API。

# 开发插件

# 目标读者

此文档主要目标读者为希望了解如何进行牧言开发平台插件开发的高级开发者。

# 前置知识

读者需要对 Gradle 有一定的了解,参考资料:Gradle (opens new window)。

# 构建环境

  • JDK 25:插件的 Java toolchain 为 25,运行 Gradle 本身的 JDK 也要是 25。
  • Gradle 9:模板自带 Gradle Wrapper(9.2.0),使用 ./gradlew 即可。

注意

如果 Gradle daemon 运行在低于 25 的 JDK 上,打包时会报 class file version 69.0 ... up to 65.0。先执行 ./gradlew --stop 停掉旧的 daemon,确认 JAVA_HOME 指向 JDK 25 后重新构建,不要降低 toolchain 版本。

# 创建插件

  1. 基于平台模板仓库 muyantech/platform 创建项目(私有仓库,需要向牧言申请访问权限)。
  2. 插件工程位于 codes/ 目录:
    • codes/settings.gradle 中的 rootProject.name 是插件名称;
    • codes/build.gradle 中的 version 是插件版本号;
    • codes/src/ 是插件的 Java/Groovy 源码;
    • codes/data/ 是插件的种子数据(csv/、groovy/ 等),打包时整体放进插件;其中 data/plugin-csv/ 里的 CSV 会合并进包内的 csv/,data/plugins/ 用来存放构建出的 .myp,不会打进包;
    • codes/ui-component/ 是插件的前端组件工程(可选)。
  3. 通过 build.gradle 添加第三方依赖,开始编写业务逻辑。

# 构建配置

模板的 codes/build.gradle 已经包含以下关键配置(摘录,完整内容以模板为准,例如项目级的 repositories、测试和覆盖率配置未列出),一般只需要修改版本号、依赖和 dependsOnPlugins:

buildscript {
  repositories {
    mavenCentral()
    maven {
      url = uri('http://packages.muyan.io/artifactory/libs-release')
      allowInsecureProtocol = true
    }
    maven {
      url = uri('http://packages.muyan.io/artifactory/libs-snapshot')
      allowInsecureProtocol = true
    }
  }
  dependencies {
    classpath 'gradle.plugin.com.github.harbby:gradle-serviceloader:1.1.8'
    classpath 'tech.muyan:gradle-plugin:1.0.0-1-2-SNAPSHOT'
  }
}

plugins {
  id 'java-library'
  id 'groovy'
}
apply plugin: 'tech.muyan.gradle.plugin'

version '0.0.1'
group 'tech.muyan.plugin'

dependencies {
  // 平台运行时已提供,必须 compileOnly,不能打进插件包
  compileOnly localGroovy()
  compileOnly 'tech.muyan:api:0.0.5'

  // 插件自己的运行时依赖,会打进插件包
  implementation 'commons-io:commons-io:2.7'
}

muyanPlatformPlugin {
  compileGroovyScript false
  // PlatformAdapter 是平台内置插件,不要删除
  dependsOnPlugins([
    'PlatformAdapter': '0.0.1',
  ])
}

// 打包前重新整理 data/ 目录,保证 clean 之后也能正确打包
tasks.register('stageMuyanData', Sync) {
  from(rootProject.file('data')) {
    exclude 'plugin-csv/**'
    exclude 'plugins/**'
  }
  from(rootProject.file('data/plugin-csv')) {
    into 'csv'
  }
  into(layout.buildDirectory.dir('tmp/muyan'))
}

tasks.named('buildDynamicFramePackage') {
  dependsOn(tasks.named('stageMuyanData'))
}

// 打包插件把空的 dependsOnPlugins 写成 JSON "[]",平台需要 "{}",生成后替换
tasks.named('generateMuyanPluginInfo') {
  doLast {
    def pluginInfo = file("${layout.buildDirectory.get().asFile}/tmp/muyan/PLUGIN_INFO")
    pluginInfo.text = pluginInfo.text.replace('"dependsOnPlugins":[]', '"dependsOnPlugins":{}')
  }
}

java {
  toolchain {
    languageVersion.set(JavaLanguageVersion.of(25))
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

最后两段是模板里对打包插件(tech.muyan:gradle-plugin)已知问题的修正,不要删除:

  • stageMuyanData:打包插件在配置阶段就把 data/ 复制到 build/tmp/muyan,执行 clean 后这份副本会被删掉;这个任务在打包前重新复制一遍,并把 data/plugin-csv/ 合并进 csv/。
  • generateMuyanPluginInfo 的 doLast:dependsOnPlugins 为空时,打包插件会写成 [],平台读取时需要 {}。

muyanPlatformPlugin 中可以配置:

配置项 默认值 说明
dependsOnPlugins 空 依赖的插件及最低版本,PlatformAdapter 必须保留。模板中写的 '0.0.1' 是最低版本要求,当前平台内置的 PlatformAdapter 是 1.1.2,满足要求
compileGroovyScript false 是否在打包时把 DynamicLogic*.csv 中引用的 Groovy 脚本预编译
devHost http://localhost:8080 开发模式下上传插件的后端地址,见 开发模式
ignoreMd5Check false 开发模式下上传插件时是否忽略 MD5 校验
overwriteConflict true 开发模式下上传插件时是否强制覆盖

# 数据绑定

在插件源代码中可以定义数据模型(POJO 类),与平台中的领域模型绑定。只需要保证 POJO 类和领域模型同名,并且实现了 tech.muyan.api.DynamicDomainEntity 接口。打包插件会自动为实现了 DynamicDomainEntity 和 MuyanPlatformComponent 的类生成 ServiceLoader 注册文件,无需手工编写。

下面给出一个例子。首先定义 DomainClass 和 DomainClassField 的 CSV 文件:

# DomainClass

shortName(*),extInfo,createRoleRequirement.name,readRoleRequirement.name,updateRoleRequirement.name,deleteRoleRequirement.name

SampleDynamicOrderDomain,,USER,USER,USER,USER
1
2
3

# DomainClassField

domainClass.shortName(*),name(*),dataType,referenceDomain.shortName,nullable,editable,defaultValue,options,extInfo

SampleDynamicOrderDomain,orderId,STRING,,Y,Y,,,
SampleDynamicOrderDomain,isActive,BOOLEAN,,N,N,,,
SampleDynamicOrderDomain,totalAmount,BIG_DECIMAL,,Y,N,,,
SampleDynamicOrderDomain,quantity,INTEGER,,N,Y,,,
SampleDynamicOrderDomain,productId,LONG,,Y,Y,,"[1,2,3]",
SampleDynamicOrderDomain,discountRate,DOUBLE,,Y,Y,,,
SampleDynamicOrderDomain,orderDate,LOCAL_DATE,,Y,Y,,,
SampleDynamicOrderDomain,deliveryDateTime,ZONED_DATETIME,,Y,Y,,,
SampleDynamicOrderDomain,additionalInfo,JSON_STRING,,Y,Y,,,
SampleDynamicOrderDomain,buyerTask,DOMAIN_OBJECT,SampleTask,Y,Y,,,
SampleDynamicOrderDomain,sellerTasks,DOMAIN_OBJECT_LIST,SampleTask,Y,Y,,,
1
2
3
4
5
6
7
8
9
10
11
12
13

提示

DomainClass CSV 中的四个 *RoleRequirement.name 列指定增删改查需要的角色要求,不配置时任何人都无法操作,见 对象权限控制。

接下来定义 POJO 类:

package tech.muyan.plugin

import tech.muyan.api.DynamicDomainEntity

import java.time.LocalDate
import java.time.ZonedDateTime

class SampleDynamicOrderDomain implements DynamicDomainEntity {

  Long id

  String orderId

  Boolean isActive

  BigDecimal totalAmount

  Integer quantity

  Long productId

  Double discountRate

  LocalDate orderDate

  ZonedDateTime deliveryDateTime

  String additionalInfo

  // SampleTask 是另一个定义好的 POJO 类,这里只是举个例子,不再给出定义。
  // 如果没有定义 SampleTask 类,可以使用 Object 代替。下面的 sellerTasks 也是同理。
  SampleTask buyerTask

  List<SampleTask> sellerTasks

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

# 使用数据模型绑定

参考 平台 API 中的 SimpleQuery 工具类,可以通过数据模型绑定的方式来操作数据。例如

SampleDynamicOrderDomain domain = SimpleQuery
   .of(SampleDynamicOrderDomain.class)
   .eq("orderId", "123456")
   .get()
1
2
3
4

或者将查询出来的数据转化为一个 POJO 对象:

SampleDynamicOrderDomain domain = SimpleQuery
   .of("SampleDynamicOrderDomain")
   .eq("orderId", "123456")
   .get() as SampleDynamicOrderDomain
1
2
3
4

# 生命周期回调

插件中实现 tech.muyan.api.MuyanPlatformComponent 接口的类,会在插件加载后收到回调:

import tech.muyan.api.MuyanPlatformComponent;

public class CrmStartup implements MuyanPlatformComponent {

  @Override
  public void onLoad() {
    // 插件加载完成后执行,例如预热缓存、检查必要的配置
  }

  @Override
  public void offLoad() {
    // 插件被卸载(重新加载前)时执行,例如释放线程池、连接
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • 平台启动完成后,会按租户逐个调用 onLoad();此后导入、启用、禁用插件或在开发模式下替换 jar 导致插件重新加载时,也会再调用一次 onLoad()(多实例部署时每个实例各执行一次),所以这里的逻辑必须可以重复执行。
  • 重新加载前,会先对旧的插件实例调用 offLoad();平台启动时的第一次加载不会调用 offLoad()。
  • 这两个方法都有默认的空实现,只需要重写用到的那个。
  • 请在 onLoad() / offLoad() 内部自行捕获异常,不要让异常抛出。平台对回调没有做异常保护,抛出异常时:
    • offLoad() 抛异常:新的插件代码装载失败,运行的还是旧代码;该租户所有插件中尚未执行的组件的 offLoad() 也会被跳过。导入仍提示成功,只在后端日志中记录 Failed to replace classloader。
    • onLoad() 抛异常:新代码已经装载,但该租户所有插件中尚未执行的组件的 onLoad() 会被跳过。
    • 两种情况下,重新加载后的收尾步骤都不会执行,影响的是该后端实例上的所有租户:插件自定义 Controller 的接口全部返回 404(路由在重新加载前已被清空,没有重建),相关缓存不刷新,定时任务全部暂停(CRON 任务不再触发)。多实例部署时,其他实例收不到"加载完成"的通知,同样处于接口 404、定时任务暂停的状态,并且仍运行旧代码。导入同样提示成功。
    • 直到下一次重新加载成功(通常需要先修好或禁用出错的插件)才会恢复。offLoad() 出错时重启后端即可恢复(启动时不调用 offLoad());如果 onLoad() 每次都抛异常,重启后端也可能无法恢复,因为启动时同样会调用 onLoad()。
  • 1.0 起移除的定时任务"启动时执行(Run at Startup)"类型,其逻辑可以迁移到 onLoad() 中。

# 插件前端模块

插件可以携带自己的前端组件(React),由平台前端通过 Module Federation 动态加载。

  1. 在 codes/ui-component/ 中编写组件。组件工程的 manifest.ts 默认导出一个 FrontendPluginManifest,在 forms 和 fields 中分别登记自定义的表单渲染器和字段组件:

    import { FrontendPluginManifest } from '@muyantech/frontend-lib';
    import TestFormRender from './TestFormRender';
    
    export const manifest: FrontendPluginManifest = {
      fields: {},
      forms: {
        'TEST_FORM': {
          Render: TestFormRender,
        }
      }
    }
    
    export default manifest;
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
  2. 在 codes/ui-component/build.json 中配置构建命令和产物目录(不写时默认为 yarn && yarn build 和 build):

    {
      "buildCommand": "yarn && yarn build",
      "targetFolder": "dist"
    }
    
    1
    2
    3
    4
  3. 在 codes/data/csv/DisplayComponentModule.csv 中登记模块,packageFile 填前端工程相对于 codes/ 的目录:

    name(*),packageFile
    
    Test Module,ui-component
    
    1
    2
    3

打包插件时,会在该目录下执行构建命令,把产物目录打成 zip 放进插件。导入插件后,平台通过 GET /theme/info 返回的 modules[].entry(/theme/module/<hash>/mf-manifest.json)告诉前端加载哪些模块。

注意

只有存在已激活的显示主题时,/theme/info 才会返回 modules。本节依据打包插件和平台源码整理,模板中的示例模块默认是注释掉的。

# WebSocket

插件可以处理前端通过 WebSocket 发来的消息,并向前端推送消息。继承 tech.muyan.api.websocket.MuyanWebSocketComponent:

import tech.muyan.api.websocket.MuyanWebSocketComponent

class EquipmentStatusSocket extends MuyanWebSocketComponent {

  @Override
  String getTopic() {
    return 'equipmentStatus'
  }

  @Override
  Object onMessage(String sessionId, String msgId, Object payload) {
    // 返回值会作为对这条消息的回复,发回给发送方
    return [received: true]
  }

  @Override
  void onClose(String sessionId) {
    // 连接关闭时的清理
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • getTopic():该组件处理的消息主题。
  • onMessage(sessionId, msgId, payload):收到该主题的消息时调用,返回值作为回复发给发送方(回复中带相同的 msgId)。
  • pushMessage(sessionId, msgId, payload):主动给某个连接发送消息。
  • broadcast(payload):给订阅了该主题的所有连接发送消息。
  • onClose(sessionId):连接关闭时调用,可选。

客户端连接地址为 ws://<服务器地址>/api/websocket/route?access_token=<access_token>,发送的消息为 JSON:{"msgId": "...", "type": "...", "topic": "equipmentStatus", "payload": {...}},交给 topic 对应的组件处理。订阅 / 取消订阅(订阅后才能收到 broadcast)时,type 为 subscribe / unsubscribe,主题列表放在 payload.topics 中,例如 {"msgId":"1","type":"subscribe","payload":{"topics":["equipmentStatus"]}}。发送纯文本 ping 会收到 pong。

api 版本

平台运行时的 onMessage 返回 Object,而 api 0.0.5 中声明的返回类型是 void。按 0.0.5 编译的 WebSocket 组件在当前平台上无法正常工作,请使用 1.0.0 系列的 api 库(见 平台 API)。

# 自定义 Controller

插件可以注册自己的 HTTP 接口。实现 tech.muyan.api.MuyanDynamicController,在类上用 @Mapping 指定路径前缀,在方法上用 @Get、@Post、@Put、@Delete 声明路由,参数用 @UrlParameter(路径中的 {变量})或 @QueryParameter(URL 参数)绑定:

import tech.muyan.api.MuyanDynamicController
import tech.muyan.api.annotations.Get
import tech.muyan.api.annotations.Mapping
import tech.muyan.api.annotations.QueryParameter
import tech.muyan.api.annotations.UrlParameter

@Mapping('/equipment')
class EquipmentController implements MuyanDynamicController {

  @Get(value = '/{code}/status', roleRequirement = 'USER')
  Map status(@UrlParameter('code') String code, @QueryParameter('detail') String detail) {
    return [code: code, status: 'RUNNING', detail: detail == 'true']
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

上例注册的接口为 GET /api/equipment/<code>/status?detail=true。平台内置路由没有匹配上的请求,都会交给插件注册的路由处理。返回值会转成 JSON。

参数值由字符串转换成参数类型,建议参数用 String 自行解析:例如声明为 Boolean 时,?detail=false 转换出来也是 true。

安全提示

  • roleRequirement 填的是 角色要求 的名称。不填,或者填了一个不存在的名称,该接口不需要登录就能访问。 请为每个接口显式填写存在的角色要求。
  • 这些注解和接口只在 1.0.0 系列的 api 库中提供,api 0.0.5 中没有。
  • @Mapping 的值不要以 / 结尾(例如写成 /equipment/),否则注册出来的路径会不正确。

# 动态 RPC

插件之间、或者不同平台实例之间,可以通过动态 RPC(基于 gRPC)互相调用:

  • 服务端:插件中实现一个继承 tech.muyan.api.MuyanRpcService 的接口,并提供实现类。
  • 调用端:在接口上加 @RpcClient(host, port, tenant, password, caCertPath, serverName) 注解,再通过 DynamicRpcClientService.getClientProxy(接口.class) 取得代理对象调用。

服务端通过以下环境变量配置:

环境变量 说明
DYNAMIC_RPC_SERVER_PORT 监听端口,默认 7777
DYNAMIC_RPC_SERVER_CERT_PATH TLS 证书路径,与下一项同时配置才启用 TLS
DYNAMIC_RPC_SERVER_KEY_PATH TLS 私钥路径
DYNAMIC_RPC_PASSWORD 调用密码,调用端 @RpcClient 的 password 要与之一致

安全提示

平台启动时总会在 7777 端口(或 DYNAMIC_RPC_SERVER_PORT)启动 RPC 服务。DYNAMIC_RPC_PASSWORD 为空时不做任何认证,未配置证书时通信不加密。能访问这个端口的任何人都可以调用插件中实现了 MuyanRpcService 的服务。生产环境请务必:

  • 设置 DYNAMIC_RPC_PASSWORD;
  • 配置 TLS 证书;
  • 不要把该端口暴露到公网,用防火墙或容器网络限制访问来源。

另外,@RpcClient 的 password 默认值是 password,调用端请显式填写。

# 插件打包

开发完成后,在 codes/ 目录执行:

./gradlew buildMuyanPlugin
1

产物为 codes/build/muyan/<插件名>-<版本号>.myp。打包过程依次执行:

  • generateMuyanPluginInfo:生成插件描述文件 PLUGIN_INFO(名称、版本、依赖插件);
  • compileSeedDataGroovy:把插件 jar 和运行时依赖复制到包内的 libs/;
  • buildDynamicFramePackage:旧版用于构建 DynamicForm*.csv 中 frameFile 列引用的前端工程。1.0 起平台已移除 frameFile 列,这一步不再产生内容,但任务仍然保留(模板让 stageMuyanData 挂在它前面);
  • buildFrontendPackage:构建 前端模块;
  • 把 codes/data/ 下的种子数据一起打包。

implementation 依赖会打进插件包的 libs/;compileOnly 的依赖(包括 tech.muyan:api)不会打进去。

# 开发模式下通过 HTTP 导入插件

开发时可以不经过界面,直接把插件推送到本地后端:

./gradlew buildAndUploadMuyanPlugin   # 打包并导入整个插件
./gradlew hotReloadSourceJar          # 只替换插件的 jar 并重新加载插件
1
2

目标地址由 muyanPlatformPlugin 的 devHost 配置(默认 http://localhost:8080)。后端需要设置环境变量 PLATFORM_DEV_MODE=true 才会开放这些接口,未开启时返回 401 OperationInvalid。

安全提示

开发模式的上传、导入、替换 jar 接口(/dev/upload/part、/dev/plugin/import、/dev/plugin/jar/replace)不需要登录。开启 PLATFORM_DEV_MODE 后,任何能访问后端的人都可以上传并运行任意代码。只在本机开发环境中开启,生产环境严禁设置 PLATFORM_DEV_MODE。

Last Updated: 2026/9/24 14:27:35