Bootstrap

Golang网络编程

今天看了看Golang的网络编程,总结了一些关于TCP/HTTP的知识,于是记录下来,方便日后回忆。有一说一记忆力差真的是硬伤!👴累趴了都。

如果你有看我的历史文章,你就知道我是个Javaer,我也是最近才转的Golang,虽然之前学过一丢丢。因为现公司用Golang,不捧不踩,Golang写起来真舒服!人生苦短,Let's Go!

TCP

Golang的tcp编程和大多数语言很像,无非是ServerSocket(TcpListener)监听端口,然后连接建立返回一个Socket(Connection)。但是有一点不同,就是Golang支持Goroutine,如果你还记得Java/C编写Socket就知道,对于每个连接是需要开辟线程处理的,然后因为连接多导致开线程开爆了,然后上NIO,然后就是非阻塞编程+异步回调... ...其实这分别对应于Java的Netty和Rust的async关键字。我都搞过,所以第一次用Goroutine觉得,嚯!真简单。

当然如果在这里讨论Goroutine的性能或者Golang的调度器就有点不合适了,你既然接受了这种用户级线程的设计,而且还是语言内置的,那就要接受它给你画出的条条框框。但是基本不会有人触碰到边界,所以这里我们不讨论这种底层,一是没必要,你上Golang就是为了省头发,而不是再去亲自管理调度;二是,我还没学到哈哈哈哈!

让我们开始吧!

首先建立监听:

func Serve() {
  address := "127.0.0.1:8190"
  tcpAddr, err := net.ResolveTCPAddr("tcp4", address)
  if err != nil {
    log.Fatal(err)
  }
  // listener对应ServerSocket
  serverSocket, err := net.ListenTCP("tcp4", tcpAddr)
  if err != nil {
    log.Fatal(err)
  }
  for {
    // 每次连接建立返回一个Connection,Connection对应Socket
    socket, err := serverSocket.AcceptTCP()
    fmt.Println("connection established...")
    if err != nil {
      log.Fatal(err)
    }
    // 开辟Goroutine去处理新的连接
    go server(socket)
  }
}

这一步和任何语言都大同小异,重点看最后,go关键字开启Goroutine去处理新的连接,我们在这个方法里进行读写操作,并响应来自客户端的关闭操作。

基础版

现在来看看服务端怎么处理读写请求的:

// 最普通的版本
func server(socket *net.TCPConn) {
  defer func(tcpConn *net.TCPConn) {
    err := tcpConn.Close()
    if err != nil {
      log.Fatal(err)
    }
  }(socket)
  for {
    request := make([]byte, 1024)
    readLen, err := socket.Read(request)
    if err == io.EOF {
      fmt.Println("连接关闭")
      return
    }
    msg := string(request[:readLen])
    fmt.Println(msg)
    msg = "echo: " + msg
    _, _ = socket.Write([]byte(msg))
  }
}

其实很好理解,定一个byte类型的slice去接收数据,然后处理,再写出。

这里再给出客户端的写法:

func client() {
  tcpAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:8190")
  socket, _ := net.DialTCP("tcp", nil, tcpAddr)
  defer func(socket *net.TCPConn) {
    _ = socket.Close()
  }(socket)
  var input string
  fmt.Println("input for 5 loops")
  for i := 0; i < 5; i++ {
    _, _ = fmt.Scanf("%s", &input)
    _, _ = socket.Write([]byte(input))
    response := make([]byte, 1024)
    readLen, _ := socket.Read(response)
    fmt.Println(string(response[:readLen]))
  }
}

但这里有不少问题,比如,如果我一次发送很多,那我这次定义的slice大小肯定不够,那我怎么办?肯定不能盲目开大,对吧!

而且TCP还有粘包和拆包这一说,我怎么保证我的数据没有被拆包,以及粘包了之后怎么处理?其实TCP协议一般处理方式都是基于分隔符(delimiter)或者基于长度(length),比方说一个发送文件的HTTP请求,实现就是首部指出整体长度,然后指出分隔字符串,又称边界符,然后根据边界符分隔出不同的文件即可。

现在我们来看这两种方式。

分隔符

基于分隔符:

// 基于分隔符的版本
func serverClientDelimiterBased(socket *net.TCPConn) {
  defer func(socket *net.TCPConn) {
    err := socket.Close()
    if err != nil {
      log.Fatal(err)
    }
  }(socket)
  // 构建一个Reader,此时会源源不断的读取,直到Socket为空
  reader := bufio.NewReader(socket)
  for {
    // 相当于对源源不断的数据流进行分割,直到不可读取
    data, err := reader.ReadSlice('\n')
    if err != nil {
      if err == io.EOF {
        // 连接关闭
        break
      } else {
        fmt.Println("出现异常" + err.Error())
      }
    }
    // 剔除分隔符
    data = data[:len(data)-1]
    text := string(data)
    fmt.Println("服务端读到了: " + text)
    resp := fmt.Sprintf("Hello, client. I have read: [%s] from you.", text)
    _, _ = socket.Write([]byte(resp))
  }
  fmt.Println("连接关闭")
}

这里和基础版最大的不同在于,它多了一个分隔符分割输入字节流,可以看到,我们把Socket的读取放到一个Reader中,Reader会源源不断地从Socket中读取,然后每次读取到分隔符(在这里时'\n'),就进行分割,把前面的部分返回,然后重制起始位置为分隔符下一个位置,直到连接被关闭,socket不可读,返回EOF为止。

来看一个可能的客户端实现:

func clientDelimiterBased() {
  tcpAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8190")
  if err != nil {
    log.Fatal(err)
  }
  socket, err := net.DialTCP("tcp", nil, tcpAddr)
  if err != nil {
    log.Fatal(err)
  }
  var input string
  fmt.Println("input for 5 loops")
  for i := 0; i < 5; i++ {
    _, _ = fmt.Scanf("%s", &input)
    // 添加分隔符
    input = input + "\n"
    _, _ = socket.Write([]byte(input))
    response := make([]byte, 1024)
    readLen, _ := socket.Read(response)
    fmt.Println(string(response[:readLen]))
  }
  err = socket.Close()
  if err != nil {
    log.Fatal(err)
  }
}

通过分隔符,我们可以不用考虑粘包和拆包,也不用猜测请求长度,只要源源不断的读,然后分割请求即可;其实这里请求也可以这么写,但是为了图省事和简化代码就没有这么做。

基于分隔符有一个小小的缺点,就是分隔符如果也是内容的一部分,可能就不好处理,此外,使用分隔符进行“分割”,要求对已读取的流进行遍历,或者说需要遍历缓冲区。所以这是一个性能损耗。

基于长度

如果,我们可以在某个位置制定此次请求的长度,而这个记录长度的值一定可以被读取到,且一定是最先读取的,那是不是就可以通过统计目前读取了多少字节来进行请求切分呢?我之前写过一个IM系统,就是自定义消息体,消息体里有长度字段,然后借用Netty的长度分割Handler,进行基于长度分割来实现划分不同的消息这一功能。

当然这里我们为了演示,不会做那么复杂,直接把长度作为第一位写出就行,后面紧跟数据。这里长度选取int32类型,占4个byte,同时因为TCP使用的是大段法,所以我们写出之前记得指定一下。

看看码:

func clientLengthBased() {
  tcpAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8190")
  if err != nil {
    log.Fatal(err)
  }
  socket, err := net.DialTCP("tcp", nil, tcpAddr)
  if err != nil {
    log.Fatal(err)
  }
  var input string
  fmt.Println("input for 5 loops")
  for i := 0; i < 5; i++ {
    _, _ = fmt.Scanf("%s", &input)
    data := []byte(input)
    var buffer = bytes.NewBuffer([]byte{})
    // 先写入长度
    _ = binary.Write(buffer, binary.BigEndian, int32(len(data)))
    // 再写入数据
    _ = binary.Write(buffer, binary.BigEndian, data)
    _, _ = socket.Write(buffer.Bytes())
    response := make([]byte, 1024)
    readLen, _ := socket.Read(response)
    fmt.Println(string(response[:readLen]))
  }
  err = socket.Close()
  if err != nil {
    log.Fatal(err)
  }
}

再来看一个可能的客户端实现:

func clientLengthBased() {
  tcpAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8190")
  if err != nil {
    log.Fatal(err)
  }
  socket, err := net.DialTCP("tcp", nil, tcpAddr)
  if err != nil {
    log.Fatal(err)
  }
  var input string
  fmt.Println("input for 5 loops")
  for i := 0; i < 5; i++ {
    _, _ = fmt.Scanf("%s", &input)
    data := []byte(input)
    var buffer = bytes.NewBuffer([]byte{})
    // 先写入长度
    _ = binary.Write(buffer, binary.BigEndian, int32(len(data)))
    // 再写入数据
    _ = binary.Write(buffer, binary.BigEndian, data)
    _, _ = socket.Write(buffer.Bytes())
    response := make([]byte, 1024)
    readLen, _ := socket.Read(response)
    fmt.Println(string(response[:readLen]))
  }
  err = socket.Close()
  if err != nil {
    log.Fatal(err)
  }
}

简单易懂是吧!好!结束。因为Socket编程本身在Golang里就没什么好说的,也不像Java还有Reactor模型,直接无脑go跑一下就行。所以人生苦短,CS:GO。

HTTP

Golang诞生之初就是为了解决谷歌网络编程的痛点问题,比如说写一个WebApp要导一堆的包,还要一堆的框架去跑,否则开发效率上不来(我可没有说Java。

Golang简单很多,直接Http监听然后设置路由,每个path对应一个HTTPHandle方法,每个请求跑在独立的Goroutine中。所以很容易扛住千万并发,也不用管什么阻塞,直接一步到位写就是了。

来看一个简单的使用:

package server

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

type HandlerFunc func(w http.ResponseWriter, r *http.Request)

type myHandler struct {
  // 我们这里路径映射匹配只在最开始是写的,所以不需要同步
  handlers map[string]HandlerFunc
}

func NewMyHandler() *myHandler {
  return &myHandler{
    handlers: make(map[string]HandlerFunc),
  }
}

func (h *myHandler) AddHandler(path, method string, handler http.Handler) {
  key := path + "#" + method
  h.handlers[key] = handler.ServeHTTP
}

func (h *myHandler) AddHandlerFunc(path, method string, f HandlerFunc) {
  key := path + "#" + method
  h.handlers[key] = f
}

type notFound struct {
}

func (n *notFound) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
}

var handler404 = notFound{}

func (h *myHandler) getHandlerFunc(path, method string) HandlerFunc {
  key := path + "#" + method
  handler, ok := h.handlers[key]
  if !ok {
    // todo 返回404专有handler
    return handler404.ServeHTTP
  } else {
    return handler
  }
}

func (h *myHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
  url := request.RequestURI
  method := request.Method
  uri := strings.Split(url, "?")[0]
  h.getHandlerFunc(uri, method)(writer, request)
}

func ServeHttp() {
  myHandler := NewMyHandler()
  myHandler.AddHandlerFunc("/hello", "GET", func(w http.ResponseWriter, r *http.Request) {
    // 必须解析哈!不然会报错
    _ = r.ParseForm()
    fmt.Println(r.Form.Get("name"))
    _, _ = w.Write([]byte("ok"))
  })
  myHandler.AddHandlerFunc("/hello", "POST", func(w http.ResponseWriter, r *http.Request) {
    _ = r.ParseForm()
    fmt.Println(r.PostForm.Get("name"))
    _, _ = w.Write([]byte("ok"))
  })
  myHandler.AddHandlerFunc("/upload", "POST", func(w http.ResponseWriter, r *http.Request) {
    // 限制大小为8MB
    _ = r.ParseMultipartForm(8 << 20)
    fileHeader := r.MultipartForm.File["my_file"][0]
    fmt.Println(fileHeader.Filename)
    _, _ = w.Write([]byte("ok"))
  })
  _ = http.ListenAndServe(":8190", myHandler)
}

上面代码我们先不说,先理一下Golang的整个HTTP处理流程,回过头来再看

揭开面纱

首先进入最开始的http.ListenAndServe()方法,这个方法源码如下:

构造一个Server对象,并调用它的ListenAndServe()方法,设置监听地址和handler,这个handler暂时理解成路由器。

