Go配置檔案熱載入 - 傳送系統訊號

360技術發表於2019-06-11

在日常專案的開發中,我們經常會使用配置檔案來儲存專案的基本後設資料,配置檔案的型別有很多,如:JSONxmlyaml、甚至可能是個純文字格式的檔案。不管是什麼型別的配置資料,在某些場景下,我們可會有熱更新當前配置檔案內容的需求,比如:使用Go執行的一個常駐程式,執行了一個 Web Server 服務程式。

此時,如果配置檔案發生變化,我們如何讓當前程式重新讀取新的配置檔案內容呢?接下來,我們將使用如下兩種方式實現配置檔案的更新:

  1. 使用系統訊號(手動式)。
  2. 使用inotify, 監聽檔案修改事件。

不管是哪一種方式,都會用到Go語言中 goroutine 的概念,我打算使用 goroutine 新起一個協程,新協程的目的是用來接收系統訊號(signal)或者監聽檔案被修改的事件,如果你對 goroutine 的概念不是很瞭解,那麼建議你先查閱相關資料。

手動式,使用系統訊號。

我之所以稱這種方式為手動式(Manual),是因為檔案的更新是需要我們自己去手動告知當前依賴的執行程式:"嘿,哥們!配置檔案更新啦,你得重新讀一下配置內容!!",我們告知的方式就是向當前執行程式傳送一個系統訊號,因此程式的大概思路如下:

  1. 在Go主程式中,新起一個goroutine,用來接收訊號。
  2. 新goroutine監聽訊號的發生,然後更新配置檔案。

*nix 系統中規定,USR1USR2均屬於使用者自定義訊號,至於USR1USR2 哪一個更合,維基百科) 也沒有給出權威的答案,所以在這裡我按約定俗稱的規矩,打算使用USR1:

如果你使用過Nginx或者Apache等Web Server,那麼你對採用傳送訊號更新配置檔案的策略肯定多少有點印象。

監聽訊號

Go語言中監聽系統訊號需要使用 signalNotify()方法,該方法至少需要兩個引數,第一個引數要求是一個系統訊號型別的通道,後續引數為一個或多個需要監聽的系統訊號:

import "os/signal"

Notify(c chan<- os.Signal, sig ...os.Signal)

因此,我們的程式碼大致如下:

package main

import (
    "os"
    "os/signal"
    "syscall"
)

func main() {
  // 宣告一個容量為1的訊號通道
    sig := make(chan os.Signal, 1)
  // 監聽系統SIGUSR1發出的訊號
    signal.Notify(sig, syscall.SIGUSR1)
}

在這裡我們建立了一個訊號容量大小為1的通道(channel),這表示,通道里最多能容納下1個訊號單元,如果當前通道里已經存在一個訊號單元,此時又接收到另一個訊號需要傳送到通道中,那麼在傳送該訊號的時候程式會被阻塞,直到通道里的訊號被處理掉。

通過這種方式,我們可以一次精確的只處理一個訊號,多個訊號都需要排隊的目的,這正是我想要的效果。

訊號的處理

當系統訊號被監聽存入通道後(sig中),接下來我們需要處理接收到到訊號,這裡我們新起的協程(goroutine),使用協程的目的是希望後續的任務不阻塞主程式的執行,在 GO 語言中,另起一個協程是非常方便的,只需要呼叫關鍵字:go 即可:

go func(){
  // 新執行緒
}()

我們希望在新協程中永不停歇的獲取通道中的系統訊號,程式碼如下:

go func() {
        for {
            select {
            case <-sig:
                // 獲取通道中的訊號,處理訊號            
            }
        }
}()

GO語言中的select 語句,其結構有點類似於其他語言的switch語句,但不同的是,select 只能被用來處理 goroutine 的通訊操作,而goroutine的通訊又是基於channel來實現的,所以直白點說:select 只能用來處理通道(channel)的操作。

當前的select一直會處於阻塞狀態,直到它的某個case符合條件時才會執行該case條件下的語句。並且此處我們使用了for迴圈結構,讓select語句處於一個無限迴圈當中,如果select 下的case接收到一個處理的訊號後,當處理結束後;由於外層for迴圈的語句的作用,相當於重置了select的狀態,在沒有接收到新的訊號時,select將再次被阻塞等待,迴圈往復。

如果你對select語句的阻塞有疑問,我們不妨考慮下面程式碼的執行情況:

for {
  select {
    case <-sig:
    // 獲取通道中的訊號,處理訊號            
  }
  fmt.Println("select block test!")
}

在如上的select語句後,我們嘗試輸出一行字串,那麼請問:"這行fmt.Println() 函式會在for迴圈中立即執行嗎?"

