Golang网络编程

1,069 阅读13分钟

今天看了看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中处理的,每个请求互相隔离

image.png

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

image.png

这个方法实现比较简单:

image.png

首先使用死循环不停轮训端口,直到有连接建立,否则阻塞。然后设置用于连接建立后的上下文,然后使用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请求和响应呗?确实如此!所以我们深入一下这个方法:

image.png

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

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

image.png

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

image.png

这是默认路由器的结构,这个读写锁用于给路由表加锁,在读取路由表时加读锁,在更新路由表时加写锁。默认路由器实现了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!