Spec-Zone.ru › Web APIs

Установление соединения: образец идеальной переговорной WebRTC

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

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

После первых развертываний браузеров, поддерживающих WebRTC, было понято, что часть процесса переговоров была сложнее, чем необходимо для типичных случаев использования. Это было связано с небольшим количеством проблем с API и некоторыми потенциальными проблемами гонок, которые необходимо было предотвратить. Эти проблемы с тех пор были решены, что позволило нам значительно упростить наши переговоры WebRTC. Шаблон идеальной переговорной является примером того, как улучшились переговоры с тех пор, как WebRTC появился.

Концепции идеальной переговорной

Идеальная переговорная позволяет беспрепятственно и полностью отделить процесс переговоров от остальной логики вашего приложения. Переговоры являются по своей природе асимметричной операцией: одна сторона должна действовать как «звонящий», а другой peer — как «принимающий». Идеальный шаблон переговоров сглаживает это различие, разделяя его на независимую логику переговоров, так что ваше приложение не должно заботиться о том, какой конец соединения оно имеет. Что касается вашего приложения, не имеет значения, совершаете ли вы звонок или принимаете звонок.

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

Идеальная переговорная работает путем назначения каждой из двух peer ролей в процессе переговоров, которые полностью отделены от состояния подключения WebRTC:

  • Вежливый peer, который использует сброс ICE для предотвращения столкновений с входящими предложениями. Вежливый peer, по сути, это peer, который может отправлять предложения, но затем отвечает, если пришло предложение от другого peer с «Хорошо, не нужно, отмените мое предложение, и я рассмотрю ваше вместо него».
  • Невежливый peer, который всегда игнорирует входящие предложения, которые сталкиваются с собственными предложениями. Он никогда не извиняется и не уступает ничего вежливому peer. Всякий раз, когда происходит столкновение, невежливый peer побеждает.

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

Как вы определяете, какой peer вежливый, а какой невежливый, обычно зависит от вас. Это может быть так просто, как назначить вежливую роль первому peer, подключившемуся к серверу сигнализации, или вы можете сделать что-то более сложное, например, заставить peer обменяться случайными числами и назначить вежливую роль победителю. Однако вы не сделаете это определение, как только эти роли будут назначены двум peer, они смогут работать вместе для управления сигнализацией таким образом, который не заблокируется и не потребует много дополнительного кода для управления.

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

Реализация идеальной переговорной

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

Обратите внимание, что этот код идентичен для обеих peer, участвующих в соединении.

Создание сигнализации и подключений peer

Сначала необходимо открыть канал сигнализации и создать подключение RTCPeerConnection. Сервер STUN, указанный здесь, очевидно, не настоящий; вам нужно заменить stun.my-server.tld адресом реального сервера STUN.

const config = {
  iceServers: [{ urls: "stun:stun.my-stun-server.tld" }],
};

const signaler = new SignalingChannel();
const pc = new RTCPeerConnection(config);

Этот код также получает элементы <video> с классами «self-view» и «remote-view»; они будут содержать соответственно локальный просмотр пользователя и просмотр входящего потока от удаленного peer.

Подключение к удаленному peer

const constraints = { audio: true, video: true };
const selfVideo = document.querySelector("video.self-view");
const remoteVideo = document.querySelector("video.remote-view");

async function start() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia(constraints);

    for (const track of stream.getTracks()) {
      pc.addTrack(track, stream);
    }
    selfVideo.srcObject = stream;
  } catch (err) {
    console.error(err);
  }
}

Функцию start() выше можно вызвать с любой из двух точек, которые хотят поговорить друг с другом. Не имеет значения, кто это сделает первым; переговоры будут просто работать.

Это не сильно отличается от более старого кода установления соединения WebRTC. Камера и микрофон пользователя получают вызовом getUserMedia(). Результирующие медиапотоки затем добавляются к RTCPeerConnection путем передачи их в addTrack(). Затем, наконец, источник медиа для элемента <video> self-view, указанного константой selfVideo, устанавливается на поток камеры и микрофона, что позволяет локальному пользователю видеть то, что видит другой peer.

Обработка входящих потоков