Golang默认对于Http的处理是开发者提供一个路由器,然后对于每次请求,调用路由器路由到指定的处理函数,一般来说,处理函数也是开发者指定的,即,你要提供一个路由器,Golang把URI传给你,你根据URI找到处理这个URI的函数,并调用它,这一切都是在一个独立的Goroutine中处理的,每个请求互相隔离

server.ListenAndServe()的实现就是简单的监听端口,得到一个Listener:

这个方法实现比较简单:

首先使用死循环不停轮训端口,直到有连接建立,否则阻塞。然后设置用于连接建立后的上下文,然后使用Accept()方法得到一个Socket,因为Socket可读可写,所以命名为rw。

这个上下文在每一个Socket中保存了创建这个Socket的ServerSocket(就是Listener);此外,context的实现也很有意思,通过父context派生出子context的方式实现扩展context,或者增加值的操作。查询则是通过递归实现,如果当前context没有,则去父context中寻找。找。

这里面使用serverSocket的newConn()方法构造了一个连接对象,这个对象包含了用于读写的Socket,ServerSocket等信息,我们称它为新的更加全面的Socket(因为它还是用来操作远程客户端的读写)。

最后看到,开启一个新的Goroutine去执行新连接的读写请求,这和我们上面写的TCP好像啊!这也就是Golang可以抗住高连接的秘密。其实到现在为止,你也猜到了,c.server()方法无非就是封装HTTP请求和响应呗?确实如此!所以我们深入一下这个方法:

这个方法很简单,就是根据Socket的读写方法和缓冲区(之前有设置,我省略了没展示)来构造一个Response对象,并通过响应对象获得与它绑定的请求,传入新构建的Server对象的ServeHTTP()进行处理。

这里可以明白,虽然每次都构造了Server对象,但实质上所有的连接共享的都是同一个ServerSocket对象,即使这里有构造操作。

这里的handle其实就是一个路由表,指出了URI+Method=Func的对应关系,Func指的是业务逻辑函数。如果没有实现,则会使用默认的路由器。这个默认的路由器实现可以一谈,因为我们可以模仿它做一个属于我们的路由器:

这是默认路由器的结构,这个读写锁用于给路由表加锁,在读取路由表时加读锁,在更新路由表时加写锁。默认路由器实现了ServeHTTP()方法,这个方法会根据URI+Method作为key,去map里面查找对应的Entry并调用它的Handler.ServerHTTP()方法。

注意⚠️,这里虽然出现了两次ServeHTTP()方法,但是前者不做业务处理,仅做请求转发,后者是被转发的请求对应的业务处理函数,负责处理实际的请求

回到最初

