俊不朗先生

俊不朗先生 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

俊不朗先生 赞了文章 · 1月18日

Node.js源码中,为什么会首选binding ipv6 ? 【精简小知识】

​写这篇文章的起因,在阅读Node.js源码的关键环节,看到了这行代码

意思就是调用listen函数监听端口的最终是调用了这个函数

借此我找到了CVTE技术经理,张师傅,张师傅给我的回复:

于是我点开了翻译,确定了一下大概意思:

意思是说,ipv4套接字无法接受ipv6包

让我们一起看看某度是怎么说ipv4和ipv6的区别的:

1.地址空间不同,IPv4中规定IP地址长度为32,而IPv6中IP地址的长度为128

2.路由表大小不同,IPv6的路由表相比IPv4的更小。

3.IPv6的组播支持以及对流的支持要强于IPv4。

4.安全性不同,IPv6的安全性更高,在使用IPv6的网络时,用户可对网络层的数据进行加密。

5.协议扩充不同,IPv6允许协议进行扩充而IPv4不允许。

打开wireshark抓包

我们看ip头部的信息,0100代表ipv4

0110表示IPv6

当IPv4接口接收到IPv6的数据包的时候会自动丢弃。在开启IPv6协议的路由器上会自动识别IP数据包的版本,并作需要处理。对于一般的电脑网卡,只要开启IPv6协议就可以处理IPv6数据包。

这也印证了那句英语的后半句:

这样你应该就搞明白了,为什么源码中会是这样写了呢。


目前ipv4和ipv6实现双协议栈通信的三种方式:

ipv4与ipv6差异
  1. 双IP层技术

 双IP层是保持IPv6与IPv4互操作性的最直接方式。适用于Internet有IPv4向IPv6过渡的前期。具有双IP层的节点成为IPv4/IPv6节点。它具有两个IP协议的完整实现。

2. 隧道技术

  隧道技术适用于Internet有IPv4向IPv6过渡的中期。其原理是两个IPv6的网络之间的中间网络的多协议路由器接收到源主机的IP包时,将其放入中间网络层数据包在和域中,当其到达中间网络另一端多协议路由器时再恢复源IP包并转发到目的主机,这样整个中间网络就像一条隧道。

3. 报头翻译技术

  当Internet中只有极少数IPv4节点时(过渡阶段后期),报头翻译技术可以保持IPv6与IPv4节点间的通讯。向IPv4节点发送消息时,路径上最后一个IPv6/IPv4路由器发现目的地址是IPv4映射地址则进行报头翻译,将IPv6报头替换成IPv4报头,并转发到目的IPv4节点。

接下来,我将会写webAssebmly在项目中的实践、Node.js核心源码解析,觉得写得不错,可以点个在看,谢谢

查看原文

赞 2 收藏 0 评论 0

俊不朗先生 赞了文章 · 1月6日

前端异常监控 Sentry 的私有化部署和使用

Sentry 为一套开源的应用监控和错误追踪的解决方案。这套解决方案由对应各种语言的 SDK 和一套庞大的数据后台服务组成。应用需要通过与之绑定的 token 接入 Sentry SDK 完成数据上报的配置。通过 Sentry SDK 的配置,还可以上报错误关联的版本信息、发布环境。同时 Sentry SDK 会自动捕捉异常发生前的相关操作,便于后续异常追踪。异常数据上报到数据服务之后,会通过过滤、关键信息提取、归纳展示在数据后台的 Web 界面中。

在完成接入后我们就可以从管理系统中实时查看应用的异常,从而主动监控应用在客户端的运行情况。通过配置报警、分析异常发生趋势更主动的将异常扼杀在萌芽状态,影响更少的用户。通过异常详情分析、异常操作追踪,避免对客户端应用异常两眼一抹黑的状态,更高效的解决问题。

这篇文章也将会从一键部署服务开始,通过解决部署过程中遇到的问题,分享到完成前端应用监控和异常数据使用的整个详细过程,希望会对你的部署和使用中遇到的问题有所帮助。

快速部署 Sentry 服务

Sentry 的管理后台是基于 Python Django 开发的。这个管理后台由背后的 Postgres 数据库(管理后台默认的数据库,后续会以 Postgres 代指管理后台数据库并进行分享)、ClickHouse(存数据特征的数据库)、relay、kafka、redis 等一些基础服务或由 Sentry 官方维护的总共 23 个服务支撑运行。可见的是,如果独立的部署和维护这 23 个服务将是异常复杂和困难的。幸运的是,官方提供了基于 docker 镜像的一键部署实现 getsentry/onpremise

这种部署方式依赖于 Docker 19.03.6+ 和 Compose 1.24.1+

准备工作

Docker 是可以用来构建和容器化应用的开源容器化技术。

Compose 是用于配置和运行多 Docker 应用的工具,可以通过一个配置文件配置应用的所有服务,并一键创建和运行这些服务。

在准备好 linux 服务器之后,并按照官方文档安装好对应版本的 Docker 和 Compose 之后,将 onpremise 的源代码克隆到工作台目录:

git clone https://github.com/getsentry/onpremise.git
# 切换到  20.10.1 版本,后续的分享将会基于这个版本进行
git checkout release/20.10.1

docker 镜像加速

在后续部署的过程中,需要拉取大量镜像,官方源拉取较慢,可以修改 docker 镜像源,修改或生成 /etc/docker/daemon.json 文件:

{
  "registry-mirrors": ["镜像地址"]
}

然后重新加载配置,并重启 docker 服务:

sudo systemctl daemon-reload
sudo systemctl restart docker

一键部署

在 onpremise 的根路径下有一个 install.sh 文件,只需要执行此脚本即可完成快速部署,脚本运行的过程中,大致会经历以下步骤:

  1. 环境检查
  2. 生成服务配置
  3. docker volume 数据卷创建(可理解为 docker 运行的应用的数据存储路径的创建)
  4. 拉取和升级基础镜像
  5. 构建镜像
  6. 服务初始化
  7. 设置管理员账号(如果跳过此步,可手动创建)

在执行结束后,会提示创建完毕,运行 docker-compose up -d 启动服务。

在使用不添加 -d 参数运行 docker-compose up 命令后,我们可以看到服务的启动日志,需要等待内部 web、relay、snuba、kafka 等全部启动并联动初始化后,服务才算完全启动,此刻才可以使用默认端口访问管理端默认服务地址,此时可以进行域名配置,并将 80 端口解析到服务的默认端口上,便可以使用域名进行访问。

welcome

第一次访问管理后台,可以看到欢迎页面,完成必填项的配置,即可正式访问管理后台。

  • Root URL:异常上报接口的公网根地址(在做网络解析配置时,后台服务可以配置到内网外网两个域名,只将上报接口的解析规则 /api/[id]/store/ 配置到公网环境,保证数据不会泄密)。
  • Admin Email:在 install.sh 阶段创建的管理员账号。
  • Outbound email:这部分内容为邮件服务配置,可以先不配置。

完成这部分工作后,对服务没有定制化需求的可以跳至前端接入和使用部分。

docker 数据存储位置修改

可以看到在服务运行的过程中,会在 docker volume 数据卷挂载位置存储数据,如 Postgres、运行日志等,docker volume 默认挂载在 /var 目录下,如果你的 /var 目录容量较小,随着服务的运行会很快占满,需要对 docker volume 挂载目录进行修改。

# 在容量最大的目录下创建文件夹
mkdir -p /data/var/lib/
# 停止 docker 服务
systemctl stop docker
# 将 docker 的默认数据复制到新路径下,删除旧数据并创建软连接,即使得存储实际占用磁盘为新路径
/bin/cp -a /var/lib/docker /data/var/lib/docker && rm -rf /var/lib/docker &&  ln -s /data/var/lib/docker /var/lib/docker
# 重启 docker 服务
systemctl start docker

服务定制

一键部署的 Sentry 服务总会有不符合我们使用和维护设计的地方,这个时候,就需要通过对部署配置的修改来满足自己的需求。

服务组成与运行机制

在通过 docker-compose 快速部署之后,我们先来观察下启动了哪些服务,并为后续的适配和修改分析下这些服务的作用,运行 docker 查看所有容器的命令:

docker ps

可以看到现在启动的所有服务,并且一些服务是使用的同一个镜像通过不同的启动参数启动的,按照镜像区分并且通过笔者的研究推测,各个服务的作用如下:

  • nginx:1.16

    • sentry_onpremise_nginx_1:进行服务间的网络配置
  • sentry-onpremise-local:以下服务使用同一个镜像,即使用同一套环境变量

    • sentry_onpremise_worker_1

      • 可能是处理后台任务,邮件,报警相关
    • sentry_onpremise_cron_1

      • 定时任务,不确定是什么定时任务,可能也是定时清理
    • sentry_onpremise_web_1

      • web 服务(UI + web api)
    • sentry_onpremise_post-process-forwarder_1
    • sentry_onpremise_ingest-consumer_1

      • 处理 kafka 消息
  • sentry-cleanup-onpremise-local

    • sentry_onpremise_sentry-cleanup_1

      • 数据清理,暂时不重要,但是应该和其他的 sentry 服务公用一些配置
    • sentry_onpremise_snuba-cleanup_1

      • 数据清理,暂时不重要
  • getsentry/relay:20.10.1

    • sentry_onpremise_relay_1

      • 来自应用上报的数据先到 relay,
      • relay 直接返回响应状态
      • 后在后台任务中继续处理数据
      • 解析事件、格式调整、启用过滤规则等丢弃数据
      • 数据写入 kafka
  • symbolicator-cleanup-onpremise-local

    • sentry_onpremise_symbolicator-cleanup_1

      • 数据清理的,暂时不重要
  • getsentry/snuba:20.10.1

    • 看起来是消费 kafka 消息,往 ClickHouse 写,用到了 redis,用途不明
    • sentry_onpremise_snuba-api_1

      • snuba 的接口服务,好像没什么作用
    • sentry_onpremise_snuba-consumer_1

      • 消费 Kafka 给 ClickHouse 提供事件
    • sentry_onpremise_snuba-outcomes-consumer_1

      • 消费 Kafka 给 ClickHouse outcomes
    • sentry_onpremise_snuba-sessions-consumer_1

      • 消费 Kafka 给 ClickHouse sessions
    • sentry_onpremise_snuba-replacer_1

      • 看起来是转换老(或者别的转换功能)数据的,从kafka拿后写到kafka
  • tianon/exim4

    • sentry_onpremise_smtp_1

      • 邮件服务
  • memcached:1.5-alpine

    • sentry_onpremise_memcached_1
    • 也许是用来降低数据存储的频次和冲突的
  • getsentry/symbolicator:bc041908c8259a0fd28d84f3f0b12daa066b49f6

    • sentry_onpremise_symbolicator_1

      • 最基础的设施:解析(native)错误信息
  • postgres:9.6

    • sentry_onpremise_postgres_1

      • 基础的设施,服务后台默认的数据库,存储异常数据
  • confluentinc/cp-kafka:5.5.0

    • sentry_onpremise_kafka_1

      • 基础的设施,ClickHouse 和 pg 的数据肯定都是从 kafka 来的
  • redis:5.0-alpine

    • sentry_onpremise_redis_1

      • 基础的设施,有一些拦截配置在这
  • confluentinc/cp-zookeeper:5.5.0

    • sentry_onpremise_zookeeper_1

      • 基础的设施
  • yandex/ClickHouse-server:19.17

    • sentry_onpremise_ClickHouse_1

      • 与pg不同的存储,存储是异常的关键信息,用于快速检索

同时,根据异常上报到服务后,日志的记录情况可知,运行机制大概如下:

  • 异常数据通过 nginx 解析到 relay 服务。
  • relay 通过 pg 获取最新的应用与 token 匹配关系,并验证数据中的 token,直接返回 403 或 200,并对数据进行拦截过滤。
  • relay 将数据发送给 kafka 的不同 topic。
  • sentry 订阅其中部分 topic,解析数据存入 Postgres,用做后续查看错误详情。
  • snuba 订阅其他 topic,对数据打标签,提取关键特征,存入 ClickHouse,用来快速根据关键特征检索数据。

文件结构与作用

要对部署和运行进行修改的话,需要找到对应的配置文件,先看下 onpremise 部署实现的主要文件结构和作用:

  • clickhouse/config.xml:clickhouse 配置文件
  • cron/:定时任务的镜像构建配置和启动脚本
  • nginx/nginx.conf:nginx 配置
  • relay/config.example.yml:relay 服务配置文件
  • sentry/:sentry-onpremise-local 镜像的构建和基于此镜像启动的主服务的配置都在这个文件夹下

    • Dockerfile:sentry-onpremise-local 的镜像构建配置,会以此启动很多服务
    • requirements.example.txt:由此生成 requirements.txt,需要额外安装的 Django 插件需要被写在这里面
    • .dockerignore:Docker 的忽略配置,初始忽略了 requirements.txt 之外的所有文件,如果构建新镜像时需要 COPY 新东西则需要修改此文件
    • config.example.yml:由此生成 config.yml,一般放运行时不能通过管理后台修改的配置
    • sentry.conf.example.py:由此生成 sentry.conf.py,为 python 代码,覆盖或合并至 sentry 服务中,从而影响 sentry 运行。
  • .env:镜像版本、数据保留天数、端口等配置
  • docker-compose.yml:Compose 工具配置,多 docker 的批量配置和启动设置
  • install.sh:Sentry 一键部署流程脚本

同时需要注意的是,一旦部署过之后,install.sh 脚本就会根据 xx.example.xx 生成实际生效的文件,而且,再次执行 install.sh 脚本时会检测这些文件存不存在,存在则不会再次生成,所以需要修改配置后重新部署的情况下,我们最好将生成的文件删除,在 xx.example.xx 文件中修改配置。

根据服务组成和运行机制得知,主服务是基于 sentry-onpremise-local 镜像启动的,而 sentry-onpremise-local 镜像中的 sentry 配置会合并 sentry.conf.py,此文件又是由 sentry.conf.example.py 生成,所以后续定制化服务时,会重点修改 sentry.conf.example.py 配置模板文件。

使用独立数据库确保数据稳定性

在数据库单机化部署的情况下,一旦出现机器故障,数据会损坏丢失,而 onpremise 的一键部署就是以 docker 的形式单机运行的数据库服务,且数据库数据也存储在本地。

可以看到 Sentry 的数据库有两个,Postgres 和 ClickHouse。

虽然 Sentry 不是业务应用,在宕机后不影响业务正常运行,数据的稳定并不是特别重要,但是 Postgres 中存储了接入 Sentry 的业务应用的 id 和 token 与对应关系,在这些数据丢失后,业务应用必须要修改代码以修改 token 重新上线。为了避免这种影响,且公司有现成的可容灾和定期备份的 Postgres 数据库,所以将数据库切换为外部数据库。

修改 sentry.conf.example.py 文件中 DATABASES 变量即可:

DATABASES = {
  'default': {
    'ENGINE': 'sentry.db.postgres',
    'NAME': '数据库名',
    'USER': '数据库用户名',
    'PASSWORD': '数据库密码',
    'HOST': '数据库域名',
    'PORT': '数据库端口号',
  }
}

由于不再需要以 Docker 启动 Postgres 数据库服务,所以将 Postgres 相关信息从 docker-compose.yml 文件中删除。删掉其中的 Postgres 相关配置即可。

depends_on:
    - redis
    - postgres # 删除
# ...
services:
# ...
# 删除开始
  postgres:
    << : *restart_policy
    image: 'postgres:9.6'
    environment:
      POSTGRES_HOST_AUTH_METHOD: 'trust'
    volumes:
      - 'sentry-postgres:/var/lib/postgresql/data'
# 删除结束
# ...
volumes:
  sentry-data:
    external: true
  sentry-postgres: # 删除
    external: true # 删除

同时,由于 Sentry 在启动前,初始化数据库结构的使用会 pg/citext 扩展,创建函数,所以对数据库的用户权限有一定要求,也需要将扩展提前启用,否则会导致 install.sh 执行失败。

控制磁盘占用

随着数据的上报,服务器本地的磁盘占用和数据库大小会越来越大,在接入300万/日的流量后,磁盘总占用每天约增加 1.4G-2G,按照 Sentry 定时数据任务的配置保留 90 天来说,全量接入后磁盘占用会维持在一个比较大的值,同时这么大的数据量对数据的查询也是一个负担。为了减轻负担,需要从服务端和业务应用端同时入手。综合考虑我们将数据保留时长改为 7 天。修改 .env 文件即可:

SENTRY_EVENT_RETENTION_DAYS=7

也可以直接修改 sentry.conf.example.py

SENTRY_OPTIONS["system.event-retention-days"] = int(
    env("SENTRY_EVENT_RETENTION_DAYS", "90")
)
# 改为
SENTRY_OPTIONS["system.event-retention-days"] = 7

需要注意的是,定时任务使用 delete 语句删除过期数据,此时磁盘空间不会被释放,如果数据库没有定时回收的机制,则需要手动进行物理删除。

# 作为参考的回收语句
vacuumdb -U [用户名] -d [数据库名] -v -f --analyze

单点登录 CAS 登录接入

Sentry 本身支持 SAML2、Auth0 等单点登录方式,但是我们需要支持 CAS3.0,Sentry 和 Django 没有对此有良好支持的插件,所以笔者组装了一个基本可用的插件 sentry_cas_ng

使用时,需要进行插件的安装、注册和配置,插件使用 github 地址安装,需要一些前置的命令行工具,就不在 requirements.txt 文件中进行配置,直接修改 sentry/Dockerfile 文件进行安装,追加以下内容:

# 设置镜像源加速
RUN echo 'deb http://mirrors.aliyun.com/debian/ buster main non-free contrib \n\
deb http://mirrors.aliyun.com/debian/ buster-updates main non-free contrib \n\
deb http://mirrors.aliyun.com/debian/ buster-backports main non-free contrib \n\
deb http://mirrors.aliyun.com/debian-security/ buster/updates main non-free contrib \n\
deb-src http://mirrors.aliyun.com/debian/ buster main non-free contrib \n\
deb-src http://mirrors.aliyun.com/debian/ buster-updates main non-free contrib \n\
deb-src http://mirrors.aliyun.com/debian/ buster-backports main non-free contrib \n\
deb-src http://mirrors.aliyun.com/debian-security/ buster/updates main non-free contrib' > /etc/apt/sources.list
# 升级和安装前置工具
RUN apt-get update && apt-get -y build-dep gcc \
    && apt-get install -y -q libxslt1-dev libxml2-dev libpq-dev libldap2-dev libsasl2-dev libssl-dev sysvinit-utils procps
RUN apt-get install -y git
# 安装这个基本可用的 cas 登录插件
RUN pip install git+https://github.com/toBeTheLight/sentry_cas_ng.git

同时修改 sentry.conf.example.py 文件,以进行插件的注册和配置项配置:

# 修改 session 库,解决 session 较长的问题
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
# 在 django 中安装插件
INSTALLED_APPS = INSTALLED_APPS + (
    'sentry_cas_ng',
)
# 注册插件中间件
MIDDLEWARE_CLASSES = MIDDLEWARE_CLASSES + (
    'sentry_cas_ng.middleware.CASMiddleware',
)
# 注册插件数据管理端
AUTHENTICATION_BACKENDS = (
    'sentry_cas_ng.backends.CASBackend',
) + AUTHENTICATION_BACKENDS
 
# 配置 CAS3.0 单点登录的登录地址
CAS_SERVER_URL = 'https://xxx.xxx.com/cas/'
# 配置 cas 版本信息
CAS_VERSION = '3'
# 因为插件是使用拦截登录页强制跳转至 SSO 页面的方式实现的
# 所以需要配置登录拦截做跳转 SSO 登录操作
# 需要将 pathReg 配置为你的项目的登录 url 的正则
# 同时,当页面带有 ?admin=true 参数时,不跳转至 SSO
def CAS_LOGIN_REQUEST_JUDGE(request):
  import re
  pathReg = r'.*/auth/login/.*'
  return not request.GET.get('admin', None) and re.match(pathReg, request.path) is not None
# 配置登出拦截做登出操作
# 让插件识别当前为登出操作,销毁当前用户 session
# 为固定内容,不变
def CAS_LOGOUT_REQUEST_JUDGE(request):
  import re
  pathReg = r'.*/api/0/auth/.*'
  return re.match(pathReg, request.path) is not None and request.method == 'DELETE'
# 是否自动关联 sso cas 信息至 sentry 用户
CAS_APPLY_ATTRIBUTES_TO_USER = True
# 登录后分配的默认组织名称,必须与管理端 UI 设置的组织名相同
AUTH_CAS_DEFAULT_SENTRY_ORGANIZATION = '[组织名]'
# 登录后默认的角色权限
AUTH_CAS_SENTRY_ORGANIZATION_ROLE_TYPE = 'member'
# 登录后默认的用户邮箱后缀,如 @163.com 中的 163.com
AUTH_CAS_DEFAULT_EMAIL_DOMAIN = '[邮箱后缀]'

完成配置后,需要使用 Sentry 的默认组织名 sentry,访问 xxx/auth/login/sentry?admin=true,避过 CAS 插件拦截,以管理员身份登录,然后修改 Sentry 设置的组织名为插件中的配置的组织名变量 AUTH_CAS_DEFAULT_SENTRY_ORGANIZATION 的值。否则新用户通过 SSO 登录后会由于要分配的组织名和服务设置的组织名不匹配出现错误。

cas

修改默认时区

在登录 Sentry 之后,可以发现异常的时间为 UTC 时间,每个用户都可以在设置中将时区改为本地时区:

时区设置

出于用户友好考虑,可以直接修改服务的默认时区,在 sentry.conf.example.py 文件中添加配置:

# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
SENTRY_DEFAULT_TIME_ZONE = 'Asia/Shanghai'

获取真实 IP

Sentry 会获取请求头中 X-Forwarded-For (结构为ip1,ip2,ip3)的第一个 IP 为真实用户 IP,Sentry 一键部署启动的服务的最靠前的服务是一个 Nginx 服务,它的配置就是之前提到的 nginx/nginx.conf 文件,在其中可以看到一行 proxy_set_header X-Forwarded-For $remote_addr;,其中 $remote_addr 表示“客户端” IP,但是这个客户端是相对于 Nginx 服务的而言的,如果前面有其他的代理服务器,那么拿到的就是代理服务器的 IP。在我们的部署环境中,X-Forwarded-For 由前置的 Nginx 服务提供,且已经处理成需要的格式,所以删除此行即可。

角色权限修改

在 Sentry 的默认的角色权限系统中有以下名词,在信息结构按照包含关系有组织、团队、项目、事件。

在角色层面又具有:

  • superuser:系统管理员(非常规角色),可删除用户账号,在 install.sh 脚本执行时创建的账号就是系统管理员。
  • owner:组织管理员,在私有化部署的情况下只有一个组织,即可以修改服务配置之外的信息,可以控制组织及以下层面的配置、删除。
  • manager:团队管理员,可从团队中移除用户,可创建删除所有项目,可创建删除所有团队。
  • admin:可进行项目的设置(如报警、入站规则),批准用户加入团队,创建团队、删除所在团队,调整所在团队的工程的配置。
  • member:可进行问题的处理。

且角色是跟随账号的,也就是说,一个 admin 会在他加入的所有的团队中都是 admin。

在我们的权限设计中,希望的是由 owner 创建团队和团队下的项目,然后给团队分配 admin。即 admin 角色管理团队下的权限配置,但是不能创建和删除团队和项目。在 Sentry 的现状下,最接近这套权限设计的情况中,只能取消 admin 对团队、项目的增删权限,而无法设置他只拥有某个团队的权限。

在 Sentry 的配置中是这么管理权限的:

SENTRY_ROLES = (
  # 其他角色
  # ...
  {
    'id': 'admin',
    'name': 'Admin',
    'desc': '省略'
    'of.',
    'scopes': set(
      [
        "org:read","org:integrations",
        "team:read","team:write","team:admin",
        "project:read", "project:write","project:admin","project:releases",
        "member:read",
        "event:read", "event:write","event:admin",
      ]),
  }
)

其中 read、write 为配置读写,admin 则是增删,我们只需要删掉 "team:admin""project:admin" 后在 sentry.conf.example.py 文件中复写 SENTRY_ROLES 变量即可。需要调整其他角色权限可以自行调整。

其他配置修改

至此,我们的定制化配置就完成了。

基本上所有的配置都可以通过在 sentry.conf.example.py 文件中重新赋值整个变量或某个字段的方式调整,有哪些配置项的话可以去源代码的 src/sentry/conf/server.py 文件中查询,有其他需求的话可以自行尝试修改。

前端接入和使用

后续的接入使用,我们以 Vue 项目示范。

SDK 接入

首先需要进行对应团队和项目的创建:

创建1

选取平台语言等信息后,可以创建团队和项目:

创建2

npm i @sentry/browser @sentry/integrations

其中 @sentry/browser 为浏览器端的接入 sdk,需要注意的是,它只支持 ie11 及以上版本的浏览器的错误上报,低版本需要使用 raven.js,我们就不再介绍。

@sentry/integrations 包里是官方提供的针对前端各个框架的功能增强,后续会介绍。

在进行接入是,我们必须要知道的是和你当前项目绑定的 DSN(客户端秘钥),可在管理端由 Settings 进入具体项目的配置中查看。

dsn

import * as Sentry from '@sentry/browser'
import { Vue as VueIntegration } from '@sentry/integrations'
import Vue from 'vue'

Sentry.init({
  // 高访问量应用可以控制上报百分比
  tracesSampleRate: 0.3,
  // 不同的环境上报到不同的 environment 分类
  environment: process.env.ENVIRONMENT,
  // 当前项目的 dsn 配置
  dsn: 'https://[clientKey]@sentry.xxx.com/[id]',
  // 追踪 vue 错误,上报 props,保留控制台错误输出
  integrations: [new VueIntegration({ Vue, attachProps: true, logErrors: true })]
})

可以看到的是 VueIntegration 增强上报了 Vue 组件的 props,同时我们还可以额外上报构建的版本信息 release。此时,Sentry 已经开始上报 console.error、ajax error、uncatch promise 等信息。同时,我们还可以进行主动上报、关联用户。

Sentry.captureException(err)
Sentry.setUser({ id: user.id })

Sentry 还提供了基于 Webpack 的 plugin:webpack-sentry-plugin 帮助完成接入,就不再做介绍。

如何使用监控数据

进入某个具体的项目后,可以看到 Sentry 根据错误的 message、stack、发生位置进行归纳分类后的 Issue 列表:

issues

在右侧,可以看到每个错误的发生趋势、发生次数、影响用户数和指派给谁解决这个问题的按钮。我们可以通过这些指标进行错误处理的优先级分配和指派。

通过发展趋势,我们也可以观察到是否与某次上线有关,还可以通过左侧的 Discover 创建自定义的趋势看板,更有针对性的进行观察。

点击进入每个 issue 后,可以看到详细信息:

issue

从上到下,可以看到错误的名称,发生的主要环境信息,Sentry 提取的错误特征,错误堆栈,在最下面的 BREADCRUMBS 中可以看到异常发生前的前置操作有哪些,可以帮助你进行问题操作步骤的还原,协助进行问题排查。

Sentry 的入门使用到此为止。其他的功能,如报警配置、性能监控可以自行探索。

招聘

作为智联招聘的前端架构团队,我们一直在寻找志同道合的前后端架构师和高级工程师,如果您也和我们一样热爱技术、热爱学习、热爱探索,就请加入我们吧!请将简历请发送至邮箱zpfe@group.zhaopin.com.cn,或者微信搜索WindieChai沟通。

查看原文

赞 18 收藏 9 评论 2

俊不朗先生 赞了文章 · 2020-11-30

解密智联招聘的大前端架构Ada

Ada是智联招聘自主研发的演进式大前端架构。于2017年正式投入使用后,又经过三年持续演进,全面覆盖了从研发到运维的各个方面,具备跨技术栈工程化体系、交互式图形界面开发工具、自动化发布流程、Serverless运行时和完善的监控预警设施。目前已经支撑集团内数百个工程,在线URL数量多达数千,每日承载请求量逾十亿次。

本文将摘取Ada的一些关键特性,向大家介绍Ada的演进成果和设计思想。

可演进的工程化机制

“可演进”是Ada最核心的设计思想。

Ada的最初版本实际上是它的内核,投入使用后便一直保持每两至三周一个版本的演进速度,不断地巩固内核,完善周边设施,同时开放更多研发能力。我们希望所有工程都能享受到最新版本的特性,不愿意看到工程版本随着时间推移变得碎片化。

考虑到Webpack的灵活性和复杂性会不可避免地助长碎片化,我们决定将其隐藏到Ada内部,由Ada来承担起统一工程化机制的责任。

Ada规范了工程的目录结构,将指定目录下的次级目录作为Webpack Entry处理,实现了对SPA和MPA的同时支持,更容易支撑巨量级的复杂视图。

同时,Ada还统一处理了Webpack Loader及插件的使用方式、CDN地址、Code Split、SourceMap、代码压缩等构建细节,并且自动处理了不同部署环境之间的差异,标准化了工程的构建输出形式。

针对工程之间可能存在的合理的差异性配置,比如域名、根路径和语言处理器(Webpack Loader)等等,Ada还向业务团队提供了一个更加精简的工程配置文件。

image

通过工程规范和工程配置文件,我们把Ada塑造成了一名“Webpack配置工程师”,它会处理好所有涉及到Webpack的工作,业务团队无需关心此类细节。我们也因此对工程化机制有了更强的治理和演进能力,能够在不影响业务团队的情况下进行迭代(比如调整逻辑、修复问题、升级Webpack版本、甚至更换到其他打包工具等等)。

支持多框架

为了更好地支持业务特有的技术诉求,以及应对不断涌现的新框架和新技术,Ada从一开始就将多框架支持能力当作了一个重要的设计目标。

依托于统一的工程化机制,Ada可以根据各种框架的特点针对性地调整Webpack配置,形成新的脚手架。所有脚手架都延用了一致的工程规范和工程配置文件,最大程度上保证了一致的开发体验,减少了框架的切换成本。

image

我们选择Vue.js作为公司的主要前端框架,并为其研发了专门的脚手架。Vue.js脚手架保留了Vue.js在研发效率方面的优点,允许开发者配置多种CSS处理器,并对服务器端渲染提供了良好的支持。

随后,Ada又提供了Weex脚手架来支持移动端快速开发,帮助业务团队将一套代码同时运行在浏览器、iOS和Andriod中。

针对需要支持旧版IE浏览器的业务,我们选择了MVVM模式的鼻祖框架Knockout.js,并将Vue.js广受赞誉的的单文件组件机制引入到Knockout.js脚手架中,为开发者带来了和Vue.js脚手架一样的开发体验。

此外,Ada还提供了用于开发Web API的Node.js脚手架,并逐步为它增加了TypeScript支持和GraphQL研发能力。

“可演进”的Ada工程化机制为新框架预留了充足的扩展空间,也让我们更容易跟进框架的版本更迭,持续为业务团队开放框架的完整能力。

服务器端研发能力

Ada基于Koa研发了Web服务器,并开放了服务器端研发能力,赋予前端工程师更全面的掌控力。不但可以在UI层面执行权限校验、重定向和服务器端渲染(SSR)等操作,还能够通过研发Web API来实现BFF层(Backend for Frontend)。完整的服务器端研发能力能将前后端的接触面(或摩擦面)从复杂的视图层面转移到相对简单可控的BFF层面,实现真正意义上的前后端分离,继而通过并行开发来最大程度提高开发效率。

为了进一步降低服务器端研发难度,Ada在脚手架目录结构规范的基础上,进一步规范了路由函数的声明方式,形成了从HTTP请求到函数的映射关系。请求函数是一个异步函数,Ada会向它传递一个上下文对象。这是一个经过了悉心封装的对象,它包含了当前Request的所有信息,提供了全面控制Response的能力,并且统一了Web API和SSR的API。

image

借助请求函数映射机制和自定义上下文对象,Ada向开发者提供了一种更加简单直接的、面向请求的开发方式,同时隐藏了Koa和Web服务器的技术细节。这种设计使得业务团队可以更加专注于产品迭代,架构团队也能在业务团队无感知的情况下进行日常维护和持续演进(比如调整逻辑、扩充能力、升级Node.js版本、甚至更换到其他Web服务器框架等等)。

Serverless架构

在降低服务器端开发门槛的同时,我们也希望能够降低服务器的运维和治理难度,让前端工程师不必分心于诸如操作系统、基础服务、网络、性能、容量、可用性、稳定性、安全性等运维细节,从而将更多的精力投入到业务和专业技能上。基于这样的考虑,我们引入了Serverless架构。

我们借助容器技术搭建了服务集群,将Ada演进成为一个更加通用的运行时,除了函数发现以及通过执行函数来响应URL请求之外,还对运行时自身提供了全方位的保障。Ada服务器有完整的请求生命周期追踪机制和日志API,能够自动识别和阻断恶意请求,还能从常见的Node.js故障中自动恢复。此外,服务集群也具备完善的安全防御和性能监控设施,并实现了容量弹性伸缩,在节约成本的同时也能更好地应对流量波动。

image

如此一来,服务便从工程中脱离出来,成为Serverless服务集群的一员,继而通过发布流程来将服务和工程连接起来。发布流程也运行在云端,分为部署和上线两个阶段。部署阶段仅仅执行文件构建、上传和注册,不会对线上版本产生任何影响。部署完成后,就可以在发布中心上线具体的URL版本,并且可以随时回滚至历史版本。无论发布还是回滚,都会即时生效。

image

URL粒度的发布方式更加契合前端业务的迭代习惯,更加灵活,与单体应用的整体发布方式相比也更加安全可控。工程作为一种代码组织形式,不再承担服务的责任,可以随时根据需要进行合并和拆分,也能更好地适应虚拟团队这样的组织形态。

工作台

和许多框架一样,Ada早期也提供了一个命令行工具来辅助开发。命令行工具的局限性非常明显,呈现形式和交互形式都过于单一。随着Ada的逐步采用,日常开发过程中产生的信息和所涉及的操作都愈发繁杂。我们需要一个更具表现力的工具来进一步提高工作效率,便基于Electron研发了Ada工作台。

Ada工作台并不是命令行功能的简单复刻,而是对前端图形界面开发工具的大胆想象和重新定义。我们为Ada工作台添加了丰富的功能,全面覆盖了前端工作流程中的开发、调试、发布等环节,使它成为真正的一站式前端开发工具。

我们在Ada工作台中引入了URL级别的按需构建。开发者选择URL之后,Ada工作台就会自动启动多个构建器来执行构建,同时以图例的形式展现构建情况。构建中出现的任何问题,比如未找到引用或者未通过开发规范检查,都可以直观地看到提示,点击提示则能浏览更详细的信息。按需构建既提升了构建速度,也在一定程度上有效地避免了Webpack在构建大型工程时可能出现地各种问题。

除了手工启动构建之外,Ada工作台提供了一种更加便利的方式——“访问即构建”,通过监听对URL的访问,自动启动按需构建,并在构建完成后主动刷新页面。“访问即构建”通过自然的本机调试行为来触发构建,免去了手工逐个选择URL的繁琐操作,很快就成为了开发者的首选构建方式。

虽然服务器端代码最终运行于Serverless环境,但并不意味着开发阶段只能远程调试,为了便于调试,Ada工作台内置了Ada服务器的一个开发版本,该版本仅对本机开发流程进行了适配和功能缩减,其余特性和Serverless版本保持高度一致,诸如端口冲突、环境差异等等困扰开发者的效率障碍在很大程度上都被消除了。

Ada工作台还提供了一个交互式的日志查看器,来帮助开发者浏览本机开发时输出的日志。所有日志都会以非常简约的形式呈现,可以通过点击来浏览明细,同时也提供了关键字搜索和日志级别过滤等功能,以便开发者能快速找到所关心的调试信息。

image

发布流程也被无缝嵌入到Ada工作台中,并且得到了进一步增强,能够方便地执行URL级别的按需发布。

目前,Ada工作台已经成为公司前端技术体系的重要基础设施。前端技术领域还在不断涌现出各种新的概念,而Ada工作台的想象空间依旧很大,这也让我们对它未来能发挥的作用更加期待。

移动端研发能力

我们选择了Weex作为移动端的快速研发框架,帮助业务团队使用熟悉的Vue.js语法开发可以同时运行于浏览器、iOS和Andriod中的应用。

Weex脚手架遵循了Ada的工程化机制,可以享受Ada工作台提供的开发和调试便利。此外,Ada工作台还以插件的形式内置了Weex真机调试工具,以便在App内进行调试。

在开发模式上,我们最大程度保留了Web的特征,为前端工程师带来更加熟悉的开发体验,Web风格的URL路由方式也在Native内核中得到了支持。Native内核向Weex提供了全方位的支持,包括路由、缓存、视图组件、互操作API等等。针对历史遗留的Native平台差异问题,则通过我们研发的mobile-js-bridge来将它们封装成一致的API。

此外,我们为Weex也提供了URL粒度的发布能力,能够独立于App的版本进行发布,极大地提高了移动端的迭代速度和问题响应速度。

image

Ada充分发挥了Weex在快速迭代方面的优势,广泛地应用于公司的各个移动端产品中,先后帮助业务团队答应了多场快速交付战役。

能力扩充

Ada除了支持开发Web页面,还支持开发一种特殊的视图——Widget。作为微前端架构的一种实现,Widget运行在宿主页面中,可以独立开发和发布。其设计目标是解耦代码、流程和团队,帮助业务团队进行跨技术栈、跨产品以及跨团队的功能复用。比如公司所有产品线都需要使用统一的登陆注册Widget,后者由平台团队来维护,在保证兼容性的前提下就可以自行迭代演进,而不需要各产品线逐版本配合发布。Widget SDK负责维护Widget的生命周期,并提供了类似于Web Worker的通信机制,从而实现Widget和宿主页面在技术框架、代码逻辑和发布流程上的完全独立。

image

Widget是一种在客户端复用能力的机制,在服务器端,Ada提供了请求上下文扩展来实现能力复用。请求上下文扩展是一组可以独立开发和发布的函数,发布之后的函数会附加到请求上下文,供特定范围的请求函数调用。借助请求上下文扩展,业务团队可以更方便地复用诸如用户认证和授权之类的服务器端公用能力。

此外,Ada服务器还内置了一些常用的第三方模块的多个版本,比如vue-server-renderer、axios和pg等等。开发者可以通过专门的公共模块API来引用这些公共模块的制定版本。由Ada服务器统一提供的公共模块一方面提升了工程的构建速度,减小了输出体积,另一方面也规避了Webpack无法处理Node.js Native的问题。

对GraphQL进行了大量调研和实践之后,我们决定通过工具包的形式提供GraphQL开发能力。GraphQL工具包同时支持graphql-js和Apollo GraphQL两种实现,并且可以将Schema转化为Ada请求函数,从而在Ada服务器中执行。GraphQL工具包会识别Schema中的异步Resolver,并将它们注册到Ada Server的性能监控和请求跟踪机制中,为业务团队在合并了多个操作的请求中定位问题提供便利。

得益于Ada的“可演进性”,我们能够更加稳健地响应业务诉求,持续不断地将技术洞察转换成新的能力,以更加“Ada”的形式提供给业务团队,上述能力扩展就是其中的典型示例。

质量保障

我们采取了多种技术手段来保障Ada核心代码的质量和Serverless服务集群的稳定性。

Ada核心代码遵循了相当严格的开发规范,并通过数千个单元测试用例100%覆盖了全部代码和执行路径。针对单元测试可能出现的“非有意覆盖”情况,我们特别设计了“混沌模式”,通过随机删除特定的代码来检验测试用例的全面性。

为了确保Ada服务器的变更不会破坏API的向下兼容性,我们在集成测试阶段将Ada的测试版本部署到一组测试容器中,并请求预先发布的测试URL来逐个进行检查API的功能是否正常。Serverless服务集群也配备了完善的日志分析、性能监控、弹性伸缩、故障恢复和预警机制。

image

除此之外,我们还制定了前端开发规范,涵盖了工程规范和JavaScript、TypeScript、Vue.js、CSS、Jest等语言或框架的代码规范。并且在ESLint和StyleLint的基础上研发了配套的检查工具,补充了部分独有的规则。随后又融入到工程化机制、Ada工作台和持续集成流程当中,以帮助业务团队即时发现和纠正问题。

为了进一步保障用户的浏览体验,我们基于Google Chrome Lighthouse研发了Web性能监控平台,长期追踪核心产品在全国各地的性能表现。目前,基于Sentry的错误跟踪和分析平台也正在试运行中。

后记

Ada已经稳定运行了三年,也持续演进了三年,大体经历了三个阶段:

  • “打造内核”阶段,快速定型了Ada的工程化机制和服务器内核,并投入试运行;
  • “完善设施”阶段,Serverless架构的周边设施趋于完善,全面提高性能和稳定性;
  • “丰富体系”阶段,推出Ada工作台和Widget等一系列周边扩展能力,开始探索更多的可能性;

在未来,Ada还将继续迎接不断更迭的前端技术,响应不断变化的业务需求。服务器端研发能力将不再局限于BFF层,更会向开发者公开完整的全栈研发能力;Widget只是Ada涉足微前端的一个小小的尝试,我们还会引入更便于业务深度融合的微前端方案;请求函数映射机制也会从形似FaaS,进一步演进成真正意义上的FaaS……

本文从宏观层面上介绍了智联招聘的大前端架构Ada,并未过多涉及技术细节,如果大家对某个特性感兴趣,可以留言告诉我们,我们会撰写专门的文章来详细介绍。

招聘

作为智联招聘的前端架构团队,我们一直在寻找志同道合的前后端架构师和高级工程师,如果您也和我们一样热爱技术、热爱学习、热爱探索,就请加入我们吧!请将简历请发送至邮箱zpfe@group.zhaopin.com.cn,或者微信搜索WindieChai沟通。

查看原文

赞 14 收藏 4 评论 7

俊不朗先生 关注了专栏 · 2020-08-31

智联大前端

作为智联招聘的前端架构团队,我们开创了细粒度的前端研发和发布模式,统一了移动端和桌面端的技术栈,搭建了灵活可靠的Serverless运行环境,率先落地了微前端方案,并且还在向FaaS和轻研发等方向不断迈进。 我们帮助芸芸众生找到更好的工作,当然也不愿错过走在前端之巅的您。 我们在 zpfe@group.zhaopin.com.cn 恭候您的简历。

关注 78

俊不朗先生 赞了文章 · 2020-08-31

如何为Electron应用实现自动更新

背景

我们使用 Electron 开发了一个桌面端开发工具 Ada 工作台,提速增效前端开发,在更新比较频繁的情况下,为了使整个更新体验更为顺畅、提升工作台的升级比率,需要优化当前的更新机制,尽量做到 VSCode 的无感知更新。

Electron 是一个使用 JavaScript, HTML 和 CSS 等 Web 技术创建原生应用的框架,使用Electron这个框架创建的桌面应用程序就是Electron应用。

Electron现有的相关类库:

打包工具:

更新服务器:

现状

介绍了一些通用的背景知识之后,来看看我们现有的版本升级方案:

  • 打包工具:通过electron-builder进行应用的打包和签名
  • 分发服务器:打包后的安装程序上传到一个自建的服务器electron-release-server
  • 升级逻辑:应用内自行编写更新逻辑,通过定时器查询是否有新的版本可供下载,如果有,将一个完整的安装程序下载到本地,下载完成后提示用户,用户确认后可以启动新的安装程序安装覆盖原有的版本

目前版本升级时的效果:

  • Windows: 应用关闭,并出现一个小小的安装进度条用于等待应用安装完毕,安装完毕后自动启动新的应用
  • macOS: 应用关闭,并出现安装界面,需要手动拖拽到应用文件夹,然后手动从应用文件夹内打开应用

看起来不错,已经实现了基本的更新逻辑,但是效果上让人不满意,与同样是Electron应用的VSCode的升级效果相差甚远,希望实现的效果是:让用户不用再等待一个新的安装过程,我们替用户去安装。每当有新版本发布时,用户只需要重新启动应用程序就能体验到最新的版本。

那么怎么才能实现这个效果呢?

调研

现有的打包工具和更新服务器已经在稳定运行,我们先看看它们能不能实现我们要的效果,如果不能,我们再去尝试别的类库。

我们使用的electron-builderelectron-release-server的文档中都有对auto update的描述。从这里可以看到服务器对更新文件是有类型要求的,比如OS X系统上,安装文件只支持dmg,更新文件只支持zip,Windows系统上,安装文件只能是exe,更新文件只能是完整的nupkg。而我们的打包工具是支持生成这些文件的(打包工具要求macOS应用要使用自动更新的话必须签名)。在应用代码中,则有electron-updater和官方的autoUpdater可供选择,用来检查是否需要更新,并处理具体的更新过程。

实施

  1. 文档中看到macOS要实现自动更新的话,签名是必须的。

    • macOS机器上打包:electron-builder会从系统的钥匙串里找到配置里或者环境变量CSC_LINK(CSC_NAME)的对应的证书来进行签名
    • Windows机器上打包:一般有两种类型的证书,我们使用的是带USB加密器的EV Code Signing Certificate

签名之后也能让用户知道应用开发者的身份,不至于是来历不明的软件。

  1. 准备好签名证书之后,我们将现有的升级逻辑进行优化,这里直接使用了官方的autoUpdater

    1. 使用autoUpdater中的方法替换现有方案中的升级逻辑
    2. 考虑自动升级失败的备用方案,在自动升级失败时,使用原有的升级逻辑中的方案,让用户重新安装完整的安装包
    autoUpdater.on('checking-for-update', () => {
      // 开始检查是否有新版本
      // 可以在这里提醒用户正在查找新版本
    })
    
    autoUpdater.on('update-available', (info) => {
      // 检查到有新版本
      // 提醒用户已经找到了新版本
    })
    
    autoUpdater.on('update-not-available', (info) => {
      // 检查到无新版本
      // 提醒用户当前版本已经是最新版,无需更新
    })
    
    autoUpdater.on('error', (err) => {
      // 自动升级遇到错误
      // 执行原有升级逻辑
    })
    
    autoUpdater.on('update-downloaded', (ev, releaseNotes, releaseName) => {
      // 自动升级下载完成
      // 可以询问用户是否重启应用更新,用户如果同意就可以执行 autoUpdater.quitAndInstall()
    })
  2. 为了配合electron-release-server对更新文件的要求,我们需要修改打包配置。

    打包工具现有的打包配置mac关键词的target是default,也就是打包mac应用时默认会生成dmg和zip文件,而win关键词的target是默认的nsis,打包后只生成了exe文件,并没有nupkg文件。于是尝试将target改为squirrel,会按照server的要求生成nupkg文件用于更新,autoUpdater.quitAndInstall之后具体的效果是:

    • Windows: 应用关闭,并出现一个可自定义的gif图片用于等待应用安装完毕,安装完毕后自动启动新的应用
    • macOS: 应用关闭,大约一秒就启动了新的应用

对比我们现有的效果,在Windows上差别不大,但进度条相对于gif来说更好一点。在macOS上明显是新方案占优。于是我将Windows和macOS分别做了升级的逻辑,在Windows上依旧走旧的升级逻辑,在macOS上通过autoUpdater来升级,并用旧逻辑作为备用方案。到此为止,我们的更新方案就完成啦,最终的效果:

  • Windows: 应用关闭,并出现一个小小的安装进度条用于等待应用安装完毕,安装完毕后自动启动新的应用
  • macOS: 应用关闭,大约一秒就启动了新的应用

踩坑

大部分情况下我们不会是第一个遇到问题的人,无数前辈在互联网上留下了他们解决问题的方案,等着我们从搜索引擎中将他们找到,然而”定义问题“本身就不是一件容易的事。留下一些开发过程中遇到的问题的解决方案,当作后来者的宝藏。

  • autoUpdater.setFeedURL出错。

    autoUpdater.on('error', (err) => {
      console.log(err) //"Error: The resource could not be loaded because the App Transport Security policy requires the use of a secure connection."
    })

    第一个遇到的问题出现得猝不及防,错误的描述其实已经很清晰,App Transport Security 要求资源通过安全的连接来加载,也就是通过HTTPS协议。autoUpdater.setFeedURL的url地址一看是https的,然后就略过了这里,殊不知后来发现这个接口中我们又对electron-release-server的接口进行了封装,而electron-release-server的接口并不是https的。然后将appUrl改成https即可,btw,这个文档的位置可真难找。

  • electron-release-server更新接口的检查更新逻辑问题。

    ${ADA_AUTOUPDATE_FEED_API}${platform}/${version}/${channel}

    channel为beta时,预期是不管任何时候,只要version不是最新的beta版本,接口应该返回最新的beta版本。但是实际发现这个接口的实现跟上传时间有关,比如version为2.18.0-beta.202008051710时,接口返回2.18.0-beta.202008142217。这是正常的。但是version为2.18.0-beta.202008051438时,接口返回2.18.0,返回的是stable的版本,并且没有按预期找到最新的beta版本。这是因为在接口内获取最新版本时,会根据版本创建时间去进行一次筛选,并且对channel采取了优先级过滤,比如我们这里是beta版本,在获取新版本时会把优先级更高的rc,以及stable版本都包含进来。

    没有考虑提交MR是因为看得出来作者在这里是有自己的考虑的,只是预期结果与我们希望的不一样而已,所以这里的解决方案是自行提供一个符合我们预期的接口。

  • Windows上通过命令行打包乱码问题。

    因为证书中有中文,所以需要修改命令行编码模式为utf-8,通过命令:chcp 65001 ,然后再次尝试即可。

总结

整体而言,Electron的开发体验还是很不错的,不管是官方文档还是第三方类库都比较完善,还有VSCode、Twitch、Facebook Messenger为它背书。桌面跨平台软件在可见的未来依旧是有不小的市场需求的,大家学起来吧!

招聘

我们是智联大前端,作为智联招聘的前端架构团队,我们在过去的几年中开创了细粒度的前端研发和发布模式,统一了移动端和桌面端的技术栈,搭建了灵活可靠的Serverless运行环境,率先落地了微前端方案,并且还在向FaaS和轻研发等方向不断迈进。

诚招前后端架构师和高级工程师,如果您也和我们一样热爱技术、热爱学习、热爱探索,就请加入我们吧!请将简历请发送至邮箱zpfe@group.zhaopin.com.cn,或者微信扫码了解详情。

image

查看原文

赞 12 收藏 5 评论 8

俊不朗先生 收藏了文章 · 2020-07-09

支付宝2020新春活动WebGL技术实践

新春红包项目,作为每年用户基数最大的支付宝活动之一,对整个项目组的技术都是一个很大的考验。而作为前端,我们的技术考验就是如何在保证稳定性的同时,为用户不断带来更好的创新体验。

而今年的新春红包项目相比以前,多了不少互动图形方面技术的运用,尤其是第一次对 3D(WebGL)技术的引进。对于新春这个亿万量级的活动而言,这无疑是个巨大的挑战。但作为合格的工程师,效果和稳定性的平衡是我们的一贯的追求,经过了前期的积累,我们使用自研的 Web3D 游戏引擎以及特效编辑器,学习了许多在整个横向前端领域、做的相对最好的游戏领域的经验,最终达到了比较复杂 3D 场景下极低的异常率。

我们的成果

我们在此次新春活动的两个场景中都达到了极好的效果和稳定性的平衡:

  • 首页 3D 展示:5 个复杂模型的内存总开销为峰值 30M,稳定 20M,对整体稳定性无影响。
  • 福满全球:3D+UI 总内存开销峰值 70M,稳定 40M,加 Webview 总开销 100M。

为了最好的效果

技术的使命只有一个——为用户带去最好的体验。所以在项目最初肯定是先按照最高视觉效果来,针对新春中的两个使用到了 3D 的场景,我们首先进行了尝试,然后发现了问题。

并非最佳体验

在这种情况下,我发现虽然视觉效果达到了最优,但又出现了很多其他方面的问题,这使得最终的用户体验反而并不是很好:

  1. 加载时间过长
  2. 用户体感卡顿
  3. 相对高发的崩溃(主要是 OOM)

而这某种程度上也符合我们一开始的预测,因为在移动端 Web 这种技术方案中,有些限制是不可避免的,而这些不安定要素会在亿万用户的量级被无限放大。在经过数据的详细的分析,我们找到了针对这两个场景的瓶颈共性。

瓶颈在何处

为了找到瓶颈,让我们先来看一些数据。

首先是首页 3D 动画,首页总共五个场景模型,使用的资源包括:

可见统计下来,3D 部分最终可以预计的传输大小为 15M,峰值内存为 65M,而稳定下来最好情况也有 30M 内存开销(这种策略下一般达不到最好,预计 40M 左右)。同时由于单场景 5W 三角形,对于中低端机的帧率也有较大挑战。

其次是福满全球页面,福满全球的模型资源开销基本可以忽略,但却有其他方面的问题:

可见,福满全球 3D 部分的主要开销是在峰值 70M 的纹理,以及高清屏大量透明物体的渲染开销。

死磕解决方案

通过瓶颈分析可知,问题主要集中在内存、传输体积和运行性能三个方面,而这三者又互相关联。那么自然的,我想到了从相对容易解决同时收益又大的方面入手。

削减模型大小

首先就是削减模型大小了,注意这个大小指的是三角形 / 顶点数量。为什么这个如此重要呢?很简单——模型的大小可以直接影响到以上的三个要素:

1. 内存:模型的大小和其占据的内存是线性正相关的。

2. 传输体积:和内存一致。

3. 运行性能:单帧三角形数量越多,顶点着色器的压力越大,尤其是在首页 3D 模型这种具有骨骼动画的情况下。

所以削减模型大小显然是必须要做的,那么我们如何去做呢?一般来讲,这件事应该交由设计同学,让他们去降低模型精度来达到一个可以接受的程度,但是结果不容乐观,经过研究,我们最终采用了以下两个策略:

1. 使用工具减模

首先是寻求能否使用工具自己进行减模,我们自研的 Web3D 游戏引擎使用 Unity 进行场景编辑,而 Unity 作为身经百战、扩展性极强的一个游戏引擎,有没有这么一个工具来帮助我们呢?答案是有的—— UnityMeshSimplify 就提供了让我们在 Unity 中自由调整模型精度并序列化的能力。而也正是使用了它,在视觉效果损失较低的情况下,平均降低了所有场景 30% 的图元数据大小:

2. 削减不必要的数据

在工具减模以后,图元数据大小从 12M 降到了 7.5M,但这显然还是不够,那么还有什么办法呢?在思考后发现了一个关键点——处于性能考量,此次模型的光影是烘焙到纹理的,也就是说整个场景没有光照。

这里就需要我们了解一些细节了,即顶点数据的构成。

图元的最基本单元是顶点,一个顶点有包含着若干信息,在绘制时这些顶点数据将会被送入顶点着色器进行一系列处理,然后进入光栅化阶段。而一个顶点的信息,最常见来看,包含:

而其中的法线和切线在首页 3D 展示中并没有作用,所以可以将其删除,我在 UnityToolkit 中添加了 Unlit(No Normals) 选项来让导出时可以自动剔除这两项:

而最终效果也令人满意,图元数据大小进一步降低到了 5MB。

3.成果小结

模型裁剪主要是针对首页 3D 展示的,经过优化,我们得到了成果:

(1)单场景最大三角形数量从 5.5W 降到 2.9W;

(2)所有场景图元和动画数据大小从 12MB 降到 5MB。

可见,我们成功将成功将图元数据 + 动画大小缩减了一半,还保证了最复杂的场景的三角形数量也缩减了将近一半,使得内存开销低了不少,同时传输体积小了不少,还大幅优化了渲染性能开销。

但显然传输体积还是太大了,这里我们还进行了进一步的优化。

使用压缩纹理

解决了模型图元大小,接下来就是纹理的开销了。通过上面的瓶颈分析可知,福满全球项目的开销主要就是在纹理方面。

1. 何为纹理

纹理读者也可以理解为贴图、图片。一般来讲,我们存储的图片都是以 JPG、PNG 等格式存储的,而格式决定的是什么呢?其实是压缩和编码算法。实际上,无论我们把一张 JPG 或者 PNG 图片压缩得再小,它最终被解码后在内存中还是以 Bitmap 的形式存在的,而且在浏览器中,基本都是以 RGBA 的像素格式存在的。

无论使用那种方式编码存储,最终都会被解码为 RGBA32 的 Bitmap,一个像素 4 字节。

这就意味着无论我们将图片的存储体积压缩到多么小,其内存开销总是固定的,比如 512x512 的图片内存开销就是 1M,而 1024x1024 的就是 4M。那么有没有办法解决这个问题呢?当然有——游戏业界为了解决这个问题,提出了压缩纹理技术。

2. 压缩纹理

压缩纹理是一种游戏领域常用的纹理压缩技术,其依赖于特定硬件实现,本质上可以以固定速率交由 GPU 即时解压,其有如下优势:

(1)内存:大幅节省内存开销。

(2)解码:免去图片解码开销,直接丢给 GPU,提升启动性能。

(3)采样:提升纹理随机采样性能。

(4)可控:由于其本身就是在 JSHeap 上申请的 buffer,所以在 Web 容器下,提供了一个可以精确控制内存的方式。

PVRTC 的 Block 说明

经过调研和一些测试,我们最终选择了安卓下使用 ASTC 和 iOS 下使用 PVRTC 的策略来进行纹理压缩,其中更为细节的配置暂且不表(都是中等精度压缩),最终在项目中得出的成果如下:

(1)首页 3D 展示:

(2)福满全球:

可见压缩纹理对于内存的开销有着极大的优化,基本完全解决了内存问题。

3. 条件和代价

当然,这世界上并没有免费的午餐,我们接受了压缩纹理的优点,就要相对得付出代价以及接受它的约束:

(1)压缩纹理是有损压缩,会对图片的质量有一定减损,这个需要视项目而定。

(2)压缩纹理的传输体积可能比 JPG/PNG 方案要高 1~4 倍。

(3)压缩纹理要求 POT,即长宽都是二的幂次。

(4)对于 iOS 的 PVRTC,还要求长宽相等。

(5)由于压缩纹理格式在不同平台不能通用,加上降级需要三份资源,对于离线加速技术不友好。

对于某些代价,比如视觉质量损失、传输体积我们是可以自行调整的,不属于原则性难题,但这个 POT 对于很对前端应用可真是个原则性问题了,比如福满全球中的地标和红包贴图,就不是 POT 的,那么怎么办呢?有办法——使用图集。

4. 纹理标准化 - 图集

图集是一种纹理标准化的方式,在游戏领域常常用于处理 UI、2D 精灵等,简单来讲,图集就是将许多图片拼到一张上,不错就是我们常说的雪碧图(精灵图):

如图,我们将四个 500x500 的地标图片拼到了一张 1024x1024 的图集中,来满足压缩纹理的需求。那么我们又如何去使用这个图集呢?很简单,我们的引擎内置了 AtlasManager,可以让你非常简单得使用它,并且在引擎标准的开发流程中,依赖于 Unity+Webpack 工作流,这个能力能够十分方便得引入——在 Unity 中直接编辑图集,后面会说到。

图集还有别的优势,就是减少内存碎片,减少数据提交次数,某些情况下还可以减少资源请求。

精确掌控内存

目前我们拥有了削减模型和压缩纹理两种策略,大幅降低了内存开销,并降低了一部分传输体积,但通过上面的论述不难发现其实我们还可以更进一步——我们很容易发现,在整个过程中,同一份数据可能在 CPU 和 GPU 端同时存在,尤其是移动设备 CPU 和 GPU 是共享内存的。所以我们一定有办法再更进一步去解决这个问题。

这也就是我选用压缩纹理的另一个理由——压缩纹理本质上是 JSHEAP 上的 ArrayBuffer,我们可以很好得通过控制引用来帮助 GC,这也就是为何上面的数据分析中能保证稳定开销是峰值的一半。

在我们引擎的设计中,这个功能是可选的,通过纹理的 isImageCanRelease 来开启,而如果遵守标准工作流,这一切都是自动的,无需开发者操心。

当然这也有代价,就是在 GL 上下文丢失后无法恢复,请酌情使用。

进一步减少传输体积

到目前为止,内存已经被控制得很好了,但是在传输体积上还是有更大的优化空间,在这个方面我首先考虑的就是内部的 Hilo3d 团队提供的模型压缩方案。

1. 模型压缩

我们采用的模型压缩方案原理很简单,针对移动端使用的模型,并不需要每个顶点数据都是 32bits 的 float 型,一般来讲 13bits 或者 14bits 就够用了,所以这里有很大的可压缩空间。而事实上经过测试,发现确实如此,但当然这也是有代价的,通过模型压缩:

也就是说,模型压缩后,首页的所有资源大小达到了安卓 5.8M、iOS5.2M。但代价是增加了解压时间和 1.5M 的峰值内存。相对于收益,开销是可以接受的。

然而即便如此,5M 的资源大小对于亿万 UV 的量级还是有些大,我们还有更多的办法吗?有,这时候就要请出我们的老朋友 GZIP 了。

2. GZIP

大家都熟知的 GZIP 其实在很多时候都能发挥意想不到的作用,而在我们的工作链路下,模型压缩会提升 GZIP 的效果,而压缩纹理也能获得收益,在 GZIP 后:

可见,我们让资源体积再减半,和一开始相比缩减了六倍。

3. 一般图片资源

当然除了 3D 相关的资源,我们也提供了方法来对普通图片进行了压缩,主要是将 PNG 图片编码压缩成了索引色,这是一种有损压缩,也就是大家常用的 TinyPNG 的策略,当然这个并没有什么神奇的,我们已经将这种算法作为了一个插件融入了工具链中,可以通过 Webpack 工作流直接无缝整合,最终普遍带来了 2~4 倍的体积压缩。

减少资源请求

到了这里我们解决了大部分主要问题,但还有一些边角问题会对体验的极致构成影响。这一点就是资源请求数量,我们不难发现,对于两个场景而言,3D 场景的资源请求数量都接近 20 个,而这个问题并非不可解。

对 3D 领域有一定了解的读者想必是知道 glTF 这个格式的,而我们自研引擎的场景序列化也是使用了这个格式。为了应对某些场合,glTF 有它的二进制形式 GLB,其可以将索引、纹理、图元数据等等都打包到一个二进制文件中,大幅降低请求数量,在两个新春场景中,请求数量均被降到了 1 次。

而打包 GLB 的功能也被我们整合进了 Webpack 链路中,开发者可以零成本将其引入。

剩余的性能问题

以上问题解决完成后,基本就可以保证项目稳定了。对于福满全球大量透明物体和高清屏的问题,经过业务层面的调优,最终发现在可控范围内。这个是由于业务性质决定的,否则我们当然可以采用强制最大画布尺寸来降低开销。

除此之外,还有一点需要注意的是我们很可能忽略的一点——运行时的 GPU 资源提交。由于引擎的设计是用到了在提交的原则(当然这很符合规范),但对于这两个项目,保证用户操作时不卡顿的优先级是很高的,而同时经过了上面的内存优化我们也已经保证了即使所有资源都被提交也可控,所以就需要一个策略将所有资源先提交到 GPU,并预编译所有 Shader。

为了做到这一点,我们采取了一个简单的策略:在第一帧将所有物体渲染一遍,再结束 Loading,这增加了些许的加载时间,但保证了整个过程中不会卡顿。

而对于首页 3D 展示,为了做到极致的效果,我们设计了渐进式展示的策略。

渐进式展示

做这个策略是考虑到项目用户量级极大,网络情况不一,所以不可能等待 Loaing 结束才展示页面,那样首次性能会很差,所以我们敲定方案——总是先展示静态图片,3D 资源加载、解析、提交 GPU 成功后,才无缝切换为 3D 动画。

首页 3D 动画的这种策略是值得很多展示型项目参考的,这里还需要注意的的一点是:若模型比较复杂,首帧渲染会卡住用户操作。所以针对本项目的场景,我们采用了时间分片的策略,将五个模型拆分为五次渲染,每次间隔 200ms,留给用户操作的时间:

并且我们还保证静态图片和 3D 场景的姿态完全一致,从而达到视觉上无缝切换的目的。

酷炫易用 - 粒子特效

我们在大促的时候都需要炫酷的页面来吸引用户,但是动画通常都是开发的噩梦,通常我们在做动画会遇到以下三个问题:

  • 动画粗糙,不能打动用户;
  • 还原度不高,和设计差距较大;
  • 性能优化不足,兼容性不好。

这次的新春红包项目大量使用了 3D 场景,在 3D 中加入了很多粒子特效,那么这些特效是如何产出并且解决以上三个问题的?

让动画设计更精美

我们在首页切换的时候增加旋转的粒子特效,效果如下:

这个是设计同学的原稿,由于 Lottie 技术的普及,设计同学做动画大多使用 After Effect 在 AE 中制作好的 transform 动画(仅使用 translate、scale、rotate 变化)导出可使用 Lottie 播放,大大降低开发成本。而 AE 本身是一个视频后期软件,里面除了可以制作简单的 transform 动画,还可以开启 3D 渲染,进行图像跟踪,加滤镜等等。这个粒子特效就是用 AE 里 Particular 插件制作的,所以 AE 的上限就是设计师设计的上限。

设计师的设计工具将直接决定设计产物的质量。如果没有 particular 插件,那么我们的设计产物永远都只会是 transform 动画,很多影视级别的特效就不会出现在产品页面中,所以提高设计工具能力将直接决定动画产出的质量。当然还有一个值得焦虑的问题,我们的产品开发并不知知道 particular 的插件是怎么实现的,那么很大概率是无法还原的,所以既要提高设计工具的质量,也要限制设计随意使用设计工具导致无法实现。

新春红包项目的粒子特效设计全部在工具里实现:

如果是手写代码还原设计稿的话,恐怕最要命的就是函数曲线的还原,动画为了更加顺滑会加入很多曲线来控制,比如说刚才的旋转上升的星星,会有一个先加速再减速的过程:

一个复杂的粒子系统有 60 多个属性,如果开发通过肉眼还原数据,哪怕是复制粘贴属性值,都可能会出问题。

最好的还原方法是不写代码。编辑器直接导出动画数据,在手机上进行播放,开发完全不用关心各种参数。而产物很容易使用,直接保存项目工程,通过 webpack 进行加载,像使用图片一样简单。

新春红包项目中 3D 场景由 引擎 搭建渲染,使用的时候也是类似的方式,将编辑器工程作为资源引入,直接播放就可以了。动画播放起来之后就是开发最关心的问题了。

保证动画性能

其实任务首页的粒子效果还很少,谈不上性能瓶颈,而福满全球大量使用粒子,特别是烟花作为常驻特效,需要特别进行优化。这里我们参考了游戏领域粒子系统的许多优化策略,将其运用到了本次的优化中。

优化一:粒子运动完全 GPU 运算

对于粒子系统来说,因为粒子数量大,使用曲线控制后运动计算复杂,如果通过 CPU 计算粒子的运动,那么网页将不堪重负,所以粒子的运动旋转和颜色变化计算全部放在 GPU 中,通过定制 shader 完成,在 shader 中计算曲线是比较复杂的事情(此处省略 3 千字)。

优化二:优化粒子发射器

可以看到进度条的粒子持续产生,因为粒子有生命周期,所以会有老的粒子死亡,新的粒子出生,繁衍不息。首先我们在内存中开辟一块固定的地址,有一个按照粒子的生命周期排序的双向列表,每一帧需要产生新粒子的时候,检查列表最先死亡的粒子,如果此粒子已经死亡,那么会把这个粒子的地址写入新粒子的数据,同时将此列表元素从后插入。大概类似如下过程:

这样的列表可以保证粒子插入的速度,假设一个粒子系统有 200 个粒子,每帧其实只有 3-4 个新粒子的插入,在 CPU 中的计算量很小。

优化三:合并发射器

一个烟花是由两个发射器组成的,构成了双层烟花的效果,同时每个烟花增加一个拖尾效果,烟花飞过的地方就有个小尾巴。编辑效果如下:

可以看到烟花以相同的模式爆炸了 6 次,但是每次爆炸的位置不一样,通常情况下我们在编辑器会做好一次的爆炸,然后复制 6 次,时间轴类似如下:

但这样的话会导致频繁创建销毁绘制元素,性能消耗很大,所以编辑器提供了合并粒子爆炸的选项,并且每次可以修改爆炸的位置。对于习惯了复制粘贴的设计师来说,很容易复制很多相同元素导致性能开销过大,将六个绘制元素合并成一个元素可以大大降低开销,同时重复利用内存。

优化四:减少拖尾使用

拖尾就是飞线,在粒子运动过的地方生成一个顶点,绘制的时候连成一条线,这样就有流星划过的感觉。但是因为粒子的计算都是在 GPU 中的,所以每帧如果要生成新的顶点,必须在 CPU 中也重新计算粒子的位置,这样的计算量是很大的。如果烟花只是进场爆炸一次,那么开销可以忽略,但是有一些烟花是常驻的,隔一段时间就会播放一次,那么对于常驻的动画,就要避免使用拖尾。这次我们选择了用贴图缩放的方法来替代拖尾。

如果不用贴图的话,看起来就是一圈延展的小菊花,这是通过通过增加长方形长度实现的,换上我们尖角的贴图就非常像一条尾巴,最后常驻烟花没有使用拖尾,但是视觉效果仍然很像划过的流星,这样保证所有的计算都在 GPU 中进行,提高动画性能。

做到极致 - 工程自动化

当然,作为引擎开发者,除了将这些技术应用到新春项目中,使得更多开发者可以简便使用这些功能也是很重要的,所以我将这一切都封入了引擎的标准工作流中:

引擎工作流

引擎的工作流集成了以上所论述的所有优化策略,其主要包括 UnityToolkit 和 Webpack 链路两部分。

1. UnityToolkit

UnityToolkit 是 Unity 的一个插件,用于将 Unity 中的各种特性导出供引擎使用,整个流程集成度很高,目前已经支持了大量特性,包括但不限于 GameObject、模型、材质、纹理、动画、光源、摄像机、天空盒、图集、精灵、物理、音频、环境反射、环境照明、光照贴图的导出和导入,支持自定义扩展组件,支持脚本逻辑绑定等等。

2. Webpack 链路

然后就是 Webpack 链路了,我对 Webpack 链路做了深度定制,用于满足引擎的工作流的需求。上面提到的压缩纹理、模型压缩、资源预处理、资源自动化发布等都被集成到了其中,包括多平台适配也是通过 Webpack 插件实现的。

这里先大概介绍一下此次项目用到的最主要的链路:gltf-loader

这个 Loader 是整个链路中非常核心的一贯,其提供了加载 gltf 文件并进行复杂预处理的能力。使用它,我们可以做到:

(1)模型压缩。

(2)纹理压缩。

(3)打包 GLB。

(4)资源预处理:对 gltf 文件引用的资源进行预处理,通过定制 Processor 接口你可以实现任何你想要的任何预处理。

(5)资源发布器:自定义发布器,在 gltf 文件引用的资源(包括自身)被产出时,拦截并进行自动化处理。

而在这两个项目中,这几个功能都被用到了,也为最终项目的稳定可靠提供了重要的保障。

关于团队

本次分享来自于支付宝 Turandot Studio,Turandot Studio 致力于通过互动和图形技术,让前端变得更加具有创意,为用户带来更美好的体验,如果你有意加入我们,请联系我的邮箱:shunguang.dty@antfin.com。

查看原文

俊不朗先生 赞了文章 · 2020-07-09

支付宝2020新春活动WebGL技术实践

新春红包项目,作为每年用户基数最大的支付宝活动之一,对整个项目组的技术都是一个很大的考验。而作为前端,我们的技术考验就是如何在保证稳定性的同时,为用户不断带来更好的创新体验。

而今年的新春红包项目相比以前,多了不少互动图形方面技术的运用,尤其是第一次对 3D(WebGL)技术的引进。对于新春这个亿万量级的活动而言,这无疑是个巨大的挑战。但作为合格的工程师,效果和稳定性的平衡是我们的一贯的追求,经过了前期的积累,我们使用自研的 Web3D 游戏引擎以及特效编辑器,学习了许多在整个横向前端领域、做的相对最好的游戏领域的经验,最终达到了比较复杂 3D 场景下极低的异常率。

我们的成果

我们在此次新春活动的两个场景中都达到了极好的效果和稳定性的平衡:

  • 首页 3D 展示:5 个复杂模型的内存总开销为峰值 30M,稳定 20M,对整体稳定性无影响。
  • 福满全球:3D+UI 总内存开销峰值 70M,稳定 40M,加 Webview 总开销 100M。

为了最好的效果

技术的使命只有一个——为用户带去最好的体验。所以在项目最初肯定是先按照最高视觉效果来,针对新春中的两个使用到了 3D 的场景,我们首先进行了尝试,然后发现了问题。

并非最佳体验

在这种情况下,我发现虽然视觉效果达到了最优,但又出现了很多其他方面的问题,这使得最终的用户体验反而并不是很好:

  1. 加载时间过长
  2. 用户体感卡顿
  3. 相对高发的崩溃(主要是 OOM)

而这某种程度上也符合我们一开始的预测,因为在移动端 Web 这种技术方案中,有些限制是不可避免的,而这些不安定要素会在亿万用户的量级被无限放大。在经过数据的详细的分析,我们找到了针对这两个场景的瓶颈共性。

瓶颈在何处

为了找到瓶颈,让我们先来看一些数据。

首先是首页 3D 动画,首页总共五个场景模型,使用的资源包括:

可见统计下来,3D 部分最终可以预计的传输大小为 15M,峰值内存为 65M,而稳定下来最好情况也有 30M 内存开销(这种策略下一般达不到最好,预计 40M 左右)。同时由于单场景 5W 三角形,对于中低端机的帧率也有较大挑战。

其次是福满全球页面,福满全球的模型资源开销基本可以忽略,但却有其他方面的问题:

可见,福满全球 3D 部分的主要开销是在峰值 70M 的纹理,以及高清屏大量透明物体的渲染开销。

死磕解决方案

通过瓶颈分析可知,问题主要集中在内存、传输体积和运行性能三个方面,而这三者又互相关联。那么自然的,我想到了从相对容易解决同时收益又大的方面入手。

削减模型大小

首先就是削减模型大小了,注意这个大小指的是三角形 / 顶点数量。为什么这个如此重要呢?很简单——模型的大小可以直接影响到以上的三个要素:

1. 内存:模型的大小和其占据的内存是线性正相关的。

2. 传输体积:和内存一致。

3. 运行性能:单帧三角形数量越多,顶点着色器的压力越大,尤其是在首页 3D 模型这种具有骨骼动画的情况下。

所以削减模型大小显然是必须要做的,那么我们如何去做呢?一般来讲,这件事应该交由设计同学,让他们去降低模型精度来达到一个可以接受的程度,但是结果不容乐观,经过研究,我们最终采用了以下两个策略:

1. 使用工具减模

首先是寻求能否使用工具自己进行减模,我们自研的 Web3D 游戏引擎使用 Unity 进行场景编辑,而 Unity 作为身经百战、扩展性极强的一个游戏引擎,有没有这么一个工具来帮助我们呢?答案是有的—— UnityMeshSimplify 就提供了让我们在 Unity 中自由调整模型精度并序列化的能力。而也正是使用了它,在视觉效果损失较低的情况下,平均降低了所有场景 30% 的图元数据大小:

2. 削减不必要的数据

在工具减模以后,图元数据大小从 12M 降到了 7.5M,但这显然还是不够,那么还有什么办法呢?在思考后发现了一个关键点——处于性能考量,此次模型的光影是烘焙到纹理的,也就是说整个场景没有光照。

这里就需要我们了解一些细节了,即顶点数据的构成。

图元的最基本单元是顶点,一个顶点有包含着若干信息,在绘制时这些顶点数据将会被送入顶点着色器进行一系列处理,然后进入光栅化阶段。而一个顶点的信息,最常见来看,包含:

而其中的法线和切线在首页 3D 展示中并没有作用,所以可以将其删除,我在 UnityToolkit 中添加了 Unlit(No Normals) 选项来让导出时可以自动剔除这两项:

而最终效果也令人满意,图元数据大小进一步降低到了 5MB。

3.成果小结

模型裁剪主要是针对首页 3D 展示的,经过优化,我们得到了成果:

(1)单场景最大三角形数量从 5.5W 降到 2.9W;

(2)所有场景图元和动画数据大小从 12MB 降到 5MB。

可见,我们成功将成功将图元数据 + 动画大小缩减了一半,还保证了最复杂的场景的三角形数量也缩减了将近一半,使得内存开销低了不少,同时传输体积小了不少,还大幅优化了渲染性能开销。

但显然传输体积还是太大了,这里我们还进行了进一步的优化。

使用压缩纹理

解决了模型图元大小,接下来就是纹理的开销了。通过上面的瓶颈分析可知,福满全球项目的开销主要就是在纹理方面。

1. 何为纹理

纹理读者也可以理解为贴图、图片。一般来讲,我们存储的图片都是以 JPG、PNG 等格式存储的,而格式决定的是什么呢?其实是压缩和编码算法。实际上,无论我们把一张 JPG 或者 PNG 图片压缩得再小,它最终被解码后在内存中还是以 Bitmap 的形式存在的,而且在浏览器中,基本都是以 RGBA 的像素格式存在的。

无论使用那种方式编码存储,最终都会被解码为 RGBA32 的 Bitmap,一个像素 4 字节。

这就意味着无论我们将图片的存储体积压缩到多么小,其内存开销总是固定的,比如 512x512 的图片内存开销就是 1M,而 1024x1024 的就是 4M。那么有没有办法解决这个问题呢?当然有——游戏业界为了解决这个问题,提出了压缩纹理技术。

2. 压缩纹理

压缩纹理是一种游戏领域常用的纹理压缩技术,其依赖于特定硬件实现,本质上可以以固定速率交由 GPU 即时解压,其有如下优势:

(1)内存:大幅节省内存开销。

(2)解码:免去图片解码开销,直接丢给 GPU,提升启动性能。

(3)采样:提升纹理随机采样性能。

(4)可控:由于其本身就是在 JSHeap 上申请的 buffer,所以在 Web 容器下,提供了一个可以精确控制内存的方式。

PVRTC 的 Block 说明

经过调研和一些测试,我们最终选择了安卓下使用 ASTC 和 iOS 下使用 PVRTC 的策略来进行纹理压缩,其中更为细节的配置暂且不表(都是中等精度压缩),最终在项目中得出的成果如下:

(1)首页 3D 展示:

(2)福满全球:

可见压缩纹理对于内存的开销有着极大的优化,基本完全解决了内存问题。

3. 条件和代价

当然,这世界上并没有免费的午餐,我们接受了压缩纹理的优点,就要相对得付出代价以及接受它的约束:

(1)压缩纹理是有损压缩,会对图片的质量有一定减损,这个需要视项目而定。

(2)压缩纹理的传输体积可能比 JPG/PNG 方案要高 1~4 倍。

(3)压缩纹理要求 POT,即长宽都是二的幂次。

(4)对于 iOS 的 PVRTC,还要求长宽相等。

(5)由于压缩纹理格式在不同平台不能通用,加上降级需要三份资源,对于离线加速技术不友好。

对于某些代价,比如视觉质量损失、传输体积我们是可以自行调整的,不属于原则性难题,但这个 POT 对于很对前端应用可真是个原则性问题了,比如福满全球中的地标和红包贴图,就不是 POT 的,那么怎么办呢?有办法——使用图集。

4. 纹理标准化 - 图集

图集是一种纹理标准化的方式,在游戏领域常常用于处理 UI、2D 精灵等,简单来讲,图集就是将许多图片拼到一张上,不错就是我们常说的雪碧图(精灵图):

如图,我们将四个 500x500 的地标图片拼到了一张 1024x1024 的图集中,来满足压缩纹理的需求。那么我们又如何去使用这个图集呢?很简单,我们的引擎内置了 AtlasManager,可以让你非常简单得使用它,并且在引擎标准的开发流程中,依赖于 Unity+Webpack 工作流,这个能力能够十分方便得引入——在 Unity 中直接编辑图集,后面会说到。

图集还有别的优势,就是减少内存碎片,减少数据提交次数,某些情况下还可以减少资源请求。

精确掌控内存

目前我们拥有了削减模型和压缩纹理两种策略,大幅降低了内存开销,并降低了一部分传输体积,但通过上面的论述不难发现其实我们还可以更进一步——我们很容易发现,在整个过程中,同一份数据可能在 CPU 和 GPU 端同时存在,尤其是移动设备 CPU 和 GPU 是共享内存的。所以我们一定有办法再更进一步去解决这个问题。

这也就是我选用压缩纹理的另一个理由——压缩纹理本质上是 JSHEAP 上的 ArrayBuffer,我们可以很好得通过控制引用来帮助 GC,这也就是为何上面的数据分析中能保证稳定开销是峰值的一半。

在我们引擎的设计中,这个功能是可选的,通过纹理的 isImageCanRelease 来开启,而如果遵守标准工作流,这一切都是自动的,无需开发者操心。

当然这也有代价,就是在 GL 上下文丢失后无法恢复,请酌情使用。

进一步减少传输体积

到目前为止,内存已经被控制得很好了,但是在传输体积上还是有更大的优化空间,在这个方面我首先考虑的就是内部的 Hilo3d 团队提供的模型压缩方案。

1. 模型压缩

我们采用的模型压缩方案原理很简单,针对移动端使用的模型,并不需要每个顶点数据都是 32bits 的 float 型,一般来讲 13bits 或者 14bits 就够用了,所以这里有很大的可压缩空间。而事实上经过测试,发现确实如此,但当然这也是有代价的,通过模型压缩:

也就是说,模型压缩后,首页的所有资源大小达到了安卓 5.8M、iOS5.2M。但代价是增加了解压时间和 1.5M 的峰值内存。相对于收益,开销是可以接受的。

然而即便如此,5M 的资源大小对于亿万 UV 的量级还是有些大,我们还有更多的办法吗?有,这时候就要请出我们的老朋友 GZIP 了。

2. GZIP

大家都熟知的 GZIP 其实在很多时候都能发挥意想不到的作用,而在我们的工作链路下,模型压缩会提升 GZIP 的效果,而压缩纹理也能获得收益,在 GZIP 后:

可见,我们让资源体积再减半,和一开始相比缩减了六倍。

3. 一般图片资源

当然除了 3D 相关的资源,我们也提供了方法来对普通图片进行了压缩,主要是将 PNG 图片编码压缩成了索引色,这是一种有损压缩,也就是大家常用的 TinyPNG 的策略,当然这个并没有什么神奇的,我们已经将这种算法作为了一个插件融入了工具链中,可以通过 Webpack 工作流直接无缝整合,最终普遍带来了 2~4 倍的体积压缩。

减少资源请求

到了这里我们解决了大部分主要问题,但还有一些边角问题会对体验的极致构成影响。这一点就是资源请求数量,我们不难发现,对于两个场景而言,3D 场景的资源请求数量都接近 20 个,而这个问题并非不可解。

对 3D 领域有一定了解的读者想必是知道 glTF 这个格式的,而我们自研引擎的场景序列化也是使用了这个格式。为了应对某些场合,glTF 有它的二进制形式 GLB,其可以将索引、纹理、图元数据等等都打包到一个二进制文件中,大幅降低请求数量,在两个新春场景中,请求数量均被降到了 1 次。

而打包 GLB 的功能也被我们整合进了 Webpack 链路中,开发者可以零成本将其引入。

剩余的性能问题

以上问题解决完成后,基本就可以保证项目稳定了。对于福满全球大量透明物体和高清屏的问题,经过业务层面的调优,最终发现在可控范围内。这个是由于业务性质决定的,否则我们当然可以采用强制最大画布尺寸来降低开销。

除此之外,还有一点需要注意的是我们很可能忽略的一点——运行时的 GPU 资源提交。由于引擎的设计是用到了在提交的原则(当然这很符合规范),但对于这两个项目,保证用户操作时不卡顿的优先级是很高的,而同时经过了上面的内存优化我们也已经保证了即使所有资源都被提交也可控,所以就需要一个策略将所有资源先提交到 GPU,并预编译所有 Shader。

为了做到这一点,我们采取了一个简单的策略:在第一帧将所有物体渲染一遍,再结束 Loading,这增加了些许的加载时间,但保证了整个过程中不会卡顿。

而对于首页 3D 展示,为了做到极致的效果,我们设计了渐进式展示的策略。

渐进式展示

做这个策略是考虑到项目用户量级极大,网络情况不一,所以不可能等待 Loaing 结束才展示页面,那样首次性能会很差,所以我们敲定方案——总是先展示静态图片,3D 资源加载、解析、提交 GPU 成功后,才无缝切换为 3D 动画。

首页 3D 动画的这种策略是值得很多展示型项目参考的,这里还需要注意的的一点是:若模型比较复杂,首帧渲染会卡住用户操作。所以针对本项目的场景,我们采用了时间分片的策略,将五个模型拆分为五次渲染,每次间隔 200ms,留给用户操作的时间:

并且我们还保证静态图片和 3D 场景的姿态完全一致,从而达到视觉上无缝切换的目的。

酷炫易用 - 粒子特效

我们在大促的时候都需要炫酷的页面来吸引用户,但是动画通常都是开发的噩梦,通常我们在做动画会遇到以下三个问题:

  • 动画粗糙,不能打动用户;
  • 还原度不高,和设计差距较大;
  • 性能优化不足,兼容性不好。

这次的新春红包项目大量使用了 3D 场景,在 3D 中加入了很多粒子特效,那么这些特效是如何产出并且解决以上三个问题的?

让动画设计更精美

我们在首页切换的时候增加旋转的粒子特效,效果如下:

这个是设计同学的原稿,由于 Lottie 技术的普及,设计同学做动画大多使用 After Effect 在 AE 中制作好的 transform 动画(仅使用 translate、scale、rotate 变化)导出可使用 Lottie 播放,大大降低开发成本。而 AE 本身是一个视频后期软件,里面除了可以制作简单的 transform 动画,还可以开启 3D 渲染,进行图像跟踪,加滤镜等等。这个粒子特效就是用 AE 里 Particular 插件制作的,所以 AE 的上限就是设计师设计的上限。

设计师的设计工具将直接决定设计产物的质量。如果没有 particular 插件,那么我们的设计产物永远都只会是 transform 动画,很多影视级别的特效就不会出现在产品页面中,所以提高设计工具能力将直接决定动画产出的质量。当然还有一个值得焦虑的问题,我们的产品开发并不知知道 particular 的插件是怎么实现的,那么很大概率是无法还原的,所以既要提高设计工具的质量,也要限制设计随意使用设计工具导致无法实现。

新春红包项目的粒子特效设计全部在工具里实现:

如果是手写代码还原设计稿的话,恐怕最要命的就是函数曲线的还原,动画为了更加顺滑会加入很多曲线来控制,比如说刚才的旋转上升的星星,会有一个先加速再减速的过程:

一个复杂的粒子系统有 60 多个属性,如果开发通过肉眼还原数据,哪怕是复制粘贴属性值,都可能会出问题。

最好的还原方法是不写代码。编辑器直接导出动画数据,在手机上进行播放,开发完全不用关心各种参数。而产物很容易使用,直接保存项目工程,通过 webpack 进行加载,像使用图片一样简单。

新春红包项目中 3D 场景由 引擎 搭建渲染,使用的时候也是类似的方式,将编辑器工程作为资源引入,直接播放就可以了。动画播放起来之后就是开发最关心的问题了。

保证动画性能

其实任务首页的粒子效果还很少,谈不上性能瓶颈,而福满全球大量使用粒子,特别是烟花作为常驻特效,需要特别进行优化。这里我们参考了游戏领域粒子系统的许多优化策略,将其运用到了本次的优化中。

优化一:粒子运动完全 GPU 运算

对于粒子系统来说,因为粒子数量大,使用曲线控制后运动计算复杂,如果通过 CPU 计算粒子的运动,那么网页将不堪重负,所以粒子的运动旋转和颜色变化计算全部放在 GPU 中,通过定制 shader 完成,在 shader 中计算曲线是比较复杂的事情(此处省略 3 千字)。

优化二:优化粒子发射器

可以看到进度条的粒子持续产生,因为粒子有生命周期,所以会有老的粒子死亡,新的粒子出生,繁衍不息。首先我们在内存中开辟一块固定的地址,有一个按照粒子的生命周期排序的双向列表,每一帧需要产生新粒子的时候,检查列表最先死亡的粒子,如果此粒子已经死亡,那么会把这个粒子的地址写入新粒子的数据,同时将此列表元素从后插入。大概类似如下过程:

这样的列表可以保证粒子插入的速度,假设一个粒子系统有 200 个粒子,每帧其实只有 3-4 个新粒子的插入,在 CPU 中的计算量很小。

优化三:合并发射器

一个烟花是由两个发射器组成的,构成了双层烟花的效果,同时每个烟花增加一个拖尾效果,烟花飞过的地方就有个小尾巴。编辑效果如下:

可以看到烟花以相同的模式爆炸了 6 次,但是每次爆炸的位置不一样,通常情况下我们在编辑器会做好一次的爆炸,然后复制 6 次,时间轴类似如下:

但这样的话会导致频繁创建销毁绘制元素,性能消耗很大,所以编辑器提供了合并粒子爆炸的选项,并且每次可以修改爆炸的位置。对于习惯了复制粘贴的设计师来说,很容易复制很多相同元素导致性能开销过大,将六个绘制元素合并成一个元素可以大大降低开销,同时重复利用内存。

优化四:减少拖尾使用

拖尾就是飞线,在粒子运动过的地方生成一个顶点,绘制的时候连成一条线,这样就有流星划过的感觉。但是因为粒子的计算都是在 GPU 中的,所以每帧如果要生成新的顶点,必须在 CPU 中也重新计算粒子的位置,这样的计算量是很大的。如果烟花只是进场爆炸一次,那么开销可以忽略,但是有一些烟花是常驻的,隔一段时间就会播放一次,那么对于常驻的动画,就要避免使用拖尾。这次我们选择了用贴图缩放的方法来替代拖尾。

如果不用贴图的话,看起来就是一圈延展的小菊花,这是通过通过增加长方形长度实现的,换上我们尖角的贴图就非常像一条尾巴,最后常驻烟花没有使用拖尾,但是视觉效果仍然很像划过的流星,这样保证所有的计算都在 GPU 中进行,提高动画性能。

做到极致 - 工程自动化

当然,作为引擎开发者,除了将这些技术应用到新春项目中,使得更多开发者可以简便使用这些功能也是很重要的,所以我将这一切都封入了引擎的标准工作流中:

引擎工作流

引擎的工作流集成了以上所论述的所有优化策略,其主要包括 UnityToolkit 和 Webpack 链路两部分。

1. UnityToolkit

UnityToolkit 是 Unity 的一个插件,用于将 Unity 中的各种特性导出供引擎使用,整个流程集成度很高,目前已经支持了大量特性,包括但不限于 GameObject、模型、材质、纹理、动画、光源、摄像机、天空盒、图集、精灵、物理、音频、环境反射、环境照明、光照贴图的导出和导入,支持自定义扩展组件,支持脚本逻辑绑定等等。

2. Webpack 链路

然后就是 Webpack 链路了,我对 Webpack 链路做了深度定制,用于满足引擎的工作流的需求。上面提到的压缩纹理、模型压缩、资源预处理、资源自动化发布等都被集成到了其中,包括多平台适配也是通过 Webpack 插件实现的。

这里先大概介绍一下此次项目用到的最主要的链路:gltf-loader

这个 Loader 是整个链路中非常核心的一贯,其提供了加载 gltf 文件并进行复杂预处理的能力。使用它,我们可以做到:

(1)模型压缩。

(2)纹理压缩。

(3)打包 GLB。

(4)资源预处理:对 gltf 文件引用的资源进行预处理,通过定制 Processor 接口你可以实现任何你想要的任何预处理。

(5)资源发布器:自定义发布器,在 gltf 文件引用的资源(包括自身)被产出时,拦截并进行自动化处理。

而在这两个项目中,这几个功能都被用到了,也为最终项目的稳定可靠提供了重要的保障。

关于团队

本次分享来自于支付宝 Turandot Studio,Turandot Studio 致力于通过互动和图形技术,让前端变得更加具有创意,为用户带来更美好的体验,如果你有意加入我们,请联系我的邮箱:shunguang.dty@antfin.com。

查看原文

赞 3 收藏 1 评论 1

俊不朗先生 关注了专栏 · 2020-07-09

蚂蚁技术

蚂蚁金服科技官方账号,专注于分享蚂蚁金服的技术

关注 2052

俊不朗先生 赞了文章 · 2020-01-07

实践这一次,彻底搞懂浏览器缓存机制

前言

[实践系列] 主要是让我们通过实践去加深对一些原理的理解。

实践系列-前端路由

实践系列-Babel原理

实践系列-Promises/A+规范

有兴趣的同学可以关注 实践系列 。 求star求follow~

如果觉得自己已经掌握浏览器缓存机制知识的同学,可以直接看实践部分哈~

目录

 1. DNS 缓存   // 虽说跟标题关系不大,了解一下也不错
 2. CDN 缓存   // 虽说跟标题关系不大,了解一下也不错
 3. 浏览器缓存 // 本文将重点介绍并实践  

DNS 缓存

什么是DNS

全称 Domain Name System ,即域名系统。

万维网上作为域名和IP地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的IP数串。DNS协议运行在UDP协议之上,使用端口号53。

DNS解析

简单的说,通过域名,最终得到该域名对应的IP地址的过程叫做域名解析(或主机名解析)。

www.dnscache.com (域名)  - DNS解析 -> 11.222.33.444 (IP地址)

DNS缓存

有dns的地方,就有缓存。浏览器、操作系统、Local DNS、根域名服务器,它们都会对DNS结果做一定程度的缓存。

DNS查询过程如下:

  1. 首先搜索浏览器自身的DNS缓存,如果存在,则域名解析到此完成。
  2. 如果浏览器自身的缓存里面没有找到对应的条目,那么会尝试读取操作系统的hosts文件看是否存在对应的映射关系,如果存在,则域名解析到此完成。
  3. 如果本地hosts文件不存在映射关系,则查找本地DNS服务器(ISP服务器,或者自己手动设置的DNS服务器),如果存在,域名到此解析完成。
  4. 如果本地DNS服务器还没找到的话,它就会向根服务器发出请求,进行递归查询。

戳此处详细了解DNS解析过程

CDN 缓存

什么是CDN

全称 Content Delivery Network,即内容分发网络。

摘录一个形象的比喻,来理解CDN是什么。

10年前,还没有火车票代售点一说,12306.cn更是无从说起。那时候火车票还只能在火车站的售票大厅购买,而我所在的小县城并不通火车,火车票都要去市里的火车站购买,而从我家到县城再到市里,来回就是4个小时车程,简直就是浪费生命。后来就好了,小县城里出现了火车票代售点,甚至乡镇上也有了代售点,可以直接在代售点购买火车票,方便了不少,全市人民再也不用在一个点苦逼的排队买票了。

简单的理解CDN就是这些代售点(缓存服务器)的承包商,他为买票者提供了便利,帮助他们在最近的地方(最近的CDN节点)用最短的时间(最短的请求时间)买到票(拿到资源),这样去火车站售票大厅排队的人也就少了。也就减轻了售票大厅的压力(起到分流作用,减轻服务器负载压力)。

用户在浏览网站的时候,CDN会选择一个离用户最近的CDN边缘节点来响应用户的请求,这样海南移动用户的请求就不会千里迢迢跑到北京电信机房的服务器(假设源站部署在北京电信机房)上了。

CDN缓存

关于CDN缓存,在浏览器本地缓存失效后,浏览器会向CDN边缘节点发起请求。类似浏览器缓存,CDN边缘节点也存在着一套缓存机制。CDN边缘节点缓存策略因服务商不同而不同,但一般都会遵循http标准协议,通过http响应头中的

Cache-control: max-age   //后面会提到

的字段来设置CDN边缘节点数据缓存时间。

当浏览器向CDN节点请求数据时,CDN节点会判断缓存数据是否过期,若缓存数据并没有过期,则直接将缓存数据返回给客户端;否则,CDN节点就会向服务器发出回源请求,从服务器拉取最新数据,更新本地缓存,并将最新数据返回给客户端。 CDN服务商一般会提供基于文件后缀、目录多个维度来指定CDN缓存时间,为用户提供更精细化的缓存管理。

CDN 优势

  1. CDN节点解决了跨运营商和跨地域访问的问题,访问延时大大降低。
  2. 大部分请求在CDN边缘节点完成,CDN起到了分流作用,减轻了源服务器的负载。

    戳此处详细了解CDN工作过程

浏览器缓存(http缓存)

对着这张图先发呆30秒~
image

什么是浏览器缓存

image

简单来说,浏览器缓存其实就是浏览器保存通过HTTP获取的所有资源,是浏览器将网络资源存储在本地的一种行为。

缓存的资源去哪里了?

你可能会有疑问,浏览器存储了资源,那它把资源存储在哪里呢?

memory cache

MemoryCache顾名思义,就是将资源缓存到内存中,等待下次访问时不需要重新下载资源,而直接从内存中获取。Webkit早已支持memoryCache。
目前Webkit资源分成两类,一类是主资源,比如HTML页面,或者下载项,一类是派生资源,比如HTML页面中内嵌的图片或者脚本链接,分别对应代码中两个类:MainResourceLoader和SubresourceLoader。虽然Webkit支持memoryCache,但是也只是针对派生资源,它对应的类为CachedResource,用于保存原始数据(比如CSS,JS等),以及解码过的图片数据。

disk cache

DiskCache顾名思义,就是将资源缓存到磁盘中,等待下次访问时不需要重新下载资源,而直接从磁盘中获取,它的直接操作对象为CurlCacheManager。
  • |memory cache | disk cache
相同点只能存储一些派生类资源文件只能存储一些派生类资源文件
不同点退出进程时数据会被清除退出进程时数据不会被清除
存储资源一般脚本、字体、图片会存在内存当中一般非脚本会存在内存当中,如css等

因为CSS文件加载一次就可渲染出来,我们不会频繁读取它,所以它不适合缓存到内存中,但是js之类的脚本却随时可能会执行,如果脚本在磁盘当中,我们在执行脚本的时候需要从磁盘取到内存中来,这样IO开销就很大了,有可能导致浏览器失去响应。

三级缓存原理 (访问缓存优先级)

  1. 先在内存中查找,如果有,直接加载。
  2. 如果内存中不存在,则在硬盘中查找,如果有直接加载。
  3. 如果硬盘中也没有,那么就进行网络请求。
  4. 请求获取的资源缓存到硬盘和内存。

浏览器缓存的分类

  1. 强缓存
  2. 协商缓存

浏览器再向服务器请求资源时,首先判断是否命中强缓存,再判断是否命中协商缓存!

浏览器缓存的优点

1.减少了冗余的数据传输

2.减少了服务器的负担,大大提升了网站的性能

3.加快了客户端加载网页的速度

强缓存

浏览器在加载资源时,会先根据本地缓存资源的 header 中的信息判断是否命中强缓存,如果命中则直接使用缓存中的资源不会再向服务器发送请求。

这里的 header 中的信息指的是 expires 和 cahe-control.

Expires

该字段是 http1.0 时的规范,它的值为一个绝对时间的 GMT 格式的时间字符串,比如 Expires:Mon,18 Oct 2066 23:59:59 GMT。这个时间代表着这个资源的失效时间,在此时间之前,即命中缓存。这种方式有一个明显的缺点,由于失效时间是一个绝对时间,所以当服务器与客户端时间偏差较大时,就会导致缓存混乱。

Cache-Control

Cache-Control 是 http1.1 时出现的 header 信息,主要是利用该字段的 max-age 值来进行判断,它是一个相对时间,例如 Cache-Control:max-age=3600,代表着资源的有效期是 3600 秒。cache-control 除了该字段外,还有下面几个比较常用的设置值:

no-cache:需要进行协商缓存,发送请求到服务器确认是否使用缓存。

no-store:禁止使用缓存,每一次都要重新请求数据。

public:可以被所有的用户缓存,包括终端用户和 CDN 等中间代理服务器。

private:只能被终端用户的浏览器缓存,不允许 CDN 等中继缓存服务器对其缓存。

Cache-Control 与 Expires 可以在服务端配置同时启用,同时启用的时候 Cache-Control 优先级高。

协商缓存

当强缓存没有命中的时候,浏览器会发送一个请求到服务器,服务器根据 header 中的部分信息来判断是否命中缓存。如果命中,则返回 304 ,告诉浏览器资源未更新,可使用本地的缓存。

这里的 header 中的信息指的是 Last-Modify/If-Modify-Since 和 ETag/If-None-Match.

Last-Modify/If-Modify-Since

浏览器第一次请求一个资源的时候,服务器返回的 header 中会加上 Last-Modify,Last-modify 是一个时间标识该资源的最后修改时间。

当浏览器再次请求该资源时,request 的请求头中会包含 If-Modify-Since,该值为缓存之前返回的 Last-Modify。服务器收到 If-Modify-Since 后,根据资源的最后修改时间判断是否命中缓存。

如果命中缓存,则返回 304,并且不会返回资源内容,并且不会返回 Last-Modify。

缺点:

短时间内资源发生了改变,Last-Modified 并不会发生变化。

周期性变化。如果这个资源在一个周期内修改回原来的样子了,我们认为是可以使用缓存的,但是 Last-Modified 可不这样认为,因此便有了 ETag。

ETag/If-None-Match

与 Last-Modify/If-Modify-Since 不同的是,Etag/If-None-Match 返回的是一个校验码。ETag 可以保证每一个资源是唯一的,资源变化都会导致 ETag 变化。服务器根据浏览器上送的 If-None-Match 值来判断是否命中缓存。

与 Last-Modified 不一样的是,当服务器返回 304 Not Modified 的响应时,由于 ETag 重新生成过,response header 中还会把这个 ETag 返回,即使这个 ETag 跟之前的没有变化。

Last-Modified 与 ETag 是可以一起使用的,服务器会优先验证 ETag,一致的情况下,才会继续比对 Last-Modified,最后才决定是否返回 304。

总结

当浏览器再次访问一个已经访问过的资源时,它会这样做:

1.看看是否命中强缓存,如果命中,就直接使用缓存了。

2.如果没有命中强缓存,就发请求到服务器检查是否命中协商缓存。

3.如果命中协商缓存,服务器会返回 304 告诉浏览器使用本地缓存。

4.否则,返回最新的资源。

实践加深理解

talk is cheap , show me the code 。让我们通过实践得真知~

在实践时,注意浏览器控制台Network的image按钮不要打钩。

以下我们只对强缓存的Cache-Control和协商缓存的ETag进行实践,其他小伙伴们可以自己实践~

package.json

{
 "name": "webcache",
 "version": "1.0.0",
 "description": "",
 "main": "index.js",
 "scripts": {
   "cache": "nodemon ./index.js"
 },
 "author": "webfansplz",
 "license": "MIT",
 "devDependencies": {
   "@babel/core": "^7.2.2",
   "@babel/preset-env": "^7.2.3",
   "@babel/register": "^7.0.0",
   "koa": "^2.6.2",
   "koa-static": "^5.0.0"
 },
 "dependencies": {
   "nodemon": "^1.18.9"
 }
}

.babelrc

{
 "presets": [
   [
     "@babel/preset-env",
     {
       "targets": {
         "node": "current"
       }
     }
   ]
 ]
}

index.js

require('@babel/register');
require('./webcache.js');

webcache.js

import Koa from 'koa';
import path from 'path';
//静态资源中间件
import resource from 'koa-static';
const app = new Koa();
const host = 'localhost';
const port = 4396;
app.use(resource(path.join(__dirname, './static')));

app.listen(port, () => {
  console.log(`server is listen in ${host}:${port}`);
});

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>前端缓存</title>
    <style>
      .web-cache img {
        display: block;
        width: 100%;
      }
    </style>
  </head>
  <body>
    <div class="web-cache"><img data-original="./web.png" /></div>
  </body>
</html>

我们用koa先起个web服务器,然后用koa-static这个中间件做静态资源配置,并在static文件夹下放了index.html和web.png。

Ok,接下来我们来启动服务。

npm run cache

server is listen in localhost:4396。

接下来我们打开浏览器输入地址:

localhost:4396

image

完美~(哈哈,猪仔别喷我,纯属娱乐效果)

Ok!!!接下来我们来实践下强缓存。~

Cache-Control

webcache.js

import Koa from 'koa';
import path from 'path';
//静态资源中间件
import resource from 'koa-static';
const app = new Koa();
const host = 'localhost';
const port = 4396;

app.use(async (ctx, next) => {
 // 设置响应头Cache-Control 设置资源有效期为300秒
  ctx.set({
    'Cache-Control': 'max-age=300'  
  });
  await next();
});
app.use(resource(path.join(__dirname, './static')));

app.listen(port, () => {
  console.log(`server is listen in ${host}:${port}`);
});

image
我们刷新页面可以看到响应头的Cache-Control变成了max-age=300。

我们顺便来验证下三级缓存原理

我们刚进行了网络请求,浏览器把web.png存进了磁盘和内存中。

根据三级缓存原理,我们会先在内存中找资源,我们来刷新页面。

image

我们在红线部分看到了, from memory cache。nice~

ok,接下来,我们关掉该页面,再重新打开。因为内存是存在进程中的,所以关闭该页面,内存中的资源也被释放掉了,磁盘中的资源是永久性的,所以还存在。

根据三级缓存原理,如果在内存中没找到资源,便会去磁盘中寻找!

image

from disk cache !!! ok,以上也就验证了三级缓存原理,相信你对缓存资源的存储也有了更深的理解了。

我们刚对资源设置的有效期是300秒,我们接下来来验证缓存是否失效。

300秒后。。。

image

我们通过返回值可以看到,缓存失效了。

通过以上实践,你是否对强缓存有了更深入的理解了呢?

Ok!!!接下来我们来实践下协商缓存。~

由于Cache-Control的默认值就是no-cache(需要进行协商缓存,发送请求到服务器确认是否使用缓存。),所以我们这里不用对Cache-Control进行设置!

ETag

//ETag support for Koa responses using etag.
npm install koa-tag -D
// etag works together with conditional-get
npm install koa-conditional-get -D

我们这里直接使用现成的插件帮我们计算文件的ETag值,站在巨人的肩膀上!

webcache.js

import Koa from 'koa';
import path from 'path';
//静态资源中间件
import resource from 'koa-static';
import conditional from 'koa-conditional-get';
import etag from 'koa-etag';
const app = new Koa();
const host = 'localhost';
const port = 4396;

// etag works together with conditional-get
app.use(conditional());
app.use(etag());
app.use(resource(path.join(__dirname, './static')));

app.listen(port, () => {
 console.log(`server is listen in ${host}:${port}`);
});

ok。第一次请求.
image
我们发现返回值里面已经有了Etag值。

接下来再请求的时候,浏览器将会带上If-None-Match请求头,并赋值为上一次返回头的Etag值,然后与 这次返回值的Etag值进行对比。如果一致则命中协商缓存。返回304 Not Modified。接下来我们来验证一下~

image
ok,如图所示,完美验证了上面的说法。

接下来我们修改web.png ,来验证是否资源改变时 协商缓存策略也就失效呢?

image

如图所示.协商缓存的实践也验证了原理。

大功告成

写文章真的是件挺累的事,如果觉得有帮助到你,请给star/follow 支持下作者~

源码地址

参考文献

前端性能优化之缓存利用

查看原文

赞 288 收藏 204 评论 13

俊不朗先生 收藏了文章 · 2020-01-02

0202年了, Chrome DevTools 你还只会console.log吗 ?

chrome.png

前言

Chrome 开发者工具(简称 DevTools)是一套 Web 开发调试工具,内嵌于 Google Chrome 浏览器中。DevTools 使开发者更加深入的了解浏览器内部以及他们编写的应用。通过使用 DevTools,可以更加高效的定位页面布局问题,设置 JavaScript 断点并且更好的理解代码优化。

本文分享 24 个 Chrome 调试技巧和一些快捷键,希望能帮你进一步了解 Chrome DevTools ~

调试技巧

1. 控制台中直接访问页面元素

在元素面板选择一个元素,然后在控制台输入$0,就会在控制台中得到刚才选中的元素。如果页面中已经包含了 jQuery,你也可以使用$($0)来进行选择。

你也可以反过来,在控制台输出的 DOM 元素上右键选择 Reveal in Elements Panel 来直接在 DOM 树种查看。

0.gif

2. 访问最近的控制台结果

在控制台输入$_可以获控制台最近一次的输出结果。

_.gif

3. 访问最近选择的元素和对象

控制台会存储最近 5 个被选择的元素和对象。当你在元素面板选择一个元素或在分析器面板选择一个对象,记录都会存储在栈中。 可以使用$x来操作历史栈,x 是从 0 开始计数的,所以$0 表示最近选择的元素,$4 表示最后选择的元素。

4.png

4. 选择元素

  • $() - 返回满足指定 CSS 规则的第一个元素,此方法为 document.querySelector()的简化。
  • $$() - 返回满足指定 CSS 规则的所有元素,此方法为 querySelectorAll()的简化。
  • $x() - 返回满足指定 XPath 的所有元素。

select.png

5. 使用 console.table

该命令支持以表格的形式输出日志信息。打印复杂信息时尝试使用 console.table 来替代 console.log 会更加清晰。

table.png

6. 使用 console.dir,可简写为 dir

console.dir(object)/dir(object) 命令可以列出参数 object 的所有对象属性。

dir.gif

7. 复制 copy

你可以通过 copy 方法在控制台里复制你想要的东西。

copy.gif

8. 获取对象键值 keys(object)/values(object)

keys_values.png

9. 函数监听器 monitor(function)/unmonitor(function)

monitor(function),当调用指定的函数时,会将一条消息记录到控制台,该消息指示调用时传递给该函数的函数名和参数。

使用 unmonitor(函数)停止对指定函数的监视。

monitor.png

10. 事件监听器 monitorEvents(object[, events])/unmonitorEvents(object[, events])

monitorEvents(object[, events]),当指定的对象上发生指定的事件之一时,事件对象将被记录到控制台。事件类型可以指定为单个事件或事件数组。

unmonitorevent (object[, events])停止监视指定对象和事件的事件。

monitorevents.png

11. 耗时监控

通过调用 time()可以开启计时器。你必须传入一个字符串参数来唯一标记这个计时器的 ID。当你要结束计时的时候可以调用 timeEnd(),并且传入指定的名字。计时结束后控制台会打印计时器的名字和具体的时间。

time.png

12. 分析程序性能

在 DevTools 窗口控制台中,调用 console.profile()开启一个 JavaScript CPU 分析器.结束分析器直接调用 console.profileEnd().

profile.png

profile_1.png

13. 统计表达式执行次数

count()方法用于统计表达式被执行的次数,它接受一个字符串参数用于标记不同的记号。如果两次传入相同的字符串,该方法就会累积计数。

count.png

14. 清空控制台历史记录

可以通过下面的方式清空控制台历史:

  • 在控制台右键,或者按下 Ctrl 并单击鼠标,选择 Clear Console。
  • 在脚本窗口输入 clear()执行。
  • 在 JavaScript 脚本中调用 console.clear()。
  • 使用快捷键 Cmd + K (Mac) Ctrl + L (Windows and Linux)。

clear.gif

15. 异步操作

async/await 使得异步操作变得更加容易和可读。唯一的问题在于 await 需要在 async 函数中使用。Chrome DevTools 支持直接使用 await。

await.png

16. debugger 断点

有时候我们需要打断点进行单步调试,一般会选择在浏览器控制台直接打断点,但这样还需要先去 Sources 里面找到源码,然后再找到需要打断点的那行代码,比较麻烦。

使用 debugger 关键词,我们可以直接在源码中定义断点,方便很多。
debugger.png

17. 截图

我们经常需要截图,Chrome DevTools 提供了 4 种截图方式,基本覆盖了我们的需求场景,快捷键 ctrl+shift+p ,打开 Command Menu,输入 screenshot,可以看到以下 4 个选项:
screenshot.png

去试试吧,很香!

18. 切换主题

Chrome 提供了 亮&暗 两种主题,当你视觉疲劳的时候,可以 switch 哦, 快捷键 ctrl+shift+p ,打开 Command Menu,输入 theme ,即可选择切换

theme.gif

19. 复制 Fetch

在 Network 标签下的所有的请求,都可以复制为一个完整的 Fetch 请求的代码。

copy-fetch.gif

20. 重写 Overrides

在 Chrome DevTools 上调试 css 或 JavaScript 时,修改的属性值在重新刷新页面时,所有的修改都会被重置。

如果你想把修改的值保存下来,刷新页面的时候不会被重置,那就看看下面这个特性(Overrides)吧。Overrides 默认是关闭的,需要手动开启,开启的步骤如下。

开启的操作:

打开 Chrome DevTools 的 Sources 标签页
选择 Overrides 子标签
选择 + Select folder for overrides,来为 Overrides 设置一个保存重写属性的目录

overrides.png

21. 实时表达式 Live Expression

从 chrome70 起,我们可以在控制台上方可以放一个动态表达式,用于实时监控它的值。Live Expression 的执行频率是 250 毫秒。

点击 "Create Live Expression" 眼睛图标,打开动态表达式界面,输入要监控的表达式

live_expression.gif

22. 检查动画

Chrome DevTools 动画检查器有两个主要用途。

  • 检查动画。您希望慢速播放、重播或检查动画组的源代码。
  • 修改动画。您希望修改动画组的时间、延迟、持续时间或关键帧偏移。 当前不支持编辑贝塞尔曲线和关键帧。

动画检查器支持 CSS 动画、CSS 过渡和网络动画。当前不支持 requestAnimationFrame 动画。

快捷键 ctrl+shift+p ,打开 Command Menu,键入 Drawer: Show Animations。

animation

23. 滚动到视图区域 Scroll into view

scrollintoview.png

24. 工作区编辑文件 Edit Files With Workspaces

工作空间使您能够将在 Chrome Devtools 中进行的更改保存到计算机上相同文件的本地副本。

进入 Sources Menu, Filesystem 下 点击 Add folder to workspace 添加要同步的工作目录

workspace

快捷键

访问 DevTools

访问 DevToolsWindowsMac
打开 Developer Tools (上一次停靠菜单)F12、Ctrl + Shift + ICmd + Opt + I
打开/切换检查元素模式和浏览器窗口Ctrl + Shift + CCmd + Shift + C
打开 Developer Tools 并聚焦到控制台Ctrl + Shift + JCmd + Opt + J

全局键盘快捷键

下列键盘快捷键可以在所有 DevTools 面板中使用:

全局键盘快捷键WindowsMac
下一个面板Ctrl + ]Cmd + ]
上一个面板Ctrl + [Cmd + [
更改 DevTools 停靠位置Ctrl + Shift + DCmd + Shift + D
打开 Device ModeCtrl + Shift + MCmd + Shift + M
切换控制台EscEsc
刷新页面F5、Ctrl + RCmd + R
刷新忽略缓存内容的页面Ctrl + F5、Ctrl + Shift + RCmd + Shift + R
在当前文件或面板中搜索文本Ctrl + FCmd + F
在所有源中搜索文本Ctrl + Shift + FCmd + Opt + F
按文件名搜索(除了在 Timeline 上)Ctrl + O、Ctrl + PCmd + O、Cmd + P
放大(焦点在 DevTools 中时)Ctrl + +Cmd + Shift + +
缩小Ctrl + -Cmd + Shift + -
恢复默认文本大小Ctrl + 0Cmd + 0
打开 command 菜单Ctrl + Shift + PCmd + Shift + P

控制台

控制台快捷键WindowsMac
上一个命令/行向上键向上键
下一个命令/行向下键向下键
聚焦到控制台Ctrl + `Ctrl + `
清除控制台Ctrl + LCmd + K
多行输入Shift + EnterShift + Enter
执行EnterReturn

后记

如果你和我一样喜欢前端,也爱动手折腾,欢迎关注我一起玩耍啊~ ❤️

github 地址,欢迎 follow 哦~

博客

我的博客,点 star,不迷路~

公众号

前端时刻

qrcode.jpg

查看原文

认证与成就

  • 获得 0 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-11-27
个人主页被 329 人浏览