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

  • 2020 年 7 月 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 “消息接口”