# 概述

动态插件调用是FizzGate提供的独特能力,主要解决了以下两个问题:

1、动态热插拔插件: 允许开发者自行开发和选择市场提供的插件,实现动态热插拔,不影响节点功能。

2、ClassLoader互相隔离: 插件与节点之间以及插件之间使用ClassLoader进行互相隔离,确保即使使用不同版本的API也能兼容。

FizzGate团队与阿里Sofa团队进行了深度合作,充分利用了Sofa的隔离技术。这种合作不仅满足了Sofa在应用场景和社区反馈能力方面的需求,还充分发挥了Sofa的Serverless能力,从而显著提升了FizzGate的整体能力。

动态插件的来源主要有两个途径:

1、自行利用模板进行二次开发。

2、从官方市场下载插件。

# 动态插件开发

# 动态插件开发

下载sample代码,https://gitee.com/fizzgate/fizz-dynamic-plugin

以下是一个插件示例的主要代码:

package com.fizzgate.plugin.extension;

import com.alipay.sofa.runtime.api.annotation.SofaService;
import com.alipay.sofa.runtime.api.annotation.SofaServiceBinding;
import com.fizzgate.plugin.FizzPluginFilter;
import com.fizzgate.plugin.FizzPluginFilterChain;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.Map;

@SofaService(uniqueId = LogPlugin.PLUGIN_ID, bindings = {@SofaServiceBinding(serialize = false)})
@Component
public class LogPlugin implements FizzPluginFilter {
    public static final String PLUGIN_ID = "logPlugin"; // 插件 id

    public void init(String pluginConfig) {
        FizzPluginFilter.super.init(pluginConfig);
    }

    public Mono<Void> filter(ServerWebExchange exchange, Map<String, Object> config) {
        System.err.println("this is my plugin"); // 本插件只输出这个
        return FizzPluginFilterChain.next(exchange); // 执行后续逻辑
    }
}

上述代码中,@SofaService 注解声明了该组件为动态组件,需要暴露服务能力给节点调用。

需要注意的是,uniqueId 必须与插件后台配置一致。其他开发细节可以参考静态插件开发方式。

# 动态插件打包

使用打包命令进行编译

mvn clean package -DskipTests

组件打包之后会在target目录下载生成对应两个的jar包。其中一个jar包以{name}-ark-biz.jar为名字,在上传动态插件时,需要使用这个jar包。

# 动态插件上传

在后台配置中,需要进行以下步骤:

  • 点击扩展中心,然后点击新增;

  • 编辑插件相关信息,确保插件名与 uniqueId 一致;

  • 上传动态插件文件;

  • 点击保存。

这些步骤能够确保插件被成功配置并能够在系统中使用。

# 动态组件开发

下载sample代码,https://gitee.com/fizzgate/fizz-dynamic-plugin,可以在组件中找到动态组件fizz-node-mysql的样例。

# 动态组件开发

# 编写节点逻辑

所有可执行的节点逻辑需要继承 com.fizzgate.aggregate.core.flow.Node 或者 comcom.fizzgate.aggregate.web.flow.RPCNode 。该接口定义了节点执行逻辑,需要实现 singleRun 方法。

public abstract Mono<NodeResponse> singleRun();

RPCNode继承了Node,因此可以对Node进行RPC调用的通用扩展。fizz-node-mysql的MysqlNode对进行的RPC调用进行了封装,实现了RPCNode。除此之外,还需实现NodeConfig或者RPCNodeConfig,用于配置节点参数。MysqlNodeConfig对界面参数的配置进行了封装。为了是的该组件在注册之后第一时间启用,项目中编写了 MysqlComponentAutoConfiguration.class,该类继承了ComponentAutoConfiguration,实现了组件的注册。

@Configuration
public class MysqlComponentAutoConfiguration {

    private static final Logger LOGGER = LoggerFactory.getLogger(MysqlComponentAutoConfiguration.class);

    public MysqlComponentAutoConfiguration(){
        if (NodeFactory.hasBuilder(MysqlNode.TYPE)){
            NodeFactory.unRegisterBuilder(MysqlNode.TYPE);
            LOGGER.info("do have component mysql , will replace it");
        }
        LOGGER.info("register component type:{}",MysqlNode.TYPE);
        NodeFactory.registerBuilder(MysqlNode.TYPE, new MysqlNode.MysqlNodeBuilder());
    }
}

因为节点并没有数据库的pom引用,所以该项目需要自行引入的mysql的pom支持。

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.33</version>
    <scope>runtime</scope>
</dependency>

# 编译打包

使用maven进行编译打包,其中一个jar包以{name}-ark-biz.jar为名字,在上传动态组件时,需要使用这个jar包。

mvn clean package -DskipTests

# 编写前端组件

下载sample代码,https://gitee.com/fizzgate/fizz-frontend-node 在modules目录中找到fizz-node-mysql的样例。 可以在src/views找到两个文件:Home.vue和Node.vue。Home.vue是节点代码,Mysql.vue是节点弹窗代码。节点代码主要是展示节点信息,节点弹窗代码主要对节点进行编辑和保存。

Home.vue

<template>
  <div>
    <div class="node-request-head">MYSQL节点ID{{model.id}}</div>
    <div class="node-request-body">
    <div>
      {{model.properties.serviceName ? "服务名:"+model.properties.serviceName: ""}}
    </div>
    <div>
      {{model.properties.path ? "路径:"+model.properties.path: ""}}
    </div>
    </div>
    <div class="node-request-footer">
      <span v-if="model.properties.type" class='node-request-logo'>
        {{ model.properties.type }}
      </span>
        
      <span @click="onComponentClick">组件:{{componentCount}}</span>
    </div>
  </div>
  
