2.3 编写公共组件

刚想正式的开始编码,你会突然发现,怎么什么配套组件都没有,写起来一点都不顺手,没法形成闭环。

实际上在我们每个公司的项目中,都会有一类组件,我们常称其为基础组件,又或是公共组件,它们是不带强业务属性的,串联着整个应用程序,一般由负责基建或第一批搭建的该项目的同事进行梳理和编写,如果没有这类组件,谁都写一套,是非常糟糕的,并且这个应用程序是无法形成闭环的。

因此在这一章节我们将完成一个 Web 应用中最常用到的一些基础组件,保证应用程序的标准化,一共分为如下五个板块:

image

2.3.1 错误码标准化

在应用程序的运行中,我们常常需要与客户端进行交互,而交互分别是两点,一个是正确响应下的结果集返回,另外一个是错误响应的错误码和消息体返回,用于告诉客户端,这一次请求发生了什么事,因为什么原因失败了。而在错误码的处理上,又延伸出一个新的问题,那就是错误码的标准化处理,不提前预判,将会造成比较大的麻烦,如下:

image

在上图中,我们可以看到客户端分别调用了三个不同的服务端,三个服务端 A、B、C,它们的响应结果的模式都不一样…如果不做任何挣扎的话,那客户端就需要知道它调用的是哪个服务,然后每一个服务写一种错误码处理规则,非常麻烦,那如果后面继续添加新的服务端,如果又不一样,那岂不是适配的更加多了?

至少在大的层面来讲,我们要尽可能的保证每个项目前后端的交互语言规则是一致的,因此在一个新项目搭建之初,其中重要的一项预备工作,那就是标准化我们的错误码格式,保证客户端是“理解”我们的错误码规则,不需要每次都写一套新的。

2.3.1.1 公共错误码

我们需要在在项目目录下的 pkg/errcode 目录新建 common_code.go 文件,用于预定义项目中的一些公共错误码,便于引导和规范大家的使用,如下:

  1. var (
  2. Success = NewError(0, "成功")
  3. ServerError = NewError(10000000, "服务内部错误")
  4. InvalidParams = NewError(10000001, "入参错误")
  5. NotFound = NewError(10000002, "找不到")
  6. UnauthorizedAuthNotExist = NewError(10000003, "鉴权失败,找不到对应的 AppKey 和 AppSecret")
  7. UnauthorizedTokenError = NewError(10000004, "鉴权失败,Token 错误")
  8. UnauthorizedTokenTimeout = NewError(10000005, "鉴权失败,Token 超时")
  9. UnauthorizedTokenGenerate = NewError(10000006, "鉴权失败,Token 生成失败")
  10. TooManyRequests = NewError(10000007, "请求过多")
  11. )

2.3.1.2 错误处理

接下来我们在项目目录下的 pkg/errcode 目录新建 errcode.go 文件,编写常用的一些错误处理公共方法,标准化我们的错误输出,如下:

  1. type Error struct {
  2. code int `json:"code"`
  3. msg string `json:"msg"`
  4. details []string `json:"details"`
  5. }
  6. var codes = map[int]string{}
  7. func NewError(code int, msg string) *Error {
  8. if _, ok := codes[code]; ok {
  9. panic(fmt.Sprintf("错误码 %d 已经存在,请更换一个", code))
  10. }
  11. codes[code] = msg
  12. return &Error{code: code, msg: msg}
  13. }
  14. func (e *Error) Error() string {
  15. return fmt.Sprintf("错误码:%d, 错误信息::%s", e.Code(), e.Msg())
  16. }
  17. func (e *Error) Code() int {
  18. return e.code
  19. }
  20. func (e *Error) Msg() string {
  21. return e.msg
  22. }
  23. func (e *Error) Msgf(args []interface{}) string {
  24. return fmt.Sprintf(e.msg, args...)
  25. }
  26. func (e *Error) Details() []string {
  27. return e.details
  28. }
  29. func (e *Error) WithDetails(details ...string) *Error {
  30. newError := *e
  31. newError.details = []string{}
  32. for _, d := range details {
  33. newError.details = append(newError.details, d)
  34. }
  35. return &newError
  36. }
  37. func (e *Error) StatusCode() int {
  38. switch e.Code() {
  39. case Success.Code():
  40. return http.StatusOK
  41. case ServerError.Code():
  42. return http.StatusInternalServerError
  43. case InvalidParams.Code():
  44. return http.StatusBadRequest
  45. case UnauthorizedAuthNotExist.Code():
  46. fallthrough
  47. case UnauthorizedTokenError.Code():
  48. fallthrough
  49. case UnauthorizedTokenGenerate.Code():
  50. fallthrough
  51. case UnauthorizedTokenTimeout.Code():
  52. return http.StatusUnauthorized
  53. case TooManyRequests.Code():
  54. return http.StatusTooManyRequests
  55. }
  56. return http.StatusInternalServerError
  57. }