Далее нам нужно настроить обработчик событий track для обработки входящих видео- и аудиопотоков, которые были согласованы для приема этим подключением peer. Для этого мы реализуем обработчик событий RTCPeerConnection’s ontrack.

pc.ontrack = ({ track, streams }) => {
  track.onunmute = () => {
    if (remoteVideo.srcObject) {
      return;
    }
    remoteVideo.srcObject = streams[0];
  };
};

Когда происходит событие track, этот обработчик выполняется. Используя деструктуризацию, извлекаются свойства RTCTrackEvent’s track и streams. Первый — это либо видеопоток, либо аудиопоток, получаемый. Последний — это массив объектов MediaStream, каждый из которых представляет собой поток, содержащий этот поток (в редких случаях поток может принадлежать нескольким потокам одновременно). В нашем случае это всегда будет содержать один поток, с индексом 0, потому что мы передали один поток в addTrack() ранее.

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

Если у нас уже есть видео от удаленного peer (что мы можем увидеть, если свойство srcObject элемента remote view уже имеет значение), мы ничего не делаем. В противном случае мы устанавливаем srcObject на поток с индексом 0 в массиве streams.

Логика идеальной переговорной

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

Обработка события negotiationneeded

Сначала мы реализуем обработчик события RTCPeerConnection onnegotiationneeded, чтобы получить локальное описание и отправить его через канал сигнализации удалённому участнику.

let makingOffer = false;

pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    await pc.setLocalDescription();
    signaler.send({ description: pc.localDescription });
  } catch (err) {
    console.error(err);
  } finally {
    makingOffer = false;
  }
};

Обратите внимание, что setLocalDescription() без аргументов автоматически создаёт и устанавливает соответствующее описание на основе текущего состояния signalingState. Установленное описание — это либо ответ на последнее предложение от удалённого участника, либо свежесозданное предложение, если переговоров ещё не начато. Здесь это всегда будет offer, потому что событие negotiationneeded генерируется только в состоянии stable.

Мы устанавливаем булеву переменную makingOffer в значение true , чтобы отметить, что мы готовим предложение. Чтобы избежать проблем с гонками, мы будем использовать это значение позже вместо состояния сигнализации для определения того, обрабатывается ли предложение, потому что значение signalingState изменяется асинхронно, что создаёт возможность конфликтов.

После того, как предложение было создано, установлено и отправлено (или произошла ошибка), makingOffer возвращается к значению false.

Обработка входящих кандидатов ICE

Далее, нам нужно обработать событие RTCPeerConnection icecandidate, с помощью которого локальный ICE-слой передаёт кандидатов для передачи удалённому участнику через канал сигнализации.

pc.onicecandidate = ({ candidate }) => signaler.send({ candidate });

Это значение candidate этого события ICE передаётся через метод send() канала сигнализации для отправки на сервер сигнализации удалённому участнику.

Обработка входящих сообщений в канале сигнализации

Последний элемент головоломки — это код для обработки входящих сообщений с сервера сигнализации. Он реализован как обработчик события onmessage объекта канала сигнализации. Этот метод вызывается каждый раз, когда приходит сообщение с сервера сигнализации.

let ignoreOffer = false;

signaler.onmessage = async ({ data: { description, candidate } }) => {
  try {
    if (description) {
      const offerCollision =
        description.type === "offer" &&
        (makingOffer || pc.signalingState !== "stable");

      ignoreOffer = !polite && offerCollision;
      if (ignoreOffer) {
        return;
      }

      await pc.setRemoteDescription(description);
      if (description.type === "offer") {
        await pc.setLocalDescription();
        signaler.send({ description: pc.localDescription });
      }
    } else if (candidate) {
      try {
        await pc.addIceCandidate(candidate);
      } catch (err) {
        if (!ignoreOffer) {
          throw err;
        }
      }
    }
  } catch (err) {
    console.error(err);
  }
};

При получении входящего сообщения с SignalingChannel через его обработчик события onmessage, полученный JSON-объект деструктурируется для получения значения description или candidate внутри него. Если входящее сообщение содержит description, это означает, что это предложение или ответ, отправленный другим участником.

Если же сообщение содержит candidate, это кандидат ICE, полученный от удалённого участника в рамках trickle ICE. Кандидат предназначен для передачи в локальный ICE-слой путём вызова addIceCandidate().

