Skip to main content

2 posts tagged with "lcap"

View All Tags

· 32 min read
赵雨森

一、什么是 Language Server?

Language Server 是市面上常见的 IDE 针对某一门语言提供的错误检查(或叫错误诊断,通常包括语法、语义和建议)、自动补全、查找引用、跳转位置、重命名等功能的服务,它通常集成在 IDE 的这门语言插件中,以独立的本地进程方式运行。

IDE 中 Language Server 的示例

这个概念最早是由微软的 VSCode 团队提出的,是为了支持语言检查等功能的开发语言与 IDE 本身的开发语言的独立,以及 CPU 和内存资源与 IDE 主进程的隔离。同时为了解决语言生态中多种语言插件与多个 IDE 集成的实现成本M * N的问题,他们制定了 Language Server Protocol,标准化了语言工具和代码编辑器之间的通信。目前已经有 VScode、Eclipse、Atom 等多个 IDE 的广泛支持。

NO LSP vs LSP

目前该协议已经规定了多种常用语言功能的标准,主要包括:

  • Diagnostics:错误检查(或叫错误诊断,通常包括语法、语义和建议)
  • Completion:自动补全
  • Find References:查找引用
  • Goto Definition/Goto Type Definition/Goto Implementation:跳转定义或实现
  • Rename/Prepare Rename:重命名相关
  • Prepare Call Hierarchy/Call Hierarchy Incoming/Call Hierarchy Outgoing:列举调用栈
  • Code Action/Refactor:代码重构,比如把几条语句提取成另一个函数
  • Hover:悬浮提示
  • Signature Help:函数签名提示
  • Document Symbol:获取文档中所有符号
  • Document Highlight:获取高亮范围
  • Formatting/Range Formatting/On Type Formatting:格式化相关
  • Folding Range/Selection Range:获取代码展开/选中范围

二、CodeWave 智能开发平台中的 NASL 和 Language Server

许多零代码/低代码平台的核心部分实现是以可视化编辑器来读写一份 Schema(一般是 JSON 形式的配置),CodeWave 智能开发平台也不例外。

编辑 Schema

不同的是,为了满足用户能够搭建复杂的企业级应用,灵活表达业务逻辑需求,CodeWave 智能开发平台引入了表达式、控制流等常见的通用语言能力;为了打通各种数据库、外部接口数据模型和目标语言 JS 和 Java 代码的类型体系,CodeWave 智能开发平台设计了统一的静态类型系统。根据以上两点,CodeWave 智能开发平台的 Schema 已经具备了许多通用编程语言特性,可以被称为是一门编程语言,我们取名叫 NASL(NetEase Application Specific Language),全称的意思是:网易的搭建 Web 应用的专用语言。

不过,用户在CodeWave 智能开发平台中使用 NASL 灵活强大功能的同时,也容易像通用编程语言一样产生错误,比如:

  • 赋值左右类型不一致,生成 Java 代码后编译会报错;
  • 调用实体 create 逻辑时,传入的不是这种实体实例也明显会出问题;
  • 前端组件双向绑定填的是一个计算表达式,生成如v-model="a + 2"的 Vue 代码会报错;

所以产品需要提供更多排查错误、约束且友好的提示以及其他辅助功能。也就是上面提到的 Language Server 的主要功能。

CodeWave 智能开发平台示例

以建设一门语言的思路,我们把这部分工作归结为 NASL Language Server 的建设,这部分也是低代码基础设施的核心重点和难点。

三、NASL 需要的语言特性

在讲 NASL Language Server 怎么建设之前,先介绍一下 NASL 需要的语言特性。

因为要生成 JS 和 Java 两套代码,NASL 语言特性的设计主要参考了 JavaScript、TypeScript 和 Java,辅助参考了 Scala、Kotlin、Python 等其他语言。

1. 统一的静态类型系统

NASL 是一门静态类型语言,类型分为三类:

  • 原子类型:Boolean、Integer、Long、Double、String、Date、Time、DateTime 等
  • 复合类型:结构体和枚举
  • 泛型类型:带参数的结构体,如 List<T>Map<K, V>

2. 变量定义和逻辑定义

CodeWave 智能开发平台中的逻辑就是通用编程语言中的函数,包括输入参数、返回值和函数体。

3. 表达式

  • 一元/二元表达式,包括常见的算术运算、逻辑运算、比较运算等
  • 成员表达式,取某个复合类型变量的属性
  • 赋值
  • 调用逻辑

