「全栈 Web 开发」在字节跳动的实践
👆 点击蓝字关注我们,不错过后续精彩内容推送~
2021 年 10 月 27-28 日的稀土开发者大会上,字节跳动 Web Infra 正式发起 Modern.js 开源项目,并第一次正式介绍了 Modern.js。
2022 年 1 月 7 日,在第十届全球软件案例研究峰会中。孔嘉聪分享了《全栈 Web 开发在字节跳动的实践》。分享围绕 Modern.js 展开,并专注于服务端的能力及收益,从不同的角度进一步介绍了 Modern.js。本文是该分享的文字版本,期待大家有所收获。
大家好,我是来自字节跳动 Web Infra 的孔嘉聪,在字节跳动,我们部门负责打造「Web 技术中台」和发展「前端研发体系」。
2021 年 10 月底,字节跳动 Web Infra 正式发起 Modern.js 开源项目,第一次正式介绍 Modern.js。
在那次的分享中,我们普及了现代 Web 开发范式,也介绍了在这种新范式下,Modern.js 提供的能力和带来的收益。
1. Modern.js 的机遇与职责
今天的分享依旧围绕 Modern.js 展开,主要分为四部分。我们首先介绍 Modern.js 出现的原因。
然后会我们专注于 Modern.js 中的服务端部分,列举 Web 应用中常见的服务端需求。这里的 Web 应用不包含纯 Node.js 应用。
接着,我们介绍 Modern.js 中是如何利用一体化开发去解决需求的。
最后会回顾一下在 Modern.js 开源之前,我们在字节内部遇到的典型业务场景。
1.1 JS新时代带来的机遇
当前业界的两个背景,是支撑与催化 Modern.js 出现的主要原因。
其一是随着 Serverless 的出现,前端开发者正在向全栈发展。这是令人兴奋的事情,因为我们可以从头到尾的去做我们想做的事情。但即使如此,前端开发者在参与全栈开发依然像这张图一样,在前端部分有丰富的知识和经验,而在服务端部分却相对薄弱。
https://css-tricks.com/ooooops-i-guess-were-full-stack-developers-now/
这让我们确信,的确需要一个框架,能够进一步助力前端开发者开发全栈应用。
其二是 Javascript 进入新的阶段。近两年,开发工具又出现新一轮更新换代的征兆,有人把这种趋势称作「JS 的第三纪元」。
https://www.swyx.io/js-third-age/
其中很重要的一点,是各个框架都有明确的职责范围。这种趋势带来的结果,就是工具层的合并。意思是从「很多工具做同一件事」向「同一种工具做很多事」发展,例如 Deno 整合了测试、Lint、Bundle。
这一趋势允许 Modern.js 框架在有限的范围内,为应用选择,并提供完整的方案。
1.2 Modern.js的职责与愿景
我们知道,完整的应用除了 UI 框架,还需要 Node.js CLI 和应用运行时的服务端。前者负责应用工程化相关工作,后者托管应用产物与其他请求逻辑。
然而集成 CLI 功能、开发服务端都是非常繁琐,不同类型的应用又不一定能够复用,需要重头再来。
Modern.js 的最大愿景就是希望为前端开发者屏蔽这些内容,让开发者能专注于产品本身,成为产品开发者。当然其中也包括在「全栈开发」时能变得足够简单。
最终我们希望做一个「渐进式」的应用框架,不论是在 Node.js CLI 还是服务器端,开发者一开始可以不使用 Modern.js 的任何功能,如 CSS In JS、TS、路由、SSR。只需要引入 src/App.jsx 就可以自包含的在开发、生产环境运行。
当需要更复杂的功能时,可以直接在框架内找到解决方案。框架从整体上考虑需求,更好的结合各个部分,产生 1 + 1 > 2 的效果。在框架内无法找到合适的方案时,也可以使用自己的方案。
在这样的理念下,有了目前 Modern.js 的设计。
Modern.js 包含了一个极简的核心、一体化 Web Server 以及完善的插件体系。
我们提供了由框架与内置插件结合的三种「标准工程方案」,开发者也可以在标准工程方案上,通过追加或者替换插件实现「自定义工程方案」。
Modern.js 几乎所有的能力都是通过插件实现的,并且任何开放给内置插件的功能,也同样可以在自定义插件中使用。
2. Web 应用常见的服务端需求
到目前为止,我们大致介绍了 Modern.js 出现的原因、愿景和设计思路。
接着我们着重介绍一下在 Modern.js 中,一体化开发的部分。首先我梳理了 Web 应用在服务端有代表性的需求。
2.1 Web Server
Web Server 指应用在本地、生产环境运行 Web 应用时需要的服务器。常见的情况下,在本地开发会使用 webpack-dev-server、在生产环境会使用 express/koa 等 Node.js 框架搭建的服务。
2.1.1 入口与路由
最常见的 Web Server 需求就是「路由」。
在多页应用中,入口对应的页面都应该拥有独立的访问路由。
通常会有以下需求:自定义路由前缀,自定义每个入口的路由,同一入口能通过不同路由访问,不同入口能通过同一个路由、不同的请求条件(例如 user-agent
)访问,路由专属的服务端响应头以及动态服务端路由。
除了入口和路由的关系,每个入口同时又可以是 SPA 页面。会存在以下内容需要考虑,例如服务器端如何正确匹配到 SPA 路由对应的入口,单入口多路由、动态路由在浏览器端运行的一致性,以及随着应用迭代,页面路由结构的切换。
2.1.2 运行前置与同构
除了「路由」这类基本需求外,业界的一个发展趋势,就是让代码的运行尽可能前置,优先在编译时运行,其次在服务器端运行,最后在浏览器里运行。例如下图中的 SSG/SSR/CSR。
在 SSR 中,首先要关注的是服务端渲染数据的重用以及渲染的一致性。其次需要考虑例如 Helmet、Loadable、style-components 等库在不同环境的协作运行。
如果要实现运行时能够手动降级(例如 QPS 超过某一阈值)的需求,那就需要保证 SSR、CSR 同构,并且能快速切换。在性能方面,需要调研流式渲染、渲染缓存、边缘渲染。
在 SSG 中,有涉及到渲染与编译结合,编译与运行时数据隔离,再往后又会遇到按照入口渲染,或者 SSG/SSR/CSR 混合渲染的需求。
2.1.3 通用服务与定制逻辑
另外,Web 应用在服务执行逻辑上也同样有许多需求。
通用需求中,例如基于 UA 的自动 polyfill,分发不同打包方式的 Javascript 资源。在我们公司内也会有微前端注入、i18n 注入等。
每个应用也会有独立的运行时需求,像鉴权,参数预处理,路由重定向,机器人访问拦截等。
2.2 API 服务
另外,部分 Web 应用,通常是中后台应用,有应用专属的 API 服务。比较常见的类型是 BFF 函数,也有趋近于完整 Node 应用的 API 服务设计。
搭建服务时,首先需要考虑如何在开发环境、生产环境运行服务,与 Web Server 如何共同启动、热更新,以及运行框架的选择与 API 写法设计。
在易用性方面,要考虑函数参数类型校验,响应数据结构定义,自动选择最佳请求方式(进程间调用、内网 ip 调用)等问题。
最后,针对使用场景也需要额外支持,实时场景下是否可以使用 WebSocket,或是使用跟客户端结合更紧密的模式:GraphQL(Apollo 模式)。
2.3 应用部署
应用部署也是算是服务端的需求之一。
在现代 Web 开发中,很多优化是要靠「平台层面」才能更好实现的需求。在字节内部,这些问题由 Modern.js 和前端部署平台协作解决。
2.3.1 一键预览
一键预览功能是在部署需求中重要的部分。这里借用了 Vercel 官网的图片,可以看到 Preview 是它最重要的三大要素之一。
一键预览可以用于功能对比、验收、提测、Bug 回溯等。通常需要考虑如何在本地完成部署,预览部署与生产部署功能是否对齐,专属预览链接生成等问题。
2.3.2 应用部署优化
另一需求是前端应用的部署优化。如果前端部署平台能够认识产物,支持把 Web、SSR、API 不同部分等拆分,就可以在最适合的容器中部署,提供更安全稳定的运行环境。
在部署优化需求中,必须考虑框架层面和平台层面如何协作,产物标记,请求转发等问题。
图上是我们部门同学在 GMTC 分享的内容,介绍了在字节内部如何快速部署一个 SSR 应用。
3.Modern.js 的一体化开发
我们刚刚梳理了 Web 应用中常见的服务端需求,以及实现需求需要的成本。其实对很多的前端开发者来说,这部分是薄弱的,是很难展开整套工作的。即使花费大量时间完成了服务端的建设,也可能成为应用的专属服务器。
Modern.js 从一开始设计就考虑了这些因素,目的就是让前端开发者能够更简单的开发服务端逻辑。我们提供顶层 API 支持和一致的抽象,避免成为某类业务的「专用服务器」,达到节本提效的目的。接下来我们看看在 Modern.js 中,是如何通过一体化开发的方式来应对上述需求的。
3.1 自动路由
先说一下 Modern.js 中是如何自动处理路由的。
3.1.1 入口与服务端路由
Modern.js 的 Node.js CLI 以入口目录结构与配置文件为基础,按约定生成路由协议。在用户请求时,服务端会根据协议,匹配正确的处理逻辑。
路由部分的需求,都可以通过路由协议来解决。例如两条指向相同 HTML 文件的路由,就可以解决多路由访问同入口的问题。定义了「头部路由」的匹配规则,就能允许同一路由按需访问多个入口。
另外,服务端会根据路由协议计算出正确的 basename,并下发浏览器端,保持 SPA 页面的运行一致性。
3.1.2 路由模式切换
另外,Modern.js 中,提供了一系列入口规范,并且内置支持了单入口、多入口等。只要简单的修改入口文件,就可以在 MPA/SPA、基于文件/代码的路由之间快速切换。
在这种模式下,即使在产品层面发生页面组织结构的变化,开发者也无需关心路由的内部实现,只要对这个项目做些小调整,就能实现传统开发中需要很多配置和样板代码,或是需要不同脚手架才能实现的效果。
3.2 渲染一体化
接下来我们说说 Modern.js 中是如何通过一体化解决渲染模式上的需求。
3.2.1 同构渲染
Modern.js 提供的所有 API 都是服务端与浏览器端同构的。
例如有类似 useEffects 这样的 Runtime Hook,叫做 useLoader。它会自动复用 SSR/SSG 数据,并在错误时降级。在浏览器端,useLoader 就相当于 useEffects。
因此,开发者只需要添加很少的代码,就可以切换使用框架提供的各种渲染模式,并得到不同模式带来的收益。如打开或关闭 server.ssr 配置就可以切换 CSR、SSR 两种模式。
同样,在配置文件中打开 output.ssg
也能直接开启编译时渲染。
这就是通过 Runtime API 与 Server 结合,一体化开发带来的优势。这种设计下,不论是运行前置,还是存量业务从 CSR 迁移到 SSR,或是一键降级都可以安全快速的实现。
3.2.2 按需渲染
SSR/SSG 同样会标记在路由协议中,因此渲染模式也可以按入口决定。Node.js CLI 能够识别这份协议完成编译时渲染。其余的页面,也会在协议中标记出是否为 SSR,服务端在运行时会选择最佳的执行逻辑。
在字节内部,部署平台提供的服务器也运行了相同的 Node.js 逻辑,它会将匹配到的 SSR、API 路由的请求转发到下游的 FaaS 函数中。此时,用户无需任何运维,就得到了自动扩缩容的能力。这就是典型的框架层面与平台层面结合得到的优化。
3.2.3 混合渲染
目前 Modern.js 支持全局 SSG/SSR、局部 CSR。例如页面的头部是内容固定的,就可以在编译时优先渲染,而推荐列表的部分可以在浏览器端完成请求与渲染。
我们也在探索更复杂的渲染模式。如全局 CSR + 局部 SSR/SSG,以及 Server Component 的能力后续会逐步加入。
3.3 前后端一体化
Modern.js 中,前后端逻辑可以共同开发调试,包括 API Server 与 Web Server。
3.3.1 一体化 API 调用
在 Modern.js 中可以像调用普通函数文件一样访问 api/ 目录下的 BFF 函数。框架基于 BFF 函数的路径、参数等自动生成 API,并在渲染时请求,开发者完全不需要了解其中的网络细节。
此时,前后端可以共享数据类型,开发者能享受到函数调用时完整的 Lint 与代码补全功能,框架也能选择最佳的调用方式进行请求。
在 BFF 的函数定义上,Modern.js 做了额外的约束,我们不允许自由定义函数的入参(即使这的确非常方便)。因为一旦这样做,产出的路由必然由「 私有协议 」进行调用,而无法实现任意的 RESTful API。
当该服务仅用于应用本身时不存在问题,但「不标准的接口定义」无法融入更大的体系,会导致其他系统也需要遵循「私有协议」。另外,在项目 API 服务需要抽离为独立服务时,也非常不方便其他应用接入。
Modern.js 中也提供了类型友好的函数定义方式,可以通过 Type Schema 实现运行时参数校验,提供标准响应格式。
3.3.2 渐进式的 API 服务
API 服务的设计也是渐进式的。应用刚起步时,可能只需要一个函数,将远端的数据聚合、裁剪。
应用迭代过程中,出现了例如鉴权的需求,Modern.js 支持用户在钩子文件中定义中间件,通过自定义逻辑来解决这类需求。
随着业务继续迭代,函数写法的文件结构已经无法很好的管理代码。Modern.js 也支持使用框架写法来启动 API 服务。如上图结构中,开发者可以使用 Egg 本身的能力。
3.3.3 定制 Web Server
Web Server 的需求也可以归为前后端一体化的一部分。
Modern.js 内置支持了部分 Web Server 的通用需求,例如静态资源差异化分发,以及根据 UA 的自动 polyfill。使用这些功能时,开发者只需要简单的进行配置,或是引用某些插件。
对于非通用的需求,Modern.js 也支持在 server 目录的钩子文件里,对框架自带的 Web Server 添加逻辑,和 API Server 的钩子文件一样,可以自由添加中间件,例如实现权限识别和重定向。
3.4 可拔插的应用结构
在字节跳动内部,会遇到希望从 Node.js 应用扩展为全栈应用的场景,也有需要将全栈应用中 API 服务部分抽离出去作为 Node.js 应用单独部署的场景。
为了解决这类应用边界变化的问题,在 Modern.js 应用中, api/
src``/
都是可拔插的,在这种设计下,应用也可以快速的实现架构调整,并且前端部分与 API 服务共同部署、独立部署或增量部署也能够方便的实现。
4.业务场景
上面部分介绍了 Modern.js 是如何通过一体化开发的方式来解决服务器端需求的。
最后我们来一起看一下,在字节内部,Modern.js 都遇到了哪些业务场景。
4.1 使用渲染缓存
在字节内部,有名为「热点大事件」的 SSR 业务。由于业务的特殊性,可能会出现流量在短时间内几十倍甚至百倍的增加。
之前提到,在字节内部我们 SSR 的服务是跑在 FaaS 上的。在流量突增过程中,函数冷启动会带来一定时间的 SSR 降级。解决这类问题的根本方法是函数预热、优化冷启动时间、提高 SSR 在单实例中的并发数,但这些方法都无法在短时间内得到量级上的提升。鉴于这种情况,应用选择使用 SPR(渲染缓存)来解决问题。
左上角图中是开发者开启渲染缓存需要追加的代码。右边图中是接入渲染缓存前后,两次流量突增时,SSR 的降级数据。
可以看到,在接入渲染缓存后有一次更大规模的流量突增,但是降级率却和平时没有任何变化。这是因为渲染缓存拦截了大部分流量,所以流入 FaaS 中的流量也相应变少,冷启动造成的降级也随之减小。并且,左下角的图中显示,在相同业务流量下,需要的实例数仅仅是原来的十分之一,节省了大量机器资源。
同时,在接入渲染缓存之后,应用整体的渲染耗时也大幅下降。
4.2 使用 SSG 代替 SSR
第二个想分享的场景是字节官网,运营通过 CMS 管理数据。之前以 SSR 作为渲染模式,并在 Web Server 中自建了数据缓存,使用独立部署的方式运行服务。期间受到过一次 DDoS 攻击,导致 SSR 服务运行中断。
经过调研,我们发现 SSR 并不是这一场景最好的方案。一是因为官网上的数据更新并不需要达到秒级。二是因为 SSR 的特殊性,在服务端能承受的 QPS 肯定是远小于纯静态 Web 的。
因此,在新的方案中,我们选择了使用 SSG + CMS Web hook 的方式,并配合平台层面,使用 v8 worker 来部署服务,得到快速扩容的能力。
在运营修改 CMS 数据后,会自动触发应用的构建流水线,重新构建页面与部署。在这之间,运营同学需要做的事情没有任何变化,应用开发者也只需要简单的开启 SSG,但能承受的 QPS 至少提高了 20 倍。
4.3 自定义工程方案
第三个场景是字节内部的火山引擎平台。它有一套自建的子应用接入方式。
应用在接入时,常常会需要修改原本的项目结构,或是放弃原有的一些功能。同时,因为一些政策原由,应用有可能同时需要接入到海外版本、国内版本,有时还要兼顾单独部署的模式,开发者会花费很多时间来顾及这些内容。
而对于 Modern.js 来说,因为我们提供了足够的抽象,应用可以很方便的完成拓展。如图,只需要接入已经开发好的插件,就可以自动的适配各种部署方式,并且初始化的目录结构没有什么变化。这样建设出来的工程方案,既能满足垂直场景的需求或自己的偏好,又能自动获得 Modern.js 的能力和收益。
4.4 其他场景
另外,我们也遇到过使用 Go Server 搭建 SSR 服务的应用。光是环境配置的文档就有很长一篇,更不用说需要独立维护 Golang 的服务端应用。这不管对于工作交接,还是个人成长上都是非常不友好的。
后来应用迁移到了 Modern.js 中,因为这些都是框架现成的功能,迁移并没有花费特别多的时间,开发者只需要迁移业务代码即可。在切换到 Modern.js 的体系后,开发效率有了明显的提升。
在以上场景中都可以发现,在 Modern.js 的设计下,应用开发者只需要追加些许代码,就可以获得很大的收益。
最后,借用之前分享中的一张图,这些部分也都算是 Modern.js 的重要功能,后续会陆续对外开放,欢迎大家持续关注。
最后,欢迎大家扫码加入「Modern.js社区交流群」,也可以在官网上了解更多 Modern.js 的内容。
谢谢大家。
👇 点击阅读原文,直达 Modern.js 官网