24

[译] 处理 JavaScript 中的非预期数据

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzI0MDYzOTEyOA%3D%3D&%3Bmid=2247484372&%3Bidx=1&%3Bsn=e8d9032cb3b414a0c779e8ad6175f048
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

原文:https://dev.to/khaosdoctor/dealing-with-unexpected-data-in-javascript-2kda

NBrYZvU.jpg!web

动态类型语言的最大问题就是无法保证数据流总是正确的,因为我们无法“强行控制”一个参数或变量,比方说,让它不为 null。当我们面对这些情况时的标准做法是简单地做一个判断:

function foo (mustExist) {
  if (!mustExist) throw new Error('Parameter cannot be null')
  return ...
}

这样做的问题在于会污染我们的代码,因为要随处做判断,并且实际上也无法保证每一位开发代码的人都像这样判断;我们甚至都不知道这样被传进来的一个参数是 undefined 还是  null ,这在不同团队负责前后端的情况下司空见惯,也是大概率的情况。

如何以更好的方式让“非预期”数据造成的副作用最小化呢?作为一个 后端开发者 ,我想给出一些个人化的意见。

I. 一切的源点

数据有多种来源,最主要的当然就是 用户输入 。但是,也存在其它有缺陷数据的来源,比如数据库、函数返回值中的隐形空数据、外部 API 等。

我们稍后将展开讨论以如何不同的方式对待每一种的情况,要知道毕竟没什么灵丹妙药。大多数这些非预期数据的起源都是人为失误,当语言解析到 null 或 undefined 时,与之配套的逻辑却没准备好处理它们。

II. 用户输入

在这种情况下,我们能做的不多,如果是用户输入的问题,我们通过称为 补水(Hydration) 的方式处理它。换句话说,我们得拿到用户发来的原始输入,比如一个 API 中的负载,并将其转换为我们可以无错应用的某些形式。

