事件驅動的微服務-建立第三方庫

倚天碼農發表於2020-07-21

本篇是我的事件驅動的微服務系列的第三篇,主要講述如何在Go語言中建立第三方庫。如果想要了解總體設計,請看第一篇"事件驅動的微服務-總體設計"
在Go語言中建立第三方庫是為了共享程式,做起來並不困難,不過你需要考慮如下幾個方面:

  • 第三方庫的對外介面
  • 第三方庫的內部結構
  • 如何處理配置引數
  • 如何擴充第三方庫

我們用日誌做例子講述如何建立第三方庫。Go語言有許多第三方日誌庫,它們各有優缺點。我在"清晰架構(Clean Architecture)的Go微服務: 日誌管理" 中講到了“ZAP”是迄今為止我發現的最好的日誌庫,但它也不是十全十美,我在等待更好的庫。不過我希望將來替換庫的時候不需要改程式碼或只要改很少的程式碼,現在的框架已經能夠支援這種替換。它的基礎就是所有的日誌呼叫都是通過通用介面(而不是某個第三方庫的專用介面),這樣只有建立日誌庫的操作是與具體庫有關的(這部分程式碼是需要修改的),而其他日誌庫的操作是不需要修改程式碼的。

第三方庫的對外介面

type Logger interface {
	Errorf(format string, args ...interface{})
	Fatalf(format string, args ...interface{})
	Fatal(args ...interface{})
	Infof(format string, args ...interface{})
	Info(args ...interface{})
	Warnf(format string, args ...interface{})
	Debugf(format string, args ...interface{})
	Debug(args ...interface{})
}

呼叫介面

上面就是日誌庫的介面,它的最重要的原則就是通用性,不能與任何特定的第三方日誌庫繫結。 這個介面非常重要,它的穩定性是決定它是否能廣泛應用的關鍵。作為碼農,一個理想就是能像搭積木一樣程式設計,這個口號已經喊了幾十年了,但沒什麼進展,主要原因就是沒有統一的服務介面,這個介面是需要跨語言的,而所有的服務都要有標準介面。這樣,應用程式才可能是可插拔的。在少數領域實現了這個理想,例如Java裡的JDBC,但它的侷限性還是很明顯的。例如,它只適合SQL資料庫,NoSQL的介面就五花八門了;而且它只適合Java,在別的語言裡就不適用了。

對日誌來說,Java裡有一個SLF4J,就是為了在Java裡實現日誌庫的可插拔而建立的。但Go裡沒有類似的東西,因此我就自己寫了一個。但因為是自己寫的,就只能是一個比較簡單的,自己用沒問題,但不能成為一個標準。

建立例項介面

除了呼叫介面,當你建立日誌庫例項時,還需要另外的介面,那就是建立例項的介面。下面就是程式碼,你只需要呼叫"Build()"函式,並把需要的配置引數傳進來。

下面的程式碼不是日誌庫中的程式碼,而是"支付服務" 呼叫日誌庫的程式碼。

func initLogger (lc *logConfig.Logging) error{
	log, err := logFactory.Build(lc)
	if err != nil {
		return errors.Wrap(err, "loadLogger")
	}
	logger.SetLogger(log)
	return nil
}

下面就是日誌庫中的“Build()”函式的程式碼

func Build(lc *config.Logging) (glogger.Logger, error) {
	loggerType := lc.Code
	l, err := GetLogFactoryBuilder(loggerType).Build(lc)
	if err != nil {
		return l, errors.Wrap(err, "")
	}
	return l, nil
}

在設計中一個讓人比較糾結的地方就是是否要把例項建立部分放到介面中。呼叫介面是肯定要標準化,定義成通用介面。那麼建立例項的函式呢?一方面似乎應把它放到標準介面中,有了它,整個過程才完整。但這樣做擴大了介面範圍,而且例項建立(包括配置引數)本身就不是標準化的,把它納入介面增加了介面的不穩定性。我最後還是決定把先它納入介面,如果有問題以後再改。

配置引數定義

一旦要把建立例項納入介面,那麼把配置引數也納入就順理成章了。

下面就是在"支付服務" 中對“glogger”庫中的配置引數的定義

type Logging struct {
	// log library name
	Code string `yaml:"code"`
	// log level
	Level string `yaml:"level"`
	// show caller in log message
	EnableCaller bool `yaml:"enableCaller"`
}

