UDP и модуль dgram в Node.js¶
Источник: theNodeBook — UDP & dgram
Поддержка UDP в Node.js живёт в модуле node:dgram. Здесь разбираются границы датаграмм, привязка сокета, send(), событие message, broadcast, multicast, connected UDP и типичные пути ошибок. UDP‑сокет отправляет и принимает датаграммы: каждое сообщение сохраняет свою границу и не сливается в байтовый поток.
UDP и dgram в Node.js¶
Успешная локальная отправка означает, что ядро приняло датаграмму к передаче. Доставка, порядок, дубликаты и обработка на удалённой стороне за пределами этого результата. Протоколы поверх UDP сами решают вопросы потерь, повторов, упорядочивания и таймаутов, если им это нужно.
UDP начинается с границ сообщений. Каждое событие приёма несёт полезную нагрузку одной датаграммы. У TCP чтение из потока следует отдельному контракту байтового потока.
Одна отправка — одна датаграмма. Если ядро доставило её сокету, Node эмитит одно событие message с этой нагрузкой и метаданными отправителя.
1 2 3 4 5 6 7 8 9 | |
Запустите процесс и отправьте одну датаграмму:
1 2 3 4 5 6 7 | |
Приёмник увидит один Buffer с ping, адрес и порт отправителя в rinfo. API ориентирован на сообщения: событие message соответствует одной датаграмме, которую ядро передало сокету.
Вся тема сводится к этой форме «одно сообщение — одно событие».
UDP — транспортный протокол для отдельных датаграмм. Каждая несёт полезную нагрузку приложения и поля заголовка UDP: порты источника и назначения, длина, контрольная сумма. IP переносит датаграмму в пакете, используя адресацию и маршрутизацию из раздела о TCP/IP и сетевом стеке. Модуль node:dgram выставляет датаграммы настолько прямо, что транспортный контракт влияет на дизайн приложения.
TCP даёт Node подключённый байтовый поток. UDP даёт отдельные сообщения. Одна отправка создаёт одну UDP‑нагрузку. Одно чтение доставляет одну UDP‑нагрузку после прохождения фильтрации в ядре. В протоколе нет состояния соединения, упорядочивания, ACK и повторной передачи. Нужные свойства строят поверх UDP или берут готовый протокол.
Эта рамка важна: у UDP свой транспортный контракт, уже на границе сообщения, а не потока.
Семантика датаграмм¶
Граница UDP‑сообщения — край вокруг одной полезной нагрузки. Если процесс отправил 12 байт одним socket.send(), приёмник получит эти 12 байт одним сообщением. Два вызова по 6 байт дадут два сообщения. Прибытие может задержаться, порядок может отличаться от порядка отправки, часть датаграмм может исчезнуть, дубликат может прийти повторно.
Граница сообщения сохраняется API UDP‑сокета. Это главное отличие от TCP, где границы записи и чтения в приложении не совпадают.
1 2 | |
Это две UDP‑датаграммы. Приёмник может увидеть one и two, two и one, только одну из них или ту же нагрузку дважды при дублировании в сети. На loopback обычно приходят обе по порядку — свойство тестового пути, а не контракт UDP.
Потеря пакетов — датаграмма не дошла до приложения‑приёмника: сброс на отправителе, локальный firewall, маршрутизатор, удалённый firewall, переполнение буфера приёма. UDP не восстанавливает пропуск на уровне протокола.
Переупорядочивание — датаграммы приходят не в порядке отправки из‑за маршрутизации, планирования, очередей интерфейсов. В UDP нет поля последовательности для сообщений приложения; переупорядочивание видно только если оно заложено в полезную нагрузку.
Дублирование — приёмник получает больше одной копии. Реже потерь, но часть контракта. Подавления дубликатов на транспорте нет; нужны идентификатор в нагрузке и окно «уже обработано».
Три слова задают тон любому UDP‑сервису: потеря, переупорядочивание, дублирование.
1 2 3 4 5 6 7 | |
Фрагмент лишь иллюстрирует одно решение по дубликатам. Приёмнику нужны данные приложения для детекции; транспорт даёт датаграмму и адрес отправителя.
Заголовок UDP компактен: порты, длина, checksum. Длина покрывает заголовок и payload. Checksum позволяет отбросить повреждённые данные, когда проверка включена. В IPv4 checksum UDP может быть нулевым; в IPv6 для обычного UDP он обязателен. Node не показывает checksum в message: отклонённые датаграммы не доходят до JavaScript.
Невидимый сброс важен при отладке: обработчик видит только то, что ядро приняло для сокета. Захват пакетов может показывать трафик на интерфейсе, а Node молчит из‑за фильтрации, checksum, адреса или давления на буфер приёма.
Поле порта источника тоже локально значимо. Многие протоколы фиксируют порт сервера и берут эфемерный порт клиента. Сервер отвечает на адрес и порт из rinfo. Если клиент отправил одну датаграмму и сразу закрыл сокет, поздний ответ некуда доставить в этом процессе.
1 2 3 4 5 6 | |
Удалённый tuple — метаданные принятой датаграммы, не сессия. Новый порт источника — другой tuple. NAT переписывает порт — в rinfo будет то, что дошло до хоста.
Протоколы часто кладут тип сообщения в начало payload:
1 2 3 4 5 | |
Сначала проверка длины. UDP легко принимает короткие, пустые или чужие по протоколу датаграммы на том же порту. Ядро проверяет транспорт; кадрирование приложения — ваша задача.
Размер — часть границы. Практический размер ограничен MTU пути (см. TCP/IP и сетевой стек). Теоретический максимум может требовать фрагментации IP; потеря фрагмента — потеря всей UDP‑нагрузки с точки зрения приёмника.
Для обычного IPv4 UDP максимум payload — 65 507 байт (65 535 минус 20 байт IPv4 и 8 байт UDP). У IPv6 другая арифметика. Реальные приложения берут гораздо меньше из‑за Ethernet, туннелей, VPN и облачных оверлеев.
Node позволит попробовать большой send; ОС может отклонить:
1 2 3 4 5 6 | |
На многих системах будет EMSGSIZE. Точный код зависит от семейства адресов, платформы, интерфейса и маршрута. Callback send сообщает о локальном завершении или локальной ошибке, не о приёме на удалённой стороне.
Локальные тесты обманывают: loopback терпит крупные датаграммы. Реальный путь может фрагментировать или отбрасывать. При UDP потеря одного фрагмента — потеря всего сообщения.
Многие production‑протоколы держат payload около 1 200 байт для неизвестных Internet‑путей — ориентир протокола, не правило Node. На LAN с известным MTU выбор другой. Правило Node проще: socket.send() принимает байты, путь решает, уместны ли они одной датаграммой.
Поверхность node:dgram¶
node:dgram — встроенный UDP‑модуль Node: создание сокетов, отправка и приём, multicast и небольшой набор опций. API ниже уровня node:http и уже, чем node:net, потому что контракт UDP меньше.
1 2 3 4 | |
Тип задаёт семейство адресов: разбор адресов, wildcard‑bind, multicast, адрес по умолчанию для connected UDP.
dgram.Socket — объект JavaScript для UDP‑сокета. Наследует EventEmitter (message, listening, error, close; см. EventEmitter). Оборачивает нативное состояние: порт, очереди, фильтры, членство в multicast.
dgram.createSocket() принимает строку типа или объект опций:
1 2 3 4 | |
reuseAddr запрашивает повторное использование адреса — важно для multicast и некоторых сценариев перезапуска. Детали — в главе об опциях сокетов; кратко: включайте осознанно, поведение платформы имеет краевые случаи.
В объекте опций также размеры буферов приёма/отправки и ipv6Only для udp6. Значения проходят через политику ОС: clamp, отказ, округление.
1 2 3 4 | |
ipv6Only — граница семейства: IPv6‑сокет без dual‑stack может не принимать IPv4‑mapped адреса. При отладке UDP делайте семейство явным и используйте числовые адреса.
Датаграммный сокет сохраняет границы сообщений при чтении и записи. Один локальный порт может принимать от многих удалённых peers и отправлять на разные адреса. bind() выбирает локальный адрес и порт; send() — удалённый, если нет connected UDP.
Типичный жизненный цикл:
1 2 3 4 5 | |
Нет accept. Один сокет принимает датаграммы от peers, прошедших фильтрацию ядра. Node сообщает peer через rinfo.
1 2 3 | |
rinfo — адрес, порт, семейство, размер. Порт источника часто эфемерный — адрес для ответа в echo‑протоколах.
ref() и unref() как у других handle: активный сокет может удерживать процесс. unref() позволяет выйти, если UDP — единственный активный handle. Удобно для телеметрии и discovery; на серверах легко выйти, пока сокет ещё привязан.
1 2 3 4 | |
Событие close — handle закрыт. Объект в JS остаётся, нативный сокет ушёл; повторные send/bind падают проверками dgram. Новый срок жизни — новый сокет.
Привязка и приём¶
socket.bind() привязывает сокет к локальному порту и, опционально, адресу. Привязанный сокет принимает датаграммы на этот локальный адрес. Это таблица сокетов ядра из сетевого стека, но состояние UDP, не TCP listen.
1 2 3 4 5 6 7 | |
listening — bind завершён; для UDP «слушать» значит готовность к приёму. Очереди accept и TCP‑handshake здесь нет.
Без адреса ОС привязывает wildcard для семейства. Без порта или с 0 — эфемерный порт.
1 2 3 | |
Удобно в тестах: стабильный локальный сокет без жёсткого порта. Порт занят процессом до close().
Bind может упасть: EADDRINUSE, EADDRNOTAVAIL, EACCES. Асинхронные ошибки — событие error; неверные аргументы могут бросить синхронно.
Привязка к конкретному адресу сужает приём: 127.0.0.1 — только loopback; LAN‑адрес — этот интерфейс; 0.0.0.0 — подходящие локальные IPv4.
1 | |
Объектная форма читабельнее с exclusive и т.п. В обычном UDP позиционная и объектная формы ведут к одному bind; в cluster/shared handle — отдельная тема.
Один сокет — много peers без per‑peer setup:
1 2 3 4 5 6 | |
Карта — состояние приложения. Ядро не создаёт сокет на peer. Тишина peer — не close от UDP; срок жизни записи решает код.
rinfo.size — размер в байтах; для парсинга предпочтительнее msg.length.
1 2 3 4 | |
Слушатель error — до bind в примерах и утилитах. Необработанный error может завершить процесс как у любого EventEmitter.
Главное событие приёма:
1 2 3 | |
UDP echo: ответ на адрес и порт из входящей датаграммы. Новая датаграмма, не per‑client сокет.
msg — Buffer одной датаграммы. Текст — decode; бинарный протокол — parse. См. буферы: граница буфера — граница протокола.
1 2 3 4 5 | |
Доверяйте границе одной датаграммы, но проверяйте длину до чтения полей.
socket.close() закрывает сокет; callback — на close. In‑flight датаграммы могут ещё быть в сети или очередях, но закрытый сокет не эмитит новые message.
Парсинг: невалидный UTF‑8, короткий payload, чужой протокол на том же порту. Транспорт не ведёт переговоров о формате.
1 2 3 4 5 6 | |
Без проверки длины readUInt32BE(2) бросит в обработчике приёма.
Для текста после проверки типа и длины:
1 2 3 4 5 | |
subarray() — view на ту же память. Для долгоживущего кэша — копия или распарсенные значения.
Пустые датаграммы допустимы:
1 2 3 | |
Discovery иногда шлёт пустые probe: сигнал в адресе/порте.
Валидация источника — логика приложения. rinfo — откуда пришло по пакету, не идентичность. NAT и spoofing решают доверие.
Один сокет — несколько peers, но обработчик на потоке JS. Медленный парсер задерживает всех. Очередь ядра не знает «важного» peer и дропает при переполнении.
Держите обработчик простым: длина, заголовок, тяжёлое — вне события. Считайте принятые/отклонённые/битые сообщения.
Поле версии окупается быстро:
1 2 3 4 5 | |
UDP не даёт схему; несколько байт заголовка упрощают логи и совместимость при rolling deploy.
Путь через Node и libuv¶
Ниже JavaScript‑объекта — нативный слой.
dgram.createSocket('udp4') создаёт dgram.Socket, EventEmitter, семейство и нативное состояние. В v24: JS → нативный UDP‑binding → uv_udp_t libuv → сокет ОС.
Handle libuv смотрит на event loop. Для UDP — uv_udp_t и uv_udp_send_t на отправку. V8 не принимает пакеты; колбэк в JS после libuv и ядра.
Bind:
1 2 3 4 5 | |
После bind libuv регистрирует готовность к чтению (epoll, kqueue, IOCP — платформа разная, контракт Node один: есть данные → message).
Приём message‑oriented: ядро кладёт датаграммы в очередь с payload и адресом peer. Node копирует в Buffer, эмитит message с rinfo.
Backpressure потоков не действует: dgram.Socket — EventEmitter, не Readable. Долгий CPU в JS останавливает drain колбэков; очередь приёма переполняется — дроп без сигнала отправителю.
getRecvBufferSize() / setRecvBufferSize() — ниже Node, с лимитами ОС (подробнее в опциях сокетов): больший буфер сглаживает всплеск, не делает UDP надёжным.
Отправка:
1 2 3 4 5 6 | |
Имя хоста — DNS (раздел DNS), задержка и ошибки lookup. Числовой адрес — быстрее.
Callback send — локальный запрос завершён; для переиспользуемых буферов не мутируйте payload до callback.
1 2 3 4 5 | |
Ошибки: DNS, семейство, размер, закрытый сокет. Не говорят, что peer обработал сообщение.
Буферизация отправки не как у TCP: нет окна приёма peer и drain. getSendQueueSize() / getSendQueueCount() в v24 — только локальная очередь send в Node/libuv.
Event loop связывает приём (message), завершение send (callback) и ошибки (error, throw). ICMP иногда приходит позже, особенно на connected UDP.
Отправка датаграмм¶
socket.send() — одна UDP‑нагрузка. На неподключённом сокете: сообщение, порт, адрес.
1 2 3 4 5 6 | |
Сообщение: string, Buffer, TypedArray, DataView или массив chunks. Строки — UTF‑8; для лимитов размера удобнее бинарный Buffer.
Срез буфера:
1 2 3 4 5 | |
Отправится payload. offset/length — в байтах.
Не привязанный сокет может отправить: Node неявно bind на wildcard и эфемерный порт.
1 2 3 4 5 6 | |
Удалённый peer увидит этот source port в rinfo. Для request‑response держите сокет открытым до ответа.
1 2 3 4 5 6 7 8 | |
Соединения нет — просто живой локальный порт для ответа.
Имена хостов:
1 2 3 | |
localhost может резолвиться в IPv4/IPv6; udp4 нужен IPv4‑адрес. Для отладки — числовые адреса.
Один локальный endpoint — много удалённых:
1 2 3 | |
Каждый вызов — отдельная датаграмма. Корреляция запрос‑ответ — в payload или состоянии приложения.
Send атомарен на границе UDP API: одна нагрузка или ничего; Node не режет одно сообщение на два message. Фрагментация IP — ниже; при неудачной сборке датаграммы нет.
Массив буферов — один datagram:
1 2 3 4 5 | |
Считайте байты после кодирования:
1 2 3 4 5 | |
Размер send — байты UTF‑8, не число code units.
Callback лучше не пропускать в инструментах и тестах:
1 2 3 | |
«Локально принято» ≠ «доставлено peer».
Быстрая отправка заполняет локальные очереди. getSendQueueCount() / getSendQueueSize() — pacing на стороне runtime.
Неявный bind скрывает локальный порт; для предсказуемых ответов — явный bind() и listening до send.
1 2 3 | |
Connected UDP¶
Connected UDP хранит удалённый адрес и порт в ядре и фильтрует входящие датаграммы с других peers.
1 2 3 4 5 | |
Это не TCP‑handshake из главы о TCP: нет SYN и accept на другой стороне. Ядро запоминает удалённый endpoint; событие connect или callback.
После connect() send() без адреса; remoteAddress() — связанный peer.
1 2 3 | |
Удобно для одного peer: меньше аргументов, чужие датаграммы не доходят до JS (фильтр ядра, не аутентификация).
1 2 3 | |
disconnect() снимает ассоциацию; после этого снова нужны явные target в send():
1 2 | |
Повторный disconnect() на уже отключённом сокете — ошибка Node.
Connected UDP меняет видимость некоторых ошибок. ICMP Port Unreachable — advisory: хост получил трафик на закрытый UDP‑порт. Это не ответ UDP. На connected сокете Linux часто даёт ECONNREFUSED; за firewall — тишина. Callback send может завершиться до ICMP: успех send не означает «порт открыт», только «локальный send request завершён».
Оба send подряд на один peer без повторения адреса:
1 2 3 4 | |
1 2 3 | |
Для смены peer — disconnect() или новый сокет.
Unicast, broadcast и multicast¶
Unicast — один адрес назначения. Broadcast в IPv4 — setBroadcast(true) перед отправкой на broadcast‑адрес.
1 2 3 4 | |
255.255.255.255 — limited broadcast; directed broadcast зависит от префикса сети. Маршрутизаторы часто блокируют broadcast. setBroadcast() — поведение IPv4; у udp6 multicast вместо broadcast.
Приём broadcast — обычный bind:
1 2 3 4 5 6 7 | |
Multicast — группа; приём через addMembership:
1 2 3 4 5 6 7 8 9 10 11 12 | |
239.255.0.1 — administratively scoped IPv4. С интерфейсом:
1 2 3 | |
dropMembership и setMulticastTTL(1) ограничивают охват. setMulticastLoopback(false) — не получать свои multicast на том же хосте.
reuseAddr часто нужен, когда несколько процессов слушают один multicast‑порт (опции сокетов).
Группа и порт bind — разные измерения:
1 2 3 | |
Это приём на порт 9999 для группы 239.255.0.1. Отправитель на 41234 — другой UDP socket address.
Broadcast и multicast — механизмы локальной сети, не Internet. Контейнеры, VPC, Wi‑Fi isolation и VPN меняют доставку. Node выставляет опции; сеть решает reachability.
Формы сбоев¶
Сбои UDP часто бесшумны.
Попытка TCP‑соединения может упасть на установке. Запись в TCP позже может упасть из‑за смены состояния. У UDP нет установки соединения. Неподключённый send на правдоподобный адрес может локально завершиться, пока датаграмма исчезает до любого приёмника.
Первое разделение при отладке — локальный сбой против удалённого отсутствия.
Локальные сбои возникают, пока Node или ОС обрабатывают операцию сокета. Неверные аргументы бросают исключение. Конфликт bind даёт ошибку. DNS может упасть до send. Слишком большая датаграмма — EMSGSIZE. Send после close — ошибка состояния dgram. Эти случаи видны, потому что происходят на локальном пути.
Удалённое отсутствие другое. Процесс слушает не тот порт. Firewall дропает UDP. NAT не держит mapping. Приёмник переполняет буфер. Промежуточное устройство теряет фрагмент. Callback send при этом может сообщить успех: ядро приняло датаграмму к передаче.
Третья категория — локальный успех с удалённой обратной связью ICMP. Port Unreachable говорит, что на UDP‑порт никто не слушает. Сообщение отдельно от исходной датаграммы, может прийти поздно, может отфильтроваться. Connected UDP даёт ядру более ясную ассоциацию сокета — ошибка чаще доходит до Node.
Классификация по месту появления упрощает логи:
1 2 3 4 5 6 7 8 | |
Отсутствие события — данные только после проверки локального пути. До этого тишина может означать bind не на тот адрес, другое семейство, иной DNS‑результат, превышение локального размера или завершённый процесс приёмника.
Минимальный локальный тест:
1 2 3 4 5 | |
Loopback убирает маршрутизацию интерфейса, Wi‑Fi‑политику и удалённый firewall, но проверяет Node, libuv, bind и message. Затем реальный адрес интерфейса или wildcard и второй процесс на том же хосте, затем другой хост в сети.
Два процесса лучше одного «сервер+клиент» в одном event loop: общие переменные, ранний exit и скрытый порядок колбэков маскируют reply path.
Приёмник:
1 2 3 4 5 6 7 | |
Отправитель:
1 2 3 4 5 6 | |
Меняйте по одному параметру: только bind, только target host. Имя хоста смешивает DNS (раздел DNS); 127.0.0.1 и ::1 — разные семейства.
Второе разделение — приём против отправки.
Для приёма: socket.address(), конфликт порта (ss -lunp на Linux), capture на интерфейсе. Логируйте из listening, не предполагайте bind:
1 2 3 4 | |
127.0.0.1 не примет трафик на LAN‑адрес. IPv6‑сервер и IPv4‑клиент — несовместимы без dual‑stack.
Для send: destination, порт, локальный адрес после bind, ошибка callback. Крупные сообщения — сначала один байт:
1 2 3 | |
sent locally — локальный запрос завершён, не обработка на peer.
Для протокола — версия и тип в заголовке:
1 2 3 | |
Переполнение буфера приёма выглядит несправедливо: bind есть, малые тесты проходят, под burst сообщения исчезают. Очередь ядра заполнилась, пока JS был занят. UDP дропает лишнее без события в Node.
1 2 3 | |
Тяжёлую работу — вне message. Больший SO_RCVBUF покупает время, не надёжность.
Давление на GC: объекты и JSON на каждый пакет отстают от сети; счётчики приложения занижают попытки. Сравнивайте счётчики отправителя, приёмника и UDP drop на хосте.
Старт и остановка: send до завершения bind; close освобождает порт, пока отправители ещё шлют. Нет handshake закрытия.
ICMP на unconnected UDP часто не связывается с конкретной операцией; на connected — чаще ECONNREFUSED после Port Unreachable на Linux:
1 2 3 4 5 6 | |
Broadcast требует setBroadcast(true). Multicast — membership и интерфейс, часто reuseAddr. Docker, VM и облако режут broadcast/multicast при корректном коде.
Не трактуйте UDP как поток: несколько датаграмм на одну логическую запись требуют sequence, сборки и обработки дубликатов. Для метрик и discovery допустимы пропуски; для денег, инвентаря и ACL — нужны сильнее семантики, чем голый UDP.
Практический цикл:
1 2 3 4 5 | |
node:dgram — чистая обёртка над датаграммными сокетами. Сложно помнить, как мало доказывает успешный UDP send.
Связанное чтение¶
- Предыдущая: Сокеты и модуль
netв Node.js - Далее: Опции сокетов и backlog в Node.js