答案是肯定的:不會!select 會阻塞調,當程式執行起來時不會有任何輸出,直到case匹配到。你不妨試試。

熱載入配置

我們已經準備好了訊號的監聽,以及訊號處理的簡單工作,接下來我們需要細化訊號處理階段的程式碼,需要新增上載入配置檔案的邏輯,我們將演示載入一份簡單的json配置檔案,檔案的路徑存放於/tmp/env.json,內容比較簡單,僅一個test欄位:

{
    "test": "D"
}

同時,我們需要建立解析該json格式配套的資料結構:

type configBean struct {
    Test string
}

我們宣告瞭一個configBean 結構體,用來和env.json配置檔案欄位一一對映,然後只要呼叫json.Unmarshal()函式,我們就可以把這份json檔案內容轉為對應的Go語言結構體內容,當然這還不夠,在解析完之後我們還需要宣告一個變數來儲存這份結構體資料,供程式在其他地方呼叫:

// 全域性配置變數
var Config config

type config struct {
    LastModify time.Time
    Data       configBean
}

此處,我並沒有直接把configBean解析的json資料賦值給全域性變數,而是又包裝了一層,額外宣告瞭一個欄位 LastModify用來儲存當前檔案的最後一次修改時間,這樣的好處在於,我們每收到一個需要更新配置檔案的訊號時,我們還需要比對當前檔案的修改是否大於上一次的更新時間,當然這僅僅是一個配置優化載入的小技巧。

如下便是我們的載入配置檔案的程式碼,這裡新增了一個loadConfig(path string) 函式,用於封裝載入配置檔案的所有邏輯:

// 全域性配置變數
var Config *config

type configBean struct {
    Test string
}

type config struct {
    LastModify time.Time
    Data       configBean // 配置內容儲存欄位
}

func loadConfig(path string) error {
    var locker = new(sync.RWMutex)
    
  // 讀取配置檔案內容
    data, err := ioutil.ReadFile(path)
    if err != nil {
        return err
    }

  // 讀取檔案屬性
    fileInfo, err := os.Stat(path)
    if err != nil {
        return err
    }
  
  // 驗證檔案的修改時間
  if Config != nil && fileInfo.ModTime().Before(Config.LastModify) {
        return errors.New("no need update")
    }
    
  // 解析檔案內容
    var configBean configBean
    err = json.Unmarshal(data, &configBean)
    if err != nil {
        return err
    }

    config := config{
        LastModify: fileInfo.ModTime(),
        Data:       configBean,
    }
    
  // 重新賦值更新配置檔案
    locker.Lock()
    Config = config
    locker.Unlock()
  
    return nil
}

關於loadConfig()函式我們需要說明的是,此處我們雖然使用了鎖,但是在檔案讀寫並沒使用鎖,僅在賦值階段使用,因為在這種場景下不存在多個goroutine同時操作同一個檔案的需求,如果你所在的場景存在多個goroutine併發寫操作,那麼保險起見,建議你把檔案的讀寫最好也加上鎖機制。

至此,我們大致完成了利用監聽系統訊號更新配置檔案的所有所有邏輯,接下來我們來演示最終成果,演示之前我們還需在main函式新增一點額外程式碼,模擬主程式成為一個常駐程式,這裡還是使用通道,最後程式碼大致如下:

func main() {
    configPath := "/tmp/env.json"
    done := make(chan bool, 1)
    
    // 定義訊號通道
    sig := make(chan os.Signal, 1)
    
    signal.Notify(sig, syscall.SIGUSR1)

    go func(path string) {
        for {
            select {
            case <-sig:
                // 收到訊號, 載入配置檔案
                _ := loadConfig(path)
            }
        }
    }(configPath)
    
    // 掛起程式,直到獲取到一個訊號
    <-done
}

最終我們使用一張gif圖片來演示最終效果:

最終的完整版程式碼,請在此處檢視:github程式碼地址,並且需要說明的是,demo中的程式碼還有些小細節,例如:錯誤的處理,訊號通道的關閉等,請自行處理。

預告:鑑於文章篇幅考慮,本文中我們只實現了第一種檔案更新方式。下一篇文章中,我們將使用第二種方式:使用inotify監聽配置檔案的變化,以實現配置檔案的自動更新,期待你的關注。

(360技術原創內容,轉載請務必保留文末二維碼,謝謝~)

圖片描述

關於360技術

360技術是360技術團隊打造的技術分享公眾號,每天推送技術乾貨內容

更多技術資訊歡迎關注“360技術”微信公眾號

相關文章