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 章 - 限流与流控 →