38. Terminal project: a TCP chat server with rooms
Goal: apply Chapters 15–17, 27–29, and 34 to a real multi-client, concurrent, stateful server — the natural "advanced" successor to the cache server in Chapter 26.
package main
import (
"bufio"
"fmt"
"log"
"net"
"strings"
"sync"
)
type Room struct {
mu sync.Mutex
clients map[string]net.Conn
}
func NewRoom() *Room { return &Room{clients: map[string]net.Conn{}} }
func (r *Room) Join(name string, conn net.Conn) {
r.mu.Lock()
defer r.mu.Unlock()
r.clients[name] = conn
r.broadcastLocked(name, "* "+name+" joined")
}
func (r *Room) Leave(name string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.clients, name)
r.broadcastLocked(name, "* "+name+" left")
}
func (r *Room) Broadcast(from, msg string) {
r.mu.Lock()
defer r.mu.Unlock()
r.broadcastLocked(from, msg)
}
// caller must hold r.mu
func (r *Room) broadcastLocked(from, msg string) {
line := fmt.Sprintf("[%s] %s\n", from, msg)
for name, conn := range r.clients {
if name == from {
continue
}
fmt.Fprint(conn, line)
}
}
func handleClient(conn net.Conn, room *Room) {
defer conn.Close()
fmt.Fprint(conn, "enter your name: ")
scanner := bufio.NewScanner(conn)
if !scanner.Scan() {
return
}
name := strings.TrimSpace(scanner.Text())
if name == "" {
return
}
room.Join(name, conn)
defer room.Leave(name)
for scanner.Scan() {
msg := scanner.Text()
if msg == "" {
continue
}
room.Broadcast(name, msg)
}
}
func main() {
room := NewRoom()
ln, err := net.Listen("tcp", ":9100")
if err != nil {
log.Fatal(err)
}
log.Println("chat server listening on :9100")
for {
conn, err := ln.Accept()
if err != nil {
log.Println("accept:", err)
continue
}
go handleClient(conn, room)
}
}
go run . &
nc localhost 9100 # open in two terminals, pick different names, chat
This uses newline-delimited framing (Chapter 34) rather than
length-prefixing because it's a human-typed, line-oriented protocol —
nc is a perfectly good client. Every shared piece of state (the
clients map) is behind one mutex; every connection's read loop runs in
its own goroutine.