conf-talks

i18n — интернационализация в GraphQL

Интернационализация в API должна удобно предоставлять данные доступные на разных языках. Важно проработать такой механизм, который удовлетворит большинство ваших потребителей.

На данный момент я для себя выработал достаточно неплохой паттерн и поделюсь им с вами.

Для начала необходимо описать все возможные пути для указания языка:

Давайте рассмотрим каждый вариант поподробнее.

i18n через HTTP-заголовки

При таком сценарии (обычно для 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 поймут меня). А на уровне компонентов ничего указывать не нужно, не нужно захламлять логику компонентов.

Для бэкендера это означает

Это шайба в шайбу такой же метод, как описано выше про заголовки Accept-Language. Только вы считываете не из заголовков а из cookie и пишете опять таки в context.

i18n из профиля клиента

Из кук, токена или еще как-то получаете id пользователя, подтягиваете его региональные настройки и записываете их в context.

i18n через аргумент корневого элемента

На верхнем уровне вашего запроса передаете язык, например так:

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 {};
    },
  }
};

Этот вариант тоже удобен для фронтендеров, т.к. позволяет не засорять свои компоненты на нижнем уровне знанием о том, какой язык выбран у пользователя.

i18n через аргумент поля

Ну это достаточно распространенный способ, но не самый приятный и удобный для фронтендеров. Конечные компоненты должны знать какой язык сейчас в приложении. И это сильно пахнет копипастой в коде.

Вот пример такого запроса:

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")
  }
}

i18n через разные поля с суффиксом (какофония)

Этот подход я считаю лютейшей какофонией. Всегда можно использовать подход выше с алиасами.

Подход через поля с суффиксом для бэкендра выглядит достаточно компактно и красиво:

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);
}

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

Бэкендер! В твоих силах спасти фронтендера от каки в его коде!