golang 实现 tcp-聊天室
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
在go.dev中是這么介紹goroutine的 A goroutine is a lightweight thread managed by the Go runtime,goroutine是一種被go runtime管理的輕量級線程,其并不會直接對應到重量級的系統線程上,而是被go scheduler調度到少量的系統線程上進行維護。所以在一個go程序中,我們可以同時創造成千上萬個goroutine,將其交給go runtime去進行調度和垃圾回收。
channel
不知道你是否聽說過一種理論叫做CSP,Communicating Sequential Processes,go將其核心思想總結成以下 Do not communicate by sharing memory, share memory by communicating,也就是說不要利用共享內存的方式去交互,利用交互的方式去共享內存(這里的交互這一名詞可能用得不太對,大家可以以communication去理解)。channel正是利用這種思想而定義的。channel可以被理解為支持寫入和讀取的消息管道,其是完全并發安全,channel的每一個元操作都是以單線程的方式進行的,這就代表我們不僅能利用channel進行線程間通信,我們還可以利用它實現鎖。以下是一種利用buffered channel (size大于0的channel) 實現的結構非常簡單的鎖:
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 }創建1000個goroutine對同一個全局變量count進行加一操作去測試我們實現的ChLock是否有效:
package mainimport ("fmt""sync""github.com/elzatahmed/channel-lock/chlock" )var count intfunc main() {var wg sync.WaitGroupwg.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:
message 是我定義的聊天室消息模型:
type msgType uint8// message is the model for every message flows through the chatroom type message struct {typ msgType // 利用msgType來區分其應該是系統消息還是用戶消息from stringcontent stringwhen time.Time }const (msgTypeUser msgType = iotamsgTypeSystem )接下來建立聊天室模型,可想而知聊天室由多個用戶組成,所以我們需要一個存儲用戶指針的slice,同時利用聊天室名去作為主鍵區分不同的聊天室,為了聊天室強化聊天室的功能在添加一個歷史消息組件,存儲一定數量的歷史消息,在新的用戶進入聊天室后將歷史消息一并發送給用戶:
// chatroom is the collection of users which they can receive every message from each other type chatroom struct {name stringusers []*usermu 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 stringrooms map[string]*chatroomusers map[string]*usermuRoom sync.MutexmuUser sync.Mutex }交互過程
在開始講解交互過程之前,我們首先需要了解net包中的一個interface net.Conn:
type Conn interface {Read(b []byte) (n int, err error)Write(b []byte) (n int, err error)Close() errorLocalAddr() AddrRemoteAddr() AddrSetDeadline(t time.Time) errorSetReadDeadline(t time.Time) errorSetWriteDeadline(t time.Time) error }net.Conn實現了io.Reader和io.Writer接口,也就是說我們可以從Conn中讀取字節,也可以往Conn中寫入字節,更好的是我們可以利用bufio包中的工具去對他進行操作。
user goroutine
交互過程的第一步,我們要在user結構體下編寫編寫一個listen的函數,其主要作用就是讀取receive channel中的內容并編寫到net.Conn中,在這我利用到了go經典的select case語句模型,也就是說當我從done 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返回的錯誤(這是一個不好的習慣,建議大家在編寫時處理該錯誤)。我們將在后續的server啟動函數中將該函數對待成goroutine來啟動它。
chatroom 廣播
我們在定義user結構體下的listen函數時從receive channel中讀取了message,那么我們需要一個goroutine往該channel中寫入內容。chatroom下的broadcast(廣播)函數,接受一個message并將其廣播發送給聊天室中的每一個用戶:
// 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:breakcase <-time.After(3 * time.Second):break}}(u)} }該函數將在下一部分chatroom goroutine內容中定義的函數中利用
chatroom goroutine
每一個聊天室對每一個用戶連接都需要保持一個tcp連接,即tcp連接的數量 = 聊天室1 * 聊天室1用戶數量 + 聊天室2 * 聊天室2用戶數量 + ···
每一個tcp連接利用下面定義的chatroom結構體下的newUser函數來維持:
上述函數的主體內容和步驟可以總結為以下內容:
server的建立
有c++、java或python tcp編程的經驗的同學都知道,tcp連接是由socket實現的,socket分為兩種(監聽socket和通信socket),每個socket又是四元組(兩組ip和端口號)。golang中并沒有socket的概念,golang中的兩種socket就是net包中的net.Listener和net.Conn接口,net.Listner對應監聽socket,net.Conn對應通信socket。net.Listener的建立我們只需要利用net包中的net.Listen函數并傳入協議名稱(這里是tcp)和服務器地址即可。在利用net.Listenet的Listen方法連接新的用戶創建通信socket(net.Conn),而這里的通信socket正式貫通了整個tcp聊天室的tcp連接體。
// 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方法),開啟新的goroutinego server.spin(conn)} }連接建立以后,我們需要提前定一個簡單的協議讓用戶決定自己的用戶名和想要加入的聊天室名稱。這里我們就定一行字符串username;chatroomName利用分號分割用戶名和聊天室名稱。在服務器接收到連接以后的第一步就是要讀取用戶發來的協議請求,解析創建并將用戶分配到對應的聊天室中,其次在啟動新的chatroom goroutine和user goroutine即可:
// 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) }以上函數的內容和步驟可以總結為以下:
到此服務器內容全部結束,現在我們就開始測試我們的服務器,首先建立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上關注我,如果上述中有表達錯誤也希望大家能提出來!
總結
以上是生活随笔為你收集整理的golang 实现 tcp-聊天室的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 探索入门云计算风向标Amazon的ECS
- 下一篇: 住逻辑APP全面升级,只为让好设计完美落