[Pitaya Demo解读笔记]6.rate_limiting demo

项目目录:examples/demo/rate_limiting

pitaya 前端服务器提供了消息限频率,本例就是通过内置的 ratelimiting 系列配置,展示了如何限制消息频率,避免一段时间处理过多请求。

限频是一个很重要的功能,部署到公网上的服务,如果会被恶意刷爆(如DDOS攻击),就会影响到正常用户的访问,所以一般来说,我们会在服务端加上访问频率限制。不多说,看一下这个简单的小 demo。

运行

开启服务器

sh 复制代码
go run main.go

服务器默认监听 3250 端口,且配置为每分钟最多处理5个请求

go 复制代码
// main.go:24
vConfig.Set("pitaya.conn.ratelimiting.limit", 5)
vConfig.Set("pitaya.conn.ratelimiting.interval", time.Minute)

为了触发限频率,我们快速发送几个消息

sh 复制代码
> pitaya-cli
Pitaya REPL Client
>>> connect localhost:3250
Using json client
connected!
>>> request room.room.ping
>>> sv->pong
>>> request room.room.ping
>>> sv->pong
>>> request room.room.ping
>>> sv->pong
>>> request room.room.ping
>>> request room.room.ping
>>> sv->{"Code":"PIT-504","Message":"request timeout","Metadata":null}

疯狂发送 ping 消息,达到限频后,服务端不再处理请求,即客户端没有收到 pong 回复,而是收到了请求超时,服务端输出了限频日志:

sh 复制代码
time="2023-12-04T17:38:37+08:00" level=error msg="Data=\x04\x00\x00\x11\x00\v\x0eroom.room.ping, Error=rate limit exceeded" source=pitaya

代码分析

限频功能不是直接在默认config里去支持配置的,所以并不能像 chat demo 那样,通过conf.Pitaya.Buffer.Handler.LocalProcess = 15 来直接设置。限频功能类似于一种"插件",通过 wrapper(包装)的方式来实现功能注入。

我们先看调用代码:

go 复制代码
func createAcceptor(port int, reporters []metrics.Reporter) acceptor.Acceptor {

	// 5 requests in 1 minute. Doesn't make sense, just to test
	// rate limiting
	vConfig := viper.New()
	vConfig.Set("pitaya.conn.ratelimiting.limit", 5)
	vConfig.Set("pitaya.conn.ratelimiting.interval", time.Minute)
	pConfig := config.NewConfig(vConfig)

	rateLimitConfig := config.NewRateLimitingConfig(pConfig)

	tcp := acceptor.NewTCPAcceptor(fmt.Sprintf(":%d", port))
	return acceptorwrapper.WithWrappers(
		tcp,
		acceptorwrapper.NewRateLimitingWrapper(reporters, *rateLimitConfig))
}

重点在最后一句,对真正的acceptor tcp 进行了包装,返回一个包装过的变量,也是一个 acceptor。跟进去看 WithWrappers 的实现:

go 复制代码
// Wrapper has a method that receives an acceptor and the struct
// that implements must encapsulate it. The main goal is to create
// a middleware for packets of net.Conn from acceptor.GetConnChan before
// giving it to serviceHandler.
type Wrapper interface {
	Wrap(acceptor.Acceptor) acceptor.Acceptor
}

// WithWrappers walks through wrappers calling Wrapper
func WithWrappers(
	a acceptor.Acceptor,
	wrappers ...Wrapper,
) acceptor.Acceptor {
	for _, w := range wrappers {
		a = w.Wrap(a)
	}
	return a
}

这种形式感觉跟函数式编程模式 Functional Options 有点类似啊?让我们暂时放下这些 Wrappers,先了解一下更简单一点的函数式选项编程模式。

Functional Options 函数式选项编程模式

这个编程模式在 go 语言里非常常见,一般也是以 WithXXX 来命名函数。比如 pitaya 在 component 包里就用到了函数式选项编程模式:

go 复制代码
// options.go
package component

type (
	options struct {
		name     string              // component name
		nameFunc func(string) string // rename handler name
	}

	// Option used to customize handler
	Option func(options *options)
)

