所有文章 > API开发 > 实例利用gin搭建一个API框架
实例利用gin搭建一个API框架

实例利用gin搭建一个API框架

在实例中,我们简单使用了net/http搭建了一个server,其实在日常开发中,比较少去使用标准库去直接写api,更多的是使用前人搭建好的轮子(我呢,是个不太喜欢重复造轮子的开发者,有开源的靠谱的,直接用就好,自己调整成自己需要的即可),那么说的go的框架,不得不说gin了。

对于gin的介绍,是github上star最好的go框架了,其他不多说,我们上手写起来吧!

使用到的库:

  • github.com/fsnotify/fsnotify
  • github.com/gin-gonic/gin
  • github.com/go-redis/redis
  • github.com/jinzhu/gorm
  • http://github.com/lestrrat-go/file-rotatelogs
  • http://go.uber.org/zap
  • github.com/spf13/pflag
  • github.com/spf13/viper

初始化

modules的引入之后,我们就可以不必使用gopath去管理项目目录了,对于modules的基本使用,建议看文章:

https://github.13sai.com/2019/12/27/219/

我们开始:

go mod init sai0556/demo2-gin-frame

可以看到go.mod已生成:

module sai0556/demo2-gin-frame

go 1.13

自定义配置与读取:

在我们使用redis和mysql之前,我们先来读取一下配置,配置呢我们使用常见的yaml,当然你也可以使用其他,比如env等。

新建config目录,用来读取与监听配置文件(config.yaml):

package config

import (
"fmt"

"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
)

type Config struct {
Name string
}

// 对外的初始化配置方法
func Run(cfg string) error {
c := Config{
Name: cfg,
}

if err := c.init(); err != nil {
return err
}

c.watchConfig()

return nil
}

func (c *Config) init() error {
if c.Name != "" {
viper.SetConfigFile(c.Name)
} else {
// 默认配置文件是./config.yaml
viper.AddConfigPath(".")
viper.SetConfigName("config")
}

viper.SetConfigType("yaml")
// viper解析配置文件
err := viper.ReadInConfig()
if err != nil {
panic(fmt.Errorf("Fatal error config file: %s \n", err))
}

// 简单打印下配置
fmt.Println(viper.GetString("name"))

return nil
}

func (c *Config) watchConfig() {
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
fmt.Println("Config file changed:", e.Name)
})
}

main:

package main

import (
"github.com/spf13/pflag"

"sai0556/demo2-gin-frame/config"
)

var (
conf = pflag.StringP("config", "c", "", "config filepath")
)

func main() {
pflag.Parse()

// 初始化配置
if err := config.Run(*conf); err != nil {
panic(err)
}

}

这里有用到大牛spf13的两个包,pflag和viper,命令行参数解析包pflag可以看作flag的进阶版本,在我们这里可以用来指定配置文件,viper是读取配置文件的包,配合fsnotify可以实现配置的热更新。(spf13大神还有其他有用的包,相信在你的go编码生涯会用到的)

写完我们可以运行一下:

go run main.go -c ./config.yaml

可以看到有打印出我们配置的name。

整合mysql与redis

mysql包我们就选用gorm,redis的使用比较多的是redigo和go-redis,redigo曾在我使用中出现过问题,因而我们选择后者,后者也支持连接池。

mysql:

package db

import (
"fmt"
"sync"
"errors"

orm "github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
"github.com/spf13/viper"
)

type MySqlPool struct {}

var instance *MySqlPool
var once sync.Once

var db *orm.DB
var err error

// 单例模式
func GetInstance() *MySqlPool {
once.Do(func() {
instance = &MySqlPool{}
})

return instance
}

func (pool *MySqlPool) InitPool() (isSuc bool) {
// 这里有一种常见的拼接字符串的方式
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=%s", viper.GetString("db.username"), viper.GetString("db.password"), viper.GetString("db.host"), viper.GetString("db.name"), viper.GetString("db.charset"))
db, err = orm.Open("mysql", dsn)
if err != nil {
panic(errors.New("mysql连接失败"))
return false
}

// 连接数配置也可以写入配置,在此读取
db.DB().SetMaxIdleConns(viper.GetInt("db.MaxIdleConns"))
db.DB().SetMaxOpenConns(viper.GetInt("db.MaxOpenConns"))
// db.LogMode(true)
return true
}

