本文目錄
- 1. 回顧
- 2. Zap日志
- 3. 配置
- 4. 引入gprc
- 梳理gRPC思路
- 優雅關閉gRPC
1. 回顧
上篇文章我們進行了路由搭建,引入了redis,現在來看看對應的效果。
首先先把前端跑起來,然后點擊注冊獲取驗證碼。
再看看控制臺輸出和redis是否已經有記錄,驗證沒問題,現在redis這個環節是打通了。
2. Zap日志
go中原生的日志比較一般,我們可以集成一個流行的日志庫進來。
這里用uber開源的zap日志庫,在common路徑下安裝zap:go get -u go.uber.org/zap。
然后再安裝一個日志分割庫,go get -u github.com/natefinch/lumberjack
,因為日志的存儲有幾種方式,比如按照日志級別將日志記錄到不同的文件,按照業務來分別記錄不同級別的日志,按照包結構劃分記錄不同級別日志。debug級別以上記錄一個,info以上記錄一個,warn以上記錄一個
。
在common
路徑下創建logs.go
,然后編寫對應的代碼。
package logsimport ("github.com/gin-gonic/gin""github.com/natefinch/lumberjack""go.uber.org/zap""go.uber.org/zap/zapcore""net""net/http""net/http/httputil""os""runtime/debug""strings""time"
)var lg *zap.Loggertype LogConfig struct {DebugFileName string `json:"debugFileName"`InfoFileName string `json:"infoFileName"`WarnFileName string `json:"warnFileName"`MaxSize int `json:"maxsize"`MaxAge int `json:"max_age"`MaxBackups int `json:"max_backups"`
}// InitLogger 初始化Logger
func InitLogger(cfg *LogConfig) (err error) {writeSyncerDebug := getLogWriter(cfg.DebugFileName, cfg.MaxSize, cfg.MaxBackups, cfg.MaxAge)writeSyncerInfo := getLogWriter(cfg.InfoFileName, cfg.MaxSize, cfg.MaxBackups, cfg.MaxAge)writeSyncerWarn := getLogWriter(cfg.WarnFileName, cfg.MaxSize, cfg.MaxBackups, cfg.MaxAge)encoder := getEncoder()//文件輸出debugCore := zapcore.NewCore(encoder, writeSyncerDebug, zapcore.DebugLevel)infoCore := zapcore.NewCore(encoder, writeSyncerInfo, zapcore.InfoLevel)warnCore := zapcore.NewCore(encoder, writeSyncerWarn, zapcore.WarnLevel)//標準輸出consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())std := zapcore.NewCore(consoleEncoder, zapcore.Lock(os.Stdout), zapcore.DebugLevel)core := zapcore.NewTee(debugCore, infoCore, warnCore, std)lg = zap.New(core, zap.AddCaller())zap.ReplaceGlobals(lg) // 替換zap包中全局的logger實例,后續在其他包中只需使用zap.L()調用即可return
}func getEncoder() zapcore.Encoder {encoderConfig := zap.NewProductionEncoderConfig()encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoderencoderConfig.TimeKey = "time"encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoderencoderConfig.EncodeDuration = zapcore.SecondsDurationEncoderencoderConfig.EncodeCaller = zapcore.ShortCallerEncoderreturn 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框架默認的日志
func GinLogger() gin.HandlerFunc {return func(c *gin.Context) {start := time.Now()path := c.Request.URL.Pathquery := c.Request.URL.RawQueryc.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掉項目可能出現的panic,并使用zap記錄相關日志
func GinRecovery(stack bool) gin.HandlerFunc {return func(c *gin.Context) {defer func() {if err := recover(); err != nil {// Check for a broken connection, as it is not really a// condition that warrants a panic stack trace.var brokenPipe boolif ne, ok := 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(err.(error)) // nolint: errcheckc.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()}
}
然后在main.go中來初始化我們的日志。
然后可以把對應的log地方進行更改了,比如下面的地方。
然后來驗證一下是否能正常生成日志文件,正常生成了,沒問題。
3. 配置
日志我們用了zap做集成,算是一個改進,但是配置比較復雜, 所以我們這里需要進一步優化這個配置。
配置我們引入viper
進行操作,也非常簡單,直接上圖和代碼吧,在user里邊裝viper這個包。
go get github.com/spf13/viper
首先在user下面創建cofig目錄,然后創建config.yaml
配置文件,然后創建config.go
代碼讀取配置。
config.go
的代碼如下所示。
package configimport ("github.com/go-redis/redis/v8""github.com/spf13/viper""log""os""test.com/project-common/logs"
)var C = InitConfig()type Config struct {viper *viper.ViperSC *ServerConfig
}type ServerConfig struct {Name stringAddr string
}func InitConfig() *Config {conf := &Config{viper: viper.New()}workDir, _ := os.Getwd()conf.viper.SetConfigName("config")conf.viper.SetConfigType("yaml")conf.viper.AddConfigPath(workDir + "/config")conf.viper.AddConfigPath("etc/msproject/user")//讀取configerr := conf.viper.ReadInConfig()if err != nil {log.Fatalln(err)}conf.ReadServerConfig()conf.InitZapLog() //初始化zap日志return conf
}func (c *Config) InitZapLog() {//從配置中讀取日志配置,初始化日志lc := &logs.LogConfig{DebugFileName: c.viper.GetString("zap.debugFileName"),InfoFileName: c.viper.GetString("zap.infoFileName"),WarnFileName: c.viper.GetString("zap.warnFileName"),MaxSize: c.viper.GetInt("maxSize"),MaxAge: c.viper.GetInt("maxAge"),MaxBackups: c.viper.GetInt("maxBackups"),}err := logs.InitLogger(lc)if err != nil {log.Fatalln(err)}
}func (c *Config) ReadServerConfig() {sc := &ServerConfig{}sc.Name = c.viper.GetString("server.name")sc.Addr = c.viper.GetString("server.addr")c.SC = sc
}// 讀redis的配置
func (c *Config) ReadRedisConfig() *redis.Options {return &redis.Options{Addr: c.viper.GetString("redis.host") + ":" + c.viper.GetString("redis.port"),Password: c.viper.GetString("redis.password"), // no password setDB: c.viper.GetInt("db"), // use default DB}
}
對應的redis.go
中原本new一個redis客戶端的代碼也需要更改了,改為已有的讀取配置的函數 ReadRedisConfig()
。
并且把原來main.go中關于zap的相關配置文件刪除即可。
然后重新啟動下,看看是否能夠運行,ok,啟動沒問題。
4. 引入gprc
可以通過引入一個API把對應的服務連起來,可以把各種服務提出來,然后通過API進行定義。
在api\proto
下新建一個名為login.service.proto
的文件,然后編寫代碼。
syntax = "proto3";
package login.service.v1;
option go_package = "project-user/pkg/service/login.service.v1";message CaptchaMessage {string mobile = 1;
}
message CaptchaResponse{
}
service LoginService {rpc GetCaptcha(CaptchaMessage) returns (CaptchaResponse) {}
}
然后在proto路徑下,運行命令:protoc --go_out=./gen --go_opt=paths=source_relative --go-grpc_out=./gen --go-grpc_opt=paths=source_relative login_service.proto
,就可以生成對應文件了。
因為是第一版,所以我們先在gen下生成,然后復制移動到service下面,防止后面不斷根據功能進行修改,而導致新生成的被覆蓋。
那么我們來看看這login-service_grpc.pb.go
文件到底生成了什么東西。
LoginServiceClient
是一個接口,定義了客戶端可以調用的 GetCaptcha 方法。該方法接收一個 CaptchaMessage
請求,返回一個 CaptchaResponse
響應。
loginServiceClient
是 LoginServiceClient
的具體實現,通過 NewLoginServiceClient
函數創建。它使用 grpc.ClientConnInterface
來發起 RPC 調用。
在 loginServiceClient.GetCaptcha
方法中,通過 c.cc.Invoke
發起 GetCaptcha
方法的 RPC 調用。它將請求數據序列化并發送到服務器,然后等待響應。
LoginServiceServer
是服務器端的接口,定義了 GetCaptcha 方法。所有實現該接口的服務器端邏輯必須嵌入 UnimplementedLoginServiceServer
,以確保向前兼容性。
UnimplementedLoginServiceServer
提供了一個默認的未實現方法的錯誤響應
,返回 codes.Unimplemented
狀態碼。
也就屙是說,接口還包含一個方法 mustEmbedUnimplementedLoginServiceServer()
,這是一個空方法
,用于確保實現者嵌入了 UnimplementedLoginServiceServer
。
這是 UnimplementedLoginServiceServer
的 GetCaptcha
方法的默認實現。它返回 nil
作為響應,并通過 status.Errorf
返回一個帶有 codes.Unimplemented
狀態碼的錯誤,表明該方法未被實現。這種設計確保了即使服務實現者沒有實現某些方法,調用這些方法時也不會導致程序崩潰,而是返回一個明確的錯誤。
mustEmbedUnimplementedLoginServiceServer()
是一個空方法,用于確保服務實現者嵌入了 UnimplementedLoginServiceServer
。
testEmbeddedByValue()
是一個輔助方法,用于在運行時檢查 UnimplementedLoginServiceServer
是否被正確嵌入(通過值而不是指針)。這避免了在方法調用時出現空指針引用。
type LoginServiceServer interface {GetCaptcha(context.Context, *CaptchaMessage) (*CaptchaResponse, error)mustEmbedUnimplementedLoginServiceServer()
}// UnimplementedLoginServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedLoginServiceServer struct{}func (UnimplementedLoginServiceServer) GetCaptcha(context.Context, *CaptchaMessage) (*CaptchaResponse, error) {return nil, status.Errorf(codes.Unimplemented, "method GetCaptcha not implemented")
}
func (UnimplementedLoginServiceServer) mustEmbedUnimplementedLoginServiceServer() {}
func (UnimplementedLoginServiceServer) testEmbeddedByValue() {}
所以主要是為了,確保向前兼容性:通過嵌入 UnimplementedLoginServiceServer
,服務實現者可以在未來版本中添加新方法,而不會破壞現有實現。
梳理gRPC思路
首先我們實現了gRPC,那么原本的api下面的user相關的我們可以刪除了。
來看看我們實現了什么。
首先main.go
中的相關代碼如下。
然后在service中我們實現了login_service.go
代碼,如下。
package login_service_v1import ("context""errors""fmt""go.uber.org/zap""log"common "test.com/project-common""test.com/project-common/logs""test.com/project-user/pkg/dao""test.com/project-user/pkg/repo""time"
)type LoginService struct {UnimplementedLoginServiceServercache repo.Cache
}func New() *LoginService {return &LoginService{cache: dao.Rc,}
}func (ls LoginService) GetCaptcha(ctx context.Context, msg *CaptchaMessage) (*CaptchaResponse, error) {//1.獲取參數moblie := msg.Mobilefmt.Println(moblie)//2.校驗參數if !common.VerifyMoblie(moblie) {return nil, errors.New("手機號不合法")}//3.生成驗證碼(隨機4位或者6位)code := "123456"//4.調用短信平臺(三方,放入go協程中執行,接口可以快速響應,短信幾秒到無所謂)go func() {time.Sleep(1 * time.Second)zap.L().Info("短信平臺調用成功,發送短信 INFO")logs.LG.Debug("短信平臺調用成功,發送短信 debug")zap.L().Error("短信平臺調用成功,發送短信 error")// redis 假設后續緩存在mysql或者mongo當中,也有可能存儲在別的當中// 所以考慮用接口實現,面向接口編程“低耦合,高內聚“// 5.存儲驗證碼redis,設置過期時間15分鐘即可c, cancel := context.WithTimeout(context.Background(), 2*time.Second)defer cancel()err := ls.cache.Put(c, "REGISTER_"+moblie, code, 15*time.Minute)if err != nil {log.Printf("驗證碼存入redis出錯,causer by :%v\n", err)}log.Printf("將手機號和驗證碼存入redis成功:REGISTER %s : %s", moblie, code)}()return &CaptchaResponse{}, nil
}
并且在router中更新了如下代碼。
package routerimport ("github.com/gin-gonic/gin""google.golang.org/grpc""log""net""test.com/project-user/config"loginServiceV1 "test.com/project-user/pkg/service/login.service.v1"
)// Router 接口
type Router interface {Route(r *gin.Engine)
}type RegisterRouter struct {
}func New() *RegisterRouter {return &RegisterRouter{}
}func (*RegisterRouter) Route(ro Router, r *gin.Engine) {ro.Route(r)
}var routers []Routerfunc InitRouter(r *gin.Engine) {for _, ro := range routers {ro.Route(r)}
}type gRPCConfig struct {Addr stringRegisterFunc func(*grpc.Server)
}func RegisterGrpc() *grpc.Server {c := gRPCConfig{Addr: config.C.GC.Addr,RegisterFunc: func(g *grpc.Server) {//注冊loginServiceV1.RegisterLoginServiceServer(g, loginServiceV1.New())}}s := grpc.NewServer()c.RegisterFunc(s)lis, err := net.Listen("tcp", c.Addr)if err != nil {log.Println("cannot listen")}//把服務放到協程里邊go func() {err = s.Serve(lis)if err != nil {log.Println("server started error", err)return}}()return s
}
好,有點復雜,這里我們畫圖梳理下關系。
首先在router.go
文件中,我們聲明了RegisterGrpc()
,這是gRPC服務的入口點,主要是配置grpc配置,包括服務地址還有注冊函數,并且創建gRPC實例,然后注冊登錄服務,最后是啟動gRPC服務器(在goroutine中運行的。)
在 login_service.go
中:LoginService
結構體:實現了 gRPC 服務接口,New() 函數:創建 LoginService
實例,GetCaptcha()
方法:實現具體的驗證碼獲取業務邏輯。
所以調用關系如下。
所以為什么要使用協程?因為如果不使用協程,s.Serve(lis)
會阻塞主線程,導致后續代碼無法繼續運行,這樣可以運行gRPC服務器與HTTP服務器(gin)
同時運行。
gRPC
可以獨立運行,不影響主程序的其他功能。
優雅關閉gRPC
在main函數中,還有個stop,這是閉包函數,說實話這是第一次看到閉包函數的使用場景,首先我們捕獲了外部變量gc,gc也就是gRPC服務器實例,然后定義了服務關閉的具體行為,也就是停止gRPC服務,作為參數傳給srv.Run。
當Run函數接受一個stop函數作為參數,注釋一種依賴注入的設計模式,當收到指令之后,會把gRPC給關閉了。
雖然 stop 函數被傳入,但它并不會立即執行,代碼會在 <-quit 這行被阻塞。只有當程序收到 SIGINT 或 SIGTERM 信號時(比如按 Ctrl+C),才會繼續往下執行,然后才會檢查 stop != nil 并執行 stop 函數。