在错误码方法的编写中,我们声明了 Error 结构体用于表示错误的响应结果,并利用 codes 作为全局错误码的存储载体,便于查看当前注册情况,并在调用 NewError 创建新的 Error 实例的同时进行排重的校验。

另外相对特殊的是 StatusCode 方法,它主要用于针对一些特定错误码进行状态码的转换,因为不同的内部错误码在 HTTP 状态码中都代表着不同的意义,我们需要将其区分开来,便于客户端以及监控/报警等系统的识别和监听。

2.3.2 配置管理

在应用程序的运行生命周期中,最直接的关系之一就是应用的配置读取和更新。它的一举一动都有可能影响应用程序的改变,其分别包含如下行为:

image

  • 在启动时:可以进行一些基础应用属性、连接第三方实例(MySQL、NoSQL)等等的初始化行为。

  • 在运行中:可以监听文件或其他存储载体的变更来实现热更新配置的效果,例如:在发现有变更的话,就对原有配置值进行修改,以此达到相关联的一个效果。如果更深入业务使用的话,我们还可以通过配置的热更新,达到功能灰度的效果,这也是一个比较常见的场景。

另外,配置组件是会根据实际情况去选型的,一般大多为文件配置或配置中心的模式,在本次博客后端中我们的配置管理使用最常见的文件配置作为我们的选型。

2.3.2.1 安装

为了完成文件配置的读取,我们需要借助第三方开源库 viper,在项目根目录下执行以下安装命令:

  1. $ go get -u github.com/spf13/viper@v1.4.0

Viper 是适用于 Go 应用程序的完整配置解决方案,是目前 Go 语言中比较流行的文件配置解决方案,它支持处理各种不同类型的配置需求和配置格式。

2.3.2.2 配置文件

在项目目录下的 configs 目录新建 config.yaml 文件,写入以下配置:

  1. Server:
  2. RunMode: debug
  3. HttpPort: 8000
  4. ReadTimeout: 60
  5. WriteTimeout: 60
  6. App:
  7. DefaultPageSize: 10
  8. MaxPageSize: 100
  9. LogSavePath: storage/logs
  10. LogFileName: app
  11. LogFileExt: .log
  12. Database:
  13. DBType: mysql
  14. Username: root # 填写你的数据库账号
  15. Password: rootroot # 填写你的数据库密码
  16. Host: 127.0.0.1:3306
  17. DBName: blog_service
  18. TablePrefix: blog_
  19. Charset: utf8
  20. ParseTime: True
  21. MaxIdleConns: 10
  22. MaxOpenConns: 30

在配置文件中,我们分别针对如下内容进行了默认配置:

  • Server:服务配置,设置 gin 的运行模式、默认的 HTTP 监听端口、允许读取和写入的最大持续时间。
  • App:应用配置,设置默认每页数量、所允许的最大每页数量以及默认的应用日志存储路径。
  • Database:数据库配置,主要是连接实例所必需的基础参数。

