Quantcast
Channel: CodeSky 代码之空

前端 SSR 系统设计思路谈

$
0
0

在过去的几个月里,除了写后端接口以外,大部分时间都在搞一些前端基础建设、技术方案的确立和整个链路的监控告警体系的搭配,而在整条链路中,SSR 起到了比重很大的一环。

有许许多多的文章都致力于教大家:怎么样去做一个 SSR 的 demo(包括一些库的文档),而作为系统的一环,除了怎么开发外,有许多更现实的问题等着我们去解决,而本文就以我们遇到的一些问题来抛砖引玉。

为什么我们需要 SSR

说回历史,Web 最初从一个 MVC 发展到 SPA,再发展成 SSR,仿佛印证了时尚是一个环,兜兜转转结果还是你。从某种角度上来说,确实是一种螺旋式上升——

  • MVC:后端 Hello World 级别的小伙伴们多半看到过,比如 PHP、JSP 这类 MVC 的开发体系,前端如果也懂对应语言,那么就能开工了。
  • SPA:我们成功的将前端和后端分离开来,大家不用在一起开发了,从系统解耦的角度考虑,这是一次很大的突破。

那么,既然前后端都解耦了,为什么我们仍然开始需要一个服务端来渲染我的 html 呢?

这里就谈到了两个问题,第一个叫 SEO,第二个叫首屏渲染。

SEO

SEO 的历史可以追溯到 1997 年,彼时,读本文的各位小伙伴们甚至可能都没有出生。它的全称是 Search Engine Optimization,伴随着搜索引擎的诞生,网站针对搜索引擎的优化也成了随之而来的问题。

对于大多数没有真正建过网站,而只在公司做开发的同学来说,这个词可能只存在于文档中,知道好像有这么个东西,而 SSR 是为了解决这个问题;但是对于更多建过网站的「站长」来说,SEO 本身就是一个和搜索引擎斗智斗勇的史诗。

如果真的要说起 SEO 来,就我所熟知的历史,同样也可以写一篇文章来仔细说说,但这里暂且不用太多的篇幅再介绍整个互联网网站的发展史,简单介绍一句 2000 年后流传的圣经:「内容为王、外链为皇」以表对一个时代的敬意吧。

扯远了,这篇文章的关键并不是阐述我怎么做内容,而是我怎么通过技术的手段来让我的内容得到更好的曝光,这也是一个技术对 SEO 能够产生的能量。

那么我们究竟为什么需要 SEO 呢?很明显的一点是,搜索引擎去掉竞价排名外,就是一个白嫖的流量入口,SEO 做的本质就是怎么让你占据更多关键词,让你的排名更靠前,就能获得免费的流量。在降本提效元年,这是一件多么诱人的事情,即使是大公司,资金投入也是能省则省。

现在,大家可能对 SEO 有了个初步概念,但是问题是——这和我的 SSR 有什么关系呢。

我们现在来想另一个问题:如果你需要写一个高性能的通用爬虫,你可能会怎么处理:

  1. 一个 IP 池 + 多进程、多线程
  2. 从首页开始,探索 DOM 中的每一个 a 标签,进行递归,然后保存数据

这里需要强调通用,是因为平时我们所做的获取某网站数据的爬虫,可能会基于它是走接口还是渲染进首屏 DOM 进行定制化的数据获取开发,但如果我是一个搜索引擎爬虫,很明显我只是快准狠的找特征值,不会浪费时间在网站定制上。

现在你知道了一个关键点:我需要解析 DOM,探索标签。因此我们需要用 SSR 来让我的 html 有数据可以抓——而不是 CSR 模式下的无 body 内容模式。

<html>
    <body><div id="app"></div></body>
</html>

尽管 Google 的爬虫目前变得高端了起来,也学会了跑一些 JavaScript,但是据说也不会等待数据获取完成,只会跑同步脚本——无论如何,系统可控性也是我们要考虑的一点,当我们说搜索引擎的时候,同样说的也不只是 Google,从这一点考虑,SSR 也是我们必须的一部分。

写到这里,我已经发现光是 SEO,已经能拉出来单独说一篇文章了,所以这里对于「我实际要塞什么数据进去来做 SEO」暂且不表,只点到为止。

首屏渲染

前面花了很多笔墨来科普 SEO,但是对于前端小伙伴来说,更熟悉的可能是一些性能指标(当然,实际上 SEO 也会根据性能指标来为你的网站打分,也有一定优化作用,这个如果有后续文章可以后续介绍),首屏时间自然也是其中一环。

同样的,本文也不是来介绍性能优化的,所以这里不对具体性能指标的定义、测量做过多的介绍,这一部分前端小伙伴们也已经很熟悉了,所以整体篇幅会比前面 SEO 要短许多(应该)。

如果你是不了解性能指标的同学,这里简单解读下:对于首屏来说,我们撇开定义和数值,从用户的角度出发,思考这样一个场景,对于用户来说,走 CSR 和 SSR,分别在什么情况下能够看到有效的数据:

  • CSR 的场合,先把无内容的 html 返回,然后跑 JS,执行 fetch,等 fetch 结束,再进行 render,最终 loading 结束,用户看到了这个页面的内容。
  • SSR 的场合,必要的首屏数据已经优先写进了 html 中,当 html 下载完成后,整个页面就基本处于可用的状态(如果你的页面去掉 JS 同样也能用的话),可能图片没有加载完,但是整体的网页结构、网站内容都已经出现了,用户可以一下子看到网站的内容。

理想情况下,SSR 的用户体验当然要更好一些(这里也可以看出,性能指标并不是空穴来风,也不是急功近利的,一切都是站在用户体验的角度出发思考的),但是这里为什么说的是理想情况,之后就会向大家说明了。

SSR VS SSG

做 SSR 的时候很容易遇到的一个问题是——一些搞不清楚 SSR 和 SSG 的区别的小伙伴可能会跑过来问你「为什么不使用 SSG」、「你说的性能瓶颈我怎么从来没有遇到过」、「你为什么要用掉这么多 CPU 资源,我的就没有」。

很明显,说这样话的人就是没有分清楚 SSR 和 SSG 真正发挥作用的场景,本着知其然也要知其所以然的态度,「礼貌探讨」一下(取决于对方礼貌与否):你知道 SSR 和 SSG 的区别吗?

如果能够回答我:SSG 是静态的去生成一个 html,而 SSR 是动态的随着请求去渲染资源,那我们的征程就可以开始了。

如果了解 SSG 的话,你会发现这完全就是两个东西嘛。如果我使用 SSG 去直出页面,那么实际上生成完页面,我可以往 CDN 上一丢,妥妥的静态页面。而这也不是说 SSG 的页面就一定得是亘古不变的,事实上,如果是定时的、固定的数据集,那么 SSG 同样可以节约资源,并且做到上文我们想要解决的两个核心基本点——很简单,跑个 job 就行了嘛,甚至假设有一些千人千面的资源,它不需要在首屏出现,那么我们其实也可以用 SSG 了。

而 SSR 的职责却完全不同,它把首屏中所需要的接口请求都放在了自己的服务中,因此我们可以很轻松的应对千人千面和实时性要求极高的场景,做好上述两个基本点(实际上当你打开 bilibili 的时候,整个首页可能都是由算法拼接而成的,这种时候就只能采用 SSR 的策略)。

可以说,针对 SSR 和 SSG 的讨论,脱离了业务场景的对撕是没有意义的,谁都不是一颗银弹,只有结合了业务去分析,你才能知道在什么场景下去用什么东西。

当然,相较之下,站在整条链路上去考虑,SSG 要简单轻松不少,因此同样的,我也不会多花时间来介绍 SSG 的相关内容。

SSR 该怎么开发

文章开头也说了,市面上有大量的文章去介绍:我怎么去开发一个 SSR 应用,但为了本文的完整性,这里也会简单介绍一下 SSR 的开发流程(以 Vue3 + Vite)为例。

从 demo 说起

关于 demo,在 Vue 和 Vite 的文档里写的还是比较明白的,至少写个 demo 不成问题:

对于(一个 demo 的)开发阶段来说,我们只要注意生命周期的执行,善用 useSSRContext 就可以了,所有的注意事项都写在了 Vue 和 Vite 的文档里。

实际上,在这个 part,更想讨论的问题是——我究竟该怎么选择一个 SSR 框架,或者我应不应该用一个 SSR 框架。

对于 Vue 来说,它本身就给了几个选项:NuxtVite Plugin SSR,对于一个大型的,需要持续迭代的 C 端项目来说,使用这种开源的高度封装框架可能并不是一个好的选择(尤其是做的时候 Nuxt 还处于 beta 版),对于有一定研发能力、且项目规模足够的团队,基于底层去进行上层封装可能是个更好的选择,还是那句话,「可控」在一个大型系统中很重要。

工程化

即使是浅尝辄止的 demo,如果要真的部署上线,不可避免的依旧会遇到前端工程化的问题,即使本章讲的是 demo,我们依旧会介绍下工程化视角下我们的整个 pipeline 需要怎么处理。

撇去原始社会的「把文件直接推进虚拟机,然后 run」这种简单粗暴的模式以外,在 docker 社会下,我们还有哪些选择呢?

第一种最直观的方案是:构建完整的文件列表后启动,直接把全量文件压进容器启动,这种方案非常的服务端思想,SSR 容器本身是一个完全独立的闭环系统,可以以此来进行灰度、回滚,容器本身是个非常好的资源维度。但是这样我可能无法保证 CSR 和 SSR 跑的一定是同一份代码。

第二种方案是:将我的 CSR 与 SSR 结合起来,SSR 只构建 server,而剩下的资源都是 CSR 的构建中产生的,这样的优点是我可以保证运行代码的版本始终是可预期的,肯定是同一份。

可能有人会问的一个问题是:上面提到的我的运行版本可预期,是同一份,为什么我需要进行这样的保障机制?

降级

这里引入了我们实际开发和 demo 差异的第一个问题,降级。

在实际开发中,我们不能保证一个服务是 100% 可用的,为了保证呈现给用户的最大可用性,我们会对系统中的每一个服务进行一些常用操作:「容错」、「熔断」、「降级」。对于一个 SSR 服务来说,多引入了一个 SSR,肉眼可见,链路多加了一层,那么如果 SSR 服务出了问题且没有降级的情况,会直接影响到用户(前端服务作为流量入口,其实对于可用性要求极高,只是前端工程师大部分场景下不需要关心),这可不是「一个接口不可用」,而是整个网站变成了 500 的状态,本身在 CSR 的情况下,我直接走静态资源,可以承载的 QPS 有着质的差别(甚至你有可能是直接静态资源推文件存储 + CDN 的),理想的降级情况就是:如果 SSR 服务出现问题,那么降级到直接访问静态资源(CSR 渲染)。毕竟我们引入 SSR 只是为了优化流量和用户体验,如果因为 SSR 服务出现问题,导致这一部分流量「被优化掉」了,属于得不偿失。

如果要降级的话,我们就必须要保证:我的 CSR 和 SSR 跑的得是同一份代码,如果版本不一致,就可能会造成更多的意外情况——Boom!

这一保障制度可以是由发布系统提供统一编译能力:一次编译,两个环境发布;也可以是业务把控(方案 2)。

有人又要问了,再怎么摆烂,我人肉保证大概可能也就差一两个 bug fix 的水平,是不是问题不大——如果你的静态资源是上 CDN 的话,问题很大,可能会导致 CSR 的版本推了,但是 SSR 因为跑的版本不一样,所以找不到对应静态资源的情况,发版再次原地爆炸。

当然,我们也可以引入资源检查来确保版本的一致性,总的来说,在这一阶段,选择什么技术方案更像是基于基建能力去进行取舍。

那么,方案 2 是怎么样将 CSR 和 SSR 串联起来的呢——我们需要一个类似于配置中心一类的服务将静态资源进行关联和获取(同时可以管理版本),在容器启动阶段下载(注入)到本地,以此作为启动依据。既然拉的本身是 CSR 构建时编译出来的资源,那么自然可以保证是同一版本了。

除此以外,方案 2 还有哪些优点呢,我们来假设我们的服务规模足够大,现在我有了 100 台容器,我发一次 Web 版本需要更新 CSR 和 SSR 中的对应资源。配置中心的网络 I/O 要比容器发布快太多了。仔细想想,这似乎是个不错的方案?

这当然也是有缺点的,比如上文我们提到了用容器来进行灰度实验,那么如果我们用方案 2,就必须要求配置中心去支持灰度。

