一种Restful接口数据剪裁查询语言ScopedRequestLanguage

在日常开发中,前端经常需要和后端进行对接,但由于前后端分离模式下,两端开发者常常在信息同步上(或者说沟通上)存在一些问题,导致对接出问题,比如后端调整数据结构之后,没有及时通知前端,或者由于后端使用了公用方法,导致修改了一处接口之后影响了另外的接口,最终导致上线后前端报错,再比如后端在一次需求中增加了一个信息,但为了他们自己方便,增加了附带了整个对象信息,导致接口撑大,多出很多前端不需要的字段,再比如前端在提交数据时,直接把本地的某些对象信息提交上去,而有些信息后端是不需要的,甚至如果传上去会对原来的数据进行覆盖,最终导致线上数据被破坏。这些情况的出现,都是因为前后端在基于接口通信时,由于双方没有准确了解接口的每一个信息,而导致的。我把这个问题,归纳为“缺乏协议”问题。即,由于前后端开发没有一个强有力的协议,导致在接口的生产和使用上没有规范,在缺乏规范的情况下,衍生出对生产环境造成破坏的极端情况。

为了以最小的代价解决这个问题,我发明了ScopedRequestLanguage,一门Restful接口数据剪裁查询语言,在不对双方工作造成习惯破坏的情况下,通过对数据进行剪裁的方法,最大限度的控制前后端接口交互的上行下行数据的准确性。

灵感与雏形

基于Restful的接口数据交互包含请求方式、url、参数信息(headers)等,同时,无论前端,还是后端,对发送或接收的数据的结构、值的形式等都有一些要求。一个接口的交互往往就包含这些东西了,因此,我们的设计,也是围绕它们展开。

作为查询语言,不需要按照编程语言一样设计,而是专注于查询,例如我们常见的SQL就是类似的语言设计。但和SQL不同的是,我们对计算的要求不高,需要专注于前端数据交互这个非常小的领域。在这个领域,graphql是佼佼者,不过我并不喜欢graphql,因为它的定义太复杂,对于前后端的接入成本都很大,因为它相当于要后端打破原来的restful的设计,改用一种新的规范来写接口,从数据库查询到把数据输出到前端,给后端开发者带来的心智负担实在太重了。除了官方提供的graphql工具,没有足够的生态支持在已经成熟的后端生态圈长期维护api接口。当然,这并不否定graphql的出色,只是成本比较大。

有没有一种比graphql简单很大,不需要什么心智的模式创建一种可以提供类似graphql的能力的语言呢?受graphql的启发,我们想要定义一种基于结构描述的查询语言,如下:

通过对restful api的响应体的结构描述,获得对应的数据。我称这种对应为“数据剪裁”,也就是从完整的api接口中,只获得自己需要的对应字段的信息。这对前端来讲有的时候非常有用。

于是,我们有了第一个部分语法雏形:

{
  hero: {
    name
    height
  }
}

这个结构描述了返回的数据的结构,且前端仅需要这样一个结构,后端api接口可能返回hero的更多信息,但是前端都不需要,因此,前端只会接收到这个结构对应的数据。

我们可以想到前端在使用这段语法时的场景:

request(url, `
  {
    hero: { 
      name
      height 
    }
  }
`)

这样的一个用法看上去还不错,不过感觉非常的弱,甚至鸡肋,它有点像一个配置,而非一门语言。于是,我们把这个请求本身也设计到语言中:

GET "https://api.my.com/hero/{id}" -> {
  name
  height
}

你看,如此一来整个语法的雏形就诞生了,我们用一段文本描述,明确的阐述了基于SRL的文本,是用来做什么的,相信但凡做过web开发的人,都能读懂这段代码。

那么,接下来,我们只需要有一个运行器,去运行这段代码:

const data = await run(`
  GET "https://api.my.com/hero/${id}" -> {
    name
    height
  }
`)

这样,我们就得到了基本的关于整个语言的雏形。接下来,我们围绕这一雏形,对语言进行丰富。

完善功能

SRL语言的雏形一确定,基本上就确定了这门语言的格调,几乎所有的其他功能,都是在这个雏形的基础上衍生、扩展、发散出来。就像做一首曲子,从只有几个连续的音符作为灵感,然后有了整首曲子一样。

