写点什么

Golang 网络编程

用户头像
CodeWithBuff
关注
发布于: 刚刚

今天看了看 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 包已经很好用了,但那不是你手撸框架的理由,这里推荐一个我个人蛮喜欢的框架:

Gin

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


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!

发布于: 刚刚阅读数: 2
用户头像

CodeWithBuff

关注

Wubba lubba dub dub. 2021.03.13 加入

上学中...

评论

发布
暂无评论
Golang网络编程