Русский
Русский
English
Статистика
Реклама

Websocket

Перевод Как создать приложение-чат за двадцать минут

09.11.2020 12:16:47 | Автор: admin
image

Мой отец любит напоминать мне, что, будучи компьютерным инженером в 1970-х, он был программистом до того, как программирование стало модным. Пару раз он даже показывал старые скрипты Fortran и COBOL. Прочитав этот код, я с уверенностью могу сказать, что программирование сегодня определенно круче.

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

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

Сколько было бы построено небоскребов, если бы строители сами добывали себе сталь?

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

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

Как сделать приложение для чата


Давайте быстро создадим что-нибудь, что раньше занимало бы дни или недели. Мы сделаем Public Chat Room приложение, которое использует WebSockets для обмена сообщениями в реальном времени.

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

  • 8base управляемый GraphQL API
  • VueJS JavaScript фреймворк

Стартовый проект и полный файл README можно найти в этом репозитории GitHub. Если вы хотите просмотреть только готовое приложение, загляните в ветку public-chat-room.

Кроме того, в видео ниже (на английском языке) более подробно объясняется каждый шаг.

Начнем.

Семь шагов для создания чат приложения:


1. Настройка проекта


Клонируйте стартовый проект и перейдите в директорию группового чата. Вы можете сами определить, использовать yarn или npm для установки зависимостей проекта. В любом случае, нам нужны все NPM пакеты, обозначенные в файле package.json.

# Клонируем проектgit clone https://github.com/8base/Chat-application-using-GraphQL-Subscriptions-and-Vue.git group-chat# Переходим в директориюcd group-chat# Устанавливаем зависимостиyarn

Чтобы взаимодействовать с GraphQL API, мы должны настроить три переменные среды. Создайте файл .env.local в корневой директории с помощью следующей команды, и приложение Vue после инициализации автоматически установит переменные среды, которые мы добавили в этот файл.

echo 'VUE_APP_8BASE_WORKSPACE_ID=<YOUR_8BASE_WORKSPACE_ID>
VUE_APP_8BASE_API_ENDPOINT=https://api.8base.com
VUE_APP_8BASE_WS_ENDPOINT=wss://ws.8base.com' \
> .env.local


Оба значения VUE_APP_8BASE_API_ENDPOINT и VUE_APP_8BASE_WS_ENDPOINT менять не нужно. Необходимо только установить значение VUE_APP_8BASE_WORKSPACE_ID.

Если у вас есть воркспейс 8base, который вы хотите использовать для создания чат-приложения по нашему руководству, обновите файл .env.local, указав свой идентификатор воркспейса. Если нет получите идентификатор воркспейса, выполнив шаги 1 и 2 из 8base Quickstart.

2. Импорт схемы


Теперь нам нужно подготовить серверную часть. В корне этого репозитория вы должны найти файл chat-schema.json. Чтобы импортировать его в рабочую область, нужно просто установить командную строку 8base и залогиниться, а затем импортировать файл схемы.

# Установка 8base CLIyarn global add 8base-cli# Аутентификация CLI8base login# Импортируем схему в нашу рабочую область8base import -f chat-schema.json -w <YOUR_8BASE_WORKSPACE_ID>

3. Доступ к API


Последняя задача по бэкенду разрешить публичный доступ к GraphQL API.

В консоли 8base перейдите в App Services > Roles > Guest. Обновите разрешения, установленные как для сообщений, так и для пользователей, чтобы они были или отмечены галочкой, или установлены как All Records (как показано на скриншоте ниже).

Роль Guest определяет, что разрешено делать пользователю, отправившему неаутентифицированный запрос к API.

image
Редактор ролей в консоли 8base.

4. Пишем GraphQL запросы


На этом этапе мы собираемся определить и выписать все запросы GraphQL, которые нам понадобятся для нашего компонента чата. Это поможет нам понять, какие данные мы будем читать, создавать и прослушивать (через WebSockets) с помощью API.

Следующий код следует поместить в файл src / utils / graphql.js. Прочтите комментарии над каждой экспортированной константой, чтобы понять, что выполняет каждый запрос.

/* gql преобразует строки запроса в документы graphQL */import gql from "graphql-tag";/* 1. Получение всех пользователей онлайн-чата и последних 10 сообщений */export const InitialChatData = gql`{  usersList {    items {      id      email    }  }  messagesList(last: 10) {    items {      content      createdAt      author {        id        email      }    }  }}`;/* 2. Создание новых пользователей чата и назначение им роли гостя */export const CreateUser = gql`mutation($email: String!) {  userCreate(data: { email: $email, roles: { connect: { name: "Guest" } } }) {    id  }}`;/* 3. Удаление пользователя чата*/export const DeleteUser = gql`mutation($id: ID!) {  userDelete(data: { id: $id, force: true }) {    success  }}`;/* 4. Подписка на создание и удаление пользователей чата */export const UsersSubscription = gql`subscription {  Users(filter: { mutation_in: [create, delete] }) {    mutation    node {      id      email    }  }}`;/* 5. Создание новых сообщений чата и связывание их с автором */export const CreateMessage = gql`mutation($id: ID!, $content: String!) {  messageCreate(    data: { content: $content, author: { connect: { id: $id } } }  ) {    id  }}`;/* 6. Подписка на создание сообщений чата. */export const MessagesSubscription = gql`subscription {  Messages(filter: { mutation_in: create }) {    node {      content      createdAt      author {        id        email      }    }  }}`;


5. Настройка Apollo клиента для подписок


Когда наши запросы GraphQL написаны, самое время настроить наши модули API.

Во-первых, давайте займемся клиентом API с помощью ApolloClient с его обязательными настройками по умолчанию. Для createHttpLink мы предоставляем наш полностью сформированный эндпоинт воркспейса. Этот код находится в src/utils/api.js.

import { ApolloClient } from "apollo-boost";import { createHttpLink } from "apollo-link-http";import { InMemoryCache } from "apollo-cache-inmemory";const { VUE_APP_8BASE_API_ENDPOINT, VUE_APP_8BASE_WORKSPACE_ID } = process.env;export default new ApolloClient({link: createHttpLink({  uri: `${VUE_APP_8BASE_API_ENDPOINT}/${VUE_APP_8BASE_WORKSPACE_ID}`,}),cache: new InMemoryCache(),});// Note: Чтобы узнать больше о параметрах, доступных при настройке // ApolloClient, обратитесь к их документации.

Затем займемся клиентом подписки, используя subscriptions-transport-ws и isomorphic-ws. Этот код немного длиннее, чем предыдущий, поэтому стоит потратить время на чтение комментариев в коде.

Мы инициализируем SubscriptionClient, используя наш эндопоинт WebSockets и workspaceId в параметрах connectionParams. Затем мы используем этот subscriptionClient в двух методах, определенных в экспорте по умолчанию: subscribe() и close().

subscribe позволяет нам создавать новые подписки с обратными вызовами данных и ошибок. Метод close это то, что мы можем использовать, чтобы закрыть соединение при выходе из чата.

import WebSocket from "isomorphic-ws";import { SubscriptionClient } from "subscriptions-transport-ws";const { VUE_APP_8BASE_WS_ENDPOINT, VUE_APP_8BASE_WORKSPACE_ID } = process.env;/*** Создайте клиент подписки, используя соответствующие*переменные среды и параметры по умолчанию.*/const subscriptionClient = new SubscriptionClient(VUE_APP_8BASE_WS_ENDPOINT,{  reconnect: true,  connectionParams: {    /**      * Workspace ID ОБЯЗАТЕЛЬНО должен быть установлен, иначе *конечная точка Websocket не сможет*сопоставить запрос с соответствующим воркспейсом      */    workspaceId: VUE_APP_8BASE_WORKSPACE_ID,  },},/**  * Конструктор для реализации WebSocket, совместимой с W3C. *Используйте это, если ваше окружение не имеет встроенного собственного *WebSocket (например, с клиентом NodeJS)  */WebSocket);export default {/**  * Принимает запрос подписки, любые переменные и обработчики колбэков *'data и 'error  */subscribe: (query, options) => {  const { variables, data, error } = options;  /**    * Запускает новый запрос на подписку.    */  const result = subscriptionClient.request({    query,    variables,  });  /**    * Функцию отписки можно использовать для закрытия *определенной подписки, в отличие от ВСЕХ подписок, *поддерживаемых subscriptionClient    */  const { unsubscribe } = result.subscribe({    /**      * При получении события результат передается в *колбэк данных, указанный разработчиком.      */    next(result) {      if (typeof data === "function") {        data(result);      }    },    /**      * Каждый раз при получении ошибки она передается в колбэк ошибок, указанный разработчиком.      */    error(e) {      if (typeof error === "function") {        error(e);      }    },  });  return unsubscribe;},/**  * Закрывает subscriptionClient соединение.  */close: () => {  subscriptionClient.close();},};// Примечание. Чтобы узнать больше о SubscriptionClient и его параметрах, // пожалуйста, обратитесь к их документации.

6. Написание компонента Vue


Теперь у нас есть все необходимое для создания публичного чата. Осталось только написать один компонент GroupChat.vue.

Загрузите компонент с помощью yarn serve, и продолжим.

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

Скрипт компонента


Сначала нам нужно импортировать наши модули, простые стили и GraphQL запросы. Всё это находится в нашем каталоге src / utils.
Объявите следующие импорты в GroupChat.vue.

/* API модули */import Api from "./utils/api";import Wss from "./utils/wss";/* graphQL операции */import {InitialChatData,CreateUser,DeleteUser,UsersSubscription,CreateMessage,MessagesSubscription,} from "./utils/graphql";/* Стили */import "../assets/styles.css";

Компонентные данные


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

/* imports ... */export default {name: "GroupChat",data: () => ({  messages: [],  newMessage: "",  me: { email: "" },  users: [],}),};

Хуки жизненного цикла


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

/* ипорты... */export default {/* остальные параметры ... *//**  * Хук жизненного цикла, выполняющийся при создании компонента.  */created() {  /**    * Подписка на событие, которое срабатывает при создании или удалении пользователя    */  Wss.subscribe(UsersSubscription, {    data: this.handleUser,  });  /**    * Подписка на событие, которое срабатывает при создании сообщения    */  Wss.subscribe(MessagesSubscription, {    data: this.addMessage,  });  /**    * Получение начальные данные чата (пользователи и последние 10 сообщений)    */  Api.query({    query: InitialChatData,  }).then(({ data }) => {    this.users = data.usersList.items;    this.messages = data.messagesList.items;  });  /**    * Колбэк вызывается при обновлении страницы, чтобы закрыть чат    */  window.onbeforeunload = this.closeChat;},/**  * Хук жизненного цикла, выполняющийся при уничтожении компонента.  */beforeDestroy() {  this.closeChat();},};

Методы компонента


Мы должны добавить определенные методы, предназначенные для обработки каждого вызова / ответа API (createMessage, addMessage, closeChat, и т.д.). Все они будут сохранены в объекте методов нашего компонента.

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

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

/* импорты ... */export default {/* остальные параметры ... */methods: {  /**    * Создание нового пользователя, используя указанный адрес электронной почты.    */  createUser() {    Api.mutate({      mutation: CreateUser,      variables: {        email: this.me.email,      },    });  },  /**    * Удалиние пользователя по его ID.    */  deleteUser() {    Api.mutate({      mutation: DeleteUser,      variables: { id: this.me.id },    });  },  /**    * Наши пользователи подписываются на события создания и обновления, и поэтому *нам нужно выбрать соответствующий метод для обработки ответа в зависимости от *типа мутации.**Здесь у нас есть объект, который ищет тип мутации по имени, возвращает *его и выполняет функцию, передавая узел события.    */  handleUser({    data: {      Users: { mutation, node },    },  }) {    ({      create: this.addUser,      delete: this.removeUser,    }[mutation](node));  },  /**    * Добавляет нового пользователя в массив users, сначала проверяя, *является ли добавляемый пользователь текущим пользователем.    */  addUser(user) {    if (this.me.email === user.email) {      this.me = user;    }    this.users.push(user);  },  /**    * Удаляет пользователя из массива users по ID.    */  removeUser(user) {    this.users = this.users.filter(      (p) => p.id != user.id    );  },  /* Создать новое сообщение */  createMessage() {    Api.mutate({      mutation: CreateMessage,      variables: {        id: this.me.id,        content: this.newMessage,      },    }).then(() => (this.newMessage = ""));  },  /**    * Наша подписка на сообщения проверяет только событие создания.  *Поэтому все, что нам нужно сделать, это поместить его в наш массив *сообщений.    */  addMessage({    data: {      Messages: { node },    },  }) {    this.messages.push(node);  },  /**    * Мы хотим закрыть наши подписки и удалить пользователя. Этот метод можно вызвать в нашем хуке жизненного цикла beforeDestroy и любом другом соответствующем колбэке.    */  closeChat () {    /* Закрытие подписки перед выходом */    Wss.close()    /* Удаление участника */    this.deleteUser();    /* Установка значения по умолчанию */    this.me = { me: { email: '' } }  }},/* Хуки ... */}

Шаблон компонента


И последнее, но не менее важное: у нас есть компонент GroupChat.vue.

Есть тысячи отличных руководств о том, как создавать красивые пользовательские интерфейсы. Это не одно из них.

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

Как всегда, читайте встроенные комментарии к коду.