2.3.2.3 编写组件

在完成了配置文件的确定和编写后,我们需要针对读取配置的行为进行封装,便于应用程序的使用,我们在项目目录下的 pkg/setting 目录下新建 setting.go 文件,写入如下代码:

  1. type Setting struct {
  2. vp *viper.Viper
  3. }
  4. func NewSetting() (*Setting, error) {
  5. vp := viper.New()
  6. vp.SetConfigName("config")
  7. vp.AddConfigPath("configs/")
  8. vp.SetConfigType("yaml")
  9. err := vp.ReadInConfig()
  10. if err != nil {
  11. return nil, err
  12. }
  13. return &Setting{vp}, nil
  14. }

在这里我们编写了 NewSetting 方法,用于初始化本项目的配置的基础属性,设定配置文件的名称为 config,配置类型为 yaml,并且设置其配置路径为相对路径 configs/,以此确保在项目目录下执行运行时能够成功启动。

另外 viper 是允许设置多个配置路径的,这样子可以尽可能的尝试解决路径查找的问题,也就是可以不断地调用 AddConfigPath 方法,这块在后续会再深入介绍。

接下来我们新建 section.go 文件,用于声明配置属性的结构体并编写读取区段配置的配置方法,如下:

  1. type ServerSettingS struct {
  2. RunMode string
  3. HttpPort string
  4. ReadTimeout time.Duration
  5. WriteTimeout time.Duration
  6. }
  7. type AppSettingS struct {
  8. DefaultPageSize int
  9. MaxPageSize int
  10. LogSavePath string
  11. LogFileName string
  12. LogFileExt string
  13. }
  14. type DatabaseSettingS struct {
  15. DBType string
  16. UserName string
  17. Password string
  18. Host string
  19. DBName string
  20. TablePrefix string
  21. Charset string
  22. ParseTime bool
  23. MaxIdleConns int
  24. MaxOpenConns int
  25. }
  26. func (s *Setting) ReadSection(k string, v interface{}) error {
  27. err := s.vp.UnmarshalKey(k, v)
  28. if err != nil {
  29. return err
  30. }
  31. return nil
  32. }

2.3.2.4 包全局变量

在读取了文件的配置信息后,还是不够的,因为我们需要将配置信息和应用程序关联起来,我们才能够去使用它,因此在项目目录下的 global 目录下新建 setting.go 文件,写入如下代码:

  1. var (
  2. ServerSetting *setting.ServerSettingS
  3. AppSetting *setting.AppSettingS
  4. DatabaseSetting *setting.DatabaseSettingS
  5. )

我们针对最初预估的三个区段配置,进行了全局变量的声明,便于在接下来的步骤将其关联起来,并且提供给应用程序内部调用。

另外全局变量的初始化,是会随着应用程序的不断演进不断改变的,因此并不是一成不变,也就是这里展示的并不一定是最终的结果。

2.3.2.5 初始化配置读取

在完成了所有的预备行为后,我们回到项目根目录下的 main.go 文件,修改代码如下:

  1. func init() {
  2. err := setupSetting()
  3. if err != nil {
  4. log.Fatalf("init.setupSetting err: %v", err)
  5. }
  6. }
  7. func main() {...}
  8. func setupSetting() error {
  9. setting, err := setting.NewSetting()
  10. if err != nil {
  11. return err
  12. }
  13. err = setting.ReadSection("Server", &global.ServerSetting)
  14. if err != nil {
  15. return err
  16. }
  17. err = setting.ReadSection("App", &global.AppSetting)
  18. if err != nil {
  19. return err
  20. }
  21. err = setting.ReadSection("Database", &global.DatabaseSetting)
  22. if err != nil {
  23. return err
  24. }
  25. global.ServerSetting.ReadTimeout *= time.Second
  26. global.ServerSetting.WriteTimeout *= time.Second
  27. return nil
  28. }

