Это глава 46 раздела «SDK и UI-библиотеки» моей книги «API». Второе издание книги будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.

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

class OfferPanelComponent {
  …
  show (offer) {
    let fullData = await api
      .getFullOfferData(offer);
    …
  }
}

Возникает вопрос: а что должно произойти, если пользователь или разработчик пытается выбрать другой offerId, пока ответ сервера ещё не пришёл? Очевидно, нам нужно выбрать, какое из двух открытий панель мы должны запретить. Предположим, что мы решили блокировать интерфейс на время подгрузки данных и, таким образом, не давать выбирать другое предложение. Чтобы реализовать эту функциональность, нам нужно оповестить вышестоящие компоненты о начале и окончании загрузки:

class OfferPanelComponent {
  …
  show () {
    this.events.emit('beginDataLoad');
    let fullData = await api
      .getFullOfferData(offer);
    this.events.emit('endDataLoad');
    …
  }
}
// `Composer` прослушивает события
// на панели предложений и выставляет
// значения соответствующего флага
class SearchBoxComposer {
  …
  constructor () {
    …
    this.offerPanel.events.on(
      'beginDataLoad', () => {
        this.isDataLoading = true;
      }
    );
    this.offerPanel.events.on(
      'endDataLoad', () => {
        this.isDataLoading = false;
      }
    );
  }
  selectOffer (offer) {
    if (this.isDataLoading) {
      return;
    }
    …
  }
}

Но этот код очень плох по множеству причин:

  • непонятно, как его модифицировать, если у нас появятся разные виды загрузок данных, причём некоторые из них будут требовать блокировки интерфейса, а некоторые — нет;

  • этот код просто плохо читается: совершенно непонятно, почему события загрузки данных на одном компоненте влияют на пользовательскую функциональность в другом компоненте;

  • если при загрузке произойдёт какая-то ошибка, событие endDataLoad не произойдёт, и интерфейс останется заблокированным навсегда.

Если вы внимательно читали предыдущие главы, решение этих двух проблем должен быть очевидным. Необходимо абстрагироваться от самого факта загрузки данных и переформулировать проблемы в высокоуровневых терминах. У нас есть разделяемый ресурс — место на экране. Мы можем показывать в один момент времени только одно предложение. Следовательно, если какому-то актору требуется длящийся доступ к панели, он должен этот доступ явно получить. Отсюда следует, что:

  • флаг такого доступа должен именоваться явно (например, offerFullViewLocked, а не isDataLoading);

  • флаг контролироваться Composer-ом, но никак не самой панелью предложения (ещё и потому, что подготовка данных для показа — также ответственность Composer-а).

class SearchBoxComposer {
  constructor () {
    …
    this.offerFullViewLocked = false;
  }
  …
  selectOffer (offer) {
    if (this.offerFullViewLocked) {
      return;
    }
    this.offerFullViewLocked = true;
    let fullData = await api
      .getFullOfferData(offer);
    this.events.emit(
      'offerFullViewChange',
      this.generateOfferFullView(fullData)
    );
    this.offerFullViewLocked = false;
  }
}

Такой подход улучшает читабельность, но не помогает с проблемами параллельного доступа и ошибочно неснятых флагов. Чтобы решить их, нам нужно сделать ещё один шаг: не просто ввести флаг, но и процедуру его захвата (вполне классическим образом по аналогии с управлением разделяемыми ресурсами в системном программировании):

class SearchBoxComposer {
  …
  selectOffer (offer) {
    let lock;
    try {
      // Пытаемся захватить ресурс
      // `offerFullView`
      lock = await this.acquireLock(
        'offerFullView', '10s'
      );
      let fullData = await api
        .getFullOfferData(offer);
      this.events.emit(
        'offerFullViewChange',
        this.generateOfferFullView(fullData)
      );
      lock.release();
    } catch (e) {
      // Если получить доступ не удалось
      return;
    } finally {
      // Не забываем освободить ресурс
      // в случае ошибки
      if (lock) {
        lock.release();
      }
    }
  }
}

NB: вторым параметром в acquireLock мы передали максимальное время жизни блокировки — 10 секунд. Если в течение этого времени блокировка не снята (например, в случае, если мы забыли обработать какое-то исключение или выставить таймаут на запрос к серверу), она будет отменена автоматически, и интерфейс будет разблокирован.

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

lock = await this.acquireLock(
  'offerFullView', '10s', {
    // Добавляем описание,
    // кто и зачем пытается
    // выполнить блокировку
    reason: 'userSelectOffer',
    offer
  }
);

Тогда текущий владелец ресурса (или диспетчер блокировок, если мы реализуем такой объект) может, в зависимости от ситуации, отдавать владение ресурсом или, наоборот, запрещать перехват. Скажем, если открытие панели инициировано программистом через вызов API компонента (а не пользователем через выбор предложения в списке), оно может иметь более высокий приоритет и быть разрешено:

lock.events.on('tryAcquire', (actor) => {
  if (sender.reason == 'apiSelectOffer') {
    lock.release();
  } else {
    // Иначе запрещаем перехват
    return false;
  }
});

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

lock.events.on('lost', () => {
  this.cancelFullOfferDataLoad();
});

Паттерн контроля разделяемых ресурсов также хорошо сочетается с паттерном «модель»: акторы могут захватывать доступ на чтение и/или изменение свойств или групп свойств модели.

NB: мы могли бы решить проблему подгрузки данных иначе:

  • открыть панель предложения;

  • вместо настоящих данных отобразить спиннер или какую-то другую индикацию загрузки;

  • асинхронно обновить отображение при получении ответа от сервера.

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

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