简易实现 Go 的 Web 框架(上) | 青训营

最近一段时间学习了Golang,为了总结学习成果,学习更多知识,提升代码能力,所以开启了手写Gee框架的项目。

一、Http基础

1.标准库启动Web服务

首先,让我们用Go语言内置的http库撸一个Web项目:

go 复制代码
func main() {
	http.HandleFunc("/", indexHandler)
	http.HandleFunc("/hello", helloHandler)
	log.Fatal(http.ListenAndServe(":9999", nil))
}

func indexHandler(w http.ResponseWriter, req *http.Request) {
	fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
}

func helloHandler(w http.ResponseWriter, req *http.Request) {
	for k, v := range req.Header {
		fmt.Fprintf(w, "Header[%q]\n", k, v)
	}
}

非常容易理解,我们定义了两个路由,分别是"/"和"/hello"。走"/"这条路由时,会调用indexHandler函数,走"/hello"这条路由时,会调用helloHandler函数。然后http.ListenAndServe函数帮我们开启9999端口,运行即可。

说明:

(1)、Printf()、Sprintf()、Fprintf() 函数都是输出格式化字符串,只是输出到的目标不一样:

Printf() 是把格式化字符串输出到标准到标准输出(一般是屏幕,可以重定向)

Sprintf() 是把格式化字符串输出到指定的字符串中,可以用一个变量来接受,然后在打印

Fprintf() 是把格式字符串输出到指定的文件设备中,所以参数比Printf 多一个文件指针*File。主要用于文件操作,Fprintf() 是格式化输出到一个 Stream ,通常是一个文件

(2)、我们的req到底拥有哪些属性呢,不妨查看一下http.Request的源码,为了方便阅读,我在每个字段后加上注释。

go 复制代码
type Request struct {

    Method string		//指定HTTP方法(GET,POST,PUT等)

    URL *url.URL		//URL指定要请求的URI(对于服务器请求)或要访问的URL(用于客户请求)

    Proto      string // "HTTP/1.0"
    ProtoMajor int    // 1
    ProtoMinor int    // 0

    Header Header		//Http请求头

    Body io.ReadCloser		//Http请求体

    GetBody func() (io.ReadCloser, error)		//客户端使用的方法的类型
	
    ContentLength int64		//记录请求关联内容的长度

    TransferEncoding []string		//列出从最外层到最内层的传输编码

    Close bool		//表示在服务端回复请求或者客户端读取到响应后是否要关闭连接

    Host string		//指定URL所在的主机

    Form url.Values		//包含已解析的表单数据

    PostForm url.Values		//包含来自PATCH,POST的已解析表单数据或PUT主体参数

    MultipartForm *multipart.Form		//已解析的多部分表单数据

    Trailer Header

    RemoteAddr string		//允许HTTP服务器和其他软件记录发送请求的网络地址

    RequestURI string		//未修改的request-target客户端发送的请求行

    TLS *tls.ConnectionState

    Cancel <-chan struct{}

    Response *Response		//

    ctx context.Context
}

2.实现http.Hander接口

观察http.ListenAndServe关于第二个参数的源码,也就是我们最初填的nil,可以看到:

go 复制代码
type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

我们发现,Handler是一个接口,需要实现ServeHTTP(ResponseWriter, *Request)这样一个方法。所以定义一个实现此方法的结构体传进去,那么所有的HTTP请求,都会交给我们定义的方法处理。

go 复制代码
type Engine struct{}

func (e Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	switch req.URL.Path {
	case "/":
		fmt.Fprintf(w, "URL.Path=%q\n", req.URL.Path)
	case "/hello":
		for k, v := range req.Header {
			fmt.Fprintf(w, "Header[%q]=%q\n", k, v)
		}
	default:
		fmt.Fprintf(w, "404 NOT FOUND:%s\n", req.URL)
	}
}

func main() {
	engine := new(Engine)
	log.Fatal(http.ListenAndServe(":9999", engine))
}

可以看到Engine结构体实现了方法ServeHTTP,在ListenAndServe方法中传入了engine后,所有的HTTP请求都会被实现的ServeHttp函数拦截,进行switch-case匹配。

Gee框架雏形

代码架构如下所示:

css 复制代码
gee/
  |--gee.go
main.go

接下来让我们看一下自己手撸的gee框架,gee.go

go 复制代码
type HandlerFunc func(http.ResponseWriter, *http.Request)

type Engine struct {
	router map[string]HandlerFunc
}

func (e *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	key := req.Method + "-" + req.URL.Path
	if handler, ok := e.router[key]; ok {
		handler(w, req)
	} else {
		fmt.Fprintf(w, "404 NOT FOUND :%s\n", req.URL)
	}
}

func New() *Engine {
	return &Engine{router: make(map[string]HandlerFunc)}
}

func (e *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
	key := method + "-" + pattern
	e.router[key] = handler
}

func (e *Engine) GET(pattern string, handler HandlerFunc) {
	e.addRoute("GET", pattern, handler)
}

func (e *Engine) POST(pattern string, handler HandlerFunc) {
	e.addRoute("POST", pattern, handler)
}

func (e *Engine) Run(addr string) (err error) {
	return http.ListenAndServe(addr, e)
}

像上文中提到的,要先定义一个Engine结构体,并让它实现ServeHTTP方法,而与上文不同的是,Engine里定义了一个map路由,key对应着"请求方法-方法名",value对应着要调用的函数;New()没什么可说的,相当于Engine的构造器,初始化了一个空的Engine;addRoute则是对Engine里的router进行赋值,将"请求方法-方法名"赋值给key,要调用的函数赋值给value。GET()和POST()本质上都是addRoute,只是写死了请求的方式。Run()则是调用了http.ListenAndServe,启动了一个web服务。

然后就到了我们的main.go部分

go 复制代码
func main() {
	r := gee.New()
	r.GET("/", func(w http.ResponseWriter, req *http.Request) {
		fmt.Fprintf(w, "YRL.Path=%q\n", req.URL.Path)
	})
	r.GET("/hello", func(w http.ResponseWriter, req *http.Request) {
		for k, v := range req.Header {
			fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
		}
	})
	r.Run(":9999")
}

使用过Gin以后再看这一段代码,确实感到无比的亲切。首先New()初始化路由,接着给路由定义了两个方法,分别对应着"/"和"/hello"路径,用户以GET请求访问这两个路径时,就会调用相应的函数,并将服务运行在9999端口。

二、上下文Context

