Building a Lightweight Go IM from Scratch
This article walks through creating a distributed instant‑messaging system in Go, covering the full tech stack, Docker‑based and local startup methods, configuration files, the end‑to‑end request flow, and detailed code examples for RPC, Redis queues, WebSocket delivery and the internal bucket architecture.
Overview
This project demonstrates how to build a lightweight instant‑messaging (IM) system similar to a live classroom chat, using Go as the programming language.
Technology Stack
golang – development language
http – web site
gin – HTTP API framework
rpcx – RPC framework
websocket – long‑connection message push
tcp – long‑connection message push and read
gorm – SQLite database access
redis – message queue and cache
viper – configuration management
etcd – service discovery (used with rpcx)
logrus – logging
docker – Docker‑compose environment and image packaging
Project Startup
Two startup methods are provided.
Local startup
# Clone the repository
git clone https://github.com/gofish2020/gochat.git
cd ./gochat
# Start Docker environment (VPN recommended)
docker-compose up -d
# Run each module
go run main.go -module site
go run main.go -module api
go run main.go -module logic
go run main.go -module connect_websocket
go run main.go -module connect_tcp
go run main.go -module taskDocker startup
# Clone the repository
git clone https://github.com/gofish2020/gochat.git
cd ./gochat
# Build the image (VPN recommended)
make build TAG=1.18
# Run the container (compiles gochat.bin, may take time)
./run.sh dev 127.0.0.1After starting, open http://127.0.0.1:8080/login in a browser, log in with any demo account (e.g., demo 111111), and test the chat functionality.
Configuration Files
Development configuration resides in config/dev; production configuration is in config/prod. The common.toml file can be edited to change Redis/etcd ports (default 6379/2379).
System Architecture
The overall flow is:
Browser accesses http://127.0.0.1:8080/login → site service renders the page.
Login request is sent to the api endpoint http://127.0.0.1/user/login, which calls the logic service (discovered via etcd) to verify credentials.
After successful login, the browser connects to the connect service via WebSocket.
When a user sends a message, the api service calls /push/pushRoom, which invokes the logic service via RPC. The logic service serialises the message to JSON and pushes it onto a Redis list gochat_queue.
The task service continuously reads gochat_queue using BRPop, then calls the connect service (via RPC) to broadcast the message to all clients in the room.
Key Code Walk‑through
API router registration
func initPushRouter(r *gin.Engine) {
pushGroup := r.Group("/push")
pushGroup.Use(CheckSessionId())
{
pushGroup.POST("/push", handler.Push)
pushGroup.POST("/pushRoom", handler.PushRoom) // send message to room
pushGroup.POST("/count", handler.Count)
pushGroup.POST("/getRoomInfo", handler.GetRoomInfo)
}
}PushRoom handler
func PushRoom(c *gin.Context) {
var formRoom FormRoom
if err := c.ShouldBindBodyWith(&formRoom, binding.JSON); err != nil {
tools.FailWithMsg(c, err.Error())
return
}
authToken := formRoom.AuthToken
msg := formRoom.Msg
roomId := formRoom.RoomId
checkAuthReq := &proto.CheckAuthRequest{AuthToken: authToken}
authCode, fromUserId, fromUserName := rpc.RpcLogicObj.CheckAuth(checkAuthReq)
if authCode == tools.CodeFail {
tools.FailWithMsg(c, "rpc fail get self info")
return
}
req := &proto.Send{Msg: msg, FromUserId: fromUserId, FromUserName: fromUserName, RoomId: roomId, Op: config.OpRoomSend}
code, msg := rpc.RpcLogicObj.PushRoom(req)
if code == tools.CodeFail {
tools.FailWithMsg(c, "rpc push room msg fail!")
return
}
tools.SuccessWithMsg(c, "ok", msg)
}Logic RPC client initialization
func InitLogicRpcClient() {
// creates LogicRpcClient that connects to the logic service via rpc
}Logic service: saving a message to Redis
func (rpc *RpcLogic) PushRoom(req *proto.Send) (code int, msg string) {
reply := &proto.SuccessReply{}
LogicRpcClient.Call(context.Background(), "PushRoom", req, reply)
code = reply.Code
msg = reply.Msg
return
}
func (logic *Logic) PushRoom(ctx context.Context, args *proto.Send, reply *proto.SuccessReply) (err error) {
redisMsg := &proto.RedisMsg{Op: config.OpRoomSend, RoomId: args.RoomId, Count: count, Msg: bodyBytes, RoomUserInfo: roomUserInfo}
redisMsgByte, err := json.Marshal(redisMsg)
if err != nil { logrus.Errorf("logic,RedisPublishRoomInfo Marshal err:%s", err); return }
err = RedisClient.LPush(config.QueueName, redisMsgByte).Err()
if err != nil { logrus.Errorf("logic,RedisPublishRoomInfo redisMsg error : %s", err); return }
reply.Code = config.SuccessReplyCode
return
}Task service: consuming the Redis queue
func (task *Task) InitQueueRedisClient() (err error) {
redisOpt := tools.RedisOption{Address: config.Conf.Common.CommonRedis.RedisAddress, Password: config.Conf.Common.CommonRedis.RedisPassword, Db: config.Conf.Common.CommonRedis.Db}
RedisClient = tools.GetRedisInstance(redisOpt)
go func() {
for {
result, err := RedisClient.BRPop(time.Second*10, config.QueueName).Result()
if err != nil { logrus.Infof("task queue block timeout,no msg err:%s", err) }
if len(result) >= 2 { task.Push(result[1]) }
}
}()
return
}
func (task *Task) Push(msg string) {
m := &proto.RedisMsg{}
json.Unmarshal([]byte(msg), m)
switch m.Op {
case config.OpRoomSend:
task.broadcastRoomToConnect(m.RoomId, m.Msg)
// other cases omitted for brevity
}
}Connect service: broadcasting to WebSocket clients
func (rpc *RpcConnectPush) PushRoomMsg(ctx context.Context, req *proto.PushRoomMsgRequest, reply *proto.SuccessReply) (err error) {
for _, bucket := range DefaultServer.Buckets {
bucket.BroadcastRoom(req)
}
return
}
func (b *Bucket) BroadcastRoom(req *proto.PushRoomMsgRequest) {
num := atomic.AddUint64(&b.routinesNum, 1) % b.bucketOptions.RoutineAmount
b.routines[num] <- req
}
func (b *Bucket) PushRoom(ch chan *proto.PushRoomMsgRequest) {
for {
arg := <-ch
if room := b.Room(arg.RoomId); room != nil {
room.Push(&arg.Msg)
}
}
}
func (r *Room) Push(msg *proto.Msg) {
r.rLock.RLock()
for ch := r.next; ch != nil; ch = ch.Next {
ch.Push(msg) // non‑blocking send to channel's broadcast queue
}
r.rLock.RUnlock()
}
func (s *Server) writePump(ch *Channel, c *Connect) {
for {
select {
case message, ok := <-ch.broadcast:
if !ok { ch.conn.WriteMessage(websocket.CloseMessage, []byte{}); return }
w, err := ch.conn.NextWriter(websocket.TextMessage)
if err != nil { return }
w.Write(message.Body)
w.Close()
case <-ticker.C:
ch.conn.SetWriteDeadline(time.Now().Add(s.Options.WriteWait))
if err := ch.conn.WriteMessage(websocket.PingMessage, nil); err != nil { return }
}
}
}Reference Project
Source code is available at https://github.com/LockGit/gochat
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Nullbody Notes
Go backend development, learning open-source project source code together, focusing on simplicity and practicality.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
