likes
comments
collection
share

如何在项目中正确使用责任链模式?

作者站长头像
站长
· 阅读数 4

我列一些我写过的设计模式文章,若有兴趣可以点链接看看。

写作背景

责任链模式很早前学习且简单用于项目里。最近有一个 case 更复杂,结合工厂模式和策略模式一起使用,顺带把一些思考总结下来。

名词解释

责任链模式(英语:Chain Of Responsibility Design Pattern),是一种行为设计模式,多个处理器被连接成一条链。当一个请求进入这条链时,每个处理器都有机会对请求进行处理。如果处理器可以处理请求,则处理请求并返回结果,如果处理器无法处理请求,则将请求传递给链中的下一个处理器。请求会在链上依次传递,直到有一个处理器能够处理请求或者到达链的尾部(也可以是请求被所有处理器都处理一遍,参考:Go gin 框架)。

如果不好理解我画个图理解下 如何在项目中正确使用责任链模式?

处理器链负责分发处理器,处理器链由 HandlerAImpl、HandlerBImpl 和 HandlerCImpl...等处理器组成,每个处理器各自承担各自的处理职责。当一个请求进入处理器链时,首先经过HandlerAImpl 处理器处理,处理完毕后再传递给 HandlerBImpl 处理器,最后传递给 HandlerCImpl 处理器。依次类推,直到请求被处理或者到达链的尾部。

责任链有 3 个名词需要了解下

抽象接口 Handler

抽象接口(定义一个标准)。通常包含一个处理方法 Handle(),提供给所有处理器实现。

处理器 HandlerImpl

实现了 Handler 接口的处理器(子类)。每个处理器根据自己的条件来决定是否能够处理请求,如果可以处理,则进行处理,否则将请求传递给下一个处理器;处理器也可以中断,整个链路退出。

处理器链 HandlerChain

处理器链管理处理器类,也可以理解成编排处理器类,它内部维护一个链表或数组主要用于整条链的关系维系(责任链模式用这两种数据结构都可以哈,我比较喜欢用数组)。

看文字可能有点抽象,我结合代码案例和文字方便理解

type Options struct {
}

type ChainsVO struct {
	s string
}


// Handler 定义抽象
type Handler interface {
	Handle(ctx context.Context, options *Options) (*ChainsVO, error)
}

// HandleAImpl 处理器A
type HandleAImpl struct {
}

func NewAImpl() Handler {
	return &HandleAImpl{}
}

func (h *HandleAImpl) Handle(ctx context.Context, options *Options) (*ChainsVO, error) {
	return &ChainsVO{s: "HandleAImpl"}, nil
}

// HandleBImpl 处理器B
type HandleBImpl struct {
}

func NewBImpl() Handler {
	return &HandleBImpl{}
}

func (h *HandleBImpl) Handle(ctx context.Context, options *Options) (*ChainsVO, error) {
	return &ChainsVO{s: "HandleBImpl"}, nil
}

// HandleCImpl 处理器C
type HandleCImpl struct {
}

func NewCImpl() Handler {
	return &HandleCImpl{}
}

func (h *HandleCImpl) Handle(ctx context.Context, options *Options) (*ChainsVO, error) {
	return &ChainsVO{s: "HandleCImpl"}, nil
}

// HandlersChains 处理器链,编排处理器
type HandlersChains struct {
	handlers []Handler
}

func NewHandlersChains() *HandlersChains {
	return &HandlersChains{
		handlers: make([]Handler, 0),
	}
}

func (h *HandlersChains) WithHandlers(handlers ...Handler) *HandlersChains {
	h.handlers = append(h.handlers, handlers...)
	return h
}

func (h *HandlersChains) Handle(ctx context.Context, options *Options) error {
	for _, v := range h.handlers {// 循环便利执行处理器类
		res, err := v.Handle(ctx, options)
		if err != nil {
			return err 
		}
		fmt.Println(res.s)
	}
	return nil
}

上段代码是完整责任链模式代码,责任链模式最重要的是 HandlersChains 处理器链,它做了 2 件事。

1、  处理器编排,可以由业务方决定链执行顺序。

2、  处理器串行触发,触发用 for 循环遍历执行 Handle 。

