【服务计算】四 程序包开发,读简单配置文件
概述
配置文件(Configuration File,CF)是一种文本文档,为计算机系统或程序配置参数和初始设置。传统的配置文件就是文本行,在 Unix 系统中随处可见,通常使用 .conf,.config,.cfg 作为后缀,并逐步形成了 key = value 的配置习惯。在 Windows 系统中添加了对 section 支持,通常用 .ini 作为后缀。面向对象语言的兴起,程序员需要直接将文本反序列化成内存对象作为配置,逐步提出了一些新的配置文件格式,包括 JSON,YAML,TOML 等。
现代配置文件的读写,官方网站一般会提供各种语言的配置读写包,而且大部分是开源的。
实验目的
- 熟悉程序包的编写习惯(idioms)和风格(convetions)
- 熟悉 io 库操作
- 使用测试驱动的方法
- 简单 Go 程使用
- 事件通知
实验内容
在 Gitee 或 GitHub 上发布一个读配置文件程序包,第一版仅需要读 ini 配置,配置文件格式案例:
# possible values : production, development
app_mode = development
[paths]
# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used)
data = /home/git/grafana
[server]
# Protocol (http or https)
protocol = http
# The http port to use
http_port = 9999
# Redirect to correct domain if host header does not match domain
# Prevents DNS rebinding attacks
enforce_domain = true
实验要求
- 核心任务:包必须提供一个函数 Watch(filename,listener) (configuration, error)
- 输入 filename 是配置文件名
- 输入 listener 一个特殊的接口,用来监听配置文件是否被修改,让开发者自己决定如何处理配置变化
- type ListenFunc func(string)
- type inteface Listener { listen(inifile string) }
- ListenFunc 实现接口方法 listen 直接调用函数
- 优点
- 所有满足签名的函数、方法都可以作为参数
- 所有实现 Listener 接口的数据类型都可作为参数
- 输出 configuration 数据类型,可根据 key 读对应的 value。 key 和 value 都是字符串
- 输出 error 是错误数据,如配置文件不存在,无法打开等
- 可选的函数 WatchWithOption(filename,listener,…) (configuration, error)
- 包必须包括以下内容:
- 生成的中文 api 文档
- 有较好的 Readme 文件,包括一个简单的使用案例
- 每个go文件必须有对应的测试文件
- 必须提供自定义错误
- 使用 init 函数,使得 Unix 系统默认采用 # 作为注释行,Windows 系统默认采用 ; 作为注释行。
- 不能使用第三方包,但可以参考、甚至复制它们的代码。
操作系统
按照实验要求,结合自己的电脑设备与系统等条件,使用VirtualBox下Ubuntu 20.04系统完成实验。虚拟机相关设置与上一次实验相同。
环境准备
虚拟机下的实验环境与上一次实验的相同,不需要额外的配置。
开发实践
创建项目
根据go的工作空间目录结构,由于现在考虑开发一个自己的读取配置文件包,因此下文的工作目录默认在"$GOPATH/src/gitee.com/alphabstc/readcfg"下(alphabstc为我的gitee和github的用户id)。
首先在Bash下执行命令:
go mod init gitee.com/alphabstc/readcfg
之后可以看到readcfg目录下新出现了一个名为go.mod的文件,查看其内容如下:
这样就完成了readcfg项目的初步初始化工作。
数据结构
本次实验用到的重要数据结构首先是KvPairs和Configuration。
配置文件相当于若干个键值对的集合。其中,支持section的配置文件,还将这些键值对在逻辑上划分到了若干个不同的section中。为了能够很好地在内存中表示配置文件内容,这里设计了Configuration和Kvpairs类型来存储键值对。
配置文件中的键和值一般来说是普通的字符串形式,因此这里采用Kv_pairs数据类型存储一个Section下面的键值对,其实际上是一个键为string类型,值为string类型的map类型。
为了增加对section段的支持,这里定义了Configuration类型,其也是一个map,其键为对应section名称的string类型,而值则是前面定义的Kv_pairs类型,也就是键值对的集合。
代码如下:
// 该类型存储一个Section下面的键值对
type Kv_pairs map[string]string
// 该类型存储读取出的配置信息 Configuration也是一个map 键为string 而值则为Kv_pairs类型
type Configuration map[string]Kv_pairs
此外,Go语言与其他传统的面向对象语言不同。在Go语言中,我们不必声明一个结构体或其他类型是某种对象(如这里的Listener)。Go语言可以凭借在一定范围内是否可以找到指定签名的方式识别是否属于某种对象类型。也就是说,Go语言不关心类型是怎么样。只要某种类型表现得像鸭子,Go语言就认为这种类型是鸭子。在这个实验中,Go语言会将所有函数签名为接收一个字符串的函数当做ListenFunc函数。而对于只要实现了Listen函数的类型,Go语言就会认为这是一个Listener。代码如下:
type ListenFunc func(string)//接收一个字符串的函数
func (listenf ListenFunc) Listen(cfgfile string) {//接收者为ListenFunc
listenf(cfgfile)
}
// Listener接口 需要实现Listen函数
type Listener interface {
Listen(cfgfile string)
}
Init函数
Init函数需要根据当前操作系统来识别对应配置文件中的注释符。
先编写测试代码如下。该测试代码是一个Example函数,之后也可以被生成在api文档中。
func ExampleInit() {
Init()
fmt.Printf("%c\n",CommentSymbol)
//Output:#
}
之后完成Init函数如下:
// 当前系统下的注释符
var CommentSymbol byte
// 通过当前操作系统确定相应的注释符
func Init() {
sysType := runtime.GOOS// 获取当前的操作系统
if sysType == "linux" {// linux系统配置文件的注释符为#
CommentSymbol = '#'
}
if sysType == "windows" {// windows系统配置文件的注释符为#
CommentSymbol = ';'
}
}
Watch函数
先编写Watch函数的测试代码如下。该测试代码是一个Example函数,之后也可以被生成在api文档中。下面的函数有几个关键之处。首先,需要创建一个go程来调用Watch函数。则是因为Watch函数会阻塞调用者,直到调用者返回。因此在测试函数中,需要创建一个go程使得Watch函数运行,同时还需要主go程执行后面的其他操作。此外,需要注意到Go中的map是无序的,为了能够方便地比较kv对信息,需要先遍历所有keys,然后将keys排序,之后按照keys排序后的结果来索引kv对的集合,这样可以保证检索到的键值对信息是有序的,才方便与标准输出进行比较。此外,注意到defer是后进先出的,因此需要先defer删除文件,再defer关闭文件。这样文件才会在ExampleWatch函数退出后先被关闭再被删除。具体代码分析详见注释:
func ExampleWatch() {
var listenf ListenFunc = func(cfgfile string) {//用户编写的listen函数
before_stat, err := os.Lstat(cfgfile)//获取目前的文件状态信息
if err != nil {//遇到错误
panic(err)
}
for {
after_stat, err := os.Lstat(cfgfile)//获取当前的文件状态信息
if err != nil {//遇到错误
panic(err)
}
if !before_stat.ModTime().Equal(after_stat.ModTime()) {//修改时间更新了
break//退出 控制权回到Watch函数
}
time.Sleep(time.Duration(1)*time.Second)//等待一秒后再次检查
}
}
go func(){
conf, err := Watch("_test.cfg", listenf)//监听_test.cfg文件
if err != nil {//遇到错误
fmt.Println(err)
}
for s, kvs := range conf {//遍历所有section
var names []string
for name := range kvs {//遍历所有kv对
names = append(names, name) //获得所有key
}
sort.Strings(names)//所有key排序
fmt.Println("Section: ", s)//输出当前section信息
for _, name := range names {//按顺序输出所有kv对
fmt.Println("Key:", name, "\tValue:", kvs[name])
}
fmt.Println()
}
}()//通过创建一个go程来执行监听
file,openErr:=os.Create("_test.cfg")//创建测试用的文件
if openErr!=nil {//打开失败
panic(openErr)
}
defer os.Remove("_test.cfg")//最后移除该文件
defer file.Close()//defer后进先出 因此关闭文件操作定义在移除文件的后面
time.Sleep(time.Duration(1)*time.Second)//等待一秒
writer:=bufio.NewWriter(file)
_,errWrite := writer.Write([]byte("[test]\n"))
_,errWrite = writer.Write([]byte("arg = 101010\n"))
_,errWrite = writer.Write([]byte("name = admin\n"))
_,errWrite = writer.Write([]byte("port = 7757\n"))
_,errWrite = writer.Write([]byte("version = 1.6.2\n"))
if errWrite!=nil {//遇到错误
os.Exit(0)
}
writer.Flush()//flush写入文件
time.Sleep(time.Duration(2)*time.Second)
/*Output:
Section: test
Key: arg Value: 101010
Key: name Value: admin
Key: port Value: 7757
Key: version Value: 1.6.2
*/
}
之后完成Watch函数如下。该函数按照实验文档中函数签名的要求,使用Listener监听文件是否改变。文件内容改变时,则从listener中返回,并执行后面解析文件内容的部分。解析文件内容首先需要打开文件,遇到错误则返回打开文件失败的错误信息。之后,将配置文件转换为一个IO缓冲区,然后从中逐行读入并处理。如果发现文件读取失败,或者一行的格式不是section标签或者规范的键值对,则返回错误。之后则将信息添加进入Configuration中,最后将解析得到的Configuration返回。具体代码分析详见注释:
func Watch(filename string, listener Listener) (Configuration, error) {// 监听自函数运行以来配置文件是否发生变化 并在发生变化时返回当前最新的配置文件解析内容。
listener.Listen(filename)//给用户提供一个接口 用户来判定什么时候其关心的配置文件内容发生了变化
conf := make(Configuration)//生成一个空的Configuration
f, err := os.Open(filename)//尝试打开文件
if err != nil {//存在错误 返回错误
err = errors.New("Open configuration file failed.")
return conf, err
}
defer f.Close()//函数结束后关闭文件
r := bufio.NewReader(f)// 将配置文件转换成一个bufio
section := ""// 当前配置内容解析对应的section
for {
line, err := r.ReadString('\n')// 以'\n'作为结束符读入一行
if err == io.EOF {//读完文件 退出循环
break
}
if err != nil {// 读取文件失败 返回错误信息
err = errors.New("Read configuration file failed.")
break
}
line = strings.TrimSpace(line)// 删除行两端的空格字符
if line == "" {// 跳过空行
continue
}
if line[0] == CommentSymbol {// 以符号CommentSymbol作为注释 如果是注释也跳过
continue
}
length := len(line)//获得字符串长度
if line[0] == '[' && line[length-1] == ']' { // 如果是一个section标签
section = line[1 : length-1]
// 如果conf中没有这个section,添加该新的section
if _, ok := conf[section]; !ok {
conf[section] = Kv_pairs{}
}
}else { // 否则应该是键值对数据
s := strings.Split(line, "=")// 使用=分割字符串
if len(s) < 2 {//如果不是合法的一个=分割的键值对形式 错误
err = errors.New("The key-value pair format is incorrect.")
break
}
key := strings.TrimSpace(s[0])//消除空格的key
value := strings.TrimSpace(s[1])//消除空格的value
if section == "" {
if _, ok := conf[section]; !ok {//如果还不存在名为""的section 添加该section
conf[section] = Kv_pairs{}
}
}
conf[section][key] = value// 把键值对添加进section里面
}
}
return conf, err//返回配置信息 以及 错误信息
}
单元测试
上面已经介绍了单元测试的代码和思路,现在运行单元测试,可以看到下面的结果:
说明正常通过了单元测试,程序的实现基本符合要求。
使用Init和Watch函数
现在考虑使用Init和Watch函数,完成实际的监听配置文件是否改变的需求。现在编写Main函数的代码如下。该函数首先创建一个和之前单元测试中类似的ListenFunc函数,然后创建一个新的go程反复调用这个函数。当文件没有改变的时候,会输出"Listening changes in file…"的信息,之后不会有新信息输出。文件内容改变时,ListenFunc函数会将控制权返回Watch函数,Watch函数将输出新的配置信息。为了功能测试的方便,这里在创建了一个go程之后,使用主go程每隔一段时间在配置文件中写入信息,然后就可以看到之前创建的go程会相应输出配置信息的变化内容。具体代码分析详见注释:
package main
import (
"bufio"
"os"
"fmt"
"time"
"gitee.com/alphabstc/readcfg/readcfg"
)
func main() {
var lis readcfg.ListenFunc = func(cfgfile string) {
before_stat, err := os.Lstat(cfgfile)//获得文件目前的状态
if err != nil {
panic(err)
}
fmt.Println("Listening changes in file......", cfgfile)//输出正在监听文件的信息
for {
after_stat, err := os.Lstat(cfgfile)//获得文件当前的状态
if err != nil {
panic(err)
}
if !(before_stat.ModTime().Equal(after_stat.ModTime())) {//修改时间有变化 说明文件有更新
fmt.Println("There are something changed in file ", cfgfile)
break//退出循环 控制权回到Watch
}
time.Sleep(time.Duration(1)*time.Second)//否则睡眠一秒再次查询
}
}
lisfile := "./1.cfg"//使用的文件
go func(){//创建一个go程来Watch
readcfg.Init()
for {
conf, err := readcfg.Watch(lisfile, lis)
if err != nil {//遇到错误 返回
fmt.Println(err)
}
for s, kvs := range conf {//否则遍历所有Section
fmt.Println("Section: ", s)
for k, v := range kvs {//遍历所有kv对
fmt.Println("Key:", k, "\tValue:", v)//输出
}
fmt.Println()
}
}
}()
time.Sleep(time.Duration(10)*time.Second)//睡眠一段时间后修改文件内容
i := 0
file,openErr := os.OpenFile(lisfile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)//打开文件
if openErr != nil {//打开文件失败
panic(openErr)
}
writer := bufio.NewWriter(file)//创建对应的IO缓冲区
_, errWrite := writer.Write([]byte(fmt.Sprintf("[test%d]\n", time.Now().Unix() & 65535)))//输出一个新的section
if errWrite != nil {//遇到错误
panic(errWrite)
}
writer.Flush()
for {
_, errWrite = writer.Write([]byte(fmt.Sprintf("TEST_KEY%d = TEST_VALUE%d\n", i, i)))//输出一个新的kv对
i += 1
if errWrite != nil {//遇到错误
panic(errWrite)
}
writer.Flush()
time.Sleep(time.Duration(5)*time.Second)//等待一段时间再写新的kv对
}
}
功能测试
运行main函数:
可以看到在10秒之内,没有监听到配置信息的变化。
10秒之后,主go程修改了1.cfg中的内容,可以看到输出反应了配置信息的变化:
之后,每隔大约5秒都会反馈配置信息的变化(由于主go程每隔大约5s写入新的kv对信息):
如果手动修改1.cfg配置文件,也会监听到变化。如将端口号由999修改为8000:
可以看到监听到变化:
生成API文档
先使用命令go get golang.org/x/tools/cmd/godoc来安装godoc。该命令会访问官网下载godoc,有可能访问超时。为此,需要在Bash下设置如下的环境变量:
export GOPROXY=https://goproxy.io
export GO111MODULE=on
这样就可以顺利安装godoc:
然后在bash下运行命令go build golang.org/x/tools/cmd/godoc
再运行godoc就可以在浏览器通过http://localhost:6060/来访问godoc了。
之后,还可以将文档导出出来:
godoc -url "http://localhost:6060/pkg/gitee.com/alphabstc/readcfg/readcfg" > api.html