Skip to main content

· 12 min read
朱子润

今天感觉允许多继承、允许实现多接口的子类型系统其实天然就是部分支持交类型(intersection types)的类型系统,只不过交类型的类型规则与学界不同且不是一等公民(first-class citizen),以及大多数 OO 语言没提供交类型成员的即时(on the fly)构造机制罢了。

交类型、子类型

交类型,莫不是一个 value(expression 或 term)同时有两种类型罢了?

例如:

let x : Int & Bool = ...

func f(_ param : Int) { print("Int received") } // _ 表示没有外部形参名,可忽略
func g(_ param : Bool) { print("Bool received") }

f(x) // 打印 Int received
g(x) // 打印 Bool received

x 同时是 Int Bool 类型,具有两种类型的特质,因此既可以被 f 调用也可以被 g 调用。

虽然上面不是合法的 Swift 代码,但经过一些改动,我们依然能实现相似的效果:

protocol IntLike { } // protocol 类似常见的 interface
protocol BoolLike { }

extension String : IntLike {} // 实现“接口”
extension String : BoolLike {}

let x = "0"

func f(_ param : IntLike) { print("Act like an Int") }
func g(_ param : BoolLike) { print("Act like a Bool") }

f(x) // 打印 Act like an Int
g(x) // 打印 Act like a Bool

x 的类型是 String,但不也是 IntLike & BoolLike 吗? x 实实在在地具有多个类型的能力,它应当是交类型!

(Java、Kotlin 等也可以模拟,除了它们不能给语言内置的基础类型(如这里的 String)添加父接口。)

再看另一个例子:

protocol IntLike { func actLikeInt() }
protocol BoolLike { func actLikeBool() }

extension String : IntLike {
func actLikeInt() {
print("Act like an Int")
}
}

extension String : BoolLike {
func actLikeBool() {
print("Act like a Bool")
}
}

let x = "0"

func f<T>(_ param : T) where T : IntLike & BoolLike { // 使用 & 记号描述约束的上界是有原因的吧
param.actLikeInt()
param.actLikeBool()
}

f(x) // 打印 Act like an Int 和 Act like a Bool

上述代码中,T : IntLike & BoolLike 表示 T 的泛型约束上界是 IntLike & BoolLike,即 T 既要是 IntLike 的子类型又要是 BoolLike 的子类型。它为什么不能称为交类型呢?我今天觉得是了。它应当是交类型!

学术界的交类型

除了上面说的“一个 value(expression 或 term)同时有两种类型”,学术界的交类型(例如 Pierce 在 1990 年代研究的交类型 [1])通常还具有下面这个特性(类型规则),以区别于上一章的例子。

// 注意:下面的代码只用来示意,并不合法
let ib : A & B

let twoFuncs : (A) -> Int & (B) -> Bool

protocol A {}
protocol B {}

let ib2 = twoFuncs(ib) // ib2 : Int & Bool

函数调用的结果 ib2 的依然是一个交类型。

上面最后一行发生的事情:

  1. twoFuncs 是两个函数类型 (A) -> Int(B) -> Bool 的交类型。
  2. 实参 ib 也是个交类型 A & B
  3. (A) -> Int & (B) -> BoolA & B 做了 2 * 2 = 4 次尝试(即 & 的左右类型俩俩尝试)
  4. 只有两组类型符合函数调用的类型检查规则(即实参、形参类型匹配):(A) -> IntA,以及 (B) - > BoolB
  5. 两组“符合规则”的调用分别产生了 IntBool 两个结果类型。
  6. 两个结果类型再次组成交类型 Int & Bool

我如何造出同时具有两个函数类型——(A) -> Int & (B) -> Bool——的东西?我可以用函数重载吗?

protocol A {}
protocol B {}

func twoFuncs(_ param : A) -> Int { } // twoFuncs 会有 A -> Int &
func twoFuncs(_ param : B) -> Bool { } // B -> Bool 类型吗?

func test<T>(_ param : T) where T : A & B {
twoFuncs(param) // Error: ambiguous use of 'twoFuncs'
}

编译器会告诉我们上面的第 8 行有错误。

因此,与学术界的交类型的规则不同,重载的函数在调用处会有一个叫重载决议(overload resolution)的算法来择优,而非保留所有结果。

那如果我不做重载决议而是保留所有结果,是不是就成了学术界的那个交类型了?

不做重载决议,问题可就又多了去了……例如

protocol A {}
class B : A {}

let x = B()

func g(_ param : A) -> Int { return 0; }
func g(_ param : B) -> Int { return 1; }

g(x)

这里假设 g : (A) -> Int & (B) -> Int,第 9 行的函数调用结果的类型应该是 Int & Int,值是 0 & 1……?——

  • Int & Int 应该与 Int 一样吧,否则不就成元组类型 (Int, Int) 了?
  • 那我们是保留 0 作为结果,还是保留 1 作为结果呢?

为了避免上述问题,大部分对交类型的研究都限缩在 disjoint intersection type 上,即只研究“不相交交类型”,或者也许应该叫做“互斥交类型”。

在不相交交类型的系统里,g : (A) -> Int & (B) -> Int 会被拒绝——因为 (A & B) -> Int(A) -> Int(B) -> Int 的一个公共父类型 [2],因此 (A) -> Int(B) -> Int 并非“不相交”,因此 (A) -> Int & (B) -> Int 本身就是非法类型,也就不用关心它的调用问题了。

工业语言欠缺的

很多人认为工业语言没有交类型这一特性,我想,应该是因为开头说的:它们“缺少了一个交类型成员的即时(on the fly)构造机制”。

Pierce 的论文 [1] 中提供的构造机制为

for α in T0..Tn . e

例如 for α in Int, Real. λ x : α. x + x ,它的类型是 Int -> Int & Real -> Real

我们“熟悉”的

func f<T>(...) where T : U0 & ... & Un { ... }

可以说是在结构上很相似,但它只能构造出泛型函数类型。(注意,论文中的那个构造,构造出的不是泛型类型,也不一定必须是函数类型。)