第三方庫的內部結構

我以前有一件事一直想不明白,就是有不少Go的第三方庫都把很多檔案放在根目錄,甚至整個庫就只有一個根目錄(裡面沒有任何子目錄),這樣當檔案多了時,就顯得雜亂無章,很難管理。為什麼不能建幾個子目錄呢?當我也開始寫第三方庫時,終於找到了原因。

在解釋之前,我先講一下,什麼是理想中的第三方庫的目錄結構。它的結構如下圖(還是用日誌做例子):

glogger.jpg

其中,由於"logger.go"裡有它的對外介面,可以把它放在根目錄,這樣當其它程式引用它時,只需要“import”根目錄。其次,它可以支援多個日誌庫的實現,這樣每個日誌庫可以建立一個目錄,例如“Logrus”和“Zap”就是支援通用日誌庫的兩個實現,它們的封裝程式碼都在自己單獨的目錄裡。

這裡面最困難的地方就是解決迴圈依賴的問題。由於它的介面是定義在根目錄,而其它部分是要用到介面的,因此是要依賴根目錄的,也就是說它的依賴方向從裡向外的。“factory”裡的程式碼是用來建立例項的,目錄裡的“factory.go"裡有一個函式"Build()",本來也應該放到"logger.go"裡,這樣應用程式就只用“import”第三方庫的根目錄就行了。但"Build()"是要呼叫內層的日誌庫的工廠函式的,這樣依賴關係就變成了從外到裡,於是形成了迴圈依賴。我想了幾種辦法來建立子目錄,但都不滿意,最後發現必須把所有的檔案都放在跟目錄才能解決問題。現在終於知道了為什麼有那麼多第三方庫都這麼做了。它的最大好處就是應用程式引用時只需要“import”一個包,比較簡單。

但它的問題是目錄內部沒有任何結構,當檔案不多時還可以接受,檔案一多就根本沒法管理。當要支援新的日誌庫時,也不知道從哪下手。我最後還是把它改成有內部結構的,這樣需要增加兩個目錄“factory”和“config”。但它的缺點就是當應用程式引用它時,總共需要需要三條“import”語句,還暴露了第三方庫的內部結構。具體哪種方案更好,可能就見仁見智了。我本人現在還是覺得這樣更好。

本來“config.go”和“factory.go”最好也是放在一個目錄下,但這樣也會造成迴圈依賴,因此只能把他們拆開存放了。

如何處理配置引數:

日誌庫的配置引數和應用程式的配置引數如何協調是另一個難點。從一方面來講,第三方庫的配置引數的程式碼和處理邏輯應該是在第三方庫裡,這樣才能保證日誌部分的邏輯是完整的,並集中在一個地方。另一方面,一個應用程式的所有引數應該統一儲存在一個地方,它有可能存在一個檔案裡,也有可能是儲存在程式碼裡。現在的框架是支援把配置引數存放在一個單獨的檔案裡的,這似乎是一個比較好的方法。這樣我們就陷入了一個兩難的境地。

解決的辦法是把配置引數分成兩個部分,一部分是配置引數的定義和邏輯,這部分由第三方庫來完成。另一部分是引數存放,這部分放在應用程式裡,這樣就保證了應用程式引數的集中管理。使用時可以讓應用程式將引數傳給第三方庫,但由第三方庫進行引數配置。

下面幾段程式碼就是在"支付服務" 中初始化glogger庫的程式碼, 它是初始化整個程式容器的一部分,它在“app.go"裡。

下面的程式碼初始化程式容器,它先讀取配置引數,然後分步初始化容器。

func InitApp(filename...string) (container.Container, error) {
	config, err := config.BuildConfig(filename...)
	if err != nil {
		return nil, errors.Wrap(err, "loadConfig")
	}
	err = initLogger(&config.LogConfig)
	if err != nil {
		return nil, err
	}
	return initContainer(config)
}

下面是從檔案中讀取配置引數(應用程式的所有引數,其中包括日誌配置引數)的程式碼,它在“appConfig.go"裡。


func BuildConfig(filename ...string) (*AppConfig, error) {
	if len(filename) == 1 {
		return buildConfigFromFile(filename[0])
	} else {
		return BuildConfigWithoutFile()
	}
}

