作者 | 不四
来源 | 阿里巴巴中间件(ID:Aliware_2018)
每个技术人心中或多或少都有一个「产品梦」,好的技术需要搭配好的产品,才能让用户爱不释手,尤其是做一款知识服务型产品。
本文从技术架构的视角,回顾了语雀的原型、内部服务和对外商业化的全过程,并对函数计算在语雀架构演进过程中所扮演的角色做了详细的介绍。
语雀是一个专业的云端知识库,用于团队的文档协作。现在已是阿里员工进行文档编写和知识沉淀的标配,并于 2018 年开始对外提供服务。
| 回到故事的开始。
2016 年,语雀孵化自蚂蚁科技,当时,蚂蚁金融云需要一个工具来承载它的文档,负责的技术同学利用业余时间,搭建了这个文档工具。项目的初期,没有任何人员和资源支持,同时也是为了快速验证原型,技术选型上选择了最低成本的方案。
底层服务完全基于体验技术部内部提供的 BaaS 服务和容器托管平台:
Object 服务:一个类 MongoDB 的数据存储服务;
File 服务:阿里云 OSS 的基础上封装的一个文件存储服务;
DockerLab:一个容器托管平台;
这些服务和平台都是基于 Node.js 实现的,专门给内部创新型应用使用,也正是由于有这些降低创新成本的内部服务,才给工程师们提供了更好的创新环境。
语雀的应用层服务端,自然而然的选用了蚂蚁体验技术部开源的 Node.js Web 框架 Egg(蚂蚁内部的封装 Chair),通过一个单体 Web 应用实现服务端。
应用层客户端也选用了 React 技术栈,结合内部的 antd,并采用 CodeMirror 实现了一个功能强大、体验优雅的 markdown 在线编辑器。
当时仅仅是一个工程师的业余项目,采用内部专为创新应用提供的 BaaS 服务和一系列的开源技术,验证了在线文档工具这个产品原型。
2017年,随着语雀得到团队内部的认可,他的目标已经不仅仅是金融云的文档工具,而是成为阿里所有员工的知识管理平台。
不仅面向技术人员 Markdown 编辑器,还向非技术知识创作者,提供了富文本编辑器,并选择了更“Web”的路线,在富文本编辑器中加入了公式、文本绘图、思维导图等特色功能。
而随着语雀在知识管理领域的不断探索,知识管理的三层结构(团队、知识库、文档)开始成型。
| 在此之上的协作、分享、搜索与消息动态等功能越来越复杂单纯的依靠 BaaS 服务已经无法满足语雀的业务需求了。
为了应对业务发展带来的挑战,我们主要从下面几个点进行改造:
BaaS 服务虽然使用简单成本低,但是它们提供的功能不足以满足语雀业务的发展,同时稳定性上也有不足。所以我们将底层服务由 BaaS 替换成了阿里云的 IaaS 服务(MySQL、OSS、缓存、搜索等服务)。
Web 层仍然采用了 Node.js 与 Egg 框架,但是业务层借鉴 rails 社区的实践开始变成了一个大型单体应用,通过引入 ORM 构建数据模型层,让代码的分层更清晰。
前端编辑器从 codeMirror 迁移到 Slate。为了更好的实现语雀编辑器的功能,我们内部 fork 了 Slate 进行深入开发,同时也自定义了一个独立的内容存储格式,以提供更高效的数据处理和更好的兼容性。
在内部服务阶段,语雀已经成为了一个正式的产品,通过在阿里内部的磨炼,语雀的产品形态基本定型。
随着语雀在内部的影响力越来越大,一些离职出去创业的阿里校友们开始找到玉伯(蚂蚁体验技术部研究员):“语雀挺好用的,有没有考虑商业化之后让外面的公司也能够用起来?”
| 经过小半年的酝酿和重构,2018 年初,语雀开始正式对外提供服务,进行商业化。
当一个应用走出公司内到商业化环境中,面临的技术挑战一下子就变大了。最核心的知识创作管理部分的功能越来越复杂,表格、思维导图等新格式的加入,多人实时协同的需求对编辑器技术提出了更高的挑战。
而为了更好的服务企业用户与个人用户,语雀在企业服务、会员服务等方面也投入了很大精力。在业务快速发展的同时,服务商业化对质量、安全和稳定性也提出了更高的要求。
为了应对业务发展,语雀的架构也随之发生了演进:
我们将底层的依赖完全上云,全部迁移到了阿里云上,阿里云不仅仅提供了基础的存储、计算能力,同时也提供了更丰富的高级服务,同时在稳定性上也有保障。
丰富的云计算基础服务,保障语雀的服务端可以选用最适合语雀业务的的存储、队列、搜索引擎等基础服务;
更多人工智能服务给语雀的产品带来了更多的可能性,包括 OCR 识图、智能翻译等服务,最终都直接转化成为了语雀的特色服务;
而在应用层,语雀的服务端依然还是以一个基于 Egg 框架的大型的 Node.js Web 应用为主。但是随着功能越来越多,也开始将一些相对比较独立的服务从主服务中拆出去,可以把这些服务分成几类:
微服务类:例如多人实时协同服务,由于它相对独立,且长连接服务不适合频繁发布,所以我们将其拆成了一个独立的微服务,保持其稳定性。
任务服务类:像语雀提供的大量本地文件预览服务,会产生一些任务比较消耗资源、依赖复杂。我们将其从主服务中剥离,可以避免不可控的依赖和资源消耗对主服务造成影响。
函数计算类:类似 Plantuml 预览、Mermaid 预览等任务,对响应时间的敏感度不高,且依赖可以打包到阿里云函数计算中,我们会将其放到函数计算中运行,既省钱又安全。
随着编辑器越来越复杂,在 slate 的基础上进行开发遇到的问题越来越多。最终语雀还是走上了自研编辑器的道路,基于浏览器的 Contenteditable 实现了富文本编辑器,通过 Canvas 实现了表格编辑器,通过 SVG 实现了思维导图编辑器。
语雀的这个阶段(也是现在所处的阶段)是商业化阶段,但是我们仍然保持了一个很小的团队,通过 JAVAScript 全栈进行研发。底层的服务全面上云,借力云服务打造语雀的特色功能。同时为企业级用户和个人知识工作者者提供知识创作和管理工具。
语雀是一个复杂的 Web 应用,也是一个典型的数据密集型应用(Data-Intensive Application),背后依赖了大量的数据库等云服务。语雀服务端是 Node.js 技术栈。
当提到 node 的时候,可能立刻就会有几个词浮现在我们脑海之中:单线程(single-threaded)、非阻塞(non-blocking)、异步(asynchronously programming),这些特性一方面非常的适合于构建可扩展的网络应用,用来实现 Web 服务这类 I/O 密集型的应用,另一方面它也是大家一直对 node 诟病的地方,对 CPU 密集型的场景不够友好,一旦有任何阻塞进程的方法被执行,整个进程就被阻塞。
像语雀这样用 node 实现整个服务端逻辑的应用,很难保证不会出现一些场景可能会消耗大量 CPU 甚至是死循环阻塞进程的,例如以 markdown 转换举例,由于用户的输入无法穷举,总有各种可能让转换代码进入到一个低效甚至是死循环的场景之中。
在 node 刚出世的年代,很难给这些问题找到完美的解决办法,而即便是 Java 等基于线程并发模型的语言,在遇到这样的场景也很头痛,毕竟 CPU 对于 web 应用来说都是非常重要的资源。而随着基础设置越来越完善,当函数计算出现时,node 最大的短板看起来有了一个比较完美的解决方案。
阿里云函数计算是事件驱动的全托管计算服务。通过函数计算,您无需管理服务器等基础设施,只需编写代码并上传,只需要为代码实际运行所消耗的资源付费,代码未运行则不产生费用。
把函数计算引入之后,我们可以将那些 CPU 密集型、存在不稳定因素的操作统统放到函数计算服务中去执行,而我们的主服务再次回归到了 I/O 密集型应用模型,又可以愉快的享受 node 给我们带来的高效研发福利了!
以语雀中遇到的一个实际场景来举例,用户传入了一些 html 或者 Markdown 格式的文档内容,我们需要将其转换成为语雀自己的文档格式。
在绝大部分情况下,解析用户输入的内容都很快,然而依然存在某些无法预料到的场景会触发解析器的 bug 而导致死循环的出现,甚至我们不太敢升级 Markdown 解析库和相关插件以免引入更多的问题。
但是随着函数计算的引入,我们将这个消耗 CPU 的转换逻辑放到函数计算上,语雀的主服务稳定性不会再被影响。
| 除了帮助 Web 系统分担一些 CPU 密集型操作以外,函数计算还能做什么呢?
在语雀上我们支持各种代码形式来绘图,包括 Plantuml、公式、Mermaid,还有一些将文档导出成 PDF、图片等功能。这些场景有两个特点:
他们依赖于一些复杂的应用软件,例如 Puppeteer、Graphviz 等;
可能需要执行用户输入的内容;
支持这类场景看似简单,通过 process.exec
子进程调用一下就搞定了。但是当我们想把它做成一个稳定的对外服务时,问题就出现了。这些复杂的应用软件可能从设计上并没有考虑要长期运行,长期运行时的内存占用、稳定性可能会有一些问题,同时在被大并发调用时,对 CPU 的压力非常大。
再加上有些场景需要运行用户输入的代码,攻击者通过构建恶意输入,可以在服务器上运行攻击代码,非常危险。
在没有引入函数计算之前,语雀为了支持这些功能,尽管单独分配了一个任务集群,在上面运行这些三方服务,接受主服务的请求来避免影响主服务的稳定性。但是为了解决上面提到的一系列问题还需要付出很大的成本:
需要维持一个不小的任务集群,尽管可能大部分时间都用不上那么多资源。
需要定时对三方应用软件进行重启,避免长时间运行带来的内存泄露,即便如此有些特殊请求也会造成第三方软件的不稳定。
对用户的输入进行检测和过滤,防止黑客恶意攻击,而黑客的攻击代码很难完全防住,安全风险依旧很大。
最后语雀将所有的第三方服务都分别打包在函数中,将这个任务集群上的功能都拆分成了一系列的函数放到了函数计算上。通过函数计算的特点一下解决了上面的所有问题:
函数计算的计费模式是按照代码实际运行的 CPU 时间计费,不需要长期维护一个任务集群了。
函数计算上的函数运行时尽管会有一些常驻函数的优化,但是基本不用考虑长期运行带来的一系列问题,且每次调用之间都相互独立,不会互相影响。
用户的输入代码是运行在一个沙箱容器中,即便不对用户输入做任何过滤,恶意攻击者也拿不到任何敏感信息,同时也无法进入内部网络执行代码,更加安全。
| 除了上面提到的这些功能之外,语雀最近还使用 OSS + 函数计算替换了之前使用的阿里云视频点播服务来进行视频和音频的转码。
由于浏览器可以直接支持播放的音视频格式并不多,大量用户上传的视频想要能够直接在语雀上进行播放需要对它们进行转码,业界一般都是通过 FFmpeg 来对音视频进行转码的。
转码服务也是一个典型的 CPU 密集型场景,如果要自己搭建视频转码集群会面临大量的资源浪费,而使用阿里云视频点播服务,成本也比较高,而且能够控制的东西也不够多。
函数计算直接集成了 FFmpeg 提供音视频处理能力,并集成到应用中心,配合 SLS 完善了监控和数据分析。语雀将音视频处理从视频点播服务迁移到函数计算之后,通过优化压缩率、减少不必要的转码等优化,将费用降低至之前的 1/5。
从语雀的实践来看,语雀并没有像 SFF 一样将 Web 服务迁移到函数计算之上(SFF 模式并不是现在的函数计算架构所擅长的),但是函数计算在语雀整体的架构中对稳定性、安全性和成本控制起到了非常重要的作用。总结下来函数计算非常适合下面几种场景:
对于时效性要求不算非常高的 CPU 密集型操作,分担主服务 CPU 压力。
当做沙箱环境执行用户提交的代码。
运行不稳定的三方应用软件服务。
需要很强动态伸缩能力的服务。
在引入函数计算之后,语雀现阶段的架构变成了以一个 Monolith Application 为核心,并将一些独立的功能模块根据使用场景和对能力的要求分别拆分成了 Microservices 和 Serverless 架构。
应用架构与团队成员组成、业务形态息息相关,但是随着各种云服务与基础设施的完善,我们可以更自如的选择更合适的架构。
为什么要特别把 Serverless 单独拿出来说呢?还记得之前说 Node.js 是单线程,不适合 CPU 密集型任务么?
由于 Serverless 的出现,我们可以将这些存在安全风险的,消耗大量 CPU 计算的任务都迁移到函数计算上。它运行在沙箱环境中,不用担心用户的恶意代码造成安全风险,同时将这些 CPU 密集型的任务从主服务中剥离,避免出现并发时阻塞主服务。
按需付费的方式也可以大大节约成本,不需要为低频功能场景部署一个常驻服务。所以我们会尽量的把这类服务都迁移到 Serverless 上(如阿里云函数计算)。
语雀这几年一步步发展过来,背后的技术一直在演进,但是始终遵循了几条原则:
技术栈选型要匹配产品发展阶段。产品在不同的阶段对技术提出的要求是不一样的,越前期,对迭代效率的要求越高,商业化规模化之后,对稳定性、性能的要求就会变高。不需要一上来就用最先进的技术方案,而是需要和产品阶段一起考虑和权衡。
技术栈选型要结合团队成员的技术背景。语雀选择 JavaScript 全栈的原因是孵化语雀的团队,大部分都是 JavaScript 背景的程序员,同时 Node.js 在蚂蚁也算是一等公民,配套的设施相对完善。
最重要的一点是,不论选择什么技术栈,安全、稳定、可维护(扩展)都是要考虑清楚的。用什么语言、用什么服务会变化,但是这些基础的安全意识、稳定性意识,如何编写可维护的代码,都是决定项目能否长期发展下去的重要因素。
本文作者:
何翊宇,花名不四,高级前端技术专家,现就职于蚂蚁金服体验技术部,语雀产品技术负责人。2011 年开始专注在 Node.js 与 Web 研发领域,负责过内部的 Node.js 的模块管理系统和中间件服务等基础设施,也做过 Node.js Web 框架的研发和开源。同时持续在使用 Node.js 进行产品研发,先后负责过淘宝时光机、天猫搭建渲染服务以及语雀等产品。开源爱好者,Koa.js 和 Egg.js 核心开发者,cnpm 中国镜像维护者。