同时 SSR 服务也需要在依赖更新时重新打包以安装上最新的依赖,如果依赖变化了,那么方案 2 就显得没有那么美好了,甚至如果检查机制没做好的情况,可能会导致 SSR 服务原地爆炸整体不可用。

服务解耦

如果你仔细想想的话,方案 1 似乎没有办法做到 SSR 和 CSR 服务的完全分离,而方案 2 从系统设计中保证了 SSR 和 CSR 是可以解耦的(毕竟只要使用网络 I/O 下拉资源)。

这里引入了新的问题:解耦是否是必须的。

在现代化设计中,我们习惯于让一个系统或者应用尽可能的小,来减少改动带来的风险,尽管这会带来一些些链路的治理成本,但大部分情况下可能是利大于弊的。而在 SSR 这个场景下,我们是否真的有必要让 SSR 独立于 CSR 代码存在(比如可以是两个 repo 分别维护和管理)。

从整个开发流程中,我们可以看出这一点对于 SSR 应用来说或许是弊大于利的——上文我们讲到了其中一点:「需要同步更新依赖」,在两个应用中肉眼人工同步毫无疑问是痛苦的,在上线前缺乏感知的,尽管我们可以独立开发,但是独立是必要的吗?

我的 SSR 服务本身就应该依附于我的 CSR 而存在,本身就是紧密合作的关系,而在「解耦」的经历中,我们其实会遇到一个很严重的问题:我在 CSR 服务下开发的代码,到了上线时,SSR 环境下是有问题的,SSR 跑不起来,此时我已经堆积了一个版本的代码了,同时也到了提测甚至是上线阶段,一般的同学到了这里已经开始慌了:咋整呀这个。

如果在开发阶段就能使用同款 SSR mode 开发,这样的问题就能相对的少很多(尽管开发中不可避免的会遇到一些 dev mode 和 production mode 不一致的问题,但相对少了很多,也更可控)。同时,我们也只要维护一份依赖清单。表面上来看,这是一个更大型的耦合的系统,但其实整体的维护和治理成本却降低了。

总结

小小总结一下开发阶段的问题:

方案 1:全量资源方案 2:CSR+SSR 整散结合
优点1. 完整独立可单独运行的系统
2. 可以利用 Docker 特性直接回滚
3. 可以基于容器做灰度
4. 构建包时刻都是最新的(依赖最新)
1. 可以保证最终一致性
2. 可以利用 CSR 模版检测资源是否有效
3. 发布和回滚更快
4. 支持服务解耦
缺点1. 独立运行,因此无法保证最终一致性
2. 无法检测对应的降级是否真正可用(无法保证 CDN 资源一致性)
1. CDN 无法保证资源访问安全性
2. 网络 IO 可能会导致启动失败
3. 无法针对 SSR 进行资源灰度
4. 可能会忘了更新依赖导致翻车

同时,我们也必须注意开发阶段尽可能的都在 SSR 模式下开发(同时也得保证 CSR 模式的可用性)。

当然,在方案 1 到方案 2 之间有大量的留白的灰度空间,大家可以根据自己的实际开发和基建情况进行定制。

如何提高系统吞吐

我们在这里假设,你的服务一定是有人用的,而且还有不少人——接下来,我们就会遇到比 demo 更难的问题,怎么样去负载日益增长的 QPS。QPS(Query Per Second) 这个词对于前端可能有些陌生,简单的来说就是每秒的请求数,而在面试阶段如果前端简历里写了 SSR,我基本上都会问类似的问题,而大部分人对于应对日益增长的体量,最直观的印象是:扩容。

当然,这样就把天聊爆了,站在系统的角度,我们还是得全面的看待问题来解决系统吞吐上的瓶颈,来帮助系统又快又好的运行。

SSR 的本质

在做优化之前,我们先来思考一下 SSR 的本质是什么,前面我们介绍的 SSR,实际上更多的是从业务的角度去解读它是什么。

  • CPU 计算:运行 JS,渲染出对应的 DOM 结构
  • 网络 IO:获取首屏所需接口数据

当然截至目前来看,我们的 SSR 服务大多数时候瓶颈都在 CPU 上,这也是它有别于我们其他后端服务的地方——对于一个业务系统中的大多数服务都是 IO 密集型的应用,很少有 CPU 密集型的应用,这也会给我们带来一些额外的考虑。

网络 IO

先从简单的说起,对于网络 IO 来说,我们自然是希望节省整个请求过程中的耗时——上文中我们介绍了「理想情况下,SSR 的用户体验更好,因为首屏会先出来」,但是如果在内部网络 IO 花费了太多时间,那么对于用户来说,白屏的时间变得更好藏了,反而成了一种负优化。

针对这一点,其实很好解决,在 IO 密集型的应用中,我们有过太多的经验:

  1. 使用内网请求接口:这一点就是字面意思
  2. 尽可能的减少网络包体积:这一点需要提供接口的后端与前端沟通共同努力,协商一个最好的方式(包括但不限于只下发必要的字段、使用 grpc 等)
  3. 有效利用缓存:利用缓存本质上是一种常用方式,但在这种场景下,可能需要思考「我要不要上缓存」和「我怎么上缓存」。

为了避免了频繁建立和销毁连接的开销,我们可以使用 keepalive 来避免极端情况的发生,比如我们可以使用 httpAgent 来保证最大 sockets 数,避免保证异常流量的情况整个系统的安全性。

请注意,开启 keepalive 需要保证请求的另一端支持 keepalive,你可以通过下文测试是否支持 keepalivehttps://stackoverflow.com/questions/4242145/how-to-test-http-keep-alive-is-actually-working

在前端应用中,我们可能也会设置 timeout,但必须区分清楚的是:timeout 的时间到底是从哪里到哪里的呢——在大多数设置中,实际上,他是从建连开始算的超时时间,这里就与 SSR 的场景有所冲突了。

我们在上面实际上准备了一个连接池,但如果请求过大,没排上号,这个请求也应该到点取消,这是由上下游整体的时间决定的,因为在我们的 SLB 层(也就是 nginx),也必然会配置一个超时时间,他可能是 1s,也可能是 800ms,在时间到了之后,没排上号的就应该直接原地取消了。(实际上即使没有池,在链路中我们同样有可能遇到这个问题)

口头上总结一下,我们就知道,我们其实迫切需要一个基于每一个请求生成上下文的 fetch 方法,它的实现可以是任意的,但至少他需要支持取消请求。这极其类似于 Golang 中的 context.WithTimeout

这里就会遇到另一个老生常谈的问题,对于 Node 服务来说,全局变量是会共享的,和只会针对端用户生效的 CSR 不一样,所以对于这个场景,我们需要针对每个请求去新建实例并使用。

在 Vue 中,我们可以利用 Vue 在 SSR 中的单独实例加上 inject / provide 去做一个基于请求 fetch。这一点会随着业务规模的增长(和受流量攻击频率的增长)越发重要,也有助于设计出一个符合服务端思想的系统。

CPU

再来考虑一下 CPU 计算的问题,CPU 是我们平时不太容易遇到的一个课题,站在原理的角度,就是「优化和改进算法」,让整个 DOM 结构尽可能快的生成;生成的核心算法本质上被封装在了你所用的方法内部(比如 Vite),因此我们所能做的一个点是,尽可能的去精简我们首屏的内容结构,让 CPU 的每个点都用在刀刃上,如果无效的 DOM、数据过多,毫无疑问就提高了渲染的开销。

因此我们这里总结一下,CPU 的优化上,能做的可能有:

  • 使用尽可能快的生成方案
  • 优化 DOM 结构和包大小

当然,也有可能是你的业务代码本身里面有一些时间复杂度比较高的代码,这也是可以优化的点。

要想知道我们的系统主要资源开销在哪里,可以「不服跑个分」,使用 Nodejs 的相关工具生成火焰图:

简单总结一下如何使用:

  • 启动:node --prof index 以采样的模式启动程序,会生成 log 文件。
  • 生成火焰图:node --prof-process --preprocess -j isolate*.log | npx flamebearer

生成完火焰图之后,我们可能还需要读懂火焰图,然后才能知道我们的系统瓶颈究竟在哪里,对于火焰图怎么读,也有很多篇文章介绍(甚至阮老师也有介绍过),在这里只贴链接,如有需要,在未来的文章中可能在详细介绍吧:

另外的,也会有人提出:我们是不是可以对 SSR 进行缓存,无论是页面级的缓存还是组件级的缓存——对于一个通用的方案来说,这是一个比较张口就来的解决方案。确实,他能有效的减少 CPU 的开销,但是无论如何,「缓存」这个设计一定得针对具体的业务模型去决定的:我的业务是否对实时性有一定要求;我的缓存粒度是什么,超时时间是多少;甚至我做了缓存之后,我的缓存命中率究竟是多少,一味的只是说上缓存是没有任何意义的。——更何况缓存同时会影响你的整个开发模型,可能会引入额外的开发成本,也同样不是银弹。

针对这种场景,我们也可以结合上文描述的 SSG,以及考虑你的首屏究竟需要什么来进行合理的取舍,最终得到一个适合自己的方案。

更进一步:系统的诞生

很显然,对于一个系统来说,SSR 必然不是孤立存在的一个服务,需要结合整条链路去进行服务的保障,上面其实我们已经提到了一些服务的防御、降级能力,但是还有一些通用能力需要安全上,这一点,可以结合自身的基建去配置来保障自身及时发现异常、以及在出现异常时不影响下游服务:

  1. 监控告警
  2. 限流限频
  3. 逐层降级

这一趴要详细说的话太多结合公司的基建了,因此在这里不做展开了。但是推荐大家在做一个服务的同时了解自己的上下游,方便排查问题。

总结

本文大致总结了一下我们在实际开发过程中遇到的一些挑战,当然,随着体量的增长毫无疑问会有一些新的挑战出现,而上文中也不断提到一点,那就是「结合实际业务场景和基建情况」,否则就变得话不投机半句多了,因此本文只是抛砖引玉,顺便科普。


对于前端测试的一点杂谈

$
0
0
之前本来在 12 月就应该写这一篇文章的,大致是同事需要做 SDK 开发,我要求必须要有对应测试,但对于怎么去设计测试比较迷茫,本来早在 19 年就写过一篇如何构造一些有意义的测试,但从某种角度来说这更偏后端一些,对于前端的测试来说有一些不同。
然后被裁了,本来不想写了,但之前帮做模拟面试以及被面试时其实都有提到一些内容,所以这里简单谈下我对前端测试的一些看法。
新读者注意:本人屁话较多,不喜勿喷,上角点叉。(我好脆弱啊哥哥.jpg)

什么时候我们需要测试

如果你的回答是:「当然是什么时候都需要测试」——那么恭喜你,你还没有接受过现实排期和业务的毒打(这里指的是国内互联网的情况)。

理想情况下,我们当然希望「都有测试,测试覆盖率达到 100%」,但是现实往往无法支撑起这个美好的愿景。

在这种情况下我们在什么时候选择编写测试代码呢?比较核心的几个点是:

  1. 核心、高风险业务内容,比如我 19 年写的文章就是针对支付、会员、订单这一块的后端服务进行的大规模覆盖
  2. 基础能力提供方,比如基础设施、通用基建,包括本文引子部分的 SDK
  3. 对于前端来说基础组件也是可以作为测试的项

其实从上面我们就可以看出,除了核心业务是为了止损外,大部分可以优先写测试的部分都是因为整体需求开发相对可控,不至于朝令夕改,同时作为基本盘,服务人数众多,牵一发而动全身,一个 Bug,全家完蛋。

当然实际上很多公司处于 0 测试的状态推进,包括之前在 B 站时我使用的一些内部工具,其实也是没有测试或者是测试覆盖率低的状态。因此也确实在其中埋了不少隐患。

出于大部分公司的迭代速率和需求的不稳定性,对于业务做一些集成测试性价比不高,更多的是如果你有闲心可以给自己的 utils 方法做一些测试。

当然,如果业务稳定、排期宽松,那毫无疑问可以达到你想要的效果。

测试覆盖率

在 19 年的文章中实际上就有关于测试覆盖率的描述,在这里再次重申下,测试需要追求覆盖率,但不要追求覆盖率 100%。

这一点在这次面试中也和面试官讨论过(似乎不止一位),如果没有测试覆盖率指标,那么测试将毫无意义,因为每个人都可以以各种理由偷懒不做测试;但如果过分追求数字,接下来面对的可能是更灾难性的结果:

在个人(开源)项目中自然是想怎么玩都可以的,但在企业开发中我们往往会遇到时间与健壮性的平衡,大部分老板其实只关注「快糙猛」,你先帮我把第一版推上去,剩下的我们之后再说。如果在这种情况下过分追求覆盖率,基本相当于没有人性的强行军,可以先顶一个小目标,比如 80% 覆盖率,而不是 90%-100%。

因为喊个口号容易,但当你实际开始写测试就会发现,要完美覆盖你的 case 而不是只在乎数字的情况下,写测试的时间将是开发时间的 1.5-3 倍,而且是随着覆盖率指标上升指数级上升的,也就是说——有一些犄角旮旯的地方它就是不太好搞,也就是「搞的性价比不高」。

当然如果这些不好搞的地方是核心内容,那就是不得不搞了,这种时候其实更偏向于一劳永逸,也是一种高性价比的投入,这是另一回事。

测试有什么用途

这话说的仿佛是一个废话,你可能会说「测试嘛,当然是为了测试」。但实际上测试为我们提供了以下便利:

  1. 方便及时发现问题——这是测试本来的功能
  2. 通过测试集提供用法的 example——可以有效减少你的文档量,少解说冷门 case
  3. 方便后人接盘代码——当有测试的时候,才会知道你写的是 Bug 还是 feature

实际上一个最简单的集成测试本身就是一个 Quick Start,可以直接贴测试用,而我们通常会同时加上注释,巧了,代码的可读性又上升了。

当然这里偶尔也会遇到让人无语的情况:接受的同学改代码连带着测试一起改了——可能是另一种正确吧。建议大家动测试之前一定要看清楚测试代码的含义和价值。

前端的测试

后端的测试在文章开头已经有传送门了,本文重点还是说下前端的测试,常见的测试还是单元测试和集成测试。

这里以埋点库 SDK 为例,因为 UI 业务上的单元测试发挥空间比较有限,相对的费力不讨好一些,前端 SDK 的 case 更典型(简单)且全面。

单元测试

说起单元测试,我们最先想到的肯定是 jest,老牌测试了,关于怎么使用可以看官方文档和各种文章,这里跳过这一趴;此外,我们最后的遗作 SDK 已经使用了 Vitest,理论上应该是比 jest 更好用一些的。

目录结构来说可以是一个 src 文件对应一个 test 文件,这样更加一目了然一些:

├── src
│   ├── index.ts
│   ├── trackers
│   │   └── web
│   │       ├── click.ts
│   │       ├── expose.ts
│   │       └── pv.ts
│   ├── typings
│   │   └── window.d.ts
│   └── utils
│       ├── buvid.ts
│       ├── format.ts
│       ├── http.ts
│       └── utils.ts
├── test
│   └── unit
│       ├── index.test.ts
│       ├── trackers
│       │   └── web
│       │       ├── click.test.ts
│       │       ├── expose.test.ts
│       │       └── pv.test.ts
│       ├── tsconfig.json
│       └── utils
│           ├── buvid.test.ts
│           ├── format.test.ts
│           └── utils.test.ts

这里有几个值得讨论的点:

首先是我们是否要使用 TDD,TDD 是一个非常老的话题,甚至早在我还没工作的时候就写过一篇文章(2016 年,看看得了,勿挖坟):Node.js 用Mocha+Chai做单元测试 入门,但是在实际工作中碍于文章开头提到的原因,我们很少去真正使用完全的 TDD(在后端测试时我有用过,因为这是明确必须测试的,剩下的情况下都可能会受到迭代排期影响),大部分情况下我们更多的开发思路还是:

  1. 我的库需要提供哪些能力
  2. 这些能力怎么拆分成函数(尽量无副作用)
  3. 挨个覆盖我 export 的函数
  4. 对于想到的边缘 case 进行填补

单元测试最难的部分可能是要挨个 mock 你需要的部分,同时为了避免干扰,建议少用全局变量、多写无副作用函数,能让你的开发之旅顺利很多。

对于大部分基建项目来说,单元测试是一件性价比相当高的方式,非常建议大家抽空做一波。(当然,很容易 mock 麻了,比如我卷到最后就卷不下去了,要 mock 太多东西了)。

集成测试

上面说了半天,其实对于单元测试来说,前后端并没有太大区别,前端 mock 浏览器、网络 IO;后端 mock 网络 IO、底层依赖、文件存储,本质上都是 mock + 对函数进行测试。

对于集成测试来说就更为痛苦,在这里我们努力的使用 cypress,如果说单元测试测的是函数,那么集成测试测的就是能力,简化一下上面在单测中介绍的思路:

  1. 我的库需要提供哪些能力
  2. 用代码怎么实现
  3. 覆盖我设计的能力
  4. 对于想到的边缘 case 进行填补

可以说,如果单元测试还是要你具体代码拆分后再进行测试的设计,那么集成测试从一开始就可以进行设计了——毕竟在系统设计阶段,你已经会敲定「我会提供什么」了。

剩下的可能就是漫长而痛苦的 mock 阶段,甚至你可能需要准备好一个 server 去承载你的测试页面。

总结

对于大部分刚开始写测试的工程师来说,卡点可能更多的在于怎么去取舍和怎么去设计我的测试用例,这里简单的提供一些思路,至于具体库的使用上,网上已经有太多基础教程,而坑就靠 stackoverflow 和 GitHub issues 去填补吧。

而为什么大家都懒得写测试——根据本人实际的测试体验,更多的不是测试,而在于 mock,漫长而痛苦。

展望未来,自然是希望 AI 能够解决我们大部分烦恼的,比如上文说的「根据提供的能力出集成测试」——这是不是有种输入给 AI 就能给我吐代码,我删删改改就能跑的美好错觉呢。

聊聊系统设计中的缓存

$
0
0
好久没更新了,本来想更新《前端是不是真的死了》,但是正好工作中发生了一些讨论,所以就改成先更新缓存了。
本文适宜对象:不太常设计缓存的各类工程师。

背景故事

今日的一个场景是:有一段国家信息数据,结构大概是:[{ region: 'CN', code: 12345, text: '中国' }] 这样的一个国家数组(实际字段不太一样),而在此之前这段信息存储在了一个提供给前端的外部接口中,你是一个提供给前端的 BFF,想基于这些数据进行二次处理。

// 一段伪代码
// 调用一个外网接口
if regionList, err := fetchRegionList(); err != nil {
    handle(regionList) // 对 region 进行处理
}

那么你会怎么去优化呢?(撇去联系底层服务提供内网接口这种沟通性工作)

从缓存说起

在这一例子中去分析,首先我们就会觉得,每次都外网调用,看上去还是不变的数据,是不是我们可以上个「缓存」,在有内容的时候直接读「缓存」,没有「缓存」的时候再去拿原始数据呢?

很棒,你想到了如何优化耗时较长的引用问题,那我们怎么去设计「缓存」呢?

提到缓存,大部分新人的第一反应估计就是「缓存嘛,说的不就是 redis」吗?

但实际上,当我们存储数据的时候,我们至少可以细分成三个层级:「本地缓存」、「redis」、「DB」,撇开 DB 不提,人人都知道它是拿来存储数据的,那么剩下的两级缓存,我该在什么情况下去使用。

回到这个场景中,如果我们用 redis,很明显,我们会需要进行:

  1. 一次网络 IO
  2. 一个 redis 资源

1 大家都能理解,那么上游有多少次请求、就会有多少次请求打到 redis,而 2 也不是个玩笑话,毕竟我相信大部分公司仍然在「降本提效」的路上。

对于这个有限数据集来说,用本地缓存可以靠少量内存来解决这个问题,它没有额外的网络 IO,不会对下游服务造成额外压力,充分满足了降本提效美学的核心思想。

缓存只是用吗

有了这个点,问题真的解决了吗?实际上,对于缓存来说,我们考虑的更多的是缓存的「写」和「更新」,怎么去解决数据一致性的问题才是缓存设计中的大头。99.9% 的 case 都是会更新的,只是有「多长保质期」的区别罢了。

在上文的设计中,很明显,我们没有考虑过期问题,永不过期是缓存设计中最糟糕的设计。缓存通常都是存储在内存中的,很明显内存是个有限级,糟糕的缓存设计配合永不过期,很快你就能得到缓存打满的快乐。

一个合理的缓存应该是:一个合理的数据结构+一个合理的淘汰策略。

缓存如何设计

在所有的程序设计中,我们考虑问题的第一步肯定都是分析我们的场景,比如上文我们分析「场景」和「更新频率」得出了一个结论:「我们用不着使用 redis」。

对于缓存来说,我们应该考虑以下几点并做好缓存失效的防御机制。:

  • 缓存的根本原因:是因为体量大还是因为下游慢
  • 缓存的命中率多少:我们到底需不需要缓存
  • 时效性多少:缓存怎么更新
  • 缓存的 QPS 是多少:热 key 问题

缓存的根本原因

缓存并不是快的代名词,他只是把建立在 DB 上的磁盘 IO 变成了建立在内存上的 IO,同时多了一层缓存副本,上面我们也提到了「降本提效」这四个字,缓存本身存储也是一份额外的开销,同时也是增加了链路的复杂性。(如果有不理解「链路复杂性」这五个字的同学欢迎留言,人多的话可以额外加餐)。我们要想清楚上缓存的根本目的,通常有两点:

  1. 我的 QPS 太大了,我的下游扛不住这么大的 QPS,需要做一些手段去干预流量向下透传,这是最常见的使用场景。
  2. 我的下游响应速度太慢,或者稳定性太差,影响了我自身的服务质量,且数据是通用的(不缓存用户相关数据),那么这个时候缓存可以加速我的服务。

缓存命中率

引用之前我的《前端 SSR 系统设计思路谈》中的一段话:

我们是不是可以对 SSR 进行缓存,无论是页面级的缓存还是组件级的缓存——对于一个通用的方案来说,这是一个比较张口就来的解决方案。确实,他能有效的减少 CPU 的开销,但是无论如何,「缓存」这个设计一定得针对具体的业务模型去决定的:我的业务是否对实时性有一定要求;我的缓存粒度是什么,超时时间是多少;甚至我做了缓存之后,我的缓存命中率究竟是多少,一味的只是说上缓存是没有任何意义的。——更何况缓存同时会影响你的整个开发模型,可能会引入额外的开发成本,也同样不是银弹。

假设我真的满足了「根本原因」,但是你发现缓存利用率太差,存了却没有被有效利用,等于没上。

这里分为两类设计思路:

  1. 先行评估:这个业务数据的通用性到底是怎么样的、缓存时效性要求怎么样(这个后文会具体说),设计好 key。这决定了他的命中率。
  2. 先上试试:在一些优化尝试中,我们可能也会决定先莽再说,在上了缓存之后还是需要观测缓存命中率,不行就是浪费钱,只能写进 KPI:我上了缓存,但是完全没用上——建议重新评估。

缓存时效性

上文我们提到了「没有超时时间的缓存就是耍流氓」,那么怎么设计我们的超时时间,代码又该怎么写呢?

缓存设计中,我们首先要设计一个合理的缓存生命周期,最简单的想法是「过期了就去重新回源」,只要衡量业务数据的生命周期,对于一个非强实时性要求的数据来说,一般分钟级别都是可以被接受的。但问题是如果过期了再去取,可能会存在「缓存击穿」的问题,如果过期生命周期正好一致,甚至有可能遇到「缓存雪崩」。这里不对「缓存击穿」和「缓存雪崩」再进行介绍,Google 太多。可见缓存时效性才是缓存设计中的一大核心要义。

这里的主要思路有:

  1. 长缓存 +job 更新,对于 DB 数据更新的同步,比较常见的操作是消费 binlog 数据刷新,这种设计中通常缓存时间都会分配的很长,甚至是永不过期的
  2. 分布式锁,利用锁合并读数据库请求,只用一个线程去读取,剩下的等待锁释放去拿缓存数据

缓存怎么存

假设我们选完了数据结构,考虑完了缓存超时时间,那缓存就完事了吗?——对于 redis 来说,他同样也是个服务,只是比 DB 耐操一些,同样也会扛不住,这里就需要我们在 QPS 预估的基础上去进行热 key 分析,关于怎么做热 key 监控,通常不是业务方要考虑的问题,可以 Google 一下具体实现,这里我们主要要对热 key 进行分析,做合理的策略操作,上文举的其实就是一个「选择缓存层级」的例子——选择本地缓存,适合少量数据但极端热门的场景。本地缓存往往是进程级别的,所以在单机多核上会存在多个副本,也不排除单个进程更新失败的可能性。

当然,对于大量业务热 key,可能就不够合适了,我们可以考虑分集群读写,这里不止是冷热分离,也可以对热 key 再进行集群分片。

缓存避雷

除了以上说的点以外,对于 redis 来说,我们还要防止大 key 问题,大 key 的读写会导致集群压力,成为十足的风险点。

缓存的想象力

刚刚上面提到的大部分 case 都以 redis 为例,但实际上,不仅我们有 redis 和服务器本地缓存,如果真的是一个国家列表要给前端消费,我们也可以让前端获取 json 文件,并走端缓存或者 CDN 缓存。

从这个角度切入,我们又有了新的缓存选择方案——缓存的控制度:不变的接口数据,比如我可以是一个带版本的配置数据;或者所谓的永恒不变的数据,压力完全不会传递到服务端。

总结

对于缓存的介绍先到这里,之后有机会可能会再用这个例子介绍一下「代码存储」、「配置中心」、与「redis 读取」,是怎么去权衡和选择的(但文章应该不会太长)。

这里本来想写的文章是「后端缓存」,但是对于我们系统设计中,无论你是前端工程师还是后端工程师,更多的是一个成本转移的过程,也就是在全链路上下游权衡利弊来决定把压力放在哪一层,怎么去做,透不透传,因此没有把结果局限在「后端」这个领域。希望与大家一起探讨关于缓存的一些用法和思考。

从接口设计看分层微服务架构

$
0
0

故事的背景来源于,今天产品提了一个需求:对某个模块更新了一种文案,理论上来说,其实这是一个 k-v 结构,比如我有一个 getTexts 接口,返回如下:

[
    { title: '你好', desc: '世界' },
    { title: '效果不错', desc: '追加一条' } // 本次追加
]

那么直观感受是,我们接入了这个接口,接下来定义完 render 的结构,无论服务端怎么变,是不是都能渲染出来。

这似乎是理所当然的——产品也是这么认为的,这里甚至不用提及系统架构中的分层,因为抽象就似乎这么简单。

现在我们的困难来了,如果有多个端需要做同样的事情呢——比如我们就有 App、PC、H5、小程序,按照最简单的架构来看,似乎会变成这样:
接口架构.drawio.png

当然,作为一个职业的开发人员,大家自然不至于做出课设级别的简单设计,至少你会知道,直连 DB 的应该只有一个服务,稍微改改这个设计:
接口架构-第 2 页.drawio.png

当然,这一个微服务的底层可能还会有好几个微服务,问题就来了,我的系统到底是怎么拆分,才能最终做到独立、解耦,同时又能满足一次变更、多端生效呢。

我们从图中的接口说起,也有人会把这一层称为 BFF,它的本质是面向视图服务的,也就是说让前端的逻辑尽可能的少,这样可以把前端做的更加轻量,避免重逻辑的操作,这很好理解,从逻辑、解耦、提速和统一的角度,我们都能找到这样拆分的理由。

但是他的下游是由一个个微服务组成的,而不是我们图中所示的只有简单的一条线,现实系统中的链路往往复杂很多,这又应该怎么拆分呢?

关于拆分,可以阅读下:微服务拆分之道,讲的挺清晰,简单总结一下,最下游的服务是基于领域模型进行拆分的,比如「用户服务」、「信息服务」、「支付服务」、「订单服务」等等。同时,我们也会基于服务重要性、QPS 等角度考虑去进行更细粒度的切割,并且在维护成本、运维成本中取得一个最终的平衡点。

但如果光按照领域模型切割完服务,才是一个服务的开始,更重要的是,我这个服务应该去做哪些事情?

拿一个获取用户订单来说,那么首先我必然需要拿到用户,并且鉴权通过后,我才能执行后续的逻辑,吐给上游服务订单,既然如此,我是不是应该调用用户服务去进行鉴权呢?——从单一职责的角度上来看,其实不用,理论上,应该是上游鉴权并且告诉你 userId,然后直接去拉指定 userId 的信息,否则一个「订单服务」的所有接口极有可能都是需要鉴权的,在一个上游服务调用多个微服务的时候,就会造成「用户服务」的严重读放大,同时平白无故的增加了链路成本。

无论是代码还是服务,我们都应该奉行高内聚低耦合的原则进行。但是如果这样,可能会导致服务之间的关联性变弱,过于散装,所以我们会在最底层的查询类服务上游再去进行一些聚合,行程上游服务,这也就是为什么微服务架构的链路存在一定复杂性,而不是上图中的简单三层结构。

接下来回到原来的话题,现在产品能够实现他提一个需求改变所有端的想法了吗:似乎是可以的,毕竟只要微服务变更,接口侧只是对微服务进行的一些拼装裁剪,上面那个 case 本质只是数据多了一条,没什么本质的变化。

但是新的问题来了,虽然这个特性最终可能是多端生效的,但是过程中产品可能要对某一段进行一些实验,比如 App 去做一些 AB 来验证想法。

这里我们的关键词有两个,一个是 App,一个是 AB;当我们提到平台时,我们会更倾向于在客户端或者接口层去进行实验桶的分配,然后在对不同的情况进行数据处理。而如果是平台无关,则可以考虑将实验逻辑进一步下沉到下游服务中,这样才更能保证多端一致。

结合需求来看,我们常见的需求有两种:

  1. 新增 feature,需要前端调整 UI 的同时后端新增数据字段
  2. 策略调整,前端不变,但是后端数据会变得不一样的场景

对于场景 1,毫无疑问,应该新增个字段,来减少对现有服务的干扰,但也有一些情况是,本来 enum 是两个值,现在有了三种值该怎么办,这种情况上游服务从一开始就应该做好防御策略,避免数据变更导致的兼容性问题。

对于场景 2,如果产品预期多端一致,是完全可以进行多端一致处理的,做到在接口层不变更的情况下,微服务下发数据修正,App 试验期间,如果用户命中实验桶,可以将实验标传给下游服务,下游服务返回实验版本,但在结束实验后,需要将代码的缺省版本更新,让它在所有端都生效。

但是对于服务端来说,可能有的顾虑是:如果各端因为数据变更出了异常,我是不是要背锅,于是始终不敢更新缺省版本,而让各端传入之前支持的实验标——其实开发并不是一项自闭工程,还是需要一些协调沟通的,不用假设。

如果 App 端只有某些版本才支持,其实应该在接口层基于版本进行兼容性处理,而不是一路无脑透传,接口层其实是一个脏活累活,也并不是无脑透传这么愉快的。

其实对于链路的每一层,都秉持 less is more 的理念,如果长期把持着实验标,对于下游服务的可维护性也造成了一定挑战,比如这次在接入一个接口的时候,我需要传十几个 options 才能保持和 App 一致,但有些并不是 App 正在进行的实验,而是已经结束后保留的选项,虽然最终接口的提供方也没能给我解释每一个选项,但是简单看名字,有的接口是决定了:是否出现某一个字段,这种裁剪能力应该由上游去做才对,也就是说,或许我们的微服务拆分就是有问题的(甚至还见到了底层微服务下发十六进制色值、下发裁剪后图片而不是原图的情况)。

我们再回去看看产品本身的诉求:多端一致。其实是不是只要把接口出入参简单化,逻辑清真化,多多沟通,就能解决的问题呢?

对于业务来说,本身没有银弹,只有结合业务后的合理的设计,但是根本依旧是,我们不仅要实现需求,还得想想怎么能更好的实现需求,能够走在产品前面,设计出一个可扩展、易使用的服务。

当然,本文本来觉得没啥好讲的,也很低端,没想到这两周接接口接的痛不欲生,同时也终于发现了为什么我们的多端永远对不齐的其中一个问题点,于是有了这篇文章。

由黑转白——群晖的最后一次折腾

$
0
0

折腾群晖以来写过了好几篇文章,这次买了白群晖,因此这应该是黑群晖的最后一篇文章了,故事是这样的。

16883043150658.jpg

痛定思痛,连夜买了个白群晖——痛,非常痛,为双十一刚过而扭曲变形。从 PDD 买了 923+。

下文会介绍:

  • 我是怎么把黑群晖修好的
  • 怎么把黑群晖升级到 7.2
  • 为什么买了 923+,数据迁移
  • 周边设备

黑群晖重建

我也没想到,主板升华了只要把主板纽扣电池抠掉放一会儿电就能恢复了,但是新的群晖已经到了,所以想了想还是刷一个新版试试。

首先,准备一个引导 U 盘,刷上 arpl-i18n,教程可以看这个:一起来玩群晖DSM7.2,ARPL引导黑群晖7.2详细教程;推荐下载 rufus 用来写入驱动。CPU 是 Intel J3455,之前使用的是 918,所以这里我还是选了 918,版本选第一个。

SN、pid、vid 理论上都会自动生成和填写,不用任何修改,选完型号和版本后启动。浏览器会提示你进入指定路由开始你的安装流程(如果你已有数据,插上一块盘,如果是可恢复就可以继续操作),但实际上启动需要好长的时间,所以需要耐心等待,大概在十分钟之内。

