[TOC]html
做爲一個C/C++的開發者而言,開啓Golang語言開發之路是很容易的,從語法、語義上的理解到工程開發,都可以快速熟悉起來;相比C、C++,Golang語言更簡潔,更容易寫出高併發的服務後臺系統java
轉戰Golang一年有餘,經歷了兩個線上項目的洗禮,總結出一些工程經驗,一個是總結出一些實戰經驗,一個是用來發現自我不足之處mysql
Go語言是谷歌推出的一種全新的編程語言,能夠在不損失應用程序性能的狀況降低低代碼的複雜性。Go語言專門針對多處理器系統應用程序的編程進行了優化,使用Go編譯的程序能夠媲美C或C++代碼的速度,並且更加安全、支持並行進程。linux
我基於Golang的兩個實際線上項目都是IM系統,本文基於現有線上系統作一些總結性、引導性的經驗輸出。nginx
既然是IM系統,那麼必然須要TCP長鏈接來維持,因爲Golang自己的基礎庫和外部依賴庫很是之多,咱們能夠簡單引用基礎net網絡庫,來創建TCP server。通常的TCP Server端的模型,能夠有一個協程【或者線程】去獨立執行accept,而且是for循環一直accept新的鏈接,若是有新鏈接過來,那麼創建鏈接而且執行Connect,因爲Golang裏面協程的開銷很是之小,所以,TCP server端還能夠一個鏈接一個goroutine去循環讀取各自鏈接鏈路上的數據並處理。固然, 這個在C++語言的TCP Server模型中,通常會經過EPoll模型來創建server端,這個是和C++的區別之處。git
關於讀取數據,Linux系統有recv和send函數來讀取發送數據,在Golang中,自帶有io庫,裏面封裝了各類讀寫方法,如io.ReadFull,它會讀取指定字節長度的數據github
爲了維護鏈接和用戶,而且一個鏈接一個用戶的一一對應的,須要根據鏈接可以找到用戶,同時也須要可以根據用戶找到對應的鏈接,那麼就須要設計一個很好結構來維護。咱們最初採用map來管理,可是發現Map裏面的數據太大,查找的性能不高,爲此,優化了數據結構,conn裏面包含user,user裏面包含conn,結構以下【只包括重要字段】。golang
// 一個用戶對應一個鏈接
type User struct {
uid int64
conn *MsgConn
BKicked bool // 被另外登錄的一方踢下線
BHeartBeatTimeout bool // 心跳超時
。。。
}
type MsgConn struct {
conn net.Conn
lastTick time.Time // 上次接收到包時間
remoteAddr string // 爲每一個鏈接建立一個惟一標識符
user *User // MsgConn與User一一映射
。。。
}
複製代碼
創建TCP server 代碼片斷以下正則表達式
func ListenAndServe(network, address string) {
tcpAddr, err := net.ResolveTCPAddr(network, address)
if err != nil {
logger.Fatalf(nil, "ResolveTcpAddr err:%v", err)
}
listener, err = net.ListenTCP(network, tcpAddr)
if err != nil {
logger.Fatalf(nil, "ListenTCP err:%v", err)
}
go accept()
}
func accept() {
for {
conn, err := listener.AcceptTCP()
if err == nil {
// 包計數,用來限制頻率
//anti-attack, 黑白名單
...
// 新建一個鏈接
imconn := NewMsgConn(conn)
// run
imconn.Run()
}
}
}
func (conn *MsgConn) Run() {
//on connect
conn.onConnect()
go func() {
tickerRecv := time.NewTicker(time.Second * time.Duration(rateStatInterval))
for {
select {
case <-conn.stopChan:
tickerRecv.Stop()
return
case <-tickerRecv.C:
conn.packetsRecv = 0
default:
// 在 conn.parseAndHandlePdu 裏面經過Golang自己的io庫裏面提供的方法讀取數據,如io.ReadFull
conn_closed := conn.parseAndHandlePdu()
if conn_closed {
tickerRecv.Stop()
return
}
}
}
}()
}
// 將 user 和 conn 一一對應起來
func (conn *MsgConn) onConnect() *User {
user := &User{conn: conn, durationLevel: 0, startTime: time.Now(), ackWaitMsgIdSet: make(map[int64]struct{})}
conn.user = user
return user
}
複製代碼
TCP Server的一個特色在於一個鏈接一個goroutine去處理,這樣的話,每一個鏈接獨立,不會相互影響阻塞,保證可以及時讀取到client端的數據。若是是C、C++程序,若是一個鏈接一個線程的話,若是上萬個或者十萬個線程,那麼性能會極低甚至於沒法工做,cpu會所有消耗在線程之間的調度上了,所以C、C++程序沒法這樣玩。Golang的話,goroutine能夠幾十萬、幾百萬的在一個系統中良好運行。同時對於TCP長鏈接而言,一個節點上的鏈接數要有限制策略。redis
每一個鏈接須要有心跳來維持,在心跳間隔時間內沒有收到,服務端要檢測超時並斷開鏈接釋放資源,golang能夠很方便的引用須要的數據結構,同時對變量的賦值(包括指針)很是easy
var timeoutMonitorTree *rbtree.Rbtree
var timeoutMonitorTreeMutex sync.Mutex
var heartBeatTimeout time.Duration //心跳超時時間, 配置了默認值ssss
var loginTimeout time.Duration //登錄超時, 配置了默認值ssss
type TimeoutCheckInfo struct {
conn *MsgConn
dueTime time.Time
}
func AddTimeoutCheckInfo(conn *MsgConn) {
timeoutMonitorTreeMutex.Lock()
timeoutMonitorTree.Insert(&TimeoutCheckInfo{conn: conn, dueTime: time.Now().Add(loginTimeout)})
timeoutMonitorTreeMutex.Unlock()
}
如 &TimeoutCheckInfo{},賦值一個指針對象
複製代碼
Golang中,不少基礎數據都經過庫來引用,咱們能夠方便引用咱們所須要的庫,經過import包含就能直接使用,如源碼裏面提供了sync庫,裏面有mutex鎖,在須要鎖的時候能夠包含進來
經常使用的如list,mutex,once,singleton等都已包含在內
list鏈表結構,當咱們須要相似隊列的結構的時候,能夠採用,針對IM系統而言,在長鏈接層處理的消息id的列表,能夠經過list來維護,若是用戶有了迴應則從list裏面移除,不然在超時時間到後尚未迴應,則入offline處理
mutex鎖,當須要併發讀寫某個數據的時候使用,包含互斥鎖和讀寫鎖
var ackWaitListMutex sync.RWMutex
var ackWaitListMutex sync.Mutex
複製代碼
once表示任什麼時候刻都只會調用一次,通常的用法是初始化實例的時候使用,代碼片斷以下
var initRedisOnce sync.Once
func GetRedisCluster(name string) (*redis.Cluster, error) {
initRedisOnce.Do(setupRedis)
if redisClient, inMap := redisClusterMap[name]; inMap {
return redisClient, nil
} else {
}
}
func setupRedis() {
redisClusterMap = make(map[string]*redis.Cluster)
commonsOpts := []redis.Option{
redis.ConnectionTimeout(conf.RedisConnTimeout),
redis.ReadTimeout(conf.RedisReadTimeout),
redis.WriteTimeout(conf.RedisWriteTimeout),
redis.IdleTimeout(conf.RedisIdleTimeout),
redis.MaxActiveConnections(conf.RedisMaxConn),
redis.MaxIdleConnections(conf.RedisMaxIdle),
}),
...
}
}
複製代碼
這樣咱們能夠在任何須要的地方調用GetRedisCluster,而且不用擔憂實例會被初始化屢次,once會保證必定只執行一次
singleton單例模式,這個在C++裏面是一個經常使用的模式,通常須要開發者本身經過類來實現,類的定義決定單例模式設計的好壞;在Golang中,已經有成熟的庫實現了,開發者無須重複造輪子,關於何時該使用單例模式請自行Google。一個簡單的例子以下
import "github.com/dropbox/godropbox/singleton"
var SingleMsgProxyService = singleton.NewSingleton(func() (interface{}, error) {
cluster, _ := cache.GetRedisCluster("singlecache")
return &singleMsgProxy{
Cluster: cluster,
MsgModel: msg.MsgModelImpl,
}, nil
})
複製代碼
若是說goroutine和channel是Go併發的兩大基石,那麼接口interface是Go語言編程中數據類型的關鍵。在Go語言的實際編程中,幾乎全部的數據結構都圍繞接口展開,接口是Go語言中全部數據結構的核心。
嚴格來講,在 Golang 中並不支持泛型編程。在 C++ 等高級語言中使用泛型編程很是的簡單,因此泛型編程一直是 Golang 詬病最多的地方。可是使用 interface 咱們能夠實現泛型編程,以下是一個參考示例
package sort
// A type, typically a collection, that satisfies sort.Interface can be
// sorted by the routines in this package. The methods require that the
// elements of the collection be enumerated by an integer index.
type Interface interface {
// Len is the number of elements in the collection.
Len() int
// Less reports whether the element with
// index i should sort before the element with index j.
Less(i, j int) bool
// Swap swaps the elements with indexes i and j.
Swap(i, j int)
}
...
// Sort sorts data.
// It makes one call to data.Len to determine n, and O(n*log(n)) calls to
// data.Less and data.Swap. The sort is not guaranteed to be stable.
func Sort(data Interface) {
// Switch to heapsort if depth of 2*ceil(lg(n+1)) is reached.
n := data.Len()
maxDepth := 0
for i := n; i > 0; i >>= 1 {
maxDepth++
}
maxDepth *= 2
quickSort(data, 0, n, maxDepth)
}
複製代碼
Sort 函數的形參是一個 interface,包含了三個方法:Len(),Less(i,j int),Swap(i, j int)。使用的時候無論數組的元素類型是什麼類型(int, float, string…),只要咱們實現了這三個方法就可使用 Sort 函數,這樣就實現了「泛型編程」。
這種方式,我在項目裏面也有實際應用過,具體案例就是對消息排序。
下面給一個具體示例,代碼可以說明一切,一看就懂:
type Person struct {
Name string
Age int
}
func (p Person) String() string {
return fmt.Sprintf("%s: %d", p.Name, p.Age)
}
// ByAge implements sort.Interface for []Person based on
// the Age field.
type ByAge []Person //自定義
func (a ByAge) Len() int { return len(a) }
func (a ByAge) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }
func main() {
people := []Person{
{"Bob", 31},
{"John", 42},
{"Michael", 17},
{"Jenny", 26},
}
fmt.Println(people)
sort.Sort(ByAge(people))
fmt.Println(people)
}
複製代碼
隱藏具體實現,這個很好理解。好比我設計一個函數給你返回一個 interface,那麼你只能經過 interface 裏面的方法來作一些操做,可是內部的具體實現是徹底不知道的。
例如咱們經常使用的context包,就是這樣的,context 最早由 google 提供,如今已經歸入了標準庫,並且在原有 context 的基礎上增長了:cancelCtx,timerCtx,valueCtx。
若是函數參數是interface或者返回值是interface,這樣就能夠接受任何類型的參數
在一個項目工程中,爲了使得代碼更優雅,須要抽象出一些模型出來,同時基於C++面向對象編程的思想,須要考慮到一些類、繼承相關。在Golang中,沒有類、繼承的概念,可是咱們徹底能夠經過struct和interface來創建咱們想要的任何模型。在咱們的工程中,抽象出一種我自認爲是相似MVC的模型,可是不徹底同樣,我的以爲這個模型抽象的比較好,容易擴展,模塊清晰。對於使用java和PHP編程的同窗對這個模型應該是再熟悉不過了,我這邊經過代碼來講明下這個模型
首先一個model包,經過interface來實現,包含一些基礎方法,須要被外部引用者來具體實現
package model
// 定義一個基礎model
type MsgModel interface {
Persist(context context.Context, msg interface{}) bool
UpdateDbContent(context context.Context, msgIface interface{}) bool
...
}
複製代碼
再定義一個msg包,用來具體實現model包中MsgModel模型的全部方法
package msg
type msgModelImpl struct{}
var MsgModelImpl = msgModelImpl{}
func (m msgModelImpl) Persist(context context.Context, msgIface interface{}) bool {
// 具體實現
}
func (m msgModelImpl) UpdateDbContent(context context.Context, msgIface interface{}) bool {
// 具體實現
}
...
複製代碼
model 和 具體實現方定義並實現ok後,那麼就還須要一個service來統籌管理
package service
// 定義一個msgService struct包含了model裏面的UserModel和MsgModel兩個model
type msgService struct {
msgModel model.MsgModel
}
// 定義一個MsgService的變量,並初始化,這樣經過MsgService,就能引用並訪問model的全部方法
var (
MsgService = msgService{
msgModel: msg.MsgModelImpl,
}
)
複製代碼
調用訪問
import service
service.MsgService.Persist(ctx, xxx)
複製代碼
總結一下,model對應MVC的M,service 對應 MVC的C, 調用訪問的地方對應MVC的V
在MVC模型的基礎下,咱們還須要考慮另一點,就是基礎資源的封裝,服務端操做必然會和mysql、redis、memcache等交互,一些經常使用的底層基礎資源,咱們有必要進行封裝,這是基礎架構部門所須要承擔的,也是一個好的項目工程所須要的
redis,咱們在github.com/garyburd/redigo/redis的庫的基礎上,作了一層封裝,實現了一些更爲貼合工程的機制和接口,redis cluster封裝,支持分片、讀寫分離
// NewCluster creates a client-side cluster for callers. Callers use this structure to interact with Redis database
func NewCluster(config ClusterConfig, instrumentOpts *instrument.Options) *Cluster {
cluster := new(Cluster)
cluster.pool = make([]*client, len(config.Configs))
masters := make([]string, 0, len(config.Configs))
for i, sharding := range config.Configs {
master, slaves := sharding.Master, sharding.Slaves
masters = append(masters, master)
masterAddr, masterDb := parseServer(master)
cli := new(client)
cli.master = &redisNode{
server: master,
Pool: func() *redis.Pool {
pool := &redis.Pool{
MaxIdle: config.MaxIdle,
IdleTimeout: config.IdleTimeout,
Dial: func() (redis.Conn, error) {
c, err := redis.Dial(
"tcp",
masterAddr,
redis.DialDatabase(masterDb),
redis.DialPassword(config.Password),
redis.DialConnectTimeout(config.ConnTimeout),
redis.DialReadTimeout(config.ReadTimeout),
redis.DialWriteTimeout(config.WriteTimeout),
)
if err != nil {
return nil, err
}
return c, err
},
TestOnBorrow: func(c redis.Conn, t time.Time) error {
if time.Since(t) < time.Minute {
return nil
}
_, err := c.Do("PING")
return err
},
MaxActive: config.MaxActives,
}
if instrumentOpts == nil {
return pool
}
return instrument.NewRedisPool(pool, instrumentOpts)
}(),
}
// allow nil slaves
if slaves != nil {
cli.slaves = make([]*redisNode, 0)
for _, slave := range slaves {
addr, db := parseServer(slave)
cli.slaves = append(cli.slaves, &redisNode{
server: slave,
Pool: func() *redis.Pool {
pool := &redis.Pool{
MaxIdle: config.MaxIdle,
IdleTimeout: config.IdleTimeout,
Dial: func() (redis.Conn, error) {
c, err := redis.Dial(
"tcp",
addr,
redis.DialDatabase(db),
redis.DialPassword(config.Password),
redis.DialConnectTimeout(config.ConnTimeout),
redis.DialReadTimeout(config.ReadTimeout),
redis.DialWriteTimeout(config.WriteTimeout),
)
if err != nil {
return nil, err
}
return c, err
},
TestOnBorrow: func(c redis.Conn, t time.Time) error {
if time.Since(t) < time.Minute {
return nil
}
_, err := c.Do("PING")
return err
},
MaxActive: config.MaxActives,
}
if instrumentOpts == nil {
return pool
}
return instrument.NewRedisPool(pool, instrumentOpts)
}(),
})
}
}
// call init
cli.init()
cluster.pool[i] = cli
}
if config.Hashing == sharding.Ketama {
cluster.sharding, _ = sharding.NewKetamaSharding(sharding.GetShardServers(masters), true, 6379)
} else {
cluster.sharding, _ = sharding.NewCompatSharding(sharding.GetShardServers(masters))
}
return cluster
}
複製代碼
總結一下:
memcached客戶端代碼封裝,依賴 github.com/dropbox/godropbox/memcache, 實現其ShardManager接口,支持Connection Timeout,支持Fail Fast和Rehash
實際開發過程當中,常常會有這樣場景,每一個請求經過一個goroutine協程去作,如批量獲取消息,可是,爲了防止後端資源鏈接數太多等,或者防止goroutine太多,每每須要限制併發數。給出以下示例供參考
package main
import (
"fmt"
"sync"
"time"
)
var over = make(chan bool)
const MAXConCurrency = 3
//var sem = make(chan int, 4) //控制併發任務數
var sem = make(chan bool, MAXConCurrency) //控制併發任務數
var maxCount = 6
func Worker(i int) bool {
sem <- true
defer func() {
<-sem
}()
// 模擬出錯處理
if i == 5 {
return false
}
fmt.Printf("now:%v num:%v\n", time.Now().Format("04:05"), i)
time.Sleep(1 * time.Second)
return true
}
func main() {
//wg := &sync.WaitGroup{}
var wg sync.WaitGroup
for i := 1; i <= maxCount; i++ {
wg.Add(1)
fmt.Printf("for num:%v\n", i)
go func(i int) {
defer wg.Done()
for x := 1; x <= 3; x++ {
if Worker(i) {
break
} else {
fmt.Printf("retry :%v\n", x)
}
}
}(i)
}
wg.Wait() //等待全部goroutine退出
}
複製代碼
Golang 的 context很是強大,詳細的能夠參考個人另一篇文章 Golang Context分析
這裏想要說明的是,在項目工程中,咱們常常會用到這樣的一個場景,經過goroutine併發去處理某些批量任務,當某個條件觸發的時候,這些goroutine要可以控制中止執行。若是有這樣的場景,那麼我們就須要用到context的With 系列函數了,context.WithCancel生成了一個withCancel的實例以及一個cancelFuc,這個函數就是用來關閉ctxWithCancel中的 Done channel 函數。
示例代碼片斷以下
func Example(){
// context.WithCancel 用來生成一個新的Context,能夠接受cancel方法用來隨時中止執行
newCtx, cancel := context.WithCancel(context.Background())
for peerIdVal, lastId := range lastIdMap {
wg.Add(1)
go func(peerId, minId int64) {
defer wg.Done()
msgInfo := Get(newCtx, uid, peerId, minId, count).([]*pb.MsgInfo)
if msgInfo != nil && len(msgInfo) > 0 {
if singleMsgCounts >= maxCount {
cancel() // 當條件觸發,則調用cancel中止
mutex.Unlock()
return
}
}
mutex.Unlock()
}(peerIdVal, lastId)
}
wg.Wait()
}
func Get(ctx context.Context, uid, peerId, sinceId int64, count int) interface{} {
for {
select {
// 若是收到Done的chan,則立馬return
case <-ctx.Done():
msgs := make([]*pb.MsgInfo, 0)
return msgs
default:
// 處理邏輯
}
}
}
複製代碼
在大型項目工程中,爲了更好的排查定位問題,咱們須要有必定的技巧,Context上下文存在於一整條調用鏈路中,在服務端併發場景下,n多個請求裏面,咱們如何可以快速準確的找到一條請求的前因後果,專業用語就是指調用鏈路,經過調用鏈咱們可以知道這條請求通過了哪些服務、哪些模塊、哪些方法,這樣能夠很是方便咱們定位問題
traceid就是咱們抽象出來的這樣一個調用鏈的惟一標識,再經過Context進行傳遞,在任何代碼模塊[函數、方法]裏面都包含Context參數,咱們就能造成一個完整的調用鏈。那麼如何實現呢 ?在咱們的工程中,有RPC模塊,有HTTP模塊,兩個模塊的請求來源確定不同,所以,要實現全部服務和模塊的完整調用鏈,須要考慮http和rpc兩個不一樣的網絡請求的調用鏈
const TraceKey = "traceId"
func NewTraceId(tag string) string {
now := time.Now()
return fmt.Sprintf("%d.%d.%s", now.Unix(), now.Nanosecond(), tag)
}
func GetTraceId(ctx context.Context) string {
if ctx == nil {
return ""
}
// 從Context裏面取
traceInfo := GetTraceIdFromContext(ctx)
if traceInfo == "" {
traceInfo = GetTraceIdFromGRPCMeta(ctx)
}
return traceInfo
}
func GetTraceIdFromGRPCMeta(ctx context.Context) string {
if ctx == nil {
return ""
}
if md, ok := metadata.FromIncomingContext(ctx); ok {
if traceHeader, inMap := md[meta.TraceIdKey]; inMap {
return traceHeader[0]
}
}
if md, ok := metadata.FromOutgoingContext(ctx); ok {
if traceHeader, inMap := md[meta.TraceIdKey]; inMap {
return traceHeader[0]
}
}
return ""
}
func GetTraceIdFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
traceId, ok := ctx.Value(TraceKey).(string)
if !ok {
return ""
}
return traceId
}
func SetTraceIdToContext(ctx context.Context, traceId string) context.Context {
return context.WithValue(ctx, TraceKey, traceId)
}
複製代碼
對於http的服務,請求方多是客戶端,也能是其餘服務端,http的入口裏面就須要增長上traceid,而後打印日誌的時候,將TraceID打印出來造成完整鏈路。若是http server採用gin來實現的話,代碼片斷以下,其餘http server的庫的實現方式相似便可
import "github.com/gin-gonic/gin"
func recoveryLoggerFunc() gin.HandlerFunc {
return func(c *gin.Context) {
c.Set(trace.TraceKey, trace.NewTraceId(c.ClientIP()))
defer func() {
...... func 省略實現
}
}()
c.Next()
}
}
engine := gin.New()
engine.Use(OpenTracingFunc(), httpInstrumentFunc(), recoveryLoggerFunc())
session := engine.Group("/sessions")
session.Use(sdkChecker)
{
session.POST("/recent", httpsrv.MakeHandler(RecentSessions))
}
這樣,在RecentSessions接口裏面若是打印日誌,就可以經過Context取到traceid
複製代碼
access log是針對http的請求來的,記錄http請求的API,響應時間,ip,響應碼,用來記錄並能夠統計服務的響應狀況,固然,也有其餘輔助系統如SLA來專門記錄http的響應狀況
Golang語言實現這個也很是簡單,並且這個是個通用功能,建議能夠抽象爲一個基礎模塊,全部業務都能import後使用
大體格式以下:
http_log_pattern='%{2006-01-02T15:04:05.999-0700}t %a - %{Host}i "%r" %s - %T "%{X-Real-IP}i" "%{X-Forwarded-For}i" %{Content-Length}i - %{Content-Length}o %b %{CDN}i'
"%a", "${RemoteIP}",
"%b", "${BytesSent|-}",
"%B", "${BytesSent|0}",
"%H", "${Proto}",
"%m", "${Method}",
"%q", "${QueryString}",
"%r", "${Method} ${RequestURI} ${Proto}",
"%s", "${StatusCode}",
"%t", "${ReceivedAt|02/Jan/2006:15:04:05 -0700}",
"%U", "${URLPath}",
"%D", "${Latency|ms}",
"%T", "${Latency|s}",
具體實現省略
複製代碼
最終獲得的日誌以下:
2017-12-20T20:32:58.787+0800 192.168.199.15 - www.demo.com:50001 "POST /arcp/unregister HTTP/1.1" 200 - 0.035 "-" "-" 14 - - 13 -
2017-12-20T20:33:27.741+0800 192.168.199.15 - www.demo.com:50001 "POST /arcp/register HTTP/1.1" 200 - 0.104 "-" "-" 68 - - 13 -
2017-12-20T20:42:01.803+0800 192.168.199.15 - www.demo.com:50001 "POST /arcp/unregister HTTP/1.1" 200 - 0.035 "-" "-" 14 - - 13 -
複製代碼
線上服務端系統,必需要有降級機制,也最好可以有開關機制。降級機制在於出現異常狀況可以捨棄某部分服務保證其餘主線服務正常;開關也有着一樣的功效,在某些狀況下打開開關,則可以執行某些功能或者說某套功能,關閉開關則執行另一套功能或者不執行某個功能。
這不是Golang的語言特性,可是是工程項目裏面必要的,在Golang項目中的具體實現代碼片斷以下:
package switches
var (
xxxSwitchManager = SwitchManager{switches: make(map[string]*Switch)}
AsyncProcedure = &Switch{Name: "xxx.msg.procedure.async", On: true}
// 使能音視頻
EnableRealTimeVideo = &Switch{Name: "xxx.real.time.video", On: true}
)
func init() {
xxxSwitchManager.Register(AsyncProcedure,
EnableRealTimeVideo)
}
// 具體實現結構和實現方法
type Switch struct {
Name string
On bool
listeners []ChangeListener
}
func (s *Switch) TurnOn() {
s.On = true
s.notifyListeners()
}
func (s *Switch) notifyListeners() {
if len(s.listeners) > 0 {
for _, l := range s.listeners {
l.OnChange(s.Name, s.On)
}
}
}
func (s *Switch) TurnOff() {
s.On = false
s.notifyListeners()
}
func (s *Switch) IsOn() bool {
return s.On
}
func (s *Switch) IsOff() bool {
return !s.On
}
func (s *Switch) AddChangeListener(l ChangeListener) {
if l == nil {
return
}
s.listeners = append(s.listeners, l)
}
type SwitchManager struct {
switches map[string]*Switch
}
func (m SwitchManager) Register(switches ...*Switch) {
for _, s := range switches {
m.switches[s.Name] = s
}
}
func (m SwitchManager) Unregister(name string) {
delete(m.switches, name)
}
func (m SwitchManager) TurnOn(name string) (bool, error) {
if s, ok := m.switches[name]; ok {
s.TurnOn()
return true, nil
} else {
return false, errors.New("switch " + name + " is not registered")
}
}
func (m SwitchManager) TurnOff(name string) (bool, error) {
if s, ok := m.switches[name]; ok {
s.TurnOff()
return true, nil
} else {
return false, errors.New("switch " + name + " is not registered")
}
}
func (m SwitchManager) IsOn(name string) (bool, error) {
if s, ok := m.switches[name]; ok {
return s.IsOn(), nil
} else {
return false, errors.New("switch " + name + " is not registered")
}
}
func (m SwitchManager) List() map[string]bool {
switches := make(map[string]bool)
for name, switcher := range m.switches {
switches[name] = switcher.On
}
return switches
}
type ChangeListener interface {
OnChange(name string, isOn bool)
}
// 這裏開始調用
if switches.AsyncProcedure.IsOn() {
// do sth
}else{
// do other sth
}
複製代碼
prometheus + grafana 是業界經常使用的監控方案,prometheus進行數據採集,grafana進行圖表展現。
Golang裏面prometheus進行數據採集很是簡單,有對應client庫,應用程序只需暴露出http接口便可,這樣,prometheus server端就能夠按期採集數據,而且還能夠根據這個接口來監控服務端是否異常【如掛掉的狀況】。
import "github.com/prometheus/client_golang/prometheus"
engine.GET("/metrics", gin.WrapH(prometheus.Handler()))
複製代碼
這樣就實現了數據採集,可是具體採集什麼樣的數據,數據從哪裏生成的,還須要進入下一步:
package prometheus
import "github.com/prometheus/client_golang/prometheus"
var DefaultBuckets = []float64{10, 50, 100, 200, 500, 1000, 3000}
var MySQLHistogramVec = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "allen.wu",
Subsystem: "xxx",
Name: "mysql_op_milliseconds",
Help: "The mysql database operation duration in milliseconds",
Buckets: DefaultBuckets,
},
[]string{"db"},
)
var RedisHistogramVec = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "allen.wu",
Subsystem: "xxx",
Name: "redis_op_milliseconds",
Help: "The redis operation duration in milliseconds",
Buckets: DefaultBuckets,
},
[]string{"redis"},
)
func init() {
prometheus.MustRegister(MySQLHistogramVec)
prometheus.MustRegister(RedisHistogramVec)
...
}
// 使用,在對應的位置調用prometheus接口生成數據
instanceOpts := []redis.Option{
redis.Shards(shards...),
redis.Password(viper.GetString(conf.RedisPrefix + name + ".password")),
redis.ClusterName(name),
redis.LatencyObserver(func(name string, latency time.Duration) {
prometheus.RedisHistogramVec.WithLabelValues(name).Observe(float64(latency.Nanoseconds()) * 1e-6)
}),
}
複製代碼
捕獲異常是否有存在的必要,根據各自不一樣的項目自行決定,可是通常出現panic,若是沒有異常,那麼服務就會直接掛掉,若是可以捕獲異常,那麼出現panic的時候,服務不會掛掉,只是當前致使panic的某個功能,沒法正常使用,我的建議仍是在某些有必要的條件和入口處進行異常捕獲。
常見拋出異常的狀況:數組越界、空指針空對象,類型斷言失敗等;Golang裏面捕獲異常經過 defer + recover來實現
C++有try。。。catch來進行代碼片斷的異常捕獲,Golang裏面有recover來進行異常捕獲,這個是Golang語言的基本功,是一個比較簡單的功能,很少說,看代碼
func consumeSingle(kafkaMsg *sarama.ConsumerMessage) {
var err error
defer func() {
if r := recover(); r != nil {
if e, ok := r.(error); ok {
// 異常捕獲的處理
}
}
}()
}
複製代碼
在請求來源入口處的函數或者某個方法裏面實現這麼一段代碼進行捕獲,這樣,只要經過這個入口出現的異常都能被捕獲,並打印詳細日誌
error錯誤,能夠自定義返回,通常工程應用中的作法,會在方法的返回值上增長一個error返回值,Golang容許每一個函數返回多個返回值,增長一個error的做用在於,獲取函數返回值的時候,根據error參數進行判斷,若是是nil表示沒有錯誤,正常處理,不然處理錯誤邏輯。這樣減小代碼出現異常狀況
若是某些狀況下,沒有捕獲異常,程序在運行過程當中出現panic,通常都會有一些堆棧信息,咱們如何根據這些堆棧信息快速定位並解決呢 ?
通常信息裏面都會代表是哪一種相似的panic,如是空指針異常仍是數組越界,仍是xxx;
而後會打印一堆信息出來包括出現異常的代碼調用塊及其文件位置,須要定位到最後的位置而後反推上去
分析示例以下
{"date":"2017-11-22 19:33:20.921","pid":17,"level":"ERROR","file":"recovery.go","line":16,"func":"1","msg":"panic in /Message.MessageService/Proces s: runtime error: invalid memory address or nil pointer dereference github.com.xxx/demo/biz/vendor/github.com.xxx/demo/commons/interceptor.newUnaryServerRecoveryInterceptor.func1.1 /www/jenkins_home/.jenkins/jobs/demo/jobs/demo--biz/workspace/src/github.com.xxx/demo/biz/vendor/github.com.xxx/demo/commons/ interceptor/recovery.go:17 runtime.call64 /www/jenkins_home/.jenkins/tools/org.jenkinsci.plugins.golang.GolangInstallation/go1.9/go/src/runtime/asm_amd64.s:510 runtime.gopanic /www/jenkins_home/.jenkins/tools/org.jenkinsci.plugins.golang.GolangInstallation/go1.9/go/src/runtime/panic.go:491 runtime.panicmem /www/jenkins_home/.jenkins/tools/org.jenkinsci.plugins.golang.GolangInstallation/go1.9/go/src/runtime/panic.go:63 runtime.sigpanic /www/jenkins_home/.jenkins/tools/org.jenkinsci.plugins.golang.GolangInstallation/go1.9/go/src/runtime/signal_unix.go:367 github.com.xxx/demo/biz/vendor/github.com.xxx/demo/mtrace-middleware-go/grpc.OpenTracingClientInterceptor.func1 /www/jenkins_home/.jenkins/jobs/demo/jobs/demo--biz/workspace/src/github.com.xxx/demo/biz/vendor/github.com.xxx/demo/m trace-middleware-go/grpc/client.go:49 github.com.xxx/demo/biz/vendor/github.com/grpc-ecosystem/go-grpc-middleware.ChainUnaryClient.func2.1.1 /www/jenkins_home/.jenkins/jobs/demo/jobs/demo--biz/workspace/src/github.com.xxx/demo/biz/vendor/github.com/grpc-ecosystem/go-gr pc-middleware/chain.go:90 github.com.xxx/demo/biz/vendor/github.com/grpc-ecosystem/go-grpc-middleware/retry.UnaryClientInterceptor.func1 複製代碼
問題分析
經過報錯的堆棧信息,能夠看到具體錯誤是「runtime error: invalid memory address or nil pointer dereference」,也就是空指針異常,而後逐步定位日誌,能夠發現最終致使出現異常的函數在這個,以下:
github.com.xxx/demo/biz/vendor/github.com.xxx/demo/mtrace-middleware-go/grpc.OpenTracingClientInterceptor.func1
/www/jenkins_home/.jenkins/jobs/demo/jobs/demo--biz/workspace/src/github.com.xxx/demo/biz/vendor/github.com.xxx/demo/m
trace-middleware-go/grpc/client.go:49
複製代碼
通常panic,都會有上述錯誤日誌,而後經過日誌,能夠追蹤到具體函數,而後看到OpenTracingClientInterceptor後,是在client.go的49行,而後開始反推,經過代碼能夠看到,多是trace指針爲空。而後一步一步看是從哪裏開始調用的
最終發現代碼以下:
ucConn, err := grpcclient.NewClientConn(conf.Discovery.UserCenter, newBalancer, time.Second*3, conf.Tracer)
if err != nil {
logger.Fatalf(nil, "init user center client connection failed: %v", err)
return
}
UserCenterClient = pb.NewUserCenterServiceClient(ucConn)
複製代碼
那麼開始排查,conf.Tracer是否是可能爲空,在哪裏初始化,初始化有沒有錯,而後發現這個函數是在init裏面,而後conf.Tracer確實在main函數裏面顯示調用的,main函數裏面會引用或者間接引用全部包,那麼init就必定在main以前執行。
這樣的話,init執行的時候,conf.Tracer尚未被賦值,所以就是nil,就會致使panic了
項目中若是可以有一些調試debug接口,有一些pprof性能分析接口,有探測、健康檢查接口的話,會給整個項目在線上穩定運行帶來很大的做用。 除了pprof性能分析接口屬於Golang特有,其餘的接口在任何語言都有,這裏只是代表在一個工程中,須要有這類型的接口
咱們的工程是經過etcd進行服務發現和註冊的,同時還提供http服務,那麼就須要有個機制來上下線,這樣上線過程當中,若是服務自己尚未所有啓動完成準備就緒,那麼就暫時不要在etcd裏面註冊,不要上線,以避免有請求過來,等到就緒後再註冊;下線過程當中,先從etcd裏面移除,這樣流量再也不導入過來,而後再等待一段時間用來處理還未完成的任務
咱們的作法是,start 和 stop 服務的時候,調用API接口,而後再在服務的API接口裏面註冊和反註冊到etcd
var OnlineHook = func() error {
return nil
}
var OfflineHook = func() error {
return nil
}
// 初始化兩個函數,註冊和反註冊到etcd的函數
api.OnlineHook = func() error {
return registry.Register(conf.Discovery.RegisterAddress)
}
api.OfflineHook = func() error {
return registry.Deregister()
}
// 設置在線的函數裏面分別調用上述兩個函數,用來上下線
func SetOnline(isOnline bool) (err error) {
if conf.Discovery.RegisterEnabled {
if !isServerOnline && isOnline {
err = OnlineHook()
} else if isServerOnline && !isOnline {
err = OfflineHook()
}
}
if err != nil {
return
}
isServerOnline = isOnline
return
}
SetOnline 爲Http API接口調用的函數
複製代碼
對於http的服務,通常訪問都經過域名訪問,nginx配置代理,這樣保證服務能夠隨意擴縮容,可是nginx既然配置了代碼,後端節點的狀況,就必需要可以有接口能夠探測,這樣才能保證流量導入到的節點必定的在健康運行中的節點;爲此,服務必需要提供健康檢測的接口,這樣才能方便nginx代理可以實時更新節點。
這個接口如何實現?nginx代理通常經過http code來處理,若是返回code=200,認爲節點正常,若是是非200,認爲節點異常,若是連續採樣屢次都返回異常,那麼nginx將節點下掉
如提供一個/devops/status 的接口,用來檢測,接口對應的具體實現爲:
func CheckHealth(c *gin.Context) {
// 首先狀態碼設置爲非200,如503
httpStatus := http.StatusServiceUnavailable
// 若是當前服務正常,並服務沒有下線,則更新code
if isServerOnline {
httpStatus = http.StatusOK
}
// 不然返回code爲503
c.IndentedJSON(httpStatus, gin.H{
onlineParameter: isServerOnline,
})
}
複製代碼
// PProf
profGroup := debugGroup.Group("/pprof")
profGroup.GET("/", func(c *gin.Context) {
pprof.Index(c.Writer, c.Request)
})
profGroup.GET("/goroutine", gin.WrapH(pprof.Handler("goroutine")))
profGroup.GET("/block", gin.WrapH(pprof.Handler("block")))
profGroup.GET("/heap", gin.WrapH(pprof.Handler("heap")))
profGroup.GET("/threadcreate", gin.WrapH(pprof.Handler("threadcreate")))
profGroup.GET("/cmdline", func(c *gin.Context) {
pprof.Cmdline(c.Writer, c.Request)
})
profGroup.GET("/profile", func(c *gin.Context) {
pprof.Profile(c.Writer, c.Request)
})
profGroup.GET("/symbol", func(c *gin.Context) {
pprof.Symbol(c.Writer, c.Request)
})
profGroup.GET("/trace", func(c *gin.Context) {
pprof.Trace(c.Writer, c.Request)
})
複製代碼
// Debug
debugGroup := engine.Group("/debug")
debugGroup.GET("/requests", func(c *gin.Context) {
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
trace.Render(c.Writer, c.Request, true)
})
debugGroup.GET("/events", func(c *gin.Context) {
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
trace.RenderEvents(c.Writer, c.Request, true)
})
複製代碼
前面有講到過,在代碼裏面須要有開關和降級機制,並講了實現示例,那麼若是須要可以實時改變開關狀態,而且實時生效,咱們就能夠提供一下http的API接口,供運維人員或者開發人員使用。
// Switch
console := engine.Group("/switch")
{
console.GET("/list", httpsrv.MakeHandler(ListSwitches))
console.GET("/status", httpsrv.MakeHandler(CheckSwitchStatus))
console.POST("/turnOn", httpsrv.MakeHandler(TurnSwitchOn))
console.POST("/turnOff", httpsrv.MakeHandler(TurnSwitchOff))
}
複製代碼
單元測試用例是必須,是自測的一個必要手段,Golang裏面單元測試很是簡單,import testing 包,而後執行go test,就可以測試某個模塊代碼
如,在某個user文件夾下有個user包,包文件爲user.go,裏面有個Func UpdateThemesCounts,若是想要進行test,那麼在同級目錄下,創建一個user_test.go的文件,包含testing包,編寫test用例,而後調用go test便可
通常的規範有:
以下:
// user.go
func UpdateThemesCounts(ctx context.Context, themes []int, count int) error {
redisClient := model.GetRedisClusterForTheme(ctx)
key := themeKeyPattern
for _, theme := range themes {
if redisClient == nil {
return errors.New("now redis client")
}
total, err := redisClient.HIncrBy(ctx, key, theme, count)
if err != nil {
logger.Errorf(ctx, "add key:%v for theme:%v count:%v failed:%v", key, theme, count, err)
return err
} else {
logger.Infof(ctx, "now key:%v theme:%v total:%v", key, theme, total)
}
}
return nil
}
//user_test.go
package user
import (
"fmt"
"testing"
"Golang.org/x/net/context"
)
func TestUpdateThemeCount(t *testing.T) {
ctx := context.Background()
theme := 1
count := 123
total, err := UpdateThemeCount(ctx, theme, count)
fmt.Printf("update theme:%v counts:%v err:%v \n", theme, total, err)
}
在此目錄下執行 go test便可出結果
複製代碼
一般,一個包裏面會有多個方法,多個文件,所以也有多個test用例,假如咱們只想測試某一個方法的時候,那麼咱們須要指定某個文件的某個方案
以下:
allen.wu@allen.wudeMacBook-Pro-4:~/Documents/work_allen.wu/goDev/Applications/src/github.com.xxx/avatar/app_server/service/centralhub$tree .
.
├── msghub.go
├── msghub_test.go
├── pushhub.go
├── rtvhub.go
├── rtvhub_test.go
├── userhub.go
└── userhub_test.go
0 directories, 7 files
複製代碼
總共有7個文件,其中有三個test文件,假如咱們只想要測試rtvhub.go裏面的某個方法,若是直接運行go test,就會測試全部test.go文件了。
所以咱們須要在go test 後面再指定咱們須要測試的test.go 文件和 它的源文件,以下:
go test -v msghub.go msghub_test.go
複製代碼
在測試單個文件之下,假如咱們單個文件下,有多個方法,咱們還想只是測試單個文件下的單個方法,要如何實現?咱們須要再在此基礎上,用 -run 參數指定具體方法或者使用正則表達式。
假如test文件以下:
package centralhub
import (
"context"
"testing"
)
func TestSendTimerInviteToServer(t *testing.T) {
ctx := context.Background()
err := sendTimerInviteToServer(ctx, 1461410596, 1561445452, 2)
if err != nil {
t.Errorf("send to server friendship build failed. %v", err)
}
}
func TestSendTimerInvite(t *testing.T) {
ctx := context.Background()
err := sendTimerInvite(ctx, "test", 1461410596, 1561445452)
if err != nil {
t.Errorf("send timeinvite to client failed:%v", err)
}
}
複製代碼
go test -v msghub.go msghub_test.go -run TestSendTimerInvite
go test -v msghub.go msghub_test.go -run "SendTimerInvite"
複製代碼
指定目錄便可 go test
go test工具給咱們提供了測試覆蓋度的參數,
go test -v -cover
go test -cover -coverprofile=cover.out -covermode=count
go tool cover -func=cover.out
服務端開發者若是在mac上開發,那麼Golang工程的代碼能夠直接在mac上編譯運行,而後若是須要部署在Linux系統的時候,在編譯參數裏面指定GOOS便可,這樣能夠本地調試ok後再部署到Linux服務器。
若是要部署到Linux服務,編譯參數的指定爲
ldflags=" -X ${repo}/version.version=${version} -X ${repo}/version.branch=${branch} -X ${repo}/version.goVersion=${go_version} -X ${repo}/version.buildTime=${build_time} -X ${repo}/version.buildUser=${build_user} "
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${ldflags}" -o $binary_dir/$binary_name ${repo}/
複製代碼
對於GC,咱們要收集起來,記錄到日誌文件中,這樣方便後續排查和定位,啓動的時候指定一下便可執行gc,收集gc日誌能夠重定向
export GIN_MODE=release
GODEBUG=gctrace=1 $SERVER_ENTRY 1>/dev/null 2>$LOGDIR/gc.log.`date "+%Y%m%d%H%M%S"` &
複製代碼
整個項目包括兩大類,一個是本身編寫的代碼模塊,一個是依賴的代碼,依賴包須要有進行包管理,本身的編寫的代碼工程須要有一個合適的目錄進行管理 main.go :入口 doc : 文檔 conf : 配置相關 ops : 運維操做相關【http接口】 api : API接口【http交互接口】 daemon : 後臺daemon相關 model : model模塊,操做底層資源 service : model的service grpcclient : rpc client registry : etcd 註冊 processor : 異步kafka消費
.
├── README.md
├── api
├── conf
├── daemon
├── dist
├── doc
├── grpcclient
├── main.go
├── misc
├── model
├── ops
├── processor
├── registry
├── service
├── tools
├── vendor
└── version
複製代碼
go容許import不一樣代碼庫的代碼,例如github.com, golang.org等等;對於須要import的代碼,可使用 go get 命令取下來放到GOPATH對應的目錄中去。
對於go來講,其實並不care你的代碼是內部仍是外部的,總之都在GOPATH裏,任何import包的路徑都是從GOPATH開始的;惟一的區別,就是內部依賴的包是開發者本身寫的,外部依賴的包是go get下來的。
依賴GOPATH來解決go import有個很嚴重的問題:若是項目依賴的包作了修改,或者乾脆刪掉了,會影響到其餘現有的項目。爲了解決這個問題,go在1.5版本引入了vendor屬性(默認關閉,須要設置go環境變量GO15VENDOREXPERIMENT=1),並在1.6版本以後都默認開啓了vendor屬性。 這樣的話,全部的依賴包都在項目工程的vendor中了,每一個項目都有各自的vendor,互不影響;可是vendor裏面的包沒有版本信息,不方便進行版本管理。
目前市場上經常使用的包管理工具主要有godep、glide、dep
godep的使用者衆多,如docker,kubernetes, coreos等go項目不少都是使用godep來管理其依賴,固然緣由多是早期也沒的工具可選,早期咱們也是使用godep進行包管理。
使用比較簡單,godep save;godep restore;godep update;
可是後面隨着咱們使用和項目的進一步增強,咱們發現godep有諸多痛點,目前已經逐步開始棄用godep,新項目都開始採用dep進行管理了。
godep的痛點:
godep若是遇到依賴項目裏有vendor的時候就可能會致使編譯不過,vendor下再嵌套vendor,就會致使編譯的時候出現版本不一致的錯誤,會提示某個方法接口不對,所有放在當前項目的vendor下
godep鎖定版本太麻煩了,在項目進一步發展過程當中,咱們依賴的項目(包)多是早期的,後面因爲升級更新,某些API接口可能有變;可是咱們項目若是已經上線穩定運行,咱們不想用新版,那麼就須要鎖定某個特定版本。可是這個對於godep而言,操做着實不方便。
godep的時候,常常會有一些包須要特定版本,而後包依賴編譯不過,尤爲是在多人協做的時候,本地gopath和vendor下的版本不同,而後本地gopath和別人的gopath的版本不同,致使編譯會遇到各類依賴致使的問題
glide也是在vendor以後出來的。glide的依賴包信息在glide.yaml和glide.lock中,前者記錄了全部依賴的包,後者記錄了依賴包的版本信息
glide create # 建立glide工程,生成glide.yaml glide install # 生成glide.lock,並拷貝依賴包 glide update # 更新依賴包信息,更新glide.lock
由於glide官方說咱們不更新功能了,只bugfix,請你們開始使用dep吧,因此鑑於此,咱們在選擇中就放棄了。同時,glide若是遇到依賴項目裏有vendor的時候就直接跪了,dep的話,就會濾掉,不會再vendor下出現嵌套的,所有放在當前項目的vendor下
golang官方出品,dep最近的版本已經作好了從其餘依賴工具的vendor遷移過來的功能,功能很強大,是咱們目前的最佳選擇。不過目前尚未release1.0 ,可是已經能夠用在生成環境中,對於新項目,我建議採用dep進行管理,不會有歷史問題,並且當新項目上線的時候,dep也會進一步優化而且可能先於你的項目上線。
dep默認從github上拉取最新代碼,若是想優先使用本地gopath,那麼3.x版本的dep須要顯式參數註明,以下
dep init -gopath -v
複製代碼
godep是最初使用最多的,可以知足大部分需求,也比較穩定,可是有一些不太好的體驗;
glide 有版本管理,相對強大,可是官方表示再也不進行開發;
dep是官方出品,目前沒有release,功能一樣強大,是目前最佳選擇;
go vendor 缺失致使import屢次致使panic
本工程下沒有vendor目錄,然而,引入了這個包「github.com.xxx/demo/biz/model/impl/hash」, 這個biz包裏面包含了vendor目錄。
這樣,編譯此工程的時候,會致使一部分import是從oracle下的vendor,另外一部分是從gopath,這樣就會出現一個包被兩種不一樣方式import,致使出現重複註冊而panic
fatal error: concurrent map read and map write
併發編程中最容易出現資源競爭,之前玩C++的時候,資源出現競爭只會致使數據異常,不會致使程序異常panic,Golang裏面會直接拋錯,這個是比較好的作法,由於異常數據最終致使用戶的數據異常,影響很大,甚至沒法恢復,直接拋錯後交給開發者去修復代碼bug,通常在測試過程當中或者代碼review過程當中就可以發現併發問題。
併發的處理方案有二:
Golang不容許包直接相互import,會致使編譯不過。可是有個項目裏面,A同窗負責A模塊,B同窗負責B模塊,因爲產品需求致使,A模塊要調用B模塊中提供的方法,B模塊要調用A模塊中提供的方法,這樣就致使了相互引用了
咱們的解決方案是: 將其中一個相互引用的模塊中的方法提煉出來,獨立爲另一個模塊,也就是另一個包,這樣就不至於相互引用
Golang進行json轉換的時候,經常使用作法是一個定義struct,成員變量使用tag標籤,而後經過自帶的json包進行處理,容易出現的問題主要有:
golang使用一年多以來,我的認爲golang有以下優勢:
【"歡迎關注個人微信公衆號:Linux 服務端系統研發,後面會大力經過微信公衆號發送優質文章"】