作为Restful API查询语言,它要实现什么目标,基于这些目标,要提供什么功能?

在前端,我们从api接口拿数据,其实并不需要什么特别复杂的编程过程,我们的主要目标,就是能够描述出前端请求数据的各种场景。

请求类型

我们已经有了GET请求的场景,那么POST、PUT请求的场景呢?我们来看下我的设计:

POST "https://api.my.com/heros" + {
  name
  height
  age
} -> {
  id
}

我们用+标识符表示这个请求将附带一个请求体,该请求体需要满足绿色部分的结构描述。你可能会想,我发送的请求数据呢?我可能还会发送文件呢。在运行时,我们这一使用它:

const data = await run(`
  POST "https://api.my.com/heros" + {
    name
    height
    age
  } -> {
    id
  }
`, {
  name: 'simolas',
  height: 195,
  age: 32,
  weight: 200,
})

我们在运行时,把要发送的数据通过参数的形式交给运行时引擎,引擎会帮我们剔除掉不需要的多余字段。为什么要对提交的数据进行裁剪?因为提交多余的字段,不仅提高网络阻塞,而且有可能导致多出来的字段覆盖数据库中的值,造成数据破坏。(当然,作为后端接口的开发者,自己也应该做裁剪,这里只是希望在对一些老系统没有这样做的情况下,前端还能提供这样的能力。)

请求参数

除了在url中提供一些search参数外,有的时候我们还需要在headers中提供一些信息给服务端进行识别。我参照CURL的参数写法,如下:

GET "..." -H "Content-Type: application/json" -H "Accept-Type: application/json" -> {
  ...
}

通过参数标记符-H,我们传递了两个Headers信息。我们可以通过-H动态给一些headers信息,例如cookie,或者JWT的token。

并发组合请求

有些情况下,我们需要一次性从多个接口取出数据,把这些来自多个接口的数据拼装成一个给前端使用。于是我们提供一种特定的语法和命令:

GET "..." -> { name } as A

GET "..." -> { height } as B

COMPOSE -> {
  name: (A.name)
  height: (B.height)
}

通过as关键字,我们给一个请求的返回结果取了一个别名,同时,我们提供一个COMPOSE命令,在该命令的结构体中,按照取好的名字,使用对应请求返回体中的数据作为新结构中字段的值。一组命令中,一般只会有一个COMPOSE命令,且在最后进行返回。这样的COMPOSE命令自带了AWAIT全部其他请求的功能。

在一组命令中,只有最后一个命令的返回结果将作为整组语句的执行结果进行返回。

分段式请求

在一些场景下,我们需要先查询某些接口,基于该接口返回的结果,再去另外一个接口查询另外一个信息。于是我们又提供了一种特定的语法和命令。

GET "..." -> { name } as A
GET "..." -> { height, ID } as B
POST "..." + {}

AWAIT A B

POST ".../?name=(A.name)&height=(B.height)" + { id: (B.ID) } -> { weight }

再沿用上面的组合语法的前提下,我们再提供一个AWAIT命令,用于分隔一个组,它的意思指在AWAIT之后的语句,必须等到AWAIT之前的语句执行完毕之后,再开始执行。同时,变量引用语法形式 (..),可以把前面提供的命名拿过来进行读取。基于前面的语法规定,这组语句最终只会返回最后一条语句的结果。

AWAIT后面跟的名字,表示对应的请求必须完成后才可以进入后面的语句的执行。假如某些请求没有被跟在AWAIT之后,意味着这些请求可以继续执行,而不影响AWAIT后面的语句。

细节补充

显然,在实际开发中,我们还会面临一些特殊的要求,这些要求不来自于前端或后端的需求,而是来自使用上述语言进行编码本身。我们需要补充一些细节,来满足编码过程中的一些小心机。

类型?格式化!

