OpenResty + Lua 实现域名的动态解析
需要实现的需求是,根据域名的前缀,去 redis 里查询,获取 IP 地址和端口号,然后把请求转发到这个地址。
需求描述起来很简单,但是实现起来有很多的细节,需求又很着急,最好立即可用。
尝试了 Spring Cloud Gateway,Envoy Gateway 等产品,默认不能动态指定后端的 IP 地址和端口,通过一些 hack 的方式工作量又很大,并且不能很好适配 websocket 的流量。
虽然API 网关有很多产品,但是有的太重量级,需要一辆自行车,结果来了一架飞机,有的又过于简陋,需要定制很多细节,整体看下来选择并不多。
失败的尝试
使用 Spring Cloud Gateway
需要创建一个自定义的GlobalFilter来实现域名前缀查询和请求转发的逻辑。
@RequiredArgsConstructor
public class DynamicRouteFilter implements GlobalFilter, Ordered {
private final ReactiveStringRedisTemplate redisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
final String fullPath = exchange.getRequest().getURI().toString();
log.info("--> Full request path: {}", fullPath);
return resolveTargetUri(exchange)
.flatMap(uri -> forwardRequest(exchange, chain, uri))
.onErrorResume(e -> handleNotFoundError(exchange)); // 在无法解析URI时处理错误
}
private Mono<URI> resolveTargetUri(ServerWebExchange exchange) {
final String host = Objects.requireNonNull(exchange.getRequest().getHeaders().getHost()).getHostName();
final String prefix = host.split("\\.")[0]; // 获取子域名前缀
log.info("Resolving URI for host: {}", host);
return redisTemplate.opsForValue().get(prefix)
.flatMap(ipAndPort -> {
if (ipAndPort != null && !ipAndPort.isEmpty()) {
try {
return Mono.just(createUriFromIpAndPort(ipAndPort, exchange.getRequest()));
} catch (IllegalArgumentException e) {
log.error("Error creating URI from IP and port: {}", e.getMessage());
return Mono.error(new IllegalStateException("Error creating URI"));
}
} else {
log.info("No mapping found in Redis for: {}", prefix);
return Mono.error(new IllegalStateException("No mapping found"));
}
});
}
private Mono<Void> handleNotFoundError(ServerWebExchange exchange) {
// 设置响应状态码为404
log.error("Error resolving URI");
exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
return exchange.getResponse().setComplete();
}
private Mono<Void> forwardRequest(ServerWebExchange exchange, GatewayFilterChain chain, URI uri) {
log.info("Forwarding request to: {}", uri);
final ServerHttpRequest mutatedRequest = exchange.getRequest().mutate().uri(uri).build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
}
private URI createUriFromIpAndPort(String ipAndPort, ServerHttpRequest request) {
log.info("Resolving URI for IP and port: {}", ipAndPort);
// ipAndPort格式为 "ip:port"
final String[] parts = ipAndPort.split(":");
final String ip = parts[0];
final int port = Integer.parseInt(parts[1]);
// 保留原始请求的路径和查询参数
return UriComponentsBuilder.fromUri(request.getURI())
.scheme("http").host(ip).port(port)
.build(true).toUri();
}
@Override
public int getOrder() {
return Integer.MIN_VALUE;
}
}
上面的实现看起来没有问题,但是默认的路由会覆盖上面的配置,这个地址没有办法动态转发:
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder, DynamicUriGatewayFilterFactory dynamicUriFilter) {
return builder.routes()
.route(r -> r.path("/**")
.filters(f -> f.filter(dynamicUriFilter.apply(new DynamicUriGatewayFilterFactory.Config())))
.uri("http://fallbackUri")) // 提供一个回退URI,以防没有找到匹配的前缀
.build();
}
基于 Envoy Gateway
Envoy 是一个开源的高性能边缘和服务代理,专为单体应用、微服务和云原生应用而设计,用于解决服务间的网络通信问题。它由Lyft公司开发,并且是CNCF(云原生计算基金会)的毕业项目。Envoy最初是作为Lyft的内部项目启动的,目的是解决微服务架构下服务间通信的复杂性问题,后来因为其出色的性能和功能而被广泛采用。
这种方式本身不能实现动态转发,和现有的架构结合成本很高,遂放弃。
基于 Golang 从零开始实现
原理很简单,就是转发请求,下面是一个基本实现的版本:
func handleRequest(w http.ResponseWriter, r *http.Request) {
// 过滤无效URL
_, err := url.Parse(r.URL.String())
if err != nil {
log.Println("Error parsing URL: ", err.Error())
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
// 去掉环境前缀(针对腾讯云,如果包含的话,目前我只用到了test和release)
newPath := strings.Replace(r.URL.Path, "/release", "", 1)
newPath = strings.Replace(newPath, "/test", "", 1)
// 拼接目标URL(带上查询字符串,如果有的话)
// 如果请求中包含 X-Target-Host 头,则使用该头作为目标域名
// 优先级 header > args > default
var targetURL string
if r.Header.Get("X-Target-Host") != "" {
targetURL = "https://" + r.Header.Get("X-Target-Host") + newPath
} else {
targetURL = target + newPath
}
if r.URL.RawQuery != "" {
targetURL += "?" + r.URL.RawQuery
}
// 本地打印代理请求完整URL用于调试
if os.Getenv("ENV") == "local" {
fmt.Printf("Proxying request to: %s\n", targetURL)
}
// 创建代理HTTP请求
proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body)
if err != nil {
log.Println("Error creating proxy request: ", err.Error())
http.Error(w, "Error creating proxy request", http.StatusInternalServerError)
return
}
// 将原始请求头复制到新请求中
for headerKey, headerValues := range r.Header {
for _, headerValue := range headerValues {
proxyReq.Header.Add(headerKey, headerValue)
}
}
// 默认超时时间设置为300s(应对长上下文)
client := &http.Client{
// Timeout: 300 * time.Second, // 代理不干涉超时逻辑,由客户端自行设置
}
// 发起代理请求
resp, err := client.Do(proxyReq)
if err != nil {
log.Println("Error sending proxy request: ", err.Error())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// 将响应头复制到代理响应头中
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
// 将响应状态码设置为原始响应状态码
w.WriteHeader(resp.StatusCode)
// 将响应实体写入到响应流中(支持流式响应)
buf := make([]byte, 1024)
for {
if n, err := resp.Body.Read(buf); err == io.EOF || n == 0 {
return
} else if err != nil {
log.Println("error while reading respbody: ", err.Error())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} else {
if _, err = w.Write(buf[:n]); err != nil {
log.Println("error while writing resp: ", err.Error())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.(http.Flusher).Flush()
}
}
}
上面代码可以基本实现一个 HTTP 代理的功能,但是 HTTP 协议本身是比较复杂的,可能还有一些边界情况。也没有实现 websocket,完整的实现短期内无法完成。
OpenResty + LuaJIT
LuaJIT通过JIT编译技术将Lua代码直接编译成本地机器代码执行,这大大提高了执行效率。JIT编译过程在程序运行时动态进行,能够根据代码的实际执行情况进行优化。
OpenResty 是一个基于 NGINX 和 LuaJIT 的高性能 web 平台。它通过将 NGINX 用于网络请求处理的强大能力与 LuaJIT 用于动态脚本的高效执行相结合,提供了一个用于构建动态 web 应用的强大环境。OpenResty 通过在 NGINX 中集成各种 Lua 模块,扩展了 NGINX 的功能,使得开发者可以用 Lua 语言编写高效、可扩展的网络应用程序。
伪代码如下:
User
local redis = require "resty.redis"
local redis_config = require "redis_config"
local function get_target_address(prefix)
local red = redis:new()
red:set_timeout(3000) -- 1 sec
local ok, err = red:connect(redis_config.redis_host, redis_config.redis_port)
if not ok then
ngx.log(ngx.ERR, "failed to connect to Redis: ", err)
return
end
if redis_config.redis_password and redis_config.redis_password ~= "" then
local res, err = red:auth(redis_config.redis_password)
if not res then
ngx.log(ngx.ERR, "failed to authenticate: ", err)
return
end
end
-- 在这里选择数据库
red:select(redis_config.redis_database)
local ip_port, err = red:get(prefix)
if not ip_port or ip_port == ngx.null then
ngx.log(ngx.ERR, "failed to get IP:Port for prefix: ", prefix, ", error: ", err)
return
end
-- 使用完毕后,将连接放回连接池
-- 参数:最大空闲时间(ms), 连接池大小
ok, err = red:set_keepalive(10000, 10)
if not ok then
ngx.log(ngx.ERR, "failed to set keepalive: ", err)
return
end
return ip_port
end
local function forward_request()
local host = ngx.var.host
local prefix = host:match("^(.-)%.") -- captures the prefix before the first dot
local target = get_target_address(prefix)
if not target then
ngx.log(ngx.ERR, "No target found for prefix: ", prefix)
return ngx.exit(404)
end
ngx.var.target = target.ip -- Set the IP for proxy_pass
ngx.var.target_port = target.port -- Set the port for proxy_pass
end
return {
forward_request = forward_request
}
local config = {}
config.redis_host = "127.0.0.1"
config.redis_port = 6379
config.redis_password = "your_redis_password"
config.redis_database = 0 -- 这里设置你的数据库编号
return config
http {
# 其他配置...
# Lua package path
lua_package_path '/app/lua/?.lua;;';
server {
listen 8080;
location / {
set $target ''; # IP to forward
set $target_port ''; # Port to forward
access_by_lua_block {
local forwarder = require "forwarder"
forwarder.forward_request()
}
proxy_pass http://$target:$target_port; # Use the variables set by Lua
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}