GO web开发

go web开发

简介

go官方提供了http服务,但它的功能很简单。

这里介绍web开发中的一些问题,和web框架(echo)怎么解决这些问题 ,对于具体的echo的使用,可看官网

官网:

https://echo.labstack.com/

GO web开发_第1张图片

它的特点:

GO web开发_第2张图片

关于echo和gin的不同可以看下面的内容

  1. https://yuiltripathee.medium.com/go-gin-vs-echo-comparison-edf1536e2e25
  2. https://mattermost.com/blog/choosing-a-go-framework-gin-vs-echo/

对于我来说,有下面几点

  1. echo可以自定义错误处理,并且handler可以直接返回error,这很符合Go的规范。
  2. echo将middleware,handler分开,但gin并没有分开,需要在它的Context中调用next来进行下一步(这也是gin的一个设计特点,但这特点我不太喜欢 )
  3. https://github.com/deepmap/oapi-codegen 默认的服务器是echo(oapi-codegen可以将swagger文档转为对于的服务端代码和stub代码,并且会生成路由注册方法,生成对应的接口,生成model,提供校验。支持的服务器有chi,gin,echo,net/http )

官网提供了详细的例子和使用说明,具体可以看官网。

web开发可分为下面的几个阶段:

  1. 路由
  2. handler
  3. 调用handler的时候在前和后增加hook
  4. 参数查找和验证
  5. 返回值的处理

除此之外,还需要注意未知的错误发生,导致程序发生意外。

在开始之前先列举go原生和echo的两种方式

go原生

package main

import (
	"fmt"
	"net/http"
)

func main() {
	// 创建一个路由器
	router := http.NewServeMux()

	// 注册路由处理函数
	router.HandleFunc("/", homeHandler)
	router.HandleFunc("/about", aboutHandler)

	// 启动HTTP服务器并指定端口
	port := ":8080"
	fmt.Printf("Server listening on port %s\n", port)
	http.ListenAndServe(port, router)
}

// 处理根路径的请求
func homeHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Welcome to the home page!")
}

// 处理关于页面的请求
func aboutHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "About page")
}

echo

func main() {
	e := echo.New()
	e.GET("/",homeHandlerEcho)
	e.GET("/about",aboutHandlerEcho)
	if err := e.Start(":8080"); err != nil {
		return 
	}
}

// 处理根路径的请求
func homeHandlerEcho(ctx echo.Context) error  {
	return ctx.String(http.StatusOK,"Welcome to the home page!")
}
func aboutHandlerEcho(ctx echo.Context) error  {
	return ctx.String(http.StatusOK,"About page")
}

路由

路由主要是包括路由注册和路由查找

原生路由有下面的问题

  1. 不能区分方法。
  2. 不支持路径参数

原生的实现很简单,将路由放在map中,map的key就是path,v为handler,路由查找和注册的方法也很简单

type ServeMux struct {
	mu    sync.RWMutex
	m     map[string]muxEntry
	es    []muxEntry // slice of entries sorted from longest to shortest.
	hosts bool       // whether any patterns contain hostnames
}

type muxEntry struct {
	h       Handler
	pattern string
}

所以在使用原生的时候,要实现上面的功能,需要自己在handler中区分Method,提取路径参数。

下面介绍echo的路由实现

Echo的路由器使用了压缩 radix tree(基数树)数据结构来实现高效的路由匹配。基数树是一种用于高效存储和检索字符串的数据结构,它允许根据字符串的前缀进行快速匹配。

radix tree

Radix树(也称为基数树或前缀树)是一种用于高效存储和检索字符串的数据结构。它通过共享公共前缀来(LCP longest common prefix)压缩存储空间,并提供快速的字符串匹配操作。

在Radix树中,每个节点代表一个字符串的前缀。树的根节点代表空字符串,而叶子节点表示完整的字符串。每个节点包含一个字符和指向子节点的指针或链接。

相比于其他树结构,Radix树的一个主要优势是它能够有效地存储具有相同前缀的字符串。这是通过将共同前缀存储在树的节点中来实现的,从而节省了存储空间。此外,Radix树还提供了高效的字符串匹配操作,因为它可以在O(k)的时间复杂度内完成字符串的插入、查找和删除操作,其中k是待操作的字符串的长度。

压缩radix tree

压缩型Radix树(Compact Radix Tree)是一种对传统Radix树进行了优化的变种。它通过合并具有相同前缀的节点来进一步减少存储空间的使用。

