微服務業務架構的探索

742161455發表於2020-05-14

原文:微服務業務架構的探索

提示

閱讀這篇文章,需要有以下準備:

  1. 在微服務下掙扎過
  2. 需要了解 DDD 和COLA架構思想
  3. 本篇文章圍繞業務架構進行討論

前言

    公司在開始探索微服務架構時,使用的是三層架構(controller/service/model)。隨著時間的推移,發現三層業務架構在微服務架構下越來越不適用,主要體現在下面 2 點:

  1. 業務邏輯離散在 service 層,不能很好的複用和表達能力差
  2. 業務程式碼和技術實現進行了強耦合,導致除錯和測試困難 針對以上問題,我們開始探索新的業務架構,整理形成我們自己研發的業務框架:Esim (Make everything simple)。

回顧

此處輸入圖片的描述

    我對業務架構的思考,來自一道比較經典的面試題:什麼是 MVC?估計剛畢業的同學,都避免不了這道面試題。當然時間總是飛逝的,從畢業到現在,經歷了 PC 時代,移動時代,到現在的微服務時代的技術變遷。技術的層出不窮,讓我應接不暇。在回顧這個變遷的過程中發現一些比較有趣的事情,所以拿出來分享:

  • 架構一直在演進

        之所以用 “演進” 這個詞,是因為新的架構思想需要一步一步形成,換句話說需要時間。我們以三層架構開始探索微服務,用了 2 年多,因為越來越痛苦才開始探索新的業務架構,但也花了 1 年多的時間,才有一個成形的框架。

  • 都是圍繞模型,行為,資料進行變化

        自從把資料,模型,行為 3 兄弟從大雜燴解放出來後,他們就一直纏著你,這種糾纏很有可能伴隨你的整個職業生涯。在 PC 時代,我們把 3 兄弟放到 model 裡,所以當時有胖M,廋C的說法,有了經驗後,在移動時代,我們把行為抽出來放到service,model 留下資料和模型,再到現在的微服務時代,我們把行為和模型放到domain,資料放到了infrastructure。整個演進過程都圍繞著這 3 兄弟。

  • 邊界明顯

        不同時代的的架構邊界很清晰,PC 時代說的是職責分離,移動時代說的是前後端分離,微服務時代說的是業務邏輯和技術分離。這些邊界的出現和當時的環境脫不了關係。

事務指令碼領域模型

使用過程來組織業務邏輯,每個過程處理來自表現層的請求。

事務指令碼勝在簡單,也正是簡單,身邊的很多同事也在使用相同的方式來組織程式碼,我自己也沉浸在裡面很長時間,沒有思考是否有更好的方式(需要吸取這個教訓)。

在領域中合併了資料和行為的物件模型

領域模型強調的是組織業務邏輯前,先關注物件的行為,而事務指令碼關注資料。

  • 例子

以我們最近重構的紅包業務邏輯舉個例看看他們之間的區別:只能在指定的洗車業務和 A 商家才能使用該紅包。

事務指令碼實現

couponService.go
//是否滿足紅包使用條件
func (cs CouponService) IsSatisfyUse(couponId int, bussinessType string, sellerId string) bool {
    couponInfo := cs.CouponDao.FindById(couponId)
    ......
    couponConfInfo := cs.CouponConfigDao.FindById(couponInfo.ConfigId)
    ......
    //空代表所有業務都可以
    if couponConfInfo.allowBussiness == "" {
        return true
    }

    if bussinessType == "" {
        return false
    }

    var allowBussiness bool
    allowBussinesses := strings.Split(couponConfInfo.allowBussiness, ",")
    for _, val := range allowBussinesses {
        if bussinessType == val {
            allowBussiness = true
        }
    }

    if inBusiness == false {
        return false
    }

    //空所有商家都允許使用
    if couponConfInfo.allowSellers == "" {
        return true
    }

    if sellerId == "" {
        return false
    }

    var allowSeller bool
    allowSellers := strings.Split(couponConfInfo.allowSellers, ",")
    for _, seller := range allowSellers {
        if sellerId == seller {
            allowSeller = true
        }
    }

    if allowSeller == true {
        return true
    } else {
        return false
    }
}

    上面的程式碼就是比較典型的” 一杆到底 “,這樣形式的程式碼在我們的系統很常見。 經常導致業務邏輯的程式碼不能很好的複用,業務邏輯分散在多個不同的方法或 service 檔案裡,很少有人能把他們慢慢找出來, 封裝成共用方法。即使找到了又不敢輕易的把它們提取出來,因為它有可能和其他業務邏輯已經綁在了一起。

    當你抱著提升程式碼質量的情懷把它們提取出來,又因為沒有很好的方法驗證是否會影響了原有的業務邏輯。 導致出了很多次和原來預期對不上的問題(當時個個都堅信不會出問題),也讓很多同學對自己產生了懷疑。 所以為了避免這些問題發生,我們通常對這些能複用的程式碼睜一隻眼閉一眼,包括我自己。