4. 控制流

  • 顺序执行
  • If 条件分支和 Switch 选择分支
  • ForEach 循环分支和 While 循环分支

5. 命名空间

用户在定义实体、数据结构和枚举这些复合类型时可能有重名的情况,另一方面如果在复杂应用中定义的复合类型都是全局的,管理起来也很不方便。所以类似 Java 的 package 和 TypeScript 的 namespace, NASL 引入了应用内的命名空间概念。


以上是一些基本语言特性,除了泛型,总体类似 C 语言特性范围。下面是一些高级语言特性,如函数式编程、有限的面向对象编程等等。

6. 泛型函数

在提供了泛型类型List<T>之后,就必须要提供列表操作相应的内置函数库。

典型的场景是添加和删除列表项:

declare function Add<T>(list: List<T>, item: T): void;
declare function Remove<T>(list: List<T>, item: T): void;
...

7. 函数重载

在一些场景不想让用户学习因支持多种类型而产生的多个内置函数,就需要引入重载。

declare function AddDays(dateTime: Date, amount: Integer): Date;
declare function AddDays(dateTime: DateTime, amount: Integer): DateTime;
declare function Convert<T extends Boolean | Double | Long | String>(value: Integer): T;
declare function Convert<T extends DateTime | String>(value: Date): T;
...

再比如前端选择框单选/多选场景中的 value 类型不同,也需要重载的支持:

export class Select<T> extends Component {
constructor(
public options?: {
// 属性
size?: 'mini' | 'small' | 'normal' | 'large',
multiple?: false,
value?: string,
...
},
);
constructor(
public options?: {
// 属性
size?: 'mini' | 'small' | 'normal' | 'large',
multiple?: true,
value?: Array<string>,
...
},
);
...
}

8. 运算符重载

比如字符串直接用+拼接其他类型变量很方便,是一种典型的运算符重载。

declare function add(left: String, right: Any): String;
declare function add(left: Any, right: String): String;
...

9. 函数式编程

有了基础的列表增删改查操作之后,用户在处理排序、查找、过滤等列表操作时,还是要结合 If、ForEach 自己实现,和平时写代码的效率仍有明显差距。

而这些 API 在通用语言中一般都需要函数式编程的支持,比如下面这些例子:

declare function ListSort<T>(list: List<T>, by: (item: T) => Any, asc: Boolean): void;
declare function ListFind<T>(list: List<T>, by: (item: T) => Boolean): T;
declare function ListFindAll<T>(list: List<T>, by: (item: T) => Boolean): List<T>;
...

另一个典型的例子就是组件中的回调函数和事件绑定:

export class Select<T> extends Component {
constructor(
public options?: {
// 属性
dataSource?: List<T> | ((params: DataSourceParams) => Promise<List<T>>),
...
},
);
...
addEventListener(event: 'click', listener: (e: MouseEvent) => void);
addEventListener(event: 'change', listener: (e: ChangeEvent) => void);
}

const select = new Select({
dataSource: this.load,
});
select.addEventListener('change', this.onChange);

10. 面向对象继承

后面需要支持数据元管理,比如用户可以配置出 Email、URL、IDCard 等类型。他们显然都是 String 的子类型:

class Email extends String {
constructor(value: String);

@nasl.annotation.Rules([
pattern(/^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/i),
])
validate(value: String): boolean;
}

class URL extends String {
constructor(value: String);

@nasl.annotation.Rules([
pattern(/^https?:\/\/(([a-zA-Z0-9_-])+(\.)?)*(:\d+)?(\/((\.)?(\?)?=?&?[a-zA-Z0-9_-](\?)?)*)*$/i),
])
validate(value: String): boolean;
}

class IDCard extends String {
constructor(value: String);

@nasl.annotation.Rules([
pattern(/^[1-9]d{5}(18|19|20|(3d))d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)d{3}[0-9Xx]$/),
])
validate(value: String): boolean;
}

这里用继承的好处是能方便的明确父子类型关系。比如在赋值和传递参数等场景下,子类型可以直接给父类型,但反过来则需要显式转换:

let email: Email = new Email();
let str: String;

str = email; // 直接赋值
email = Convert<Email>(str); // 反过来显式转换

11. 泛型型变(协变与逆变)

那么既然有了父子类型,又有泛型,就会产生泛型型变(协变与逆变)的问题。

协变(covariant)表示与泛型参数 T 的变化相同,而逆变(contravariant)表示与泛型参数 T 的变化相反。

