Skip to content

公共组件

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

本节我们将完成一个 Web 应用中最常用的一些基础组件,以保障应用程序的标准化。
该基础组件一共分为五个板块,

  • 错误码标准化
  • 配置管理
  • 数据库连接
  • 日志写入
  • 响应处理

错误码标准化

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

如图所示:

在图中,可以看到客户端分别调用了三个不同的服务端,即服务端 A、服务端 B 和服务端 C。
它们的响应结果的模式不同,如果不做修改,客户端就需要知道它调用的是哪个服务,然后对每一个服务写一种错误码处理规则,非常烦琐。
如果后续添加了新的服务端,且响应结果的模式与之前的不同,则必须添加新的错误码处理规则。

因此,我们要尽可能地保证每个项目前后端的交互语言规则是一致的,也就是说,在搭建一个新项目之初,其中重要的一项预备工作就是将错误码标准化,以保证客户端可以“理解”错误码规则,不需要每次都写一套新的

公共错误码

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package errcode

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

错误处理

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package errcode

import (
    "fmt"
    "net/http"
)

type Error struct {
    code    int      `json:"code"`
    msg     string   `json:"msg"`
    details []string `json:"details"`
}

var codes = map[int]string{}

func NewError(code int, msg string) *Error {
    if _, ok := codes[code]; ok {
        panic(fmt.Sprintf("错误码 %d 已经存在,请更换一个", code))
    }
    codes[code] = msg
    return &Error{code: code, msg: msg}
}

func (e *Error) Error() string {
    return fmt.Sprintf("错误码: %d, 错误信息: %s", e.Code(), e.Msg())
}

func (e *Error) Code() int {
    return e.code
}

func (e *Error) Msg() string {
    return e.msg
}

func (e *Error) Msgf(args []interface{}) string {
    return fmt.Sprintf(e.msg, args...)
}

func (e *Error) Details() []string {
    return e.details
}

func (e *Error) WithDetails(details ...string) *Error {
    newError := *e
    newError.details = []string{}
    for _, d := range details {
        newError.details = append(newError.details, d)
    }

    return &newError
}

func (e *Error) StatusCode() int {
    switch e.Code() {
    case Success.Code():
        return http.StatusOK
    case ServerError.Code():
        return http.StatusInternalServerError
    case InvalidParams.Code():
        return http.StatusBadRequest
    case UnauthorizedAuthNotExist.Code():
        fallthrough
    case UnauthorizedTokenError.Code():
        fallthrough
    case UnauthorizedTokenGenerate.Code():
        return http.StatusUnauthorized
    case UnauthorizedTokenTimeout.Code():
        return http.StatusUnauthorized
    case TooManyRequests.Code():
        return http.StatusTooManyRequests
    }

    return http.StatusInternalServerError
}

首先,在编写错误处理公共方法过程中,声明了 Error 结构体,用于表示错误的响应结果。
然后,把 codes 作为全局错误码的存储载体,以便查看当前的注册情况。
最后,在调用 NewError 创建新的 Error 实例的同时,进行排重校验。

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

配置管理

在应用程序的运行生命周期中,应用的配置读取和更新可以直接改变应用程序,其包含的行为如图所示

  • 在启动时: 可以进行一些初始化行为,如配置基础应用属性、连接第三方实例(MySQL、NoSQL)等
  • 在运行时: 可以通过监听文件或变更其他存储载体来实现热更新配置效果。例如,一旦发现有变更,就对原有配置值进行修改,以此达到相关联的一个效果。另外,我们还可以通过配置热更新,达到功能灰度的效果,这也是一个比较常见的场景。

此外,配置组件可以根据实际情况去选型,一般来说,多为文件配置模式或配置中心模式。
我们的配置管理使用最常见的文件配置作为我们的选型。

安装 viper

https://github.com/spf13/viper

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

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

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

配置文件

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

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

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

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

编写组件

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package setting

import "github.com/spf13/viper"

type Setting struct {
    vp *viper.Viper
}

func NewSetting() (*Setting, error) {
    vp := viper.New()
    vp.SetConfigName("config")
    vp.AddConfigPath("configs/")
    vp.SetConfigType("yaml")
    err := vp.ReadInConfig()
    if err != nil {
        return nil, err
    }

    return &Setting{vp}, nil
}

在上述代码中,我们编写了 NewSetting 方法,用于初始化本项目配置的基础属性,即设定配置文件的名称为 config、配置类型为 yaml,并且设置其配置路径为相对路径 configs/,以确保在项目目录下能够成功启动编写组件

