golang 後臺服務設計精要

原文地址

守護進程

傳統的後臺服務通常做爲守護進程(daemon)運行。linux 上建立 daemon 的步驟通常以下:python

  1. 建立子進程,父進程退出;
  2. 調用系統調用 setsid() 脫離控制終端;
  3. 調用系統調用 umask() 清除進程 umask 確保 daemon 建立文件和目錄時擁有所需權限;
  4. 修改當前工做目錄爲系統根目錄;
  5. 關閉從父進程繼承的全部文件描述符,並將標準輸入/輸出/錯誤重定向到 /dev/null

前 3 個步驟是必須的。一些用來建立 daemon 的第三方庫(好比這個)基本也是這麼實現的,不過建立子進程的方式有些是用系統調用 fork ,有些是用標準庫 exec 包,目的都同樣。mysql

優雅的結束進程

粗暴一點的方式是使用 kill 發送信號給後臺進程,而後後臺進程也沒有安裝任何自定義的信號處理器,直接按默認的行爲終止進程。進程退出後,雖然其佔用的內核資源都會被操做系統回收,但某些狀況下,好比原子性的事務執行到一半時由於進程退出而停止,這種粗暴的退出會致使更復雜的情況。可控的退出是更優雅的方式。linux

響應信號

SIGKILL 和 SIGSTOP 信號被定義成沒法被忽略或者捕獲,這兩個信號會致使後臺進程直接粗暴的退出。其餘信號會被 golang 語言運行時捕獲。golang 語言運行時對這些信號的默認處理行爲是:git

  • SIGBUS / SIGFPE / SIGSEGV 轉換成運行時 panic
  • SIGHUP / SIGINT / SIGTERM 進程退出
  • SIGQUIT / SIGILL / SIGTRAP / SIGABRT / SIGSTKFLT / SIGEMT / SIGSYS 進程退出並打印調用堆棧
  • SIGTSTP / SIGTTIN / SIGTTOU 終端做業控制相關,仍然保持默認的處理方式
  • SIGPROF golang 運行時用來實現 runtime.CPUProfile
  • 其餘信號無任何動做

標準庫中的 signal.Notify() 可讓 golang 語言運行時把相應的信號轉發到 channel 裏來代替默認的處理行爲。下面的例子中,進程在監聽到結束進程的的信號 SIGINT 或 SIGTERM 後,開始執行回收資源的操做,等待全部資源回收完成(事務執行完畢)後進程再退出。github

package main

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

func main() {
	c := make(chan os.Signal)
	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
	for s := range c {
		// wait for all resources are released
		// ...
		fmt.Printf("catch signal %v, now exit...\n", s)
		break
	}

	return
}

等待全部協程退出

當主協程退出時,並不會等待其餘其餘協程(後面稱爲工做協程)執行完畢。工做協程可能正在執行某個事務,主協程退出後,這個事務也就中斷了。所以在主協程收到退出信號時,應該給工做協程發送退出信號,而後工做協程都退出後主協程再退出。工做協程在檢測到退出信號後,開始釋放佔有的資源而後退出,這個操做不該該間隔過久,不然在主協程收到退出信號後由於要等待工做協程的退出,致使進程遲遲不會結束。主協程給工做協程發送退出信號可使用前文中介紹的用 channel 當成信號量的方式。後文中將介紹更通用的使用標準庫中的 context.Context 來管理工做協程生命期的方式。golang

goroutine 生命期管理

考慮這樣一種場景:某個函數執行耗時比較大,多是因爲要經過網絡調用後端服務而網絡延遲可能很大,也多是比較複雜的 CPU 計算執行時間比較久;調用者調用這個函數時會設置一個固定的超時時間,而不會無限制等待函數執行完成,超時時間到了後尚未等到結果的話就會認爲調用失敗而後進行相應的錯誤處理。一般調用者會開啓一個協程來執行這個耗時的調用,而後經過 channel 來超時等待執行結果。以下例所示:sql

package main

import (
	"fmt"
	"time"
)

func longtimeCostFunc(c chan<- int) {
	for i := 0; i < 10; i++ {
		time.Sleep(time.Second)
		fmt.Println("calculating...")
	}
	c <- 1
	fmt.Println("calculate finished")
}

func main() {
	result := make(chan int, 1)
	go longtimeCostFunc(result)

	// 結果等待時間最多3秒
	select {
	case r := <-result:
		fmt.Println("longtimeCostFunc return:", r)
	case <-time.After(3 * time.Second):
		// handle timeout error
		fmt.Println("too long to wait for the result")
	}

	// blocking main goroutine exit
	time.Sleep(time.Minute)

	return
}

