block-proto v0.0.12
简介
koko-server 在服务端配合 koko 完成协同能力,它的 3 大核心功能很简单明了:
- 保存 block 变更
- 读取 block 内容
- 订阅 block 的变化
koko-server 的功能本身很简单,因此,该仓库旨在用 node-js 的代码呈现一下逻辑,主要供学习探讨,不建议直接在 production 环境作为使用
数据协议
koko 在前后端交互上,做了必要的约定
案例:
读取协议
// 客户端发起请求
const loadRequestBody = {
// 标识一次请求,由客户端生成,如果用 websocket 协议,是必须的,以便与响应关联上
requestId: "e29882ab6ce3445f847b24e9784d197e",
// 标识客户端 id,页面每次打开会生成一个
clientId: "0308ac9cc10a41fc91844d117a3a91be",
// 以数组承载多个 block 查询
body: [
{
// pointer 里可指明查询的信息,比如 id,也可包括其他辅助信息,例如 spaceId
pointer: {
// 查询的 blockId
id: "de98096af0ac42b19d6087cc4b6ba134-00b",
},
},
{
pointer: {
// 查询的另一个 blockId
id: "082d4495f2c54252bbba4a623708f9d0-00b",
},
},
],
};
// 服务端响应
const loadResponseBody = {
status: 0,
message: "",
data: {
// block 类型,这里增加一次,便于服务端扩展分类
block: {
// 返回的 block 信息,与请求的对应
"de98096af0ac42b19d6087cc4b6ba134-00b": {
// 可选,比如 对 block 的权限界定
role: "manager",
// 数据示例
value: {
id: "de98096af0ac42b19d6087cc4b6ba134-00b",
version: 26,
type: "text",
properties: { text: "world" },
},
},
"082d4495f2c54252bbba4a623708f9d0-00b": {
role: "reader",
// 数据示例
value: {
id: "082d4495f2c54252bbba4a623708f9d0-00b",
version: 26,
type: "text",
properties: { text: "hello" },
},
},
},
},
};
保存协议
const saveRequestBody = {
requestId: "d77f4d53117a4a94a3c971591d8a212f",
clientId: "0308ac9cc10a41fc91844d117a3a91be",
transactions: [
{
// 由客户端随机生成一个 transaction id
id: "3a35264ea26c4e73bd5b2853447c7aa0",
// 具体操作可以有 1 个,或者多个;操作 command 包括 set、update、listBefore、listAdd、listRemove 5 种,囊括了增删改的范畴
operations: [
{
// 改动表的相关信息
pointer: {
// 需要修改的 blockId
id: "de98096af0ac42b19d6087cc4b6ba134-00b",
},
// 修改协议 command,set 表示设置
command: "set",
// 修改路径
path: ["properties", "user"],
// 协议参数示例
args: "xiaoming",
},
{
pointer: {
id: "de98096af0ac42b19d6087cc4b6ba134-00b",
},
// 修改协议 command,update 表示设置 kv 合并进入
command: "update",
path: ["properties"],
// 协议参数示例
args: {
modified: "2022-05",
},
},
],
},
],
};
// 响应
const saveResponseBody = {
status: 0,
};
关于 5 类 command,下文中会有具体释义
订阅(取消订阅)协议
// 请求
const subscribeRequestBody = {
requestId: "c5f4d996fdf44696b40bf275a76b7318",
// action 表示请求的类型,subscribe 表示订阅,unsubscribe 表示取消订阅
action: "subscribe",
// 可同时多个订阅,比如案例里订阅了 3 个
batchEvents: [
// version 表示订阅类型,后面为订阅的 blockId;表示对特定 block 产生变化的订阅
"version:3be6e8ebff8d4ef7be074473dedece07-00b",
"version:99ddc78d57dc453bb8bfae64b4d2539f-00b",
// custom 表示自定义类型,hello 代表具体子类,该类型非服务端自动触发
"custom:hello",
],
};
// 响应
const subscribeResponseBody = {
requestId: "",
};
// 响应-失败
const subscribeResponseBodyFail = {
requestId: "",
code: 1,
};
// 注:对于所有响应,客户端判断失败的标准为 → 「status 和 code 有任意一个存在且非 0」
发送自定义事件
我们知道 block 内容变化后,服务端会察觉并推送给订阅者,因此推送行为由服务端触发; 当我们需要由客户端触发一些推送,可以借助自定义事件
const triggerCustomEventBody = {
requestId: "c5f4d996fdf44696b40bf275a76b7318",
action: "sendCustomEvent",
// 自定义事件名称
event: "custom:hello",
eventType: "custom",
// 自定义内容,透传给订阅者
body: {},
};
订阅通知(下行)协议
当订阅的内容触发时,由服务端下行通知 (比如基于 socket)
// version 类的通知
const versionPushBody = {
// type 代表通知的大类别,version 类属于 content
type: "content",
// event 对应我们之前订阅的内容
event: "version:93ffa93d0e0242afbde2b5b41bb9448b-00b",
// 表示该 block 目前最新的版本号,用于对比本地新旧
body: { version: 4 },
// 表示是否由自己触发的这次变更
fromSelfClientId: false,
};
// 自定义事件(custom) 类的通知
const custionPushBody = {
type: "custom",
event: "custom:hello",
// body 可以是任意内容,由触发者提供
body: {},
};
5 种 command
在保存一个 block 的时候,我们提供 5 种 command:
- set 设置
- update 部分更新
- listBefore 在列表里添加(前)
- listAfter 在列表里添加(后)
- listRemove 在列表里移除
set
类似 loadash.set,指定 path,设置为指定 value,例如
例如,设置前数据为:
{
"name": "xiaoming",
"age": 20
}
我们进行如下的 set 命令:
{
"command": "set",
"path": ["friends"],
"args": ["zhangsan", "lisi"]
}
设置后的数据为:
{
"name": "xiaoming",
"age": 20,
"friends": ["zhangsan", "lisi"]
}
set 可以覆盖原有的数据,如果路径不存在,支持递归创建
update
update 支持对原有的对象进行 kv 的更新,类似 js 中的 Object.assign 的效果
例如,设置前数据为:
{
"name": "xiaoming",
"age": 20,
"properties": {
"level": 1,
"rate": "10%"
}
}
我们进行如下的 update 命令:
{
"command": "update",
"path": ["properties"],
"args": {
"level": 2,
"score": 100
}
}
设置后的数据为:
{
"name": "xiaoming",
"age": 20,
"properties": {
"level": 2,
"score": 100,
"rate": "10%"
}
}
如上述示例,update 只能针对 map 类型的数据
listBefore/listAfter
listBefore/listAfter 支持向列表里追加 item
例如,设置前数据为:
{
"name": "dad",
"children": ["x1", "x2", "x3"]
}
我们进行如下的 listBefore 命令 (listAfter 类似):
{
"command": "listBefore",
"path": ["children"],
"args": {
"before": "x2",
"id": "yyyyyy"
}
}
设置后的数据为:
{
"name": "dad",
"children": ["x1", "yyyyyy", "x2", "x3"]
}
注意的是,listBefore/listAfter 使用时,有以下的约定:
- 只能操作数组
- 数组的 item 需要是字符串,并且都不相同,否则可能插入位置不符合预期
- before 或者 after 指定的参照 item,如果实际不存在,则会放在第一个/最后一个
listRemove
支持从列表里移除 item
例如,设置前数据为:
{
"name": "dad",
"children": ["x1", "x2", "x3"]
}
我们进行如下的 listRemove 命令:
{
"command": "listRemove",
"path": ["children"],
"args": {
"id": "x2"
}
}
设置后的数据为:
{
"name": "dad",
"children": ["x1", "x3"]
}
注意的是,listBefore/listAfter 使用时,有以下的约定:
- 只能操作数组
- 数组的 item 需要是字符串,并且都不相同,否则删除的不符合预期
- 如果删除的内容不存在,则不会引起任何变化
聊一聊 command 的设计思路
实际上我们如果使用 set 命令能满足一切能力,但并不推荐这么使用
update 和 list 系列命令,更像是一种增量操作,我们更推荐这么做,这样在协同冲突的时候,能尽量保持双方的意图
demo server 的模块划分
本仓库用 nodejs 实现了一个服务端,遵从 koko 的前后端协议
各个模块的主要功能如下
- koa-inst:基于 koa 实现了一个 server
- midware:负责协议的包装和解析
- netapp:处理请求并管理连接的 clients
- store:实现存储与读取,应用 commands
代码地址 : https://h1.static.yximgs.com/udata/pkg/IS-DOCS-MD/d/koko/koko-base-url.v1.js 使用前配置 baseURL,否则回退到 gifshow,例如:
baseURL = "http://localhost:8082";