Go配置文件熱加載 - 發送系統信號

在平常項目的開發中,咱們常常會使用配置文件來保存項目的基本元數據,配置文件的類型有不少,如:JSONxmlyaml、甚至多是個純文本格式的文件。不論是什麼類型的配置數據,在某些場景下,咱們可會有熱更新當前配置文件內容的需求,好比:使用Go運行的一個常駐進程,運行了一個 Web Server 服務進程。git

此時,若是配置文件發生變化,咱們如何讓當前程序從新讀取新的配置文件內容呢?接下來,咱們將使用以下兩種方式實現配置文件的更新:github

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

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

手動式,使用系統信號。

我之因此稱這種方式爲手動式(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)

所以,咱們的代碼大體以下:app

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 便可:spa

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技術」微信公衆號

相關文章
相關標籤/搜索