Bruno 的论文 [2] 中提供的构造机制为

e0 ,, e1

例如 1 ,, true 即为一个 Int & Bool(交)类型的成员。

C++ 的启发

C++ 有很多为人(学术界)诟病的特性,例如多继承问题(diamond problem,或 Deadly Diamond of Death)。借用维基百科的例子 [3]

class Animal {
public:
virtual void eat() { cout << "Animal eat." << endl; };
};

class Mammal : public Animal {
public:
virtual void eat() { cout << "Mammal eat." << endl; };
};

class WingedAnimal : public Animal {
public:
virtual void eat() { cout << "WingedAnimal eat." << endl; };
};

// A bat is a winged mammal
class Bat : public Mammal, public WingedAnimal { };

Bat bat;
bat.eat();

由于多继承,最后一行(第 20 行)的 bat.eat() 会被编译器抱怨 “error: request for member ‘eat’ is ambiguous”,这是因为 Bat 自己没有重新实现 eat 方法,而其直接继承的 Mammal 和 WingedAnima 却有不同的 eat 方法,因此编译器不知道要调用哪一个父类中的 eat 方法。

针对此类问题,其实 C++ 给出了自己的解决方案(而不是像有些语言一样束手无策):我们可以使用语法 [4] bat.Mammal::eat()bat.WingedAnimal::eat() 来指定无歧义的(父类)调用,前者输出 Mammal eat. 而后者输出 WingedAnimal eat.

我猜测,对于多继承,C++ 给出的语法消歧义方案和整套的实现方案——即使有成员数量爆炸问题——应当可以指导我们实现出 fully intersection types(而非上面所提到的 disjoint intersection types)。例如对于

class A { }
class B : A { }

let x : A & B = A() ,, B() // 借用一下构造交类型的符号

func f(_ param : A) -> Int { return 0; }
func g(_ param : B) -> Int { return 1; }

f(x)
g(x)

我们可以把 x 的两份数据都记录下来,在后续调用 f(x)g(x) 中:

  • 函数 f 要求实参是 A 的子类型,
    • 那么这里可以像 C++ 一样报错 ambigous argument ...
    • 也可以选择 “x 中的 A 成分”——它最接近形参类型,
    • 也可以选择 “x 中的 B 成分”——在所有可用的“成分”中,它在子类型关系上最优,
    • 也可以按学术界的思路,两个成分都选择,f 被调用两次,两次调用的结果再次组成交类型……
  • 函数 g 要求实参是 B 的子类型,
    • 那么这里必然只能选择 “x 中的 B 成分”。

这便是(为人诟病)的 C++ 带给我的一些启发,应该对我们项目后续的交类型设计有启发作用。

参考

[1] Programming with Intersection Types and Bounded Polymorphism - Benjamin Crawford Pierce

[2] Disjoint Intersection Types - Bruno C. d. S. Oliveira et al.

[3] 虚继承 - Wikipedia

[4] Inheritance Ambiguity in C++ - GeeksForGeeks

· 26 min read
朱子润

据说早期人类语言只有“听说”系统,后因国家税收之需,又演化出了“读写”系统。而读写相较于听说的方式,有易于复制传播、歧义少、保存持久等优点……

类似的,早期用于人机交流的程序语言只有“字符”系统,现在因为某些诉求,市场上涌现出一批“可视化低代码”系统。什么是低代码,它有哪些优势,我为什么要学习、使用它?低代码未来的市场是怎样的?