另外,viper 是允许设置多个配置路径的,这样可以尽可能地尝试解决路径查找问题,也就是说,可以不断地调用 AddConfigPath 方法

下面新建 setting/section.go 文件,用于声明配置属性的结构体,并编写读取区段配置的配置方法,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package setting

import "time"

type ServerSettings struct {
    RunMode      string
    HttpPort     string
    ReadTimeout  time.Duration
    WriteTimeout time.Duration
}

type AppSettings struct {
    DefaultPageSize int
    MaxPageSize    int
    LogSavePath     string
    LogFileName     string
    LogFileExt      string
}

type DatabaseSettings struct {
    DBType       string
    UserName     string
    Password     string
    Host         string
    DBName       string
    TablePrefix  string
    Charset      string
    ParseTime    bool
    MaxIdleConns int
    MaxOpenConns int
}

func (s *Setting) ReadSection(k string, v interface{}) error {
    err := s.vp.UnmarshalKey(k, v)
    if err != nil {
        return err
    }

    return nil
}

包全局变量

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

1
2
3
4
5
6
7
8
9
package global

import "example.com/blog-service/pkg/setting"

var (
    ServerSetting   *setting.ServerSettings
    AppSetting      *setting.AppSettings
    DatabaseSetting *setting.DatabaseSettings
)

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

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

初始化配置读取

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

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

    global.ServerSetting.ReadTimeout *= time.Second
    global.ServerSetting.WriteTimeout *= time.Second
    return nil
}

func init() {
    err := setupSetting()
    if err != nil {
        log.Fatalf("init.setupSetting err: %v", err)
    }
}

这里新增了一个 init 方法。
在 Go 语言中,init 方法常用于应用程序内的一些初始化操作,它在 main 方法之前自动执行。
在 Go 语言中,程序的执行顺序是: 全局变量初始化 -> init 方法 -> main 方法... 注意,不要滥用 init 方法,如果 init 方法过多,则很容易迷失在各个库的 init 方法中。

在上面的应用程序中,init 方法的主要作用是控制应用程序的初始化流程。
在整个应用代码的只有一个 init 方法,因此在这里调用了初始化配置的方法,起到把配置文件内容映射到应用配置结构体中的作用。

修改服务端配置

在启动文件 main.go 中设置已经映射好的配置和 gin 的运行模式,这样在程序重新启动之后即可生效,代码如下:

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

数据库连接

安装

https://github.com/go-gorm/gorm

https://gorm.io/

https://gorm.io/zh_CN/

https://gorm.io/zh_CN/docs/index.html

在本项目中,与数据库相关的数据操作将使用第三方开源库 gorm。
它是目前 Go 语言中最流行的 ORM 库(从 GitHub Star 来看),功能十分齐全,且对开发人员非常友好。安装 gorm 的命令如下:

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

另外,在社区中,也有其他的声音,例如有人认为不使用 ORM 库更好,这类的比较我们不做探讨,若想了解,可以看看 database/sql 扩展库

编写组件

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func NewDBEngine(databaseSetting *setting.DatabaseSettings) (*gorm.DB, error) {
    db, err := gorm.Open(databaseSetting.DBType, fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=%s&parseTime=%t&loc=Local",
        databaseSetting.UserName,
        databaseSetting.Password,
        databaseSetting.Host,
        databaseSetting.DBName,
        databaseSetting.Charset,
        databaseSetting.ParseTime,
    ))
    if err != nil {
        return nil, err
    }

    if global.ServerSetting.RunMode == "debug" {
        db.LogMode(true)
    }
    db.SingularTable(true)

    db.DB().SetMaxIdleConns(databaseSetting.MaxIdleConns)
    db.DB().SetMaxOpenConns(databaseSetting.MaxOpenConns)

    return db, nil
}

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

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

internal/model/model.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package model

import (
    "fmt"

    "example.com/blog-service/global"
    "example.com/blog-service/pkg/setting"
    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/mysql"
)

注意修改configs/config.yaml中的数据库用户名和密码

包全局变量

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

1
2
3
4
5
6
7
package global

import "github.com/jinzhu/gorm"

var (
    DBEngine *gorm.DB
)

初始化

回到启动文件,即项目目录下的 main.go 文件,在其中新增 setupDBEngine 方法的初始化代码,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func init() {
    ...
    err = setupDBEngine()
    if err != nil {
        log.Fatalf("init.setupDBEngine err: %v", err)
    }
}