我们新增了一个 init 方法,有的读者可能会疑惑它有什么作用,在 Go 语言中,init 方法常用于应用程序内的一些初始化操作,它在 main 方法之前自动执行,它的执行顺序是:全局变量初始化 =》init 方法 =》main 方法,但并不是建议滥用,因为如果 init 过多,你可能会迷失在各个库的 init 方法中,会非常麻烦。

而在我们的应用程序中,该 init 方法主要作用是进行应用程序的初始化流程控制,整个应用代码里也只会有一个 init 方法,因此我们在这里调用了初始化配置的方法,达到配置文件内容映射到应用配置结构体的作用。

2.3.2.7 修改服务端配置

接下来我们只需要在启动文件 main.go 中把已经映射好的配置和 gin 的运行模式进行设置,这样的话,在程序重新启动时后就可以生效,如下:

  1. func main() {
  2. gin.SetMode(global.ServerSetting.RunMode)
  3. router := routers.NewRouter()
  4. s := &http.Server{
  5. Addr: ":" + global.ServerSetting.HttpPort,
  6. Handler: router,
  7. ReadTimeout: global.ServerSetting.ReadTimeout,
  8. WriteTimeout: global.ServerSetting.WriteTimeout,
  9. MaxHeaderBytes: 1 << 20,
  10. }
  11. s.ListenAndServe()
  12. }

2.3.2.8 验证

在完成了配置相关的初始化后,我们需要校验配置是否真正的映射到配置结构体上了,我们一般可以通过断点或简单打日志的方式进行查看,最终配置的包全局变量的值应当要得出如下结果:

  1. global.ServerSetting: &{RunMode:debug HttpPort:8000 ReadTimeout:1m0s WriteTimeout:1m0s}
  2. global.AppSetting: &{DefaultPageSize:10 MaxPageSize:100}
  3. global.DatabaseSetting: &{DBType:mysql User: Password:rootroot Host:127.0.0.1:3306 DBName:blog TablePrefix:blog_}

2.3.3 数据库连接

2.3.3.1 安装

我们在本项目中数据库相关的数据操作将使用第三方的开源库 gorm,它是目前 Go 语言中最流行的 ORM 库(从 Github Star 来看),同时它也是一个功能齐全且对开发人员友好的 ORM 库,目前在 Github 上相当的活跃,具有一定的保障,安装命令如下:

  1. $ go get -u github.com/jinzhu/gorm@v1.9.12

另外在社区中,也有其它的声音,例如有认为不使用 ORM 库更好的,这类的比较本文暂不探讨,但若是想了解的话可以看看像 sqlx 这类 database/sql 的扩展库,也是一个不错的选择。

2.3.3.2 编写组件

我们打开项目目录 internal/model 下的 model.go 文件,新增 NewDBEngine 方法,如下:

  1. import (
  2. ...
  3. "github.com/jinzhu/gorm"
  4. _ "github.com/jinzhu/gorm/dialects/mysql"
  5. )
  6. type Model struct {...}
  7. func NewDBEngine(databaseSetting *setting.DatabaseSettingS) (*gorm.DB, error) {
  8. db, err := gorm.Open(databaseSetting.DBType, fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=%s&parseTime=%t&loc=Local",
  9. databaseSetting.UserName,
  10. databaseSetting.Password,
  11. databaseSetting.Host,
  12. databaseSetting.DBName,
  13. databaseSetting.Charset,
  14. databaseSetting.ParseTime,
  15. ))
  16. if err != nil {
  17. return nil, err
  18. }
  19. if global.ServerSetting.RunMode == "debug" {
  20. db.LogMode(true)
  21. }
  22. db.SingularTable(true)
  23. db.DB().SetMaxIdleConns(databaseSetting.MaxIdleConns)
  24. db.DB().SetMaxOpenConns(databaseSetting.MaxOpenConns)
  25. return db, nil
  26. }

