今天看了看 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!
评论