01.Server详解与面试要点

PART1. 学习路线

PART2. Web框架的核心

2.1 Web框架的构成

在框架对比的时候,我们注意到对于一个Web框架来说,至少要提供三个抽象:

  • 代表服务器的抽象:这里我们称之为Server

  • 代表上下文的抽象:这里我们称之为Context

  • 路由树

2.2 Server

从前面框架对比来看,对于一个Web框架来说,我们首先要有一个整体代表服务器的抽象,也就是Server.

Server从特性上来说,至少要提供三部分功能:

  • 生命周期控制:即启动、关闭.如果在后期,还要考虑增加生命周期回调特性

    • TODO:这个回调是指啥?

  • 路由注册接口:提供路由注册功能

  • 作为http包到Web框架的桥梁

如果在你的代码中,没有这个代表服务器的抽象(换言之,你的代码里没有定义服务器这个概念,或者说没有用于表示服务器的类),那么很多功能你无法实现.例如上边说过的生命周期控制,你的代码里都没有服务器这个"实体"(这里的实体概念上有点像CRUD中的业务实体),自然也就没有控制这个实体"成往怀灭"的能力的.

同样的,路由注册功能没有Server这个抽象也无法实现.因为不同的路由可能要注册到不同的Server上.回忆一下之前讲过的多端口进程的例子.

PART3. http.Handler接口

那么问题来了,怎么接入go原生的net/http包?

假设我们现在已经定义了一个Server结构体(当然我们现在还不知道它应该有什么成员属性和成员方法),那我们该如何让它和原生的net/http包交互?

先来看看原生的net/http包如何启动一个Server:

package server

import (
	"net/http"
	"testing"
)

func TestServer(t *testing.T) {
	http.ListenAndServe(":8085", nil)
}

这样就已经启动了一个HTTP服务器.那么答案就很明确了:用我们的Server替换掉代码中的nil,那我们的Server就和原生的net/http包交互了.而我们要做的就是在请求与http包之间,做一个WEB框架.

PART4. Server Interface的定义

4.1 我们需要什么?

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

http.ListenAndServe()方法可以看出,我们需要实现一个Handler类型:

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

4.2 构建Server Interface的方案:组合http.Handler

4.2.1 组合http.Handler

通常而言,设计一个稍微复杂的系统,总是需要先使用接口来定义行为,而非直接写实现类的.

工程结构:

(base) root@yuanhong 01-composingHandlerIntoServer % tree ./
./
├── server.go
└── server_test.go

0 directories, 2 files
  • server.go:定义Server接口

package composingHandlerIntoServer

import "net/http"

type Server interface {
	// Handler 组合http.Handler接口
	http.Handler
}
  • server_test.go:使用Server接口

package composingHandlerIntoServer

import (
	"net/http"
	"testing"
)

func TestServer(t *testing.T) {
	var s Server
	http.ListenAndServe(":8085", s)
	http.ListenAndServeTLS(":443", "", "", s)
}

4.2.2 组合http.Handler方案的优缺点

这样设计的优点:

  • 用户在使用的时候只需要调用http.ListenAndServe就可以

  • 和HTTPS协议完全无缝衔接

  • 极简设计(相当于没设计)

这样设计的缺点:

  • 难以控制生命周期,并且很难在控制生命周期的时候增加回调支持

    • 比如在端口监听之后,服务启动之前,想要做一些操作,这样的设计是不支持的.因为从端口启动开始,后续的所有工作都是http包来完成的.我们肯定不能去魔改http包

  • 缺乏控制力:如果将来希望支持优雅退出的功能,将难以支持

    • 只能通过http.Server.Shutdown()/http.Server.Close()等http包里的方法去实现,没办法自己实现.因为这样的设计即使你给自定义Server接口的实现类设计了Shutdown()方法,也调用不到.本质上还是因为从端口启动开始,后续的所有工作都是http包来完成的,我们无法干涉这其中的步骤

