案例——《多主机资源监测》解析

项目介绍

这个项目主要用来收集主机的 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……确实心智负担比较小,虽然时间格式化很想让人吐槽。