模板方法模式在開發(fā)中的應(yīng)用

模板方法模式在開發(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 標(biāo)示數(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í)行當(dāng)天數(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" {
      //同步當(dāng)天之前一段時間的數(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)一進行錯誤處理
    }

接口方案問題

當(dāng)以上方案定義好之后,接下來我們就開始愉快地寫業(yè)務(wù)代碼。但是在不斷的接入業(yè)務(wù)源的過程中,因為接入各個數(shù)據(jù)源都是存儲在數(shù)據(jù)庫里面的,機智的我逐漸發(fā)現(xiàn)了如下問題

  1. Repair 接口實現(xiàn)缺少規(guī)范。因為目前方案沒有對 Repair 接口如何實現(xiàn)做限制,各個業(yè)務(wù)方在實現(xiàn)的時候就可以隨心所欲,信馬由韁,缺少規(guī)范
  2. Repair 接口實現(xiàn)存在大量重復(fù)代碼。因為數(shù)據(jù)源大部分都是從數(shù)據(jù)庫里面接入數(shù)據(jù),實現(xiàn)的流程大部分是相似,將數(shù)據(jù)進行統(tǒng)一保存的邏輯也是相同的,但是目前方案并沒有對此流程進行抽象,所以各個業(yè)務(wù)方都要重讀寫這塊相似代碼
  3. Repair 接口實現(xiàn)代碼質(zhì)量無法保證。
  4. 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è)計模式主要是一種思想,并沒有完全嚴(yán)格的格式,并且大部分的設(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ù)同步的流程固定下來,很好地解決了方案一的問題

  1. Repair 接口由 Executor 來實現(xiàn),代碼有了規(guī)范,質(zhì)量也有了保障
  2. 各個數(shù)據(jù)源的處理流程由 Executor 來實現(xiàn),不用寫大量的重復(fù)代碼
  3. Repair 接口有 Executor 來實現(xiàn),修改、擴展直接修改 Executor 就可以

模板方法模式的擴展

到目前為止,似乎一切都是完美的,于是我們就又開始愉快地寫代碼了。但是天有不測風(fēng)云 ,果然寫代碼的過程不能是順順利利的。目前的查詢流程是按照數(shù)據(jù)的創(chuàng)建時間查出來id,然后按照 id 來取數(shù)據(jù)。

突然有一天又要接入兩個新的數(shù)據(jù)源,一個是數(shù)據(jù)有更新,更新也要獲取到;一個是通過接口取數(shù)據(jù),接口只支持按照時間取數(shù)據(jù),這就讓我頭疼了。最開始的我的想法是擴展 Executor 的 Repair 的執(zhí)行流程,讓它支持新的查詢方案,但總覺得怪怪的。因為代碼會變得越來越復(fù)雜,也會越來越難以維護,并且也違背了職責(zé)單一原則。

突然聰明的我又靈機一動,在寫一個模板來實現(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 圖

template.png

總結(jié)

  1. 抽象的過程是從具體到抽象,在從抽象到具體。沒有具體的 case ,沒有具體的需求,抽象是沒有意義的
  2. 設(shè)計模式最重要的是思想,掌握思想,可以使用各種方法實現(xiàn)。比如這個需求最終實現(xiàn)的效果,如果看 UML 圖的話,更像是橋接模式。并且整體分析的話,也有符合橋接模式的思想。但從本質(zhì)上來說,還是模板方法模式
  3. 抽象不是銀彈,抽象不能解決所有問題,并且抽象是有害的。抽象在規(guī)范的同時,也屏蔽了細(xì)節(jié),并且失去了靈活性。比如上面的例子,Repair 接口的抽象使得接口的修改變得困難,Tempate 接口的抽象,使得接入的數(shù)據(jù)源只能按照接口提供的方法實現(xiàn)功能。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容