4.3 构建Server Interface的方案:组合http.Handler并增加Start()方法

4.3.1 增加Start()方法

在这个方案中,我们希望使用者调用我们的Server.Start()方法来启动HTTP服务器.

工程结构如下:

(base) root@yuanhong 02-composingHandlerAndAddStart % tree ./
./
├── httpServer.go
├── httpServer_test.go
└── serverInterface.go

0 directories, 3 files
  • serverInterface.go:定义Server接口

package composingHandlerAndAddStart

import "net/http"

type Server interface {
	http.Handler
	Start(addr string) error
}
  • httpServer.go:Server接口的HTTP实现

package composingHandlerAndAddStart

import (
	"net"
	"net/http"
)

type HTTPServer struct {
}

func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// TODO implement me
	panic("implement me")
}

func (s *HTTPServer) Start(addr string) error {
	l, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}

	// 在监听端口之后,启动服务之前做一些操作
	// 例如在微服务框架中,启动服务之前需要注册服务

	return http.Serve(l, s)
}

可以看到,这样的设计就支持注册after start回调了.大白话来说就是:相比于直接调用http.ListenAndServe()的方案,这种方案拆解了监听和启动服务这2个步骤,使得在这2个步骤之间可以做一些操作了.

  • httpServer_test.go:使用HTTP Server

package composingHandlerAndAddStart

import "testing"

func TestServer(t *testing.T) {
	s := &HTTPServer{}
	s.Start(":8084")
}

4.3.2 支持HTTPS

可以使用装饰器模式,基于HTTPServer结构体进行再封装:

工程结构如下:

(base) root@yuanhong 02-composingHandlerAndAddStart % tree ./
./
├── httpServer.go
├── httpServer_test.go
├── httpsServer.go
└── serverInterface.go

0 directories, 4 files
  • httpsServer.go:Server接口的HTTPS实现

package composingHandlerAndAddStart

type HTTPSServer struct {
	HTTPServer
}

这个不是现在的重点,只是提一下可以这么去设计.

4.3.3 在Start()方法内部创建一个原生的http.Server对象

  • 工程结构如下:

(base) root@yuanhong 02-composingHandlerAndAddStart % tree ./
./
├── createHttpServerInStart.go
├── httpServer.go
├── httpServer_test.go
├── httpsServer.go
└── serverInterface.go

0 directories, 5 files
  • createHttpServerInStart.go:Server接口的HTTP实现

package composingHandlerAndAddStart

import (
	"net"
	"net/http"
)

type HTTPServerWithOriginHttpServer struct {
}

func (s *HTTPServerWithOriginHttpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// TODO implement me
	panic("implement me")
}

func (s *HTTPServerWithOriginHttpServer) Start(addr string) error {
	originServer := http.Server{
		Addr:              addr,
		Handler:           s,
	}

	l, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}

	return originServer.Serve(l)
}

4.3.4 组合http.Handler并增加Start()方法的优缺点

这样设计的优点:

  • Server接口的实现既可以当成普通的http.Handler接口的实现来使用,又可以作为一个独立的实体,拥有自己的管理生命周期的能力

  • 完全的控制,可以为所欲为

这样设计的缺点:

  • 如果用户不希望通过调用http.ListenAndServeTLS()的方式来实现HTTPS支持,那么Server接口需要提供HTTPS的支持(也就是HTTPS的实现)

PART5. ServeHTTP的实现

ServeHTTP()方法是处理请求的入口.这里说的"入口"指的是你设计的WEB框架的入口.在这个方法中需要完成3个操作:

  • Context构建

  • 路由匹配

  • 执行业务逻辑

PART6. 注册路由API设计

上文说过,ServeHTTP()方法作为WEB框架的入口,需要完成3个操作:

  • Context构建:还没设计了先不考虑

  • 路由匹配:首先需要把路由注册到Server上,才能考虑匹配的过程

  • 执行业务逻辑:这里也先不考虑