HandleXImpl 处理器类返回 err 不为空退出整个链执行。这种方式就是典型的遇到 err 就终止;其实还有一种变体,请求会被所有的处理器都处理一遍。

这里留一个问题,HandlersChains.Handle() 方法除了用 for 循环遍历执行处理器类业务逻辑,还有其他其它方案吗?后面给答案。

框架实战

责任链模式在框架层面使用非常广,主要是为框架提供扩展点,框架的使用者在不修改框架源码的情况下,基于扩展点添加新的功能。责任链模式最常用来开发框架的过滤器和拦截器,我喜欢称它们为中间件。接下来我介绍一个高性能的 http 框架。

Go Gin 框架

Gin 是一个轻量级的 Web 框架,用于构建高性能的Web应用程序。它基于Go语言,并提供了简洁的API和快速的路由引擎,使得开发者可以快速地构建 RESTful Web 服务和 Web 应用程序。

github 地址

https://github.com/gin-gonic/gin

先介绍 gin 使用,如果已经熟悉的同学可以跳过

// initRouter 初始化路由
func initRouter(router *gin.Engine) error {
	router.GET("/ready"func(ctx *gin.Context) {
		ctx.String(http.StatusOK, "ok")
	})

	router.GET("/healthy"func(ctx *gin.Context) {
		ctx.String(http.StatusOK, "ok")
	})
	return nil
}

func router() http.Handler {
	r := gin.New()
	if err := initRouter(r); err != nil {
		return r
	}

	return r
}

var httpServer *http.Server

func Serve() error {
	httpServer = &http.Server{
		Addr:         fmt.Sprintf("%s:%s", "127.0.0.1", "7878"),
		Handler:      router(),
		ReadTimeout:  300 * time.Second,
		WriteTimeout: 300 * time.Second,
	}
	return httpServer.ListenAndServe()
}

var (
	g errgroup.Group
)

func main() {
	g.Go(func() error {
		if err := Serve(); err != nil && err != http.ErrServerClosed {
			panic(err)
		}
		return nil
	})

	if err := g.Wait(); err != nil {
		panic(err)
	}
}

启动服务一个简单的 http 服务器运行成功了,用 Postman 请求。

curl --location 'http://127.0.0.1:7878/ready'

结果输出

ok

那责任链模式在Gin中用于哪些场景?

中间件(拦截器、鉴权、日志。。。)

基于 Gin 框架来实现拦截器能力是非常简单的,只要注册一个中间件函数就可以了。

注册中间件之后的路由会被增强,而未注册中间件的路由不受影响;

// 鉴权中间件
func Auth(ctx *gin.Context) {
	fmt.Println("鉴权中间件") // session、cook、校验。。。做一些上下文信息传递等。。
	ctx.Next() // 执行下一个中间件
}

// 日志打印中间件
func Log(ctx *gin.Context) {
	fmt.Println("日志中间件")
	ctx.Next() // 执行下一个中间件
}

// initRouter 初始化路由
func initRouter(router *gin.Engine) error {
	group := router.Group("test")
	group.Use(Auth)
	group.Use(Log)
	group.GET("/ready", func(ctx *gin.Context) {
		fmt.Println("ready 执行业务逻辑")
		ctx.String(http.StatusOK, "ok")
	})
	return nil
}

定义2个中间件函数 Auth 和 Log,参数是 *gin.Context。

Auth 函数:session、cook 校验、做一些上下文信息透传等。

Log 函数:打印前端请求body、返回值body等。

将 2 个中间件函数分别用 group.Use() 函数注入 gin 框架,当有请求到来时,会先经过中间件 Auth 和 Log,然后才执行业务代码。

结果输出

鉴权中间件
日志中间件
ready 执行业务逻辑

从代码中,我们不难看出,添加中间件是非常方便的,不需要修改任何代码的。定义一个函数入参是 *gin.Context,通过 group.Use() 注入框架就搞定了。那 gin 底层代码是如何做到如此好的扩展性的呢?先卖个关子(可能你已经猜到用责任链模式了),下面我来剖析下源码。

源码剖析

源码位置

github.com/gin-gonic/gin@v1.9.1/gin.go:48

上面讲过责任链模式实现包含:抽象接口(Handler)、处理器类(HandlerImpl)、处理器链(HandlerChain)。

