【第十三期】基于 GraphQL 自定义指令实现高级验证器

2,476 阅读5分钟

本文预期读者阅读过本专栏之前的两篇文章

《【第十期】基于 Apollo、Koa 搭建 GraphQL 服务端》

《【第十一期】实现 Javascript 版本的 Laravel 风格参数验证器》

或对 GraphQLLaravel 的验证器有所了解。

前面两篇文章分别讲解了:

  • 如何搭建一个 GraphQL 服务器
  • 如何实现一个 Laravel 风格的验证器

今天我们来尝试将二者结合,在 GraphQL工程中实现一个 Laravel 风格的高级验证器。

需求

一个 GraphQL 请求,会经历三个阶段:

  • 解析阶段(Parse phase
  • 验证阶段(Validation phase
  • 执行阶段(Execution phase

其中,在验证阶段(Validation phase),会根据 GraphQL SDL 的类型系统,对参数进行基本校验:

  • 客户端传递未定义的查询字段,会在验证阶段失败
  • 客户端传递与预期类型不匹配的参数,会在验证阶段失败
  • 客户端没有传递必传参数,会在验证阶段失败

但是,对于一些稍复杂场景,类型系统的功能无法覆盖到:

  • 对某些数字类型的字段,限制上限和下限。例如:年龄,限制在 0 到 150 之间
  • 对某些日期类型的字段,限制一个时间区间;例如:出生日期,限制在 1900 年到 2020 年之间
  • ......

因此,我们针对更加复杂一些的校验规则,需要一个更高级的验证器。

设计

确定了需求,我们来看如何实现这个高级验证器。

预想中的方案

我们知道自定义标量(custom scalar)可以限制一个字段值的类型,因此在标量上做高级验证器是个不错的开始。

例如:对于年龄字段,我们新设计一个名为 age 的标量,限制它的取值范围为 0 到 150 之间。对于出生日期字段同理:birthDay

但是,这么做有一个问题:我们的字段类型各种各样,没个尽头,如果为每一个类型的字段都设计一个标量,那么我们将被迫维护数量庞大的标量库。

如果标量能支持参数,我们只需要将各种高级验证规则抽象为一组 rules 库就好了,这样在不同字段类型之间,可以复用一些 rules,避免了标量库随着字段类型的增加而增长的问题。例如: age(max:150,min:0)birthDay(Date,lt:2020-01-01,gt:1900-01-01)

可惜的是,目前为止,GraphQL 的实现对于标量并不支持设置参数,因此,我们只能寻求其他的方式。

实际方案

除了自定义标量外,还有自定义指令(custom directive)。

Apollo GraphQL 提供了一种方式,有兴趣的读者可以去参考:通过自定义指令动态生成自定义标量

考虑到动态自定义标量对于研发人员并不友好(自定义标量定义在自定义指令的代码中,这增加了阅读和理解工程的成本)

我们选择使用:通过自定义指令调整解析器的方式来实现高级校验。

实现步骤

  1. 创建自定义指令 @validation,此指令作用于字段定义上,并支持一个参数 rules,值的类型为字符串。
  2. GraphQL 服务启动时,在自定义指令 @validation 内部,针对定义了 rules 的字段,会调整其解析器,在其原有解析器外围包裹一层验证器逻辑。在解析器执行期间,验证逻辑会执行并对字段值进行校验。
  3. 对于具体某个 rule 的解析和校验工作,由 validator-simple 库提供支持(validator-simple 库是我们在之前的文章《【第十一期】实现 Javascript 版本的 Laravel 风格参数验证器》中实现的)

设计语法

  1. 单个字段的多个 rules 之间,使用 | 分割

  2. 字段名称与 rules 之间,使用 => 分割

  3. 多个字段校验描述,使用英文分号 ; 来分割。例如:

gql`
  extend type Mutation {
    createBook(
      book: inputBook
    ): Book  @validation(
      rules: "book.name => max:5|min:3;book.price => max:999|min:10"
    )
  }
`

虽然 GraphQL 标准中不允许字符串换行,但为了可读性,我们可以在外部定义可读性更好的描述:

const createBookValidationRules = `"` +
  `book.name => max:5|min:3;` +
  `book.price => max:999|min:10` +
  `"`
 
gql`
  extend type Mutation {
    createBook(
      book: inputBook
    ): Book  @validation(
      rules: ${createBookValidationRules}
    )
  }
`
  1. 关于所有可用 rules 的列表,请查看 validator-simple

准备工作

开始前,准备好:

实现

开始之前,graphql-server-demo 工程的目录结构如下:

.
├── index.js
├── package.json
├── src
│   ├── components
│   │   ├── book
│   │   │   ├── resolver.js
│   │   │   └── schema.js
│   │   └── cat
│   │       ├── resolver.js
│   │       └── schema.js
│   ├── graphql
│   │   ├── directives
│   │   │   ├── auth.js
│   │   │   └── index.js
│   │   ├── index.js
│   │   └── scalars
│   │       ├── date.js
│   │       └── index.js
│   └── middlewares
│       └── auth.js
└── yarn.lock

安装 validator-simple:

yarn add validator-simple@1.0.1

注意:在文章《【第十一期】实现 Javascript 版本的 Laravel 风格参数验证器》中创建的 v1.0.0 版本的 validator-simple 并不支持在 rules 中使用 . 符号指定深层字段名。在 v1.0.1 版本支持此功能。

适配 validator-simple

因为我们设计好了在 GraphQL schema 中表达验证规则的语法,它和 validator-simple 的语法有些许差异。

因此,我们创建一个文件 src/libs/validation.js 来做适配的工作。

代码如下:

const V = require('validator-simple')

const findFirstInvalidParam = (params, rules) => {
  const serializationRules = {}

  rules.split(';').forEach(item => {
    const [itemName, itemRules] = item.split('=>')
    serializationRules[itemName.trim()] = itemRules.trim()
  })

  const invalidMsg = V(params, serializationRules)

  if (invalidMsg && invalidMsg.length) return invalidMsg[0]
}

module.exports = {
  findFirstInvalidParam
}

实现自定义指令 @validation

接下来,在文件夹 src/graphql/directives 中新建文件 validation.js

内容如下:

const { SchemaDirectiveVisitor, UserInputError } = require('apollo-server-koa')
const { defaultFieldResolver } = require('graphql')
const { findFirstInvalidParam } = require('../../libs/validation.js')

class VallidationDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition (field) {
    this.modifyResolver(field)
  }

  modifyResolver (field) {
    const { resolve = defaultFieldResolver } = field
    const { rules } = this.args

    if (!rules) return

    field.resolve = async function (...args) {
      const invalidInfo = findFirstInvalidParam(args[1], rules)

      if (invalidInfo) throw new UserInputError(invalidInfo.invalidMessage)

      return resolve.apply(this, args)
    }
  }
}

module.exports = {
  validation: VallidationDirective
}

src/graphql/directives/index.js 中导出指令:

module.exports = {
  ...require('./validation.js'),
  ...require('./auth.js')
}

然后在 src/graphql/index.js 中注册新的自定义指令:

...
  directive @auth on FIELD_DEFINITION

  # 注册验证器指令
  directive @validation(rules: String) on FIELD_DEFINITION

  type Query {
    _: Boolean
  }
...

使用 @validation

打开文件 src/components/book/schema.js,并增加一个创建 bookmutation,并对 book 字段使用我们刚刚注册好的验证器指令 @validation

代码如下:

...
  extend type Mutation {
    createBook ( book: inputBook ): Book! @validation(
      rules: "book.name => max:5|min:3;book.price => max:999|min:10"
    )
  }
...

保存文件,启动服务,然后发出一个创建 book 的请求,并有意填写一个过长的名称,来验证一下我们刚才设置的规则:

curl 'http://localhost:4000/graphql' \
  -H 'Content-Type: application/json' \
  --data-binary '{"query":"mutation createBook($newBook: inputBook) {\n  createBook(book: $newBook) {\n    name\n    price\n    created\n  }\n}\n","variables":{"newBook":{"name":"this is new book name","price":100,"created":"2019-01-01"}}}' \
  --compressed

上面的请求发出后,我们会收到下面的响应内容:

{
  "errors":[
    {
      "code":"BAD_USER_INPUT",
      "message":"book.name 的长度或大小不能大于 5. 实际值为:this is new book name"
    }
  ],
  "data":null
}

通过响应结果,我们看到验证器已经生效了。

最终,graphql-server-demo 的目录结构如下:

.
├── index.js
├── package.json
├── src
│   ├── components
│   │   ├── book
│   │   │   ├── resolver.js
│   │   │   └── schema.js
│   │   └── cat
│   │       ├── resolver.js
│   │       └── schema.js
│   ├── graphql
│   │   ├── directives
│   │   │   ├── auth.js
│   │   │   ├── index.js
│   │   │   └── validation.js
│   │   ├── index.js
│   │   └── scalars
│   │       ├── date.js
│   │       └── index.js
│   ├── libs
│   │   └── validation.js
│   └── middlewares
│       └── auth.js
└── yarn.lock

结束语

至此,我们的高级验证器就开发完毕了。

今后只需要根据实际需求在 validator-simple 中增加新的验证规则,就能很容易得在 @validation 指令中使用它们。

validator-simple 只是一个为了方便表达文章内容而创建的库。 这里推荐一个更成熟的库node-input-validator


水滴前端团队招募伙伴,欢迎投递简历到邮箱:fed@shuidihuzhu.com