在结构体中,你可能会想到,是不是应该规定每个字段的类型?例如,age这个字段应该是10,而后端给你的是"10",这我在前端直接age.toFixed就会报错。类似错用后端返回值的错误经常遇到,那么我们是不是应该对后端返回的数据进行类型检查呢?应该,但没意义。例如上面的例子,我们要的是10,而后端给的是"10",请问后端给错了吗?并没有,在很多情况下,后端数据库中存储的是字符串形式的数值,他们不会为了照顾前端而还要额外花精力去遍历返回的数据,把其中的一些节点进行类型转化。因此,在前端,我们应该自动认为"10"就是10.所以,做类型检查可以,但是意义不大。那么什么才有意义呢?将"10"自动格式化为10才是有意义的。

GET "..." -> {
  age: number
}

在结构体中,我们通过:指明该字段在前端使用时需要的数据类型,那么SRL的引擎就会自动把这个字段的值转化为该类型。当age为"10"时,就会自动转化为10,如果是其他不可转化的值,例如null,那么会被转化为0.

当然,这里的格式化应该是可定制的,例如前端可以自己定制number这个格式的行为模式,遇到null时,到底是转化为0,还是保留null,可以通过这个定制来做到。

总而言之,类型检查固然可以有,但是最重要的是,我们应该进行格式化。

嵌套

有些返回的数据里面,是具有嵌套层级的数据,例如 { books: [ { title: 'book title', price: 12.3 }, { title: 'book title', price: 14.5 }] } 是一个嵌套了数组里面嵌套了对象的对象,但是实际上,我们只需要读取每本书的title就可以了,我们这样写结构体:

GET "..." -> {
  books: [
    {
      title
    }
  ]
}

通过对深层级的结构进行描述,我们可以对具有嵌套结构的对象进行深度裁剪。例如:

{
  books
}

会把books这个数组的全部数据返回,而:

{
  books: [ { title } ]
}

会对数组的每一个元素进行裁剪,只返回其title字段。

能够具有这种嵌套性质的,有3类对象,分别是:

  • 对象 { name: string }
  • 数组 [string]
  • 元组 <string, number>

它们都可以嵌套其他,也可以被其他嵌套。

数组可预设多个,每个之间用,连接,引擎会根据接收的数据的形状来决定使用哪一个。另外,也支持对某些特定的位置进行规定,例如:

{
  items: [0:string, number]
}

意思是指,索引为0的元素使用string格式,其他的使用number格式。这在特定情况下还是比较有用的。

结束标识符

语句结束怎么来判定呢?上文所有的案例,都是以换行作为语句结束的标识符,但是实际上,有3种用于表示结束的标识符,分别是:

  • 换行 \n
  • 分号 ;
  • 逗号 ,

例如下面的代码:

GET "..." -> {
  name: string 
  age: number,
  height: number;
}

你可以直接把一些简单的typescript代码拷贝过来:

{
  name: string;
  age: number;
  height: number;
}

注释

你可以使用//进行注释,但是需要注意,注释必须独立一行,不可与代码混在一行。使用 /* ... */ 可多行注释。

GET "..." -> {
  name: string
  age: number

  // 注释
  // 更多注释
  height: number

  /**
   * 多行注释
   * 再来一行
   */
  weight: number
}

换行

你可以使用\进行换行,但是需要注意,在大部分开发语言中\是转义字符标记,因此,你可能需要使用\\才能得到一个\字符。

GET "..." \
// 换行效果
-> {
  name: string
}

修饰符

还有一些比较特殊的场景,例如你可以允许后端返回的结果中,不存在该字段,你自己在前端代码中通过 typeof [] === 'undefined' 来执行一些逻辑。用于处理这类问题的,我统称为修饰符。修饰符被挂在字段名后面:

GET "..." -> {
  name: string
  age: number

  // height字段可以不存在,如果存在的情况下,会被格式化为number
  height?: number 
}

修饰符有4个

  • ? 字段可以不存在
  • ! 强制使用对象或数组
  • ?? 允许为null
  • ~ 将后端的字段名映射为前端字段名

! 可以强制把字段的值平铺为对象或数组。举个例子:

{
  region!: {
    region_id
  }
}

返回的结果中,可能是一个类似 { region: [ { region_id } ] } 的数据,经过强制平铺后,你将得到 { region: { region_id } } 这个对象,它把数组的第一个元素作为当前字段的值。同理,你也可以把一个对象强制转化为数组,转化后的数组只包含一个元素,这个元素就是原来的对象。

