Хабрахабр

Делаем мессенджер*, который работает даже в лифте

*на самом деле мы напишем только прототип протокола.

Или иногда ваш провайдер связи неправильно конфигурирует сеть и 50% пакетов пропадает, и тоже ничего не работает. Возможно, вы встречались с подобной ситуацией – сидите в любимом мессенджере, переписываетесь с друзьями, заходите в лифт/тоннель/вагон, и интернет вроде ещё ловит, но отправить ничего не получается? Вы не одни. Возможно, вы думали в этот момент — ну ведь можно же наверное как-то сделать, чтобы при плохой связи всё равно можно было отправить тот маленький кусочек текста, который вы хотите?

Источник картинки

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

Проблемы TCP/IP

Когда у нас плохое (мобильное) соединение, то начинает теряться большой процент пакетов (или ходить с очень большой задержкой), и протокол TCP/IP может воспринимать это как сигнал о том, что сеть перегружена, и всё начинает работать оооочень медленно, если работает вообще. Не добавляет радости тот факт, что установление соединения (особенно TLS) требует отправки и приема нескольких пакетов, и даже небольшие потери сказываются на его работе очень плохо. Также часто требуется обращение к DNS перед тем, как установить соединение — ещё пара лишних пакетов.

Итого, проблемы типичного REST API, основанного на TCP/IP при плохом соединении:

  • Плохая реакция на потери пакетов (резкое уменьшение скорости, большие таймауты)
  • Установление соединения требует обмена пакетами (+3 пакета)
  • Часто нужен «лишний» DNS-запрос, чтобы узнать IP сервера (+2 пакета)
  • Часто нужен TLS (+2 пакета минимум)

Суммарно это означает, что только для соединения с сервером нам нужно послать 3-7 пакетов, и при высоком проценте потерь соединение может занять существенное количество времени, а мы ещё даже ничего не отправили.

Идея реализации

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

Возможные подводные камни для production-ready реализации

Ниже перечислены (далеко не все) вещи, которые нужно продумать перед тем, как использовать что-либо подобное в «боевых» условиях:

  1. UDP может «резаться» провайдером — нужно уметь работать и по TCP/IP
  2. UDP плохо дружит с NAT — обычно есть мало (~30 сек) времени, чтобы ответить клиенту на его запрос
  3. Сервер должен быть устойчив к атакам усиления — нужно гарантировать, что пакет с ответом будет не больше пакета с запросом
  4. Шифрование — это сложно, и если вы не эксперт по безопасности, у вас мало шансов реализовать его корректно
  5. Если выставить интервал перепосылок неправильно (например, вместо того, чтобы пробовать заново раз в секунду, пробовать заново без остановки), то можно сделать намного хуже, чем TCP/IP
  6. На ваш сервер может начать приходить больше трафика из-за отсутствия обратной связи в UDP и бесконечных повторных попыток отправки
  7. IP-адресов у сервера может быть несколько, и они могут меняться со временем, поэтому кеш нужно уметь обновлять (у Telegram хорошо получается :))

Реализация

Напишем сервер, который будет отдавать ответ по UDP и присылать в ответе номер запроса, который к нему пришел (запрос выглядит как «request-ts текст сообщения»), а также timestamp получения ответа:

// Это Go.
// Обработка ошибок убрана для краткости
buf := make([]byte, maxUDPPacketSize) // Начинаем слушать UDP
addr, _ := net.ResolveUDPAddr("udp", fmt.Sprintf("0.0.0.0:%d", serverPort))
conn, _ := net.ListenUDP("udp", addr) for { // Читаем из UDP, нам обязательно нужен обратный адрес n, uaddr, _ := conn.ReadFromUDP(buf) req := string(buf[0:n]) parts := strings.SplitN(req, " ", 2) // Высчитываем время на сервере по сравнению с временем клиента curTs := time.Now().UnixNano() clientTs, _ := strconv.Atoi(parts[0]) // Тут можно сходить в базу или куда-нибудь ещё и непосредственно сохранить сообщение // Отправляем ответ conn.WriteToUDP([]byte(fmt.Sprintf("%d %d", curTs, clientTs)), uaddr)
}

