前言
Hertz 是字節跳動服務框架團隊研發的超大規模的企業級微服務 HTTP 框架,具有高易用性、易擴展、低時延等特點。在經過了字節跳動內部一年多的使用和迭代,如今已在 CloudWeGo 正式開源。目前,Hertz 已經成為了字節跳動內部最大的 HTTP 框架,線上接入的服務數量超過 1 萬,峰值 QPS 超過 4 千萬。除了各個業務線的同學使用外,也服務于內部很多基礎組件,如:函數計算平臺 FaaS、壓測平臺、各類網關、Service Mesh 控制面等,均收到不錯的使用反饋。在如此大規模的場景下,Hertz 擁有極強的穩定性和性能,在內部實踐中某些典型服務,如框架占比較高的服務、網關等服務,遷移 Hertz 后相比 Gin 框架,資源使用顯著減少,CPU 使用率隨流量大小降低 30%-60%,時延也有明顯降低。
Hertz 堅持內外維護一套代碼,為開源使用提供了強有力的保障。通過開源, Hertz 也將豐富云原生的 Golang 中間件體系,完善 CloudWeGo 生態矩陣,為更多開發者和企業搭建云原生化的大規模分布式系統,提供一種現代的、資源高效的的技術方案。
本文將重點關注 Hertz 的架構設計與功能特性。
項目緣起
最初,字節跳動內部的 HTTP 框架是對 Gin 框架的封裝,具備不錯的易用性、生態完善等優點。隨著內部業務的不斷發展,高性能、多場景的需求日漸強烈。而 Gin 是對 Golang 原生.NET/http 進行的二次開發,在按需擴展和性能優化上受到很大局限。因此,為了滿足業務需求,更好的服務各大業務線,2020 年初,字節跳動服務框架團隊經過內部使用場景和外部主流開源 HTTP 框架 Fasthttp、Gin、Echo 的調研后,開始基于自研網絡庫 Netpoll 開發內部框架 Hertz,讓 Hertz 在面對企業級需求時,有更好的性能及穩定性表現,也能夠滿足業務發展和應對不斷演進的技術需求。
架構設計
Hertz 設計之初調研了大量業界優秀的 HTTP 框架,同時參考了近年來內部實踐中積累的經驗。為了保證框架整體上滿足:1. 極致性能優化的可能性;2. 面對未來不可控需求的擴展能力, Hertz 采用了 4 層分層設計,保證各個層級功能內聚,同時通過層級之間的接口達到靈活擴展的目標。整體架構圖如圖 1 所示。
圖 1:Hertz 架構圖
Hertz 從上到下分為:應用層、路由層、協議層和傳輸層,每一層各司其職,同時公共能力被統一抽象到公共層(common),做到跨層級復用。另外,同主庫一同發布的還有作為子模塊的 Hz 腳手架,它能夠協助使用者快速搭建出項目核心骨架以及提供實用的構建工具鏈。
應用層
應用層是和用戶直接交互的一層,提供豐富易用的 API,主要包括 Server、Client 和一些其他通用抽象。Server 提供了注冊 HandlerFunc、Binding、Rendering 等能力;Client 提供了調用下游和服務發現等能力;以及抽象一個 HTTP 請求所必須涉及到的請求(Request)、響應(Response)、上下文(RequestContext)、中間件(Middleware)等等。Hertz 的 Server 和 Client 都能夠提供中間件這樣的擴展能力。
應用層中一個非常重要的抽象就是對 Server HandlerFunc 的抽象。早期,Hertz 路由的處理函數 (HandlerFunc)中并沒有接收標準的 context.Context,我們在大量的實踐過程中發現,業務方通常需要一個標準的上下文在 RPC Client 或者日志、Tracing 等組件間傳遞,但由于請求上下文(RequestContext)生命周期局限于一次 HTTP 請求之內,而以上提到的場景往往存在異步的傳遞和處理,導致如果直接傳遞請求上下文,會導致出現一些數據不一致的問題。為此我們做了諸多嘗試,但是因為核心原因在于請求上下文(RequestContext)的生命周期無法優雅的按需延長,最終在各種設計權衡下,我們在路由的處理函數簽名中增加一個標準的上下文入參,通過分離出生命周期長短各異的兩個上下文的方式,從根本上解決各種因為上下文生命周期不一致導致的異常問題,即:
type HandlerFunc func(c context.Context, ctx *App.RequestContext)
路由層
路由層負責根據 URI 匹配對應的處理函數。
起初,Hertz 的路由基于 httprouter 開發,但隨著使用的用戶越來越多,httprouter 漸漸不能夠滿足需求,主要體現在 httprouter 不能夠同時注冊靜態路由和參數路由,即 /a/b,/:c/d 這兩個路由不能夠同時注冊;甚至有一些更特殊的需求,如/a/b、/:c/b ,當匹配 /a/b 路由時,兩個路由都能夠匹配上。
Hertz 為滿足這些需求重新構造了路由樹,用戶在注冊路由時擁有很高的自由度:支持靜態路由、參數路由的注冊;支持按優先級匹配,如上述例子會優先匹配靜態路由 /a/b ;支持路由回溯,如注冊 /a/b、/:c/d,當匹配 /a/d 時仍然能夠匹配上;支持尾斜線重定向,如注冊 /a/b,當匹配 /a/b/ 時能夠重定向到 /a/b 上。Hertz 提供了豐富的路由能力來滿足用戶的需求,更多的功能可以參考 Hertz 配置文檔。
協議層
協議層負責不同協議的實現和擴展。
Hertz 支持協議的擴展,用戶只需要實現下面的接口便可以按照自己的需求在引擎(Engine) 上擴展協議,同時也支持通過 ALPN 協議協商的方式注冊。Hertz 首批只開源了 HTTP1 實現,未來會陸續開源 HTTP2、QUIC 等實現。協議層擴展提供的靈活性甚至可以超越 HTTP 協議的范疇,用戶完全可以按需注冊任意符合自身需求的協議層實現,并且加入到 Hertz 的引擎中來,同時,也能夠無縫享受到傳輸層帶來的極致性能。
type ServerFactory interface {
New(core Core) (server protocol.Server, err error)
}
type Server interface {
Serve(c context.Context, conn network.Conn) error
}
傳輸層
傳輸層負責底層的網絡庫的抽象和實現。
Hertz 支持底層網絡庫的擴展。Hertz 原生完美適配 Netpoll,在時延方面有很多深度的優化,非常適合時延敏感的業務接入。Netpoll 對 TLS 能力的支持有待完善,而 TLS 能力又是 HTTP 框架必備能力,為此 Hertz 底層同時支持基于 Golang 標準網絡庫的實現適配,支持網絡庫的一鍵切換,用戶可根據自己的需求選擇合適的網絡庫進行替換。如果用戶有更加高效的網絡庫或其他網絡庫需求,也完全可以根據需求自行擴展。
Hz 腳手架
與 Hertz 一并開源的還有一個易用的命令行工具 Hz,用戶只需提供一個 IDL,根據定義好的接口信息,Hz 便可以一鍵生成項目腳手架,讓 Hertz 達到開箱即用的狀態;Hz 也支持基于 IDL 的更新能力,能夠基于 IDL 變動智能地更新項目代碼。目前 Hz 支持了 Thrift 和 Protobuf 兩種 IDL 定義。命令行工具內置豐富的選項,可以根據自己的需求使用。同時它底層依賴 Protobuf 官方的編譯器和自研的 Thriftgo 的編譯器,兩者都支持自定義的生成代碼插件。如果默認模板不能夠滿足需求,完全能夠按需定義。
未來,我們將繼續迭代 Hz,持續集成各種常用的中間件,提供更高層面的模塊化構建能力。給 Hertz 的用戶提供按需調整的能力,通過靈活的自定義配置打造一套滿足自身開發需求的腳手架。
Common 組件
Common 組件主要存放一些公共的能力,比如錯誤處理、單元測試能力、可觀測性相關能力(Log、Trace、Metrics 等)。對于服務可觀測性的能力,Hertz 提供了默認的實現,用戶可以按需裝配;如果用戶有特殊的需求,也可以通過 Hertz 提供的接口注入。比如對于 Trace 能力,Hertz 提供了默認的實現,也提供了將 Hertz 和 Kitex 串起來的 Example。如果想注入自己的實現,也可以實現下面的接口:
// Tracer is executed at the start and finish of an HTTP.
type Tracer interface {
Start(ctx context.Context, c *app.RequestContext) context.Context
Finish(ctx context.Context, c *app.RequestContext)
}
功能特性
中間件
Hertz 除了提供 Server 的中間件能力,還提供了 Client 中間件能力。用戶可以使用中間件能力將通用邏輯(如:日志記錄、性能統計、異常處理、鑒權邏輯等等)和業務邏輯區分開,讓用戶更加專注于業務代碼。Server 和 Client 中間件使用方式相同,使用 Use 方法注冊中間件,中間件執行順序和注冊順序相同,同時支持預處理和后處理邏輯。
Server 和 Client 的中間件實現方式并不相同。對于 Server 來說,我們希望減少棧的深度,同時也希望中間件能夠默認的執行下一個,用戶需要手動終止中間件的執行。因此,我們將 Server 的中間件分成了兩種類型,即不在同一個函數調用棧(該中間件調用完后返回,由上一個中間件調用下一個中間件,如圖 2 中 B 和 C)和在同一個函數調用棧的中間件(該中間件調用完后由該中間件繼續調用下一個中間件,如圖 2 中 C 和 Business Handler)。
圖 2: 中間件鏈路
其核心是需要一個地方存下當前的調用位置 index,并始終保持其遞增。恰好 RequestContext 就是一個存儲 index 合適的位置。但是對于 Client,由于沒有合適的地方存儲 index,我們只能退而求其次,拋棄 index 的實現,將所有的中間件構造在同一調用鏈上,需要用戶手動調用下一個中間件。
流式處理
Hertz 提供 Server 和 Client 的流式處理能力。HTTP 的文件場景是十分常見的場景,除了 Server 側的上傳場景之外,Client 的下載場景也十分常見。為此,Hertz 支持了 Server 和 Client 的流式處理。在內部網關場景中,從 Gin 遷移到 Hertz 后,cpu 使用量隨流量大小不同可節省 30%-60% 不等,服務壓力越大,收益越大。Hertz 開啟流式功能的方式也很容易,只需要在 Server 上或 Client 上添加一個配置即可,可參考 CloudWeGo 官網 Hertz 文檔的流式處理部分。
由于 Netpoll 采用 LT 的觸發模式,由網絡庫主動將將數據從 TCP 緩沖區讀到用戶態,并存儲到 buffer 中,否則 epoll 事件會持續觸發。因此 Server 在超大請求的場景下,由于 Netpoll 持續將數據讀到用戶態內存中,可能會有 OOM 的風險。HTTP 文件上傳場景就是一個典型的場景,但 HTTP 上傳服務又是很常見的場景,因此我們支持標準網絡庫 go net,并針對 Hertz 做了特殊優化,暴露出 Read() 接口,防止 OOM 發生。
對于 Client,情況并不相同。流式場景下會將連接封裝成 Reader 暴露給用戶,而 Client 有連接池管理,那這樣連接就多了一種狀態,何時關連接,何時復用連接成了一個問題。由于框架側并不知道該連接何時會用完,框架側復用該連接不現實,會導致串包問題。由于 GC 會關閉連接,因此我們起初設想流式場景下的連接交由用戶后,由 GC 負責關閉,這樣也不會導致資源泄漏。但是在測試后發現,由于 GC 存在一定時間間隔,另外 TCP 中主動關閉連接的一方需要等待 2RTT,在高并發場景下會導致 fd 被打滿的情況。最終我們提供了復用連接的接口,對于性能有場要求用戶,在使用完連接后可以將連接重新放入連接池中復用。
性能表現
Hertz 使用字節跳動自研高性能網絡庫 Netpoll,在提高網絡庫效率方面有諸多實踐,參考已發布文章字節跳動在 Go 網絡庫上的實踐。除此之外,Netpoll 還針對 HTTP 場景進行優化,通過減少拷貝和系統調用次數提高吞吐以及降低時延。為了衡量 Hertz 性能指標,我們選取了社區中有代表性的框架 Gin(net/http)和 Fasthttp 作為對比,如圖 3 所示。可以看到,Hertz 的極限吞吐、TP99 等指標均處于業界領先水平。未來,Hertz 還將繼續和 Netpoll 深度配合,探索 HTTP 框架性能的極限。
圖 3:Hertz 和其他框架性能對比
一個 Demo
下面簡單演示一下 Hertz 是如何開發一個服務的。
- 首先,定義 IDL,這里使用 Thrift 作為 IDL 的定義(也支持使用 Protobuf 定義的 IDL),編寫一個名為 Demo 的 service。這個服務有一個 API: Hello,它的請求參數是一個 query,響應是一個包含一個 RespBody 字段的 Json。
// idl/hello.thrift
namespace go hello.example
struct HelloReq {
1: string Name (api.query="name");
}
struct HelloResp {
1: string RespBody;
}
service HelloService {
HelloResp Hello(1: HelloReq request) (api.get="/hello");
}
- 接下來我們使用 hz 生成代碼,并整理和拉取依賴
$ hz new -idl idl/hello.thrift -mod Demo
$ go mod tidy && go mod verify
- 填充業務邏輯,比如我們返回 hello, ${Name},那我們在biz/handler/example/hello_service.go 中添加以下代碼即可
// Hello .
// @router /hello [GET]
func Hello(ctx context.Context, c *app.RequestContext) {
var err error
var req example.HelloReq
err = c.BindAndValidate(&req)
if err != nil {
c.String(400, err.Error())
return
}
resp := new(example.HelloResp)
resp.RespBody = "hello, " + req.Name
c.JSON(200, resp)
}
- 編譯并運行項目
$ go build
$ ./Demo
到現在一個簡單的 Hertz 項目已經生成,下面我們來測試一下
$ curl http://localhost:8888/hello?name=Xiaoming
// 如果看到以下返回說明服務已經正常啟動起來啦
$ {"RespBody":"hello, Xiaoming"}
(以上 demo 可以在 hertz-examples 中查看) 之后就可以愉快的構建自己的項目了。
后記
希望以上的分享能夠讓大家對 Hertz 有一個整體上的認識。同時,我們也在不斷地迭代 Hertz、完善 CloudWeGo 整體生態。歡迎各位感興趣的同學們加入我們,共同建設 CloudWeGo。
參考資料
- Hertz: https://github.com/cloudwego/hertz
- Hertz Doc: https://www.cloudwego.io/zh/docs/hertz/
- 字節跳動在 Go 網絡庫上的實踐: https://www.cloudwego.io/zh/blog/2021/10/09/%E5%AD%97%E8%8A%82%E8%B7%B3%E5%8A%A8%E5%9C%A8-go-%E7%BD%91%E7%BB%9C%E5%BA%93%E4%B8%8A%E7%9A%84%E5%AE%9E%E8%B7%B5/