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.rs、handlers_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_save、redis_read、memory_save等)都会在操作前调用该校验。
Query:单 Key 与前缀查询
Query(用于查询/订阅)同样不能包含上述特殊字符,但支持前缀查询:前缀以段分隔符/结尾。
GET/SUBSCRIBE ... a/b—— 精确匹配单个 Keya/b;GET/SUBSCRIBE ... a/b/c/—— 匹配所有以a/b/c/开头的 Key。
前缀多匹配的私有段规则:
- 选取所有以该前缀起始的 Key;
- 跳过前缀右侧包含私有段(
$)的 Key。
前缀匹配示例(官方文档原例)
假设存在四个 Key:
/a/b/$c/$d/a/b/c/a/b/$c/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_list用SCAN 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()看,实际返回字段比文档示例更丰富,包括:status、backend("redis"/"memory")、websockets、subscriptions、heartbeats、serverping、loops、loglevel、memory_info、version。
PUT /{workspace}/{key}—— 保存 Key
输入:
- Body:data(JSON)
Content-Type: application/jsonContent-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-Match | If-None-Match | SaveMode | 语义 |
|---|---|---|---|
| 空 | 空 | 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/XX,SaveMode::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/delete或hub_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_URLS | Redis 连接串(逗号分隔,支持多个,用于 Sentinel) | redis://huly.local:6379 |
HULY_REDIS_PASSWORD | Redis 密码 | "<invalid>" |
HULY_REDIS_MODE | Redis 模式"direct"或"sentinel" | "direct" |
HULY_REDIS_SERVICE | Redis 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)、loglevel(TRACE/DEBUG/INFO/WARN/ERROR,映射见 main.rs),以及 auth 特性下的policy_file(Rego 策略文件路径)。
两个后端的语义差异
- TTL 上限:两者都强制
TTL > 0且TTL <= max_ttl;但内存后端内部用u8tick 计数表示过期时刻,因此单次 TTL 被进一步限制在 255 秒以内(见 memory.rs 的compute_ttl_u8,超限返回412 TTL exceeds MAX_TTL or 255 sec),Redis 后端无此限制。 - 绝对值过期:
Ttl::At(timestamp)在两端都会先换算为相对秒数,若时间戳已过期(<= now)返回400。 - 信息上报:
info中memory_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/amd64与linux/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。
内部架构:从请求到事件广播的完整链路
- 入口:main.rs 启动时初始化
HubState(会话/订阅中枢)、心跳检查协程与存储后端;backend = redis时还会拉起redis::receiver协程消费 keyspace 事件。actix-web 服务配置全开放 CORS,挂载/status、/api/{workspace}/...、/ws、/ws/{client_name}。 - 存储抽象:db.rs 的
Db枚举封装Redis(ConnectionManager)与Memory(HashMap + Hub)两个后端,对上层(HTTP/WS 处理器)暴露统一接口save/read/list/delete/info。 - 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。 - 内存后端:
HashMap<String, Entry{data, ttl_tick}>加每秒 ticker 过期扫描与Expired广播;所有变更(save/delete)同步向 Hub 广播Set/Del事件。 - 会话中枢:hub_service.rs 维护
sessions、subs、heartbeats;check_heartbeat每 2 秒巡检,超heartbeat_timeout(90s)未活动的会话被强制关闭,超ping_timeout(30s)未活动的会话先发ping探活。 - 客户端参考实现: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-Match、HULY-TTL过期(写入 7 秒与 1 秒后等待 1.05 秒再读应 404)、/status后端字段断言等; - db.rs:内存后端单元测试,验证
save → read → list → delete的 CRUD 闭环; - tests/ws.rs:WebSocket 协议测试;
- scripts 下还有
TEST_HTTP_API.sh、TEST_WS_API.sh、typing-test.sh、pulse_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),仅供参考