領域模型的實現

coupon_service.go
//是否滿足紅包使用條件
func (cs CouponService) IsSatisfyUse(couponId int, bussinessType string, sellerId string) bool {
    couponInfo := cs.CouponDao.FindById(couponId)
    ......
    couponConfInfo := cs.CouponConfigDao.FindById(couponInfo.ConfigId)
    ......
    if couponConfInfo.CheckAllowBusiness(bussinessType) == false {
        return false
    }

    if couponConfInfo.CheckAllowSeller(sellerId) == false {
        return false
    } 

    return true
}
entity/coupon_config.go
type CouponConfig struct {
    id int 

    allowBussiness string

    allowSellers string

    ......
}

func (cc CouponConfig) CheckAllowBusiness(bussinessType string) bool {
    //所有業務都可以
    if cc.allowBussiness == "" {
        return true
    }

    if bussinessType == "" {
        return false
    }

    allowBussinesses := strings.Split(cc.allowBussiness, ",")
    for _, val := range allowBussinesses {
        if bussinessType == val {
            return true
        }
    }

    return false
}

func (cc CouponConfig) CheckAllowSeller(sellerId string) bool {
    //所有商家都允許使用
    if cc.allowSellers == "" {
        return true
    }

    if sellerId == "" {
        return false
    }

    allowSellers := strings.Split(cc.allowSellers, ",")
    for _, seller := range allowSellers {
        if sellerId == seller {
            return true
        }
    }

    return false
}

    從上面的程式碼可以看出,我們把原來在coupon_service.go的業務邏輯都放到了實體coupon_config.go裡面(行為和模型綁在了一起)。 業務邏輯不再離散,更內聚,能很好的複用,且寫單元測試變得簡單。

entity/coupon_config_test.go
func TestEntity_CheckAllowSeller(t *testing.T)  {
    testCases := []struct{
        caseName string
        sellerId string
        allowSellers string
        expected bool
    }{
        {"允許—空", "100", "", true},
        {"允許2", "1", "1,100", true},
        {"不允許", "1", "2,3,4", false},
    }

    for _, test := range testCases{
        t.Run(test.caseName, func(t *testing.T) {
            cc := CouponConfig{}
            cc.allowSellers = test.allowSellers
            result := cc.CheckAllowSeller(test.sellerId)
            assert.Equal(t, test.expected, result)
        })
    }
}

領域模型讓我們寫單元測試的時候不再關注所依賴的儲存實現,讓寫單元測試這件事變得輕鬆、簡單。

三層架構 到 四層架構

三層架構和四層架構一個明顯的區別是業務和實現技術分離。

    在三層架構,業務和實現技術進行了強耦合,讓開發在除錯和測試時都要依賴真實的服務,導致浪費了很多時間在部署服務,造資料環節上,這個問題在微服務架構下更突出。四層架構可以很好的解決這個問題。還是以上面的程式碼為例(直接依賴了 mysql):

coupon_service.go
//是否滿足紅包使用條件
func (cs CouponService) IsSatisfyUse(couponId int, bussinessType string, sellerId string) bool {
    couponInfo := cs.CouponRepo.FindById(couponId)
    ......
    couponConfInfo := cs.CouponConfigDao.FindById(couponInfo.ConfigId)
    ......

    return true
}

三層實現測試IsSatisfyUse(使用 gorm 的mock SDK):