后面获取连接池就可以直接使用 db.GetInstance()

redis:

package db

import (
"fmt"

"github.com/spf13/viper"
"github.com/go-redis/redis"
)

var RedisClient *redis.Client

func InitRedis() {
RedisClient = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%s", viper.GetString("redis.host"), viper.GetString("redis.port")),
Password: viper.GetString("redis.auth"),
DB: 0,
})

_, err := RedisClient.Ping().Result()
if err != nil {
panic("redis ping error")
}
}

RedisClient就是我们后面可以用的redis连接池。

在main中加入初始化连接池的代码即可:// 连接mysql数据库

btn := db.GetInstance().InitPool()
if !btn {
log.Println("init database pool failure...")
panic(errors.New("init database pool failure"))
}

// redis
db.InitRedis()

路由与控制器

为了方便路由,我们把路由管理单独到router。

package router

import (
"net/http"

"github.com/gin-gonic/gin"

"sai0556/demo2-gin-frame/controller"
)

func Load(g *gin.Engine) *gin.Engine {
g.Use(gin.Recovery())
// 404
g.NoRoute(func (c *gin.Context) {
c.String(http.StatusNotFound, "404 not found");
})

g.GET("/", controller.Index)

return g
}

controller:

package controller

import (
"net/http"

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

// 返回
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}

// api返回结构
func ApiResponse(c *gin.Context, code int, message string, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: code,
Message: message,
Data: data,
})
}

func Index(c *gin.Context) {
ApiResponse(c, 0, "success", nil)
}

到这呢,基本也就差不多了。

我们来看下完整的main:

package main

// import 这里我习惯把官方库,开源库,本地module依次分开列出
import (
"log"
"errors"

"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/gin-gonic/gin"

"sai0556/demo2-gin-frame/config"
"sai0556/demo2-gin-frame/db"
"sai0556/demo2-gin-frame/router"
)

var (
conf = pflag.StringP("config", "c", "", "config filepath")
)

func main() {
pflag.Parse()

// 初始化配置
if err := config.Run(*conf); err != nil {
panic(err)
}

// 连接mysql数据库
btn := db.GetInstance().InitPool()
if !btn {
log.Println("init database pool failure...")
panic(errors.New("init database pool failure"))
}

// redis
db.InitRedis()

gin.SetMode(viper.GetString("mode"))
g := gin.New()
g = router.Load(g)
g.Run(viper.GetString("addr"))
}

整合日志

这里我们先定义下log:

log:
level: debug # 日志级别,info,debug,error
file_format: "%Y%m%d" # 文件格式
max_save_days: 30 # 保存天数
file_type: one # one, level 单文件存储还是以level级别存储

整合logger:

package logger

import (
"io"
"log"
"time"

"github.com/lestrrat-go/file-rotatelogs"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/spf13/viper"
)

var Logger *zap.Logger
var LogLevel string
var FileFormat string

// 初始化日志 logger
func init() {
// 设置一些基本日志格式
config := zapcore.EncoderConfig{
MessageKey: "msg",
LevelKey: "level",
EncodeLevel: zapcore.CapitalLevelEncoder,
TimeKey: "ts",
EncodeTime: func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendString(t.Format("2006-01-02 15:04:05"))
},
CallerKey: "file",
EncodeCaller: zapcore.ShortCallerEncoder,
EncodeDuration: func(d time.Duration, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendInt64(int64(d) / 1000000)
},
}
encoder := zapcore.NewConsoleEncoder(config)

FileFormat, saveType, LogLevel := "%Y%m%d", "one", "info"

if viper.IsSet("log.file_format") {
FileFormat = viper.GetString("log.file_format")
}

if viper.IsSet("log.level") {
LogLevel = viper.GetString("log.level")
}

