【4-1 Golang】常用标准库—net/http.server

tomato01 · · 757 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

&emsp;&emsp;Go语言创建HTTP服务还是非常方便的,基于http.Server几行代码就能实现,本篇文章主要介绍http.Server的基本使用方式以及HTTP请求处理流程。当然,目前很多web服务都基于gin框架实现,所以我们也会简单介绍下gin框架的一些使用套路。 ## http.Server 概述 &emsp;&emsp;基于http.Server只需要短短几行代码就能创建一个HTTP服务,最简单的只需要配置好监听地址,以及请求处理handler就可以了,如下面程序所示: ``` package main import ( "fmt" "net/http" ) func main() { server := &http.Server{ Addr: "0.0.0.0:8080", } //注册路由(也就是请求处理方法),处理/ping请求,精确匹配(请求地址必须完全一致) http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hello world")) }) //启动HTTP服务 err := server.ListenAndServe() if err != nil { fmt.Println(err) } } /* curl http://127.0.0.1:8080/ping hello world curl http://127.0.0.1:8080/ping/1 404 page not found */ ``` &emsp;&emsp;如上面程序所示,http.Server.Addr设置HTTP服务监听地址,如果没有设置,默认监听80端口;http.HandleFunc函数用于注册路由,也就是请求对应的处理方法,有两个参数:第一个参数含义是pattern,有两种匹配方式,"/ping"为精确匹配即请求地址必须等于"/ping",如请求"/ping/1"无法匹配,而"/ping/"为前缀匹配,如请求"/ping/1"也会匹配成功;第二个参数是函数类型,func(ResponseWriter, * Request),ResponseWriter可用于向客户端返回数据,Request代表当前HTTP请求。 &emsp;&emsp;函数http.Server.ListenAndServe用于启动HTTP服务,想想流程应该是怎样的呢?肯定需要Listen吧(底层肯定少不了socket,bind,listen三个系统调用),其次呢?等待客户端连接呗,也就是循环等待accept,一旦返回创建新的协程处理该客户端请求就行了(包括读取数据,解析HTTP请求,处理,返回数据)。还是比较简单的,整个流程的代码如下: ``` func (srv *Server) ListenAndServe() error { ln, err := net.Listen("tcp", addr) return srv.Serve(ln) } func (srv *Server) Serve(l net.Listener) error { for { rw, err := l.Accept() c := srv.newConn(rw) // 子协程处理请求 go c.serve(connCtx) } } func (c *conn) serve(ctx context.Context) { for { //读取&解析HTTP请求 w, err := c.readRequest(ctx) //处理HTTP请求 serverHandler{c.server}.ServeHTTP(w, w.req) //结束&返回 w.finishRequest() if !w.conn.server.doKeepAlives() { return } } } ``` &emsp;&emsp;整个流程框架其实非常简单,与我们预想的基本一致,当然这里我们省略了很多细节问题。如,c.serve函数主流程为什么是一个for循环呢?会多次处理请求吗?当然是的,因为HTTP协议keepalive存在,客户端建立连接之后,可以基于这一个连接发送多个HTTP请求。而且,一般处理HTTP请求是不是都有超时时间,如何处理超时问题我们也省略了,我们只关注都有哪些超时配置,这些配置都定义在http.Server结构,如下: ``` type Server struct { //读取HTTP请求head超时时间 ReadHeaderTimeout time.Duration //读取HTTP请求超时时间 ReadTimeout time.Duration //写请求响应超时时间(在此之前必须向客户端返回响应数据),超时关闭连接 WriteTimeout time.Duration //空闲长连接超时时间(注意如果没有设置,默认使用ReadTimeout),超时关闭连接 IdleTimeout time.Duration } ``` &emsp;&emsp;有两个超时配置需要关注下:WriteTimeout设置的是写请求响应超时时间,也就是说从收到客户端请求开始,该时间内必须向客户端返回响应数据,否则触发超时,关闭与客户端的连接(划重点:可能会导致502或reset),所以这也是业务请求最大处理时间;IdleTimeout设置的是空闲长连接超时时间,也就是说处理完当前请求之后,Go服务等待下一个请求到达的最大时间(在此时间段认为长连接空闲),超过该时间段,会关闭与客户端的长连接(划重点:可能会导致502或reset),划重点,如果IdleTimeout没有设置,默认使用ReadTimeout。 &emsp;&emsp;另外,注意处理HTTP请求时,是通过http.serverHandler.ServeHTTP函数处理的,而我们创建HTTP服务设置请求处理方法是通过函数http.HandleFunc实现的,这之间有什么关系吗?Go语言HTTP请求处理器都必须实现接口http.Handler,该接口只定义了一个方法ServeHTTP: ``` type Handler interface { ServeHTTP(ResponseWriter, *Request) } //http.serverHandler结构实现了http.Handler接口 type serverHandler struct { srv *Server } func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) { // http.Server.Handler字段的类型就是 http.Handler,也就是说通过这个字段也能定义请求处理方法 handler := sh.srv.Handler if handler == nil { //http.HandleFunc注册路由到全局DefaultServeMux handler = DefaultServeMux } //处理请求 handler.ServeHTTP(rw, req) } ``` &emsp;&emsp;http.serverHandler结构实现了http.Handler接口(实现了方法ServeHTTP),该方法可根据请求地址,匹配对应的处理方法并执行。另外注意到http.Server还有一个字段Handler也能定义请求处理方法,因为该字段的类型就是http.Handler,也就是说我们可以自己定义一个结构(只要实现ServeHTTP方法),自定义请求匹配方式。 &emsp;&emsp;上面提到的多个结构/接口关系如下图所示: ![4-1-1.png](https://static.golangjob.cn/221014/24a30ce0fade80495c6435a412766f91.png) ## gin 概述 &emsp;&emsp;gin是一款非常热门的的web框架,很多web服务都是基于他搭建的。gin为我们提供了更加丰富的路由匹配方式,还有middleware拦截器更是为我们提供了极大便利。gin的使用非常简单,如下所示: ``` package main import ( "net/http" "github.com/gin-gonic/gin" ) func main() { r := gin.Default() //注册路由 r.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "pong", }) }) r.Run() } ``` &emsp;&emsp;gin框架有三个非常重要的结构定义:1)gin.RouterGroup提供多个路由注册方法,这些方法对应着HTTP请求method,如gin.RouterGroup.GET只注册GET请求路由,gin.RouterGroup.Any注册的路由与请求method无关;2)gin.Engine就是gin框架实例,其继承了结构gin.RouterGroup,所以上面的事例程序才能通过"r.GET"注册路由。另外,gin框架底层其实还是基于http.Server创建的web服务,也就是HTTP请求的解析、处理流程、响应阶段依然是http.Server完成的。那么,HTTP请求的处理gin框架是如何接管的呢?还记得http.Handler接口吗(只有一个方法ServeHTTP),Go语言HTTP请求处理器都必须实现接口http.Handler,所以只需要gin.Engine实现http.Handler接口就可以了。 ``` type RouterGroup struct { //请求处理链(拦截器) Handlers HandlersChain //基本路径 basePath string //engin实例 engine *Engine } //路由组 func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) *RouterGroup { return &RouterGroup{ Handlers: group.combineHandlers(handlers), basePath: group.calculateAbsolutePath(relativePath), engine: group.engine, } } func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) IRoutes { return group.handle(http.MethodPost, relativePath, handlers) } type Engine struct { RouterGroup //路由 trees methodTrees } func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { } ``` &emsp;&emsp;gin.Engine实现了方法ServeHTTP,所以可以直接替换Go语言HTTP请求处理阶段,实现自定义的路由匹配,参数解析,请求处理,返回响应结果等等。gin.Engine结构包含一个字段类型为methodTrees,注册的所有路由都存储在该字段,gin框架路由存储与匹配基于前缀树(Tire)实现,提升路由匹配效率,有兴趣的读者可以自己研究下前缀树。 &emsp;&emsp;基于gin框架注册路由时,请求处理方法只有一个输入参数,类型为gin.Context,该结构封装了原生的http.ResponseWriter以及http.Request,基于此定义了很多方法,如解析参数,返回响应结果: ``` //解析json请求,obj结构体指针类型变量 func (c *Context) BindJSON(obj interface{}) error //获取请求query参数 func (c *Context) Query(key string) string //获取请求header func (c *Context) GetHeader(key string) string //响应结果添加header func (c *Context) Header(key, value string) //返回json结果,code为HTTP状态码,obj为返回结构变量 func (c *Context) JSON(code int, obj interface{}) ``` &emsp;&emsp;最后不得不提gin框架非常重要的概念,middleware拦截器。设想有这么一个需求,服务的部分接口需要校验登录态怎么办?将登录态逻辑写到每一个请求处理方法吗?成本貌似有点高。不知道你有没有注意到,gin框架注册路由的第二个参数,是可变参数,也就是可以传递多个HandlerFunc(处理链),路由匹配成功后遍历执行所有的HandlerFunc。middleware拦截器与此类似,gin.Engine.Use方法可以注册全局的拦截器,gin.RouterGroup.Group方法也可以注册路由组的拦截器,通过在蓝拦截器校验登录态成本更低(校验成功,设置用户信息上下文,执行下一个拦截器;校验失败,直接返回),校验登录拦截器可以如下所示: ``` loginGroup := router.Group("/admin", middleware.AuthCheck()) //所有以/admin开始的请求都匹配到loginGroup组,并且执行AuthCheck方法 func AuthCheck() gin.HandlerFunc { return func(c *gin.Context) { if 失败 { // 返回未登录 c.AbortWithStatusJSON(http.StatusOK, errorCode.UserNotLogin) } //执行下一个拦截器 c.Next() } } //遍历执行下一个拦截器 func (c *Context) Next() { c.index++ for c.index < int8(len(c.handlers)) { c.handlers[c.index](c) c.index++ } } ``` &emsp;&emsp;gin.Context.Next方法用于执行下一个拦截器(也可以没有,当前拦截器执行完毕后,自动执行下一个拦截器);结合Next方法,我们可以在同一个拦截器实现,既拦截请求到达又拦截请求返回(Next方法之后的逻辑,在请求返回时执行)。 &emsp;&emsp;通过web服务会注册多个拦截器,包括trace用于全链路追踪,recover用于捕获请求panic,accesslog用于记录访问日志等等,而gin框架本身也提供了几个默认的拦截器可以使用,如gin.Logger()、gin.Recovery()。 ## 如何记录access日志 &emsp;&emsp;我们写的服务不可能没有任何异常,而且日常工作中可能经常需要排查客户反馈问题,这时候你能得到的信息可能非常少,只有客户的手机号或者用户ID等,根据这些简单的信息如何去定位排查问题呢?第一步肯定是要搞清楚,客户在什么时候出的什么错,也就是什么时候请求的什么接口,返回的什么数据。这些信息也可以去接入层如网关查询,不过网关可能不会记录用户ID(网关不会校验登录态),甚至没有请求参数与返回数据。 &emsp;&emsp;这就需要我们自身服务具备这样的功能了,记录请求访问日志(包括用户信息,请求参数,请求接口,返回数据等等),当然日志记录与采集也是需要成本的,这就需要在问题排查与成本之间平衡选择了。 &emsp;&emsp;参考gin框架,我们可以注册一个全局的拦截器accesslog用于记录访问日志,gin框架本身也提供有默认的拦截器可以使用: ``` func LoggerWithConfig(conf LoggerConfig) HandlerFunc { return func(c *Context) { // Start timer start := time.Now() path := c.Request.URL.Path raw := c.Request.URL.RawQuery // Process request c.Next() // Log only when path is not being skipped if _, ok := skip[path]; !ok { param := LogFormatterParams{ Request: c.Request, isTerm: isTerm, Keys: c.Keys, } // Stop timer param.TimeStamp = time.Now() param.Latency = param.TimeStamp.Sub(start) param.ClientIP = c.ClientIP() param.Method = c.Request.Method param.StatusCode = c.Writer.Status() param.ErrorMessage = c.Errors.ByType(ErrorTypePrivate).String() param.BodySize = c.Writer.Size() if raw != "" { path = path + "?" + raw } param.Path = path fmt.Fprint(out, formatter(param)) } } } //[GIN] 2022/09/14 - 11:21:26 | 200 | 201.43µs | 127.0.0.1 | GET "/ping" ``` &emsp;&emsp;可以看到,默认日志拦截器记录了客户端IP,请求接口,耗时,响应状态码等信息。当然你自己实现时也可以添加其他额外信息,如用户信息。只不过在记录请求参数以及响应结果时,你会发现,请求参数Request.Body只能读取一次,拦截器读取了后续流程怎么办?响应数据压根获取不到! &emsp;&emsp;针对这两个问题,有一些方法可以解决,请求参数Request.Body只能读取一次,那就读取之后再将数据塞回去,那后续流程就能继续获取请求数据了。至于获取响应数据无法获取的问题,可以封装并替换gin框架原生的ResponseWriter,在写响应数据的时候,先缓存到buffer,再调用原生ResponseWriter返回数据即可。这两个问题的解决方案如下面代码所示: ``` //body就是请求参数了,也不影响后续流程 var body []byte if c.Request.Body != nil { body, _ = ioutil.ReadAll(c.Request.Body) } //将数据再塞回去 c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) ``` ``` //封装gin.ResponseWriter type bodyLogWriter struct { gin.ResponseWriter body *bytes.Buffer } //将响应数据同时缓存在buffer func (w bodyLogWriter) Write(b []byte) (int, error) { if _, err := w.body.Write(b); err != nil { log.Printf("bodyLogWriter err:%v", err) } return w.ResponseWriter.Write(b) } //替换writer blw := &bodyLogWriter{body: bytes.NewBuffer([]byte{}), ResponseWriter: c.Writer} c.Writer = blw ``` ## 总结 &emsp;&emsp;本篇文章首先介绍了http.Server的主要流程,包括监听,解析请求,处理请求,响应数据等阶段,我们也直到了Go语言HTTP请求处理器都必须实现接口http.Handler,通过实现该接口,我们可以自定义请求匹配以及处理方式。另外,还讲解了热门gin框架的基本使用,包括路由注册方式,middleware拦截器的概念以及原理,以及在此之上如何记录access日志。

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

757 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传