手搓 Web 框架 -- #2 实现一个服务器抽象
Web 核心
在框架对比的时候,我们注意到对于一个 Web 框架来说,至少要提供三个抽象:
- 代表服务器的抽象,这里我们称之为 Server
- 代表上下文的抽象,这里我们称之为 Context
- 路由树
Server 概述
从前面框架对比来看,对于一个 Web 框架来说,我们首先要有一个整体代表服务器的抽象,也就是 Server。
Server 从特性上来说,至少要提供三部分功能:
- 生命周期控制:即启动、关闭。如果在后期,我们还要考虑增加生命周期回调特性
- 路由注册接口:提供路由注册功能
- 作为 http 包到 Web 框架的桥梁
http.Handler 接口
http 包暴露了一个接口,Handler。
它是我们引入自定义 Web 框架相关的连接点。
// ListenAndServe listens on the TCP network address addr and then calls
// Serve with handler to handle requests on incoming connections.
// Accepted connections are configured to enable TCP keep-alives.
//
// The handler is typically nil, in which case the DefaultServeMux is used.
//
// ListenAndServe always returns a non-nil error.
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
Server 接口定义
Server 定义版本一:只组合 http.Handler。
优点:
- 用户在使用的时候只需要调用 http.ListenAndServe 就可以
- 和 HTTPS协议完全无缝衔接
- 极简设计
缺点:
- 难以控制生命周期,并且在控制生命周期的时候增加回调支持
- 缺乏控制力:如果将来希望支持优雅退出的功能,将难以支持
type Server interface {
http.Handler
}
func TestServer(t *testing.T) {
var s Server
http.ListenAndServe(":8080", s)
http.ListenAndServeTLS(":40000", "certFile", "keyFile", s)
}
Server 定义版本二:组合 http.Handler 并且增加 Start 方法
优点:
- Server既可以当成普通的 http.Handler 来使用,又可以作为一个独立的实体,拥有自己的管理生命周期的能力
- 完全的控制,可以为所欲为
缺点:
- 如果用户不希望直接使用 ListenAndServeTLS 的方法,那么 Server 需要提供 HTTPS 的支持
版本一和版本二都直接耦合了 Go 自带的 http 包,如果我们希望切换为 fasthttp 或者类似的 http 包,则会非常困难。
type Server interface {
http.Handler
Start(addr string) error
}
func TestServer(t *testing.T) {
var s Server
s.Start(":8080")
}
注意:Start 方法可以不需要 addr 参数,那么在创建实现类的时候传入地址就可以。
Server 接口实现
该实现直接使用 http.ListenAndServe 来启动,后续可以根据需要替换为:
- 内部创建 http.Server 来启动
- 使用 http.Serve 来启动,换取更大的灵活性,如将端口监听和服务器启动分离等
ServeHTTP 则是我们整个 Web 框架的核心入口。 我们将在整个方法内部完成:
- Context 构建
- 路由匹配
- 执行业务逻辑
func Start() {
var s Server = &HTTPServer{}
var h1 HandleFunc = func(context *Context) {
fmt.Println("步骤1")
time.Sleep(time.Second)
}
var h2 HandleFunc = func(context *Context) {
fmt.Println("步骤2")
time.Sleep(time.Second)
}
s.AddRoute(http.MethodPost, "/user", func(context *Context) {
// 循环调用多个 handlefunc
h1(context)
h2(context)
})
s.AddRoute(http.MethodPost, "/user", nil)
// s.AddRoutes(http.MethodPost, "/user")
// http.ListenAndServe(":8081", s)
// http.ListenAndServeTLS("4000", "xxx", "aaa", s)
s.Start(":8082")
}
type Context struct {
Req *http.Request
Writer http.ResponseWriter
}
type HandleFunc func(*Context)
type Server interface {
http.Handler
Start(addr string) error
// AddRoute 注册路由的核心抽象
AddRoute(method, path string, handler HandleFunc)
// 不支持这种签名,因为不知道怎么调度 handlers
// 又或者用户一个都不传如何处理
// AddRoutes(method, path string, handlers ...HandleFunc)
}
type HTTPServer struct {
}
func (m *HTTPServer) AddRoute(method, path string, handler HandleFunc) {
}
func (m *HTTPServer) Get(path string, handler HandleFunc) {
m.AddRoute(http.MethodGet, path, handler)
}
func (m *HTTPServer) Post(path string, handler HandleFunc) {
m.AddRoute(http.MethodPost, path, handler)
}
func (m *HTTPServer) Start(addr string) error {
// 端口启动前
listener, err := net.Listen("tcp", ":8081")
if err != nil {
return err
}
// 端口启动后
// web 服务的服务发现
// 注册本服务器到你的管理平台
// 比如说你注册到 etcd,然后你打开管理界面,你就能看到这个实例
// 10.0.0.1:8081
println("成功监听端口 8081")
// http.Serve 接收了一个 Listener
return http.Serve(listener, m)
// 这个是阻塞的
return http.ListenAndServe(addr, m)
// 你没办法在这里做点什么
}
type HTTPSServer struct {
// HTTPServer
Server
CertFile string
KeyFile string
}
func (m *HTTPSServer) Start(addr string) error {
return http.ListenAndServeTLS(addr, m.CertFile, m.KeyFile, m)
}
func (m *HTTPServer) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
ctx := &Context{
Req: request,
Writer: writer,
}
// 接下来就是
// 查找路由
// 执行业务逻辑
m.serve(ctx)
}
func (m *HTTPServer) serve(ctx *Context) {
}
注册路由 API 设计
暂时我们可以考虑先站在用户的角度,考虑如何注册路由。
大体上有两类方法:
- 针对任意方法的:如 Gin 和 Iris 的 Handle 方法、Echo 的 Add 方法
- 针对不同 HTTP 方法的:如 Get、POST、Delete,这一类方法基本上都是委托给前一类方法
AddRoute 方法
思考点:
- AddRoute 方法只接收一个 HandleFunc。因为我希望它只注册业务逻辑。即便真有多个的场景,用户可以自己组合成一个。
- 如果允许注册多个,那么在实现的时候就要考虑,其中一个失败了,是否还允许继续执行下去;反过来,如果其中一个 HandleFunc 要中断执行,怎么中断。
- 这里我采用了新的名字 AddRoute,我认为这更加贴近这个方法本意。
- Handle:看上去像是处理什么东西,而实质上我们这里只是注册路由,所以用 AddRoute 会更加合适
type Server interface {
http.Handler
// Start 启动服务器
// addr 是监听地址。如果只指定端口,可以使用 ":8081"
// 或者 "localhost:8082"
Start(addr string) error
// addRoute 注册一个路由
// method 是 HTTP 方法
addRoute(method string, path string, handler HandleFunc)
// 我们并不采取这种设计方案
// addRoute(method string, path string, handlers... HandleFunc)
}
这里我们叫做 HandleFunc 而不是 HandlerFunc,采用动词 Handle 更加符合Go 的命名风格。
type HandleFunc func(ctx *Context)
其它三个框架都是允许注册多个,但其实用起来体验不会很好。
- Gin 和 Iris 最后一个是不定参数,那么完全可以一个都不传,如
PUT("path")
。这个在编译期无法发现 - Echo 则是存在我希望 h 传入 nil 的可能。实际上 Echo 是将中间件注册逻辑和路由注册逻辑合并在了一起
AddRoute 衍生方法
针对不同 HTTP 方法的注册 API,都可以委托给 Handle 方法。这种设计思路很常用。
func (s *HTTPServer) Post(path string, handler HandleFunc) {
s.addRoute(http.MethodPost, path, handler)
}
func (s *HTTPServer) Get(path string, handler HandleFunc) {
s.addRoute(http.MethodGet, path, handler)
}
ServeHTTP 方法
ServeHTTP 方法是作为 http 包与 Web 框架的关联点,需要在 ServeHTTP 内部,执行:
- 构建起 Web 框架的上下文
- 查找路由树,并执行命中路由的代码
type Context struct {
Req *http.Request
Resp http.ResponseWriter
}
// ServeHTTP HTTPServer 处理请求的入口
func (s *HTTPServer) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
ctx := &Context{
Req: request,
Resp: writer,
}
s.serve(ctx) // 查找路由,执行代码
}
func (s *HTTPServer) serve(ctx *Context) {
n, ok := s.findRoute(ctx.Req.Method, ctx.Req.URL.Path)
if !ok || n.handler == nil {
ctx.Resp.WriteHeader(404)
ctx.Resp.Write([]byte("Not Found"))
return
}
n.handler(ctx)
}
Server 小结
- HTTP 服务器的生命周期?一般来说就是启动、运行和关闭。在这三个阶段的前后都可以插入生命周期回调。一般来说,面试生命周期,多半都是为了问生命周期回调。例如说怎么做 Web 服务的服务发现?就是利用生命周期回调的启动后回调,将 Web 服务注册到服务中心。
- HTTP Server 功能?记住在不同的框架里面有不同的叫法,比如说在 Gin 里面叫做 Engine,它们的基本功能都是提供路由注册,生命周期控制以及作为与 http 包结合的桥梁。
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。