如何使用 Go Fiber 框架创建一个简单的聊天室

本文分享如何使用 Go Fiber 框架创建一个简单的聊天室,以展示 goroutines、channel、WebSockets 的使用以及如何将它们应用于聊天交互。

简介

首先,看看什么是 goroutines、channels(通道)和 WebSockets 的基本解释:

WebSockets

要创建一个聊天应用程序,实时通信是必不可少的,这样才能确保一个客户端发送的信息能被其他客户端即时接收。这就是 WebSockets 发挥作用的地方。

WebSockets 是一种能在客户端和服务器之间进行实时、全双工通信的协议。与传统的 HTTP 请求不同,WebSockets 保持开放的连接通道,客户端和服务器可随时相互发送数据,而无需为每条信息建立新的连接,避免了重复建立新连接的开销。

如何使用 Go Fiber 框架创建一个简单的聊天室
WebSocket 连接 VS 传统 HTTP 请求

Goroutines:

goroutines 是 Go 编程语言的一项功能,允许您并发运行代码。简单地说,goroutine 就像是 Go 运行时管理的一个轻量级线程。当你以 goroutine 方式运行一个函数时,它开始独立于主程序流执行。这意味着你的程序可以同时做多件事,比如处理请求或处理数据。

Go channels

将通道视为连接并发程序的管道,允许它们相互发送和接收数据。通道允许 goroutines(并发执行的函数)之间进行通信和同步。

请参阅下面的示例:

如何使用 Go Fiber 框架创建一个简单的聊天室
两个 goroutine 并发运行并同步一个 int 值

开始编码

项目结构和初始化:

将项目文件夹分为 chat(用于聊天和客户端功能)、handlers(用于 WebSocket 和 HTTP 路由)和 views(用于显示聊天交互的模板)。

.
├── cmd
│   └── main.go
└── internal
    ├── chat
    ├── handlers
    └── views

我们将在本项目中使用 Go Fiber HTML 模板,以避免使用单独的前端,从而简化项目开发。

要使用 Go 模块创建一个新的 Go 项目,请创建文件夹结构并运行此命令:

go mod init "name_of_your_repo"

依赖项:

在这个项目中使用 Go Fiber、websockets 和 uuid(我们稍后会看到),因此需要安装这些依赖项:

go get -u github.com/gofiber/fiber/v2
go get -u github.com/gofiber/contrib/websocket
go get github.com/google/uuid

设置 Fiber APP:

设置新的 Fiber App 非常简单。只需按照以下步骤操作即可:main.go:

package main

import (
 "github.com/gofiber/fiber/v2"
)

func main() {
 app := fiber.New()
 app.Listen(":3000") // replace with the port you need
}

如果想暴露一些端点,可以这样做:

package main

import (
 "github.com/gofiber/fiber/v2"
)

func main() {
 app := fiber.New()
 app.Get("/api/sample", sampleHandler)
 app.Listen(":3000") // replace with the port you need
}

func sampleHandler(c *fiber.Ctx) error {
 return c.JSON("Hello")
}

Manager 和客户端

首先解释客户端与聊天应用程序之间的交互。请看下图:

如何使用 Go Fiber 框架创建一个简单的聊天室
聊天管理员和用户之间的交互
  • 首先定义聊天所需的实体。在internal/chat/client.go中创建消息和客户端结构:
package chat

import (
 "github.com/gofiber/contrib/websocket"
)

type Message struct {
 OriginId        string `json:"origin_id"`
 DestinationId   string `json:"destination_id"`
 OriginName      string `json:"origin_name"`
 DestinationName string `json:"destination_name"`
 Content         string `json:"content"`
 Broadcast       bool   `json:"broadcast"`
}

type Client struct {
 Id                 string
 Name               string
 WebsocketConn      *websocket.Conn // websocket connection used by client to communicate with server
 ReceiveMessageChan chan *Message   // channel through which messages are received
}

我们创建了具有目的地和来源属性的消息,以便了解谁发送了该消息以及谁接收了该消息。当消息发送给所有用户时,Broadcast 属性将为 true。

另一方面,对于 Client 结构体,除了 Id 和 Name 之外,我们还定义了一个 WebSocket 连接和一个用于接收消息的通道。该通道将用于在客户端的 read goroutine 中接收消息,因此该通道的类型为消息指针。

  • 接下来在 internal/chat/chat_manager.go 中定义一个 ChatManager 结构
package chat

type ChatManager struct {
 Clients                   []*Client
 SubscribeClientChan       chan *Client
 UnsubscribeClientChan     chan *Client
 BroadcastNotificationChan chan *Message
 SendMessageChan           chan *Message
}

var Manager = ChatManager{
 Clients:                   make([]*Client, 0),
 SubscribeClientChan:       make(chan *Client),
 UnsubscribeClientChan:     make(chan *Client),
 BroadcastNotificationChan: make(chan *Message),
 SendMessageChan:           make(chan *Message),
}

因为管理员需要知道客户端何时加入或离开聊天室,所以定义了两个通道(SubscribeClientChan 和 UnsubscribeClientChan)来传递订阅或取消订阅的客户端。这样监控程序就能执行相应的操作,例如通知有新用户加入聊天室。

我们还定义了 BroadcastNotificationChan 和 SendMessageChan。第一个是向 Clients 字段(Clients 字段代表所有订阅的客户端)中所有客户端的读取 goroutine 发送消息的通道。第二个通道用于向另一个特定客户端的读取程序发送消息。这两个通道都携带一条消息,因此它们都是消息指针类型的通道。

最后,初始化一个 Manager 变量,它将用于监控所有客户端。

此时,我们需要在 Client 结构中添加一个 Manager 字段,它代表监控此客户端的管理器。

type Client struct {
 Id                 string
 Name               string
 Manager            *ChatManager.   // Used to pass data through Manager channels or to perform Manager actions
 WebsocketConn      *websocket.Conn // websocket connection used by client to communicate with server
 ReceiveMessageChan chan *Message   // channel through which messages are received
}

文件 internal/chat/client.go(我们还添加了一个 “构造函数 “方法来创建一个新的客户端)将会是这样的:

package chat

import (
 "github.com/gofiber/contrib/websocket"
)

type Message struct {
 OriginId        string `json:"origin_id"`
 DestinationId   string `json:"destination_id"`
 OriginName      string `json:"origin_name"`
 DestinationName string `json:"destination_name"`
 Content         string `json:"content"`
 Broadcast       bool   `json:"broadcast"`
}

type Client struct {
 Id                 string
 Name               string
 Manager            *ChatManager    // Used to pass data through Manager channels or to perform Manager actions
 WebsocketConn      *websocket.Conn // websocket connection used by client to communicate with server
 ReceiveMessageChan chan *Message   // channel through which messages are received
}

func NewClient(id string, name string, manager *ChatManager, conn *websocket.Conn) *Client {
 return &Client{
  Id:                 id,
  Name:               name,
  Manager:            manager,
  WebsocketConn:      conn,
  ReceiveMessageChan: make(chan *Message), // TOO IMPORTANT (If there isn't an channel initialized, the message will never be received)
 }
}

聊天逻辑:

为 ChatManager 结构添加了一个 Start 方法,用于管理订阅、取消订阅、发送和广播消息。该方法被设计为 goroutine 执行。因此,使用了 for 循环和 select 语句。

package chat

type ChatManager struct {
 Clients                   []*Client
 SubscribeClientChan       chan *Client
 UnsubscribeClientChan     chan *Client
 BroadcastNotificationChan chan *Message
 SendMessageChan           chan *Message
}

func (manager *ChatManager) Start() {
 for {
  select {
  case channel := <-manager.SubscribeClientChan:
   manager.Clients = append(manager.Clients, channel)

  case channel := <-manager.UnsubscribeClientChan:
   for i, client := range manager.Clients {
    if client.Id == channel.Id {
     manager.Clients = append(manager.Clients[:i], manager.Clients[i+1:]...)
    }
   }

  case channel := <-manager.SendMessageChan: // send message to destination client
   for _, client := range manager.Clients {
    if client.Id == channel.DestinationId {
     client.ReceiveMessageChan <- channel
    }
   }

  case channel := <-manager.BroadcastNotificationChan: // send notification to destination client
   for _, client := range manager.Clients {
    client.ReceiveMessageChan <- channel
   }
  }
 }
}

var Manager = ChatManager{
 Clients:                   make([]*Client, 0),
 SubscribeClientChan:       make(chan *Client),
 UnsubscribeClientChan:     make(chan *Client),
 BroadcastNotificationChan: make(chan *Message),
 SendMessageChan:           make(chan *Message),
}

select 语句的行为与 switch 语句类似,但它是专门为通道设计的。当 goroutine 中的某个特定通道接收到数据时,就会执行该通道类型对应的 case 语句。

我们有以下几种情况:

  • channel := <-manager.SubscribeClientChan.Chan: 在这种情况下,客户端指针会被添加到管理器的客户端数组中。
  • channel := <-manager.UnsubscribeClientChan: 从管理器的客户端数组中删除接收到的客户端指针。
  • channel := <-manager.SendMessageChan.Chan: 从管理器的客户端数组中删除接收到的客户端指针: 通过 DestinationId 在管理器的客户端数组中找到客户端,并通过找到的客户端的 ReceiveMessageChan 通道发送消息。这一点很有必要,因为每个客户端都会执行 “读取消息 “和 “写入消息 “例行程序。当通过 ReceiveMessageChanto 向客户端发送消息时,应将消息传递给 “读取消息 “例程,以便将消息发送给客户端(浏览器客户端)。
  • channel := <-manager.BroadcastNotificationChan: 向管理器客户端数组中的所有客户端发送接收到的消息。该消息将通过 ReceiveMessageChan 传递给每个客户端的 “读取消息 “例程。

现在我们需要根据上图为 Client 结构定义“ReadMessages”和“WriteMessages”方法:

var (
 Wg sync.WaitGroup
)

func (c *Client) WriteMessages() {
 defer func() {
  Wg.Done()
  c.Manager.UnsubscribeClientChan <- c
  _ = c.WebsocketConn.Close()

  var unregisterNotification = &Message{
   OriginId:   "Manager",
   OriginName: "Manager",
   Content:    fmt.Sprintf("***  %s (%s) left this room ***", c.Name, c.Id),
   Broadcast:  true,
  }

  c.Manager.BroadcastNotificationChan <- unregisterNotification
 }()

 for {
  _, msg, err := c.WebsocketConn.ReadMessage()

  if err != nil {
   fmt.Println(err)
   break
  }

  chatMessage := Message{}
  json.Unmarshal(msg, &chatMessage)
  chatMessage.OriginId = c.Id
  c.Manager.SendMessageChan <- &chatMessage
 }
}


func (c *Client) ReadMessages() {
 defer func() {
  Wg.Done()
  _ = c.WebsocketConn.Close()
 }()

 for {
  select {
  case messageReceived := <-c.ReceiveMessageChan:
   data, _ := json.Marshal(messageReceived)
   c.WebsocketConn.WriteMessage(websocket.TextMessage, data)
  }
 }
}

由于这两种方法都旨在作为 goroutine 执行,因此它们各自都有一个 for 循环和一个 defer 函数,该函数将在 goroutine 完成时执行。

用户界面和 WebSocket 端点:

首先,我们将为聊天室所需的用例添加路由和处理程序:

  • internal/handlers/chat_handlers.go:
package handlers

import (
 "fmt"
 "sync"

 "github.com/gofiber/contrib/websocket"
 "github.com/gofiber/fiber/v2"
 "github.com/google/uuid"
 "github.com/pelusa-v/pelusa-chat.git/internal/chat"
)

func RegisterRoomViewHandler(c *fiber.Ctx) error {

 if c.Method() == fiber.MethodPost {
  nickName := c.FormValue("nick")
  return c.Redirect(fmt.Sprintf("/room/%s", nickName))
 }

 return c.Render("internal/views/register.html", nil)
}


func ChatRoomViewHandler(c *fiber.Ctx) error {
 data := fiber.Map{
  "nick": c.Params("nick"),
 }
 return c.Render("internal/views/room.html", data)
}


func RegisterHandler(c *websocket.Conn) {
 chat.Wg.Add(2)

 client := chat.NewClient(uuid.New().String(), c.Params("nick"), &chat.Manager, c)
 client.Manager.SubscribeClientChan <- client

 var registerNotification = &chat.Message{
  OriginId:   "Manager",
  OriginName: "Manager",
  Content:    fmt.Sprintf("***  %s (%s) joined to this room ***", client.Name, client.Id),
  Broadcast:  true,
 }
 client.Manager.BroadcastNotificationChan <- registerNotification

 go client.ReadMessages()
 go client.WriteMessages()

 chat.Wg.Wait()
}

在 Fiber 中,处理程序会收到一个 *fiber.Ctx 参数(上下文),您可以从中获取请求数据。在本例中,该上下文用于获取路由参数(c.Params),并在用户提交表单时获取注册表单中昵称的值(c.FormValue)。

RegisterRoomViewHandler:此处理程序会渲染一个需要昵称才能进入聊天室的注册表单。提交表单后,它会重定向到聊天室视图,同时在路由中传递昵称参数。

ChatRoomViewHandler:此处理程序将渲染聊天室页面,并将昵称作为模板变量传递(客户端使用昵称进入聊天室)。该页面将打开与注册端点(RegisterHandler)的 WebSocket 连接,通过 WebSocket 连接读取和发送信息。

RegisterHandler:首先,我们可以使用与 *fiber.Ctx 类似的 *websocket.Conn。在该处理程序中,我们根据 nick 变量(路由参数)创建一个新客户端,并为其分配一个新 Id(uuid)。然后,该客户端被订阅到管理器。我们会创建一条新消息,通知有新客户加入聊天室,并使用管理器的 BroadcastNotificationChan 发送这条消息。最后,将客户端的 ReadMessages 和 WriteMessages 方法作为程序执行。添加一个 WaitGroup 来等待 goroutines 的执行。如果不添加等待组,就会出错,因为在执行这些 goroutines(使用 go 语句)而未指定等待它们时,Fiber 处理程序会关闭 WebSocket 连接,而这些 goroutines 需要该连接。

现在,我们在主文件中添加路由并执行管理器

  • cmd/main.go:
package main

import (
 "github.com/gofiber/contrib/websocket"
 "github.com/gofiber/fiber/v2"
 "github.com/pelusa-v/pelusa-chat.git/internal/chat"
 "github.com/pelusa-v/pelusa-chat.git/internal/handlers"
)

func main() {
 app := fiber.New()

 go chat.Manager.Start()

 app.Get("/api/ws/register/:nick", websocket.New(handlers.RegisterHandler))

 app.Get("/room/:nick", handlers.ChatRoomViewHandler)
 app.All("/", handlers.RegisterRoomViewHandler)

 app.Listen("127.0.0.1:3000")
}

如您所见,管理器会在主函数的开头以 goroutine 的形式执行 Start 方法,以便开始监控客户端读取和发送消息的情况。

我们还为 register 和 room 视图定义了路由,并使用 nickname 参数公开了 WebSocket 端点。现在,这些路由使用了之前定义的处理程序,我们只需要模板。

我们将在 internal/views/ 文件夹中添加模板,以显示注册和聊天室视图。

  • register template(注册模板): 它本质上是一个表单,向”/”发送包含注册数据(客户昵称)的 POST 请求。

internal/views/register.html:

<div class="container mt-5">
        <h3>Welcome to mini Go chat!</h3>
        <div class="row mt-4">
            <div class="col-6">
                <div>
                    <h5>Enter to chat room:</h5>
                    <form action="/" method="POST">
                        <div>
                            <label for="nick">Nickname:</label>
                            <input class="form-control form-control-sm" placeholder="Enter a nickname" type="text" name="nick" id="nick" required>
                        </div>
                        <br>
                        <div>
                            <button class="btn btn-dark btn-sm mt-1" type="submit">Go to chat</button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
  • room template(房间模板):这是一个使用昵称(模板变量)并与负责处理注册的端点(RegisterHandler)打开 WebSocket 连接的页面。注册完成后,WebSocket 连接将保持激活状态,允许客户端浏览器通过 WebSocket 连接发送或接收信息。我们在 HTML 中直接使用 JavaScript,以简化开发。

internal/views/room.html:

<body>
    <div class="container mt-5">
        <h3>Welcome to mini chat room!</h3>
        <div class="row mt-5">
            <div class="col-6">
                <label for="message_content">Destination</label>
                <input id="destination" class="form-control form-control-sm" type="text" placeholder="Destination Id" aria-label=".form-control-sm example">
                <label for="message_content">Message</label>
                <input id="message_content" class="form-control form-control-sm" type="text" placeholder="Message..." aria-label=".form-control-sm example">
                <button class="btn btn-dark btn-sm mt-2" id="sendTest">Send test message!</button>

                <div class="border mt-4">
                    <div id="messages"></div>
                </div>
            </div>
            <div class="col-6">
            </div>
        </div>
    </div>
</body>

<script>
    console.log("{{ .nick }}")
    registerUrl = "ws://localhost:3000/api/ws/register/{{ .nick }}"
    conn = new WebSocket(registerUrl);

    $("#sendTest").click(
        () => {
            defaultTestMessage = {
                "origin_id": null,
                "destination_id": $("#destination").val(),
                "content": $("#message_content").val(),
                "broadcast": false,
            }
            $("#messages").append("<p style=\"color: blue;\">" + defaultTestMessage.content + " ----------> Sent (" + defaultTestMessage.destination_id + ")\n</p>")
            conn.send(JSON.stringify(defaultTestMessage))
        }
    )

    conn.onmessage = (msg) => {
        var websocketData = JSON.parse(msg.data)
        console.log(websocketData)

        if (websocketData.broadcast) {
            $("#messages").append("<p style=\"color: grey;\">" + websocketData.content + "\n</p>")
        } else {
            $("#messages").append("<p style=\"color: red;\">" + websocketData.content + " <--------- Received (" + websocketData.origin_id + ")\n</p>")
        }
    }
</script>

注册表单视图的屏幕截图:

如何使用 Go Fiber 框架创建一个简单的聊天室

房间视图的屏幕截图:

如何使用 Go Fiber 框架创建一个简单的聊天室

至此,聊天室就可以测试了。可以使用:

go run cmd/main.go

在本项目中,使用了 channels 和 goroutines。用图表勾勒出想法并通过通道可视化 goroutines 之间的交互非常有用。此外,我们还探索了如何使用 WebSocket 连接来使用 Go 实现聊天功能。

本文中使用的项目源代码:https://github.com/pelusa-v/pelusa-chat。

作者:Jean Pierre León

本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/im/47038.html

(0)

相关推荐

发表回复

登录后才能评论