自己最近在思考一個問題,如何讓自己的程式碼質量逐漸提高,於是想到整理這個系列,通過閱讀別人的程式碼,從別人的程式碼中學習,來逐漸提高自己的程式碼質量。本篇是這個系列的第一篇,我也不知道自己會寫多少篇,但是希望自己能堅持下去。
第一個自己學習的原始碼是:https://github.com/LyricTian/gin-admin
自己整理的程式碼地址:https://github.com/peanut-pg/gin_admin 這篇文章整理的時候只是為了跑起來整體的程式碼,對作者的程式碼進行精簡。
這篇部落格主要是閱讀gin-admin的第一篇,整理了從程式碼專案目錄到日誌庫使用中學習到的內容:
-
專案目錄規範
-
配置檔案的載入
-
github.com/sirupsen/logrus
日誌庫在專案的使用 -
專案的優雅退出
-
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 作者的這個專案中我們首先從大的方面學習了:
- golang 專案的目錄規範
- github.com/koding/multiconfig 配置檔案庫
- logrus 日誌庫的使用
- 專案的優雅退出實現,訊號相關知識
- golang的選項模式
對於我自己來說,對於之後寫golang專案,通過上面這些知識點,可以很快速的取構建一個專案的基礎部分,也希望看到這篇文章能夠幫到你,如果有寫的不對的地方,也歡迎評論指出。
延伸閱讀
- https://github.com/LyricTian/gin-admin
- https://github.com/golang-standards/project-layout
- https://sfxpt.wordpress.com/2015/06/19/beyond-toml-the-gos-de-facto-config-file/
- https://github.com/koding/multiconfig
- https://github.com/sirupsen/logrus
- https://blog.csdn.net/lixiaogang_theanswer/article/details/80301624
- https://www.sohamkamani.com/golang/options-pattern/