我们通过上述代码,编写了一个针对创建 DB 实例的 NewDBEngine 方法,同时增加了 gorm 开源库的引入和 MySQL 驱动库 github.com/jinzhu/gorm/dialects/mysql 的初始化(不同类型的 DBType 需要引入不同的驱动库,否则会存在问题)。

2.3.3.3 包全局变量

我们在项目目录下的 global 目录,新增 db.go 文件,新增如下内容:

  1. var (
  2. DBEngine *gorm.DB
  3. )

2.3.3.4 初始化

回到启动文件,也就是项目目录下的 main.go 文件,新增 setupDBEngine 方法初始化,如下:

  1. func init() {
  2. ...
  3. err = setupDBEngine()
  4. if err != nil {
  5. log.Fatalf("init.setupDBEngine err: %v", err)
  6. }
  7. }
  8. func main() {...}
  9. func setupSetting() error {...}
  10. func setupLogger() error {...}
  11. func setupDBEngine() error {
  12. var err error
  13. global.DBEngine, err = model.NewDBEngine(global.DatabaseSetting)
  14. if err != nil {
  15. return err
  16. }
  17. return nil
  18. }

这里需要注意,有一些人会把初始化语句不小心写成:global.DBEngine, err := model.NewDBEngine(global.DatabaseSetting),这是存在很大问题的,因为 := 会重新声明并创建了左侧的新局部变量,因此在其它包中调用 global.DBEngine 变量时,它仍然是 nil,仍然是达不到可用标准,因为根本就没有赋值到真正需要赋值的包全局变量 global.DBEngine 上。

2.3.4 日志写入

如果有心的读者会发现我们在上述应用代码中都是直接使用 Go 标准库 log 来进行的日志输出,这其实是有些问题的,因为在一个项目中,我们的日志需要标准化的记录一些的公共信息,例如:代码调用堆栈、请求链路 ID、公共的业务属性字段等等,而直接输出标准库的日志的话,并不具备这些数据,也不够灵活。

日志的信息的齐全与否在排查和调试问题中是非常重要的一环,因此在应用程序中我们也会有一个标准的日志组件会进行统一处理和输出。

2.3.4.1 安装

  1. $ go get -u gopkg.in/natefinch/lumberjack.v2

我们先拉取日志组件内要使用到的第三方的开源库 lumberjack,它的核心功能是将日志写入滚动文件中,该库支持设置所允许单日志文件的最大占用空间、最大生存周期、允许保留的最多旧文件数,如果出现超出设置项的情况,就会对日志文件进行滚动处理。

而我们使用这个库,主要是为了减免一些文件操作类的代码编写,把核心逻辑摆在日志标准化处理上。

2.3.4.2 编写组件

首先在这一节中,实质上代码都是在同一个文件中的,但是为了便于理解,我们会在讲解上会将日志组件的代码切割为多块进行剖析。

2.3.4.2.1 日志分级

我们在项目目录下的 pkg/ 目录新建 logger 目录,并创建 logger.go 文件,写入日志分级相关的代码:

  1. type Level int8
  2. type Fields map[string]interface{}
  3. const (
  4. LevelDebug Level = iota
  5. LevelInfo
  6. LevelWarn
  7. LevelError
  8. LevelFatal
  9. LevelPanic
  10. )
  11. func (l Level) String() string {
  12. switch l {
  13. case LevelDebug:
  14. return "debug"
  15. case LevelInfo:
  16. return "info"
  17. case LevelWarn:
  18. return "warn"
  19. case LevelError:
  20. return "error"
  21. case LevelFatal:
  22. return "fatal"
  23. case LevelPanic:
  24. return "panic"
  25. }
  26. return ""
  27. }

我们先预定义了应用日志的 Level 和 Fields 的具体类型,并且分为了 Debug、Info、Warn、Error、Fatal、Panic 六个日志等级,便于在不同的使用场景中记录不同级别的日志。

2.3.4.2.2 日志标准化