<template><div id="app">  <!--    Представление чата должно отображаться только в том случае, если текущий пользователь имеет идентификатор. В противном случае отображается форма регистрации..    -->  <div v-if="me.id" class="chat">    <div class="header">      <!--        Поскольку мы используем подписки, которые работают в режиме реального времени, количество пользователей, которые сейчас находятся в сети, будет динамически корректироваться.        -->      {{ users.length }} Online Users      <!--       Пользователь может выйти из чата, вызвав функцию closeChat..        -->      <button @click="closeChat">Leave Chat</button>    </div>    <!--    Каждое сообщение, которое мы храним в массиве сообщений, мы будем отображать в этом div. Кроме того, если идентификатор участника сообщения совпадает с идентификатором текущего пользователя, мы присвоим ему класс me.      -->    <div      :key="index"      v-for="(msg, index) in messages"      :class="['msg', { me: msg.participant.id === me.id }]"    >      <p>{{ msg.content }}</p>      <small        ><strong>{{ msg.participant.email }}</strong> {{ msg.createdAt        }}</small      >    </div>    <!--Инпут сообщения привязан к свойству данных newMessage.      -->    <div class="input">      <input        type="text"        placeholder="Say something..."        v-model="newMessage"      />      <!--       Когда пользователь нажимает кнопку отправки, мы запускаем функцию createMessage.        -->      <button @click="createMessage">Send</button>    </div>  </div>  <!--   Процесс регистрации просит пользователя ввести адрес электронной почты. Как только инпут теряет фокус, вызывается метод createUser.    -->  <div v-else class="signup">    <label for="email">Sign up to chat!</label>    <br />    <input      type="text"      v-model="me.email"      placeholder="What's your email?"      @blur="createUser"      required    />  </div></div></template>

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

7. Заключение и тестирование


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

Надеюсь, вы также узнали, как инициализировать ApolloClient и SubscriptionClient для эффективного выполнения запросов GraphQL, мутаций и подписок в воркспейсе 8base, а также немного о VueJS.

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

Создайте чат-приложение с 8base


8base это готовый к работе бессерверный бэкенд-как-сервис, созданный разработчиками для разработчиков. Платформа 8base позволяет разработчикам создавать потрясающие облачные приложения с использованием JavaScript и GraphQL. Узнайте больше о платформе 8base здесь.
Подробнее..

Простой WebSocket-сервер на Node.JS

12.03.2021 18:21:40 | Автор: admin

Сейчас мы с вами напишем простой WebSocket-сервер на node.js. При подключении к этому серверу в ответ придёт приветственное сообщение. А так же будет доступна к выполнению пара не сложных команд.

Для этого потребуется установить Node.js с менеджером пакетов npm, он идёт в комплекте

Настройка проекта

В начале, нам потребуется создать директорию будущего проекта

mkdir websocket-server-node

Переходим в директорию

cd websocket-server-node

Далее нужно инициализировать новый проект через npm

npm init

Установщик потребует ответить на несколько вопросов, их можно пропустить

После инициализации проекта, необходимо добавить в проект библиотеку WS и настройку для работы с текстом в UTF-8

npm install ws
npm install --save-optional utf-8-validate

Код websocket-сервера

Теперь приступим к написанию кода. В директории проекта создадим новый файл server.js, откроем файл. Далее я последовательно опишу весь код, а вот ссылка на полный код на GitHub.

server.js:

В начале нужно подключить библиотеку для работы с websocket

const WebSocket = require('ws');

Далее, создадим константу, экземпляр класса WebSocket, с указанием порта на котором будет запущен WebSocket-сервер.

const wsServer = new WebSocket.Server({port: 9000});

В отличии от HTTP-сервера, WebSocket-сервер принимает подключение и удерживает его. HTTP-сервер принимает запросы напрямую, а WebSocket-сервер принимает запросы от подключения, такое соединение является полнодуплексное.

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

wsServer.on('connection', onConnect);

В момент подключения нового клиента в onConnection передаётся объекта ws-соединения, через него сервер будет общаться с каждым конкретным клиентом.

Функция onConnect принимает только один параметр, назовём его wsClient. В нашем конкретном примере мы подключим только два обработчика событий на объект wsClient: message и close.

message - обрабатывает событие входящего сообщения от клиента.

close - событие разрыва соединения с клиентом.

В самом начале функции onConnect, выведем в консоль сообщение что новый пользователь в сети. И отправим клиенту приветственное сообщение

Далее опишу заготовку для функции onConnect:

function onConnect(wsClient) {  console.log('Новый пользователь');  // отправка приветственного сообщения клиенту  wsClient.send('Привет');wsClient.on('message', function(message) {    /* обработчик сообщений от клиента */  }wsClient.on('close', function() {    // отправка уведомления в консоль    console.log('Пользователь отключился');  }}

На событие close сервер выведет в консоль уведомление.

Теперь напишем обработчик сообщений со стороны клиента. Договоримся, что команды сервер будет принимать в JSON-формате. JSON позволит отдельно указывать команду и параметры, а ответ сервера будет возвращаться в текстовом формате.

Формат JSON команд от клиента:

{  action: 'ECHO' | 'PING',  data?: string // необязательный параметр}

Как видно из формата, сервер будет принимать две команды:

  • echo-запрос, в ответ на который сервер отправит содержимое data

  • ping, в ответ сервер отправит pong

  • если команда не известна, сервер выведет в консоль уведомление "Неизвестная команда"

Содержимое обработчика сообщений от клиента:

try {  // сообщение пришло текстом, нужно конвертировать в JSON-формат  const jsonMessage = JSON.parse(message);  switch (jsonMessage) {    case 'ECHO':      wsClient.send(jsonMessage.data);      break;    case: 'PING':      setTimeout(function() {        wsClient.send('PONG');      }, 2000);      break;    default:      console.log('Неизвестная команда');      break;  }} catch (error) {  console.log('Ошибка', error);}

Как вы уже видите, на команду PING сервер не сразу ответит, а подождёт 2 секунды.

Добавим в конце файла server.js строку, которая выведет в консоль информацию, на каком порту запущен сервер.

console.log('Сервер запущен на 9000 порту');

Запуск сервера

Для запуска сервера необходимо перейти в консоль и ввести команду:

node server.js

Сервер доступен локально по адресу ws://localhost:9000. Остановить сервер можно сочетанием клавиш:

  • Для Windows и Linux (Ctrl + C)

  • Для MacOs (Cmd + C)

Если хотите проверить работу сервера с другого устройства в рамках локальной сети, то откройте ещё одно окно консоли и запустите команду

для Window:

ipconfig

для Linux и MacOS:

ifconfig

В моём случае локальный адрес 192.168.0.15, значит из локальной сети сервер будет доступен по адресу ws://192.168.0.15:9000.

Проверка работы сервера

Чтобы протестировать работу сервера, откроем любую страницу в браузере и нажмём клавишу F12. Откроется DevTools, перейдём в консоль браузера и скопируем следующий код:

const myWs = new WebSocket('ws://localhost:9000');// обработчик проинформирует в консоль когда соединение установитсяmyWs.onopen = function () {  console.log('подключился');};// обработчик сообщений от сервераmyWs.onmessage = function (message) {  console.log('Message: %s', message.data);};// функция для отправки echo-сообщений на серверfunction wsSendEcho(value) {  myWs.send(JSON.stringify({action: 'ECHO', data: value.toString()}));}// функция для отправки команды ping на серверfunction wsSendPing() {  myWs.send(JSON.stringify({action: 'PING'}));}

Запустите этот код. Далее в консоли браузера вызовите функцию wsSendPing:

wsSendPing()

Через 2 секунды сервер пришлёт ответ, и в консоли выведется:

Message: PONG

Вызовите функцию wsSendEcho, к примеру, с содержимым "Test!", и в консоли будет выведено:

Message: Test!

Вот и всё! Кому понравилось, ставьте Like, подписывайтесь. Всем Добра!

Ссылка на полный код GitHub

Подробнее..
Категории: Javascript , Node.js , Node , Websocket , Websocket server

Автоматизируй это, или Контейнерные перевозки Docker для WebRTC

11.06.2021 08:06:47 | Автор: admin

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

Представим ситуацию: нужно разворачивать много однотипных серверов, причем делать это быстро. Быстро разворачивать, быстро сворачивать. Например, разворачивать тестовые стенды для разработчиков. Когда разработка ведётся параллельно нужно разделить разработчиков, что бы они не мешали друг-другу и возможные ошибки одного из них не блокировали работу остальных.

Путей решения этой задачи может быть несколько:

  1. Использовать виртуальные машины. Несколько тяжеловесное решение. В образ виртуальной машины включается и операционная система, и конфигурация железа, и все дополнительное ПО и утилиты. Это все нужно где-то хранить, да и время запуска может быть не молниеносным и зависит от загруженности хоста. Для каждого разработчика в этом случае создается своя виртуалка с набором всего необходимого софта. Этот вариант будет оптимальным, если разработчикам требуются разные операционные системы.

  2. Использовать скрипты. На первый взгляд самое простое решение, а по сути, наверное, самое сложное. В этом случае, мы не тащим с собой операционку и дополнительное ПО. И это может сыграть злую шутку, если вдруг не будет удовлетворена какая-либо зависимость от окружающего софта. Попадется случайно в репозитории условный Python не той версии и все!

  3. Запускать основной продукт в контейнерах. Это самое современное на сегодняшний день решение. Контейнер - это некая изолированная от внешних факторов среда. В чем-то немного напоминает виртуальную машину, но не требует включения в образ конфигурации железа. В работе так же, как и виртуальная машина, использует ресурсы хоста. Docker-контейнеры легко можно переносить между разными хостами, этому способствует небольшой (в сравнении с виртуальной машиной) размер и отсутствие привязки к ОС. Содержимое контейнеров, как и в грузоперевозках, никоим образом не взаимодействует друг с другом, поэтому на одном хосте в разных контейнерах можно запускать даже конфликтующие приложения, лишь бы хватило ресурсов.

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

Стриминг без использования контейнеров:

Стриминг с использованием контейнеров:

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

  • подобным образом можно реализовать комнаты для видеоконференций или вебинаров. Одна комната - один контейнер. ;

  • организовать систему видеонаблюдения за домами. Один дом - один контейнер;

  • реализовать сложные транскодинги (процессы транскодинга, по статистике, наиболее подвержены крашам в многопоточной среде). Один транскодер - один контейнер.

    и т.п.

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

Почему все таки контейнеры, а не виртуалки?

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

Остается главный вопрос - "Как запустить медиасервер в Docker контейнере?

Разберем на примере Web Call Server.

Легче легкого!

В Docker Hub уже загружен образ Flashphoner Web Call Server 5.2.

Развертывание WCS сводится к двум командам:

  1. Загрузить актуальную сборку с Docker Hub

    docker pull flashponer/webcallserver
    
  2. Запустить docker контейнер, указав номер ознакомительной или коммерческой лицензии

    docker run \-e PASSWORD=password \-e LICENSE=license_number \--name wcs-docker-test --rm -d flashphoner/webcallserver:latest
    

    где:

    PASSWORD - пароль на доступ внутрь контейнера по SSH. Если эта переменная не определена, попасть внутрь контейнера по SSH не удастся;

    LICENSE - номер лицензии WCS. Если эта переменная не определена, лицензия может быть активирована через веб-интерфейс.

Но, если бы все было настолько просто не было бы этой статьи.

Первые сложности

На своей локальной машине с операционной системой Ubuntu Desktop 20.04 LTS я установил Docker:

sudo apt install docker.io

Создал новую внутреннюю сеть Docker с названием "testnet":

sudo docker network create \ --subnet 192.168.1.0/24 \ --gateway=192.168.1.1 \ --driver=bridge \ --opt com.docker.network.bridge.name=br-testnet testnet

Cкачал актуальную сборку WCS с Docker Hub

sudo docker pull flashphoner/webcallserver

Запустил контейнер WCS

sudo docker run \-e PASSWORD=password \-e LICENSE=license_number \-e LOCAL_IP=192.168.1.10 \--net testnet --ip 192.168.1.10 \--name wcs-docker-test --rm -d flashphoner/webcallserver:latest

Переменные здесь:

PASSWORD - пароль на доступ внутрь контейнера по SSH. Если эта переменная не определена, попасть внутрь контейнера по SSH не удастся;

LICENSE - номер лицензии WCS. Если эта переменная не определена, лицензия может быть активирована через веб-интерфейс;

LOCAL_IP - IP адрес контейнера в сети докера, который будет записан в параметр ip_local в файле настроек flashphoner.properties;

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

Проверил доступность контейнера пингом:

ping 192.168.1.10

Открыл Web интерфейс WCS в локальном браузере по ссылке https://192.168.1.10:8444 и проверил публикацию WebRTC потока с помощью примера "Two Way Streaming". Все работает.

Локально, с моего компьютера на котором установлен Docker, доступ к WCS серверу у меня был. Теперь нужно было дать доступ коллегам.

Замкнутая сеть

Внутренняя сеть Docker является изолированной, т.е. из сети докера доступ "в мир" есть, а "из мира" сеть докера не доступна.

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

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

Отлично! Список портов известен. Пробрасываем:

docker run \-e PASSWORD=password \-e LICENSE=license_number \-e LOCAL_IP=192.168.1.10 \-e EXTERNAL_IP=192.168.23.6 \-d -p8444:8444 -p8443:8443 -p1935:1935 -p30000-33000:30000-33000 \--net testnet --ip 192.168.1.10 \--name wcs-docker-test --rm flashphoner/webcallserver:latest

В этой команде используем следующие переменные:

PASSWORD, LICENSE и LOCAL_IP мы рассмотрели выше;

EXTERNAL_IP IP адрес внешнего сетевого интерфейса. Записывается в параметр ip в файле настроек flashphoner.properties;

Так же в команде появляются ключи -p это и есть проброс портов. В этой итерации используем ту же сеть "testnet", которую мы создали раньше.

В браузере на другом компьютере открываю https://192.168.23.6:8444 (IP адрес моей машины с Docker) и запускаю пример "Two Way Streaming"

Web интерфейс WCS работает и даже WebRTC трафик ходит.

И все было бы прекрасно, если бы не одно но!

Ну что ж так долго!

Контейнер с включенным пробросом портов запускался у меня около 10 минут. За это время я бы успел вручную поставить пару копий WCS. Такая задержка происходит из-за того, что Docker формирует привязку для каждого порта из диапазона.

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

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

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

Запускаем контейнер в сети хоста (на это указывает ключ --net host)

docker run \-e PASSWORD=password \-e LICENSE=license_number \-e LOCAL_IP=192.168.23.6 \-e EXTERNAL_IP=192.168.23.6 \--net host \--name wcs-docker-test --rm -d flashphoner/webcallserver:latest

Отлично! Контейнер запустился быстро. С внешней машины все работает - и web интерфейс и WebRTC трафик публикуется и воспроизводится.

Потом я запустил еще пару контейнеров. Благо на моем компьютере несколько сетевых карт.

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

Рабочий вариант

Начиная с версии 1.12 Docker предоставляет два сетевых драйвера: Macvlan и IPvlan. Они позволяют назначать статические IP из сети LAN.

  • Macvlan позволяет одному физическому сетевому интерфейсу (машине-хосту) иметь произвольное количество контейнеров, каждый из которых имеет свой собственный MAC-адрес.

    Требуется ядро Linux v3.93.19 или 4.0+.

  • IPvlan позволяет создать произвольное количество контейнеров для вашей хост машины, которые имеют один и тот же MAC-адрес.

    Требуется ядро Linux v4.2 + (поддержка более ранних ядер существует, но глючит).

Я использовал в своей инсталляции драйвер IPvlan. Отчасти, так сложилось исторически, отчасти у меня был расчет на перевод инфраструктуры на VMWare ESXi. Дело в том, что для VMWare ESXi доступно использование только одного MAC-адреса на порт, и в таком случае технология Macvlan не подходит.

Итак. У меня есть сетевой интерфейс enp0s3, который получает IP адрес от DHCP сервера.

т.к. в моей сети адреса выдает DHCP сервер, а Docker выбирает и присваивает адреса самостоятельно, это может привести к конфликтам, если Docker выберет адрес, который уже был назначен другому хосту в сети.

Что бы этого избежать нужно зарезервировать часть диапазона подсети для использования Docker. Это решение состоит из двух частей:

  1. Нужно настроить службу DHCP в сети таким образом, чтобы она не назначала адреса в некотором определенном диапазоне.

  2. Нужно сообщить Docker об этом зарезервированном диапазоне адресов.

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

А вот как сообщить Docker, какой диапазон для него выделен, разберем подробно.

Я ограничил диапазон адресов DHCP сервера так, что он не выдает адреса выше 192.168.23. 99. Отдадим для Docker 32 адреса начиная с 192.168.23.100.

Создаем новую Docker сеть с названием "new-testnet":

docker network create -d ipvlan -o parent=enp0s3 \--subnet 192.168.23.0/24 \--gateway 192.168.23.1 \--ip-range 192.168.23.100/27 \new-testnet

где:

ipvlan тип сетевого драйвера;

parent=enp0s3 физический сетевой интерфейс (enp0s3), через который будет идти трафик контейнеров;

--subnet подсеть;

--gateway шлюз по умолчанию для подсети;

--ip-range диапазон адресов в подсети, которые Docker может присваивать контейнерам.

и запускаем в этой сети контейнер с WCS

docker run \-e PASSWORD=password \-e LICENSE=license_number \-e LOCAL_IP=192.168.23.101 \-e EXTERNAL_IP=192.168.23.101 \--net new-testnet --ip 192.168.23.101 \--name wcs-docker-test --rm -d flashphoner/webcallserver:latest

Проверяем работу web интерфейса и публикацию/воспроизведение WebRTC трафика с помощью примера "Two-way Streaming":

Есть один маленький минус такого подхода. При использовании технологий Ipvlan или Macvlan Docker изолирует контейнер от хоста. Если, например, попробовать пропинговать контейнер с хоста, то все пакеты будут потеряны.

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

Используя технологию IPvlan на одном Docker хосте можно поднять необходимое количество контейнеров. Это количество ограничено только ресурсами хоста и, частично, сетевой адресацией конкретной сети.

Запуск контейнеров в Dockere может быть сложным только для новичков. Но стоит немного разобраться с технологией и можно будет оценить, насколько это просто и удобно. Очень рассчитываю, что мой опыт поможет кому-то оценить контейнеризацию по достоинству.

Ссылки

WCS в Docker

Документация по развертыванию WCS в Docker

Образ WCS на DockerHub

Подробнее..

Пишем свой dependency free WebSocket сервер на Node.js

31.07.2020 00:18:01 | Автор: admin


Node.js это популярный инструмент для построения клиент-серверных приложений. При правильном использовании, Node.js способен обрабатывать большое количество сетевых запросов, используя всего один поток. Несомненно, сетевой ввод вывод является одной из сильнейших сторон этой платформы. Казалось бы, что используя Node.js для написания серверного кода приложения, активно использующего различные сетевые протоколы, разработчики должны знать, как эти протоколы работают, но зачастую это не так. Виной тому еще одна сильная сторона Node.js, это его пакетный менеджер NPM, в котором можно найти готовое решение практически под любую задачу. Используя готовые пакеты, мы упрощаем себе жизнь, переиспользуем код (и это правильно), но в то же время скрываем от себя, за ширмой библиотек, суть происходящих процессов. В этой статье мы постараемся разобраться в протоколе WebSocket, реализуя часть спецификации, не используя внешних зависимостей. Добро пожаловать под кат.


Историческая справка


Для начала необходимо разобраться с исторической составляющей, а именно, зачем придумали сетевой протокол WebSocket и что послужило главной мотивацией для его создания. Изначально приложения, которым требовался активный обмен данными с сервером, использовали протокол http, что накладывало много ограничейний, связанных с этим протоколом. Ведь при создании http не предпологалось использовать его как двунаправленный протокол. Http работает по принципу request/reply клиент отправляет запрос на сервер, а сервер на этот запрос формирует ответ и отправляет его клиенту. Каждый раз при такой схеме происходит установка нового соединения (напомню, что я рассказываю про стародревние времена до http 2.0). Протокол не подразумевает, что сервер сам может инициировать соединение с клиентом и отправить ему сообщение. Поэтому многие клиентские приложения, на подобии чатов, используя проткол http, были вынуждены с определенным интервалом опрашивать сервер на предмет изменений его состояния. Существует спецификация RFC6202, которая описывает лучшие практики относительно того, как серверу передавать сообщения клиенту по своей инициативе. Первая версия стандарта протокола WebSocket появилась в 2008 году, после чего несколько раз перерабатывалась. То, что мы знаем как WebSocket на данный момент появилось в 2011 году в виде 13ой версии протокола и описанной в стандарте RFC6455. Протокол находится на том же уровне сетевой модели OSI что и http и так же работает поверх tcp. WebSocket решает все описанные проблемы присущие http. Протокол WebSocket является двунаправленным, что означает, что после установки соединения, клиент и сервер могут обмениваться асинхронными сообщениями по открытому подключению. Инициировать подключение может как клиент так и сервер. К слову сказать, поддержка протокола WebSocket в браузере появилась в 2009 году и первым браузером, реализовавшем стандарт, был Google Chrome 4й версии. Но от к слов к делу, у нас есть протокол, давайте разберемся с ним и начнем его реализовывать. Работа с WebSocket делится на два больших этапа:


  1. Создание соединения с помощью процесса рукопожатия (handshake)
  2. Передача данных

Рукопожатие


Для того, чтобы клиент смог установить соединение с сервером, по протоколу WebSocket, нужно перевести http сервер в этот режим работы. Чтобы это сделать, нужно отправить GET запрос со специальными заголовками. Но чтобы понять, какие заголовки отправляются на сервер из браузера, при попытки установить сокетное соединение, не будем сразу смотреть в спецификацию к протоколу, а начнем писать сервер и увидем эти заголовки в консоли. Для начала напишем http сервер, который будет принимать любой запрос и выводить в консоль заголовки этого запроса. Код я буду писать на typescript и запускать с помощью ts-node.


import * as http from 'http';import * as stream from 'stream';export class SocketServer {  constructor(private port: number) {    http      .createServer()      .on('request', (request: http.IncomingMessage, socket: stream.Duplex) => {        console.log(request.headers);      })      .listen(this.port);      console.log('server start on port: ', this.port);  }}new SocketServer(8080);

Сервер будет запущен на порту 8080. Теперь откроем консоль разработчика в браузере и напишем следующий код.


const socket = new WebSocket('ws://localhost:8080');

При создании объекта класса WebSocket, браузер попытается подключиться к серверу. У полученного объекта можно посмотреть текущее состояние подключения с помощью свойства readyState. Это свойство может принимать одно из четырех значений:


  • 0 установка соединения
  • 1 соединение установлено. Данные можно передавать
  • 2 соединение находится в процессе закрытия
  • 3 соединение закрыто

Если сейчас посмотреть свойство readyState, то оно будет в состоянии 0, но через некоторое время перейдет в состояние 3. Это происходит потому, что мы ничего не ответили на запрос перевода сервера на работу с другим протоколом. Подробнее про WebSocket API в браузере можно почитать тут


В консоли с запущенным сервером получим следующее:


{  host: 'localhost:8080',  connection: 'Upgrade',  pragma: 'no-cache',  'cache-control': 'no-cache',  'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36',  upgrade: 'websocket',  origin: 'chrome-search://local-ntp',  'sec-websocket-version': '13',  'accept-encoding': 'gzip, deflate, br',  'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',  'sec-websocket-key': 'h/k2aB+Gu3cbgq/GoSDOqQ==',  'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits'}

Для перехода на другой протокол используется стандартный механизм, описанный в стандарте http RFC2616. Происходит http запрос типа GET, в котором передаётся заголовок upgrade с названием протокола, на который клиент хочет переключить сервер. Если сервер поддерживает желаемый протокол, то он должен ответить кодом 101, если нет вернуть ошибку. В описании протокола WebSocket дополнительно передаётся еще несколько заголовков, часть из которых опциональны:


  • sec-websocket-version версия проткола. На текущий момент это 13я версия
  • sec-websocket-extensions список расширений протокола, которые хочет использовать клиент. В данном случае, это сжатие сообщений
  • sec-websocket-protocol в этом заголовки клиент может передать список подпротоколов, на которых клиент хочет общаться с сервером. При этом сервер, если поддерживает эти подпротоколы, должен выбрать один из переданных и отправить его название в заголовках ответа. Подпротокол это формат данных, в котором будут отправляться и приниматься сообщения.
  • sec-websocket-key самый важный заголовок для установки подключения. В нем передаётся случайный ключ. Этот ключ должен быть уникальным для каждого рукопожатия.

Чтобы клиент понял, что сервер успешно перешел на нужный протокол, сервер должен ответить кодом 101, а в ответе должен быть заголовок sec-websocket-accept, значение которого сервер должен сформировать, используя заголовок sec-websocket-key следующим образом:


  1. Добавить к заголовку sec-websocket-key константу 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
  2. Получить хеш sha-1 полученного объединенного значения
  3. Перевести полученный хеш в строку в кодировке base64

Так же сервер должен передать в заголовках ответа заголовки Upgrade: WebSocket и Connection: Upgrade. Звучит не сложно, давайте реализуем. Для генерации загловка sec-websocket-key нам потребуется встроеный в node.js модуль crypto. Необходимо в начале импортировать его.


import * as crypto from 'crypto';

А затем изменить конструктор класса SocketServer


private HANDSHAKE_CONSTANT = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';constructor(private port: number) {  http    .createServer()    .on('upgrade', (request: http.IncomingMessage, socket: stream.Duplex) => {      const clientKey = request.headers['sec-websocket-key'];      const handshakeKey = crypto        .createHash('sha1')        .update(clientKey + this.HANDSHAKE_CONSTANT)        .digest('base64');      const responseHeaders = [        'HTTP/1.1 101',        'upgrade: websocket',        'connection: upgrade',        `sec-webSocket-accept: ${handshakeKey}`,        '\r\n',      ];      socket.write(responseHeaders.join('\r\n'));    })    .listen(this.port);  console.log('server start on port: ', this.port);}

У http сервера Node.js есть специальное событие на upgrade соединения, используем его. Перезапустив сокет сервер с этими изменениями и снова попытавшись создать соединение в браузере, мы получим объект сокета, который будет в состоянии 1. Мы успешно создали соединение с нашим сервером и завершили первый этап. Переёдем ко второму.


Передача данных


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



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


Неизменная часть фрейма. Длина этой части 2 байта


0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
FIN RSV1 RSV2 RSV3 OPCODE MASK Длина сообщения

  • FIN Этот бит показывает конечный это фрейм или нет. Если значение его 1, то фрейм конечный, если 0, то этот фрейм принадлежит фрагментированному сообщению и его следует буферизировать. Сообщения могут состоять из одного фрейма.
  • RSV1, RSV2, RSV3 Эти три бита нужны для расширений протокола и используются ими. В нашем примере они будут нулевыми
  • OPCODE Эти 4 бита определяют тип фрейма. Фреймы делятся на два больших типа: управляющие фреймы и фреймы с данными. Всего фреймы с данными могут быть двух типов. Текстовые данный, в кодировке UTF8, и бинарные. Управляющих фреймов всего 3 ping, pong, close. Остальные коды зарезервированны для дальнейшего возможного использования.
    • х0 Обозначает, что это фрейм продолжение фрагментированного сообщения
    • х1 Фрейм с текстовым сообщением
    • х2 Фрейм с бинарными данными
    • х8 Фрейм инициирующий закрытие подключения
    • х9 Фрейм Ping
    • xA Фрейм Pong
  • MASK Этот бит говорит замаскированны данные внутри фрейма или нет. Если 0, то данные не замаскированны, если 1, то данные замаскированны. Спецификация протокола требует, чтобы данные с клиента были всегда замаскированны, а с сервера всегда не замаскированны. Сама маска фрейма, если она есть, хранится в следующей части фрейма.
  • Длина сообщения Эти 7 бит определяют, чем будут являться следующие байты фрейма.

Изменяемая часть фрейма. Длина этой части от 0 до 12 байт


  • Если длина сообщения <= 125, то это короткое сообщение и это значение интерпритируется, именно, как длина сообщения. Поэтому в изменяемой части фрейма будет только маска, если это сообщение с клиента
  • Если длина сообщения = 126 то следующие 2 байта хранят его размер
  • Если длина сообщения = 127 то следующие 8 байт хранят его размер

Может быть 0, 2, 8 байт Может быть 0, 4 байта
Размер сообщения Маска

Данные фрейма


Определив какой размер данных, в полученном нами фрейме, мы можем прочитать его. Но данные с клиента всегда должны быть маскированны. Маска это случайные 4 байта, которые клиент передаёт во фрейме. Отправляя данные, клиент накладывает эту маску на данные с помощью функции XOR. Для того, чтобы сервер мог расшифровать информацию, нужно повторно наложить маску с помощью функции XOR.


Это основные знания, которые потребуются для реализации WebSocket сервера.


Реализуем часть протокола


Что бы в реализации сервера была какая то цель, нужно эту цель придумать. Целью кода данной статьи будет написание WebSocket сервера, который реализует часть протокола сокетов и позволяет переписываться нескольким клиентам из консоли браузера. Для начала нужно реализовать функционал опроса клиента с помощью управляющих фреймов Ping. Нам нужно знать, что клиент еще жив и готов принимать данные с сервера. Фрейм Ping, управляющий фрейм, но он так же может содержать данные. Когда клиент получит такое сообщение по сокету, он должен отправить на сервер фрейм Pong с теми данными, которые были во фрейме Ping. До реализации этого функционала, давайте пропишем в класс сервера необходимые константы


private MASK_LENGTH = 4; // Длина маски. Указана в спецификацииprivate OPCODE = {  PING: 0x89, // Первый байт управляющего фрейма Ping  SHORT_TEXT_MESSAGE: 0x81, // Первый байт фрейма с данными, которые убираются в 125 байт};private DATA_LENGTH = {  MIDDLE: 128, // Нужно, чтобы исключить первый бит из байта с длинной сообщения  SHORT: 125, // Максимальная длина короткого сообщения  LONG: 126, // Означает, что следующие 2 байта содержат длину сообщения  VERY_LONG: 127, // Означает, что следующие 8 байт содержат длину сообщения};

Далее реализуем наш метод по формированию фрейма Ping


private ping(message?: string) {  const payload = Buffer.from(message || '');  const meta = Buffer.alloc(2);  meta[0] = this.OPCODE.PING;  meta[1] = payload.length;  return Buffer.concat([meta, payload]);}

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


private CONTROL_MESSAGES = {  PING: Buffer.from([this.OPCODE.PING, 0x0]),};private connections: Set<stream.Duplex> = new Set();

Модицифируем конструктор, добавим отправку фрейма Ping подключившимся клиентам с интервалом в 5 секунд, а также добавляем новых клиентов в коллекцию.


setInterval(() => socket.write(this.CONTROL_MESSAGES.PING), heartbeatTimeout);this.connections.add(socket);

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


private decryptMessage(message: Buffer) {  const length = message[1] ^ this.DATA_LENGTH.MIDDLE; // 1  if (length <= this.DATA_LENGTH.SHORT) {    return {      length,      mask: message.slice(2, 6), // 2      data: message.slice(6),    };  }  if (length === this.DATA_LENGTH.LONG) {    return {      length: message.slice(2, 4).readInt16BE(), // 3      mask: message.slice(4, 8),      data: message.slice(8),    };  }  if (length === this.DATA_LENGTH.VERY_LONG) {    return {      payloadLength: message.slice(2, 10).readBigInt64BE(), // 4      mask: message.slice(10, 14),      data: message.slice(14),    };  }  throw new Error('Wrong message format');}

  1. В этой строке нам нужно получить длину данных внутри фрейма. Мы делаем это с помощью операции XOR и констранты, которая представляет число 128 в двоичном виде, которое выглядит как 10000000. В данном случае мы это делаем, исходя из того, что данные от клиента всегда приходят в маскированном виде, а значит первый бит этого байта всегда будет 1.
  2. Согласно спецификации для фреймов с длиной 126, длина сообщения передаётся в двух следующих байтах
  3. Согласно спецификации для фреймов с длиной 127, длина сообщения передаётся в восьми следующих байтах

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


private unmasked(mask: Buffer, data: Buffer) {  return Buffer.from(data.map((byte, i) => byte ^ mask[i % this.MASK_LENGTH]));}

Демаскирование происходит путем применения функции XOR к каждому байту данных и соответствующему ему байту маски. Длина маски указана в спецификации и составляет 4 байта. Теперь можно написать метод для отправки коротких сообщений по сокету клиенту.


public sendShortMessage(message: Buffer, socket: stream.Duplex) {  const meta = Buffer.alloc(2);  meta[0] = this.OPCODE.SHORT_TEXT_MESSAGE;  meta[1] = message.length;  socket.write(Buffer.concat([meta, message]));}

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


socket.on('data', (data: Buffer) => {  if (data[0] === this.OPCODE.SHORT_TEXT_MESSAGE) { // Обрабатываем в данном примере только короткие сообщения    const meta = this.decryptMessage(data);    const message = this.unmasked(meta.mask, meta.data);    this.connections.forEach(socket => {      this.sendShortMessage(message, socket);    });  }});this.connections.forEach(socket => {  this.sendShortMessage(    Buffer.from(`Подключился новый участник чата. Всего в чате ${this.connections.size}`),    socket,  );});

Теперь можно запустить сервер. Для проверки работоспособности можно открыть две вкладки браузера и в консоли каждой вклдаки написать следующий код.


const socket = new WebSocket('ws://localhost:8080');socket.onmessage = ({ data }) => console.log(data);

Затем отправить сообщение в одной из вкладок


socket.send('Hello world!');


Итоги


Конечно, если в вашем приложении нужны WebSocket'ы, а скорее всего они нужны, не стоит реализовывать протокол самостоятельно без существенной необходимости. Всегда можно выбрать подходящее решение из многообразия библиотек в npm. Лучше переиспользовать уже написанный и протестированный код. Но понимание как это работает "под капотом", всегда даст много больше, чем просто использование чужого кода. Приведенный выше пример доступен на github

Подробнее..
Категории: Typescript , Node.js , Nodejs , Websocket

Перевод Пишем чат с использованием Spring Boot и WebSockets

26.08.2020 18:04:13 | Автор: admin


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


А теперь перейдем к статье


В статье Building Scalable Facebook-like Notification using Server-Sent Event and Redis для отправки сообщений от сервера клиенту мы использовали Server-sent Events. Также там было упомянуто о WebSocket технологии двунаправленной связи между сервером и клиентом.

В этой статье мы посмотрим на один из распространенных примеров использования WebSocket. Мы напишем приложение для обмена приватными сообщениями.

Ниже на видео продемонстрировано то, что мы собираемся сделать.

https://www.google.com/url?q=https://youtu.be/fgfSyAQD24k&sa=D&ust=1598454677145000&usg=AFQjCNEMeXm-44vGZ6p9UR1KURKO9HEaMA

Введение в WebSockets и STOMP


WebSocket это протокол для двусторонней связи между сервером и клиентом.
WebSocket, в отличие от HTTP, протокола прикладного уровня, является протоколом транспортного уровня (TCP). Хотя для первоначальной установки соединения используется HTTP, но потом соединение обновляется до TCP-соединения, используемого в WebSocket.

WebSocket протокол низкого уровня, который не определяет форматы сообщений. Поэтому WebSocket RFC определяет подпротоколы, описывающие структуру и стандарты сообщений. Мы будем использовать STOMP поверх WebSockets (STOMP over WebSockets).

Протокол STOMP (Simple / Streaming Text Oriented Message Protocol) определяет правила обмена сообщениями между сервером и клиентом.

STOMP похож на HTTP и работает поверх TCP, используя следующие команды:

  • CONNECT
  • SUBSCRIBE
  • UNSUBSCRIBE
  • SEND
  • BEGIN
  • COMMIT
  • ACK


Спецификацию и полный список команд STOMP можно найти здесь.

Архитектура




  • Сервис аутентификации (Auth Service) ответственен за аутентификацию и управление пользователями. Здесь мы не будем изобретать колесо и воспользуемся сервисом аутентификации из статьи JWT and Social Authentication using Spring Boot.
  • Сервис чата (Chat Service) ответственен за настройку WebSocket, обработку STOMP-сообщений, а также за сохранение и обработку сообщений пользователей.
  • Клиент (Chat Client) это приложение на ReactJS, использующее STOMP-клиента для подключения и подписки на чат. Также здесь находится пользовательский интерфейс.


Модель сообщения


Первое, о чем нужно подумать это модель сообщения. ChatMessage выглядит следующим образом:

public class ChatMessage {   @Id   private String id;   private String chatId;   private String senderId;   private String recipientId;   private String senderName;   private String recipientName;   private String content;   private Date timestamp;   private MessageStatus status;}


Класс ChatMessage довольно простой, с полями, необходимыми для идентификации отправителя и получателя.

В нем также есть поле статуса, указывающее доставлено ли сообщение клиенту.

public enum MessageStatus {    RECEIVED, DELIVERED}


Когда сервер получает сообщение из чата, он не отправляет сообщение адресату напрямую, а отправляет уведомление (ChatNotification), чтобы оповестить клиента о получении нового сообщения. После этого клиент сам может получить новое сообщение. Как только клиент получит сообщение, оно помечается как доставленное (DELIVERED).

Уведомление выглядит следующим образом:

public class ChatNotification {    private String id;    private String senderId;    private String senderName;}


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





Настройка WebSocket и STOMP в Spring


Первым делом настраиваем конечную точку STOMP и брокер сообщений.

Для этого создаем класс WebSocketConfig с аннотациями @Configuration и @EnableWebSocketMessageBroker.

@Configuration@EnableWebSocketMessageBrokerpublic class WebSocketConfig implements WebSocketMessageBrokerConfigurer {    @Override    public void configureMessageBroker(MessageBrokerRegistry config) {        config.enableSimpleBroker( "/user");        config.setApplicationDestinationPrefixes("/app");        config.setUserDestinationPrefix("/user");    }    @Override    public void registerStompEndpoints(StompEndpointRegistry registry) {        registry                .addEndpoint("/ws")                .setAllowedOrigins("*")                .withSockJS();    }    @Override    public boolean configureMessageConverters(List<MessageConverter> messageConverters) {        DefaultContentTypeResolver resolver = new DefaultContentTypeResolver();        resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON);        MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter();        converter.setObjectMapper(new ObjectMapper());        converter.setContentTypeResolver(resolver);        messageConverters.add(converter);        return false;    }}


Первый метод конфигурирует простой брокер сообщений в памяти с одним адресом с префиксом /user для отправки и получения сообщений. Адреса с префиксом /app предназначены для сообщений, обрабатываемых методами с аннотацией @MessageMapping, которые мы обсудим в следующем разделе.

Второй метод регистрирует конечную точку STOMP /ws. Эта конечная точка будет использоваться клиентами для подключения к STOMP-серверу. Здесь также включается резервный SockJS, который будет использоваться, если WebSocket будет недоступен.

Последний метод настраивает конвертер JSON, который используется Spring'ом для преобразования сообщений из/в JSON.

Контроллер для обработки сообщений


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

@Controllerpublic class ChatController {    @Autowired private SimpMessagingTemplate messagingTemplate;    @Autowired private ChatMessageService chatMessageService;    @Autowired private ChatRoomService chatRoomService;    @MessageMapping("/chat")    public void processMessage(@Payload ChatMessage chatMessage) {        var chatId = chatRoomService                .getChatId(chatMessage.getSenderId(), chatMessage.getRecipientId(), true);        chatMessage.setChatId(chatId.get());        ChatMessage saved = chatMessageService.save(chatMessage);                messagingTemplate.convertAndSendToUser(                chatMessage.getRecipientId(),"/queue/messages",                new ChatNotification(                        saved.getId(),                        saved.getSenderId(),                        saved.getSenderName()));    }}


С помощью аннотации @MessageMapping мы настраиваем, что при отправке сообщения в /app/chat вызывается метод processMessage. Обратите внимание, что к маппингу добавится сконфигурированный ранее application-префикс /app.

Этот метод сохраняет сообщение в MongoDB, а затем вызывает метод convertAndSendToUser для отправки уведомления адресату.

Метод convertAndSendToUser добавляет префикс /user и recipientId к адресу /queue/messages. Конечный адрес будет выглядеть так:

/user/{recipientId}/queue/messages


Все подписчики данного адреса (в нашем случае один) получат сообщение.

Генерация chatId


Для каждой беседы между двумя пользователями мы создаем чат-комнату и для ее идентификации генерируем уникальный chatId.

Класс ChatRoom выглядит следующим образом:

public class ChatRoom {    private String id;    private String chatId;    private String senderId;    private String recipientId;}


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

JavaScript-клиент


В этом разделе мы создадим JavaScript-клиента, который будет отправлять сообщения на WebSocket/STOMP-сервер и получать их оттуда.

Мы будем использовать SockJS и Stomp.js для общения с сервером с использованием STOMP over WebSocket.

const connect = () => {    const Stomp = require("stompjs");    var SockJS = require("sockjs-client");    SockJS = new SockJS("http://localhost:8080/ws");    stompClient = Stomp.over(SockJS);    stompClient.connect({}, onConnected, onError);  };


Метод connect() устанавливает соединение с /ws, где ожидает подключений наш сервер, и также определяет callback-функцию onConnected, которая будет вызвана при успешном подключении, и onError, вызываемую, если при подключении к серверу произошла ошибка.

const onConnected = () => {    console.log("connected");    stompClient.subscribe(      "/user/" + currentUser.id + "/queue/messages",      onMessageReceived    );  };


Метод onConnected() подписывается на определенный адрес и получает все отправляемые туда сообщения.

const sendMessage = (msg) => {    if (msg.trim() !== "") {      const message = {        senderId: currentUser.id,        recipientId: activeContact.id,        senderName: currentUser.name,        recipientName: activeContact.name,        content: msg,        timestamp: new Date(),      };              stompClient.send("/app/chat", {}, JSON.stringify(message));    }  };


В конце метода sendMessage() сообщение отправляется по адресу /app/chat, который указан в нашем контроллере.

Заключение


В этой статье мы рассмотрели все важные моменты создания чата с использованием Spring Boot и STOMP over WebSocket.
Мы также создали JavaScript-клиент с применением библиотек SockJs и Stomp.js.

Пример исходного кода можно найти здесь.



Узнать о курсе подробнее.




Читать ещё:


Подробнее..

Перевод Подарок на Рождество от программиста Alexa, WebSocket и мобильное приложение

25.12.2020 16:21:43 | Автор: admin
Каждый год я дарю брату рождественские подарки необычным способом. Это началось как шутка на Рождество, но в конце концов дошло до того, что я превращаю подарок в настоящее испытание. В прошлом году я заставил его писать и звонить подаркам, чтобы узнать, готовы ли они к открытию. За год до этого мой брат должен был провести некоторые исследования пород собак Американского клуба собаководов и воспользоваться их результатами, чтобы понять, в каком порядке открывать свои подарки. Но в этом году всё по-другому.

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




Игра


Итак, я создал простую игру обход лабиринта в подземелье. Начал с сетки 8 х 8, нарисовал пол подземелья со стенами, запертыми дверьми и спрятанными на карте предметами. Цель найти все предметы и пройти через подземелье. Найдя предмет, можно открыть соответствующий предмету подарок.

В игре можно выбрать два действия: move или explore. Explore исследование комнаты в поисках лакомства и возможности открыть подарок, move перемещение в соседнюю комнату. По мере перемещения карта начинает заполняться, с каждым движением макет видно всё лучше. Во всех комнатах Alexa рассказывает часть истории. Чем глубже мой брат в подземелье, тем более запутанной становится история. Здорово, да?


Карта

Мобильное приложение


Чтобы вызвать восторг от завтрашних приключений, каждый год в канун Рождества я даю брату ламинированное Руководство по получению подарков. В этом году в нём есть QR-код, в котором зашифрована ссылка для скачивания мобильного приложения. Брат установит приложение и будет готов к приключениям. Само приложение довольно простое. На самом деле это приложение только для того, чтобы смотреть на карту. Все команды идут через Alexa, так что нельзя делать ничего другого, кроме как смотреть на телефон и планировать следующий шаг.

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


Страницы мобильного приложения

Каждый раз, когда мне нужно что-то быстро разработать, я захожу в OutSystems. Это интуитивно понятная платформа для разработки с минимумом кода, которая позволяет быстро создавать реактивные веб-страницы, веб-сервисы и мобильные приложения. Что в ней самое лучшее? Её можно бесплатно использовать для проверки концепции! Есть несколько подвижных частей, создающих впечатление волшебства:

  • API.
  • Мобильное приложение.
  • Навыки Alexa.
  • WebSocket.

API


Для выполнения логики и хранения состояния у приложения должен быть мозг. Мозг лучшего рождественского подарка это простой API, созданный в OutSystems. Он загружает положение на карте, проверяет возможность движения, перемещает вас, а затем рассказывает вам небольшую часть истории. Приложение целиком имеет только две конечные точки API: одну для перемещения, а другую для получения текущего состояния. Получить текущее состояние можно только при выходе из приложения и возврате в него (я знаю, что брат иногда будет делать перерывы). За кулисами, ниже, есть модель данных, которая строит карту, сюжет, подарок в инвентаре и переходы между комнатами.


Диаграмма отношений в игре.

С помощью OutSystems я создал модель данных, логику перемещения персонажа, а также REST API для управления всем этим. Пришло время создать фронтенд!

Мобильное приложение


И снова нам поможет OutSystems. У сервиса простой пользовательский интерфейс, позволяющий перетаскивать компоненты на экран, а затем собрать всё это в мобильное приложение для вас. Итак, я приступил к работе и нарисовал две страницы, чтобы отслеживать, какие подарки были найдены и какие области на карте исследованы. Для загрузки данных карты и их отображения на экране я воспользовался API. Как я уже упоминал, это довольно простое мобильное приложение, на него можно только смотреть. Я поиграл с CSS, добавил несколько рождественских изображений и решил, что этого достаточно.

Навык Alexa


Каждый день на работе я использую AWS, так что довольно хорошо знаком со многими сервисами платформы. Но я никогда раньше не разрабатывал навыки Alexa. Подойдя к приставке, я начал смотреть видео. Оказывается, команда Alexa в AWS действительно знает, что делает. Я увидел одно из лучших представлений простого решения сложной проблемы. В навыке есть две составляющие:

  • Модель взаимодействия.
  • Код на бэкенде.

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


Два интента для навыка Alexa.

Код на бэкенде писался так же легко. Можно написать код навыков в VS Code, с помощью расширения Alexa отправив его в облако. Это достаточно просто. Определите в коде, что будет делать каждый интент при вызове. В игре я только вызывал разработанное в OutSystems API, поэтому для каждого интента написал быстрый вызов с помощью axios к соответствующей конечной точке API, попросив Alexa повторить ответное сообщение. Готово!

WebSocket


Во время тестирования приложения я быстро понял, что кое-что забыл. Как обновить мобильное приложение, когда Alexa перемещает персонажа? Мне нужно было что-то, что передаёт данные в приложение всякий раз, когда происходит событие. И это был WebSocket. WebSocket, по сути, открывает двусторонний канал связи между браузером (или мобильным приложением) и сервером. Это позволяет получать сообщения сразу вместо того, чтобы постоянно опрашивать сервер на предмет обновлений.

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

Я обновил API для публикации события Pusher всякий раз, когда персонаж перемещался или исследовал комнату. Кроме того, я включил в сообщение часть возвращённой API истории. В мобильное приложение я добавил простой фрагмент кода подписки на события на JavaScript. Подписка обновляет данные на экране и отображает новую часть истории. Быстрый тест через мою Echo показал, что персонаж перемещается по карте, как только я проговариваю слово. Круто, да?


Панель управления Pusher показывает график сообщений WebSocket.

Разоблачение


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

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

И это бесплатно! Всё, что я сделал для этого подарка, возможно на уровне бесплатного использования, а это значит, что единственной затратой было моё время. Я потратил около 30 часов, чтобы придумать историю, нарисовать карту и собрать всё воедино. Это была инвестиция наверняка. На Рождество мы узнаем, стоила ли она того. Обязательно сообщу о результатах. С Рождеством!


Обучение со скидкой чем не подарок самому себе, в новом 2021 году? А промокод HABR сделает этот подарок еще приятнее, добавив 10% к скидке на баннере.
image


Подробнее..

Море, пираты 3D онлайн игра в браузере

09.07.2020 16:21:19 | Автор: admin
Приветствую пользователей Хабра и случайных читателей. Это история разработки браузерной многопользовательской онлайн игры с low-poly 3D графикой и простейшей 2D физикой.

Позади немало браузерных 2D мини-игр, но подобный проект для меня в новинку. В gamedev решать задачи, с которыми ещё не сталкивался, может быть довольно увлекательно и интересно. Главное не застрять со шлифовкой деталей и запустить рабочую игру пока есть желание и мотивация, поэтому не будем терять время и приступим к разработке!


Об игре в двух словах


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

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

Зарабатываем золото занимая первые места в рейтингах игроков за 24 часа и за 7 дней (сброс в 00:00 по МСК) и выполняя ежедневные задания (на день выдается одно из трех, по очереди). Золото за сражения тоже есть, но меньше.

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

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

Стек технологий


Three.js одна из самых популярных библиотек для работы с 3D в браузере с хорошей документацией и большим количеством различных примеров. Кроме того, я использовал Three.js и раньше выбор очевиден.

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

Node.js потому что просто, быстро и удобно, хотя опыта непосредственно в Node.js не имел. В качестве альтернативы рассматривал Java, проводил пару локальных экспериментов в том числе с веб-сокетами, но сложно ли запустить джаву на VPS выяснять не решился. Ещё один вариант Go, его синтаксис вгоняет меня в уныние не продвинулся в его изучении ни на йоту.

Для веб-сокетов используется модуль ws в Node.js.

PHP и MySQL менее очевидный выбор, но критерий всё тот же быстро и просто, так как есть опыт в данных технологиях.

Получается вот такая схема:



PHP нужен в первую очередь для отдачи клиенту веб-страничек и для редких AJAX запросов, но по большей части клиент общается всё же с игровым сервером на Node.js по веб-сокетам.

Мне совсем не хотелось связывать игровой сервер с БД, поэтому всё идет через PHP. На мой взгляд тут есть свои плюсы, хотя не уверен значимы ли они. Например, так как в Node.js приходят уже готовые данные в нужном виде, Node.js не тратит время на обработку и дополнительные запросы в БД, а занимается более важными делами переваривает действия игроков и изменяет состояние игрового мира в комнатах.

Сначала модель


Разработка началась с простого и самого главного некой модели игрового мира, описывающей морские сражения с серверной точки зрения. Обычный canvas 2D для схематичного отображения модели на экране подходит идеально.



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

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

Клиентский JavaScript для визуализации модели игрового мира, обрабатывающий движение кораблей и выстрелы, я перенес на Node.js сервер почти без изменений.

Игровой сервер


Node.js WebSocket сервер состоит всего из 3 скриптов:

  • main.js основной скрипт, который получает WS сообщения от игроков, создаёт комнаты и заставляет шестеренки этой машины крутиться
  • room.js скрипт, отвечающий за игровой процесс внутри комнаты: обновление игрового мира, рассылка обновлений игрокам комнаты
  • funcs.js включает класс для работы с векторами, пару вспомогательных функций и класс реализующий двусвязный список

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

Актуальный список классов игрового сервера:

  • WaitRoom комната, куда попадают игроки ожидающие начала сражения, здесь есть собственный tick метод, рассылающий свои обновления и запускающий создание игровой комнаты когда больше половины игроков готовы к бою
  • Room игровая комната, где проходят сражения: обновляются состояния игроков/кораблей, затем обрабатываются возможные столкновения, в конце формируется и рассылается всем сообщение с актуальными данными
  • Player по сути обёртка с некоторыми дополнительными свойствами и методами для следующего класса:
  • Ship этот класс заставляет корабли плавать: реализует движение и повороты, здесь также хранятся данные о повреждениях, чтобы в конце игры зачислить очки игрокам, принимавшим участие в уничтожении корабля
  • PhysicsEngine класс, реализующий простейшие столкновения круглых объектов
  • PhysicsBody всё есть круглые объекты со своими координатами на карте и радиусом

Игровой цикл в классе Room выглядит примерно так
let upd = {p: [], t: this.gamet};let t = Date.now();let dt = t - this.lt;let nalive = 0;for (let i in this.players) {this.players[i].tick(t, dt);}this.physics.run(dt);for (let i in this.players) {upd.p.push(this.players[i].getUpd());}this.chronology.addLast(clone(upd));if (this.chronology.n > 30) this.chronology.remFirst();let updjson = JSON.stringify(upd);for (let i in this.players) {let pl = this.players[i];if (pl.ship.health > 0) nalive++;if (pl.deadLeave) continue;pl.cl.ws.send(updjson);}this.lt = t;this.gamet += dt;if (nalive <= 1) return false;return true;


Кроме классов, есть такие функции, как получить данные пользователя, обновить ежедневное задание, получить награду, купить скин. Эти функции в основном отправляют https запросы в PHP, который выполняет один или несколько MySQL запросов и возвращает результат.

Сетевые задержки


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

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

При решении проблемы лагов очень многое зависит от игры и её темпа. Я жертвую быстрым откликом на действия игрока в пользу плавности анимации и точного соответствия картинки состоянию игрового мира в некий момент времени. Единственное исключение залп из пушек воспроизводится незамедлительно по нажатию кнопки. Остальное можно списать на законы вселенной и излишек рома у команды корабля :)

Фронт-энд


К сожалению здесь нет четкой структуры или иерархии классов и методов. Весь JS разбит на объекты со своими функциями, которые в каком то смысле являются равноправными. Почти все мои предыдущие проекты были устроены более логично чем этот. Отчасти так вышло потому что сначала целью было отладить модель игрового мира на сервере и сетевое взаимодействие не обращая внимания на интерфейс и визуальную составляющую игры. Когда пришло время добавлять 3D я в буквальном смысле его добавил в существующую тестовую версию, грубо говоря, заменил 2D функцию drawShip на точно такую же, но 3D, хотя по-хорошему стоило пересмотреть всю структуру и подготовить основу для будущих изменений.

3D корабль


Three.js поддерживает использование готовых 3D моделей различных форматов. Я выбрал для себя GLTF / GLB формат, где могут быть вшиты текстуры и анимация, т.е. разработчик не должен задаваться вопросом все ли текстуры загрузились?.

Раньше я ни разу не имел дела с 3D редакторами. Логичным шагом было обратиться к специалисту на фриланс бирже с задачей создания 3D модели парусного корабля с вшитой анимацией залпа из пушек. Но я не удержался от мелких изменений в готовой модели специалиста своими силами, а закончилось это тем, что я создал свою модель с нуля в Blender. Создать low-poly модель почти без текстур просто, сложно без готовой модели от специалиста изучить в 3D редакторе то, что нужно для конкретной задачи (как минимум морально :).



Шейдеры богу шейдеров


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

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

Пустая заготовка, структура кода, которую мне было удобно копировать, дополнять и изменять для создания своего 3D объекта подобным образом:

Показать код
let vs = `attribute vec4 color;varying vec4 vColor;void main(){vColor = color;gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );// gl_PointSize = 5.0; // for particles}`;let fs = `uniform float opacity;varying vec4 vColor;void main() {gl_FragColor = vec4(vColor.xyz, vColor.w * opacity);}`;let material = new THREE.ShaderMaterial( {uniforms: {opacity: {value: 0.5}},vertexShader: vs,fragmentShader: fs,transparent: true});let geometry = new THREE.BufferGeometry();//let indices = [];let vertices = [];let colors = [];/* ... *///geometry.setIndex( indices );geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( vertices, 3 ) );geometry.setAttribute( 'color', new THREE.Float32BufferAttribute( colors, 4 ) );let mesh = new THREE.Mesh(geometry, material);


Повреждение корабля


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



Море


Море также реализовано с помощью ShaderMaterial. Каждая вершина движется во всех 3-х направлениях по синусоиде образуя рандомные волны. Атрибуты определяют амплитуды для каждого направления движения и фазу синусоиды.

Чтобы разнообразить цвета на воде и сделать игру интереснее и приятнее глазу было решено добавить дно и острова. Цвет дна зависит от высоты/глубины и просвечивает сквозь водную поверхность создавая темные и светлые области.

Морское дно создаётся из карты высот, которая создавалась в 2 этапа: сначала в графическом редакторе было создано дно без островов (в моём случае инструментами были render -> clouds и Gaussian blur), затем средствами Canvas JS онлайн на jsFiddle в случайном порядке были добавлены острова рисованием окружности и размытием. Некоторые острова низкие, через них можно стрелять в противников, другие имеют определённую высоту, через них выстрелы не проходят. Кроме самой карты высот, на выходе я получаю данные в json формате об островах (их положение и размеры) для физики на сервере.



Что дальше?


Есть много планов по развитию игры. Из крупных новые режимы игры. Более мелкие придумать тени/отражение на воде с учетом ограничений производительности WebGL и JS. Про возможность разбудить Кракена я уже упоминал :) Ещё не реализовано объединение игроков в комнаты на основе их накопленного опыта. Очевидное, но не слишком приоритетное усовершенствование создать несколько карт морского дна и островов и выбирать для нового сражения одну из них случайным образом.

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

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

Пасхалка


Кто не любит вспомнить старые компьютерные игры, которые дарили так много эмоций? Мне нравится перепроходить игру Корсары 2 (она же Sea Dogs 2) снова и снова до сих пор. Не мог не добавить в свою игру секрет и явно и косвенно напоминающий о Корсарах 2. Не стану раскрывать все карты, но дам подсказку: моя пасхалка это некий объект, который вы можете найти исследуя морские просторы (далеко плыть по бескрайнему морю не нужно, объект находится в пределах разумного, но всё же вероятность найти его не высока). Пасхальное яйцо полностью восстанавливает поврежденный корабль.

Что получилось


Минутное видео (тест с 2 устройств):


Ссылка на игру: https://sailfire.pw

Там же есть форма для связи, сообщения летят мне в телеграм: https://sailfire.pw/feedback/
Подробнее..

Изучаю Scala Часть 4 WebSocket

30.08.2020 02:18:14 | Автор: admin

Привет, Хабр! На этот раз я по пробовал сделать простенький чат через ВебСокеты. За подробностями добро пожаловать под кат.

Содержание



Ссылки


  1. Исходники
  2. Образы docker image
  3. Tapir
  4. Http4s
  5. Fs2
  6. Doobie
  7. ScalaTest
  8. ScalaCheck
  9. ScalaTestPlusScalaCheck


Собственно весь код находиться в одном объект ChatHub
class ChatHub[F[_]] private(                             val topic: Topic[F, WebSocketFrame],                             private val ref: Ref[F, Int]                           )                           (                             implicit concurrent: Concurrent[F],                             timer: Timer[F]                           ) extends Http4sDsl[F] {  val endpointWs: ServerEndpoint[String, Unit, String, Stream[IO, WebSocketFrame], IO] = endpoint    .get    .in("chat")    .tag("WebSockets")    .summary("Подключится к общему чату. Например по такому адресу: ws://localhost:8080/chat")    .description("Подключает к общему чату")    .in(      stringBody        .description("Сообщение которое будет отправлено пользователям в чате")        .example("Привет!")    )    .out(      stringBody        .description("Сообщение которое кто-то написал в чат")        .example("6 : Сообщение от клиента с Id подключения f518a53d: Привет!")    )    //Заглушка которая всегда отвечает ошибкой.     .serverLogic(_ => IO(Left(()): Either[Unit, String]))  def routeWs: HttpRoutes[F] = {    HttpRoutes.of[F] {      case GET -> Root / "chat" => logic()    }  }  private def logic(): F[Response[F]] = {    val toClient: Stream[F, WebSocketFrame] =      topic.subscribe(1000)    val fromClient: Pipe[F, WebSocketFrame, Unit] =      handle    WebSocketBuilder[F].build(toClient, fromClient)  }  private def handle(s: Stream[F, WebSocketFrame]): Stream[F, Unit] = s    .collect({      case WebSocketFrame.Text(text, _) => text    })    .evalMap(text => ref.modify(count => (count + 1, WebSocketFrame.Text(s"${count + 1} : $text"))))    .through(topic.publish)}object ChatHub {  def apply[F[_]]()(implicit concurrent: Concurrent[F], timer: Timer[F]): F[ChatHub[F]] = for {    ref <- Ref.of[F, Int](0)    topic <- Topic[F, WebSocketFrame](WebSocketFrame.Text("==="))  } yield new ChatHub(topic, ref)}

Тут надо сразу сказать про Topic примитив синхронизации из Fs2 который позволяет сделать модель Publisher Subscriber причем у вас может быть много Publisher и одновременно много Subscriber. Вообще в него лучшее отправлять сообщения через какой-то буфер вроде Queue потому что у него есть ограничения на количество сообщения в очереди и Publisher ждет пока все Subscriber не получат сообщения в свою очередь сообщений и если она переполнена то может и зависнуть.
val topic: Topic[F, WebSocketFrame],

Тут еще я считаю количество сообщений которые были переданы в чат как номер каждого сообщения. Так как это мне нужно делать из разных потоков я использовал аналог Atomic который тут называется Ref и гарантирует атомарность операции.
  private val ref: Ref[F, Int]

Обработка потока сообщений от пользователей.
  private def handle(stream: Stream[F, WebSocketFrame]): Stream[F, Unit] =     stream//Достаем из фрейма текстовое сообщение и фильтруем фреймы.     .collect({      case WebSocketFrame.Text(text, _) => text    })//Атомарно увеличиваем наш счетчик с сохранением нового значения и добавления его значения к тексту сообщения пользователя.    .evalMap(text => ref.modify(count => (count + 1, WebSocketFrame.Text(s"${count + 1} : $text"))))//Каждое пришедшее сообщение отправляем в топик    .through(topic.publish)

Собственно сама логика создания сокета.
private def logic(): F[Response[F]] = {//Откуда получать данные для клиента.    val toClient: Stream[F, WebSocketFrame] =//Просто подписываемся на данные которые будут приходить в топик      topic.subscribe(1000)//Что будем делать с данными которые приходить от клиента    val fromClient: Pipe[F, WebSocketFrame, Unit] =//Просто отправляем данные в топик после обработки      handle//Создаем веб сокет с созданными ранее генератором и потребителем данных.    WebSocketBuilder[F].build(toClient, fromClient)  }

Связываем наш сокет с роутом на сервере (ws://localhost:8080/chat)
def routeWs: HttpRoutes[F] = {    HttpRoutes.of[F] {      case GET -> Root / "chat" => logic()    }  }

Собственно на этом все. Дальше уже можно запускать сервер с этим роутом. Мне еще захотелось какую ни какую документацию сделать. Вообще для документирования WebSocket и прочего основанного на событиях взаимодействия вроде RabbitMQ AMPQ есть AsynAPI но под Tapir там нет ничего поэтому просто сделал для Swagger описание эндпойнта как GET запрос. Работать он конечно не будет. Точнее 501 ошибку будет возвращать зато будет отображаться в Swagger
  val endpointWs: Endpoint[String, Unit, String, fs2.Stream[F, Byte]] = endpoint    .get    .in("chat")    .tag("WebSockets")    .summary("Подключится к общему чату. Например по такому адресу: ws://localhost:8080/chat")    .description("Подключает к общему чату")    .in(      stringBody        .description("Сообщение которое будет отправлено пользователям в чате")        .example("Привет!")    )    .out(      stringBody        .description("Сообщение которое кто-то написал в чат")        .example("6 : Сообщение от клиента с Id подключения f518a53d: Привет!")    )

В самом сваггере это выглядит вот так

Подключаем наш чат к нашему серверу API
    todosController = new TodosController()    imagesController = new ImagesController()//Создаем объект нашего чата    chatHub <- Resource.liftF(ChatHub[IO]())    endpoints = todosController.endpoints ::: imagesController.endpoints//Добавляем его эндпойнт в документацию Swagger    docs = (chatHub.endpointWs :: endpoints).toOpenAPI("The Scala Todo List", "0.0.1")    yml: String = docs.toYaml//Добавляем его маршрут в список маршрутов приложения    routes = chatHub.routeWs <+>      endpoints.toRoutes <+>      new SwaggerHttp4s(yml, "swagger").routes[IO]    httpApp = Router(      "/" -> routes    ).orNotFound    blazeServer <- BlazeServerBuilder[IO](serverEc)      .bindHttp(settings.host.port, settings.host.host)      .withHttpApp(httpApp)      .resource

Подключаемся к чату крайне простым скриптом.
    <script>        const id = `f${(~~(Math.random() * 1e8)).toString(16)}`;        const webSocket = new WebSocket('ws://localhost:8080/chat');        webSocket.onopen = event => {            alert('onopen ');        };        webSocket.onmessage = event => {            console.log(event);            receive(event.data);        };        webSocket.onclose = event => {            alert('onclose ');        };        function send() {            let text = document.getElementById("message");            webSocket.send(`Сообщение от клиента с Id подключения ${id}: ${text.value}`);            text.value = '';        }        function receive(m) {            let text = document.getElementById("chat");            text.value = text.value + '\n\r' + m;        }    </script>

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

Как использовать Websocket на примере простого Express API?

24.08.2020 14:14:04 | Автор: admin

Краткое описание технологии

Websocket это протокол связи поверх TCP-соединения, предназначенный для обмена сообщениями между браузером и веб-сервером в режиме реального времени.
Для установления соединения WebSocket клиент и сервер используют протокол, похожий на HTTP. Клиент формирует особый HTTP-запрос, на который сервер отвечает определенным образом.

Примечания.
Несмотря на похожесть новых запросов и ответов на запросы и ответы протокола HTTP, они таковыми не являются. Например, в запросе есть тело, но в заголовках поле Content-Length отсутствует (что нарушает соглашения HTTP). Подробнее об этом можно прочитать в Википедии.
Одним из главных преимуществ технологии это ее простота. На клиенте и сервере есть всего 4 события для обработки:

  1. connection
  2. error
  3. message
  4. close

Почему Websocket?


Кроме ws существуют еще два способа непрерывной передачи данных: Server-Sent Events (SSE) и Long Polling.
Приведем сравнения механизмов непрерывной связи сервера и клиента, а также сделаем выводы, почему стоит (или не стоит) использовать вебсокет.
Websocket sse long pooling
протокол websocket (ws, или wss) HTTP(S) HTTP(S)
скорость высокая низкая низкая
направленность потоков данных двунаправленная однонаправленная двунаправленная
дополнительно передача бинарных данных,
отсутствует поддержка некоторых старых браузеров
автоматическое переподключение при обрыве соединения

Одним из главных преимуществ технологии ws это скорость передачи данных. SSE и LP используют протокол HTTP(S) и работают примерно так:

  1. Делаем запрос на изменения;
  2. Если изменения на сервере появились, то сервер их отправляет;
  3. Когда клиент получает изменения, клиент делает новый запрос.

Выводы:
  1. Вебсокет не требует от клиента постоянно запрашивать изменения и именно поэтому он быстрее.
  2. Вебсокет позволяет передавать бинарные данные, что не позволяет протокол HTTP(S).
  3. Вебсокет не нужно использовать, если проект требует совместимость со старыми версиями браузеров. Читать о совместимости с браузерами

Пример работы простейшего api.


const http = require("http");const express = require( "express");const WebSocket = require( "ws");const app = express();const server = http.createServer(app);const webSocketServer = new WebSocket.Server({ server });webSocketServer.on('connection', ws => {   ws.on('message', m => {webSocketServer.clients.forEach(client => client.send(m));   });   ws.on("error", e => ws.send(e));   ws.send('Hi there, I am a WebSocket server');});server.listen(8999, () => console.log("Server started"))

Что здесь происходит?
Чтобы создать сервер поддерживающий ws, мы создаем обычный http сервер, а потом привязываем к нему при создании websocket сервер.

Функция on помогает управлять событиями websocket. Самым примечательным является событие message, так что рассмотрим его подробнее.

Здесь функция получает параметр m сообщение, то есть то, что отправил пользователь. Таким образом мы можем отправить с клиента строку и обработать ее на сервере. В данном случае сервер просто пересылает это сообщение всем, кто подключен к серверу websocket. Массив clients объекта webSocketServer содержит все подключения к серверу. Объект ws в то же время хранит данные только об одном подключении.

Замечание.

Не стоит использовать такой подход в реальном приложении. Если описать api таким образом, то сервер не может отличить один запрос от другого. О том, как можно построить api на основе websocket будет написано далее.

Взаимодействие с сервером на клиенте будет выглядеть так:
export const wsConnection = new WebSocket("ws://localhost:8999");wsConnection.onopen = function() {    alert("Соединение установлено.");};wsConnection.onclose = function(event) {    if (event.wasClean) {        alert('Соединение закрыто чисто');    } else {        alert('Обрыв соединения'); // например, "убит" процесс сервера    }    alert('Код: ' + event.code + ' причина: ' + event.reason);};wsConnection.onerror = function(error) {    alert("Ошибка " + error.message);};export const wsSend = function(data) {// readyState - true, если есть подключение    if(!wsConnection.readyState){        setTimeout(function (){            wsSend(data);        },100);    } else {        wsConnection.send(data);    }};

API на основе Websocket


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

1) С клиента мы будем передавать запросы в виде строки-json, которую распарсим на сервере:
const sendMessage = (message) => conn.send(JSON.stringify({ event: "chat-message", payload: { userName, message }}));

2) На сервере мы распарсим строку и выделем в ней поле event тип запроса. Пропишем для каждого типа соответствующий ответ:
const dispatchEvent = (message, ws) => {   const json = JSON.parse(message);   switch (json.event) {       case "chat-message": webSocketServer.clients.forEach(client => client.send(message));       default: ws.send((new Error("Wrong query")).message);   }}

Таким образом мы можем отправлять разные запросы на сервер и обрабатывать ответ в зависимости от запроса.

Заключение


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

Кроссплатформенный мультиплеер на Godot без боли

30.01.2021 14:20:43 | Автор: admin

Что хотим сделать?

Синхронизацию действий игроков в игре с клиент-серверной архитектурой. Должна быть возможность играть из браузера.

Для примера реализуем простую чат-комнату:

  1. При соединении:

    1. Клиент получает уникальный ID;

    2. Клиент получает информацию о всех остальных игроках (ID + имя);

    3. Все остальные игроки получают информацию о новом игроке (ID + имя по умолчанию);

    4. В консоли появляется сообщение о входе.

  2. При потере соединения:

    1. Все остальные игроки получают информацию о выходе игрока с сервера (ID);

    2. В консоли появляется сообщение о выходе.

  3. При изменении имени:

    1. Если имя уже занято - игрок получает ошибку;

    2. Все игроки уведомляются об изменении имени;

    3. В консоли появляется сообщение.

  4. При отправке сообщения в чат:

    1. Все игроки видят сообщение в логе/консоли.

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

Что получилось?

Готовый проект можно изучить здесь: https://github.com/ktori/godobuf-over-websocket-demo

Скриншоты можно посмотреть в конце статьи.

Что будем использовать?

  • Godot - free and open source кроссплатформенный игровой движок;

  • Protobuf - механизм для эффективной сериализации/десериализации данных;

  • Godobuf - плагин для Godot, позволяющий генерировать .gd (GDScript) файлы из .proto;

  • Ktor - фреймворк для создания асинхронных сервисов Kotlin (в этой статье я буду использовать Kotlin - но бэкэнд может быть написан на любом другом языке, главное - иметь в фреймворке возможность принимать вебсокет-соединения и желательно - генератор кода из Protobuf, эти генераторы существуют для множества языков).

Плюсы этого подхода

  • Все сообщения, которыми обмениваются клиент и сервер, описываются в одном месте:

    • Из этих файлов можно сразу сгенерировать код и для сервера и для клиента;

    • В них же можно вести документацию, оставляя комментарии;

    • Описание протокола можно легко хранить в любой VCS, т.к. по сути это просто текстовые файлы;

    • Можно точно знать что обе стороны будут сериализовывать и десериализовывать сообщения одинаково - генерация кода обеспечит отсутствие забытых полей и каких-либо других ошибок свойственных при ручном чтении/записи.

  • Protobuf - бинарный формат, и в отличие от, например, JSON - будет использоваться меньший объем трафика для передачи одного и того же объема данных;

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

Минусы этого подхода

Совсем явных минусов я назвать не могу - но:

  • Сериализация/десериализация в protobuf будет проходить медленнее, чем, например, прямая запись в буфер в собственном формате;

  • Код, который генерируется из protobuf часто получается довольно громоздким и, соответственно, имеет определенную стоимость в рантайме.

Описание протокола

Готовый протофайл можно посмотреть здесь: game.proto

Создадим пустой .proto-файл, например - game.proto. В этом файле нужно описывать все сообщения, которыми будут обмениваться сервер и клиент (если сообщений будет много - можно выносить их в отдельные файлы и импортировать из основного).

В этот файл следует сразу прописать опции для парсера и кодогенератора:

syntax = "proto3";// Название пакетаoption java_package = "me.ktori.game.proto";// Название класса в котором будут находиться подклассы сообщенийoption java_outer_classname = "GameProto";

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

Сообщения клиент-сервер

Это сообщения, которые клиент отправляет серверу - часто они будут по сути RPC вызовами с ответом в сообщении Cl**Result от сервера. Здесь был бы очень кстати gRPC - возможно в будущем с помощью godobuf можно будет делать и gRPC-сервисы. Но пока:

//// Сообщения клиент-сервер//// Запрос на изменение имениmessage ClSetName {  string name = 1;}// Отправка сообщения в чатmessage ClSendChatMessage {  string text = 1;}// Объединение всех сообщений, отсылаемых клиентомmessage ClMessage {  // Только одно из этих полей может быть заполнено, таким образом сервер  // может быстро определить, что именно хочет сделать клиент  oneof data {    ClSetName set_name = 1;    ClSendChatMessage send_chat_message = 2;  }}

Сообщения сервер-клиент

//// Сообщения сервер-клиент//// Результат выполнения команды ClSetNamemessage ClSetNameResult {  // Удалось ли изменить имя - имя нельзя изменить на уже занятое  bool success = 1;}// Отсылается сервером - объединение всех возможных результатов выполнения команды от клиентаmessage ClMessageResult {  oneof result {    ClSetNameResult set_name = 1;  }}// Отсылается клиенту один раз при соединении// Получатель этого сообщения сохраняет у себя полученный ID и выданное сервером имяmessage SvConnected {  int32 id = 1;  string name = 2;}// Уведомление о подключении нового клиента// Получатель должен сохранить имя клиента по IDmessage SvClientConnected {  int32 id = 1;  string name = 2;}// Уведомление об отключении клиента// Получатель может удалить у себя информацию о клиенте по IDmessage SvClientDisconnected {  int32 id = 1;}// Уведомление об изменении имени// Получатель должен изменить имя клиента по ID на новоеmessage SvNameChanged {  int32 id = 1;  string name = 2;}// Сообщение в чатеmessage SvChatMessage {  int32 from = 1;  string text = 2;}// Объединение всех сообщений которые сервер посылает клиентуmessage SvMessage {  // Только одно из этих полей будет заполнено в одном SvMessage  oneof data {    ClMessageResult result = 1;    SvConnected connected = 2;    SvClientConnected client_connected = 3;    SvClientDisconnected client_disconnected = 4;    SvNameChanged name_changed = 5;    SvChatMessage chat_message = 6;  }}

Таким образом получаем следующую структуру:

  • Все возможные сообщения от клиента обернуты в ClMessage;

  • Все возможные сообщения от сервера обернуты в SvMessage;

    • Ответы на вызовы клиента обернуты в поле result - сообщение ClMessageResult.

Лично для себя я определилась с такой naming convention:

  • ClFooBar для сообщений, которые шлёт клиент серверу;

  • SvFooBar для сообщений, которые шлёт сервер клиенту, за исключением:

  • ClFooBarResult для передачи результата обработки ClFooBar.

Создание клиентской части на Godot

Для начала нужно создать проект и основную сцену (обычную пустую 2D сцену).

Добавление плагина Godobuf

Плагин можно скачать здесь: https://github.com/oniksan/godobuf, инструкция по установке есть в README репозитория - нужно распаковать себе в проект папку addons.

Проект после установки аддона godobuf Проект после установки аддона godobuf

Открытие соединения

Для соединения с сервером используется класс WebSocketClient (документация по WebSocketClient). Работать с ним просто: устанавливаем обработчики событий, а затем указываем URL сервера для соединения.

Создадим скрипт, который будет открывать соединение на корневой ноде сцены - там же будут заготовки для функций обработки событий от вебсокета:

extends Node2Dvar ws: WebSocketClient# Вызывается при загрузке сценыfunc _ready():    # Создаем WebSocketClient и подключаем обработчики событий    ws = WebSocketClient.new()    ws.connect("connection_established", self, "_on_ws_connection_established")    ws.connect("data_received", self, "_on_ws_data_received")    # Подключаемся к локалхосту по порту 8080    ws.connect_to_url("ws://127.0.0.1:8080")# Будет вызываться при установке соединенияfunc _on_ws_connection_established(_protocol):    pass# Будет вызываться при получении сообщений из вебсокетаfunc _on_ws_data_received():    pass

Генерация биндингов protobuf:GDScript

Здесь всё очень просто! Во вкладке Godobuf указываем путь до нашего proto-файла и путь куда будет сохранен получившийся скрипт:

Окно GodobufОкно Godobuf

Если в прото-файле нет ошибок, то мы увидим сообщение об успешной компиляции и в папке проекта появится нужный скрипт.

Отправка сообщений

Настройка сцены

 Сцена Сцена

В своей сцене я сделала отдельный контейнер для сообщений и два поля - для ввода текста и имени. Сигналы pressed от кнопок Send и Rename я подключила в скрипт на корневой ноде. Также для вывода сообщений на сцену я сделала функцию show_message, она просто добавляет новый объект Label с текстом сообщения в VBoxContainer, который располагает объекты вертикально.

Отправка запросов на сервер

После создания этих полей ввода и кнопок нужно сделать так чтобы они что-то делали.

Сперва загрузим получившиеся биндинги в наш скрипт:

const GameProto = preload("res://game_proto.gd") 

Теперь можно добавить код создания ClMessage при нажатии на кнопки Send/Rename:

# Изменяем имя на введенное в $Namefunc _on_SetName_pressed():    var msg = GameProto.ClMessage.new()    var sn = msg.new_set_name()    sn.set_name(name_input.text)    send_msg(msg)# Отправляем сообщение из $Message и очищаем полеfunc _on_SendMessage_pressed():    var msg = GameProto.ClMessage.new()    var scm = msg.new_send_chat_message()    scm.set_text(message_input.text)    message_input.clear()    send_msg(msg)

Самое интересное - сама отправка сообщения по вебсокету происходит в функции send_msg. Вот она:

# Отправляет ClMessage на серверfunc send_msg(msg: GameProto.ClMessage):    # Конвертируем ClMessage в PoolByteArray и отправляем его по соединению ws    ws.get_peer(1).put_packet(msg.to_bytes())

Функция to_bytes (как и весь класс ClMessage) сгенерированы плагином godobuf - и никаких операций с буферами руками нам делать не надо!

Обработка сообщений

Теперь наш клиент может отправлять сообщения - но он ещё не способен их принимать. Сейчас мы это исправим, добавив обработку входящих сообщений - этот блок кода будет объемнее, но по большей части код там повторяется.

Код получения и обработки сообщений
# Вызывается часто по интервалуfunc _process(_delta):    # Производит чтение из вебсокета, читает входящие сообщения    ws.poll()# Будет вызываться при установке соединенияfunc _on_ws_connection_established(_protocol):    show_message("Connection established!")# Будет вызываться при получении сообщений из вебсокетаfunc _on_ws_data_received():    # Обработка каждого пакета в очереди    for i in range(ws.get_peer(1).get_available_packet_count()):        # Сырые данные из пакета        var bytes = ws.get_peer(1).get_packet()        var sv_msg = GameProto.SvMessage.new()        # Превращение массива байтов в структурированное сообщение        sv_msg.from_bytes(bytes)        # Обрабатываем уже сконвертированное сообщение        _on_proto_msg_received(sv_msg)# Будет вызываться после чтения и конвертации сообщения из вебсокетаfunc _on_proto_msg_received(msg: GameProto.SvMessage):    # т.к. все эти поля находятся в блоке oneof - заполнено может быть только    # одно из них    if msg.has_connected():        pass    elif msg.has_client_connected():        pass    elif msg.has_client_disconnected():        pass    elif msg.has_chat_message():        pass    elif msg.has_name_changed():        pass    elif msg.has_result():        pass    else:        push_warning("Received unknown message: %s" % msg.to_string())

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

После этого остается только заполнить логику обработки конкретных сообщений - но сначала добавим хранилище известных клиенту имён и переменную для ID текущего клиента:

# Хранит ID этого клиентаvar own_id: int# Хранит пары ID <> Имяvar names = Dictionary()

И обработку одного из возможных сообщений с сервера:

# Внутри _on_proto_msg_received  if msg.has_connected():var c = msg.get_connected()own_id = c.get_id()name_input.text = c.get_name()show_message("Welcome! Your ID is %d and your assigned name is '%s'." % [c.get_id(), c.get_name()])

Остальные блоки в этом if/elif примерно одинаковы. Получившийся код для каждого отдельного сообщения можно посмотреть на GitHub: Main.gd

Серверная часть

Серверная часть очень подробно разбираться не будет. Её можно написать на любом языке - и в данном случае это будет Kotlin с фреймворком Ktor. Напоминаю, что весь код этого проекта доступен на GitHub - сервер там достаточно простой. Но в двух словах выделю основные моменты моей

сервера:

Структура проекта

Основной gradle-проект состоит из двух модулей:

  • server - сам сервер;

  • proto - прото-файлы и сгенерированные из них биндинги:

    • Стоит обратить внимание на плагин com.google.protobuf, зависимость com.google.protobuf:protobuf-java и их конфигурацию;

    • В процессе сборки этого модуля генерируются классы, позволяющие сериализовывать/десериализовывать сообщения описанные в прото-файле.

Сам сервер работает по простому алгоритму - хранит открытые соединения, broadcast-канал для уведомлений и сообщений, принимает сообщения от клиента пока это возможно и отвечает на его запросы.

Результаты

Получившийся Godot-проект может работать как из браузера, так и с нативных сборок под Linux/Windows/Android и т.д. - всё взаимодействие клиента с сервером описывается в одном месте и в протокол легко вносить изменения.

Скриншоты

Нативный клиентНативный клиентWebSocket-клиентWebSocket-клиент

Заключение

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

  • Обработку ошибок (например, передавать отдельное сообщение error в ClMessageResult);

  • Обработку потери/восстановления соединения;

  • Многое другое.

Я надеюсь эта статья оказалась полезной и помогла разобраться в Godot, вебсокетах и protobuf.

Подробнее..

Как мы в ZeroTech подружили Apple Safari и клиентские сертификаты с websocket-ами

03.07.2020 10:08:54 | Автор: admin
Статья будет полезна тем, кто:
знает, что такое Client Cert, и понимает для чего ему websocket-ы на мобильном Safari;
хотел бы публиковать web-сервисы ограниченному кругу лиц или только себе;
думает, что всё уже кем-то сделано, и хотел бы сделать мир немного удобнее и безопаснее.

История веб-сокетов началась примерно 8 лет назад. Ранее использовались методы вида долгих http-запросов (на самом деле ответов): браузер пользователя отправлял запрос на сервер и ждал, пока он ему что-то ответит, после ответа подключался вновь и ждал. Но потом появились веб-сокеты.



Несколько лет назад мы разработали собственную реализацию на чистом php, которая не умеет использовать запросы https, так как это канальный уровень. Не так давно практически все web-серверы научились проксировать запросы по https и поддерживать connection:upgrade.

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

Хотя Сlient Сert появился уже довольно давно, он всё ещё остаётся мало поддерживаемым, так как создаёт массу проблем с попытками его обойти. И (возможно :slightly_smiling_face: ) поэтому IOS-браузеры (все, кроме Safari) не хотят его использовать и запрашивать у локального хранилища сертификатов. Сертификаты обладают массой преимуществ по сравнению с ключами login/pass или ssh или закрытием через firewall нужных портов. Но речь не об этом.

На IOS процедура установки сертификата довольно проста (не без специфики), но в общем делается по инструкциям, которых в сети очень много и которые доступны только для браузера Safari. К сожалению, Safari не умеет использовать Сlient Сert для веб-сокетов, но в интернете есть множество инструкций, как сделать такой сертификат, но на практике это недостижимо.



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

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

Гипотезы:
1. Возможно настроить такое исключение для использования сертификатов (зная, что их не будет) к веб-сокетам внутренних/внешних проксируемых ресурсов.
2. Для веб-сокетов можно сделать уникальное безопасное и защищаемое соединение с помощью временных сессий, которые генерируются при обычном (не веб-сокет) запросе браузера.
3. Временные сессии можно реализовать с помощью одного proxy web-сервера (только встроенные модули и функции).
4. Временные сессии-токены уже были реализованы в качестве готовых модулей apache.
5. Временные сессии-токены можно реализовать, логически спроектировав структуру взаимодействий.

Видимое состояние после внедрения.
Цель работы: управление сервисами и инфраструктурой должно быть доступно с мобильного телефона на IOS без дополнительных программ (таких как VPN), унифицировано и безопасно.

Дополнительная цель: экономия времени и ресурсов/трафика телефона (некоторые сервисы без веб-сокетов генерируют лишние запросы) с ускорением отдачи контента на мобильном интернете.

Как проверить?
1. Открытие страниц:
 например, https://teamcity.yourdomain.com в мобильном браузере Safari (доступен также в десктопной версии)  вызывает успешное подключение к веб-сокетам. например, https://teamcity.yourdomain.com/admin/admin.html?item=diagnostics&tab=webS показывает ping/pong. например, https://rancher.yourdomain.com/p/c-84bnv:p-vkszd/workload/deployment:danidb:ph-> viewlogs  показывает логи контейнера.


2. Или в консоли разработчика:


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

Тут было найдено 2 решения:
а) На уровне

<Location sock*> SSLVerifyClient optional </Location><Location /> SSLVerifyClient require </Location>

менять уровень доступа.

У данного метода возникли такие нюансы:
Проверка сертификата происходит после запроса к проксируемому ресурсу, то есть post request handshake. Это означает, что прокси сначала нагрузит, а потом отсечёт запрос к защищаемому сервису. Это плохо, но не критично;
В протоколе http2. Он ещё находится в draft-е, и производители браузеров не знают, как его реализовать #info about tls1.3 http2 post handshake (not working now) Implement RFC 8740 Using TLS 1.3 with HTTP/2;
Непонятно, как унифицировать эту обработку.

б) На базовом уровне разрешить ssl без сертификата.
SSLVerifyClient require => SSLVerifyClient optional, но это снижает уровень защиты proxy-сервера, так как такое соединение будет обработано без сертификата. Однако можно далее запретить доступ к проксируемым сервисам такой директивой:

RewriteEngine        onRewriteCond     %{SSL:SSL_CLIENT_VERIFY} !=SUCCESSRewriteRule     .? - [F]ErrorDocument 403 "You need a client side certificate issued by CAcert to access this site"


Более подробная информация в статье о ssl: Apache Server Client Certificate Authentication

Оба варианта были проверены, выбран вариант б за универсальность и совместимость с протоколом http2.

Для завершения проверки этой гипотезы потребовалось немало экспериментов с конфигурацией, были проверены конструкции:
if = require = rewrite
Apache Core Features
Expressions in Apache HTTP Server

Получилась следующая базовая конструкция:
SSLVerifyClient optionalRewriteEngine onRewriteCond %{SSL:SSL_CLIENT_VERIFY} !=SUCCESSRewriteCond %{HTTP:Upgrade} !=websocket [NC]RewriteRule     .? - [F]#ErrorDocument 403 "You need a client side certificate issued by CAcert to access this site"#websocket for safari without cert auth<If "%{SSL:SSL_CLIENT_VERIFY} != 'SUCCESS'"><If "%{HTTP:Upgrade} = 'websocket'">...    #замещаем авторизацию по владельцу сертификата на авторизацию по номеру протокола    SSLUserName SSl_PROTOCOL</If></If>


С учётом существующей авторизации по владельцу сертификата, но при отсутствующем сертификате пришлось добавить несуществующего владельца сертификата в виде одной из доступных переменных SSl_PROTOCOL (вместо SSL_CLIENT_S_DN_CN), подробнее в документации:
Apache Module mod_ssl



2. Для веб-сокетов можно сделать уникальное безопасное и защищаемое соединение с помощью временных сессий, которые генерируются при обычном (не веб-сокет) запросе браузера.

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

#подготовка передача себе Сookie через пользовательский браузер<If "%{SSL:SSL_CLIENT_VERIFY} = 'SUCCESS'"><If "%{HTTP:Upgrade} != 'websocket'">Header set Set-Cookie "websocket-allowed=true; path=/; Max-Age=100"</If></If>#проверка Cookie для установления веб-сокет соединения<source lang="javascript"><If "%{SSL:SSL_CLIENT_VERIFY} != 'SUCCESS'"><If "%{HTTP:Upgrade} = 'websocket'">#check for exists cookie#get and checkSetEnvIf Cookie "websocket-allowed=(.*)" env-var-name=$1#or rewrite ruleRewriteCond %{HTTP_COOKIE} !^.*mycookie.*$#or if<If "%{HTTP_COOKIE} =~ /(^|; )cookie-name\s*=\s*some-val(;|$)/ ></If</If></If>


Проверка показала, что это работает. Возможно через пользовательский браузер передавать cебе Cookie.

3. Временные сессии можно реализовать с помощью одного proxy web-сервера (только встроенные модули и функции).
Как мы выяснили ранее, у Apache довольно много core-функциональности, которая позволяет создавать условные конструкции. Однако нам нужны средства защиты нашей информации, пока она находится в пользовательском браузере, поэтому устанавливаем, что и для чего хранить, и какие встроенные функции будем задействовать:
Нужен такой токен, который не поддаётся простому декодированию.
Нужен такой токен, в котором зашито устаревание и возможность проверки устаревания на сервере.
Нужен такой токен, который будет связан с владельцем сертификата.

Для этого нужна функция хеширования, соль и дата для устаревания токена. Исходя из документации Expressions in Apache HTTP Server у нас есть всё это из коробки sha1 и %{TIME}.

Получилась такая конструкция:
#нет сертификата, и обращение к websocket<If "%{SSL:SSL_CLIENT_VERIFY} != 'SUCCESS'"><If "%{HTTP:Upgrade} = 'websocket'">    SetEnvIf Cookie "zt-cert-sha1=([^;]+)" zt-cert-sha1=$1    SetEnvIf Cookie "zt-cert-uid=([^;]+)" zt-cert-uid=$1    SetEnvIf Cookie "zt-cert-date=([^;]+)" zt-cert-date=$1#только так можно работать с переменными, полученными в env-ах в этот момент времени, более они нигде не доступны для функции хеширования (по отдельности можно, но не вместе, да и ещё с хешированием)    <RequireAll>        Require expr %{sha1:salt1%{env:zt-cert-date}salt3%{env:zt-cert-uid}salt2} == %{env:zt-cert-sha1}        Require expr %{env:zt-cert-sha1} =~ /^.{40}$/    </RequireAll></If></If>#есть сертификат, запрашивается не websocket<If "%{SSL:SSL_CLIENT_VERIFY} = 'SUCCESS'"><If "%{HTTP:Upgrade} != 'websocket'">    SetEnvIf Cookie "zt-cert-sha1=([^;]+)" HAVE_zt-cert-sha1=$1    SetEnv zt_cert "path=/; HttpOnly;Secure;SameSite=Strict"#Новые куки ставятся, если старых нет    Header add Set-Cookie "expr=zt-cert-sha1=%{sha1:salt1%{TIME}salt3%{SSL_CLIENT_S_DN_CN}salt2};%{env:zt_cert}" env=!HAVE_zt-cert-sha1    Header add Set-Cookie "expr=zt-cert-uid=%{SSL_CLIENT_S_DN_CN};%{env:zt_cert}" env=!HAVE_zt-cert-sha1    Header add Set-Cookie "expr=zt-cert-date=%{TIME};%{env:zt_cert}" env=!HAVE_zt-cert-sha1</If></If>


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



4. Временные сессии-токены уже были реализованы в качестве готовых модулей Аpache.

С предыдущей итерации осталась одна существенная проблема невозможность контролировать устаревание токена.

Ищем готовый модуль, который это делает, по словам: apache token json two factor auth
Client authentication using tokens based on JSON Web Tokens
Apache Two-Factor (2FA) Authentication
How to Add Two-Factor Authentication to Apache
Bring two-factor authentication to your Apache instance with a simple module install

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

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

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

(%{env:zt-cert-date} + 30) > %{DATE}

Можно сравнивать только два числа.

При поиске обхода проблемы Safari нашлась интересная статья: Securing HomeAssistant with client certificates (works with Safari/iOS)
В ней описан пример кода на Lua для Nginx, и который, как оказалось, очень повторяет логику той части конфигурации, которую мы уже ранее реализовали, за исключением использования hmac-метода расстановки соли для хеширования (такого в Apache не нашлось).

Стало понятно, что Lua это язык, с понятной логикой, возможно сделать что-то простенькое и для Apache:
LuaHookAccessChecker Directive
UnsetEnv Directive

Изучив разницу с Nginx и Apache:
modules_lua
lua_load_resty_core

И доступные функции от производителя языка Lua:
22.1 Date and Time

Найден способ задания переменных env в небольшом Lua-файле для того, чтобы установить дату из будущего для сверки с текущей.

Вот так выглядит простенький Lua-скрипт:
require 'apache2'function handler(r)    local fmt = '%Y%m%d%H%M%S'    local timeout = 3600 -- 1 hour    r.notes['zt-cert-timeout'] = timeout    r.notes['zt-cert-date-next'] = os.date(fmt,os.time()+timeout)    r.notes['zt-cert-date-halfnext'] = os.date(fmt,os.time()+ (timeout/2))    r.notes['zt-cert-date-now'] = os.date(fmt,os.time())    return apache2.OKend


И вот так это всё работает в сумме, c оптимизацией числа Cookie и заменой токена при наступлении половинного времени до истечения старых Cookie (токена):
SSLVerifyClient optional#LuaScope thread#generate event variables zt-cert-date-nextLuaHookAccessChecker /usr/local/etc/apache24/sslincludes/websocket_token.lua handler early#запрещаем без сертификата что-то ещё, кроме webscoketRewriteEngine onRewriteCond %{SSL:SSL_CLIENT_VERIFY} !=SUCCESSRewriteCond %{HTTP:Upgrade} !=websocket [NC]RewriteRule     .? - [F]#ErrorDocument 403 "You need a client side certificate issued by CAcert to access this site"#websocket for safari without certauth<If "%{SSL:SSL_CLIENT_VERIFY} != 'SUCCESS'"><If "%{HTTP:Upgrade} = 'websocket'">    SetEnvIf Cookie "zt-cert=([^,;]+),([^,;]+),[^,;]+,([^,;]+)" zt-cert-sha1=$1 zt-cert-date=$2 zt-cert-uid=$3    <RequireAll>        Require expr %{sha1:salt1%{env:zt-cert-date}salt3%{env:zt-cert-uid}salt2} == %{env:zt-cert-sha1}        Require expr %{env:zt-cert-sha1} =~ /^.{40}$/        Require expr %{env:zt-cert-date} -ge %{env:zt-cert-date-now}    </RequireAll>       #замещаем авторизацию по владельцу сертификата на авторизацию по номеру протокола    SSLUserName SSl_PROTOCOL    SSLOptions -FakeBasicAuth</If></If><If "%{SSL:SSL_CLIENT_VERIFY} = 'SUCCESS'"><If "%{HTTP:Upgrade} != 'websocket'">    SetEnvIf Cookie "zt-cert=([^,;]+),[^,;]+,([^,;]+)" HAVE_zt-cert-sha1=$1 HAVE_zt-cert-date-halfnow=$2    SetEnvIfExpr "env('HAVE_zt-cert-date-halfnow') -ge %{TIME} && env('HAVE_zt-cert-sha1')=~/.{40}/" HAVE_zt-cert-sha1-found=1    Define zt-cert "path=/;Max-Age=%{env:zt-cert-timeout};HttpOnly;Secure;SameSite=Strict"    Define dates_user "%{env:zt-cert-date-next},%{env:zt-cert-date-halfnext},%{SSL_CLIENT_S_DN_CN}"    Header set Set-Cookie "expr=zt-cert=%{sha1:salt1%{env:zt-cert-date-next}sal3%{SSL_CLIENT_S_DN_CN}salt2},${dates_user};${zt-cert}" env=!HAVE_zt-cert-sha1-found</If></If>SetEnvIfExpr "env('HAVE_zt-cert-date-halfnow') -ge %{TIME} && env('HAVE_zt-cert-sha1')=~/.{40}/" HAVE_zt-cert-sha1-found=1работает,а так работать не будетSetEnvIfExpr "env('HAVE_zt-cert-date-halfnow') -ge  env('zt-cert-date-now') && env('HAVE_zt-cert-sha1')=~/.{40}/" HAVE_zt-cert-sha1-found=1 


Потому что LuaHookAccessChecker будет активирован только после проверок доступа исходя из этой информации от Nginx.


Cсылка на источник изображения.

Ещё один момент.
В целом неважно, в какой последовательности в конфигурации Аpache (вероятно и Nginx) написаны директивы, так как в итоге всё будет отсортировано исходя из очерёдности прохождения запроса от пользователя, который соответствует схеме для отработки Lua-скриптов.

Завершение:
Видимое состояние после внедрения (цель):
Управление сервисами и инфраструктурой доступно с мобильного телефона на IOS без дополнительных программ (VPN), унифицировано и безопасно.

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

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru