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

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

第一個自己學習的源碼是: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項目,通過上面這些知識點,可以很快速的取構建一個項目的基礎部分,也希望看到這篇文章能夠幫到你,如果有寫的不對的地方,也歡迎評論指出。

延伸閱讀

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章