Ядро OpenResty: cosocket
API7.ai
October 28, 2022
Сегодня мы узнаем о ключевой технологии в OpenResty: cosocket.
Мы уже упоминали его много раз в предыдущих статьях, cosocket является основой различных неблокирующих библиотек lua-resty-*. Без cosocket разработчики не смогут быстро подключаться к внешним веб-сервисам с использованием Lua.
В более ранних версиях OpenResty, если вы хотели взаимодействовать с такими сервисами, как Redis и memcached, вам нужно было использовать C-модули redis2-nginx-module, redis-nginx-module и memc-nginx-module. Эти модули до сих пор доступны в дистрибутиве OpenResty.
Однако с добавлением функции cosocket C-модули были заменены на lua-resty-redis и lua-resty-memcached. Теперь никто не использует C-модули для подключения к внешним сервисам.
Что такое cosocket?
Так что же такое cosocket? cosocket — это специальный термин в OpenResty. Название cosocket состоит из coroutine + socket.
cosocket требует поддержки функции конкурентности Lua и базового механизма событий в NGINX, что вместе позволяет реализовать неблокирующий сетевой ввод-вывод. cosocket также поддерживает TCP, UDP и Unix Domain Socket.
Внутренняя реализация выглядит следующим образом, если мы вызываем функцию, связанную с cosocket в OpenResty.

Я также использовал эту диаграмму в предыдущей статье о принципах и основных концепциях OpenResty. Как видно из диаграммы, для каждой сетевой операции, вызванной скриптом Lua пользователя, будет происходить yield и resume корутины.
При возникновении сетевого ввода-вывода, он регистрирует сетевое событие в списке слушателей NGINX и передает управление (yield) NGINX. Когда событие NGINX достигает условия срабатывания, оно пробуждает корутину для продолжения обработки (resume).
Вышеописанный процесс — это схема, которую OpenResty использует для инкапсуляции операций connect, send, receive и других, которые составляют API cosocket, которые мы видим сегодня. Я буду использовать API для работы с TCP в качестве примера. Интерфейс для управления UDP и Unix Domain sockets такой же, как и у TCP.
Введение в API и команды cosocket
API cosocket, связанные с TCP, можно разделить на следующие категории.
- Создание объектов:
ngx.socket.tcp. - Установка тайм-аута:
tcpsock:settimeoutиtcpsock:settimeouts. - Установление соединения:
tcpsock:connect. - Отправка данных:
tcpsock:send. - Получение данных:
tcpsock:receive,tcpsock:receiveanyиtcpsock:receiveuntil. - Пул соединений:
tcpsock:setkeepalive. - Закрытие соединения:
tcpsock:close.
Мы также должны обратить особое внимание на контексты, в которых можно использовать эти API.
rewrite_by_lua*, access_by_lua*, content_by_lua*, ngx.timer.*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*_
Еще один момент, который я хочу подчеркнуть, это то, что существует множество недоступных сред из-за различных ограничений в ядре NGINX. Например, API cosocket недоступен в set_by_lua*, log_by_lua*, header_filter_by_lua* и body_filter_by_lua*. Он также недоступен в init_by_lua* и init_worker_by_lua* на данный момент, но ядро NGINX не ограничивает эти две фазы, и поддержка для них может быть добавлена позже.
Существует восемь команд NGINX, начинающихся с lua_socket_, связанных с этими API. Давайте кратко рассмотрим их.
lua_socket_connect_timeout: тайм-аут соединения, по умолчанию 60 секунд.lua_socket_send_timeout: тайм-аут отправки, по умолчанию 60 секунд.lua_socket_send_lowat: порог отправки (низкий уровень воды), по умолчанию 0.lua_socket_read_timeout: тайм-аут чтения, по умолчанию 60 секунд.lua_socket_buffer_size: размер буфера для чтения данных, по умолчанию 4k/8k.lua_socket_pool_size: размер пула соединений, по умолчанию 30.lua_socket_keepalive_timeout: время простоя объекта cosocket в пуле соединений, по умолчанию 60 секунд.lua_socket_log_errors: логировать ли ошибки cosocket при их возникновении, по умолчаниюon.
Здесь также можно увидеть, что некоторые команды имеют ту же функциональность, что и API, например, установка тайм-аута и размера пула соединений. Однако, если между ними возникает конфликт, API имеет более высокий приоритет, чем команды, и переопределяет значение, установленное командой. Поэтому, как правило, мы рекомендуем использовать API для настройки, что также более гибко.
Далее давайте рассмотрим конкретный пример, чтобы понять, как использовать эти API cosocket. Функция следующего кода проста: он отправляет TCP-запрос на веб-сайт и выводит возвращенное содержимое:
$ resty -e 'local sock = ngx.socket.tcp() sock:settimeout(1000) -- тайм-аут в одну секунду local ok, err = sock:connect("api7.ai", 80) if not ok then ngx.say("failed to connect: ", err) return end local req_data = "GET / HTTP/1.1\r\nHost: api7.ai\r\n\r\n" local bytes, err = sock:send(req_data) if err then ngx.say("failed to send: ", err) return end local data, err, partial = sock:receive() if err then ngx.say("failed to receive: ", err) return end sock:close() ngx.say("response is: ", data)'
Давайте подробно проанализируем этот код.
- Сначала создается объект TCP cosocket с именем
sockс помощьюngx.socket.tcp(). - Затем с помощью
settimeout()устанавливается тайм-аут в 1 секунду. Обратите внимание, что тайм-аут здесь не различает соединение и получение; это единая настройка. - Далее используется API
connect()для подключения к порту 80 указанного веб-сайта и завершения, если это не удается. - Если соединение успешно, используется
send()для отправки сконструированных данных и завершения, если это не удается. - Если данные успешно отправлены, используется
receive()для получения данных с веб-сайта. Здесь параметр по умолчаниюreceive()—*l, что означает возврат только первой строки данных. Если параметр установлен на*a, он получает данные до закрытия соединения. - Наконец, вызывается
close()для активного закрытия соединения сокета.
Как видите, использование API cosocket для сетевого обмена данными просто и выполняется в несколько шагов. Давайте внесем некоторые изменения, чтобы глубже изучить пример.
1. Установите время тайм-аута для каждого из трех действий: подключение сокета, отправка и чтение.
Мы использовали settimeout() для установки времени тайм-аута на одно значение. Чтобы установить время тайм-аута отдельно для каждого действия, нужно использовать функцию settimeouts(), например, следующую.
sock:settimeouts(1000, 2000, 3000)
Параметры settimeouts указаны в миллисекундах. Эта строка кода указывает тайм-аут соединения в 1 секунду, тайм-аут отправки в 2 секунды и тайм-аут чтения в 3 секунды.
В OpenResty и библиотеках lua-resty большинство параметров API, связанных с временем, указаны в миллисекундах. Но есть исключения, на которые нужно обращать особое внимание при их вызове.
2. Получение содержимого указанного размера.
Как я уже сказал, API receive() может получать одну строку данных или получать данные непрерывно. Однако, если вы хотите получить данные размером 10K, как это настроить?
Для этого предназначен receiveany(). Он разработан для удовлетворения этой потребности, поэтому посмотрите на следующую строку кода.
local data, err, partial = sock:receiveany(10240)
Этот код означает, что будет получено не более 10K данных.
Конечно, еще одно общее требование пользователей к receive() — это продолжать получать данные до тех пор, пока не встретится указанная строка.
receiveuntil() предназначен для решения такого рода проблем. Вместо возврата строки, как receive() и receiveany(), он возвращает итератор. Таким образом, вы можете вызывать его в цикле, чтобы читать совпадающие данные по частям и возвращать nil, когда чтение завершено. Вот пример.
local reader = sock:receiveuntil("\r\n") while true do local data, err, partial = reader(4) if not data then if err then ngx.say("failed to read the data stream: ", err) break end ngx.say("read done") break end ngx.say("read chunk: [", data, "]") end
receiveuntil возвращает данные до \r\n и читает их по четыре байта за раз через итератор.
3. Вместо закрытия сокета напрямую, поместите его в пул соединений.
Как мы знаем, без пула соединений каждый раз при поступлении запроса приходится создавать новое соединение, что приводит к созданию объектов cosocket каждый раз и их частому уничтожению, что приводит к ненужным потерям производительности.
Чтобы избежать этой проблемы, после завершения использования cosocket вы можете вызвать setkeepalive(), чтобы поместить его в пул соединений, как показано ниже.
local ok, err = sock:setkeepalive(2 * 1000, 100) if not ok then ngx.say("failed to set reusable: ", err) end
Этот код устанавливает время простоя соединения на 2 секунды и размер пула соединений на 100, так что при вызове функции connect() объект cosocket будет сначала извлекаться из пула соединений.
Однако при использовании пула соединений нужно учитывать два момента.
- Во-первых, нельзя помещать ошибочное соединение в пул соединений. В противном случае, при следующем использовании оно не сможет отправлять и получать данные. Это одна из причин, почему нам нужно определять, успешен ли каждый вызов API.
- Во-вторых, нужно учитывать количество соединений. Пул соединений является уровнем
Worker, и каждый Worker имеет свой пул соединений. Если у вас 10Workerов и размер пула соединений установлен на30, это 300 соединений для сервиса на бэкенде.
Резюме
Подводя итог, мы изучили основные концепции, связанные команды и API cosocket. Практический пример позволил нам ознакомиться с тем, как использовать API, связанные с TCP. Использование UDP и Unix Domain Socket аналогично использованию TCP. Вы сможете легко справляться со всеми этими вопросами после понимания того, что мы изучили сегодня.
Мы знаем, что cosocket относительно прост в использовании, и мы можем подключаться к различным внешним сервисам, хорошо используя его.
Наконец, мы можем подумать над двумя вопросами.
Первый вопрос: в сегодняшнем примере tcpsock:send отправляет строку; что, если нам нужно отправить таблицу, состоящую из строк?
Второй вопрос: как вы можете видеть, cosocket нельзя использовать на многих этапах, так можете ли вы придумать способы обойти это?
Пожалуйста, не стесняйтесь оставлять комментарии и делиться ими со мной. Приветствуется распространение этой статьи среди ваших коллег и друзей, чтобы мы могли общаться и прогрессировать вместе.