В один из прекрасных рабочих дней мне прилетела задачка на то, что необходимо исправить очередной баг. Проблема заключалась в следующем: на сайте при заказе товара можно было выбрать временной интервал для доставки, и если клиент задумался или отошел на n-ое количество времени, то он не мог сделать заказ, так как временной интервал на сервере обновился, и стал недоступен, а на клиенте остались старые интервалы.

На первый взгляд, данную задачу решить максимально просто, сделать запрос, получить новые интервалы, обновить состояние (работаем на react) и отрисовать. Вот и мне показалось, что решение займет пару часов.

Итак, я приступил, начал с того, что позвонил backend разработчику, что бы узнать по какому принципу обновляются интервалы на сервере. В итоге выяснилось, что они обновляются каждые 15 минут, и по его словам, в скрипте много бизнес-логики и отрабатывает он довольно долго.

Было принято решение, чтобы не нагружать сервер делать запрос раз в 15 минут, получать актуальные данные и все прекрасно. Спустя несколько минут код был написан и осталось лишь дождаться обновления интервалов на сервере и запроса.

После проверки стало понятно, что необходимо учесть промежуток времени, до истечения 15 минутного интервала. Например, первый интервал, который будет доступен клиенту это с 14:00 до 14:15, интервалы должны обновиться в 13:45 и первым доступным интервалом должно стать время с 14:15 до 14:30. Таким образом, если клиент будет делать заказ в 13:40, то следующий запрос будет в 13:55, соответственно у нас 10 минут будут висеть неактуальные интервалы.

Что с этим делать? Нужно высчитать время до первого интервала от текущего времени, вычесть 15 минут и сделать первый запрос по истечению этого времени, а дальше, как и до этого, каждые 15 минут. В целом неплохая идея, как мне тогда показалось, я приступил к реализации:

  const timingUpdate = () => {
    if (checkout.intervals) {
      let first = null;
      for (let key in checkout.intervals) {
        if (!first) {
          first = checkout.intervals[key][0][0].split(":");
        }
      }
      let currentTime = new Date();
      currentTime = (currentTime.getHours() * 3600 + currentTime.getMinutes() * 60 + currentTime.getSeconds());

      return (+first[0] * 3600 + +first[1] * 60 - currentTime - 14.9 * 60) * 1000;
    }
  };
  
	useEffect(() => {
    const currentTiming = timingUpdate();

    let onInterval = setTimeout(() => {
      if (checkout.currentRestaurant) {
        dispatch(fetchRestaurantIntervals({ restaurant_id: currentRestaurant.id }));
      }
    }, currentTiming);

    return () => {
      clearTimeout(onInterval);
    };
  }, [intervals]);

Хотелось бы отметить, что пришлось повозиться, так как с сервера приходил объект а не массив и первый интервал вычленять было довольно неприятно. Так же, можно заметить, что запрос я делаю не через 15 минут ровно, а через 15,1 чтобы дать время отработать "тяжелому" скрипту на сервере. Мои скромные мануальные тесты были пройдены и я отправил задачу на тестирование.

Спустя неделю, тестировщик дал сценарий, при котором интервалы не обновлялись и заказ было невозможно сделать, потом последовало несколько созвонов с тестировщиком и backend разработчиком, и как выяснилось, интервалы обновляются на сервере не всегда через 15 минут, а в зависимости от загрузки курьеров. Время обновления присылать с сервера оказалось невозможным.

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

  const timingUpdate = () => {
    let timeUpdate = new Date();
    return (65 - timeUpdate.getSeconds()) * 1000;
  };
  
  useEffect(() => {
    let currentTiming = timingUpdate();
    let onInterval = null;

    let timer = () => {
      currentTiming = timingUpdate();
      
      if (checkout.currentRestaurant) {
        dispatch(fetchRestaurantIntervals({ restaurant_id: currentRestaurant.id }, intervals));
      }
    };

    let onTimeout = setTimeout(() => {
      timer();
      clearInterval(onInterval);
      onInterval = setInterval(timer, currentTiming);
    }, currentTiming);

    return () => {
      clearInterval(onInterval);
      clearTimeout(onTimeout);
    };
  }, [intervals]);