在后端,当使用 Express 这样的 web 服务器时,我们可以通过标准的 JSON Schema (https://www.npmjs.com/package/ajv) 或是  Joi 这样的工具对来自前端的用户输入执行所有的操作。

关于我们能用 Express 和 AJV 对一个路由做什么的例子可能是下面这样:

const Ajv = require('ajv')
const Express = require('express')
const bodyParser = require('body-parser')

const app = Express()
const ajv = new Ajv()

app.use(bodyParser.json())

app.get('/foo', (req, res) => {
  const schema = {
    type: 'object',
    properties: {
      name: { type: 'string' },
      password: { type: 'string' },
      email: { type: 'string', format: 'email' }
    },
    additionalProperties: false
    required: ['name', 'password', 'email']
  }

  const valid = ajv.validate(schema, req.body)
    if (!valid) return res.status(422).json(ajv.errors)
    // ...
})

app.listen(3000)

可见我们对一个路由中请求的 body 做了校验,默认情况下 body 是个从 body-parser 包中通过负载接收到的对象,在本例中将其传到一个  JSON-Schema 实例中校验,看看其中的某个属性是否有不同的类型或格式。

重要:注意我们返回了一个 HTTP 422  Unprocessable Entity 状态码,意味着“无法处理的实体”。许多人对待像这样 body 或者 query 错误的请求,使用了表示整体错误的 400  Bad Request 报错;在这种情况中,请求本身并没有错,只是用户发送的数据不符合预期而已。

默认值的可选参数

我们之前做的校验的一个额外收获是,我们开启了一种可能性,那就是 如果一个可选域没有被传值,一个空值也能被传递进我们的应用 。例如,想象一个有  page 和  size 两个参数作为查询字符串的分页路由,但二者都不是必须的;如果它们都没收到的话,必须设定一个默认值。

理想的话,我们的控制器里应该有一个像这样的函数:

function searchSomething (filter, page = 1, size = 10) {
  // ...
}

注意:正如之前我们返回的 422 一样,对于分页查询,重要的是返回恰当的状态码,无论何时对于一个只在返回值中包含了部分数据的请求,都应该返回 HTTP 206  Partial Content ,也就是 “不完整的内容”;当用户到达最后一页且再没有更多数据时,才返回 200;如果用户尝试查询超出了总范围的页数,则返回一个 204  No Content

这将会解决我们接受两个 空值 的案例,但这触碰到了在 JavaScript 中通常非常引起争论的一点。 对于可选参数的默认值,只假设了   当且仅当   其为空的情况,而为   null   时就不灵了。 所以如果我们这样操作:

function foo (a = 10) {
  console.log(a)
}

foo(undefined) // 10
foo(20) // 20
foo(null) // null

因此,不能仅靠可选参数。对于这样的情况我们有两种处理方式:

  1. 前端控制器中的 if 语句,虽然看着有点啰嗦:

function searchSomething (filter, page = 1, size = 10) {
  if (!page) page = 1
  if (!size) size = 10
  // ...
}
  1. 直接用 JSON-Schema 处理路由:

可以再次使用 AJV 或 @expresso/validator 来校验数据:

app.get('/foo', (req, res) => {
  const schema = {
    type: 'object',
    properties: {
      page: { type: 'number', default: 1 },
      size: { type: 'number', default: 10 },
    },
    additionalProperties: false
  }

  const valid = ajv.validate(schema, req.params)
    if (!valid) return res.status(422).json(ajv.errors)
    // ...
})

III. 应对 Null 和 Undefined

我个人对在 JavaScript 中用 null 还是  undefined 来表示空值这类争论兴趣不大。如果你对这些概念仍有疑问,下图是个很好的比方:

eQVn6jv.jpg!web

现在我们知道了每种定义,而 JavaScript 在 2020 将新增了两个实验性的特性(译注:部分引自 MDN)。

空值合并运算符 ??

空值合并运算符 ?? 是一个逻辑运算符。当左侧操作数为 null 或 undefined 时,其返回右侧的操作数。否则返回左侧的操作数。

let myText = '';

let notFalsyText = myText || 'Hello world';
console.log(notFalsyText); // Hello world

let preservingFalsy = myText ?? 'Hi neighborhood';
console.log(preservingFalsy); // ''

可选链操作符 ?.

?. 运算符功能类似于  . 运算符,不同之处在于如果链条上的一个引用 null 或 undefined, . 操作符会引起一个错误,而  ?. 操作符则会按照短路计算的方式返回一个 undefined。

const adventurer = {
  name: 'Alice',
  cat: {
    name: 'Dinah'
  }
};

const dogName = adventurer.dog?.name;
console.log(dogName);
// undefined

console.log(adventurer.someNonExistentMethod?.())
// undefined

结合 空值合并运算符 ?? 使用:

let customer = {
  name: "Carl",
  details: { age: 82 }
};
let customerCity = customer?.city ?? "Unknown city";
console.log(customerCity); // Unknown city

这两项新增特性将让事情简单得多,因为我们可以把焦点集中在 null 和  undefined 上从而作出恰当的操作了;用  ?? 而不是布尔值判断  !obj 更易于处理很多错误情况。

IV. 隐性 null 函数

这个暗中作祟的问题更加复杂。一些函数会假设要处理的数据都是正确填充的,但有时并不能如意:

function foo (num) {
  return 23*num
}

num 为  null ,则函数返回值会为  0 (译注:如果操作值之一不是数值,则被隐式调用 Number() 进行转换),这不符合我们的期望。在这种情况下,我们能做的只有加上判断。可行的判断形式有两种,第一种可以简单地使用  if

function foo (num) {
  if (!num) throw new Error('Error')
  return 23*num
}

第二种办法是使用一个叫做 Either 的 Monad(译注:Monad 是一种对函数计算过程的通用抽象机制,关键是统一形式和操作模式,相当于是把值包装在一个 context 中。https://zhuanlan.zhihu.com/p/65449477 )中。对于数据是不是 null 这种模棱两可的问题,这可是个好办法;因为 JavaScript 已经有了一个支持双动作流的原生的函数,即  Promise

function exists (value) {
  return x != null
    ? Promise.resolve(value)
    : Promise.reject(`Invalid value: ${value}`)
}

async function foo (num) {
  return exists(num).then(v => 23 * v)
}

通过这种方式就可以把来自 exists 中的  catch 方法委派到调用  foo 的函数中:

function init (n) {
  foo(n)
    .then(console.log)
    .catch(console.error)
}

init(12) // 276
init(null) // Invalid value: null

V. 外部 API 和数据库记录

这也是相当常见的情况,特别是当系统是在先前创建和填充的数据库之上开发的时候。例如,一个沿用之前成功产品数据库的新产品、在不同系统间整合用户等等。

这里的大问题不在于不知道数据库,实际上则是我们不知道在数据库层面有什么已经被完成了,我们没法证明数据会不会是 null 或  undefined 。另一个问题是缺乏文档,难以令人满意的数据库文档化还是会带来前面一个问题。

因为返回值数据量可能较大,这样的情况能施展的空间也不大,除了不得不对个别数据作出判断外,在对成组的数据进行正式操作之前用 map 或  filter 进行一遍过滤是个好的做法。

抛出 Errors

对于数据库和外部 API 中的服务器代码使用 断言函数(Assertion Functions) 也是个好的实践,基本上这些函数的做法就是如果数据存在就返回否则报错。这类函数的大多数常见情况,比方说有一个根据一个 id 搜索某种数据的 API:

async function findById (id) {
  if (!id) throw new InvalidIDError(id)

  const result = await entityRepository.findById(id)
  if (!result) throw new EntityNotFoundError(id)
  return result
}

实际应用中,应把 Entity 替换为符合情况的名字,如  UserNotFoundError

该做法之所以好,是因为我们可以用这样一个函数找到的 user,可以被另外的函数用来检索位于其它数据库中的相关数据,比如用户的详细资料;而当我们调用后一个检索函数时,前置函数 findUser 已经 保证 了 user 的真实存在,因为如果出错就会抛出错误并可以据此直接在路由逻辑中找到问题。

async function findUserProfiles (userId) {
  const user = await findUser(userId)

  const profile = await profileRepository.findById(user.profileId)
  if (!profile) throw new ProfileNotFoundError(user.profileId)
  return profile
}

路由逻辑会像这样:

app.get('/users/{id}/profiles', handler)

// --- //

async function handler (req, res) {
  try {
    const userId = req.params.id
    const profile = await userService.findUserProfiles(userId)
    return res.status(200).json(profile)
  } catch (e) {
    if (e instanceof UserNotFoundError
        || e instanceof ProfileNotFoundError)
            return res.status(404).json(e.message)
    if (e instanceof InvalidIDError)
        return res.status(400).json(e.message)
  }
}

只要检查错误实例的名称,就能得知返回了什么类型的错误了。

VI. 总结

  • 在必要的地方单独判断非预期数据

  • 设置可选参数的默认值

  • 用 ajv 等工具对可能不完整的数据进行补水处理

  • 恰当使用实验性的 空值合并运算符 ?? 和 可选链操作符  ?.

  • 用 Promise 包装隐性的空值、统一操作模式

  • 用前置的 map 或 filter 过滤成组数据中的非预期数据

  • 在职责明确的控制器函数中,各自抛出类型明确的错误

用这些方法处理数据就能得到连续而可预测的信息流了。

--End--

bqMjEbe.jpg!web

查看更多前端好文

请搜索 fewelife 关注公众号

转载请注明出处


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK