Huly 平台实时白板服务 Hulypulse 实战指南:Key 模型、REST/WebSocket API 与源码级原理剖析
2026/9/11 18:40:08 网站建设 项目流程

Huly 平台实时白板服务 Hulypulse 实战指南:Key 模型、REST/WebSocket API 与源码级原理剖析

【免费下载链接】platformHuly — All-in-One Project Management Platform (alternative to Linear, Jira, Slack, Notion, Motion)项目地址: https://gitcode.com/GitHub_Trending/platform80/platform

Hulypulse 是 Huly 全栈协作平台(foundations/hulypulse)中一个用 Rust 实现的轻量级"共享白板"服务:多个客户端连接到同一个命名空间后,彼此写入的键值数据可被实时同步与订阅,服务同时暴露 REST 与 WebSocket 两种 API。读完本文,你将掌握 Hulypulse 的 Key 命名模型与私有段语义、完整的 HTTP/WS 协议交互(含 TTL 与条件写等并发控制)、内存/Redis 双后端切换、JWT 认证与构建运行方式,并能依据源码理解其事件广播与心跳保活机制。

Hulypulse 是什么:为"在线协作状态"而生的共享白板

Hulypulse 的核心抽象是白板(whiteboard):连接到同一白板的客户端能够看到其他客户端写入该白板的数据。服务本身并不关心数据的业务含义,它只负责"按 Key 存取 JSON、按前缀订阅、实时广播变更"三件事。

官方列举的典型使用场景包括:

  • 文档中的用户在线状态(presence)
  • 用户的"正在输入"(typing)事件
  • 编辑器或绘图板中的光标位置(cursor position)
  • 服务端推送的进程状态(process status)

从源码结构看,Hulypulse 由main.rs(actix-web 服务入口)、handlers_http.rshandlers_ws.rs(协议层)、db.rs(存储抽象层)、redis.rs/memory.rs(两个后端实现)、hub_service.rs(连接会话与订阅广播中枢)组成,并在 Cargo.toml 中声明版本0.4.2(edition 2024)。

Key 模型:命名空间、层级前缀与私有段

Key 的格式与约束

Key是由一个或多个段(segment)通过/拼接而成的字符串,例如foo/bar/baz。规则如下:

  • Key不能以/结尾(单 Key 语义);
  • 段内不得包含特殊字符*?[]\\x00..\x1F(控制字符)、\x7F"'
  • 不能为空
  • 段可以是私有段:以$前缀标记,例如/a/b/$c/$d

这些限制在 db.rs 中有直接实现:deprecated_symbol()逐一比对上述字符集合,deprecated_symbol_error()在命中时返回412 Deprecated symbol in key;读写路径(redis_saveredis_readmemory_save等)都会在操作前调用该校验。

Query:单 Key 与前缀查询

Query(用于查询/订阅)同样不能包含上述特殊字符,但支持前缀查询:前缀以段分隔符/结尾。

  • GET/SUBSCRIBE ... a/b—— 精确匹配单个 Keya/b
  • GET/SUBSCRIBE ... a/b/c/—— 匹配所有以a/b/c/开头的 Key。

前缀多匹配的私有段规则

  • 选取所有以该前缀起始的 Key;
  • 跳过前缀右侧包含私有段($)的 Key

前缀匹配示例(官方文档原例)

假设存在四个 Key:

  1. /a/b/$c/$d
  2. /a/b/c
  3. /a/b/$c
  4. /a/b/$c/$d/e

查询前缀与结果的对应关系:

前缀结果
/[2]
/a/b/[2]
/a/b/$c/[3]
/a/b/$c/$d/[4]
/a/b/$c/$d[1]

可以看到:私有段$c$d本身可以被精确查询或作为前缀继续匹配,但当它们作为中间段出现时,公开前缀(如/a/b/)不会泄漏其下的私有内容。

源码中对应实现有两处:

  • Redis 后端的 redis.rs:redis_listSCAN MATCH {prefix}*遍历后,通过k.strip_prefix(key).is_some_and(|s| s.contains('$'))过滤掉前缀右侧含$的 Key;
  • 订阅匹配的 hub_service.rs:subscription_matches()对以/结尾的订阅,校验key.starts_with(sub_key)且剩余部分rest不含$

Data:任意 JSON 文档

Data是任意 JSON 文档,大小被限制在"一个合理的范围"。这个上限在存储层有两处落地:

  • redis_save/memory_save都会读取CONFIG.max_size,当值超过max_size时返回400(提示 "Value in memory mode must be less than {max_size} bytes");
  • 内存后端对值有 UTF-8 校验,非 UTF-8 数据返回400

max_size在 config/default.toml 中以注释形式存在(# max_size = 100),即默认不限制(0表示关闭校验),按需开启即可(见后文配置章节)。

HTTP API

Hulypulse 的所有 HTTP 路由挂在 actix-web 的/api/{workspace}/{key...}之下(详见 main.rs),其中{key:.+/}(以/结尾)路由到 list 处理器,{key:.+}路由到 get/put/delete 处理器。工作区(workspace)在带认证编译时还会经过check_workspace中间件校验。

GET /status—— 服务器状态与 WebSocket 连接数

GET /status

应答示例:

{"status":"OK","websockets":2}

从 hub_service.rs 的info_json()看,实际返回字段比文档示例更丰富,包括:statusbackend("redis"/"memory")、websocketssubscriptionsheartbeatsserverpingloopsloglevelmemory_infoversion

PUT /{workspace}/{key}—— 保存 Key

输入

  • Body:data(JSON)
  • Content-Type: application/json
  • Content-Length:可选
  • TTL 头(二选一):
    • HULY-TTL:N 秒后自动删除
    • HULY-EXPIRE-AT:在指定 UnixTime 自动删除
    • 默认max_ttl = 3600(config/default.toml)
  • 条件头(Conditional Headers):
    • If-Match: *—— 仅当 Key 存在时更新
    • If-Match: <md5>—— 仅当当前值的 MD5 匹配时更新
    • If-None-Match: *—— 仅当 Key 不存在时插入

输出(README 文档约定):

  • 201:以If-None-Match: *插入成功
  • 204:普通插入或更新成功
  • 412:条件不满足
  • 400:请求头非法
  • Body:DONE

实现层面的实际行为:当前仓库的 handlers_http.rs 中,put处理器把HULY-TTL/HULY-EXPIRE-AT解析为Ttl::Sec/Ttl::At(同时指定两者返回400 Multiple ttl specified),把If-Match/If-None-Match组合映射为四种SaveMode

If-MatchIf-None-MatchSaveMode语义
Upsert覆盖写入(默认)
*Update仅更新已存在 Key
<md5>Equal(md5)仅当 MD5 匹配时更新
*Insert仅插入不存在的 Key

保存成功后处理器实际返回的是200与正文DONE(而非文档中的 201/204),这一行为与 tests/rest_api.rs 中assert!(r.code == 200); assert!(r.text == "DONE")的断言一致——使用时请以实测行为为准,文档中的 201/204 属于协议设计约定。

在 Redis 后端,SaveMode::Insert/Update会翻译为SET key value EX <sec> NX/XXSaveMode::Equal则通过WATCH+GET+MULTI/EXEC实现带 MD5 校验的乐观锁(CAS),并在冲突时循环重试(上限MAX_LOOP_COUNT = 1000),见 redis.rs。

DELETE /{workspace}/{key}—— 删除 Key

  • 成功:204 No Content,无正文
  • Key 不存在:404 Not Found

可选的If-Match条件:If-Match: <md5>仅当 MD5 匹配时删除;If-Match: *在 Key 不存在时返回错误(Redis 后端实现为SaveMode::Update,见 handlers_ws.rs 与 redis.rs)。

GET /{workspace}/{key}—— 读取单个 Key

  • 状态:200
  • Content-type: application/json
  • 响应头:Etag: <md5>
  • Body:workspace(输入回显)、key(输入回显)、data(输入回显)、expiresAt/TTL(可选)、etag <md5>

实现层面的实际响应:存储层统一返回DbArray { key, data, ttl, etag }(见 db.rs),其中ttl为剩余秒数、etag为 data 的 MD5;handlers_http.rs 的get处理器将etag放入ETag响应头,并以 JSON 序列化DbArray(实际字段为key/data/ttl/etag)。Key 不存在时返回404与正文empty

GET /{workspace}/{key}/—— 读取 Key 数组(前缀列表)

  • 状态:200
  • Content-type: application/json
  • Body(数组):[{"key","data","ttl","etag"}, ...]

注意 URL 必须以/结尾,否则会被路由到单 Key 读取;若前缀未以/结尾,存储层返回412 Key must end with slash

WebSocket API

WebSocket 入口为/ws/ws/{client_name}(后者配合lopt特性启用命名会话),握手后客户端发送 JSON 命令帧,服务端以 JSON 应答。每条命令可携带可选的correlation id用于配对请求与响应。此外,连接还支持应用层ping/pong文本帧做心跳(见 handlers_ws.rs)。

Client → Server 命令

PUT

{ "type": "put", "correlation": "abc123", "key": "workspace/foo/bar", // 共享 Key "data": "hello", "TTL": 60, // 可选:N 秒后自动删除 "expiresAt": 1700000000, // 可选:UnixTime 自动删除 "ifMatch": "*", // 可选:仅当存在时更新;或传 <md5> "ifNoneMatch": "*" // 可选:仅当不存在时插入 }
  • Key 写法:"workspace/foo/bar"(共享 Key)或"workspace/foo/bar/$/secret"(私有 Key)
  • TTL 默认max_ttl = 3600
  • 应答:{"action":"put","correlation":"abc123","result":"OK"}

GET

{ "type": "get", "correlation": "abc123", "key": "workspace/foo/bar" }
  • 应答:{"action":"get","result":{"data":"hello","etag":"5d41402abc4b2a76b9719d911017c592","ttl":3599,"key":"00000000-0000-0000-0000-000000000001/foo/bar"}}

LIST

{ "type": "list", "correlation": "abc123", "key": "workspace/foo/bar/" }
  • Key 写法:"workspace/foo/bar/"(公共空间前缀)或"workspace/foo/bar/$/secret/"(私有空间前缀)
  • 应答:{"action":"list","result":[{"data":"hello 1","etag":"df0649bc4f1be901c85b6183091c1d83","ttl":3570,"key":"00000000-0000-0000-0000-000000000001/foo/bar1"},{"data":"hello 2","etag":"bb21ec8394b75795622f61613a777a8b","ttl":3555,"key":"00000000-0000-0000-0000-000000000001/foo/bar2"}]}

DELETE

{ "type": "delete", "correlation": "abc123", "key": "workspace/foo/bar" }
  • 可选条件:ifMatch: <md5>(仅当 MD5 匹配时删除)、ifMatch: *(Key 不存在时返回错误)
  • 应答:{"action":"delete","result":"OK"}(不存在时返回 error "not found")

SUBSCRIBE

{ "type": "sub", "correlation": "abc123", "key": "workspace/foo/bar" }
  • Key 写法(与 LIST 相同的私有段规则):
    • "workspace/foo/bar"—— 订阅单个共享 Key
    • "workspace/foo/bar/"—— 订阅所有以该前缀起始的 Key
    • "workspace/foo/bar/$/my_secret"—— 订阅单个私有 Key
    • "workspace/foo/bar/$/my_secret/"—— 订阅所有以该私有前缀起始的 Key
  • 应答:{"action":"sub","result":"OK"}

UNSUBSCRIBE

{ "type": "unsub", "correlation": "abc123", "key": "workspace/foo/bar" }
  • Key 写法:"workspace/foo/bar"(退订指定 Key)或"*"(退订全部)
  • 应答:{"action":"unsub","result":"OK"}

SUBLIST(我的订阅列表)

{ "type": "sublist", "correlation": "abc123" }
  • 应答:{"action":"list","result":["00000000-0000-0000-0000-000000000001/foo/bar1","00000000-0000-0000-0000-000000000001/foo/bar2"]}

INFO

{ "type": "info", "correlation": "abc123" }
  • 应答:{"db_mode":"memory","memory_info":"1231 keys, 80345 bytes","status":"OK","websockets":164}

WsCommand枚举与上述命令一一对应,定义于 handlers_ws.rs;其中correlation缺省时默认为"1"。命令处理统一走handle_command():先做 workspace/Rego 权限校验(auth 特性下),再按语义调用db.save/read/list/deletehub_state.subscribe/unsubscribe,最后回写应答帧。lopt特性还额外提供personal/answer两类点对点消息命令。

Server → Client 订阅事件

一旦某个 Key 发生变更,所有订阅了该 Key(或匹配其前缀)的会话都会收到广播事件:

  • 写入:{"message":"Set","key":"00000000-0000-0000-0000-000000000001/foo/bar","value":"hello"}
  • 过期:{"message":"Expired","key":"00000000-0000-0000-0000-000000000001/foo/bar"}
  • 删除:{"message":"Del","key":"00000000-0000-0000-0000-000000000001/foo/bar"}

事件广播链路在 hub_service.rs 的broadcast_event():先根据事件 Key 计算出所有匹配订阅的接收方(recipients_for_key复用subscription_matches的私有段过滤逻辑),再逐会话发送 JSON 文本帧。Redis 后端的事件来源是 Redis 的keyspace 通知(见 redis.rs):服务启动后通过CONFIG SET notify-keyspace-events E$gx开启,并PSUBSCRIBE四个模式__keyevent@*__:set/del/unlink/expired,收到RedisEvent后对 Set 事件回查GET取当前值再广播;断线会以指数退避(1s 起、上限 60s)自动重连。内存后端则由每秒一次的 ticker 扫描过期 Key 并广播Expired(见 memory.rs)。

配置详解

config/default.toml

config/default.toml 是仓库内的默认配置文件:

bind_port = 8099 bind_host = "0.0.0.0" token_secret = "secret" backend = "redis" redis_urls = "redis://huly.local:6379" redis_password = "<invalid>" redis_mode = "direct" redis_service = "mymaster" max_ttl = 3600 heartbeat_timeout = 90 ping_timeout = 30 loglevel = "INFO" # optional settings # max_size = 100 # permit_file = "/home/user/hulipulse/permit.rego"

加载顺序(config.rs):内嵌默认 TOML → 可选的etc/config.toml→ 以HULY为前缀的环境变量,后者逐层覆盖前者;配置解析失败会打印错误并退出进程。

环境变量

README 中列出的环境变量如下:

环境变量说明默认值
HULY_BIND_HOST服务绑定主机0.0.0.0
HULY_BIND_PORT服务绑定端口8094(README 文档值;仓库内 config/default.toml 实际为8099,Docker 示例则映射8095,请以部署配置为准)
HULY_TOKEN_SECRET用于签发/校验 JWT 的密钥secret
HULY_BACKEND存储后端"redis""memory""redis"
HULY_REDIS_URLSRedis 连接串(逗号分隔,支持多个,用于 Sentinel)redis://huly.local:6379
HULY_REDIS_PASSWORDRedis 密码"<invalid>"
HULY_REDIS_MODERedis 模式"direct""sentinel""direct"
HULY_REDIS_SERVICERedis Sentinel 服务名"mymaster"
HULY_MAX_TTL最大存储时长(秒)3600
HULY_PAYLOAD_SIZE_LIMIT最大载荷大小(TODO,尚未实现)2Mb(计划值)

此外,源码 config.rs 与 default.toml 还支持max_size(单值最大字节数,0 表示不限制)、heartbeat_timeout(心跳超时,默认 90s)、ping_timeout(服务端 ping 超时,默认 30s)、loglevelTRACE/DEBUG/INFO/WARN/ERROR,映射见 main.rs),以及 auth 特性下的policy_file(Rego 策略文件路径)。

两个后端的语义差异

  • TTL 上限:两者都强制TTL > 0TTL <= max_ttl;但内存后端内部用u8tick 计数表示过期时刻,因此单次 TTL 被进一步限制在 255 秒以内(见 memory.rs 的compute_ttl_u8,超限返回412 TTL exceeds MAX_TTL or 255 sec),Redis 后端无此限制。
  • 绝对值过期Ttl::At(timestamp)在两端都会先换算为相对秒数,若时间戳已过期(<= now)返回400
  • 信息上报infomemory_info字段在内存后端格式为"1231 keys, 80345 bytes"(HashMap 长度与数据字节总和,见 memory.rs),在 Redis 后端则解析INFO输出的db0: keys=used_memory:(见 redis.rs)。

构建与运行

Cargo 特性构建

Hulypulse 通过 Cargo features 控制认证能力(Cargo.toml):

  • 默认特性包含auth(使用 huly-authorization,即hulyrsJWT 与 Rego 策略);
  • 禁用认证:cargo build --no-default-features
  • 显式启用认证:cargo build --no-default-features --features "auth"
  • lopt为可选特性,开启后额外支持命名会话与personal/answer点对点消息。

Docker 运行

预构建镜像位于hardcoreeng/service_hulypulse:{tag},本地运行:

docker run -p 8095:8095 -it --rm hardcoreeng/service_hulypulse:{tag}

构建流程参见 Dockerfile:基于rust:1.88多阶段交叉编译linux/amd64linux/arm64,产物拷贝进debian:12-slim运行镜像。

从源码运行

使用 Redis 后端:

HULY_REDIS_URLS=redis://huly.local:6379 cargo run

使用内存后端(无需 Redis):

HULY_BACKEND=memory cargo run

注意:内存后端下,HULY_MAX_TTL若大于 255 仍受单值 255 秒限制,且数据不持久化、重启即失,仅适合开发与演示。

加入本地 Huly 开发环境

若要以本地 Huly 开发环境的一员运行(与其余服务共享网络、对接本地开发 Redis):

export HULY_REDIS_URLS="redis://huly.local:6379" docker run --rm -it --network dev_default -p 8095:8095 hardcoreeng/service_hulypulse:{tag}

随后即可通过http://localhost:8095访问服务(/status/api/ws)。另可参考目录下的测试脚本(如 scripts/TEST_HTTP_API.sh、scripts/TEST_WS_API.sh)做端到端冒烟验证。

认证与授权

Hulypulse 使用Bearer JWT认证(auth 特性默认开启)。当前实现接受任何由HULY_TOKEN_SECRET(环境变量,默认secret)签名的 Token。

  • HTTP 与 WebSocket 均可通过Authorization: Bearer <token>头携带 Token;也可在查询串传?token=<token>(见 main.rs 的extract_claims中间件)。
  • 带认证编译时,HTTP 请求还会经过check_workspace中间件:claims.workspace必须与 URL 中的 workspace 一致(claims.is_system()系统级 Token 除外),否则返回401 Unauthorized
  • WebSocket 命令层另有 workspace 校验(check_workspace_core)与Rego 策略校验:test_rego_http/test_rego_claims依据动作(Put/Get/List/Delete/Sub...)与 Key 执行策略判定,未通过则拒绝(HTTP 返回403 Forbidden,WS 返回 "Unauthorized: Rego policy")。策略文件通过policy_file配置项指定,仓库内附带了示例策略 policy.repo。

内部架构:从请求到事件广播的完整链路

  1. 入口:main.rs 启动时初始化HubState(会话/订阅中枢)、心跳检查协程与存储后端;backend = redis时还会拉起redis::receiver协程消费 keyspace 事件。actix-web 服务配置全开放 CORS,挂载/status/api/{workspace}/.../ws/ws/{client_name}
  2. 存储抽象:db.rs 的Db枚举封装Redis(ConnectionManager)Memory(HashMap + Hub)两个后端,对上层(HTTP/WS 处理器)暴露统一接口save/read/list/delete/info
  3. Redis 后端SET ... EX <sec> [NX|XX]写入;GET/TTL读取(TTL 为-1/-2时视为错误);SCAN MATCH列表;WATCH/MULTI/EXEC实现 MD5 CAS;Sentinel 模式通过SentinelClientBuilder连接(RESP3、db 11),direct 模式直连(RESP3、db 0),默认端口分别为6379(sentinel)/6380(direct),见 redis.rs。
  4. 内存后端HashMap<String, Entry{data, ttl_tick}>加每秒 ticker 过期扫描与Expired广播;所有变更(save/delete)同步向 Hub 广播Set/Del事件。
  5. 会话中枢:hub_service.rs 维护sessionssubsheartbeatscheck_heartbeat每 2 秒巡检,超heartbeat_timeout(90s)未活动的会话被强制关闭,超ping_timeout(30s)未活动的会话先发ping探活。
  6. 客户端参考实现:client/off/client.ts 提供了HulypulseClient:30 秒应用层ping、5 分钟无响应判定断线、1 秒自动重连、连接类错误(broken pipe / connection reset 等)触发重连,可作为接入端协议模板。

测试与验证

仓库提供了多层测试用于验证协议正确性:

  • tests/rest_api.rs:覆盖PUT/GET/DELETE全流程,包括If-Match: *If-Match: <md5>匹配/不匹配(412 md5 mismatch)、If-None-MatchHULY-TTL过期(写入 7 秒与 1 秒后等待 1.05 秒再读应 404)、/status后端字段断言等;
  • db.rs:内存后端单元测试,验证save → read → list → delete的 CRUD 闭环;
  • tests/ws.rs:WebSocket 协议测试;
  • scripts 下还有TEST_HTTP_API.shTEST_WS_API.shtyping-test.shpulse_lib.sh等可执行的集成测试脚本与TEST.html浏览器调试页。

已知限制与路线图

README 列出的待办事项(按原文档顺序):

  • 可选的值加密(Optional value encryption)
  • OpenTelemetry 支持
  • 数据库迁移的并发控制(多个 Hulypulse 实例同时升级时)
  • TLS 支持
  • Liveness/readiness 探针端点

此外,README 中标记为 TODO 的HULY_PAYLOAD_SIZE_LIMIT尚未在源码中实现,目前载荷大小仅由max_size控制。

许可证与贡献

Hulypulse 以EPL-2.0(Eclipse Public License 2.0)开源(见 LICENSE),接受 issue 与 pull request 形式的社区贡献。若要在自己的服务中集成"在线状态/光标/输入中/状态推送"这类实时协作能力,Hulypulse 这套"Key 前缀 + 私有段 + 条件写 + 订阅广播"的协议模型可直接复用,其事件广播与心跳机制均可对照上述源码路径进一步深入研读。

【免费下载链接】platformHuly — All-in-One Project Management Platform (alternative to Linear, Jira, Slack, Notion, Motion)项目地址: https://gitcode.com/GitHub_Trending/platform80/platform

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询