Пришлось немного повозиться с setTimeout и setInterval, так как теперь, если у нас не приходил ответ, то состояние не менялось, перерендера не было и соответственно setTimeout не запускался. Поэтому я использовал setTimeout для запуска первого запроса, который срабатывал через несколько секунд, когда наступала новая минута, и далее через setInterval каждую минуту. Так же можно заметить, что я сделал дополнительные 5 секунд на отработку "тяжелого" скрипта на и обновления интервалов на сервере(Как показали эксперименты, 5 секунд было всегда достаточно). Ну и конечно же нужно не забыть очистить setTimeout и setInterval, иначе при изменении состояний различных кнопочек на странице мы получим огромное количество перерендеров.

Резюмируем: Не всегда простая на вид задача, на самом деле простая! Ну, и конечно же, лучше 10 раз переспросить как работает backend, прежде чем пытаться быстрее реализовать frontend, чтобы не делать двойную работу.

P.S.: Первая статья, не судите строго!

Комментарии (6)


  1. sinneren
    16.03.2022 11:25
    +1

    5 секунд было всегда достаточно

    сегодня да - завтра новые эксперименты и гадания на кофейной гуще?

    непонятно, а юзер-то узнает, что у него интервалы изменились? Задача выглядит как бизнесовая, а не фронтовая, в первую очередь.


    1. danilabot Автор
      16.03.2022 11:53

      Спасибо за комментарий. Согласен с Вами. К сожалению бек изменить нельзя, и подсказать точно время обновления интервалов мне не смогли, поэтому пришлось гадать на кофейной гуще.

      Юзер узнает о смене интервалов, так как при их обновлении интервалы скинуться и ему придется заново выбирать интервалы.

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

      Так же мне понравилась идея @xxxphilinxxx c отслеживанием активности пользователя, чтобы не грузить сервер тяжелыми запросами.


  1. xxxphilinxxx
    16.03.2022 11:27
    +1

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

    Какие я вижу варианты:

    • На бэке при получении формы заказа возвращаем ошибку "интервал не существует", на фронте при ее получении запрашиваем интервалы и просим пользователя вернуться и выбрать заново. Минимум запросов и правок, простая логика, не остается тупика, но чуть напрягаем пользователя.

    • Тяжелый обсчет интервалов по крону? Кладем результат в кеш и отдаем фронту из кеша. Запрашивай хоть каждую секунду. Просто, дешево и быстро. Хорошо дополняет предыдущий вариант, но и само по себе годится.

    • Неизвестно, когда интервалы обновились, а почему-то все равно надо дергать тяжелый запрос? Делаем еще один эндпоинт, возвращающий дату последнего обновления интервалов. Он дешевый, дергать можно будет часто, а интервалы запрашивать, только если полученная дата свежее, чем на клиенте.

    Если на бэке работы не провести, то вот еще как можно выкрутиться на фронте:

    • Интервалы обновляем однократно перед отправкой формы заказа, а если изменились и выбранные пользователем больше не применимы - выдаем пользователю уведомление и просим еще раз их выставить, показав уже новые. Почти как первый вариант, но без бэка.

    • В конце концов, можно отслеживать активность пользователя на вкладке с сайтом: движения мыши, клики, нажатия клавиш. Если он надолго ушел, то перестать впустую нагружать сервер, а загрузить интервалы, когда он вернется после перерыва.


    1. danilabot Автор
      16.03.2022 11:48

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

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


  1. funca
    18.03.2022 09:55

    который срабатывал через несколько секунд, когда наступала новая минута, и далее через setInterval каждую минуту

    Интересно, не получится-ли так, что все зашедшие на страницу клиенты будут отправлять запрос на еле живой бекенд практически одновременно (по границе минут) и не добьёт-ли его такой характер нагрузки?

    Хотя в коде у вас это сделано не так как в описании, что оставляет некоторые шансы.

    onTimeout = setTimeout(() => {...}, currentTiming);

    onInterval = setInterval(timer, currentTiming);

    currentTiming это сколько секунд осталось до конца минуты. Использование такого значения в качестве задержки в первом случае похоже на правду. Но подставляя его в интервал, вы получаете запуск функции с некоторой плавающей периодичностью не раз в минуту, а где-то чаще, где-то - реже.


    1. danilabot Автор
      18.03.2022 12:27

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