Навіщо фронтендерам мʼютекси: Web Locks API

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Усім привіт! Мене звати Святослав Головін, я — Frontend Engineer у Solidgate.

Коли думаєш про роботу Frontend-розробника, уявляєш верстку та стилізацію кнопок. Проте на практиці часто доводиться занурюватися у складніші й цікавіші концепти, які стають ключовими під час розробки нового функціоналу чи розв’язання проблем.

Один із класичних викликів у Backend розробці — синхронізація паралельних процесів та керування спільними ресурсами. Здається, що для фронтенду такі завдання нетипові, але іноді й тут виникає потреба їх вирішувати та, відповідно, мати для цього необхідні інструменти.

З таким викликом зіштовхнувся і я, коли виникла наступна проблема: користувачів нашого застосунка розлогінювало, хоча їхня сесія ще мала бути активною. Як виявилось, причиною стали паралельні запити на оновлення сесії, які були не синхронізовані та перебивали один одного.

Рішенням стало використання концепції м’ютекса і Web Locks API як способу реалізації м’ютексів на фронтенді.

Цей досвід підштовхнув мене написати цю статтю, щоб поділитися тим, як суто бекендовий підхід може стати у пригоді й у Front-end розробці. У процесі я не лише розв’язав конкретну проблему, а й поглибив знання про інструменти браузера, які допоможуть у схожих ситуаціях у майбутньому.

Що таке мʼютекс

Мʼютекс — це засіб синхронізації, який обмежує можливість одночасного доступу до спільного ресурсу або накладає обмеження на виконання паралельної дії.

Де зустрічаються мʼютекси

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

Є така ситуація: мені потрібно зберегти в базі даних інформацію про нового користувача, але тільки якщо його username унікальний.

Може виникнути випадок, коли два користувачі намагаються одночасно зареєструватися й вводять однаковий username. В такому разі очікувана поведінка така — один із них повинен отримати помилку, а другий — успішно створити акаунт.

Виникає питання: як буде поводитись система, коли запити будуть виконані паралельно? Відповідь проста — буде два акаунти з однаковими іменами, що не є очікуваним.

Тут можна згадати про мʼютекс, який гарантуватиме, що лише один з користувачів зможе створити акаунт в певний момент часу, водночас інший буде чекати на свою чергу.

Таким чином оновлена система з використанням мʼютексу матиме:

  • контроль доступу до спільного ресурсу, бази даних, і як результат — цілісність даних, що зазвичай реалізовано через транзакції, які виступають мʼютексами;
  • гарантію порядку виконання запитів, тобто гарантію, що другий користувач не зможе зареєструватися, доки перший у процесі реєстрації.

Де виникає потреба в мʼютексах у світі Frontend

Насправді паралелізм присутній і в браузері, наприклад:

  • в async/await операціях;
  • мережевих операціях;
  • між браузерними вкладками чи вікнами.

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

Уявімо, що в нас є банківський застосунок з можливістю блокувати платіжну картку. Саме блокування має вигляд звичайної кнопки, яка змінює boolean стан.

Логіка її роботи проста: натискання на кнопку → у фоні була виконана асинхронна мутація → сервер надіслав нам оновлений стан → стан змінився.

Але тут є проблема — користувач може натискати на кнопку кілька разів поспіль. І його очікування прості: натиснув, щоб розблокувати, натиснув, щоб заблокувати.

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

Перше рішення, яке мені спало на думку — зробити кнопку неактивною, доки виконується зміна стану, але це знижує час відгуку інтерфейсу, що є антипатерном у світі веброзробки.

Наступне рішення, як можна здогадатись, використовує мʼютекс і дозволяє зробити наш інтерфейс оптимістичним.

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

Таким чином отримаємо дуже схожу логіку до тієї, яка була в прикладі для бекенду, з головним доповненням у вигляді оптимістичних оновлень стану інтерфейсу. Тепер користувач не буде чекати на завершення асинхронних дій, а одразу побачить оновлений стан.

Де я зіштовхнувся з потребою в мʼютексах

Перейдемо до ситуації, яка і дала початок цій статті.

Для контексту: у нас на проєкті є ряд застосунків, кожен з яких інтегрований з внутрішнім сервісом аутентифікації. За один з таких застосунків відповідаю я.

Система аутентифікації побудована на базі OAuth 2.0 протоколу з використанням JWT та Refresh-токенів. У такій звʼязці JWT відповідає за надання доступу до застосунку, а Refresh потрібен для поновлення сесії користувача, якщо він активно користується ним. Таким чином ми можемо уникнути закінчення активної сесії попри те, що час життя автентифікаційного (JWT) токену вичерпано.

Логіка така — якщо отримано 401 Unauthorized помилку від сервера, спершу потрібно виконати запит на refreshToken, якщо він пройшов успішно, повторити запит з помилкою, а інакше — закінчити сесію.

Як і в попередніх прикладах, все звучить прямолінійно, але на практиці застосунок завжди може робити кілька запитів на сервер водночас і, якщо кожен з них отримає помилку 401, це спровокує кілька паралельних запитів на refreshToken.

Це призведе до того, що перший запит буде виконано успішно з результатом 200 OK, а другий вже з помилкою 401, оскільки він використав застарілий refresh токен. А це, своєю чергою, призведе до завершення сесії, погіршення UX та проблеми, що була на самому початку статті, коли користувачів неочікувано розлогінює.

І знову нам потрібен мʼютекс...