一般来说,对于只有读操作的函数,我们希望函数参数兼容的类型更广一些,用协变;对于只有写操作的函数,函数体操作更泛化的类型容易出问题,用逆变。

function main() {
let stringList: List<String> = [new String('abc'), new String('小明')];
let emailList: List<Email> = [new Email('zhao@163.com'), new Email('hztest@corp.netease.com')];

printStringList(stringList); // 没有问题
printStringList(emailList); // 希望协变
printEmailList(emailList); // 没有问题
printEmailList(stringList); // 需要限制

addStringItem(stringList); // 没有问题
addStringItem(emailList); // 会有安全问题,email 列表中会加入普通 String
addEmailItem(emailList); // 没有问题
addEmailItem(stringList); // 希望逆变
}

function printStringList(list: List<String>) {
list.forEach((item) => console.log(item));
}

function printEmailList(list: List<Email>) {
list.forEach((item) => console.log(item));
}

function addStringItem(list: List<String>) {
list.add(new String('def'));
}

function addEmailItem(list: List<Email>) {
list.add(new Email('forrest@126.com'));
}

12. 类型操作器

在前面的组件的例子中,我们想从配置的数据源中,获取其中的 item 类型。用 TypeScript 表示如下:

declare function load(params: DataSourceParams): Promise<List<Student>>;

type GetItemTypeFromDataSource<T> = T extends List<infer U> | ((...args: any) => List<infer U>) ? U : never;

type ItemType = GetItemFromDataSource<typeof load>; // Student

13. 其他

还有许多其他局部特性,就不在这里一一列举了。

可能这里有同学会问,你们不是做的低代码吗,为什么要引入这么多高级语言特性,会不会增加用户的学习成本和复杂度?

答案是否定的。一方面,引入的语言特性会以三种形式在低代码中体现:

  • 全部暴露让用户定义和使用,如上面的复合类型和逻辑;
  • 只让用户使用,定义是由低代码的内置库提供的,如上面的泛型类型、泛型函数;
  • 作为语言的底层设施,用户不会直接接触到该概念,但在可视化交互中能体会到或者完全感知不到,如数据查询的语句链路等。

所以用户接触到高级语言特性的入口一定是我们简化过的

另一方面,恰恰相反的是,用户在搭建复杂应用中,表达复杂需求用合适的高级语言特性才会更简单。假设在内置库都预置好的情况下,让你用 C 语言写一段 Web 应用的复杂逻辑,和用成熟的如 TypeScript 的高级语言相比,哪个更简单?

四、Language Server 的建设路线

自研路线

首先能想到的方案是自研一个 Language Server。与通用编程语言类似(通用编程语言的这部分功能一般是实现在编译器中),基本思路如下:

  1. 首先,Language Server 的输入是 NASL AST,输出是错误检查、自动补全等信息;
  2. 在初次拿到 AST 后,Language Server 会生成符号表和作用域等相关缓存数据;
  3. 接下来就是有多少语言特性,就要投入多少成本,比如上面列举的基础表达式、函数式编程、型变、重载等诸多特性;
  4. 最后整理成最终的错误检查、自动补全等信息;
  5. 另外还需要处理 AST 增量修改的场景。

自研路线

可以看出,主要实现成本集中在第 3 点。这里一方面高级语言特性本身比较复杂,另一方面语言特性之间往往不是独立的。比如:

  • 泛型和继承,需要把型变的问题处理妥当;
  • 泛型和函数式编程,需要把泛型函数、泛型类型的函数字段等一系列问题搞定;
  • 重载和函数式编程,需要计算函数实参的类型、判断重载到哪个函数里(比如前面的绑定事件例子);再遇上前面的泛型和继承,就很酸爽了;

也就是说,随着更多语言特性的引入,实现成本会陡然上升。不亚于研发一门通用编程语言的成本(一般综合编译器前后端和内置库,大约30-60人左右)。

自研路线成本

那么这种路线成本这么高,还有没有别的方案了?

宿主语言路线

有。另一种路线是以成熟的通用编程语言为宿主,借助它的 Language Server 来实现我们的 NASL Language Server。

什么是宿主语言?一般在提到内部 DSL(Embedded DSL 或 Internal DSL)时,我们会关心它是建立在哪一门通用编程语言之上的。比如远古框架 jQuery:

$('#user_panel')
.click(hidePanel)
.slideDown()
.find('button')
.html('follow');

它的语义是:

  1. 获取 #user_panel 节点(jQuery 实例);
  2. 设置点击后隐藏它(传入函数);
  3. 向下动效展开(调用函数);
  4. 然后找到它下面的所有 button 节点(jQuery 实例);
  5. 为这些按钮填充 follow 内容(字符串)。

一般来说,内部 DSL 的语法和基础语义是宿主语言的子集,可以共享宿主语言的编译与调试工具等基础设施。

因此,如果 NASL 是某一门语言的子集,就可以以该语言为宿主语言,借用它的 Language Server 设施。那么就有以下一条路线:

  1. NASL AST 翻译成这门宿主语言的代码 + SourceMap;
  2. 在 AST 初始化和变更时,捕获宿主语言的错误检查、自动补全等信息(带有位置信息);
  3. 适配层根据位置信息,结合 SourceMap 在 NASL AST 中找到原来的节点;
  4. 根据找到节点的上下文,再补充成最终的错误检查、自动补全信息等。(这部分不需要关心语言特性的处理,只需关注 NASL 节点上下文补充信息即可)

宿主语言路线

可以看出,这条路线的后期成本肯定会降低。但前期未知性的问题较多:

  1. 首先,NASL 吸收了 JS/Java 的各种特性,是不是某一门语言的子集了?
  2. 前期处于迷雾状态,不知道有多少坑,实现成本不明朗。
  3. 这种实现是黑盒模型,后面会不会遇到不可持续迭代的问题?比如:
    • 吐出的原始信息够不够我们用?
    • 我们想做类 SQL 的语义,能不能支持?
    • 文本式的语言能力能不能满足我们可视化场景的需求?
    • ...

宿主语言路线成本

宿主语言的选择

首先针对第 1 个问题,我们调研了许多语言,最主要的是 Java、TypeScript 和 Scala。就语言特性方面来说:Java < TypeScript < Scala。

以它们为宿主语言的主要问题是:

  • Java:不能 hold 住许多前端泛型组件的场景,也没有灵活的类型操作器;
  • TypeScript:数值类型只有 number 类型和 Java 的 Integer/Long/Double 多种类型设计有出入,Structural Typing 和 Java 的 Nominal Typing 有出入;
  • Scala:团队技术主要的体系不在这一块,维护成本较高,招聘门槛高。

相对来说,TypeScript 的问题较轻,需要尝试看能不能用一些技术手段规避掉。

另外,根据我们对 TypeScript Playground 和 Monaco Editor 的观察,TypeScript Language Server 还有一个明显的优势,就是能以 Worker 的形式在浏览器中运行。因为CodeWave 智能开发平台是 Web IDE,对 Language Server 实时性要求很高,放在浏览器端可以大大减轻服务器资源。

TypeScript Playground

五、基于 TypeScript 宿主语言的核心实现

TypeScript 所有的语言设施其实就在我们平时使用的node_modules/typescript/lib下:

TypeScript 目录

它主要有以下设施:

  • typescript.js:TypeScript 核心包,就是编译器 API,可以在 JS 引入,然后解析和编译一段 TypeScript 文本;

  • tsc.js:TypeScript Compiler,就是我们平时经常使用的编译成 JS 的命令行;

  • typescriptServices.js: TypeScript Services,提供较多的语言服务 API,用于 IDE 的插件开发,比如 Monaco Editor 和 VSCode 内置的 TypeScript 插件都基于这个文件包装;

  • tsserver.js: TypeScript Language Server,可独立运行的服务器,stdin/stdout JSON 形式的协议,支持 Node.js 和浏览器。

这些在 TypeScript Wiki 讲得比较清楚。

以 TypeScript 核心包为突破口

最简单的是 TypeScript 核心包,官方示例比较详细,它在编译的时候,能直接返回错误检查信息,我们就以它为突破口,打通链路,驱散迷雾。

首先,列举了几个不同难度的场景:1. 基本逻辑;2. 数据查询;3. 外部 SQL;4. 页面组件。

1、将这几个场景的 NASL AST 翻译成 TypeScript + SourceMap。

比如这样一段简单的包含 If 逻辑的 NASL AST:

{
"concept": "Logic",
"name": "logic1",
"params": [
{
"concept": "Param",
"name": "param1",
"typeAnnotation": {
"concept": "TypeAnnotation",
"typeKind": "primitive",
"typeNamespace": "nasl.core",
"typeName": "Integer",
"typeArguments": null
}
}
],
"returns": [],
"variables": [],
"body": [
{
"concept": "Start",
"label": "开始"
},
{
"concept": "IfStatement",
"label": "条件分支",
"folded": false,
"test": {
"concept": "BinaryExpression",
"left": {
"concept": "Identifier",
"name": "param1"
},
"right": {
"concept": "NumericLiteral",
"value": "3",
"typeAnnotation": {
"concept": "TypeAnnotation",
"typeNamespace": "nasl.core",
"typeName": "Integer",
"typeArguments": null
}
},
"operator": ">"
},
"consequent": [],
"alternate": []
},
{
"concept": "End",
"label": "结束",
"folded": false
}
],
"playground": []
}

