Parte 3: Como Construir um API Gateway de Microservices Usando OpenResty

API7.ai

February 3, 2023

OpenResty (NGINX + Lua)

Neste artigo, a construção do gateway de API de microsserviços chega ao fim. Vamos usar um exemplo mínimo para juntar os componentes selecionados anteriormente e executá-los de acordo com o projeto desenhado!

Configuração e inicialização do NGINX

Sabemos que o gateway de API é usado para lidar com a entrada de tráfego, então primeiro precisamos fazer uma configuração simples no nginx.conf para que todo o tráfego seja tratado pelo código Lua do gateway.

server {
    listen 9080;

    init_worker_by_lua_block {
        apisix.http_init_worker()
    }

    location / {
        access_by_lua_block {
            apisix.http_access_phase()
        }
        header_filter_by_lua_block {
            apisix.http_header_filter_phase()
        }
        body_filter_by_lua_block {
            apisix.http_body_filter_phase()
        }
        log_by_lua_block {
            apisix.http_log_phase()
        }
    }
}

Aqui usamos o gateway de API de código aberto Apache APISIX como exemplo, então o exemplo de código acima contém a palavra-chave apisix. Neste exemplo, escutamos a porta 9080 e interceptamos todas as solicitações para essa porta por location /, processando-as através das fases access, rewrite, header filter, body filter e log, chamando as funções correspondentes dos plugins em cada fase. A fase rewrite é combinada na função apisix.http_access_phase.

A inicialização do sistema é tratada na fase init_worker, que inclui a leitura dos parâmetros de configuração, a pré-definição do diretório no etcd, a obtenção da lista de plugins do etcd e a ordenação dos plugins por prioridade, etc. Liste e expliquei as partes principais do código aqui, e você pode ver uma função de inicialização mais completa no GitHub.

function _M.http_init_worker()
    -- Inicialização de Roteamento, Serviços e Plugins - as três partes mais importantes
    router.init_worker()
    require("apisix.http.service").init_worker()
    require("apisix.plugins.ext-plugin.init").init_worker()
end

Como você pode ver neste código, a inicialização das partes do roteador e do plugin é um pouco mais complicada, principalmente envolvendo a leitura dos parâmetros de configuração e a tomada de algumas decisões dependendo deles. Como isso envolve a leitura de dados do etcd, usamos ngx.timer para contornar a restrição de "não poder usar cosocket na fase init_worker". Se você estiver interessado nesta parte, recomendamos a leitura do código-fonte para entender melhor.

Correspondência de Rotas

No início da fase access, primeiro precisamos corresponder a rota com base no uri, host, args, cookies, etc., carregados pela solicitação, às regras de roteamento que foram configuradas.

router.router_http.match(api_ctx)

A única linha de código exposta ao público é a acima, onde o api_ctx armazena as informações de uri, host, args e cookie da solicitação. A implementação específica da função de correspondência usa o lua-resty-radixtree que mencionamos anteriormente. Se nenhuma rota for correspondida, a solicitação não terá um upstream correspondente e retornará 404.

local router = require("resty.radixtree")

local match_opts = {}

function _M.match(api_ctx)
    -- Obtém os parâmetros da solicitação do ctx e os usa como condição de julgamento para a rota
    match_opts.method = api_ctx.var.method
    match_opts.host = api_ctx.var.host
    match_opts.remote_addr = api_ctx.var.remote_addr
    match_opts.vars = api_ctx.var
    -- Chama a função de julgamento da rota
    local ok = uri_router:dispatch(api_ctx.var.uri, match_opts, api_ctx)
    -- Se nenhuma rota for correspondida, retorna 404
    if not ok then
        core.log.info("not find any matched route")
        return core.response.exit(404)
    end

    return true
end

Carregamento de Plugins

Claro, se a rota for atingida, passamos para a etapa de filtragem e carregamento do plugin, que é o núcleo do gateway de API. Vamos começar com o seguinte código.

local plugins = core.tablepool.fetch("plugins", 32, 0)
-- A lista de plugins no etcd e a lista de plugins no arquivo de configuração local são intersectadas
api_ctx.plugins = plugin.filter(route, plugins)

-- Executa as funções montadas pelo plugin nas fases rewrite e access em sequência
run_plugin("rewrite", plugins, api_ctx)
run_plugin("access", plugins, api_ctx)

Neste código, primeiro solicitamos uma tabela de comprimento 32 através do pool de tabelas, que é uma técnica de otimização de desempenho que introduzimos anteriormente. Em seguida, vem a função de filtro do plugin. Você pode se perguntar por que essa etapa é necessária. Na fase init worker do plugin, não obtivemos a lista de plugins do etcd e os ordenamos?

