從別人的程式碼中學習golang系列--01

syncd發表於2020-07-02

自己最近在思考一個問題,如何讓自己的程式碼質量逐漸提高,於是想到整理這個系列,通過閱讀別人的程式碼,從別人的程式碼中學習,來逐漸提高自己的程式碼質量。本篇是這個系列的第一篇,我也不知道自己會寫多少篇,但是希望自己能堅持下去。

第一個自己學習的原始碼是:https://github.com/LyricTian/gin-admin

自己整理的程式碼地址:https://github.com/peanut-pg/gin_admin 這篇文章整理的時候只是為了跑起來整體的程式碼,對作者的程式碼進行精簡。

這篇部落格主要是閱讀gin-admin的第一篇,整理了從程式碼專案目錄到日誌庫使用中學習到的內容:

  1. 專案目錄規範

  2. 配置檔案的載入

  3. github.com/sirupsen/logrus 日誌庫在專案的使用

  4. 專案的優雅退出

  5. Golang的選項模式

專案目錄規範

作者的專案目錄還是非常規範的,應該也是按照https://github.com/golang-standards/project-layout 規範寫的,這個規範雖然不是官方強制規範的,但是確實很多開源專案都在採用的,所以我們在生產中正式的專案都應該儘可能遵循這個目錄規範標準進行程式碼的編寫。關於這個目錄的規範使用,自己會在後續實際使用中逐漸完善。

/cmd

main函式檔案(比如 /cmd/myapp.go)目錄,這個目錄下面,每個檔案在編譯之後都會生成一個可執行的檔案。

不要把很多的程式碼放到這個目錄下面,這裡面的程式碼儘可能簡單。

/internal

應用程式的封裝的程式碼。我們的應用程式程式碼應該放在 /internal/app 目錄中。而這些應用程式共享的程式碼可以放在 /internal/pkg目錄中

/pkg

一些通用的可以被其他專案所使用的程式碼,放到這個目錄下面。

/vendor

應用程式的依賴項,go mod vendor 命令可以建立vendor目錄。

Service Application Directories

/api

協議檔案,Swagger/thrift/protobuf

Web Application Directories

/web

web服務所需要的靜態檔案

Common Application Directories

/configs

配置檔案目錄

/init

系統的初始化

/scripts

用於執行各種構建,安裝,分析等操作的指令碼。

/build

打包和持續整合

/deployments

部署相關的配置檔案和模板

/test

其他測試目錄,功能測試,效能測試等

Other Directories

/docs

設計和使用者文件

/tools

常用的工具和指令碼,可以引用 /internal 或者 /pkg 裡面的庫

/examples

應用程式或者公共庫使用的一些例子

/assets

其他一些依賴的靜態資源

配置檔案的載入

作者的gin-admin 專案中配置檔案載入庫使用的是:github.com/koding/multiconfig

在這之前,聽到使用最多的就是大名鼎鼎的viper ,但是相對於viper相對來說就比較輕便了,並且功能還是非常強大的。感興趣的可以看看這篇文章:https://sfxpt.wordpress.com/2015/06/19/beyond-toml-the-gos-de-facto-config-file/

栗子

程式目錄結構為:

configLoad
├── configLoad
├── config.toml
└── main.go

通過一個簡單的例子來看看multiconfig的使用

package main

import (
	"fmt"

	"github.com/koding/multiconfig"
)

type (
	// Server holds supported types by the multiconfig package
	Server struct {
		Name     string
		Port     int `default:"6060"`
		Enabled  bool
		Users    []string
		Postgres Postgres
	}

	// Postgres is here for embedded struct feature
	Postgres struct {
		Enabled           bool
		Port              int
		Hosts             []string
		DBName            string
		AvailabilityRatio float64
	}
)

func main() {
	m := multiconfig.NewWithPath("config.toml") // supports TOML and JSON

	// Get an empty struct for your configuration
	serverConf := new(Server)

	// Populated the serverConf struct
	m.MustLoad(serverConf) // Check for error

	fmt.Println("After Loading: ")
	fmt.Printf("%+v\n", serverConf)

	if serverConf.Enabled {
		fmt.Println("Enabled field is set to true")
	} else {
		fmt.Println("Enabled field is set to false")
	}
}

