代碼地址:https://gitee.com/lymgoforIT/bluebell
導(dǎo)言
有了前述知識(shí)的基礎(chǔ)后,我們便可以開始搭建基本腳手架了。
腳手架應(yīng)該包含如下信息:
- 較好的代碼管理、即清晰的目錄結(jié)構(gòu),層次分明。
- 配置文件管理和加載。
- 日志組件初始化和加載。
-
Redis
初始化和加載。 -
MySQL
初始化和加載。 - 路由拆分管理。
- 中間件使用。
- 服務(wù)啟動(dòng)。
有了腳手架之后,后續(xù)的CRUD
就比較簡(jiǎn)單啦。
本文完成后總體目錄結(jié)構(gòu)如下注意:我們主要關(guān)注的是后端邏輯,所以涉及前端的static和templates兩個(gè)目錄沒有過多介紹,可以直接從代碼倉(cāng)庫(kù)獲取即可。
最關(guān)鍵的是main
文件,畢竟它是整個(gè)程序的入口,我們的main
文件應(yīng)該足夠清晰,讓人一眼就能看出做了哪些事情,大致結(jié)構(gòu)如下:
package main
func main() {
// 1. 加載配置
// 2. 初始化日志
// 3. 初始化MySQL連接
// 4. 初始化Redis連接
// 5. 初始化gin框架內(nèi)置的校驗(yàn)器使用的翻譯器
// 6. 注冊(cè)路由
// 7. 啟動(dòng)服務(wù)(優(yōu)雅關(guān)機(jī)和重啟)
}
一、加載配置
首先我們應(yīng)該定義一個(gè)config.yaml
配置文件,將相關(guān)配置寫到里面
config/config.yaml
name: "bluebell"
mode: "release"
port: 8084
version: "v0.0.1"
log:
level: "info"
filename: "web_app.log"
max_size: 200
max_age: 30
max_backups: 7
mysql:
host: "127.0.0.1"
port: 3306
user: "root"
password: "root"
dbname: "bluebell"
max_open_conns: 200
max_idle_conns: 50
redis:
host: "127.0.0.1"
port: 6379
password: ""
db: 0
pool_size: 100
由于我們后續(xù)是要將配置加載到一個(gè)全局結(jié)構(gòu)體對(duì)象中,然后各個(gè)地方使用這個(gè)全局變量讀取配置的,所以很自然的想到,我們應(yīng)該定義對(duì)應(yīng)的配置結(jié)構(gòu)體,并提供一個(gè)全局變量以及相應(yīng)的初始化函數(shù)。
setting/setting.go
package setting
import (
"fmt"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
)
var Conf = new(AppConfig)
type AppConfig struct {
Name string `mapstructure:"name"`
Mode string `mapstructure:"mode"`
Version string `mapstructure:"version"`
Port int `mapstructure:"port"`
*LogConfig `mapstructure:"log"`
*MySQLConfig `mapstructure:"mysql"`
*RedisConfig `mapstructure:"redis"`
}
type LogConfig struct {
Level string `mapstructure:"level"`
Filename string `mapstructure:"filename"`
MaxSize int `mapstructure:"max_size"`
MaxAge int `mapstructure:"max_age"`
MaxBackups int `mapstructure:"max_backups"`
}
type MySQLConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DB string `mapstructure:"dbname"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
}
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
PoolSize int `mapstructure:"pool_size"`
MinIdleConns int `mapstructure:"min_idle_conns"`
}
func Init(filePath string) (err error) {
// 方式1:直接指定配置文件路徑(相對(duì)路徑或者絕對(duì)路徑)
// 相對(duì)路徑:相對(duì)執(zhí)行的可執(zhí)行文件的相對(duì)路徑
//viper.SetConfigFile("./conf/config.yaml")
// 絕對(duì)路徑:系統(tǒng)中實(shí)際的文件路徑
//viper.SetConfigFile("/Users/liwenzhou/Desktop/bluebell/conf/config.yaml")
// 方式2:指定配置文件名和配置文件的位置,viper自行查找可用的配置文件
// 配置文件名不需要帶后綴
// 配置文件位置可配置多個(gè)
//viper.SetConfigName("config") // 指定配置文件名(不帶后綴)
//viper.AddConfigPath(".") // 指定查找配置文件的路徑(這里使用相對(duì)路徑)
//viper.AddConfigPath("./conf") // 指定查找配置文件的路徑(這里使用相對(duì)路徑)
// 基本上是配合遠(yuǎn)程配置中心使用的,告訴viper當(dāng)前的數(shù)據(jù)使用什么格式去解析
//viper.SetConfigType("json")
viper.SetConfigFile(filePath)
err = viper.ReadInConfig() // 讀取配置信息到viper中
if err != nil {
fmt.Printf("viper.ReadInconfig failed,err:%v\n", err)
return
}
// 把讀取到的配置信息反序列化到 Conf 全局變量中
if err := viper.Unmarshal(Conf); err != nil {
fmt.Printf("viper.Unmarshal failed,err:%v\n", err)
}
viper.WatchConfig()
viper.OnConfigChange(func(in fsnotify.Event) {
fmt.Println("配置文件修改了")
if err := viper.Unmarshal(Conf); err != nil {
fmt.Printf("viper.Unmarshal failed,err:%v\n", err)
}
})
return
}
二、初始化日志
我們?cè)擁?xiàng)目使用之前介紹的Zap
作為日志組件,并會(huì)仿照Gin
中的Logger
和Recovery
中間件,寫自己的中間件替換掉Gin
自帶的。如下
logger/logger.go
package logger
import (
"bluebell/setting"
"net"
"net/http"
"net/http/httputil"
"os"
"runtime/debug"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/natefinch/lumberjack"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var lg *zap.Logger
// Init 初始化lg
func Init(cfg *setting.LogConfig, mode string) (err error) {
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 == "dev" {
// 進(jìn)入開發(fā)模式,日志輸出到終端
consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
core = zapcore.NewTee(
zapcore.NewCore(encoder, writeSyncer, l),
zapcore.NewCore(consoleEncoder, zapcore.Lock(os.Stdout), zapcore.DebugLevel),
)
} else {
core = zapcore.NewCore(encoder, writeSyncer, l)
}
lg = zap.New(core, zap.AddCaller())
// 使用 lg 替換zap中的全局L,從而外部可以直接使用zap.L().Info記錄日志,而不是logger.lg.Info
zap.ReplaceGlobals(lg)
zap.L().Info("init logger success")
return
}
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)
}
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)
}
// GinLogger 接收gin框架默認(rèn)的日志
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
cost := time.Since(start)
lg.Info(path,
zap.Int("status", c.Writer.Status()),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.String("query", query),
zap.String("ip", c.ClientIP()),
zap.String("user-agent", c.Request.UserAgent()),
zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()),
zap.Duration("cost", cost),
)
}
}
// GinRecovery recover掉項(xiàng)目可能出現(xiàn)的panic,并使用zap記錄相關(guān)日志
func GinRecovery(stack bool) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != any(nil) {
// Check for a broken connection, as it is not really a
// condition that warrants a panic stack trace.
var brokenPipe bool
if ne, ok := any(err).(*net.OpError); ok {
if se, ok := ne.Err.(*os.SyscallError); ok {
if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {
brokenPipe = true
}
}
}
httpRequest, _ := httputil.DumpRequest(c.Request, false)
if brokenPipe {
lg.Error(c.Request.URL.Path,
zap.Any("error", err),
zap.String("request", string(httpRequest)),
)
// If the connection is dead, we can't write a status to it.
c.Error(any(err).(error)) // nolint: errcheck
c.Abort()
return
}
if stack {
lg.Error("[Recovery from panic]",
zap.Any("error", err),
zap.String("request", string(httpRequest)),
zap.String("stack", string(debug.Stack())),
)
} else {
lg.Error("[Recovery from panic]",
zap.Any("error", err),
zap.String("request", string(httpRequest)),
)
}
c.AbortWithStatus(http.StatusInternalServerError)
}
}()
c.Next()
}
}
三、初始化MySQL連接
使用Gorm
,初始化代碼如下
dao/mysql/mysql.go
package mysql
import (
"bluebell/setting"
"fmt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var db *gorm.DB
func Init(cfg *setting.MySQLConfig) (err error) {
dsn := fmt.Sprintf("%s:%s@(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DB)
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic(any("failed to connect to db"))
}
sqlDB, err := db.DB()
if err != nil {
panic(any("create table err"))
}
// SetMaxIdleConns sets the maximum number of connections in the idle connection pool.
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
// SetMaxOpenConns sets the maximum number of open connections to the database.
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
return nil
}
四、初始化Redis連接
與初始化MySQL
非常類似
dao/redis/redis.go
package redis
import (
"bluebell/setting"
"fmt"
"github.com/go-redis/redis"
)
var (
client * redis.Client
Nil = redis.Nil
)
func Init(cfg *setting.RedisConfig) (err error) {
client = 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,
MinIdleConns: cfg.MinIdleConns,
})
// 注:使用Val()不會(huì)返回錯(cuò)誤,出錯(cuò)時(shí)返回零值,使用Result則可以根據(jù)返回的error判斷是否出錯(cuò)了
_, err = client.Ping().Result()
if err != nil {
fmt.Println("init redis failed")
return err
}
return nil
}
五、初始化gin框架內(nèi)置的校驗(yàn)器使用的翻譯器
由于我們會(huì)使用validator
組件進(jìn)行gin
的參數(shù)校驗(yàn),所以為了錯(cuò)誤提示信息更為友好,需要初始化翻譯器。
controller/validator.go
package controller
import (
"bluebell/models"
"fmt"
"reflect"
"strings"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/locales/en"
"github.com/go-playground/locales/zh"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
enTranslations "github.com/go-playground/validator/v10/translations/en"
zhTranslations "github.com/go-playground/validator/v10/translations/zh"
)
// 定義一個(gè)全局翻譯器T
var trans ut.Translator
// InitTrans 初始化翻譯器
func InitTrans(locale string) (err error) {
// 修改gin框架中的Validator引擎屬性,實(shí)現(xiàn)自定制
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
// 注冊(cè)一個(gè)獲取json tag的自定義方法
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
// 為SignUpParam注冊(cè)自定義校驗(yàn)方法
v.RegisterStructValidation(SignUpParamStructLevelValidation, models.ParamSignUp{})
zhT := zh.New() // 中文翻譯器
enT := en.New() // 英文翻譯器
// 第一個(gè)參數(shù)是備用(fallback)的語言環(huán)境
// 后面的參數(shù)是應(yīng)該支持的語言環(huán)境(支持多個(gè))
// uni := ut.New(zhT, zhT) 也是可以的
uni := ut.New(enT, zhT, enT)
// locale 通常取決于 http 請(qǐng)求頭的 'Accept-Language'
var ok bool
// 也可以使用 uni.FindTranslator(...) 傳入多個(gè)locale進(jìn)行查找
trans, ok = uni.GetTranslator(locale)
if !ok {
return fmt.Errorf("uni.GetTranslator(%s) failed", locale)
}
// 注冊(cè)翻譯器
switch locale {
case "en":
err = enTranslations.RegisterDefaultTranslations(v, trans)
case "zh":
err = zhTranslations.RegisterDefaultTranslations(v, trans)
default:
err = enTranslations.RegisterDefaultTranslations(v, trans)
}
return
}
return
}
// removeTopStruct 去除提示信息中的結(jié)構(gòu)體名稱
func removeTopStruct(fields map[string]string) map[string]string {
res := map[string]string{}
for field, err := range fields {
res[field[strings.Index(field, ".")+1:]] = err
}
return res
}
// SignUpParamStructLevelValidation 自定義SignUpParam結(jié)構(gòu)體校驗(yàn)函數(shù)
func SignUpParamStructLevelValidation(sl validator.StructLevel) {
su := sl.Current().Interface().(models.ParamSignUp)
if su.Password != su.RePassword {
// 輸出錯(cuò)誤提示信息,最后一個(gè)參數(shù)就是傳遞的param
sl.ReportError(su.RePassword, "re_password", "RePassword", "eqfield", "password")
}
}
其中我們?yōu)樽?cè)參數(shù)ParamSignUp
校驗(yàn)做了自定義校驗(yàn),用到了注冊(cè)參數(shù),所以這里也把model
定義一下。
models/params.go
package models
// ParamSignUp 注冊(cè)請(qǐng)求參數(shù)
type ParamSignUp struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
RePassword string `json:"re_password" binding:"required,eqfield=Password"`
}
六、注冊(cè)路由
注冊(cè)路由用到了我們之前介紹的路由拆分和管理技巧。
package router
import (
"bluebell/logger"
"net/http"
"github.com/gin-gonic/gin"
)
func SetupRouter(mode string) *gin.Engine {
if mode == gin.ReleaseMode {
gin.SetMode(gin.ReleaseMode) // gin 設(shè)置成發(fā)布模式
}
r := gin.New()
// 使用我們自定義的兩個(gè)中間件
r.Use(logger.GinLogger(), logger.GinRecovery(true))
// 加載首頁(yè)html文件
r.LoadHTMLFiles("./templates/index.html")
// 加載靜態(tài)文件
r.Static("/static", "./static")
r.GET("/ping", func(c *gin.Context) {
c.String(http.StatusOK, "pong")
})
r.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", nil)
})
r.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"msg": "404",
})
})
return r
}
七、 啟動(dòng)服務(wù)
main.go
package main
import (
"bluebell/controller"
"bluebell/dao/mysql"
"bluebell/dao/redis"
"bluebell/logger"
"bluebell/router"
"bluebell/setting"
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("need config file. eg:bluebell config.yaml")
return
}
// 1. 加載配置
if err := setting.Init(os.Args[1]); err != nil {
fmt.Printf("load config failed,err:%v\n", err)
return
}
// 2. 初始化日志
if err := logger.Init(setting.Conf.LogConfig, setting.Conf.Mode); err != nil {
fmt.Printf("init logger failed,err:%v\n", err)
return
}
// 3. 初始化MySQL連接
if err := mysql.Init(setting.Conf.MySQLConfig); err != nil {
fmt.Printf("init mysql failed,err:%v\n", err)
}
// 4. 初始化Redis連接
if err := redis.Init(setting.Conf.RedisConfig); err != nil {
fmt.Printf("init redis failed,err:%v\n", err)
}
// 5. 初始化gin框架內(nèi)置的校驗(yàn)器使用的翻譯器
if err := controller.InitTrans("zh"); err != nil {
fmt.Printf("init validator trans failed, err:%v\n", err)
return
}
// 6. 注冊(cè)路由
r := router.SetupRouter(setting.Conf.Mode)
// 7. 啟動(dòng)服務(wù)(優(yōu)雅關(guān)機(jī)和重啟)
err := r.Run(fmt.Sprintf(":%d", setting.Conf.Port))
if err != nil {
fmt.Printf("run server failed, err:%v\n", err)
return
}
}
八、測(cè)試運(yùn)行
運(yùn)行前需要先保證MySQL
中已經(jīng)建立了對(duì)應(yīng)的bluebell
庫(kù),然后Redis
服務(wù)端時(shí)開啟的。
首先我們需要使用go build編譯項(xiàng)目
,在項(xiàng)目路徑下執(zhí)行go build
即可,編譯成功會(huì)出現(xiàn)一個(gè)可執(zhí)行文件,如下
隨后我們傳入配置文件路徑執(zhí)行,可以看到啟動(dòng)后,沒有報(bào)錯(cuò)且光標(biāo)一直在閃爍,便是項(xiàng)目啟動(dòng)成功且在8084
端口監(jiān)聽了(可通過Ctrl+C
退出程序)。此外,我們也能看到產(chǎn)生了日志文件web_app.log
通過瀏覽器訪問也成功了
還可以訪問一下首頁(yè)看看,訪問成功!只是目前還沒有業(yè)務(wù)數(shù)據(jù),所以是一個(gè)非常簡(jiǎn)單的空頁(yè)面而已。
九:注意事項(xiàng)
項(xiàng)目中我們使用了os.Args
接收參數(shù),實(shí)際也可以使用flag
。那么為什么要運(yùn)行時(shí)傳配置文件路徑,而不是直接在代碼中用相對(duì)路徑寫死呢?文章來源:http://www.zghlxwxcb.cn/news/detail-839918.html
原因是項(xiàng)目運(yùn)行時(shí)的基準(zhǔn)目錄,是以執(zhí)行運(yùn)行程序所在目錄為準(zhǔn)的,也就是說,編譯后產(chǎn)生了.exe
文件,我們?nèi)绾畏诺搅似渌夸浵氯?zhí)行,代碼中寫死配置文件讀取目錄的話可能就讀不到了,因?yàn)槁窂讲粚?duì)了。但是讓用戶自己指定目錄,在執(zhí)行時(shí)保證指定路徑下配置文件存在,就可以正常執(zhí)行。文章來源地址http://www.zghlxwxcb.cn/news/detail-839918.html
到了這里,關(guān)于11. 搭建較通用的GoWeb開發(fā)腳手架的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!