在传统的Radix树中,每个节点都包含一个字符和指向子节点的指针或链接。而在压缩型Radix树中,当一个节点只有一个子节点时,会将该节点与其子节点合并成一个节点。这样做可以减少节点的数量,从而减少了存储空间的使用。

压缩型Radix树的另一个优化是使用压缩路径(Compressed Path),即将具有相同前缀的节点路径合并成一个路径。这样可以进一步减少存储空间,并提高查找操作的效率。相比于传统的Radix树,压缩型Radix树在存储空间方面具有更好的性能。它可以在相同的功能下使用更少的内存。然而,由于合并节点和路径的操作,压缩型Radix树的插入和删除操作可能会更复杂一些,且可能需要更多的计算资源。

本质上来说是用空间换时间,压缩radix tree 的查找比radix tree 快。

Router结构体

Router struct {
		tree   *node // compressed redix tree root note
		routes map[string]*Route  // key是method,v是tree
		echo   *Echo
	}

// 树的节点
node struct {
    // 节点类型,有三种 staticKind(正常的路径),paramKind(路径有参数),anyKind(通配符)
    kind           kind 
    // label对应kind类型,也有三种,正常(正常路径的index为0的字符),路径有参数(:),通配符(*)
    label          byte 
    prefix         string // 前缀
    parent         *node   // 父节点
    staticChildren children  // 静态的子节点(切片类型)也就是说,静态节点可以有多个子节点
    originalPath   string   // 
    methods        *routeMethods   // 方法
    paramChild     *node   // paramKind 子节点(只有一个)
    anyChild       *node  // anyKind 子节点(只有一个)
    paramsCount    int    // 路径参数数量
    // isLeaf indicates that node does not have child routes
    isLeaf bool
    // isHandler indicates that node has at least one handler registered to it
    isHandler bool  // 是否有handler

    // notFoundHandler is handler registered with RouteNotFound method and is executed for 404 cases
    notFoundHandler *routeMethod
}
kind        uint8
children    []*node    
routeMethod struct {
    ppath   string
    pnames  []string
    handler HandlerFunc
}
routeMethods struct {
    connect     *routeMethod     // 方法和对应的method
    delete      *routeMethod
    get         *routeMethod
    head        *routeMethod
    options     *routeMethod
    patch       *routeMethod
    post        *routeMethod
    propfind    *routeMethod
    put         *routeMethod
    trace       *routeMethod
    report      *routeMethod
    anyOther    map[string]*routeMethod
    allowHeader string
}

现在,我们有下面的路径

			e := New()
			r := e.router
			r.Add(http.MethodGet, "/a/:b/c", handlerHelper("case", 1))
			r.Add(http.MethodGet, "/a/c/d", handlerHelper("case", 2))
			r.Add(http.MethodGet, "/a/d/c", handlerHelper("case", 2))
			r.Add(http.MethodGet, "/a/c/df", handlerHelper("case", 3))
			r.Add(http.MethodGet, "/a/*/f", handlerHelper("case", 4))
			r.Add(http.MethodGet, "/:e/c/f", handlerHelper("case", 5))
			r.Add(http.MethodGet, "/*", handlerHelper("case", 6))

构建的压缩形radix tree如下:

GO web开发_第3张图片

这个图是用graphviz来实现的

graphviz官网:https://www.graphviz.org/docs/attrs/label/

graphviz很好用,在图形化展示的时候很方便。

我用了下面的代码,生成了graphviz的语法,然后渲染了一下

注意:echo中node类型是不可导出的,没办法在包外直接获取它,遍历,所以,我在它的单元测试中做了下面的代码

他的原理是图的深度优先遍历,构建graphviz的语法

var kmap = make(map[*node]string) //key是节点的名字,v是节点
var kindMap  = map[kind]string{
	staticKind:"staticKind",
	paramKind:"paramKind",
	anyKind:"anyKind",
}

var index int
var edgesStr []string
func deepInfo(note *node)  {
	if note == nil{
		return
	}
	index++

	var builder strings.Builder
	name := fmt.Sprintf("node%d", index)
	builder.WriteString(name)
	builder.WriteString("[")

	builder.WriteString("id=")
	builder.WriteString(strconv.Itoa(index))
	builder.WriteString(" ")

	var a = " \\n "
	var labelBuilder strings.Builder
	labelBuilder.WriteString("kind=")
	labelBuilder.WriteString(kindMap[note.kind])
	labelBuilder.WriteString(a)
	labelBuilder.WriteString("label=")
	labelBuilder.WriteByte(note.label)
	labelBuilder.WriteString(a)
	labelBuilder.WriteString("prefix=")
	labelBuilder.WriteString(note.prefix)
	labelBuilder.WriteString(a)
	labelBuilder.WriteString("paramsCount=")
	labelBuilder.WriteString(strconv.Itoa(note.paramsCount))
	labelBuilder.WriteString(a)
	labelBuilder.WriteString("isHandler=")
	labelBuilder.WriteString(strconv.FormatBool(note.isHandler))
	labelBuilder.WriteString(a)
	labelBuilder.WriteString("isLeaf=")
	labelBuilder.WriteString(strconv.FormatBool(note.isLeaf))
	labelBuilder.WriteString(a)

	builder.WriteString("label=")
	builder.WriteString("\"")
	builder.WriteString(labelBuilder.String())
	builder.WriteString("\"")

	builder.WriteString("]")

	fmt.Printf("%s \n",builder.String())
	kmap[note] = name
	s,ok := kmap[note.parent]
	if ok{
		var edgeBuilder strings.Builder
		edgeBuilder.WriteString(s)
		edgeBuilder.WriteString(" -> ")
		edgeBuilder.WriteString(name)
		edgeBuilder.WriteString("[")
		edgeBuilder.WriteString("label=")
		edgeBuilder.WriteString(edgeLabel(note,note.parent))
		edgeBuilder.WriteString("]")
		edgesStr = append(edgesStr,edgeBuilder.String() )
	}

	for _, child := range note.staticChildren {
		deepInfo(child)
	}
	deepInfo(note.anyChild)
	deepInfo(note.paramChild)

}
func edgeLabel(cur *node,parent *node) string {
	for _, child := range parent.staticChildren {
		if cur == child{
			return "staticChildren"
		}
	}
	if cur == parent.paramChild{
		return "paramChild"
	}
	if cur == parent.anyChild{
		return "anyChild"
	}
	return ""
}

// 使用如下:
func TestRouteMultiLevelBacktracking(t *testing.T) {
	e := New()
	r := e.router

	r.Add(http.MethodGet, "/a/:b/c", handlerHelper("case", 1))
	r.Add(http.MethodGet, "/a/c/d", handlerHelper("case", 2))
	r.Add(http.MethodGet, "/a/d/c", handlerHelper("case", 2))
	r.Add(http.MethodGet, "/a/c/df", handlerHelper("case", 3))
	r.Add(http.MethodGet, "/a/*/f", handlerHelper("case", 4))
	r.Add(http.MethodGet, "/:e/c/f", handlerHelper("case", 5))
	r.Add(http.MethodGet, "/*", handlerHelper("case", 6))
	deepInfo(r.tree)
	println(strings.Join(edgesStr, "\n"))

}

digraph的文件内容如下:

digraph {
  node [shape=box]
  layout=dot
node1[id=1 label="kind=staticKind \n label=/ \n prefix=/ \n paramsCount=0 \n isHandler=false \n isLeaf=false \n "]
node2[id=2 label="kind=staticKind \n label=a \n prefix=a/ \n paramsCount=0 \n isHandler=false \n isLeaf=false \n "]
node3[id=3 label="kind=staticKind \n label=c \n prefix=c/d \n paramsCount=0 \n isHandler=true \n isLeaf=false \n "]
node4[id=4 label="kind=staticKind \n label=f \n prefix=f \n paramsCount=0 \n isHandler=true \n isLeaf=true \n "]
node5[id=5 label="kind=staticKind \n label=d \n prefix=d/c \n paramsCount=0 \n isHandler=true \n isLeaf=true \n "]
node6[id=6 label="kind=anyKind \n label=* \n prefix=* \n paramsCount=1 \n isHandler=true \n isLeaf=false \n "]
node7[id=7 label="kind=staticKind \n label=/ \n prefix=/f \n paramsCount=1 \n isHandler=true \n isLeaf=true \n "]
node8[id=8 label="kind=paramKind \n label=: \n prefix=: \n paramsCount=0 \n isHandler=false \n isLeaf=false \n "]
node9[id=9 label="kind=staticKind \n label=/ \n prefix=/c \n paramsCount=1 \n isHandler=true \n isLeaf=true \n "]
node10[id=10 label="kind=anyKind \n label=* \n prefix=* \n paramsCount=1 \n isHandler=true \n isLeaf=true \n "]
node11[id=11 label="kind=paramKind \n label=: \n prefix=: \n paramsCount=0 \n isHandler=false \n isLeaf=false \n "]
node12[id=12 label="kind=staticKind \n label=/ \n prefix=/c/f \n paramsCount=1 \n isHandler=true \n isLeaf=true \n "]
node1 -> node2[label=staticChildren]
node2 -> node3[label=staticChildren]
node3 -> node4[label=staticChildren]
node2 -> node5[label=staticChildren]
node2 -> node6[label=anyChild]
node6 -> node7[label=staticChildren]
node2 -> node8[label=paramChild]
node8 -> node9[label=staticChildren]
node1 -> node10[label=anyChild]
node1 -> node11[label=paramChild]
node11 -> node12[label=staticChildren]
}