翻译成 TypeScript 文件 /embedded/someApp/logics/logic1.ts 如下:

namespace app.logics {
export function logic1(param1: nasl.core.String) {
if (param1 > new nasl.core.Integer(3)) {
}
return;
}
}

SourceMap 的结构是 Map<BaseNode, { code: string, range: Range }>

比如上面 name=param1 的 Identifier 的 SourceMap 是:

{
code: 'param1',
range: {
start: { line: 3, character: 12, offset: 102 },
end: { line: 3, character: 18, offset: 108 },
}
}

而 BinaryExpression 的 SourceMap 是:

{
code: 'param1 > new nasl.core.Integer(3)',
range: {
start: { line: 3, character: 12, offset: 102 },
end: { line: 3, character: 45, offset: 135 },
}
}

2、这时将翻译好的 TypeScript 代码丢进核心包,它会报一条错误和相应的位置。

Operator '>' cannot be applied to types 'String' and 'Integer'. { "start": { "line": 3, "character": 12 }, "end": { "line": 3, "character": 45 } }

3、接下来从 SourceMap 所有点中,找到最接近错误信息的位置的节点 findClosestNode。上面例子中即为 BinaryExpression 那个节点。

4、实现 translator 函数,用正则表达式等方式,将上面的英文转换成产品所想展示的错误信息。根据节点信息,红框并显示上下文,比如所在逻辑为 logic1。最终效果如下:

低代码中的效果

实现了针对上述场景的几个 Demo,就基本验证了很多问题:

  • 前期实现成本不高;
  • 吐出的原始信息基本够用;
  • TypeScript 能处理 Java 语言特性的问题

最后一个点,是这样处理的:

  1. 干脆不用 TypeScript 的原生类型,自己用 class 定义 NASL 需要的基本类型:
export class Integer {
accept: 'Integer';
constructor(num?: number);
}

export class Double {
accept: 'Double' | 'Integer' | 'Long';
constructor(num?: number);
}

export class Long {
accept: 'Double' | 'Integer';
constructor(num?: number);
}
  1. 在 Structural Typing 下用唯一字段__name来模拟 Nominal Typing:
namespace app.dataSources.defaultDS.entities {
@nasl.annotation.Entity()
export class Student {
__name: 'app.dataSources.defaultDS.entities.Student';
id: nasl.core.Long;
...
}
}
  1. TypeScript 中没有运算符重载,干脆不用普通的+-*/,直接用函数重载来模拟:
declare function add(left: Integer, right: Integer): Integer;
declare function add(left: Integer, right: Long): Long;
declare function add(left: Integer, right: Double): Double;

declare function minus(left: Integer, right: Integer): Integer;
declare function minus(left: Integer, right: Long): Long;
declare function minus(left: Integer, right: Double): Double;

declare function multiply(left: Integer, right: Integer): Integer;
declare function multiply(left: Integer, right: Long): Long;
declare function multiply(left: Integer, right: Double): Double;

declare function divide(left: Integer, right: Integer): Integer;
declare function divide(left: Integer, right: Long): Long;
declare function divide(left: Integer, right: Double): Double;

攻坚 Language Server 全部能力

上面的 Demo 只有错误检查功能,其他的 Language Server 能力是集成在 typescriptServices.js 和 tsserver.js 中的。接下来就需要实现完整的 Language Server 能力。

目前我们在各种渠道发现有 4 个项目中是比较成熟地使用了 TypeScript Language Server 的大部分能力,所有我们从这些项目入手攻坚:

1. MonacoEditor

TypeScript Playground 就是基于它实现的,MonacoEditor 官网也有许多 TypeScript Demo。从实现效果来说和我们想要的最接近。

我们需要做的是,找出 MonacoEditor 中封装 Language Server 能力的入口,然后最好能拆出来。因为一个 MonacoEditor 很庞大,包含了许多其他我们并不需要的编辑器功能。

2. VSCode 内置的 TypeScript 插件

直接进攻 VSCode 编辑器也是很好的一个方向,VSCode 项目本身是开源的,也有许多编写插件的示例。