配置檔案config.toml內容為:

Name              = "koding"
Enabled           = false
Port              = 6066
Users             = ["ankara", "istanbul"]

[Postgres]
Enabled           = true
Port              = 5432
Hosts             = ["192.168.2.1", "192.168.2.2", "192.168.2.3"]
AvailabilityRatio = 8.23

編譯之後執行,效果如下:

➜  configLoad ./configLoad 
After Loading: 
&{Name:koding Port:6066 Enabled:false Users:[ankara istanbul] Postgres:{Enabled:true Port:5432 Hosts:[192.168.2.1 192.168.2.2 192.168.2.3] DBName: AvailabilityRatio:8.23}}
Enabled field is set to false
➜  configLoad ./configLoad -h
Usage of ./configLoad:
  -enabled
        Change value of Enabled. (default false)
  -name
        Change value of Name. (default koding)
  -port
        Change value of Port. (default 6066)
  -postgres-availabilityratio
        Change value of Postgres-AvailabilityRatio. (default 8.23)
  -postgres-dbname
        Change value of Postgres-DBName.
  -postgres-enabled
        Change value of Postgres-Enabled. (default true)
  -postgres-hosts
        Change value of Postgres-Hosts. (default [192.168.2.1 192.168.2.2 192.168.2.3])
  -postgres-port
        Change value of Postgres-Port. (default 5432)
  -users
        Change value of Users. (default [ankara istanbul])

Generated environment variables:
   SERVER_ENABLED
   SERVER_NAME
   SERVER_PORT
   SERVER_POSTGRES_AVAILABILITYRATIO
   SERVER_POSTGRES_DBNAME
   SERVER_POSTGRES_ENABLED
   SERVER_POSTGRES_HOSTS
   SERVER_POSTGRES_PORT
   SERVER_USERS

flag: help requested
➜  configLoad ./configLoad -name=test
After Loading: 
&{Name:test Port:6066 Enabled:false Users:[ankara istanbul] Postgres:{Enabled:true Port:5432 Hosts:[192.168.2.1 192.168.2.2 192.168.2.3] DBName: AvailabilityRatio:8.23}}
Enabled field is set to false
➜  configLoad export SERVER_NAME="test_env"
➜  configLoad ./configLoad                 
After Loading: 
&{Name:test_env Port:6066 Enabled:false Users:[ankara istanbul] Postgres:{Enabled:true Port:5432 Hosts:[192.168.2.1 192.168.2.2 192.168.2.3] DBName: AvailabilityRatio:8.23}}
Enabled field is set to false

從上面的使用中,你能能夠看到,雖然multiconfig 非常輕量,但是功能還是非常強大的,可以讀配置檔案,還可以通過環境變數,以及我們常用的命令列模式。

日誌庫在專案的使用

這個可能對很多初學者來說都是非常有用的,因為一個專案中,我們基礎的就是要記錄日誌,golang有很多強大的日誌庫,如:作者的gin-admin 專案使用的github.com/sirupsen/logrus; 還有就是uber開源的github.com/uber-go/zap等等

這裡主要學習一下作者是如何在專案中使用logrus,這篇文章對作者使用的進行了精簡。當然只是去掉了關於gorm,以及mongo的hook的部分,如果你的專案中沒有使用這些,其實也先不用關注這兩個hook部分的程式碼,不影響使用,後續的系列文章也會對hook部分進行整理。

作者封裝的logger庫是在pkg/loggger目錄中,我精簡之後如下:

package logger

import (
	"context"
	"fmt"
	"io"
	"os"
	"time"

	"github.com/sirupsen/logrus"
)

// 定義鍵名
const (
	TraceIDKey      = "trace_id"
	UserIDKey       = "user_id"
	SpanTitleKey    = "span_title"
	SpanFunctionKey = "span_function"
	VersionKey      = "version"
	StackKey        = "stack"
)

// TraceIDFunc 定義獲取跟蹤ID的函式
type TraceIDFunc func() string

var (
	version     string
	traceIDFunc TraceIDFunc
	pid         = os.Getpid()
)

