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……确实心智负担比较小,虽然时间格式化很想让人吐槽。