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