模板方法模式在开发中的应用
先说一下业务背景吧,公司这边需要做一个数据聚合的项目,要从各个数据源清洗出来历史数据,并进行整合统一存储。数据源大概有7、8 个,时间粒度包括历史全量数据、每天的新增数据、从某天开始至今的数据。
面对这个需求,首先的想法是,定义一个接口,抽象各个数据源的处理过程;通过一个类单独进行参数解析、数据源接口实例管理、任务分发。定义好方案之后,于是我们就开始愉快地进行开发了。
第一版接口方案
首先我们定义一个数据源数据的接口,接口定义如下
type Executor interface {
Repair(ctx context.Context, start time.Time) error
}
start 标示数据开始处理的时间,从 start 开始处理目前为止的所有数据,start 为 0 ,表示处理全部数据
还有一个类,进行参数解析、数据源接口实例管理、任务分发。类的实现代码如下
type StudentStory struct {
//管理接口实例
executors map[string]studentstory.Executor
//要执行的任务
story string
//任务开始时间
start string
//执行当天数据的回退时间
backoff time.Duration
//解析参数的锁
paramLock sync.Mutex
}
func (t *StudentStory) init()
//参数定义
t.flag.StringVar(&t.story, "story", "", "同步事件类型")
t.flag.StringVar(
&t.start,
"start",
"daily",
"同步开始时间(Y-m-d|daily|full),Y-m-d: 从 Y-m-d 开始同步; daily:从前几天开始同步数据;full:同步全量数据",
)
t.flag.DurationVar(&t.backoff, "backoff", -24*time.Hour, "")
//注册接口实例
t.register()
}
//将接口实例注册到结构体
func (t *StudentStory) register() {
t.executors["credit"] = studentstory.NewCreditExecutor(...)
t.executors["comment"] = studentstory.NewCommentExecutor(...)
...
}
func (t *StudentStory) Run(ctx context.Context, params []string) {
//参数解析,使用锁进行并发控制
t.paramLock.Lock()
err := external.FlagSetSmartParse(params, t.flag)
if err != nil {
xlog.Ctx(ctx).Errorw("studentstory: param parse error", "param", params, "err", err)
}
//为了避免并发问题,使用局部变量
story := t.story
start := t.start
backoff := t.backoff
t.reset()
t.paramLock.Unlock()
//根据 story 参数获取 execotur
xlog.Ctx(ctx).Infow("studentstory: run command", "story", story, "start", start, "backoff", backoff)
executor, ok := t.executors[story]
if !ok {
xlog.Ctx(ctx).Errorw("studentstory: executor not exists", "story", story)
return
}
//根据参数解析出来时间
var begin time.Time
if start == "daily" {
//同步当天之前一段时间的数据
yesterday := time.Now().Add(backoff)
beginStr := yesterday.Format("2006-01-02") + " 00:00:00"
begin, err = time.Parse("2006-01-02 15:04:05", beginStr)
if err != nil {
xlog.Ctx(ctx).Errorw("studentstory: time parse err", "beginStr", beginStr, "err", err)
return
}
} else if start == "full" {
//同步全部数据
begin = time.Unix(0, 0)
} else {
//根据 start 参数指定的时间同步数据
beginStr := start + " 00:00:00"
begin, err = time.Parse("2006-01-02 15:04:05", beginStr)
if err != nil {
xlog.Ctx(ctx).Errorw("studentstory: time parse err", "beginStr", beginStr, "err", err)
return
}
}
xlog.Ctx(ctx).Infow("studentstory: run executor", "story", story, "begin", begin.Format("2006-01-02 15:04:05"), "conf", cronConf)
err = executor.Repair(ctx, begin)
if err != nil {
//统一进行错误处理
}
接口方案问题
当以上方案定义好之后,接下来我们就开始愉快地写业务代码。但是在不断的接入业务源的过程中,因为接入各个数据源都是存储在数据库里面的,机智的我逐渐发现了如下问题
- Repair 接口实现缺少规范。因为目前方案没有对 Repair 接口如何实现做限制,各个业务方在实现的时候就可以随心所欲,信马由缰,缺少规范
- Repair 接口实现存在大量重复代码。因为数据源大部分都是从数据库里面接入数据,实现的流程大部分是相似,将数据进行统一保存的逻辑也是相同的,但是目前方案并没有对此流程进行抽象,所以各个业务方都要重读写这块相似代码
- Repair 接口实现代码质量无法保证。
- Repair 接口难以修改,扩展。虽然理想方案是接口定义完成之后不进行修改,但实际开发往往计划赶不上变化。而目前实现方案由于是各个数据源的类直接实现 Repair 接口,接口一旦修改就会影响到每个数据源。修改成本比较高。
模板方法实现方案
针对上面方案存在的问题,模板方法模式正好可以解决这个问题。(模板方法模式)
定义一个操作中的算法的骨架,而将一些步骤延迟到子类中,使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
通过模板方法的描述我们知道实现模板方法模式需要父类调用子类实现的模板方法,但是 go 语言的继承是通过匿名属性组合来实现的,并且父类无法调用子类的方法。
这怎么办呢?我们知道首先设计模式主要是一种思想,并没有完全严格的格式,并且大部分的设计模式都有继承和组合两种实现方法。是不是想到解决方案了,既然 go 不支持完整的继承,我们可以用组合的方式来实现模板方法模式啊。
首先我们先定义模板方法的接口
type ExecutorTemplate interface {
//描述要执行的任务
GetTitle() string
//获取某个时间点之前的最大 id
GetMaxIDBeforeCreateTime(context.Context, time.Time) (int64, error)
//获取整个数据的最大 id
GetMaxID(context.Context) (int64, error)
//获取整个数据的最小 id
GetMinID(context.Context) (int64, error)
//获取某个 id 后面的一批数据
GetItemsAfterID(context.Context, int64) ([]interface{}, error)
//将从数据库里面查出来的数据,装换成可以统一保存的数据
ConvertItemsToEvents(context.Context, []interface{}) ([]entity.StudentStoryEvent, int64, error)
}
定义好模板方法之后,接来下我们定义一个类,来利用模板方法实现算法流程
type Executor struct {
template IdExecutorTemplate
studentStorySrv *service.StudentstorySrv
}
//实现 Repair 接口,利用模板方法实现算法流程
func (e *Executor) Repair(ctx context.Context, start time.Time) error {
var startID int64
var err error
if start.IsZero() {
//同步全量数据,查出来数据的最小 id
startID, err = e.template.GetMinID(ctx)
startID -= 1
} else {
//按某个时间点同步数据,查出来时间点之前的最大 id
startID, err = e.template.GetMaxIDBeforeCreateTime(ctx, start)
}
if err != nil {
return err
}
if startID < 0 {
startID = 0
}
endID, err := e.template.GetMaxID(ctx)
if err != nil {
return err
}
//bar 是一个进度条组件,用来显示进度条,endID-startID 用来估算要处理数据的总的条数
bar := processbar.NewProcessBar(e.template.GetTitle(), endID-startID)
for {
items, err := e.template.GetItemsAfterID(ctx, startID)
if err != nil {
return err
}
if len(items) == 0 {
return nil
}
events, maxID, err := e.template.ConvertItemsToEvents(ctx, items)
if err != nil {
return err
}
err = e.studentStorySrv.StudentstoryRepo.Saves(ctx, events)
if err != nil {
return err
}
startID = maxID
bar.Advance(int64(len(items)))
}
}
func newExecutor(
studentStorySrv *service.StudentstorySrv,
template IdExecutorTemplate,
) *Executor {
return &Executor{
studentStorySrv: studentStorySrv,
template: template,
}
}
我们通过模板方法模式将数据同步的流程固定下来,很好地解决了方案一的问题
- Repair 接口由 Executor 来实现,代码有了规范,质量也有了保障
- 各个数据源的处理流程由 Executor 来实现,不用写大量的重复代码
- Repair 接口有 Executor 来实现,修改、扩展直接修改 Executor 就可以
模板方法模式的扩展
到目前为止,似乎一切都是完美的,于是我们就又开始愉快地写代码了。但是天有不测风云 ,果然写代码的过程不能是顺顺利利的。目前的查询流程是按照数据的创建时间查出来id,然后按照 id 来取数据。
突然有一天又要接入两个新的数据源,一个是数据有更新,更新也要获取到;一个是通过接口取数据,接口只支持按照时间取数据,这就让我头疼了。最开始的我的想法是扩展 Executor 的 Repair 的执行流程,让它支持新的查询方案,但总觉得怪怪的。因为代码会变得越来越复杂,也会越来越难以维护,并且也违背了职责单一原则。
突然聪明的我又灵机一动,在写一个模板来实现这个处理流程不就行了。果然只要想对了方向,一切都会豁然开朗。于是我将上线的模板接口和 Executor 改名为 IdExecutorTemplate 和 IdExecotor,并对新的需求,建新的接口 TimeExecutorTemplate 和执行器 TimeExecotor来实现
TimeExecutorTemplate 接口定义如下
type TimeExecutorTemplate interface {
//描述要执行的任务
GetTitle() string
//获取时间步长
GetTimeStep() int64
//从某个时间范围内获取一批数据
GetItemsBetweenTime(context.Context, time.Time, time.Time, int64) ([]interface{}, error)
//获取某个时间范围的数据总数
GetTotalBetweenTime(context.Context, time.Time, time.Time) (int64, error)
//获取某个时间之后的数据总数
GetTotalAfterTime(context.Context, time.Time) (int64, error)
//获取所有数据的最小时间
GetMinTime(context.Context) (time.Time, error)
//将从数据库里面查出来的数据,装换成可以统一保存的数据
ConvertItemsToEvents(context.Context, []interface{}) ([]entity.StudentStoryEvent, error)
}
TimeExecotor 类实现如下
var globalBar *processbar.ProcessBar
var stepBar *processbar.ProcessBar
type TimeExecutor struct {
template TimeExecutorTemplate
studentStorySrv *service.StudentstorySrv
}
func (e *TimeExecutor) Repair(ctx context.Context, startTime time.Time, conf config.StudentStoryDataCron) error {
var err error
endTime := time.Now()
if startTime.IsZero() {
//获取全量数据,取出来最小时间
startTime, err = e.template.GetMinTime(ctx)
if err != nil {
return err
}
}
//获取全部要处理的数据总数
total, err := e.template.GetTotalAfterTime(ctx, startTime)
if err == nil {
//如果获取成功,定义全局进度条
globalBar = processbar.NewProcessBar(e.template.GetTitle(), total)
}
//因为按照时间取数据,一般都会用到分页进行查询,为了尽量分页的 offset 过大,将时间进行分段查询
timeStep := time.Duration(e.template.GetTimeStep()) * time.Second
for stepStartTime := startTime; stepStartTime.Before(endTime); stepStartTime = stepStartTime.Add(timeStep) {
stepEndTime := stepStartTime.Add(timeStep)
if globalBar == nil {
//全局进度条初始化失败,初始化 step 进度条
stepTotal, err := e.template.GetTotalBetweenTime(ctx, stepStartTime, stepEndTime)
if err == nil {
title := fmt.Sprintf("%s [%s - %s]", e.template.GetTitle(), stepStartTime.Format("2006.01.02 15:04:05"), stepEndTime.Format("2006.01.02 15:04:05"))
stepBar = processbar.NewProcessBar(title, stepTotal)
}
}
var offset int64
for {
//按照 step 时间查询数据
items, err := e.template.GetItemsBetweenTime(ctx, stepStartTime, stepEndTime, offset)
if err != nil {
return err
}
if len(items) == 0 {
break
}
events, err := e.template.ConvertItemsToEvents(ctx, items)
if err != nil {
return err
}
err = e.studentStorySrv.StudentstoryRepo.Saves(ctx, events)
if err != nil {
return err
}
offset += int64(len(items))
if globalBar != nil {
globalBar.Advance(int64(len(items)))
}
if stepBar != nil {
stepBar.Advance(int64(len(items)))
}
}
}
return nil
}
func newTimeExecutor(
studentStorySrv *service.StudentstorySrv,
template TimeExecutorTemplate,
) *TimeExecutor {
return &TimeExecutor{
studentStorySrv: studentStorySrv,
template: template,
}
}
哈哈,现在我们就又可以愉快地写代码了
类 UML 图
接下来我们我们来看一下这些类的 UML 图
总结
- 抽象的过程是从具体到抽象,在从抽象到具体。没有具体的 case ,没有具体的需求,抽象是没有意义的
- 设计模式最重要的是思想,掌握思想,可以使用各种方法实现。比如这个需求最终实现的效果,如果看 UML 图的话,更像是桥接模式。并且整体分析的话,也有符合桥接模式的思想。但从本质上来说,还是模板方法模式
- 抽象不是银弹,抽象不能解决所有问题,并且抽象是有害的。抽象在规范的同时,也屏蔽了细节,并且失去了灵活性。比如上面的例子,Repair 接口的抽象使得接口的修改变得困难,Tempate 接口的抽象,使得接入的数据源只能按照接口提供的方法实现功能。