// WithName used to rename component name
func WithName(name string) Option {
	return func(opt *options) {
		opt.name = name
	}
}

// WithNameFunc override handler name by specific function
// such as: strings.ToUpper/strings.ToLower
func WithNameFunc(fn func(string) string) Option {
	return func(opt *options) {
		opt.nameFunc = fn
	}
}

这些 options 最终在 NewService 中应用了:

go 复制代码
// service.go
func NewService(comp Component, opts []Option) *Service {
    ...
    // apply options
	for i := range opts {
		opt := opts[i]
		opt(&s.Options)
	}
    ...
}

但是这个示例的包裹层次比较深,不是很方便学习,建议移步耗子叔的文章,他将演进过程讲解得非常通透(R.I.P.):

Go 编程模式:Functional Options | 酷 壳 - CoolShell

究其根本,就是通过 WithXXX 函数闭包了参数信息,在 for...range 遍历赋值给了真正的接收方:

go 复制代码
for _, option := range options {
    option(&srv)
}

我们看这段代码,好像是跟前面的 WithWrappers 有点像:

那么找不同,不同在于什么?Wrap 完了又赋值回去了,这就是包装的意义,一层一层包裹起来,即修饰器模式。

修饰器编程模式

我看这段代码的时候也是云里雾里,平时没打好设计模式的基础,到这里就有点蒙圈了,幸得朋友推荐了耗子叔的文章,才总算是解了惑。依然建议移步耗子叔的文章(最末的泛型部分可以不看,go 1.18 已经正式支持泛型了):

Go编程模式:修饰器 | 酷 壳 - CoolShell

(耗子叔千古,多说一句,希望我也能做到持续学习、持续分享,即使达不到陈皓的高度,也努力去继承这样的精神。缅怀,感谢......)

回到代码里来,如果你看完以上文章,应该对修饰器有了一定的理解,修饰器模式是对原型的一种扩展和包装,所以它需要将 Wrap 后的变量再赋值回来,实现一层一层的包裹。

AcceptorWrapper

pitaya的 Wrapper 本质上就是对 Acceptor 的修饰,核心就是耗子叔给出的示例的修饰器编程模式,然而,它,更复杂......也不知道是什么样的脑子能写出这样的代码,我看都看不懂(菜呀= =),让我们结合代码、画图,一步步探究下去。

先从调用部分入手:

go 复制代码
// part1
tcp := acceptor.NewTCPAcceptor(fmt.Sprintf(":%d", port))
return acceptorwrapper.WithWrappers(
    tcp,
    acceptorwrapper.NewRateLimitingWrapper(reporters, *rateLimitConfig))

// part2
func WithWrappers(a acceptor.Acceptor, wrappers ...Wrapper) acceptor.Acceptor {
	for _, w := range wrappers {
		a = w.Wrap(a)
	}
	return a
}

// part3
func (r *RateLimitingWrapper) Wrap(a acceptor.Acceptor) acceptor.Acceptor {
	r.Acceptor = a
	return r
}

真正的 acceptortcp,通过 Wrap 函数,将其包装了一层,赋值给了RateLimitingWrapper.BaseWrapper.Acceptor,如图示:

再看WithWrappers的第二个实参,就是构建的一个RateLimitingWrapper,我们看看它是如何构建的:

go 复制代码
// part1
// NewRateLimitingWrapper returns an instance of *RateLimitingWrapper
func NewRateLimitingWrapper(reporters []metrics.Reporter, c config.RateLimitingConfig) *RateLimitingWrapper {
	r := &RateLimitingWrapper{}

	r.BaseWrapper = NewBaseWrapper(func(conn acceptor.PlayerConn) acceptor.PlayerConn {
		return NewRateLimiter(reporters, conn, c.Limit, c.Interval, c.ForceDisable)
	})

	return r
}

// part2
// NewBaseWrapper returns an instance of BaseWrapper.
func NewBaseWrapper(wrapConn func(acceptor.PlayerConn) acceptor.PlayerConn) BaseWrapper {
	return BaseWrapper{
		connChan: make(chan acceptor.PlayerConn),
		wrapConn: wrapConn,
	}
}

