Table of Contents
项目介绍
这个项目主要用来收集主机的 cpu 等资源信息,并上报到指定主机存储以及前端展示。
所以这个项目会分为服务端、采集端、web 端三个部分。服务端和采集端采用 go 编写,框架为 gin。web 端是原生,图标库为 echarts。
之所以采用 go,而不是更熟悉的 nodejs,主要是因为 go 可以打包成二进制直接运行,省去了运行时依赖。虽然 nodejs 可以使用 pkg 打包,但好像不是很好,并且打包体积较大。
项目地址:https://gitea.dowhat.top/tony/distributed-computer-monitor
go 的安装
开发需要先安装 go 环境。可以到 https://golang.google.cn/dl/ 这里下载对应平台的安装包。
安装好之后,需要设置 go mod 代理(因为默认的地址访问不了)。
go env -w GOPROXY=https://goproxy.cn,direct
然后新建项目,比如:
go mod init hello
添加依赖可以使用 go get xxx 命令。
实现解析
服务端
技术栈:go、gin、go-sqlite3
web 端实际上是嵌入到服务端的。因为 web 端比较简陋,没用 webpack,也没通过 nginx 转发(减少项目依赖),直接由 gin 提供静态资源访问。
项目结构
. ├── README.md ├── common │ ├── common.go │ ├── host │ │ └── host.go │ ├── record │ │ └── record.go │ └── response │ └── response.go ├── config │ ├── config.go │ ├── dev.yml │ ├── prod.yml │ └── struct.go ├── controller │ ├── collect.go │ └── web.go ├── db │ ├── clear.go │ ├── data.db │ └── initSqlite.go ├── go.mod ├── go.sum ├── main.go ├── routes │ ├── collect.go │ ├── routes.go │ └── web.go ├── service │ ├── collect.go │ └── web.go ├── util │ └── util.go └── web ├── css │ └── style.css ├── favicon.ico ├── index.html └── js ├── index.js └── lib
项目目录比较简单,因为功能就简单。
入口文件 main.go
package main import ( "distributed-computer-monitor-server/config" "distributed-computer-monitor-server/db" "distributed-computer-monitor-server/routes" "flag" "log" ) func main() { var ( err error envCl string ) // 获取命令行参数 flag.StringVar(&envCl, "e", "dev", "运行环境:dev / prod") flag.Parse() // 初始化配置 err = config.InitConfig(envCl) if err != nil { log.Fatal(err.Error()) } // 初始化数据库 err = db.InitSqlite() if err != nil { log.Fatal(err.Error()) } db.ClearTimer() // 初始化服务器 err = routes.InitRouter() if err != nil { log.Fatal(err.Error()) } }
main 函数里主要就是各个模块的初始化调用。distributed-computer-monitor-server 这个是项目的包名。所以,下面逐个说明一下各个模块是如何编写的,以及其中碰到的问题。
项目配置
首先是项目的配置部分。配置的代码在 config 目录里。基本就是读取配置文件里的对服务、数据库的配置。
package config import ( "gopkg.in/yaml.v2" "io/ioutil" ) var GConfig *Config func init() { GConfig = &Config{} } func InitConfig(env string) (err error) { var file = "./config/" + env + ".yml" yamlConf, err := ioutil.ReadFile(file) if err != nil { return err } if err = yaml.Unmarshal(yamlConf, GConfig); err != nil { return err } return nil }
InitConfig 的参数就是 main.go 里的传入的环境字符串。根据这个字符串,程序读取相应的配置文件并解析。Config 这个结构体定义在 config/struct.go 文件里。
package config // 配置类型 type Config struct { Mode string `yaml:"mode"` Server Server `yaml:"server"` Database Database `yaml:"database"` } // 服务配置 type Server struct { AppName string `yaml:"appName"` Address string `yaml:"address"` Port string `yaml:"port"` Env string `yaml:"env"` } // 数据库配置 type Database struct { Sqlite Sqlite `yaml:"sqlite"` } // sqlite 配置 type Sqlite struct { Path string `yaml:"path"` MaxDay int `yaml:"maxDay"` }
这里 Databse 字段可以省略中间一层的,因为我只用到了 sqlite。
数据库配置
在项目启动的时候,需要对数据做一些操作,比如初始化表,设定定时器(我只想保留 90 天的数据,需要定时清理过期数据)等。
package db import ( "database/sql" "distributed-computer-monitor-server/config" "distributed-computer-monitor-server/util" "fmt" _ "github.com/mattn/go-sqlite3" ) var GSqlite *sql.DB func InitSqlite() error { GSqlite, err := sql.Open("sqlite3", config.GConfig.Database.Sqlite.Path) util.CheckErr(err) // 创建数据表 fmt.Println("生成数据表") // sqlite3 注释为 -- sql_table := ` CREATE TABLE IF NOT EXISTS "host" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "hostname" VARCHAR(64) NULL, "created" TIMESTAMP default (datetime('now', 'localtime')), -- 创建时间,使用当前时间 "updated" TIMESTAMP default (datetime('now', 'localtime')) -- 更新时间,第一次自动,record 表更新的时候手动修改 ); CREATE TABLE IF NOT EXISTS "record" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "host_id" INTEGER NULL, "cpu_type" VARCHAR(64) NULL, -- cpu 型号 "cpu_core" INTEGER NULL, -- cpu 核心数(逻辑核心) "cpu_load" REAL NULL, -- cpu 整体负载 0~1 "cpu_temp" REAL NULL, -- cpu 温度 "mem_cap" INTEGER NUll, -- 内存容量,单位 KB "mem_load" REAL NULL, -- 内存整体负载 0~1 "mem_temp" REAL NULL, -- 内存温度 "swap_cap" INTEGER NULL, -- 虚拟内存容量,单位 KB "swap_load" REAL NULL, -- 虚拟内存负载 0~1 "gpu_type" VARCHAR(64) NULL, -- gpu 类型 "gpu_load" REAL NULL, -- gpu 负载 0~1 "gpu_temp" REAL NULL, -- gpu 温度 "net_up" INTEGER NULL, -- 网络上传速度,单位 KB "net_down" INTEGER NULL, -- 网络下载速度,单位 KB "created" TIMESTAMP default (datetime('now', 'localtime')) -- 创建时间,使用当前时间 ) ` _, sqlerr := GSqlite.Exec(sql_table) util.CheckErr(sqlerr) GSqlite.Close() return nil }
从表字段可以看出,我想获取的信息还是挺多的,但实际上,最后我只获取了 cpu、内存、swap 等信息(😶)。
sqlite3数据库用了这个包操作:github.com/mattn/go-sqlite3。
sqlite3 在打开数据库文件的时候,如果文件不存在,会自动创建,所以不需要我们手动创建一个数据库文件。
sqlite3 的注释是“–”。之前尝试了 “//” 和 “/**/” 都不行。
为了时间显示正确(我们在东八区),用了 TIMESTAMP default (datetime(‘now’, ‘localtime’)) 。
sqlite3 支持的数据类型不如 MySQL 之类的丰富,所以指定类型的时候需要注意。https://www.runoob.com/sqlite/sqlite-data-types.html。
另外,我这里将数据库连接定义成了可导出变量,但数据库初始完毕后又关闭了连接。之所以这样,是因为一开始我希望连接全局可用。但在其他文件导入后调用报错:invalid memory address or nil pointer dereference。咨询了懂 go 的同事,了解到可能是连接断了。想要在其他文件使用,连接需要是可持续的。gorm 就可以管理连接池。但我不想让项目变得复杂,所以,就改成每次都重连数据库了。
数据定时清理
为了实现定时清理过期数据,项目启动的时候需要设置一个定时器。
package db import ( "database/sql" "distributed-computer-monitor-server/config" "distributed-computer-monitor-server/util" "fmt" "time" "github.com/robfig/cron/v3" ) func ClearTimer() { // 定时器 cron2 := cron.New() clear() _, err := cron2.AddFunc("@daily", clear) util.CheckErr(err) // 启动定时器 cron2.Start() } // 清理过期数据 func clear() { oldTime := time.Unix(int64(time.Now().Unix())-int64(config.GConfig.Database.Sqlite.MaxDay*24*3600), 0).Local() // 打开数据库 db, err := sql.Open("sqlite3", config.GConfig.Database.Sqlite.Path) defer db.Close() // 删除记录 stmt, err := db.Prepare("DELETE FROM record WHERE created<?") util.CheckErr(err) _, err = stmt.Exec(oldTime.String()) util.CheckErr(err) // 删除主机 stmt, err = db.Prepare("DELETE FROM host WHERE updated<?") util.CheckErr(err) _, err = stmt.Exec(oldTime.String()) util.CheckErr(err) fmt.Print("清理过期数据完成\n") }
定时器的设置用了 github.com/robfig/cron/v3 这个包。这个包的用法和 linux cron 差不多。并且这个包预定义了一些时间表。这大大方便了开发者。
路由设置
gin 的使用非常简单,有种使用 nodejs 的 express 的感觉。
package routes import ( "context" "distributed-computer-monitor-server/config" "io/ioutil" "log" "net/http" "os" "os/signal" "time" "github.com/gin-gonic/gin" ) func InitRouter() error { gin.SetMode(config.GConfig.Mode) // 如果非 debug 模式,不打印信息 if config.GConfig.Mode != gin.DebugMode { gin.DefaultWriter = ioutil.Discard } router := gin.Default() // ping 处理 router.GET("/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") }) // web 端接口 WebRoutes(router) // 采集端接口 CollectRoutes(router) // 创建服务 srv := &http.Server{ Addr: config.GConfig.Server.Address + ":" + config.GConfig.Server.Port, Handler: router, } go func() { log.Printf("server running at http://%s:%s", config.GConfig.Server.Address, config.GConfig.Server.Port) // 服务连接 if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %s\n", err) } }() // 等待中断信息以优雅低关闭服务器(设置 5 秒的超时时间) quit := make(chan os.Signal) signal.Notify(quit, os.Interrupt) <-quit log.Println("Shutdown Server……") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Fatal("Server Shutdown:", err) } log.Println("Server exiting") return nil }
router := gin.Default()。简单一句就生成了一个路由实例,然后在其上添加路由就可以。
router.GET("/path", handler)
post 也只是把 GET 换成 POST 而已。
创建服务的代码里面,我打印了当前服务运行的地址。这主要是为了开发时方便地知道服务跑在哪个端口。而这句代码放在服务启动之前是因为服务启动后会处于阻塞状态(监听),后面的代码不会继续执行,除非中断程序。
保存采集的数据
实际的请求处理是从 router 到 controller 再到 service 的。主要逻辑在 serivce,所以这里只贴 service 代码。
package service import ( "database/sql" "distributed-computer-monitor-server/common/record" "distributed-computer-monitor-server/config" "distributed-computer-monitor-server/util" "github.com/gin-gonic/gin" ) // 处理上报的记录 func PostRecord(c *gin.Context) (err error) { // post 数据 reqData := record.RecordDataType{} reqErr := c.ShouldBindJSON(&reqData) if reqErr != nil { err = reqErr return } // 打开数据库 db, err := sql.Open("sqlite3", config.GConfig.Database.Sqlite.Path) defer db.Close() util.CheckErr(err) // 检索是否存在指定的主机 hostId := int64(0) queryErr := db.QueryRow("SELECT id FROM host WHERE hostname=?", reqData.Hostname).Scan(&hostId) if queryErr != nil { if queryErr != sql.ErrNoRows { util.CheckErr(queryErr) } else { // 主机不存在,插入新主机数据 stmt, err := db.Prepare("INSERT INTO host(hostname) values(?)") util.CheckErr(err) insertRes, err := stmt.Exec(reqData.Hostname) util.CheckErr(err) hostId, err = insertRes.LastInsertId() util.CheckErr(err) } } else { // 主机存在,更新数据(updated) stmt, err := db.Prepare("UPDATE host SET updated=datetime('now', 'localtime') WHERE id=?") util.CheckErr(err) _, err = stmt.Exec(hostId) util.CheckErr(err) } // 插入记录 stmt, err := db.Prepare("INSERT INTO record(host_id, cpu_type, cpu_core, cpu_load, cpu_temp, mem_cap, mem_load, mem_temp, swap_cap, swap_load, gpu_type, gpu_load, gpu_temp, net_up, net_down) values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") util.CheckErr(err) _, err = stmt.Exec(hostId, reqData.CpuType, reqData.CpuCore, reqData.CpuLoad, reqData.CpuTemp, reqData.MemCap, reqData.MemLoad, reqData.MemTemp, reqData.SwapCap, reqData.SwapLoad, reqData.GpuTemp, reqData.GpuLoad, reqData.GpuTemp, reqData.NetUp, reqData.NetDown) util.CheckErr(err) return }
首先通过 c.ShouldBindJSON 解析出请求参数。然后检查是否存在对应的主机,如果没有,主机表需要添加一条主机记录,否则更新主机记录时间。主机时间更新的时候依然要处理时间本地化问题——updated=datetime(‘now’, ‘localtime’),虽然建表的时候已经指定了需要本地化。
插入记录数据那里是真的冗长。虽然 go 已经很简洁了,但果然还是更喜欢 JavaScript(nodejs)。
采集信息读取
采集到的信息需要通过 web 端接口输出。比如主机列表。
// 获取主机列表 func GetHostList(c *gin.Context) (res []host.HostDataType, err error) { db, err := sql.Open("sqlite3", config.GConfig.Database.Sqlite.Path) util.CheckErr(err) /** * 用 datetime 把时间从 2022-08-09T16:24:28Z 变成 2022-08-09 16:24:28 * 有点奇怪。如果用 datetime(created, 'localtime') 转换,则时间会快八个小时,不使用 datetime 也会快 8 个小时。虽然知道是时区问题,但不知如何解决。网络上的解决方案都是 date(created, 'localtime')。 **/ rows, err := db.Query("SELECT id, hostname, datetime(created), datetime(updated) FROM host") util.CheckErr(err) for rows.Next() { var host = new(host.HostDataType) err = rows.Scan(&host.ID, &host.Hostname, &host.Created, &host.Updated) util.CheckErr(err) res = append(res, *host) } rows.Close() db.Close() return }
这里碰到一个问题——时区差异。根据网上的说法,为了从 sqlite 读取正确的本地时间,需要用 date(created, ‘localtime’)。但是,我这里读取出来的还是有差异,把 ‘localtime’ 去掉反而就好了。但如果不使用 date 函数转换也不正确。???
获取指定时间范围记录的接口差不都,只是需要处理一下默认时间。
// 默认参数设置 if reqData.StartTime.IsZero() { reqData.StartTime = time.Now().Add(-time.Hour).Local() } if reqData.EndTime.IsZero() { reqData.EndTime = time.Now().Local() }
时间是否为零时刻通过 time.Time.IsZero 判断。减去某个时间长度(duration)通过 Add(-duration)。Sub 是计算时间差的。func (t Time) Sub(u Time) Duration。
web 端集成
如前所述,web 端的文件是通过 gin 处理的,而不是 nginx。
// routes/routes.go func InitRouter() error { //…… router := gin.Default() // web 文件 router.Static("/js", "./web/js") router.Static("/css", "./web/css") router.StaticFile("/", "./web/index.html") router.StaticFile("/index", "./web/index.html") router.StaticFile("/index.html", "./web/index.html") router.StaticFile("/favicon.ico", "./web/favicon.ico") // …… }
web 端代码就不贴了,主要就是 art-template 模板渲染,echarts 画图。
不过说真的,vue 之类的用习惯了,再写原生 html 是真的难……受……。
采集端
采集端比较简单,不需要 gin。
config.json 配置一下服务端地址和主机名。
{ "hostname": "macos2", "server": { "address": "http://localhost:10020" } }
其余的代码都在 main.go 里(懒得拆分了)。
cpu
获取设备资源信息用的 github.com/shirou/gopsutil/v3 这个包。比如,获取 cpu 型号:
package main import ( "github.com/shirou/gopsutil/v3/cpu" ) // 获取 cpu 类型 func getCpuType() string { cpuInfo, err := cpu.Info() if err != nil { return "" } if len(cpuInfo) == 0 { return "" } return cpuInfo[0].ModelName }
获取 cpu 核心数:
// 获取 cpu 核心数 func getCpuCore() int { cores, err := cpu.Counts(true) if err != nil { return 0 } return cores }
获取 cpu 使用率:
// 获取 cpu 使用率 func getCpuLoad() float64 { percent, err := cpu.Percent(time.Second, false) if err != nil { return 0 } return percent[0] }
获取 cpu 温度:
// 获取 cpu 温度 func getCpuTemp() float64 { // windows 暂无法取得温度 sys := runtime.GOOS if sys == "windows" { return 0 } cmd := exec.Command("cat", "/sys/class/thermal/thermal_zone0/temp") var out bytes.Buffer cmd.Stdout = &out err := cmd.Run() if err != nil { return 0 } tempStr := strings.Replace(out.String(), "\n", "", -1) temp, err := strconv.Atoi(tempStr) if err != nil { return 0 } return float64(temp) / 1000 }
内存
获取内存容量:
// 获取内存容量 func getMemCap() int32 { memInfo, err := mem.VirtualMemory() if err != nil { return 0 } // 内存容量需要除以 1024 得到 kb。 return int32(memInfo.Total / 1024) }
获取内存负载:
// 获取内存负载 func getMemLoad() float64 { memInfo, err := mem.VirtualMemory() if err != nil { return 0 } return memInfo.UsedPercent }
swap
获取 swap 容量:
// 获取 swap 容量 func getSwapCap() int32 { memInfo, err := mem.SwapMemory() if err != nil { return 0 } return int32(memInfo.Total / 1024) }
在 windows 平台获取的 swap 是物理内存+分页文件。
获取 swap 负载:
// 获取 swap 负载 func getSwapLoad() float64 { memInfo, err := mem.SwapMemory() if err != nil { return 0 } return memInfo.UsedPercent }
上报
然后,把各个信息组装发送到服务端
// 发送信息 func sendInfo() { var info RecordDataType info.Hostname = config.Hostname // 获取信息 info.CpuType = getCpuType() info.CpuCore = getCpuCore() info.CpuLoad = getCpuLoad() info.CpuTemp = getCpuTemp() info.MemCap = getMemCap() info.MemLoad = getMemLoad() info.SwapCap = getSwapCap() info.SwapLoad = getSwapLoad() // 打印 json // jsonstr, err := json.MarshalIndent(info, "", " ") // if err == nil { // fmt.Print(string(jsonstr)) // } // 发送请求 jsonstr, err := json.Marshal(info) if err != nil { return } res, err := http.Post(config.Server.Address+"/api/collect/record/create", "application/json", bytes.NewBuffer(jsonstr)) if err != nil { fmt.Print(err.Error() + "\n") return } if res.StatusCode != 200 { fmt.Print("接口请求失败" + res.Status + "\n") } res.Body.Close() return }
发送完之后需要调用 res.Body.Close() 释放内存。
另外,如注释处,为了打印出美观的 json,可以使用 json.MarshalIndent(info, “”, ” “)。
打包部署
打包
go 的打包直接 go build 就可以了。交叉编译也只需要先设置 GOARCH 和 GOOS 为目标平台。但是,实际操作哪有这么顺利。
不管是 mac 还是 windows,编译自身平台是没有问题的,但是,交叉编译到 linux 就不行了。执行的时候报错。可能是 cpu 新旧问题。
后台,我是在服务器的 linux 环境部署了一个 go 的 docker 镜像,然后通过这个镜像交叉编译 windows 版本。
// linux 编译 docker run --rm -v "$PWD":/usr/src/myapp -w /usr/src/myapp golang go build -v // linux 交叉编译 windows 版本 docker run --rm -v "$PWD":/usr/src/myapp -w /usr/src/myapp -e GOOS=windows -e GOARCH=amd64 golang go build -v
打包完成后就可以把执行文件复制到对应主机运行了。是吗?
当然不是。我一开始以为打包会把需要用的文件都打包到二进制可执行文件里。但执行的时候发现缺少配置文件。所以,配置文件、数据库文件夹还是需要先创建好的。也可以理解,配置文件是动态读取的,并且为了修改配置,也确实不需要打包进可执行文件里。
后台运行
对于 windows,直接双击可执行文件就可以了,反正远程桌面断开不影响 cmd 的后台运行。
但 linux 就不行了。ssh 断开后运行的程序也会停掉。为了让程序后台运行,可以用 nohup 命令。
nohup ./xxx & // 会出现下面的提示,直接回车 nohup: ignoring input and appending output to 'nohup.out'
或者,使用 screen 命令创建一个会话,然后让这个会话后台运行。
screen -S DCM ./xxx // Ctrl-a d 退出
总结
这个项目比较简单,功能不多,就是一个采集端收集数据上报,然后服务端接口并提供数据查询的功能。涉及到的知识不多也不少。gin、sqlite3、定时器、硬件信息获取、交叉编译、后台运行等。
至于 go……确实心智负担比较小,虽然时间格式化很想让人吐槽。