Alex Chueng's blogs
©2022 Alex Chueng's blogs
BACK
DSL、解析器、可视化编辑器
23 min read
# 观点# 技术
View

这里讨论的 DSL 并不是真正意义上的 DSL,毕竟从新定义一门专用语言并不是本意,这里借用了 DSL 的概念,主要从 3 个方面来讲述个人对 DSL 的看法:

  1. DSL
  2. 解析器 -> 解析 DSL
  3. 可视化编辑器 -> 生成、编辑、存储 DSL

简述

DSL(domain specific language),是一种特定格式的描述配置。

可以作为 DSL 的格式:

  • JSON
  • protobuf
  • yaml
  • js
  • 自定义格式

DSL 和解析器:

  • typeORM: ORM 解析器,例如定义数据表:
import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";

@Entity()
export class User {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    firstName: string;

    @Column()
    lastName: string;

    @Column()
    age: number;

}
  • sails
  • React

个人理解, DSL 本质是一个特定格式的文件,需要一个解析器(parser)解析(parse)。

定制 DSL

如果要定制 DSL,我们需要准备相当的编译原理知识:

  1. 前端编译

    1. 预编译,就是文本替换
    2. 词法分析(lexical analysis),拆词
    3. 语法分析(parsing),分析各个词是否符合规则
    4. 语义分析(semantic analysis),分析各个词是否符合语法要求
    5. 产出中间代码,例如 AST
    6. clang 工具
  2. 后端编译

    1. 根据前端编译生成的 AST 转译成目标代码,这个过程叫 compile,交给 compiler 完成这部分工作
    2. 代码优化
    3. 生成目标代码
    4. LLVM 工具
  3. GCC 包括前后端编译工作

我相信发明一门专用的语言并不是项目需求,我们更需要一份约定俗成的配置描述。

对于前端项目,应该优先考虑将 json 格式来作为 DSL。如果用业界标准的 json schema 那样现成工具是很多的。

回到 js ,著名工具 babel 提供了 js 代码的转译(transform)功能,具体过程是 parse -> tramsform -> generate。如果需要特定格式的内容,可以使用 babel ,根据实际业务需求完成所需的转译工作。

这样会比重新发明语言要简单且稳定。

DSL 和解析器

那么如果我们可以将 DSL 看作是一个约定俗成的文件描述,事情会变得简单,我们只需要将数据结构设计好,然后再编写解析器即可。

由于 DSL 只是一份描述文件,本身并不具备任何执行能力。举个例子,例如使用 json 描述一个 selector 选择器组件:

{
  "type": "selector",
  "value": "Selector's value"
}

这份 json 格式的描述文件本身不具备任何能力,所以需要写一个解析器(parser)来解析:

import Selector from 'SelectorComponent'

import selectorDesc from './path-to-json'

const Parser {
  switcher => (config) {
    const { type, ...props } = config;
    switch(type) {
      case 'selector':
        return (
          <Selector {...props} />
        );
    }
  }
  render(config) {
    return (
      <div>
      	{
          this.switcher(config)
        }
      </div>
    )
  }
}

// 渲染
const p = new Parser();
p.render(selectorDesc);
// -> 页面输出,交给对应的页面渲染工具,例如 React VUE 或 angular

一个简易的配置描述 + 解析器大致完成。


但是这里有两个问题:

  1. 垂直扩展难度大
  2. 缺少系统运行时获取或更改系统状态的 hook,就是系统加载配置或者插件的生命周期回调

造成上述问题的是因为 json 是纯文本,并不具备与执行环境的交互能力,所以如果要监听 Selector 的某些回调事件,例如 onChange,并且加上一些特定的业务逻辑,需要在解析器做对应的功能,例如:

{
  "type": "selector",
  "value": "Selector's value",
  "onChange": "function(event) { ...logic }",// 组建事件
  "onLoaded": "function(event) { ...logic }" // 生命周期
}
const Parser {
  mounted = () => {
    // 系统运行状态的生命周期
  }
  switcher => (config) {
    const { type, ...props } = config;
    switch(type) {
      case 'selector':
        const { onChange } = config;
        return (
          <Selector {...props} 
    				// 这里做解析字符串操作
    				onChange={() => eval(onChange)} />
        );
    	default:
    		return <Other />
    }
  }
  render(config) {
		...
  }
}

// 渲染
const p = new Parser();
p.render(selectorDesc);
// ... 系统的其他操作
p.mounted(); // 执行对应的生命周期

可以使用 eval 或 Function 通过字符串构造交互函数,但是作用域必须小心处理。

这样的配置会有一个好处,就是与执行环境无关,但是对于前端,特别是浏览器环境来说,json 并不是很适合作为业务描述格式。

像通讯协议 protobuf 是一种全新的 DSL,由客户端和服务端各自做一遍 protobuf 解析


如果是 js 文件作为描述,就可以解决上述的两个问题:

// config
const selectorConfig = {
  "type": "selector",
  "value": "Selector's value",
  "onChange": function(event, state) { ...logic },// 组建事件
  "onLoaded": function(event, state) { ...logic } // 生命周期
}

export {
	selectorConfig
}

解析器部分只需要在适当的时机调用(call)配置中描述的函数,还可以将系统运行中的状态传入到回调中:

const Parser {
  state = {
    isMounted: false
  }
  mounted = (config) => {
    // 系统运行状态的生命周期
    this.setState({
      isMounted: true
    });
    config.forEach((item) => {
      item?.onLoaded({}, this.state);
    })
  }
  switcher => (config) {
    const { type, ...props } = config;
    switch(type) {
      case 'selector':
        return (
          // onChange 并不需要额外的代码来兼容
          <Selector {...props} />
        );
    }
  }
  render(config) {
	  ...
  }
}

这样会提高自定义 DSL 的表达能力。

系统横向与垂直能力

横向拓展是指再添加一个组件,横向扩展性是否好用是指是否很好的多添加一个组件,例如:

// config
const selectorConfig = {
  "type": "selector",
  "value": "Selector's value"
}
const radioConfig = {
  "type": "radio",
  "value": "Selector's value",
}

export {
	selectorConfig, radioConfig
}

垂直拓展是指其中深入控制一个组件,垂直扩展性意味着系统是否强大,以及足够灵活,例如:

// config
const selectorConfig = {
  "type": "selector",
  "value": "Selector's value",
  "onChange": function(event, state) { ...logic },// 组建事件
  "onLoaded": function(event, state) { ...logic } // 生命周期
}

export {
	selectorConfig
}

通过保证核心解析器核心能力,通过对 js 描述文件(DSL 概念)的解析,系统便有了易用性、灵活性、可扩展性。

Scaffold 实现(解析器 + 交互框架)

以上是个人对前端管理系统开发中的一些理解,过去根据上述思路构建出一个 Scaffold,用以解析业务描述文件(配置)。如果熟悉 react,还支持使用 react 的语法自定义页面。

以下是基于上述思路定义的配置文件(.js):

import React from "react";

import { ShowModal, CloseModal, TableRow } from "@deer-ui/core";
import { Services } from "@dashboard/services";
import { HOCReportRender } from "@dashboard/template-engine";
import { getTestData, keyFieldsForReport } from "@dashboard/mock-data/report-data";

class TestReportClass extends Services {
  propsForTable = {
    rowKey: (record) => record.avatar
  }

  state = {
    ...this.state
  };

  constructor(props) {
    super(props);

    this.conditionOptions = [
      {
        ref: 'ref1',
        tips: [123,321,222],
        type: 'radio',
        title: '单选控件',
        values: {
          value1: 'value1',
          value2: 'value2',
          value3: 'value3',
        }
      },
      {
        ref: 'input',
        type: 'input',
        title: '输入',
      },
      {
        ref: 'refSelector',
        type: 'select',
        title: '多选控件',
        isMultiple: true,
        isNum: true,
        defaultValue: [1, 2],
        values: {
          1: 'value1',
          2: 'value2',
          3: 'value3',
        }
      },
    ]

    this.columns = [
      ...this.getFields({
        names: keyFieldsForReport
      }),
      {
        key: "action",
        filter: (str, ...other) => this.getRecordBtns(...other)
      }
    ];

    this.templateOptions = {
      needCheck: true,
      checkedOverlay: (
        <div>
          <span className="btn theme">批量操作逻辑</span>
        </div>
      )
    };
  }

  // 与 HOCReportRender 模版对接的查询接口
  queryData = async (reportData) => {
    const postData = this.reportDataFilter(reportData);
    const agentOptions = {
      actingRef: "querying",
      after: this.reportAfter
    };
    await this.reqAgent(getTestData, agentOptions)(postData);
  };

  showDetail(item) {
    const ModalId = ShowModal({
      title: "详情",
      width: 700,
      children: <TableRow columns={this.columns} record={item} />
    });
  }

  // 与 HOCReportRender 模版对接的按钮接口
  recordActionBtns = [
    {
      text: "详情",
      id: "detail",
      action: (...args) => {
        this.showDetail(...args);
      }
    }
  ];
}

const TestReport = HOCReportRender(TestReportClass);

export default TestReport;

这份 js 文件配置,主要还是由前端组员编写。


那这里进一步做拓展,将这份文件继续抽离成几个部分:

  1. 组件引入描述
  2. 组件配置描述
  3. 页面中的组件布局描述
  4. 组件的行为描述
  5. 页面被加载的生命周期描述

特定区域抽离成为可视化编辑工具的选项,例如将查询条件区域抽离,查询条件中的每一个组件的 props 抽离,提供可执行代码(这里需要注意运行时作用域安全,需要沙箱技术),按钮摆放、事件抽离等。

可视化编辑器

可视化编辑器其实就是在不断操作这份 DSL 数据,提供可视化界面,屏蔽技术细节。

这里有一些个人对于项目进度的规划想法,将系统分为两个部分:

  1. DSL 标准定义和核心解析器

    1. DSL 建议使用 js
    2. UI 配置
    3. UI 回调
    4. 系统生命周期回调
    5. 解析器
    6. DSL 解析
    7. 系统运行时生命周期回调
  2. 可视化编辑引擎,可视化生成 DSL 并提供存储

    1. 编辑器部分内容
    2. 舞台 stage,编辑器的中心
    3. 元素 element,最小的 UI 元素
    4. 组件 component,由 element 构成的处理特定用户交互的控件
    5. 容器 container,记录和展示组件之间的布局关系
    6. 页面 page,由多个 container 之间的布局组成的页面
    7. props 编辑器,用于编辑元素或组件的表现形式
    8. 数据存储部分
    9. 项目

      • 页面

        • 组件
        • 元素

参考项目