
我使用Go来编写一些工具也有一段时间了。接下来我决定花更多的时间和心思去深入学习它,主要的方向是系统编程以及分布式编程。
这个聊天室是灵光一现所得。对于一个我的沙盒项目而言,它足够的简洁但也不至于太过简单。我会尽量尝试从0开始去编写这个项目。
本文更像是一份我在练习如何去用Go编写程序时的总结,如果你更趋向于看源代码,你可以查看我github的项目。
需求
聊天室的基础的功能:
- 一个简单的聊天室
- 用户可以连接到这个聊天室
- 用户可以设置他们连接时的用户名
- 用户可以在里面发消息,并且消息会被广播给所有其他用户
目前聊天室是没有做数据持久化的,用户只能看到他/她登陆以后所接收到的消息。
通讯协议
客户端和服务端通过字符串进行TCP通讯。我原本打算使用RPC协议进行数据传输,但是最后还是采用TCP的一个主要原因是我并不是很经常去接触到TCP底层的数据流操作,而RPC偏向于上层的通讯操作,所以也想借此机会尝试和学习一下。
有了以上需求能引申出以下3个指令:
- 发送指令(SEND):客户端可以发送聊天消息
- 命名指令(Name):客户端设置用户名
- 消息指令(MESSAGE):服务端广播聊天消息给其他用户
\n
SEND Hello\nMESSAGE 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
}
复制代码
readerwriterio.Readerio.Writer
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
服务端编写
interfacestructinterface
type ChatServer interface {
Listen(address string) error
Broadcast(command interface{}) error
Start()
Close()
}
复制代码
structclientsusername
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)
}
}
}
复制代码
mutexGoroutine
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()
}
复制代码
serveSEND
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
structconn
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
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
}
复制代码
SendcmdWriter
func (c *TcpChatClient) Send(command interface{}) error {
return c.cmdWriter.Write(command)
}
复制代码
Start
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)
}
}
}
}
复制代码
客户端的完整代码戳这里
TUI
我花了一些时间在客户端的UI的编写上,这能让整个项目更加可视化,直接在终端上显示UI是一件很酷的事情。Go有很多第三方的包去支持终端UI,但是tui-go是目前为止我发现的唯一一个支持文本框的,并且它已经有一个非常不错的聊天示例。这里是一部分相当多的代码由于篇幅有限就不在赘述,又可以戳这里查看完整的代码。
结论
这无疑是一个非常有趣的练习,整个过程下来刷新了我对TCP网络编程的认识以及学到了很多终端UI的知识。
接下来要做什么?或许可以考虑增加更多的功能,例如多聊天室,数据持久化,也或许是更好的错误处理,当然不能忘了,还有单元测试。😉