首先需要在接口中定义路由注册的行为.参考GIN的IRoutes接口/Iris的APIBuilder结构体/Echo的Echo结构体,他们都是把注册路由的行为定义在接口上的.

在之前的课程分析GIN框架时说过,实际上Engine结构体的GET()/POST()等HTTP动词方法,本质上调用的都是Handle()方法.所以注册路由的方法分为2类:

  • 针对任意方法的:如Gin和Iris的Handle()方法、Echo的Add()方法

  • 针对不同HTTP方法的:如GET()POST()DELETE()这一类方法基本上都是委托给前一类方法

所以实际上核心方法只需要有一个,例如Handle()方法.其它的方法都建立在这上面.

PART7. AddRoute方法

在本例中,我们将上文的Handle()方法命名为AddRoute()方法.从命名上来讲,Handle()表示"处理"的含义,而实际上这个方法要完成的操作是注册路由,而非处理,因此用AddRoute()更合适.

7.1 定义注册路由行为

  • 工程结构如下:

(base) root@yuanhong 03-serveHTTP % tree ./
./
├── handleFunc.go	// 定义业务逻辑函数类型
├── httpServer.go	// 定义HTTP服务器
└── serverInterface.go	// 定义HTTP服务器接口

0 directories, 3 files
  • handleFunc.go:定义业务逻辑函数类型

package serveHTTP

// HandleFunc 定义业务逻辑函数类型
// Tips: 该类型应与http.HandlerFunc类型一致 此处只是暂时定义一下这个类型
type HandleFunc func()

注:此处将其命名为HandleFunc而非HandlerFunc,是因为handle是动词而handler是名词,后边跟的Func很明显是名词.使用动名词的组合更加符合GO的命名风格.

  • serverInterface.go:定义HTTP服务器接口

package serveHTTP

import "net/http"

// Server WEB服务器接口
type Server interface {
	// Handler 组合http.Handler接口
	http.Handler

	// Start 启动WEB服务器
	Start(addr string) error

	// AddRoute 注册路由
	AddRoute(method string, path string, handleFunc HandleFunc)
}
  • httpServer.go:定义HTTP服务器

package serveHTTP

import (
	"net"
	"net/http"
)

// 为确保HTTPServer结构体为Server接口的实现而定义的变量
var _ Server = &HTTPServer{}

// HTTPServer HTTP服务器
type HTTPServer struct {
}

// ServeHTTP WEB框架入口
func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// TODO implement me
	panic("implement me")
}

// Start 启动WEB服务器
func (s *HTTPServer) Start(addr string) error {
	l, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}

	// 在监听端口之后,启动服务之前做一些操作
	// 例如在微服务框架中,启动服务之前需要注册服务

	return http.Serve(l, s)
}

// AddRoute 注册路由
func (s *HTTPServer) AddRoute(method string, path string, handleFunc HandleFunc) {
	// TODO: implement me
	panic("implement me")
}

7.2 定义Context

其实相比7.1小节,就是增加了一个Context类型的定义,并修改了HandleFunc类型的入参

  • 工程结构如下:

(base) root@yuanhong 03-serveHTTP % tree ./
./
├── context.go	// 定义HandleFunc的上下文
├── handleFunc.go
├── httpServer.go
└── serverInterface.go

0 directories, 4 files
  • context.go:定义HandleFunc的上下文

package serveHTTP

// Context HandleFunc的上下文
type Context struct {
}
  • handleFunc.go:

package serveHTTP

// HandleFunc 定义业务逻辑函数类型
// Tips: 该类型应与http.HandlerFunc类型一致 此处只是暂时定义一下这个类型
type HandleFunc func(ctx Context)

其他2个文件没有变化.

7.3 思考:是否需要AddRoutes()方法?

7.3.1 如果需要AddRoutes()方法,该如何实现?

来看GIN的Engine.Handle()方法:

func (group *RouterGroup) Handle(httpMethod, relativePath string, handlers ...HandlerFunc) IRoutes {
	if matched := regEnLetter.MatchString(httpMethod); !matched {
		panic("http method " + httpMethod + " is not valid")
	}
	return group.handle(httpMethod, relativePath, handlers)
}

可以看到该方法是支持1次注册多个逻辑处理函数到路由上的.如果我们要是也支持这个功能,该如何实现?

  • 工程结构如下:

(base) root@yuanhong 04-addRoutes % tree ./
./
├── context.go
├── handleFunc.go
├── httpServer.go
└── serverInterface.go

0 directories, 4 files
  • serverInterface.go:

package addRoutes

import "net/http"

// Server WEB服务器接口
type Server interface {
	// Handler 组合http.Handler接口
	http.Handler

	// Start 启动WEB服务器
	Start(addr string) error

	// AddRoute 注册路由
	AddRoute(method string, path string, handleFunc HandleFunc)

	// AddRoutes 支持1个路由对应多个处理函数的注册路由
	AddRoutes(method string, path string, handles ...HandleFunc)
}
  • httpServer.go:

package addRoutes

import (
	"net"
	"net/http"
)

// 为确保HTTPServer结构体为Server接口的实现而定义的变量
var _ Server = &HTTPServer{}

// HTTPServer HTTP服务器
type HTTPServer struct {
}

// ServeHTTP WEB框架入口
func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// TODO implement me
	panic("implement me")
}

// Start 启动WEB服务器
func (s *HTTPServer) Start(addr string) error {
	l, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}

	// 在监听端口之后,启动服务之前做一些操作
	// 例如在微服务框架中,启动服务之前需要注册服务

	return http.Serve(l, s)
}

// AddRoute 注册路由
func (s *HTTPServer) AddRoute(method string, path string, handleFunc HandleFunc) {
	// TODO: implement me
	panic("implement me")
}

// AddRoutes 支持1个路由对应多个处理函数的注册路由
func (s *HTTPServer) AddRoutes(method string, path string, handles ...HandleFunc) {
	// TODO: implement me
	panic("implement me")
}

7.3.2 从使用的角度思考是否需要AddRoutes()方法

写个单元测试,演示一下如何使用AddRoutes()方法:

  • 工程结构如下:

(base) root@yuanhong 04-addRoutes % tree ./
./
├── context.go
├── handleFunc.go
├── httpServer.go
├── httpServer_test.go		// 演示如何调用Server实现的单测
└── serverInterface.go

0 directories, 5 files
  • httpServer_test.go:演示如何调用Server实现的单测

package addRoutes

import (
	"fmt"
	"net/http"
	"testing"
)

func TestServer(t *testing.T) {
	s := &HTTPServer{}

	// 注册多个处理函数到路由
	handleFoo := func(ctx Context) { fmt.Println("处理第1件事") }
	handleBar := func(ctx Context) { fmt.Println("处理第2件事") }

	s.AddRoutes(http.MethodGet, "/getUser", handleFoo, handleBar)

	_ = s.Start(":8080")
}

其他文件无变化.

7.3.3 尝试使用AddRoute()方法实现同等效果

完全可以将7.3.2示例中的handleFoohandleBar封装成1个函数,然后调用AddRoute()方法,其实是可以达到同等效果的.

  • 工程结构如下:

(base) root@yuanhong 03-serveHTTP % tree ./
./
├── context.go
├── handleFunc.go
├── httpServer.go
├── httpServer_test.go		// 演示如何调用Server实现的单测
└── serverInterface.go

0 directories, 5 files
  • httpServer_test.go:演示如何调用Server实现的单测

package serveHTTP

import (
	"fmt"
	"net/http"
	"testing"
)

func TestServer(t *testing.T) {
	s := &HTTPServer{}

	// 注册1个处理函数到路由
	handleFoo := func(ctx Context) { fmt.Println("处理第1件事") }
	handleBar := func(ctx Context) { fmt.Println("处理第2件事") }

	// 将2个处理函数封装为1个处理函数
	handleAssemble := func(ctx Context) {
		handleFoo(ctx)
		handleBar(ctx)
	}

	s.AddRoute(http.MethodGet, "/getUser", handleAssemble)

	_ = s.Start(":8080")
}

也就是说,让使用者自行将多个处理函数封装为1个,然后交给WEB框架注册路由.

7.3.4 结论

不需要支持AddRoutes()方法.

理由:把"将多个函数组合成1个函数"的职责交由使用者去实现即可

而且,如果需要支持AddRoutes()方法,还会带来一些其他问题:

  1. 如果允许注册多个函数,那么在实现的时候就要考虑,若其中一个HandleFunc执行失败了,是否还允许继续执行后续的HandleFunc?反之,如果其中一个HandleFunc要中断后续执行,该怎么中断?

    • 中断后续执行:例如共有5个HandleFunc,但执行完第2个之后,业务逻辑上判断后续的HandleFunc不需要执行了,即为中断后续执行

  2. 站在使用者的视角上来看,由于AddRoutes()方法中的handles是不定长参数,因此使用时可能出现一个函数都不传的情况.这种情况在编译期间不会被发现

另外,Echo框架虽然不支持传入多个注册函数的功能,但它保留了传入的HandleFunc为nil的可能性.实际上Echo是将中间件注册逻辑和路由注册逻辑合并在了一起:

// GET registers a new GET route for a path with matching handler in the router
// with optional route-level middleware.
func (e *Echo) GET(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
	return e.Add(http.MethodGet, path, h, m...)
}

7.4 AddRoute方法的衍生方法

针对不同HTTP方法的注册API,都可以委托给AddRoute()方法.这种设计思路很常用.

在我们的设计中,我们不认为针对HTTP动词的路由注册方法是框架的核心方法.保持接口简洁,但这并不意味着实现不能复杂.

因此,我们将GET()/POST()等方法实现在HTTPServer结构体上.

  • 工程结构如下:

(base) root@yuanhong 03-serveHTTP % tree ./     
./
├── context.go
├── handleFunc.go
├── httpServer.go
├── httpServer_test.go
└── serverInterface.go

0 directories, 5 files
  • httpServer.go:

package serveHTTP

import (
	"net"
	"net/http"
)

// 为确保HTTPServer结构体为Server接口的实现而定义的变量
var _ Server = &HTTPServer{}

// HTTPServer HTTP服务器
type HTTPServer struct {
}

// ServeHTTP WEB框架入口
func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// TODO implement me
	panic("implement me")
}

// Start 启动WEB服务器
func (s *HTTPServer) Start(addr string) error {
	l, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}

	// 在监听端口之后,启动服务之前做一些操作
	// 例如在微服务框架中,启动服务之前需要注册服务

	return http.Serve(l, s)
}

// AddRoute 注册路由
func (s *HTTPServer) AddRoute(method string, path string, handleFunc HandleFunc) {
	// TODO: implement me
	panic("implement me")
}

// GET 注册GET请求路由
func (s *HTTPServer) GET(path string, handleFunc HandleFunc) {
	s.AddRoute(http.MethodGet, path, handleFunc)
}

// POST 注册POST请求路由
func (s *HTTPServer) POST(path string, handleFunc HandleFunc) {
	s.AddRoute(http.MethodPost, path, handleFunc)
}

其他文件代码无变化.

这样设计的话,就需要注意在使用时,初始化HTTPServer结构体时,不能将其类型声明为Server接口:

package serveHTTP

import (
	"fmt"
	"net/http"
	"testing"
)

