Интернационализация в API должна удобно предоставлять данные доступные на разных языках. Важно проработать такой механизм, который удовлетворит большинство ваших потребителей.
На данный момент я для себя выработал достаточно неплохой паттерн и поделюсь им с вами.
Для начала необходимо описать все возможные пути для указания языка:
Давайте рассмотрим каждый вариант поподробнее.
При таком сценарии (обычно для web-пользователей) с GraphQL-запросом по http-протоколу прилетает заголовок Accept-Language
, который обычно передает браузер. По умолчанию он берется из языка установки браузера, но пользователь может его изменить.
Выглядит он так: Accept-Language: en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7,kk;q=0.6
. Сначала давай английский, потом с весом 0.8 русский, ну а потом казахский если ничего больше нет. С большой долей вероятности этому заголовку можно доверять. И это лучше чем ничего.
Также этот заголовок могут подставлять мобильные приложения, если попросить разрабов.
Прелесть этого подхода в том, что никак не нужно модифицировать GraphQL-запрос. Просто переключите заголовок и отправьте тот же запрос и получите данные на нужном языке. Для фронтендера или разработчика мобильного приложения это означает, что язык нужно задать на уровне NetworkLayer’a (тот кто юзает Relay и Apollo поймут меня). А на уровне компонентов ничего указывать не нужно, не нужно захламлять логику компонентов.
Для бэкендера это означает
req
(объект http-запроса) в context
(контекст GraphQL-сервера). В статье про GraphQL-сервера написано как сформировать контекст для GraphQL-запроса.resolve(source, args, context, info)
считывать третий аргумент context
и оттуда получать язык на котором необходимо предоставить данные.Это шайба в шайбу такой же метод, как описано выше про заголовки Accept-Language
. Только вы считываете не из заголовков а из cookie и пишете опять таки в context
.
Из кук, токена или еще как-то получаете id пользователя, подтягиваете его региональные настройки и записываете их в context
.
На верхнем уровне вашего запроса передаете язык, например так:
query {
viewer(lang: "ru") {
article(id: 10) { ... }
someOtherData
}
}
Как в таком случае пробросить язык до resolver’ов article
, someOtherData
и глубже? Правильно нагадить опять в context
. Кто сказал, что в resolve
-методе нельзя что-то записывать в контекст?!
Конечно можно, но только очень осторожно, т.к. могут быть такие запросы:
query {
v1: viewer(lang: "ru") {
article(id: 10) { ... }
}
v2: viewer(lang: "en") {
article(id: 10) { ... }
}
}
С таким запросом, т.к. context
един для всех resolve-методов, на нижний уровень в контексте прилетит либо ru
, либо en
. И весь ответ вернется на одном языке. Но можно потребителей вашего АПИ предупредить об этом, и если они сделают такой запрос, то сами себе злые буратины.
В коде у бэкендеров на чистом GraphQL это будет выглядеть так:
const Query = new GraphQLObjectType({
name: 'Query',
fields: () => ({
viewer: {
type: ViewerType,
args: {
lang: {
type: GraphQLString, // ну либо Enum, кому как удобнее
defaultValue: 'ru',
},
},
resolve: (_, args, context) => {
// раз и записали язык из аргумента в контекст
context.lang = args.lang;
// LIFEHACK: возвращаем пустой объект
// чтоб GraphQL вызвал resolve-методы у вложенных полей
return {};
},
},
}),
});
На подходе от аполловцев так:
const typeDefs = gql`
type Viewer {
article: Article
...
}
type Query {
viewer(lang: String = "ru"): Viewer
}
`;
const resolvers = {
Query: {
viewer: (_, args, context) => {
// раз и записали язык из аргумента в контекст
context.lang = args.lang;
// LIFEHACK: возвращаем пустой объект
// чтоб GraphQL вызвал resolve-методы у вложенных полей
return {};
},
}
};
Этот вариант тоже удобен для фронтендеров, т.к. позволяет не засорять свои компоненты на нижнем уровне знанием о том, какой язык выбран у пользователя.
Ну это достаточно распространенный способ, но не самый приятный и удобный для фронтендеров. Конечные компоненты должны знать какой язык сейчас в приложении. И это сильно пахнет копипастой в коде.
Вот пример такого запроса:
query {
article(lang: "ru") {
title
reviews(lang: "ru", limit: 10) {
text
}
}
message {
text(lang: "ru")
}
}
Продвинутые фронтендеры, чтоб не таскать копипасту по коду передадут язык на уровне отправки Query через переменную. Т.е. конечные компоненты будут избавлены от знания языка. Вот пример такого запроса:
query ($lang: String) {
article(lang: $lang) {
title
reviews(lang: $lang, limit: 10) {
text
}
}
message {
text(lang: $lang)
}
}
Такой подход по мне не шибко хорош, но вполне применим. Но правда бывают ситуации, когда нужно сделать именно так, а не иначе. Например фронтендеры пилят админку для управления переводами. И вот там то они и буду дергать разные языки для одного объекта и желательно через алиасы:
query {
article(id: 10) {
textRU: text(lang: "ru")
textEN: text(lang: "en")
textES: text(lang: "es")
}
}
Этот подход я считаю лютейшей какофонией. Всегда можно использовать подход выше с алиасами.
Подход через поля с суффиксом для бэкендра выглядит достаточно компактно и красиво:
query {
article(id: 10) {
textRU
textEN
textES
}
}
Но у фронтендера просто ад какой-то с таким подходом на фронте. Мало того, что при добавлении нового языка ему надо куролесить новые запросы, так еще и допиливать все компоненты.
Также это может убить всю оптимизацию для прекомпилированных запросов в Relay (надо будет в коде пачку таких запросов хранить, на каждый случай языка).
В общем, трешак этот подход в 99% случаев. Но всегда можно найти 1% когда он будет нужен, только сейчас он мне на ум не приходит.
Берите сразу все, кроме какофонии.
Вам все равно на самом нижнем уровне писать резолвер, который будет подтягивать из базы текст на нужном языке. Так пусть он сперва проверит аргумент lang
(тока не ставьте дефолтное значение и не делайте его обязательным), и если язык не задан, то считайте его с контекста.
А сам контекст может быть сперва записан через Accept-Language
, затем перезаписан через куки, потом перезаписан через профиль пользователя, а дальше мог фронтендер указать через корневое поле в запросе.
Вы всегда можете написать такую функцию помогайку и таскать ее по всем резолверам:
function getLang(args, context) {
if (args.lang) return args.lang;
if (context.lang) return context.lang;
return 'ru';
}
Или даже такую
function getFieldValue(fieldName, source, args, context) {
let ln = 'ru';
if (args.lang) ln = args.lang;
else if (context.lang) ln = context.lang;
source.getSomehowTranslatedData(fieldName, ln);
}
В общем, вы сами знаете как лучше оптимальнее и красивше написать у себя код. Вот только дайте возможность фронтендерам удобно получать данные! Дайте им выбор, пусть сами выбирают как им удобнее. Т.к. для веба один способ может быть хорош, то для мобилок уже может быть другой.
Бэкендер! В твоих силах спасти фронтендера от каки в его коде!