一个好的技术一定是其核心思想处处透着简单。大道至简。erlang 的世界观可以用 6 个函数涵盖,这六个函数像乐高积木一样,衍生出一个个的复杂但精美的软件系统;区块链技术核心就三点:p2p 网络,数字签名和共识算法,却诞生了人类世界最叹为观止的一场实验;在 minecraft 里,大家搭建出多姿多彩,美轮美奂的宏大世界所使用的,不过是小小的的方块。
作为一个没事瞎琢磨的中老年程序员,喜欢咬文嚼字的未秃顶理工男,喜欢上一个东西,就总想像剥洋葱那样,把其外表的纷繁复杂一点点祛除,探究内在的机理。
Notion 也不例外。当我在使用产品的过程中慢慢感受和理解 Notion 的机制后,我常常胡思乱想:如果让我从头做个 Notion 这样的软件,该如何入手?
今早起床例行冲凉,大脑还沉浸在昨夜的美梦中,目光呆呆地盯着墙上那经花洒喷头喷出的,拍到我头上又反弹到墙上的无数水珠。水珠被引力的指挥着,不断向下,不断牵引,汇集成一道道细流。莫名其妙地,我想起了 inode,仿佛阿基米德上脑,我 Eureka! 着就冲出了浴室。
在 iPad 上,我画了这样一个图:
还有一些其他(有点乱糟糟)的草图(就不拿出来丢人现眼了)。顺着这些图想下去,我尝试着回答了自己对于 Notion 实现上的一些问题。
一些粗浅的想法
首先,什么是 block?
在我看来,block 的概念非常类似 unix 文件系统中的 inode,但比 inode 丰富得多。一个 block,包含两部分内容:content 和 attributes。
content 是用户在这个 block 里输入的内容 — 但不全是。我很怀疑 Notion 内部有一套类似 Markdown 的标记语言(但从 API 上没有体现出来),甚至就是一个 Markdown 的超集,通过一个 parser,来将 block 的 content 渲染成最终的展现形式。比如说,page block 就是一个对子页面的引用,渲染出来就是一个点击进入子页面的链接。
block 有一些属性(attribute)或者元数据(metadata)。里面涵盖 block 的颜色,样式,父页面的引用,等等。
一个 block 只能属于一个页面(page)。如果需要同样内容的 block 被包含在不同的页面,只能通过复制。但这种复制产生的是一个完整的副本。
block 是如何组织成 page 的?
一个页面下的所有的 block 通过 btree(或者其他类似的结构)组织起来。btree 可以在插入效率和有序遍历间达到一个不错的平衡 — 在 Notion 里,我经常需要在文档中间插来插去,或者调整段落的顺序,如果用列表这样的结构表述,效率太低。
如果使用 btree,那么每个 block 都有一个唯一可变的序号,代表其在文章中的位置。添加或者移动 block 时按照这个序号来插入。如果用户把一个 block 拽到两个 block 之间,那么这个 block 先被删除,然后再插入到两个已有的 block 之间(比如 block 1 和 block 2 之间,那么新的序号是 1.5)。
因为 page 可以支持多栏的版式,所以 block 仅仅有一个维度的序号还不够,它需要两个维度的序号,水平方向和垂直方向。下图展示了按照序号所列的插入顺序插入这些 block 之后,它们的序号是如何变化的:
最终,page 在渲染的时候,按照序号的顺序遍历 blocks,然后依次调用 block 的 render 方法,把 block 渲染出来 — 这个渲染的结果可能还是个中间过程,因为 page 还有自己的样式和 attributes,最后再附上 page 自己的样式,得到最终在不同端上的渲染结果。
page 是如何多人编辑的?
在 Notion 里一个页面可以分享给多人,共同编辑。这种协同并不是一个新鲜事,google docs,trello 等工具早已实现了富文本下多人协同互不干扰的方法,其思想基础就是 Operational Transformation。因为 Notion 引入了 block 的概念,它做协同的方法可以来的简单很多:在页面一级,只有调整 block 的位置才需要使用 OT 协同。一个用户调整页面的结构,并不会影响另一个用户修改受影响的 block 本身。而两个用户之间同时对一个 block 进行修改,也需要做 OT。
当然,因为 block 的粒度很细,整个多人协作编辑时不用 OT,使用乐观锁(optimistic locking)也可以达到目的,毕竟产生冲突的机会不多。如果有人在 page 一级修改 btree 相关的结构(添加新 block,移动 block,删除 block),加一个乐观锁,没拿到锁的人重试,甚至取消操作即可。在 block 级别,我们可以直接对正在编辑的 block 加锁,使得在修改 block 的时候,任何其他人都无法编辑或者删除这个 block。google sheet 就使用类似的策略:当你修改一张表的某个单元格的时候,别人无法修改这个单元格。我认为这样满足大部分场景的需求,且足够简单。
而某个用户对文档某部分的编辑,可以通过 CQRS(Command and Query Responsibility Segregation)/ Event Sourcing 的方式来在各个客户端,各个共享用户间进行同步:用户的行为触发事件,各个平台接收事件,然后本地进行事件的处理和状态的更新。只要大家的更新算法一致,事件的顺序保持一致,那么所有人可以得到同样的状态。
page 如何做版本控制呢?
我对 Notion 本身的页面历史是如何生成的,还没有找到太多的规律 — 似乎历史的生成跟时间有关(比如定时器),但又不是完全严格按照时间。在修改页面后关闭页面再打开,甚至关闭 Notion 再打开,都不一定会生成新的版本。我感觉新的版本可能需要多个条件一齐算出一个分数,然后当分数超过某个阈值才触发,比如:
- 新生成了很多内容 / block
- block 累积了很多新版本
- 草稿持续了一段时间
- 某些事件被触发了:关闭文档,关闭 app,计算机休眠等
不管怎样,我觉得版本控制会在两个层次都发生:block 和 page。每个 block 各自维护自己的版本,而 page 的版本就像 git 的 commit 一样,是所有 block 当前的版本的一个快照。
我的想法究竟对不对?
沿着上面的抛出问题和解答问题的方法,我们还可以进一步去研究 database 的组织,本地数据的存储,服务端数据的存储等一系列问题,不过这些内容我就不详细写出来了。无论对错,这都是一个很好的思维训练,而我出于对很多产品的好奇,经常会做这样的思考。
如果非要验证自己的想法对不对,把这样的想法实现出来是最好的方法。当然,我们还可以通过观察 Notion API 的行为,来进一步分析其内部的实现。我这里简单抛砖引玉一下。
Block 的确有版本
如果你同时在 web / desktop app / mobile app 上打开某篇文章,然后在一处修改一下,修改立刻展示在其他平台。从下面的截图可以看出:
每次修改的时候,web 立即得到通知,然后去主动 post 到 Notion 的 syncRecordValues REST API 上。即使仅仅修改一两个字,这个 API 每次都返回完整的 block 的内容。从这里我们能确定的是每次修改,block 的版本一直在增长。
API 并不是描述事件,而在描述状态
其实从上面的 API 我们大概就有这样一个预期,但我想尝试更多的情况。我把一个两栏数据拖拽成一栏数据,通过在 web 上观看分栏动作产生的 syncRecordValues API,发现,Notion 的表述方式和我想的不太一样(可能我想的太简单),显得更复杂一些,API 似乎没有使用 CQRS 的思想,去描述事件,而是描述了事件之后对应的结果:
这种方法的弊端是随着数据的增多,API 里要描绘的状态就会越来越多,从而拖累整个系统运行的效率。
然而并没有用 btree 来管理页面里的 blocks
上一个 API 的截图没法展开那个 type: page
的内容,所以我又做了一个简单的实验,创建了一个 hello world
的 block,然后将其拖拽到上一个 block 上面。Notion API 返回这样的数据:除了 hello world
block 本身,还有整个 page 的 block 的列表:
ah-oh,出人意料,自己被 piapia 打脸 — 所以 Notion 并没有为 page 使用什么复杂的数据结构,每次在 page 里插入 block 的时候,Notion 都会发回整个 page 的 block 的列表,然后客户端按照这个列表更新。窃以为 Notion 在此处的处理并不高明,太多浪费的,反复传递的数据,而列表的插入,删除,查找效率都是很低的,是 O(N)。当然,大多数情况,N < 100 时用户可能感知不到,而一篇文章很少有超过 100 个 block 的。但 Notion 还是为此付出了很多本可避免的服务器端的资源和网络带宽,而由此带来的数据库更新代价很可能也不小。大家都是程序员,如果能用更好的算法去解决问题,为什么要用这样看上去还很原始的方案呢?
贤者时刻
很多时候,技术上的能力不一定会带来商业上的成功;而成功的商业背后不一定有强大或者高超的技术为保证。产品做得好,用户(客户)喜欢,并为之付费,这才是王道。而有了商业上的成功,最终一定会带来技术上的成功 — 因为,技术和优秀的技术团队可以用钱堆出来。