Думаю, многие разработчики рано или поздно сталкиваются с необходимостью локализации / интернационализации своих приложений, будь то локализация текстов или просто сбор метрик о различных группах пользователей, в этой статье я затрону тему локализации бэкенда на Node.js.
В данном случае я затрону тему отправки сообщений о неудаче запроса, на различных его этапах, начиная от обычных сообщений в теле запроса, и заканчивая локализацией сообщений об ошибках на этапе проверки тела запроса.
Сразу отмечу, что во всех примерах будет использоваться веб-фреймворк Fastify, но они также вполне применимы к Express, Koa и другим веб-фреймворкам.
И так, для начала давайте познакомимся с визуализацией жизненного цикла запроса, и как будет происходить реализация работы по локализации:
Обратите внимание на две основные части:
- Бизнес-логика — локализация ошибок/текстов, которые будут отправляться в результате работы с бизнес-логикой, сюда же можно отнести простые ошибки из
preHandlers
, например, авторизация - Validation — логика, отвечающая за проверку тела запроса
req.body
, (проверка авторизации пользователя сюда не входит).
Ниже мы рассмотрим в основном работу с уровнем бизнес-логики, а в конце статьи будет приведен пример локализации валидации.
Перед прочтением этой статьи вы должны иметь представление о том, как работает обработка ошибок в Fastify, Express и т.д.
Стратегия детектора языка
И так, прежде всего, когда мы говорим о локализации, возникает вопрос о стратегии определения языка пользователя, в случае Backend приложения у нас есть два наиболее используемых метода:
- HTTP-заголовок запроса Accept-Language — который браузер автоматически отправляет, основываясь на языковых настройках браузера (рекомендуемый метод)
- GEO IP — определение местоположения и, соответственно, языка пользователя на основе его IP-адреса.
Мы будем использовать HTTP-заголовок Accept-Language, так как в моей практике этот метод дает наибольшую точность в определении языка пользователя, а также является самым простым в реализации.
Реализация
И так, для начала, чтобы определить язык по заголовку Accept-Language, нам необходимо установить пакет Locale:
# Install with yarn
yarn add locale
# Install with npm
npm install locale --save
А теперь я предлагаю создать в проекте директорию i18n
, которая будет содержать функцию-помощник для определения языка, а чуть позже мы добавим туда словари с текстами для локализации:
// i18n/index.ts
import locale from 'locale'
// Constant with the languages that we will support
export enum Language {
DE = 'de',
EN = 'en',
}
// fallback language
const defaultLang = Language.DE
const supportedLangs = new locale.Locales(Object.values(Language), defaultLang)
Далее добавим функцию parseAcceptLanguage
, которая будет определять язык в зависимости от заголовка:
// i18n/index.ts
export const parseAcceptLanguage = (acceptLanguage: string): Language => {
const locales = new locale.Locales(acceptLanguage)
const language = locales.best(supportedLangs).toString() as Language
return language
}
Теперь я предлагаю создать одинаковые словари с текстами для разных языков.
В качестве примера создадим два словаря для английского языка EN и для немецкого языка DE.
// i18n/dictionaries/en.ts
export default {
EMAIL_ALREADY_IN_USE: 'This Email is already in use!',
USERNAME_ALREADY_IN_USE: 'This username is already in use!',
LOGIN_ERROR: 'Wrong Username/Email/Password',
FILE_LIMIT_MB: 'The file in the '#fieldName#' field exceeds the file size limit of #mb# MB',
}
// i18n/dictionaries/de.ts
export default {
EMAIL_ALREADY_IN_USE: 'Diese E-Mail-Adresse wird schon verwendet!',
USERNAME_ALREADY_IN_USE: 'Dieser Benutzername wird bereits benutzt!',
LOGIN_ERROR: 'Falscher Benutzername/E-Mail/Passwort',
FILE_LIMIT_MB: 'Die Datei im Feld '#fieldName#' überschreitet die Dateigrößenbeschränkung von #mb# MB',
}
// i18n/index.ts
import ru from './dictionaries/ru'
import en from './dictionaries/en'
export const dictionaries: Record<Language, typeof en> = {
ru,
en,
}
Возможно, вы заметили, что в некоторых ключах есть слоты типа #fieldName#
или #mb#
, это сделано не просто так, а для того, чтобы во время выброса ошибки можно было указать дополнительные аргументы, которые будут вводиться туда динамически.
И так теперь напишем свой класс для брошенной ошибки, которую мы в дальнейшем будем ловить и преобразовывать в сообщения с локализацией.
// errors.ts
type Slots = Record<string, string | number> | null
export class HttpError extends Error {
status: number
slots: Slots
constructor(status: number, message = '', slots: Slots = null) {
super()
this.status = status
this.message = message
this.slots = slots
}
}
А теперь давайте соберем все вместе и получим легкий и гибкий механизм локализации.
А соберем мы все на месте при обработке ошибок, так как Fastify уже умеет обрабатывать исключения из коробки, а с помощью setErrorHandler мы можем настроить его, что позволит нам проверить, является ли ошибка экземпляром класса HttpError
, затем попытаться проверить наличие слотов, и отправить сообщение пользователю.
// server.ts
app.setErrorHandler((error, req, res) => {
if (typeof req.lang !== 'string') {
req.lang = parseAcceptLanguage(req.headers['accept-language']!)
}
const dictionary = dictionaries[req.lang]
let message = dictionary[error.message] ?? error.message
if (error instanceof HttpError) {
if (error.slots !== null) {
const slots = Object.keys(error.slots)
for (const slot of slots) {
message.replace(`#${slot}#`, slots[slot])
}
}
return res.status(error.status).send({
status: error.status,
message,
})
}
res.status(500).send({
status: 500,
message,
})
})
Также не забудьте добавить поле lang
к объекту FastifyRequest
в файлах объявления типов.
// types.ts
declare module 'fastify' {
interface FastifyRequest {
lang?: Language
}
}
Вот и все!
Теперь на уровне бизнес-логики мы можем бросать исключения, которые будут локализованы для каждого конкретного пользователя:
// someHandler.ts
const emailInDb = await UserModel.exists({ email })
if (emailInDb) {
throw new HttpError(400, 'EMAIL_ALREADY_IN_USE')
}
// or an example using slots
if (file.size > MBtoBytes(10)) {
throw new HttpError(400, 'FILE_LIMIT_MB', {
mb: 10,
fieldName: 'photo',
})
}
Локализация сообщений проверки
А теперь перейдем к валидации, для примера я возьму довольно популярную библиотеку Yup.
Я знаю, что Yup имеет встроенные механизмы для локализации, но цель этой статьи — посмотреть на валидацию не как на готовые ошибки, которые нам выкидывает библиотека, а как на пару
error key
->value + metadata
, которой так или иначе придерживаются все библиотеки, и поняв это, не составит труда сделать локализацию на основе любой библиотеки.
Вот пример схемы, которая отвечает за валидацию:
// schemas.ts
import { object, string } from 'yup'
const createSchema = object().shape({
name: string().required(),
email: string().min(20).required(),
sessionId: string().required(),
})
А теперь напишем функцию, которая будет автоматически превращать схему в preHandler:
// errors.ts
export const validateYup = schema => async req => {
try {
await schema.validate(req.body, {
abortEarly: false,
})
} catch (e) {
req.lang = parseAcceptLanguage(req.headers['accept-language']!)
throw new ValidationError(422, { data: translateYupError(e, req.lang) })
}
}
И теперь при определении маршрута мы будем просто писать preHandler: validateYup(createSchema)
.
Далее мы добавим в наш словарь типы ошибок Yup, будь то required
или min
, но я также рекомендую добавить для них префикс YUP_
, чтобы в случае чего их можно было легко отделить от других подобных ошибок, связанных с валидацией:
// i18n/dictionaries/en.ts
export default {
YUP_MIN: ({ params }) => `Min ${params.min}`,
YUP_REQUIRED: 'Required text'
}
Не удивляйтесь, что YUP_MIN
является функцией, в данном случае это самый простой способ передать метаданные ошибки, в остальных случаях я рекомендую придерживаться синтаксиса слотов, описанного выше.
Но я думаю, вы заметили, что основная магия кроется в функции translateYupError
, вот собственно и все:
// errors.ts
const translateYupError = (errors, lang) => errors.inner
.map(error => {
const key = `YUP_${error.type.toUpperCase()}`
const message = dictionary[lang][key] ?? error.type // or fallback to error.message
return {
message: typeof message === 'function'
? message(error)
: message,
field: error.path,
}
})
И теперь в ответе с ошибкой валидации у нас будут локализованы все поля, так как мы попадем в setErrorHandler
, где будет обработана ошибка ValidationError.
И пользователь увидит ошибку на своем родном языке 🙂