前言
最近想学习一下网关相关的知识, 搜了一下, 看到有个悟空 API 网关的项目. 文档图文并茂, 又是企业级别的, 决定就是它了, 项目地址: GOKU-API-Gateway https://github.com/eolinker/GoKu-API-Gateway
问题
看在源码之前, 得先定一下目标, 盲目地看代码容易迷失. 在看了官方的文档和跟着文档搭起来试用了一下之后, 定下了下面这些目标.
GOKU-API-Gateway 监控信息如何收集? 如何存储?
如何做到高效的转发?
QPS 限制, 在分布式的情况下是怎么做的, 尤其是秒级的限制?
如何做到方便添加新的过滤功能?
有没有什么可以学习的?
有没有可以改进的地方?
思考网关应该提供一些什么功能?
思考网关所面临着的挑战有哪些?
GOKU 关键的结构体
看代码之前, 有必要理解一下 GOKU-API-Gateway 中数据的抽象是怎样的. 这个打开管理后台, 把用起需要设置的东西都设置一遍, 这一块基本也就可以了. 对应的结构体在这里: server/conf.
关键的
API: 定义了一个接口转发, 里面主要包含了, 请求的 URL, 转发的 URL, 方法, 流量策略等等信息
策略: 定义了流量限制的策略, 主要有: 鉴权方式, IP 的黑白名单, 流量控制等等信息
一次请求处理的大体流程
入口
在工程的最外层有两个文件: goku-ce.go,goku-ce-admin.go. 点进去瞄一眼, 大体就知道 goku-ce-admin.go 是后台管理的接口, goku-ce.go 是真正的网关服务.
goku-ce.go
看到有 ListenAndServe 估计就是 web 框架那一套东西, 可以全局搜一下 ServeHTTP. 其中 middleware.Mapping 是每一个 API 的处理函数.
- func main() { server := goku.New()
- // 注册路由的处理函数 server.RegisterRouter(server.ServiceConfig,middleware.Mapping,middleware.GetVisitCount)
- fmt.Println("Listen",server.ServiceConfig.Port)
- // 启动服务
- err := goku.ListenAndServe(":" + server.ServiceConfig.Port,server)
- if err != nil {
- log.Println(err)
- }
- log.Println("Server on" + server.ServiceConfig.Port + "stopped")
- os.Exit(0)
- }
复制代码
ServeHTTP
看到代码中的 trees 就想到了 gin 这个框架, 点进去发现路由树这一块基本上和 gin 框架的差不多, 但是节点中的内容有点不一样. 不再是一个接口对应一组处理函数, 而是只有一个. 多了个 Context 的指针, Context 对象里面主要是保存了 API 的中的转发地址, 限流策略, 统计信息等等, context 对象是理解整个网关的处理最重要的对象, 没有之一. 相当于接口信息的本地缓存, 当找到路由的处理函数时, 就找到了接口信息的本地缓存, 减少了一次缓存查询, 这个思路非常棒!!!
- func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
- // 省略 N 多代码
- // 看到这个 trees 就想到了之前看的 gin 框架,
- if root := r.trees[req.Method]; root != nil {
- // context 是个关键点,
- handle, ps, context,tsr := root.getValue(path);
- if handle != nil {
- handle(w, req, ps,context)
- return
- } else{
- // 省略 N 多代码
- }
- // 省略 N 多代码
- }
- //
- type node struct {
- path string
- wildChild bool
- nType nodeType
- maxParams uint8
- indices string
- children []*node
- // 只有一个处理函数
- handle Handle
- priority uint32
- // API 的中的转发地址, 限流策略, 统计信息都这 context 里面
- context *Context
- }
复制代码
middleware.Mapping
在 goku-ce.go 中就说了这个是接口的处理函数, 整个流程很清晰, 各种过滤是怎么做的顺着点进去就可以看到了. 其实可以发现, 整个代码对应处理高并发中的一些小细节做不是很好, 具体的在有什么可以改进的地方会重点描述.
- func Mapping(res http.ResponseWriter, req *http.Request,param goku.Params,context *goku.Context) {
- // 更新实时访问次数
- go context.VisitCount.CurrentCount.UpdateDayCount()
- // 验证 IP 是否合法
- f,s := IPLimit(context,res,req)
- if !f {
- res.WriteHeader(403)
- res.Write([]byte(s))
- // 统计信息的收集
- go context.VisitCount.FailureCount.UpdateDayCount()
- go context.VisitCount.TotalCount.UpdateDayCount()
- return
- }
- // 权限验证
- f,s = Auth(context,res,req)
- if !f {
- res.WriteHeader(403)
- res.Write([]byte(s))
- go context.VisitCount.FailureCount.UpdateDayCount()
- go context.VisitCount.TotalCount.UpdateDayCount()
- return
- }
- // 速率限制
- f,s = RateLimit(context)
- if !f {
- res.WriteHeader(403)
- res.Write([]byte(s))
- go context.VisitCount.FailureCount.UpdateDayCount()
- go context.VisitCount.TotalCount.UpdateDayCount()
- return
- }
- // 接口转发
- statusCode,body,headers := CreateRequest(context,req,res,param)
- for key,values := range headers {
- for _,value := range values {
- res.Header().Set(key,value)
- }
- }
- res.WriteHeader(statusCode)
- res.Write(body)
- if statusCode != 200 {
- go context.VisitCount.FailureCount.UpdateDayCount()
- go context.VisitCount.TotalCount.UpdateDayCount()
- } else {
- go context.VisitCount.SuccessCount.UpdateDayCount()
- go context.VisitCount.TotalCount.UpdateDayCount()
- }
- return
- }
复制代码
问题的答案
GOKU-API-Gateway 监控信息如何收集? 如何存储?
监控信息请求过程中进行手机, 直接存储在接口对应的 Context 里面. 问题来了, 当网关部署多个节点时, 怎么将各个节点的监控信息收集起来? 带着问题, 去找代码, 发现没有这一块的代码. 估计这个开源的版本的阉割版吧, 只能单节点部署.
QPS 限制, 在分布式的情况下是怎么做的, 尤其是秒级的限制?
代码当中木有考虑到这一块
如何做到方便添加新的过滤功能?
有新的过滤功能需要, 在 middleware.Mapping 函数里面添加. 我觉得这里可以借鉴 gin 框架那一套, 一个 URI 对应多个处理函数, 每个处理函数就是一个过滤功能. 这样的话, 甚至可以实现热拔插功能, 只要每个进程提供对应的接口修改, URI 的处理函数列表.
有没有什么可以学习的?
接口信息放在路由树中
这个在上面已经说了, 就不再做说明, 很棒的思路.
有没有可以改进的地方?
在超高并发的场合, 对代码要求会很高, 没有必要的开销能省就省, 考虑到一般用上了网关这东西, 并发量肯定比较高的了, 所以才有了下面的那些改进点.
时间如果不需要绝对的精确, 没有必要每次都调用 time.now() 获取
代码里面有很多关于时间判断, 其实都不要求绝对的精准, 可以直接从缓存里面获取时间. 因为每次调用 time.now() 都会进行系统调用, 开销虽然很小. 缓存也很简单, 弄个定时器每秒更新一次就好. 代码中的可以改进的例子.
- func (l *LimitRate) UpdateDayCount() {
- // TODO 改进
- l.lock.Lock()
- now := time.Now()
- // 这里损失 1 以内秒的统计不会造成太大的影响, 当前时间也应该从缓存里面拿, 避免系统调用
- if now.Day() != l.begin.Day(){
- l.begin = now
- l.count = 0
- }
- l.count++
- l.lock.Unlock()
- }
复制代码
能缓存的就缓存起来, 不需要每次都计算
- func (l *LimitRate) UpdateDayCount() {
- // TODO 改进
- l.lock.Lock()
- now := time.Now()
- // 应为 begin 的时间是不变的日期应该在初始化的时候就计算好, 这样就不用每次都调用 l.begin.Day()
- if now.Day() != l.begin.Day(){
- l.begin = now
- l.count = 0
- }
- l.count++
- l.lock.Unlock()
- }
复制代码
高并发场景尽量不要打 LOG, 而且 LOG 也要有缓冲区的, 缓冲区满了再打印
这里的尽量不要打 log, 并不是说不要不打 log. 因为把 log 打印到磁盘是涉及到 IO 的, 对性能是有所影响的. 如果可以忍受一定的丢失, log 应该设置一定的缓冲区, 等缓冲区满了才打印到磁盘.
- func (l *LimitRate) DayLimit() bool {
- result := true
- l.lock.Lock()
- now := time.Now()
- // 清除, 重新计数
- if now.Day() != l.begin.Day(){
- l.begin = now
- l.count = 0
- }
- if l.rate != 0 {
- t := now.Hour()
- bh := l.begin.Hour()
- // TODO 改进 求加括号, 用意很不明确
- if bh <= t && t <l.end || (bh> l.end && (t <bh && t < l.end)){
- // TODO 改进 万一有错超过了 rate 那就 GG 了, 应用用 >=
- if l.count == l.rate {
- result = false
- } else {
- l.count++
- }
- }
- }
- // TODO 改进 这种高并发场景不要打印
- fmt.Println("Day count:")
- fmt.Println(l.count)
- l.lock.Unlock()
- return result
- }
复制代码
开启 goruntime 是有成本的, 简单的操作不应该开新的 goruntime
goruntimes 的声誉非常非常之好, 既轻量, 又廉价, 开成千上万不成问题, 但是这并不意味着没有开销. goruntime 也是要有结构体来保存, 也是要参与调度, 也是要排队的等等. 在代码当中, 统计信息的收集都是开启一个 goruntime, 里面仅仅是加个锁, 将计数器 ++, 这个完全是没有必要的. 这里可以通过 channle 的方式, 弄常驻的 goruntime 专门来处理统计信息.
- func Mapping(res http.ResponseWriter, req *http.Request,param goku.Params,context *goku.Context) {
- // 更新实时访问次数
- go context.VisitCount.CurrentCount.UpdateDayCount()
- // 验证 IP 是否合法
- f,s := IPLimit(context,res,req)
- if !f {
- res.WriteHeader(403)
- res.Write([]byte(s))
- go context.VisitCount.FailureCount.UpdateDayCount()
- go context.VisitCount.TotalCount.UpdateDayCount()
- return
- }
- }
复制代码
思考网关应该提供一些什么功能?
这个需要再看看其它的网关代码, 才能总结出来.
思考网关所面临着的挑战有哪些?
网关作为所有 API 的入口, 几乎可以说必然会有高并发的挑战. 由于是所有 API 的入口, 也必然要求高可用.
总结
总的来说, 目前开源的部分估计仅仅是单机的代码, 并没有我想要的东西. 需要看其它开源的网关代码, 继续学习.
来源: https://juejin.im/post/5b4b4841f265da0fad0cf4c7