核心逻辑就是将一个 func 赋值给了 BaseWrapper.wrapConn,画图帮助理解:

现在我们在 createAcceptor 返回得到的 Acceptor 其实是包装后的,即上图中 RateLimitingWrapper 包裹的这部分,Acceptor 是它的第二层嵌入结构,所以该结构体也实现了 Acceptor interface,也是一种 Acceptor

那么,当 main 函数中 app.Start() 内部调用了 Acceptor.ListenAndServe() 开启服务时,实际调用的是 RateLimitingWrapper.ListenAndServe,由于它没有直接实现,那就看它的第一层嵌入结构,这里是有实现的:

go 复制代码
// acceptorwrapper/base.go:47
// ListenAndServe starts a goroutine that wraps acceptor's conn
// and calls acceptor's listenAndServe
func (b *BaseWrapper) ListenAndServe() {
	go b.pipe()
	b.Acceptor.ListenAndServe()
}

先看第二行 b.Acceptor.ListenAndServe(),我们看图可以清楚的知道,这里的b.Accetpor 即最底层的真实 Acceptor -> tcp,这一行代码在本例中就是调用了

go 复制代码
// tcp_acceptor.g:129
// ListenAndServe using tcp acceptor
func (a *TCPAcceptor) ListenAndServe() {
    ...
}

即真正的开启 tcp 服务。

回到第一行 go b.pipe(),看看这行代码开了一个 goroutine 做了些啥:

go 复制代码
func (b *BaseWrapper) pipe() {
	for conn := range b.Acceptor.GetConnChan() {
		b.connChan <- b.wrapConn(conn)
	}
}

遍历 ConnChan,对 Acceptor 接收到的新连接[1]做一层 wrapConn(conn) 处理[2]后再扔到自己的 connChan 里[3],在上图的基础上再画一下这个数据流向:

(PS: 这不是什么标准的UML啥的类图、流程图画法,只是为了帮助理解自创瞎画的)

再看第[2]步,wrapConn 在图中我们已经标识出了,就是 NewRateLimiter

go 复制代码
type RateLimiter struct {
	acceptor.PlayerConn
	reporters    []metrics.Reporter
	limit        int
	interval     time.Duration
	times        list.List
	forceDisable bool
}

// NewRateLimiter returns an initialized *RateLimiting
func NewRateLimiter(
	reporters []metrics.Reporter,
	conn acceptor.PlayerConn,
	limit int,
	interval time.Duration,
	forceDisable bool,
) *RateLimiter {
	r := &RateLimiter{
		PlayerConn:   conn,
		reporters:    reporters,
		limit:        limit,
		interval:     interval,
		forceDisable: forceDisable,
	}

	r.times.Init()

	return r
}

这个 RateLimiter 又嵌入了一个 PlayerConn,当我们通过 wrapConn(conn) 处理完连接、加入到 connChan 时,这个 conn 已经变成了被 RateLimiter 修饰过的连接(这一大段实现,感觉实质上就是各种的嵌入结构,各种的修饰器模式)。

所以,当底层 HandlerService 调用 conn.GetNextMessage 时:

go 复制代码
// service/handler.go
func (h *HandlerService) Handle(conn acceptor.PlayerConn) {
    ...
    msg, err := conn.GetNextMessage()
    ...
}

实际上,是对已经包装过的conn来调用,也就会进入到 RateLimiter.GetNextMessage 函数:

go 复制代码
// GetNextMessage gets the next message in the connection
func (r *RateLimiter) GetNextMessage() (msg []byte, err error) {
	if r.forceDisable {
		return r.PlayerConn.GetNextMessage()
	}

	for {
		msg, err := r.PlayerConn.GetNextMessage()
		if err != nil {
			return nil, err
		}

		now := time.Now()
		if r.shouldRateLimit(now) {
			logger.Log.Errorf("Data=%s, Error=%s", msg, constants.ErrRateLimitExceeded)
			metrics.ReportExceededRateLimiting(r.reporters)
			continue
		}

		return msg, err
	}
}