При получении описания

Если мы получили description, мы готовимся ответить на входящее предложение или ответ. Сначала мы проверяем, находимся ли мы в состоянии, позволяющем принять предложение. Если состояние сигнализации соединения не stable или если наша сторона соединения начала процесс создания собственного предложения, то нам нужно следить за коллизией предложений.

Если мы — невежливый участник и получаем коллидующее предложение, мы возвращаемся без установки описания и вместо этого устанавливаем ignoreOffer в значение true , чтобы убедиться, что мы также игнорируем все кандидаты, которые другая сторона может нам отправлять по каналу сигнализации, относящиеся к этому предложению. Это предотвращает ошибки, так как мы никогда не сообщали нашей стороне об этом предложении.

Если мы — вежливый участник и получаем коллидующее предложение, нам ничего специально не нужно делать, потому что наше существующее предложение автоматически будет отменено на следующем шаге.

Убедившись, что мы хотим принять предложение, мы устанавливаем удалённое описание на входящее предложение, вызвав setRemoteDescription(). Это позволяет WebRTC узнать, какова предполагаемая конфигурация другого участника. Если мы вежливые участники, мы отбросим своё предложение и примет новое.

Если только что установленное удалённое описание является предложением, мы попросим WebRTC выбрать соответствующую локальную конфигурацию, вызвав метод RTCPeerConnection setLocalDescription() без параметров. Это заставит WebRTC автоматически сгенерировать соответствующий ответ на полученное предложение. Затем мы отправим ответ через канал сигнализации обратно первому участнику.

При получении кандидата ICE

С другой стороны, если полученное сообщение содержит кандидата ICE, мы передаём его в локальный ICE-слой, вызвав метод RTCPeerConnection addIceCandidate(). Если произошла ошибка и мы проигнорировали последнее предложение, мы также проигнорируем любую ошибку, которая может возникнуть при попытке добавить кандидата.

Идеализация процесса переговоров

Если вас интересует, что делает идеальные переговоры именно идеальными, этот раздел для вас. Здесь мы рассмотрим изменения, внесённые в API WebRTC и рекомендации по лучшим практикам для достижения идеальных переговоров.

Бесконфликтное setLocalDescription()

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

Старый способ

Рассмотрим этот обработчик события onnegotiationneeded:

pc.onnegotiationneeded = async () => {
  try {
    await pc.setLocalDescription(await pc.createOffer());
    signaler.send({ description: pc.localDescription });
  } catch (err) {
    console.error(err);
  }
};

Поскольку метод createOffer() асинхронный и занимает некоторое время, у удалённого участника есть время, чтобы попытаться отправить собственное предложение, заставив нас выйти из состояния stable и перейти в состояние have-remote-offer, что означает, что мы теперь ждём ответа на предложение. Но как только удалённый участник получит только что отправленное нами предложение, он тоже окажется в подобной ситуации. Это ставит оба участника в состояние, при котором попытка соединения завершить невозможно.

Идеальные переговоры с обновлённым API

Как показано в разделе Реализация идеальных переговоров, мы можем устранить эту проблему, введя переменную (здесь она называется makingOffer), которую мы используем для обозначения того, что мы в процессе отправки предложения, и используя обновлённый метод setLocalDescription():

let makingOffer = false;

pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    await pc.setLocalDescription();
    signaler.send({ description: pc.localDescription });
  } catch (err) {
    console.error(err);
  } finally {
    makingOffer = false;
  }
};

Мы устанавливаем makingOffer сразу же перед вызовом setLocalDescription() , чтобы предотвратить вмешательство в отправку этого предложения, и мы не сбрасываем его до значения false до тех пор, пока предложение не будет отправлено на сервер сигнализации (или произошла ошибка, не позволившая создать предложение). Таким образом, мы избегаем риска столкновения предложений.

Автоматическое откат в setRemoteDescription()

Ключевым компонентом идеальной (perfect) переговорной процедуры является концепция вежливого (polite) узла, который всегда отменяет (rolls itself back) свои действия, если получает предложение (offer) во время ожидания ответа на ранее отправленное предложение. Ранее, для инициирования отмены требовалось вручную проверять условия отмены и вручную запускать отмену, устанавливая локальное описание (local description) с типом rollback, примерно так:

await pc.setLocalDescription({ type: "rollback" });

Это возвращает локальный узел в состояние stable signalingState из любого предыдущего состояния. Поскольку узел может принять предложения только в состоянии stable , узел тем самым отменяет своё предложение и готов принять предложение от удалённого (невежливого) узла. Однако, как мы увидим чуть позже, с этим подходом есть проблемы.

Идеальная переговорная процедура со старым API

Использование предыдущего API для обработки входящих сообщений о переговорах во время идеальной переговорной процедуры выглядело бы примерно так:

signaler.onmessage = async ({ data: { description, candidate } }) => {
  try {
    if (description) {
      if (description.type === "offer" && pc.signalingState !== "stable") {
        if (!polite) {
          return;
        }

        await Promise.all([
          pc.setLocalDescription({ type: "rollback" }),
          pc.setRemoteDescription(description),
        ]);
      } else {
        await pc.setRemoteDescription(description);
      }

      if (description.type === "offer") {
        await pc.setLocalDescription(await pc.createAnswer());
        signaler.send({ description: pc.localDescription });
      }
    } else if (candidate) {
      try {
        await pc.addIceCandidate(candidate);
      } catch (err) {
        if (!ignoreOffer) {
          throw err;
        }
      }
    }
  } catch (err) {
    console.error(err);
  }
};

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

Код проверяет, является ли сообщение предложением (offer), и если да, то стабильно ли состояние локального сигнализационного узла stable. Если оно не стабильно, *и* локальный узел вежлив, нам нужно инициировать отмену, чтобы заменить исходящее предложение новым входящим. И оба этих действия должны быть выполнены до обработки полученного предложения.

Так как нет единой команды «отменить и использовать это предложение вместо», для выполнения этих изменений на вежливом узле требуется два шага, выполняемые в контексте Promise.all(), что гарантирует полное выполнение обоих утверждений перед продолжением обработки полученного предложения. Первый шаг инициирует отмену, а второй устанавливает удалённое описание (remote description) к полученному, завершая процесс замены ранее *отправленного* предложения новым *полученным*. Вежливый узел теперь стал стороной, принимающей вызов (callee), вместо стороны, совершающей вызов (caller).

Все остальные описания, полученные от невежливого узла, обрабатываются как обычно, путём передачи их в setRemoteDescription().

Наконец, мы обрабатываем полученное предложение, вызывая setLocalDescription() для установки нашего локального описания (local description) к значению, возвращённому createAnswer(). Затем оно отправляется вежливому узлу через канал сигнализации.

Если входящее сообщение содержит кандидат ICE, а не SDP-описание, оно передаётся в слой ICE путём передачи его в метод RTCPeerConnection addIceCandidate(). Если при этом произойдёт ошибка, и мы не просто отбросили предложение из-за того, что являемся невежливым узлом во время коллизии, мы throw ошибку, чтобы вызывающий код мог её обработать. В противном случае мы игнорируем ошибку, так как в этом контексте она не имеет значения.

Идеальная переговорная процедура с обновлённым API

Обновлённый код использует возможность вызова setLocalDescription() без параметров, чтобы он выполнял правильные действия автоматически, а также тот факт, что setRemoteDescription() автоматически отменяет действия при необходимости. Это позволяет нам избавиться от необходимости использования Promise для поддержания временной последовательности, так как отмена становится неотъемлемой частью вызова setRemoteDescription().

let ignoreOffer = false;

signaler.onmessage = async ({ data: { description, candidate } }) => {
  try {
    if (description) {
      const offerCollision =
        description.type === "offer" &&
        (makingOffer || pc.signalingState !== "stable");

      ignoreOffer = !polite && offerCollision;
      if (ignoreOffer) {
        return;
      }

      await pc.setRemoteDescription(description);
      if (description.type === "offer") {
        await pc.setLocalDescription();
        signaler.send({ description: pc.localDescription });
      }
    } else if (candidate) {
      try {
        await pc.addIceCandidate(candidate);
      } catch (err) {
        if (!ignoreOffer) {
          throw err;
        }
      }
    }
  } catch (err) {
    console.error(err);
  }
};

