golang程式優雅關閉與重啟
何謂優雅
當線上程式碼有更新時,我們要首先關閉服務,然後再啟動服務,如果訪問量比較大,當關閉服務的時候,當前伺服器很有可能有很多
連線,那麼如果此時直接關閉服務,這些連線將全部斷掉,影響使用者體驗,絕對稱不上優雅
所以我們要想出一種可以平滑關閉或者重啟程式的方式
是謂優雅。
思路
- 服務端啟動時多開啟一個協程用來監聽關閉訊號
- 當協程接收到關閉訊號時,將拒絕接收新的連線,並處理好當前所有連線後斷開
- 啟動一個新的服務端程式來接管新的連線
- 關閉當前程式
實現
以 siluser/bingo框架為例
關於這個框架的系列文章:
我使用了tim1020/godaemon這個包來實現平滑重啟的功能(對於大部分專案來說,直接使用可以滿足大部分需求,無需改造)
期望效果:
在控制檯輸入 bingo run daemon [start|restart|stop]
可以令伺服器 啟動|重啟|停止
- 先看如何開啟一個伺服器 (
bingo run dev
)
關於 bingo
命令的實現可以看我以前的部落格: 仿照laravel-artisan實現簡易go開發腳手架
因為是開發環境嘛,大體的思路就是吧 bingo run
命令轉換成令 go run start.go
這種 shell
命令
所以 bingo run dev
就等於 go run start.go dev
//處理http.Server,使支援graceful stop/restart
func Graceful(s http.Server) error {
// 設定一個環境變數
os.Setenv("__GRACEFUL", "true")
// 建立一個自定義的server
srv = &server{
cm: newConnectionManager(),
Server: s,
}
// 設定server的狀態
srv.ConnState = func(conn net.Conn, state http.ConnState) {
switch state {
case http.StateNew:
srv.cm.add(1)
case http.StateActive:
srv.cm.rmIdleConns(conn.LocalAddr().String())
case http.StateIdle:
srv.cm.addIdleConns(conn.LocalAddr().String(), conn)
case http.StateHijacked, http.StateClosed:
srv.cm.done()
}
}
l, err := srv.getListener()
if err == nil {
err = srv.Server.Serve(l)
} else {
fmt.Println(err)
}
return err
}
複製程式碼
這樣就可以啟動一個伺服器,並且在連線狀態變化的時候可以監聽到
- 以守護程式啟動伺服器
當使用 bingo run daemon
或者 bingo run daemon start
的時候,會觸發 DaemonInit()
函式,內容如下:
func DaemonInit() {
// 得到存放pid檔案的路徑
dir, _ := os.Getwd()
pidFile = dir + "/" + Env.Get("PID_FILE")
if os.Getenv("__Daemon") != "true" { //master
cmd := "start" //預設為start
if l := len(os.Args); l > 2 {
cmd = os.Args[l-1]
}
switch cmd {
case "start":
if isRunning() {
fmt.Printf("
%c[0;48;34m%s%c[0m", 0x1B, "["+strconv.Itoa(pidVal)+"] Bingo is running", 0x1B)
} else { //fork daemon程式
if err := forkDaemon(); err != nil {
fmt.Println(err)
}
}
case "restart": //重啟:
if !isRunning() {
fmt.Printf("
%c[0;48;31m%s%c[0m", 0x1B, "[Warning]bingo not running", 0x1B)
restart(pidVal)
} else {
fmt.Printf("
%c[0;48;34m%s%c[0m", 0x1B, "["+strconv.Itoa(pidVal)+"] Bingo restart now", 0x1B)
restart(pidVal)
}
case "stop": //停止
if !isRunning() {
fmt.Printf("
%c[0;48;31m%s%c[0m", 0x1B, "[Warning]bingo not running", 0x1B)
} else {
syscall.Kill(pidVal, syscall.SIGTERM) //kill
}
case "-h":
fmt.Println("Usage: " + appName + " start|restart|stop")
default: //其它不識別的引數
return //返回至呼叫方
}
//主程式退出
os.Exit(0)
}
go handleSignals()
}
複製程式碼
首先要獲取pidFile
這個檔案主要是儲存令程式執行時候的程式pid
,為什麼要持久化pid
呢?是為了讓多次程式執行過程中,判定是否有相同程式啟動等操作
之後要獲取對應的操作 (start|restart|stop),一個一個說
case
start
:
首先使用 isRunning()
方法判斷當前程式是否在執行,如何判斷?就是從上面提到的 pidFile
中取出程式號
然後判斷當前系統是否執行令這個程式,如果有,證明正在執行,返回 true
,反之返回 false
如果沒有執行的話,呼叫 forkDaemon()
函式啟動程式,這個函式是整個功能的核心
func forkDaemon() error {
args := os.Args
os.Setenv("__Daemon", "true")
procAttr := &syscall.ProcAttr{
Env: os.Environ(),
Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()},
}
pid, err := syscall.ForkExec(args[0], []string{args[0], "dev"}, procAttr)
if err != nil {
panic(err)
}
savePid(pid)
fmt.Printf("
%c[0;48;32m%s%c[0m", 0x1B, "["+strconv.Itoa(pid)+"] Bingo running...", 0x1B)
fmt.Println()
return nil
}
複製程式碼
syscall
包不支援win系統,也就意味著如果想在 windows
上做開發的話,只能使用虛擬機器或者 docker
啦
這裡的主要功能就是,使用 syscall.ForkExec()
,fork
一個程式出來
執行這個程式所執行的命令就是這裡的引數(因為我們的原始命令是 go run start.go dev
,所以這裡的args[0]
實際上是 start.go
編譯之後的二進位制檔案)
然後再把 fork
出來的程式號儲存在 pidFile
裡
所以最終執行的效果就是我們第一步時候說到的 bingo run dev
達到的效果
case
restart
:
這個比較簡單,通過 pidFile
判定程式是否正在執行,如果正在執行,才會繼續向下執行
函式體也比較簡單,只有兩行
syscall.Kill(pid, syscall.SIGHUP) //kill -HUP, daemon only時,會直接退出
forkDaemon()
複製程式碼
第一行殺死這個程式
第二行開啟一個新程式
case
stop
:
這裡就一行程式碼,就是殺死這個程式
額外的想法
在開發過程中,每當有一丁點變動(比如更改來一丁點控制器),就需要再次執行一次 bingo run daemon restart
命令,讓新的改動生效,十分麻煩
所以我又開發了 bingo run watch
命令,監聽改動,自動重啟server伺服器
我使用了github.com/fsnotify/fs…包來實現監聽
func startWatchServer(port string, handler http.Handler) {
// 監聽目錄變化,如果有變化,重啟服務
// 守護程式開啟服務,主程式阻塞不斷掃描當前目錄,有任何更新,向守護程式傳遞訊號,守護程式重啟服務
// 開啟一個協程執行服務
// 監聽目錄變化,有變化執行 bingo run daemon restart
f, err := fsnotify.NewWatcher()
if err != nil {
panic(err)
}
defer f.Close()
dir, _ := os.Getwd()
wdDir = dir
fileWatcher = f
f.Add(dir)
done := make(chan bool)
go func() {
procAttr := &syscall.ProcAttr{
Env: os.Environ(),
Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()},
}
_, err := syscall.ForkExec(os.Args[0], []string{os.Args[0], "daemon", "start"}, procAttr)
if err != nil {
fmt.Println(err)
}
}()
go func() {
for {
select {
case ev := <-f.Events:
if ev.Op&fsnotify.Create == fsnotify.Create {
fmt.Printf("
%c[0;48;33m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]created file:"+ev.Name, 0x1B)
}
if ev.Op&fsnotify.Remove == fsnotify.Remove {
fmt.Printf("
%c[0;48;31m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]deleted file:"+ev.Name, 0x1B)
}
if ev.Op&fsnotify.Rename == fsnotify.Rename {
fmt.Printf("
%c[0;48;34m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]renamed file:"+ev.Name, 0x1B)
} else {
fmt.Printf("
%c[0;48;32m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]modified file:"+ev.Name, 0x1B)
}
// 有變化,放入重啟陣列中
restartSlice = append(restartSlice, 1)
case err := <-f.Errors:
fmt.Println("error:", err)
}
}
}()
// 準備重啟守護程式
go restartDaemonServer()
<-done
}
複製程式碼
首先按照 fsnotify
的文件,建立一個 watcher
,然後新增監聽目錄(這裡只是監聽目錄下的檔案,不能監聽子目錄)
然後開啟兩個協程:
-
監聽檔案變化,如果有檔案變化,把變化的個數寫入一個
slice
裡,這是一個阻塞的for
迴圈 -
每隔1s中檢視一次記錄檔案變化的
slice
, 如果有的話,就重啟伺服器,並重新設定監聽目錄,然後清空slice
,否則跳過遞迴遍歷子目錄,達到監聽整個工程目錄的效果:
func listeningWatcherDir(dir string) {
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
dir, _ := os.Getwd()
pidFile = dir + "/" + Env.Get("PID_FILE")
fileWatcher.Add(path)
// 這裡不能監聽 pidFile,否則每次重啟都會導致pidFile有更新,會不斷的觸發重啟功能
fileWatcher.Remove(pidFile)
return nil
})
}
複製程式碼
這裡這個 slice
的作用也就是為了避免當一次儲存更新了多個檔案的時候,也重啟了多次伺服器
下面看看重啟伺服器的程式碼:
go func() {
// 執行重啟命令
cmd := exec.Command("bingo", "run", "daemon", "restart")
stdout, err := cmd.StdoutPipe()
if err != nil {
fmt.Println(err)
}
defer stdout.Close()
if err := cmd.Start(); err != nil {
panic(err)
}
reader := bufio.NewReader(stdout)
//實時迴圈讀取輸出流中的一行內容
for {
line, err2 := reader.ReadString(`
`)
if err2 != nil || io.EOF == err2 {
break
}
fmt.Print(line)
}
if err := cmd.Wait(); err != nil {
fmt.Println(err)
}
opBytes, _ := ioutil.ReadAll(stdout)
fmt.Print(string(opBytes))
}()
複製程式碼
使用 exec.Command()
方法得到一個 cmd
呼叫 cmd.Stdoutput()
得到一個輸出管道,命令列印出來的資料都會從這個管道流出來
然後使用 reader := bufio.NewReader(stdout)
從管道中讀出資料
用一個阻塞的for
迴圈,不斷的從管道中讀出資料,以
為一行,一行一行的讀
並列印在控制檯裡,達到輸出的效果,如果這幾行不寫的話,在新的程式裡的 fmt.Println()
方法列印出來的資料將無法顯示在控制檯上.
就醬,最後貼下專案連結 silsuer/bingo ,歡迎star,歡迎PR,歡迎提意見