本文重点描述低代码未来 5~10 年的广阔市场,但仍循序渐进,从概述低代码编程的历史发展开始。(所以想要了解市场规模和前景的读者,请直接跳转到低代码的市场和机遇部分。

低代码定义:发展与变迁

提高生产效率是人类永恒的主题。正因此,我们认为广义的低代码编程,取其字面意思,应当是指在某个时间段内,完成项目所需的代码量(大幅)低于其他语言(平均值)的编程模式。

无独有偶,我们的认知与 ITPro Today 相合 [1]:文中描述 Fortran 和 COBOL 在 1950 年代刚出现时,它们作为高级编程语言的始祖,是那个年代里不折不扣的低代码编程语言——即便现在大多数的编程人员已经不这么认为。

文章 [1] 还表示,1987 年苹果公司开发的 HyperCard 编程语言(尽管不为大多数人所知)是低代码发展中的一个里程碑。HyperCard 融合了一套图形化、可扩展、可编辑的接口和数据库功能,它的部分设计理念与微软的 Visual Basic 相似,但早于后者 4 年发布。

HyperCardLogo
图 1:HyperCard 图标。图片来源:维基百科
HyperCardMain
图 2:HyperCard 界面。图片来源:OSXDaily

1990 年代,随着第四代编程语言(SQL、MATLAB、Clipper 等)的发展与成熟以及高效的应用程序开发工具如 Visual Studio,Delphi 等的横空出世,UML + 设计器 + MDD 红极一时,模型驱动设计、自动代码生成和可视化开发思想成为了当年低代码开发的核心 [2]。直到这时为止,低代码的核心诉求都是提高专业开发人员的开发效率

UML Design
图 3:UML 设计图。图片来源:百度图片

2010 年代,低代码的核心诉求发生剧烈变化,开始向“让业务专家将自己的专业知识和经验落地成在线应用“转移 [2]。很多公司没有经费聘用专业的开发人员,因此希望业务专家经过一定的培训也可以上手开发应用,这些应用很大一部分是用于市场营销、销售的,也比较同质化,容易从统一的模板中搭建出。由此,便有了我们所熟知的在当前时代背景下的(狭义)的低代码编程,国际上最具有影响力和代表性的产品有 OutSystems、Mendix、MicroSoft Power Apps 等,这些产品具有以下特点 [3]

  • 为让非专业编码人员易于使用,配备了完善的可视化开发框架,不仅提供可视化开发前端、UI 的能力,还提供可视化开发复杂逻辑的能力:无需记忆编程语言的语法,通过拖拽和点击即可实现业务功能。
  • 提供完整的前后端、数据库、部署、运维等解决方案,屏蔽了前端(HTML、JS、Vue / React 等)、后端(NodeJs 或 Java + SpringBoot 等)、数据库(SQL 或 MongoDB 等)、云服务等技术,无需点击,即开即用。
OutSystems Logic
图 4:OutSystems 可视化逻辑。图片来源:CodeWave 智能开发平台为什么要做编程语言?
SF Logic
图 5:CodeWave 智能开发平台可视化逻辑。图片来源:CodeWave 智能开发平台为什么要做编程语言?

了解了低代码的大致历史后,我们便知道它并不是先有定义,再发展起来的事物 [2]:它的定义随着时代发展而变化,以反应当前时代市场的诉求。因此,我们不妨援引西门子 Mendix 的定义来概述 21 世纪 20 年代的低代码 [4]:“低代码开发是一种可视化应用开发方法。通过低代码开发,不同经验水平的开发人员能够通过图形用户界面,使用拖放式组件和模型驱动逻辑来创建 Web 和移动应用。”

了解了低代码的大致概念后,我们再来看看现有的低代码产品。

现有的低代码产品

时至今日,国内的低代码产品已经比较丰富,例如 CodeWave 智能开发平台阿里低代码引擎葡萄城活字格奥哲氚云轻流普元 EOS 平台 等:

  • CodeWave 智能开发平台是网易打造的企业级应用开发平台,兼具(下述)原生低代码厂商和开发工具厂商的特点。它通过简单易上手的可视化编程语言,帮助企业搭建高复杂度、融合企业 IT 资产、交互视觉高保真还原的企业级应用,致力于帮助企业实现低成本、高效率的数字化转型和应用创新。
  • 阿里低代码引擎的代表产品有钉钉宜搭等。钉钉宜搭基于阿里云的云基础设施和钉钉的企业数字化操作系统,提供低门槛、高效率的数字化业务 [2]。云平台的厂商希望借助低代码吸引更多用户购买其云服务,因此其低代码产品的发展方向上以深度整合自家云资源,降低门槛为主。
  • 葡萄城活字格(Forguncy)是厂商整合自身的开发工具资源后推出了低代码产品,发展方向上以产品力提升为主,在技术门槛和扩展性中间会更倾向于后者 [2]。用户集中在初创型软件开发团队、行业软件代理商、系统集成商和中大企业 IT 中心,而不是一线业务人员。
  • 奥哲氚云是低代码行业的创业公司,正处于风口快速发展阶段,后获得阿里系投资,更关注流量和市占率。产品发展上倾向于在降低门槛、与钉钉等流量平台整合 [2]。类似的还有明道云、简道云等。
  • 轻流是专注于流程和表单的 BPM 厂商在自身软件的基础上增加可视化设计器,进一步降低使用门槛后,从而实现低代码转型的产品。这些产品的核心优势是强大的工作流引擎,但这种基于表单而不是数据模型的架构,在应对复杂应用场景时可能会遇到诸多障碍 [2]。类似的产品还有炎黄盈动 AWS PaaS 等。
  • 普元 EOS 平台可以看成行业软件向通用平台的演进的尝试,它希望利用低代码技术降低实施、特别是实施中客户化开发环节的工作量,提升行业软件自身的竞争力 [2]。典型产品还有用友的 iUAP 等。

目前上述产品的海外市场并不多。根据 Fortune Business Insights [5],我们列出几个重要的海外低代码产品和其简介 [6, 7] 供参考:

  • Appian Corporation:Appian 配备了原生部署工具和与 Jenkins 等开发工具的集成选项,允许用户建立业务流程管理(BPM)应用程序,帮助企业组织和优化业务流程。
  • Microsoft Power Apps:可快速构建客制化的商业应用程序,它包含了预建的人工智能组件,还具备良好的可扩展性。
  • Salesforce.com, Inc.:为每个用户创建个性化的体验,用拖放字段和动作来定制页面,在正确的时间提供正确的信息,构建智能的、可移动的应用程序。
  • OutSystems Inc.:提供可视化、模型驱动的工具,以实现快速开发和部署应用程序。它还提供实时性能监控和拥有高安全性。
  • Mendix Technology BV:常被用于构建改善公司内部流程的解决方案,其用户只需最少量的编程知识和一个良好的策略。Mendix 被用于数字保险、SAP 供应链、货物交付管理和商业分析等各种市场领域。
  • ……

说了这么多,哪个低代码平台最火爆呀?——对于这个程序员永远关注的话题,我们找到了来自 GradientFlow 的“排名” [8] 如下(选购产品时需综合考量,不能依赖单一排名)

LowCodePlatformIndex
图 6:低代码开发平台指数。数据来源:GradientFlow [10]

低代码的市场和机遇

本章我们调换一下顺序:先讨论低代码这一新兴技术在全球的发展,再描绘其在中国特定时代背景下(更大)的前景。

全球的发展

在低代码领域,目前海外大约活跃着六十七家供应商,而且它的生态系统正在迅速成长。2017 年是一个分界点,在大型软件供应商中,17 年之前只有 Salesforce 采用 Force.com 的低代码平台。随后,微软、甲骨文、IBM 和 SAP 等企业都纷纷加入了这个市场,低代码(含无代码)市场呈爆发式增长:

  • 2020 年市场规模达到 108.2 亿美元 [5]
  • 2021 年市场规模达到 163 亿美元 [9]
  • ……
  • 2030 年市场规模达到 1485 亿美元 [9]
  • ……

更重要的是,目前低代码市场的增长是超过预期的,例如 2021 年 2 月曾有机构预测改年的低代码市场为 138 亿美元 [10],而 2022 年回顾的市场实际值为 163 亿 [9]

LowCodeMarket
图 7:低代码全球市场份额。原始数据来源:SpreadSheetWeb [11]

当我们聚焦代表性的个体时,数据同样展现出了潜力 [2]

  • 2018 年 Outsystems 获得 KKR 和高盛 3.6 亿美元融资,估值超过 10 亿美元。
  • WordPress、Wix 已逐渐成长为生态完善的可视化(低代码)建站平台,数以百万计的个人和企业在这些平台上搭建自己的官网或者应用。其中 WordPress 的生态厂商 Elementor 在 2020 年初获得 1500 万 A 轮融资,在过去一年中,该插件已部署了 300 多个新功能,还被翻译成 55 种语言,目前获得超过了 400 万活跃安装。低代码建站市场和低代码企业服务领域一样快速发展中。
  • ……

为什么低代码会有如此广阔的市场?正是因为低代码开发高效灵活稳定,降低了应用搭建的门槛和对专业工程师的依赖,让业务部门用拖拽的方式自行搭建应用平台,减少与 IT 部门反复沟通的流程,最终实打实地降低了人力成本,克服了传统开发交付周期长、定制能力差、难以应对不断变化的市场和客户期望等弱点 [18]

You don’t need to invest in expensive training programs for your employees. You can have them build apps faster, with less training. And at the end of the day, all of this makes your business more revenue [19]. 翻译:你不需要为你的员工投资昂贵的培训项目。你可以让他们以更快的速度建立应用程序,并减少培训。而在一天结束时,所有这些都使你的企业获得更多的收入。

中国的优势

在《“十四五”数字经济发展规划》等政策以及企业数字化转型大背景下,能为企业提供降本、增效、提质,并推动数字经济发展的低代码、无代码在正在蓬勃发展 [12];数字化转型是企业必经的可持续进化历程,越来越多的企业将从“不得不转”转变到“主动要转” [13]。其中无论是政企、医疗、金融业务,还是房地产、制造、零售等,我们都能看到低代码的身影 [14](以下内容均来自该引用):

  • 泰康人寿-泰行销 APP 分公司专栏项目,包含新闻、文章管理,搜索、滚动新闻,消息提醒,新闻、文章列表,大量的数据统计、业绩数据、排名数据、考核数据以及客户数据等特性、内容。使用CodeWave 智能开发平台,通过可视化方式实现数据建模、页面搭建以及业务逻辑编排,在教练指导下 2 周完成开发。项目通过扩展组件实现 IT 资产复用,可导出为模板并成为平台资产,其他分公司从而可以通过模板快速搭建专栏。
  • 中国联通软件研究院针对业务部门业务需求多样化,开发门槛高,开发周期长等问题,通过研究低代码在轻量级业务场景、通用型业务场景下的应用,帮助企业开发人员提高研发效率,降低开发门槛,赋能企业运营,助力数字化转型。同时,中国联通低代码赋能政企业务受理集约,实现联通政企业务受理集约流程可视化、分钟级配置、所见即所得,流程发布由原来的 2 周提升至 0.5 天,支撑 30 省超 300 个地市,8 大类业务的受理集约。
  • 重庆长安汽车与华为基于 AppCube 合作,构建智慧党建系统、在线业务预算系统等 6 个企业应用。其中在线业务预算系统需要将庞杂的表单业务在线化,实现计划管理系统、合同管理系统、整车利润分析系统,以及填报、审核、分析、自动生成相应财务报表等预算编制流程等。借助 AppCube 低代码,应用构建效率整体提升了 2~3 倍,经过 3 个多月的努力,基本实现预期目标。
  • 中国雄安集团电子招标采购交易平台,结合雄安集团业务复杂性和具体需求,最终在评审结果汇总计算、价格评审计算、费用计算等招采业务场景中使用计算规则引擎整体解决方案,只需 2 小时即可完成传统做法需要 5 天的业务系统升级迭代流程。此方案的计算代码部分由低代码组成
  • ……

宏观统计数据也表明:

  • 2022 年中国低代码无代码市场规模预计为 40.6 亿元,并预计在 2025 年达到 118.5 亿元,其年均复合增长率高达 42.9%。其中,低代码产品是总体市场的主要构成,占比 77.6% [12]

  • 2022 年将有 40%~60% 的大型企业使用低代码开发应用 [15]

  • 低代码的领军 Mendix 更是表示 [16],低代码市场在中国展现出巨大潜力:“低代码发展正当时,中国将取代美国成为低代码开发的全球领导者,报告显示中国低代码市场呈现了高速发展,85% 的 IT 决策者表示表示正积极拥抱低代码技术,认为低代码是他们不容错过的趋势”,且近一半(44%)的日常开发工作可以在低代码平台上完成。

  • 尽管转型领军者的数字化优势进一步加大,2022 年进入转型领军者行列的企业比例仅为 17%,与去年(16%)基本持平。低代码在中国有着巨大的增量市场 [13, 16]

  • 低代码应用在高校放彩,促进产学研全链路一体化建设 [17]……

  • ……

立足中国市场,随着“十四五”规划的推进,中国企业的数字化转型必将取得长足的进步,在降本增效的驱使下,低代码也必将成为中国大多数企业的最佳选择。

更多的统计数据

根据 UserGuiding 的统计,人们对低代码持有以下观点 [20]

  • 美国有超过 50 万个计算机科学职位空缺,预计它的增长速度是其他工作领域的 2 倍,但世界上只有 0.5% 的人口知道如何编码。

  • 到 2024 年,低代码将占到应用开发活动的 65% 以上。

  • “易用性”是人们在描述 低代码 / 无代码开发平台时使用最多的 (20%) 正面词汇,这是基于从所有低代码 / 无代码开发平台公司的客户评论中收集的数据。

  • 24% 的用户在使用低代码 / 无代码平台之前完全没有经验。其中 40% 的用户大多有商业背景。

  • 84% 的企业转向低代码 / 无代码,因为它们能够减少 IT 资源的压力,提高上市速度,并让企业参与数字资产开发。

  • 31% 使用低代码/无代码的企业没有使用它们来构建和交付任何最高价值的应用程序。

  • 72% 的用户在 3 个月或更短的时间内用低代码应用开发应用程序。

  • 低代码 / 无代码解决方案有可能将应用程序的开发时间减少 90%

  • 30% 的企业在未来更愿意使用定制的低代码 / 无代码来处理复杂的商业逻辑。

  • ……

小结

我们概述了上世纪和本世纪的低代码产品定位(提高专业开发人员的开发效率和让业务专家将自己的专业知识和经验落地成在线应用),讨论了低代码的大致定义,然后通过大量统计数据和一些中国的典型案例描绘了低代码在近年的高速发展和其在未来 5~10 年国内外的巨大市场。

为了较好地解决低代码产品的诉求,低代码平台具体需要怎么做,技术上有哪些挑战?我们会在今后的专栏文章中一一解明。

参考

[1] The History of Low-Code/No-Code Development - ITPro Today

[2] 低代码发展现状调研和思考 - 网易 KM

[[3] CodeWave 智能开发平台为什么要做编程语言?- NASL LCAP Group](https://nasl.codewave.163.com/faq/2022/12/21/CodeWave 智能开发平台为什么要做编程语言?)

[4] 一种可视化编程语言建设方法 - 网易 KM

[5] Low Code Development Platform Market Scope with Size, Share - Fortune Business Insights)

[6] 9 Best Low-Code Platforms To Use in 2022 - Trio Developers

[7] 10 Best Low-Code / No-Code Platforms To Create an Digital Product - Mobi Touch)

[8] Ranking Low-code Development Platforms - Gradient Flow

[9] Low Code Development Platform Market Size is projected to - Globe News Wire)

[10] Gartner Forecasts Worldwide Low-Code Development Technologies Market to Grow 23% in 2021 - Gartner

[11] How Big is the Global Low-Code / No-Code Market and How Fast is it Growing? - Spread Sheet Web

[12]《2022 年中国低代码无代码市场研究及选型评估报告》- 海比研究院

[13] 数字化转型:可持续的进化历程 - Accenture

[14] 《2022 低代码 · 无代码 应用案例汇编》,2022 年 8 月第一版 - 企业数字化发展共建共享平台

[15] 2022 中国“低代码”领域十大趋势 - 腾讯新闻

[16] 低代码发展正当时,中国将取代美国成为低代码开发的全球领导者 - Mendix

[17] 2022 年中国低代码行业生态发展洞察报告 - 网易

[18] 化繁为简:低代码行业研究报告 - 艾瑞咨询

[19] The History of Low-Code Development Platform - kissflow

[20] No-Code / Low-Code Statistics and Trends - User Guiding

· 27 min read
张炜昕

表达式问题

模块化和可扩展性是开发复杂软件系统的重要基础。而表达式问题(Expression Problem)则是检验编程语言对模块化和可扩展性支持程度的根本问题。表达式问题要求在不修改和重复既有代码、保障类型安全、分离编译和模块化类型检查的前提下同时扩展数据结构及其操作。传统的面向对象和函数式编程范式仅支持单一维度的扩展性。

