golang 实现 tcp-聊天室

以下代码都已传到github平台:https://github.com/ElzatAhmed/go-tcp-chatroom

想必做golang网络开发的同学对gosdk中net/http包十分熟悉,使用net/http可以快速实现http服务器的搭建。但是大家对tcp协议的直接使用可能就没有那么熟悉了。在接下来的文档里我将讲解如何使用gosdk中的net包实现一个以tcp协议为基础的简易的终端群聊聊天室应用。
在dive in之前,需要回顾/介绍以下golang并发编程中的核心概念:

goroutine

A goroutine is a lightweight thread managed by the Go runtime

channel

Communicating Sequential ProcessesDo not communicate by sharing memory, share memory by communicating
package chlock

// Chlock is a locker implemented using channel
type Chlock struct {
	ch chan interface{}
}

// New returns the pointer to a new Chlock struct
func New() *Chlock {
	return &Chlock{
		ch: make(chan interface{}, 1),
	}
}

// Lock writes once to the channel and
// blocks all the other goroutines which tries to write
func (lock *Chlock) Lock() {
	lock.ch <- struct{}{}
}

// Unlock reads from the channel and unblock one goroutine
func (lock *Chlock) Unlock() {
	<-lock.ch
}
count
package main

import (
	"fmt"
	"sync"

	"github.com/elzatahmed/channel-lock/chlock"
)

var count int

func main() {
	var wg sync.WaitGroup
	wg.Add(1000)
	lock := chlock.New()
	for i := 0; i < 1000; i++ {
		go add(lock, &wg)
	}
	wg.Wait()
	fmt.Printf("count = %d\n", count)
}

func add(lock *chlock.Chlock, wg *sync.WaitGroup) {
	lock.Lock()
	defer wg.Done()
	defer lock.Unlock()
	count++
}

输出:

➜  channel-lock go run main.go
count = 1000

介绍完goroutine和channel之后我们就dive in到实现tcp聊天室的过程当中:

tcp聊天室的实现

首先我们开始打造聊天室的模型基础:

模型

第一个模型即用户模型,我们简单的以用户的名字作为用户的主键,并为其创建两个channel:

  1. receive channel:即获取消息的channel
  2. done channel:发送/获取断开连接的channel
// user is a single connection to the tcp chat server
type user struct {
	name    string
	receive chan message
	done    chan interface{}
}

message 是我定义的聊天室消息模型:

type msgType uint8

// message is the model for every message flows through the chatroom
type message struct {
	typ     msgType 	// 利用msgType来区分其应该是系统消息还是用户消息
	from    string
	content string
	when    time.Time
}

const (
	msgTypeUser msgType = iota
	msgTypeSystem
)

接下来建立聊天室模型,可想而知聊天室由多个用户组成,所以我们需要一个存储用户指针的slice,同时利用聊天室名去作为主键区分不同的聊天室,为了聊天室强化聊天室的功能在添加一个历史消息组件,存储一定数量的历史消息,在新的用户进入聊天室后将历史消息一并发送给用户:

// chatroom is the collection of users which they can receive every message from each other
type chatroom struct {
	name  string
	users []*user
	mu    sync.Mutex	// 在对用户slice进行操作进行加锁时使用
	his   *history
}

最后时server模型的创建,一个tcp聊天室中应包含多个群聊聊天室和多个群聊用户,我们利用map结构的方式去存储这些数据,同时聊天室需要有一个网络地址:

// chatServer is the listening and dispatching server for tcp chatroom,
// it stores information about all the rooms and all the users ever created
type chatServer struct {
	addr   string
	rooms  map[string]*chatroom
	users  map[string]*user
	muRoom sync.Mutex
	muUser sync.Mutex
}

交互过程

net.Conn
type Conn interface {
	
	Read(b []byte) (n int, err error)

	Write(b []byte) (n int, err error)

	Close() error

	LocalAddr() Addr

	RemoteAddr() Addr

	SetDeadline(t time.Time) error

	SetReadDeadline(t time.Time) error

	SetWriteDeadline(t time.Time) error
}
net.Connio.Readerio.Writerbufio

user goroutine

listenreceive channelnet.Conndone channel
// listen starts a loop to receive from the receive channel and writes to the net.Conn
func (u *user) listen(conn net.Conn) {
	for {
		select {
		case msg := <-u.receive:
			_, _ = conn.Write(msg.bytes()) 
			// msg.bytes()是我在message结构体下定义的将message打包成字符串
			// 并转化成字节数组返回的函数
		case <-u.done:
			break
		}
	}
}
conn.Write

chatroom 广播

listen
// broadcast sends the message to every user in the chatroom except the sender
func (room *chatroom) broadcast(msg message) {
	for _, u := range room.users {
		if u.name == msg.from {
			continue
		}
		// 这里启动的goroutine是为了定义写入的超时时间(因为写入可能会block)
		// 如果不需要也可以抛弃这里的goroutine,直接进行写入
		go func(u *user) {
			select {
			case u.receive <- msg:
				break
			case <-time.After(3 * time.Second):
				break
			}
		}(u)
	}
}

该函数将在下一部分chatroom goroutine内容中定义的函数中利用

chatroom goroutine

每一个聊天室对每一个用户连接都需要保持一个tcp连接,即tcp连接的数量 = 聊天室1 * 聊天室1用户数量 + 聊天室2 * 聊天室2用户数量 + ···
每一个tcp连接利用下面定义的chatroom结构体下的newUser函数来维持:

