使用 Rocket 實作「即時聊天室」應用程式

前言

本文為「Realtime Chat App in Rust!」教學影片的學習筆記。

建立專案

建立專案。

1
2
cargo new rocket-chat-app
cd rocket-chat-app

修改 Cargo.toml 檔。

1
2
3
4
5
[dependencies]
rocket = { version = "0.5.0-rc.2", features = ["json"] }

[dev-dependencies]
rand = "0.8"

實作後端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#[macro_use]
extern crate rocket;

use rocket::form::Form;
use rocket::fs::{relative, FileServer};
use rocket::response::stream::{Event, EventStream};
use rocket::serde::{Deserialize, Serialize};
use rocket::tokio::select;
use rocket::tokio::sync::broadcast::{channel, error::RecvError, Sender};
use rocket::{Shutdown, State};

#[post("/message", data = "<form>")]
fn post(form: Form<Message>, queue: &State<Sender<Message>>) {
let _res = queue.send(form.into_inner());
}

#[get("/events")]
async fn events(queue: &State<Sender<Message>>, mut end: Shutdown) -> EventStream![] {
let mut rx = queue.subscribe();
EventStream! {
loop {
let msg = select! {
msg = rx.recv() => match msg {
Ok(msg) => msg,
Err(RecvError::Closed) => break,
Err(RecvError::Lagged(_)) => continue,
},
_ = &mut end => break,
};

yield Event::json(&msg);
}
}
}

#[derive(Debug, Clone, FromForm, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
struct Message {
#[field(validate = len(...30))]
pub room: String,
#[field(validate = len(...20))]
pub username: String,
pub message: String,
}

#[launch]
fn rocket() -> _ {
rocket::build()
.manage(channel::<Message>(1024).0)
.mount("/", routes![post, events])
.mount("/", FileServer::from(relative!("static")))
}

實作前端

新增 static/index.html 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Rocket Rooms</title>
<link rel="stylesheet" href="/style.css">
<script src="/script.js" charset="utf-8" defer></script>
</head>
<body>
<main>
<div id="sidebar">
<div id="status" class="pending"></div>
<div id="room-list">
<template id="room">
<button class="room"></button>
</template>
</div>
<form id="new-room">
<input type="text" name="name" id="name" autocomplete="off" placeholder="new room..." maxlength="29"></input>
<button type="submit">+</button>
</form>
</div>
<div id="content">
<div id="messages">
<template id="message">
<div class="message">
<span class="username"></span>
<span class="text"></span>
</div>
</template>
</div>
<form id="new-message">
<input type="text" name="username" id="username" maxlength="19" placeholder="guest" autocomplete="off">
<input type="text" name="message" id="message" autocomplete="off" placeholder="Send a message..." autofocus>
<button type="submit" id="send">Send</button>
</form>
</div>
</main>
</body>
</html>

新增 static/style.css 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
html,body,p,ol,ul,li,dl,dt,dd,blockquote,figure,fieldset,legend,textarea,pre,iframe,hr,h1,h2,h3,h4,h5,h6{margin:0;padding:0}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal}ul{list-style:none}button,input,select{margin:0}html{box-sizing:border-box}*,*::before,*::after{box-sizing:inherit}img,video{height:auto;max-width:100%}iframe,button,input{border:0}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}

:root {
--bg-dark: #242423;
--bg-light: #333533;
--fg-light: #E8EDDF;
--callout: rgb(255, 255, 102);
--callout-dark: #101010;
}

* {
font-size: 14px;
}

html,
body,
main {
background-color: var(--bg-dark);
color: #fff;
font-family: "Inter", Arial, Helvetica, sans-serif, "Noto Color Emoji";
font-weight: 400;
text-shadow: rgb(77, 81, 86) 0px 0px 0px;
height: 100%;
}

main {
display: flex;
}

button:hover:not(.active) {
filter: brightness(1.15);
cursor: pointer;
}

#sidebar {
flex: 3 30%;
display: flex;
flex-direction: column;
overflow: auto;
background-color: var(--bg-light);
}

#room-list {
display: flex;
flex-direction: column;
overflow: auto;
flex: 1;
}

#sidebar button {
height: 40px;
margin-bottom: 1px;
background: var(--bg-light);
color: #fff;
overflow: hidden;
}

#sidebar button.active {
background: var(--bg-dark);
color: var(--callout);
font-weight: bold;
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.9);
z-index: 10;
}

#content {
flex: 7 100%;
overflow: auto;
display: flex;
flex-direction: column;
}

.message {
display: flex;
flex-direction: column;
padding: 10px 0;
}

.message:last-child {
padding-bottom: 20px;
}

.message .username {
font-weight: bold;
padding-bottom: 5px;
color: var(--callout);
}

#messages {
padding: 10px 20px;
flex: 1;
}

form#new-message {
bottom: 0;
position: sticky;
flex: 0 0 auto;
width: 100%;
}

form {
display: flex;
border-top: 2px solid #242424;
}

form * {
height: 40px;
background: var(--fg-light);
color: var(--bg-dark);
}

input {
padding: 0 10px;
}

input:focus {
outline: 0;
filter: brightness(1.05);
}

input#username {
text-align: right;
flex: 1 25%;
width: 25%;
border-right: 1px solid #303030;
}

input#message {
flex: 10 100%;
}

form button {
padding: 0 10px;
}

#sidebar #new-room {
display: flex;
flex: 0 0 auto;
flex-direction: row;
}

#new-room input:focus,
#new-room button:hover {
filter: brightness(1.2);
}