我们完成了日志的分级方法后,开始编写具体的方法去进行日志的实例初始化和标准化参数绑定,继续写入如下代码:

  1. type Logger struct {
  2. newLogger *log.Logger
  3. ctx context.Context
  4. fields Fields
  5. callers []string
  6. }
  7. func NewLogger(w io.Writer, prefix string, flag int) *Logger {
  8. l := log.New(w, prefix, flag)
  9. return &Logger{newLogger: l}
  10. }
  11. func (l *Logger) clone() *Logger {
  12. nl := *l
  13. return &nl
  14. }
  15. func (l *Logger) WithFields(f Fields) *Logger {
  16. ll := l.clone()
  17. if ll.fields == nil {
  18. ll.fields = make(Fields)
  19. }
  20. for k, v := range f {
  21. ll.fields[k] = v
  22. }
  23. return ll
  24. }
  25. func (l *Logger) WithContext(ctx context.Context) *Logger {
  26. ll := l.clone()
  27. ll.ctx = ctx
  28. return ll
  29. }
  30. func (l *Logger) WithCaller(skip int) *Logger {
  31. ll := l.clone()
  32. pc, file, line, ok := runtime.Caller(skip)
  33. if ok {
  34. f := runtime.FuncForPC(pc)
  35. ll.callers = []string{fmt.Sprintf("%s: %d %s", file, line, f.Name())}
  36. }
  37. return ll
  38. }
  39. func (l *Logger) WithCallersFrames() *Logger {
  40. maxCallerDepth := 25
  41. minCallerDepth := 1
  42. callers := []string{}
  43. pcs := make([]uintptr, maxCallerDepth)
  44. depth := runtime.Callers(minCallerDepth, pcs)
  45. frames := runtime.CallersFrames(pcs[:depth])
  46. for frame, more := frames.Next(); more; frame, more = frames.Next() {
  47. callers = append(callers, fmt.Sprintf("%s: %d %s", frame.File, frame.Line, frame.Function))
  48. if !more {
  49. break
  50. }
  51. }
  52. ll := l.clone()
  53. ll.callers = callers
  54. return ll
  55. }
  • WithLevel:设置日志等级。
  • WithFields:设置日志公共字段。
  • WithContext:设置日志上下文属性。
  • WithCaller:设置当前某一层调用栈的信息(程序计数器、文件信息、行号)。
  • WithCallersFrames:设置当前的整个调用栈信息。

2.3.4.2.3 日志格式化和输出

我们开始编写日志内容的格式化和日志输出动作的相关方法,继续写入如下代码:

  1. func (l *Logger) JSONFormat(level Level, message string) map[string]interface{} {
  2. data := make(Fields, len(l.fields)+4)
  3. data["level"] = level.String()
  4. data["time"] = time.Now().Local().UnixNano()
  5. data["message"] = message
  6. data["callers"] = l.callers
  7. if len(l.fields) > 0 {
  8. for k, v := range l.fields {
  9. if _, ok := data[k]; !ok {
  10. data[k] = v
  11. }
  12. }
  13. }
  14. return data
  15. }
  16. func (l *Logger) Output(level Level, message string) {
  17. body, _ := json.Marshal(l.JSONFormat(level, message))
  18. content := string(body)
  19. switch level {
  20. case LevelDebug:
  21. l.newLogger.Print(content)
  22. case LevelInfo:
  23. l.newLogger.Print(content)
  24. case LevelWarn:
  25. l.newLogger.Print(content)
  26. case LevelError:
  27. l.newLogger.Print(content)
  28. case LevelFatal:
  29. l.newLogger.Fatal(content)
  30. case LevelPanic:
  31. l.newLogger.Panic(content)
  32. }
  33. }

2.3.4.2.4 日志分级输出