在這個例子中,當超時時間(3 second)到了後, 調用者再也不等待 longtimeCostFunc() 的執行結果,開始進行相應的超時錯誤處理。此時執行 longtimeCostFunc() 的協程(工做協程)仍然在執行。在極端狀況下,可能會致使無數個協程在默默進行毫無心義的操做白白耗費系統資源。所以須要一種機制讓調用者再也不關注工做協程的執行結果後通知工做協程退出。下面的示例中展現了使用標準庫的 context 包來通知工做協程退出的方式:數據庫

package main

import (
	"context"
	"fmt"
	"time"
)

func longtimeCostFunc(ctx context.Context, c chan<- int) {
	for i := 0; i < 10; i++ {
		select {
		case <-ctx.Done():
			fmt.Println("calculate interrupted")
			return
		case <-time.After(time.Second):
			fmt.Println("calculating...")
		}
	}
	c <- 1
	fmt.Println("calculate finished")
}

func main() {
	result := make(chan int, 1)
	ctx, cancel := context.WithCancel(context.Background())
	go longtimeCostFunc(ctx, result)

	select {
	case r := <-result:
		fmt.Println("longtimeCostFunc return:", r)
	case <-time.After(3 * time.Second):
		// notify worker goroutine to exit
		cancel()
		// handle timeout error
		fmt.Println("too long to wait for the result")
	}

	// blocking main goroutine exit
	time.Sleep(time.Minute)

	return
}

調用者經過 context.WithCancel() 獲取一個能夠取消的 context 及與之關聯的取消函數 cancel,而後將獲取的 context 傳遞給工做協程(通常做爲第一個參數),工做協程經過 context.Done() 監聽此 context 是否已經取消,當監聽到取消事件後,工做協程就能夠再也不繼續正常的業務流程能夠退出了。當調用者調用取消函數 cancel 時,全部經過 context.Done() 監聽此 context 是否取消的工做協程都會收到取消的信號。使用這種方式來管理子協程的生命期的時候,要注意子協程在執行正常的業務流程中要能及時響應 context 已取消的信號。 context 機制除了能夠用來管理協程生命期外,還能夠用來在有建立關係的一組協程中共享變量。經過這個特性能夠實現相似其餘語言的但 golang 沒有的線程本地存儲特性,編程

數據庫操做與 ORM

標準庫中的數據庫操做接口

golang 標準庫的數據接口使用了模板方法模式的設計模式。database/sql 包將 SQL 數據庫的操做抽象成幾個通用的接口對外提供。調用者使用 SQL 數據庫時,無論底層用的哪一種具體的 SQL 產品(MySQL, PostgreSQL, SQLite等等),都只須要調用通用的 database/sql 包中的接口。database/sql 中各通用接口的實現又只用到了標準庫中的 database/sql/driver 包定義的各類類型。從標準庫文檔中能夠看到 database/sql/driver 中的各類類型都是純的 interface ,並無實現。所以使用某種具體的 SQL 數據庫產品時,須要提供一個第三方的包,這個包必須實現 database/sql/driver 中的各個 interface 並註冊全局的接口實例。這個第三方包就是 SQL 數據庫的驅動。當調用都使用 database/sql 操做數據庫時,會找到註冊的具體數據庫驅動的實例,最終調用到第三方包中實現的數據庫操做。 MySQL 是互聯網公司普遍使用的 SQL 數據庫產品,以流行的 MySQL 第三方 golang 驅動 github.com/go-sql-driver/mysql 使用爲例,使用時須要 import 標準庫的 database/sql 包和第三方驅動包:後端

import (
	"database/sql"
	_ "github.com/go-sql-driver/mysql"
)

import 的包前面加上下劃線表示源碼中沒用使用此包,但編譯時不會報錯,而且經過這種方式 import 的包的初始化函數 init() 在 main 函數前會被運行時先調用。在驅動的源碼 driver.go 中定義的初始化函數將實現了 database/sql/driver 接口的 MySQL 驅動實例註冊到了全局的實例表裏:

func init() {
	sql.Register("mysql", &MySQLDriver{})
}

經過這種方式,調用者只須要使用 database/sql 包,就能操做各類類型的 SQL 數據庫了。

ORM

