跳到主要内容

实现基本功能

前言

  • 连接数据库
  • 读取配置
  • 日志

读取配置

配置文件格式比较多的有.ini, .yaml, .json, .toml等,但在go项目中,还是.yaml格式用的比较多。读取.yaml文件个人一般用gopkg.in/yaml.v3包或viper。这里以viper为例。

假设有这么一个配置文件config.yaml,位于项目根目录的conf目录

web:
host: 0.0.0.0
port: 8000
mode: release # debug or release

配置模块可以放在pkg/config目录下

package config

import (
"log/slog"
"os"
"os/signal"
"syscall"

"github.com/spf13/viper"
)

var v *viper.Viper

func init() {
v = viper.New()
v.AddConfigPath("conf") // 配置文件的目录
v.SetConfigType("yaml") // 配置文件的类型
v.SetConfigName("config")

err := v.ReadInConfig()
if err != nil {
slog.Error("Error reading config file", "error", err)
panic(err)
}
reloadWithSighup()
}

// 监听SIGHUP信号,用于热加载
func reloadWithSighup() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGHUP)

go func() {
for range sigs {
slog.Info("SIGHUB signal received, reloading config...")
if err := v.ReadInConfig(); err != nil {
slog.Error("Error reloading config file", "error", err)
}
slog.Info("Reload config successfully")
}
}()
}

type webaddr struct {
Host string
Port int
}

func GetWebAddr() webaddr {
host := v.GetString("web.host")
port := v.GetInt("web.port")
return webaddr{Host: host, Port: port}
}


func GetGinMode() string {
slog.Info("gin mode: " + v.GetString("web.mode"))
return v.GetString("web.mode")
}

在其他模块中使用

package main

import (
"fmt"
"net/http"

"github.com/gin-gonic/gin"

"gin-hello/pkg/config"
)

func main() {
if config.GetGinMode() == "release" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.Default()

router.GET("/header", func(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")
c.String(http.StatusOK, userAgent)
})

webaddr := config.GetWebAddr()
server := fmt.Sprintf("%s:%d", webaddr.Host, webaddr.Port)

if err := router.Run(server); err != nil {
panic(err)
}
}

日志

go在1.22版本中正式推出slog,相较于log,性能会好一些,而且终于支持日志级别了。

除了日志级别,生产业务一般还会把日志输出到文件中,并且日志文件还要能自动切割,防止单个日志文件体积过大。(如果生产业务跑在k8s,还可以直接输出到控制台,一般生产级别的k8s都会配置控制台日志收集监控,不需要业务应用自己操心日志文件怎么管理)

日志模块也放在pkg/log目录下

package log

import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"time"

"gopkg.in/natefinch/lumberjack.v2"

"gin-hello/pkg/config"
)

var (
logger *slog.Logger
logFile string = "app.log"
logDir string
logRetention int
)

func init() {
logcfg := config.GetLogCfg()
logDir = logcfg.LogDir
logRetention = logcfg.Retention
if _, err := os.Stat(logDir); os.IsNotExist(err) { // 创建日志目录
err := os.Mkdir(logDir, os.ModePerm)
if err != nil {
fmt.Printf("Create log directory '%s' failed\n", logDir)
panic(err)
}
}
logOpts := &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelInfo,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { // 日志时间格式化
if a.Key == slog.TimeKey {
t, ok := a.Value.Any().(time.Time)
if !ok {
return a
}
return slog.String(slog.TimeKey, t.Format("2006-01-02 15:04:05"))
}
return a
},
}

rotateLogger := getRorateLogger()
var multiWriter io.Writer
if logcfg.Console {
multiWriter = io.MultiWriter(rotateLogger)
} else {
multiWriter = io.MultiWriter(os.Stdout, rotateLogger)
}

fileHandler := slog.NewJSONHandler(multiWriter, logOpts)
logger = slog.New(fileHandler)
}

// 配置日志自动切割
func getRorateLogger() *lumberjack.Logger {
logFilePath := filepath.Join(logDir, logFile)
return &lumberjack.Logger{
Filename: logFilePath,
MaxAge: logRetention,
Compress: true,
}
}

func Debug(msg string, args ...any) {
logger.Debug(msg, args...)
}

func Info(msg string, args ...any) {
logger.Info(msg, args...)
}

func Error(msg string, args ...any) {
logger.Error(msg, args...)
}

func Warn(msg string, args ...any) {
logger.Warn(msg, args...)
}

在其他模块中使用。注意,slog偏向结构化日志记录,并不提供像log.Infof()的格式化记录方法。

package main

import (
"fmt"
"net/http"

"github.com/gin-gonic/gin"

"gin-hello/pkg/config"
"gin-hello/pkg/log"
)

func main() {
if config.GetGinMode() == "release" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.Default()

router.GET("/header", func(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")
log.Info("User-Agent", "userAgent", userAgent)
c.String(http.StatusOK, userAgent)
})

webaddr := config.GetWebAddr()
server := fmt.Sprintf("%s:%d", webaddr.Host, webaddr.Port)

if err := router.Run(server); err != nil {
panic(err)
}
}

监控

这里主要讲的是指标监控,Web应用通过一个接口暴露运行状态的指标,便于监控系统收集。目前主流的做法是对接Prometheus监控系统。下面以暴露api访问次数的指标为例

  1. 编辑internal/metrics/metrics.go
package metrics

import (
"regexp"

"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
)

// 不使用默认的registry, 重新声明一个
var registry *prometheus.Registry

func init() {
registry = prometheus.NewRegistry()
registry.MustRegister(CounterHTTPRequest) // 注册统计请求的counter
registry.MustRegister(collectors.NewBuildInfoCollector())
registry.MustRegister(collectors.NewGoCollector(
collectors.WithGoCollectorRuntimeMetrics(collectors.GoRuntimeMetricsRule{
Matcher: regexp.MustCompile("/.*"),
}),
))
}

func GetRegistry() *prometheus.Registry {
return registry
}

var CounterHTTPRequest = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of http requests",
},
[]string{"method", "endpoint"},
)

  1. 在中间件中调用。pkg/middlewares/mids.go
package middlewares

import (
"gin-hello/internal/metrics"

"github.com/gin-gonic/gin"
)

type mids struct{}

func NewMids() *mids {
return &mids{}
}

func (m *mids) ApiTime(c *gin.Context) {
path := c.FullPath()
if path == "" {
path = "unknown"
}
method := c.Request.Method
c.Next()
metrics.CounterHTTPRequest.WithLabelValues(method, path).Inc()
}
  1. 绑定路由。main.go
package main

import (
"fmt"
"net/http"

"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"

"gin-hello/internal/metrics"
"gin-hello/pkg/config"
"gin-hello/pkg/log"
"gin-hello/pkg/middlewares"
)

func main() {
if config.GetGinMode() == "release" {
gin.SetMode(gin.ReleaseMode)
}
mids := middlewares.NewMids()
router := gin.Default()
router.Use(mids.ApiTime)

router.GET("/header", func(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")
log.Info("User-Agent", "userAgent", userAgent)
c.String(http.StatusOK, userAgent)
})

// 指标接口一般命名为 /metrics
registry := metrics.GetRegistry()
router.GET("/metrics", gin.WrapH(promhttp.HandlerFor(registry, promhttp.HandlerOpts{
EnableOpenMetrics: true,
})))

webaddr := config.GetWebAddr()
server := fmt.Sprintf("%s:%d", webaddr.Host, webaddr.Port)

if err := router.Run(server); err != nil {
panic(err)
}
}

连接数据库

在web应用中,难免需要跟数据库打交道,最常见的就是连接关系型数据库,比如mysql、postgres、sqlite。一些键值型数据库也很常见,用来当缓存。比如Redis、memcache.

关系型数据库