Gin 框架里没有定义接口,定义一个标准处理函数 HandlerFunc

type HandlerFunc func(*Context)

HandlerChain 定义也比较简单,用切片实现存 HandlerFunc

// HandlersChain 定义了一个 HandlerFunc 切片
type HandlersChain []HandlerFunc

下面我们重点看下 HandlerChain 是如何被引用的。我对代码进行了一些简化,详细代码自己去看。

HandlerChain 被引用源码位置

github.com/gin-gonic/gin@v1.9.1/routergroup.go:55
type RouterGroup struct {
	Handlers HandlersChain // 你注册的中间件
	basePath string // 基础路径
}

basePath 是基础路径,例如:group := router.Group("test"),基础路径是"/test",你可以调 group.BasePath() 验证。

中间件注册也比较简单,当我们调用 Use() 函数,Use 函数会把 middleware 添加到 Handlers 里,这只是其中一种方式。

func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
	group.Handlers = append(group.Handlers, middleware...)
	return group.returnObj()
}

如何触发 Handlers 调用的呢?我们我 Debug 打一个断点看看调用链路。 如何在项目中正确使用责任链模式?

Context 结构体内有一个 Next 方法。源码位置

github.com/gin-gonic/gin@v1.9.1/context.go:171
func (c *Context) Next() {
	c.index++
	for c.index < int8(len(c.handlers)) {
		c.handlers[c.index](c)
		c.index++
	}
}

Context 结构体内 handlers 是处理请求时被赋值的(客户端发起 http 请求,gin 框架收到请求后 handlers 就会被赋值),源码位置

/github.com/gin-gonic/gin@v1.9.1/gin.go:618
..... 省略代码
if value.handlers != nil {
	c.handlers = value.handlers
	c.fullPath = value.fullPath
	c.Next()
	c.writermem.WriteHeaderNow()
	return
}
..... 省略代码

细心的读者会发现中间件会调用 ctx.Next() 方法,整个链就会被执行下去,直到数组末尾。

func Log(ctx *gin.Context) {
	fmt.Println("日志中间件")
	ctx.Next() // 执行下一个中间件
}

上面这段代码是处理器串行执行第二种方案。

项目实战

本文以“SOP”为案例,“SOP”也叫”营销自动化“。所谓 SOP,是 Standard Operating Procedure 三个单词中首字母的大写 ,即标准作业程序,指将某一事件的标准操作步骤和要求以统一的格式描述出来,用于指导和规范日常的工作标准作业程序_百度百科。比如你去餐厅,服务人员先给你拿菜单、点菜、上菜、买单这一套操作就是标准的流程。

”SOP“旨在帮助企业将复杂场景的用户运营策略自动化执行,并提供运营效果量化追踪的平台。比如:自动发短信、发消息等。

比如你要在情人节给朋友圈客户发送满减活动。你会经历下面这几步

1、  谁来发「员工」

2、  发给谁「客户」

3、  发什么「内容」

4、  何时发「时间」

5、  怎么发「通道」

这次围绕动作引擎展开,一条 SOP 触发可能会有下面几种动作编排:

1、  配置一:生成 N 条员工任务(动作1),发送通知(动作2),调用企业微信接口触达客户(动作3)。

2、  配置二:发送通知(动作1)、调用企业微信接口触达客户(动作2)。

3、  .....

根据配置不同, SOP 编排的动作链不同。所以决定把动作抽象下(代码能更好复用),粒度更细、更原子、职责更明确;方便上游能任一编排(顺序任一),所以考虑用责任链模式,在未做重构前编码非常臃肿、代码复用低,新增配置都要实现一套调用链路。

定义抽象接口 Handler

定义接口标准,用于处理器类实现

// Handler 责任链处理器,主要用于编排动作
type Handler interface {
	Handle(ctx context.Context, handler *HandlersChains) error
}
定义处理器类
type ActionAImpl struct {
}

func NewAImpl() Handler {
	return &ActionAImpl{}
}

func (h *ActionAImpl) Handle(ctx context.Context, handler *HandlersChains) error {
	fmt.Println("ActionAImpl 动作执行完成")
	return handler.Next(ctx)
}

type ActionBImpl struct {
}

func NewBImpl() Handler {
	return &ActionBImpl{}
}

