使用 bs-socket 在 ReasonML 中实现实时通信

2025-06-04

使用 bs-socket 在 ReasonML 中实现实时通信

在本文中,我将使用ReasonML在一个简单的应用程序中演示一些实时通信。如果您是 Reason 的新手,那么一些 JavaScript 基础知识应该足以满足您的需求,并且这里有一个方便的速查表可以帮助您入门。

我正在使用socket.io的bs-socket绑定(一种广泛使用的 Node.js 实时引擎)及其示例作为基础。

完成的应用程序将为每个客户端呈现一组命名按钮和一个用于添加新按钮的对话框,以及当前连接的客户端总数。点击某个按钮会将其从集合中移除,并且该集合将在所有连接的客户端之间保持同步。

要求

这是一个Node项目。如果你想完全按照教程操作,我会使用yarn。所有其他依赖项将由 node 处理。

设置

如果您还没有安装BuckleScript平台,请先安装它:

$ yarn global add bs-platform
Enter fullscreen mode Exit fullscreen mode

现在我们可以使用bsb构建工具来创建一个基本项目:

$ bsb -init reason-buttons -theme basic-reason
$ cd reason-buttons/
$ yarn start
Enter fullscreen mode Exit fullscreen mode

这将以监视模式启动编译器——你对文件所做的任何更改都会立即触发生成的 JavaScript 的重新编译,就在源代码旁边。确认你在 下看到了Demo.re。将 Reason 文件重命名为 ,你会看到它立即重新编译以反映差异——被删除,并且相同的内容现在填充了Demo.bs.jsreason-buttons/srcButtonServer.reDemo.bs.jsButtonServer.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"
},
// ..
Enter fullscreen mode Exit fullscreen mode

我还将其重命名startstart:re- 请随意管理您的脚本,但这样最舒服。

我在 Node.js 应用中总是会立即做的一个改动是提取端口号,以便通过环境变量指定。幸运的是,互操作非常简单!我们只需使用 Node 从环境变量中获取端口号即可。创建一个src/Extern.re包含以下内容的文件:

[@bs.val] external portEnv: option(string) = "process.env.PORT";
[@bs.val] external parseInt: (string, int) => int = "parseInt";
Enter fullscreen mode Exit fullscreen mode

该语法是一个 BuckleScript 编译器指令。这里[@bs.val]有各种语法的概述,该指南的其余部分深入介绍了每种语法的适用情况。我不会在这篇文章中深入探讨 JS 互操作的具体细节,文档已经很详尽,并且在大多数情况下,生成的代码清晰易读。其基本思想是,关键字有点像,只不过 body 是一个指向外部函数的字符串名称。这样,我们就可以逐步对所需的 JavaScript 进行强类型化,并让 Reason 顺利地进行类型检查。externallet

此代码还将利用可空值的option 数据类型实用程序,例如Reason 附带的标准库getWithDefaultfrom 。将 的内容替换为以下内容:Beltsrc/ButtonServer.js

open Belt.Option;
open Extern;

let port = getWithDefault(portEnv, "3000");

print_endline("Listening at *:" ++ port);
Enter fullscreen mode Exit fullscreen mode

我喜欢使用它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 */
Enter fullscreen mode Exit fullscreen mode

让我们验证一下它是否正常工作。打开一个单独的终端并输入yarn serve。你应该看到以下内容:

$ yarn serve
yarn run v1.13.0
$ node src/ButtonServer.bs.js
Listening at *:3000
Done in 0.09s
$
Enter fullscreen mode Exit fullscreen mode

依赖项

Http有关如何手动使用 Node 模块的示例,请参阅 Maciej Smolinski 的这篇文章。为了简单起见,我将仅使用社区绑定bs-express。我们还将引入bs-socket

$ yarn add -D bs-express https://github.com/reasonml-community/bs-socket.io.git
Enter fullscreen mode Exit fullscreen mode

然后将其添加到bs-config.json

// ..
"bs-dependencies": [
  "bs-express",
  "bs-socket"
],
// ..
Enter fullscreen mode Exit fullscreen mode

只要相关包裹有 ,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));
Enter fullscreen mode Exit fullscreen mode

这些是我们将来回发送的各种消息。这与socket.ioJavaScript 中最大的区别在于,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. */
Enter fullscreen mode Exit fullscreen mode

它们最终在我们的 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 = "";
};
Enter fullscreen mode Exit fullscreen mode

现在我们准备好了!回到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)
);
Enter fullscreen mode Exit fullscreen mode

|>是管道运算符。简而言之,a |> b与 相同b(a)。在链接多个函数时,它可以提高可读性。

为了验证它是否有效,请/在该Http.listen()线上方添加一个占位符端点。我们稍后再回到客户端。

App.get(app, ~path="/") @@
Middleware.from((_, _) => Response.sendString("<h1>HELLO, REASON</h1>"));
Enter fullscreen mode Exit fullscreen mode

好吧,我撒谎了——那里还有一点语法。根据文档 (@@),应用运算符是——“g @@ f @@ x 完全等价于 g (f (x))”。如果你熟悉 Haskell,它就是($),或者如果你熟悉……数学,我想,它就是g o f(x)

让我们确保一切顺利:

$ yarn serve
$ node src/ButtonServer.bs.js
Listening at *:3000
Enter fullscreen mode Exit fullscreen mode

如果您指向浏览器,您应该会看到HELLO REASON

索克特里

现在来看看实时部分!在/端点下方、调用 上方添加以下两行代码Http.listen()

module Server = BsSocket.Server.Make(Messages);

let io = Server.createWithHttp(http);
Enter fullscreen mode Exit fullscreen mode

现在socket.io已配置为使用新定义的 Message 类型。为了跟踪当前的按钮和连接的客户端,我们需要一些状态:

type appState = {
  buttons: list(string),
  clients: list(BsSocket.Server.socketT),
};

let state = ref({buttons: ["Click me"], clients: []});
Enter fullscreen mode Exit fullscreen mode

状态保存在可变的 中ref。我们可以通过 访问当前内容state^,并使用赋值运算符 赋值:=。服务器启动时,它没有客户端,只有一个默认按钮。

这个辅助函数也很方便,可以向除了传递的客户端之外的每个存储的客户端发送消息:

let sendToRest = (socket, msg) =>
  state^.clients
  |> List.filter(c => c != socket)
  |> List.iter(c => Server.Socket.emit(c, msg));
Enter fullscreen mode Exit fullscreen mode

现在一切都已设置完毕,接下来是定义应用程序的真正内容。从以下大纲开始:

Server.onConnect(
  io,
  socket => {
    // our code here....
  },
);
Enter fullscreen mode Exit fullscreen mode

第一部分是如何处理客户端连接。将占位符注释替换为以下内容:

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)),
    );
Enter fullscreen mode Exit fullscreen mode

为了方便起见,我们将Server模块开放到本地作用域,然后调整状态以包含新客户端。我们使用该函数向所有可能已经存储在 中的客户端sendToRest发送消息,最后返回该消息,告知新连接的客户端当前状态。ClientDeltastate.clientsSuccess

下一步是处理断开连接。在最后一个Socket.emit()调用下面添加:

    Socket.onDisconnect(
      socket,
      _ => {
        print_endline("Client disconnected");
        sendToRest(socket, ClientDelta(-1));
        state :=
          {...state^, clients: List.filter(c => c == socket, state^.clients)};
      },
    );
Enter fullscreen mode Exit fullscreen mode

客户端退出应用状态,其他所有仍在连接的用户都会收到更新。剩下的部分就是处理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");
        },
    );
Enter fullscreen mode Exit fullscreen mode

每当添加或删除按钮时,我们都会相应地调整状态,并让其他人知道这一变化。服务器部分就是这样!

客户端

螺母和螺栓

如果这个演示没有用到 ReasonReact 库,那我可就太失礼了。它太棒了。首先,添加依赖项:

$ yarn add react react-dom
$ yarn add -D reason-react
Enter fullscreen mode Exit fullscreen mode

另请添加reason-reactbsconfig.json

  "bs-dependencies": [
    "bs-express",
    "bs-socket",
    "reason-react"
  ],
Enter fullscreen mode Exit fullscreen mode

既然我们已经在这里了,那就激活 JSX。将以下条目添加到顶层:

  "reason": {
    "react-jsx": 2
  },
Enter fullscreen mode Exit fullscreen mode

为了处理打包,我将使用Parcel。这不是必需的——你可以使用任何你喜欢的。接下来,添加依赖项:

$ yarn add -D parcel-bundler
Enter fullscreen mode Exit fullscreen mode

还添加一个脚本来package.json运行它:

"scripts": {
  //..
  "start:bundle": "parcel watch index.html",
  //..
},
Enter fullscreen mode Exit fullscreen mode

我们还需要创建它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>
Enter fullscreen mode Exit fullscreen mode

此存根在 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 = "";
Enter fullscreen mode Exit fullscreen mode

现在将端点替换为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})
);
Enter fullscreen mode Exit fullscreen mode

这将设置我们的静态文件服务和dist/index.html由 Parcel 生成的服务,而/不是占位符字符串。

代码

我们已经将 Parcel 指向了src/Index.re——在那里放一个文件可能是个好主意!创建它并包含以下内容:

ReactDOMRe.renderToElementWithId(<ButtonClient />, "app");
Enter fullscreen mode Exit fullscreen mode

这就是 ReasonReact 挂载到 DOM 的方式。我们终于可以构建组件了。

在实际应用中,理想情况下应该将其拆分成几个组件——一个用于按钮,一个用于输入,或许还有一个单独的组件用于计数器。为了演示,我把所有组件都放在一个组件中,但如果这个应用规模更大,拆分很可能是第一步。

在 处创建一个文件src/ButtonClient.re。首先,我们将在文件顶部设置套接字客户端:

module Client = BsSocket.Client.Make(Messages);

let socket = Client.create();
Enter fullscreen mode Exit fullscreen mode

在此之下,我们需要定义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");
Enter fullscreen mode Exit fullscreen mode

这与消息非常相似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>,
};
Enter fullscreen mode Exit fullscreen mode

我们将分别讨论每个部分。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);
Enter fullscreen mode Exit fullscreen mode

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})
},
Enter fullscreen mode Exit fullscreen mode

扩展运算...符真是帮了大忙!这段代码还利用了“双关”功能——例如,在 中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,
)}
Enter fullscreen mode Exit fullscreen mode

我们既将其发送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>
Enter fullscreen mode Exit fullscreen mode

提交后,组件将重置该字段为空字符串。

最后一位是连接客户端的总数:

{ReasonReact.string(
     (self.state.numClients |> string_of_int) ++ " connected",
 )}
Enter fullscreen mode Exit fullscreen mode

好了!好了,开始吧!假设你已经yarn start:re运行yarn start:bundle了 ,打开一个新的终端,最后调用yarn serve。现在打开几个浏览器窗口,将它们全部指向localhost:3000,你应该会看到它们在你添加和删除按钮时保持同步。太棒了!

完整的代码可以在这里找到

封面图片可在此处找到

文章来源:https://dev.to/decidously/real-time-communication-in-reasonml-with-bs-socket-1p5l
PREV
Rust 你自己的小 Lisp
NEXT
使用 Rust/WebAssembly 和 web-sys 实现 Reactive Canvas,或者说我如何学会不再担心并爱上宏