Рассказ о решении проблемы с производительностью Moment.js

Автор оригинала: David Xu
  • Перевод
Moment.js — это одна из самых популярных JavaScript-библиотек для разбора и форматирования дат. В компании WhereTo используют Node.js, поэтому для них применение этой библиотеки было совершенно естественным ходом. Проблем с серверным использованием Moment.js не ожидалось. В конце концов, они с самого начала использовали эту библиотеку во фронтенде для вывода дат и были довольны её работой. Однако то, что библиотека хорошо показала себя на клиенте, ещё не означало, что и на сервере с ней проблем не будет.



Материал, перевод которого мы публикуем сегодня, посвящён рассказу решении проблемы с производительностью Moment.js.

Рост проекта и падение производительности


Недавно число записей о самолётных рейсах, которые возвращает система WhereTo, выросло примерно в десять раз. Тогда мы столкнулись с очень сильным падением производительности. Оказалось, что цикл рендеринга, который занимал менее 100 миллисекунд, теперь, для вывода около 5000 результатов поиска, выполняется более 3 секунд. Наша команда занялась исследованиями. После нескольких сеансов профилирования мы заметили, что более 99% этого времени тратится в единственной функции, которая называется createInZone.


На выполнение функции createInZone приходится около 3.3 секунд

Продолжив исследование ситуации, мы обнаружили, что эта функция вызывается функцией Moment.js parseZone. Почему она такая медленная? У нас было такое ощущение, что библиотека Moment.js была создана в расчёте на распространённые сценарии её использования, и в результате она будет пытаться обработать входную строку различными способами. Может быть стоит ограничить её? После того, как мы вчитались в документацию, мы выяснили, что функция parseZone принимает необязательный аргумент, задающий формат даты:

moment.parseZone(input, [format])

Первым, что мы сделали, была попытка использовать функцию parseZone с передачей ей информации о формате даты, но это, как показали тесты производительности, ни к чему не привело:

$ node bench.js
moment#parseZone x 22,999 ops/sec ±7.57% (68 runs sampled)
moment#parseZone (with format) x 30,010 ops/sec ±8.09% (77 runs sampled)

Хотя теперь функция parseZone и работала немного быстрее, для наших нужд такой скорости было явно недостаточно.

Оптимизация с учётом особенностей проекта


Мы использовали Moment.js для парсинга дат, получаемых из API нашего провайдера (Travelport). Мы поняли, что он всегда возвращает данные в одном и том же формате:

"2019-12-03T14:05:00.000-07:00"

Зная это, мы начали разбираться во внутреннем устройстве Moment.js для того, чтобы (как мы надеялись) написать гораздо более эффективную функцию, выдающую те же результаты.

Создание более быстрой альтернативы parseZone


Для начала нам нужно было разобраться в том, как выглядят объекты Moment.js. Понять это было довольно просто:

> const m = moment()
> console.log(m)
Moment {
  _isAMomentObject: true,
  _i: '2019-12-03T14:05:00.000-07:00',
  _f: 'YYYY-MM-DDTHH:mm:ss.SSSSZ',
  _tzm: -420,
  _isUTC: true,
  _pf: { ...snip },
  _locale: [object Locale],
  _d: 2019-12-03T14:05:00.000Z,
  _isValid: true,
  _offset: -420
}

Следующим шагом было создание экземпляра Moment без использования конструктора:

export function parseTravelportTimestamp(input: string) {
  const m = {}
  // $FlowIgnore
  m.__proto__ = moment.prototype
  return m
}

Теперь возникало такое ощущение, что у нас имеется множество свойств экземпляра Moment, которые мы можем просто установить (я не вдаюсь в детали того, как мы об этом узнали, но если вы посмотрите исходный код Moment.js — вы это поймёте):

const FAKE = moment()
const TRAVELPORT_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSSSZ'
export function parseTravelportTimestamp(input: string) {
  const m = {}
  // $FlowIgnore
  m.__proto__ = moment.prototype
  const offset = 0 // TODO
  const date = new Date(input.slice(0, 23))
  m._isAMomentObject = true
  m._i = input
  m._f = TRAVELPORT_FORMAT
  m._tzm = offset
  m._isUTC = true
  m._locale = FAKE._locale
  m._d = date
  m._isValid = true
  m._offset = offset
  return m
}

Последний шаг нашей работы заключался в том, чтобы выяснить то, как надо разбирать значение offset отметки времени. Оказалось, что это — всегда одна и та же позиция в строке. В результате мы смогли оптимизировать и это:

function parseTravelportDateOffset(input: string) {
  const hrs = +input.slice(23, 26)
  const mins = +input.slice(27, 29)
  return hrs * 60 + (hrs < 0 ? -mins : mins)
}

Вот что получилось после того, как мы всё это собрали:

const FAKE = moment()
const TRAVELPORT_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSSSZ'

function parseTravelportDateOffset(input: string) {
  const hrs = +input.slice(23, 26)
  const mins = +input.slice(27, 29)

  return hrs * 60 + (hrs < 0 ? -mins : mins)
}

/**
 * Обрабатываются только даты формата ISO-8601, представленные в следующем виде:
 * - "2019-12-03T12:30:00.000-07:00"
 */
export function parseTravelportTimestamp(input: string): moment {
  const m = {}
  // $FlowIgnore
  m.__proto__ = moment.prototype

  const offset = parseTravelportDateOffset(input)
  const date = new Date(input.slice(0, 23))

  m._isAMomentObject = true
  m._i = input
  m._f = TRAVELPORT_FORMAT
  m._tzm = offset
  m._isUTC = true
  m._locale = FAKE._locale
  m._d = date
  m._isValid = true
  m._offset = offset

  return m
}