func setupDBEngine() error {
    var err error
    global.DBEngine, err = model.NewDBEngine(global.DatabaseSetting)
    if err != nil {
        return err
    }

    return nil
}

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

日志写入

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

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

安装

https://github.com/natefinch/lumberjack

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

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

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

编写组件

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

日志分级

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package logger

type Level int8

type Fields map[string]interface{}

const (
    LevelDebug Level = iota
    LevelInfo
    LevelWarn
    LevelError
    LevelFatal
    LevelPanic
)

func (l Level) String() string {
    switch l {
    case LevelDebug:
        return "debug"
    case LevelInfo:
        return "info"
    case LevelWarn:
        return "warn"
    case LevelError:
        return "error"
    case LevelFatal:
        return "fatal"
    case LevelPanic:
        return "panic"
    }
    return ""
}

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

日志标准化

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
type Logger struct {
    newLogger *log.Logger
    ctx       context.Context
    fields    Fields
    callers   []string
}

func NewLogger(w io.Writer, prefix string, flag int) *Logger {
    l := log.New(w, prefix, flag)
    return &Logger{newLogger: l}
}

func (l *Logger) clone() *Logger {
    nl := *l
    return &nl
}

func (l *Logger) WithFields(f Fields) *Logger {
    ll := l.clone()
    if ll.fields == nil {
        ll.fields = make(Fields)
    }
    for k, v := range f {
        ll.fields[k] = v
    }
    return ll
}

func (l *Logger) WithContext(ctx context.Context) *Logger {
    ll := l.clone()
    ll.ctx = ctx
    return ll
}

func (l *Logger) WithCaller(skip int) *Logger {
    ll := l.clone()
    pc, file, line, ok := runtime.Caller(skip)
    if ok {
        f := runtime.FuncForPC(pc)
        ll.callers = []string{fmt.Sprintf("%s: %d %s", file, line, f.Name())}
    }

    return ll
}

func (l *Logger) WithCallersFrames() *Logger {
    maxCallerDepth := 25
    minCallerDepth := 1
    callers := []string{}
    pcs := make([]uintptr, maxCallerDepth)
    depth := runtime.Callers(minCallerDepth, pcs)
    frames := runtime.CallersFrames(pcs[:depth])
    for frame, more := frames.Next(); more; frame, more = frames.Next() {
        callers = append(callers, fmt.Sprintf("%s: %d %s", frame.File, frame.Line, frame.Function))
        if !more {
            break
        }
    }
    ll := l.clone()
    ll.callers = callers
    return ll
}
  • WithLevel:设置日志等级。
  • WithFields:设置日志公共字段。
  • WithContext:设置日志上下文属性。
  • WithCaller:设置当前某一层调用栈的信息(程序计数器、文件信息、行号)。
  • WithCallersFrames:设置当前的整个调用栈信息。

日志格式化输出

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func (l *Logger) JSONFormat(level Level, message string) map[string]interface{} {
    data := make(Fields, len(l.fields)+4)
    data["level"] = level.String()
    data["time"] = time.Now().Local().UnixNano()
    data["message"] = message
    data["callers"] = l.callers
    if len(l.fields) > 0 {
        for k, v := range l.fields {
            if _, ok := data[k]; !ok {
                data[k] = v
            }
        }
    }

    return data
}

func (l *Logger) Output(level Level, message string) {
    body, _ := json.Marshal(l.JSONFormat(level, message))
    content := string(body)
    switch level {
    case LevelDebug:
        l.newLogger.Print(content)
    case LevelInfo:
        l.newLogger.Print(content)
    case LevelWarn:
        l.newLogger.Print(content)
    case LevelError:
        l.newLogger.Print(content)
    case LevelFatal:
        l.newLogger.Fatal(content)
    case LevelPanic:
        l.newLogger.Panic(content)
    }
}

日志分级输出

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func (l *Logger) Info(v ...interface{}) {
    l.Output(LevelInfo, fmt.Sprint(v...))
}

func (l *Logger) Infof(format string, v ...interface{}) {
    l.Output(LevelInfo, fmt.Sprintf(format, v...))
}

func (l *Logger) Errorf(format string, v ...interface{}) {
    l.Output(LevelError, fmt.Sprintf(format, v...))
}