// newUser adds the user to the chatroom and starts a loop for reading from the net.Conn
func (room *chatroom) newUser(user *user, conn net.Conn) {
	room.mu.Lock()
	room.users = append(room.users, user)
	room.mu.Unlock()
	room.broadcast(newSystemMsg(contentHello(user.name)))
	room.writeHistory(conn)
	for {
		reader := bufio.NewReader(conn)
		bytes, err := reader.ReadBytes('\n')
		if err != nil {
			continue
		}
		content := strings.Trim(string(bytes), "\n")
		log.Printf("%s -> %s: %s\n", user.name, room.name, content)
		// if content equals to "exit" then close the connection
		if content == contentExit {
			user.done <- struct{}{}
			room.broadcast(newSystemMsg(contentGoodbye(user.name)))
			_ = conn.Close()
			break
		}
		msg := newUserMsg(user.name, content)
		room.his.push(msg)
		room.broadcast(msg)
	}
}

上述函数的主体内容和步骤可以总结为以下内容:

net.Conndone channel

server的建立

net.Listenernet.Connnet.Listnernet.Connnet.Listenernet.Listennet.Listenetnet.Conn
// Spin starts the chatServer at given address
func (server *chatServer) Spin() {
	listener, err := net.Listen("tcp", server.addr)
	if err != nil {
		log.Fatalf("failed to start the server at %s, err: %s\n", server.addr, err.Error())
	}
	log.Printf("server started at address %s...\n", server.addr)
	for {
		// 开启循环接受新的连接
		conn, err := listener.Accept()
		log.Printf("server accepted a new connection from %s\n", conn.RemoteAddr())
		if err != nil {
			continue
		}
		// 将获取的conn对象传给spin方法(注意不是Spin方法),开启新的goroutine
		go server.spin(conn)
	}
}
username;chatroomName
// spin do the protocol procedure and starts the connection goroutines
func (server *chatServer) spin(conn net.Conn) {
	reader := bufio.NewReader(conn)
	bytes, err := reader.ReadBytes('\n')
	if err != nil {
		log.Printf("connection failed with client %s with err: %s\n",
			conn.RemoteAddr(), err.Error())
		return
	}
	username, roomname, err := parseProtocol(bytes)
	if err != nil {
		_, _ = conn.Write(comm.BytesProtocolErr)
		return
	}
	if _, ok := server.users[username]; ok {
		_, _ = conn.Write(comm.BytesUsernameExists)
		_ = conn.Close()
		log.Printf("connection from %s closed by server\n", conn.RemoteAddr())
		return
	}
	log.Printf("connecting user %s to chatroom %s...\n", username, roomname)
	u := server.newUser(username)
	room, ok := server.rooms[roomname]

	if !ok {
		room = server.newRoom(roomname)
	}
	go room.newUser(u, conn)
	go u.listen(conn)
	log.Printf("user %s is connected to chatroom %s\n", username, roomname)
}

以上函数的内容和步骤可以总结为以下:

net.Connusers

到此服务器内容全部结束,现在我们就开始测试我们的服务器,首先建立main函数:

func main() {
	// 利用flag获取命令行输入参数
	host := flag.String("h", "127.0.0.1", "the host name of the server")
	port := flag.Int("p", 8888, "the port number of the server")
	flag.Parse()
	chatServer := server.New(*host, *port)
	chatServer.Spin()
}

在终端中启动服务器:

➜  go-tcp-chatroom go run server.go -h 127.0.0.1 -p 8888
2022/07/03 22:47:53 server started at address 127.0.0.1:8888...

我们利用linux工具nc(netcat)进行对服务器的tcp连接发送内容:

➜  go-tcp-chatroom nc 127.0.0.1 8888
xiaoming;room1
2022-07-03 22:49:02 server: user xiaoming entered the chatroom and says hello!
hello everyone!
my name is xiaoming!
2022-07-03 22:49:31 server: user xiaohong entered the chatroom and says hello!
2022-07-03 22:49:37 xiaohong: hello xiaoming!
2022-07-03 22:49:42 xiaohong: my name is xiaohong!
➜  go-tcp-chatroom nc 127.0.0.1 8888
xiaohong;room1
2022-07-03 22:49:10 xiaoming: hello everyone!
2022-07-03 22:49:18 xiaoming: my name is xiaoming!
2022-07-03 22:49:31 server: user xiaohong entered the chatroom and says hello!
hello xiaoming!
my name is xiaohong!
➜  go-tcp-chatroom go run server.go -h 127.0.0.1 -p 8888
2022/07/03 22:47:53 server started at address 127.0.0.1:8888...
2022/07/03 22:48:52 server accepted a new connection from 127.0.0.1:65236
2022/07/03 22:49:02 connecting user xiaoming to chatroom room1...
2022/07/03 22:49:02 user xiaoming is connected to chatroom room1
2022/07/03 22:49:10 xiaoming -> room1: hello everyone!
2022/07/03 22:49:18 xiaoming -> room1: my name is xiaoming!
2022/07/03 22:49:26 server accepted a new connection from 127.0.0.1:65330
2022/07/03 22:49:31 connecting user xiaohong to chatroom room1...
2022/07/03 22:49:31 user xiaohong is connected to chatroom room1
2022/07/03 22:49:37 xiaohong -> room1: hello xiaoming!
2022/07/03 22:49:42 xiaohong -> room1: my name is xiaohong!

PS: 源代码中实现client端,大家可以在源代码中查看client端实现逻辑,欢迎大家在github上关注我,如果上述中有表达错误也希望大家能提出来!