Введение
Мне очень нравится подход «проблема/решение». Мы видим какую-то проблему, а затем действительно хорошее решение. Но для этого разговора, я думаю, нам нужно некоторое введение.
Когда вы разрабатываете веб-приложение, вы обычно хотите разделить фронтенд и бэкенд. Для этого вам нужно что-то, что обеспечит связь между ними.
Для примера, вы можете построить фронтенд (обычно называемый GUI или пользовательский интерфейс), используя ванильный HTML, CSS и Javascript, или, часто, используя несколько фреймворков, таких как Vue, React и многие другие, доступные в Интернете. Я отметил Vue, потому что это мое личное предпочтение.
Почему? Я действительно не изучал другие настолько глубоко, что не могу заверить вас, что Vue — лучший, но мне понравилось, как он работает, синтаксис и так далее. Это как ваша влюбленность, это личный выбор.
Но, помимо этого, какой бы фреймворк вы ни использовали, вы столкнетесь с одной и той же проблемой: _как общаться с бэкендом_ (который может быть написан на стольких языках, что я не осмелюсь упомянуть некоторые. Мое нынешнее увлечение? Python и Flask).
Одним из решений является использование AJAX (Что такое AJAX? Асинхронный JavaScript и XML). Вы можете использовать XMLHttpRequest напрямую, чтобы делать запросы к бэкенду и получать нужные вам данные, но недостатком является то, что код получается многословным. Вы можете использовать Fetch API, который сделает абстракцию поверх XMLHttpRequest
, с мощным набором инструментов. Другим важным изменением является то, что Fetch API
будет использовать Promises, избегая обратных вызовов из XMLHttpRequest
(предотвращая ад обратных вызовов).
В качестве альтернативы у нас есть потрясающая библиотека Axios, которая имеет хороший API (для любопытства, под капотом используется XMLHttpRequest
, что обеспечивает очень широкую поддержку браузеров). Axios API оборачивает XMLHttpRequest
в Promises
, отличающийся от Fetch API
. Кроме того, в настоящее время Fetch API
хорошо поддерживается доступными движками браузеров и имеет полифиллы для старых браузеров. Я не буду обсуждать, кто из них лучше, потому что я действительно думаю, что это личное предпочтение, как и любая другая библиотека или фреймворк. Если у вас нет своего мнения, я предлагаю вам найти несколько сравнений и глубоко погрузиться в статьи. У меня есть хорошая статья, которую я упомяну, написанная Фаразом Келхини.
Мой личный выбор — Axios
, потому что у него хороший API, есть таймаут ответа, автоматическое преобразование JSON, перехватчики (мы будем использовать их в предлагаемом решении) и многое другое. Нет ничего, что нельзя было бы реализовать с помощью Fetch API
, но есть другой подход.
Проблема
Говоря о Axios
, простой GET HTTP запрос может быть сделан с помощью этих строк кода:
import axios from 'axios'
//here we have an generic interface with basic structure of a api response:
interface HttpResponse<T> {
data: T[]
}
// the user interface, that represents a user in the system
interface User {
id: number
email: string
name: string
}
//the http call to Axios
axios.get<HttpResponse<User>>('/users').then((response) => {
const userList = response.data
console.log(userList)
})
Мы использовали Typescript (интерфейсы и generics), модули ES6, Promises, Axios и Arrow Functions. Мы не будем глубоко их затрагивать и предположим, что вы уже знаете о них.
Итак, в приведенном выше коде, если все идет хорошо, а именно: сервер находится в сети, сеть работает отлично и т.д., при запуске этого кода вы увидите список пользователей на консоли. Реальная жизнь не всегда идеальна.
У нас, разработчиков, есть миссия:
Сделать жизнь пользователей проще!
Поэтому, когда что-то идет не так, мы должны использовать все имеющиеся в наших руках усилия, чтобы решить проблему самостоятельно, так, чтобы пользователь даже не заметил, и, когда уже ничего нельзя сделать, мы обязаны показать им действительно красивое сообщение, объясняющее, что пошло не так, чтобы облегчить их души.
Axios
, как и Fetch API
, использует Promises
для обработки асинхронных вызовов и избежания обратных вызовов, о которых мы упоминали ранее. Promises
— это действительно хороший API и не слишком сложный для понимания. Мы можем выстраивать цепочки действий (then
) и обработчиков ошибок (catch
) друг за другом, и API будет вызывать их по порядку. Если в Promise произойдет ошибка, будет найден и выполнен ближайший catch
.
Таким образом, код выше с базовым обработчиком ошибок станет:
import axios from 'axios'
//..here go the types, equal above sample.
//here we call axios and passes generic get with HttpResponse<User>.
axios
.get<HttpResponse<User>>('/users')
.then((response) => {
const userList = response.data
console.log(userList)
})
.catch((error) => {
//try to fix the error or
//notify the users about somenthing went wrong
console.log(error.message)
})
Хорошо, и в чем же тогда проблема? Ну, у нас есть сотня ошибок, которые в каждом вызове API имеют одно и то же решение/сообщение. Ради любопытства Axios показывает нам их небольшой список: ERR_FR_TOO_MANY_REDIRECTS, ERR_BAD_OPTION_VALUE, ERR_BAD_OPTION, ERR_NETWORK, ERR_DEPRECATED, ERR_BAD_RESPONSE, ERR_BAD_REQUEST, ERR_CANCELED, ECONNABORTED, ETIMEDOUT
. У нас есть HTTP Status Codes, где мы находим множество ошибок, например 404
(Page Not Found), и так далее. В общем, вы поняли. У нас слишком много общих ошибок, чтобы элегантно обрабатывать их в каждом запросе API.
Очень уродливое решение
Одно из очень уродливых решений, которое мы можем придумать, — это написать одну большую функцию, которая будет инкрементировать каждую новую найденную ошибку. Помимо уродства этого подхода, он будет работать, если вы и ваша команда не забудете вызывать функцию в каждом запросе API.
function httpErrorHandler(error) {
if (error === null) throw new Error('Unrecoverable error!! Error is null!')
if (axios.isAxiosError(error)) {
//here we have a type guard check, error inside this if will be treated as AxiosError
const response = error?.response
const request = error?.request
const config = error?.config //here we have access the config used to make the api call (we can make a retry using this conf)
if (error.code === 'ERR_NETWORK') {
console.log('connection problems..')
} else if (error.code === 'ERR_CANCELED') {
console.log('connection canceled..')
}
if (response) {
//The request was made and the server responded with a status code that falls out of the range of 2xx the http status code mentioned above
const statusCode = response?.status
if (statusCode === 404) {
console.log('The requested resource does not exist or has been deleted')
} else if (statusCode === 401) {
console.log('Please login to access this resource')
//redirect user to login
}
} else if (request) {
//The request was made but no response was received, `error.request` is an instance of XMLHttpRequest in the browser and an instance of http.ClientRequest in Node.js
}
}
//Something happened in setting up the request and triggered an Error
console.log(error.message)
}
С нашей волшебной функцией «badass» на месте, мы можем использовать ее вот так:
import axios from 'axios'
axios
.get('/users')
.then((response) => {
const userList = response.data
console.log(userList)
})
.catch(httpErrorHandler)
Мы должны помнить о необходимости добавления этого catch
в каждый вызов API, и для каждой новой ошибки, которую мы можем любезно обработать, мы должны увеличить наш мерзкий httpErrorHandler
с некоторым количеством кода и уродливыми if's
.
Другая проблема, которую мы имеем с этим подходом, помимо уродства и отсутствия мантируемости, заключается в том, что если в одном, единственном вызове API я захочу обработать ошибку, отличную от глобального подхода, я не смогу этого сделать.
Функция будет расти в геометрической прогрессии, как и все проблемы, которые возникли вместе. Это решение не будет правильно масштабироваться!
Элегантное и рекомендуемое решение
Когда мы работаем в команде, заставить их запомнить стройность каждой части программного обеспечения трудно, очень трудно. Члены команды приходят и уходят, и я не знаю ни одной достаточно хорошей документации, чтобы решить эту проблему.
С другой стороны, если сам код может решать эти проблемы на общем уровне, делайте это! Разработчики не могут совершать ошибки, если им ничего не нужно делать!
Прежде чем мы перейдем к коду (а именно этого мы ожидаем от этой статьи), мне нужно кое-что рассказать, чтобы вы поняли, что делают коды.
Axios позволяет нам использовать нечто под названием Interceptors
, которые будут выполняться в каждом запросе, который вы делаете. Это отличный способ проверить разрешение, добавить некоторые заголовки, которые должны присутствовать, например, токен, и предварительно обработать ответы, сократив количество кода, который можно использовать.
У нас есть два типа Interceptors
. До (запрос) и после (ответ) вызова AJAX.
Их использование очень простое:
//Intercept before request is made, usually used to add some header, like an auth
const axiosDefaults = {}
const http = axios.create(axiosDefaults)
//register interceptor like this
http.interceptors.request.use(
function (config) {
// Do something before request is sent
const token = window.localStorage.getItem('token') //do not store token on localstorage!!!
config.headers.Authorization = token
return config
},
function (error) {
// Do something with request error
return Promise.reject(error)
}
)
Но в этой статье мы будем использовать перехватчик ответа, поскольку именно здесь мы хотим работать с ошибками. Ничто не мешает вам расширить решение и для обработки ошибок запроса.
Простым использованием перехватчика ответа является вызов нашей большой уродливой функции для обработки всех видов ошибок.
Как и любой другой автоматический обработчик, нам нужен способ обойти его (отключить), когда мы захотим. Мы расширим интерфейс AxiosRequestConfig
и добавим две опциональные опции raw
и silent
. Если raw
имеет значение true
, мы ничего не будем делать. silent
служит для отключения уведомлений, которые мы показываем при работе с глобальными ошибками.
declare module 'axios' {
export interface AxiosRequestConfig {
raw?: boolean
silent?: boolean
}
}
Следующим шагом будет создание класса Error
, который мы будем бросать каждый раз, когда захотим сообщить обработчику ошибок о возникшей проблеме.
export class HttpError extends Error {
constructor(message?: string) {
super(message) // 'Error' breaks prototype chain here
this.name = 'HttpError'
Object.setPrototypeOf(this, new.target.prototype) // restore prototype chain
}
}
Теперь давайте напишем перехватчики:
// this interceptor is used to handle all success ajax request
// we use this to check if status code is 200 (success), if not, we throw an HttpError
// to our error handler take place.
function responseHandler(response: AxiosResponse<any>) {
const config = response?.config
if (config.raw) {
return response
}
if (response.status == 200) {
const data = response?.data
if (!data) {
throw new HttpError('API Error. No data!')
}
return data
}
throw new HttpError('API Error! Invalid status code!')
}
function responseErrorHandler(response) {
const config = response?.config
if (config.raw) {
return response
}
// the code of this function was written in above section.
return httpErrorHandler(response)
}
//Intercept after response, usually to deal with result data or handle ajax call errors
const axiosDefaults = {}
const http = axios.create(axiosDefaults)
//register interceptor like this
http.interceptors.response.use(responseHandler, responseErrorHandler)
Нам не нужно помнить о нашей волшебной функции badass в каждом вызове ajax. И мы можем отключить ее, когда захотим, просто передав raw
в конфиг запроса.
import axios from 'axios'
// automagically handle error
axios
.get('/users')
.then((response) => {
const userList = response.data
console.log(userList)
})
//.catch(httpErrorHandler) this is not needed anymore
// to disable this automatic error handler, pass raw
axios
.get('/users', {raw: true})
.then((response) => {
const userList = response.data
console.log(userList)
}).catch(() {
console.log("Manually handle error")
})
Хорошо, это хорошее решение, но эта ужасная уродливая функция вырастет настолько, что мы не сможем увидеть конец. Функция станет настолько большой, что никто не захочет ее поддерживать.
Можем ли мы улучшить ее еще больше? О да.
Усовершенствованное и элегантное решение
Мы разработаем класс Registry
, используя паттерн проектирования Registry. Класс позволит вам регистрировать обработку ошибок по ключу (мы углубимся в это в ближайшее время) и действию, которое может быть строкой (сообщением), объектом (который может делать некоторые неприятные вещи) или функцией, которая будет выполняться, когда ошибка соответствует ключу. Реестр будет иметь родителя, которого можно поместить, чтобы позволить вам переопределять ключи для пользовательских сценариев обработки.
Вот некоторые типы, которые мы будем использовать на протяжении всего кода:
// this interface is the default response data from ours api
interface HttpData {
code: string
description?: string
status: number
}
// this is all errrors allowed to receive
type THttpError = Error | AxiosError | null
// object that can be passed to our registy
interface ErrorHandlerObject {
after?(error?: THttpError, options?: ErrorHandlerObject): void
before?(error?: THttpError, options?: ErrorHandlerObject): void
message?: string
notify?: QNotifyOptions
}
//signature of error function that can be passed to ours registry
type ErrorHandlerFunction = (error?: THttpError) => ErrorHandlerObject | boolean | undefined
//type that our registry accepts
type ErrorHandler = ErrorHandlerFunction | ErrorHandlerObject | string
//interface for register many handlers once (object where key will be presented as search key for error handling
interface ErrorHandlerMany {
[key: string]: ErrorHandler
}
// type guard to identify that is an ErrorHandlerObject
function isErrorHandlerObject(value: any): value is ErrorHandlerObject {
if (typeof value === 'object') {
return ['message', 'after', 'before', 'notify'].some((k) => k in value)
}
return false
}
Итак, с типами разобрались, давайте посмотрим на реализацию класса. Мы будем использовать Map для хранения объектов/ключей и родителя, которого мы будем искать, если ключ не найден в текущем классе. Если parent равен null, поиск будет завершен. При конструировании мы можем передать родителя и, по желанию, экземпляр ErrorHandlerMany
, чтобы зарегистрировать некоторые обработчики.
class ErrorHandlerRegistry {
private handlers = new Map<string, ErrorHandler>()
private parent: ErrorHandlerRegistry | null = null
constructor(parent: ErrorHandlerRegistry = undefined, input?: ErrorHandlerMany) {
if (typeof parent !== 'undefined') this.parent = parent
if (typeof input !== 'undefined') this.registerMany(input)
}
// allow to register an handler
register(key: string, handler: ErrorHandler) {
this.handlers.set(key, handler)
return this
}
// unregister a handler
unregister(key: string) {
this.handlers.delete(key)
return this
}
// search a valid handler by key
find(seek: string): ErrorHandler | undefined {
const handler = this.handlers.get(seek)
if (handler) return handler
return this.parent?.find(seek)
}
// pass an object and register all keys/value pairs as handler.
registerMany(input: ErrorHandlerMany) {
for (const [key, value] of Object.entries(input)) {
this.register(key, value)
}
return this
}
// handle error seeking for key
handleError(
this: ErrorHandlerRegistry,
seek: (string | undefined)[] | string,
error: THttpError
): boolean {
if (Array.isArray(seek)) {
return seek.some((key) => {
if (key !== undefined) return this.handleError(String(key), error)
})
}
const handler = this.find(String(seek))
if (!handler) {
return false
} else if (typeof handler === 'string') {
return this.handleErrorObject(error, { message: handler })
} else if (typeof handler === 'function') {
const result = handler(error)
if (isErrorHandlerObject(result)) return this.handleErrorObject(error, result)
return !!result
} else if (isErrorHandlerObject(handler)) {
return this.handleErrorObject(error, handler)
}
return false
}
// if the error is an ErrorHandlerObject, handle here
handleErrorObject(error: THttpError, options: ErrorHandlerObject = {}) {
options?.before?.(error, options)
showToastError(options.message ?? 'Unknown Error!!', options, 'error')
return true
}
// this is the function that will be registered in interceptor.
resposeErrorHandler(this: ErrorHandlerRegistry, error: THttpError, direct?: boolean) {
if (error === null) throw new Error('Unrecoverrable error!! Error is null!')
if (axios.isAxiosError(error)) {
const response = error?.response
const config = error?.config
const data = response?.data as HttpData
if (!direct && config?.raw) throw error
const seekers = [
data?.code,
error.code,
error?.name,
String(data?.status),
String(response?.status),
]
const result = this.handleError(seekers, error)
if (!result) {
if (data?.code && data?.description) {
return this.handleErrorObject(error, {
message: data?.description,
})
}
}
} else if (error instanceof Error) {
return this.handleError(error.name, error)
}
//if nothings works, throw away
throw error
}
}
// create ours globalHandlers object
const globalHandlers = new ErrorHandlerRegistry()
Давайте углубимся в код resposeErrorHandler
. Мы решили использовать key
в качестве идентификатора для выбора лучшего обработчика ошибки. Когда вы смотрите на код, вы видите, что в нем есть порядок, по которому key
будет искаться в реестре. Правило таково: искать от самого специфического к самому общему.
const seekers = [
data?.code, //Our api can send an error code to you personalize the error messsage.
error.code, //The AxiosError has an error code too (ERR_BAD_REQUEST is one).
error?.name, //Error has a name (class name). Example: HttpError, etc..
String(data?.status), //Our api can send an status code as well.
String(response?.status), //respose status code. Both based on Http Status codes.
]
Это пример ошибки, отправленной API:
{
"code": "email_required",
"description": "An e-mail is required",
"error": true,
"errors": [],
"status": 400
}
Другой пример:
{
"code": "no_input_data",
"description": "You doesnt fill input fields!",
"error": true,
"errors": [],
"status": 400
}
Итак, в качестве примера мы можем зарегистрировать нашу общую обработку ошибок:
globalHandlers.registerMany({
//this key is sent by api when login is required
login_required: {
message: 'Login required!',
//the after function will be called when the message hides.
after: () => console.log('redirect user to /login'),
},
no_input_data: 'You must fill form values here!',
//this key is sent by api on login error.
invalid_login: {
message: 'Invalid credentials!',
},
'404': { message: 'API Page Not Found!' },
ERR_FR_TOO_MANY_REDIRECTS: 'Too many redirects.',
})
// you can registre only one:
globalHandlers.register('HttpError', (error) => {
//send email to developer that api return an 500 server internal console.error
return { message: 'Internal server errror! We already notify developers!' }
//when we return an valid ErrorHandlerObject, will be processed as whell.
//this allow we to perform custom behavior like sending email and default one,
//like showing an message to user.
})
Мы можем зарегистрировать обработчик ошибок в любом месте, сгруппировать самые общие в одном файле typescript, а специфические — в строке. Выбирайте сами. Но, чтобы это работало, нам нужно прикрепить к нашему экземпляру http
axios. Это делается следующим образом:
function createHttpInstance() {
const instance = axios.create({})
const responseError = (error: any) => globalHandlers.resposeErrorHandler(error)
instance.interceptors.response.use(responseHandler, responseError)
return instance
}
export const http: AxiosInstance = createHttpInstance()
Теперь мы можем делать ajax-запросы, и обработчик ошибок будет работать, как ожидалось:
import http from '/src/modules/http'
// automagically handle error
http.get('/path/that/dont/exist').then((response) => {
const userList = response.data
console.log(userList)
})
Приведенный выше код покажет на экране пользователя шарик Notify ballon, потому что будет срабатывать код состояния ошибки 404
, который мы зарегистрировали ранее.
Настройка для одного http-вызова
На этом решение не заканчивается. Предположим, что в одном, только одном http-запросе, вы хотите обрабатывать 404
по-разному, но только 404
. Для этого мы создадим функцию dealsWith
, приведенную ниже:
export function dealWith(solutions: ErrorHandlerMany, ignoreGlobal?: boolean) {
let global
if (ignoreGlobal === false) global = globalHandlers
const localHandlers = new ErrorHandlerRegistry(global, solutions)
return (error: any) => localHandlers.resposeErrorHandler(error, true)
}
Эта функция использует родительский ErrorHandlerRegistry
для персонализации одной клавиши, но для всех остальных использует глобальные обработчики (если вы хотели этого, ignoreGlobal
есть, чтобы этого не делать).
Итак, мы можем написать код следующим образом:
import http from '/src/modules/http'
// this call will show the message 'API Page Not Found!'
http.get('/path/that/dont/exist')
// this will show custom message: 'Custom 404 handler for this call only'
// the raw is necessary because we need to turn off the global handler.
http.get('/path/that/dont/exist', { raw: true }).catch(
dealsWith({
404: { message: 'Custom 404 handler for this call only' },
})
)
// we can turn off global, and handle ourselves
// if is not the error we want, let the global error take place.
http
.get('/path/that/dont/exist', { raw: true })
.catch((e) => {
//custom code handling
if (e.name == 'CustomErrorClass') {
console.log('go to somewhere')
} else {
throw e
}
})
.catch(
dealsWith({
404: { message: 'Custom 404 handler for this call only' },
})
)
Заключительные размышления
Все эти объяснения хороши, но код, ах, код, намного лучше. Поэтому я создал репозиторий на github со всем кодом из этой статьи, чтобы вы могли его опробовать, улучшить и настроить.
- Нажмите здесь, чтобы получить доступ к репозиторию на github.
СНОСКИ:
- Этот пост получился намного больше, чем я предполагал, но я люблю делиться своими мыслями.
- Если у вас есть какие-то улучшения в коде, пожалуйста, дайте мне знать в комментариях.
- Если вы видите, что что-то не так, пожалуйста, исправьте меня!