Безопасная выборка данных в современном JavaScript
Выборка - неправильный путь
fetch
в JavaScript - это потрясающе.
Но у вас может быть что-то подобное, разбросанное по всему вашему коду:
const res = await fetch('/user')
const user = await res.json()
Несмотря на приятность и простоту, этот код имеет ряд проблем.
Вы могли бы сказать “о, да, обрабатывайте ошибки” и переписать это так:
try {
const res = await fetch('/user')
const user = await res.json()
} catch (err) {
// Handle the error
}
Это, конечно, улучшение, но все еще есть проблемы.
Здесь мы предполагаем, что user
на самом деле является пользовательским объектом ... но это предполагает, что мы получили ответ 200
.
Но fetch
не выдает ошибок для статусов, отличных от 200
, поэтому вы могли фактически получить 400
(неверный запрос), 401
(не авторизован), 404
(не найден), 500
(внутренняя ошибка сервера) или все виды других проблем.
Более безопасный, но более уродливый способ
Итак, мы могли бы сделать еще одно обновление:
try {
const res = await fetch('/user')
if (!res.ok) {
switch (res.status) {
case 400: /* Handle */ break
case 401: /* Handle */ break
case 404: /* Handle */ break
case 500: /* Handle */ break
}
}
// User *actually* is the user this time
const user = await res.json()
} catch (err) {
// Handle the error
}
Теперь мы, наконец, добились довольно хорошего использования fetch
. Но это может быть немного неуклюже, если каждый раз не забывать записывать, и вам придется надеяться, что все в вашей команде каждый раз справляются с каждой из этих ситуаций.
Это также не самый элегантный с точки зрения потока управления. С точки зрения удобочитаемости, лично мы предпочитаем проблемный код в начале этой статьи (в некотором роде). Он читается довольно чисто - извлеките user, проанализируйте в json, сделайте что-нибудь с пользовательским объектом.
Но в этом формате мы должны извлекать user, обрабатывать кучу случаев ошибок, анализировать json, обрабатывать другие случаи ошибок и т.д. Это немного раздражает, особенно когда на данный момент у нас есть обработка ошибок как выше, так и ниже нашей бизнес-логики, в отличие от централизованной в одном месте.
Менее уродливый способ
Более элегантным решением может быть throw
, если у запроса есть проблемы, в отличие от обработки ошибок в нескольких местах:
try {
const res = await fetch('/user')
if (!res.ok) {
throw new Error('Bad fetch response')
}
// User *actually* is the user this time
const user = await res.json()
} catch (err) {
// Handle the error
}
Но у нас остается последняя проблема - когда приходит время обрабатывать ошибку, мы теряем много полезного контекста. На самом деле мы не можем получить доступ к res
в блоке catch, поэтому во время обработки ошибки мы фактически не знаем, каким был код состояния или тело ответа.
Это затруднит определение наилучшего действия, а также оставит нас с очень неинформативными журналами.
Улучшенным решением здесь может быть создание собственного пользовательского класса ошибок, куда вы можете пересылать сведения об ответе:
class ResponseError extends Error {
constructor(message, res) {
super(message)
this.response = res
}
}
try {
const res = await fetch('/user')
if (!res.ok) {
throw new ResponseError('Bad fetch response', res)
}
const user = await res.json()
} catch (err) {
// Handle the error, with full access to status and body
switch (err.response.status) {
case 400: /* Handle */ break
case 401: /* Handle */ break
case 404: /* Handle */ break
case 500: /* Handle */ break
}
}
Теперь, когда мы сохраняем коды состояния, мы можем гораздо разумнее подходить к обработке ошибок.
Например, мы можем предупредить user
на 500
о том, что у нас возникла проблема, и потенциально повторить попытку или обратиться в нашу службу поддержки.
Или, если статус равен 401
, в настоящее время они неавторизованы и, возможно, потребуется снова войти в систему и т.д.
Создание оболочки
У нас есть последняя проблема с нашим последним и самым лучшим решением - от разработчика по-прежнему зависит каждый раз писать приличный фрагмент шаблона. Внесение изменений в масштабах всего проекта или обеспечение того, чтобы мы всегда использовали эту структуру, все еще может быть непростой задачей.
Вот где мы можем обернуть fetch, чтобы обрабатывать вещи так, как нам нужно:
class ResponseError extends Error {
constructor(message, res) {
this.response = res
}
}
export async function myFetch(...options) {
const res = await fetch(...options)
if (!res.ok) {
throw new ResponseError('Bad fetch response', res)
}
return res
}
И тогда мы можем использовать его следующим образом:
try {
const res = await myFetch('/user')
const user = await res.json()
} catch (err) {
// Handle issues via error.response.*
}
В нашем последнем примере было бы неплохо убедиться, что у нас есть унифицированный способ обработки ошибок. Это может включать оповещения для пользователей, ведение журнала и т.д.
Решения с открытым исходным кодом
Изучать это было весело и все такое, но важно иметь в виду, что вам не всегда нужно создавать свои собственные упаковки для вещей. Вот некоторые ранее существовавшие варианты, которые популярны и, возможно, их стоит использовать, в том числе некоторые размером менее 1 Кб:
Axios. Axios - это очень популярный вариант выборки данных в JS, который автоматически обрабатывает несколько из вышеперечисленных сценариев для нас.
try {
const { data } = await axios.get('/user')
} catch (err) {
// Handle issues via error.response.*
}
Наша единственная критика Axios заключается в том, что он удивительно велик для простой оболочки выборки данных. Итак, если размер кб является для вас приоритетом (что, мы бы сказали, обычно должно быть для поддержания вашей производительности на высшем уровне), вы можете проверить один из двух приведенных ниже вариантов:
Redaxios. Если вам нравится Axios, но не нравится, что он добавит 11kb к вашему пакету, Redaxios - отличная альтернатива, которая использует тот же API, что и Axios, но менее 1kb.
import axios from 'redaxios'
// use as you would normally
Wretch. Один новый вариант, который представляет собой очень тонкую оболочку вокруг Fetch, очень похожую на Redaxios, — это Wretch. Wretch уникален тем, что он в значительной степени по-прежнему похож на выборку, но дает вам полезные методы для обработки общих статусов, которые можно красиво связать вместе:
const user = await wretch("/user")
.get()
// Handle error cases in a more human-readable way
.notFound(error => { /* ... */ })
.unauthorized(error => { /* ... */ })
.error(418, error => { /* ... */ })
.res(response => /* ... */)
.catch(error => { /* uncaught errors */ })
Не забывайте также безопасно записывать данные
И последнее, но не менее важное: давайте не будем забывать, что прямое использование fetch
может иметь общие подводные камни при отправке данных через POST
, PUT
или PATCH
Можете ли вы обнаружить ошибку в этом коде?
// 🚩 We have at least one bug here, can you spot it?
const res = await fetch('/user', {
method: 'POST',
body: { name: 'Steve Sewell', company: 'Builder.io' }
})
Есть по крайней мере один, но, скорее всего, два.
Во-первых, если мы отправляем JSON, свойство body должно быть JSON-сериализованной строкой:
const res = await fetch('/user', {
method: 'POST',
// ✅ We must JSON-serialize this body
body: JSON.stringify({ name: 'Steve Sewell', company: 'Builder.io' })
})
Это может быть легко забыто, но если мы используем TypeScript, это, по крайней мере, может быть поймано для нас автоматически.
Дополнительная ошибка, которую TypeScript не исправит для нас, заключается в том, что мы не указываем здесь заголовок Content-Type
. Многие серверные части требуют, чтобы вы указали это, так как в противном случае они не будут обрабатывать тело должным образом.
const res = await fetch('/user', {
headers: {
// ✅ If we are sending serialized JSON, we should set the Content-Type:
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify({ name: 'Steve Sewell', company: 'Builder.io' })
})
Теперь у нас есть относительно надежное и безопасное решение.
(Необязательно) Добавление автоматической поддержки JSON в нашу оболочку
Мы могли бы также решить добавить некоторую безопасность для этих распространенных ситуаций в нашу оболочку. Например, с помощью приведенного ниже кода:
const isPlainObject = value => value?.constructor === Object
export async function myFetch(...options) {
let initOptions = options[1]
// If we specified a RequestInit for fetch
if (initOptions?.body) {
// If we have passed a body property and it is a plain object or array
if (Array.isArray(initOptions.body) || isPlainObject(initOptions.body)) {
// Create a new options object serializing the body and ensuring we
// have a content-type header
initOptions = {
...initOptions,
body: JSON.stringify(initOptions.body),
headers: {
'Content-Type': 'application/json',
...initOptions.headers
}
}
}
}
const res = await fetch(...initOptions)
if (!res.ok) {
throw new ResponseError('Bad fetch response', res)
}
return res
}
И теперь мы можем просто использовать нашу оболочку следующим образом:
const res = await myFetch('/user', {
method: 'POST',
body: { name: 'Steve Sewell', company: 'Builder.io' }
})
Просто и безопасно.
Решения с открытым исходным кодом
Хотя определять наши собственные абстракции весело и интересно, давайте обязательно укажем, как пара популярных проектов с открытым исходным кодом также автоматически справляются с этими ситуациями для нас:
Axios/Redaxios. Для Axios и Redaxios код, аналогичный нашему оригинальному «flawed» коду с необработанной fetch
, на самом деле работает так, как ожидалось:
const res = await axios.post('/user', {
name: 'Steve Sewell', company: 'Builder.io'
})
Wretch.
Точно так же с Wretch самый простой пример работает так, как и ожидалось:
const res = await wretch('/user').post({
name: 'Steve Sewell', company: 'Builder.io'
})
(Необязательно) Делаем нашу оболочку type-safe
И последнее, но не менее важное: если вы хотите реализовать свою собственную оболочку вокруг fetch
, давайте, по крайней мере, убедимся, что она type-safe с помощью TypeScript, если это то, что вы используете (и, надеюсь, вы используете!).
Вот наш окончательный код, включая определения типов:
const isPlainObject = (value: unknown) => value?.constructor === Object
class ResponseError extends Error {
response: Response
constructor(message: string, res: Response) {
super(message)
this.response = res
}
}
export async function myFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
let initOptions = init
// If we specified a RequestInit for fetch
if (initOptions?.body) {
// If we have passed a body property and it is a plain object or array
if (Array.isArray(initOptions.body) || isPlainObject(initOptions.body)) {
// Create a new options object serializing the body and ensuring we
// have a content-type header
initOptions = {
...initOptions,
body: JSON.stringify(initOptions.body),
headers: {
"Content-Type": "application/json",
...initOptions.headers,
},
}
}
}
const res = await fetch(input, initOptions)
if (!res.ok) {
throw new ResponseError("Bad response", res)
}
return res
}
Одна последняя ошибка
При использовании нашей новой блестящей оболочки выборки с безопасным типом вы столкнетесь с одной последней проблемой. В блоке catch
в машинописном тексте по умолчанию error
типа any
.
try {
const res = await myFetch
} catch (err) {
// 🚩 Doh, error is of `any` type, so we missed the below typo:
if (err.respons.status === 500) ...
}
Вы могли бы сказать: о! просто введу сообщение об ошибке:
try {
const res = await myFetch
} catch (err: ResponseError) {
// 🚩 TS error 1196: Catch clause variable type annotation must be 'any' or 'unknown' if specified
}
Мы не можем вводить ошибки в TypeScript. Это потому, что технически вы можете throw
в TypeScript что угодно и где угодно. Все приведенное ниже является допустимым JavaScript/TypeScript и теоретически может существовать в любом блоке try
.
throw null
throw { hello: 'world' }
throw 123
// ...
Не говоря уже о том, что сама fetch
может выдать свою собственную ошибку, которая не является ResponseError
, например, для сетевых ошибок, таких как недоступное соединение.
Мы также могли случайно обнаружить законную ошибку в нашей оболочке выборки, которая выдает другие ошибки, такие как TypeError
Таким образом, окончательное, чистое и типобезопасное использование этой оболочки было бы чем-то вроде:
try {
const res = await myFetch
const user = await res.body()
} catch (err: unknown) {
if (err instanceof ResponseError) {
// Nice and type-safe!
switch (err.response.status) { ... }
} else {
throw new Error('An unknown error occured when fetching the user', {
cause: err
})
}
Здесь мы можем проверить с помощью instanceof
, является ли err
экземпляром ResponseError
, и получить полную безопасность типов в условном блоке для ответа об ошибке.
И затем мы также можем повторно выдать ошибку, если произошли какие-либо неожиданные ошибки, и использовать свойство new cause в JavaScript для пересылки исходных сведений об ошибке для более удобной отладки.
Повторно используемая обработка ошибок
Наконец, может быть неплохо не всегда иметь специально созданный переключатель для каждого возможного состояния ошибки для каждого HTTP-вызова.
Было бы неплохо инкапсулировать нашу обработку ошибок в функцию многократного использования, которую мы можем использовать в качестве запасного варианта после обработки любых разовых случаев, для которых, как мы знаем, нам нужна специальная логика, уникальная для этого вызова.
Например, у нас может быть обычный способ, которым мы хотим предупредить пользователей о 500
с сообщением "ой, извините, пожалуйста, обратитесь в службу поддержки", или для 401
с сообщением "пожалуйста, войдите снова", если нет более конкретного способа, которым мы хотим обработать этот статус для этот конкретный запрос.
На практике это могло бы, например, выглядеть следующим образом:
try {
const res = await myFetch('/user')
const user = await res.body()
} catch (err) {
if (err instanceof ResponseError) {
if (err.response.status === 404) {
// Special logic unique to this call where we want to handle this status,
// like to say on a 404 that we seem to not have this user
return
}
}
// ⬇️ Handle anything else that we don't need special logic for, and just want
// our default handling
handleError(err)
return
}
Который мы могли бы реализовать следующим образом:
export function handleError(err: unkown) {
// Safe to our choice of logging service
saveToALoggingService(err);
if (err instanceof ResponseError) {
switch (err.response.status) {
case 401:
// Prompt the user to log back in
showUnauthorizedDialog()
break;
case 500:
// Show user a dialog to apologize that we had an error and to
// try again and if that doesn't work contact support
showErrorDialog()
break;
default:
// Show
throw new Error('Unhandled fetch response', { cause: err })
}
}
throw new Error('Unknown fetch error', { cause: err })
}
С Wretch
Это одно из мест, где, мы думаем, сияет Wretch, поскольку приведенный выше код мог бы выглядеть аналогичным образом:
try {
const res = await wretch.get('/user')
.notFound(() => { /* Special not found logic */ })
const user = await res.body()
} catch (err) {
// Catch anything else with our default handler
handleError(err);
return;
}
С Axios/Redaxios
С Axios или Redaxios все выглядит аналогично нашему исходному примеру
try {
const { data: user } = await axios.get('/user')
} catch (err) {
if (axios.isAxiosError(err)) {
if (err.response.status === 404) {
// Special not found logic
return
}
}
// Catch anything else with our default handler
handleError(err)
return
}
Вывод
Если не ясно иное, мы бы рекомендовали использовать готовую оболочку для fetch, поскольку они могут быть очень маленькими (1-2 КБ) и, как правило, содержат больше документации, тестирования и сообщества, помимо того, что они уже доказаны и проверены другими как эффективное решение.
Но все это говорит о том, что независимо от того, решите ли вы вручную использовать fetch, написать свою собственную оболочку или использовать оболочку с открытым исходным кодом - ради ваших пользователей и вашей команды, пожалуйста, убедитесь, что ваши данные извлекаются должным образом