我们根据先前定义的日志分级,编写对应的日志输出的外部方法,继续写入如下代码:

  1. func (l *Logger) Info(v ...interface{}) {
  2. l.Output(LevelInfo, fmt.Sprint(v...))
  3. }
  4. func (l *Logger) Infof(format string, v ...interface{}) {
  5. l.Output(LevelInfo, fmt.Sprintf(format, v...))
  6. }
  7. func (l *Logger) Fatal(v ...interface{}) {
  8. l.Output(LevelFatal, fmt.Sprint(v...))
  9. }
  10. func (l *Logger) Fatalf(format string, v ...interface{}) {
  11. l.Output(LevelFatal, fmt.Sprintf(format, v...))
  12. }
  13. ...

上述代码中仅展示了 Info、Fatal 级别的日志方法,这里主要是根据 Debug、Info、Warn、Error、Fatal、Panic 六个日志等级编写对应的方法,大家可自行完善,除了方法名以及 WithLevel 设置的不一样,其他均为一致的代码。

2.3.4.3 包全局变量

在完成日志库的编写后,我们需要定义一个 Logger 对象便于我们的应用程序使用。因此我们打开项目目录下的 global/setting.go 文件,新增如下内容:

  1. var (
  2. ...
  3. Logger *logger.Logger
  4. )

我们在包全局变量中新增了 Logger 对象,用于日志组件的初始化。

2.3.4.4 初始化

接下来我们需要修改启动文件,也就是项目目录下的 main.go 文件,新增对刚刚定义的 Logger 对象的初始化,如下:

  1. func init() {
  2. err := setupSetting()
  3. if err != nil {
  4. log.Fatalf("init.setupSetting err: %v", err)
  5. }
  6. err = setupLogger()
  7. if err != nil {
  8. log.Fatalf("init.setupLogger err: %v", err)
  9. }
  10. }
  11. func main() {...}
  12. func setupSetting() error {...}
  13. func setupLogger() error {
  14. global.Logger = logger.NewLogger(&lumberjack.Logger{
  15. Filename: global.AppSetting.LogSavePath + "/" + global.AppSetting.LogFileName + global.AppSetting.LogFileExt,
  16. MaxSize: 600,
  17. MaxAge: 10,
  18. LocalTime: true,
  19. }, "", log.LstdFlags).WithCaller(2)
  20. return nil
  21. }

通过这段程序,我们在 init 方法中新增了日志组件的流程,并在 setupLogger 方法内部对 global 的包全局变量 Logger 进行了初始化,需要注意的是我们使用了 lumberjack 作为日志库的 io.Writer,并且设置日志文件所允许的最大占用空间为 600MB、日志文件最大生存周期为 10 天,并且设置日志文件名的时间格式为本地时间。

2.3.4.5 验证

在完成了上述的步骤后,日志组件已经正式的初始化完毕了,为了验证你是否操作正确,你可以在 main 方法中执行下述测试代码:

  1. global.Logger.Infof("%s: go-programming-tour-book/%s", "eddycjy", "blog-service")

接着可以查看项目目录下的 storage/logs/app.log,看看日志文件是否正常创建且写入了预期的日志记录,大致如下:

  1. {"callers":["~/go-programming-tour-book/blog-service/main.go: 20 main.init.0"],"level":"info","message":"eddycjy: go-programming-tour-book/blog-service","time":xxxx}

2.3.5 响应处理

在应用程序中,与客户端对接的常常是服务端的接口,那客户端是怎么知道这一次的接口调用结果是怎么样的呢?一般来讲,主要是通过对返回的 HTTP 状态码和接口返回的响应结果进行判断,而判断的依据则是事先按规范定义好的响应结果。

因此在这一小节,我们将编写统一处理接口返回的响应处理方法,它也正正与错误码标准化是相对应的。

2.3.5.1 类型转换

