【译】使用Go和Angular通过WebSocket构建实时聊天应用

写在前面

本文原文,详细讲解了如何使用Go和Angular通过WebSocket构建实时聊天应用。

正文

我最近听到很多关于WebSocket的东西,以及WebSocket如何在应用程序和服务器之间实现实时通信。WebSocket作为RESTful API的替代和补充,已经存在了很长时间。使用WebSocket可以做例如实时聊天,与IoT通信,游戏,和其他很多需要在客户端和服务器之间进行即时消息传递的东西。

最近一段时间,我使用了一个叫Socket.io的库,用来在Node.js中使用websockets,但是当我真正使用Go以后,我打算研究一下如何在Go中使用WebSocket。

通过本文,我们将学习如何创建一个聊天应用,其中客户端是一个 Angular 2 应用,服务端使用Go。

要求

在这个应用中有很多操作,所以有一些必要的前提条件,如下所示:

处理所有消息和客户端的聊天服务器使用Go编写。客户端前端使用 Angular 2编写,has a dependency of the Node Package Manager (NPM) which ships with Node.js.

创建Go聊天服务器

我们打算先开发整个应用的服务器端部分,它需要依赖几个第三方的包。

在命令行执行以下命令,下载第三方包:

//Install Go Dependencies
go get github.com/gorilla/websocket
go get github.com/satori/go.uuid

websocket包的作者同时也是 Mux 这个路由包 的作者,我们还需要一个UUID包来分配每一个客户端的唯一ID。

在 $GOPATH 目录创建一个新的项目,我自己的项目目录是 $GOPATH/src/github.com/nraboy/realtime-chat/main.go。

在进行下一步之前,需要注意的是,我从 Dinosaurs CodeGorilla websocket chat example 获取了一部分Go 代码,为了避免剽窃的嫌疑,我使用了很多原始代码中的一部分,但我也为这个项目加入了很多自己的独特的东西。

这次我们要做的聊天应用有3个结构体:

// $GOPATH/src/github.com/nraboy/realtime-chat/main.go
type ClientManager struct {
    clients    map[*Client]bool
    broadcast  chan []byte
    register   chan *Client
    unregister chan *Client
}
 
type Client struct {
    id     string
    socket *websocket.Conn
    send   chan []byte
}
 
type Message struct {
    Sender    string `json:"sender,omitempty"`
    Recipient string `json:"recipient,omitempty"`
    Content   string `json:"content,omitempty"`
}

ClientManager用于管理所有已连接的客户端,尝试连接的客户端,已经断开连接等待删除的客户端,和所有已连接客户端收发的消息。

每个客户端有一个唯一的ID,一个socket连接,和等待发送的消息。

为了增加传递的数据的复杂性,消息将使用 JSON 格式。而不是传递一串不容易被理解,阅读的数据。使用JSON格式,我们可以使用元数据和其他有用的东西。每一条消息将包含发送消息的客户端,接收消息的客户端,和消息的实际内容。

首先定义一个全局的ClientManager。

//$GOPATH/src/github.com/nraboy/realtime-chat/main.go
var manager = ClientManager{
    broadcast:  make(chan []byte),
    register:   make(chan *Client),
    unregister: make(chan *Client),
    clients:    make(map[*Client]bool),
}

服务器端将使用3个goroutine,一个用于管理客户端,一个用于读取websocket数据,另一个用于往websocket里写数据。这里指的是读取和写入的goroutine将为每个连接的客户端创建一个新的实例。所有的goroutine将循环运行直至不再需要。

编写如下代码,来开始服务:

//$GOPATH/src/github.com/nraboy/realtime-chat/main.go
func (manager *ClientManager) start() {
    for {
        select {
        case conn := <-manager.register:
        	manager.clients[conn] = true
        	jsonMessage, _ := json.Marshal(&Message{Content: "/A new socket has connected."})
            manager.send(jsonMessage, conn)
        case conn := <-manager.unregister:
            if _, ok := manager.clients[conn]; ok {
                close(conn.send)
                delete(manager.clients, conn)
                jsonMessage, _ := json.Marshal(&Message{Content: "/A socket has disconnected."})
                manager.send(jsonMessage, conn)
            }
        case message := <-manager.broadcast:
            for conn := range manager.clients {
                select {
                case conn.send <- message:
                default:
                    close(conn.send)
                    delete(manager.clients, conn)
                }
            }
        }
    }
}

每当 manager.register 接收到数据,这个正在建立连接的客户端将会被添加到 manager (前文创建的 ClientManager 实例)的 clients 中。然后,将向所有其他客户端发送一条JSON消息。

同时,如果客户端断开连接,manager.unregister channel将会收到消息,断开连接的客户端的 channel 中的数据将被关闭,客户端也会从manager中删除。然后发送消息给其他的客户端告知某个客户端已断开连接。

如果 manager.broadcast channel 中存在数据,则表示正在尝试发送和接收消息。我们打算遍历每个已连接的客户端,将消息发送给它们。如果由于某些原因,channel 被阻塞或消息无法发送,我们会认为这个客户端已断开连接,然后将其删除。

