强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

OpenResty 高性能网关开发教程 / 第 05 章 - 路由与动态路由

第 05 章 - 路由与动态路由

5.1 路由概述

路由是 API 网关的核心功能之一,负责将客户端请求分发到正确的后端服务。

路由匹配策略

策略说明适用场景
精确匹配URI 完全一致单个 API 端点
前缀匹配URI 以指定路径开头服务分组
正则匹配使用正则表达式复杂 URL 模式
主机匹配根据 Host 头分发多租户系统
方法匹配根据 HTTP 方法分发RESTful API
参数匹配根据查询参数分发版本路由

5.2 Nginx 原生路由

5.2.1 location 匹配规则

server {
    listen 8080;

    # 精确匹配(优先级最高)
    location = /api/health {
        content_by_lua_block { ngx.say("OK") }
    }

    # 前缀匹配(停止搜索正则)
    location ^~ /api/v1/ {
        proxy_pass http://v1_backend;
    }

    # 正则匹配(按顺序匹配,第一个匹配的生效)
    location ~ ^/api/users/(\d+)$ {
        set $user_id $1;
        proxy_pass http://user_backend;
    }

    # 正则匹配(忽略大小写)
    location ~* \.(jpg|png|gif)$ {
        root /var/www/static;
    }

    # 普通前缀匹配
    location /api/ {
        proxy_pass http://default_backend;
    }

    # 默认匹配
    location / {
        content_by_lua_block { ngx.say("Welcome") }
    }
}

匹配优先级

1. =    精确匹配      (最高)
2. ^~  前缀匹配      (停止正则搜索)
3. ~   正则匹配      (按配置顺序)
4. ~*  正则匹配(忽略大小写)
5. /   普通前缀匹配   (最长前缀)
6. /   默认          (最低)

5.3 Lua 动态路由引擎

当 Nginx 原生路由不够灵活时,使用 Lua 实现动态路由。

5.3.1 路由表结构

-- /usr/local/openresty/lua/router.lua
local _M = {}
local cjson = require "cjson"

-- 路由表
local routes = {
    -- 精确匹配
    exact = {
        ["/api/health"]   = {upstream = "health_service",  strip_prefix = false},
        ["/api/version"]  = {upstream = "version_service", strip_prefix = false},
    },

    -- 前缀匹配
    prefix = {
        ["/api/users"]    = {upstream = "user_service",  strip_prefix = false},
        ["/api/orders"]   = {upstream = "order_service", strip_prefix = false},
        ["/api/products"] = {upstream = "product_service", strip_prefix = false},
        ["/api/payments"] = {upstream = "payment_service", strip_prefix = false},
    },

    -- 正则匹配
    pattern = {
        {pattern = "^/api/v(%d+)/(.+)$",  upstream = "versioned", strip_prefix = false},
        {pattern = "^/user/(%d+)/profile$", upstream = "user_service", strip_prefix = false},
    },
}

-- 匹配函数
function _M.match(uri)
    -- 1. 精确匹配(O(1) 哈希查找)
    local route = routes.exact[uri]
    if route then
        return route, uri
    end

    -- 2. 前缀匹配
    for prefix, r in pairs(routes.prefix) do
        if uri:sub(1, #prefix) == prefix then
            local remainder = uri:sub(#prefix + 1)
            return r, remainder
        end
    end

    -- 3. 正则匹配
    for _, r in ipairs(routes.pattern) do
        local captures = {uri:match(r.pattern)}
        if #captures > 0 then
            return r, captures
        end
    end

    return nil, nil
end

return _M

5.3.2 在 Nginx 中使用

server {
    listen 8080;

    # 定义后端上游
    upstream user_service {
        server 127.0.0.1:8081;
        server 127.0.0.1:8082;
    }

    upstream order_service {
        server 127.0.0.1:8083;
    }

    upstream product_service {
        server 127.0.0.1:8084;
    }

    location /api/ {
        content_by_lua_file /usr/local/openresty/lua/route_handler.lua;
    }
}
-- /usr/local/openresty/lua/route_handler.lua
local router = require "router"
local http = require "resty.http"
local cjson = require "cjson"

local uri = ngx.var.uri
local route, remainder = router.match(uri)

if not route then
    ngx.status = 404
    ngx.say(cjson.encode({
        error = "Not Found",
        message = "No route matched: " .. uri,
        available_routes = {"/api/users", "/api/orders", "/api/products"},
    }))
    return
end

-- 获取上游地址
local upstream = route.upstream

-- 转发请求
local httpc = http.new()
httpc:set_timeout(5000)

local upstream_url = "http://" .. upstream .. uri

local res, err = httpc:request_uri(upstream_url, {
    method = ngx.req.get_method(),
    body = ngx.req.get_body_data(),
    headers = ngx.req.get_headers(),
})

if not res then
    ngx.status = 502
    ngx.say(cjson.encode({error = "Bad Gateway", message = err}))
    return
end

ngx.status = res.status
ngx.say(res.body)

5.4 高性能前缀树路由

前缀树(Trie)是高性能路由的数据结构,支持 O(k) 时间复杂度的匹配(k 为 URI 长度)。

-- /usr/local/openresty/lua/trie_router.lua
local _M = {}

function _M.new()
    return {
        root = {children = {}, handler = nil, params = {}}
    }
end

-- 注册路由
function _M:add(method, path, handler)
    local node = self.root
    local segments = {}

    for segment in path:gmatch("[^/]+") do
        -- 参数段 :param
        if segment:sub(1, 1) == ":" then
            table.insert(segments, {name = segment:sub(2), is_param = true})
            segment = ":"
        else
            table.insert(segments, {name = segment, is_param = false})
        end

        if not node.children[segment] then
            node.children[segment] = {children = {}, handler = nil, params = {}}
        end
        node = node.children[segment]
    end

    node.handler = handler
    node.segments = segments
end

-- 匹配路由
function _M:match(method, path)
    local node = self.root
    local params = {}
    local segments = {}

    for segment in path:gmatch("[^/]+") do
        table.insert(segments, segment)
    end

    for i, seg in ipairs(segments) do
        -- 先尝试精确匹配
        if node.children[seg] then
            node = node.children[seg]
        -- 再尝试参数匹配
        elseif node.children[":"] then
            node = node.children[":"]
            if node.segments and node.segments[i] then
                params[node.segments[i].name] = seg
            end
        else
            return nil
        end
    end

    if node.handler then
        return node.handler, params
    end
    return nil
end

return _M

使用示例

-- /usr/local/openresty/lua/route_handler_trie.lua
local trie = require "trie_router"

local router = trie.new()

-- 注册路由
router:add("GET", "/api/users", function(params)
    ngx.say("List users")
end)

router:add("GET", "/api/users/:id", function(params)
    ngx.say("Get user: " .. params.id)
end)

router:add("GET", "/api/users/:id/orders", function(params)
    ngx.say("Get orders for user: " .. params.id)
end)

router:add("POST", "/api/users", function(params)
    ngx.req.read_body()
    ngx.say("Create user: " .. (ngx.req.get_body_data() or ""))
end)

-- 执行路由匹配
local method = ngx.req.get_method()
local uri = ngx.var.uri

local handler, params = router:match(method, uri)
if handler then
    handler(params)
else
    ngx.status = 404
    ngx.say('{"error":"Not Found"}')
end

5.5 版本路由

5.5.1 URL 路径版本

-- /usr/local/openresty/lua/version_router.lua
local _M = {}

local upstreams = {
    v1 = "http://api-v1-service:8080",
    v2 = "http://api-v2-service:8080",
    v3 = "http://api-v3-service:8080",
}

-- 默认版本
local default_version = "v2"

-- 版本提取
function _M.extract_version()
    local uri = ngx.var.uri

    -- 方式 1:路径版本 /api/v1/users
    local version = uri:match("^/api/(v%d+)/")
    if version then
        return version, uri:gsub("^/api/v%d+", "")
    end

    -- 方式 2:查询参数 ?api_version=v1
    local args = ngx.req.get_uri_args()
    version = args.api_version or args.v
    if version and version:match("^v%d+$") then
        return version, uri
    end

    -- 方式 3:请求头 X-API-Version
    version = ngx.var.http_x_api_version
    if version and version:match("^v%d+$") then
        return version, uri
    end

    -- 方式 4:Accept 头版本
    local accept = ngx.var.http_accept or ""
    version = accept:match("application/vnd%.api%.(v%d+)%+json")
    if version then
        return version, uri
    end

    return default_version, uri
end

-- 获取上游地址
function _M.get_upstream(version)
    return upstreams[version] or upstreams[default_version]
end

return _M

5.5.2 版本路由 nginx 配置

server {
    listen 8080;

    location /api/ {
        set $api_version "";
        set $backend_uri "";

        access_by_lua_block {
            local vr = require "version_router"
            local version, stripped_uri = vr.extract_version()

            ngx.var.api_version = version
            ngx.var.backend_uri = stripped_uri

            -- 记录版本信息
            ngx.log(ngx.INFO, "API version: ", version, ", URI: ", stripped_uri)
        }

        proxy_pass http://api-$api_version-service:8080$backend_uri;
        proxy_set_header X-API-Version $api_version;
        proxy_set_header X-Original-URI $request_uri;
    }
}

5.6 灰度发布(Canary Release)

灰度发布允许将部分流量导向新版本,逐步验证新版本的稳定性。

5.6.1 基于用户 ID 的灰度

-- /usr/local/openresty/lua/canary.lua
local _M = {}

-- 灰度配置(可从配置中心动态加载)
local canary_config = {
    enabled = true,
    strategy = "user_id",   -- 灰度策略
    percentage = 10,        -- 10% 流量到灰度版本
    whitelist = {           -- 强制灰度的用户
        "user_001", "user_002", "user_003",
    },
    -- 基于 header 的灰度
    header_name = "X-Canary",
    header_value = "true",
}

-- 一致性哈希(确保同一用户始终路由到同一版本)
local function stable_hash(key)
    local hash = 5381
    for i = 1, #key do
        hash = ((hash * 33) + string.byte(key, i)) % 2147483647
    end
    return hash
end

function _M.should_canary()
    if not canary_config.enabled then
        return false
    end

    -- 策略 1:Header 灰度
    local canary_header = ngx.req.get_headers()[canary_config.header_name]
    if canary_header == canary_config.header_value then
        return true
    end

    -- 策略 2:白名单用户
    local user_id = ngx.var.http_x_user_id or ngx.var.cookie_user_id
    if user_id then
        for _, uid in ipairs(canary_config.whitelist) do
            if uid == user_id then
                return true
            end
        end
    end

    -- 策略 3:百分比灰度(基于用户 ID 的一致性哈希)
    if user_id then
        local hash = stable_hash(user_id)
        if (hash % 100) < canary_config.percentage then
            return true
        end
    else
        -- 无用户 ID 时使用 IP
        local client_ip = ngx.var.remote_addr
        local hash = stable_hash(client_ip)
        if (hash % 100) < canary_config.percentage then
            return true
        end
    end

    return false
end

function _M.get_upstream()
    if _M.should_canary() then
        return "canary_backend"
    end
    return "stable_backend"
end

return _M

5.6.2 灰度 nginx 配置

http {
    upstream stable_backend {
        server 10.0.1.1:8080;
        server 10.0.1.2:8080;
    }

    upstream canary_backend {
        server 10.0.2.1:8080;  -- 新版本
    }

    server {
        listen 8080;

        location /api/ {
            set $target_backend "";

            access_by_lua_block {
                local canary = require "canary"
                ngx.var.target_backend = canary.get_upstream()
            }

            proxy_pass http://$target_backend;
            proxy_set_header X-Backend $target_backend;
        }

        # 灰度管理接口
        location /admin/canary {
            content_by_lua_block {
                local cjson = require "cjson"
                ngx.req.read_body()
                local method = ngx.req.get_method()

                if method == "POST" then
                    local body = cjson.decode(ngx.req.get_body_data())
                    -- 更新灰度配置
                    local shared = ngx.shared.gateway_config
                    shared:set("canary_enabled", body.enabled or false)
                    shared:set("canary_percentage", body.percentage or 0)
                    ngx.say(cjson.encode({status = "updated"}))
                elseif method == "GET" then
                    local shared = ngx.shared.gateway_config
                    ngx.say(cjson.encode({
                        enabled = shared:get("canary_enabled"),
                        percentage = shared:get("canary_percentage"),
                    }))
                end
            }
        }
    }
}

5.7 A/B 测试路由

A/B 测试与灰度发布类似,但关注的是功能差异而非版本差异。

-- /usr/local/openresty/lua/ab_testing.lua
local _M = {}

-- A/B 测试实验配置
local experiments = {
    {
        name = "new_search_algorithm",
        -- 流量分配
        variants = {
            {name = "control",  weight = 50, upstream = "search_v1"},
            {name = "variant_a", weight = 30, upstream = "search_v2"},
            {name = "variant_b", weight = 20, upstream = "search_v3"},
        },
        -- 参与条件
        conditions = {
            path = "^/api/search",
            method = "GET",
        },
    },
    {
        name = "checkout_flow",
        variants = {
            {name = "control", weight = 50, upstream = "checkout_v1"},
            {name = "new_flow", weight = 50, upstream = "checkout_v2"},
        },
        conditions = {
            path = "^/api/checkout",
        },
    },
}

-- 基于权重的随机选择
local function weighted_select(variants, seed)
    local hash = 0
    for i = 1, #seed do
        hash = (hash * 31 + string.byte(seed, i)) % 10000
    end

    local point = hash % 100
    local cumulative = 0

    for _, variant in ipairs(variants) do
        cumulative = cumulative + variant.weight
        if point < cumulative then
            return variant
        end
    end

    return variants[#variants]
end

function _M.route()
    local uri = ngx.var.uri
    local method = ngx.req.get_method()
    local user_id = ngx.var.http_x_user_id or ngx.var.remote_addr

    for _, exp in ipairs(experiments) do
        -- 检查是否参与实验
        local path_match = uri:match(exp.conditions.path)
        local method_match = (not exp.conditions.method) or (method == exp.conditions.method)

        if path_match and method_match then
            -- 选择变体
            local variant = weighted_select(exp.variants, user_id .. exp.name)

            -- 记录实验信息
            ngx.req.set_header("X-Experiment", exp.name)
            ngx.req.set_header("X-Variant", variant.name)

            -- Cookie 持久化(确保同一用户始终看到同一版本)
            local cookie_name = "ab_" .. exp.name
            local existing = ngx.var["cookie_" .. cookie_name]
            if existing then
                -- 使用已有的变体
                for _, v in ipairs(exp.variants) do
                    if v.name == existing then
                        variant = v
                        break
                    end
                end
            else
                -- 设置新 Cookie
                ngx.header["Set-Cookie"] = cookie_name .. "=" .. variant.name .. "; Path=/; Max-Age=86400"
            end

            return variant.upstream, exp.name, variant.name
        end
    end

    return nil, nil, nil
end

return _M

5.8 动态路由配置热加载

-- /usr/local/openresty/lua/dynamic_config.lua
local _M = {}

local cjson = require "cjson"

-- 配置版本号
local config_version = 0

-- 从共享内存加载路由配置
function _M.load_routes()
    local shared = ngx.shared.gateway_config
    local routes_json = shared:get("routes_json")

    if not routes_json then
        return nil, "No routes configured"
    end

    local new_version = shared:get("routes_version") or 0
    if new_version <= config_version then
        return nil, "Config not changed"
    end

    local routes = cjson.decode(routes_json)
    config_version = new_version
    return routes
end

-- Admin API 更新路由
function _M.update_routes(new_routes_json)
    local shared = ngx.shared.gateway_config
    local ok, err = shared:set("routes_json", new_routes_json)
    if not ok then
        return false, err
    end

    -- 递增版本号
    local new_version = (shared:get("routes_version") or 0) + 1
    shared:set("routes_version", new_version)

    -- 通知所有 Worker 刷新配置
    -- ngx.timer.at(0, function() _M.load_routes() end)

    return true, new_version
end

return _M
# Admin API
location /admin/routes {
    content_by_lua_block {
        local cjson = require "cjson"
        local dc = require "dynamic_config"
        local method = ngx.req.get_method()

        if method == "PUT" or method == "POST" then
            ngx.req.read_body()
            local body = ngx.req.get_body_data()
            local ok, result = dc.update_routes(body)
            if ok then
                ngx.say(cjson.encode({status = "ok", version = result}))
            else
                ngx.status = 500
                ngx.say(cjson.encode({error = result}))
            end
        elseif method == "GET" then
            local shared = ngx.shared.gateway_config
            local routes = shared:get("routes_json") or "{}"
            ngx.say(routes)
        end
    }
}

5.9 注意事项

并发安全:路由表的读写需要注意并发安全。使用 ngx.shared.DICT 可以保证原子操作,但如果使用 Lua 全局变量存储路由表,可能会出现竞态条件。

性能影响:正则匹配比前缀匹配慢 10-100 倍。如果路由数量较多(> 1000),建议使用前缀树或哈希表而非正则。

路由冲突:当多个路由规则匹配同一 URI 时,需要明确优先级。建议使用优先级字段或注册顺序来解决冲突。

配置持久化ngx.shared.DICT 在 Nginx reload 后会丢失,需要将路由配置持久化到 Redis 或文件中。


上一章← 第 04 章 - Lua 语言基础 下一章第 06 章 - 限流与流控 →