本文分享如何使用 Go Fiber 框架创建一个简单的聊天室,以展示 goroutines、channel、WebSockets 的使用以及如何将它们应用于聊天交互。
简介
首先,看看什么是 goroutines、channels(通道)和 WebSockets 的基本解释:
WebSockets:
要创建一个聊天应用程序,实时通信是必不可少的,这样才能确保一个客户端发送的信息能被其他客户端即时接收。这就是 WebSockets 发挥作用的地方。
WebSockets 是一种能在客户端和服务器之间进行实时、全双工通信的协议。与传统的 HTTP 请求不同,WebSockets 保持开放的连接通道,客户端和服务器可随时相互发送数据,而无需为每条信息建立新的连接,避免了重复建立新连接的开销。
Goroutines:
goroutines 是 Go 编程语言的一项功能,允许您并发运行代码。简单地说,goroutine 就像是 Go 运行时管理的一个轻量级线程。当你以 goroutine 方式运行一个函数时,它开始独立于主程序流执行。这意味着你的程序可以同时做多件事,比如处理请求或处理数据。
Go channels:
将通道视为连接并发程序的管道,允许它们相互发送和接收数据。通道允许 goroutines(并发执行的函数)之间进行通信和同步。
请参阅下面的示例:
开始编码
项目结构和初始化:
将项目文件夹分为 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 和客户端
首先解释客户端与聊天应用程序之间的交互。请看下图:
- 首先定义聊天所需的实体。在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 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