新闻资讯

新闻资讯 行业动态

前端 DSL 实践指南——内部 DSL 风格指南

编辑:008     时间:2020-02-20

风格 1:级联方法

级联方法是内部 DSL 的最常用模式,我们先以原生 DOM 操作作为反面案例:

const userPanel = document.querySelector('#user_panel');

userPanel.addEventListener('click', hidePanel);

slideDown(userPanel); //假设这是一个已实现的动画封装 const followButtons = userPanel.querySelectorAll('button');

followButtons.forEach(node => {
  node.innerHTML = 'follow';
});

相信大家很难一眼看出做了什么,但假如我们使用远古框架 jQuery 来实现等价效果:

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

就很容易理解其中的含义:

  1. 找到 #user_panel 节点;
  2. 设置点击后隐藏它;
  3. 向下动效展开;
  4. 然后找到它下面的所有 button 节点;
  5. 为这些按钮填充 follow 内容。

级联方法等链式调用风格的核心在于调用不再设计特定返回值,而是直接返回下一个上下文(通常是自身),从而实现级联调用。

风格 2:级联管道

级联管道只是一种级联方法的特殊应用,代表案例就是 gulp:

gulp 是一种类似 make 构建任务管理工具,它将文件抽象为一种叫 Vinyl(Virtual file format) 的类型,抽象文件使用 pipe 方法依次通过 transformer 从而完成任务。
gulp.src('./scss/**/*.scss')
  .pipe(plumber())
  .pipe(sass())
  .pipe(rename({ suffix: '.min' }))
  .pipe(postcss())
  .pipe(dest('./css'))

很多人会觉得 gulp 似曾相识,因为它的设计哲学是衍生自 Unix 命令行中的管道,上例可以直接类比以下命令:

cat './scss/**/*.scss' | plumber | sass | rename --suffix '.min' | postcss | dest './css/'

上述针对 Pipeline 的抽象也有用常规级联调用的方式来构建 DSL,比如 chajs:

cha()
  .glob('./scss/**/*.scss')
  .plumber()
  .sass()
  .rename({ suffix: '.min' })
  .postcss()
  .dest('./css')
上述只是 DSL 的语法类比,chajs 不一定有 plumber 等功能模块。

由于减少了多个 pipe,代码显然是有减少的,但流畅度上并没有更大的提升。

其次 chajs 的风格要求这些扩展方法都注册到实例中,这就平添了集成成本,这些集成代码也会影响到 DSL 的流畅度。

cha
  .in('glob', require('task-glob'))
  .in('combine', require('task-combine'))
  .in('replace', require('task-replace'))
  .in('writer', require('task-writer'))
  .in('uglifyjs', require('task-uglifyjs'))
  .in('copy', require('task-copy'))
  .in('request', require('task-request'))

相比之下,gulp 将扩展统一抽象为一种外部 transformer,显然设计的更加优雅。

风格 3:级联属性

级联方法如文章开篇的 (2).weeks().ago() ,其实还不够简洁,存在明显的语法噪音,(2).weeks.ago 显然是个更好的方式,我们可以通过属性静态代理来实现,核心就是 Object.defineProperty(),它可以劫持属性的 setter 与 getter:

const hours = 1000 * 60 * 60; const days = hours * 24; const weeks = days * 7; const UNIT_TO_NUM = { hours, days, weeks }; class Duration { constructor(num, unit) { this.number = num; this.unit = unit;
  }
  toNumber() { return UNIT_TO_NUM[this.unit] * this.number;
  }
  get ago() { return new Date(Date.now() - this.toNumber());
  }
  get later() { return new Date(Date.now() + this.toNumber());
  }
} Object.keys(UNIT_TO_NUM).forEach(unit => { Object.defineProperty(Number.prototype, unit, {
    get() { return new Duration(this, unit);
    }
  });
});

将上述代码粘贴到控制台后,再输入 (2).weeks.ago 试试吧,可以看到级联属性可以比级联方法拥有更简洁的表述,但同时也丢失了参数层面的灵活性。

可能有人会疑问为何不是 2.weeks.ago,这就是 JavaScript 的一个「Feature」了。唯一的解决方式就是去使用诸如 CoffeeScript 那些语法噪音更小的宿主语言吧。

在 DSL 风格中,无论是级联方法、级联管道还是级联属性,本质都是链式调用风格,链式调用的核心是上下文传递,所以每一次调用的返回实体是否符合用户的心智是 DSL 设计是否成功的重要依据。

风格 4:嵌套函数

开发中也存在一些层级抽象的场景,比如 DOM 树的生成,以下是纯粹命令式使用 DOM API 来构建的例子:

const container = document.createElement('div');
container.id = 'container'; const h1 = document.createElement('h1');
h1.innerHTML = 'This is hyperscript'; const list = document.createElement('ul');
list.setAttribute('title', title); const item1 = document.createElement('li'); const link = document.createElement('a');
link.innerHTML = 'One list item';
link.href = href;
item1.appendChild(link1); const item2 = document.createElement('li');
item2.innerHTML = 'Another list item';
list.appendChild(item1);
list.appendChild(item2);

