likes
comments
collection
share

手把手带你从0到1封装Gin框架:10 事件机制&单元测试

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

项目源码

Github

前言

想象这样一个场景,在电商系统中,用户创建订单之后需要先占用一下商品库存并等待用户支付,用户支付成功之后需要通知用户下单成功并将订单推送给离收货地址最近的仓库进行发货,用户超时未支付时还需要自动关闭订单并归还库存。

上边的业务场景很常见,我们在开发中可以挨着业务逻辑往下写,也可以做一些封装。仔细观察可以发现有些逻辑是强关联的,比如下单占用库存,关闭订单归还库存,但是也有一些逻辑并不需要强关联,比如支付成功通知用户和推单给仓库,这些甚至可以通过异步的方式去执行。这就涉及到一个解耦的问题,事件机制就很好的解决了这一问题

事件机制是一种软件设计和编程中的通信模式,它基于事件的产生(发布)和对这些事件的响应(处理)来实现系统内各部分之间的交互与协作。

在事件机制中,当某个特定的条件被满足、操作被执行或者状态发生变化时,一个事件就会被生成(发布)。这个事件通常包含有关发生了什么事情以及相关数据的信息。

另一方面,系统中其他部分可以预先注册对特定类型事件的兴趣(订阅)。当对应的事件被发布后,订阅了该事件的部分就会收到通知,并执行相应的处理逻辑来响应这个事件。

例如,在一个图形用户界面应用中,点击按钮是一个事件,当用户点击按钮时,该事件被发布,而预先订阅了该按钮点击事件的功能模块会接收到通知并执行相应的操作,如显示新的窗口、执行数据处理等。

总的来说,事件机制通过解耦事件的生产者和消费者,使系统更加灵活、可扩展和易于维护。

系统中引入事件机制

新建internal/event/event.go文件:

package event

type EventInterface interface {
	Name() string
}

新建internal/event/listener.go文件:

package event

type ListenerInterface interface {
	Listen() []EventInterface
	Process(EventInterface)
}

新建internal/event/dispatcher.go文件:

package event

import "sync"

type Dispatcher struct {
	listenerMap map[string][]ListenerInterface
	mu          sync.RWMutex
}

func New() *Dispatcher {
	return &Dispatcher{
		listenerMap: make(map[string][]ListenerInterface),
		mu:          sync.RWMutex{},
	}
}

// Register 注册监听者
func (d *Dispatcher) Register(listener ListenerInterface) {
	d.mu.Lock()
	defer d.mu.Unlock()

	// 获取监听者监听的事件列表
	eventList := listener.Listen()

	// 记录事件和监听者的关系
	for _, event := range eventList {
		d.listenerMap[event.Name()] = append(d.listenerMap[event.Name()], listener)
	}
}

// Dispatch 触发事件
func (d *Dispatcher) Dispatch(event EventInterface) {
	// 获取事件的监听者列表
	listenerList, exist := d.listenerMap[event.Name()]
	if !exist {
		return
	}

	// 执行listener的process方法
	for _, listener := range listenerList {
		listener.Process(event)
	}
}

修改internal/global/global.go文件:

package global

import (
	"eve/internal/config"
	"eve/internal/event"
	"go.uber.org/zap"
	"gorm.io/gorm"
)

var (
	Config          *config.Config
	DB              *gorm.DB
	Logger          *zap.Logger
	EventDispatcher *event.Dispatcher
)

修改internal/bootstrap/init.go文件:

package bootstrap

import (
	"eve/internal/config"
	"eve/internal/event"
	"eve/internal/global"
	"eve/internal/logger"
	"eve/internal/mysql"
	"eve/internal/validator"
)

func init() {
	var err error

	// 初始化配置文件
	global.Config = config.GetConfig()

	// 初始化数据库
	global.DB = mysql.GetConnection()

	// 初始化日志
	if global.Logger, err = logger.New(); err != nil {
		panic(err)
		return
	}

	// 注册验证器
	validator.InitValidator()

	// 初始化事件机制
	global.EventDispatcher = event.New()
}

新增app/event/event/foo_event.go文件定义事件实体:

package event

type FooEvent struct {
}

func (f *FooEvent) Name() string {
	return "foo_event"
}

新增app/event/listener/foo_listener.go文件:

package listener

import (
	appEvent "eve/app/event/event"
	"eve/internal/event"
	"fmt"
)

type FooListener struct{}

func (f FooListener) Listen() []event.EventInterface {
	return []event.EventInterface{
		&appEvent.FooEvent{},
	}
}

func (f FooListener) Process(e event.EventInterface) {
	fmt.Println("foo listener process event:", e, e.Name())
}

新增app/event/listener/bar_listener.go文件:

package listener

import (
	appEvent "eve/app/event/event"
	"eve/internal/event"
	"fmt"
)

type BarListener struct{}

func (f BarListener) Listen() []event.EventInterface {
	return []event.EventInterface{
		&appEvent.FooEvent{},
	}
}

func (f BarListener) Process(e event.EventInterface) {
	fmt.Println("bar listener process event:", e, e.Name())
}

再新增注册初始化文件app/event/register.go文件:

package event