我们将以皮亚诺(Peano)数及其扩展为例贯穿本文。一开始只支持零(Zero)和后继(Succ)来构造自然数和将皮亚诺表示转换为数字表示的求值操作(eval)。例如eval作用在Succ(Zero)上的结果是1。以下的Scala代码分别用面向对象和函数式实现了皮亚诺数:

//面向对象式
trait Tm { def eval: Int }
object Zero extends Tm {
def eval = 0
}
class Succ(t: Tm) extends Tm {
def eval = t.eval + 1
}

//函数式
sealed trait Tm
case object Zero extends Tm
case class Succ(t: Tm) extends Tm

def eval(t: Tm): Int = t match {
case Zero => 0
case Succ(t1) => eval(t1) + 1
}

面向对象式是操作优先,先以接口描述数据结构所支持的操作,再用实现接口来定义数据结构。在面向对象式中,添加数据结构易而添加新的操作难。添加数据结构仅需定义新的子类比如前驱(Pred):

class Pred(t: Tm) extends Tm {
def eval = t.eval - 1
}

而添加新的操作则需修改接口及其所有子类。 相反,函数式是数据结构优先,先以代数数据类型来定义数据结构,再用模式匹配定义操作。在函数式中,添加操作易而添加新的数据结构难。添加操作仅需定义一个新的函数比如打印(print):

def print(t: Tm): String = t match {
case Zero => "Zero"
case Succ(t1) => "(Succ " + print(t1) + ")"
}

而添加数据结构则需修改代数数据类型定义以及给已有函数增加一条新的模式匹配语句。

访问者模式

那么如何在面向对象语言中实现操作扩展呢?这就需要借助访问者设计模式(Visitor pattern)了。访问者模式将操作从类层次结构中分离出来从而允许添加新的操作而不修改既有的类层次结构,如下所示:

//类层次结构
trait Tm {
def accept[A](v: Visitor[A]): A
}
object Zero extends Tm {
def accept[A](v: Visitor[A]) = v.zero
}
class Succ(val t: Tm) extends Tm {
def accept[A](v: Visitor[A]) = v.succ(this)
}
//访问者接口
trait Visitor[A]{
def zero: A
def succ(x: Succ): A
}

访问者接口声明的访问方法(zerosucc)与类层次结构的每个子类(ZeroSucc)一一对应,用来实现类层次结构中的accept方法。类型参数A抽象了访问方法的返回类型。

操作通过实现访问者接口定义:

//求值访问者
class Eval extends Visitor[Int] {
def zero = 0
def succ(x: Succ) = x.t.accept(this) + 1 //递归调用accept方法遍历子表达式
}

重复实现访问者接口即可添加新的操作,比如打印:

//打印访问者
class Print extends Visitor[String]{
def zero = "Zero"
def succ(x: Succ) = "(Succ " + x.t.accept(this) + ")"
}

然而,传统的访问者模式只是转换了扩展维度并没有解决表达式问题:当我们想添加一个新的子类时,访问者接口并没有对应的访问方法来实现其accept方法。因此,需要修改访问者接口及已有的访问者。此外,访问者模式引入了样板代码(boilerplate code),使用起来较为繁琐。为解决上述缺陷,本文将分别介绍基于Java和Scala两种语言的可扩展访问者模式元编程框架EVF1和Castor2

