在上一篇文章《Go配置文件热加载 - 发送系统信号》中给大家介绍了在Go语言中 利用发送系统信号更新配置文件 其核心思想就是:新起一个协程,监听linux 的用户自定义信号 USR1 , 当收到该信号类型时,主动更新当前配置文件。

那么接下来,我们将继续完成上一篇文章提到的第二种实现配置文件热更新方式:利用linux提供的inotify 接口实现配置文件自动更新。

1. 关于inotify

首先在我们实操之前,让我们先来了解下什么是 inotify

Linux 内核 2.6.13 (June 18, 2005)版本之后,Linux 内核新增了一批文件系统的扩展接口(API),其中之一就是inotifyinotify 提供了一种基于 inode 的监控文件系统事件的机制,可以监控文件系统的变化如文件修改、新增、删除等,并可以将相应的事件通知给应用程序。

inotify 既可以监控文件,也可以监控目录。当监控目录时,它可以同时监控目录本身以及目录中的各文件的变化。此外,inotify 使用文件描述符作为接口,因而可以使用通常的文件I/O操作 selectpollepoll 来监视文件系统的变化。

总之,简单来说就是:inotify 为我们从系统层面提供了一种可以监控文件变化的接口,我们可以利用它来监控文件或目录的变化。

inotify常用监控事件

inotify 提供常用的监控事件如下:

IN_ACCESS

文件被访问时触发事件,例如一个文件正在被read时。

IN_ATTRIB

文件属性(Metadata)发送变化触发的事件,例如文件权限发生变化(使用 chmod 修改),文件所属用户发生变化(使用chown修改),文件时间戳发生变化等。

IN_CLOSE_WRITE

当一个文件写入操作结束文件被关闭时触发。

IN_CLOSE_NOWRITE

当一个文件或目录被打开没有任何写操作,当被关闭时触发。

IN_CREATE

当一个文件或目录被创建时触发。

IN_DELETE

文件或目录被删除时触发。

IN_DELETE_SELF

监控文件或目录本身被删除时触发,而且,如果一个文件或目录被移到其它地方,比如使用mv 命令,也会触发该事件,因为 mv 命令本质上是拷贝一份当前文件,然后删除当前文件的操作。

IN_MODIFY

文件被修改时触发,例如:有写操作( write) 或者文件内容被清空(truncate)操作。不过需要注意的是,IN_MODIFY 可能会连续触发多次。

IN_MOVE_SELF

所监控的文件或目录本身发生移动时触发。

IN_MOVED_FROM

文件或目录移除所监控目录。

IN_MOVED_TO

文件或目录移入所监控目录。

IN_ALL_EVENTS

监控所有事件。

IN_OPEN

文件被打开事件。

IN_CLOSE

文件被关闭事件,包括 IN_CLOSE_WRITEIN_CLOSE_NOWRITE 的事件总和。

IN_MOVE

涉及所有的移动事件,包括 IN_MOVED_FROMIN_MOVED_TO

如上便是inotify提供给我们的常用监听事件,我们可以在自己的项目中监听如上的一个或多个事件来实现特定的需求,如果想查阅更多事件细节,请参考此处:inotify doc

但需要说明的是,inotify 并非是跨平台的,所以在macOSwindows下则无法使用,但在macOS也提供类似的实现:FSEvents ,以及 windows下的 FindFirstChangeNotificationA,这里我们不再展开跨平台实现讨论,读者要是有兴趣可以查阅相关资料,或者使用文末推荐的开源库。

2. 代码实现

接下来,我们开始实现项目的配置文件更新监控功能(实操)。在GO语言中,我们使用 golang.org/x/sys/unix 这个包来调用底层操作系统的一些封装功能,inotify相关接口也包含在此包中,使用时只需要导入此包即可:

import "golang.org/x/sys/unix"

最简单的使用inotify大致分为三个步骤:

  1. inotify初始化。
  2. 添加文件监听,设置需要监听的一个事件或多个事件。
  3. 获取监听到的事件。

我们将按照这三个步骤来实现一个简单的 GO版 配置文件监控脚本 demo ,此处我们还是继续沿用 上一篇文章 的配置文件,当该文件发生变化时,我们需要通知Go代码重新读取该文件内容,从而实现热更新的目的。

/tmp/env.json

2.1 初始化inotify

按照之前所说的步骤,第一步需要初始化inotify,初始化需要使用:InotifyInit() 函数,该函数会返回一个文件句柄和错误信息,之后的操作都是基于该文件句柄:

fd, err := unix.InotifyInit()
if err != nil {
  log.Fatal(err)
}

2.2 添加文件监听

完成inotify初始化后,接着我们需要添加我们需要监控的文件和以及想要监听的一个或多个事件,由于是项目的配置,此处的使用场景是:配置文件一般不会有删除的需求,而通常的操作是部署更新,因此此处我们选择的监听事件是:

IN_CLOSE_WRITE

如第一小节中提到的,当所监听的配置文件以写的方式被打开后,当此文件关闭时触发的事件,在这种情况下就可能发生文件的更新,正好此种场景正是我们想要的。

path := "/tmp/env.json"
watched, err := unix.InotifyAddWatch(fd, path,  syscall.IN_CLOSE_WRITE)
if err != nil {
  _ = unix.Close(fd)
  log.Fatal(err)
}

如上代码中,文件监听使用了 InotifyAddWatch() 函数,第一个参数 fd 为第一步中的初始化文件句柄,第二个参数:path 为需要监听文件的路径,第三个参数为需要监听的事件。如果监听失败,较友好的方式是我们需要关闭当前的文件句柄。

2.3 获取监听事件

有了前两个步骤的准备,那么接下来我们只需要读取获取到监听事件即可:

events := make(chan uint32)
go func() {
		var buf   [unix.SizeofInotifyEvent * 4096]byte
		for {
      n, err := unix.Read(fd, buf[:])
			if err != nil {
				n = 0
				continue
			}

			var offset uint32
			for offset <= uint32(n - unix.SizeofInotifyEvent) {
				raw := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset]))

				mask := uint32(raw.Mask)
				nameLen := uint32(raw.Len)

				events <- mask
				offset += unix.SizeofInotifyEvent + nameLen
			}
		}
}()

在这里,我们新起了一个goroutine, 因为接收事件通知是一个循环往复的过程。然后我们把文件句柄中的事件使用 unix.Read() 函数读取到一个 buffer 中,如果unix.Read() 读取不到任何事件,那么它就会处于阻塞状态。然后,我们循环遍历的方式,获取到 buffer 中的所接受到的所有事件通知,然后上报到 events 通道中。

那么现在一旦有新的监控事件通知,那么就会立即达到 events 通道中,接着我们需要做的便是从 events 通道中获取通知事件即可:

for {
		select {
			case event := <-events:
				if event & syscall.IN_CLOSE_WRITE == syscall.IN_CLOSE_WRITE {
          // 调用加载配置文件函数
          loadConfig(path)
				}
		}
}

最终,整个代码大致如下:

func main() {
	path := "/tmp/env.json"

	// 初始化inotify文件监控
	fd, err := unix.InotifyInit()
	if err != nil {
		log.Fatal(err)
	}
	watched, err := unix.InotifyAddWatch(fd, path,  syscall.IN_CLOSE_WRITE)
	if err != nil {
		_ = unix.Close(fd)
		log.Fatal(err)
	}

	defer func() {
		_ = unix.Close(fd)
		_ = unix.Close(watched)
	}()

	events := make(chan uint32)
	go func() {
		var (
			buf   [unix.SizeofInotifyEvent * 4096]byte
			n     int
		)

		for {
			n, err = unix.Read(fd, buf[:])
			if err != nil {
				n = 0
				fmt.Println(err)
				continue
			}

			var offset uint32
			for offset <= uint32(n - unix.SizeofInotifyEvent) {
				raw := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset]))

				mask := uint32(raw.Mask)
				nameLen := uint32(raw.Len)

				// 塞到事件队列
				events <- mask
				offset += unix.SizeofInotifyEvent + nameLen
			}
		}
	}()

  // 获取监听事件
	for {
		select {
			case event := <-events:
				if event & syscall.IN_CLOSE_WRITE == syscall.IN_CLOSE_WRITE {
				    // 接收到事件,加载配置文件
            loadConfig(path)
			}
		}
	}
}

如上的代码,我们其实就完成了一个简单的配置文件监控的代码思路,但整体代码的质量是纯面条式的,因此有必要封装一下,在这里我打算把它们封装成一个 Watcher 类(其实Go语言没有类的概念,实质就是一个struct),代码内容请参考链接地址,这里不再此处展开,因为编程思想又是另一个话题,有了这个struct之后,我们只需要直接使用即可:

func main() {

	path := "/tmp/env.json"
	notify, err := watcher.NewWatcher()
	if err != nil {
		log.Fatal(err)
	}

	err = notify.AddWatcher(path, syscall.IN_CLOSE_WRITE)
	if err != nil {
		log.Fatal(err)
	}

	done := make(chan bool, 1)

	go func() {
		for {
			select {
			case event := <-notify.Events:
				if event & syscall.IN_CLOSE_WRITE == syscall.IN_CLOSE_WRITE {
					fmt.Printf(" file changed \n")

					// 加载配置文件函数, 配置文件代码参考上一篇文章内容
					// loadConfig(path)
				}
			}
		}
	}()

	<- done
}

总结

至此,我们完成了一个基于inofity的配置文件热更新全部代码,在Go中来实现还算比较简单,接下来我们需要总结一下:

  1. inotifyLinux 是内核系统提供的监控系统,使用它做热更新,其实和语言无关,所以你可以熟悉的语言来开发。

  2. inotify需要内核版本为2.6.13以上,不支持macOSWindows系统,如果希望实现跨平台文件监控那么可以使用如下第三点的 fsnotify 库。

  3. 如果不想重复早轮子,那么我们可以站在巨人的肩上,推荐两个文件监听库:

(360技术原创内容,转载请务必保留文末二维码,谢谢~)

基于inotify实现配置文件热更新-LMLPHP

03-08 21:20