但它是 Node.js 环境,VSCode 中包装了许多 vscode-language-client, vscode-language-server 等 package,要拆解起来还是有一定的复杂度。

3. tsserver

node_modules/typescript/lib 下 tsserver 可以直接启动,而有个入口文档,与 TypeScript 核心包一脉相承。

可以用命令行启动的 tsserver,以 stdin/stdout JSON 形式的协议进行通信,同时支持 Node.js 和浏览器。但语言协议是 TSP(TypeScript Server Protocol)协议,非标准的 LSP 协议。

4. 社区提供的标准 LSP 协议 TypeScript Language Server

是在 3 的基础上包装了 LSP 协议。


经过一段时日的研究,我们发现 2 中 VSCode 包装的成本太高。4 是 3 的包装版。最终我们重点研究 1 和 3。

测评下来,在 240 个 class(实体 + 枚举 + 数据结构)+ 240 个 logic + 80 个 view = 560 个 ts 文件的情况下:

MonacoEditortsserver
启动时全量检查75s2.6s
单文件小修改1s1.3s
修改一个被大量引用的文件x1.3s
多个文件同时发生修改x1.2s
平均占用内存440MB160MB

MonacoEditor 直接集成进来性能有较大问题。最终我们选择离核心包最接近的 tsserver。

包装 ts-worker

Language Server 想在浏览器中跑,并且不阻塞用户界面主线程,就需要利用 Web Worker 新开线程。JavaScript 是单线程的,Web Worker 是浏览器提供的一种新开线程运行脚本的技术,Worker 线程可以在不阻塞用户界面的情况下执行任务。它和主线程主要用 postMessage 通信。

这部分不是很复杂,我们基于 postMessage 包装了一套 Messager,然后直接在 Worker 内外实例化:

// ts-worker 中
const messager = new Messager({
protocol: 'ts-worker',
sender: 'worker',
context: this,
getReceiver: () => self,
getSender: () => self,
});

// 外部
const messager = new Messager({
protocol: 'ts-worker',
sender: 'ide',
context: this,
getReceiver: () => worker as any,
getSender: () => worker as any,
handleMessage({ data }: any) {
if (data && data.event === 'publishDiagnostics') {
diagnosticManager.pushAll(naslServer._resolveDiagnosticRecords(data.records));
}
},
});

最终架构

最终实现的架构如下:

  • 可视化编辑器和 NASL Language Server 均在浏览器中运行;
  • TS Language Server 以 Worker 的形式运行;
  • NASL Language Server 中的 Adapter 计算量不大,并且和可视化编辑器是共享内存的,所以暂时没有切出的必要。

使用时的数据流向是:

  1. 用户在可视化编辑器初次全量加载/实时变更编辑 NASL;
  2. 可视化编辑器初次全量/实时差量生成 TS 代码,并请求 NASL Language Server;
  3. NASL Language Server 的 Adapter 向 TS Language Server(Worker)请求信息;
  4. Adapter 通过上下文整理出最终信息,返回给可视化编辑器。

Node.js 同构 NASL Language Server 备选

考虑到在浏览器中运行 Language Server 特别依赖用户打开浏览器,另外不确定会不会有其他方面的局限。于是我们同时做了一个 Node.js 同构的 NASL Language Server 服务的备选方案。

  • TS Language Server 切成 Node.js 的 Worker;
  • Adapter 通过一些打包处理兼容 Node.js 即可。

即时不用于线上产品,也可以做自动化测试。

最终效果

最终方案上线之后,产品的错误检查、自动补全、重命名等语言方面的实时性和准确性体验有了明显提升。

同时开发成本有明显的降低,比如需要函数式编程机制的列表操作 API,原来预估 2 人 1.5 个月的语言研发工作,现在只要 1 人 1 星期就能完成。

后续规划

基于新的这版 Language Server 继续优化产品底层的语言机制,增强产品的类型检查、类型提示等体验,比如:前端相关类型提示、数据查询类型爆炸、数据元管理机制等问题。

另外当前的一个主要挑战是 NASL 中有许多用户方便使用的隐式转换规则,但编译 Java 时需要更多类型信息,目前 TS Language Server 还不能快速全量获取,需要进一步调研处理。

· 12 min read
赵雨森

一、需求

从编程角度来说,低代码核心是为用户打造一款能够快速入门搭建、同时又能支持中等复杂度的企业级应用的编程系统(Programming System)。