#new-room input {
flex: 8 80%;
width: 20%;
background-color: var(--callout-dark);
color: #fff;
}

#new-room button {
flex: 2 20%;
width: 20%;
background-color: var(--bg-dark);
}

#status {
padding: 5px 10px;
text-align: center;
font-size: 12px;
}

#status.pending::before {
content: "status: connected";
}

#status.pending {
background-color: yellow;
color: #000;
}

#status.connected::before {
content: "status: connected";
}

#status.connected {
background-color: green;
color: #fff;
}

#status.reconnecting::before {
content: "status: reconnecting";
}

#status.reconnecting {
background-color: red;
color: #fff;
}

新增 static/script.js 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
let roomListDiv = document.getElementById('room-list');
let messagesDiv = document.getElementById('messages');
let newMessageForm = document.getElementById('new-message');
let newRoomForm = document.getElementById('new-room');
let statusDiv = document.getElementById('status');

let roomTemplate = document.getElementById('room');
let messageTemplate = document.getElementById('message');

let messageField = newMessageForm.querySelector("#message");
let usernameField = newMessageForm.querySelector("#username");
let roomNameField = newRoomForm.querySelector("#name");

var STATE = {
room: "lobby",
rooms: {},
connected: false,
}

// Generate a color from a "hash" of a string. Thanks, internet.
function hashColor(str) {
let hash = 0;
for (var i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}

return `hsl(${hash % 360}, 100%, 70%)`;
}

// Add a new room `name` and change to it. Returns `true` if the room didn't
// already exist and false otherwise.
function addRoom(name) {
if (STATE[name]) {
changeRoom(name);
return false;
}

var node = roomTemplate.content.cloneNode(true);
var room = node.querySelector(".room");
room.addEventListener("click", () => changeRoom(name));
room.textContent = name;
room.dataset.name = name;
roomListDiv.appendChild(node);

STATE[name] = [];
changeRoom(name);
return true;
}

// Change the current room to `name`, restoring its messages.
function changeRoom(name) {
if (STATE.room == name) return;

var newRoom = roomListDiv.querySelector(`.room[data-name='${name}']`);
var oldRoom = roomListDiv.querySelector(`.room[data-name='${STATE.room}']`);
if (!newRoom || !oldRoom) return;

STATE.room = name;
oldRoom.classList.remove("active");
newRoom.classList.add("active");

messagesDiv.querySelectorAll(".message").forEach((msg) => {
messagesDiv.removeChild(msg)
});

STATE[name].forEach((data) => addMessage(name, data.username, data.message))
}

// Add `message` from `username` to `room`. If `push`, then actually store the
// message. If the current room is `room`, render the message.
function addMessage(room, username, message, push = false) {
if (push) {
STATE[room].push({ username, message })
}

if (STATE.room == room) {
var node = messageTemplate.content.cloneNode(true);
node.querySelector(".message .username").textContent = username;
node.querySelector(".message .username").style.color = hashColor(username);
node.querySelector(".message .text").textContent = message;
messagesDiv.appendChild(node);
}
}

// Subscribe to the event source at `uri` with exponential backoff reconnect.
function subscribe(uri) {
var retryTime = 1;

function connect(uri) {
const events = new EventSource(uri);

events.addEventListener("message", (ev) => {
console.log("raw data", JSON.stringify(ev.data));
console.log("decoded data", JSON.stringify(JSON.parse(ev.data)));
const msg = JSON.parse(ev.data);
if (!"message" in msg || !"room" in msg || !"username" in msg) return;
addMessage(msg.room, msg.username, msg.message, true);
});

events.addEventListener("open", () => {
setConnectedStatus(true);
console.log(`connected to event stream at ${uri}`);
retryTime = 1;
});

events.addEventListener("error", () => {
setConnectedStatus(false);
events.close();

let timeout = retryTime;
retryTime = Math.min(64, retryTime * 2);
console.log(`connection lost. attempting to reconnect in ${timeout}s`);
setTimeout(() => connect(uri), (() => timeout * 1000)());
});
}

connect(uri);
}

// Set the connection status: `true` for connected, `false` for disconnected.
function setConnectedStatus(status) {
STATE.connected = status;
statusDiv.className = (status) ? "connected" : "reconnecting";
}

// Let's go! Initialize the world.
function init() {
// Initialize some rooms.
addRoom("lobby");
addRoom("rocket");
changeRoom("lobby");
addMessage("lobby", "Rocket", "Hey! Open another browser tab, send a message.", true);
addMessage("rocket", "Rocket", "This is another room. Neat, huh?", true);

// Set up the form handler.
newMessageForm.addEventListener("submit", (e) => {
e.preventDefault();

const room = STATE.room;
const message = messageField.value;
const username = usernameField.value || "guest";
if (!message || !username) return;

if (STATE.connected) {
fetch("/message", {
method: "POST",
body: new URLSearchParams({ room, username, message }),
}).then((response) => {
if (response.ok) messageField.value = "";
});
}
})

// Set up the new room handler.
newRoomForm.addEventListener("submit", (e) => {
e.preventDefault();

const room = roomNameField.value;
if (!room) return;

roomNameField.value = "";
if (!addRoom(room)) return;

addMessage(room, "Rocket", `Look, your own "${room}" room! Nice.`, true);
})

// Subscribe to server-sent events.
subscribe("/events");
}

init();

啟動服務

啟動服務。

1
cargo run

前往 http://localhost:8000 瀏覽。

程式碼

參考資料