Мы будем отправлять сообщения по одному и дожидаться ответа сервера перед тем, как послать следующее. Теперь сложная часть — клиент. Слать будем текущий timestamp и кусок текста — timestamp будет служить идентификатором запроса.

// Создаем сокеты
addr, _ := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", serverIP, serverPort))
conn, _ := net.DialUDP("udp", nil, addr) // В UDP запись и чтение будут идти независимо, поэтому используем канал для удобства.
resCh := make(chan udpResult, 10)
go readResponse(conn, resCh) for i := 0; i < numMessages; i++ { requestID := time.Now().UnixNano() send(conn, requestID, resCh)
}

Код функций:

func send(conn *net.UDPConn, requestID int64, resCh chan udpResult) }
} // Ждем свой ответ, или таймаут.
// В сети пакеты могут как теряться, так и дублироваться, поэтому нужно
// проверять, что присланный ответ действительно относится к тому сообщению,
// которое мы посылали.
func waitReply(requestID int64, timeout <-chan time.Time, resCh chan udpResult) (ok bool) { for { select { case res := <-resCh: if res.requestTs == requestID { return true } case <-timeout: return false } }
} // Распарсенный ответ сервера
type udpResult struct { serverTs int64 requestTs int64
} // Функция для чтения ответа из соединения и засовывания ответа в канал.
func readResp(conn *net.UDPConn, resCh chan udpResult) { buf := make([]byte, maxUDPPacketSize) for { n, _, _ := conn.ReadFromUDP(buf) respStr := string(buf[0:n]) parts := strings.SplitN(respStr, " ", 2) var res udpResult res.serverTs, _ = strconv.ParseInt(parts[0], 10, 64) res.requestTs, _ = strconv.ParseInt(parts[1], 10, 64) resCh <- res }
}

Обращение делалось по доменному имени, кеширование DNS в системе не запрещалось. Также я реализовал то же самое на основе (более-менее) стандартного REST: с помощью HTTP POST посылаем те же requestTs и текст сообщения и дожидаемся ответа, после чего переходим к следующему. Таймаут был выставлен в 15 секунд: в TCP/IP уже есть перепосылки потерянных пакетов, а сильно больше 15 секунд пользователь, скорее всего, ждать не станет. HTTPS не использовался, чтобы сравнение было более честным (в прототипе шифрования нет).

Тестирование, результаты

При тестировании прототипа измерялись следующие вещи (всё в миллисекундах):

  1. Время ответа на первый запрос (first)
  2. Среднее время ответа (avg)
  3. Максимальное время ответа (max)
  4. H/U — соотношение «время HTTP» / «время UDP» — во сколько раз меньше задержка при использовании UDP

Делалось 100 серий по 10 запросов — симулируем ситуацию, когда нужно послать буквально несколько сообщений и после этого уже становится доступен нормальный интернет (например Wi-Fi в метро, или 3G/LTE на улице).

Протестированные виды связи:

  1. Профиль «Very Bad Network» (10% потерь, 500 мс latency, 1 мбит/сек) в Network Link Conditioner — «Very Bad»
  2. EDGE, телефон в холодильнике («лифте») — fridge
  3. EDGE
  4. 3G
  5. LTE
  6. Wi-Fi

Результаты (время в миллисекундах):

(то же самое в формате CSV)

Выводы

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

  1. Если не считать аномалию с LTE, то разница при посылке первого сообщения тем больше, чем хуже связь (в среднем в 2-3 раза быстрее)
  2. Последующая отправка сообщений в HTTP не сильно медленней — в среднем в 1,3 раза медленней, а на стабильном Wi-Fi вообще разницы нет
  3. Время ответа на основе UDP намного стабильнее, что косвенно видно по максимальному времени ожидания — оно тоже меньше в 1,4-1,8 раз

Другими словами, в соответствующих («плохих») условиях наш протокол будет работать намного лучше, особенно при посылке первого сообщения (часто это всё, что необходимо отправить).

Реализация прототипа

Прототип выложен на github: github.com/YuriyNasretdinov/instant-im. Не используйте его в продакшене!

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»