在这里,真正实现了对每个连接的请求限频。

我们不具体看限频逻辑了,来看看行8:msg, err := r.PlayerConn.GetNextMessage(),既然我们劫持了真正的消息处理逻辑,自然要把这段逻辑给续上,所以还得要调用真正的连接的 GetNextMessage

扩展

pitaya目前只提供了请求限频这一种 wrapper 插件,那如果我们想要再写一个 wrapper,再包裹一层,那整个代码逻辑会是什么样子的呢?

让我们照抄 RateLimitingWrapper 写两个简单的 wrapper,并输出一些 log 翻遍查看逻辑流转

MsgCounterWrapper

go 复制代码
// msg_counter_wrapper.go
type MsgCounterWrapper struct {
	BaseWrapper
}

func NewMsgCounterWrapper() *MsgCounterWrapper {
	r := &MsgCounterWrapper{}
	r.BaseWrapper = NewBaseWrapper(func(conn acceptor.PlayerConn) acceptor.PlayerConn {
		return NewMsgCounter(conn)
	})

	return r
}

func (r *MsgCounterWrapper) Wrap(a acceptor.Acceptor) acceptor.Acceptor {
	r.Acceptor = a
	return r
}

// msg_counter.go
type MsgCounter struct {
	acceptor.PlayerConn
	msgCount int
}

func NewMsgCounter(conn acceptor.PlayerConn) *MsgCounter {
	logrus.Infof("===================[MsgCounter] New")
	return &MsgCounter{
		PlayerConn: conn,
	}
}

func (r *MsgCounter) GetNextMessage() (msg []byte, err error) {
	msg, err = r.PlayerConn.GetNextMessage()
	r.msgCount++
	logrus.Infof("===================[MsgCounter] msgCount: %d", r.msgCount)
	return msg, err
}

WriteCounterWrapper

RateLimitingWrapperMsgCounterWrapper 可以"劫持" GetNextMessage,因为它嵌入了 PlayerConn,就"继承"到了这个函数。如果我们再看 PlayerConn,发现它又嵌入了 net.Conn,那说明我们还可以"继承"、"劫持"到 Conn 的方法。

go 复制代码
// write_counter_wrapper.go
type WriteCounterWrapper struct {
	BaseWrapper
}

func NewWriteCounterWrapper() *WriteCounterWrapper {
	r := &WriteCounterWrapper{}
	r.BaseWrapper = NewBaseWrapper(func(conn acceptor.PlayerConn) acceptor.PlayerConn {
		return NewWriteCounter(conn)
	})

	return r
}

func (r *WriteCounterWrapper) Wrap(a acceptor.Acceptor) acceptor.Acceptor {
	r.Acceptor = a
	return r
}

// write_counter.go
type WriteCounter struct {
	acceptor.PlayerConn
	writeCount int
}

func NewWriteCounter(conn acceptor.PlayerConn) *WriteCounter {
	logrus.Infof("===================[WriteCounter] New")
	return &WriteCounter{
		PlayerConn: conn,
	}
}

func (c *WriteCounter) Write(b []byte) (int, error) {
	n, err := c.PlayerConn.Write(b)
	c.writeCount++
	logrus.Infof("===================[WriteCounter] writeCount: %d", c.writeCount)
	return n, err
}

测试

懒得再写测试代码了,把这个 demo 的 createAcceptor 改吧改吧:

go 复制代码
func createAcceptor(port int, reporters []metrics.Reporter) acceptor.Acceptor {
    tcp := acceptor.NewTCPAcceptor(fmt.Sprintf(":%d", port))
    return acceptorwrapper.WithWrappers(
        tcp,
        acceptorwrapper.NewWriteCounterWrapper(),
        acceptorwrapper.NewMsgCounterWrapper())
}

所以我们是 tcp 包一层 writeCounter 再包一层 msgCounter,开启服务和 pitaya-cli 看看输出:

go 复制代码
pitaya-cli
Pitaya REPL Client
>>> connect localhost:3250
Using json client
connected!

客户端只要连上就可以了,不需要消息,底层会发送握手消息和心跳包。

看看服务端的日志:

sh 复制代码
time="2023-12-05T16:16:13+08:00" level=info msg="===================[WriteCounter] New"
time="2023-12-05T16:16:13+08:00" level=info msg="===================[MsgCounter] New"
time="2023-12-05T16:16:13+08:00" level=debug msg="New session established: Remote=[::1]:59560, LastTime=1701764173" source=pitaya  
time="2023-12-05T16:16:13+08:00" level=info msg="===================[MsgCounter] msgCount: 1"
time="2023-12-05T16:16:13+08:00" level=debug msg="Received handshake packet" source=pitaya
time="2023-12-05T16:16:13+08:00" level=info msg="===================[WriteCounter] writeCount: 1"
time="2023-12-05T16:16:13+08:00" level=debug msg="Session handshake Id=4, Remote=[::1]:59560" source=pitaya
time="2023-12-05T16:16:13+08:00" level=debug msg="Successfully saved handshake data" source=pitaya
time="2023-12-05T16:16:13+08:00" level=info msg="===================[MsgCounter] msgCount: 2"
time="2023-12-05T16:16:13+08:00" level=debug msg="Receive handshake ACK Id=4, Remote=[::1]:59560" source=pitaya
time="2023-12-05T16:16:13+08:00" level=info msg="===================[MsgCounter] msgCount: 3"

包装顺序是,由内到外依次是:Acceptor -> WriteCounterWrapper -> MsgCounterWrapper。

当一个新连接进来时,包装结构也是由内而外创建的:PlayerConn -> WriteCounter -> MsgCounter。

如果你还想把最底层的 PlayerConn 创建时也输出日志,也可以,我们改一下源码:

go 复制代码
// tcp_acceptor.go 新增一个函数
func NewTcpPlayerConn(conn net.Conn, remoteAddr net.Addr) PlayerConn {
	logrus.Infof("===================[PlayerConn] New")
	return &tcpPlayerConn{
		Conn:       conn,
		remoteAddr: remoteAddr,
	}
}

func (a *TCPAcceptor) serve() {
    ...
    // 方法末尾,注释掉之前的写channel代码,改成调用NewTcpPlayerConn
    //a.connChan <- &tcpPlayerConn{
    //	Conn:       conn,
    //	remoteAddr: remoteAddr,
    //}
    a.connChan <- NewTcpPlayerConn(conn, remoteAddr)
}

再次开启服务,使用pitaya-cli连接上,看看日志,与预期的一致,最先输出的是 PlayerConn 部分的创建,由内而外依次构建:

go 复制代码
time="2023-12-05T16:28:47+08:00" level=info msg="===================[PlayerConn] New"
time="2023-12-05T16:28:47+08:00" level=info msg="===================[WriteCounter] New"
time="2023-12-05T16:28:47+08:00" level=info msg="===================[MsgCounter] New"
time="2023-12-05T16:28:47+08:00" level=debug msg="New session established: Remote=[::1]:62000, LastTime=1701764927" source=pitaya

好了,这个 demo 总算是告一段落,真的有点子复杂啊,不知道我有没有把这部分内容给讲明白,太难了......

相关推荐
2401_8576226615 分钟前
SpringBoot框架下校园资料库的构建与优化
spring boot·后端·php
2402_8575893619 分钟前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端
哎呦没1 小时前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch2 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
杨哥带你写代码3 小时前
足球青训俱乐部管理:Spring Boot技术驱动
java·spring boot·后端
AskHarries4 小时前
读《show your work》的一点感悟
后端
A尘埃4 小时前
SpringBoot的数据访问
java·spring boot·后端
yang-23074 小时前
端口冲突的解决方案以及SpringBoot自动检测可用端口demo
java·spring boot·后端
Marst Code4 小时前
(Django)初步使用
后端·python·django
代码之光_19804 小时前
SpringBoot校园资料分享平台:设计与实现
java·spring boot·后端