在前文中,给一个路径绑定对应的方法,采用的是r.GET("/", func(w http.ResponseWriter, req *http.Request){xxxxx},可在使用gin框架的过程中,会发现是这种形式r.GET("/",func (c *gin.Context){xxxxxxxx}),那如何把w http.ResponseWriter, req *http.Request这两个无比长的东西封装成gin.Context呢,这就是我们本次要解决的内容。

请看我们的/gee/Context.go

go 复制代码
type H map[string]interface{}

type Context struct {
	Writer     http.ResponseWriter
	Req        *http.Request
	Path       string
	Method     string
	StatusCode int
}

func newContext(w http.ResponseWriter, req *http.Request) *Context {
	return &Context{
		Writer: w,
		Req:    req,
		Path:   req.URL.Path,
		Method: req.Method,
	}
}

func (c *Context) PostForm(key string) string {
	return c.Req.FormValue(key)
}

func (c *Context) Query(key string) string {
	return c.Req.URL.Query().Get(key)
}

func (c *Context) Status(code int) {
	c.StatusCode = code
	c.Writer.WriteHeader(code)
}

func (c *Context) SetHeader(key string, value string) {
	c.Writer.Header().Set(key, value)
}

func (c *Context) String(code int, format string, values ...interface{}) {
	c.SetHeader("Content-Type", "text/plain")
	c.Status(code)
	c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
}

func (c *Context) JSON(code int, obj interface{}) {
	c.SetHeader("Content-Type", "application/json")
	c.Status(code)
	encoder := json.NewEncoder(c.Writer)
	if err := encoder.Encode(obj); err != nil {
		http.Error(c.Writer, err.Error(), 500)
	}
}

func (c *Context) Data(code int, data []byte) {
	c.Status(code)
	c.Writer.Write(data)
}

func (c *Context) HTML(code int, html string) {
	c.SetHeader("Content-Type", "text/html")
	c.Status(code)
	c.Writer.Write([]byte(html))
}

首先,我们给map[string]interface{}取名为H,使用时更加简便。而要把http.ResponseWriter*http.Request封装为Context的思路很简单,无非就是定义个结构体,把它们塞进去!针对于这个Context结构体,我们又定义了很多个方法,看起来很复杂,但无非就是获取结构体里的值,或者是给结构体里面的东西赋值。

为了方便对路由进行管理,以及后续的扩展功能,我们把它单独拎出来放到gee/route.go里面:

go 复制代码
type router struct {
	handlers map[string]HandlerFunc
}

func newRouter() *router {
	return &router{handlers: make(map[string]HandlerFunc)}
}

func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {
	log.Printf("Route %4s - %s", method, pattern)
	key := method + "-" + pattern
	r.handlers[key] = handler
}

func (r *router) handle(c *Context) {
	key := c.Method + "-" + c.Path
	if handler, ok := r.handlers[key]; ok {
		handler(c)
	} else {
		c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
	}
}

可以发现其中微小的调整,就是handler的参数,变成了 Context。

在剔除router的部分后,gee/gee.go就变成了以下样子

go 复制代码
type HandlerFunc func(*Context)

type Engine struct {
	router *router
}

func (e *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	c := newContext(w, req)
	e.router.handle(c)
}

func New() *Engine {
	return &Engine{router: newRouter()}
}

func (e *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
	e.router.addRoute(method, pattern, handler)
}

func (e *Engine) GET(pattern string, handler HandlerFunc) {
	e.addRoute("GET", pattern, handler)
}

func (e *Engine) POST(pattern string, handler HandlerFunc) {
	e.addRoute("POST", pattern, handler)
}

func (e *Engine) Run(addr string) (err error) {
	return http.ListenAndServe(addr, e)
}

到了这里就会觉得,刚才把router踢出去封装是值得的!因为这里很多的功能都是直接调router的接口,并没有多写任何东西。而由于要实现ServeHTTP接口,所以把接收到的wreq统一装到封装好的Context里。

最后,就来编写项目的入口main.go吧~

go 复制代码
func main() {
	r := gee.New()
	r.GET("/", func(c *gee.Context) {
		c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
	})
	r.GET("/hello", func(c *gee.Context) {
		c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
	})
	r.POST("/login", func(c *gee.Context) {
		c.JSON(http.StatusOK, gee.H{
			"username": c.PostForm("username"),
			"password": c.PostForm("password"),
		})
	})
	r.Run(":9999")
}

三、前缀树路由Router

上面的代码看似比较完善,能够做到返回JSON、HTML、query传参等操作,但对于Restful风格来讲,还没有实现动态路由效果。例如"/student/:id",目前的代码获取不到这个":id"参数。so,撸起袖子开干!

1.Trie 树

Trie树,也就是前缀树,是实现动态路由常用的数据结构。假设我们拥有以下路由:

  • /:subject/grade 查看某个课程的成绩,课程作为动态参数
  • /:subject/teacher 查看某个课程的任课老师,课程作为动态参数
  • /about
  • /blog/sanjin
  • /blog/geektutu

于是就可以得到以下Trie树: 因此通过对树进行查询操作,可以实现路由的匹配。

接下来我们实现的动态路由具备以下两个功能。

  • 参数匹配:。例如 /:subject/grade,可以匹配 /chinese/grade/english/grade
  • 通配*。例如 /static/*filepath,可以匹配/static/fav.ico,也可以匹配/static/js/jQuery.js,这种模式常用于静态服务器,能够递归地匹配子路径。

为了实现Trie树,编写以下代码gee/trie.go

go 复制代码
type node struct {
	pattern  string  // 待匹配的路由,例如 /:subject/grade
	part     string  // 路由中的一部分,例如/:subject
	children []*node // 子节点
	isWild   bool    // 是否精确匹配,part含有:或*时为true
}

// matchChild 第一个匹配成功的节点,用于插入
func (n *node) matchChild(part string) *node {
	for _, child := range n.children {
		if child.part == part || child.isWild {
			return child
		}
	}
	return nil
}

// matchChildren 所有匹配成功的节点,用于查找
func (n *node) matchChildren(part string) []*node {
	nodes := make([]*node, 0)
	for _, child := range n.children {
		if child.part == part || child.isWild {
			nodes = append(nodes, child)
		}
	}
	return nodes
}

func (n *node) insert(pattern string, parts []string, height int) {
	if len(parts) == height {
		n.pattern = pattern
		return
	}
	part := parts[height]
	child := n.matchChild(part)
	if child == nil {
		child = &node{
			part:   part,
			isWild: part[0] == ':' || part[0] == '*',
		}
		n.children = append(n.children, child)
	}
	child.insert(pattern, parts, height+1)
}

func (n *node) search(parts []string, height int) *node {
	if len(parts) == height || strings.HasPrefix(n.part, "*") {
		if n.pattern == "" {
			return nil
		}
		return n
	}
	part := parts[height]
	children := n.matchChildren(part)
	for _, child := range children {
		result := child.search(parts, height+1)
		if result != nil {
			return result
		}
	}
	return nil
}

PS:讲真的,这段代码我是真的没看懂,先放在这,以后慢慢品味。

在此贴一个chatgpt的解答:

该代码定义了一个数据结构node,用于表示一个路由的部分。每个node包含了四个属性,分别是待匹配的路由pattern、路由的一部分part、子节点children和一个标志isWild来表示是否精确匹配。接下来定义了几个方法用于操作这个数据结构:

  1. matchChild(part string) *node:该方法用于在children中查找第一个能够匹配指定part的子节点。如果找不到匹配的子节点,返回nil

  2. matchChildren(part string) []*node:该方法用于在children中查找所有能够匹配指定part的子节点,并返回一个node类型的切片。

  3. insert(pattern string, parts []string, height int):该方法用于将一条路由插入到根节点的子树中。pattern是要插入的路由字符串,parts是路由字符串按/分隔后的切片,height表示目前匹配到的路由字符串的深度。在递归过程中,如果当前节点的子节点列表中存在一个能够匹配parts[height]的子节点,则在该子节点下继续递归;否则,创建一个新的子节点并插入到当前节点的子节点列表中,然后在该新节点下继续递归,直到匹配到parts中的最后一个元素时,将pattern赋值给该叶子节点的pattern属性。

  4. search(parts []string, height int) *node:该方法用于在根节点的子树中查找与指定路由相匹配的叶子节点。parts是指定的路由字符串按/分隔后的切片,height表示目前匹配到的路由字符串的深度。在递归过程中,如果匹配到了parts的最后一个元素或者当前节点是以*开头的子节点,则在当前节点的子节点列表中查找与parts[height]匹配的子节点,然后在该子节点下继续递归。如果递归结果不为nil,则返回该结果;否则,返回nil

这个数据结构和方法的实现是为了方便路由匹配和查询的操作。通过将一条路由拆分成多个part,并且每个part作为node节点的一部分,通过匹配子节点和查询子节点,可以快速地匹配和查询路由。

2.Router

ok,解决了树的部分,改把它应用到路由当中了!/gee/router.go代码如下:

go 复制代码
type router struct {
	handlers map[string]HandlerFunc
	roots    map[string]*node
}

func newRouter() *router {
	return &router{
		handlers: make(map[string]HandlerFunc),
		roots:    make(map[string]*node),
	}
}

func parsePattern(pattern string) []string {
	vs := strings.Split(pattern, "/")
	parts := make([]string, 0)
	for _, item := range vs {
		if item != "" {
			parts = append(parts, item)
			if item[0] == '*' {
				break
			}
		}
	}
	return parts
}

func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {
	parts := parsePattern(pattern)
	key := method + "-" + pattern
	_, ok := r.roots[method]
	if !ok {
		r.roots[method] = &node{}
	}
	r.roots[method].insert(pattern, parts, 0)
	r.handlers[key] = handler
}

func (r *router) getRoute(method string, path string) (*node, map[string]string) {
	searchParts := parsePattern(path)
	params := make(map[string]string)
	root, ok := r.roots[method]
	if !ok {
		return nil, nil
	}
	n := root.search(searchParts, 0)
	if n != nil {
		parts := parsePattern(n.pattern)
		for index, part := range parts {
			if part[0] == ':' {
				params[part[1:]] = searchParts[index]
			}
			if part[0] == '*' && len(part) > 1 {
				params[part[1:]] = strings.Join(searchParts[index:], "/")
				break
			}
		}
		return n, params
	}
	return nil, nil
}

func (r *router) handle(c *Context) {
	n, params := r.getRoute(c.Method, c.Path)
	if n != nil {
		c.Params = params
		key := c.Method + "-" + n.pattern
		r.handlers[key](c)
	} else {
		c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
	}
}

router中定义了roots来存储每种请求方式的Trie树根节点,getRoute 函数中还解析了:*两种匹配符的参数,返回一个 map 。例如/chinese/grade匹配到/:subject/grade,解析结果为:{subject: "chinese"}/static/css/geektutu.css匹配到/static/*filepath,解析结果为{filepath: "css/geektutu.css"}

此时,context.go也需要发生一些变化

go 复制代码
type Context struct {
	// origin objects
	Writer http.ResponseWriter
	Req    *http.Request
	// request info
	Path   string
	Method string
	Params map[string]string
	// response info
	StatusCode int
}

func (c *Context) Param(key string) string {
	value, _ := c.Params[key]
	return value
}

Context中增加了Params字段,存储解析后的路径参数。而router.go中的handle()实现了在调用匹配到的handler前,将解析出来的路由参数赋值给了c.Params。这样就能够在handler中,通过Context对象访问到具体的值了。

b话少说,上main.go

go 复制代码
func main() {
	r := gee.New()
	r.GET("/", func(c *gee.Context) {
		c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
	})

	r.GET("/hello", func(c *gee.Context) {
		// expect /hello?name=geektutu
		c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
	})

	r.GET("/hello/:name", func(c *gee.Context) {
		// expect /hello/geektutu
		c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
	})

	r.GET("/assets/*filepath", func(c *gee.Context) {
		c.JSON(http.StatusOK, gee.H{"filepath": c.Param("filepath")})
	})

	r.Run(":9999")
}
相关推荐
Find24 天前
MaxKB 集成langchain + Vue + PostgreSQL 的 本地大模型+本地知识库 构建私有大模型 | MarsCode AI刷题
青训营笔记
理tan王子24 天前
伴学笔记 AI刷题 14.数组元素之和最小化 | 豆包MarsCode AI刷题
青训营笔记
理tan王子24 天前
伴学笔记 AI刷题 25.DNA序列编辑距离 | 豆包MarsCode AI刷题
青训营笔记
理tan王子24 天前
伴学笔记 AI刷题 9.超市里的货物架调整 | 豆包MarsCode AI刷题
青训营笔记
夭要7夜宵25 天前
分而治之,主题分片Partition | 豆包MarsCode AI刷题
青训营笔记
三六1 个月前
刷题漫漫路(二)| 豆包MarsCode AI刷题
青训营笔记
tabzzz1 个月前
突破Zustand的局限性:与React ContentAPI搭配使用
前端·青训营笔记
Serendipity5651 个月前
Go 语言入门指南——单元测试 | 豆包MarsCode AI刷题;
青训营笔记
wml1 个月前
前端实践-使用React实现简单代办事项列表 | 豆包MarsCode AI刷题
青训营笔记
用户44710308932421 个月前
详解前端框架中的设计模式 | 豆包MarsCode AI刷题
青训营笔记