func buildConfigFromFile(filename string) (*AppConfig, error) {

	var ac AppConfig
	file, err := ioutil.ReadFile(filename)
	if err != nil {
		return nil, errors.Wrap(err, "read error")
	}
	err = yaml.Unmarshal(file, &ac)

	if err != nil {
		return nil, errors.Wrap(err, "unmarshal")
	}
	fmt.Println("appConfig:", ac)
	return &ac, nil
}

下面的程式碼初始化日誌庫,它把前面讀到的引數傳給日誌庫,並通過呼叫“Build()"函式來獲得符合日誌介面的具體實現。

func initLogger (lc *logConfig.Logging) error{
	log, err := logFactory.Build(lc)
	if err != nil {
		return errors.Wrap(err, "loadLogger")
	}
	logger.SetLogger(log)
	return nil
}

如何增加新的介面實現

現在的介面封裝了兩個支援通用日誌介面的庫,"zap""Logrus"。 當你需要增加一個新的日誌庫,例如"glog" 時,你需要完成以下操作。

第一,你需要修改”logFactory.go", 在其中增加一個新的日誌庫選項。
下面是現在的程式碼:

const (
	LOGRUS string = "logrus"
	ZAP    string = "zap"
)

// logger mapp to map logger code to logger builder
var logfactoryBuilderMap = map[string]logFbInterface{
	ZAP:    &zap.ZapFactory{},
	LOGRUS: &logrus.LogrusFactory{},
}

下面是修改後的程式碼:

const (
	LOGRUS string = "logrus"
	ZAP    string = "zap"
	GLOG    string = "glog"
)

// logger mapp to map logger code to logger builder
var logfactoryBuilderMap = map[string]logFbInterface{
	ZAP:    &zap.ZapFactory{},
	LOGRUS: &logrus.LogrusFactory{},
	GLOG: &glog.glogFactory{},
}

第二,你需要在根目錄下建立“glog”目錄,它裡面要包含兩個檔案。“glogFactory.go”和“glog.go”。其中“glogFactory.go”是工廠檔案,與“logrusFactory.go”基本一樣,這裡就不詳細講了。“glog.go”主要是完成引數配置和日誌庫的初始化。

下面就是logrus的檔案“logrus.go”。“glog.go”可參照這個來寫。其中“RegisterLogrusLog()”函式是對logrus的通用配置,“customizeLogrusLogFromConfig()”是根據應用程式傳過來的引數,進行有針對性的配置。

func RegisterLogrusLog(lc logconfig.LogConfig) (glogger.Logger, error) {
	//standard configuration
	log := logrus.New()
	log.SetFormatter(&logrus.TextFormatter{})
	log.SetReportCaller(true)
	//log.SetOutput(os.Stdout)
	//customize it from configuration file
	err := customizeLogrusLogFromConfig(log, lc)
	if err != nil {
		return nil, errors.Wrap(err, "")
	}
	//This is for loggerWrapper implementation
	//logger.Logger(&loggerWrapper{log})

	//SetLogger(log)
	return log, nil
}

// customizeLogrusLogFromConfig customize log based on parameters from configuration file
func customizeLogrusLogFromConfig(log *logrus.Logger, lc logconfig.LogConfig) error {
	log.SetReportCaller(lc.EnableCaller)
	//log.SetOutput(os.Stdout)
	l := &log.Level
	err := l.UnmarshalText([]byte(lc.Level))
	if err != nil {
		return errors.Wrap(err, "")
	}
	log.SetLevel(*l)
	return nil
}

結論:

上面講了如果要建立一個第三方庫需要做些什麼,它用日誌服務來做例子,主要的工作是建立一個通用的日誌介面以及封裝一個新的支援這個介面的日誌庫。建立其它的通用服務介面(例如"資料庫事務管理""訊息介面") 也和它類似。它主要包含兩部分的程式碼,一個是通用介面,一個是具體實現的封裝。具體實現可以以後逐漸加多。

源程式:

完整的源程式連結:

索引:

1 事件驅動的微服務-總體設計

2 "清晰架構(Clean Architecture)的Go微服務: 日誌管理"

3 "支付服務"

4 "zap"

5 "Logrus"

6 "glog"

7 "資料庫事務管理"

8 "訊息介面"

相關文章