coupon_service_test.go
func TestCouponRepo_IsSatisfyUse(t *testing.T) {
  cs := NewCouponService()
  ......
  couponReply := []map[string]interface{}{{"config_id": "100"}}
  couonConfigReply := []map[string]interface{}{{"allow_bussinesses": "1,2", "allow_sellers" : "1,2"}}
  Catcher.Attach([]*FakeResponse{
        {
            Pattern:"SELECT * FROM coupon WHERE", 
            Response: couponReply, 
            Once: false, 
        },
        {
            Pattern:"SELECT * FROM coupon_config WHERE", 
            Response: couonConfigReply, 
            Once: false, 
        },
    })

  result := cs.IsSatisfyUse(100, "1", "1")
  assert.Equal(t, true, result)
}

    上面的程式碼問題在於:如果業務程式碼依賴了某個技術實現,就要用對應的 mock SDK 來寫單元測試。 只依賴一個mysql可能不會有太大問題,但技術發展到現在,業務邏輯基本不可能只依賴 mysql。 還有可能是:redis,mongodb,http,grpc 等,這說明你需要學習各式各樣的 mock SDk。 我當初就被這些海量的 SDK,折騰的異常痛苦。也是這個原因才去尋找更好的辦法:分離業務邏輯和技術實現。

四層實現 IsSatisfyUse(使用依賴倒置)

coupon_service.go
//是否滿足紅包使用條件
func (cs CouponService) IsSatisfyUse(couponId int, bussinessType string, sellerId string) bool {
    couponInfo := cs.CouponRepo.FindById(couponId)
    ......
    couponConfInfo := cs.CouponConfigRepo.FindById(couponInfo.ConfigId)
    ......
    return true
}

infra/repo/coupon_repo.go
//定義介面
type CouponRepo interface {
    FindById(int64) entity.Coupon
}

//db實現
type DBCouponRepo struct {
    couponDao *dao.CouponDao
}

func (dcr *DBCouponRepo) FindById(id int64) entity.Coupon {
  ......
  coupon, err = dcr.couponDao.Find("*", "id = ? ", id)
  ......
  return coupon
}

//coupon_config 同理

四層實現測試IsSatisfyUse(使用mockery SDK):

coupon_service_test.go
func TestCouponRepo_IsSatisfyUse(t *testing.T) {
  cs := NewCouponService()
  ......
  couponRepo := &mocks.CouponRepo{}
  couponRepo.On("FindById", int64(100)).Return(entity.Coupon{ConfigId : 100})
  cs.CouponRepo = couponRepo

  couponConfigRepo := &mocks.CouponConfigRepo{}
  couponConfigRepo.On("FindById", int64(100)).Return(entity.CouponConfig{AllowBussiness : "1", "AllowSellers" : "1"})
  cs.CouponConfigRepo = couponConfigRepo

  result := cs.IsSatisfyUse(100, "1", "1")
  assert.Equal(t, true, result)
}

    通過依賴倒置將具體的技術實現和業務分離,你將不再需要學習各式各樣的 mock SDK。 使用這種方式還有其他好處:

  1. 如果你要從 mysql 切換成其他儲存層,只需要重新實現CouponRepo就可以了。不需要改動任何業務邏輯,且TestCouponRepo_IsSatisfyUse,還能正常使用。
  2. 使用介面分離技術實現,可以讓你在開發過程不用關注依賴的服務是否可用,非常的便利。

結語

領域模型和四層架構可以很好的解決了我們當前存在的問題,但它們也存在其他問題:

  1. 有一定的學習成本

    有學習成本的一個原因是:現在大量的開發都是在使用事務指令碼和三層架構做業務開發,要想轉向領域模型和四層架構, 需要花點時間(他們向工程師提了要求),但是如果轉成功了,將會對公司的業務程式碼在測試性和擴充套件性上有很大的提升。

  2. 增加了一些繁瑣工作

    四層比三層多了一些繁瑣的檔案建立:對每個資源都要提取介面和實現,依賴注入等,這些工作都很繁瑣,所以我們才寫了一個工具db2entity,把這些工作交由一個工具解決。

探索的過程可能很痛苦,但是探索出成果後會有成就感,這估計就是探索的樂趣了。

更多原創文章乾貨分享,請關注公眾號
  • 微服務業務架構的探索
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章