10 сентября

Работа с непредвиденными данными в JavaScript

Блог компании MicrosoftJavaScriptAPIIT-компании
Перевод
Автор оригинала: Lucas Santos


Одна из основных проблем в работе с динамически типизированными языками заключается в том, что невозможно всегда гарантировать правильность потока данных, так как невозможно принудительно задать для параметра или переменной значение, например отличное от null. В таких случаях мы, как правило, используем простой код:

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

Проблема при использовании такого подхода заключается в загрязнении кода, так как приходится тестировать переменные везде и нет возможности гарантировать, что все разработчики будут действительно выполнять этот тест всегда, особенно в ситуациях, когда переменная или параметр не может иметь значение null. Часто мы даже не знаем, что такой параметр может иметь значение undefined или null — так часто бывает, когда над клиентской и серверной частями работают разные специалисты, то есть в подавляющем большинстве случаев.

Чтобы немного оптимизировать этот сценарий, я начал искать, как и с помощью каких стратегий лучше всего свести к минимуму фактор неожиданности. Именно тогда мне попалась великолепная статья Эрика Эллиотта. Цель этой работы — не полностью опровергнуть его статью, а добавить интересную информацию, которую мне удалось обнаружить со временем благодаря опыту в сфере разработки на JavaScript.

Прежде чем начать, мне бы хотелось пройтись по некоторым пунктам, которые рассматриваются в этой статье, и выразить свое мнение в качестве разработчика серверных компонентов, так как другая статья ориентирована больше на клиентскую часть.

С чего все начиналось


Проблема с обработкой данных может быть обусловлена несколькими факторами. Основная причина, конечно, заключается в пользовательском вводе. Однако существуют и другие источники неверно сформированных данных, помимо указанных в другой статье:

  • Записи баз данных
  • Функции, которые неявно возвращают данные null
  • Внешние API-интерфейсы

Во всех рассматриваемых случаях будут применяться разные решения, а позже мы подробно проанализируем каждое из них, помня, что ни одно не является панацеей. Причина большинства проблем — человеческая ошибка: во многих случаях языки подготовлены для работы с нулевыми или неопределенными данными (null или undefined), однако в процессе трансформации этих данных способность обрабатывать их может быть утеряна.

Данные, вводимые пользователем


В этом случае у нас очень немного возможностей. Если проблема заключается во вводимых пользователем данных, решить ее можно в ходе так называемой гидратации (другими словами, мы должны взять сырые вводные данные, которые отправляет нам пользователь (например, в составе полезной нагрузки API-интерфейса) и преобразовать их в нечто, с чем мы сможем работать без ошибок).

Что касается серверной части, то при использовании веб-сервера, такого как Express, мы можем выполнять все операции с вводимыми пользователем в клиентской части данными с помощью стандартных инструментов, таких как JSON-схема или 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-parser в составе полезной нагрузки. В данном случае мы передаем его через JSON-схему, так что он пройдет проверку, если одно из этих свойств имеет другой тип или другой формат (в случае с электронной почтой).

Важно! Обратите внимание, что мы возвращаем код HTTP 422, означающий необрабатываемый объект. Многие расценивают ошибку запроса, например неверную основную часть или строку запроса, в качестве ошибки 400 Недопустимый запрос — это отчасти верно, однако в данном случае проблема заключалась не в самом запросе, а в данных, которые с ним отправил пользователь. Так что оптимальным ответом пользователю будет ошибка 422: это означает, что запрос правильный, однако не может быть обработан, так как формат его содержимого отличается от ожидаемого.

Другой вариант (помимо использования AJV) — использовать библиотеку, которую я создал вместе с Роз. Мы назвали ее Expresso, и она представляет собой набор библиотек, которые немного упрощают разработку API-интерфейсов, использующих Express. Один из таких инструментов — @expresso/validator, который, по сути выполняет то, что мы продемонстрировали выше, но может быть передан в качестве промежуточного ПО.

Дополнительные параметры со значениями по умолчанию


В дополнение к проверенному ранее мы обнаружили возможность передачи значения null в наше приложение в случае, если оно не отправляется в необязательном поле. Представьте, например, что у нас есть маршрут разбиения на страницы, который принимает два параметра: page и size в качестве строк запроса. Однако они не являются обязательными и в случае неполучения должны принимать значение по умолчанию.

В идеале в нашем контроллере должна быть функция, которая делает примерно следующее:

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

Примечание. Точно так же как в случае с ошибкой 422, которую мы возвращали в ответ на запросы разбиения на страницы, важно возвращать правильный код ошибки, 206 Неполное содержимое, всякий раз в ответ на запрос, объем возвращаемых данных по которому является частью целого, мы возвращаем код 206. Когда пользователь дошел до последней страницы и больше нет данных, мы можем вернуть код 200, а когда пользователь пытается найти страницу за пределами общего диапазона страниц, мы возвращаем код 204 Содержимое отсутствует.

Это бы решило проблему в случае, когда мы получаем два пустых значения, однако здесь речь идет об очень спорном аспекте JavaScript в целом. Необязательные параметры принимают значение по умолчанию исключительно в том случае, если значение пусто, однако это правило не работает для значения null, так что если мы сделаем следующее:

function foo (a = 10) {
  console.log(a)
}
 
foo(undefined) // 10
foo(20) // 20
foo(null) // null

и нам нужно, чтобы информация была обработана как null, мы не можем полагаться в этом исключительно на необязательные параметры. Поэтому в таких случаях у нас два пути:

1.    Использовать операторы If в контроллере

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

Это и выглядит не очень, и достаточно неудобно.

2. Использовать JSON-схемы непосредственно на маршруте

Опять же, мы можем использовать для проверки этих данных 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
  }
 
<a href=""></a>  const valid = ajv.validate(schema, req.params)
    if (!valid) return res.status(422).json(ajv.errors)
    // ...
})

Работа со значениями Null и Undefined


Я лично не в восторге от идеи использовать в JavaScript значения null и undefined одновременно, чтобы доказать, что значение пусто, и тому есть несколько причин. Помимо сложностей с выводом этих понятий на абстрактный уровень нельзя забывать и о необязательных параметрах. Если у вас все еще есть сомнения по поводу этих понятий, позвольте привести прекрасный пример из практики:



Теперь, когда мы разобрались с определениями, можно сказать, что в 2020 году в JavaScript появятся две важнейшие функции:оператор объединения null (Null Coalescing Operator) и необязательное связывание (Optional Chaining). Я не буду сейчас вдаваться в подробности, так как уже написал об этом статью (она на португальском), но отмечу, что эти два нововведения значительно упростят нашу задачу, так как мы сможем сконцентрироваться на двух этих понятиях, null и undefined с подходящим оператором (??), вместо того чтобы использовать логические отрицания, такие как !obj, являющиеся благодатной почвой для ошибок.

Функции, которые неявно возвращают значение null


Решить эту проблему гораздо сложнее из-за ее неявного характера. Некоторые функции обрабатывают данные, исходя из того, что они всегда будут предоставлены, однако в некоторых случаях это не так. Рассмотрим стандартный пример:

function foo (num) {
  return 23*num
}

Если num равно null, результат этой функции будет равен 0, чего невозможно было ожидать. В таких случаях нам не остается ничего, кроме как тестировать код. Можно провести тестирование двух видов. Первый — использовать простой оператор if:

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

Второй способ заключается в использовании монады Either, которая подробно рассматривается в упомянутой мной статье. Это прекрасный способ обработки неоднозначных данных, то есть данных, которые могут быть равны 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)
}

Так можно делегировать оператор catch из exists функции, вызвавшей функцию foo:

function init (n) {
  foo(n)
    .then(console.log)
    .catch(console.error)
}
 
init(12) // 276
init(null) // Invalid value: null

Внешние API и записи баз данных


Это очень распространенный случай, особенно когда имеются системы, разработанные на основе баз данных, которые были созданы или заполнены ранее. Например, новый продукт, использующий ту же базу данных, что и его успешный предшественник, тем самым интегрируя пользователей разных систем, и так далее.

Большой проблемой при этом является не тот факт, что база данных неизвестна, — по сути, это причина, так как мы не знаем, что было сделано на уровне базы данных, и не можем подтвердить, получим мы данные со значением null или undefined или нет… Нельзя не сказать и о некачественной документации, когда база данных не документируется должным образом и мы сталкиваемся с той же проблемой, что и раньше.

Здесь мы практически ничего не можем сделать, и лично я предпочитаю проверять состояние данных, чтобы убедиться, что я смогу с ними работать. Однако все данные не проверишь, поскольку многие возвращаемые объекты попросту могут быть слишком большими. Поэтому прежде чем выполнять какие-либо операции, рекомендуется проверять данные, участвующие в работе функции, например карту или фильтр, чтобы убедиться, имеют ли они значение undefined или нет.

Генерирование ошибок


Хорошей практикой является использование функций утверждения для баз данных и внешних API. По сути, эти функции возвращают данные, если таковые имеются, а в противном случае генерируется ошибка. Самый распространенный вариант использования функций такого типа — это когда у нас есть API, например для поиска определенного типа данных по идентификатору, хорошо известный findById:

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.

Это хорошо, так как мы можем в пределах одного и того же контроллера иметь функцию для поиска пользователей по ИД и еще одну функцию, использующую этого пользователя для поиска других данных, например профилей этого пользователя в другой коллекции баз данных. При вызове функции поиска профилей мы используем утверждение, чтобы гарантировать, что пользователь действительно существует в нашей базе данных. В противном случае функция даже не будет выполнена и можно искать ошибку непосредственно на маршруте:

async function findUser (id) {
  if (!id) throw new InvalidIDError(id)
 
  const result = await userRepository.findById(id)
  if (!result) throw new UserNotFoundError(id)
  return result
}
 
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.getProfile(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)
  }
}

Мы можем узнать тип возвращаемой ошибки, просто проверив название экземпляра имеющегося класса ошибки.

Заключение


Существует несколько способов обработки данных, гарантирующих непрерывный и прогнозируемый поток информации. Знаете еще какие-нибудь советы?! Оставляйте их в комментариях

Понравился материал?! Хотите дать совет, выразить мнение или просто поздороваться? Вот как найти меня в социальных сетях:




Эта статья была оригинально размещена на dev.to Лукасом Сантосом. Если у вас есть какие-либо вопросы или комментарии по теме статьи, разместите их под исходной статьей на dev.to
Теги:Microsoftjs
Хабы: Блог компании Microsoft JavaScript API IT-компании
+11
3,8k 45
Комментарии 3
Лучшие публикации за сутки