然后你可以选择正常安装 7.2 或者自己在官网下载驱动并上传安装,如果遇到了「无法安装此文件,文件可能已经损毁」,可以尝试一下:

  1. 官网人工下载驱动
  2. 使用 ChipGenius 查看 U 盘 pid / uid 后使用 DiskGenius 修改 grub.cfg(可以参考:https://www.zleoco.com/?p=1020
  3. 更换型号和版本:可以在这里查看兼容性列表:https://www.mi-d.cn/1338

通过以上方案安装完成后还是需要等时间重启,重启完后再试试关机-启动正不正常,正常就是安装完成了。

黑转白

先从介绍 923+ 开始,因为四盘位好多年没更新了,之前其实就在观望中,但是 923+ 不支持硬解,从硬件的角度似乎不太划算,而且群晖本身实在太贵了,属于买软件送硬件了,虽然很多人推荐 920,但是 920+ 现在只有闲鱼才有了,有点溢价。因此本着买新不买旧的原则还是买了下 923+,最高达 32G 面向未来。

迁移过程很顺利,但是升级系统+恢复数据+修复还是需要一段时间的,记得先插上一块盘,我这里 RAID10 所以直接塞了两块进去意思意思,等到进系统了再插上两块盘选择修复磁盘(会对两块盘进行格式化重新恢复数据)。

因为是白群晖,所以第一时间配置了 Quick Connect,同时可以用 Active Insight 和群晖管家查看你的设备状况,同时 DS Photo 要升级到 Photos,相册信息需要重新配置;Drive 也需要安装升级版。

这里推荐几个群辉 7.x 的第三方套件中心源:

需要注意我的 Emby 媒体库丢了数据,重新恢复特别糟心……Docker 改名叫 Container Manager 了,比以前好用了一些。

未来配件

准备买块 M.2 固态硬盘和一根 16G 内存条,不确定是不是随便买个就能识别,可能需要试一下,毕竟群晖自己卖的太贵了。坐等双十一吧。

过去文章汇总

DNS 科普·从 DNS 到 DNS 劫持

$
0
0
本文为科普向,文字描述较为浅显易懂,不适合想要深入了解的读者。

Get Started

在了解 DNS 劫持是怎么进行之前,我们需要先了解 DNS 是怎么进行的。这里回到一道经典的面试题:

当你在浏览器里输入 www.baidu.com 时做了哪些事情?

这道题之所以经典,是因为千人千面,你能从「前端」、「后端」、「运维」、「Devops」这几种工种中得到截然不同的答案。

而今天讨论的就是这个问题的第一步:「DNS」。

考虑到这是一篇科普向的文章,我们用一句话解释下 「What is DNS」。

DNS 可以看做一个 domain to IP 的黄页,你告诉它域名,它给你对应的 IP 地址。

那么下一个问题:「DNS 是怎么运作的」。

如图所示,一个用户发起请求后经过浏览器和 ISP 的 DNS 解析器(英文通常是 DNS resolver 或者 Local DNS),然后经由「根域名服务器」、「TLD 域名服务器」和「权威域名服务器」,最终拿到一个域名的门牌号。

17003745014829.jpg
下一个问题是,图中的每个节点到底是拿来做什么的呢?

域名解析绑定

在完全开始之前,一次域名购买到解析的经历能帮助你更好的理解这一切。以 cloudflare 为例,假设你的域名放在 cloudflare 解析,那么 cloudflare 会为你提供两个 NS 记录:
17003745598837.jpg
并且告诉你将这俩 NS 记录绑定到你的域名上。

绑定成功并验证通过后,你就可以进行下一步操作,也就是进行记录绑定了:
17003745693070.jpg

节点解析

简单消化一下,上面的插叙并非突发奇想,而会构成你理解下面步骤的重要部分。

根据上图中讲到的步骤,我们用更拟人的方式来解释每一步:

  1. 用户键入了「codesky.me」这个地址后,首先先问本地的 DNS 记录(你在 Network 的 DNS 设置里能看到对应的 IP,通常第一跳指向路由器),DNS 记录转发去路由器,路由器中下一跳的 IP 地址。路由器说:「你等等,我去帮你问问 ISP,你这个域名的门牌号是什么」。(图中步骤 1)
  2. ISP 收到了请求,但它也不知道,不过作为一个优秀的中间商,它选择问出来了再告诉你。「让我先去挨个问问」ISP 这么想。(图中步骤 2)
  3. 于是它先找了根域名服务器,毕竟现在他还没有任何线索,根域名服务器说,「.me 啊,你去找 .me TLD 域名服务器吧,这是他的名片」。(图中步骤 3、4)
  4. 于是 ISP 又找到了 TLD 域名服务器,TLD 域名服务器说:「codesky.me 啊,它在我这里登记的 NS 记录是这个,你去那边找他吧」(图中步骤 5、6)
  5. 好不容易,ISP 总算是到了「权威域名服务器」处(比如上文我们绑的 cloudflare),权威域名服务器吭哧吭哧的找到对应的 A 记录,返回给 ISP。ISP 总算使命必达了。(图中 7、8)
  6. ISP 拿着对应的 IP 回复路由器,路由器再回复给用户,这下,你总算能访问「codesky.me」了。(图中的 9、10)

然而,如果你绑定的不是一条 A 记录,而是 Cname:
17003746284354.jpg
那么可能「DNS 解析器」又要重新跑一遍了。

而实际上,我们会发现,本图中的步骤数字有些特殊之处—— ISP 自己全问了一遍,而用户到路由器、路由器到 ISP 只是在躺平等通知,这就是两类查询类型:「迭代查询」和「递归查询」。

节点缓存

当然,倒也不用每次都这么辛苦,毕竟「一切能被缓存的东西终将被缓存」。拿你的这一次访问来说,实际上浏览器第一步会先问问自己:「我有缓存吗?」有的话我就不挣扎了(Chrome 浏览器缓存可见:chrome://net-internals/#dns)

如果没有,Chrome 在进行一次系统调用,让操作系统干活。但操作系统说:「且慢,让我来看看我有没有缓存」。

等到操作系统也确认自己没有缓存了,他再去路由器里问(下面同理不再赘述,台词不好编)。
当然,并不需要完全命中也行:假设查完 dashboard.var.moe,他指向了一个 CNAME f.var.moe,但 f.var.moe 之前已经查询过了,那也是可以直接用的。

所以劫持是什么

这里我们说的劫持更多的是个中性词,因为它不止能违法乱纪,还能做些特殊的引流操作。

过去我们往往听到的词是「运营商(ISP)劫持」,运营商个个是人才,说话又好听,可以看到,上文的链路中一直是经过 ISP 的,ISP 自然也是希望降本提效的,因此他会尽可能的缓存下来结果,方便下一次更快的返回,但黑心商户做的可能不止这么多,他们甚至可能会缓存整个网页信息,直接指向一个缓存后的网页而并非你的源站,大聪明觉得:这样我不是更省钱了?你还会觉得访问特别快。

病毒或者木马的劫持是另一种,同样都是基于缓存「直接返回」做的恶。

而在公司内部我们提到劫持的时候,就显得单纯不少了,我们只是在第一跳中将某某域名强制绑定在某个 IP 中,比如在办公网环境下绑定了一个 1.2.3.4 到 codesky.me,那么我们向公司的路由器(或者交换机,或者上游解析服务器)请求时,「DNS 解析器」直接说「不用找了,去 1.2.3.4」。而如果在服务器机房内调用时,由于不经过对应的解析服务器,所以就会按照你绑定的记录正常访问。

17003746742572.jpg

为什么劫持有时候会失败

从上文中我们知道了为什么我们会需要分开来挨个配置不同场景的 DNS 劫持:「DNS 解析器不一样」。

那么失败又是为什么呢?试想一下这样的场景:

公司今天新买了一批用来做办公网访问的设备,小明配置好了,能联网了,但是忘了还要更新劫持清单。

「忘了」,就是最简单的理由,也就是设备列表没理清楚,少更新了。

如何体验:自己搭建一个 DNS 服务器

可以参考 2017 年我曾经写过的一篇文章(实际上我自己都忘了):[[翻译]在 macOS 使用 Dnsmasq 进行本地开发](https://www.codesky.me/archives/macos-dnsmasq-to-local-dev.wind),你可以通过 dnsmasq 体验一个「DNS 解析器」的配置与运行。

附录

DNS Records

上述我们所提到的 NS、A 记录以及 CNAME,如果是初次接触 DNS 的读者一定会疑问:这到底是什么东西,实际上 DNS 记录不只有上面说的几种,但常用的就那么几个,简单介绍一下:

  • A 记录:保存域名 IPV4 门牌号。
  • AAAA:保存 IPV6 门牌号。
  • CNAME:你去找谁(域名)问。
  • NS:谁是权威。

当然,A 和 AAAA 可以不止一条,他会以循环的方式以此请求,以便进行多 IP 的负载均衡。
另外:iOS 一直在实验 HTTPS 记录,它还是一个 rfc:https://www.rfc-editor.org/rfc/rfc9460.html,用于传递更多信息,比如:

example.com 3600 IN HTTPS 1 . alpn=”h3,h2” ipv4hint=”192.0.2.1” ipv6hint=”2001:db8::1”

这一记录中表明支持的 HTTP 版本和 ipv4、ipv6 的地址,设备会同时发起对 A / AAAA / HTTPS 的请求,如上文 HTTPS 记录中的信息表示齐全,那么就不会使用 A / AAAA 的内容了。

Reference

DNS 科普·DNS 安全

$
0
0
上回我们说到 DNS 的基本知识以及 DNS 劫持的科普,那么 DNS 安全上又有哪些措施呢?

加密通信

如同 HTTP 通信一样,默认的 DNS 查询同样也是明文的,那么必然也可能遭受到攻击,比如下图中表示了一次中间人攻击(图源 cloudflare):

dns-traffic-unsecured.jpg
那么同样的,我们也可以通过加密流量的方式来保证数据的可靠性。对于 DNS 来说,主要有 DoT 和 DoH 两种。

DoT (DNS over TLS) 意味着信道会基于 TLS(SSL) 进行加密传输,而 DoH(DNS over HTTPS)则是基于 HTTPS / HTTP2 进行数据加密传输的。当然,除了这些以外,还新推出了一个叫做 DoQ 的(DNS over QUIC)

从 DoT 到 DoH 到 DoQ,为什么会产生不同的设计和演进呢?

DoQ 的产生相对的比较好理解,毕竟我们可能已经读过太多关于 HTTP2 究竟还有哪些待解决的问题,而 DoQ 又改善了其中的哪些点这类的文章:比如「减少 TCP 三次握手及 TLS 握手时间」、「改进拥塞控制」等等,关于 QUIC 暂不在本文的讨论范围中,因此不做过多赘述。

而从 DoT 到 DoH 的演进和选择则更让人迷惑:加密一下不就够了,为什么还需要基于 HTTP 协议呢,HTTP 协议设计的更为复杂,不够轻量,仿佛会增加通信的成本,增加数据包的大小(比如存在一些不必要的 HTTP Headers)。

他们同时产生并且不被淘汰的原因更多的是安全隐私之间的取舍,TLS 使用 853 端口,而 HTTPS 使用 443 端口,试想一下,当我们需要进行针对性的流量审计时,如果我们有一个明确的端口号特征,是不是更容易受到管控和监听?

而如果使用 443 流量,DNS 查询的流量就被混在了万千普通 HTTPS 连接中,对于用户来说,隐私加密性更高。

加密了通道,我们就可以避免在 DNS 查询期间被偷天换日了。

HTTPDNS

但是很明显,上面所有的防御手段并不能防止所谓的 DNS 劫持——如果作妖的不是中间商,而是服务器本身呢?

而 HTTPDNS 选择绕过 DNS 本身,直击灵魂深处:使用 HTTP + IP Port 请求 HTTPDNS 服务器,由它直接返回内容,减少中间商赚差价的情况。

回忆一下我们原来的链路设计,假设完全没用缓存,需要经过 10 步,用户才能最终拿到 IP。(请见上一篇文章:DNS 科普·从 DNS 到 DNS 劫持

17003745014829.jpg

而在 HTTPDNS 中,假设我们的 HTTPDNS 服务器 IP 为 6.6.6.6,只要告诉他需要查询的域名(codesky.me),HTTP Response 中就会告诉你对应的 IP。

17015243205754.jpg

当然,这本身并不是 DNS 协议的一部分,而是为了避免劫持污染问题应运而生的一项「应用服务」。因此在实际使用 HTTPDNS 时,往往会同时提供一些注入:「SDK」、「软件」等工具,来保证「先请求 HTTPDNS 服务器,再带着 IP 直达业务链接」。
如果我们用 curl 来模拟请求,相当于用户从原来的一次:

curl https://codesky.me/search

变成了:

curl https://6.6.6.6?domain=codesky.me # 返回 1.2.3.4
curl https://1.2.3.4/search -H "Host: codesky.me" # 直接用 IP-Port 访问

总结

说完了这一部分,DNS 的科普之旅暂时告一段落,由于近期工作比较忙,跑医院也比较多,MySQL 笔记只整理了 40%,正在努力。

如果有其他前后端方面希望我讨论(科普)的话题,欢迎留言。

参考资料

禁用第三方 Cookie 战记:我的第三方 Cookie 怎么办

$
0
0
本文适用人群:受到第三方 Cookie 影响,想要一些第三方 Cookie 解决方案的战友

本文可能会是我插图最多的文章之一,因为要解释清楚各种概念,可能需要引用很多张图片辅助消化。

尽管 Google 在 2022 / 2023 疯狂铺垫自己要禁用第三方 Cookie,大家马上改造,但实际上大部分人的工作还是在死线前后死亡冲刺。Google 官方宣称禁用第三方 Cookie 是为了「减少跨网站跟踪,同时确保让每个人都能免费访问在线内容和服务的功能」,但实际上好处还没感受到,却带来了一大堆麻烦。

Story Start

第三方 Cookie 是什么

在开始之前,首先简单介绍下第三方的定义,Google 的解释是:

第三方是指由与您正在访问的网站不同的网域提供的资源。
例如,网站 foo.com 可能会使用来自 google-analytics.com 的分析代码(通过 JavaScript)、来自 use.typekit.net 的字体(通过链接元素)以及来自 vimeo.com 的视频(在 iframe 中)。另请参阅第一方。

也就是说,跨站携带的 Cookie 就是第三方 Cookie

那么下一个问题,跨站的定义是什么,或许你在面试题中会听到「跨域」、「跨站」,他们俩到底有什么区别:

  • 跨域:域名、端口号、协议有一个不一样,就是跨域的
  • 跨站:eTLD 相同的站点,不用管端口号,但需要注意协议

25486.png
1937.png

请注意:下文我们说的所谓的「同站」、「相同站点」都是基于这一定义衍生的名词。

我为什么要改造

实际上当你看到这篇文章才开始改造时已经晚了,因为 Chrome 已经开始进行 1% 的灰度了:

38045.png
也就是说,已经有部分用户会受到影响了,每个人都身处漩涡之中,不过好处是,在最差的情况下,你可以选择引导用户关闭第三方 Cookie。

关于这个的教程可以参考:https://support.google.com/accounts/answer/61416?hl=zh-Hans&co=GENIE.Platform%3DDesktop,本文不多做赘述。

同时需要注意,Safari 和 Chrome 的解决方案是不同的,这点在下文介绍解决方案时会再次说明,而 Safari 其实已经禁用第三方 Cookie 很久了,所以如果你没有考虑过,现在也不用想得太复杂。

所以解决方案是什么

在明确解决方案之前,首先我们需要知道有那些场景,Google 为不同的场景提供了不同的解决方案:

  1. 用户标识符:各种数据统计上报可能会用 Cookie 来做用户标识符(uid / fingerprint),通过数据上报进行各种统计(比如 PV、UV)
  2. iframe:过于典型,嵌入站点常规使用 Cookie 就会遇到
  3. 个性化投放:用于进行广告归因和行为分析并进行投放
  4. SSO 登录:跨站共享登录身份
  5. CSRF Token:防止 CSRF 攻击

而针对这些场景,Google 提供了一系列解决方案,包括但不限于:

  • Topics API:用于解决用户个性化投放(场景 3)
  • Attribution Reporting:用于衡量广告点击或观看何时促成转化,例如在广告客户网站上完成购买。(场景 3)
    • Protected Audience API:设备端广告竞价,用于向再营销和自定义受众群体投放广告,无需进行跨网站第三方跟踪。(场景 3)
  • FedCM:支持联合身份服务,可让用户登录网站和服务。(场景 4)
  • 私密状态令牌:可通过跨网站交换有限的非身份信息来防范欺诈和反垃圾内容。(场景 5)

值得一提的是,根据我个人对于 FedCM 的理解,结合Federated Credential Management API 开发者指南,FedCM 像是身份提供方与浏览器的梦幻联动,因此更像是 Chrome Only 级别的解决方案。

fedcm.gif

而场景 1 用户标识符,参考 Google Analytics SDK 的实现,我们其实可以以前端的方式写入 Cookie,再用 querystring 拼接身份标识符来解决上报问题

而上述介绍的 API 目前还可能受到兼容性的挑战,因此本文更多希望站在第三方 Cookie 本身来讨论问题。

因此本文的重点在对于 iframe(场景 2) 和 SSO (场景 4) 的解决方案,并且尽可能的考虑到方案的通用性。

解决方案

狠一点的话走代理、加绑同站域名当然也是一种方式,但大部分场景下没有办法这么简单粗暴。

LocalStorage / Querystring

最简单的 Cookie 平替就是 localstorage 或者 querystring。

比如上面我们曾经说过的 Google Analytics SDK,假设在某些场景下(比如 iframe)前端无法顺利的写入 Cookie,也可以将值写入 localstorage,在上报时读取 localstorage 并通过 querystring (或者 body)带入接口。

也就是说,我们用 localstorage 作为持久化手段;将 querystring 作为前后端通信手段,来等效的解决 cookie 的读写问题。

值得一提的是,localstorage 同样也是分区的,而不是全局共享的,例如我直接访问 www.codesky.me 写入 localstorage 的值,和我在 xsky.me 中嵌了个 iframe,iframe 内嵌入 www.codesky.me 写入的 localstorage 值是独立的,无法交叉读取。

Storage Access

本方案通用性较高,Chrome(Chromium)、Firefox、Safari 以及移动端都支持但考虑到几个 Chromium 内核浏览器都是 2023 年十月左右的版本加入的,所以建议增加兼容性代码。此外,Webview 不一定兼容,根据 MDN 的说法,2023-12-05 才加入。

如果你用过浏览器的其他 API,Storage Access 相比之下就很好理解,简单的来说就是先问「我能不能用」,如果获得了用户的授权,接下来申请的站点就能正常使用 Cookie 了,这一部分 Cookie 甚至可以是未分区的 Cookie,也就是可以在授权站点内达成形如「未禁用第三方 Cookie」所表现的效果。

Storage Access 使用上来说体验会有一些劣化,正如上面说的,他需要用户先进行交互才能正常调用 document.requestStorageAccess()(简单的来说就是不能自动触发,需要用户点个按钮才能正常触发事件)。你还可以通过 hasStorageAccess() 来检测用户是否已经授权。这两个 API 都需要在 iframe 内部才能触发。

这一授权是站点到站点的,也就是即使你内嵌的是 www.codesky.me,之后换成了 m.codesky.me,授权仍然有效。

需要注意一点,只有 iframe 加上了以下属性才能成功触发事件:

  • 必须授予 allow-storage-access-by-user-activation 权限才能访问 Storage Access API。
  • 必须授予 allow-scripts 权限,才能使用 JavaScript 调用该 API。
  • 必须授予 allow-same-origin 权限,才能允许访问同源 Cookie 和其他存储空间。
<iframe sandbox="allow-storage-access-by-user-activation
                 allow-scripts
                 allow-same-origin"
        src="..."></iframe>

28633.png
21581.png

当然,这并不意味着每次你都需要让用户点击按钮才能继续,在授权一次后接下来有一定时间的保质期,保质期内的规则是:

  • 默认同意,不需要再次手动选择
  • Chrome 和 Firefox 之后可以允许静默调用 document.requestStorageAccess(),而 Safari 需要每次都进行互动(也就是每次访问都得点一下授权)

当然,接下来我们会将一个叫相关网站集的概念,有了它就可以很好的简化这一授权流程:

72358.png

另外,刚刚我们其实也提到了,这两个 API 只能用于 iframe 中,Chrome 更进一步提供了顶级页面用的 API:requestStorageAccessFor(),*如果跨网站请求包含 CORS 或跨域属性,则这些请求将包含 Cookie。这样可以一定程度上解决一些站点前后端跨站的问题,但问题是——这个 API 兼容性过于离谱(其实就是 Chrome 新加的),如果需要进一步了解,可以参考:Document: requestStorageAccessFor() method

这个方案更多可以阅读:Storage Access API

相关网站集

刚刚我们也提到了相关网站集这个概念,相关网站集相当于你有一堆看上去毫不相干的网站(都是不同站),比如 codesky.mexsky.me,但实际上都是你的网站,你也希望他们之间能够互相共享,拿 Chrome 官方的例子就是,首先你需要有一份配置文件,形如:

{
  "primary": "https://primary.com",
  "associatedSites": ["https://associate1.com", "https://associate2.com", "https://associate3.com"]
}

然后把这份 JSON 提交给 Google 的 GitHub 仓库(这步属实魔幻行为),提交成功后就可以作为「相关站点」处理,然后你在调用上面提到的 document.requestStorageAccess 或者 requestStorageAccessFor 就可以尽享特权了——没错,费了半天劲,结果还是需要唤起他俩 API。

而且提交给 Chrome 的仓库,怎么想都不会是一个标准化行为,如果其他浏览器要效仿标准,难道要维护一份 Firefox 版和一份 Safari 版吗?因此这个方案看上去更迷了。

具有独立分区状态 (CHIPS) 的 Cookie

CHIPS 这个方案是我目前大力推荐的一种改造形式,因为他改造成本小,在 iframe 上作为解决方案效果较为显著,只需要注意一些 corner case 就能很好的解决这一限制。

CHIPS 也就是分区的 Cookie,用官方出品的图比较好理解分区 Cookie 这个东西:
17052476225833.jpg

17052476000888.jpg
未分区时 C 的 Cookie 无论是嵌套在 A 站点还是 B 站点都可以读取,而有了分区(Partitioned) Cookie 后,A 站点嵌入 C 和 B 站点嵌入 C 之间读取的 Cookie 是无法共享的,如果用 DB 的概念来说,其实就是加上了联合索引:

16731.png
这样你就能简单理解什么是 Partitioned Cookie 了,在浏览器支持上,目前 Firefox 和 Safari 都是不支持的。(但 Firefox 宣称他们对所有的第三方 Cookie 默认都是分区的,只是 Chrome 觉得这个方法不好,可能会带来不必要的麻烦/Bug;而 Safari 中是真没办法,还得用 Storage Access API 申请。)

但虽然不支持,如果你在低版本浏览器或者不支持的浏览器中使用,也不会产生什么副作用,只会默认忽略这个 Partitioned 标记罢了。

Set-Cookie: __Host-example=34d8g; SameSite=None; Secure; Path=/; Partitioned;

如果是不支持 Partitioned 的浏览器,会将其忽略,也就是:

Set-Cookie: __Host-example=34d8g; SameSite=None; Secure; Path=/;

Chrome 新版虽然禁用了第三方 Cookie,但如果你分区了,就可以正常在 iframe 内读写隔离版 Cookie 了。

这个方案就是这么简单吗?——对,就是这么简单,这也就是为什么我(以及 Google)都拿这个方案出来说的原因。

当然,实际改造中需要对读写进行一些处理:从优化体验的角度以及 Google 的建议,你不应该默认无脑写分区 Cookie(这个论点可以参考Partition all third-party cookies by default),尤其是在顶级站点的访问上。也就是说理论上一个良好的体验是需要你按需写入 Cookie 或者双写 Cookie 的。双写可以更好的让那未被灰度到 99% 的用户保证原来的访问体验,而 1% 的用户使用 Partitioned 版本。

但所谓双写一时爽,如果你只是普通的对 Cookie 进行读写似乎没什么问题,如果你在同一个 Cookie 中进行 Append 操作,也就是 Cookie 写入不幂等的情况,可能就会出现一些尴尬的情况了,场景可以类似于一个加减法:

99264.png
也就是说,不幂等会导致 Cookie 不一致,在未禁用第三方 Cookie 那 99% 的用户场景下可能会带来灾难性的后果——因为我不知道读的是哪一个。

特别需要注意的是:尽管在前端可以看到两个 Cookie 一个带 Partitioned 标,一个不带,但是在后端只能收到 Cookie 的 key 和 value,所以对于后端来说,这只是普通的两个同名 Cookie。(RFC 中提到:客户端实际上有排序规则,但服务端不应该依赖排序)而笔者读了几种语言的标准 cookie 库,通常本质都是构造一个 Map,或者直接以分号和等号作为分隔符进行切割并循环遍历,本质上都是取第一次出现或者最后一次出现作为有效 Cookie,而忽略其他结果。),目前几乎所有的标准库实现对倾向于取一个(也可能是因为 RFC 中提到客户端如果收到多个同名 Cookie 只取一个,而库之间尽可能对齐带来的现象)。

如果对此抱有疑问,可以通过检查后端库或者观察目前站点发送同名 Cookie 的表现来预测行为是否会存在异常。

而不同于上面的兼容性处理,对于这一方案来说,更重要的是「降级方案」,也就是如果 Partitioned 实现过程中出了问题,我们是不是只能让用户去清除他们的 Cookie——实际上我们可以通过写入 max-age=0 的 Cookie 来清除 Cookie,或者使用 Clear-Site-Data 来清除 Cookie,这一点在 Chrome 对于 CHIPS 的提案中有所提及。

再次说明,这个方案相当简单粗暴好用,但解决不了 Safari 第三方 Cookie 的问题。

共享存储空间

共享存储空间(Shared Storage)可以提供一系列 API 帮助你实现未分区的跨站存储,这样就可以和以前使用 Cookie 那样使用它了。

虽然什么方案都会比相关网站集方便,但这个 API 高度依赖 fencedframe,而这个 HTML 标签甚至没有一个属于自己的 MDN 页面,给这个方案带来了一些「面向未来」的感觉,笔者并不推荐,研究的也比较少,如果仍想了解,可以参考:

JWT / Access Token

最后说了半天,前面的解决方案好像大部分更多着眼于 iframe 中,还是不太能解决 SSO 场景中的问题,对于 SSO 来说,流程可以简单看做:

33233.png
登录步骤从原来的写 Cookie 变成写 localstorage,从某种程度上来说也是从后端写变成了生成页面前端写(后端返回 HTML 限时复刻)。最后再跳回原始页面。

其中,Ticket 是一次性的;而 Token 是持久化的,具有一定时间的保质期。

当然,也可以是 callback 直接带着 ticket 跳回原始页面,原始页面收到 ticket 后自己换 token 写入 localstorage 进行持久化(使用 localstorage + querystring 是我们最早提出的方案)。

而对于登录态的判断上,也从原来的直接调用接口变成了需要从 localstorage 取值,或者拿着 ticket 去换 token 再鉴权。(注意,这里 ticket 必须马上被消费掉,避免泄露后被利用)

58106.png

这套方案最麻烦的地方可能是,即使 Cookie 中用户数据和 Token 的算法进行了对齐,值都一样了,但是后端还是取不到 Cookie 值,需要前端显式的传入,而在显式传入的过程中,必然涉及到后端的读取改造,这一方案对于整站的回归成本极大。

另外需要注意的是,建议不要直接把 Token 放在 querystring 中,这相当于在 URL 中写明:username=sky&password=123456 这么露骨,可以选择放在 Header 或者 Body 中,更为安全。

这一方案的优点是,绝对通用,一次改造,终生受益,没有任何兼容性烦恼;缺点是,改造成本大到多数站点会退避三舍。

本身想把这个方案称为 JWT,但实际上他只是一个普通的 Access Token,而加密算法和内部实现还是有很多灵活空间的。如果真的采用 JWT,登出问题可能会变成另一个新的问题。

总结

尽管第三方 Cookie 已经禁用有一周多了,各种文章也同步出台,但是很少看到有具体完善的解决方案,而本文借用相当长的篇幅去介绍多种解决方案,所以写作成本比较高,收集了大量资料和花了好几个小时才写好(其实是拖更了一周多 emmm)。

本文确实有点长,感谢大家耐心的读到结尾,如果有对于第三方 Cookie 其他方案的想法,也欢迎留言讨论。

最后,最近有个小想法,之后更新的时候想更新一些自己的「想法」而不是「实践」,因为「实践」写作/验证周期比较长,而「想法」较为天马行空,更偏向「脑洞」,更新起来比较容易。最近有很多杂七杂八的感慨,如果大家有兴趣,之后我会开始写更短篇幅的想法类分享。

Reference

本文文章中链接较多,Reference 不代表全部引用文章。


2023 Web 开发年度观察报告

$
0
0
本来并不是很想写这类文章,很明显我这几年所有文章都聚焦在解决方案和落地经验,因此大家也可以看出,这是篇《有背景》的文章——当我接到这个需求的时候,我数了数我去年写的七篇文章,和行业趋势有个半毛钱关系。——此处插入一首阳光开朗大男孩。
本文不含娱乐圈八卦,但可能带主观吐槽,此外由于对文中提到的内容并没有深入理解,可能会有失偏颇,关注点和选材也多为「本人」感兴趣的部分,如果有误请各位大佬轻喷指正。
本人不跟风任何热点,纯属观众,能把这篇文章写完已经实属不易了(抹泪)。

年度数据观察

在全部开始之前,首先向大家推荐下几个我每年都会看的年度统计报表,主打凑个热闹:

当然,Golang 也有自己的 H2 统计:https://go.dev/blog/survey2023-h2-results

这些统计可以帮助你更好的了解到行业画像和最新热度,即使不怎么追新也可以在最短的时间在这些报告中了解到一些本来可能不认识的技术点。

前端年度总结

刚开始接到这个任务的时候我的的第一反应是:我?前端?我追前端现在追的最勤快的是娱乐圈八卦。

就 2023 年的前端来说,没有革命性的爆点,不过某种程度上大家也都是在不断的完善前端社区过去遗留的不足。

Node.js

2023 年 4 月 Node.js 20 都来了:主要亮点包括了全新的 Node.js 权限模型、同步返回的import.meta.resolve、稳定的 test_runner,将 V8 JavaScript 引擎更新至 11.3 版本,Ada 更新至 2.0 等等!

一个 import.meta.resolve 同步返回总算解决了之前别扭的 await import() 或者文艺复兴 require()

更多 Changes 参考:https://github.com/nodejs/node/releases/tag/v20.0.0

当然,一边我们为 Node.js 20 拍手叫好,另一边 Node.js 16 的世界已于 2023 年 9 月悄然落幕。让我看看现在还有多少公司在维护 Node 14 的项目。

55559.png

前端框架与工具

Vue2 终于也在 2023 年 12 月 31 日达到了终止支持时间,未来属于 Vue3!

当然 Vue 为你关闭了一道门,就会为你打开一扇窗,你可以选择充值变强。

43128.png

参考:https://v2.cn.vuejs.org/lts/index.html

另一方面,Vue 在 2023 年 4 月就推出了考证服务,一度引发了写 Vue 需不需要持证上岗:

83391.png

当然大家可能是忘了早在 2019 年,工信部就发布了一版《Web 前端开发职业技能等级标准》,也没见各位持证上岗。

不管怎么说,Vue 项目以及整个 Vue 生态一直给我一种在个人探索开源新的可能性的感觉,如果开源世界能产生互利共赢,或许会比现在这种多数白嫖发展的更好(你说是吧 B 站)。

React 本人一直不怎么会,关注的也不是很多,不过 React 全新的文档站也一度引起热议:一方面,React 在其中努力穿插了各种交互式学习,图文并茂的文档极大的减少了上手难度,并且对于一些容易出问题和有歧义的点特别贴心的在官方文档中就指出了。

但另一方面,需要阅读一本厚厚的手册、官方也必须把常见坑写在文档里提醒开发者避免的一个系统究竟是不是一个好系统也让人感到困惑。或许 React 是把自己的心智成本放在了台面上——要精通我可是很难的,不要随便在简历上写「精通 React」了。

甚至 React 自己都提议别直接用它,而是用上层封装的框架了:
25631.png
考虑到这样的学习成本,或许 React 比 Vue 更适合持证上岗 。

本条参考:https://react.dev/blog/2023/03/16/introducing-react-dev

同时,2023 年 3 月发布的 rspack 其实也很有意思,尽管一定意义上「启发」和「支持」的清单不得不让人遐想这是不是一个大型缝合怪,而从文档卖点中品出来的大概是:「是 Rust,我们换了 Rust」。关于 Rust 的话题后续再提,某种意义上也算是前端开发工具链全面 Rust 化的一小步了。但不可否认的是比起 webpack 确实有很大的性能提升。

参考:https://www.rspack.dev/zh/blog/announcement.html

除此以外,在 JavaScript Rising Stars 里有几个很有意思的项目:

Bun 终于发布了属于它的 1.0,和 Deno 一样作为 Node.js 的替代品,主打一个性能更好、语法兼容——JavaScript 运行时的战争可能才刚刚开始。

htmx 竟然在前端框架榜第二名,甚至超过了 Vue.js。

72659.png
但你看他的语法像不像一场文艺复兴,梦回十年前。无论如何,在前端框架越来越复杂的当下化繁为简,回归质朴,感觉是个非常适合后端开发体质的框架。

如果你对 Htmx 感兴趣,或许还可以读一下这篇:Htmx 意外走红,我们从 React“退回去”后:代码行数减少 67%,JS 依赖项从 255 下降到 9

TypeScript

TypeScript 5.0 在 2023 年 3 月发布,编译性能有明显提升。

92468.png
13926.png

而一系列新特性更像是「帮助我们更好的跳体操」,增强了一系列类型推导的能力,让之前不方便跳的体操变得更容易了。当然,装饰器、const 修饰符、Union Enum 之类的更新某种意义上也能降低跳体操的成本,毕竟谁记得整天 as const 呢。

如果你想了解具体增加了哪些 features,可以参考:https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/

一方面,TypeScript 在努力的更新自己的体操姿势,而另一方面也掀起了反叛大旗:退出 TypeScript 阵营回归 JavaScript,比如 Svelte 就宣布回归 JSDoc,从 TypeScript 退回 JavaScript。这一行为也一度引发热议,很多人都说「再也跳不动了」。

99787.png

当然,我始终坚信在大型软件开发过程中「强类型约束」的必要性,虽然我刚学编程时也对于弱类型趋之若鹜,非常推崇包括 PHP、Python 和 JavaScript 在内的弱类型脚本语言,但在企业级应用的开发中,弱类型带来的隐式危害太过可怕,当接手一个几年维度的项目,没有类型定义的情况下进行改造可以说是噩梦般的体验。

而 TypeScript 为 JavaScript 提供的强类型保证简直是梦中情人(如果你不用 any 的话),唯一的问题就是跳体操的难度略高,很容易变成 AnyScript。(TypeScript 的类型推导越智能,体操成本就越小,只能说一句未来可期)

Svelte 退回 JavaScript 更多的是出于对自身项目的考虑,同时也依旧提供了 d.ts 的类型文件,所以使用者并不会受到影响。实际上 Svelte 团队也并没有否认 TypeScript 的价值,大家也不用无脑跟进。

浏览器

终于要提到我最喜欢的浏览器环节了,毕竟我长期跟浏览器出于相爱相杀的关系(指每次遇到 Chromium 的 Bug 就一阵激动)。

首先不得不提几个 CSS 的新特性,实际上 2023 年互联网上推了好多 CSS 的新特性,比如 container queriesCascade LayersColor Functions 等等,但都不知道何年才能真正落地在生产中,但是有些特性却在今年终于转正了,或许在不远的将来,我们就可以不用 CSS 预编译器了。

2023 年九月后,CSS Nesting 总算在 PC 浏览器市场遍地开花,如果你是个纯 PC 应用,那么现在你已经可以愉快的在没有预编译器的情况下使用这一特性了。

45436.png

另一方面,CSS :has 选择器也让 CSS 样式变得更加灵活了,毕竟我们愁了好多年的父选择器终于有找落了,随着 2023 年 12 月 Firefox 浏览器的实装,终于可以全面开花了:

60431.png

当然,关于这个特性,除了用来作为父节点外,其实还有更多的遐想空间,可以参考:https://css-tricks.com/the-css-has-selector/

Chrome 的更新方面,Chrome 113 中对开发者工具增加了一个我个人非常喜欢的功能:HTTP Mocking。现在你不需要使用额外的抓包软件(比如 Chales 或者 Proxyman)就能修改请求:

33420.png

甚至是为它添加上缺失的跨域头。

17057608720378.jpg

关于这一点可以阅读:https://developer.chrome.com/blog/new-in-devtools-113

Chrome 116(2023.8)版本中支持了自定义画中画的 API,让任意元素置顶。给了一些需要置顶能力的 Web APP 新的展望空间(比如官方例子中的番茄时钟),当然,对于国内视频网站来说,可能更多的是给了贴片广告更多的想象空间。

可以通过这个了解更多:https://developer.chrome.com/docs/web-platform/document-picture-in-picture?hl=zh-cn

Chrome 119 版本(2023.10)开始会默认启用 WebAssembly 垃圾回收 (WasmGC),这样 WebAssembly 的未来更加具有想象空间了。比如可以支持更多的语言,不再需要将语言虚拟机编译进来,提高性能,减少目标代码体积。

可以阅读了解更多:https://developer.chrome.com/blog/wasmgc?hl=zh-cn

而让人最为头大也最重量级的恐怕就是开年大礼包的第三方 Cookie 了,尽管他是在 2024 年 1 月 4 日正式开始灰度的,但是过去一个月的日子都不太好过,上一篇文章刚刚讲过第三方 Cookie,这里就不再赘述了,可以读我的上一篇文章:禁用第三方 Cookie 战记:我的第三方 Cookie 怎么办

鸿蒙

2023 年鸿蒙也有大动作,比如鸿蒙终于宣布不再兼容安卓,并且公布了自己的开发者生态工具,无论它抄的是 TypeScript 还是抄的是 Flutter 我其实都不太在意,某种意义上对于前端来说学习成本相对较低。搞不好这也是一波新的就业岗位呢。

只是做跨端的同学们就猛男落泪了,日子还过不过啊。

阅读了解更多:https://developer.harmonyos.com/cn/develop/

语言

刚刚我们提到 Rust 之后再谈,因此我想在语言这个篇章里聊聊今年可能会被大家关心到的几个语言,(为此甚至我还特地查了一下 wasm 算不算语言)。

但个人其实并没有用过,只能凭借自己的理解稍微提一下。

49849.png

WebAssembly 的一个大更新在上面的 WebAssembly GC 中已经提到过了,如果你还有更多想要了解的特性,可以参考:WebAssembly 2023 年回顾与 2024 年展望。对于我来说,它带给了浏览器这个运行容器更多的可能性,或者可以作为跨语言的的兼容层使用(FFmpeg 可能是经典场景吧)。

Rust 在 2023 年依旧是科技与狠活的代名词,这不仅体现在前端的 All in Rust 重构中,实际上服务端、AI 领域也都能找到他的影子。

比如 Nginx 官方开坑:模块使用 Rust 编写,相比 Lua 或者 C++,Rust 更能让我接受(好比 Excel 支持了 Python),不过项目目前处于「WIP」状态,希望能够把这个坑完善好,项目地址:https://github.com/nginxinc/ngx-rust

而因为其可靠性、高性能,之前的区块链、现在的人工智能场景也大量的使用 Rust 进行开发,目前 Rust 似乎成为了系统编程的头把交椅。毕竟 C++ 虽然快,但实在太折磨了,而 Rust 更现代化,同时提供了高性能,为什么不呢?

但与此同时,Mojo 的横空出世或者又会带来新的革命,首先它兼容了人工智能界大佬 Python 的语法,甚至可以复用 Python 库,像极了每个想要淘汰上时代语言的新语言的做法。而比 Python 快 35000 倍这个数字实在太过诱人。尽管我并不会太过纠结语言间的性能差距,但谁不想要一个更快的语言呢。

关于这一点也可以阅读:Mojo 正式发布! Rust 能否与之匹敌?

数据库

数据库上不得不提 StackOverflow 年度统计中的一个神奇数字了:「PostgreSQL 超越 MySQL 成为开发者心头好」,不知道这一增一减有没有锅在 Oracle 的嫌疑,不过 MySQL 其实也在努力为自己擦屁股,比如 MySQL Router 终于实现了读写分离,尽管我们常常说「读写分离」,但应用层的读写分离和引擎层透明的读写分离还是差了很多的,极大程度的减少了数据库和开发的管理、研发成本。

关于 MySQL 支持读写分离可以阅读:https://dev.mysql.com/doc/mysql-router/8.3/en/router-read-write-splitting-configuration.html

另一方面,今年国产数据库处于遍地开花状态,但我依然对国产处于一个观望状态,一方面是 MySQL Based 我为什么不直接用 MySQL,另一方面是其稳定性和可靠性还没有经受住一些大型应用的验证。当然,政府和银行有了更好更新的选择。

本想再聊聊因为 AI 而爆火的向量数据库,但是本人实在没什么了解,所以还是不多说了。

大模型

如果要提名互联网行业 2023 年度热词,那我估计 GPT 一定榜上有名,自从 OpenAI 推出 ChatGPT 后,大模型应用层出不穷,现在谁上班还不用个 ChatGPT + Copilot 呢(或者类似的组合)。

遥想当年,从支持向量机的复杂公式,到深度学习苦哈哈的写爬虫、区分训练集测试集还要担心过拟合,到现在只要会打字,写 Prompt 就能实现自己的专属 AI 应用也不过寥寥数年,世界变化的实在太快了。

紧随 ChatGPT 的脚步,国内也有自己的文心一言、讯飞星火、豆包,而海外 Google 也推出过 Bard、NewBing 等等,但强度仍然差了一个版本号。

而模型上,GPT 升级到了 GPT4,甚至有了 GPT4V,DALL-E 2 升级到了 DALL-E 3,Stable Diffusion 也升级到了 Stable Diffusion XL,给应用层开发带来了更多的可能性。

包括上面提到的 ChatGPT / Copilot,你可能还用过:

  • 集成工具:Poe / TypingMind
  • 开发者工具:Cursor / Jetbrains AI / v0.dev
  • 写作:Notion AI / Grammarly / Office 365
  • 图像生成:Midjourney / Imagen

全民 AI 时代正在到来,但比起应用软件,我个人更希望 AI 能结合硬件,让生活变得更好,比如阿尔兹海默症老人看护之类的社会问题。

而 AI 带来的版权问题可能是长期挥之不去的一大问题,尤其是对于图像和文章生成类的工具,不能说让创作者没饭吃,但一定程度上确实侵犯了他们的版权(未经允许拿来训练,生成了相似的画风)。

当然对于我个人来说,我对于 AI 的看法依旧不忘初心:这辈子我就想玩到 VRMMO,类似于「刀剑神域」或者「加速世界」,希望有生之年虚拟世界能真的是「世界」。

降本提效

说到降本提效,就不由得说起一直以来议论的云计算成本的问题了,实际上有很多公司在评估了成本后决定从下云——转回自建机房,来更好的控制成本以及对应用程序和数据做到完全的控制。

关于上云还是自建的议论一直以来都没有停下来过,国内公有云的稳定性也一直为人诟病。

互联网终于从「加机器」「上云」到了关注基建成本、注重资源消耗的时代了,虽然大厂依旧有钱,但可能在不远的将来就会一个 U 一个 U 的抠了。

有兴趣也可以阅读:

在我看来,更多的不是下不下云,而是从「上了云我们就可以减少对基建的建设」变成了「你也不能乱来」,开始真正对应用进行资源消耗的管控了。而具体最终的选择是不是下云,还是得结合实际情况来看。

降本增效的另一面,是今年国内外大厂频繁的宕机,前有 Twitter(X) 、苹果宕机的珠玉在前,后有国内大厂的前赴后继,比如「腾讯视频」「阿里云」「语雀」「滴滴」「Boss」「钉钉」「B 站」,让大厂一直以来对自己做高可用建设的分享变成了年度笑话。

当然,这里并不是说高可用真的没用导致的问题,回顾大部分事故,似乎都是由一个非常智障的人工错误所导致的问题,也就是说,高可用并不防呆,防呆更像是个项目管理、研发流程上的问题。

另一方面,也确实和「裁员裁到大动脉」有密不可分的关系,大厂过河拆桥式的裁撤老人,导出了问题找不到人的情况也比比皆是,降本增效也就成了降本增笑。

总结

总的来说,2023 年对于互联网依旧是爆发式的一年。

实际上,前端娱乐圈也依旧时常有乐子发生(这可比技术好看多了)。

大模型会不会让我失业我也并不清楚,但我相信未来 AI 能让生活变得更好。

你问 2023 年后端在干嘛?后端在闷声发大财吧。

瞎逼逼:研发效能度量的得与失

$
0
0
之前曾经说过关于更新成本较大,之后如果有一些只是思考而不是落地实践后验证的产物的话更新可能会快一点,因此有了本期——以后就叫「瞎逼逼」系列了。

「瞎逼逼」系列不严谨,只是个人的一些简单粗暴学习后的粗浅的看法,请注意。

故事开始:工时计件制

众所周知,程序员不是一个计件工种,但程序员的工资又高,因此,老板们总得有一个考核办法,能够让牛马们心悦诚服的接受自己的工作成果。

最低端的不懂技术的老板,会像工厂组长那样,研究一个程序员每天的打卡时间:这个公式可能是「下班时间 - 上班时间 - 午休时间 = 工作时长 = 你对工作的上心程度」,如果粒度更细的话,可以计算你每次进出门的打卡记录差值,也可以在厕所装上计时器,再完美的扣除「带薪拉屎」的时间。

其实我自己也装过类似于 Timing 之类的用于记录电脑使用阶段前台应用的使用时间,也就是说公司只要愿意统计,完全可以再看看,你多少时间在上网冲浪挂机,多少时间写代码。甚至老板也可以像考场里的监考老师那样四处巡视,看看谁不在工位上。

最终毫无疑问,老板能够得到一个量化数据——你的有效工作时长是多少。但归根结底这也就是个乐子,你肯定不服啊——咱们多少页算得上是个脑力劳动吧,这个量化方法太像工厂拧螺丝的了吧。

强度升级:代码行数 / 需求数量 与 Bug / 事故率

接下来,稍微上点道的老板会想到一些新的统计方式,比如「统计你一周做了多少需求、写了多少代码」又或者根据你写的「Bug 数」、「事故率」来衡量你的专业水平。

在外行眼里乍一看,这似乎满足了「脑力劳动」的衡量标准,但实际上实施后,更容易变成「冲业绩」、「抢着做简单/小的需求」、「遇到事情互相甩锅」。

这个问题我们很好举出反例:

  1. 假设我的需求涉及到一个比较复杂的架构,我花了一周时间思考边界情况、挑选技术方案、最终用 100 行搞定了这个事情;另一个同事本着能用就行,随便复制粘贴了一个一千行的实现。
  2. 我这个需求复杂度较高,所以需要一周才能做完,而别的同事能力有限,老板只给他分配了一些边缘的小需求,一天就能做完。
  3. 我由于负责了一个百万 QPS 的核心业务或者一个迭代了好多年的项目,本身维护成本很高,测试也更仔细,更容易发现 Bug;和一个没什么人用的系统,没有安排测试。

这三个场景中,哪个人干了更重、更难的活?但如果基于这些标准量化,哪个人的业绩又更好呢?

而且「工时计件制」本质只是无端内耗,大家浪费时间。而这个升级版的考核方式让每个人的工作变得更加消极——众所周知,程序员不可能不写 Bug,出了问题第一时间应该想的是怎么样去解决问题,下次不再犯,而不是去追责「到底谁写的这个 Bug,你年终奖没了」。这样只会让更多的人不愿意承担责任,不愿意主动复盘。

当然,这里其实还有一些以数量计算,容易造假的内容,比如:「MR 数量」、「Commit 数量」等等。

量化再升级:函数重复率、与圈复杂度、代码当量

接下来一些更懂行的研发设计出了一些更高端的指标。

以函数重复率为例,上面的一个反例中提到「复制粘贴了一个一千行的实现」,那么我把「复制粘贴」给 ban 了不就行了吗,跟论文查重一样,你得保证你写出来的代码是有质量的,学会代码复用的。

不过,函数重复率更多的也是项目内的一些探测,如果有些人「恶意引用」其他库中的代码,而不采用包的形式引入,也不一定能被检测到。

圈复杂度也是类似,假设你的代码质量低下,完全没有考虑复杂度,属于编码一时爽,维护火葬场,那么相当于你的产出质量其实是不高的,因此有了圈复杂度这个定义。

圈复杂度这个概念其实早在 1976 年就已经提出了,详情可以参考这篇文章,写的很详细:https://kaelzhang81.github.io/2017/06/18/%E8%AF%A6%E8%A7%A3%E5%9C%88%E5%A4%8D%E6%9D%82%E5%BA%A6/

一个最简单的计算公式是:V(G) = E - N + 2

其中,E 表示控制流图中边的数量, N 表示控制流图中节点的数量。

17105914016667.jpg

也就是说第一步:我们需要绘制出流程图,第二步,我们数节点数和边数(实践中肯定是依靠系统来解决这两步的),最终就能得到圈复杂度了。

而得到的量化数据值,我们可以代入表格中在进行打分:

圈复杂度代码状况可测性维护成本
1-10清晰、结构化
10-20复杂
20-30非常复杂
30不可读不可测非常高

这样看下来就比前两个只考虑数量、不考虑质量的靠谱的多。

同样的,当我们衡量一次变更复杂度的时候,也可以不参考代码行数,而是采用一个叫做「代码当量」的东西。很神奇,这东西其实刚提出没几年,而且是由一家公司提的,但他说的似乎又比单纯的代码行数要靠谱一些,关于这个名词可以阅读:[「代码当量」指标解读看这一篇就够了
](https://www.merico.cn/blog/equivalent-line-of-code)

17105918913247.jpg

简单的来说就是通过 AST 分析变更前后语法树的变动情况,然后进行加权打分来衡量你这次变更的工作量,其中,删除代码的权重就可以比新增和修改要低。这样对于一段代码的增删改和移动操作都能被以一种看上去更科学的方式量化。

这样确实有效避免了注释、Markdown 玩家以及 code style 专家混代码变更行数,可以更好地帮助你衡量脑力劳动量。

这就结束了吗?

就算我们上了强度,想要造假的人依旧可以造假,比如说,我就开个自己的分支,先大刀阔斧的改,跑不跑的起来不管,然后 MR 我合我自己,之后下一个 MR 再把这些都改回来,岂不是双倍的快乐。

对于公司级别的指标来看,这样的问题很难避免,无论是怎么样「科学」的量化指标,都面临道高一尺魔高一丈的挑战。

当然,我并不是完全反量化的,事实上,没有量化数据基础更容易陷入「向上管理优于一切」。在我看来不能以这个冰冷的数字作为一个人工作量的全部去考核,也不能完全以 Leader 一个人判断为准,更多的是基于 Leader 对每个人的工作分配辅以他的对应产出来决定,比如:我们确实不看 Bug / 事故数,但如果一个事故就是人为造成的,非常低端,甚至是由于不合规的操作导致的,那么必然会被追责。但如果一个事故只是因为大家都缺乏相关的经验,那么事后总结,下次注意就够了,不必追责到人。

此外,在上面介绍的考核指标中,我们还可以发现,似乎忽略了对于「需求调研」、「系统设计」、「代码自测」的评估,甚至有些团队在排期时都不一定算上了,而实际上这几点在软件开发过程中甚至比「动手写代码」更为重要。比如我一直倡导技术方案清晰化,要做到「这个方案拿出来,我叫另一个同学按照方案执行,和你自己想的一模一样」,在系统设计阶段就得想到可能存在的问题和落地的方案。

但是在实际工作中,大家往往会忽视这一部分,从某一种角度上来说也是因为这些工作很难被量化价值,每当这种时候,我都会想起扁鹊三兄弟的故事:

魏文侯问神医扁鹊:你们兄弟三人谁医术最好啊?

扁鹊说:大哥最好,二哥次之,我最下。

魏文侯说:为什么这么说?

扁鹊说:我大哥在人家未发病之前就把病根给除了,防患于未然,因此名气连家门口都出不去;我二哥能在人家病情刚起时就给治好,就好像他每次看的都是小病一样,所以他的名声不出当乡本土;我呢,看起大病来,又是在经脉上穿针放血,又是用些猛药贵药,又是开刀做手术,所以人人都觉得我的医术最高明。

总结

「瞎逼逼」系列第一期结束,文章中大部分都以个人 BB 为主,本文其实只是一个很理想的讨论,因为在实际(曾经)带领团队的过程中,即使我强调「不看考勤」和「不看 Bug 率」,大环境如此的情况下,大家的思维习惯也很难改变。(算不算一种「当 Leader 救不了中国码农」)





Latest Images