func init() {
	traceIDFunc = func() string {
		return fmt.Sprintf("trace-id-%d-%s",
			os.Getpid(),
			time.Now().Format("2006.01.02.15.04.05.999999"))
	}
}

// Logger 定義日誌別名
type Logger = logrus.Logger

// Hook 定義日誌鉤子別名
type Hook = logrus.Hook

// StandardLogger 獲取標準日誌
func StandardLogger() *Logger {
	return logrus.StandardLogger()
}

// SetLevel設定日誌級別
func SetLevel(level int) {
	logrus.SetLevel(logrus.Level(level))
}

// SetFormatter 設定日誌輸出格式
func SetFormatter(format string) {
	switch format {
	case "json":
		logrus.SetFormatter(new(logrus.JSONFormatter))
	default:
		logrus.SetFormatter(new(logrus.TextFormatter))
	}
}

// SetOutput 設定日誌輸出
func SetOutput(out io.Writer) {
	logrus.SetOutput(out)
}

// SetVersion 設定版本
func SetVersion(v string) {
	version = v
}

// SetTraceIDFunc 設定追蹤ID的處理函式
func SetTraceIDFunc(fn TraceIDFunc) {
	traceIDFunc = fn
}

// AddHook 增加日誌鉤子
func AddHook(hook Hook) {
	logrus.AddHook(hook)
}

type (
	traceIDKey struct{}
	userIDKey  struct{}
)

// NewTraceIDContext 建立跟蹤ID上下文
func NewTraceIDContext(ctx context.Context, traceID string) context.Context {
	return context.WithValue(ctx, traceIDKey{}, traceID)
}

// FromTraceIDContext 從上下文中獲取跟蹤ID
func FromTraceIDContext(ctx context.Context) string {
	v := ctx.Value(traceIDKey{})
	if v != nil {
		if s, ok := v.(string); ok {
			return s
		}
	}
	return traceIDFunc()
}

// NewUserIDContext 建立使用者ID上下文
func NewUserIDContext(ctx context.Context, userID string) context.Context {
	return context.WithValue(ctx, userIDKey{}, userID)
}

// FromUserIDContext 從上下文中獲取使用者ID
func FromUserIDContext(ctx context.Context) string {
	v := ctx.Value(userIDKey{})
	if v != nil {
		if s, ok := v.(string); ok {
			return s
		}
	}
	return ""
}

type spanOptions struct {
	Title    string
	FuncName string
}

// SpanOption 定義跟蹤單元的資料項
type SpanOption func(*spanOptions)

// SetSpanTitle 設定跟蹤單元的標題
func SetSpanTitle(title string) SpanOption {
	return func(o *spanOptions) {
		o.Title = title
	}
}

// SetSpanFuncName 設定跟蹤單元的函式名
func SetSpanFuncName(funcName string) SpanOption {
	return func(o *spanOptions) {
		o.FuncName = funcName
	}
}

// StartSpan 開始一個追蹤單元
func StartSpan(ctx context.Context, opts ...SpanOption) *Entry {
	if ctx == nil {
		ctx = context.Background()
	}
	var o spanOptions
	for _, opt := range opts {
		opt(&o)
	}
	fields := map[string]interface{}{
		VersionKey: version,
	}
	if v := FromTraceIDContext(ctx); v != "" {
		fields[TraceIDKey] = v
	}
	if v := FromUserIDContext(ctx); v != "" {
		fields[UserIDKey] = v
	}
	if v := o.Title; v != "" {
		fields[SpanTitleKey] = v
	}
	if v := o.FuncName; v != "" {
		fields[SpanFunctionKey] = v
	}

	return newEntry(logrus.WithFields(fields))

}

// Debugf 寫入除錯日誌
func Debugf(ctx context.Context, format string, args ...interface{}) {
	StartSpan(ctx).Debugf(format, args...)
}

// Infof 寫入訊息日誌
func Infof(ctx context.Context, format string, args ...interface{}) {
	StartSpan(ctx).Infof(format, args...)
}

// Printf 寫入訊息日誌
func Printf(ctx context.Context, format string, args ...interface{}) {
	StartSpan(ctx).Printf(format, args...)
}

// Warnf 寫入警告日誌
func Warnf(ctx context.Context, format string, args ...interface{}) {
	StartSpan(ctx).Warnf(format, args...)
}

