模板方法模式在開發(fā)中的應(yīng)用
先說一下業(yè)務(wù)背景吧变姨,公司這邊需要做一個數(shù)據(jù)聚合的項目聂喇,要從各個數(shù)據(jù)源清洗出來歷史數(shù)據(jù)跷敬,并進行整合統(tǒng)一存儲。數(shù)據(jù)源大概有7亡蓉、8 個,時間粒度包括歷史全量數(shù)據(jù)遍尺、每天的新增數(shù)據(jù)质蕉、從某天開始至今的數(shù)據(jù)。
面對這個需求传睹,首先的想法是耳幢,定義一個接口,抽象各個數(shù)據(jù)源的處理過程蒋歌;通過一個類單獨進行參數(shù)解析帅掘、數(shù)據(jù)源接口實例管理、任務(wù)分發(fā)堂油。定義好方案之后修档,于是我們就開始愉快地進行開發(fā)了。
第一版接口方案
首先我們定義一個數(shù)據(jù)源數(shù)據(jù)的接口府框,接口定義如下
type Executor interface {
Repair(ctx context.Context, start time.Time) error
}
start 標示數(shù)據(jù)開始處理的時間吱窝,從 start 開始處理目前為止的所有數(shù)據(jù),start 為 0 迫靖,表示處理全部數(shù)據(jù)
還有一個類院峡,進行參數(shù)解析、數(shù)據(jù)源接口實例管理系宜、任務(wù)分發(fā)照激。類的實現(xiàn)代碼如下
type StudentStory struct {
//管理接口實例
executors map[string]studentstory.Executor
//要執(zhí)行的任務(wù)
story string
//任務(wù)開始時間
start string
//執(zhí)行當天數(shù)據(jù)的回退時間
backoff time.Duration
//解析參數(shù)的鎖
paramLock sync.Mutex
}
func (t *StudentStory) init()
//參數(shù)定義
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:從前幾天開始同步數(shù)據(jù)盹牧;full:同步全量數(shù)據(jù)",
)
t.flag.DurationVar(&t.backoff, "backoff", -24*time.Hour, "")
//注冊接口實例
t.register()
}
//將接口實例注冊到結(jié)構(gòu)體
func (t *StudentStory) register() {
t.executors["credit"] = studentstory.NewCreditExecutor(...)
t.executors["comment"] = studentstory.NewCommentExecutor(...)
...
}
func (t *StudentStory) Run(ctx context.Context, params []string) {
//參數(shù)解析俩垃,使用鎖進行并發(fā)控制
t.paramLock.Lock()
err := external.FlagSetSmartParse(params, t.flag)
if err != nil {
xlog.Ctx(ctx).Errorw("studentstory: param parse error", "param", params, "err", err)
}
//為了避免并發(fā)問題,使用局部變量
story := t.story
start := t.start
backoff := t.backoff
t.reset()
t.paramLock.Unlock()
//根據(jù) story 參數(shù)獲取 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
}
//根據(jù)參數(shù)解析出來時間
var begin time.Time
if start == "daily" {
//同步當天之前一段時間的數(shù)據(jù)
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" {
//同步全部數(shù)據(jù)
begin = time.Unix(0, 0)
} else {
//根據(jù) start 參數(shù)指定的時間同步數(shù)據(jù)
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 {
//統(tǒng)一進行錯誤處理
}
接口方案問題
當以上方案定義好之后汰寓,接下來我們就開始愉快地寫業(yè)務(wù)代碼口柳。但是在不斷的接入業(yè)務(wù)源的過程中,因為接入各個數(shù)據(jù)源都是存儲在數(shù)據(jù)庫里面的有滑,機智的我逐漸發(fā)現(xiàn)了如下問題
- Repair 接口實現(xiàn)缺少規(guī)范跃闹。因為目前方案沒有對 Repair 接口如何實現(xiàn)做限制,各個業(yè)務(wù)方在實現(xiàn)的時候就可以隨心所欲毛好,信馬由韁望艺,缺少規(guī)范
- Repair 接口實現(xiàn)存在大量重復(fù)代碼。因為數(shù)據(jù)源大部分都是從數(shù)據(jù)庫里面接入數(shù)據(jù)肌访,實現(xiàn)的流程大部分是相似荣茫,將數(shù)據(jù)進行統(tǒng)一保存的邏輯也是相同的,但是目前方案并沒有對此流程進行抽象场靴,所以各個業(yè)務(wù)方都要重讀寫這塊相似代碼
- Repair 接口實現(xiàn)代碼質(zhì)量無法保證啡莉。
- Repair 接口難以修改港准,擴展。雖然理想方案是接口定義完成之后不進行修改咧欣,但實際開發(fā)往往計劃趕不上變化浅缸。而目前實現(xiàn)方案由于是各個數(shù)據(jù)源的類直接實現(xiàn) Repair 接口,接口一旦修改就會影響到每個數(shù)據(jù)源魄咕。修改成本比較高衩椒。
模板方法實現(xiàn)方案
針對上面方案存在的問題,模板方法模式正好可以解決這個問題哮兰。(模板方法模式)
定義一個操作中的算法的骨架毛萌,而將一些步驟延遲到子類中,使得子類可以不改變一個算法的結(jié)構(gòu)即可重定義該算法的某些特定步驟喝滞。
通過模板方法的描述我們知道實現(xiàn)模板方法模式需要父類調(diào)用子類實現(xiàn)的模板方法阁将,但是 go 語言的繼承是通過匿名屬性組合來實現(xiàn)的,并且父類無法調(diào)用子類的方法右遭。
這怎么辦呢做盅?我們知道首先設(shè)計模式主要是一種思想,并沒有完全嚴格的格式窘哈,并且大部分的設(shè)計模式都有繼承和組合兩種實現(xiàn)方法吹榴。是不是想到解決方案了,既然 go 不支持完整的繼承滚婉,我們可以用組合的方式來實現(xiàn)模板方法模式啊图筹。
首先我們先定義模板方法的接口
type ExecutorTemplate interface {
//描述要執(zhí)行的任務(wù)
GetTitle() string
//獲取某個時間點之前的最大 id
GetMaxIDBeforeCreateTime(context.Context, time.Time) (int64, error)
//獲取整個數(shù)據(jù)的最大 id
GetMaxID(context.Context) (int64, error)
//獲取整個數(shù)據(jù)的最小 id
GetMinID(context.Context) (int64, error)
//獲取某個 id 后面的一批數(shù)據(jù)
GetItemsAfterID(context.Context, int64) ([]interface{}, error)
//將從數(shù)據(jù)庫里面查出來的數(shù)據(jù),裝換成可以統(tǒng)一保存的數(shù)據(jù)
ConvertItemsToEvents(context.Context, []interface{}) ([]entity.StudentStoryEvent, int64, error)
}
定義好模板方法之后让腹,接來下我們定義一個類远剩,來利用模板方法實現(xiàn)算法流程
type Executor struct {
template IdExecutorTemplate
studentStorySrv *service.StudentstorySrv
}
//實現(xiàn) Repair 接口,利用模板方法實現(xiàn)算法流程
func (e *Executor) Repair(ctx context.Context, start time.Time) error {
var startID int64
var err error
if start.IsZero() {
//同步全量數(shù)據(jù)哨鸭,查出來數(shù)據(jù)的最小 id
startID, err = e.template.GetMinID(ctx)
startID -= 1
} else {
//按某個時間點同步數(shù)據(jù),查出來時間點之前的最大 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 用來估算要處理數(shù)據(jù)的總的條數(shù)
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,
}
}
我們通過模板方法模式將數(shù)據(jù)同步的流程固定下來,很好地解決了方案一的問題
- Repair 接口由 Executor 來實現(xiàn)哈恰,代碼有了規(guī)范只估,質(zhì)量也有了保障
- 各個數(shù)據(jù)源的處理流程由 Executor 來實現(xiàn),不用寫大量的重復(fù)代碼
- Repair 接口有 Executor 來實現(xiàn)着绷,修改蛔钙、擴展直接修改 Executor 就可以
模板方法模式的擴展
到目前為止,似乎一切都是完美的荠医,于是我們就又開始愉快地寫代碼了吁脱。但是天有不測風云 桑涎,果然寫代碼的過程不能是順順利利的。目前的查詢流程是按照數(shù)據(jù)的創(chuàng)建時間查出來id兼贡,然后按照 id 來取數(shù)據(jù)攻冷。
突然有一天又要接入兩個新的數(shù)據(jù)源,一個是數(shù)據(jù)有更新遍希,更新也要獲取到等曼;一個是通過接口取數(shù)據(jù),接口只支持按照時間取數(shù)據(jù)凿蒜,這就讓我頭疼了禁谦。最開始的我的想法是擴展 Executor 的 Repair 的執(zhí)行流程,讓它支持新的查詢方案废封,但總覺得怪怪的州泊。因為代碼會變得越來越復(fù)雜,也會越來越難以維護虱饿,并且也違背了職責單一原則拥诡。
突然聰明的我又靈機一動,在寫一個模板來實現(xiàn)這個處理流程不就行了氮发。果然只要想對了方向渴肉,一切都會豁然開朗。于是我將上線的模板接口和 Executor 改名為 IdExecutorTemplate 和 IdExecotor爽冕,并對新的需求仇祭,建新的接口 TimeExecutorTemplate 和執(zhí)行器 TimeExecotor來實現(xiàn)
TimeExecutorTemplate 接口定義如下
type TimeExecutorTemplate interface {
//描述要執(zhí)行的任務(wù)
GetTitle() string
//獲取時間步長
GetTimeStep() int64
//從某個時間范圍內(nèi)獲取一批數(shù)據(jù)
GetItemsBetweenTime(context.Context, time.Time, time.Time, int64) ([]interface{}, error)
//獲取某個時間范圍的數(shù)據(jù)總數(shù)
GetTotalBetweenTime(context.Context, time.Time, time.Time) (int64, error)
//獲取某個時間之后的數(shù)據(jù)總數(shù)
GetTotalAfterTime(context.Context, time.Time) (int64, error)
//獲取所有數(shù)據(jù)的最小時間
GetMinTime(context.Context) (time.Time, error)
//將從數(shù)據(jù)庫里面查出來的數(shù)據(jù),裝換成可以統(tǒng)一保存的數(shù)據(jù)
ConvertItemsToEvents(context.Context, []interface{}) ([]entity.StudentStoryEvent, error)
}
TimeExecotor 類實現(xiàn)如下
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() {
//獲取全量數(shù)據(jù)颈畸,取出來最小時間
startTime, err = e.template.GetMinTime(ctx)
if err != nil {
return err
}
}
//獲取全部要處理的數(shù)據(jù)總數(shù)
total, err := e.template.GetTotalAfterTime(ctx, startTime)
if err == nil {
//如果獲取成功乌奇,定義全局進度條
globalBar = processbar.NewProcessBar(e.template.GetTitle(), total)
}
//因為按照時間取數(shù)據(jù),一般都會用到分頁進行查詢眯娱,為了盡量分頁的 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 時間查詢數(shù)據(jù)
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,
}
}
哈哈徙缴,現(xiàn)在我們就又可以愉快地寫代碼了
類 UML 圖
接下來我們我們來看一下這些類的 UML 圖
總結(jié)
- 抽象的過程是從具體到抽象试伙,在從抽象到具體。沒有具體的 case 于样,沒有具體的需求疏叨,抽象是沒有意義的
- 設(shè)計模式最重要的是思想,掌握思想穿剖,可以使用各種方法實現(xiàn)蚤蔓。比如這個需求最終實現(xiàn)的效果,如果看 UML 圖的話糊余,更像是橋接模式秀又。并且整體分析的話单寂,也有符合橋接模式的思想。但從本質(zhì)上來說涮坐,還是模板方法模式
- 抽象不是銀彈凄贩,抽象不能解決所有問題,并且抽象是有害的袱讹。抽象在規(guī)范的同時疲扎,也屏蔽了細節(jié),并且失去了靈活性捷雕。比如上面的例子椒丧,Repair 接口的抽象使得接口的修改變得困難,Tempate 接口的抽象救巷,使得接入的數(shù)據(jù)源只能按照接口提供的方法實現(xiàn)功能壶熏。