func (l *Logger) Fatal(v ...interface{}) {
    l.Output(LevelFatal, fmt.Sprint(v...))
}

func (l *Logger) Fatalf(format string, v ...interface{}) {
    l.Output(LevelFatal, fmt.Sprintf(format, v...))
}

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

包全局变量

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

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

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

初始化

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func init() {
    ...
    err = setupLogger()
    if err != nil {
        log.Fatalf("init.setupLogger err: %v", err)
    }
    ...
}

func setupLogger() error {
    global.Logger = logger.NewLogger(&lumberjack.Logger{
        Filename: global.AppSetting.LogSavePath + "/" + global.AppSetting.LogFileName + global.AppSetting.LogFileExt,
        MaxSize:   600,
        MaxAge:    10,
        LocalTime: true,
    }, "", log.LstdFlags).WithCaller(2)

    return nil
}

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

验证

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

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

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

1
2021/03/28 11:25:06 {"callers":["/Users/nocilantro/Desktop/blog-service/main.go: 45 main.init.0"],"level":"info","message":"eddycjy: go-programming-tour-book/blog-service","time":1616901906602637000}

响应处理

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

我们将编写统一处理接口返回的响应处理方法,该方法与错误码标准化相对应

类型转换

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package convert

import "strconv"

type StrTo string

func (s StrTo) String() string {
    return string(s)
}

func (s StrTo) Int() (int, error) {
    v, err := strconv.Atoi(s.String())
    return v, err
}

func (s StrTo) MustInt() int {
    v, _ := s.Int()
    return v
}

func (s StrTo) UInt32() (uint32, error) {
    v, err := strconv.Atoi(s.String())
    return uint32(v), err
}

func (s StrTo) MustUInt32() uint32 {
    v, _ := s.UInt32()
    return v
}

分页处理

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package app

import (
    "example.com/blog-service/global"
    "example.com/blog-service/pkg/convert"
    "github.com/gin-gonic/gin"
)

func GetPage(c *gin.Context) int {
    page := convert.StrTo(c.Query("page")).MustInt()
    if page <= 0 {
        return 1
    }

    return page
}

func GetPageSize(c *gin.Context) int {
    pageSize := convert.StrTo(c.Query("page_size")).MustInt()
    if pageSize <= 0 {
        return global.AppSetting.DefaultPageSize
    }
    if pageSize > global.AppSetting.MaxPageSize {
        return global.AppSetting.MaxPageSize
    }

    return pageSize
}

func GetPageOffset(page, pageSize int) int {
    result := 0
    if page > 0 {
        result = (page - 1) * pageSize
    }

    return result
}

响应处理

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package app

import (
    "net/http"

    "example.com/blog-service/pkg/errcode"
    "github.com/gin-gonic/gin"
)

type Response struct {
    Ctx *gin.Context
}

type Pager struct {
    Page      int `json:"page"`
    PageSize  int `json:"page_size"`
    TotalRows int `json:"total_rows"`
}

func NewResponse(ctx *gin.Context) *Response {
    return &Response{Ctx: ctx}
}

func (r *Response) ToResponse(data interface{}) {
    if data == nil {
        data = gin.H{}
    }
    r.Ctx.JSON(http.StatusOK, data)
}

func (r *Response) ToResponseList(list interface{}, totalRows int) {
    r.Ctx.JSON(http.StatusOK, gin.H{
        "list": list,
        "pager": Pager{
            Page:      GetPage(r.Ctx),
            PageSize:  GetPageSize(r.Ctx),
            TotalRows: totalRows,
        },
    })
}

func (r *Response) ToErrorResponse(err *errcode.Error) {
    response := gin.H{"code": err.Code(), "msg": err.Msg()}
    details := err.Details()
    if len(details) > 0 {
        response["details"] = details
    }

    r.Ctx.JSON(err.StatusCode(), response)
}

验证

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

修改internal/routers/v1/article.go中的 Get 方法:

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

重新启动程序, 验证响应结果, 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ curl -v http://127.0.0.1:8000/api/v1/articles/1
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0)
> GET /api/v1/articles/1 HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/7.61.1
> Accept: */*
> 
< HTTP/1.1 500 Internal Server Error
< Content-Type: application/json; charset=utf-8
< Date: Sun, 28 Mar 2021 03:47:06 GMT
< Content-Length: 44
< 
* Connection #0 to host 127.0.0.1 left intact
{"code":10000000,"msg":"服务内部错误"}%     

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

小结

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