// Errorf 寫入錯誤日誌
func Errorf(ctx context.Context, format string, args ...interface{}) {
	StartSpan(ctx).Errorf(format, args...)
}

// Fatalf 寫入重大錯誤日誌
func Fatalf(ctx context.Context, format string, args ...interface{}) {
	StartSpan(ctx).Fatalf(format, args...)
}

// ErrorStack 輸出錯誤棧
func ErrorStack(ctx context.Context, err error) {
	StartSpan(ctx).WithField(StackKey, fmt.Sprintf("%+v", err)).Errorf(err.Error())
}

// Entry 定義統一的日誌寫入方式
type Entry struct {
	entry *logrus.Entry
}

func newEntry(entry *logrus.Entry) *Entry {
	return &Entry{entry: entry}
}

func (e *Entry) checkAndDelete(fields map[string]interface{}, keys ...string) *Entry {
	for _, key := range keys {
		_, ok := fields[key]
		if ok {
			delete(fields, key)
		}
	}
	return newEntry(e.entry.WithFields(fields))
}

// WithFields 結構化欄位寫入
func (e *Entry) WithFields(fields map[string]interface{}) *Entry {
	e.checkAndDelete(fields,
		TraceIDKey,
		SpanTitleKey,
		SpanFunctionKey,
		VersionKey)
	return newEntry(e.entry.WithFields(fields))
}

// WithField 結構化欄位寫入
func (e *Entry) WithField(key string, value interface{}) *Entry {
	return e.WithFields(map[string]interface{}{key: value})
}

// Fatalf 重大錯誤日誌
func (e *Entry) Fatalf(format string, args ...interface{}) {
	e.entry.Fatalf(format, args...)
}

// Errorf 錯誤日誌
func (e *Entry) Errorf(format string, args ...interface{}) {
	e.entry.Errorf(format, args...)
}

// Warnf 警告日誌
func (e *Entry) Warnf(format string, args ...interface{}) {
	e.entry.Warnf(format, args...)
}

// Infof 訊息日誌
func (e *Entry) Infof(format string, args ...interface{}) {
	e.entry.Infof(format, args...)
}

// Printf 訊息日誌
func (e *Entry) Printf(format string, args ...interface{}) {
	e.entry.Printf(format, args...)
}

// Debugf 寫入除錯日誌
func (e *Entry) Debugf(format string, args ...interface{}) {
	e.entry.Debugf(format, args...)
}

通過Logrus & Context 實現了統一的 TraceID/UserID 等關鍵欄位的輸出,從這裡也可以看出來,作者這樣封裝非常也符合符合pkg目錄的要求,我們可以很容易的在internal/app 目錄中使用封裝好的logger。接著就看一下如何使用,作者在internal/app 目錄下通過logger.go 中的InitLogger進行日誌的初始化,設定了日誌的級別,日誌的格式,以及日誌輸出檔案。這樣我們在internal/app的其他包檔案中只需要匯入pkg下的logger即可以進行日誌的記錄。

專案的優雅退出

在這裡不得不提的就是一個基礎知識Linux訊號signal,推薦看看https://blog.csdn.net/lixiaogang_theanswer/article/details/80301624

linux系統中signum.h中有對所有訊號的巨集定義,這裡注意一下,我使用的是manjaro linux,我的這個檔案路徑是/usr/include/bits/signum.h 不同的linux系統可能略有差別,可以通過find / -name signum.h 查詢確定

#define SIGSTKFLT       16      /* Stack fault (obsolete).  */
#define SIGPWR          30      /* Power failure imminent.  */

#undef  SIGBUS
#define SIGBUS           7
#undef  SIGUSR1
#define SIGUSR1         10
#undef  SIGUSR2
#define SIGUSR2         12
#undef  SIGCHLD
#define SIGCHLD         17
#undef  SIGCONT
#define SIGCONT         18
#undef  SIGSTOP
#define SIGSTOP         19
#undef  SIGTSTP
#define SIGTSTP         20
#undef  SIGURG
#define SIGURG          23
#undef  SIGPOLL
#define SIGPOLL         29
#undef  SIGSYS
#define SIGSYS          31

在linux終端可以使用kill -l 檢視所有的訊號