</template>

<script>
export default {
  name:"home",
  props: {
      model:{
        type: Object,
        default: () => ({
          id:"",
          properties:{
            components:[]
          }
        })
      },
      graphModel:{
        type: Object
      }
  },
  data () {
    return {
    }
  },
  methods:{
    onComponentClick(event){
      window.event? window.event.cancelBubble = true : event.stopPropagation();
      const { graphModel, model } = this.$props;
      const data = model.getData();
      graphModel.eventCenter.emit("node:components:click", 
        {
          target:graphModel,
          model:data
        }
      );
      return false;
    }
  },
  computed:{
    componentCount(){
      return this.model.properties.components ? this.model.properties.components.length: 0;
    }
  },
  mounted () {
  },
  watch: {
  }
}
</script>
<style scoped>

</style>

Node.vue

<template>
  <el-form ref="requestForm" :rules="rules" :model="requestForm"  size="small"
          label-width="110px" >    
          <el-form-item label="节点名称" prop="name" key="name">
            <el-input v-model="requestForm.name"
                placeholder="节点名称"></el-input>
          </el-form-item>
        
      <el-form-item label="连接地址" prop="URL" key="URL">
              <el-input v-model="requestForm.URL" clearable></el-input>
              <span class="key-tips">数据库链接地址,如:r2dbcs:mysql://root:password@localhost:3306/archer?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&serverTimezone=GMT%2B8&nullCatalogMeansCurrent=true&allowPublicKeyRetrieval=true</span>
      </el-form-item>
      <el-form-item label="查询数据SQL" prop="sql" key="sql">
              <el-input type="textarea" v-model="requestForm.sql" clearable></el-input>
              <span class="key-tips">示例:Select dd* from users 请勿以分号结尾</span>
      </el-form-item>
      <el-form-item label="绑定参数" prop="binds" key="binds">
              <el-input v-model="requestForm.binds" clearable></el-input>
              <span class="key-tips">输入使用JSON{"id":"1"}</span>
      </el-form-item>
      <footer class="drawer-footer">
      <el-button size="small" type="primary" @click="submitForm()" v-if="!disabled && dialogType !== 'detail'">
        确 定
      </el-button>
      <el-button size="small" @click="onCancel">
        {{!disabled && dialogType !== 'detail' ? ' 取 消' : '关 闭'}}
      </el-button>
    </footer>
  </el-form>
</template>
<script>
export default {
  name: 'node',
  props: {
      model:{
        type: Object,
        default: () => ({
          id:"",
          properties:{
            components:[]
          }
        })
      },
      lf:{
        type: Object
      },
      graphModel:{
        type: Object
      },
      closeDialog:{
        type: Function
      }
  },
  data() {
    return {
      rules: {
        URL: [
          { required: true, message: 'URL是必填', trigger: 'change' }
        ],
        sql: [
          { required: true, message: '必填', trigger: 'change' },
        ],
        binds: [
          { required: true, message: '必填', trigger: 'change' }
        ],
        'fallback.defaultResult': [
          {
            validator: (rule, value, callback) => {
              if (value && !validateJson(value)) {
                callback(new Error('请输入正确格式的JSON'));
              } else {
                callback();
              }
            }, trigger: 'blur'
          }
        ]
      },
      disabled:false,
      dialogType:'create',
      requestForm: {
          URL:"",
          sql:"",
          binds:""
      }
    }
  },
  created(){
        const { properties, id} = this.model.getData();
        this.requestForm = {...properties, name:id};
        
  },
  methods: {
    submitForm() {
      this.$refs.requestForm && this.$refs.requestForm.validate().then(() => {
        const nodeData = this.model.getData();
        const {name, ...properties} =  this.$data.requestForm 
        this.lf.setProperties(this.model.id, properties);
        const closeDialog = this.closeDialog;
        if (closeDialog){
          closeDialog();
        }
        
      }).catch(() => {
        this.$message.error('请完善步骤信息');
      })
   
    },

    onCancel(){
      const closeDialog = this.closeDialog;
      if (closeDialog){
        closeDialog();
      }
    }
  }

}
</script>

除此之外,还需要注意编辑器工具栏图标信息,该文件位置在src/public/dynamic.json

{
    "name":"mysql",
    "entry": "//localhost:1890",
    "nodeSize":{
        "width":"140", 
        "height":"80"
    },
    "panelItem": {
        "text": "mysql",
        "type": "mysql",
        "class": "node-mysql",
        "style": "background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAAH6ji2bAAAABGdBTUEAALGPC/xhBQAAAOVJREFUOBGtVMENwzAIjKP++2026ETdpv10iy7WFbqFyyW6GBywLCv5gI+Dw2Bluj1znuSjhb99Gkn6QILDY2imo60p8nsnc9bEo3+QJ+AKHfMdZHnl78wyTnyHZD53Zzx73MRSgYvnqgCUHj6gwdck7Zsp1VOrz0Uz8NbKunzAW+Gu4fYW28bUYutYlzSa7B84Fh7d1kjLwhcSdYAYrdkMQVpsBr5XgDGuXwQfQr0y9zwLda+DUYXLaGKdd2ZTtvbolaO87pdo24hP7ov16N0zArH1ur3iwJpXxm+v7oAJNR4JEP8DoAuSFEkYH7cAAAAASUVORK5CYII=');background-size: cover;}"
    }
}

# 编译打包

使用vue编译打包命令进行打包

npm run build

项目会在dist 目录下生成一个dist目录,里面就是打包好的文件,最后自行在dist目录下将所有的文件压缩为zip包(请注意需要进入dist目录下进行压缩)。后台配置中管理端包上传该zip包即可。