第一部分:EVF1

一种基于Java的可扩展访问者模式

让访问者模式可扩展的关键在于如何解耦访问者接口和类层次结构。用可扩展访问者模式定义的皮亚诺数访问者接口如下:

interface Visitor<Tm,A> {
A Zero();
A Succ(Tm t);
A visitTm(Tm t);
}

通过新增类型参数Tm来抽象数据结构类型以及从Tm转换到返回值类型A的方法visitTm来解耦访问者接口和类层次结构。求值访问者的定义如下:

interface Eval<Tm> extends Visitor<Tm,Integer> {
default Integer Zero() {
return 0;
}
default Integer Succ(Tm t) {
return visitTm(t) + 1; //递归调用visitTm方法遍历子表达式
}
}

在定义具体的访问者时,Tm保持抽象并通过递归调用visitTm方法实现对子表达式的遍历。这里运用Java 8引入的default methods使得访问者不仅可以扩展还能利用接口多重继承来组合访问者。

扩展

现在,前驱可以模块化地定义了:

//扩展访问者接口
interface ExtVisitor<Tm,A> extends Visitor<Tm,A> {
A Pred(Tm t);
}
//扩展求值访问者
interface ExtEval<Tm> extends ExtVisitor<Tm,Integer>, Eval<Tm> {
default Integer Pred(Tm t) {
return visitTm(t) - 1;
}
}

这些接口定义是为了复用而非使用,因此需要额外定义一些类来实体化这些接口才能创建对象:

//数据结构类型
interface CTm {
<A> A accept(ExtVisitor<CTm, A> v);
}
interface VisitTm<A> extends ExtVisitor<CTm,A> {
default A visitTm(CTm tm) {
return tm.accept(this);
}
}
//工厂模式
class Factory implements ExtVisitor<CTm,CTm>, VisitTm<CTm> {
public CTm Zero() {
return new CTm() {
public <A> A accept(ExtVisitor<CTm, A> v) {
return v.Zero();
}};}
public CTm Succ(CTm t) {
return new CTm() {
public <A> A accept(ExtVisitor<CTm, A> v) {
return v.Succ(t);
}};}
public CTm Pred(CTm t) {
return new CTm() {
public <A> A accept(ExtVisitor<CTm, A> v) {
return v.Pred(t);
}};}
}
class EvalImpl implements ExtEval<CTm>, VisitTm<Integer> {}

至此,我们可以用工厂创建表达式并用访问者对其进行遍历:

Factory f = new Factory();
CTm t = f.Pred(f.Succ(f.Zero()));
t.accept(new EvalImpl()); // 0

用EVF框架简化代码

可以看到可扩展访问者模式使用起来更加繁琐(例如为实例化所定义的类)。所幸,这些样板代码大多可以通过EVF框架自动生成。用户仅需在描述数据结构的接口上加上@Visitor注解,例如:

@Visitor interface Peano<Tm> {
Tm Zero();
Tm Succ(Tm t);
}

在IDE中(如Eclipse)保存后即可生成包括访问者接口,数据结构类型,工厂等在内的源文件:

访问者的实现跟先前所示的手写几乎一致,唯一的区别在于使用了生成的代码:

interface Eval<Tm> extends GPeano<Tm,Integer> { /*同上*/ }

这里GPeano是生成的扩展访问者接口。类似地,扩展访问者的EVF定义如下:

@Visitor interface ExtPeano<Tm> extends Peano<Tm> {
Tm Pred(Tm t);
}
interface ExtEval<Tm> extends GExtPeano<Tm,Integer>, Eval<Tm> { /*同上*/ }

由于EVF生成了包括数据结构、工厂等繁琐代码,用户只需要定义一些类来实体化访问者即可:

class EvalImpl implements ExtEval<CTm>ExtPeanoVisitor<Integer> {}
ExtPeano<CTm> f = new ExtPeanoFactory();
CTm t = f.Pred(f.Succ(f.Zero()));
new EvalImpl().visitTm(t); // 0

遍历模版

另一大部分样板代码来源于遍历复杂的抽象语法树(AST)。很多情况下我们只关心某些特定节点,而其它大部分节点什么也不做或是只做简单地递归调用。遍历模版预定义了AST遍历,通过继承遍历模版,我们仅需重写(override)所关心的节点。遍历可以粗略地分为:查询(query)和变换(transformation)—— 前者遍历AST计算一个值,后者构造一个新的AST。EVF生成了多种遍历模版供用户灵活选择。

查询

假设我们要为一个复杂的语言定义一些操作:

@visitor interface ComplexLang<Exp> {
Exp Var(String x);
... // 其它许多语言结构省略
}

首先是一个查询操作,收集一个表达式中用到的所有变量名集合。其定义如下:

interface CollectVars<Exp> extends ComplexLangQuery<Exp, Set<String>> {
default Monoid<Set<String>> m() {
return new SetMonoid<>();
}
@Override default Set<String> Var(String x) {
return Collections.singleton(x);
}
}

通过继承生成的ComplexLangQuery模版,我们只需要重写Var访问方法以及提供一个SetMonoid对象作为m方法的实现即可。ComplexLangQuery调用了Monoid接口定义了两个方法emptyjoin来为各访问方法提供了缺省定义。其中empty表示缺省值,用于实现Var这类不包含子表达式的访问方法而join是一个二元操作符用来结合子表达式的计算结果。这里集合就是幺半群(monoid)的一个实例,其中empty是空集而join是并集操作。其它的幺半群实例包括加、乘、列表等。

变换

接下来是一个变换的例子,将表达式中的某个变量替换成另一个一个表达式的操作定以如下:

interface Subst<Exp> extends ComplexLangTransform<Exp> {
String x(); //变量名
Exp y(); //替换成的表达式
@Override default Exp Var(String z) {
return z.equals(x()) ? y() : alg().Var(z);
}
}

