本文目录
- 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 logs
import (
"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.Logger
type 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.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框架默认的日志
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掉项目可能出现的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 bool
if 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: 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()
}
}
然后在main.go中来初始化我们的日志。
然后可以把对应的log地方进行更改了,比如下面的地方。
然后来验证一下是否能正常生成日志文件,正常生成了,没问题。
3. 配置
日志我们用了zap做集成,算是一个改进,但是配置比较复杂, 所以我们这里需要进一步优化这个配置。
配置我们引入viper
进行操作,也非常简单,直接上图和代码吧,在user里边装viper这个包。
go get github.com/spf13/viper
首先在user下面创建cofig目录,然后创建config.yaml
配置文件,然后创建config.go
代码读取配置。
config.go
的代码如下所示。
package config
import (
"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.Viper
SC *ServerConfig
}
type ServerConfig struct {
Name string
Addr 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")
//读取config
err := 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 set
DB: 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_v1
import (
"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 {
UnimplementedLoginServiceServer
cache repo.Cache
}
func New() *LoginService {
return &LoginService{
cache: dao.Rc,
}
}
func (ls LoginService) GetCaptcha(ctx context.Context, msg *CaptchaMessage) (*CaptchaResponse, error) {
//1.获取参数
moblie := msg.Mobile
fmt.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 router
import (
"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 []Router
func InitRouter(r *gin.Engine) {
for _, ro := range routers {
ro.Route(r)
}
}
type gRPCConfig struct {
Addr string
RegisterFunc 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 函数。