一直用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