现在我们看到,构造一个HTTP服务器,需要的两个参数,分别是监听地址和实现了ServeHTTP()方法的对象,Golang会为每一个请求调用我们传入的对象的ServeHTTP()方法去处理,所以我们必须在这个方法里实现我们自己的路由逻辑。至于路由之后你完全可以重新定一个新的业务逻辑方法,只要能根据请求找到可以处理它的Handler/Func即可。默认路由器使用业务处理函数和路由函数一样的定义是为了省事(我也是。

不要重复造轮子

最后,不要重复造轮子,Golang的HTTP包已经很好用了,但那不是你手撸框架的理由,这里推荐一个我个人蛮喜欢的框架:

这里给出一些它的基本用法:

package src

import (
  "fmt"
  "github.com/gin-gonic/gin"
  "strings"
)

func wrapStr(context *gin.Context, str string) {
  context.String(200, "%s", str)
}

// CRUD 比较简单的RESTFul格式的请求
func CRUD() {
  router := gin.Default()
  // 每个请求一个Context
  router.GET("/isGet", func(context *gin.Context) {
    context.String(200, "%s", "ok")
  })
  router.POST("/isPost", func(context *gin.Context) {
    context.String(200, "%s", "ok")
  })
  router.DELETE("/isDelete", func(context *gin.Context) {
    context.String(200, "%s", "ok")
  })
  router.PUT("isPut", func(context *gin.Context) {
    context.String(200, "%s", "ok")
  })
  router.Run("127.0.0.1:8190")
}

func PathVariable() {
  router := gin.Default()
  // 路径参数
  router.GET("/param/:name", func(context *gin.Context) {
    wrapStr(context, "name is:"+context.Param("name"))
  })
  // 强匹配,优先于路径参数匹配,和书写顺序无关
  router.GET("/param/msl", func(context *gin.Context) {
    wrapStr(context, "just msl")
  })
  // 可为空匹配
  router.GET("/param/nullable/:name1/*name2", func(context *gin.Context) {
    wrapStr(context, "nullable name: "+context.Param("name1")+", "+context.Param("name2"))
  })
  router.Run(":8190")
}

func GetAndPost() {
  r := gin.Default()
  r.GET("/get", func(context *gin.Context) {
    // 进行参数查询,也可以设置缺省值
    name := context.DefaultQuery("name", "msl")
    age := context.Query("age")
    wrapStr(context, "name: "+name+", age: "+age)
  })
  r.POST("/post", func(context *gin.Context) {
    name := context.DefaultPostForm("name", "msl")
    age := context.PostForm("age")
    wrapStr(context, "name: "+name+", age: "+age)
  })
  // 当然,路径查询也可以和表单查询混合使用
  r.POST("/map", func(context *gin.Context) {
    // 进行map解析,要求查询参数符合map书写形式,比如:/map?ids[0]=1&ids[1]=2
    // 同时请求体:names[0]=msl;names[1]=cwb
    ids := context.QueryMap("ids")
    names := context.PostFormMap("names")
    context.JSON(200, gin.H{
      "ids":   ids,
      "names": names,
    })
  })
  r.Run(":8190")
}

func FileUpload() {
  r := gin.Default()
  // 限制文件存储使用的内存大小为8MB
  r.MaxMultipartMemory = 8 << 20
  r.POST("/upload", func(context *gin.Context) {
    file, _ := context.FormFile("file")
    wrapStr(context, "get file: "+file.Filename+", size: "+fmt.Sprintf("%d", file.Size))
  })
  // 多文件上传
  r.POST("/uploads", func(context *gin.Context) {
    form, _ := context.MultipartForm()
    files := form.File["files"]
    stringBuilder := strings.Builder{}
    for _, file := range files {
      // 保存文件
      // context.SaveUploadedFile(file, "")
      stringBuilder.WriteString(file.Filename)
      stringBuilder.WriteString(", ")
    }
    wrapStr(context, stringBuilder.String())
  })
  r.Run(":8190")
}

func MiddleWare() {
  r := gin.New()
  r.GET("/test1", func(context *gin.Context) {
    wrapStr(context, "ok")
  })
  // 对所有/a开头的请求进行拦截
  auth := r.Group("/a")
  // 类似于添加请求拦截器
  auth.Use(func(context *gin.Context) {
    fmt.Println("need auth")
  })
  // 这个花括号就是为了美观
  // 在这里处理所有以/a为开头的请求
  {
    auth.POST("/signIn", func(context *gin.Context) {
      username := context.PostForm("username")
      password := context.PostForm("password")
      context.JSON(200, gin.H{
        "username": username,
        "password": password,
      })
    })
  }
  // 统一拦截和书写位置无关
  r.GET("/test2", func(context *gin.Context) {
    wrapStr(context, "ok")
  })
  r.Use(gin.CustomRecovery(func(context *gin.Context, err interface{}) {
    // 在这里编写panic处理逻辑
  }))
  r.Run(":8190")
}

一些想法

Golang的Goroutine固然好用,也很容易扛住高连接,而且开发心智低,但是它不是万能药,尤其是在性能方面。

现在处理高连接无非就是Golang/Kotlin的协程;或者是Java/Rust/C++的异步,听说C++也开始支持协程了。这两个方式有好有坏。

首先是协程,优点很明显,就是简单,写起来不容易出问题,学习成本低;但是缺点有一个不太容易想到的,就是用户级线程通过自定义执行上下文的方法,会造成更多的Cache Miss以及更难让CPU做出指令级优化,比如分支预测。

其次是异步,优点不是很明显,但是它本质依旧是函数调用,可以让编译器进行更多的优化,以及指令级优化;缺点就很明显,如果你写过类似的框架,比如Java的Netty,就知道需要处处处理阻塞和线程,这样会容易产生BUG和降低开发效率。

最后,人生苦短,Let's Go!