傳統的後臺服務通常做爲守護進程(daemon)運行。linux 上建立 daemon 的步驟通常以下:python
setsid()
脫離控制終端;umask()
清除進程 umask 確保 daemon 建立文件和目錄時擁有所需權限;/dev/null
。前 3 個步驟是必須的。一些用來建立 daemon 的第三方庫(好比這個)基本也是這麼實現的,不過建立子進程的方式有些是用系統調用 fork
,有些是用標準庫 exec
包,目的都同樣。mysql
粗暴一點的方式是使用 kill 發送信號給後臺進程,而後後臺進程也沒有安裝任何自定義的信號處理器,直接按默認的行爲終止進程。進程退出後,雖然其佔用的內核資源都會被操做系統回收,但某些狀況下,好比原子性的事務執行到一半時由於進程退出而停止,這種粗暴的退出會致使更復雜的情況。可控的退出是更優雅的方式。linux
SIGKILL 和 SIGSTOP 信號被定義成沒法被忽略或者捕獲,這兩個信號會致使後臺進程直接粗暴的退出。其餘信號會被 golang 語言運行時捕獲。golang 語言運行時對這些信號的默認處理行爲是:git
標準庫中的 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
考慮這樣一種場景:某個函數執行耗時比較大,多是因爲要經過網絡調用後端服務而網絡延遲可能很大,也多是比較複雜的 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 沒有的線程本地存儲特性,編程
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 數據庫了。
在代碼中直接經過 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 ,具體使用教程參考官方文檔,這裏就不贅述了。
使用 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() }
發現就是將參數 addr
和 handler
構造出一個 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) }
所以在這裏能夠猜測到 Server
的 ListenAndServe()
方法應該是從 TCP 鏈接上讀取數據後解析出 HTTP 請求報文,將這個 HTTP 包文抽象成 Request
對象並將其指針做爲參數傳遞給調用者設置的 Handler
的 ServeHTTP()
方法,而後接收此方法寫入第一個 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() }
效果和前面的例子如出一轍。繼續閱讀 Server
的 ListenAndServe()
方法也會發現確實和上面猜測的同樣。這個方法裏實現了 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
,就會使用這個默認的多路選擇器。運行時 Server
的 ListenAndServe
會調用 ServerMux
的 ServeHTTP()
方法,這個方法中根據請求路徑在註冊的路由表找到對應的 Handler
,最終把請求交給這個 Handler
來處理。這種用多路選擇器來實現路由功能的流程簡單用圖形描述以下:
golang 標準庫的提供多路選擇器實現的路由功能比較簡單,只能根據請求路徑進行字符串全匹配。如今流行的 RESTful 風格的 HTTP 接口通常會在路徑裏帶上參數: /user/:id
,並且還會使用不一樣的 HTTP Method 表示對資源的不一樣操做。這須要針對 HTTP Method 和請求路徑的組合作路由選擇,而且還須要從路徑裏提取出參數。這時候標準庫就不夠用了。httprouter 是一個普遍使用的高性能開源多路選擇器,在有複雜路由的場景下推薦使用。具體的使用教程參考官方文檔,這裏不贅述。 雖然使用這個庫來實現 HTTP 服務時,寫的代碼好像和使用標準庫時有點不同,可是從上面的分析中應該知道,httprouter 只是一個實現了路由功能的多路選擇器,它仍然是一個 Handler
並用來構造 Server
。理解了這點,應該就能更快的上手這個庫了。
中間件(middleware)在不一樣的語境下有不一樣的含義。這裏說的中間件能夠理解爲一個修飾器(參考設計模式的修飾模式),中間件把處理 HTTP 業務邏輯的原始 Handler
修飾(增長一些額外的功能)成另一個 Handler
。net/http
包中的 StripPrefix
和 TimeoutHandler
能夠當作是中間件應用。使用中間件能夠在不修改原有業務邏輯的基礎上方便擴展新功能。 仍以上面的示例代碼爲例。在上面的示例代碼中,實現了兩個 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 協議框架與業務邏輯處理之間的橋樑的。
經過上面的介紹,讀者應該基本上能實現一個微型的 HTTP 框架了。HTTP 協議的解析與組裝能夠直接使用標準庫 http.Server
的實現,加上比標準庫強大點的路由功能,定製一些經常使用的中間件,再加上一些工具函數,一個輕量高效的 HTTP 框架就誕生了。實際上流行的開源框架 gin 作的工做也就是這些。使用這類微型的 HTTP 框架,再加上數據庫相關的及一些其餘的外圍開源庫,就能夠開發企業級應用了。
本文總結了在開發後臺服務時的一些慣常作法和並簡單介紹了相關的技術及開源庫。 後在服務通常做爲守護進程存在。守護進程應該要捕獲經常使用的退出信號,並確保佔用的資源都釋放後才能退出主協程,不響應任何信號讓 golang 運行時採用默認的信號處理動做不是好的作法。回收資源的過程也應該避免耗時過久,可使用標準庫的 context
包來管理工做協程的生命期,工做協程收到退出信號後應該及時退出避免阻塞整個進程的退出。 一般後臺服務離不開數據庫,這裏主要介紹了關係型數據庫和 golang 標準庫的 SQL 接口實現,這個實現使用了模板方法模式:將各類數據數據庫產品的底層協議交互抽象到數據庫驅動層,將 SQL 編程接口抽象成 database/sql
包的公開接口,這些接口的實現是調用固定組合的驅動層接口。特定的數據庫驅動只須要實現驅動層的各個接口,這樣調用者就能經過標準庫統一的接口操做各類各樣的數據庫產品了。ORM 將語言的類型映射成數據庫表,使用者再也不須要再關注操做數據庫的 SQL ,直接操做語言的本地類型對象,ORM 底層會轉換成相應的 SQL 語句。 HTTP 協議做爲互聯網的標配之一,HTTP 庫也歸入到了 golang 的標準庫裏。只使用標準庫寫一個 HTTP 服務仍是比較簡單的。標準庫將 HTTP 協議框架與業務邏輯解耦,二者之間只經過 Handler
接口來鏈接,這個接口只有一個方法。標準庫的 ServerMux
用來實現簡單的路由功能,須要更復雜的路由功能可使用第三方庫 httprouter 或者本身實現一個定製的路由器也不是什麼難事。使用中間件能夠在不修改原有代碼的基礎上擴展新功能。流行的開源框架 gin 也是用中間件大大減輕了 HTTP 服務開發的工做量。