?? 实际上包含了 ? ,它一方面可以允许字段不存在,另一方面允许字段为null。但是无论这两种情况中的哪一种,它都会让该字段的值为null代替。

~ 则提供了一种可以实现字段名映射的方式,例如:

{
  name~title: string
}

这一句将后端的title字段映射为前端的name字段。这样做的好处在于,当后端调整了字段名之后,前端只需要修改此处对应的后端字段名,而不需要调整前端业务代码中的字段名。

它可以和其他修饰符一起使用。例如:

{ name~title!?? }

多个修饰符的顺序是无关紧要的,你可以随意调整它们的顺序,例如上面的一句你也可以写成 name!??~title.

操作符

修饰符对字段名起作用,操作符对字段格式起作用。我们有1个操作符:

  • & 引用片段(下文详解)

片段

有些结构实际上会被多次用到,例如在你的返回结果中有 readers, buyers, managers,它们都是关于一组人员的数组,它们的结构实际上都是一样的,此时,你可以使用片段来使用同一种结构。

FRAGMENT person: {
  user_id
  user_name
}

你需要使用fragment命令,配合:标识符,定义一个片段,之后,你需要使用&标识符来引用该片段:

GET "..." -> {
  readers: [&person]
  buyers: [&person]
  // manager是单个人
  manager: &person
}

被引用的片段,在实际运行时,会进行展开,也就是说manager实际上是一个包含user_id, user_name两个字段的对象。

插值

在参数部分,支持使用 {} 作为插值,在运行时替换为某个具体的值。参数部分只在命令后面,结构体前面的部分,例如下面:

GET "..." -H "..." -> {}
POST "..." -H "..." + {} -> {}

这个部分常常是在具体运行时才确定的,例如你要请求的是具体哪个id的资源,请求时需要携带的token是什么。这些动态的值在语法中使用 {{}} 作为插值展位进行标记。具体如下:

GET "https://api.my.com/artciles/{id}" -H "JWT-Token: {jwt}" -> {
  ..
}

在运行时,我们通过传入具体的某个参数:

const data = await run(srl, { id: 123, jwt: 'xxx' })

通过传入一个params参数,来替换语句中使用的插值。

必传/可选参数

在url中,我们常会通过search query来传递参数,但是这些参数在某些情况下是必传的,我们通过一个特殊的标记来强调这个必传:

GET "/api/v1/somes?code={code!}&name={name}&age={age?}"

上面的code是必传的,age是非必传的,name是必传的(默认),如果给的params中不存在name、code、age,在最终请求时不会存在name、code、age参数,但由于code、name是必传,如果code、name不存在,则会通过debug报一个error错误。有code用!进行了强调,所以,当code没有传的时候,在url中仍然会给空值。因此,如果上面的所有参数都没有给,那么会出现如下现象:

  • debug报code、name没有传
  • 请求 /api/v1/somes?code=

注意,末尾的code强制传入了空。

表达式

在上文中我们也给出了在await语句后面,引用前面的别名作为引用的情况,我们再来回顾一下。

GET "..." -> { name } as A
GET "..." -> { height } as B

AWAIT A B

GET ".../?name=(A.name)&height=(B.height)" -> { weight }

表达式通过 (..) 括号括起来,其内部表达式类似JS表达式语法,可以引用上文中as后面的别名作为变量读取信息:

GET "..." -> { height, ID } as B 

AWAIT A B 

POST "..." + { id: (B.ID) } -> { weight }

除了引用变量,你还可以在 (..) 中使用简单的表达式,例如:

COMPOSE -> {
  total: (a.total + b.total)
}

此处通过多个变量计算出最终的total。

在 (...) 内部支持多个表达式,但只有最后一个表达式的执行结果会作为运行结果交给需要使用的节点。

同时,(..) 承担着给格式器传参数的功能:

GET ".." -> {
  date: date('YY-MM-DD')
}