在代碼中直接經過 SQL 語句來操做關係型數據庫有時候會很繁瑣,所以出現了各類 sqlbuilder 和 ORM 。ORM 在具體的編程語言和 SQL 數據庫之間增長了一層抽象,將具體語言的類型系統(好比 golang 中的 struct, python 中的 class) 映射成關係型數據庫中的表。在須要操做 SQL 數據庫時,ORM 的使用者仍然只須要在關注各自語言的類型系統上操做,ORM 組件層會將這些操做轉化成相應的 SQL 語句來操做底層的 SQL 數據庫。換句話說, SQL 數據庫對使用者來講明透明的。

抽象和分層是解決複雜問題的基本原則。

好比一個 Student 類,在 golang 中被定義成一個 struct :

type Student struct {
	id     int
	name   string
	gender int
	age    int
	class  int
}

當須要在 SQL 數據庫保存一個 Student 類的實例時,SQL 數據庫中須要一張相應的 t_student 表,此表相應也有 5 個 column (id, name, gender, age, class)。使用 ORM 時,golang 中 Student 類的實例被映射成數據庫 t_student 表的一個記錄。當須要更新某個 Student 實例的屬性時,golang 中直接修改 struct 的某個字段:

studentA.age = 15

ORM 組件會將這個操做映射成相應的 update SQL 語句,最終實現數據庫中相應實體的更新。 須要注意的時編程語言的類型和數據庫表能被 ORM 映射是由於二者都具體一些相似的特性。ORM 把這些共同的特性抽象成通用的操做,顯然這二者也不是徹底一一對應的,所以某些特殊的數據庫的操做並不能用 ORM 來實現,仍然須要用 SQL 語句來操做。 另外,ORM 層作的轉換並不見得很智能,在 DBA 看來這種轉換來的 SQL 可能至關低效。所以在某些性能敏感的場合下,最好對 ORM 轉換的 SQL 作下審覈。 ORM 通常須要用到語言的反射(reflect)特性。在沒有反射特性的語言上(好比 C++),要實現一個 ORM 庫是至關複雜的。所以這類語言都沒什麼好用的 ORM 庫。幸運的是,golang 不是這類語言。 golang 的 ORM 庫,推薦使用 gorm ,具體使用教程參考官方文檔,這裏就不贅述了。

HTTP 服務

標準庫 net/http 包

使用 golang 標準庫的 net/http 包實現一個 HTTP server 很簡單,只須要幾行代碼:

package main

import (
	"net/http"
)

func foo(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("foo\n"))
}

func bar(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("bar\n"))
}

func main() {
	http.HandleFunc("/foo", foo)
	http.HandleFunc("/bar", bar)
	http.ListenAndServe(":8080", nil)
}

下面結合 net/http 包源碼簡單的分析下這個 server 在內部是如何運行的。首先忽略掉前面接口註冊的代碼,查看服務總入口處 http.ListenAndServe() 的實現:

func ListenAndServe(addr string, handler Handler) error {
	server := &Server{Addr: addr, Handler: handler}
	return server.ListenAndServe()
}

發現就是將參數 addrhandler 構造出一個 Server 對象,而後直接調用其 ListenAndServe() 方法。繼續查看 Server 的定義:

type Server struct {
	Addr    string  // TCP address to listen on, ":http" if empty
	Handler Handler // handler to invoke, http.DefaultServeMux if nil
	// ...
}

其餘字段都是一些數據字段,構造 Server 對象的時候也沒有設置,這裏省略不表。除此以外,只有 Handler 字段是個接口,定義以下:

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

所以在這裏能夠猜測到 ServerListenAndServe() 方法應該是從 TCP 鏈接上讀取數據後解析出 HTTP 請求報文,將這個 HTTP 包文抽象成 Request 對象並將其指針做爲參數傳遞給調用者設置的 HandlerServeHTTP() 方法,而後接收此方法寫入第一個 ResponseWriter 類型參數的數據,將其組包成 HTTP 響應報文,最後經過 TCP 鏈接發送給客戶端。爲了驗證這個猜測,直接實現一個自定義的 Handler 來調用:

package main

import (
	"net/http"
)

func foo(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("foo\n"))
}

func bar(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("bar\n"))
}

type MyHandler struct{}

func (mh *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	switch r.URL.Path {
	case "/foo":
		foo(w, r)
	case "/bar":
		bar(w, r)
	default:
		w.WriteHeader(404)
	}
}

func main() {
	server := &http.Server{Addr: ":8080", Handler: &MyHandler{}}
	server.ListenAndServe()
}

效果和前面的例子如出一轍。繼續閱讀 ServerListenAndServe() 方法也會發現確實和上面猜測的同樣。這個方法裏實現了 HTTP 報文的解析與組裝,這部分仍是比較複雜的,這裏就不深究了。上面這個流程簡單用圖形描述以下:

在這個自定義的 Handler 實現裏,用 switch 把不一樣的路徑的請求分發給不一樣的處理函數,實現這個路由功能的實體叫作多路選擇器(mux)。在 net/http 包中提供了 ServerMux 類型專門用來作這種路由功能。ServerMux 也實現了 ServeHTTP() 方法,所以也能夠當成 Handler 用來構造 Server 對象。ServerMux 內部維護了 HTTP 請求路徑與對應 Handler 的路由表,經過 HandleFunc() 能夠將路徑與對應 Handler 註冊到這個路由表裏。上面第一個例子中 http.HandleFunc("/foo", foo) 實際上就是把路徑 /foo 和對應的處理函數 foo 註冊到默認的多路選擇器 DefaultServeMux 的路由表裏,構造 Server 時若是沒有指定 Handler ,就會使用這個默認的多路選擇器。運行時 ServerListenAndServe 會調用 ServerMuxServeHTTP() 方法,這個方法中根據請求路徑在註冊的路由表找到對應的 Handler ,最終把請求交給這個 Handler 來處理。這種用多路選擇器來實現路由功能的流程簡單用圖形描述以下:

httprouter

golang 標準庫的提供多路選擇器實現的路由功能比較簡單,只能根據請求路徑進行字符串全匹配。如今流行的 RESTful 風格的 HTTP 接口通常會在路徑裏帶上參數: /user/:id ,並且還會使用不一樣的 HTTP Method 表示對資源的不一樣操做。這須要針對 HTTP Method 和請求路徑的組合作路由選擇,而且還須要從路徑裏提取出參數。這時候標準庫就不夠用了。httprouter 是一個普遍使用的高性能開源多路選擇器,在有複雜路由的場景下推薦使用。具體的使用教程參考官方文檔,這裏不贅述。 雖然使用這個庫來實現 HTTP 服務時,寫的代碼好像和使用標準庫時有點不同,可是從上面的分析中應該知道,httprouter 只是一個實現了路由功能的多路選擇器,它仍然是一個 Handler 並用來構造 Server 。理解了這點,應該就能更快的上手這個庫了。

middleware

中間件(middleware)在不一樣的語境下有不一樣的含義。這裏說的中間件能夠理解爲一個修飾器(參考設計模式的修飾模式),中間件把處理 HTTP 業務邏輯的原始 Handler 修飾(增長一些額外的功能)成另一個 Handlernet/http 包中的 StripPrefixTimeoutHandler 能夠當作是中間件應用。使用中間件能夠在不修改原有業務邏輯的基礎上方便擴展新功能。 仍以上面的示例代碼爲例。在上面的示例代碼中,實現了兩個 Handler 分別處理路徑爲 /foo/bar 的請求。假設這時候須要增長一個功能:將每一個請求處理耗時記錄到日誌。笨一點的作法是在每一個 Handler 裏都加上記錄耗時日誌的代碼,在業務比較簡單隻有幾個 Handler 是這樣作還能接受,可是若是有幾十上百個 Hanlder 的話,相同的代碼片段要拷貝幾十上百份,這樣的代碼就很醜陋了。這時候可使用一箇中間件把原有的 Handler 增長耗時日誌的功能:

package main

import (
	"log"
	"net/http"
	"time"
)

func foo(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("foo\n"))
}

func bar(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("bar\n"))
}

func timeLogMiddleware(f http.HandlerFunc) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		defer log.Printf("handle request: %s cost %.4f seconds\n", r.URL.String(), time.Now().Sub(start).Seconds())
		f(w, r)
	})
}

func main() {
	http.Handle("/foo", timeLogMiddleware(foo))
	http.Handle("/bar", timeLogMiddleware(bar))
	http.ListenAndServe(":8080", nil)
}

可是這段代碼有可能會讓人迷惑:這裏又是 Handler 又是 HandlerFunc 究竟是什麼關係?而普通函數 foo 怎麼又變成了 Handler 註冊到了路由表裏?這裏簡單梳理一下。先看 HandlerFunc 相關的定義:

type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
	f(w, r)
}

