原文出處:medium.com/@nqbao/writ…git
我使用Go來編寫一些工具也有一段時間了。接下來我決定花更多的時間和心思去深刻學習它,主要的方向是系統編程以及分佈式編程。github
這個聊天室是靈光一現所得。對於一個個人沙盒項目而言,它足夠的簡潔但也不至於太過簡單。我會盡可能嘗試從0開始去編寫這個項目。golang
本文更像是一份我在練習如何去用Go編寫程序時的總結,若是你更趨向於看源代碼,你能夠查看我github的項目。編程
聊天室的基礎的功能:網絡
目前聊天室是沒有作數據持久化的,用戶只能看到他/她登錄之後所接收到的消息。併發
客戶端和服務端經過字符串進行TCP通信。我本來打算使用RPC協議進行數據傳輸,可是最後仍是採用TCP的一個主要緣由是我並非很常常去接觸到TCP底層的數據流操做,而RPC偏向於上層的通信操做,因此也想借此機會嘗試和學習一下。app
有了以上需求能引伸出如下3個指令:socket
每一個指令都是字符串,以指令名稱開始,中間帶有參數/內容,以\n
結束。tcp
例如,要發送一個「Hello」的消息,用戶端會將字符串SEND Hello\n
提交給TCP socket,服務端接受後會廣播MESSAGE username Hello\n
給其餘用戶。編程語言
首先定義好struct
來表示全部的指令
// SendCommand is used for sending new message from client
type SendCommand struct {
Message string
}
// NameCommand is used for setting client display name
type NameCommand struct {
Name string
}
// MessageCommand is used for notifying new messages
type MessageCommand struct {
Name string
Message string
}
複製代碼
接下來我會繼承一個reader
來將這些命令轉化成字節流,再經過writer
去將這些字節流轉化回字符串。Go將 io.Reader
以及io.Writer
做爲通用的接口是一個很是好的作法,可使得集成的時候不須要去關心TCP字節流部分的實現。
Writer的編寫比較容易
type CommandWriter struct {
writer io.Writer
}
func NewCommandWriter(writer io.Writer) *CommandWriter {
return &CommandWriter{
writer: writer,
}
}
func (w *CommandWriter) writeString(msg string) error {
_, err := w.writer.Write([]byte(msg))
return err
}
func (w *CommandWriter) Write(command interface{}) error {
// naive implementation ...
var err error
switch v := command.(type) {
case SendCommand:
err = w.writeString(fmt.Sprintf("SEND %v\n", v.Message))
case MessageCommand:
err = w.writeString(fmt.Sprintf("MESSAGE %v %v\n", v.Name, v.Message))
case NameCommand:
err = w.writeString(fmt.Sprintf("NAME %v\n", v.Name))
default:
err = UnknownCommand
}
return err
}
複製代碼
Reader的代碼相對長一些,將近一半的代碼是錯誤處理。因此在編寫這一部分代碼的時候我就會想念其餘錯誤處理很是簡易的編程語言。
type CommandReader struct {
reader *bufio.Reader
}
func NewCommandReader(reader io.Reader) *CommandReader {
return &CommandReader{
reader: bufio.NewReader(reader),
}
}
func (r *CommandReader) Read() (interface{}, error) {
// Read the first part
commandName, err := r.reader.ReadString(' ')
if err != nil {
return nil, err
}
switch commandName {
case "MESSAGE ":
user, err := r.reader.ReadString(' ')
if err != nil {
return nil, err
}
message, err := r.reader.ReadString('\n')
if err != nil {
return nil, err
}
return MessageCommand{
user[:len(user)-1],
message[:len(message)-1],
}, nil
// similar implementation for other commands
default:
log.Printf("Unknown command: %v", commandName)
}
return nil, UnknownCommand
}
複製代碼
完整的代碼能夠在此處查看reader.go以及writer.go
先定義一個server的interface
,我沒有直接定義一個struct
是由於interface
能讓這個server的行爲更加清晰明瞭。
type ChatServer interface {
Listen(address string) error
Broadcast(command interface{}) error
Start()
Close()
}
複製代碼
如今開始編寫實際的server的方法,我傾向於在struct
中增長一個私有屬性clients
,爲了方便跟蹤鏈接的用戶以其餘的username
type TcpChatServer struct {
listener net.Listener
clients []*client
mutex *sync.Mutex
}
type client struct {
conn net.Conn
name string
writer *protocol.CommandWriter
}
func (s *TcpChatServer) Listen(address string) error {
l, err := net.Listen("tcp", address)
if err == nil {
s.listener = l
}
log.Printf("Listening on %v", address)
return err
}
func (s *TcpChatServer) Close() {
s.listener.Close()
}
func (s *TcpChatServer) Start() {
for {
// XXX: need a way to break the loop
conn, err := s.listener.Accept()
if err != nil {
log.Print(err)
} else {
// handle connection
client := s.accept(conn)
go s.serve(client)
}
}
}
複製代碼
當服務端接受一個鏈接時,它會建立對應的client去跟蹤此用戶。同時我須要用mutex
去鎖定此共享資源,避免併發請發下的數據不一致問題。Goroutine
是一個強大的功能,但你依然須要本身去留意和注意一些併發狀況下的數據處理問題。
func (s *TcpChatServer) accept(conn net.Conn) *client {
log.Printf("Accepting connection from %v, total clients: %v", conn.RemoteAddr().String(), len(s.clients)+1)
s.mutex.Lock()
defer s.mutex.Unlock()
client := &client{
conn: conn,
writer: protocol.NewCommandWriter(conn),
}
s.clients = append(s.clients, client)
return client
}
func (s *TcpChatServer) remove(client *client) {
s.mutex.Lock()
defer s.mutex.Unlock()
// remove the connections from clients array
for i, check := range s.clients {
if check == client {
s.clients = append(s.clients[:i], s.clients[i+1:]...)
}
}
log.Printf("Closing connection from %v", client.conn.RemoteAddr().String())
client.conn.Close()
}
複製代碼
serve
方法主要的邏輯是從客戶端發送過來的指令而且根據指令的不一樣去處理他們。因爲咱們有reader和writer的通信協議,因此server只要處理高層的信息而不是底層的二進制流。若是server接收到SEND
命令,則會廣播信息給其餘用戶。
func (s *TcpChatServer) serve(client *client) {
cmdReader := protocol.NewCommandReader(client.conn)
defer s.remove(client)
for {
cmd, err := cmdReader.Read()
if err != nil && err != io.EOF {
log.Printf("Read error: %v", err)
}
if cmd != nil {
switch v := cmd.(type) {
case protocol.SendCommand:
go s.Broadcast(protocol.MessageCommand{
Message: v.Message,
Name: client.name,
})
case protocol.NameCommand:
client.name = v.Name
}
}
if err == io.EOF {
break
}
}
}
func (s *TcpChatServer) Broadcast(command interface{}) error {
for _, client := range s.clients {
// TODO: handle error here?
client.writer.Write(command)
}
return nil
}
複製代碼
啓動這個server的代碼相對簡單
var s server.ChatServer
s = server.NewServer()
s.Listen(":3333")
// start the server
s.Start()
複製代碼
完整的server代碼戳這裏
一樣咱們使用interface
先定義客戶端
type ChatClient interface {
Dial(address string) error
Send(command interface{}) error
SendMessage(message string) error
SetName(name string) error
Start()
Close()
Incoming() chan protocol.MessageCommand
}
複製代碼
客戶端經過Dial()
鏈接到服務端,Start()
Close()
負責中止和關閉服務,Send()
用於發送指令。SetName()
和SendMessage()
負責設置用戶名以及發送消息的邏輯封裝。最後Incoming()
返回一個channel
,做爲和服務端創建起來做爲通信的鏈接通道。
下來定義客戶端的struct
,裏面設置一些私有變量用於跟蹤鏈接的conn
,同時reader/writer是發送消息放方法的封裝。
type TcpChatClient struct {
conn net.Conn
cmdReader *protocol.CommandReader
cmdWriter *protocol.CommandWriter
name string
incoming chan protocol.MessageCommand
}
func NewClient() *TcpChatClient {
return &TcpChatClient{
incoming: make(chan protocol.MessageCommand),
}
}
複製代碼
全部的方法都相對簡單,Dial
創建鏈接而且建立通信協議的reader和writer。
func (c *TcpChatClient) Dial(address string) error {
conn, err := net.Dial("tcp", address)
if err == nil {
c.conn = conn
}
c.cmdReader = protocol.NewCommandReader(conn)
c.cmdWriter = protocol.NewCommandWriter(conn)
return err
}
複製代碼
Send
使用cmdWriter
將制定發送到服務端
func (c *TcpChatClient) Send(command interface{}) error {
return c.cmdWriter.Write(command)
}
複製代碼
其餘方法相對簡單我就不一一在本文贅述。最重要的方法是client的Start
方法,這是用來監聽服務端廣播的消息而且將他們發送回channel。
func (c *TcpChatClient) Start() {
for {
cmd, err := c.cmdReader.Read()
if err == io.EOF {
break
} else if err != nil {
log.Printf("Read error %v", err)
}
if cmd != nil {
switch v := cmd.(type) {
case protocol.MessageCommand:
c.incoming <- v
default:
log.Printf("Unknown command: %v", v)
}
}
}
}
複製代碼
客戶端的完整代碼戳這裏
我花了一些時間在客戶端的UI的編寫上,這能讓整個項目更加可視化,直接在終端上顯示UI是一件很酷的事情。Go有不少第三方的包去支持終端UI,可是tui-go是目前爲止我發現的惟一一個支持文本框的,而且它已經有一個很是不錯的聊天示例。這裏是一部分至關多的代碼因爲篇幅有限就不在贅述,又能夠戳這裏查看完整的代碼。
這無疑是一個很是有趣的練習,整個過程下來刷新了我對TCP網絡編程的認識以及學到了不少終端UI的知識。
接下來要作什麼?或許能夠考慮增長更多的功能,例如多聊天室,數據持久化,也或許是更好的錯誤處理,固然不能忘了,還有單元測試。😉