通过继承生成的ComplexLangTransform模版,我们只需要重写Var访问方法ComplexLangTransform模版实现访问方法的方式是对子表达式递归调用访问者并用抽象工厂alg重新构造遍历后的表达式。

EVF的实现

EVF运用Java注解处理器(Java Annotation Processor)在编译期生成一系列源文件。主要用到了javax.annotation.processing和javax.lang.model两个库。前者提供了包括AbstractProcessor在内的注解处理器基础设施,后者用于分析Java AST。EVF的实现约800行代码。

第二部分:Castor2

受限于Java注解处理器和Java语言本身,EVF有如下局限性:

  • 不支持对用户代码的直接简化
  • 对模式匹配支持不佳
  • 仅支持函数式、树结构的访问者

针对这些缺陷,我们在EVF基础之上开发了基于Scala语言的Castor框架。作为一个同时支持函数式和面向对象范式的语言,Scala有着更简洁的语法,原生的模式匹配支持,更强大的类型系统以及更好的元编程支持。

一种基于Scala的可扩展访问者模式

trait Peano {
type TmV <: TmVisit
trait Tm {
def accept(v: TmV): v.OTm
}
case object Zero extends Tm {
def accept(v: TmV) = v.zero
}
case class Succ(t: Tm) extends Tm {
def accept(v: TmV) = v.succ(this)
}
trait TmVisit { _: TmV =>
type OTm
def zero: OTm
def succ: Succ => OTm
def apply(t: Tm) = t.accept(this)
}
trait Eval extends TmVisit { _: TmV =>
type OTm = Int
def zero = 0
def succ = x => this(x.t) + 1
}
}

相较于Java版,Scala版可扩展访问者的编码有几大不同之处:

  1. 用嵌套的trait来定义,通过mixin-composition实现扩展和组合。
  2. 不同于Java版从访问者接口角度出发,Scala版从类层次结构角度出发,将 accept方法的参数类型声明为类型成员TmV来实现和特定的访问者接口的解耦。通过将TmV限制为TmVisit的子类型(subtype),我们可以调用TmVisit声明的访问方法来实现ZeroSuccaccept方法。
  3. ZeroSucc前面加上了case关键字来获得Scala原生模式匹配的支持。
  4. TmVisit中访问方法的返回值类型也声明为类型成员。这么做的好处是当访问者被继承时,返回值类型不必重复实例化。
  5. TmVisit定义了apply语法糖,将x.t.accept(this)简化为this(x.t)。注意到这里可以将this作为参数传给accept的原因是用self-type annotation声明了自身类型是TmV

扩展

下面的代码同时扩展的数据结构和操作:

trait ExtPeano extends Peano {
type TmV <: TmVisit
case class Pred(t: Tm) extends Tm {
def accept(v: TmV) = v.pred(this)
}
trait TmVisit extends super.TmVisit { _: TmV =>
def pred: Pred => OTm
}
trait Eval extends super.Eval with TmVisit { _: TmV =>
def pred = x => this(x.t) - 1
}
}

ExtPeano中定义了一个新的case类Pred。相应地,我们扩展了访问者接口,声明了pred访问方法。通过covariant refinementTmV约束为扩展访问者接口的子类型我们得已调用pred来实现Predaccept方法。可以看到,使用嵌套的trait的一个好处是不必为扩展的访问者起新名字。

类似于Java版本中将interface实体化为class的步骤,Scala版本需要将trait实体化为object,定义如下:

object ExtPeano extends ExtPeano {
type TmV = TmVisit
object eval extends Eval
}

类型成元TmV绑定为TmVisiteval实体化了访问者Eval

导入ExtPeano即可构造和求值表达式,例如:

import ExtPeano._
val t = Pred(Succ(Zero))
eval(t) // 0

用Castor框架简化代码

用Castor框架简化后的代码如下:

@family trait Peano {
@adt trait Tm {
case object Zero
case class Succ(t: Tm)
}
@visit(Tm) trait Eval {
type OTm = Int
def zero = 0
def succ = x => this(x.t) + 1
}
}

最外层的trait用@family注解,数据结构使用@adt trait定义,而其case写在trait里面并且不必显示地写出extends语句(类似的语法在最近发布的Scala 3中的enum采纳)。EvalTm的访问者上用注解@visit(Tm)表明。可以看到,访问者接口和其它高级的语言特性如带有类型约束的类型成员、self-type annotation等均由Castor隐式地生成,使得Castor容易上手使用。

类似地,扩展的定义如下:

@family trait ExtPeano extends Peano {
@adt trait Tm extends super.Tm {
case class Pred(t: Tm)
}
@visit(Tm) trait Eval extends super.Eval {
def pred = x => this(x.t) - 1
}
@visit(Tm) trait Print {
type OTm = String
def zero = "Zero"
def succ = x => "(Succ " + this(x.t) + ")"
def pred = x => "(Pred " + this(x.t) + ")"
}
}

Castor为带有@family注解的trait自动生成了伴生对象,可以直接导入用户代码。

GADT和模式匹配

假设我们想进一步扩展我们的例子让它支持布尔值和if表达式:

case object TmTrue
case object TmFalse
case class TmIf(t1: Tm, t2: Tm, t3: Tm)

合法的if表达式要求t1为布尔类型且t2t3类型相同。类似TmIf(TmZero,TmTrue,TmFalse)这样的非法表达式一般通过定义类型检查访问者来剔除。

GADT

一种更好的做法是用GADT(Generalized Algebraic Data Types,泛化代数数据类型)嵌入类型约束,通过宿主语言的类型系统来阻止非法表达式的构建。这一点特别适用于实现EDSL(Embedded Domain-Speicific Languages,嵌入式领域特地语言)。用GADT定义的数据结构如下(注:可以模块化地定义两个子语言再合并,为节省篇幅将其定义在一起):