container.appendChild(h1);
container.appendChild(list);

这种写法略显晦涩,很难一眼看出最终的 HTML 结构,那如何构建内部 DSL 来流畅解决这种层级抽象呢?

有人就尝试用类似链式调用的方式去实现,比如 concat.js:

builder(document.body)
  .div('#container')
    .h1().text('This is hyperscript').end()
    .ul({title})
      .li()
        .a({href:'abc.com'}).text('One list item').end()
      .end()
      .li().text('Another list item').end()
    .end()
  .end()

这似乎比命令式的写法好了不少,但构建这种 DSL 存在不少问题:

  1. 因为链式调用的关键是上下文传递,在层级抽象中需额外的 end() 出栈动作实现上下文切换。
  2. 可读性强依赖于手动缩进,而往往编辑器的自动缩进往往会打破这种和谐。

所以一般层级结构抽象很少使用链式调用风格来构建 DSL,而会更多的使用基本的嵌套函数来实现。

我们以另一个骨灰开源项目 DOMBuilder 为例:

这里先抛开 with 本身的使用问题
with(DOMBuilder.dom) { const node =
    div('#container',
      h1('This is hyperscript'),
      ul({title},
        li(
            a({herf:'abc.com'}, 'One list item')
        ),
        li('Another list item')
    )
}

可以看到层级结构抽象使用嵌套函数来实现会更流畅。

如果使用 CoffeeScript 来描述,语法噪音可以降到更低,可以接近 pug 这种外部 DSL 的语法:

div '#container',
  h1 'This is hyperscript' ul {title},
    li(
      a href:'abc.com', 'One list item' )
    li 'Another list item'
CoffeeScript 是一门编译到 JavaScript 的语言,它旨在去除 JavaScript 语言设计上的糟粕,并增加了很多语法糖,影响了很多 JavaScript 后续标准的演进,目前完成了它的历史任务,逐步销声匿迹中。

嵌套函数本质上是将在链式调用中需要处理的上下文切换隐含在了函数嵌套操作中,所以它在层级抽象场景是非常适用的。

另外,嵌套函数在 DSL 的应用类似解析树,因为其符合语法树生成思路,往往可直接映射转换为对应外部 DSL,比如 JSX:

<div id='container'> <h1 id='heading'> This is hyperscript </h1> <ul title={title} > <li><a href={href} > One list item </a></li> <li> Another list item </li> </ul> </div>

嵌套函数并不是万金油,它天然不适合流程、时间等顺序敏感的场景。

如果将风格 2 的级联管道修改为嵌套函数:

执行逻辑与阅读顺序显然不一致,并且会加重书写负担(同时要关心开闭逻辑),极大影响读写流畅度。

风格 5:对象字面量

业界很多 DSL 都类似于配置文件,例如 JSON、YAML 等外部 DSL,它们在嵌套数据展现中有很强的表达力。

而 JavaScript 也有一个适合在此场景构建 DSL 的特性,那就是字面量对象,实际上,JSON(全称 JavaScript Object Notation)正是衍生自它的这个特性,成为了一种标准数据交换格式。

例如在项目 puer 中,路由配置文件选择了 JS 的对象字面量而不是 JSON:

module.exports = { 'GET /homepage': './view/static.html' 'GET /blog': { title: 'Hello' } 'GET /user/:id': (req, res)=>{
        res.render('user.vm')
    }
}

因为 JSON 有一个天然缺陷就是要求可序列化,这极大的限制了它的表达力(不过也使它成为了最流行的跨语言数据交换格式),比如上例最后一条还引入了函数,虽然从 DSL 角度来说变得“不纯粹”了,但功能性却上了一个台阶。这也是为什么一些构建任务相关的 DSL(make、rake、cake、gradle 等)几乎全部都是内部 DSL 的原因。

除此之外,因为对象 key 值的存在,对象字面量也能提高参数可读性,比如:

div({id: 'container', title: 'This is a tip' }) // CoffeeScript Version div id: 'container', title: 'This is a tip'

显然比用词更少的下例可读性更佳:

div('container''This is a tip')
构造 DSL 并非越简洁越好,提高流畅度才是关键。

对象字面量的结构性较强,一般只用来做配置等数据抽象的场景,不适合用在过程抽象的场景。

风格 6:动态代理

之前所列举内部 DSL 的构造方式有一个典型缺陷就是它们都是静态定义的属性或方法,没有动态性。

如上节 [风格4: 嵌套函数] 中的提到 concat.js,它的所有类似 div、p 等方法都是静态具名定义的。而实际上因为 custom elements 特性的存在,这种静态穷举的方式显然是有坑的,更别说 html 标准本身也在不断增加新标签。

而在外部 DSL,这个问题是不存在的,比如我早期写的 regularjs/regular,它内置的模板引擎在词法解析阶段把类似/<(\w+)/的文本匹配为统一的TAG 词法元素,这样就可以避免穷举。

内部 DSL 要实现这种特性,就强依赖宿主语言的元编程能力了。
Ruby 作为典型宿主语言经常会用来证明其强大元编程能力的特性就是 method_missing,这个方法可以动态接收所有未定义的方法,最直接功能就是动态命名方法(或元方法),这样就可以解决上面提到的内部 DSL 都是具名静态定义的问题。

值得庆幸的是在 JavaScript 中也有了一个更强大的语言特性,就是 Proxy,它可以代理属性获取,从而解决上文 concat.js 的穷举问题。

以下并非完整代码,只是简单演示
function tag(tagName){ return {tag: tagName}
} const builder = new Proxy(tag, {
  get (target, property) { return tag.bind(null, property)
  }
})

builder.h1() // {tag: 'h1'} builder.tag_not_defined() // {tag: 'tag_not_defined'}

Proxy 使得 JavaScript 具备了极强的元编程能力,它除了可以轻松模拟出 Ruby 沾沾自喜的 method_missing 特性外,也可以有很多其它动态代理能力,这些都是实现内部 DSL 的重要工具。

风格 7:Lambda 表达式

市面上有大量的查询库使用链式风格,它们非常接近 SQL 本身的写法,比如:

const users = User.select('name') 
  .where('id==1');
  .where('age > 1');
  .sortBy('create_time')

为了将 id==1 等表达式转化为可运行的过滤条件,我们不得不去实现完整的表达式解析器,以最终编译得到等价函数

function(user){ return user.id === 1 }

实现成本非常高,而使用 lambda 表达式可以更低成本地解决这种需求

const users = User.select('name')
  .where(user => user.id === 1);
  .where(user => user.age > 20);
  .sortBy('create_time')

这种应用案例其实早就存在了,比如基于C#的LINQ(Language-Integrated Query),这也是最常活跃在内部 DSL 技术圈的典型案例。

var result = products
    .Where(p => p.UnitPrice >= 20)
    .GroupBy(p => p.CategoryName)
    .OrderByDescending(g => g.Count())
    .Select(g => new { Name = g.Key, Count = g.Count() });

Lambda 表达式本质上是一种直观易读且延迟执行的逻辑表达能力,从而避免额外的解析工作,不过它强依托宿主的语言特性支持(匿名函数 + 箭头表示),并且也会引入一定的语法噪音。

风格 8:自然语言抽象

自然语言抽象即以更贴近自然语言的方式去设计 DSL 的语法,它行得通的基本逻辑是领域专家基本都是和你我一样的自然人,更容易接受自然语言的语法。

自然语言抽象的本质是一些语法糖,和一般 GPPL 的语法糖不一样,
DSL 的语法糖并不一定是最简洁的,反而会加入一些「冗余」的非功能性语法词汇。

举个栗子,在云音乐团队开源的 svrx(Server-X) 项目(一个插件化 dev-server 平台)中,路由是个高频使用的功能,为此我们设计了一套内部 DSL 来方便开发者使用,如下例所示:

get('/blog/:id').to.send('Demo Blog')

put('/api/blog/:id').to.json({code: 200})

get('/(.*)').to.proxy('https://music.163.com')

其中 to 就是个非功能性词汇,但却使得整个语句更容易被自然人(当然也包括我们程序员)所理解使用。

通过自然语言抽象,内部 DSL 的优势在单元测试场景中被发挥的淋漓尽致,比如如果我们裸用类似 assert 的断言方法,单元测试用例可能是这样的:

var foo = '43';

assert(typeof foo === 'number', 'expect foo to be a number');
assert(
  tea.flavors && tea.flavors.length === 3, 'c should have property flavors with length of 3' )

有几个显著待优化的问题:

  1. 命令式的断言语句阅读不直观;
  2. 为了 report 的可读性,需要传入额外的提示语(如expect foo to be a number)。

如果这个 case 基于 chai 来书写的话,可读性会立马上一个台阶:

var foo = '43' // AssertionError: '43' should be a 'number'. foo.should.be.a('number');

tea.should.have.property('flavors').with.lengthOf(3);

可以发现测试用例变得更加易读易写了,而且当断言失败,也会自动根据链式调用产生的状态,自动拼装出更友好的错误信息,特别是当与 mocha 等测试框架结合时,可以直接生成直观的测试报告:

通过增加类似自然语言的辅助语法(动、名、介、副等),可以使得程序语句更直观易懂。

风格总结

本文并未囊括所有内部 DSL 实现风格(比如也有些基于 Decorator 装饰器的玩法),且所列风格都不是银弹,都有其适用场景,它们之间存在互补效应。



原文链接:https://segmentfault.com/a/1190000021791568

郑重声明:本文版权归原作者所有,转载文章仅为传播更多信息之目的,如作者信息标记有误,请第一时间联系我们修改或删除,多谢。

回复列表

相关推荐