常见的关系型数据库有MySQL、SQLite、Postgres、Oracle、DB2,其中后两种商业关系型数据库暂不讨论。Go语言并没有为任何数据库提供标准的驱动程序,但是Go语言定义了database/sql接口,按照此接口开发驱动就可以直接在Go程序中使用。不过本人很少在web应用中直接写sql,用的比较多的是ORM,比如GROM、XORM,这样方便在各种数据库之间切换。部分应用中也会用"sqlx"来直接写sql,性能会好一些,本节还是介绍怎么用GORM连接数据库。GORM具体用法请见官网文档:https://gorm.io

假设在配置文件中配置db的连接信息

store:
rdb:
dbtype: sqlite # mysql | sqlite | postgres
host: 127.0.0.1
port: 3306
user: root
pass: 123456
dbname: test
dbfile: test.db # used for sqlite

在以viper为基础的配置模块中读取

type rdbcfg struct {
Dbtype string
Host string
Port int
Username string
Password string
Dbname string
Dbfile string
}

func GetRdbCfg() rdbcfg {
dbtype := v.GetString("store.rdb.dbtype")
host := v.GetString("store.rdb.host")
port := v.GetInt("store.rdb.port")
username := v.GetString("store.rdb.username")
password := v.GetString("store.rdb.password")
dbname := v.GetString("store.rdb.dbname")
dbfile := v.GetString("store.rdb.dbfile")
return rdbcfg{Dbtype: dbtype, Host: host, Port: port, Username: username, Password: password, Dbname: dbname, Dbfile: dbfile}
}

创建文件pkg/db/rdb.go,通过gorm支持三种关系型数据库

package db

import (
"fmt"
"gin-hello/pkg/config"
"strings"

"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)

var (
db *gorm.DB
err error
)

func InitDB() error {
cfg := config.GetRdbCfg()
if cfg.Host == "" || cfg.Dbname == "" || cfg.Username == "" || cfg.Password == "" {
return fmt.Errorf("invalid database configuration")
}
switch strings.ToLower(cfg.Dbtype) {
case "mysql":
dsn := getDSNMysql(cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.Dbname)
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return fmt.Errorf("db mysql connect error %s", err)
}
case "postgres":
dsn := getDSNPG(cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.Dbname)
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return fmt.Errorf("db postgres connect error %s", err)
}
case "sqlite":
db, err = gorm.Open(sqlite.Open(cfg.Dbfile), &gorm.Config{})
if err != nil {
return fmt.Errorf("db sqlite connect error %s", err)
}
default:
return fmt.Errorf("unsupport dbtype %s", cfg.Dbtype)
}
sqlDB, _ := db.DB()
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(20)
return nil
}

func GetDB() *gorm.DB {
return db
}

func getDSNMysql(host string, port int, user string, password string, dbname string) string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", user, password, host, port, dbname)
}

func getDSNPG(host string, port int, user string, password string, dbname string) string {
return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable TimeZone=Asia/Shanghai", host, user, password, dbname, port)
}

redis

在web项目中,redis常用作缓存。除了缓存功能,redis还能提供消息队列、分布式锁功能等。使用 go-redis来连接redis数据库

配置文件

store:
redis:
enabled: true
addr: 127.0.0.1:6379
username: ""
password: ""
dbnum: 0

读取配置

type rediscfg struct {
Enabled bool
Addr string
Username string
Password string
Dbnum int
}

func GetRedisCfg() rediscfg {
return rediscfg{
Enabled: v.GetBool("store.redis.enabled"),
Addr: v.GetString("store.redis.addr"),
Username: v.GetString("store.redis.username"),
Password: v.GetString("store.redis.password"),
Dbnum: v.GetInt("store.redis.dbnum"),
}
}

实例化 /pkg/db/redis.go

package db

import (
"gin-hello/pkg/config"

"github.com/redis/go-redis/v9"
)

var redisClient *redis.Client

func InitRedis() {
cfg := config.GetRedisCfg()

redisClient = redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.Dbnum,
})
}

func GetRedisClient() *redis.Client {
return redisClient
}