cnryb

cnryb 查看完整档案

北京编辑  |  填写毕业院校  |  填写所在公司/组织 cnryb.github.io 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

cnryb 赞了文章 · 1月15日

前端异常监控 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

cnryb 赞了文章 · 1月15日

智联招聘的微前端落地实践——Widget

ThoughtWorks在几年前提出了微前端的概念,其核心理念是将前端单体应用在开发阶段拆分成多个独立的工程,并在运行阶段组合成完整的应用。不仅解耦了视图和代码,使得应用可以容纳多种技术栈,还进一步解耦了流程和团队,极大地提高了团队的自主性和协作效率。

智联招聘的大前端架构Ada本身就可以看作一个基于路由的微前端架构,围绕URL的研发方式能够灵活地实现页面级别的解耦。而在在视图区域级别,Ada也引入了专门的微前端实现机制——Widget。

什么是 Widget

Widget是一种可以独立开发和发布的视图区域,它运行于宿主页面中,并且能够和宿主页面进行双向通信。

在设计Widget架构时,我们考虑到Ada的多框架支持能力,应当让Widget在使用时不受框架的限制,也就是说,使用Knockout.js开发的Widget可以运行在Vue.js的页面中,反之亦然,这就决定了Widget的最终形态必须是框架无关的Plain JavaScript。出于同样的考量,通信机制也不应该受框架所限,而应该制定属于Widget的通信API规范。

总结一下,Widget的设计目标是:

  • 框架独立,不受宿主页面技术栈限制;
  • 流程独立,能够独立开发和发布,集成后无需宿主页面再次配合发布;
  • 执行独立,运行逻辑不直接操控宿主页面,而是通过API来交换信息;
  • 展现独立,内容和样式均限定在Widget视图区域之内,不直接影响宿主页面;

整体架构

为了统一开发体验,Ada从开发、调试、发布和运行都为Widget进行了专门的支持。

image.png

在开发阶段,理论上任何能够编译成Plain JavaScript的框架都可以使用,Ada在Vue.js等脚手架中内置了对Widget的支持,开发者可以使用熟悉的技术开发Widget,也可以像调试页面一样预览和调试Widget。

我们在脚手架内核中为Widget单独设计了Webpack配置,使得基于各种框架开发的Widget都能输出成一个独立的JavaScript Bundle(样式也会构建到同一个Bundle中),藉此来保证输出的文件符合Widget规范。

就像Ada体系里的其他工件一样,每个Widget都有一个唯一URN,宿主页面通过该URN来引用Widget,从而和Widget的JavaScript Bundle解耦。在发布阶段,Ada会为URN关联最新的输出清单,这样一来,Widget不但可以脱离宿主页面独立发布,还能进一步实现灰度发布和A/B实验。

运行时

Widget的生命周期包括四个阶段:注册、初始化、运行和销毁,各阶段之间的转换是由Widget SDK来负责调度的。

宿主页面使用<script>标签加载Widget URN时,Ada Server会负责调度并返回正确的JavaScript Bundle,加载完毕后,就会向Widget SDK注册自己。

注册完毕之后,宿主页面就可以调用Widget SDK的init方法,并传递Widget名称和父元素DOM,来决定Widget的初始化时机和位置。宿主页面还可以初始化同一个Widget的多个实例,并和它们分别进行通信。

Widget的消息通信机制借用了Web Worker的API规范,宿主页面可以通过Widget SDK的postMessage方法向指定Widget发送消息,同时通过onmessage方法监听Widget发来的消息。反过来,Widget也可以用同样的方式和宿主页面通信。

当宿主页面需要销毁组件时,可以调用Widget SDK的destory方法,后者会指示Widge销毁视图、清理存储,然后再将Widget移出事件中心。

image.png

开发 Widget

Widget本质上是一个规范化的Class,可以使用Plain JavaScript,也可以融入任何框架,比如借助Vue.js来开发Widget的代码可能是这样的:

import Vue from 'vue'
import Widget from './Widget.vue' // 具体业务代码

class Widget {
  constructor ({ el }) {
    this.el = el // 当前 Widget 的父 DOM
    this.mount()
  }

  mount () {
    const app = new Vue({
      render: h => h(Widget)
    })

    app.$mount(this.el)
  }
}

export default Widget 

使用 Widget

宿主页面通过<script>标签导入Widget URN,然后初始化即可:

const scriptElement = document.createElement('script')

scriptElement.type = 'text/javascript'
scriptElement.async = true
scriptElement.src = YOUR_WIDGET_URN
scriptElement.onload = () => {
  window.zpWidget.init(this.widgetName, {
    el: YOUR_WIDGET_PARENT_DOM
  })
}

document.body.appendChild(scriptElement) 

为了贴合现代Web框架组件化的研发习惯,我们为Vue.js、Knockout.js和Weex提供了Widget组件,藉此来简化Widget加载和初始化步骤。例如在Vue.js中,可以这样加载一个Widget:

<template>
  <!--
    参数解释:urn 可以是上线后的 widget 地址也可以当前项目的相对路径,
  -->
  <widget url="/widgets/YOUR_WIDGET_NAME" />
</template>

<script> import Widget from '@zpfe/widget-components/web'

export default {
  name: 'YOUR_COMPONENT_NAME',
  components: {
    Widget
  }
} </script> 

初始化Widget之后,就可以与宿主页面进行双向通信了,例如:

// Widget 内部
window.zpWidget.postMessage({
  widgetName: 'timeNow',
  eventName: 'click'
}, new Date())

// 宿主页面
window.zpWidget.onmessage({
  widgetName: 'timeNow',
  eventName: 'click'
}, (time) => {
  console.log(`当前时间是:${time}`)
}) 

实际应用

目前集团内已累计发布了100余个Widget,各条产品线都招到了Widget能发挥作用的场景,比如:

  • Passport借由Widget统一了集团内的所有登录逻辑,能够更好地调整安全策略,业务方通过接入 Widget 即可实现登录功能;
  • 限时推广的Banner或隐私通知,常常同时在多个产品中同时展示,且变化频率较高,Widget能够有效地将其和业务代码解耦;
  • 内部系统在升级时,可以借助Widget在视图层面一小块一小块地逐步迁移,从而在用户无感的情况下渐进式地完成整体升级工作;

微前端不止步于Widget

发布于2019年初的Widget机制,是我们在微前端领域的第一次尝试,成效令人满意。集团内对Widget的广泛应用带来了更多的诉求和灵感激发,未来,我们还会结合业务特点去探索微前端的其他可能性,让架构赋能业务,为用户带来价值。

查看原文

赞 6 收藏 0 评论 0

cnryb 关注了专栏 · 2020-11-30

智联大前端

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

关注 78

cnryb 赞了文章 · 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

cnryb 赞了文章 · 2020-09-01

10分钟理解 Node.js koa 源码架构设计

clipboard.png

koa 发布已经快 6 年的时间,作为继 express 之后 node 服务框架最大的黑马,有很多的设计思想值得我们学习,本文从简到繁逐步介绍 koa,同时适合新老手阅读。

介绍

这里引用中文官方网站的原文

Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。

既然是 web 框架大家一定不陌生,通过启动一个 node http server,监听一个端口,进而我们就可以通过类似 localhost:3000 在本地访问我们的服务了,这个服务可以是 web 网站,可以是 restful 接口,也可以是静态文件服务等等。

Hello Word

任何语言、框架都存在 Hello Word 示例,来表达其最简单的入门 Demo,代码如下

clipboard.png

此时访问浏览器 localhost:3000,我们会看到打印出了 Hello Word,此时一个基于 koa 的服务就启动完成了。

上下文

理解 koa 第一步,搞清楚上下文的作用

例如:微信群里面有人说外面下雪了,你跑到窗边看到的却是晴空万里,这时你才意识到同样是 10 月份,他在寒冷的北方,你在酷暑的南方

类似的,一次请求会包含用户的登录状态,或者一些Token之类的信息,这些信息就是上下文的一部分,用于确定一次的请求环境

Koa 的 Context 把 node 的 request, response 对象封装进一个单独对象, 并提供许多开发 web 应用和 APIs 有用的方法. 那些在 HTTP server 开发中使用非常频繁操作, 直接在 Koa 里实现, 而不是放在更高层次的框架, 这样中间件就不需要重复实现这些通用的功能。

中间件

先来看一个官方的例子:

clipboard.png

简单解释下,代码起始初始化一个 koa 实例,下面分别通过 use 方法载入了三个中间件方法,执行顺序:

  1. 进入第一个中间件
  2. next() 跳到下一个中间件
  3. new Data() 记录当前时间
  4. next() 跳到下一个中间件
  5. ctx.body 赋值
  6. 回到上一个中间件再次记录当前时间并计算时间差存到 http header
  7. 回到上一个中间件将 header 中的 X-Response-time 打印出来

这里的执行顺序延伸出了十分经典的洋葱模型

clipboard.png

在一次请求的过程中会往返经过同一中间件两次,允许我们处理不同请求阶段的逻辑

源码解析

上面分别介绍了 koa 里面两个最重要的概念,下面我们分析下 koa 内部是如何运作的,所谓的洋葱模型是如何建立的

koa 源码的 lib 目录十分简单

lib
  |- application.js
  |- context.js
  |- request.js
  |- response.js

Application 类初始化

入口文件是 application.js,我们先从这里入手

clipboard.png

Application 是一个 class,这个类继承了 node 的 Events 这里不详细展开,在 constructor 中初始化了以下内容:

  • proxy 代理默认不开启
  • middleware 中间件是个空数组,这里重点注意下
  • env 根据环境变量 NODE_ENV 来判断
  • context、request、response 分别通过 Object.create 方法将 lib 目录下对应的文件导入到 this 当前上下文,且不污染引入对象

use 方法

按照正常的编码顺序,在初始化完 koa 实例后(即 const app = new Koa()),我们需要调用 app.use() 去挂载我们的中间件,那么我们看下 use 方法做了什么

clipboard.png

判断中间件为 function,判断中间件是否为 generator function 类型,只是简单的将中间件函数 push 到了 middleware 数组中。

此时心中有没有大写的 WHAT?

其实就是这么直白,没什么复杂逻辑,后面也许大家都猜到了,循环调用 middleware 中的方法去执行,此处尚未表明洋葱模型是怎么来的,我们先不展开,继续按代码顺序执行。

listen 方法

按照正常的编码顺序,在 use 完我们的中间件之后就是 app.listen(3000)

一起看下这个 listen 干了什么

clipboard.png

这里的 http.createServer 就是 node 原生启动 http 服务的方法,这里稍微扩展下基础知识,此方法接受两个参数

  • options[IncomingMessage, ServerResponse] 这里从 node 版本 v9.6.0, v8.12.0 后才支持,这里不赘述
  • requestListener 此参数为 function 类型,每次请求会传入 req, res 两个参数

不难理解这里的 this.callback() 方法一定是返回了一个函数,并且接收两个参数 (req, res),下面看下源码

clipboard.png

这个 callback 中的信息量有点大,代码本身并不难理解,注释也有说明,从这里展开从上到下分别解释

compose 方法

这里的 compose 方法主要负责生成洋葱模型,通过 koa-compose 包实现,源码如下

clipboard.png

从注释看得出大致逻辑,这里的巧妙之处在于 fn(context, dispatch.bind(null, i + 1))

这个 dispatch.bind(null, i + 1) 就是我们通常写中间件的第二个参数 next

我们执行这个 next() 方法实际上得到的是下一个中间件的执行。

也就不难理解为什么我们 await next() 的时候等待的是后面所有中间件串联执行后了,回头再看下上文中间件部分的执行顺序就豁然开朗了。

createContext 方法

callback 中的展开解释,看下 const ctx = this.createContext(req, res) 做了什么

clipboard.png

这里主要是将 req, res 及 this.request, this.response 都挂载到了 context 上,并通过赋值理清了循环引用层级关系,为使用者提供方便。

handleRequest 方法

还是 callback 中的展开解释,看下 this.handleRequest(ctx, fn) 这部分做了什么

clipboard.png

分别拿到 ctx 和 compose 生成的洋葱模型,开始逐一消费中间件。

context.js 文件

上面理清了整体框架,下面看下 context.js 内部的细节,在文件结尾有两大段的代理

clipboard.png

clipboard.png

这里可以看到所有的 req 及 res 的方法集合,那么哪些方法可读,哪些可写,哪些既可读又可写,哪些方法不允许修改

这就是 delegates 这个库做的事情。

delegates 内部利用了,__defineGetter____defineSetter__ 方法控制读写,当然我们可以从中学习思想,也不能盲从

这两个 api 去 MDN 上搜索会给出相同的警告信息

This feature is deprecated in favor of defining setters using the object initializer syntax or the Object.defineProperty() API.

其实还是建议我们使用 vue 的代理方式 Object.defineProperty(),不过这个库有四年没更新了依然稳定运行着,还是深受 koa 开发者认可的。

其它

request.jsresponse.js 文件没什么可以讲,就是具体的工具方法实现,方便开发人员调用,感兴趣可以自行阅读源码。

应用

智联前端架构整体的 node 服务都基于 koa 实现,包括我们的 vue 服务端渲染和 node restful api 等等。

我们选择 koa 的原因是其本身轻巧,可扩展性良好,支持 async、await 的异步,彻底摆脱了回调地狱。

市面上也有成熟基于 koa2 的企业级解决方案,如 eggjs 和 thinkjs。

总结

揭开 koa 的神秘面纱,让开发者关注业务逻辑同时也关注下框架本身,有利于问题排查和编写扩展,与此同时可以学习 express、hapi 等同类型框架的思想,结合现有企业级解决方案,选一款适合你的框架,总之框架不论好坏,只论场景。

查看原文

赞 68 收藏 46 评论 1

cnryb 赞了文章 · 2020-08-26

@babel/preset-env 与@babel/plugin-transform-runtime 使用及场景区别

之前在用babel 的时候有个地方一直挺晕的,@babel/preset-env@babel/plugin-transform-runtime都具有转换语法的能力, 并且都能实现按需 polyfill ,但是网上又找不到比较明确的答案, 趁这次尝试 roullp 的时候试了试.

如果我们什么都不做, 没有为babel 编写参数及配置, 那babel 并没有那么大的威力, 它什么都不会做, 正是因为各个预设插件的灵活组合、赋能, 让 babel 充满魅力, 创造奇迹

首先是 @babel/preset-env

@babel/preset-env

这是一个我们很常用的预设, 几乎所有的教程和框架里都会让你配置它, 它的出现取代了 preset-es20** 系列的babel 预设, 你再也不需要繁杂的兼容配置了。 每出一个新提案就加一个? 太蠢了。

有了它, 我们就可以拥有全部, 并且! 它还可以做到按需加载我们需要的 polyfill。 就是这么神奇。
但是吧, 它也不是那么自动化的, 如果你要是不会配置,很有可能就没有用起它的功能

不管怎么养, 首先试一下,眼见为实

首先创建一个 index.js ,内容如下, 很简单

function test() {
  new Promise()
}
test()
const arr = [1,2,3,4].map(item => item * item)
console.log(arr)

然后我们在根目录下创建一个 .babelrc文件, 帮我们刚刚说的预设加进去

{
  "presets": [
    ["@babel/preset-env"]
  ]
}

然后我我们打包一下(这里我用的是roullup)

看一下产出的结果
2019-12-03-21-00-52

我们可以看到, 它babel帮我们做了这几件事情:

  1. 转换箭头函数
  2. const 变为 var

奇怪, 为什么 babel 不帮我们转换 map ? 还有 promise 这些也都是es6的特性呀

嗯~,会不会是我们的目标浏览器不对, babel 觉得不需要转换了, 会不会是这样, 那我们加一个 .browserslistrc 试一下

那就。让我们在根目录下创建一个 .browserslistrc
2019-12-03-21-06-54

好。现在让我们再打包一次.
2019-12-03-21-07-43

咦, 没什么效果。 跟刚刚一样啊。 说明不是目标浏览器配置的问题, 是babel 做不了这个事。

因为默认 @babel/preset-env 只会转换语法,也就是我们看到的箭头函数、const一类。
如果进一步需要转换内置对象、实例方法,那就得用polyfill, 这就需要你做一点配置了,

这里有一个至关重要的参数 "useBuiltIns",他是控制 @babel/preset-env 使用何种方式帮我们导入 polyfill 的核心, 它有三个值可以选

entry

这是一种入口导入方式, 只要我们在打包配置入口 或者 文件入口写入 import "core-js" 这样一串代码, babel 就会替我们根据当前你所配置的目标浏览器(browserslist)来引入所需要的polyfill 。

像这样, 我们在 index.js 文件中加入试一下core-js

// src/index.js
import "core-js";
function test() {
  new Promise()
}
test()
const arr = [1,2,3,4].map(item => item * item)
console.log(arr)

babel配置如下

[
  "presets": [
    ["@babel/preset-env", 
      {
        "useBuiltIns": "entry"
      }
    ]
  ]
}

当前 .browserslistrc 文件(更改目标浏览器为 Chrome 是为了此处演示更直观,简洁), 我们只要求兼容 chrome 50版本以上即可(当下最新版本为78)

Chrome > 50

那打包后如何呢?
2019-12-03-21-46-14

恐怖如斯啊,babel把我们填写的 import "core-js"替换掉, 转而导入了一大片的polyfill, 而且都是一些我没有用到的东西。

那我们提升一下目标浏览器呢? 它还会导入这么多吗?
此时, 我们把目标浏览器调整为比较接近最新版本的 75(当下最新版本为78)

// .browserslistrc

Chrome > 75

此刻打包后引入的 polyfill 明显少了好多。
2019-12-03-21-51-46

但同样是我们没用过的。
这也就是印证了上面所说的, 当 useBuiltIns 的值为 entry 时, @babel/preset-env 会按照你所设置的目标浏览器在入口处来引入所需的 polyfill,
不管你需不需要。

如此,我们可以知道, useBuiltIns = entry 的优点是覆盖面积就比较广, 一股脑全部搞定, 但是缺点就是打出来的包就大了多了很多没有用到的 polyfill, 并且还会污染全局

useage

这个就比较神奇了, useBuiltIns = useage 时,会参考目标浏览器(browserslist) 和 代码中所使用到的特性来按需加入 polyfill

当然, 使用 useBuiltIns = useage, 还需要填写另一个参数 corejs 的版本号,

core-js 支持两个版本, 2 或 3, 很多新特性已经不会加入到 2 里面了, 比如: flat 等等最新的方法, 2 这个版本里面都是没有的, 所以建议大家用3

此时的 .babelrc

{
  "presets": [
    ["@babel/preset-env", 
      {
        "useBuiltIns": "usage",
        "corejs": 3
      }
    ]
  ]
}

此时的 index.js


function test() {
  new Promise()
}
test()
const arr = [1,2,3,4].map(item => item * item)
const hasNumber = (num) => [4, 5, 6, 7, 8].includes(num)
console.log(arr)
console.log( hasNumber(2) )

此时的 .browserslistrc

> 1%
last 10 versions
not ie <= 8

打包后:
2019-12-03-22-59-03

nice ,够神奇, 我们用的几个新特性真的通通都加上了

这种方式打包体积不大,但是如果我们排除node_modules/目录,遇上没有经过转译的第三方包,就检测不到第三方包内部的 ‘hello‘.includes(‘h‘)这种句法,这时候我们就会遇到bug

false

剩下最后一个 useBuiltIns = false , 那就简单了, 这也是默认值 , 使用这个值时不引入 polyfill

@babel/runtime

这种方式会借助 helper function 来实现特性的兼容,
并且利用 @babel/plugin-transform-runtime 插件还能以沙箱垫片的方式防止污染全局, 并抽离公共的 helper function , 以节省代码的冗余

也就是说 @babel/runtime 是一个核心, 一种实现方式, 而 @babel/plugin-transform-runtime 就是一个管家, 负责更好的重复使用 @babel/runtime

@babel/plugin-transform-runtime 插件也有一个 corejs 参数需要填写

版本2 不支持内置对象 , 但自从Babel 7.4.0 之后,拥有了 @babel/runtime-corejs3 , 我们可以放心使用 corejs: 3 对实例方法做支持

当前的 .babelrc

{
  "presets": [
    ["@babel/preset-env"]
  ],
  "plugins": [
    [ 
      "@babel/plugin-transform-runtime", {
        "corejs": 3
      }
    ]
  ]
}

当前的 index.js

function test() {
  new Promise()
}
test()
const arr = [1,2,3,4].map(item => item * item)
const hasNumber = (num) => [4, 5, 6, 7, 8].includes(num)
console.log(arr)
console.log( hasNumber(2) )

打包后如下:
2019-12-04-00-01-39

我们看到使用 @babel/plugin-transform-runtime 编译后的代码和之前的 @babel/preset-env 编译结果大不一样了,
它使用了帮助函数, 并且赋予了别名 , 抽出为公共方法, 实现复用。 比如它用了 _Promise 代替了 new Promise , 从而避免了创建全局对象

上面两种方式一起用会怎么样

useage 和 @babel/runtime

useage 和 @babel/runtime 同时使用的情况下比较智能, 并没有引入重复的 polyfill

个人分析原因应该是: babel 的 plugin 比 prset 要先执行, 所以preset-env 得到了 @babel/runtime 使用帮助函数包装后的代码,而 useage 又是检测代码使用哪些新特性来判断的, 所以它拿到手的只是一堆 帮助函数, 自然没有效果了

实验过程如下:

当前index.js


function test() {
  new Promise()
}
test()
const arr = [1,2,3,4].map(item => item * item)
const hasNumber = (num) => [4, 5, 6, 7, 8].includes(num)
const hasNumber2 = (num) => [4, 5, 6, 7, 8, 9].includes(num)
console.log(arr)
console.log( hasNumber(2))
console.log( hasNumber2(3) )

当前 .babelrc

{
  "presets": [
    ["@babel/preset-env", 
      {
        "useBuiltIns": "usage",
        "corejs": 3
      }
    ]
  ],
  "plugins": [
    [ 
      "@babel/plugin-transform-runtime", {
        "corejs": 3
      }
    ]
  ]
}

打包结果:

2019-12-04-00-23-24

entry 和 @babel/runtime

跟 useage 的情况不一样, entry 模式下, 在经过 @babel/runtime 处理后不但有了各种帮助函数还引入了许多polyfill, 这就会导致打包体积无情的增大

个人分析: entry 模式下遭遇到入口的 import "core-js" 及就立即替换为当前目标浏览器下所需的所有 polyfill, 所以也就跟 @babel/runtime 互不冲突了, 导致了重复引入代码的问题, 所以这两种方式千万不要一起使用, 二选一即可

实现过程如下:

当前 index.js:

import "core-js"
function test() {
  new Promise()
}
test()
const arr = [1,2,3,4].map(item => item * item)
const hasNumber = (num) => [4, 5, 6, 7, 8].includes(num)
const hasNumber2 = (num) => [4, 5, 6, 7, 8, 9].includes(num)
console.log(arr)
console.log( hasNumber(2))
console.log( hasNumber2(3) )

当前 .babelrc

{
  "presets": [
    ["@babel/preset-env", 
      {
        "useBuiltIns": "entry"
      }
    ]
  ],
  "plugins": [
    [ 
      "@babel/plugin-transform-runtime", {
        "corejs": 3
      }
    ]
  ]
}

当前 .browserslistrc 的目标版本(为了减少打包后的文件行数为又改为chrome 了, 懂那个意思就行)

Chrome > 70

打包结果:

2019

总结

  1. @babel/preset-env 拥有根据 useBuiltIns 参数的多种polyfill实现,优点是覆盖面比较全(entry), 缺点是会污染全局, 推荐在业务项目中使用

    • entry 的覆盖面积全, 但是打包体积自然就大,
    • useage 可以按需引入 polyfill, 打包体积就小, 但如果打包忽略node_modules 时如果第三方包未转译则会出现兼容问题
  2. @babel/runtime 在 babel 7.4 之后大放异彩, 利用 corejs 3 也实现了各种内置对象的支持, 并且依靠 @babel/plugin-transform-runtime 的能力,沙箱垫片和代码复用, 避免帮助函数重复 inject 过多的问题, 该方式的优点是不会污染全局, 适合在类库开发中使用

    上面 1, 2 两种方式取其一即可, 同时使用没有意义, 还可能造成重复的 polyfill 文件

查看原文

赞 38 收藏 17 评论 15

cnryb 关注了用户 · 2020-07-16

智联大前端 @zpfe

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

关注 3800

cnryb 赞了回答 · 2019-11-11

解决学react native还是weex , 目前我会vue不会react

weex和react native在性能上差别不大,
但从招聘软件上看,react native的需求更多。

现在比较火的跨平台开发方案
flutter、weex、react native、uniapp
flutter的渲染性能是最高效的,逼格也最高,但是dart的写法难受的要死,而且是新技术,现在采用这个技术的公司也不多。
weex和react native是原生渲染引擎,效率比flutter要低(因为有和js的通讯折损)、比uniapp这种webview方案高。
但无论是flutter、weex还是react native 都要求web开发者懂一点原生开发(至少打包你要会吧)。

现在采用这三种技术方案的公司,基本都是公司本来就有原生开发工程师,当web开发遇到问题时(例如打包、自定义模块)的时候,要原生工程师配合做一下。如果一个公司没有原生工程师,采用这三种技术开发app想节约成本快速开发,那最后要哭死。

对web开发者最友好的开发方案其实是uniapp
你只需要写vue代码就可以了,打包什么的他帮你完成(Android、ios都能在线打包)、官方提供了大量的常用原生模块、社区也包含了大量的插件(flutter、weex、react native只是渲染引擎,大部分模块都是社区提供并维护的)能满足大多数的开发需求。唯一的缺憾就是性能要差一些,但并不是差到不能用。

就我的感受来说,用户对app的要求真的不高。
你只要界面好看一点,能完成既定的功能。用户并不会因为你页面渲染慢1s而放弃你。
uniapp做出来的东西,足够应付多数的场面了,而且还能跨小程序。

关注 11 回答 10

cnryb 回答了问题 · 2019-11-07

解决weex创建项目报错

执行 weex doctor 检查一下各种依赖啥的都正确不,特别是node的版本有没过低

关注 2 回答 2

认证与成就

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

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2014-01-03
个人主页被 606 人浏览