当 ( 前方有对应的格式器名字时,它就代表着传参,当然,其内部仍然是表达式,只不过此处的表达式结果为一个字符串而已,你可以引用变量作为参数。

插值和表达式虽然看上去差不多,主要不同:

  • 插值只能在参数部使用,只能引用外部传入的params,这个params只能来自运行时传参
  • 表达式可在参数和值部使用,只能引用上文中的变量作为表达式,也可以是普通值表达式(如字符串、数字)

语法归类

以上就是SRL这门语言的几乎所有内容了。我们将语言的这些内容进行归类,大致如下:

  • 命令
  • 标识符
  • 参数
  • 结构体
    • 格式器
    • 修饰符
    • 操作符
    • 嵌套
    • 片段
  • 别名
  • 插值
  • 表达式
  • 注释
  • 换行
  • 必传参数

命令包含4种:请求命令,如GET, POST, PUT, DELETE;组合命令,如COMPOSE;分段命令,如AWAIT;片段命令,如FRAGMENT。实际上,我们并不要求命令必须是大写,只是为了在描述中可以让人一眼找到一条语句的开头,所以用大写是一种规范。一组命令的执行结果,总是最后一条的响应体部分。

标识符包括:-> 用于指向返回的响应体结构,+ 用于附带请求体的结构体,FRAGMENT语句中的:前面是片段名后面是结构体,& 用于引用对应的片段,\n 和 ; 和 , 用于结束语句。

参数包含2种:命令参数,例如GET后面的"..." url;标记参数,可以使用-H等进行标记的参数。参数中可以使用 {{}} 插值。

结构体是专门用来描述一个数据应该以什么形式存在的部分,是SRL中的主体部分。它包含格式化、修饰符、操作符,嵌套、片段等各种语法或用意。

在一条请求的末尾使用as关键字可以给这条请求取一个别名。请求是指以请求命令开头的语句。在不同的情况下,别名指代的意义不同。别名可被用于AWAIT后面,表示等待这些请求完成之后,才能进入下面的语句的执行。别名可以被用于COMPOSE中,用于把对应请求的结果组合在一起。别名可以在AWAIT之后的参数部分被用到插值中。

插值仅能在参数部分被使用。

注释必须独占一行,不能和语句同在一行。

必传通过插值末尾!来表示。

完整的看,一条语句可能包含如下:

// 注释
命令 命令参数 [: 片段体] [标记参数] [+ 请求体] [-> 响应体] [as 别名] 

例如
// 片段
FRAGMENT fragmentName: { ... }
// 请求
PUT "..." + { .. } -> { ...}
GET "..." -H "Content-Type: ..." -> {}
// 组合
COMPOSE -> { .. } as some
// 分段
AWAIT a b

一个属性节点的形式:

[字段名][修饰符][: [操作符][名字][(表达式)]]

例如:
// 字段
name?~title: &fragment1
// 数组中的某个元素
[string, 2: date("YY-MM-DD")]

上面的[]部分是可选的部分,没有这些部分,该命令也是可以被执行的。

命令 参数部分 ... {
  key: 格式器
}
  • 插值,只能在参数部分被使用,包含命令参数和标记参数两部分中都可以使用
  • 表达式,可以在参数部分和格式器部分使用,格式器部分可能作为函数参数的标记

以上就是SRL这门语言的全部,你看是不是也并不复杂。

结语

我们不是去为设计一门语言而设计,而是为解决前端问题而去设计。SRL的核心在于它是可选的,你可以用它,也可以不用它,使用它的改造成本非常低,对前后端原有的架构没有任何影响,但是它却解决了关于前端请求的抽象问题。我们通过SRL,可以让我们的接口文档更简单,不需要类似swagger之类的工具,因为SRL本身就是文档,阅读SRL就是阅读前后端接口的交付和使用本身。本文所列举的是SRL的当前这个版本,将来可能还会有变化,但是有些核心原则永远不会变,就是我们是要解决问题的,我们让问题变得简单,而不是更复杂。当然,单有SRL还是不够的,我们还需要有一个运行时去执行SRL,下一篇文章我会详细介绍它的实现 scopedrequest 这个库。你可以关注我的博客或公众号 wwwtangshuangnet 获得有关它的实时更新。

2022-04-04 525

为价值买单

本文价值5.25RMB