编程系统分为两个方面来看:

  • 一方面,我们需要引入完善的编程表达能力(如泛型、模块、函数式等等),满足复杂应用的开发。
  • 另一方面,我们需要优化配套的编程环境,提升用户的编程体验(如 IDE、LanguageServer、Debugger、实时编译等等)

目标用户包含两类:

  • 企业业务人员,会使用一般的办公系统如 Excel 等,经过我们的培训可以独立使用低代码平台搭建系统;
  • 专业开发人员,如工商银行的专业前后端开发。

关于用户的需求复杂度和使用难度,目前我们产品中的使用曲线还是比较割裂:

我们希望通过对编程系统中的各个设施的优化,能够达到比较平滑的效果。

二、路线

在我们目前的规划中,编程系统的发展路线分为两个阶段:

第一个阶段(1-2 年)

借助 TS Language Server 能力,快速补齐和完善编程系统的表达能力。对标 OutSystems 等产品的语言能力。

举一个例子,现在用户在使用部分场景的时候,没有类型提示了。或者在使用的一些链路中,类型推断断掉了。

目前这个阶段的一些主要问题如下:

  • 前端组件类型体系不完善,如属性传递、事件回调等;
  • 数据查询出来,由于用户 SELECT 和 JOIN 等,会产生出来很多数据结构,如果按具名的话会爆炸;
  • 用户在操作列表数据的时候,目前只能使用 ForEach。无法用 find、filter、map 等方式;
  • 一个复杂的应用有划分模块的需要;
  • 目前逻辑块的空间利用率过低,想用一些更合适的交互形式代替;
  • 用户编程的实时反馈、互动、调试方面比较困难;
  • ...

第三节“典型问题整理”有详细描述。

第二个阶段

自研 Language Server,提供智能化的推荐能力。

三、典型问题整理

1. 支持前端组件的类型体系不完善

示例 1

比如下图是一个选择框组件的属性面板:

下面是选择框组件的一个简化版的 ts 声明和使用它的页面场景:

interface ChangeEvent<T> {
item: T;
value: string;
}
interface MultipleChangeEvent<T> {
item: T;
value: Array<string>;
}

export class Select<T> extends Component {
constructor(
public options?: {
// 属性
color?: 'default' | 'primary' | 'danger',
size?: 'mini' | 'small' | 'normal' | 'large',
multiple?: false,
dataSource?: Array<T> | ((params: DataSourceParams) => Promise<Array<T>>),
value?: string | Array<string>,
// 事件
onChange?: (e: ChangeEvent<T>) => void,
},
);
constructor(
public options?: {
// 属性
color?: 'default' | 'primary' | 'danger',
size?: 'mini' | 'small' | 'normal' | 'large',
multiple?: true,
dataSource?: Array<T> | ((params: DataSourceParams) => Promise<Array<T>>),
value?: string | Array<string>,
// 事件
onChange?: (e: ChangeEvent<T>) => void,
},
);
}
class View1 {
render(): VNode {
return new components.Select<Student>({
size: 'large',
multiple: true,
dataSource: this.loadStudents,
onChange: (e) => {
console.log(e.item.age);
},
});
}
}
class View2 {
render(): VNode {
return new components.Select<Student>({
size: 'large',
multiple: true,
dataSource: this.loadStudents,
onChange: (e) => {
showMessage(e.value.length);
},
});
}
}

它已经涉及到了以下的一些语言表达能力:

  • 属性都是可选类型
  • 选择框的数据源可以是数组、分页数组或函数(产品概念中的逻辑)
  • 有单选、多选的可能,多选时 value 为数组
  • onSelect 的事件入参是一个回调函数
  • 泛型 T 取决于数据源中的 T

示例 2

页面中会使用循环组件,甚至会有双循环的场景。类似如下 ts 代码表达。希望用户在编辑 Text 组件的文本时,可以自动补全一些 scope, scope2 的上下文提示。

