一直用go编写TCP、HTTP、websocket服务器,得空总结一些简单的范式,供参考。代码在github上都可以看到。
1、TCP server
之前用c++写TCP server,一般两种模式:
- 1个listener线程 + N个processor线程
- 通过REUSEPORT机制,N个listener线程,TCP包的处理可以直接在listener线程中做,也可以起processor线程处理。
用go写TCP的server,做并发简单多了,因为goroutine比线程轻量很多,在一定的并发下,创建以及销毁一个goroutine的性能损耗可以不用过多的考虑。所以通常的做法是:一个listener goroutine,来了新连接就启动两个goroutine,一个负责读一个负责写。 至于其他的逻辑处理,可以在读的goroutine中处理,也可以在其他业务逻辑的goroutine中处理。
我的这个范式中,先定义个处理TCP包的handler
type MessageHandler struct {
conn net.Conn
ExitCmd chan bool
writeChan chan []byte
}
func (this *MessageHandler)WaitingForRead(){
......
}
func (this *MessageHandler)WaitingForWrite(){
......
}
tcp server的代码片段一般如下:
serverAddr, err := net.ResolveTCPAddr("tcp", hostAndPort)
if err != nil {
log.Printf("Resolving %s failed: %s\n", hostAndPort, err.Error())
return err
}
listener, err := net.ListenTCP("tcp", serverAddr)
if err != nil {
log.Printf("listen %s failed: %s", hostAndPort, err.Error())
return err
}
log.Printf("start tcp server %s\n", hostAndPort)
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("accept error: %s , close this socket and exit\n", err.Error())
listener.Close()
return err
}
handler := NewMessageHandler(conn)
//this.clientList = append(this.clientMap, handler)
go handler.WaitingForRead()
go handler.WaitingForWrite()
}
}
每一个新的连接,new一个MessageHandler,然后分别启动一个读和写的goroutine来等待socket的读写事件。
一般我们都要定义好服务端跟客户端之间的协议,封好应用层的包写到socket中。但是TCP是流式传输字节流,通过TCP传输数据,存在粘包情况,例如:一端write两个包,另一端某次read,可能read到0.5个包,也有可能read到1.5个包。所以对于tcp的读,需要判断什么时候读到了完整的应用层包。一般做法有两个:
- 每一个应用层的包以特定字符串结尾,比如”/r/n/r/n”。
这样的话,read的时候需要对读到的每一个字符串做比较,以判断是否到了结束符。这个字符串比较也是不小的开销。
- 将应用层的包设计成head+body样式,比如head为固定的2个字节,表示body的长度。read的时候,先取2个字节,解析出body的长度,然后再取该长度的字节流解析出body。
这种做法,read的时候,需要知道约定的消息格式,代码中socket的读需要跟应用层的协议耦合在一起。但是避免了频繁的字符串比较,所以我们一般都选择这种做法。
比如我们约定消息格式如下:
8 16 24 32
|--------|--------|--------|--------|
| bodyLen | magic |
|--------|--------|--------|--------|
| seq |
|--------|--------|--------|--------|
| body |
| |
那read的代码如下:
func (this *MessageHandler)WaitingForRead(){
//......
var ibuf []byte = make([]byte, 1024)//[注释1] 1024字节大小的缓冲区
var needRead int = 1024
var bodyLen uint16 = 0
var endPos int = 0
var startPos int = 0
var magic uint16 = 0
var seq uint32 = 0
for {
//[注释2]开始等待读,每次读到缓冲区endPos指定的位置
length, err := this.conn.Read(ibuf[endPos:])
log.Printf("read data: %d\n", length)
switch err {
case nil:
endPos += length
//有可能一次读到了多个应用层的包,所以要循环处理
for {
if endPos-startPos < 8 {
break
}
if bodyLen == 0 {
bodyLen = binary.BigEndian.Uint16(ibuf[startPos : startPos+2])
magic = binary.BigEndian.Uint16(ibuf[startPos+2 : startPos+4])
seq = binary.BigEndian.Uint32(ibuf[startPos+4 : startPos+8])
}
needRead = int(bodyLen) - (endPos - startPos - 8)
log.Printf("startPos:%d, endPos:%d, bodyLen:%d, magic:%d, seq:%d, needRead:%d", startPos, endPos, bodyLen, magic, seq, needRead)
if needRead > 0 {
break
} else {
//[注释3]读到完整的消息后,处理
res := this.handleMsg(8, bodyLen, ibuf[startPos:], magic, seq)
if res == -1 {
log.Printf("handle msg error, close the connection\n")
goto DISCONNECT
}
startPos += int(bodyLen) + 8
bodyLen = 0
needRead = 0
if startPos == endPos {
startPos = 0
endPos = 0
}
}
}
//[注释4]循环处理完后,如果缓冲区中还有剩余的数据未处理,则挪到缓冲区最左端,避免缓冲区满无法再读数据
if startPos < endPos && startPos > 0 {
reader := bytes.NewReader(ibuf)
reader.ReadAt(ibuf, int64(endPos-startPos))
startPos = 0
endPos -= startPos
}
case syscall.Errno(0xb): // try again
log.Printf("read need try again\n")
continue
default:
log.Printf("read error %s\n", err.Error())
goto DISCONNECT
}
}
DISCONNECT:
//......
}
这里有几处可能存在问题:
- [注释1] 的地方,缓冲区应该根据实际设置大一点,粘包情况还是比较频繁的。
- [注释2] 的地方,这里用简单的阻塞Read。也可以用io.ReadFull,每次读满一定的字节,比如先读8个字节的head,解析出body长度,再读出该长度的数据处理,这样不存在边界问题。但是跟Read相比,可能会多出很多ReadFull的系统调用。因为如果对端数据比较频繁,Read可以一次从缓冲区读出多个应用层包的数据。
- [注释3] 的地方,要特别注意,slice作为传参只是传的引用,缓冲区的数据可能被覆盖,所以如果HandleMessage是在其他goroutine中处理,一定要先把数据copy出去,否则极有可能前一个包的数据被覆盖造成解包错误。
- [注释4] 的地方,涉及到缓冲区移动数据,太频繁的ReadAt调用可能有性能损耗。可以采用环形缓冲区减少字节移动,或者在大缓冲区情况下,当缓冲区快到右边边界时才挪动数据。
以上几点有兴趣的童鞋可以自己改进。具体代码实例如下:tcpserver
2、TCP client
client端的代码读写部分可以参考server部分的代码,这里就不做过多描述。具体代码实例如下:tcpclient