func (h *ActionBImpl) Handle(ctx context.Context, handler *HandlersChains) error {
	fmt.Println("ActionBImpl 动作执行完成")
	return handler.Next(ctx)
}
定义处理器链 HandlerChain
type HandlersChains struct {
	handlers []func() Handler
	index    int
}

func NewHandlersChains() *HandlersChains {
	return &HandlersChains{
		handlers: make([]func() Handler, 0),
	}
}

func (h *HandlersChains) WithHandlers(handlers ...func() Handler) *HandlersChains {
	h.handlers = append(h.handlers, handlers...)
	return h
}

func (h *HandlersChains) HasNext() bool {
	return h.index < len(h.handlers)
}

func (h *HandlersChains) Next(ctx context.Context) error {
	if h.HasNext() {
		tf := h.handlers[h.index]
		h.index++

		return tf().Handle(ctx, h)
	}

	return nil
}
init 注册和编排动作

提前注册动作,方便业务方使用。下段代码用了策略模式和工厂模式,主要是用于根据不同 SOP 配置(ActionType)创建不同对象;不同配置(ActionType) 对应不同的动作编排、执行不同动作。

type ActionType string

const (
	Action1Type ActionType = "action1"
)

var (
	mapping = make(map[ActionType][]func() Handler)
)

func init() {
	register(Action1Type, func() Handler {
		return NewAImpl()
	}, func() Handler {
		return NewBImpl()
	})
}

func register(k ActionType, fn ...func() Handler) {
	mapping[k] = fn
}

func lookup(k ActionType) ([]func() Handler, bool) {
	v, exists := mapping[k]
	return v, exists
}

func trigger(ctx context.Context, k ActionType) error {
	fns, exists := lookup(k)
	if !exists {
		return errors.New("未找到处理器类")
	}
	chains := NewHandlersChains().WithHandlers(fns...)
	return chains.Next(ctx)
}
业务使用方
func TestChains(t *testing.T) {
	err := trigger(context.TODO(), Action1Type)
	if err != nil {
		panic(err)
	}
}

结果输出:

=== RUN   TestChains
ActionAImpl 动作执行完成
ActionBImpl 动作执行完成
--- PASS: TestChains (0.00s)
PASS

好了,案例讲完了。当前业务场景集合了策略模式、工厂模式。不同的场景设计模式的搭配使用不同,大家自己探究。另外,在案例中当前处理器包含对下一个处理器的调用,和 for 循环调用有一些区别,这两种方案分别是在哪些场景使用?文章末尾揭晓。

有人会说了,责任链模式用于框架更合理呀?其实这是我第2次把责任链模式用于业务上,引入责任链后收益是代码扩展性更好、复用的代码更多了(另外建议大家多尝试,只有尝试了你才知道是否适合你的业务场景)。

文章总结

责任链模式常用在框架中,用于实现过滤、拦截器功能,让使用框架的开发者在不需要修改框架代码添加自定义的中间件能力。

责任链模式中,存在两种模式。1、假如有某个处理器处理了请求,就不会传递给后面的处理器。2、处理器不会被终止,而是被所有处理器都处理一次(gin 框架就是例子)。

责任链模式中,处理器链(HandlerChain)有 2 种数据结构实现。第一种是数组存处理器类;第二种是链表存处理器类;据我观察用数组的偏多,因为实现比较简单。

末尾讨论

1、你们项目中有用责任链模式吗?用于什么场景?

2、处理器链中处理器类串行执行业务逻辑用 for 循环遍历调用?还是在处理器类中调用?请你们思考下。2 种方案有不同的使用场景,我讲下我的理解。


用 for 循环,对框架使用者来说成本更低,对于一个不熟悉这种代码结构的开发人员来说,添加新的处理器类,很有可能忘记在处理器类方法中调用 ctx.Next() 方法,会导致代码出现 bug,而且不好排查问题。

处理器类中包含对下一个处理器类调用 ctx.Next() ,这类场景一般在整条处理器链中,处理器类复用了某一个对象、数据、或前面处理器类产生的数据被后面处理器类依赖,比如 gin 框架。如果你对框架熟悉,你思考下你在用『group.Use(Log)』注入的中间件是不是需要从 context 中获取数据,亦或把需要透传的数据设置到 context 里?