*на самом деле мы напишем только прототип протокола.Возможно, вы встречались с подобной ситуацией – сидите в любимом мессенджере, переписываетесь с друзьями, заходите в лифт/тоннель/вагон, и интернет вроде ещё ловит, но отправить ничего не получается? Или иногда ваш провайдер связи неправильно конфигурирует сеть и 50% пакетов пропадает, и тоже ничего не работает. Возможно, вы думали в этот момент — ну ведь можно же наверное как-то сделать, чтобы при плохой связи всё равно можно было отправить тот маленький кусочек текста, который вы хотите? Вы не одни.
Источник картинки
В этой статье я расскажу про свою идею для реализации протокола на основе UDP, который может помочь в этой ситуации.
Проблемы TCP/IP
Когда у нас плохое (мобильное) соединение, то начинает теряться большой процент пакетов (или ходить с очень большой задержкой), и протокол TCP/IP может воспринимать это как сигнал о том, что сеть перегружена, и всё начинает работать оооочень медленно, если работает вообще. Не добавляет радости тот факт, что установление соединения (особенно TLS) требует отправки и приема нескольких пакетов, и даже небольшие потери сказываются на его работе очень плохо. Также часто требуется обращение к DNS перед тем, как установить соединение — ещё пара лишних пакетов.
Итого, проблемы типичного REST API, основанного на TCP/IP при плохом соединении:
- Плохая реакция на потери пакетов (резкое уменьшение скорости, большие таймауты)
- Установление соединения требует обмена пакетами (+3 пакета)
- Часто нужен «лишний» DNS-запрос, чтобы узнать IP сервера (+2 пакета)
- Часто нужен TLS (+2 пакета минимум)
Суммарно это означает, что только для соединения с сервером нам нужно послать 3-7 пакетов, и при высоком проценте потерь соединение может занять существенное количество времени, а мы ещё даже ничего не отправили.
Идея реализации
Идея состоит в следующем: нам требуется всего-лишь отправить один UDP-пакет на заранее зашитый IP-адрес сервера с необходимыми данными авторизации и с текстом сообщения, и получить на него ответ. Все данные можно дополнительно зашифровать (этого в прототипе нет). Если ответ в течение секунды не пришел, то считаем, что запрос потерялся и пробуем отправить его заново. Сервер должен уметь убирать дубли сообщений, поэтому повторная отправка не должна создать проблем.
Возможные подводные камни для production-ready реализации
Ниже перечислены (далеко не все) вещи, которые нужно продумать перед тем, как использовать что-либо подобное в «боевых» условиях:
- UDP может «резаться» провайдером — нужно уметь работать и по TCP/IP
- UDP плохо дружит с NAT — обычно есть мало (~30 сек) времени, чтобы ответить клиенту на его запрос
- Сервер должен быть устойчив к атакам усиления — нужно гарантировать, что пакет с ответом будет не больше пакета с запросом
- Шифрование — это сложно, и если вы не эксперт по безопасности, у вас мало шансов реализовать его корректно
- Если выставить интервал перепосылок неправильно (например, вместо того, чтобы пробовать заново раз в секунду, пробовать заново без остановки), то можно сделать намного хуже, чем TCP/IP
- На ваш сервер может начать приходить больше трафика из-за отсутствия обратной связи в UDP и бесконечных повторных попыток отправки
- 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) { for { // Отправляем пакет до тех пор, пока не получим ответ на своё сообщение. conn.Write([]byte(fmt.Sprintf("%d %s", requestID, testMessageText))) if waitReply(requestID, time.After(time.Second), resCh) { return } } } // Ждем свой ответ, или таймаут. // В сети пакеты могут как теряться, так и дублироваться, поэтому нужно // проверять, что присланный ответ действительно относится к тому сообщению, // которое мы посылали. 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 } }
Также я реализовал то же самое на основе (более-менее) стандартного REST: с помощью HTTP POST посылаем те же requestTs и текст сообщения и дожидаемся ответа, после чего переходим к следующему. Обращение делалось по доменному имени, кеширование DNS в системе не запрещалось. HTTPS не использовался, чтобы сравнение было более честным (в прототипе шифрования нет). Таймаут был выставлен в 15 секунд: в TCP/IP уже есть перепосылки потерянных пакетов, а сильно больше 15 секунд пользователь, скорее всего, ждать не станет.
Тестирование, результаты
При тестировании прототипа измерялись следующие вещи (всё в миллисекундах):
- Время ответа на первый запрос (first)
- Среднее время ответа (avg)
- Максимальное время ответа (max)
- H/U — соотношение «время HTTP» / «время UDP» — во сколько раз меньше задержка при использовании UDP
Делалось 100 серий по 10 запросов — симулируем ситуацию, когда нужно послать буквально несколько сообщений и после этого уже становится доступен нормальный интернет (например Wi-Fi в метро, или 3G/LTE на улице).
Протестированные виды связи:
- Профиль «Very Bad Network» (10% потерь, 500 мс latency, 1 мбит/сек) в Network Link Conditioner — «Very Bad»
- EDGE, телефон в холодильнике («лифте») — fridge
- EDGE
- 3G
- LTE
- Wi-Fi
Результаты (время в миллисекундах):

(то же самое в формате CSV)
Выводы
Вот, какие выводы можно сделать из получившихся результатов:
- Если не считать аномалию с LTE, то разница при посылке первого сообщения тем больше, чем хуже связь (в среднем в 2-3 раза быстрее)
- Последующая отправка сообщений в HTTP не сильно медленней — в среднем в 1,3 раза медленней, а на стабильном Wi-Fi вообще разницы нет
- Время ответа на основе UDP намного стабильнее, что косвенно видно по максимальному времени ожидания — оно тоже меньше в 1,4-1,8 раз
Другими словами, в соответствующих («плохих») условиях наш протокол будет работать намного лучше, особенно при посылке первого сообщения (часто это всё, что необходимо отправить).
Реализация прототипа
Прототип выложен на github. Не используйте его в продакшене!
Команда для запуска клиента на телефоне или компьютере:
. Сервер пока что запущен :). Нужно смотреть в первую очередь на время первого ответа, а также на максимальную задержку. Все эти данные печатаются в конце.instant-im -client -num 10
Пример запуска в лифте



