这是我所参与搭建的第三个框架,还继续以以前的风格,从main函数开始拆解分析这个框架,是目前我所能接触和实现的一个最为有技术含量的go的后端项目框架。
main函数
这个注释的作用是swagger生成接口文档的第一步即:添加注释
具体:
@title 这里写的是标题
@version 这里是版本号
@description 这是对这个项目的一个描述
@contact.name 这个写的是联系人的名字
@contact.url 这里写的是联系网址
@contact.email 这里写的是联系邮箱
@host 这里写的是接口服务的host
@BasePath 这里写base path
定义命令行flag参数的两种方式之一
Go语言内置的flag包实现了命令行参数的解析,flag包使得开发命令行工具更为简单。
flag.StringVar(type指针,flag名,默认值,帮助信息):用来
定义好o命令行flag参数后,调用flag.Parse()来对命令行参数进行解析
支持的命令行参数格式有以下几种:
config.Setup()该方法的作用是载入配置文件,具体如下(讲解一下config):
这个文件代码较多,涉及也较广,具体如下:
下面这些变量是子树。原因:所有配置写在一个文件,里面是针对不同方面的不同配置,所以使用下面这种方式来定义每一块儿配置
// Mysql配置项
var cfgMysql *viper.Viper
// 应用配置项
var cfgApplication *viper.Viper
// Token配置项
var cfgJwt *viper.Viper
// Log配置项
var cfgLogger *viper.Viper
// Redis配置项
var cfgRedis *viper.Viper
下面这个函数的作用是载入配置文件(我会对其进行拆解,一小块一小块的解析):
//载入配置文件
func Setup(path string) {
}
下面这一系列方法作用:
viper.SetConfigFile()指定配置文件
ioutil.ReadFile()读取文件的方式之一,传入文件名字,返回一个byte[]和一个err,会将文件的内容做为一个字节数组返回,如果报错则打印。
使用ioutil.ReadFile()注意:①不需要手动 打开与关闭文件,系统会帮我们自动完成;②只适合于小文件
// 加载配置文件
viper.SetConfigFile(path)
content, err := ioutil.ReadFile(path)
if err != nil {
log.Fatal(fmt.Sprintf("Read config file fail: %s", err.Error()))
}
下面这些方法作用:
string(content):将[]byte类型数据转为string类型
os.ExpandEnv():把字符串的s替换成环境变量的内容,函数的原形是func ExpandEnv(s string) string
strings.NewReader():创建一个从s读取数据的Reader,返回的*Reader中的i指的是读取到的位置
viper.ReadConfig():viper 支持从io.Reader中读取配置。这种形式很灵活,来源可以是文件,也可以是程序中生成的字符串,甚至可以从网络连接中读取的字节流。
//Replace environment variables
err = viper.ReadConfig(strings.NewReader(os.ExpandEnv(string(content))))
if err != nil {
log.Fatal(fmt.Sprintf("Parse config file fail: %s", err.Error()))
}
viper.Sub():访问嵌套的键
通过传入.分隔的路径来访问嵌套字段
将不同模块儿的配置信息存给不同的变量。
然后在不同位置初始化(添加配置信息)各自模块。
cfgMysql = viper.Sub("settings.mysql")
if cfgMysql == nil {
panic("No found settings.mysql in the configuration")
}
MysqlConfig = InitMySql(cfgMysql)
cfgJwt = viper.Sub("settings.jwt")
if cfgJwt == nil {
panic("No found settings.jwt in the configuration")
}
JwtConfig = InitJwt(cfgJwt)
cfgRedis = viper.Sub("settings.redis")
if cfgRedis == nil {
panic("No found settings.redis in the configuration")
}
RedisConfig = InitRedis(cfgRedis)
下面这个是mysql数据库的初始化:
定义mysql结构体
然后对其进行初始化:值通过viper的方式传递。
返回的类型是Mysql类型的指针
以下这一系列方法都有一个特点:
即最后一行代码(很特别的一种写法):
声明一个对象供外部使用。
上面的代码即是这种方式实现的。
type Mysql struct {
User string
Password string
Host string
DbName string
Port int
DbMaxOpen int
DbMaxIdle int
}
// InitMySql 初始化mysql配置
func InitMySql(cfg *viper.Viper) *Mysql {
db := &Mysql{
User: cfg.GetString("user"),
Password: cfg.GetString("password"),
Host: cfg.GetString("host"),
DbName: cfg.GetString("dbname"),
Port: cfg.GetInt("port"),
DbMaxOpen: cfg.GetInt("maxopen"),
DbMaxIdle: cfg.GetInt("maxidle"),
}
return db
}
var MysqlConfig = new(Mysql)
下面这个是redis初始化配置操作
Redis里的字段作用:
poolSize:最大连接数(设置为0则视情况而定)
IdleTimeOutSec:最大空闲连接时间
DB:数据库(redis一共十六个数据库)
Port:端口
Password:密码(一般不设密码)
Host:服务器地址
type Redis struct {
PoolSize int
IdleTimeOutSec int
DB int
Port int
Password string
Host string
}
// InitRedis 初始化redis配置
func InitRedis(cfg *viper.Viper) *Redis {
db := &Redis{
PoolSize: cfg.GetInt("poolsize"),
IdleTimeOutSec: cfg.GetInt("idletimeoutsec"),
DB: cfg.GetInt("db"),
Port: cfg.GetInt("port"),
Host: cfg.GetString("host"),
Password: cfg.GetString("password"),
}
return db
}
var RedisConfig = new(Redis)
下面是jwt初始化配置操作(加盐+设置超时时间)
type Jwt struct {
Secret string
Timeout int64
}
// InitJwt 初始化jwt配置
func InitJwt(cfg *viper.Viper) *Jwt {
return &Jwt{
Secret: cfg.GetString("secret"),
Timeout: cfg.GetInt64("timeout"),
}
}
var JwtConfig = new(Jwt)
回到main方法
// 2. 初始化日志
if err := logger.Init(config.LoggerConfig, config.ApplicationConfig.Mode); err != nil {
fmt.Printf("init logger failed, err:%v\n", err)
return
}
defer zap.L().Sync()
zap.L().Debug(utils.Green("logger init success..."))
初始化日志操作,传入两个参数:一个是日志配置信息,另一个是模式(主要是开发模式和生产模式)
下面这个方法就是初始化日志操作,返回一个错误。
主要是想使用定制的logger:将日志写入文件而不是终端
内部代码见下图:
func Init(cfg *config.Logger, mode string) (err error)
传入指定四个参数,返回一个zapcore.WriteSyncer对象(下)
writeSyncer := getLogWriter(
cfg.Filename,
cfg.MaxSize,
cfg.MaxBackups,
cfg.MaxAge,
)
encoder := getEncoder()
var l = new(zapcore.Level)
err = l.UnmarshalText([]byte(cfg.Level))
if err != nil {
return
}
var core zapcore.Core
if mode == string(utils.ModeProd) {
// 进入生产模式
core = zapcore.NewCore(encoder, writeSyncer, l)
} else {
// 进入开发模式或测试模式
consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
core = zapcore.NewTee(
zapcore.NewCore(encoder, writeSyncer, l),
zapcore.NewCore(consoleEncoder, zapcore.Lock(os.Stdout), l),
)
}
lg := zap.New(core, zap.AddCaller())
zap.ReplaceGlobals(lg) // 替换zap包中全局的logger实例,后续在其他包中只需使用zap.L()调用即可
return
具体步骤一:WriterSyncer :指定日志将写到哪里去。
但是如果想实现将日志切割归档,则需要使用第三方库Lumberjack来实现,
要在zap中加入Lumberjack支持,我们需要修改WriteSyncer代码
其中Logger属性的意思如下:
Filename: 日志文件的位置
MaxSize:在进行切割之前,日志文件的最大大小(以MB为单位)
MaxBackups:保留旧文件的最大个数
MaxAges:保留旧文件的最大天数
Compress:是否压缩/归档旧文件
最后使用zapcore.AddSync()函数并且将打开的文件句柄传进去
func getLogWriter(filename string, maxSize, maxBackup, maxAge int) zapcore.WriteSyncer {
lumberJackLogger := &lumberjack.Logger{
Filename: filename,
MaxSize: maxSize,
MaxBackups: maxBackup,
MaxAge: maxAge,
}
return zapcore.AddSync(lumberJackLogger)
}
具体步骤二:Encoder:编码器(如何写入日志)
使用开箱即用的NewJSONEncoder()
并使用预先设置的ProductionEncoderConfig()
添加一些其他信息(覆盖默认的):
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder // 修改时间编码器
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder// 在日志文件中使用大写字母记录日志级别
EncodeDuration:一般zapcore.SecondsDurationEncoder,执行消耗的时间转化成浮点型的秒
EncodeCaller:一般zapcore.ShortCallerEncoder,以包/文件:行号 格式化调用堆栈 caller:可以以相对格式short和绝对格式full显示
TimeKey:输出时间的key名
func getEncoder() zapcore.Encoder {
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.TimeKey = "time"
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
encoderConfig.EncodeDuration = zapcore.SecondsDurationEncoder
encoderConfig.EncodeCaller = zapcore.ShortCallerEncoder
return zapcore.NewJSONEncoder(encoderConfig)
}
- 下面两行代码的主要作用是:设置日志级别
- 级别是日志记录的优先级,级别越高越重要。实例化level,然后调用其UnmarshalText()方法,
var l = new(zapcore.Level)
err = l.UnmarshalText([]byte(cfg.Level))
if err != nil {
return
}
通过zapcore.NewCore创建一个core,将日志写入WriteSyncer。
zapcore.NewConsoleEncoder():将日志信息在控制台打印(将编码器从JSON Encoder更改为普通Encoder)
通过调用zap.NewProduction()/zap.NewDevelopment()或者zap.Example()创建一个Logger。其中的每一个函数都将创建一个logger。唯一的区别在于它将记录的信息不同。NewDevelopment()方法打印内容更详细。
zap.NewDevelopmentEncoderConfig():该方法返回一个初始化好的zapcore.EncoderConfig(编码器)
var core zapcore.Core
if mode == string(utils.ModeProd) {
// 进入生产模式
core = zapcore.NewCore(encoder, writeSyncer, l)
} else {
// 进入开发模式或测试模式
consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
core = zapcore.NewTee(
zapcore.NewCore(encoder, writeSyncer, l),
zapcore.NewCore(consoleEncoder, zapcore.Lock(os.Stdout), l),
)
}
zap.New(…)方法来手动传递所有配置来创建日志实例 func New(core zapcore.Core, options ...Option) *Logger
zap.AddCaller():添加将调用函数信息记录到日志中的功能
lg := zap.New(core, zap.AddCaller())
zap.ReplaceGlobals(lg) // 替换zap包中全局的logger实例,后续在其他包中只需使用zap.L()调用即可
return
Sync调用底层核心的Sync方法,刷新任何缓冲的日志条目。应用程序在退出之前应该注意调用sync。
zap.L().Debug():记录日志
这里面调用了utils里面的一个方法。
defer zap.L().Sync()
zap.L().Debug(utils.Green("logger init success..."))
进入utils.setText文件
先看批量声明的变量:const中每新增一行常量声明将使iota计数一次
package utils
import (
"fmt"
)
// 前景 背景 颜色
// ---------------------------------------
// 30 40 黑色
// 31 41 红色
// 32 42 绿色
// 33 43 黄色
// 34 44 蓝色
// 35 45 紫红色
// 36 46 青蓝色
// 37 47 白色
//
// 代码 意义
// -------------------------
// 0 终端默认设置
// 1 高亮显示
// 4 使用下划线
// 5 闪烁
// 7 反白显示
// 8 不可见
const (
TextBlack = iota + 30
TextRed
TextGreen
TextYellow
TextBlue
TextMagenta
TextCyan
TextWhite
)
func Black(msg string) string {
return SetColor(msg, 0, 0, TextBlack)
}
func Red(msg string) string {
return SetColor(msg, 0, 0, TextRed)
}
func Green(msg string) string {
return SetColor(msg, 0, 0, TextGreen)
}
func Yellow(msg string) string {
return SetColor(msg, 0, 0, TextYellow)
}
func Blue(msg string) string {
return SetColor(msg, 0, 0, TextBlue)
}
func Magenta(msg string) string {
return SetColor(msg, 0, 0, TextMagenta)
}
func Cyan(msg string) string {
return SetColor(msg, 0, 0, TextCyan)
}
func White(msg string) string {
return SetColor(msg, 0, 0, TextWhite)
}
func SetColor(msg string, conf, bg, text int) string {
return fmt.Sprintf("%c[%d;%d;%dm%s%c[0m", 0x1B, conf, bg, text, msg, 0x1B)
}
继续main方法:
初始化MySQL连接
对于mysql.Init()方法下面会进行拆分。
关闭连接这一部分:
把*gorm.DB放到一个公共目录中,哪里需要哪里调
// 3. 初始化MySQL连接
if err := mysql.Init(config.MysqlConfig); err != nil {
zap.L().Error(fmt.Sprintf("init mysql failed, err:%v\n", err))
return
}
defer mysql.Close()
zap.L().Debug(utils.Green("mysql init success..."))
// Close 关闭连接
func Close() {
db, err := global.Eloquent.DB()
if err != nil {
zap.L().Error("db close err", zap.Error(err))
}
_ = db.Close()
}
传入的是mysql的配置信息,使用一个结构体存储
charset=utf8mb4:这种字符编码方式可以处理表情包
parseTime=true&loc=Local说明会解析时间,时区是机器的local时区。机器之间的时区可能不一致会设置有问题,这导致从相同库的不同实例查询出来的结果可能解析以后就不一样。因此推荐将loc统一设置为一个时区
timeout=1000ms:连接超时
在控制台打印提示信息
// Init 配置mysql gorm
func Init(cfg *config.Mysql) (err error) {
source := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=1000ms",
cfg.User,
cfg.Password,
cfg.Host,
cfg.Port,
cfg.DbName,
)
zap.L().Info(utils.Green(source))
设置最大连接数和最大空闲连接数
db.SetMaxOpenConns(cfg.DbMaxOpen)
db.SetMaxIdleConns(cfg.DbMaxIdle)
下面这一波操作作用是:与数据建立连接
zap.L().xxx():xxx表示的是打印日志的级别,不同级别的日志信息颜色不同。
zap.L().Info(utils.Green(source))
db, err := sql.Open("mysql", source)
if err != nil {
zap.L().Fatal(utils.Red("mysql connect error :"), zap.Error(err))
return
}
db.SetMaxOpenConns(cfg.DbMaxOpen)
db.SetMaxIdleConns(cfg.DbMaxIdle)
global.Eloquent, err = open(db, &gorm.Config{
NamingStrategy: schema.NamingStrategy{
SingularTable: true,
},
})
if err != nil {
zap.L().Fatal(utils.Red("mysql connect error :"), zap.Error(err))
return
} else {
zap.L().Info(utils.Green("mysql connect success !"))
}
调用下面的方法,返回一个抽象的gorm数据库。
连接数据库比较简单,直接传入地址即可。下面这种是较为高级的用法:通过一个现有的数据库连接来初始化 *gorm.DB
GORM 允许用户通过覆盖默认的NamingStrategy来更改命名约定,这需要实现1245/*--*接口 Namer
SingularTable: true, // 使用单数表名,启用该选项后,`User` 表将是`user`
// Open 打开数据库连接
func open(db *sql.DB, cfg *gorm.Config) (*gorm.DB, error) {
return gorm.Open(mysql.New(mysql.Config{Conn: db}), cfg)
}
判断生成的连接池是否有错误
if global.Eloquent.Error != nil {
zap.L().Fatal(utils.Red(" database error :"), zap.Error(global.Eloquent.Error))
return
}
GORM 定义一个 gorm.Model 结构体,可以将其嵌入到您的结构体中,以包含这几个字段
AutoMigrate 用于自动迁移您的 schema,保持您的 schema 是最新的(根据模型对象建表,没有则创建,修改则自动修改)
err = migrateModel()
if err != nil {
zap.L().Fatal(utils.Red(" migrateModel error :"), zap.Error(err))
}
return
// 这两部分代码不在一个文件中
func migrateModel() error {
err := orm.Eloquent.AutoMigrate(&models.SysUser{})
return err
}
package models
type SysUser struct {
*BaseModel
Username string `json:"username"`
Password string `json:"password"`
DeptId int `json:"dept_id"` //部门id
PostId int `json:"post_id"` //
RoleId int `json:"role_id"` //
NickName string `json:"nick_name"` //
Phone string `json:"phone"` //
Email string `json:"email"` //
AvatarPath string `json:"avatar_path"` //头像路径
Avatar string `json:"avatar"` //
Sex string `json:"sex"` //
Status string `json:"status"` //
Remark string `json:"remark"` //
Salt string `json:"salt"` //
Gender []byte `json:"gender"` //性别(0为男默认,1为女)
IsAdmin []byte `json:"is_admin"` //是否为admin账号
Enabled []byte `json:"enabled"` //状态:1启用(默认)、0禁用
PwdResetTime int64 `json:"pwd_reset_time"` //修改密码的时间
CreateBy int `json:"create_by"` //
UpdateBy int `json:"update_by"` //
}
package models
// BaseModel orm公有字段
type BaseModel struct {
ID int `gorm:"primary_key" json:"id"` //ID
IsDeleted []byte `gorm:"default:[]byte{0}" json:"is_deleted"` //默认为零
CreateTime int64 `gorm:"autoCreateTime:milli" json:"create_time"` //创建日期 默认当前时间戳 毫秒
UpdateTime int64 `gorm:"autoUpdateTime:milli" json:"update_time"` //更新时间 默认当前时间戳 毫秒
}
至此,MySQL数据库初始化结束,
下面代码是初始化redis连接
// 4. 初始化Redis连接
if err := redis.Init(config.RedisConfig); err != nil {
zap.L().Error(fmt.Sprintf("init redis failed, err:%v\n", err))
return
}
defer redis.Close()
zap.L().Debug(utils.Green("redis init success..."))
通过 redis.NewClient 函数即可创建一个 redis 客户端
这个方法接收一个 redis.Options 对象参数, 通过这个参数, 我们可以配置 redis 相关的属性, 例如 redis 服务器地址, 数据库名, 数据库密码等.
通过 cient.Ping() 来检查是否成功连接到了 redis 服务器
// Init 初始化redis连接
func Init(cfg *config.Redis) (err error) {
global.Rdb = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d",
cfg.Host,
cfg.Port,
),
Password: cfg.Password, // no password set
DB: cfg.DB, // use default DB
PoolSize: cfg.PoolSize,
IdleTimeout: time.Duration(cfg.IdleTimeOutSec),
})
_, err = global.Rdb.Ping().Result()
return
}
func Close() {
_ = global.Rdb.Close()
}
初始化casbin
//初始化casbin
if err := mycasbin.Setup(); err != nil {
zap.L().Error("casbin failed set up", zap.Error(err))
}
gormadapter.NewAdapterByDB(): // 将数据库连接同步给插件, 插件用来操作数据库,生成mySQL适配器
可以先将字符串解析为Model对象,然后再创建Enforcer:
enforcer, err = casbin.NewSyncedEnforcer(m): // 在多线程环境下使用Enforcer对象的接口,必须使用casbin.NewSyncedEnforcer创建Enforcer
casbin简述:
Model存储:
在 Casbin 中, 访问控制模型被抽象为基于 PERM (Policy, Effect, Request, Matcher) 的一个文件
Model语法:
Request定义:[requset_definition]部分用于request的定义,sub:访问实体(subject),obj:访问资源(object)和访问方法(Action)
Policyt定义:对policy的定义,policy部分的每一行称之为一个策略规则
matchers定义:定义了策略匹配者。匹配者是一组表达式。它定义了如何根据请求来匹配策略规则
与policy不同,model只能加载,不能保存(如下图的text)
使用加载模型(这里使用从字符串中加载model):model.NewModelFromString(): //可以从多行字符串加载整个模型文本。这种方法的优点是您不需要维护模型文件。
// Initialize the model from a string.
var text = `
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && (keyMatch2(r.obj, p.obj) || keyMatch(r.obj, p.obj)) && (r.act == p.act || p.act == "*")
`
func Setup() (err error) {
//var err error
var Apter *gormAdapter.Adapter
var m model.Model
var e *casbin.SyncedEnforcer
Apter, err = gormAdapter.NewAdapterByDB(global.Eloquent)
if err != nil {
zap.L().Error("NewAdapterByDB()", zap.Error(err))
return err
}
m, err = model.NewModelFromString(text)
if err != nil {
zap.L().Error("NewModelFromString()", zap.Error(err))
return err
}
e, err = casbin.NewSyncedEnforcer(m, Apter)
if err != nil {
zap.L().Error("NewSyncedEnforcer()", zap.Error(err))
return err
}
global.CasbinEnforcer = e
return nil
}