go web开发
简介
go官方提供了http服务,但它的功能很简单。
这里介绍web开发中的一些问题,和web框架(echo)怎么解决这些问题 ,对于具体的echo的使用,可看官网
官网:
https://echo.labstack.com/
它的特点:
关于echo和gin的不同可以看下面的内容
- https://yuiltripathee.medium.com/go-gin-vs-echo-comparison-edf1536e2e25
- https://mattermost.com/blog/choosing-a-go-framework-gin-vs-echo/
对于我来说,有下面几点
- echo可以自定义错误处理,并且handler可以直接返回error,这很符合Go的规范。
- echo将middleware,handler分开,但gin并没有分开,需要在它的Context中调用next来进行下一步(这也是gin的一个设计特点,但这特点我不太喜欢 )
- https://github.com/deepmap/oapi-codegen 默认的服务器是echo(oapi-codegen可以将swagger文档转为对于的服务端代码和stub代码,并且会生成路由注册方法,生成对应的接口,生成model,提供校验。支持的服务器有chi,gin,echo,net/http )
官网提供了详细的例子和使用说明,具体可以看官网。
web开发可分为下面的几个阶段:
- 路由
- handler
- 调用handler的时候在前和后增加hook
- 参数查找和验证
- 返回值的处理
除此之外,还需要注意未知的错误发生,导致程序发生意外。
在开始之前先列举go原生和echo的两种方式
go原生
package mainimport ("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")
}
路由
路由主要是包括路由注册和路由查找
原生路由有下面的问题
- 不能区分方法。
- 不支持路径参数
原生的实现很简单,将路由放在map中,map的key就是path,v为handler,路由查找和注册的方法也很简单
type ServeMux struct {mu sync.RWMutexm map[string]muxEntryes []muxEntry // slice of entries sorted from longest to shortest.hosts bool // whether any patterns contain hostnames
}type muxEntry struct {h Handlerpattern 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 noteroutes map[string]*Route // key是method,v是treeecho *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 routesisLeaf bool// isHandler indicates that node has at least one handler registered to itisHandler bool // 是否有handler// notFoundHandler is handler registered with RouteNotFound method and is executed for 404 casesnotFoundHandler *routeMethod
}
kind uint8
children []*node
routeMethod struct {ppath stringpnames []stringhandler HandlerFunc
}
routeMethods struct {connect *routeMethod // 方法和对应的methoddelete *routeMethodget *routeMethodhead *routeMethodoptions *routeMethodpatch *routeMethodpost *routeMethodpropfind *routeMethodput *routeMethodtrace *routeMethodreport *routeMethodanyOther map[string]*routeMethodallowHeader string
}
现在,我们有下面的路径
e := New()r := e.routerr.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如下:
这个图是用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.Buildername := 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.BuilderlabelBuilder.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] = names,ok := kmap[note.parent]if ok{var edgeBuilder strings.BuilderedgeBuilder.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.routerr.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 {// 返回了一个新的handlerreturn func(writer http.ResponseWriter, request *http.Request) {// 调用原生的handlerhandler(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 {// 返回了一个新的handlerreturn func(writer http.ResponseWriter, request *http.Request) {// 调用原生的handlerstart := 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 {// 返回了一个新的handlerreturn func(writer http.ResponseWriter, request *http.Request) {// 调用原生的handlerstart := 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的绑定源码。
这种绑定和校验的有两种方式
- 反射操作
- 解析源码,预先生成好校验操作的代码,之后直接调用。
反射虽然说性能不太好,但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
echo:https://github.com/labstack/echo/blob/master/context.go
到这里,结束了。