@adt trait Tm[A] {
case object Zero extends Tm[Int]
case class Succ(t: Tm[Int]) extends Tm[Int]
case class Pred(t: Tm[Int]) extends Tm[Int]
case object TmTrue extends Tm[Boolean]
case object TmFalse extends Tm[Boolean]
case class TmIf[A](t1: Tm[Boolean], t2: Tm[A], t3: Tm[A]) extends Tm[A]
}

Tm的定义引入了类型参数A。通过显示的extends语句对A不同的实例化来区隔出表达式的类型。可以看到TmIf的定义约束了合法的if表达式的形式,即t1Tm[Boolean]t2t3类型一致为Tm[A]。现在TmIf(Zero,TmTrue,TmFalse)将不被类型系统接受因为TmZero的类型是Tm[Int]

模块化的大步(big-step)语义

布尔值和if表达式的扩展带来的第二个问题是如何定义大步语义求值访问者。已有的Eval定义需要修改否则无法复用:

@visit(Tm) trait Eval {
type OTm = Int | Boolean
def zero = 0
def succ = x => this(x.t) match {
case v: Int => v + 1
case _ => throw new RuntimeException
}
...
}

访问者的返回值类型修改为联合类型(union types)Int | Boolean来表示返回值类型既可能是Int也可能是Boolean。此外,子表达式的遍历结果需要额外的处理来识别出期待的返回值类型。

这种方式定义的大步语义是不模块化的,因为每当有新的表达式类型引入时(比如浮点数)时,既有的求值访问者需要修改返回类型否则不兼容。

而GADT使得大步语义访问者的定义变得简洁且模块化:

@visit(Tm) trait Eval {
type OTm[A] = A
def zero = 0
def succ = x => this(x.t) + 1
def pred = x => this(x.t) - 1
def tmTrue = true
def tmFalse = false
def tmIf[A] = x => if (this(x.t1)) this(x.t2) else this(x.t3)
}

返回值类型与表达式携带的类型参数一致,即类型为Tm[Int]的表达式将返回IntTm[Boolean]的表达式返回Boolean。子表达式的遍历不需要做额外处理且既有的访问者不受新引入的表达式类型影响。

模块化的小步(small-step)语义

Castor的优势进一步地反映在小步语义的定义上:

@default(Tm) trait Eval1 {
type OTm[A] = Tm[A]
def tm[A] = x => throw NoRuleApplies
override def tmIf[A] = {
case TmIf(TmTrue,t2,_) => t2
case TmIf(TmFalse,_,t3) => t3
case TmIf(t1,t2,t3) => TmIf(this(t1),t2,t3)
}
...
}

小步语义逐步地改写表达式直到其不能再被改写,因此访问者返回类型就是表达式本身。一个表达式的小步语义可能有多条规则,比如if表达式,若t1为真则改写为t2,若t1为假则改写为t3,否则继续改写t1。而访问方法仅揭露当前表达式最外层的形式,要识别出子表达式的形式一般需要定义额外的访问者写起来比较繁琐。所幸Castor使用了case class定义层次结构,可以用Scala原生的模式匹配来识别子表达式。tmIf的定义用三条case语句分别对应上述三种情形。注意到Eval1的注解是default,表示它使用了带有缺省实现的模版。其缺省定义抛出一个异常,表示表达式要么已经是个值了要么是非法的。

除了GADT和模式匹配,Castor还提升了一些EVF所欠缺的表达能力,比如图结构,命令式访问者等。受限于篇幅就不一一展开说明了。

Castor的实现

Castor使用了Scalameta元编程库来分析、变换和生成Scala程序。不同于Java,Scala提供了一种更简洁、安全的操纵Scala语法树的方式称为quasiquotes。Quasiquotes既可用于构建也可用于匹配AST。例如, q"trait Tm"等价于如下AST定义:

Defn.Trait(Nil, Type.Name("Tm"), 
Nil, Ctor.Primary(Nil, Ctor.Ref.Name("this"), Nil),
Template(Nil, Nil, Term.Param(Nil, Name.Anonymous(), None, None), None))

相较于EVF,Castor注解处理器的代码行仅是其1/4。

案例分析

我们分别用EVF和Castor重构了《Types and Programming Languages》这一编程语言经典书籍中的解释器实现。该书逐步新的语言特性,从一开始的数值、布尔慢慢扩展无类型lambda演算、简单类型lambda演算、let表达式、record等。其解释器的实现是将原始语言特性的实现复制黏贴到新的扩展语言实现中,因而是不模块化的。我们将语言特性分离出来,使其能够模块化的扩展和复用,如下图所示:

灰色方框代表10个原始语言,白色方框代表抽取出来的语言特性,箭头代表依赖。可以看到这些特性在这些语言中被广泛复用。

下面的表格比较这10个解释器分别用EVF、Castor以及Scala的实现:

可以看到代码行数显著下载,同为Scala语言的实现,Castor实现的代码量不到其一半。受限于Java语言,EVF实现比Castor多用了400多行,但依然比非模块化的Scala代码少1000多行。

当然模块化带来了间接性,对性能造成影响。为了评估这个影响我们比较了同为Scala语言的两个实现,用随机生成10000个表达式计算10次求值的平均时间得出下图:

Castor比Scala慢1.35x (arith)至3.92x (fullsub),越复杂的语言性能下降得越多,但还在可接受的范围内。

结语

表达式问题是检验模块化和可扩展性的根本问题。可扩展访问者模式给出了表达式问题的一种解决方案。可扩展访问者模式的复杂性在很大程度上可以通过元编程自动生成代码来消除。当然元编程不是万灵丹,可能会造成调试的困难并且要求用户对生成的代码有所了解。


  1. Weixin Zhang and Bruno C. d. S. Oliveira . EVF: An Extensible and Expressive Visitor Framework for Programming Language Reuse
    In 31st European Conference on Object-Oriented Programming (ECOOP), 2017.
  2. Weixin Zhang and Bruno C. d. S. Oliveira. Castor: Programming with extensible generative visitors , In Science of Computer Programming, 2020

· 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. 用户编程的实时反馈、互动、调试方面比较困难

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