Перша імплементація (локальний мʼютекс)

Історичним розв’язанням цієї проблеми було використання бібліотеки, що надає функціонал мʼютекса з асинхронним інтерфейсом на основі Promise.

Завдяки йому логіка виклику refreshToken матиме такий вигляд: якщо є помилка 401 — робимо запит на отримання мʼютексу. Якщо він доступний — робимо запит на refreshToken та повторюємо помилковий запит. Якщо мʼютекс недоступний — чекаємо на його звільнення і просто повторюємо запит, без додаткового запиту на refreshToken, оскільки ми знаємо, що доки ми чекали, цей запит вже був виконаний.

Так ми уникли неочевидних закінчень сесії та оптимізували запити на refreshToken, щоб не доводилося робити їх повторно. Імплементація має такий вигляд:

async function refreshToken() {
  if (mutex.isLocked()) {
    // already refreshing, wait for it to finish and skip additional request
    await mutex.waitForUnlock();
    return;
  }

  const release = await mutex.acquire();
  try {
    // ...
  } catch (error) {
    // ...
  } finally {
    release();
  }
}

Крім цього, у нас весь цей час існувала інша проблема: запити на сервер можуть продовжувати виконуватися одночасно з запитом на refreshToken. Відповідно, нам необхідно спершу дочекатися завершення refreshToken, тобто звільнення мʼютексу, а вже потім виконувати запит.

У коді це можна репрезентувати завдяки патерну Middleware таким чином:

async function refreshTokenMiddleware(next) {
  await mutex.waitForUnlock();

  try {
    return await next();
  } catch (error) {
    if (error.response.status === 401) {
      await refreshToken();
      return await next();
    }

    throw error;
  }
}

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

Поточний мʼютекс я назвав локальним через те, що його дія обмежена рамками однієї браузерної вкладки. Тому, коли користувач відкриває кілька вкладок з нашим застосунком, і відбувається запит на refreshToken, мʼютекс не спрацює, оскільки кожна вкладка має свій окремий мʼютекс і вони ніяк не взаємодіють між собою.

Відсутність взаємодії — це проблема, оскільки обидві вкладки керують одним і тим самим Refresh-токеном, бо він зберігається в Cookie-сховищі, яке є спільним для всіх вкладок. Таким чином у нас знову виникають труднощі при роботі зі спільними ресурсами.

Тут нам на допомогу, як і завжди, приходить браузер...

Web Locks API. Браузер надає нам просте й зрозуміле API, яке дозволило мені імплементувати власне рішення з мʼютексами. Перед цим дам трохи контексту й відповім на питання: «А що саме нам дає браузер?».

Введення

Перед викликом потрібної нам функції ми можемо спитати в браузера, чи є така можливість, тобто чи вільний наразі мʼютекс. Якщо вільний — виконуємо, інакше — чекаємо:

navigator.locks.request('my_mutex', async (lock) => {
  await users();
});

Інтерфейс досить прямолінійний, все, що передано в callback, буде виконано, коли мʼютекс стане доступним. Водночас доки callback виконується, мʼютекс буде заблоковано.

Іноді виникає потреба виконати операцію за межами callback-функції. На щастя, JavaScript дає нам можливість таке зробити, використовуючи Promise:

async function acquire(): Promise<() => void> {
  return new Promise((resolve, reject) => {
    navigator.locks
      .request('my_mutex', () => {
        return new Promise<void>((r) => {
          resolve(r);
        });
      })
      .catch(reject);
  });
}

const release = await acquire();

try {
  await users();
  release();
} catch (error) {
  console.error(error);
}

Сподіваюсь, ви зауважили, що таке використання release-функції може призвести до того, що мʼютекс залишиться заблокованим, якщо в нас станеться помилка в запиті на сервер і ми потрапимо в catch-блок.

Така ситуація називається deadlock і з нею краще не жартувати. У цьому випадку це призведе до того, що всі наступні запити чекатимуть на звільнення мʼютекса, тобто запит на отримання користувачів не буде виконуватися й інтерфейс буде відображати нескінченне завантаження списку даних.

Виправити це легко. Потрібно, щоб release викликався незалежно від фінального результату виконання коду:

// ...

const release = await acquire();

try {
  await users();
} catch (error) {
  console.error(error);
} finally {
  release(); // always release the lock
}

Особливості API

Браузер дає нам декілька гарантій:

  • мʼютекс існує глобально в межах одного origin, відповідно, потенційні колізії з іншими застосунками відсутні;
  • мʼютекс існує в межах всього браузера, тому ми можемо не лише синхронізувати різні вкладки, але й цілі браузерні вікна;
  • у випадку, коли користувач закриє вкладку, м’ютекс завжди буде звільнено автоматично, тому ризик deadlock у такому сценарії відсутній;
  • мʼютекси блокуються послідовно, за принципом черги, тому ми можемо бути певні, що попередні запити не очікуватимуть виконання нових;
  • request-метод приймає різну конфігурацію, а саме:
navigator.locks.request(
  'my_mutex',
  {
    signal: undefined, // AbortSignal
    steal: false, // boolean
    ifAvailable: false, // boolean
    mode: 'exclusive', // "exclusive" | "shared"
  },
  async (lock) => {
    await users();
  },
);

Розглянемо кожен з параметрів детальніше.

signal

Як і всі сучасні асинхронні API, request дає можливість передавати AbortController для переривання дій під час їх виконання.

steal

Іноді потрібно виконати операцію незалежно від стану м’ютексу. У такому разі можна передати в параметр steal значення true. Це призведе до розблокування поточного м’ютексу й виконання операції першою в черзі.

Важливий нюанс використання — ризик одночасного виконання кількох операцій, тобто конкурентна поведінка. Це відбувається тому, що під час виконання операції параметр steal не перериває її, а лише блокує м’ютекс і водночас виконує іншу операцію.

ifAvailable

Зі steal зрозуміло, але існує мʼякша альтернатива — параметр ifAvailable. Якщо передати true, запит у request виконуватиметься негайно, незалежно від того, чи заблокований м’ютекс. Поточний стан м’ютекса можна визначити за параметром lock, що передається у callback: якщо значення є — м’ютекс було заблоковано цим запитом; якщо null — callback виконано під час зайнятого іншими м’ютекса.

Це може бути корисним для зчитування поточного стану мʼютекса:

async function isLocked(): Promise<boolean> {
  return new Promise((resolve, reject) => {
    navigator.locks
      .request(
        'my_mutex',
        /*
          `ifAvailable` ensures that callback will be fired immediately, even if the lock is held
        */
        { ifAvailable: true },
        (lock) => {
          resolve(lock === null); // null === the lock is already held
        },
      )
      .catch(reject);
  });
}

mode

Якщо ви працювали з мʼютексами, могли чути про патерн readers-writer. Він дозволяє кільком функціям, що класифікуються як reader, одночасно блокувати мʼютекс, якщо він ще не заблокований функцією writer. Водночас тільки один writer може блокувати мʼютекс.

Якщо передати значення shared у параметр mode, request переключиться в саме такий режим.

query

Відходячи від параметрів request, API також дає можливість отримати стан всіх поточних мʼютексів.

Це зручний інструмент відладки, але використовувати його для логік у коді не рекомендовано.

Використання має такий вигляд:

const state = await navigator.locks.query();

А результатом є такий обʼєкт:

const state = {
  "held": [
    {
      "clientId": "af7993cd-aa63-4997-9620-74ff0a1bf056",
      "mode": "exclusive",
      "name": "my_mutex"
    }
  ],
  "pending": [
    {
      "clientId": "af7993cd-aa63-4997-9620-74ff0a1bf056",
      "mode": "shared",
      "name": "my_mutex"
    }
  ]
}

Браузерна підтримка

Підтримка API на момент виходу статті майже 95%, що дозволяє вільно інтегрувати його у свої застосунки.

Я вирішив перестрахуватися у своєму застосунку та додав логіку з локальним м’ютексом на випадок недоступності API. Такий підхід неефективний для роботи між вкладками, але не вплине на досвід користувачів у межах однієї вкладки, якщо вони зайдуть зі старого браузера.

Альтернативно можна використати поліфіл для розв’язання проблеми, але мене повністю влаштувало таке рішення:

async function acquire(): Promise<() => void> {
  const isApiAvailable =
    Boolean(navigator.locks?.request) &&
    typeof navigator.locks.request === 'function';

  if (!isApiAvailable) {
    return await mutex.acquire();
  }

  return new Promise((resolve, reject) => {
    navigator.locks
      .request('my_mutex', () => {
        return new Promise<void>((r) => {
          resolve(r);
        });
      })
      .catch(reject);
  });
}

Обмеження API

Слід зазначити декілька незручних обмежень наразі:

— немає можливості підписатися на стан одного чи всіх м’ютексів, тому доводиться реалізовувати polling, що є не оптимальним з точки зору використання ресурсів.

async function getLock(): Promise<Lock | null> {
  return new Promise((resolve, reject) => {
    navigator.locks
      .request('my_mutex', { ifAvailable: true }, (lock) => {
        resolve(lock);
      })
      .catch(reject);
  });
}

async function pollForLock(): Promise<Lock> {
  let lock: Lock | null = null;

  while (lock === null) {
    lock = await getLock();

    if (lock) {
      return lock;
    }

    await new Promise((resolve) => setTimeout(resolve, 300));
  }
}

— немає можливості вказати максимальний час очікування на заблокований м’ютекс, що може призвести до нескінченного очікування. Тут можу запропонувати власне рішення з використанням уже відомого AbortController:

async function acquire(timeout?: number): Promise<() => void> {
  const abortController = new AbortController();
  const { signal } = abortController;

  return new Promise((resolve, reject) => {
    navigator.locks
      .request('my_mutex', { signal }, () => {
        return new Promise<void>((r) => {
          resolve(r);
        });
      })
      .catch(reject);

    if (timeout) {
      setTimeout(() => {
        abortController.abort();
      }, timeout);
    }
  });
}

Друга імплементація (Web Locks API)

Підбиваючи підсумок: у нас є локальний м’ютекс, розуміння роботи Web Locks API та потреба в синхронізації між вкладками.

Рішення — об’єднати весь описаний функціонал у клас, який надає зручний інтерфейс для роботи з наявним API та додає локальний м’ютекс на випадок, якщо браузер користувача не має відповідної підтримки.

import { Mutex } from 'some-mutex';

class CrossTabMutex {
  private static readonly isApiAvailable =
    Boolean(navigator.locks?.request) &&
    typeof navigator.locks.request === 'function';

  private readonly fallbackMutex = new Mutex();
  private static readonly keyPrefix = 'cross_tab_mutex_';

  private key: string;

  constructor(key: string) {
    this.key = `${CrossTabMutex.keyPrefix}${key}`;
  }

  async acquire(options: LockOptions = {}): Promise<() => void> {
    if (!CrossTabMutex.isApiAvailable) {
      return await this.fallbackMutex.acquire();
    }

    return new Promise((resolve, reject) => {
      navigator.locks
        .request(this.key, options, () => {
          return new Promise<void>((r) => {
            resolve(r);
          });
        })
        .catch(reject);
    });
  }

  async isLocked(): Promise<boolean> {
    return new Promise((resolve, reject) => {
      if (!CrossTabMutex.isApiAvailable) {
        resolve(this.fallbackMutex.isLocked());
        return;
      }

      navigator.locks
        .request(this.key, { ifAvailable: true }, (lock) => {
          resolve(lock === null); // null === the lock is held
        })
        .catch(reject);
    });
  }

  async waitForUnlock(): Promise<void> {
    if (!CrossTabMutex.isApiAvailable) {
      return await this.fallbackMutex.waitForUnlock();
    }

    return new Promise((resolve) => {
      navigator.locks.request(this.key, () => {
        resolve();
      });
    });
  }
}

export default CrossTabMutex;

Тепер можна повернутися до функції refreshToken. Використання м’ютекса залишається таким самим, проте з’являється потреба в синхронізації між вкладками, щоб уникнути подвійних запитів на оновлення токенів. Для цього можна використати добре відомий localStorage:

async function refreshToken() {
  const previouslyRefreshedAt = localStorage.getItem('my_refresh_token');

  const release = await crossTabMutex.acquire();

  if (previouslyRefreshedAt !== localStorage.getItem('my_refresh_token')) {
    // another tab has already refreshed the token, while we were waiting to acquire the lock
    release();
    return;
  }

  try {
    // ...
  } catch (error) {
    // ...
  } finally {
    localStorage.setItem('my_refresh_token', `${Date.now()}`);
    release();
  }
}

Таким чином ми не будемо виконувати другу операцію, якщо вона чекала на виконання першої, бо токени для аутентифікації в цей момент вже будуть актуалізовані.

Далі варто докладніше розглянути refreshTokenMiddleware. Новий клас має аналогічний інтерфейс, тож попередня версія продовжить працювати, проте пропоную розглянути його детальніше:

async function refreshTokenMiddleware(next) {
  await mutex.waitForUnlock();

  try {
    return await next();
  } catch (error) {
    if (error.response.status === 401) {
      await refreshToken();
      return await next();
    }

    throw error;
  }
}

refreshToken може запускатися під час виконання іншого запиту — це може призвести до непередбачуваних помилок 401.

Розв’язання цієї проблеми — використання параметра mode. Якщо передати значення shared, запит на refreshToken почекає, поки завершаться всі поточні мережеві операції, а потім виконається. Водночас всі інші запити автоматично очікуватимуть на оновлення токена, як це було раніше.

Можна також скористатися режимом exclusive, але тоді всі мережеві запити виконуватимуться послідовно, що не дуже ефективно з погляду ресурсів браузера.

Фінальний варіант коду виглядає так:

async function fetchWithLock(execute: () => Promise<unknown>) {
  const release = await crossTabMutex.acquire({ mode: 'shared' });

  try {
    return await execute();
  } catch (error) {
    throw error;
  } finally {
    release();
  }
}

async function refreshTokenMiddleware(next) {
  try {
    return await fetchWithLock(next);
  } catch (error) {
    if (error.response.status === 401) {
      await refreshToken();
      return await fetchWithLock(next);
    }

    throw error;
  }
}

Можливі покращення. Поточне рішення повністю справляється зі своєю задачею, хоча є кілька аспектів, які можна покращити.

Проблеми з deadlock. Як уже було показано, якщо не викликати release, усі подальші операції з м’ютексом зависнуть.

Одним із рішень може бути автоматичне звільнення м’ютекса через певний проміжок часу. Проте це не завжди бажано, адже деякі операції можуть тривати довше.

Альтернативний підхід — відмовитися від концепції окремої release-функції і реструктуризувати код у стилі, рекомендованому документацією Web Locks API. Тобто виконувати всі асинхронні запити всередині переданої callback-функції:

navigator.locks.request('my_mutex', async (lock) => {
  await users();
});

Обмеження в один браузер

Наразі Web Locks API працює лише в межах одного браузера. Тобто, якщо користувач відкриє декілька вікон у різних браузерах, механізм працюватиме так само як локальний м’ютекс.

Це обмеження не є проблемою при роботі з refresh токеном, оскільки кожен браузер буде мати своє cookie сховище, що прибирає необхідність роботи з спільним ресурсом, але відсутність кросс-браузерної підтримки все ще може викликати проблеми в інших випадках.

Одне з потенційних рішень — відстежувати події, пов’язані зі зміною активності вкладки. Як наслідок, коли вкладка стає неактивною, всі операції зупиняються. Це прибирає потребу в м’ютексі та додатково оптимізує використання ресурсів користувача.

Реалізувати це можна за допомогою Page Visibility API та подій blur і focus для обʼєкта window:

window.addEventListener('blur', () => console.log('Bye!'));
window.addEventListener('focus', () => console.log('Hi!'));

Недоліком є те, що не всі асинхронні запити, включно з мережевими, можна завжди успішно скасувати, якщо вони вже виконуються. Це буде призводити до потенційних конкурентних помилок.

Конкурентна поведінка при закритті вкладки

Одним із плюсів API є те, що м’ютекс автоматично звільняється при закритті вкладки. Але саме це може призвести до конкурентної поведінки.

Наприклад, під час виконання refreshToken користувач закриває вкладку. Мережевий запит не зупиняється, а от м’ютекс звільняється. Наступний запит refreshToken у черзі може виконатися паралельно. Це створює ризик небажаних помилок.

Рішенням стало побудувати канал звʼязку між вкладками, використовуючи Broadcast Channel API. Таким чином при закритті однієї вкладки інші можуть про це дізнаватися й підлаштовувати свою поведінку так, щоб уникати конкурентних запитів.

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

Workers API

Сучасні браузери надають ряд API для роботи з окремими процесами. Коли мова заходить про синхронізацію, першим на думку спадає Shared Worker, основною задачею котрого є можливість використання спільного середовища різними вкладками. Це API не було використано через низьку підтримку на момент створення рішення, хоча воно й має потенціал для розв’язання аналогічних проблем.

Також були ідеї використати Service Worker як proxy шар, що буде централізовано перехоплювати запити та за потреби виконувати оновлення сесії. Це рішення не було впроваджено, оскільки вимагало змін в інфраструктуру нашого застосунка й вносило додаткову складність.

BFF

Ідейно схожим рішенням до Service Worker API є використання патерну Backend for Frontend, окремого сервісу, що виконує проксювання та трансформацію запитів між фронтенд-застосунком та іншими сервісами системи. Рішення нам підходило, оскільки BFF вже присутній у нашій системі й це дозволило б спростити поточний застосунок і уникнути проблеми синхронізації рефреш-запитів.

Однак після додаткового дослідження було виявлено ряд недоліків:

  • необхідність зберігати та управляти станом на рівні BFF, що вимагало впровадження кешу чи аналогічного рішення;
  • вичерпування ресурсів та додаткове навантаження на BFF, оскільки кожен запит вимагає додаткової логіки з менеджментом сесії.

Таким чином попереднє рішення з мʼютексом було вдосконалено шляхом інтеграції Web Locks API.

Підсумок

Отже, м’ютекси — це не лише поняття, що живе на сервері. Вони можуть стати у пригоді й на фронтенді, коли йдеться про паралельні операції чи доступ до спільного ресурсу, наприклад:

  • при роботі з localStorage чи cookies (спільний ресурс);
  • при координації роботи Web Workers (паралельні операції);
  • при роботі з Service Workers, коли потрібне власне кешування (спільний ресурс).

Не бійтеся використовувати м’ютекси у своїх проєктах. А щоб зробити їхнє використання ефективнішим, варто залучити Web Locks API браузера.

Ресурси:

— Мʼютекс
— Взаємне блокування (Deadlock)
— Optimistic UI
— JSON Web Token
— HTTP 401 Unauthorized
— HTTP 200 OK
— Promise
— Chain of Responsibility Pattern
— Web Locks API
— AbortController
— Readers-Writer Lock
— Web Locks API Support
— Polling (Computer Science)
— Document.cookie
— Page Visibility API
— Web Workers API
— Service Worker API

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось15
До обраногоВ обраному8
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

> Уявімо, що в нас є банківський застосунок з можливістю блокувати платіжну картку. Саме блокування має вигляд звичайної кнопки, яка змінює boolean стан.
> Перше рішення, яке мені спало на думку — зробити кнопку неактивною, доки виконується зміна стану, але це знижує час відгуку інтерфейсу, що є антипатерном у світі веброзробки.

Чи приклад поганий, чи підхід. Блокування картки — настільки серйозна операція, що як раз треба і неактивність кнопки, поки не зроблено, і якусь нотифікацію, що зроблено (при тому таку, щоб юзер підтвердив отримання відповіді).

Але навіть якщо ствердити, що операція проста, чому це раптом «антипаттерн»? Є якесь пояснення цьому рішенню? «знижує час відгуку» звучить якось надто абстрактно і не зовсім коректно.

> Ми можемо не одразу виконувати запит на зміну стану, а чекати на завершення попереднього запиту та оновлення асинхронного стану, і тільки після виконувати наступний за чергою асинхронний запит.

Ось саме для блокування, повторюсь, це вкрай погана ідея. Дуже легко натиснути таку кнопку двічі. Скільки навколо мишок з брязкітом контактів, будь-який адмін знає, і пересічний юзер. На лаптопі ще простіше зачепити тачпад, налаштований на детект коротких «тапів». Нормально треба ще підтвердити блокування чи розблокування в окремому діалозі і тільки після цього віддавати запит.

Спробуйте знайти інший приклад, цей надто невдалий.

Ну і якщо уявити собі дію, яку дійсно можна виконувати стільки разів, скільки натиснуто кнопку (яка там була ігра рік-два тому, де треба було пестити тваринку?), якщо є хоч якісь дані крім самого факта натискання, треба створювати чергу (queue) запитів, а не мʼютекс. Це і знижує навантаження — менше тредів чи промісів, і дозволяє контролювати сам стан черги (ґрупувати запити, реагувати на їх кількість).

Приклад з рефрешем вже якось на щось схожий. Ну і Web locks API це вже корисно.

треба створювати чергу (queue) запитів, а не мʼютекс

А вона буде створена неявно через проміси. Так, це буде важко контролювати, але у випадку, коли таки не вдалося зробити оновлення токена, то й пофігу на всі ті запити, що чекають на реліз, все одно користувача на логін треба відправляти.

Хоча я також не сильно фанат жодних блокувань.

Там де блок, там й дедлок.

Старовинна українська приказка...

Згоден, якщо дивитися більш з продуктової точки зору, то для блокуванням картки відсутність блокування кнопки може нашкодити, але для безпечніших операцій це часто бажана поведінка. Наприклад, коли ви в інстаграмі ставите вподобайку, то кнопка не блокується на час мережевого запиту, а лишається активною, що дає користувачу можливість вподобайку прибрати та візуальний відгук, що кнопка змінила свій стан одразу.

Черга це один із можливих варіантів вирішення даної задачи. В контексті статті хотілось надати читачу спрощений приклад мутації спільного стану паралельними асинхронними запитами, для введення у тему.

Дізнався для себе чимало нового, що чим далі рідше. І гарно написано, оформлено. Можливо, варто дещо скоротити, сфокусувавшись на основній темі. Авторе, пиши ще!

Окремо повеселило:
зробити кнопку неактивною, доки виконується зміна стану є антипатерном у світі веброзробки

Тобто ми перебудовуємо стан інтерфейсу і показуємо користувачу всі доступні можливості додатку не дочекавшись відповіді сервера? Для чого? Щоб він мав можливість викликати нові АРІ доки попередній запит ще не завершився?

А чому це погано ? Коли десктоп апплікація може робити щось в бакграунді то це ок, а коли Web то ні ?
Насправді классична синхронна схема клієнт серверної архітектури, це швидше ліміиація яка робить товсті застосунки якими безперечно є сучасні SPA на :Angular, React чи Vue з 100% AJAX окрім випадків SSR схожими на MS DOS.
Так втрачається частина ідеї AJAX, що зараз зветься API First.
Скажімо у вас є текстовий процессор типу Google Doc, ви натиснули зберігти документ, в цей час на сервер полетіла команда екпортуапти модель в файл odc, зжаьи його якимось gzip потім зберігти в бакет Claud Storage і т.д. В цей час юзер може не чекати, а скажімо почати створювати новий документ.
Колись давно, я розробляв компонент — календар, якій шукав можливості бронювання готелів в бакграунді, роблячи важкі запити на службу пошуку коли юзер переключав тижні. Та інтерфейс не заморожувався докі йшла відповідь, можна було заповняти інщі форми.

1. Це не мютекси. Трохи наркоманіі.
2. Для рефреш апі не треба слати відразу в клієнті рефреш тільки в ui. Якщо і піде ніяких помилок не має бути при 2 паралельних запитах. Або якщо другий запит на рефреш буде то все одно нічого це не зламає
3. Вся стаття з поганим прикладом.

Atomics то інше взагалі не наркоманьте дякую.

Або якщо другий запит на рефреш буде то все одно нічого це не зламає

При умові, що дві відповіді на запити прийшли зі збереженням порядку виклику. Інакше, при ситуації

  1. Request A
  2. Request B
  3. Response B
  4. Response A
В системі буде записаний токен A, що є вже невалідним.

1. Краще про локалсторадж розкажіть чи покажіть як синхронізувати доступ до локалсторадж :) я посміюсь.
2. не забивайте сервак ви можете 5 реквестів слати одночасно взагалі HTTP/1 ну чи 6 на один оріджі.
3. твій реквест один відпаде же чи ви тільки вкатуєтесь в программування, типу два реквеста на рефреш? ось ваш зумерський клауд пише імплементацію, ну воно нічого не поламає (лишній запит)

/ GOOD: Atomic operation
app.post(’/oauth/refresh’, async (req, res) => {
const { refresh_token } = req.body;

// Атомарно читаємо і видаляємо
const result = await db.query(
’DELETE FROM tokens WHERE token = $1 RETURNING user_id’,
[refresh_token]
);

if (result.rows.length === 0) {
// Токен не існує АБО вже використаний
return res.status(401).json({ error: ’invalid_token’ });
}

const userId = result.rows[0].user_id;

// Генеруємо нові
const newAccessToken = generateAccessToken(userId);
const newRefreshToken = generateRefreshToken(userId);

// Зберігаємо новий
await db.query(
’INSERT INTO tokens (token, user_id) VALUES ($1, $2)’,
[newRefreshToken, userId]
);

res.json({ access_token: newAccessToken, refresh_token: newRefreshToken });
});

не поламає якщо ви самі не ламаєте, звичайно, це сервер сайд імплементація.

Краще про локалсторадж розкажіть чи покажіть як синхронізувати доступ до локалсторадж :) я посміюсь.

Банальна кьюшка в шаред воркері. Наступне запитання.

2. не забивайте сервак ви можете 5 реквестів слати одночасно взагалі HTTP/1 ну чи 6 на один оріджі.

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

типу два реквеста на рефреш?

Два реквеста на рефреш з ОДНОГО таба нічого не зламають. Це валідна ситуація. Але два паралельних реквеста з РІЗНИХ табів зроблять один з токенів невалідним. Саме тому в статті описана система, при якій ДВА чи більше табів будуть нормально існувати та не призводити до нескінченних логаутів чи оновлень токенів.

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

Як девелопер ви це не можете контролювати і не треба, 5 запитів і все.

один з токенів невалідним

Який один з токенів, там тільки один рефреш токен, який він роллить і все. Не поламається ні одна таба. Та яка пошле успішний реквест оновить його і все, інша зробить... Але ще раз не шліть в один момент 5 реквестів за ваших 50 табів, дякую.

Я зрозумів думаю, що чувак хоче добитись, він хоче пофіксити конкретно один поломаний, якийсь апі, або неправильно оброблений, і прибрати повністю рейскондішін у якого їх мільйон в аппці судячи з того шо він шле купу реквестів замість юзера з браузера зі всіх табів. Треба правильно писати апі і обробляти його.

Шаред воркер ще рано, мабуть, говорити, я про локалсторадж і синхронізацію запитую.

Отже, м’ютекси — це не лише поняття, що живе на сервері. Вони можуть стати у пригоді й на фронтенді, коли йдеться про паралельні операції чи доступ до спільного ресурсу, наприклад:

при роботі з localStorage чи cookies (спільний ресурс);
при координації роботи Web Workers (паралельні операції);

Ось цього приклад, будь ласка (не про воркери)

Якщо казати про приклад синхронізації localStorage, то, хоча сама операція читання/запису до нього є синхронною, але код може мати логіку роботи, що містить асинхронність, як приклад нижче, де одночасно дві таби можуть прочитати однакове init значення та одночасно почати виконувати асинхронну дію, хоча, за вимогою, тільки одна таба може одночасно робити асинхронний запит:

```ts
const initValue = getFromLS(key);
if (!initValue) {
const newValue = await requestNewValue();
setLS(key, newValue);
}

Як девелопер ви це не можете контролювати і не треба, 5 запитів і все.

Звісно ні. Воно й не треба.

Але ще раз не шліть в один момент 5 реквестів за ваших 50 табів, дякую.

Це неможливо проконтролювати. В табі щось може жити своїм життям, якісь хартбіти, періодичні запити на оновлення даних, тощо. Браузер, наскільки я памʼятаю (можу помилятися) не сповіщує ніяк сторінці, що вона в неактивному стані. Тому зупинити комунікацію між сторінкою та бекендом не вийде.

ось як треба робити аутентифікацію, відос для джунів

youtu.be/...​e4Q_A?si=vmGsrcWeO05Spdap

Моя персональна думка — підхід має право на життя, але не для авторизації. Все одне залишається шанс на дедлок. Так, його можна пофіксити, теоретично, шляхом впровадження семафорів або подібних алгоритмів зі світу операційних систем (тут допоможе праця Дейтела «Введення в операційні системи»), але це буде неймовірний оверкіл.

Як на мене, рефреш токени не для фронта, їх простіше контролювати на бекенді. Основна вимога тут — функція оновлення рефреш токена мусить бути тільки в одному місці. Розподілена система може бути побудована тільки для розповсюдження оновленого токена, а не для його оновлення. А в браузері майже немає механізмів для реалізації цього надійно.

Семафори мютекси — це обʼєкти ядра. Мьютекс то для коду, js — single thread. Localstorage сінхронний. Ну так, можна для індексед дб використовувати, але там є транзакціі.

Якщо тут про воркери тільки писати то може знадобитись ця фіча але дуже рідко. Можна просто через local storage писати читати дані синхронізуватись. Це якщо папроще

SharedWorker впорається з овновленням токена на ура. Його перевага в тому, що не треба підчищати за ним сміття. Недолік: закрив всі таби — втратив токен, логінься наново.

я про воркери казав шо локи можуть мабуть знадобитись, для рефреш токена це оверхед, ви джун?

я про воркери казав шо локи можуть мабуть знадобитись

Для шаред воркера він нафіг не потрібний. Інтерфейс цього воркера мусить бути простим — він завжди повертає проміс з токеном. Дві таби звертаються до воркера водночас. Якщо запити попали в час, коли йде процес оновлення, то обидва будуть чекати на завершення. Навіщо тут локи?

ви джун?

Всі ми джуни.

я кажу шо для локалстораджа синхронізація не потрібна. А локалсторадж потрібен для синхронізації табів, наприклад, але це дуже рідко

Якщо всі ми джуни, то ок, я зрозумів.

я читаю шо шаредворкери не у всіх. браузерах підтримуються, це для вас дуже сучасні технології

js — single thread

Це для одного таба. Різні таби можуть жити в різних потоках. Браузер не мусить гарантувати те, що всі таби з одним оріджином будуть в одному потоці бовтатися. Плюс, вся HTTP комунікація за замовчуванням асинхронна. Ось тут й випливають всі проблеми.

Ну так, можна для індексед дб використовувати

Це вже рух в правильному напрямку, як на мене. Але можна взагалі не заморочуватися. Якщо імплементувати механізм replay, коли запит, отримавши 401, буде намагатися повторюватися до тих пір, допоки не прийде щось інше, але з притомним TTL, то нафіг вся ця синхронізаційно-мʼютексна «магія» не потрібна.

Мені продовжує полихати, можете показати приклад (код), де це проблема для ВАС (з localStorage, з cookie). 100% магія не потрібна, я про індекседдб говорив про конкуренсі нюанси, але там є транзакції, знову ж.

Тут не треба код, щоб зрозуміти проблему. Запис в локалсторейдж, попри те, що він синхронний для однієї з таб, може статися в період, коли в іншій табі в іншому потоці буде читання до того, як записаний новий токен.

це ви просто видумки, трохи наркоманії я ж кажу, тобто те, чого нема, вважається що є, можна далі не обговорювати без прикладу
localStorage — синхронний, те ж кукі

То й що? В будь-якому асинхронному коді є шматки де код виконується синхнонно. Що це мусить доводити? Нічого.

Механізм повтору запитів це гарне просте рішення, але мною не був обраний такий варіант, оскільки це збільшує навантаження на клієнт і сервер через додаткові запити, але не дає повну гарантію запобігання конкретних проблем у моєму випадку.

збільшує навантаження на клієнт і сервер через додаткові запити

Це так звана економія на сірниках. Якщо ваш сервер опрацьовує мільйон запитів в годину, то додаткові 100 не зроблять взагалі ніякого навантаження.

але не дає повну гарантію запобігання конкретних проблем у моєму випадку.

Яких саме проблем? Можна детальніше?

Умовно, коли запит отримує 401 помилки й починає процес ретраю, то TTL цього ретраю може закінчитися раніше, ніж refresh операція закінчиться і результат буде logout. Хотілось уникнути такої вірогідністі.

TTL зазвичай мусить бути трохи довшим за час експайра токена. Але можна його кількісним зробити, наприклад, не більше 100 разів кожні 10 секунд.

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

Потенційно так, але сам рефреш токен може жити досить довго

Якщо він живе довго, то краще його не використовувати взагалі. Його мета полягала в тому, щоб позбутися кук та сесій на сервері та мати якийсь короткий час токена, щоб швидше інвалідувати його стан та не давати зайвий раз його використати зловмиснику. Тому його час життя зазвичай хвилин 5 лише. Якщо ви робите replay кожні 30 секунд, то за 5 хвилин ви можете зробити «зайвих» 10 запитів.

що користувач буде довше очікувати на виконання запиту.

Це не сильно принципово. Шанс попасти на таке очікування тим менше, чим більше вкладанок у вас відкрито. ;)

Дякую за працю, треба буде почитати, виглядає цікаво.
За тривалий час проблем з deadlock у даному рішенні не зустрічав, можете поділитись потенційним прикладом такого випадку?

Вирогідність дедлоку мала просто. Тому не зустрічали. Все через закриття таба та інтеркомунікації між ними. Це асинхронний процес, який може виконуватися з різною швидкістю (браузер сповільнює таби при довгому періоді неактивності). Але, може я й не правий.

Дякую за статью, мені якраз знадобилося це, а джпт навіть не натякнув і почав ускладнювати про лідер таби браузера через конкурентність

Цікаво, що в JavaScript можливо використовувавти і Lock Free, через класс Atomics. Де це викорастати в Frontend гадки не маю, та в Node в якихось дуже дивних випадках може знадобиьтсь.

1 atomics дуже часто в браузері використовую
2 node це також v8 javascript

Особисто не стикався з випадками, де можна було б використати ці можливості, але API точно корисне для вирішення точкових проблем, пов’язаних з конкурентністю, дякую.

Крута стаття! Хоча виглядає наче колеги на бекенді недопрацювали 💻

Бекенд там ні до чого. Це все наслідки стандарту OAuth.

Не ламай нічого при /рефреш. Не шли зі всіх вкладок відразу рефреш

Щоб нічого не слати з різних вкладок, треба щоб вони знали хоча б, що сусідні вкладки існують. А це та сама синхронізація, проблему якої намагався вирішити автор статті.

просто не треба такої логіки в клієнті дякую, аутентифікація не повинна нічого знати про інші треди і вкладки, також дякую

Або на бекенді або на клієнті недопрацювали. Але стаття — 75 відсотків булшіт. Якщо це буде різний браузер то там буде взагалі різний локалсторадж. Мені лінь це все розписувати але я прогорів від цієї статті це Джуни таке воротять зараз походу

Зауваження з localstorage коректне в розрізі проблеми авторизації, але відсутність можливості синхронізувати різних браузери все ще може бути проблемою, яку дане API не вирішує, тому вирішив це згадати

Не треба ніколи синхронізувати різні браузери. Це вкрай небезпечне рішення.

для аутентифікації не треба синхронізувати вкладки, кукі так само синхронні як локалсторадж

github.com/...​332947/test/refresh.ts#L4
Тут нема ніяких локів, для прикладу, покажіть мені код чужий, якщо свій не можете.

Кукі не рефреш токен. Не треба їх плутати.

Та однаково як там переноситься токен Це я піду в скверик відкласти як усі києвляни а мої банківські рахунки будуть доступні моїм побратимам з соціальної підтримки. це ріал ворлд проблема не в тему. Тому що таби завалюють сервер реквестами на рефреш

Не однаково. Сутність рефреш-токена полягає в тому, що він тимчасовий, при його крадіжці мамкини кулхацкєри отримують доступ до системи тільки до його оновлення. При куці, яка може бути безлімітною по часу, заходь хто хочеш, бери що хочеш, ніхто не проти.

async refreshToken(): Promise {

if (this.activeRefresh) {
// If we are currently already doing this operation,
// make sure we don’t do it twice in parallel.
return this.activeRefresh;
}

Це класика, але вайбкодерам треба mutex, коли сефамори пудїдуть?

А це працюватиме між різними сторінками (табами) одного браузеру? Згідно з тим, що я побачив зі статті, ні.

На жаль, не працюватиме, оскільки нам потрібно якимось чином дізнатися чи відбувається рефреш у іншій табі.

Клюб джунів по версії AQUA DECENTRALIZED поповнився ще парою учасників... :D

Супер стаття, дякую! Знаємо, користуємося.

Підписатись на коментарі