Хотя разница в размере кода незначительна, и сложность не сильно уменьшилась, код стал намного надёжнее. Давайте детально рассмотрим его работу.

При получении описания

В переработанном коде, если полученное сообщение — SDP description, мы проверяем, было ли оно получено во время попытки передачи предложения. Если полученное сообщение — offer *и* локальный узел является невежливым, *и* происходит коллизия, мы игнорируем предложение, потому что хотим продолжить попытку использования предложения, которое уже находится в процессе отправки. Это действие невежливого узла.

В любом другом случае мы попробуем обработать входящее сообщение. Это начинается с установки удалённого описания (remote description) к полученному значению description путём передачи его в setRemoteDescription(). Это работает независимо от того, обрабатываем ли мы предложение или ответ, так как отмена будет выполнена автоматически при необходимости.

В этот момент, если полученное сообщение — offer, мы используем setLocalDescription() для создания и установки соответствующего локального описания (local description), а затем отправляем его на удалённый узел через сервер сигнализации.

При получении кандидата ICE

С другой стороны, если полученное сообщение — кандидат ICE (определяемый JSON-объектом, содержащим член candidate), мы передаём его в локальный слой ICE, вызывая метод RTCPeerConnection addIceCandidate(). Ошибки, как и прежде, игнорируются, если мы только что отбросили предложение из-за того, что являемся невежливым узлом во время коллизии.

Добавление явного метода restartIce()

Методы, которые ранее использовались для инициирования перезапуска ICE во время обработки события negotiationneeded, содержат серьёзные недостатки. Эти недостатки затрудняли безопасное и надёжное инициирование перезапуска во время переговоров. Улучшения в идеальной переговорной процедуре устранили эту проблему, добавив новый метод restartIce() в RTCPeerConnection.

Прежний метод

В прошлом, если вы сталкивались с ошибкой ICE и вам нужно было перезапустить переговоры, вы могли сделать что-то вроде этого:

pc.onnegotiationneeded = async (options) => {
  await pc.setLocalDescription(await pc.createOffer(options));
  signaler.send({ description: pc.localDescription });
};
pc.oniceconnectionstatechange = () => {
  if (pc.iceConnectionState === "failed") {
    pc.onnegotiationneeded({ iceRestart: true });
  }
};

Это имело ряд проблем с надёжностью и прямых ошибок (например, неудачу, если событие iceconnectionstatechange срабатывает, когда состояние сигнализации не stable), но не было возможности запросить перезапуск ICE, кроме как созданием и отправкой предложения с опцией iceRestart установленной в значение true. Таким образом, отправка запроса перезапуска потребовала прямого вызова обработчика события negotiationneeded. Правильно это сделать было непросто, и легко ошибиться, что и приводит к ошибкам.

Использование restartIce()

Теперь вы можете использовать restartIce() для выполнения этого намного чище:

let makingOffer = false;

pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    await pc.setLocalDescription();
    signaler.send({ description: pc.localDescription });
  } catch (err) {
    console.error(err);
  } finally {
    makingOffer = false;
  }
};
pc.oniceconnectionstatechange = () => {
  if (pc.iceConnectionState === "failed") {
    pc.restartIce();
  }
};

С этим улучшенным методом, вместо прямого вызова onnegotiationneeded с опциями для запуска перезапуска ICE, failed состояние соединения ICE вызывает restartIce(). restartIce() сообщает слою ICE автоматически добавить флаг iceRestart в следующее отправленное сообщение ICE. Проблема решена!

Отмена (Rollback) больше не поддерживается в состоянии pranswer

Последнее из значительных изменений API заключается в том, что отмена (rollback) больше не поддерживается в состояниях have-remote-pranswer или have-local-pranswer. К счастью, при использовании идеальной переговорной процедуры нет необходимости в этом, так как ситуации, которые могли бы потребовать такой отмены, распознаются и предотвращаются до того, как отмена станет необходимой.

Таким образом, попытка вызвать отмену (rollback) в одном из двух указанных pranswer состояний теперь будет вызывать исключение InvalidStateError.

См. также

  • API WebRTC
  • Продолжительность сессии WebRTC

© 2005–2024 MDN contributors.
Licensed under the Creative Commons Attribution-ShareAlike License v2.5 or later.
https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API