A filtragem aqui é feita em comparação com a configuração local pelos seguintes dois motivos:

  1. Primeiro, um plugin recém-desenvolvido precisa ser lançado em canário. Nesse momento, o novo plugin existe na lista do etcd, mas está apenas no estado aberto em alguns dos nós do gateway. Portanto, precisamos fazer uma operação de interseção adicional.
  2. Para suportar o modo de depuração. Quais plugins são processados pela solicitação do cliente? Qual é a ordem de carregamento desses plugins? Essas informações serão úteis ao depurar, então a função de filtro também determinará se está no modo de depuração e registrará essas informações no cabeçalho da resposta.

Então, no final da fase access, pegamos esses plugins filtrados e os executamos um por um em ordem de prioridade, como mostrado no código a seguir.

local function run_plugin(phase, plugins, api_ctx)
    for i = 1, #plugins, 2 do
        local phase_fun = plugins[i][phase]
        if phase_fun then
            -- O código de chamada principal
            phase_fun(plugins[i + 1], api_ctx)
        end
    end

    return api_ctx
end

Ao iterar pelos plugins, você pode ver que fazemos isso em intervalos de 2. Isso ocorre porque cada plugin terá dois componentes: o objeto do plugin e os parâmetros de configuração do plugin. Agora, vamos olhar para a linha de código principal no exemplo de código acima.

phase_fun(plugins[i + 1], api_ctx)

Se essa linha de código for um pouco abstrata, vamos substituí-la por um plugin limit_count concreto, o que ficará muito mais claro.

limit_count_plugin_rewrite_function(conf_of_plugin, api_ctx)

Neste ponto, estamos praticamente concluídos com o fluxo geral do gateway de API. Todo esse código está no mesmo arquivo, que contém mais de 400 linhas de código, mas o núcleo do código são as poucas dezenas de linhas que descrevemos acima.

Escrita de Plugins

Agora, há uma coisa a fazer antes que uma demonstração completa possa ser executada, e isso é escrever um plugin. Vamos pegar o plugin limit-count como exemplo. A implementação completa tem pouco mais de 60 linhas de código, que você pode ver clicando no link. Aqui, vou explicar as linhas de código-chave em detalhes:

Primeiro, vamos introduzir lua-resty-limit-traffic como a biblioteca base para limitar o número de solicitações.

local limit_count_new = require("resty.limit.count").new

Em seguida, usando o json schema em rapidjson para definir quais são os parâmetros deste plugin:

local schema = {
    type = "object",
    properties = {
        count = {type = "integer", minimum = 0},
        time_window = {type = "integer", minimum = 0},
        key = {type = "string",
        enum = {"remote_addr", "server_addr"},
        },
        rejected_code = {type = "integer", minimum = 200, maximum = 600},
    },
    additionalProperties = false,
    required = {"count", "time_window", "key", "rejected_code"},
}

Esses parâmetros do plugin correspondem à maioria dos parâmetros do resty.limit.count, que contêm a chave do limite, o tamanho da janela de tempo e o número de solicitações a serem limitadas. Além disso, o plugin adiciona um parâmetro: rejected_code, que retorna o código de status especificado quando a solicitação é limitada.

Na etapa final, montamos a função de manipulação do plugin na fase rewrite:

function _M.rewrite(conf, ctx)
    -- Obtém o objeto de limite de contagem do cache, se não, usa a função `create_limit_obj` para criar um novo objeto e armazená-lo em cache
    local lim, err = core.lrucache.plugin_ctx(plugin_name, ctx,  create_limit_obj, conf)

    -- Obtém o valor da chave de `ctx.var` e compõe uma nova chave junto com o tipo de configuração e o número da versão da configuração
    local key = (ctx.var[conf.key] or "") .. ctx.conf_type .. ctx.conf_version

    -- Função para determinar se o limite é atingido
    local delay, remaining = lim:incoming(key, true)
    if not delay then
        local err = remaining
        -- Se o valor limite for excedido, retorna o código de status especificado
        if err == "rejected" then
            return conf.rejected_code
        end

        core.log.error("failed to limit req: ", err)
        return 500
    end

    -- Se o limite não for excedido, libera e define o cabeçalho de resposta correspondente
    core.response.set_header("X-RateLimit-Limit", conf.count,
                             "X-RateLimit-Remaining", remaining)
end

Há apenas uma linha de lógica no código acima que faz a determinação do limite, o resto está aqui para fazer o trabalho de preparação e definir os cabeçalhos de resposta. Se o limite não for excedido, ele continuará a executar o próximo plugin de acordo com a prioridade.

Resumo

Finalmente, vou deixar uma pergunta para reflexão. Sabemos que os gateways de API podem lidar não apenas com tráfego de camada 7, mas também com tráfego de camada 4. Com base nisso, você consegue pensar em alguns cenários de uso para ele? Bem-vindo a deixar seus comentários e compartilhar este artigo para aprender e se comunicar com mais pessoas.

Anterior: Parte 1: Como construir um gateway de API de microsserviços usando OpenResty Parte 2: Como construir um gateway de API de microsserviços usando OpenResty