import (
	"eve/app/event/listener"
	"eve/internal/event"
	"eve/internal/global"
)

var listenerList = []event.ListenerInterface{
	listener.FooListener{},
	listener.BarListener{},
}

func init() {
	for _, l := range listenerList {
		global.EventDispatcher.Register(l)
	}
}

修改cmd/start.go文件,引入注册初始化文件:

// Package cmd /*
package cmd

import _ "eve/app/event"

import (
	"eve/app/route"
	"eve/internal/server"
	"github.com/spf13/cobra"
)

// startCmd represents the start command
var startCmd = &cobra.Command{
	Use:   "start",
	Short: "start serve",
	Long:  `start serve`,
	Run: func(cmd *cobra.Command, args []string) {
		run()
	},
}

func init() {
	rootCmd.AddCommand(startCmd)
}

func run() {
	http := server.New()
	http.GenRouter(route.New())
	http.Run()
}

上边的代码中,实现了事件机制的逻辑,并注册了监听者

测试

功能完成了我们需要测试一下,可以把功能写在接口里边启动服务之后请求一下看看测试效果,当然这是很低效的方法,也很不可取,我们在这里使用一下golang的test

在根目录下创建test目录,并创建test/bootstrap.go文件:

package test

import _ "eve/internal/bootstrap"
import _ "eve/app/event"

这个文件主要用来做一些项目初始化的事情

再创建文件test/event_test.go文件:

package test

import (
	"eve/app/event/event"
	"eve/internal/global"
	"testing"
)

func TestEvent(t *testing.T) {
	global.EventDispatcher.Dispatch(&event.FooEvent{})
}

这个文件中定义了一个TestEvent方法,方法内抛出了一个event.FooEvent事件,这个事件有两个监听者,分别是listener.FooListenerlistener.BarListener,监听到事件时会输出信息到控制台,现在我们执行一下这个测试方法

执行测试方法有两种途径,第一种是利用IDEA的快捷方式,在方法名称左侧有一个绿色的右三角,点击之后选择第一个选项执行即可,也可以进入test目录执行命令go test -run TestEvent,这里试一下第二种:

➜  eve_api git:(main) ✗ cd test
➜  test git:(main) ✗ go test -run TestEvent
ReadInConfigError:  open /Users/lining/Documents/develop/book/eve_api/test/config.yaml: no such file or directory
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x102e333f0]

goroutine 1 [running]:
eve/internal/mysql.GetConnection()
        /Users/lining/Documents/develop/book/eve_api/internal/mysql/mysql.go:13 +0x30
eve/internal/bootstrap.init.0()
        /Users/lining/Documents/develop/book/eve_api/internal/bootstrap/init.go:19 +0x54
exit status 2
FAIL    eve/test        0.536s

可以看到报错了,提示配置文件eve_api/test/config.yaml不存在,这个确实是有问题,因为我们的配置文件在项目根目录下,没有在test目录下,那我们去调整一下

当前internal/config/config.go文件中的写法是:

// GetConfig 读取配置文件
func GetConfig() (c *Config) {
	// 获取项目的根目录
	rootDir, _ := tool.GetRootDir()

	// 实例化viper,并根据地址读取配置文件
	v := viper.New()
	v.SetConfigFile(rootDir + "/config.yaml")
	err := v.ReadInConfig()
	if err != nil {
		fmt.Println("ReadInConfigError: ", err)
		return
	}

	// 将读取到的配置文件绑定到返回参数c
	err = v.Unmarshal(&c)
	if err != nil {
		fmt.Println("ConfigUnmarshalError: ", err)
		return
	}

	return
}

它是通过tool.GetRootDir方法获取项目根目录的,其实它是获取执行的入口文件所在目录,我们在test目录执行单元测试,那就相当于入口文件在test目录,所以才回去test目录取配置文件,这里我们改一下,通过当前config.go文件的相对路径去取配置文件,改成:

// GetConfig 读取配置文件
func GetConfig() (c *Config) {
	// 获取项目的根目录
	_, filePath, _, _ := runtime.Caller(0)
	dirPath := path.Dir(filePath)
	rootDir := dirPath + "/../../"

	// 实例化viper,并根据地址读取配置文件
	v := viper.New()
	v.SetConfigFile(rootDir + "/config.yaml")
	err := v.ReadInConfig()
	if err != nil {
		fmt.Println("ReadInConfigError: ", err)
		return
	}

	// 将读取到的配置文件绑定到返回参数c
	err = v.Unmarshal(&c)
	if err != nil {
		fmt.Println("ConfigUnmarshalError: ", err)
		return
	}

	return
}

改完之后再运行测试命令:

➜  test git:(main) ✗ go test -run TestEvent
foo listener process event: &{} foo_event
bar listener process event: &{} foo_event
PASS
ok      eve/test        0.640s

可以看到命令行输出了两条信息,分别是两个监听者所输出,证明我们的代码没问题

总结

  • 事件机制在系统开发中的应用场景
  • Golang项目中集成事件机制
  • Golang项目中如何做一些简单的单元测试

commit-hash: 9247d7a

转载自:https://juejin.cn/post/7397015953446895651
评论
请登录