使用 bs-socket 在 ReasonML 中实现实时通信
在本文中,我将使用ReasonML在一个简单的应用程序中演示一些实时通信。如果您是 Reason 的新手,那么一些 JavaScript 基础知识应该足以满足您的需求,并且这里有一个方便的速查表可以帮助您入门。
我正在使用socket.io的bs-socket绑定(一种广泛使用的 Node.js 实时引擎)及其示例作为基础。
完成的应用程序将为每个客户端呈现一组命名按钮和一个用于添加新按钮的对话框,以及当前连接的客户端总数。点击某个按钮会将其从集合中移除,并且该集合将在所有连接的客户端之间保持同步。
要求
这是一个Node项目。如果你想完全按照教程操作,我会使用yarn。所有其他依赖项将由 node 处理。
设置
如果您还没有安装BuckleScript平台,请先安装它:
$ yarn global add bs-platform
现在我们可以使用bsb
构建工具来创建一个基本项目:
$ bsb -init reason-buttons -theme basic-reason
$ cd reason-buttons/
$ yarn start
这将以监视模式启动编译器——你对文件所做的任何更改都会立即触发生成的 JavaScript 的重新编译,就在源代码旁边。确认你在 下看到了Demo.re
和。将 Reason 文件重命名为 ,你会看到它立即重新编译以反映差异——被删除,并且相同的内容现在填充了。Demo.bs.js
reason-buttons/src
ButtonServer.re
Demo.bs.js
ButtonServer.bs.js
向新生成的文件中添加一个脚本package.json
来执行此文件:
// ..
"scripts": {
"build": "bsb -make-world",
"serve": "node src/ButtonServer.bs.js", // <- here
"start:re": "bsb -make-world -w",
"clean": "bsb -clean-world"
},
// ..
我还将其重命名start
为start:re
- 请随意管理您的脚本,但这样最舒服。
我在 Node.js 应用中总是会立即做的一个改动是提取端口号,以便通过环境变量指定。幸运的是,互操作非常简单!我们只需使用 Node 从环境变量中获取端口号即可。创建一个src/Extern.re
包含以下内容的文件:
[@bs.val] external portEnv: option(string) = "process.env.PORT";
[@bs.val] external parseInt: (string, int) => int = "parseInt";
该语法是一个 BuckleScript 编译器指令。这里[@bs.val]
有各种语法的概述,该指南的其余部分深入介绍了每种语法的适用情况。我不会在这篇文章中深入探讨 JS 互操作的具体细节,文档已经很详尽,并且在大多数情况下,生成的代码清晰易读。其基本思想是,关键字有点像,只不过 body 是一个指向外部函数的字符串名称。这样,我们就可以逐步对所需的 JavaScript 进行强类型化,并让 Reason 顺利地进行类型检查。external
let
此代码还将利用可空值的option
数据类型实用程序,例如Reason 附带的标准库getWithDefault
from 。将 的内容替换为以下内容:Belt
src/ButtonServer.js
open Belt.Option;
open Extern;
let port = getWithDefault(portEnv, "3000");
print_endline("Listening at *:" ++ port);
我喜欢使用它3000
作为我的默认设置,当然你也可以使用任何你喜欢的。
编译后的ButtonServer.bs.js
输出非常易读:
// Generated by BUCKLESCRIPT VERSION 4.0.18, PLEASE EDIT WITH CARE
'use strict';
var Belt_Option = require("bs-platform/lib/js/belt_Option.js");
var Caml_option = require("bs-platform/lib/js/caml_option.js");
var port = Belt_Option.getWithDefault((process.env.PORT == null) ? undefined : Caml_option.some(process.env.PORT), "3000");
console.log("Listening at *:" + port);
exports.port = port;
/* port Not a pure module */
让我们验证一下它是否正常工作。打开一个单独的终端并输入yarn serve
。你应该看到以下内容:
$ yarn serve
yarn run v1.13.0
$ node src/ButtonServer.bs.js
Listening at *:3000
Done in 0.09s
$
依赖项
Http
有关如何手动使用 Node 模块的示例,请参阅 Maciej Smolinski 的这篇文章。为了简单起见,我将仅使用社区绑定bs-express
。我们还将引入bs-socket
:
$ yarn add -D bs-express https://github.com/reasonml-community/bs-socket.io.git
然后将其添加到bs-config.json
:
// ..
"bs-dependencies": [
"bs-express",
"bs-socket"
],
// ..
只要相关包裹有 ,Bucklescript 就会处理其余的事情bsconfig.json
。
消息
不过,在实际实现服务器之前,我们需要定义一些消息类型。这将帮助我们规划应用程序的范围。创建一个src/Messages.re
包含以下内容的新文件:
/* Messages */
type labelName = string;
type buttonList = list(labelName);
type numClients = int;
type msg =
| AddButton(labelName)
| RemoveButton(labelName);
type clientToServer =
| Msg(msg)
| Howdy;
type serverToClient =
| Msg(msg)
| ClientDelta(int)
| Success((numClients, buttonList));
这些是我们将来回发送的各种消息。这与socket.io
JavaScript 中最大的区别在于,JavaScript 中的自定义事件以字符串命名。在这里,我们始终只发送通用消息,但使用 ReasonML 模式匹配来解构有效负载本身。该库目前不涵盖字符串类型的事件,尽管有一个问题正在咨询它。该 GitHub 仓库的自述文件简洁地指出:“该 API 与 socket.io 的 API 略有不同,以便在 Reason 中更符合语言习惯。通常,例如 JavaScript 的 API在 Reason 中socket.emit("bla", 10)
会变成这样Server.emit(socket, Bla(10))
。”
请看一下Messages.bs.js
:
// Generated by BUCKLESCRIPT VERSION 4.0.18, PLEASE EDIT WITH CARE
/* This output is empty. Its source's type definitions, externals and/or unused code got optimized away. */
它们最终在我们的 bundle 中根本不会出现——这只是一个编译时的好处。太棒了!
服务器
表达
好了——在我们编写服务器之前还有最后一步。回到,在文件底部src/Extern.re
添加以下内容:Http
module Http = {
type http;
[@bs.module "http"] external create: Express.App.t => http = "Server";
[@bs.send] external listen: (http, int, unit => unit) => unit = "";
};
现在我们准备好了!回到src/ButtonServer.re
并使其看起来像这样:
open Belt.Option;
open Express;
open Extern;
let port = getWithDefault(portEnv, "3000");
let app = express();
let http = Http.create(app);
Http.listen(http, port |> int_of_string, () =>
print_endline("Listening at *:" ++ port)
);
|>
是管道运算符。简而言之,a |> b
与 相同b(a)
。在链接多个函数时,它可以提高可读性。
为了验证它是否有效,请/
在该Http.listen()
线上方添加一个占位符端点。我们稍后再回到客户端。
App.get(app, ~path="/") @@
Middleware.from((_, _) => Response.sendString("<h1>HELLO, REASON</h1>"));
好吧,我撒谎了——那里还有一点语法。根据文档 (@@)
,应用运算符是——“g @@ f @@ x 完全等价于 g (f (x))”。如果你熟悉 Haskell,它就是($)
,或者如果你熟悉……数学,我想,它就是g o f(x)
。
让我们确保一切顺利:
$ yarn serve
$ node src/ButtonServer.bs.js
Listening at *:3000
如果您指向浏览器,您应该会看到HELLO REASON。
索克特里
现在来看看实时部分!在/
端点下方、调用 上方添加以下两行代码Http.listen()
:
module Server = BsSocket.Server.Make(Messages);
let io = Server.createWithHttp(http);
现在socket.io
已配置为使用新定义的 Message 类型。为了跟踪当前的按钮和连接的客户端,我们需要一些状态:
type appState = {
buttons: list(string),
clients: list(BsSocket.Server.socketT),
};
let state = ref({buttons: ["Click me"], clients: []});
状态保存在可变的 中ref
。我们可以通过 访问当前内容state^
,并使用赋值运算符 赋值:=
。服务器启动时,它没有客户端,只有一个默认按钮。
这个辅助函数也很方便,可以向除了传递的客户端之外的每个存储的客户端发送消息:
let sendToRest = (socket, msg) =>
state^.clients
|> List.filter(c => c != socket)
|> List.iter(c => Server.Socket.emit(c, msg));
现在一切都已设置完毕,接下来是定义应用程序的真正内容。从以下大纲开始:
Server.onConnect(
io,
socket => {
// our code here....
},
);
第一部分是如何处理客户端连接。将占位符注释替换为以下内容:
open Server;
print_endline("Client connected");
state := {...state^, clients: List.append(state^.clients, [socket])};
sendToRest(socket, ClientDelta(1));
Socket.emit(
socket,
Success((List.length(state^.clients), state^.buttons)),
);
为了方便起见,我们将Server
模块开放到本地作用域,然后调整状态以包含新客户端。我们使用该函数向所有可能已经存储在 中的客户端sendToRest
发送消息,最后返回该消息,告知新连接的客户端当前状态。ClientDelta
state.clients
Success
下一步是处理断开连接。在最后一个Socket.emit()
调用下面添加:
Socket.onDisconnect(
socket,
_ => {
print_endline("Client disconnected");
sendToRest(socket, ClientDelta(-1));
state :=
{...state^, clients: List.filter(c => c == socket, state^.clients)};
},
);
客户端退出应用状态,其他所有仍在连接的用户都会收到更新。剩下的部分就是处理clientToServer
我们在 中定义的消息Messages.re
:
Socket.on(
socket,
fun
| Msg(msg) => {
switch (msg) {
| AddButton(name) =>
print_endline("Add " ++ name);
state :=
{...state^, buttons: state^.buttons |> List.append([name])};
sendToRest(socket, Msg(AddButton(name)));
| RemoveButton(name) =>
print_endline("Remove " ++ name);
state :=
{
...state^,
buttons: state^.buttons |> List.filter(a => a == name),
};
sendToRest(socket, Msg(RemoveButton(name)));
};
}
| Howdy => {
print_endline("Howdy back, client");
},
);
每当添加或删除按钮时,我们都会相应地调整状态,并让其他人知道这一变化。服务器部分就是这样!
客户端
螺母和螺栓
如果这个演示没有用到 ReasonReact 库,那我可就太失礼了。它太棒了。首先,添加依赖项:
$ yarn add react react-dom
$ yarn add -D reason-react
另请添加reason-react
到bsconfig.json
:
"bs-dependencies": [
"bs-express",
"bs-socket",
"reason-react"
],
既然我们已经在这里了,那就激活 JSX。将以下条目添加到顶层:
"reason": {
"react-jsx": 2
},
为了处理打包,我将使用Parcel。这不是必需的——你可以使用任何你喜欢的。接下来,添加依赖项:
$ yarn add -D parcel-bundler
还添加一个脚本来package.json
运行它:
"scripts": {
//..
"start:bundle": "parcel watch index.html",
//..
},
我们还需要创建它index.html
。将其放在项目根目录下:
<!-- https://github.com/sveltejs/template/issues/12 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Reason Buttons</title>
<script id="s"></script>
<script>
document.getElementById('s').src = "socket.io/socket.io.js"
</script>
</head>
<body>
<div id="app"></div>
<script defer src="./src/Index.re"></script>
</body>
</html>
此存根在 head 部分包含一个在客户端使用 Parcel 和 socket.io 的变通方法Index.re
。另请注意,Parcel 支持 ReasonML——我们可以直接将其作为入口点。将该文件复制到此处后,打开一个新的终端并输入yarn start:bundle
——它可以保持运行,并在需要时重新编译你的 bundle。
现在我们需要告诉服务器提供这个文件,而不是我们的占位符字符串。我们将在此基础上使用一些互操作性——将以下内容添加到Extern.re
,这很有帮助,摘自bs-socket 示例:
module Path = {
type pathT;
[@bs.module "path"] [@bs.splice]
external join : array(string) => string = "";
};
[@bs.val] external __dirname : string = "";
现在将端点替换为ButtonServer.re
:
App.use(
app,
{
let options = Static.defaultOptions();
Static.make(Path.join([|__dirname, "../dist"|]), options)
|> Static.asMiddleware;
},
);
App.get(app, ~path="/") @@
Middleware.from((_, _, res) =>
res |> Response.sendFile("index.html", {"root": __dirname})
);
这将设置我们的静态文件服务和dist/index.html
由 Parcel 生成的服务,而/
不是占位符字符串。
代码
我们已经将 Parcel 指向了src/Index.re
——在那里放一个文件可能是个好主意!创建它并包含以下内容:
ReactDOMRe.renderToElementWithId(<ButtonClient />, "app");
这就是 ReasonReact 挂载到 DOM 的方式。我们终于可以构建组件了。
在实际应用中,理想情况下应该将其拆分成几个组件——一个用于按钮,一个用于输入,或许还有一个单独的组件用于计数器。为了演示,我把所有组件都放在一个组件中,但如果这个应用规模更大,拆分很可能是第一步。
在 处创建一个文件src/ButtonClient.re
。首先,我们将在文件顶部设置套接字客户端:
module Client = BsSocket.Client.Make(Messages);
let socket = Client.create();
在此之下,我们需要定义state
组件的以及action
我们可以采取的措施来转换该状态以创建reducerComponent
:
type state = {
numClients: int,
buttons: list(string),
newButtonTitle: string,
};
type action =
| AddButton(string)
| ClientDelta(int)
| RemoveButton(string)
| Success((int, list(string)))
| UpdateTitle(string);
let component = ReasonReact.reducerComponent("ButtonClient");
这与消息非常相似socket.io
,但增加了一个newButtonTitle
以允许客户端命名他们添加的按钮。
组件的其余部分将存在于这个骨架中:
let make = _children => {
...component,
initialState: _state => {numClients: 1, buttons: [], newButtonTitle: ""},
didMount: self => {
// socket.io message handling
},
reducer: (action, state) =>
switch (action) {
// actions
},
render: self =>
<div>
<h1> {ReasonReact.string("Reason Buttons")} </h1>
<div>
// Buttons
</div>
<div>
// Add A Button
</div>
<span>
// Current Count
</span>
</div>,
};
我们将分别讨论每个部分。initialState
这里给出的值仅用于立即渲染组件 - 一旦我们的客户端连接上,它就会收到一条Success
消息,该消息将覆盖此值。
我们需要翻译收到的socket.io
消息。我把这个功能放在didMount
方法中,以确保客户端已成功加载。将占位符替换为:
Client.on(socket, m =>
switch (m) {
| Msg(msg) =>
switch (msg) {
| AddButton(name) => self.send(AddButton(name))
| RemoveButton(name) => self.send(RemoveButton(name))
}
| ClientDelta(amt) => self.send(ClientDelta(amt))
| Success((numClients, buttons)) =>
self.send(Success((numClients, buttons)))
}
);
Client.emit(socket, Howdy);
该Client.on()
部分是对传入serverToClient
消息进行模式匹配,并将其映射到正确的 ReasonReact 。成功加载后,我们还会向服务器action
发送一条消息。Howdy
下一个工作是我们的 Reducer。我们需要定义每个 Reduceraction
应该如何操作state
:
switch (action) {
| AddButton(name) =>
ReasonReact.Update({
...state,
buttons: List.append(state.buttons, [name]),
})
| ClientDelta(amt) =>
ReasonReact.Update({...state, numClients: state.numClients + amt})
| RemoveButton(name) =>
ReasonReact.Update({
...state,
buttons: List.filter(b => b != name, state.buttons),
})
| Success((numClients, buttons)) =>
ReasonReact.Update({...state, numClients, buttons})
| UpdateTitle(newButtonTitle) =>
ReasonReact.Update({...state, newButtonTitle})
},
扩展运算...
符真是帮了大忙!这段代码还利用了“双关”功能——例如,在 中UpdateTitle(newButtonTitle)
,newButtonTitle
既用作消息有效负载的临时名称,也用作应用程序中字段的名称state
。如果它们的名称相同,我们可以使用简写{...state, newButtonTitle}
代替{...state, newButtonTitle: newButtonTitle}
。
剩下要定义的就是 UI!按钮列表会将每个按钮名称渲染成state
一个按钮,点击后会删除该按钮:
{ReasonReact.array(
self.state.buttons
|> List.map(button =>
<button
key=button
onClick={_ => {
self.send(RemoveButton(button));
Client.emit(socket, Msg(RemoveButton(button)));
}}>
{ReasonReact.string(button)}
</button>
)
|> Array.of_list,
)}
我们既将其发送action
到组件的 reducer,又将clientToServer
消息发送到服务器,以确保它在任何地方都被删除。
接下来是设置所创建的任何新按钮的名称的框:
<input
type_="text"
value={self.state.newButtonTitle}
onChange={evt =>
self.send(UpdateTitle(ReactEvent.Form.target(evt)##value))
}
/>
<button
onClick={_ => {
let name = self.state.newButtonTitle;
self.send(UpdateTitle(""));
self.send(AddButton(name));
Client.emit(socket, Msg(AddButton(name)));
}}>
{ReasonReact.string("Add button " ++ self.state.newButtonTitle)}
</button>
提交后,组件将重置该字段为空字符串。
最后一位是连接客户端的总数:
{ReasonReact.string(
(self.state.numClients |> string_of_int) ++ " connected",
)}
好了!好了,开始吧!假设你已经yarn start:re
运行yarn start:bundle
了 ,打开一个新的终端,最后调用yarn serve
。现在打开几个浏览器窗口,将它们全部指向localhost:3000
,你应该会看到它们在你添加和删除按钮时保持同步。太棒了!
完整的代码可以在这里找到。
封面图片可在此处找到。
文章来源:https://dev.to/decidously/real-time-communication-in-reasonml-with-bs-socket-1p5l