Sunday, April 24, 2022

SegmentFault 最新的文章

SegmentFault 最新的文章


Redis 内存满了怎么办?这样设置才正确!

Posted: 23 Apr 2022 10:45 PM PDT

上回在《Redis 数据过期了会被立马删除么?》说到如果过期的数据太多,定时删除无法删除完全(每次删除完过期的 key 还是超过 25%),同时这些 key 再也不会被客户端请求,就无法走惰性删除,内存被打满会怎样?

答案是走内存淘汰机制


故事从一个叫 Redis 帝国的三公九卿官职说起……

在 Redis 帝国中,整个帝国的国法、家法和军法等都记录在 redis.conf中,它控制着整个帝国的运行。

公务员占用的国家地盘资源大小限定由名叫「maxmemory」的司法官员制定,一共有两种方式实现:

  • 在运行时使用 CONFIG SET maxmemory 4gb指定帝国官职人员最大地盘资源为 4GB;
  • maxmemory 4gb法令记录到 redis.conf「法典」中,在帝国运转指定使用该「法典」运行。

需要注意的是,如果 maxmemory 为 0 ,在 64 位「空间」上则没有限制,而 32 位「空间」则有 3GB 的隐式限制。

Redis 内存淘汰策略

设置了帝国官职地盘资源限制,每年选拔新人就会导致没有地盘资源可以使用怎么办?如何选择一些公务员淘汰?

在 Redis 4.0 时代,一共有 6 种淘汰策略,之后,又新增了 2 中策略。

总体上我们可以根据是否需要淘汰可以分为两大类:

  • 不执行淘汰策略,noeviction
  • 根据不同法则淘汰的其他 7 中策略。

noeviction 不退伍策略

默认情况下,资源超过 maxmemory 的值也不会执行淘汰,不允许新人加入。

关系户啊这是,皇亲国戚,永久 vip 啊喂。

随着官职人员的新增,由于不会淘汰,资源容量迟早会满。满了以后,当有「新人」想要进来的时候,Redis 直接返回错误,并罢工

秀,真是任性。

各式各样的淘汰策略

剩下的 7 种策略还可以根据淘汰的候选集合和淘汰范围分为两大类:

  • 有设置任职过期时间的职员进行淘汰,没有设定任职过期时间的不会淘汰,淘汰策略如下:

    • volatile-lru:淘汰最近最少上一线干活的人员;
    • volatile-lfu:4.0 之后新增的策略,淘汰上一线干活次数最少的人员;
    • volatile-random:随机淘汰,腾出坑位给新人;
    • volatile-ttl:淘汰设置了任期时间的公务员,谁最接近任期时间就先淘汰谁。
  • 所有类型人员淘汰,不管是永久 vip 的皇亲国戚还是设置了任职过期时间的人员。

    • allkeys-lru:淘汰最近最少上一线干活的职员;
    • allkeys-lfu:淘汰最少上一线干活的公务员;
    • allkeys-random:随机淘汰职员,为新兵腾出空位。

故事到这里就结束了,接下来「码哥」分享下在实际 Redis 中如何选择合适的淘汰策略和设置最佳缓存大小给大家。

淘汰执行过程如下图所示:

redis-eviction

  • 客户端发送新命令到服务端;
  • 服务端收到客户端命令,Redis 检查内存使用情况,如果大于 maxmemory 限制,则根据策略驱逐数据。
  • 执行新命令。

allkeys-lru 使用场景

假如你的应用存在明显的冷热数据区别,根据经验推荐你使用这个策略,充分利用 LRU 算法把最近最常访问的数据保留,有限的内存提高访问性能。

allkeys-random 使用场景

假如数据没有明显的冷热分别,所有的数据分布查询比较均衡,这些数据都会被随机查询,那就使用 allkeys-random 策略,让其随机选择淘汰数据。

volatile-lru 使用场景

业务场景有一些数据不能删除,比如置顶新闻、视频,这时候我们为这些数据不设置过期时间,这样的话数据就不会被删除,该策略就会去根据 LRU 算法去淘汰哪些设置了过期时间且最近最少被访问的数据。

有一个点需要注意下,为 key 执行 expire 设置过期时间会消耗一些内存,所以使用 allkeyds-lru 会提高内存效率。

将需要持数据不能删除的和全都可以淘汰数据的业务系统分别使用不同的 Redis 实例集群是更好的方案。

针对业务场景有一些数据不能删除的使用 volatile-lru策略,另一类则可以使用 allkyes-lru 或者 allkeys-random

Redis 容量设置多大合适

缓存并不是越大越好,用最小的代价去获得最高的收益才是老板想要的。

数据访问有局部性,根据「二八原理」:通常 20% 的数据能支撑 80% 的访问请求。

所以我们可不可以把缓存容量大小设置为总数据量的 20%?

当然,不能这么绝对,这是理想状态。因为可能存在一些个性化需求,不同的用户访问的数据可能差别很大,不完全具备「二八原理」。

我们应当结合实际的访问特点和成本来综合评估。根据经验建议将容量设置成总数据量的 15%~30%。

码哥,其他淘汰规则比较简单,volatile-lru 和 volatile-lfu 则比较复杂,他们的算法是怎样的?

volatile-lru 使用了 LRU 算法,淘汰最近最少使用的数据。而volatile-lfu 使用了 LFU 算法,它在 LRU 算法基础上同时考虑了数据的时效性和访问频率,最少访问的 key 会被删除。

至于具体算法细节,我们下回分解。一次性太多的话大家容易在知识的海洋里里呛水。

现在的文章阅读量越来越低

大家可以在评论区叫我一声靓仔么?

不想叫我靓仔的可以帮我点个赞和在看么?

参考资料

1.https://redis.io/docs/manual/...

2.Redis 核心技术与实战

如何构建可控,可靠,可扩展的 PWA 应用

Posted: 19 Apr 2022 10:09 PM PDT

概述

PWA (Progressive Web App)指的是使用指定技术和标准模式来开发的 Web 应用,让 Web 应用具有原生应用的特性和体验。比如我们觉得本地应用使用便捷,响应速度更加快等。

PWA 由 Google 于 2016 年提出,于 2017 年正式技术落地,并在 2018 年迎来重大突破,全球顶级的浏览器厂商,Google、Microsoft、Apple 已经全数宣布支持 PWA 技术。

PWA 的关键技术有两个:

  1. Manifest:浏览器允许你提供一个清单文件,从而实现 A2HS
  2. ServiceWorker:通过对网络请求的代理,从而实现资源缓存、站点加速、离线应用等场景。

这两个是目前绝大部分开发者构建 PWA 应用所使用的最多的技术。

其次还有诸如:消息推送、WebStream、Web蓝牙、Web分享、硬件访问等API。出于浏览器厂商的支持不一,普及度还不高。

不管怎么样,使用 ServiceWorker 来优化用户体验,已经成为Web前端优化的主流技术。

工具与框架

2018 年之前,主流的工具是:

  1. google/sw-toolbox: 提供了一套工具,用于方便的构建 ServiceWorker。
  2. google/sw-precache: 提供在构建阶段,注入资源清单到 ServiceWorker 中,从而实现预缓存功能。
  3. baidu/Lavas: 百度开发的基于 Vue 的 PWA 集成解决方案。

后来由于 Google 开发了更加优秀的工具集 Workboxsw-toolboxsw-precache 得以退出舞台。

而 Lavas 由于团队解散,主要作者离职,已处于停止维护状态。

痛点

Workbox 提供了一套工具集合,用以帮助我们管理 ServiceWorker ,它对 CacheStorage 的封装,也得以让我们更轻松的去管理资源。

但是在构建实际的 PWA 应用的时候,我们还需要关心很多问题:

  1. 如何组织工程和代码?
  2. 如何进行单元测试?
  3. 如何解决 MPA (Multiple Page Application) 应用间的 ServiceWorker 作用域冲突问题?
  4. 如何远程控制我们的 ServiceWorker?
  5. 最优的资源缓存方案?
  6. 如何监控我们的 ServiceWorker,收集数据?

由于 Workbox 的定位是 「Library」,而我们需要一个 「Framework」 去为这些通用问题提供统一的解决方案。

并且, 我们希望它是渐进式(Progressive)的,就犹如 PWA 所提倡的那样。

代码解耦

是什么问题?

当我们的 ServiceWorker 程序代码越来越多的时候,会造成代码臃肿,管理混乱,复用困难。
同时一些常见的实现,如:远程控制、进程通讯、数据上报等,希望能实现按需插拔式的复用,这样才能达到「渐进式」的目的。

我们都知道,ServiceWorker 在运行时提供了一系列事件,常用的有:

self.addEventListener('install', event => { }); self.addEventListener('activate', event => { }); self.addEventListener("fetch", event => { }); self.addEventListener('message', event => { });

当我们有多个功能实现都要监听相同的事件,就会导致同个文件的代码越来越臃肿:

self.addEventListener('install', event => {   // 远程控制模块 - 配置初始化   ...   // 资源预缓存模块 - 缓存资源   ...   // 数据上报模块 - 收集事件   ... });    self.addEventListener('activate', event => {   // 远程控制模块 - 刷新配置   ...   // 数据上报模块 - 收集事件   ... });    self.addEventListener("fetch", event => {   // 远程控制模块 - 心跳检查   ...   // 资源缓存模块 - 缓存匹配   ...   // 数据上报模块 - 收集事件   ... });  self.addEventListener('message', event => {   // 数据上报模块 - 收集事件   ... });

你可能会说可以进行「模块化」:

import remoteController from './remoete-controller.ts';  // 远程控制模块 import assetsCache from './assets-cache.ts';  // 资源缓存模块 import collector from './collector.ts';  // 数据收集模块 import precache from './pre-cache.ts';  // 资源预缓存模块  self.addEventListener('install', event => {   // 远程控制模块 - 配置初始化   remoteController.init(...);   // 资源预缓存模块 - 缓存资源   assetsCache.store(...);   // 数据上报模块 - 收集事件   collector.log(...); });    self.addEventListener('activate', event => {   // 远程控制模块 - 刷新配置   remoteController.refresh(..);   // 数据上报模块 - 收集事件   collector.log(...); });    self.addEventListener("fetch", event => {   // 远程控制模块 - 心跳检查   remoteController.heartbeat(...);   // 资源缓存模块 - 缓存匹配   assetsCache.match(...);   // 数据上报模块 - 收集事件   collector.log(...); });  self.addEventListener('message', event => {   // 数据上报模块 - 收集事件   collector.log(...); });

模块化能减少主文件的代码量,同时也一定程度上对功能进行了解耦,但是这种方式还存在一些问题:

  1. 复用困难:当要使用一个模块的功能时,要在多个事件中去正确的调用模块的接口。同样,要去掉一个模块事,也要多个事件中去修改。
  2. 使用成本高:模块暴露各种接口,使用者必须了解透彻模块的运转方式,以及接口的使用,才能很好的使用。
  3. 解耦有限:如果模块更多,甚至要解决同域名下多个前端应用的命名空间冲突问题,就会显得捉襟见肘。

要达到我们目的:「渐进式」,我们需要对代码的组织再优化一下。

插件化实现

我们可以把 ServiceWorker 的一系列事件的控制权交出去,各模块通过插件的方式来使用这些事件。

我们知道 Koa.js 著名的洋葱模型:

koa洋葱模型

洋葱模型是「插件化」的很好的思想,但是它是 「一维」 的,Koa 完成一次网络请求的应答,各个中间件只需要监听一个事件。

而在 ServiceWorker 中,除了上面提及到的常用四个事件,他还有更多事件,如:SyncEvent, NotificationEvent

所以,我们还要多弄几个「洋葱」去满足更多的事件。

同时由于 PWA 应用的代码一般会运行在两个线程:主线程、ServiceWorker 线程。

最后,我们去封装原生的事件,去提供插件化支持,从而有了:「多维洋葱插件系统」

GlacierJS 多维洋葱插件系统

对原生事件和生命周期进行封装之后,我们为每一个插件提供更优雅的生命周期钩子函数:

GlacierJS 生命周期图示

我们基于 GlacierJS 的话,可以很容易做到模块的插件化。

在 ServiceWorker 线程的主文件中注册插件:

import { GlacierSW } from '@glacierjs/sw'; import RemoteController from './remoete-controller.ts';  // 远程控制模块 import AssetsCache from './assets-cache.ts';  // 资源缓存模块 import Collector from './collector.ts';  // 数据收集模块 import Precache from './pre-cache.ts';  // 资源预缓存模块 import MyPluginSW from './my-plugin.ts'  const glacier = new GlacierSW();  glacier.use(new Log(...)); glacier.use(new RemoteController(...)); glacier.use(new AssetsCache(...)); glacier.use(new Collector(...)); glacier.use(new Precache(...));  glacier.listen();

而在插件中,我们可以通过监听事件去收归一个独立模块的逻辑:

import { ServiceWorkerPlugin } from '@glacierjs/sw'; import type { FetchContext, UseContext  } from '@glacierjs/sw';  export class MyPluginSW implements ServiceWorkerPlugin {     constructor() {...}     public async onUse(context: UseContext) {...}     public async onInstall(event) {...}     public async onActivate() {...}     public async onFetch(context: FetchContext) {...}     public async onMessage(event) {...}     public async onUninstall() {...} }

作用域冲突

我们都知道关于 ServiceWorker 的作用域有两个关键特性:

  1. 默认的作用域是注册时候的 Path。
  2. 同个路径下同时间只能有一个 ServiceWorker 得到控制权。

作用域缩小与扩大

关于第一个特性,例如注册 Service Worker 文件为 /a/b/sw.js,则 scope 默认为 /a/b/

if (navigator.serviceWorker) {     navigator.serviceWorker.register('/a/b/sw.js').then(function (reg) {         console.log(reg.scope);         // scope => https://yourhost/a/b/     }); }

当然我们可以在注册的的时候指定 scope 去向下缩小作用域,例如:

if (navigator.serviceWorker) {     navigator.serviceWorker.register('/a/b/sw.js', {scope: '/a/b/c/'})         .then(function (reg) {             console.log(reg.scope);             // scope => https://yourhost/a/b/c/         }); }

也可以通过服务器对 ServiceWorker 文件的响应设置 Service-Worker-Allowed 头部,去扩大作用域。

例如 Google Docs 在作用域 https://docs.google.com/document/u/0/ 注册了一个来自于 https://docs.google.com/document/offline/serviceworker.js 的 ServiceWorker

img

MPA下的 ServiceWorker 治理

现代 Web App 项目主要有两种架构形式存在: SPA(Single Page Application)MPA(Multiple Page Application)

MPA 这种架构的模式在现如今的大型 Web App 非常常见,这种 Web App 相比较于 SPA 能够承受更重的业务体量,并且利于大型 Web App 的后期维护和扩展,它往往会有多个团队去维护。

假设我们有一个 MPA 的站点:

. |-- app1 |   |-- app1-service-worker.js |   `-- index.html |-- app2 |   `-- index.html |-- index.html `-- root-service-worker.js

app1app2 分别由不同的团队维护。

如果我们在根目录 '/' 注册了 root-service-worker.js,去完成一些通用的功能,例如:「日志收集」、「静态资源缓存」等。

然后 app1 团队利用 ServiceWorker 的能力开发了一些特定的功能需要,例如 app1 的「离线化功能」。

他们在 app1/index.html 目录注册了 app1-service-worker.js

这时候,访问 app1/* 下的所有页面,ServiceWorker 控制权会交给 app1-service-worker.js,也就是只有app1的「离线化功能」在工作,而原来的「日志收集」、「静态缓存」等功能会失效。

显然这种情况是我们不希望看到的,并且在实际的开发中发生的概率会很大。

解决这个问题有两种方案:

  1. 封装「日志收集」、「静态资源缓存」功能,app1-service-worker.js引入并使用这些功能。
  2. 把「离线化功能」整合到 root-service-worker.js,只允许注册该 ServiceWorker。

关于方案一,封装通用功能这是正确的,但是主域下的功能可能完全没办法一一拆解,并且后续主域的 ServiceWorker 更新了新功能,子域下的 ServiceWorker 还需要主动去更新和升级。

关于方案二,显然可以解决方案一的问题,但是其他应用,例如 app2 可能不需要「离线化功能」。

基于此,我们引入方案三:功能整合到主域,支持功能的组合按照作用域隔离。

基于 GlacierJS 的话代码上可能会是这样的:

const mainPlugins = [   new Collector(); // 日志收集功能   new AssetsCache(); // 静态资源缓存功能 ];  glacier.use('/', mainPlugins); glacier.use('/app1', [   ...mainPlugins,   new Offiline(),  // 离线化功能 ]);

资源缓存

ServiceWorker 一个很核心的能力就是能结合 CacheAPI 进行灵活的缓存资源,从而达到优化站点的加载速度、弱网访问、离线应用等。

image-20220414092525515

对于静态资源有五种常用的缓存策略:

  1. stale-while-revalidate
    该模式允许您使用缓存(如果可用)尽快响应请求,如果没有缓存则回退到网络请求,然后使用网络请求来更新缓存,它是一种比较安全的缓存策略。
  2. cache-first
    离线 Web 应用程序将严重依赖缓存,但对于非关键且可以逐渐缓存的资源,「缓存优先」是最佳选择。
    如果缓存中有响应,则将使用缓存的响应来满足请求,并且根本不会使用网络。
    如果没有缓存响应,则请求将由网络请求完成,然后响应会被缓存,以便下次直接从缓存中提供下一个请求。
  3. network-first
    对于频繁更新的请求,「网络优先」策略是理想的解决方案。
    默认情况下,它会尝试从网络获取最新响应。如果请求成功,它会将响应放入缓存中。如果网络未能返回响应,则将使用缓存的响应。
  4. network-only
    如果您需要从网络满足特定请求,network-only 模式会将资源请求进行透传到网络。
  5. cache-only
    该策略确保从缓存中获取响应。这种场景不太常见,它一般匹配着「预缓存」策略会比较有用。

那这些策略中,我们应该使用哪种呢?答案是根据资源的种类具体选择。

例如一些资源如果只是在 Web 应用发布的时候才会更新,我们就可以使用 cache-first 策略,例如一些 JS、样式、图片等。

而 index.html 作为页面的加载的主入口,更加适宜使用 stale-while-revalidate 策略。

我们以 GlacierJS 的缓存插件(@glacierjs/plugin-assets-cache)为例:

// in service-worker.js importScripts("//cdn.jsdelivr.net/npm/@glacierjs/core/dist/index.min.js"); importScripts('//cdn.jsdelivr.net/npm/@glacierjs/sw/dist/index.min.js'); importScripts('//cdn.jsdelivr.net/npm/@glacierjs/plugin-assets-cache/dist/index.min.js');  const { GlacierSW } = self['@glacierjs/sw']; const { AssetsCacheSW, Strategy } = self['@glacierjs/plugin-assets-cache'];  const glacierSW = new GlacierSW();  glacierSW.use(new AssetsCacheSW({     routes: [{         // capture as string: store index.html with stale-while-revalidate strategy.         capture: 'https://mysite.com/index.html',         strategy: Strategy.STALE_WHILE_REVALIDATE,     }, {         // capture as RegExp: store all images with cache-first strategy         capture: /\.(png|jpg)$/,         strategy: Strategy.CACHE_FIRST     }, {         // capture as function: store all stylesheet with cache-first strategy         capture: ({ request }) => request.destination === 'style',         strategy: Strategy.CACHE_FIRST     }], }));

远程控制

基于 ServiceWorker 的原理,一旦在浏览器安装上了,如果遇到紧急线上问题,唯有发布新的 ServiceWorker 才能解决问题。但是 ServiceWorker 的安装是有时延的,再加上有些团队从修改代码到发布的流程,这个反射弧就很长了。我们有什么办法能缩短对于线上问题的反射弧呢?

我们可以在远程存储一个配置,针对可预见的场景,进行「远程控制」

remote-controller.drawio

那么我们怎么去获取配置呢?

方案一,如果我们在主线程中获取配置:

  1. 需要用户主动刷新页面才会生效。
  2. 做不到轻量的功能关闭,什么意思呢,我们会有开关的场景,主线程只能通过卸载或者清理缓存去实现「关闭」,这个太重了。

方案二,如果我们在 ServiceWorker 线程去获取配置:

  1. 可以实现轻量功能关闭,透传请求就行了。
  2. 但是如果遇到要干净的清理用户环境的需要,去卸载 ServiceWorker 的时候,就会导致主进程每次注册,到了 ServiceWorker 就卸载,造成频繁安装卸载。

image-20220417012859191

所以我们的 最后方案「基于双线程的实时配置获取」

主线程也要获取配置,然后配置前面要加上防抖保护,防止 onFetch 事件短时间并发的问题。

image-20220417012934418

代码上,我们使用 Glacier 的插件 @glacierjs/plugin-remote-controller 可以轻松实现远程控制:

// in ./remote-controller-sw.ts import { RemoteControllerSW } from '@glacierjs/plugin-remote-controller'; import { GlacierSW } from '@glacierjs/sw'; import { options } from './options';  const glacierSW = new GlacierSW(); glacierSW.use(new RemoteControllerSW({   fetchConfig: () => getMyRemoteConfig(); }));  // 其中 getMyRemoteConfig 用于获取你存在远端的配置,返回的格式规定如下: const getMyRemoteConfig = async () => {     const config: RemoteConfig = {         // 全局关闭,卸载 ServiceWorker         switch: true,                  // 缓存功能开关           assetsEnable: true,                  // 精细控制特定缓存         assetsCacheRoutes: [{             capture: 'https://mysite.com/index.html',             strategy: Strategy.STALE_WHILE_REVALIDATE,         }],     }, }

数据收集

ServiceWorker 发布之后,我们需要保持对线上情况的把控。 对于一些必要的统计指标,我们可能需要进行上统计和上报。

@glacierjs/plugin-collector 内置了五个常见的数据事件:

  1. ServiceWorker 注册:SW_REGISTER
  2. ServiceWorker 安装成功:SW_INSTALLED
  3. ServiceWorker 控制中:SW_CONTROLLED
  4. 命中 onFetch 事件:SW_FETCH
  5. 命中浏览器缓存:CACHE_HIT of CacheFrom.Window
  6. 命中 CacheAPI 缓存:CACHE_HIT of CacheFrom.SW

基于以上数据的收集,我们就可以得到一些常见的通用指标:

  1. ServiceWorker 安装率 = SW_REGISTER / SW_INSTALLED
  2. ServiceWorker 控制率 = SW_REGISTER / SW_CONTROLLED
  3. ServiceWorker 缓存命中率 = SW_FETCH / CACHE_HIT (of CacheFrom.SW)

首先我们在 ServiceWorker 线程中注册 plugin-collector:

import { AssetsCacheSW } from '@glacierjs/plugin-assets-cache'; import { CollectorSW } from '@glacierjs/plugin-collector'; import { GlacierSW } from '@glacierjs/sw';  const glacierSW = new GlacierSW();  // should use plugin-assets-cache first in order to make CollectedDataType.CACHE_HIT work. glacierSW.use(new AssetsCacheSW({...})); glacierSW.use(new CollectorSW());

然后在主线程中注册 plugin-collector,并且监听数据事件,进行数据上报:

import {   CollectorWindow,   CollectedData,   CollectedDataType, } from '@glacierjs/plugin-collector'; import { CacheFrom } from '@glacierjs/plugin-assets-cache'; import { GlacierWindow } from '@glacierjs/window';  const glacierWindow = new GlacierWindow('./service-worker.js');  glacierWindow.use(new CollectorWindow({     send(data: CollectedData) {       const { type, data } = data;        switch (type) {         case CollectedDataType.SW_REGISTER:           myReporter.event('sw-register-count');           break;          case CollectedDataType.SW_INSTALLED:           myReporter.event('sw-installed-count');           break;          case CollectedDataType.SW_CONTROLLED:           myReporter.event('sw-controlled-count');           break;          case CollectedDataType.SW_FETCH:           myReporter.event('sw-fetch-count');           break;          case CollectedDataType.CACHE_HIT:           // hit service worker cache           if (data?.from === CacheFrom.SW) {             myReporter.event(`sw-assets-count:hit-sw-${data?.url}`);           }            // hit browser cache or network           if (data?.from === CacheFrom.Window) {             myReporter.event(`sw-assets-count:hit-window-${data?.url}`);           }           break;       }     }, }));

其中 myReporter.event 是你可能会实现的数据上报库。

单元测试

ServiceWorker 测试可以分解为常见的测试组。

img

在顶层的是 「集成测试」,在这一层,我们检查整体的行为,例如:测试页面可加载,ServiceWorker注册,离线功能等。集成测试是最慢的,但是也是最接近现实情况的。

再往下一层的是 「浏览器单元测试」,由于 ServiceWorker 的生命周期,以及一些 API 只有在浏览器环境下才能有,所以我们使用浏览器去进行单元测试,会减少很多环境的问题。

接着是 「ServiceWorker 单元测试」,这种测试也是在浏览器环境中注册了测试用的 ServiceWorker 为前提进行的单元测试。

最后一种是 「模拟 ServiceWorker」,这种测试粒度会更加精细,精细到某个类某个方法,只检测入参和返回。这意味着没有了浏览器启动成本,并且最终是一种可预测的方式测试代码的方式。

但是模拟 ServiceWorker 是一件困难的事情,如果 mock 的 API 表面不正确,则在集成测试或者浏览器单元测试之前问题不会被发现。我们可以使用 service-worker-mock 或者 MSW 在 NodeJS 环境中进行 ServiceWorker 的单元测试。

由于篇幅有限,后续我另开专题来讲讲 ServiceWorker 单元测试的实践。

总结

本文开篇描述了关于 PWA 的基本概念,然后介绍了一些现在社区优秀的工具,以及要去构建一个「可控、可靠、可扩展的 PWA 应用」所面临的的实际的痛点。

于是在三个「可」给出了一些实践性的建议:

  1. 通过「数据收集」、「远程控制」保证我们对已发布的 PWA 应用的 「可控性」
  2. 通过「单元测试」、「集成测试」去保障我们 PWA 应用的 「可靠性」
  3. 通过「多维洋葱插件模型」支持插件化和 MPA 应用,以及整合多个插件,从而达到 PWA 应用的 「可扩展性」

参考

从源码里的一个注释,我追溯到了12年前,有点意思。

Posted: 17 Apr 2022 09:31 PM PDT

你好呀,我是歪歪。

那天我正在用键盘疯狂的输出:

突然微信弹出一个消息,是一个读者发给我的。

我点开一看:

啊,这熟悉的味道,一看就是 HashMap,八股文梦开始的地方啊。

但是他问出的问题,似乎又不是一个属于 HashMap 的八股文:

为什么这里要把 table 变量赋值给 tab 呢?

table 大家都知道,是 HashMap 的一个成员变量,往 map 里面放的数据就存储在这个 table 里面的:

在 putVal 方法里面,先把 table 赋值给了 tab 这个局部变量,后续在方法里面都是操作的这个局部变量了。

其实,不只是 putVal 方法,在 HashMap 的源码里面,"tab= table" 这样的写发多达 14 个,比如 getNode 里面也是这样的用法:

我们先思考一下,如果不用 tab 这个局部变量,直接操作 table,会不会有问题?

从代码逻辑和功能上来看,是不会有任何毛病的。

如果是其他人这样写,我会觉得可能是他的编程习惯,没啥深意,反正又不是不能用。

但是这玩意可是 Doug Lea 写的,隐约间觉得必然是有深意在里面的。

所以为什么要这样写呢?

巧了,我觉得我刚好知道答案是什么。

因为我在其他地方也看到过这种把成员变量赋值给局部变量的写法,而且在注释里面,备注了自己为什么这么写。

而这个地方,就是 Java 的 String 类:

比如 String 类的 trim 方法,在这个方法里面就把 String 的 value 赋给了 val 这个局部变量。

然后旁边给了一个非常简短的注释:

avoid getfield opcode

本文的故事,就从一行注释开始,一路追溯到 2010 年,我终于抽丝剥茧找到了问题的答案。

一行注释,就是说要避免使用 getfield 字节码。

虽然我不懂是啥意思,但是至少我拿到了几个关键词,算是找到了一个"线头",接下来的事情就很简单了,顺着这个线头往下缕就完事了。

而且直觉上告诉我这又是一个属于字节码层面的极端的优化,缕到最后一定是一个骚操作。

那么我就先给你说结论了:这个代码确实是 Doug Lea 写的,在当年确实是一种优化手段,但是时代变了,放到现在,确实没有卵用。

答案藏在字节码

既然这里提到了字节码的操作,那么接下来的思路就是对比一下这两种不同写法分别的字节码是长啥样的不就清楚了吗?

比如我先来一段这样的测试代码:

public class MainTest {      private final char[] CHARS = new char[5];      public void test() {         System.out.println(CHARS[0]);         System.out.println(CHARS[1]);         System.out.println(CHARS[2]);     }      public static void main(String[] args) {         MainTest mainTest = new MainTest();         mainTest.test();     } }

上面代码中的 test 方法,编译成字节码之后,是这样的:

可以看到,三次输出,对应着三次这样的字节码:

在网上随便找个 JVM 字节码指令表,就可以知道这几个字节码分别在干啥事儿:

  • getstatic:获取指定类的静态域, 并将其压入栈顶
  • aload_0:将第一个引用类型本地变量推送至栈顶
  • getfield:获取指定类的实例域, 并将其值压入栈顶
  • iconst_0:将int型0推送至栈顶
  • caload:将char型数组指定索引的值推送至栈顶
  • invokevirtual:调用实例方法

如果,我把测试程序按照前面提到的写法修改一下,并重新生成字节码文件,就是这样的:

可以看到,getfield 这个字节码只出现了一次。

从三次到一次,这就是注释中写的"avoid getfield opcode"的具体意思。

确实是减少了生成的字节码,理论上这就是一种极端的字节码层面的优化。

具体到 getfield 这个命令来说,它干的事儿就是获取指定对象的成员变量,然后把这个成员变量的值、或者引用放入操作数栈顶。

更具体的说,getfield 这个命令就是在访问我们 MainTest 类中的 CHARS 变量。

往底层一点的说就是如果没有局部变量来承接一下,每次通过 getfield 方法都要访问堆里面的数据。

而让一个局部变量来承接一下,只需要第一次获取一次,之后都把这个堆上的数据,"缓存"到局部变量表里面,也就是搞到栈里面去。之后每次只需要调用 aload_<n> 字节码,把这个局部变量加载到操作栈上去就完事。

aload_<n> 的操作,比起 getfield 来说,是一个更加轻量级的操作。

这一点,从 JVM 文档中对于这两个指令的描述的长度也能看出来:

https://docs.oracle.com/javas...

就不细说了,看到这里你应该明白:把成员变量赋值到局部变量之后再进行操作,确实是一种优化手段,可以达到"avoid getfield opcode"的目的。

看到这里你的心开始有点蠢蠢欲动了,感觉这个代码很棒啊,我是不是也可以搞一波呢?

不要着急,还有更棒的,我还没给你讲完呢。

stackoverflow

在 Java 里面,我们其实可以看到很多地方都有这样的写法,比如我们前面提到的 HashMap 和 String,你仔细看 J.U.C 包里面的源码,很多都是这样写的。

但是,也有很多代码并没有这样写。

比如在 stackoverflow 就有这样的一个提问:

提问的哥们说为什么 BigInteger 没有采用 String 的 trim 方法 "avoid getfield opcode" 这样的写法呢?

下面的回答是这样说的:

在 JVM 中,String 是一个非常重要的类,这种微小的优化可能会提高一点启动速度。另一方面,BigInteger 对于 JVM 的启动并不重要。

所以,如果你看了这篇文章,自己也想在代码里面用这样的"棒"写法,三思。

醒醒吧,你才几个流量呀,值得你优化到这个程度?

而且,我就告诉你,前面字节码层面是有优化不假,我们都眼见为实了。

但是这个老哥提醒了我:

他提到了 JIT,是这样说的:这些微小的优化通常是不必要的,这只是减少了方法的字节码大小,一旦代码变得足够热而被 JIT 优化,它并不真正影响最终生成的汇编。

于是,我在 stackoverflow 上一顿乱翻,终于在万千线索中,找出了我觉得最有价值的一个。

这个问题,就和文章开头的读者问我的可以说一模一样了:

https://stackoverflow.com/que...

这个哥们说:在 jdk 源码中,更具体地说,是在集合框架中,有一个编码的小癖好,就是在表达式中读取变量之前,先将其赋值到一个局部变量中。这只是一个简单的小癖好吗,还是里面藏着一下我没有注意到的更重要的东西?

随后,还有人帮他补充了几句:

这代码是 Doug Lea 写的,小 Lea 子这人吧,经常搞一些出其不意的代码和优化。他也因为这些"莫名其妙"的代码闻名,习惯就好了。

然后这个问题下面有个回答是这样说的:

Doug Lea 是集合框架和并发包的主要作者之一,他编码的时候倾向于进行一些优化。但是这些优化这可能会违反直觉,让普通人感到困惑。

毕竟人家是在大气层。

接着他给出了一段代码,里面有三个方法,来验证了不同的写法生成的不同的字节码:

三个方法分别如下:

对应的字节码我就不贴了,直接说结论:

The testSeparate method uses 41 instructions
The testInlined method indeed is a tad smaller, with 39 instructions
Finally, the testRepeated method uses a whopping 63 instructions

同样的功能,但是最后一种直接使用成员变量的写法生成的字节码是最多的。

所以他给出了和我前面一样的结论:

这种写法确实可以节省几个字节的字节码,这可能就是使用这种方式的原因。

但是...

主要啊,他要开始 but 了:

但是,在不论是哪个方法,在被 JIT 优化之后,产生的机器代码将与原始字节码"无关"。

可以非常确定的是:三个版本的代码最终都会编译成相同的机器码(汇编)。

因此,他的建议是:不要使用这种风格,只需编写易于阅读和维护的"愚蠢"代码。你会知道什么时候轮到你使用这些"优化"。

可以看到他在"write dumb code"上附了一个超链接,我挺建议你去读一读的:

https://www.oracle.com/techni...

在这里面,你可以看到《Java Concurrency in Practice》的作者 Brian Goetz:

他对于"dumb code"这个东西的解读:

他说:通常,在 Java 应用程序中编写快速代码的方法是编写"dumb code"——简单、干净,并遵循最明显的面向对象原则的代码。

很明显,tab = table 这种写法,并不是 "dumb code"。

好了,说回这个问题。这个老哥接着做了进一步的测试,测试结果是这样的:

他对比了 testSeparate 和 TestInLine 方法经过 JIT 优化之后的汇编,这两个方法的汇编是相同的。

但是,你要搞清楚的是这个小哥在这里说的是 testSeparate 和 testInLine 方法,这两个方法都是采用了局部变量的方式:

只是 testSeparate 的可读性比 testInLine 高了很多。

而 testInLine 的写法,就是 HashMap 的写法。

所以,他才说:我们程序员可以只专注于编写可读性更强的代码,而不是搞这些"骚"操作。JIT 会帮我们做好这些东西。

从 testInLine 的方法命名上来看,也可以猜到,这就是个内联优化。

它提供了一种(非常有限,但有时很方便)"线程安全"的形式:它确保数组的长度(如 HashMap 的 getNode 方法中的 tab 数组)在方法执行时不会改变。

他为什么没有提到我们更关心的 testRepeated 方法呢?

他也在回答里面提到这一点:

他对之前的一个说法进行了 a minor correction/clarification。

啥意思,直接翻译过来就是进行一个小的修正或者澄清。用我的话说就是,前面话说的有点满,现在打脸了,你听我狡辩一下。

前面他说的是什么?

他说:这都不用看,这三个方法最终生成的汇编肯定是一模一样的。

但是现在他说的是:

it can not result in the same machine code
它不能产生相同的汇编

最后,这个老哥还补充了这个写法除了字节码层面优化之外的另一个好处:

一旦在这里对 n 进行了赋值,在 getNode 这个方法中 n 是不会变的。如果直接使用数组的长度,假设其他方法也同时操作了 HashMap,在 getNode 方法中是有可能感知到这个变化的。

这个小知识点我相信大家都知道,很直观,不多说了。

但是,看到这里,我们好像还是没找到问题的答案。

那就接着往下挖吧。

继续挖

继续往下挖的线索,其实已经在前面出现过了:

通过这个链接,我们可以来到这个地方:

https://stackoverflow.com/que...

瞟一眼我框起来的代码,你会发现这里抛出的问题其实又是和前面是一样。

我为什么又要把它拿出来说一次呢?

因为它只是一个跳板而已,我想引出这下面的一个回答:

这个回答说里面有两个吸引到我注意的地方。

第一个就是这个回答本身,他说:这是该类的作者 Doug Lea 喜欢使用的一种极端优化。这里有个超链接,你可以去看看,能很好地回答你的问题。

这里面提到的这个超链接,很有故事:

http://mail.openjdk.java.net/...

但是在说这个故事之前,我想先说说这个回答下面的评论,也就是我框起来的部分。

这个评论观点鲜明的说:需要着重强调"极端"!这不是每个人都应该效仿的、通用的、良好的写法。

凭借我在 stackoverflow 混了这么几年的自觉,这里藏龙卧虎,一般来说 说话底气这么足的,都是大佬。

于是我点了他的名字,去看了一眼,果然是大佬:

这哥们是谷歌的,参与了很多项目,其中就有我们非常熟悉的 Guava,而且不是普通开发者,而是 lead developer。同时也参与了 Google 的 Java 风格指南编写。

所以他说的话还是很有分量的,得听。

然后,我们去到那个很有故事的超链接。

这个超链接里面是一个叫做 Ulf Zibis 的哥们提出的问题:

Ulf 同学的提问里面提到说:在 String 类中,我经常看到成员变量被复制到局部变量。我在想,为什么要做这样的缓存呢,就这么不信任 JVM 吗,有没有人能帮我解答一下?

Ulf 同学的问题和我们文章中的问题也是一样的,而他这个问题提出的时间是 2010 年,应该是我能找到的关于这个问题最早出现的地方。

所以你要记住,下面的这些邮件中的对话,已经是距今 12 年前的对话了。

在对话中,针对这个问题,有比较官方的回答:

回答他问题这个人叫做 Martin Buchholz,也是 JDK 的开发者之一,Doug Lea 的同事,他在《Java并发编程实战》一书里面也出现过:

.png)

来自 SUN 公司的 JDK 并发大师,就问你怕不怕。

他说:这是一种由 Doug Lea 发起的编码风格。这是一种极端的优化,可能没有必要。你可以期待 JIT 做出同样的优化。但是,对于这类非常底层的代码来说,写出的代码更接近于机器码也是一件很 nice 的事情。

关于这个问题,这几个人有来有回的讨论了几个回合:

在邮件的下方,有这样的链接可以点击,可以看到他们讨论的内容:

主要再看看这个叫做 Osvaldo 对线 Martin 的邮件:

https://mail.openjdk.java.net...

Osvaldo 老哥写了这么多内容,主要是想喷 Martin 的这句话:这是一种极端的优化,可能没有必要。你可以期待 JIT 做出同样的优化。

他说他做了实验,得出的结论是这个优化对以 Server 模式运行的 Hotspot 来说没有什么区别,但对于 Client 模式运行的 Hotspot 来说却非常重要。在他的测试案例中,这种写法带来了 6% 的性能提升。

然后他说他现在包括未来几年写的代码应该都会运行在以 Client 模式运行的 Hotspot 中。所以请不要乱动 Doug 特意写的这种优化代码,我谢谢你全家。

同时他还提到了 JavaME、JavaFX Mobile&TV,让我不得不再次提醒你:这段对话发生在 12 年前,他提到的这些技术,在我的眼里已经是过眼云烟了,只听过,没见过。

哦,也不能算没见过,毕竟当年读初中的时候还玩过 JavaME 写的游戏。

就在 Osvaldo 老哥言辞比较激烈的情况下,Martin 还是做出了积极的回应:

Martin 说谢谢你的测试,我也已经把这种编码风格融合到我的代码里面了,但是我一直在纠结的事情是是否也要推动大家这样去做。因为我觉得我们可以在 JIT 层面优化这个事情。

接下来,最后一封邮件,来自一位叫做 David Holmes 的老哥。

巧了,这位老哥的名字在《Java并发编程实战》一书里面,也可以找到。

人家就是作者,我介绍他的意思就是想表达他的话也是很有分量的:

因为他的这一封邮件,算是给这个问题做了一个最终的回答。

我带着自己的理解,用我话来给你全文翻译一下,他是这样说的:

我已经把这个问题转给了 hotspot-compiler-dev,让他们来跟进一下。

我知道当时 Doug 这样写的原因是因为当时的编译器并没有相应的优化,所以他这样写了一下,帮助编译器进行优化了一波。但是,我认为这个问题至少在 C2 阶段早就已经解决了。如果是 C1 没有解决这个问题的话,我觉得是需要解决一下的。

最后针对这种写法,我的建议是:在 Java 层面上不应该按照这样的方式去敲代码。

There should not be a need to code this way at the Java-level.

至此,问题就梳理的很清楚了。

首先结论是不建议使用这样的写法。

其次,Doug 当年这样写确实是一种优化,但是随着编译器的发展,这种优化下沉到编译器层面了,它帮我们做了。

最后,如果你不明白前面提到的 C1,C2 的话,那我换个说法。

C1 其实就是 Client Compiler,即客户端编译器,特点是编译时间较短但输出代码优化程度较低。

C2 其实就是 Server Compiler,即服务端编译器,特点是编译耗时长但输出代码优化质量也更高。

前面那个 Osvaldo 说他主要是用客户端编译器,也就是 C1。所以后面的 David Holmes 才一直在说 C2 是优化了这个问题的,C1 如果没有的话可以跟进一下,巴拉巴拉巴拉的...

关于 C2 的话,简单提一下,记得住就记,记不住也没关系,这玩意一般面试也不考。

大家常常提到的 JVM 帮我们做的很多"激进"的为了提升性能的优化,比如内联、快慢速路径分析、窥孔优化,都是 C2 搞的事情。

另外在 JDK 10 的时候呢,又推出了 Graal 编译器,其目的是为了替代 C2。

至于为什么要替换 C2,额,原因之一你可以看这个链接...

http://icyfenix.cn/tricks/202...

C2 的历史已经非常长了,可以追溯到 Cliff Click 大神读博士期间的作品,这个由 C++ 写成的编译器尽管目前依然效果拔群,但已经复杂到连 Cliff Click 本人都不愿意继续维护的程度。

你看前面我说的 C1、C1 的特点,刚好是互补的。

所以为了在程序启动、响应速度和程序运行效率之间找到一个平衡点,在 JDK 6 之后,JVM 又支持了一种叫做分层编译的模式。

也是为什么大家会说:"Java 代码运行起来会越来越快、Java 代码需要预热"的根本原因和理论支撑。

在这里,我引用《深入理解Java虚拟机HotSpot》一书中 7.2.1 小节[分层编译]的内容,让大家简单了解一下这是个啥玩意。

首先,我们可以使用 -XX:+TieredCompilation 开启分层编译,它额外引入了四个编译层级。

  • 第 0 级:解释执行。
  • 第 1 级:C1 编译,开启所有优化(不带 Profiling)。Profiling 即剖析。
  • 第 2 级:C1 编译,带调用计数和回边计数的 Profiling 信息(受限 Profiling).
  • 第 3 级:C1 编译,带所有Profiling信息(完全Profiling).
  • 第 4 级:C2 编译。

常见的分层编译层级转换路径如下图所示:

  • 0→3→4:常见层级转换。用 C1 完全编译,如果后续方法执行足够频繁再转入 4 级。
  • 0→2→3→4:C2 编译器繁忙。先以 2 级快速编译,等收集到足够的 Profiling 信息后再转为3级,最终当 C2 不再繁忙时再转到 4 级。
  • 0→3→1/0→2→1:2/3级编译后因为方法不太重要转为 1 级。如果 C2 无法编译也会转到 1 级。
  • 0→(3→2)→4:C1 编译器繁忙,编译任务既可以等待 C1 也可以快速转到 2 级,然后由 2 级转向 4 级。

如果你之前不知道分层编译这回事,没关系,现在有这样的一个概念就行了。

再说一次,面试不会考的,放心。

好了,恭喜你看到这里了。回想全文,你学到了什么东西呢?

是的,除了一个没啥卵用的知识点外,什么都没有学到。

本文首发于公众号why技术,转载请注明出处和链接。

原来,这才是开发者打开世界读书日的正确姿势!

Posted: 21 Apr 2022 07:29 PM PDT

"独阅乐不如众阅乐"。

什么意思?就是独自快乐阅读,不如让更多人能够快乐阅读。这就是程序员阿强朴素的阅读观。

作为外文图书爱好者,阿强想尽情享受外文原版书的原汁原味,却总被不时出现的陌生词汇整得磕磕绊绊;

学生时代的外文阅读阻碍扎堆出现在查阅文献阶段,专业且生僻的词汇是他理解文献效率的最大"杀手";

而如今的全球实时新闻,再大的词汇量也不能完美防守住新词发起的挑战。

为了让更多人都能拥有更好的外文阅读体验,一个"哪里不会划哪里"的阅读神器在阿强的数行代码间诞生了,使用HMS Core机器学习服务的文本翻译能力,徒手实现一个划词翻译功能,让外文读物阅读体验更顺畅。

关于文本翻译

机器学习服务提供在线和离线文本翻译能力,支持不同语种互译并且有网络的场景。例如:

旅游类App中,提供将外文路牌、菜单等翻译成母语的功能,优化体验;教育学习类App,学习多种语言时,轻松实现将陌生语言翻译成熟悉的语言,提高学习效率。

同时,离线翻译可不需网络支持,旅途中即使身处无网环境也可轻松解决沟通障碍。

效果展示

开发准备

在正式开发"划词翻译"功能之前需要完成以下准备工作:

配置AppGallery Connect

开通服务

集成HMS Core SDK

配置混淆脚本

添加权限

具体开发准备可以参考文档

应用开发

2.1 参考云测鉴权信心使用须知,设置应用的鉴权信息

 MLApplication.getInstance().setApiKey("apiKey or Token"); 

2.2 创建在线文本翻译器

 MLLocalTranslateSetting setting = new MLLocalTranslateSetting         .Factory()         .setSourceLangCode(mSourceLangCode)         .setTargetLangCode(mTargetLangCode)         .create(); this.localTranslator = MLTranslatorFactory.getInstance().getLocalTranslator(setting); 

2.3 查询在线翻译所支持的语种

 MLTranslateLanguage.getCloudAllLanguages().addOnSuccessListener(new OnSuccessListener<Set<String>>() {     @Override     public void onSuccess(Set<String> result) {         // 成功获取在线翻译所支持的语种。     } }); 

2.4 进行在线文本翻译

 localTranslator.preparedModel(downloadStrategy, modelDownloadListener).addOnSuccessListener(new OnSuccessListener<Void>() {     @Override     public void onSuccess(Void aVoid) {          final Task<String> task = localTranslator.asyncTranslate(input);          task.addOnSuccessListener(new OnSuccessListener<String>() {             @Override             public void onSuccess(String text) {                 displaySuccess(text, true);             }         }).addOnFailureListener(new OnFailureListener() {             @Override             public void onFailure(Exception e) {                 displayFailure(e);             }         });      } }).addOnFailureListener(new OnFailureListener() {     @Override     public void onFailure(Exception e) {         displayFailure(e);     } }); 

2.5 翻译完成,释放资源

if (localTranslator != null) {     localTranslator.stop();          } 

了解更多机器学习服务>>

访问机器学习服务联盟官网

获取机器学习服务开发指导文档

了解更多详情>>

访问华为开发者联盟官网
获取开发指导文档
华为移动服务开源仓库地址:GitHubGitee

关注我们,第一时间了解 HMS Core 最新技术资讯~

No comments:

Post a Comment