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


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

Возможно, вы встречались с подобной ситуацией – сидите в любимом мессенджере, переписываетесь с друзьями, заходите в лифт/тоннель/вагон, и интернет вроде ещё ловит, но отправить ничего не получается? Или иногда ваш провайдер связи неправильно конфигурирует сеть и 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) {
  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 секунд пользователь, скорее всего, ждать не станет.

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


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

  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. Не используйте его в продакшене!

Команда для запуска клиента на телефоне или компьютере:
instant-im -client -num 10
. Сервер пока что запущен :). Нужно смотреть в первую очередь на время первого ответа, а также на максимальную задержку. Все эти данные печатаются в конце.

Пример запуска в лифте





К сожалению, не доступен сервер mySQL