if viper.IsSet("log.save_type") {
saveType = viper.GetString("log.save_type")
}

logLevel := zap.DebugLevel
switch LogLevel {
case "debug":
logLevel = zap.DebugLevel
case "info":
logLevel = zap.InfoLevel
case "error":
logLevel = zap.ErrorLevel
default:
logLevel = zap.InfoLevel
}

switch saveType {
case "level":
Logger = getLevelLogger(encoder, logLevel, FileFormat)
default:
Logger = getOneLogger(encoder, logLevel, FileFormat)
}


}

func getLevelLogger(encoder zapcore.Encoder, logLevel zapcore.Level, fileFormat string) *zap.Logger {
infoLevel := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
return lvl == zapcore.InfoLevel && lvl >= logLevel
})

debugLevel := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
return lvl == zapcore.DebugLevel && lvl >= logLevel
})

errorLevel := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
return lvl >= zapcore.ErrorLevel && lvl >= logLevel
})
// 获取 info、warn日志文件的io.Writer 抽象 getLoggerWriter() 在下方实现
infoWriter := getLoggerWriter("./log/info", fileFormat)
errorWriter := getLoggerWriter("./log/error", fileFormat)
debugWriter := getLoggerWriter("./log/debug", fileFormat)

// 最后创建具体的Logger
core := zapcore.NewTee(
zapcore.NewCore(encoder, zapcore.AddSync(debugWriter), debugLevel),
zapcore.NewCore(encoder, zapcore.AddSync(infoWriter), infoLevel),
zapcore.NewCore(encoder, zapcore.AddSync(errorWriter), errorLevel),
)
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zap.WarnLevel))
}

func getOneLogger(encoder zapcore.Encoder, logLevel zapcore.Level, fileFormat string) *zap.Logger {
infoWriter := getLoggerWriter("./log/info", fileFormat)

infoLevel := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
return lvl == zapcore.InfoLevel && lvl >= logLevel
})

core := zapcore.NewTee(
zapcore.NewCore(encoder, zapcore.AddSync(infoWriter), infoLevel),
)
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zap.WarnLevel))
}

func getLoggerWriter(filename, fileFormat string) io.Writer {
// 生成rotatelogs的Logger 实际生成的文件名 file_YYmmddHH.log
hook, err := rotatelogs.New(
filename+fileFormat+".log",
rotatelogs.WithLinkName(filename),
// 保存天数
rotatelogs.WithMaxAge(time.Hour*24*30),
// 切割频率24小时
rotatelogs.WithRotationTime(time.Hour*24),
)
if err != nil {
log.Println("日志启动异常")
panic(err)
}
return hook
}

func Debug(format string, v ...interface{}) {
Logger.Sugar().Debugf(format, v...)
}

func Info(format string, v ...interface{}) {
Logger.Sugar().Infof(format, v...)
}

func Error(format string, v ...interface{}) {
Logger.Sugar().Errorf(format, v...)
}

这里注意init函数,我们直接调用logger其中函数即可,程序加载包的过程中会自动执行init函数。关于init有以下说明:

  1. init函数是用于程序执行前做包的初始化的函数,比如初始化包里的变量等
  2. 每个包可以拥有多个init函数
  3. 包的每个源文件也可以拥有多个init函数
  4. 同一个包中多个init函数的执行顺序go语言没有明确的定义(说明)
  5. 不同包的init函数按照包导入的依赖关系决定该初始化函数的执行顺序
  6. init函数不能被其他函数调用,而是在main函数执行之前,自动被调用

我们直接使用:

logger.Info("i'm log123-----Info")
logger.Error("i'm log123-----Error")

平滑重启

当程序在线上稳定运行后,我们可能会去更新一些功能,但发布代码的同时,假如有用户正在使用,盲目发布代码可能会造成用户短暂失真,这时候平滑重启就来了。

对于平滑重启,其实有很多方案,这里我们只从自身代码级别来完成,而即便是代码级别,目前也有多种实现方案,比如第三方库endless这种,我这里主要参考了