在 golang 裏函數做爲一等公民是能夠當成值傳遞的。這裏中間件 timeLogMiddleware 是一個函數,而且須要一個類型爲 HandlerFunc 的參數,而普通函數 foo 和類型 HandlerFunc 具備相同的函數簽名,所以 foo 能夠直接傳給 timeLogMiddleware() 調用。在 timeLogMiddleware() 的定義裏,又實現了一個匿名函數,這個匿名函數增長了記錄耗時日誌的功能並最終會調用傳進來的普通函數 foo。這個匿名函數由於和類型 HandlerFunc 具備相同的函數簽名所以能夠轉型成 HandlerFunc 類型的值。最後由於 HandlerFunc 類型實現了 ServeHTTP 接口,所以 HandlerFunc 類型的值能夠用 Handler 類型來接收。最終整個的過程就是經過函數調用 timeLogMiddleware(foo) 獲得了一個 Handler 值。 固然中間件也能夠實現成自定義的 struct 類型,只要實現 ServeHTTP 接口便可。上面的示例若是這樣實現的話可能更容易理解一點:

package main

import (
	"log"
	"net/http"
	"time"
)

func foo(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("foo\n"))
}

func bar(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("bar\n"))
}

type timeLogMiddleware struct {
	f http.HandlerFunc
}

func (t *timeLogMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	start := time.Now()
	defer log.Printf("handle request: %s cost %.4f seconds\n", r.URL.String(), time.Now().Sub(start).Seconds())
	t.f(w, r)
}

func main() {
	http.Handle("/foo", &timeLogMiddleware{foo})
	http.Handle("/bar", &timeLogMiddleware{bar})
	http.ListenAndServe(":8080", nil)
}

上面的例子展現瞭如何使用中間件在沒有修改原有業務邏輯代碼的狀況下擴展新功能。中間件也能夠繼續修飾中間件,或者不一樣的中間件組合使用,真正作到代碼的可插撥。這裏的關鍵是要理解 Handler 是如何做爲 HTTP 協議框架與業務邏輯處理之間的橋樑的。

gin

經過上面的介紹,讀者應該基本上能實現一個微型的 HTTP 框架了。HTTP 協議的解析與組裝能夠直接使用標準庫 http.Server 的實現,加上比標準庫強大點的路由功能,定製一些經常使用的中間件,再加上一些工具函數,一個輕量高效的 HTTP 框架就誕生了。實際上流行的開源框架 gin 作的工做也就是這些。使用這類微型的 HTTP 框架,再加上數據庫相關的及一些其餘的外圍開源庫,就能夠開發企業級應用了。

小贏其餘自研組件介紹

  • 日誌庫 支持文件滾動和分級日誌;
  • ZRPC 庫 和公司的 zmq rpc 服務交互時須要用到;
  • 網絡工具庫
  • dc 庫 模調上報接口,模調系統的說明參考這裏
  • apollo 客戶端庫 ,apollo 是公司使用的配置中心,基於攜程開源的 apollo 開發,推薦儘可能使用 apollo 配置,配置文件的方式不方便動態更新,使用說明參考這裏

總結

本文總結了在開發後臺服務時的一些慣常作法和並簡單介紹了相關的技術及開源庫。 後在服務通常做爲守護進程存在。守護進程應該要捕獲經常使用的退出信號,並確保佔用的資源都釋放後才能退出主協程,不響應任何信號讓 golang 運行時採用默認的信號處理動做不是好的作法。回收資源的過程也應該避免耗時過久,可使用標準庫的 context 包來管理工做協程的生命期,工做協程收到退出信號後應該及時退出避免阻塞整個進程的退出。 一般後臺服務離不開數據庫,這裏主要介紹了關係型數據庫和 golang 標準庫的 SQL 接口實現,這個實現使用了模板方法模式:將各類數據數據庫產品的底層協議交互抽象到數據庫驅動層,將 SQL 編程接口抽象成 database/sql 包的公開接口,這些接口的實現是調用固定組合的驅動層接口。特定的數據庫驅動只須要實現驅動層的各個接口,這樣調用者就能經過標準庫統一的接口操做各類各樣的數據庫產品了。ORM 將語言的類型映射成數據庫表,使用者再也不須要再關注操做數據庫的 SQL ,直接操做語言的本地類型對象,ORM 底層會轉換成相應的 SQL 語句。 HTTP 協議做爲互聯網的標配之一,HTTP 庫也歸入到了 golang 的標準庫裏。只使用標準庫寫一個 HTTP 服務仍是比較簡單的。標準庫將 HTTP 協議框架與業務邏輯解耦,二者之間只經過 Handler 接口來鏈接,這個接口只有一個方法。標準庫的 ServerMux 用來實現簡單的路由功能,須要更復雜的路由功能可使用第三方庫 httprouter 或者本身實現一個定製的路由器也不是什麼難事。使用中間件能夠在不修改原有代碼的基礎上擴展新功能。流行的開源框架 gin 也是用中間件大大減輕了 HTTP 服務開發的工做量。

參考資料

相關文章
相關標籤/搜索