➜  ~ kill -l
HUP INT QUIT ILL TRAP ABRT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM STKFLT CHLD CONT STOP TSTP TTIN TTOU URG XCPU XFSZ VTALRM PROF WINCH POLL PWR SYS
➜  ~ 

訊號說明:

訊號 起源 預設行為 含義
SIGHUP POSIX Term 控制終端掛起
SIGINT ANSI Term 鍵盤輸入以終端程式(ctrl + C)
SIGQUIT POSIX Core 鍵盤輸入使程式退出(Ctrl + \)
SIGILL ANSI Core 非法指令
SIGTRAP POSIX Core 斷點陷阱,用於除錯
SIGABRT ANSI Core 程式呼叫abort函式時生成該訊號
SIGIOT 4.2BSD Core 和SIGABRT相同
SIGBUS 4.2BSD Core 匯流排錯誤,錯誤記憶體訪問
SIGFPE ANSI Core 浮點異常
SIGKILL POSIX Term 終止一個程式。該訊號不可被捕獲或被忽略
SIGUSR1 POSIX Term 使用者自定義訊號之一
SIGSEGV ANSI Core 非法記憶體段使用
SIGUSR2 POSIX Term 使用者自定義訊號二
SIGPIPE POSIX Term 往讀端關閉的管道或socket連結中寫資料
SIGALRM POSIX Term 由alarm或settimer設定的實時鬧鐘超時引起
SIGTERM ANSI Term 終止程式。kill命令預設發生的訊號就是SIGTERM
SIGSTKFLT Linux Term 早期的Linux使用該訊號來報告數學協處理器棧錯誤
SIGCLD System V Ign 和SIGCHLD相同
SIGCHLD POSIX Ign 子程式狀態發生變化(退出或暫停)
SIGCONT POSIX Cont 啟動被暫停的程式(Ctrl+Q)。如果目標程式未處於暫停狀態,則訊號被忽略
SIGSTOP POSIX Stop 暫停程式(Ctrl+S)。該訊號不可被捕捉或被忽略
SIGTSTP POSIX Stop 掛起程式(Ctrl+Z)
SIGTTIN POSIX Stop 後臺程式試圖從終端讀取輸入
SIGTTOU POSIX Stop 後臺程式試圖往終端輸出內容
SIGURG 4.3 BSD Ign socket連線上接收到緊急資料
SIGXCPU 4.2 BSD Core 程式的CPU使用時間超過其軟限制
SIGXFSZ 4.2 BSD Core 檔案尺寸超過其軟限制
SIGVTALRM 4.2 BSD Termhttps://github.com/LyricTian/gin-admin 與SIGALRM類似,不過它只統計本程式使用者空間程式碼的執行時間
SIGPROF 4.2 BSD Term 與SIGALRM 類似,它同時統計使用者程式碼和核心的執行時間
SIGWINCH 4.3 BSD Ign 終端視窗大小發生變化
SIGPOLL System V Term 與SIGIO類似
SIGIO 4.2 BSD Term IO就緒,比如socket上發生可讀、可寫事件。因為TCP伺服器可觸發SIGIO的條件很多,故而SIGIO無法在TCP伺服器中用。SIGIO訊號可用在UDP伺服器中,但也很少見
SIGPWR System V Thttps://github.com/LyricTian/gin-adminerm 對於UPS的系統,當電池電量過低時,SIGPWR訊號被觸發
SIGSYS POSIX Core 非法系統呼叫
SIGUNUSED Core 保留,通常和SIGSYS效果相同

作者中程式碼是這樣寫的:

func Run(ctx context.Context, opts ...Option) error {
	var state int32 = 1
	sc := make(chan os.Signal, 1)
	signal.Notify(sc, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
	cleanFunc, err := Init(ctx, opts...)
	if err != nil {
		return err
	}

EXIT:
	for {
		sig := <-sc
		logger.Printf(ctx, "接收到訊號[%s]", sig.String())
		switch sig {
		case syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT:
			atomic.CompareAndSwapInt32(&state, 1, 0)
			break EXIT
		case syscall.SIGHUP:
		default:
			break EXIT
		}
	}
    // 進行一些清理操作
	cleanFunc()
	logger.Printf(ctx, "服務退出")
	time.Sleep(time.Second)
	os.Exit(int(atomic.LoadInt32(&state)))
	return nil
}

這裡監聽了syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT,當收到這些訊號的,則會進行系統的退出,同時在程式退出之前進行一些清理操作。

這裡我們有一個知識點需要回顧一下:golang中的break label 和 goto label

  • break label,break的跳轉標籤(label)必須放在迴圈語句for前面,並且在break label跳出迴圈不再執行for迴圈裡的程式碼。break標籤只能用於for迴圈
  • goto label的label(標籤)既可以定義在for迴圈前面,也可以定義在for迴圈後面,當跳轉到標籤地方時,繼續執行標籤下面的程式碼。

Golang的選項模式

其實在很多的開源專案中都可以看到golang 選項模式的使用,推薦看看https://www.sohamkamani.com/golang/options-pattern/

假如我們現在想要建造一個房子,我們思考,建造房子需要木材,水泥......,假如我們現在就想到這兩個,我們用程式碼實現:

type House struct {
	wood   string // 木材
	cement string // 水泥
}

// 造一個房子
func NewHouse(wood, cement shttps://github.com/LyricTian/gin-admintring) *House {
	house := &House{
		wood:   wood,
		cement: cement,
	}
	return house
}

上面這種方式,應該是我們經常可以看到的實現方式,但是這樣實現有一個很不好的地方,就是我們突然發現我們建造房子海需要鋼筋,這個時候我們就必須更改NewHouse 函式的引數,並且NewHouse的引數還有順序依賴。

對於擴充套件性來說,上面的這種實現放那格式其實不是非常好,而golang的選項模式很好的解決了這個問題。

栗子

type HouseOption func(*House)

type House struct {
	Wood   string // 木材
	Cement string // 水泥
}

func WithWood(wood string) HouseOption {
	return func(house *House) {
		house.Wood = wood
	}
}

func WithCement(Cement string) HouseOption {
	return func(house *House) {
		house.Cement = Cement
	}
}

// 造一個新房子
func NewHouse(opts ...HouseOption) *House {
	h := &House{}
	for _, opt := range opts {
		opt(h)
	}
	return h
}

這樣當我們這個時候發現,我建造房子還需要石頭,只需要在House結構體中增加對應的欄位,同時增加一個

WithStone 函式即可,同時我們我們呼叫NewHouse的地方也不會有順序依賴,只需要增加一個引數即可,更改之後的程式碼如下:

type HouseOption func(*House)

type House struct {
	Wood   string // 木材
	Cement string // 水泥
	Stone  string // 石頭
}

func WithWood(wood string) HouseOption {
	return func(house *House) {
		house.Wood = wood
	}
}

func WithCement(Cement string) HouseOption {
	return func(house *House) {
		house.Cement = Cement
	}
}

func WithStone(stone string) HouseOption {
	return func(house *House) {
		house.Stone = stone
	}
}

// 造一個新房子
func NewHouse(opts ...HouseOption) *House {
	h := &House{}
	for _, opt := range opts {
		opt(h)
	}
	return h
}

func main() {
	house := NewHouse(
		WithCement("上好的水泥"),
		WithWood("上好的木材"),
		WithStone("上好的石頭"),
	)
	fmt.Println(house)https://github.com/LyricTian/gin-admin
}

選項模式小結

  • 生產中正式的並且較大的專案使用選項模式可以方便後續的擴充套件
  • 增加了程式碼量,但讓引數的傳遞更新清晰明瞭
  • 在引數確實比較複雜的場景推薦使用選項模式

總結

從https://github.com/LyricTian/gin-admin 作者的這個專案中我們首先從大的方面學習了:

  1. golang 專案的目錄規範
  2. github.com/koding/multiconfig 配置檔案庫
  3. logrus 日誌庫的使用
  4. 專案的優雅退出實現,訊號相關知識
  5. golang的選項模式

對於我自己來說,對於之後寫golang專案,通過上面這些知識點,可以很快速的取構建一個專案的基礎部分,也希望看到這篇文章能夠幫到你,如果有寫的不對的地方,也歡迎評論指出。

延伸閱讀

相關文章