中间件

有这样的一个需求,现在要打印每个请求的耗费的时间。

显然需要一个地方来集中处理这种问题, 写在业务代码中不合适,我们用原生来讲一下middleware的原理

middleware就是将handler包了一层。比如下面的代码

// 它的函数签名和是 HandlerFunc
func homeHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Welcome to the home page!")
}
// 我们只需要在写一个方法,想办法在这个方法里面调用上面的handler方法就好了,并且返回值还是一个新的handler,这里用type就可以实现


func timeMiddleware(handler http.HandlerFunc)  http.HandlerFunc {
	// 返回了一个新的handler
	return func(writer http.ResponseWriter, request *http.Request) {
		// 调用原生的handler
		 handler(writer,request)
	}
}

按照这个,我们开始改动代码

func main() {
	// 创建一个路由器
	router := http.NewServeMux()
	// 注册路由处理函数
	router.HandleFunc("/", timeMiddleware(homeHandler))
	router.HandleFunc("/about", timeMiddleware(aboutHandler))

	
	// 启动HTTP服务器并指定端口
	port := ":8080"
	fmt.Printf("Server listening on port %s\n", port)
	http.ListenAndServe(port, router)
}

func timeMiddleware(handler http.HandlerFunc)  http.HandlerFunc {
	// 返回了一个新的handler
	return func(writer http.ResponseWriter, request *http.Request) {
		// 调用原生的handler
		start := time.Now()
		handler(writer,request)
		println(time.Now().Sub(start).Seconds())
	}
}

// 处理根路径的请求
func homeHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Welcome to the home page!")
}

// 处理关于页面的请求
func aboutHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "About page")
}

按照这个原理我们还可以增加更多的middleware,如下

func requestLogTimeMiddleware(handler http.HandlerFunc)  http.HandlerFunc  {
	return func(writer http.ResponseWriter, request *http.Request) {
		s := make([]string, 0, 10)
		for k, valus := range request.Header {
			s = append(s, k,strings.Join(valus,","))
		}
		fmt.Printf("%v",s)
		handler(writer,request)
	}
}

func main() {
	// 创建一个路由器
	router := http.NewServeMux()
	// 注册路由处理函数
                             // 再次包了一层
	router.HandleFunc("/", requestLogTimeMiddleware(timeMiddleware(homeHandler)))
	router.HandleFunc("/about", timeMiddleware(aboutHandler))


	// 启动HTTP服务器并指定端口
	port := ":8080"
	fmt.Printf("Server listening on port %s\n", port)
	http.ListenAndServe(port, router)
}

但这样使用起来不是很方便,包裹起来看起来很丑陋,这样我们将middleware集中放在一个地方,然后统一应用,来减少注册时候的冗余。

// 定义类型
type middleware func(handlerFunc http.HandlerFunc) http.HandlerFunc

// 中间件集中管理
var middlewareList = make([]middleware,0,10)

// 添加中间件
func use(m middleware)  {
	middlewareList = append(middlewareList, m)
}

// 包装handler
func applyMiddleware(handle http.HandlerFunc) http.HandlerFunc {
	for i := len(middlewareList)-1; i <=0 ; i-- {
		m := middlewareList[i]
		handle = m(handle)
	}
	return handle
}

func main() {
	// 创建一个路由器
	router := http.NewServeMux()
	
	
	// 注册中间件
	use(requestLogTimeMiddleware)
	use(timeMiddleware)
	
	router.HandleFunc("/",applyMiddleware(homeHandler))
	router.HandleFunc("/about", applyMiddleware(aboutHandler))


	// 启动HTTP服务器并指定端口
	port := ":8080"
	fmt.Printf("Server listening on port %s\n", port)
	http.ListenAndServe(port, router)
}

func requestLogTimeMiddleware(handler http.HandlerFunc)  http.HandlerFunc  {
	return func(writer http.ResponseWriter, request *http.Request) {
		s := make([]string, 0, 10)
		for k, valus := range request.Header {
			s = append(s, k,strings.Join(valus,","))
		}
		fmt.Printf("%v",s)
		handler(writer,request)
	}
}