class View {
render(): VNode {
return new components.ForComponent<Student>({
dataSource: this.loadStudents,
slotDefault: (scope) => [
new components.ForComponent<Course>({
dataSource: scope.item.courses,
slotDefault: (scope2) => [
new components.Text({
text: scope2.item.course.name,
}),
],
],
});
}
}

另外 scope 这种很生疏的字段又不想向用户暴露,我们可以用析构去除。但又会引入一些语言特性、如析构时 as。

class View {
render(): VNode {
return new components.ForComponent<Student>({
dataSource: this.loadStudents,
slotDefault: ({ item }) => [
new components.ForComponent<Course>({
dataSource: item.courses,
slotDefault: ({ item: item2 }) => [
new components.Text({
text: item2.course.name,
}),
],
],
});
}
}

2. 数据查询具名类型爆炸

比如用户了定义一个如下两个实体(对应两张数据库表):

class School {
id: Long;
name: String;
description: String;
address: String;

static get(id: Long): School;
static create(student: School): Long;
static update(student: School): void;
static delete(id: Long): void;
}

class Student {
id: Long;
name: String;
age: Integer;
gender: Gender;
number: String;
idCard: String;
phone: String;
schoolId: Long;

static get(id: Long): Student;
static create(student: Student): Long;
static update(student: Student): void;
static delete(id: Long): void;
}

首先,比如最简单的修改功能:

用户的编程意图大概如下:

let student = Student.get('123'); // 查询出一个学生
// 在表单中编辑 student
Student.update(student); // 再提交更新

那么复杂一点的情况是这样的:

示例 1

有时比如不需要查询出全部字段

用户的编程意图类似下面的方式:

interface PartialStudent {
id: Long;
name: String;
age: Long;
gender: Gender;
}

let partialStudent: PartialStudent = query('SELECT id, name, age, gender FROM Student'); // 查询出一个学生
// 在表单中编辑 partialStudent
Student.update(student); // 再提交更新。❌ 类型错误

这时用户如果直接使用我们自动生成的 update 接口会类型不匹配。需要用户额外做很多操作去处理这个事情。

另外也产生了一个 PartialStudent 这样一个临时的具名类型,用户如果使用了很多这类场景,这些具名类型就会爆炸。

示例 2

JOIN 的场景,用户想查询学生时附带查询关联的学校信息(schoolId)。

用户的编程意图类似下面的方式:

interface StudentRecord {
id: Long;
name: String;
age: Integer;
gender: Gender;
number: String;
idCard: String;
phone: String;
schooId: Long;
schoolName: String;
schoolDescription: String;
schoolAddress: String;
}

let record: StudentRecord = query('SELECT * FROM Student JOIN School ON Student.schoolId = School.id'); // 查询出一个学生及相关的学校信息
// 在表单中编辑 record
Student.update(record); // 再提交更新。❌ 类型错误

这种方式下,用户查询出来的字段比以前多,同样与 update 的类型不匹配。

后来我们参考 OutSystems,改进成以下方式:

interface StudentRecord {
student: Student;
school: School;
}

let record: StudentRecord = query('SELECT * FROM Student JOIN School ON Student.schoolId = School.id'); // 查询出一个学生及相关的学校信息
// 在表单中编辑 record
Student.update(record.student); // 再提交更新。

这种方式下,用户已经可以 update 了。但目前仍然会产生许多类似 StudentRecord 这样的具名类型,这是我们现在的一个主要问题。

3. 列表操作比较薄弱

目前只给用户提供了 ForEach。

ForEach(list, start, list.length - 1, (item, i) => {
//
});

用户需要类似下面的一些高级操作:

list.filter((student) => student.age > 20).map((student) => ({
name: student.name,
age: student.age,
number: 'HZ' + student.number,
}));

语言表达能力上可能需要 lambda 的支持。

4. 一个复杂的应用有划分模块的需要

这个上次会议上讨论过,我还是在下面列一下。

我们的模块粒度比较大,把一个大应用能拆解,支持 import/export 即可。

下面是一个伪代码:

import * as moduleB from './appB/moduleB.nasl';

export entity Student {
name: moduleB.School;
age: Integer;
}

export struct School {
name: String;
count: Integer;
}

export enum Color {
RED,
GREEN,
BLUE,
}

export logic logic1(param1: Student) {
return some;
}

后面这种定义比较繁琐:

import * as moduleB from './appB/moduleB.nasl';

namespace entities {
export entity Student {
name: moduleB.School;
age: Integer;
}
}

namespace structures {
export struct School {
name: String;
count: Integer;
}
}

namespace enums {
export enum Color {
RED,
GREEN,
BLUE,
}
}

export logic logic1(param1: entities.Student) {
return some;
}

5. 目前逻辑块的空间利用率过低

darklang 我们可以做一个参考。

但目标用户群体中包含了没有编程经验的业务人员,因此都是代码编程的方式可能对他们来说比较困难。还请老师能提供更多的一些思路。

6. 用户编程的实时反馈、互动、调试方面比较困难

作为低代码可视化为主的编程环境,想提升一些实时反馈、互动,调试方面的体验,希望老师能提供更多的思路。