func TestServer(t *testing.T) {
	// Tips: 初始化HTTPServer结构体时,不能将其类型声明为Server接口的实现,因为一些方法被我们定义在了
	// Tips: 实现上,而不是在接口上,所以不能将其类型声明为Server接口的实现.即:不能写成如下形式:
	// var s Server = &HTTPServer{}
	s := &HTTPServer{}

	// 注册1个处理函数到路由
	handleFoo := func(ctx Context) { fmt.Println("处理第1件事") }
	handleBar := func(ctx Context) { fmt.Println("处理第2件事") }

	// 将2个处理函数封装为1个处理函数
	handleAssemble := func(ctx Context) {
		handleFoo(ctx)
		handleBar(ctx)
	}

	s.AddRoute(http.MethodGet, "/getUser", handleAssemble)

	_ = s.Start(":8080")
}

AddRoute()方法最终会和路由树交互,我们后面再考虑.

如上图示,保持核心API的方法较少,而衍生API的方法较多,是我们这个框架的核心设计思想.因此在设计的时候,需要区分核心API和衍生API.

PART8. ServeHTTP()方法

ServeHTTP()方法是作为http包与WEB框架的关联点,需要在ServeHTTP()方法内部完成如下操作:

  • 构建起WEB框架的上下文

  • 查找路由树,并执行命中路由的代码

说句再直白一点的话:请求进来了就执行ServeHTTP()方法

8.1 定义Context

那么问题来了,一个HandleFunc的上下文是什么?首先至少肯定有请求响应.因此我们的Context就需要能够将请求响应"带"到具体的HandleFunc中去.

  • 工程结构如下:

(base) root@yuanhong 03-serveHTTP % tree ./
./
├── context.go
├── handleFunc.go
├── httpServer.go
├── httpServer_test.go
└── serverInterface.go

0 directories, 5 files
  • context.go:

package serveHTTP

import "net/http"

// Context HandleFunc的上下文
type Context struct {
	// Req 请求
	Req *http.Request
	// Resp 响应
	Resp http.ResponseWriter
}
  • httpServer.go:修改了ServeHTTP()方法的代码.增加了构建上下文的部分.

package serveHTTP

import (
	"net"
	"net/http"
)

// 为确保HTTPServer结构体为Server接口的实现而定义的变量
var _ Server = &HTTPServer{}

// HTTPServer HTTP服务器
type HTTPServer struct {
}

// ServeHTTP WEB框架入口
func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// 构建上下文
	ctx := &Context{
		Req:  r,
		Resp: w,
	}

	// 查找路由树并执行命中的业务逻辑
	s.serve(ctx)

	// TODO implement me
	panic("implement me")
}

// serve 查找路由树并执行命中的业务逻辑
func (s *HTTPServer) serve(ctx *Context)  {
	// TODO implement me
	panic("implement me")
}

// Start 启动WEB服务器
func (s *HTTPServer) Start(addr string) error {
	l, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}

	// 在监听端口之后,启动服务之前做一些操作
	// 例如在微服务框架中,启动服务之前需要注册服务

	return http.Serve(l, s)
}

// AddRoute 注册路由
func (s *HTTPServer) AddRoute(method string, path string, handleFunc HandleFunc) {
	// TODO: implement me
	panic("implement me")
}

// GET 注册GET请求路由
func (s *HTTPServer) GET(path string, handleFunc HandleFunc) {
	s.AddRoute(http.MethodGet, path, handleFunc)
}

// POST 注册POST请求路由
func (s *HTTPServer) POST(path string, handleFunc HandleFunc) {
	s.AddRoute(http.MethodPost, path, handleFunc)
}

PART9. 面试要点

9.1 HTTP服务器的生命周期?

一般来说就是启动、运行和关闭.在这三个阶段的前后都可以插入生命周期回调.一般来说,面试生命周期,多半都是为了后边问生命周期回调.例如说怎么做WEB服务的服务发现?就是利用生命周期回调的启动后回调,将WEB服务注册到服务中心.

9.2 HTTP Server的功能?

记住在不同的框架里面有不同的叫法.比如说在Gin里面叫做Engine,它们的基本功能都是提供路由注册、生命周期控制以及作为与http包结合的桥梁.

附录

问题1. 生命周期回调究竟是个啥?

Last updated