func timeMiddleware(handler http.HandlerFunc)  http.HandlerFunc {
	// 返回了一个新的handler
	return func(writer http.ResponseWriter, request *http.Request) {
		// 调用原生的handler
		start := time.Now()
		handler(writer,request)
		fmt.Printf("cost: %v",time.Now().Sub(start).Seconds())
	}
}

// 处理根路径的请求
func homeHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Welcome to the home page!")
}

// 处理关于页面的请求
func aboutHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "About page")
}

applyMiddleware的解释如下:

倒序是因为要保持后添加的middleware先执行。可以理解为持续压栈,然后出栈执行

先添加的requestLogTimeMiddleware,之后是timeMiddleware,为了保证在真正执行的时候也可以按照这个顺序,我们需要先将timeMiddleware压栈,然后是requestLogTimeMiddleware,这样保证的handler第一个执行的就是requestLogTimeMiddleware

echo

回头看echo中间件的相关代码,和上面的示例差不多。

源码:https://github.com/labstack/echo/blob/master/echo.go#L473

echo提供了很多的中间件:https://github.com/labstack/echo/tree/master/middleware

参数查找和验证

查找

http.Request提供了读取各种content/type

echo对它们进行了封装,提供了bind操作来绑定数据

官网:https://echo.labstack.com/guide/binding/

这种原理就是反射操作,在结构体中提供了标签tag,通过反射来操作,获取对应的值

验证

echo并不提供验证的能力,需要引用第三方的库来操作

官网:https://echo.labstack.com/guide/request/#validate-data

和上面一样,在结构体中提供tag标签,通过反射来操作。

代码

func main() {
  
 // 定义绑定参数的名字和校验规则
 type Vocab struct {
 		Id uint64 `param:"id" min:"1" max:"124"`
 }
	
 // query中的数据
 data := map[string]string{
 	"id":"31232",   // 会触发校验
 }
	v := new(Vocab)
    // 绑定并且校验
	err := bindAndValidate(data, v)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%+v",v)
}
func bindAndValidate(data map[string]string,v interface{}) error  {
     // 反射获取类型                                                           
	typ := reflect.TypeOf(v)
	if typ.Kind()!= reflect.Ptr{
		return errors.New("not ptr")
	}
	typ = typ.Elem()
	val := reflect.ValueOf(v).Elem()
    // 拿到字段
	for i := 0; i < typ.NumField(); i++ {
		typeField := typ.Field(i)
		structField := val.Field(i)
		if !structField.CanSet() {
			continue
		}
        // 获取tag,从map中获取数据,执行校验
		inputFieldName := typeField.Tag.Get("param")
		validateMin,_ := strconv.ParseUint(typeField.Tag.Get("min"),10,64)
		validateMax,_ := strconv.ParseUint(typeField.Tag.Get("max"),10,64)
		inputValue,err := strconv.ParseUint(data[inputFieldName],10,64)
		if err != nil {
			return errors.New("convert error")
		}
		if inputValue < validateMin || inputValue > validateMax{
			return errors.New("validate error")
		}
		structField.SetUint(inputValue)
	}
	return nil
}

为了说明原理,我这里写的很简单,如果给生产环境编写校验库的话,请务必做好功能的完善和容错

可以用在生成中的校验和绑定的功能肯定比我们上面复杂的多,可以看echo的绑定源码。

这种绑定和校验的有两种方式

  1. 反射操作
  2. 解析源码,预先生成好校验操作的代码,之后直接调用。

反射虽然说性能不太好,但web开发中是需要存在大量的参数校验的,性能瓶颈不一定是反射出现的,具体的情况还需要通过pprof来发现。

如果反射真的是问题,就可以采用第二种方法,对源代码做解析,生成校验代码,然后在代码中调用这些校验操作。比如利用下面的库

  • https://github.com/dave/dst
  • https://pkg.go.dev/go/parser

返回值的处理

原生的操作比较粗暴,直接对response做操作,echo提供了一些封装的方法

具体的可看官方

https://echo.labstack.com/guide/response/

这一点我是觉得gin做的比较好,提供了统一的render接口,通过不同的实现类来做渲染,echo在代码中一把梭哈。

gin:https://github.com/gin-gonic/gin/tree/master/render

GO web开发_第4张图片

echo:https://github.com/labstack/echo/blob/master/context.go

GO web开发_第5张图片


到这里,结束了。

你可能感兴趣的:(go语言,golang,前端,开发语言,后端,go)