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.

Nullbody Notes
Nullbody Notes
Nullbody Notes
Building a Lightweight Go IM from Scratch

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 task

Docker 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.1

After 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/loginsite 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

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

DockergolangRPCRedisimwebsocketetcdgin
Nullbody Notes
Written by

Nullbody Notes

Go backend development, learning open-source project source code together, focusing on simplicity and practicality.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.