- 开发工具
【免费下载链接】xray
An experimental next-generation Electron-based text editor
Xray 在 2018 年 4 月 9 日的更新周报中记录了共享工作区(Shared Workspaces)的地基工程:一个允许多台本地客户端共同"栖息"(co-inhabit)在远程无头 Xray 实例工作区中的协作编辑能力。本文以该周报为核心,结合仓库中xray_core/src/rpc的完整实现、共享工作区架构文档 与 RPC 示意图,系统讲解这套基于能力(capability)的自定义 RPC 系统的设计目标、消息协议、状态复制与动态资源管理在源码中的落地方式,读完你可以掌握"CRDT 缓冲区 + 状态复制 + RPC 请求"三者如何组合出多端实时协作编辑。
一、什么是共享工作区:周报交代的核心场景
2018 年 4 月 9 日这期更新的第一段就点明了本周工作:为共享工作区"铺设地基"。其基本设想是:
- 在远程机器上启动一个无头(headless)的 Xray 实例;
- 多名开发者从各自的本地机器连接进来,共同栖息在这个远程工作区中协作。
由于 Xray 的缓冲区是 CRDT(无冲突复制数据类型),并发缓冲区编辑本身"相对直接",但真正缺失的是一块基础设施:对等同步状态、以及请求/响应(request/response)通道。这正是本周产出的核心——一套能力式 RPC 系统的设计与大部分实现。
周报还交代了一个关键决策过程:团队曾尝试过 Cap'N Proto 自带的 RPC 框架,但"被生成出来的代码搞得有些招架不住"(feeling a bit overwhelmed by the generated code),于是决定探索一个量身定制的方案。这条决策线索非常值得注意:它说明 Xray 的 RPC 层并非通用 RPC 框架,而是围绕"复制对象领域模型 + 能力安全 + 动态资源回收"三个具体诉求裁剪出来的轻量系统。
二、设计目标:四个约束从架构文档到源码
共享工作区架构文档 将这套系统的设计目标归纳为四条,每一条都能在xray_core源码中找到对应物。
2.1 支持复制对象(Replicated Objects)
首要目标是构建一个"复制的对象导向领域模型":除了远程过程调用之外,系统还要显式支持长期存活、随时间演化的有状态对象。复制支持应当是"附加式"的——服务端代码几乎可以像对象没有被复制一样来设计;客户端与远程对象表示的交互则应当"显式但方便"。
这一目标在 Service trait 定义 中体现得非常直接:
pub trait Service { type State: 'static + Serialize + for<'a> Deserialize<'a>; type Update: 'static + Serialize + for<'a> Deserialize<'a>; type Request: 'static + Serialize + for<'a> Deserialize<'a>; type Response: 'static + Serialize + for<'a> Deserialize<'a>; fn init(&mut self, connection: &Connection) -> Self::State; fn poll_update(&mut self, _connection: &Connection) -> Async<Option<Self::Update>> { Async::NotReady } fn request(...) -> Option<Box<Future<Item = Self::Response, Error = Never>>> { None } }一个Service恰好暴露架构文档所描述的三样东西:初始状态的静态快照(init返回State)、更新流(poll_update驱动Update)、请求处理能力(request返回Responsefuture)。服务端领域对象只需为它写一个服务包装即可接入复制,业务逻辑本身不需要感知"被复制"这件事。
2.2 基于能力的安全模型
架构文档说明:服务端对象通过*服务(services)*暴露,服务可被视为"能力",授予对一小片动态定义功能的访问权。远程用户从唯一的根服务开始,随着被授予更多能力而逐步获得更大范围的访问。
这个"能力 = 句柄"的模型在源码中的边界非常清晰:客户端拿到一个Service<S>句柄后,只能通过该句柄发起请求(client.rs 的 request 方法),而它能否"取出"某个新服务,完全取决于服务端是否在自己的响应中通过add_service登记并返回ServiceId。没有服务端的"授权动作",客户端就无从获得新能力——这与能力安全"能力即凭证、不可伪造"的哲学一致。
2.3 动态资源管理
文档写道:服务端的服务只需要在被客户端引用期间存活;如果服务端和客户端双方都放开了对这个服务的引用计数句柄,服务端应当自动丢弃该服务。
这段描述对应三处实现:
- server.rs 中的 ServiceRegistration Drop 实现:当服务端持有的
ServiceHandle被丢弃时,Drop从connection.services中移除该服务并标记removed,把"服务消失"这件事随下一条Update消息推给客户端; - client.rs 中的 ServiceRegistration Drop 实现:客户端句柄被丢弃时,向服务端发送
MessageToServer::DroppedService(service_id),服务端随即释放对应的客户端句柄(server.rs 的处理逻辑); - xray_core 的单元测试
test_create_and_drop_service用Rc::strong_count逐断言验证了完整的引用计数生命周期:创建子服务后计数从 2 升到 3,DropService请求(服务端主动放弃引用)不降低计数,丢弃客户端句柄也不降低计数,直到客户端更新流也被丢弃、DroppedService消息往返之后,计数才回落到 2。这个测试是"双向引用计数、任一侧单独放手都不回收"这一设计的最佳活文档。
2.4 二进制消息与协议演化
架构文档坦承:为了在两端之间高效移动数据,需要二进制编码;当前"为了便利使用 bincode,但最终应当切换到 Protocol Buffers 以支持协议的优雅演化"。
这一点在实现中得到精确印证:server.rs 与 client.rs 都是直接use bincode::{deserialize, serialize},而所有State/Update/Request/Response关联类型都强制实现Serialize + Deserialize。也就是说,协议层把"编码方案"收敛到了 serde 一处,未来换编码方案时改动面被刻意压到了最小——这正是"优雅演化"目标的工程铺垫。
三、消息协议:一次"批处理"如何压缩网络往返
理解这套系统最快的入口是 xray_core/src/rpc/messages.rs:整个协议只有两个消息类型。
pub type RequestId = usize; pub type ServiceId = usize; #[derive(Serialize, Deserialize)] pub enum MessageToClient { Update { insertions: HashMap<ServiceId, Bytes>, // 新服务:id -> 初始状态 updates: HashMap<ServiceId, Vec<Bytes>>, // 既有服务:id -> 本批更新 removals: HashSet<ServiceId>, // 被回收的服务 responses: HashMap<ServiceId, Vec<(RequestId, Response)>>, // 请求响应 }, } pub type Response = Result<Bytes, Error>; #[derive(Debug, Serialize, Deserialize)] pub enum MessageToServer { Request { service_id: ServiceId, request_id: RequestId, payload: Bytes, }, DroppedService(ServiceId), }几个值得注意的设计点:
- 服务端到客户端只有一种消息
Update,且内部是四个"集合"的批量结构。服务端在 Connection 的 poll_outgoing 中把待决响应(pending_responses)、新插入服务的初始状态(insertions)、各服务的增量更新(updates)、被移除服务(removals)收集齐,只要任一部分非空就打包成一条消息发出;全部为空则返回NotReady并登记pending_task等待唤醒。这种"攒批"策略天然合并了多路更新,降低了消息粒度; - 类型擦除到
Bytes:所有负载在网络层只是二进制块,具体的State/Update反序列化被推迟到客户端拿到Service<S>句柄之后进行(见 client.rs 的 updates 方法)。协议层因此对具体领域类型完全无感,这也是"服务端对象几乎像未被复制一样设计"的协议侧支撑; - 失败也是一等公民:
Response = Result<Bytes, Error>,Error 枚举 定义了ConnectionDropped、IoError、ServiceDropped、ServiceNotFound、ServiceTaken、UpdatesTaken六种错误,分别对应连接中断、IO 失败、句柄已失效、找不到服务、服务已被"取走"(take_service是排他的)、更新流已被消费过这几种语义。客户端收到错误响应时不会 panic,而是让对应 future 以错误结束。
四、服务端侧:Connection 如何把 Stream 变成能力注册表
架构文档描述了服务端的连接模型,server.rs 是其完整实现:
- 服务端每接受一个客户端,就用该客户端入站消息流构造一个
rpc::server::Connection。Connection::new 接收两条输入:入站Stream<Item = Bytes>与一个根服务;构造函数内部立即调用add_service(root_service),意味着根服务的初始状态会成为握手的第一帧数据。Connection本身实现Stream,其产出消息流即发给该客户端的所有出站数据。 - add_service 分配递增的
ServiceId,把服务存入services表、把 id 记入inserted待发表,并返回一个ServiceHandle(内部是Rc<ServiceRegistration>+ 对连接状态的Weak引用)。注意ServiceHandle的持有者是服务端自己的业务代码:例如AppService处理OpenWorkspace请求时,调用connection.add_service(...)并把service_id()写进响应返回给客户端。 - 入站处理 poll_incoming 把
MessageToServer::Request分发给对应服务,服务返回的 future 被推入共享的pending_responses(FuturesUnordered);若service_id查不到,则以Error::ServiceNotFound包成响应回送。MessageToServer::DroppedService则移除对应的客户端句柄。
五、客户端侧:take_service 与"根服务"的取得
架构文档描述客户端握手:"把入站消息流传给rpc::client::Connection::new,它返回一个 future,产出(client::Service, client::Connection)二元组——前者是服务端发来的根服务,后者是发往服务端的出站消息流。"
client.rs 的 Connection::new 与这段描述逐句对应:它等待首帧Update消息,解析后调用update处理,随后执行Self::service(&connection, 0)——硬编码取 id 为 0 的服务作为根服务(因为服务端next_service_id从 0 开始,根服务必然最先注册),若该 id 不存在则报ServiceNotFound。
take_service(client.rs L108-L111)是"能力获取"在客户端的落点,其内部实现 Connection::service 有两个关键细节:
client_states中查不到该 id 时返回Error::ServiceNotFound;- 若
has_client标记已置位则返回Error::ServiceTaken——即同一个ServiceId只能被take_service消费一次,取出后该服务在连接状态中进入"已被某个句柄独占"的标记状态。
对于"状态与更新同形"的服务(State == Update全量覆盖式更新),客户端还提供 FullUpdateService 包装:它缓存latest_state,消费更新流时同步刷新本地最新状态,并在流结束时把状态置为ServiceDropped。这正对应架构文档所说"客户端与远程对象表示的交互应当显式但方便"——上层只调用latest_state()/updates()/request()三个方法。
六、一次真实的"打开远程工作区":OpenWorkspace 调用链
架构文档给出了连接建立后的典型请求/响应流程,app.rs 中保留了完整实现,可以完整走查一遍:
- 服务端注册根服务:
App的根服务是AppService,其init返回ServiceState { workspace_ids }——即本地共享工作区 id 列表(AppService 的 init 与 state)。客户端连接成功后,PeerList::connect_to_server用该根服务创建Peer(内部是FullUpdateService),并在当前版本中自动打开第一个工作区——源码里留了注释// TODO: Eliminate this once we have a UI for the PeerList,与文档"目前先自动打开第一个 workspace,将来构建PeerListView"的表述完全一致; - 客户端发起请求:
Peer::open_workspace发送ServiceRequest::OpenWorkspace(workspace_id); - 服务端授权新能力:
AppService::request处理该请求时,若工作区存在且为本地工作区,则connection.add_service(WorkspaceService::new(...))并返回ServiceResponse::OpenedWorkspace(service_id);若是远程工作区或不存在的 id,则返回ServiceError::WorkspaceNotFound; - 客户端取走能力:响应回来后,客户端调用
take_service(service_id)得到WorkspaceService句柄,交给RemoteWorkspace::new建立RemoteWorkspace。
架构文档特别指出:RemoteWorkspace与LocalWorkspace都实现同一个Workspacetrait,使远程工作区可以在系统中以与本地工作区完全相同的方式被使用——"远程对象其实是本地的"这一错觉,正是靠状态复制 + RPC 二者组合制造出来的。
七、复制策略的分工:哪些走复制,哪些走 RPC
架构文档最后一段给出了一条非常实用的选型原则,也是本文值得单独提炼的工程经验:
- 项目文件树的模糊查找走复制。数据量通常很小且对延迟敏感,直接复制文件树状态到客户端,本地即可完成 fuzzy finding;
- 全项目搜索走 RPC。复制整个远程文件系统代价过高(尤其是浏览器内运行的场景),所以按需请求、按需返回;
- 缓冲区编辑走 CRDT 操作中继。复制的是冲突自由的编辑操作序列,各端缓冲区由于基于 CRDT,可以在任何次序下正确整合这些操作——这正是周报开头"缓冲区是 CRDT,使并发编辑相对直接"一语的落地。
也就是说,Xray 并不把"复制"当成万能手段,而是按数据体积 × 延迟敏感度把功能切分到复制、RPC 与 CRDT 操作中继三条通道上。
八、网络层与命令行:--listen / --headless / --connect
架构文档列出了共享工作区的操作面,仓库中的 xray_cli 帮助文本 给出了对应的真实参数:
xray [--socket-path=<path>] [--headless] [--listen=<port>] [--connect=<address>] [<path>...] -H --headless Start Xray in headless mode. -l --listen=<port> Listen for TCP connections on the specified port. -c --connect=<address> Connect to the specified address.与文档的对应关系:
xray foo/ bar/ --listen 8888启动监听 8888 端口的服务器;--headless启动只托管工作区、不显示自身 UI 的服务器。CLI 在 xray_cli/src/main.rs 中把--listen翻译为TcpListen { port }、把--connect翻译为ConnectToPeer { address }消息,经由 Unix 域套接字发给xray_server进程(进程内实际以XRAY_HEADLESS环境变量传递 headless 状态,见 xray_server/src/main.rs);- 服务端的 tcp_listen 在
0.0.0.0:port上绑定TcpListener,对每条新连接设置set_nodelay(true)(降低交互延迟,契合协作编辑场景),用length_delimited::Framed做消息分帧,随后调用App::connect_to_client把入站流交给 RPC 层; - 客户端侧的 connect_to_peer 同样设置
set_nodelay(true)、同样的分帧方式,然后调用app.connect_to_server(...)建立rpc::client::Connection,并把客户端出站消息流 spawn 回 socket。
共享工作区架构文档 还补充了两个操作细节:若远端主机暴露多个工作区,xray --connect hostname:port会弹出Open Workspace对话框供选择;在任何 Xray 窗口中按cmd-o会打开列出所有已连接服务器工作区的对话框,cmd-t则搜索远程工作区内的路径。多客户端打开同一文件缓冲区时,编辑会实时复制到其他协作者。
九、测试证据:协议行为如何被验证
rpc模块自带一组基于内存unsync::mpsc通道(无需真实 socket)的单元测试(xray_core/src/rpc/mod.rs),覆盖了协议的关键行为面:
test_connection:两个客户端先后接入同一模型,验证初始状态快照(42 / 44)、更新流的增量可见性、以及请求(Increment(3))后所有客户端最终收敛到 51——完整演示了"快照 + 增量 + 请求"三通道协作;test_create_and_drop_service:如上所述,精确验证双向引用计数的回收时机;test_creating_service_in_async_response:用节流连接模拟"响应与新服务的插入帧合并在一条消息里发出",验证客户端在收到响应时新服务必然已就位;test_add_service_on_init_or_update:验证服务在init与poll_update期间调用add_service的合法性——即"服务端可以在处理请求的 future 里动态注册新能力";- 三个连接中断测试(L209-L251):分别在握手前/握手后掐断任一方向,断言
poll返回Ready(None)或 future 以错误结束,证明连接层对对端消失的处理是收敛的。
从源码结构看,测试中的TestService通过NotifyCell/NotifyCellObserver观察模型变化来驱动poll_update,这正是架构文档所说"服务端代码像对象未被复制一样设计"的微型样板:领域模型只管改自己的状态,服务层负责把变化变成Update。
十、边界、限制与后续方向
忠实于周报与架构文档的表述,这套实现有其明确的阶段性边界:
- 周报本身声明"实现尚未完成",当周目标是设计"相当扎实"的雏形,后续一周(4 月 16 日)的计划是完成 RPC 系统初版、构建共享工作区基本 demo(支持客户端查找并打开路径、多客户端并发编辑),并提到作者将赴阿姆斯特丹与 @as-cii 当面结对推进;
- 编码层目前是 bincode,文档明确计划切换到 Protocol Buffers 以获得协议优雅演化能力;
PeerListView尚不存在,连接后自动打开第一个远程工作区是过渡行为(源码中的 TODO 注释佐证);- 服务端 headless 模式一旦启用,后续所有 CLI 命令必须保持 headless,xray_server 会显式报错拒绝混合模式。
小结
2018 年 4 月 9 日的这份周报,记录的是 Xray 协作编辑能力的"协议定调"时刻:在 CRDT 缓冲区解决了"内容合并"之后,团队用一套自研的、仅两个消息类型的能力式 RPC 系统解决了"状态同步 + 请求响应"的通道问题。其核心遗产——Servicetrait 的三关联类型模型、ServiceId作为可授权可回收的能力凭证、take_service的排他语义、按数据特征在复制/RPC/操作中继之间分配工作负载的选型原则——都完整保留在 xray_core/src/rpc 与 xray_core/src/app.rs 的实现和测试中,是理解 Xray 共享工作区从设计文档走向可运行系统的最短路径。
- 开发工具
【免费下载链接】xray
An experimental next-generation Electron-based text editor
相关推荐
DataEase 3D 地图大屏实操:从 2D 基准线到可旋转场景的调参法
DataEase 3D 地图大屏实操:从 2D 基准线到可旋转场景的调参法 一张省级销售报表,321 个地级市、每个城市 12 个月销售额,3852 个数据点。
数据分析数据可视化后端前端Ultimate Plumber实时协作:基于WebSocket的共享编辑实现
Ultimate Plumber实时协作:基于WebSocket的共享编辑实现 你是否曾在团队协作调试Linux管道命令时,因反复传输脚本文件而效率低下?是否经
开发工具Falco规则共享平台设计:社区协作系统
Falco规则共享平台设计:社区协作系统 你是否还在为Kubernetes集群中的安全规则重复编写而烦恼?是否希望能够轻松获取和分享经过实战检验的安全检测规则?
云原生运行时防护IDS应用安全
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考