1

本篇是我的事件驱动的微服务系列的第三篇,主要讲述如何在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 "消息接口"


倚天码农
206 声望162 粉丝

不堆砌术语,不罗列架构,不迷信权威,不盲从流行,坚持独立思考