在项目目录下的 pkg/convert 目录下新建 convert.go 文件,如下:

  1. type StrTo string
  2. func (s StrTo) String() string {
  3. return string(s)
  4. }
  5. func (s StrTo) Int() (int, error) {
  6. v, err := strconv.Atoi(s.String())
  7. return v, err
  8. }
  9. func (s StrTo) MustInt() int {
  10. v, _ := s.Int()
  11. return v
  12. }
  13. func (s StrTo) UInt32() (uint32, error) {
  14. v, err := strconv.Atoi(s.String())
  15. return uint32(v), err
  16. }
  17. func (s StrTo) MustUInt32() uint32 {
  18. v, _ := s.UInt32()
  19. return v
  20. }

2.3.5.2 分页处理

在项目目录下的 pkg/app 目录下新建 pagination.go 文件,如下:

  1. func GetPage(c *gin.Context) int {
  2. page := convert.StrTo(c.Query("page")).MustInt()
  3. if page <= 0 {
  4. return 1
  5. }
  6. return page
  7. }
  8. func GetPageSize(c *gin.Context) int {
  9. pageSize := convert.StrTo(c.Query("page_size")).MustInt()
  10. if pageSize <= 0 {
  11. return global.AppSetting.DefaultPageSize
  12. }
  13. if pageSize > global.AppSetting.MaxPageSize {
  14. return global.AppSetting.MaxPageSize
  15. }
  16. return pageSize
  17. }
  18. func GetPageOffset(page, pageSize int) int {
  19. result := 0
  20. if page > 0 {
  21. result = (page - 1) * pageSize
  22. }
  23. return result
  24. }

2.3.5.3 响应处理

在项目目录下的 pkg/app 目录下新建 app.go 文件,如下:

  1. type Response struct {
  2. Ctx *gin.Context
  3. }
  4. type Pager struct {
  5. Page int `json:"page"`
  6. PageSize int `json:"page_size"`
  7. TotalRows int `json:"total_rows"`
  8. }
  9. func NewResponse(ctx *gin.Context) *Response {
  10. return &Response{Ctx: ctx}
  11. }
  12. func (r *Response) ToResponse(data interface{}) {
  13. if data == nil {
  14. data = gin.H{}
  15. }
  16. r.Ctx.JSON(http.StatusOK, data)
  17. }
  18. func (r *Response) ToResponseList(list interface{}, totalRows int) {
  19. r.Ctx.JSON(http.StatusOK, gin.H{
  20. "list": list,
  21. "pager": Pager{
  22. Page: GetPage(r.Ctx),
  23. PageSize: GetPageSize(r.Ctx),
  24. TotalRows: totalRows,
  25. },
  26. })
  27. }
  28. func (r *Response) ToErrorResponse(err *errcode.Error) {
  29. response := gin.H{"code": err.Code(), "msg": err.Msg()}
  30. details := err.Details()
  31. if len(details) > 0 {
  32. response["details"] = details
  33. }
  34. r.Ctx.JSON(err.StatusCode(), response)
  35. }

2.3.5.4 验证

我们可以找到其中一个接口方法,调用对应的方法,检查是否有误,如下:

  1. func (a Article) Get(c *gin.Context) {
  2. app.NewResponse(c).ToErrorResponse(errcode.ServerError)
  3. return
  4. }

验证响应结果,如下:

  1. $ curl -v http://127.0.0.1:8080/api/v1/articles/1
  2. ...
  3. < HTTP/1.1 500 Internal Server Error
  4. {"code":10000000,"msg":"服务内部错误"}

从响应结果上看,可以知道本次接口的调用结果的 HTTP 状态码为 500,响应消息体为约定的错误体,符合我们的要求。

2.3.6 小结

在本章节中,我们主要是针对项目的公共组件初始化,做了大量的规范制定、公共库编写、初始化注册等等行为,虽然比较繁琐,这这些公共组件在整个项目运行中至关重要,早期做的越标准化,后期越省心省事,因为大家直接使用就可以了,不需要过多的关心细节,也不会有人重新再造新的公共库轮子,导致要适配多套。

本图书由 煎鱼©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。

2.3 编写公共组件 - 图4