为一个 Javascript 库设计一个以用户为中心的应用程序编程接口 (API)
在之前的文章中,我简要提及了测试驱动开发(TDD)如何帮助我为一个我当时正在开发的小型库设计一个更好的开发者体验。这一次,我想要重点介绍一下关于 API 设计相关的内容
问题描述
我的项目围绕着一个简单的概念-正则表达式领域特定语言(DSL)很难编写,几乎好像他是为计算机设计的,而不是为人类设计的。
我相信你们在某种程度上都熟悉这个问题,编写一个正则表达式可能会很痛苦,尤其是当他变得更长、更复杂的时候。他很难读懂,很难维护,并且特别难复用正则表达式的某些部分。那么,我要怎样才能让自己和其他人更轻松地编写正则表达式模式呢
!重要说明
这个文章中的代码是无法正常运行的,他只是反映了我的一个粗略的草稿,展示了测试驱动开发(TDD)是如何与之关联的,并且是一种用代码将正在讨论的内容可视化的方法。后续会有一篇或多篇关于这个项目的代码实现文章。
目标陈述
我的主要目标是创建一种更自然的方式来表达正则表达式。次要目标是,我还想让模式的复用变得更简单,这样从概念上来说,人们就可以创建正则表达式模式的构建块或库。最后,和我创建的任何东西一样,我希望使用这个库编写的代码易于阅读和维护。
自然语言描述
如前所述,主要需求是找到一种更自然的方式来描述正则表达式。为此,我希望能够在代码中表达出我用自然语言向略懂该概念的同事描述的内容。这意味着像 /.*/ 这样的正则表达式大致可以描述为 “任意字符出现零次或多次”。
import { describe, it, expect } from 'vitest'
import './matchers.js'
import { anyCharacter, zeroOrMore } from '../src/myDraft.js'
describe('Natural language', () => {
it('should read like a sentence', () => {
// 这读起来像一个句子吗
const expression = new RegExp(zeroOrMore(anyCharacter))
expect(expression).toMatch('a')
})
})
// 我所设想的底层原理的伪代码近似表示
const anyCharacter = '.'
const zeroOrMore = (pattern) => `${pattern}*`
注意我是如何基于这样一个事实的,即最终用户对领域特定语言(DSL)有一定程度的了解。在这里,最终用户非常重要,因为我们正努力让他们的生活更轻松。不熟悉正则表达式的人会由于不熟悉其核心结构而用更冗长的方式来描述,所以试图针对这些用户的话效率会较低。
可复用性
这个项目相对不那么宏大的目标是让正则表达式模式的复用变得更简单。这意味着我应该能够创建一个模式,然后在多个地方使用它。归根结底,就是要创建所有必要的构建块来实现这一点,并确保它们能够以对最终用户有意义的所有方式进行组合。
import { describe, it, expect } from 'vitest'
import './matchers.js'
import { digit, range, repeat, or } from '../src/myDraft.js'
describe('Reusability', () => {
it('should be able to create reusable blocks', () => {
// 可重复使用的构建模块
const hex = or(digit, range('a', 'f'))
//我可以在多个地方复用这个模块。
const threeHexes = new RegExp(repeat(hex, 3))
expect(threeHexes).toMatch('a1b')
const sixHexes = new RegExp(repeat(hex, 6))
expect(sixHexes).toMatch('a1b2c3')
})
})
// 我所设想的底层原理的伪代码近似表述
const digit = '\\d'
const range = (start, end) => `[${start}-${end}]`
const repeat = (pattern, times) => `${pattern}{${times}}`
const or = (...patterns) => `(${patterns.join('|')})`
可读性与可维护性
如果可以这么说的话,我们的 “健康指标” 就是使用这个库编写的代码的易读性和可维护性如何。正则表达式的主要麻烦在于,要花大量时间去弄清楚上一个人(或者过去的你自己)做了什么,以及他们是如何得出这个模式的。然后,还要花费同样多甚至更多的时间去琢磨如何在不破坏原有功能的前提下对其进行修改。
// 这是一个八位字节(0 到 255)的模式
// 在这里耗费的时间:42 个小时
// 如果你正在读这段内容,就把计数增加 1
;/(?:(?:0)\*(?:(?:(?:[0-1])?(?:\d){1,2})|(?:2[0-4]\d)|(?:25[0-5])))/
自然而然地,我们希望避免出现这种情况。因此,至关重要的是,所生成的代码无论你是其编写者,还是第一次看到它,都应同样易于理解。此外,对代码进行修改以及解释修改的原因都应该是轻松的,从而降低所有相关人员的入门门槛。
import { describe, it, expect } from 'vitest'
import './matchers.js'
import {
or,
concat,
zeroOrMore,
zeroOrOne,
range,
repeat,
digit,
} from '../src/myDraft.js'
describe('Readability & maintainability', () => {
it('should be easy to read and maintain', () => {
// 这是一个八位字节(0 - 255)的模式
// 它可读性很强,而且如果我需要修改它,也很容易操作。
const octet = concat(
zeroOrMore(0),
or(
// 0-199
concat(zeroOrOne(range(0, 1)), repeat(digit, { times: [1, 2] })),
// 200-249
concat(2, range(0, 4), digit),
// 250-255
concat(25, range(0, 5))
)
)
expect(new RegExp(octet)).toMatch('255')
})
})
// 这是我所设想的底层原理的伪代码近似表达
const or = (...patterns) => `(${patterns.join('|')})`
const concat = (...patterns) => patterns.join('')
const zeroOrMore = (pattern) => `${pattern}*`
const zeroOrOne = (pattern) => `${pattern}?`
const range = (start, end) => `[${start}-${end}]`
const repeat = (pattern, { times }) => `${pattern}{${times.join(',')}}`
const digit = '\\d'
API 设计
在明确了目标和总体方向之后,是时候深入关注开发者体验了。到目前为止,我通过测试驱动开发(TDD)所做的草案,即便还不能完全正常运行,也让我对 API 应该呈现出怎样的面貌有了清晰的认识。这些草案也让我意识到作为最终用户可能会面临的潜在问题和麻烦。
命名
在计算机科学领域,只有两件难事:缓存失效和给事物命名。
—— 菲尔・卡尔顿
命名是件难事,但它也极其重要。还记得最初的那个假设吗,即最终用户对正则表达式有一定程度的了解?这意味着我们可以使用那些已经确立的术语,而不是试图让这个东西对于那些不知道什么是字符类或限定符的人来说更易于理解。
// 好
const wordBoundary = '\\b'
// 差
const wordSeparator = '\\b'
// 好
const whitespaceCharacter = '\\s'
// 差
const space = '\\s'
// 好
const zeroOrOne = '?'
// 差
const maybe = '?'
// 好
const oneOrMoreLazy = '+?'
// 差
const oneOrMoreMatchingAsFewAsPossible = '+?'
要记住,我们并非要重新创造一门语言,只是想让它更易于使用。通过使用大家已经熟悉的术语,我们能让用户更轻松地学习该库的 API,而不用再教他们一套全新的词汇。
函数参数
任何 API 中至关重要的一个方面就是其函数的参数。参数的数量、顺序、类型以及默认值,都会影响到 API 使用的便捷程度。在这里,歧义是大忌,但还有其他方面也需要考虑。
// 糟糕的设计 —— 等等,那个第二个 “true” 到底又是什么意思呢?
const result = myFunction(10, true, false, true)
当我们着眼于可扩展性时,很自然地就会想到剩余参数和可变参数函数。这将使我们能够更轻松地在模式的开头、结尾或中间添加更多模式,而不会产生太多阻碍。
// 糟糕 —— 参数不一致,造成了较高的认知负担
or('a', digit, concat([range({ start: 'a', end: 'z' }), wordBoundary]))
// 好 —— 参数结构一致,认知负担低
or('a', digit, concat(range('a', 'z'), wordBoundary))
遵循这一设计原则将减少学习 API 时的认知负担,因为一切都会是一致且可预测的。但这就引出了下一个问题:特殊参数该如何处理呢?
特殊参数
自然地,无论设计多么出色和优雅,任何设计都会有边缘情况。就这个小库而言,像 namedGroup 或 repeat 这样的一些模式将需要一些特殊参数。
虽然一开始我认为可以把这些参数放在函数末尾就万事大吉了,但这会产生一些问题。首先,用户不会立刻意识到自己是否遗漏了某个重要参数。其次,这会让库的代码比原本应有的复杂得多。最后,在浏览复杂代码时,可读性也不佳。
为了保持一切的一致性,这些特殊参数应该在函数签名中排在前面。这样不仅更容易发现它们,还能让它们作为 API 的一部分更易于被发现,同时确保开发者意识到这些参数是必需的。
// 假设 repeat 函数期望传入:
//- 一个模式(如 'a')
//- 重复次数(如 3)
//- 一个表示是否进行惰性匹配的布尔值(如 true)
// 糟糕 —— 特殊参数放在末尾
const result = repeat('a', 3, true)
// 更好的做法 —— 将特殊参数放在开头
const result = repeat(3, true, 'a')
注意到这里还有其他问题吗?比如在这两个例子中,很难弄清楚模式重复的次数以及布尔值的作用,这该怎么办呢?接下来我们就试着解决这个问题吧!
具名参数
我希望 JavaScript 拥有的特性之一是类似 Ruby 的关键字参数。幸运的是,多年来我们一直在使用对象,而且早在 ES6 出现的时候,就已经使用对象解构来实现类似的目的了。
鉴于我们已经有了相应的工具,并且已经确定了特殊参数的位置,那么解决让这些参数易于识别的问题似乎就很简单了。我们将使用对象来处理特殊参数,这样用户就必须明确说明他们传入的是什么。
// 假设 repeat 函数需要以下参数:
//- 一个模式(例如 'a')
//- 重复次数(例如 3)
//- 一个表示是否进行惰性匹配的布尔值(例如 true)
// 糟糕之处 —— 特殊参数缺乏上下文,难以解读
const result = repeat(3, true, 'a')
// 好的做法 —— 特殊参数都有命名,易于理解
const result = repeat({ times: 3, lazy: true }, 'a')
边缘情况
即使已经建立了所有这些体系,很可能仍会存在一些不符合常规模式的特殊情况。我们将逐案处理这些情况,但确立一个通用策略将有助于我们做出合理的决策,这些决策对用户来说是有意义的。
反向引用
我想讨论的第一个情况是反向引用。它们是一种特殊的模式,指向先前捕获的组。与其他模式的不同之处在于,它们不是模式本身,而是对某个模式的引用,通过名称或索引来实现。
我们在这里可以使用具名参数的方法,但我觉得这会给用户带来不便。毕竟,我们只需要向函数传递一个参数,而且这个参数是什么非常明确。
// 不太好 —— 为单个参数使用命名参数
backreference({ name: 'group' })
backreference({ index: 1 })
// 或许更好 —— 单个参数不使用命名参数
backreference('group')
backreference(1)
💬 备注
我对此并没有特别强烈的偏好,我认为两种方式都可行。我决定选择第二种方案,因为在我看来它更简单,但我也认识到这里可能没有绝对正确的答案。
限定符
在这些例子中,重复限定符是最常见的,这是有充分理由的。它是一种非常常见且用途广泛的模式。然而,以一种合理的方式设计它有点棘手。我们需要支持以下三种语法形式: Exact match (exactly 3: {3}) Range (between 3 and 5: {3,5}) Open-ended (3 or more: {3,}) 精确匹配(恰好 3 次:{3}) 范围匹配(3 到 5 次之间:{3,5}) 无上限匹配(3 次或更多:{3,})
我首先想到的是采用 { 最小值,最大值 } 或 { 次数 } 的方式。毕竟,这样能清楚地区分不同情况,而且没什么歧义。然而,我还是忍不住担心多种语法选项并非最佳选择。我也不太喜欢在指定无上限范围时只使用单个最小值或最大值选项的做法。
// 不算太糟 —— 能清楚各个参数的含义
repeat({ times: 3 }, pattern) // {3}
repeat({ min: 3, max: 5 }, pattern) // {3,5}
repeat({ min: 3 }, pattern) // {3,}
repeat({ max: 5 }, pattern) // {0,5}
这有点让人左右为难,但我还是决定采用统一的 {times} 方式。这样能确保用户只有一种操作方式。如果设计得当,它也会显得更自然:单个数字表示精确匹配,一个数组表示范围,并且我们可以使用空数组值来指定无上限的情况。
// 或许更好 —— 采用统一的方法
repeat({ times: 3 }, pattern) // {3}
repeat({ times: [3, 5] }, pattern) // {3,5}
repeat({ times: [3] }, pattern) // {3,}
repeat({ times: [, 5] }, pattern) // {0,5}
我倾向于认为,这种与原始领域特定语言(DSL)的相似之处,能让熟悉正则表达式的人回想起他们编写表达式的方式,从而更容易记住这种语法。而且很自然地,任何不是精确匹配的情况都会使用数组来表示范围。当然,空数组值似乎很自然地适用于无上限的范围情况。
字符集
最后但同样重要的是,字符集本身就是一种特殊情况。一个范围有起始和结束字符,但如果我们想要匹配一组并非连续范围的字符中的任意字符,那该怎么办呢?
// 好 —— 简单的范围设定
range('a', 'z') // [a-z]
// 不太好 —— 多个范围设定
or(range('a', 'c'), range('x', 'z')) // (?:[a-c]|[x-z])
// 更糟糕 —— 使用字符集
or('a', 'b', 'c', 'x', 'y', 'z') // (?:a|b|c|x|y|z)
自然而然地,我们希望这一过程能够顺畅无阻。为了达到这个效果,摒弃范围函数(range function),转而创建一个 “从…… 中任选其一” 函数(anythingFrom function)是有意义的。这个函数会很特别,因为它既要能够处理常规参数,又得能处理范围。这就引出了一个问题,即如何区分这两种情况。嗯,一个范围不过是有两个端点,所以一个包含两个元素的数组应该就足够了。
// 不错 - 简单的范围设定
anythingFrom(['a', 'z']) // [a-z]
// 好 —— 多个范围设置
anythingFrom(['a', 'c'], ['x', 'z']) // [a-cx-z]
// 好 —— 使用字符集
anythingFrom('a', 'b', 'c', 'x', 'y', 'z') // [abcxyz]
同样地,我们随后可以创建一个 “除…… 之外任选其一” 函数(anythingBut function),该函数会使用 [^] 而非 [] 来匹配除了所提供字符之外的任何字符。我并不完全确定这种方法是否完美,但阅读起来很合理,编写时也不会觉得陌生。
💬 备注
我也能想到范围(range)和 “或”(or)的实现方式或许可行,但我认为这些使用场景非常常见,从长远来看,提供一种更直接的表达方式是有益的,即便这会给 API 增添一些复杂度。
结论
这是一段相当漫长的探索过程,但我认为,以用户为中心、精心设计的 API 对于任何库或工具的成功都至关重要。设计良好的 API 会让一个库用起来得心应手,而设计糟糕的 API 则会让使用它成为一场噩梦。毕竟,开发者体验和用户体验并没有太大区别 —— 关键都在于让用户的生活更轻松!