想必大家应该都有用过API网关,简单的说,API网关就像一个代理转发站,统一接收不同来源的请求,并将它们精准地送到目的地。所以,API网关是一个代理,而且是一个反向代理,那啥是反向代理,为啥不是正向代理,这里有张很有趣的图非常形象。
正向代理位于客户端的前面,确保没有源站直接与特定客户端通信,起到主动出击的效果;而反向代理服务器位于源站前面,确保没有客户端直接与该源站通信,起到主动防御的效果。
那我们的api网关实现就从“反向代理”开始。
golang下实现一个反向代理非常简单,只需3行代码,来看示例:
func main() {
target, _ := url.Parse("http://127.0.0.1:2003")
reverseProxy := httputil.NewSingleHostReverseProxy(target)
http.ListenAndServe(":2002", reverseProxy)
}
示例开启了一个端口为2002的http服务,访问它的请求会被代理至ip为127.0.0.1,端口为2003的服务上。示例图如下:
我们可以看出实际上:2003是实际源站,而:2002API网关,网关反向代理了请求,对源站起到了保护作用,这里贴一下核心源码。
// ReverseProxy是一个HTTP Handler,它接受传入的请求并将其发送到另一台服务器,将响应代理回客户。
type ReverseProxy struct {
// 必须,用于将请求转化为使用Transport传输新的请求并发送。然后复制它的响应回到未修改的原始客户端。不能访问返回后的内容。
Director func(*http.Request)
// 可选,用于执行代理请求的传输。默认http.DefaultTransport
Transport http.RoundTripper
// 可选,指定刷新间隔
FlushInterval time.Duration
// 可选,自定义日志收集器
ErrorLog *log.Logger
// 可选,指定一个缓冲池来获取字节切片供io.CopyBuffer使用时复制 HTTP 响应正文
BufferPool BufferPool
// 可选,用于修改来自后端的响应
ModifyResponse func(*http.Response) error
// 可选,用于处理到达后端的错误或来自ModifyResponse的错误
ErrorHandler func(http.ResponseWriter, *http.Request, error)
}
// 实现了ServeHTTP
func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {}
// 新建单主机反向代理
func NewSingleHostReverseProxy(target *url.URL) *ReverseProxy {
return &ReverseProxy{Director: director}
}
至此,我们很容易的实现了一个具有代理功能的“API网关”,但是它太简陋,简陋到我们只能访问到一个服务,实际上我们的网关通常需要接入多个不同的服务,根据一定的规则,代理请求到不同的服务上。所以我们来完善一下,让我们的网关支持一下多路由多服务。
简单的示例:
// Proxy 多后端(target)代理
type Proxy struct {
}
// NewHttpReverseProxy 创建代理
func (p *Proxy) NewHttpReverseProxy(target *url.URL) *httputil.ReverseProxy {
rp := httputil.NewSingleHostReverseProxy(target)
return rp
}
// ServeHTTP 实现接口:ServeHTTP(w http.ResponseWriter, r *http.Request)
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var target *url.URL
if r.URL.Path == "/test1" {
target, _ = url.Parse("http://127.0.0.1:2003")
} else if r.URL.Path == "/test2" {
target, _ = url.Parse("http://127.0.0.1:2004")
} else {
target, _ = url.Parse("http://127.0.0.1:2005")
}
proxy := p.NewHttpReverseProxy(target)
proxy.ServeHTTP(w, r)
}
func main() {
addr := ":2002"
p := &Proxy{}
log.Println("Starting HttpServer at " + addr)
err := http.ListenAndServe(addr, p)
if err != nil {
log.Fatalln("ListenAndServe: ", err)
}
}
本示例开启了一个端口为2002的http服务,充当API网关,访问它的请求,会根据path的不同被分发到对应的后端服务上。不过这里的路由匹配过于简陋,我们可以做的更灵活,实际上API网关需要多维度的、灵活的、高效的路由匹配,目前业界通常采用前缀树算法实现路由匹配,像gin框架、api2go框架均有使用,这里有开源的组件:github.com/julienschmidt/httprouter,很好用。路由匹配规则通常是path、host, http method, query 参数等条件,网关管理员可以按需灵活配置。最后,这些服务路由的元数据最好做成配置存储,而不是这么写死在代码里,最终我们的API网关应该是这个样子。
当请求过来时,路由匹配器通过解析当前请求信息(url、host、query等),并基于网关管理端配置的规则,获得本次请求所要转发至的地址上。
附件中的example1是我写的一个例子,这里贴一段核心代码:
// ServeHTTP 实现接口:ServeHTTP(w http.ResponseWriter, r *http.Request)
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ps:实际上下面的第一步和第二步应该放在启动过程中就初始化好,这里为了大家理解放到这
// 第一步:获取服务路由配置
services := p.getServices(r)
// 第二步:生成前缀树
tree := p.getRouteTree(services)
// 第三步:从前缀树中匹配出当前请求所匹配的目标服务
route, _ := p.findRouteWithRouteTree(r, tree)
if route == nil {
proxy1 := http.NewServeMux()
proxy1.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
fmt.Fprintf(w, "404 not found")
}))
proxy1.ServeHTTP(w, r)
return
}
// 第四步:反向代理
target, _ := url.Parse(route.Service.Conf.Upstream)
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ServeHTTP(w, r)
}
至此我们的API网关已经具备了基本的代理能力,不过上面的示例都是默认后端服务是单节点的情况,是一个个明确的ip,如果后端服务是多节点呢?当一个请求过来,我们应该转发到哪台机器上,那是不是需要在网关这一层实现负载均衡策略呢?很明显,是要的,通常负载均衡是API网关很重要的基础能力,那我们应该如何去实现?目前常用的负载均衡算法有下面这几种:
1、随机
每次请求都是完全随机选取一个节点
2、一致hash
相同来源条件下的请求都会选取到同一个节点
3、轮询
依次轮流选取节点
4、加权轮询
给服务器分配权重,依照权重选取节点
也比较好实现,详细代码请参阅附件中的example2。这里可以聊聊“加权轮询”,常用的加权轮询算法有很多:随机数版本、递增版本、红黑树版本、LVS版本、Nginx版本,其中nginx版本最为巧妙。大致如下:
type WeightedNode struct {
addr string // 服务器addr
weight int // 配置的权重,即在配置文件或初始化时约定好的每个节点的权重
curWeight int // 有效权重,初始值为weight
effectiveWeight int // 节点当前权重
}
核心流程如下:
1、effectiveWeight有效的权重,初始值为 weight ,通讯过程中发现节点异常,则 -1 ,之后再次选择本节点,调用成功一次则 +1 ,直到恢复到 weight。
2、轮询所有节点,计算当前状态下所有的节点的 effectiveWeight 之和 作为 totalWeight;
3、更新每个节点的 currentWeight, currentWeight = currentWeight + effectiveWeight; 选出所有节点 currentWeight 中最大的一个节点作为选中节点;
4、选择中的节点再次更新 currentWeight, currentWeight = currentWeight - totalWeight;
加上负载均衡后,我们的API网关成了这个样子。
匹配器在发现匹配到的后端服务是多节点的时候,会基于设置的负载均衡策略和多节点元数据配置,获取到其中的一个节点作为最终目的地址进行转发。
不过有时候负载均衡可能并不在网关中实现,如:upstream可能是域名、k8s中的服务地址、cl5、北极星等等,这些服务本身实现了负载均衡策略,我们只需要在网关内对接这些服务获得真实地址。于是,我们的API网关成了这个样子。
匹配器在发现匹配到的后端服务是cl5、北极星等服务地址时,将调用这些服务的开放接口,获取到真实目的服务地址。
我们知道网关的作用是实现了统一的服务代理和服务出口,基于这样的能力,我们自然而然地会想到将之前分散在各个服务中单独实现的通用能力前移到网关这一层,通过对输入和输出的拦截来实现所有可复用能力的抽取和实现。这些共性能力可以理解为网关实现的一个个拦截插件,本身可插拔,灵活可配置。这些共性能力最核心的就是安全,日志,流控。可如何实现这些插件才能做到更灵活更可扩展呢?nginx可以说是一个非常好的参考,它不仅是一个高性能Http服务器,还是一个优秀的反向代理。Nginx将处理请求的过程一共划分为11个阶段,按照执行顺序依次是post-read、server-rewrite、find-config、rewrite、post-rewrite、 preaccess、access、post-access、try-files、content、log,除此之外,nginx整体采用模块化设计,甚至http服务器核心功能也是一个模块,这些模块基于这11个阶段实现具体功能,在相应阶段执行相应的逻辑,如同可插拨的插件,可以看一张Openresty(基于nginx的优秀的API网关)的示意图,在请求的不同阶段,插入执行了不同的插件。
到了编码层面,在golang下,插件要如何实现,这里主要有两种模型,一种是洋葱模型,如下图:
一层一层的嵌套,请求执行的时候,一层层的进去然后再一层层的出来,而每一层可以对应到网关执行的各个阶段,另一种是数组模型:
网关服务启动时,将插件中对各个阶段功能的实现放到以阶段名定义的不同数组中,当请求执行时,在不同的阶段,依次遍历该阶段对应的数组,并执行数组的每个功能逻辑。这里贴一段示例代码
// 注册、匹配插件
func (p *Proxy) SetPlugins() {
p.Plugins = append(p.Plugins, logp.NewPlugin())
p.Plugins = append(p.Plugins, cors.NewPlugin())
// 根据权重排序
p.Plugins = p.orderPlugins(p.Plugins)
}
//运行路由阶段插件
func (p *Proxy) RouteFlow(ctx *context.GatewayContext) {
select {
case <-ctx.Context.Done():
default:
}
for _, pl := range p.Plugins {
if err := pl.Route(ctx); err != nil {
fmt.Println(err)
}
}
}
//运行proxy阶段插件
func (p *Proxy) ProxyFlow(ctx *context.GatewayContext) {
fmt.Println("from ctx key1", ctx.Get("key1"))
for _, pl := range p.Plugins {
if err := pl.Proxy(ctx); err != nil {
fmt.Println(err)
}
}
}
//实现接口:ServeHTTP(w http.ResponseWriter, r *http.Request)
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := context.NewContext(w, r)
//在这个时候运行路由阶段中间件
p.RouteFlow(ctx)
var target *url.URL
//获取目标服务
target = p.GetRemotes(r)
//创建反向代理
proxy := p.NewHttpReverseProxy(target)
//服务处理
proxy.ServeHTTP(w, r)
//在这个时候运行Proxy阶段中间件
p.ProxyFlow(ctx)
}
func main() {
//......
//注册插件
p.SetPlugins()
//......
}
正是由于上面灵活的设计,我们总需要一个容器去保持每个阶段的运行时状态,在系统的底层逻辑中,上下文是一个很重要的东西,用于保存cpu切换时各个线程、进程的相关信息,在API网关这里,上下文同样适用,golang原生支持上下文的实现,我们只需在入口处创建一个新的上下文对象,然后传递下去,这里频繁创建上下文对象会比较耗性能,可以考虑对象池去实现。
网关除了转发代理服务主体外,通常还需要一个可视化管理端,来统一管理维护网关的服务、路由、插件等元数据。管理端功能主要包括对服务、路由、插件的增删改查,以及一些分发策略的配置,这里不做够多说明,我想跟大家聊聊在更新这些元数据时,网关是如何做到热更新的,这里我以kong网关和gopass网关作为示例:
kong网关主要基于通过数据库+定时轮询方式实现,当元数据变更时会往集群间分发事件表cluster_events插入一条记录,记录包含事件类型、产生时间、生效时间等数据,另一边,采用分布式部署的kong网关的每个节点创建了多个worker进程,每个worker进程都会启动一个定时任务,间隔轮询cluster_events表,查询当前时间节点内有效的事件,当获得事件,变从数据库中查询最新数据,最终将数据更新至内存(kong网关将元数据缓存在内存中),其中,虽然多个worker都启动了定时任务,但是只会有一个worker处理缓存更新,由lua-resty-lock 锁实现,那缓存更新了 又如何通知到其他worker呢,kong网关通过基于nginx共享内存的事件发布-订阅机制实现。
gopass网关有点类似,基于数据版本+定时轮询的方式实现,当元数据变更时会更将整个配置数据更新到redis等中间件缓存内,并带有新的版本号(时间戳),同样采用分布式部署的gopass实例会启动一个协程,定义轮询redis中的版本号对比本地的版本号,如果不同,则更新本地数据,真正的数据更新是基于conf.Watch订阅监听实现,定时器触发更新事件,监听器更新数据。
到此,我们基本实现了一个API网关的核心功能,不过还有许多优化空间,例如:负载均衡的时候要考虑如何剔除异常节点、日志的存储上报、安全监控的实现,得益于灵活的插件机制,我们总能很快速地实现这些功能。最后补一张整体效果图
--
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。