在日常專案的開發中,我們經常會使用配置檔案來儲存專案的基本後設資料,配置檔案的型別有很多,如:JSON
、xml
、yaml
、甚至可能是個純文字格式的檔案。不管是什麼型別的配置資料,在某些場景下,我們可會有熱更新當前配置檔案內容的需求,比如:使用Go執行的一個常駐程式,執行了一個 Web Server
服務程式。
此時,如果配置檔案發生變化,我們如何讓當前程式重新讀取新的配置檔案內容呢?接下來,我們將使用如下兩種方式實現配置檔案的更新:
- 使用系統訊號(手動式)。
- 使用
inotify
, 監聽檔案修改事件。
不管是哪一種方式,都會用到Go語言中 goroutine
的概念,我打算使用 goroutine
新起一個協程,新協程的目的是用來接收系統訊號(signal)或者監聽檔案被修改的事件,如果你對 goroutine
的概念不是很瞭解,那麼建議你先查閱相關資料。
手動式,使用系統訊號。
我之所以稱這種方式為手動式(Manual
),是因為檔案的更新是需要我們自己去手動告知當前依賴的執行程式:"嘿,哥們!配置檔案更新啦,你得重新讀一下配置內容!!",我們告知的方式就是向當前執行程式傳送一個系統訊號,因此程式的大概思路如下:
- 在Go主程式中,新起一個
goroutine
,用來接收訊號。 - 新goroutine監聽訊號的發生,然後更新配置檔案。
在 *nix
系統中規定,USR1
和USR2
均屬於使用者自定義訊號,至於USR1
和 USR2
哪一個更合,維基百科) 也沒有給出權威的答案,所以在這裡我按約定俗稱的規矩,打算使用USR1
:
如果你使用過Nginx或者Apache等Web Server,那麼你對採用傳送訊號更新配置檔案的策略肯定多少有點印象。
監聽訊號
在Go
語言中監聽系統訊號需要使用 signal
包Notify()
方法,該方法至少需要兩個引數,第一個引數要求是一個系統訊號型別的通道,後續引數為一個或多個需要監聽的系統訊號:
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技術”微信公眾號