Испытания производительности


Мы провели испытания производительности получившегося решения с использованием npm-модуля benchmark. Вот код бенчмарка:

const FAKE = moment()
const TRAVELPORT_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSSSZ'

function parseTravelportDateOffset(input: string) {
  const hrs = +input.slice(23, 26)
  const mins = +input.slice(27, 29)

  return hrs * 60 + (hrs < 0 ? -mins : mins)
}

/**
 * Обрабатываются только даты формата ISO-8601, представленные в следующем виде:
 * - "2019-12-03T12:30:00.000-07:00"
 */
export function parseTravelportTimestamp(input: string): moment {
  const m = {}
  // $FlowIgnore
  m.__proto__ = moment.prototype

  const offset = parseTravelportDateOffset(input)
  const date = new Date(input.slice(0, 23))

  m._isAMomentObject = true
  m._i = input
  m._f = TRAVELPORT_FORMAT
  m._tzm = offset
  m._isUTC = true
  m._locale = FAKE._locale
  m._d = date
  m._isValid = true
  m._offset = offset

  return m
}

Вот какие результаты исследования производительности у нас получились:

$ node fastMoment.bench.js
moment#parseZone x 21,063 ops/sec ±7.62% (73 runs sampled)
moment#parseZone (with format) x 24,620 ops/sec ±6.11% (71 runs sampled)
fast#parseTravelportTimestamp x 1,357,870 ops/sec ±5.24% (79 runs sampled)
Fastest is fast#parseTravelportTimestamp

Как оказалось, нам удалось ускорить разбор отметок времени примерно в 64 раза. Но как это повлияло на реальную работу системы? Вот что получилось в результате профилирования.


Общее время выполнения parseTravelportTimestamp составляет менее 40 мс.

Результаты оказались просто потрясающими: мы начинали с 3.3 секунд, уходящих на разбор дат, а пришли к менее чем 40 миллисекундам.

Итоги


Когда мы начинали работать над нашей платформой, нам предстояло решить просто страшное количество задач. Мы только и знали, что повторяли себе: «Пусть сначала заработает, а заняться оптимизацией можно и позже».

За последние несколько лет сложность нашего проекта очень сильно выросла. К счастью, теперь мы пришли туда, где можно переходить ко второй части нашей «мантры» — к оптимизации.

Библиотечные решения помогли проекту добраться до того места, где он находится в наши дни. Но мы столкнулись с «библиотечной» проблемой. Решая её, мы узнали о том, что, создав собственный механизм, ориентированный на наши нужды, мы можем сделать код «легче» в плане потребления системных ресурсов и сэкономить ценное время наших пользователей.

Уважаемые читатели! Сталкивались ли вы с проблемами, подобными той, о которой шла речь в этом материале?


RUVDS.com
989,70
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией

Похожие публикации

Комментарии 14

    0
    А что Moment такого делает, что это занимает три секунды?
      0
      5к раз считает свой объект из строки датавремя произвольного формата
        0

        Так на сколько я понял кастомное решение тоже создает moment обьект. Возникает вопрос почему указание формата не сработало.

      0
      И какой вывод? Что функция, написанная для стотыщмильйонов проектов, будет работать медленнее, чем функция, написанная для одного? Кэп.
        +4
        Использовали moment.js на фронте, но тянуть 60кб бандл, который занимал треть всего преложения, оказалось слишком дорого. Сейчас есть новые библиотеки которые могут частично или полностью заменить moment.js. Сравнение альтернатив. Мы используем. Не реклама, может просто кому то пригодится.
          0
          Это она даже с tree shaking'ом столько бандла занимает?
            +2
            Tree shaking не даст никакого результата в случае с moment.js
            0

            Это сработает только в том случае, когда сторонние библиотеки не используют moment, и не тянут его в качестве зависимости. В обратном же случае вариантов не так много, например можно "изобретать велосипед", однако это не всегда возможно.

            0

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

              0

              Похожий хак в своё время применили для cytoscape.js — тоже пришлось влезть в приватные поля и подменить одну функцию, чтобы пользователь мог таскать узлы по экрану, и после этого layout уже не мог их сдвинуть. До сих пор не вполне этим доволен, особенно учитывая, что мы работаем с TypeScript, и этот кусок весь был сплошь усыпан as any (потом его переписали слегка поприятнее), но оно работает так, как нужно заказчику, а это здесь главное.

                +1
                А зачем moment для парсинга даты, которая в формате iso? new Date(str) великолепно с этим справляется.
                  0

                  Что-то тоже интересно стало. Стоит отметить, что полученное решение все же быстрее практически в 2 раза.


                  https://jsperf.com/moment-parse-date

                    0
                    Да, быстрее. Из-за того, что объект моментжс создается. Надо смотреть, зачем он там дальше используется и нужен ли он. Но я бы первой оптимизацией все равно бы сделал именно это и, с вероятностью процентов так 90, на этом бы остановился, так как это примерно в ~18 раз быстрее и, вероятно, этого было бы достаточно.
                      +1

                      Нужен или нет это уже совершенно другой разговор. А так да, подобная оптимизация решила бы проблему, без необходимости вкорячивать подобные костыли, зависящие от инкапсулированной логики сторонних библиотек.


                      Да еще и работает этот парсер не правильно

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое