O núcleo do OpenResty: cosocket

API7.ai

October 28, 2022

OpenResty (NGINX + Lua)

Hoje vamos aprender sobre a tecnologia central do OpenResty: o cosocket.

Já mencionamos isso várias vezes nos artigos anteriores, o cosocket é a base de várias bibliotecas lua-resty-* não bloqueantes. Sem o cosocket, os desenvolvedores não podem usar Lua para se conectar rapidamente a serviços web externos.

Nas versões anteriores do OpenResty, se você quisesse interagir com serviços como Redis e memcached, precisava usar os módulos C redis2-nginx-module, redis-nginx-module e memc-nginx-module. Esses módulos ainda estão disponíveis na distribuição do OpenResty.

No entanto, com a adição do recurso cosocket, os módulos C foram substituídos por lua-resty-redis e lua-resty-memcached. Ninguém mais usa módulos C para se conectar a serviços externos.

O que é cosocket?

Então, o que exatamente é o cosocket? Cosocket é um termo próprio no OpenResty. O nome cosocket é composto por coroutine + socket.

O cosocket requer suporte ao recurso de concorrência do Lua e o mecanismo de eventos fundamental no NGINX, que se combinam para permitir I/O de rede não bloqueante. O cosocket também suporta TCP, UDP e Unix Domain Socket.

A implementação interna se parece com o seguinte diagrama se chamarmos uma função relacionada ao cosocket no OpenResty.

chamar função relacionada ao cosocket

Eu também usei este diagrama no artigo anterior sobre princípios e conceitos básicos do OpenResty. Como você pode ver no diagrama, para cada operação de rede acionada pelo script Lua do usuário, ambos terão o yield e o resume da corrotina.

Ao encontrar I/O de rede, ele registra o evento de rede na lista de Ouvintes do NGINX e transfere o controle (yield) para o NGINX. Quando um evento do NGINX atinge a condição de acionamento, ele acorda a corrotina para continuar o processamento (resume).

O processo acima é o plano que o OpenResty usa para encapsular as operações de conexão, envio, recebimento, etc., que compõem as APIs cosocket que vemos hoje. Vou usar a API para lidar com TCP como exemplo. A interface para controlar UDP e Unix Domain sockets é a mesma que a do TCP.

Introdução às APIs e comandos do cosocket

As APIs cosocket relacionadas ao TCP podem ser divididas nas seguintes categorias.

  • Criar objetos: ngx.socket.tcp.
  • Definir tempo limite: tcpsock:settimeout e tcpsock:settimeouts.
  • Estabelecer conexão: tcpsock:connect.
  • Enviar dados: tcpsock:send.
  • Receber dados: tcpsock:receive, tcpsock:receiveany e tcpsock:receiveuntil.
  • Pool de conexões: tcpsock:setkeepalive.
  • Fechar a conexão: tcpsock:close.

Também devemos prestar atenção especial aos contextos em que essas APIs podem ser usadas.

rewrite_by_lua*, access_by_lua*, content_by_lua*, ngx.timer.*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*_

Outro ponto que também quero enfatizar é que existem muitos ambientes indisponíveis devido a várias limitações no kernel do NGINX. Por exemplo, a API cosocket não está disponível em set_by_lua*, log_by_lua*, header_filter_by_lua* e body_filter_by_lua*. Não está disponível em init_by_lua* e init_worker_by_lua* por enquanto, mas o kernel do NGINX não restringe essas duas fases, o suporte para as quais pode ser adicionado posteriormente.

Existem oito comandos do NGINX começando com lua_socket_ relacionados a essas APIs. Vamos dar uma olhada rapidamente.

  • lua_socket_connect_timeout: tempo limite de conexão, padrão 60 segundos.
  • lua_socket_send_timeout: tempo limite de envio, padrão 60 segundos.
  • lua_socket_send_lowat: limite de envio (baixa água), padrão é 0.
  • lua_socket_read_timeout: tempo limite de leitura, padrão 60 segundos.
  • lua_socket_buffer_size: tamanho do buffer para leitura de dados, padrão 4k/8k.
  • lua_socket_pool_size: tamanho do pool de conexões, padrão 30.
  • lua_socket_keepalive_timeout: tempo ocioso do objeto cosocket do pool de conexões, padrão 60 segundos.
  • lua_socket_log_errors: se deve registrar erros do cosocket quando ocorrem, padrão é on.

Aqui você também pode ver que alguns comandos têm a mesma funcionalidade que a API, como definir o tempo limite e o tamanho do pool de conexões. No entanto, se houver um conflito entre os dois, a API tem prioridade sobre os comandos e substituirá o valor definido pela ordem. Então, de modo geral, recomendamos usar as APIs para fazer as configurações, o que também é mais flexível.

A seguir, vamos ver um exemplo concreto para entender como usar essas APIs cosocket. A função do seguinte código é simples, que envia uma solicitação TCP para um site e imprime o conteúdo retornado:

$ resty -e 'local sock = ngx.socket.tcp()
sock:settimeout(1000) -- tempo limite de um segundo
local ok, err = sock:connect("api7.ai", 80)
if not ok then
    ngx.say("falha ao conectar: ", 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("falha ao enviar: ", err)
    return
end
local data, err, partial = sock:receive()
if err then
    ngx.say("falha ao receber: ", err)
    return
end
sock:close()
ngx.say("resposta é: ", data)'

Vamos analisar este código em detalhes.

  • Primeiro, crie um objeto TCP cosocket com o nome sock usando ngx.socket.tcp().
  • Em seguida, use settimeout() para definir o tempo limite para 1 segundo. Observe que o tempo limite aqui não diferencia entre conexão e recebimento; é uma configuração uniforme.
  • Em seguida, use a API connect() para se conectar à porta 80 do site especificado e saia se falhar.
  • Se a conexão for bem-sucedida, use send() para enviar os dados construídos e saia se falhar.
  • Se o envio de dados for bem-sucedido, use receive() para receber os dados do site. Aqui, o parâmetro padrão de receive() é *l, o que significa que apenas a primeira linha de dados é retornada. Se o parâmetro for definido como *a, ele recebe dados até que a conexão seja fechada.
  • Finalmente, chame close() para fechar a conexão do socket ativamente.

Como você pode ver, usar as APIs cosocket para fazer comunicação de rede é simples em apenas algumas etapas. Vamos fazer alguns ajustes para explorar o exemplo mais profundamente.

1. Defina o tempo limite para cada uma das três ações: conexão do socket, envio e leitura.

O settimeout() que usamos para definir o tempo limite para um único valor. Para definir o tempo limite separadamente, você precisa usar a função settimeouts(), como a seguir.

sock:settimeouts(1000, 2000, 3000)

Os parâmetros de settimeouts estão em milissegundos. Esta linha de código indica um tempo limite de conexão de 1 segundo, um tempo limite de envio de 2 segundos e um tempo limite de leitura de 3 segundos.

No OpenResty e nas bibliotecas lua-resty, a maioria dos parâmetros das APIs relacionadas ao tempo estão em milissegundos. Mas há exceções que você precisa prestar atenção especial ao chamá-las.

2. Recebe o conteúdo do tamanho especificado.

Como eu acabei de dizer, a API receive() pode receber uma linha de dados ou receber dados continuamente. No entanto, se você quiser receber apenas dados de 10K de tamanho, como deve configurar?

É aí que entra o receiveany(). Ele foi projetado para atender a essa necessidade, então veja a seguinte linha de código.

local data, err, partial = sock:receiveany(10240)

Este código significa que apenas até 10K de dados serão recebidos.

Claro, outro requisito comum do usuário para receive() é continuar buscando dados até encontrar a string especificada.

O receiveuntil() foi projetado para resolver esse tipo de problema. Em vez de retornar uma string como receive() e receiveany(), ele retornará um iterador. Dessa forma, você pode chamá-lo em um loop para ler os dados correspondentes em segmentos e retornar nil quando a leitura estiver concluída. Aqui está um exemplo.

local reader = sock:receiveuntil("\r\n")
     while true do
         local data, err, partial = reader(4)
         if not data then
             if err then
                 ngx.say("falha ao ler o fluxo de dados: ", err)
                 break
             end
             ngx.say("leitura concluída")
             break
         end
         ngx.say("leitura do bloco: [", data, "]")
     end

O receiveuntil retorna os dados antes de \r\n e lê quatro bytes deles de cada vez através do iterador.

3. Em vez de fechar o socket diretamente, coloque-o no pool de conexões.

Como sabemos, sem o pool de conexões, uma nova conexão precisa ser criada, fazendo com que objetos cosocket sejam criados cada vez que uma solicitação chega e sejam frequentemente destruídos, resultando em perda de desempenho desnecessária.

Para evitar esse problema, após terminar de usar um cosocket, você pode chamar setkeepalive() para colocá-lo no pool de conexões, como a seguir.

local ok, err = sock:setkeepalive(2 * 1000, 100)
if not ok then
    ngx.say("falha ao definir reutilizável: ", err)
end

Este código define o tempo ocioso da conexão para 2 segundos e o tamanho do pool de conexões para 100, de modo que quando a função connect() for chamada, o objeto cosocket será buscado primeiro no pool de conexões.

No entanto, há duas coisas que precisamos estar cientes ao usar o pool de conexões.

  • Primeiro, você não pode colocar uma conexão com erro no pool de conexões. Caso contrário, na próxima vez que você usá-la, ela falhará ao enviar e receber dados. É uma das razões pelas quais precisamos determinar se cada chamada de API foi bem-sucedida ou não.
  • Segundo, precisamos descobrir o número de conexões. O pool de conexões é de nível Worker, e cada Worker tem seu próprio pool de conexões. Se você tiver 10 Workers e o tamanho do pool de conexões for definido como 30, isso significa 300 conexões para o serviço de back-end.

Resumo

Para resumir, aprendemos os conceitos básicos, os comandos relacionados e as APIs do cosocket. Um exemplo prático nos familiarizou com como usar as APIs relacionadas ao TCP. O uso de UDP e Unix Domain Socket é semelhante ao do TCP. Você pode facilmente lidar com todas essas questões após entender o que aprendemos hoje.

Sabemos que o cosocket é relativamente fácil de usar, e podemos nos conectar a vários serviços externos usando-o bem.

Finalmente, podemos pensar em duas questões.

A primeira questão, no exemplo de hoje, tcpsock:send envia uma string; e se precisarmos enviar uma tabela composta por strings?

A segunda questão, como você pode ver, o cosocket não pode ser usado em muitos estágios, então você pode pensar em algumas maneiras de contornar isso?

Sinta-se à vontade para deixar um comentário e compartilhá-lo comigo. Bem-vindo a compartilhar este artigo com seus colegas e amigos para que possamos nos comunicar e progredir juntos.