https://github.com/kuangchanglang/gracefulgithub.com

简单说明下处理步骤:

  1. 监听信号(USR2,可自定义其他信号)
  2. 收到信号时fork子进程(使用相同的启动命令),将服务监听的socket文件描述符传递给子进程
  3. 子进程监听父进程的socket,这个时候父进程和子进程都可以接收请求
  4. 子进程启动成功之后,父进程停止接收新的连接,等待旧连接处理完成(或超时)
  5. 父进程退出,重启完成

详细分析可看底部参考-Golang服务器热重启、热升级、热更新

启动检查

结合上面的优雅重启,我们在启动时配置上启动健康检查:

package main

// import 这里我习惯把官方库,开源库,本地module依次分开列出
import (
"fmt"
"time"
"errors"
"net/http"

"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/gin-gonic/gin"
"sai0556/demo2-gin-frame/config"
"sai0556/demo2-gin-frame/db"
"sai0556/demo2-gin-frame/router"
"sai0556/demo2-gin-frame/logger"
"sai0556/demo2-gin-frame/graceful"
)

var (
conf = pflag.StringP("config", "c", "", "config filepath")
)

func main() {
pflag.Parse()

// 初始化配置
if err := config.Run(*conf); err != nil {
panic(err)
}

// logger.Info("i'm log123-----Info")
// logger.Error("i'm log123-----Error")


// 连接mysql数据库
DB := db.GetDB()
defer db.CloseDB(DB)

// redis
db.InitRedis()

gin.SetMode(viper.GetString("mode"))
g := gin.New()
g = router.Load(g)

// g.Run(viper.GetString("addr"))

go func() {
if err := pingServer(); err != nil {
fmt.Println("fail:健康检测失败", err)
}
fmt.Println("success:健康检测成功")
}()

logger.Info("启动http服务端口%s\n", viper.GetString("addr"))

if err := graceful.ListenAndServe(viper.GetString("addr"), g); err != nil && err != http.ErrServerClosed {
logger.Error("fail:http服务启动失败: %s\n", err)
}
}

// 健康检查
func pingServer() error {
for i := 0; i < viper.GetInt("max_ping_count"); i++ {
url := fmt.Sprintf("%s%s%s", "http://127.0.0.1", viper.GetString("addr"), viper.GetString("healthCheck"))
fmt.Println(url)
resp, err := http.Get(url)
if err == nil && resp.StatusCode == 200 {
return nil
}
time.Sleep(time.Second)
}
return errors.New("健康检测404")
}

这里就比较简单,另外启动一个协程,去ping健康检测的url即可。

打包脚本

shell

#!/bin/bash
SERVER="demo2-gin-frame"

function status()
{
if [ "`pgrep $SERVER -u $UID`" != "" ];then
echo $SERVER is running
else
echo $SERVER is not running
fi
}

function build()
{
echo "build..."

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./$SERVER main.go
if [ $? -ne "0" ];then
echo "built error!!!"
return
fi

echo "built success!"
}


case "$1" in
'status')
status
;;
'build')
build
;;
*)
echo "unknown, please: $0 {status or build}"
exit 1
;;
esac

bat

echo "build..."
SET CGO_ENABLED=0
SET GOOS=linux
go build -o demo2-gin-frame
echo commitid=%commitid%
if %errorlevel% == 0 (
echo "built successfully"
) else (
echo "built fail!!!"
)

对于程序的重启和保活,建议配合supervisor使用。

好,到这里我们的round 2就结束了。下一轮我们来玩玩钉钉智能机器人。

直达完整代码https://github.com/13sai/go-example/tree/main/demo2-gin-frame

参考:

  • golang zap 日志库使用(含文件切割、分级别存储和全局使用等)
  • 基于Go语言构建企业级的 RESTful API 服务
  • golang init函数 – Go语言中文网 – Golang中文社区
  • Golang服务器热重启、热升级、热更新(safe and graceful hot-restart/reload http server)详解

本文章转载微信公众号@SaiWeng

#你可能也喜欢这些API文章!