为了使代码简洁,创建一个 manager.send 方法遍历每个客户端。

//$GOPATH/src/github.com/nraboy/realtime-chat/main.go
func (manager *ClientManager) send(message []byte, ignore *Client) {
    for conn := range manager.clients {
        if conn != ignore {
            conn.send <- message
        }
    }
}

至于conn.send如何发送数据,会在后面探讨。

现在我们可以探索 goroutine 如何读取客户端发送的 websocket 数据。这个 goroutine 的关键是读取 socket 数据,并将数据添加到 manager.boradcast 做进一步处理。

//$GOPATH/src/github.com/nraboy/realtime-chat/main.go
func (c *Client) read() {
    defer func() {
        manager.unregister <- c
        c.socket.Close()
    }()
 
    for {
        _, message, err := c.socket.ReadMessage()
        if err != nil {
            manager.unregister <- c
            c.socket.Close()
            break
        }
        jsonMessage, _ := json.Marshal(&Message{Sender: c.id, Content: string(message)})
        manager.broadcast <- jsonMessage
    }
}

如果读取 websocket 数据出错,可能意味着客户端已经断开连接。如果是这样,我们需要从服务器中注销这个客户端。

还记得前边的 conn.send 吗,它用来在第三个 goroutine 中写数据。

//$GOPATH/src/github.com/nraboy/realtime-chat/main.go
func (c *Client) write() {
    defer func() {
        c.socket.Close()
    }()
 
    for {
        select {
        case message, ok := <-c.send:
            if !ok {
                c.socket.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }
 
            c.socket.WriteMessage(websocket.TextMessage, message)
        }
    }
}

如果 c.send channel有数据,我们将尝试发送这些数据。如果由于某些原因,channel 运行不正常,我们将向客户端发送断开连接的消息。

那么,如何启动这些 goroutine 呢,当我们启动服务器时,服务器 goroutine 将会启动,当有客户端连接时,其他 goroutine 将会启动。

main方法中的代码:

//$GOPATH/src/github.com/nraboy/realtime-chat/main.goGo
func main() {
    fmt.Println("Starting application...")
    go manager.start()
    http.HandleFunc("/ws", wsPage)
    http.ListenAndServe(":12345", nil)
}

我们在12345端口启动服务器,通过 websocket 连接访问。名为 wsPage 的方法如下所示:

//$GOPATH/src/github.com/nraboy/realtime-chat/main.goGo
func wsPage(res http.ResponseWriter, req *http.Request) {
    conn, error := (&websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}).Upgrade(res, req, nil)
    if error != nil {
        http.NotFound(res, req)
        return
    }
    client := &Client{id: uuid.NewV4().String(), socket: conn, send: make(chan []byte)}
 
    manager.register <- client
 
    go client.read()
    go client.write()
}

通过使用 websocket 包将HTTP请求升级到websocket请求。通过添加 CheckOrigin ,我们可以接受来自外部域的请求,从而消除跨域资源共享(CORS)的错误。

创建连接后,将创建一个客户端,并分配唯一的ID。如前所述,该客户端已经注册到服务器。客户端注册后,读写 goroutine 将被触发。

此时,我们可以通过如下命令启动应用。

//Run Go Application
go run *.go

你不能在直接 web 浏览器中测试,但是可以建立一个 websocket 连接到 ws://localhost:12345/ws。

创建Angular2 聊天客户端

现在我们需要创建一个客户端的应用,客户端可以发送和接收消息。假设您已经安装了Angular 2 CLI,请执行以下操作:

//Create New Angular 2 Project
ng new SocketExample

执行完将会生成一个单页应用,而我们想要完成的内容,是下方的动图演示的这样。

补充:此处需cd SocketExmapl && npm install。

JavaScript的 websocket 在Angular 2提供的一个类中。使用 Angular 2 CLI,通过执行如下操作创建provider。

//Create Angular 2 Provider
ng g service socket

上述命令会在您的项目中创建 **src/app/socket.service.ts ** 和 src/app/socket.service.spec.ts 。spec文件用于单元测试,不在本文讨论范围内。打开 src/app/socket.service.ts 文件,编写以下 TypeScript 代码:

//src/app/socket.service.ts
import { Injectable, EventEmitter } from '@angular/core';
 
@Injectable()
export class SocketService {
 
    private socket: WebSocket;
    private listener: EventEmitter<any> = new EventEmitter();
 
    public constructor() {
        this.socket = new WebSocket("ws://localhost:12345/ws");
        this.socket.onopen = event => {
            this.listener.emit({"type": "open", "data": event});
        }
        this.socket.onclose = event => {
            this.listener.emit({"type": "close", "data": event});
        }
        this.socket.onmessage = event => {
            this.listener.emit({"type": "message", "data": JSON.parse(event.data)});
        }
    }
 
    public send(data: string) {
        this.socket.send(data);
    }
 
    public close() {
        this.socket.close();
    }
 
    public getEventListener() {
        return this.listener;
    }
 
}

该提供者是可以注射的,并在触发某些事件事发送数据。在构造方法中,建立了与Go应用的WebSocket 连接,并创建了3个事件监听器。分别对应每个socket创建和销毁时,及接收到消息时。

send方法允许我们向Go应用发送消息,close方法用于通知Go应用我们将断开连接。

提供者程序已创建,但是还不能在我们的的应用程序的任何文件中使用。因此,我们需要将其添加到 src/app/app.module.ts 文件的 @NgModule 块中。打开文件并输入:

//src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
 
import { AppComponent } from './app.component';
import { SocketService } from "./socket.service";
 
@NgModule({
    declarations: [
        AppComponent
    ],
    imports: [
        BrowserModule,
        FormsModule,
        HttpModule
    ],
    providers: [SocketService],
    bootstrap: [AppComponent]
})
export class AppModule { }

需要注意的是,此时我们已经将provider导入并且添加到 @NgModule 块的 providers数组中了。

现在我们可以专注处理页面的逻辑了。打开 src/app/app.component.ts 文件,并输入以下代码:

//src/app/app.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { SocketService } from "./socket.service";
 
@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnDestroy {
 
    public messages: Array<any>;
    public chatBox: string;
 
    public constructor(private socket: SocketService) {
        this.messages = [];
        this.chatBox = "";
    }
 
    public ngOnInit() {
        this.socket.getEventListener().subscribe(event => {
            if(event.type == "message") {
                let data = event.data.content;
                if(event.data.sender) {
                    data = event.data.sender + ": " + data;
                }
                this.messages.push(data);
            }
            if(event.type == "close") {
                this.messages.push("/The socket connection has been closed");
            }
            if(event.type == "open") {
                this.messages.push("/The socket connection has been established");
            }
        });
    }
 
    public ngOnDestroy() {
        this.socket.close();
    }
 
    public send() {
        if(this.chatBox) {
            this.socket.send(this.chatBox);
            this.chatBox = "";
        }
    }
 
    public isSystemMessage(message: string) {
        return message.startsWith("/") ? "<strong>" + message.substring(1) + "</strong>" : message;
    }
 
}

在上述 AppComponent类的构造方法中,我们注册服务提供者并初始化需要绑定到UI的变量。在构造函数中加载或订阅不太好,我们使用ngOninit方法来代替。

//src/app/app.component.ts
public ngOnInit() {
    this.socket.getEventListener().subscribe(event => {
        if(event.type == "message") {
            let data = event.data.content;
            if(event.data.sender) {
                data = event.data.sender + ": " + data;
            }
            this.messages.push(data);
        }
        if(event.type == "close") {
            this.messages.push("/The socket connection has been closed");
        }
        if(event.type == "open") {
            this.messages.push("/The socket connection has been established");
        }
    });
}

在上述方法中,我们订阅了在provider中创建的事件监听器。在这里我们需要检查发生了什么事件。如果是一条消息,需要检查是否存在发件人,然后将其添加到消息中。

你可能注意到了,一些消息是以斜线开始的。用来表示系统消息,稍后会将其加粗。

当客户端断开时,关闭事件将会发送到服务器,如果消息已经发送,它也会被发送到服务器。

在查看HTML之前,先添加一些CSS,使其看起来更像一个聊天应用。打开 src/style.css,输入以下内容:

/*src/styles.css*/
/* You can add global styles to this file, and also import other style files */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font: 13px Helvetica, Arial; }
form { background: #000; padding: 3px; position: fixed; bottom: 0; width: 100%; }
form input { border: 0; padding: 10px; width: 90%; margin-right: .5%; }
form button { width: 9%; background: rgb(130, 224, 255); border: none; padding: 10px; }
#messages { list-style-type: none; margin: 0; padding: 0; }
#messages li { padding: 5px 10px; }
#messages li:nth-child(odd) { background: #eee; }

现在,需要处理下HTML了。打开 src/app/app.component.html 文件,并输入以下内容:

<!--src/app/app.component.html-->
<ul id="messages">
    <li *ngFor="let message of messages">
        <span [innerHTML]="isSystemMessage(message)"></span>
    </li>
</ul>
<form action="">
    <input [(ngModel)]="chatBox" [ngModelOptions]="{standalone: true}" autocomplete="off" />
    <button (click)="send()">Send</button>
</form>

这里我们只是简单的将消息数组遍历到屏幕上 。以斜线开头的消息将会被加粗。提交按钮绑定到了send方法中,当按下时,会提交输入框中的内容到Go应用。

结语

刚刚演示了如何使用 Go 和 Angular 2 创建一个 WebSocket 实时聊天应用。虽然没有在这个示例中存储聊天记录,但是这套逻辑可以应用于更复杂的项目,比如游戏,IOT,和其他很多场景。

关于原作者

Nic Raboy是现代网络和移动开发技术的倡导者。 他在Java,JavaScript,Golang以及各种框架(如Angular,NativeScript和Apache Cordova)方面拥有丰富的经验。 Nic写作的内容主要是他在使Web和移动开发更容易理解相关方面的经验。