i鱿鱼

i鱿鱼 查看完整档案

北京编辑  |  填写毕业院校前智联招聘  |  大前端架构师 编辑填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

i鱿鱼 赞了文章 · 1月28日

使用 async_hooks 模块进行请求追踪

async_hooks 模块是在 v8.0.0 版本正式加入 Node.js 的实验性 API。我们也是在 v8.x.x 版本下投入生产环境进行使用。

那么什么是 async_hooks 呢?

async_hooks 提供了追踪异步资源的 API,这种异步资源是具有关联回调的对象。

简而言之,async_hooks 模块可以用来追踪异步回调。那么如何使用这种追踪能力,使用的过程中又有什么问题呢?

认识 async_hooks

v8.x.x 版本下的 async_hooks 主要有两部分组成,一个是 createHook 用以追踪生命周期,一个是 AsyncResource 用于创建异步资源。

const { createHook, AsyncResource, executionAsyncId } = require('async_hooks')

const hook = createHook({
  init (asyncId, type, triggerAsyncId, resource) {},
  before (asyncId) {},
  after (asyncId) {},
  destroy (asyncId) {}
})
hook.enable()

function fn () {
  console.log(executionAsyncId())
}

const asyncResource = new AsyncResource('demo')
asyncResource.run(fn)
asyncResource.run(fn)
asyncResource.emitDestroy()

上面这段代码的含义和执行结果是:

  1. 创建一个包含在每个异步操作的 initbeforeafterdestroy 声明周期执行的钩子函数的 hooks 实例。
  2. 启用这个 hooks 实例。
  3. 手动创建一个类型为 demo 的异步资源。此时触发了 init 钩子,异步资源 idasyncId,类型为 type(即 demo),异步资源的创建上下文 idtriggerAsyncId,异步资源为 resource
  4. 使用此异步资源执行 fn 函数两次,此时会触发 before 两次、after 两次,异步资源 idasyncId,此 asyncIdfn 函数内通过 executionAsyncId 取到的值相同。
  5. 手动触发 destroy 生命周期钩子。

像我们常用的 asyncawaitpromise 语法或请求这些异步操作的背后都是一个个的异步资源,也会触发这些生命周期钩子函数。

那么,我们就可以在 init 钩子函数中,通过异步资源创建上下文 triggerAsyncId(父)到当前异步资源 asyncId(子)这种指向关系,将异步调用串联起来,拿到一棵完整的调用树,通过回调函数(即上述代码的 fn)中 executionAsyncId() 获取到执行当前回调的异步资源的 asyncId,从调用链上追查到调用的源头。

同时,我们也需要注意到一点,init异步资源创建的钩子,不是异步回调函数创建的钩子,只会在异步资源创建的时候执行一次,这会在实际使用的时候带来什么问题呢?

请求追踪

出于异常排查和数据分析的目的,希望在我们 Ada 架构的 Node.js 服务中,将服务器收到的由客户端发来请求的请求头中的 request-id 自动添加到发往中后台服务的每个请求的请求头中。

功能实现的简单设计如下:

  1. 通过 init 钩子使得在同一条调用链上的异步资源共用一个存储对象。
  2. 解析请求头中 request-id,添加到当前异步调用链对应的存储上。
  3. 改写 httphttps 模块的 request 方法,在请求执行时获取当前当前的调用链对应存储中的 request-id

示例代码如下:

const http = require('http')
const { createHook, executionAsyncId } = require('async_hooks')
const fs = require('fs')

// 追踪调用链并创建调用链存储对象
const cache = {}
const hook = createHook({
  init (asyncId, type, triggerAsyncId, resource) {
    if (type === 'TickObject') return
    // 由于在 Node.js 中 console.log 也是异步行为,会导致触发 init 钩子,所以我们只能通过同步方法记录日志
    fs.appendFileSync('log.out', `init ${type}(${asyncId}: trigger: ${triggerAsyncId})\n`);
    // 判断调用链存储对象是否已经初始化
    if (!cache[triggerAsyncId]) {
      cache[triggerAsyncId] = {}
    }
    // 将父节点的存储与当前异步资源通过引用共享
    cache[asyncId] = cache[triggerAsyncId]
  }
})
hook.enable()

// 改写 http
const httpRequest = http.request
http.request = (options, callback) => {
  const client = httpRequest(options, callback)
  // 获取当前请求所属异步资源对应存储的 request-id 写入 header
  const requestId = cache[executionAsyncId()].requestId
  console.log('cache', cache[executionAsyncId()])
  client.setHeader('request-id', requestId)

  return client
}

function timeout () {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, Math.random() * 1000)
  })
}
// 创建服务
http
  .createServer(async (req, res) => {
    // 获取当前请求的 request-id 写入存储
    cache[executionAsyncId()].requestId = req.headers['request-id']
    // 模拟一些其他耗时操作
    await timeout()
    // 发送一个请求
    http.request('http://www.baidu.com', (res) => {})
    res.write('hello\n')
    res.end()
  })
  .listen(3000)

执行代码并进行一次发送测试,发现已经可以正确获取到 request-id

陷阱

同时,我们也需要注意到一点,init异步资源创建的钩子,不是异步回调函数创建的钩子,只会在异步资源创建的时候执行一次。

但是上面的代码是有问题的,像前面介绍 async_hooks 模块时的代码演示的那样,一个异步资源可以不断的执行不同的函数,即异步资源有复用的可能。特别是对类似于 TCP 这种由 C/C++ 部分创建的异步资源,多次请求可能会使用同一个 TCP 异步资源,从而使得这种情况下,多次请求到达服务器时初始的 init 钩子函数只会执行一次,导致多次请求的调用链追踪会追踪到同一个 triggerAsyncId,从而引用同一个存储。

我们将前面的代码做如下修改,来进行一次验证。
存储初始化部分将 triggerAsyncId 保存下来,方便观察异步调用的追踪关系:

    if (!cache[triggerAsyncId]) {
      cache[triggerAsyncId] = {
        id: triggerAsyncId
      }
    }

timeout 函数改为先进行一次长耗时再进行一次短耗时操作:

function timeout () {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, [1000, 5000].pop())
  })
}

重启服务后,使用 postman (不用 curl 是因为 curl 每次请求结束会关闭连接,导致不能复现)连续的发送两次请求,可以观察到以下输出:

{ id: 1, requestId: '第二次请求的id' }
{ id: 1, requestId: '第二次请求的id' }

即可发现在多并发且写读存储的操作之间有耗时不固定的其他操作情况下,先到达服务器的请求存储的值会被后到达服务器的请求执行复写掉,使得前一次请求读取到错误的值。当然,你可以保证在写和读之间不插入其他的耗时操作,但在复杂的服务中这种靠脑力维护的保障方式明显是不可靠的。此时,我们就需要使每次读写前,JS 都能进入一个全新的异步资源上下文,即获得一个全新的 asyncId,避免这种复用。需要将调用链存储的部分做以下几方面修改:

const http = require('http')
const { createHook, executionAsyncId } = require('async_hooks')
const fs = require('fs')
const cache = {}

const httpRequest = http.request
http.request = (options, callback) => {
  const client = httpRequest(options, callback)
  const requestId = cache[executionAsyncId()].requestId
  console.log('cache', cache[executionAsyncId()])
  client.setHeader('request-id', requestId)

  return client
}

// 将存储的初始化提取为一个独立的方法
async function cacheInit (callback) {
  // 利用 await 操作使得 await 后的代码进入一个全新的异步上下文
  await Promise.resolve()
  cache[executionAsyncId()] = {}
  // 使用 callback 执行的方式,使得后续操作都属于这个新的异步上下文
  return callback()
}

const hook = createHook({
  init (asyncId, type, triggerAsyncId, resource) {
    if (!cache[triggerAsyncId]) {
      // init hook 不再进行初始化
      return fs.appendFileSync('log.out', `未使用 cacheInit 方法进行初始化`)
    }
    cache[asyncId] = cache[triggerAsyncId]
  }
})
hook.enable()

function timeout () {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, [1000, 5000].pop())
  })
}

http
.createServer(async (req, res) => {
  // 将后续操作作为 callback 传入 cacheInit
  await cacheInit(async function fn() {
    cache[executionAsyncId()].requestId = req.headers['request-id']
    await timeout()
    http.request('http://www.baidu.com', (res) => {})
    res.write('hello\n')
    res.end()
  })
})
.listen(3000)

值得一提的是,这种使用 callback 的组织方式与 koajs 的中间件的模式十分一致。

async function middleware (ctx, next) {
  await Promise.resolve()
  cache[executionAsyncId()] = {}
  return next()
}

NodeJs v14

这种使用 await Promise.resolve() 创建全新异步上下文的方式看起来总有些 “歪门邪道” 的感觉。好在 NodeJs v9.x.x 版本中提供了创建异步上下文的官方实现方式 asyncResource.runInAsyncScope。更好的是,NodeJs v14.x.x 版本直接提供了异步调用链数据存储的官方实现,它会直接帮你完成异步调用关系追踪、创建新的异步上线文、管理数据这三项工作!API 就不再详细介绍,我们直接使用新 API 改造之前的实现

const { AsyncLocalStorage } = require('async_hooks')
// 直接创建一个 asyncLocalStorage 存储实例,不再需要管理 async 生命周期钩子
const asyncLocalStorage = new AsyncLocalStorage()
const storage = {
  enable (callback) {
    // 使用 run 方法创建全新的存储,且需要让后续操作作为 run 方法的回调执行,以使用全新的异步资源上下文
    asyncLocalStorage.run({}, callback)
  },
  get (key) {
    return asyncLocalStorage.getStore()[key]
  },
  set (key, value) {
    asyncLocalStorage.getStore()[key] = value
  }
}

// 改写 http
const httpRequest = http.request
http.request = (options, callback) => {
  const client = httpRequest(options, callback)
  // 获取异步资源存储的 request-id 写入 header
  client.setHeader('request-id', storage.get('requestId'))

  return client
}

// 使用
http
  .createServer((req, res) => {
    storage.enable(async function () {
      // 获取当前请求的 request-id 写入存储
      storage.set('requestId', req.headers['request-id'])
      http.request('http://www.baidu.com', (res) => {})
      res.write('hello\n')
      res.end()
    })
  })
  .listen(3000)

可以看到,官方实现的 asyncLocalStorage.run API 和我们的第二版实现在结构上也很一致。

于是,在 Node.js v14.x.x 版本下,使用 async_hooks 模块进行请求追踪的功能很轻易的就实现了。

查看原文

赞 3 收藏 1 评论 1

i鱿鱼 赞了文章 · 1月13日

智联招聘的微前端落地实践——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

i鱿鱼 赞了文章 · 1月7日

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

i鱿鱼 收藏了文章 · 2020-12-09

手写koa-static源码,深入理解静态服务器原理

这篇文章继续前面的Koa源码系列,这个系列已经有两篇文章了:

  1. 第一篇讲解了Koa的核心架构和源码:手写Koa.js源码
  2. 第二篇讲解了@koa/router的架构和源码:手写@koa/router源码

本文会接着讲一个常用的中间件----koa-static,这个中间件是用来搭建静态服务器的。

其实在我之前使用Node.js原生API写一个web服务器已经讲过怎么返回一个静态文件了,代码虽然比较丑,基本流程还是差不多的:

  1. 通过请求路径取出正确的文件地址
  2. 通过地址获取对应的文件
  3. 使用Node.js的API返回对应的文件,并设置相应的header

koa-static的代码更通用,更优雅,而且对大文件有更好的支持,下面我们来看看他是怎么做的吧。本文还是采用一贯套路,先看一下他的基本用法,然后从基本用法入手去读源码,并手写一个简化版的源码来替换他。

本文可运行代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaStatic

基本用法

koa-static使用很简单,主要代码就一行:

const Koa = require('koa');
const serve = require('koa-static');

const app = new Koa();

// 主要就是这行代码
app.use(serve('public'));

app.listen(3001, () => {
    console.log('listening on port 3001');
});

上述代码中的serve就是koa-static,他运行后会返回一个Koa中间件,然后Koa的实例直接引用这个中间件就行了。

serve方法支持两个参数,第一个是静态文件的目录,第二个参数是一些配置项,可以不传。像上面的代码serve('public')就表示public文件夹下面的文件都可以被外部访问。比如我在里面放了一张图片:

image-20201125163558774

跑起来就是这样子:

image.png

注意上面这个路径请求的是/test.jpg,前面并没有public,说明koa-static对请求路径进行了判断,发现是文件就映射到服务器的public目录下面,这样可以防止外部使用者探知服务器目录结构。

手写源码

返回的是一个Koa中间件

我们看到koa-static导出的是一个方法serve,这个方法运行后返回的应该是一个Koa中间件,这样Koa才能引用他,所以我们先来写一下这个结构吧:

module.exports = serve;   // 导出的是serve方法

// serve接受两个参数
// 第一个参数是路径地址
// 第二个是配置选项
function serve(root, opts) {
    // 返回一个方法,这个方法符合koa中间件的定义
    return async function serve(ctx, next) {
        await next();
    }
}

调用koa-send返回文件

现在这个中间件是空的,其实他应该做的是将文件返回,返回文件的功能也被单独抽取出来成了一个库----koa-send,我们后面会看他源码,这里先直接用吧。

function serve(root, opts) {
    // 这行代码如果效果就是
    // 如果没传opts,opts就是空对象{}
    // 同时将它的原型置为null
    opts = Object.assign(Object.create(null), opts);

    // 将root解析为一个合法路径,并放到opts上去
    // 因为koa-send接收的路径是在opts上
    opts.root = resolve(root);
  
      // 这个是用来兼容文件夹的,如果请求路径是一个文件夹,默认去取index
    // 如果用户没有配置index,默认index就是index.html
    if (opts.index !== false) opts.index = opts.index || 'index.html';

      // 整个serve方法的返回值是一个koa中间件
      // 符合koa中间件的范式: (ctx, next) => {}
    return async function serve(ctx, next) {
        let done = false;    // 这个变量标记文件是否成功返回

        // 只有HEAD和GET请求才响应
        if (ctx.method === 'HEAD' || ctx.method === 'GET') {
            try {
                // 调用koa-send发送文件
                // 如果发送成功,koa-send会返回路径,赋值给done
                // done转换为bool值就是true
                done = await send(ctx, ctx.path, opts);
            } catch (err) {
                // 如果不是404,可能是一些400,500这种非预期的错误,将它抛出去
                if (err.status !== 404) {
                    throw err
                }
            }
        }

        // 通过done来检测文件是否发送成功
        // 如果没成功,就让后续中间件继续处理他
        // 如果成功了,本次请求就到此为止了
        if (!done) {
            await next()
        }
    }
}

opt.defer

defer是配置选项opt里面的一个可选参数,他稍微特殊一点,默认为false,如果你传了truekoa-static会让其他中间件先响应,即使其他中间件写在koa-static后面也会让他先响应,自己最后响应。要实现这个,其实就是控制调用next()的时机。在讲Koa源码的文章里面已经讲过了,调用next()其实就是在调用后面的中间件,所以像上面代码那样最后调用next(),就是先执行koa-static然后再执行其他中间件。如果你给defer传了true,其实就是先执行next(),然后再执行koa-static的逻辑,按照这个思路我们来支持下defer吧:

function serve(root, opts) {
    opts = Object.assign(Object.create(null), opts);

    opts.root = resolve(root);

    // 如果defer为false,就用之前的逻辑,最后调用next
    if (!opts.defer) {
        return async function serve(ctx, next) {
            let done = false;    

            if (ctx.method === 'HEAD' || ctx.method === 'GET') {
                try {
                    done = await send(ctx, ctx.path, opts);
                } catch (err) {
                    if (err.status !== 404) {
                        throw err
                    }
                }
            }

            if (!done) {
                await next()
            }
        }
    }

    // 如果defer为true,先调用next,然后执行自己的逻辑
    return async function serve(ctx, next) {
        // 先调用next,执行后面的中间件
        await next();

        if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return

        // 如果ctx.body有值了,或者status不是404,说明请求已经被其他中间件处理过了,就直接返回了
        if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line

        // koa-static自己的逻辑还是一样的,都是调用koa-send
        try {
            await send(ctx, ctx.path, opts)
        } catch (err) {
            if (err.status !== 404) {
                throw err
            }
        }
    }
}

koa-static源码总共就几十行:https://github.com/koajs/static/blob/master/index.js

koa-send

上面我们看到koa-static其实是包装的koa-send,真正发送文件的操作都是在koa-send里面的。文章最开头说的几件事情koa-static一件也没干,都丢给koa-send了,也就是说他应该把这几件事都干完:

  1. 通过请求路径取出正确的文件地址
  2. 通过地址获取对应的文件
  3. 使用Node.js的API返回对应的文件,并设置相应的header

由于koa-send代码也不多,我就直接在代码中写注释了,通过前面的使用,我们已经知道他的使用形式是:

send (ctx, path, opts)

他接收三个参数:

  1. ctx:就是koa的那个上下文ctx
  2. pathkoa-static传过来的是ctx.path,看过koa源码解析的应该知道,这个值其实就是req.path
  3. opts: 一些配置项,defer前面讲过了,会影响执行顺序,其他还有些缓存控制什么的。

下面直接来写一个send方法吧:

const fs = require('fs')
const fsPromises = fs.promises;
const { stat, access } = fsPromises;

const {
    normalize,
    basename,
    extname,
    resolve,
    parse,
    sep
} = require('path')
const resolvePath = require('resolve-path')

// 导出send方法
module.exports = send;

// send方法的实现
async function send(ctx, path, opts = {}) {
    // 先解析配置项
    const root = opts.root ? normalize(resolve(opts.root)) : '';  // 这里的root就是我们配置的静态文件目录,比如public
    const index = opts.index;    // 请求文件夹时,会去读取这个index文件
    const maxage = opts.maxage || opts.maxAge || 0;     // 就是http缓存控制Cache-Control的那个maxage
    const immutable = opts.immutable || false;   // 也是Cache-Control缓存控制的
    const format = opts.format !== false;   // format默认是true,用来支持/directory这种不带/的文件夹请求

    const trailingSlash = path[path.length - 1] === '/';    // 看看path结尾是不是/
    path = path.substr(parse(path).root.length)             // 去掉path开头的/

    path = decode(path);      // 其实就是decodeURIComponent, decode辅助方法在后面
    if (path === -1) return ctx.throw(400, 'failed to decode');

    // 如果请求以/结尾,肯定是一个文件夹,将path改为文件夹下面的默认文件
    if (index && trailingSlash) path += index;

    // resolvePath可以将一个根路径和请求的相对路径合并成一个绝对路径
    // 并且防止一些常见的攻击,比如GET /../file.js
    // GitHub地址:https://github.com/pillarjs/resolve-path
    path = resolvePath(root, path)

    // 用fs.stat获取文件的基本信息,顺便检测下文件存在不
    let stats;
    try {
        stats = await stat(path)

        // 如果是文件夹,并且format为true,拼上index文件
        if (stats.isDirectory()) {
            if (format && index) {
                path += `/${index}`
                stats = await stat(path)
            } else {
                return
            }
        }
    } catch (err) {
        // 错误处理,如果是文件不存在,返回404,否则返回500
        const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']
        if (notfound.includes(err.code)) {
              // createError来自http-errors库,可以快速创建HTTP错误对象
            // github地址:https://github.com/jshttp/http-errors
            throw createError(404, err)
        }
        err.status = 500
        throw err
    }

    // 设置Content-Length的header
    ctx.set('Content-Length', stats.size)

    // 设置缓存控制header
    if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString())
    if (!ctx.response.get('Cache-Control')) {
        const directives = [`max-age=${(maxage / 1000 | 0)}`]
        if (immutable) {
            directives.push('immutable')
        }
        ctx.set('Cache-Control', directives.join(','))
    }

    // 设置返回类型和返回内容
   if (!ctx.type) ctx.type = extname(path)
    ctx.body = fs.createReadStream(path)

    return path
}

function decode(path) {
    try {
        return decodeURIComponent(path)
    } catch (err) {
        return -1
    }
}

上述代码并没有太复杂的逻辑,先拼一个完整的地址,然后使用fs.stat获取文件的基本信息,如果文件不存在,这个API就报错了,直接返回404。如果文件存在,就用fs.stat拿到的信息设置Content-Length和一些缓存控制的header。

koa-send的源码也只有一个文件,百来行代码:https://github.com/koajs/send/blob/master/index.js

ctx.type和ctx.body

上述代码我们看到最后并没有直接返回文件,而只是设置了ctx.typectx.body这两个值就结束了,为啥设置了这两个值,文件就自动返回了呢?要知道这个原理,我们要结合Koa源码来看。

之前讲Koa源码的时候我提到过,他扩展了Node原生的res,并且在里面给type属性添加了一个set方法:

set type(type) {
  type = getType(type);
  if (type) {
    this.set('Content-Type', type);
  } else {
    this.remove('Content-Type');
  }
}

这段代码的作用是当你给ctx.type设置值的时候,会自动给Content-Type设置值,getType其实是另一个第三方库cache-content-type,他可以根据你传入的文件类型,返回匹配的MIME type。我刚看koa-static源码时,找了半天也没找到在哪里设置的Content-Type,后面发现是在Koa源码里面。所以设置了ctx.type其实就是设置了Content-Type

koa扩展的type属性看这里:https://github.com/koajs/koa/blob/master/lib/response.js#L308

之前讲Koa源码的时候我还提到过,当所有中间件都运行完了,最后会运行一个方法respond来返回结果,在那篇文章里面,respond是简化版的,直接用res.end返回了结果:

function respond(ctx) {
  const res = ctx.res; // 取出res对象
  const body = ctx.body; // 取出body

  return res.end(body); // 用res返回body
}

直接用res.end返回结果只能对一些简单的小对象比较合适,比如字符串什么的。对于复杂对象,比如文件,这个就不合适了,因为你如果要用res.write或者res.end返回文件,你需要先把文件整个读入内存,然后作为参数传递,如果文件很大,服务器内存可能就爆了。那要怎么处理呢?回到koa-send源码里面,我们给ctx.body设置的值其实是一个可读流:

ctx.body = fs.createReadStream(path)

这种流怎么返回呢?其实Node.js对于返回流本身就有很好的支持。要返回一个值,需要用到http回调函数里面的res,这个res本身其实也是一个流。大家可以再翻翻Node.js官方文档,这里的res其实是http.ServerResponse类的一个实例,而http.ServerResponse本身又继承自Stream类:

image-20201203154324281

所以res本身就是一个流Stream,那Stream的API就可以用了ctx.body是使用fs.createReadStream创建的,所以他是一个可读流,可读流有一个很方便的API可以直接让内容流动到可写流:readable.pipe,使用这个API,Node.js会自动将可读流里面的内容推送到可写流,数据流会被自动管理,所以即使可读流更快,目标可写流也不会超负荷,而且即使你文件很大,因为不是一次读入内存,而是流式读入,所以也不会爆。所以我们在Koarespond里面支持下流式body就行了:

function respond(ctx) {
  const res = ctx.res; 
  const body = ctx.body; 
  
  // 如果body是个流,直接用pipe将它绑定到res上
  if (body instanceof Stream) return body.pipe(res);

  return res.end(body); 
}

Koa源码对于流的处理看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L267

总结

本文可运行代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaStatic

现在,我们可以用自己写的koa-static来替换官方的了,运行效果是一样的。最后我们再来回顾下本文的要点:

  1. 本文是Koa常用静态服务中间件koa-static的源码解析。
  2. 由于是一个Koa的中间件,所以koa-static的返回值是一个方法,而且需要符合中间件范式: (ctx, next) => {}
  3. 作为一个静态服务中间件,koa-static本应该完成以下几件事情:

    1. 通过请求路径取出正确的文件地址
    2. 通过地址获取对应的文件
    3. 使用Node.js的API返回对应的文件,并设置相应的header

但是这几件事情他一件也没干,都扔给koa-send了,所以他官方文档也说了他只是wrapper for koa-send.

  1. 作为一个wrapper他还支持了一个比较特殊的配置项opt.defer,这个配置项可以控制他在所有Koa中间件里面的执行时机,其实就是调用next的时机。如果你给这个参数传了true,他就先调用next,让其他中间件先执行,自己最后执行,反之亦然。有了这个参数,你可以将/test.jpg这种请求先作为普通路由处理,路由没匹配上再尝试静态文件,这在某些场景下很有用。
  2. koa-send才是真正处理静态文件,他把前面说的三件事全干了,在拼接文件路径时还使用了resolvePath来防御常见攻击。
  3. koa-send取文件时使用了fs模块的API创建了一个可读流,并将它赋值给ctx.body,同时设置了ctx.type
  4. 通过ctx.typectx.body返回给请求者并不是koa-send的功能,而是Koa本身的功能。由于http模块提供和的res本身就是一个可写流,所以我们可以通过可读流的pipe函数直接将ctx.body绑定到res上,剩下的工作Node.js会自动帮我们完成。
  5. 使用流(Stream)来读写文件有以下几个优点:

    1. 不用一次性将文件读入内存,暂用内存小。
    2. 如果文件很大,一次性读完整个文件,可能耗时较长。使用流,可以一点一点读文件,读到一点就可以返回给response,有更快的响应时间。
    3. Node.js可以在可读流和可写流之间使用管道进行数据传输,使用也很方便。

参考资料:

koa-static文档:https://github.com/koajs/static

koa-static源码:https://github.com/koajs/static/blob/master/index.js

koa-send文档:https://github.com/koajs/send

koa-send源码:https://github.com/koajs/send/blob/master/index.js

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

i鱿鱼 收藏了文章 · 2020-12-09

npm 私库从搭建到数据迁移最后容灾备份的一些解决方案

这是第 80 篇不掺水的原创,想获取更多原创好文,请搜索公众号关注我们吧~ 本文首发于政采云前端博客:npm 私库从搭建到数据迁移最后容灾备份的一些解决方案

前言

按照国际惯例,正文开始之前,我们先简单介绍下目前市面上的 npm 私库开源框架。

  • Verdaccio

    Verdaccio 是 sinopia 开源框架的一个分支。它提供了自己的小数据库,以及代理其他注册中心的能力(例如。npmjs.org 网站),配置以及部署相对简单,一步到"胃"。如果公司的私包比较少的话或者你想偷懒,可以考虑一下。

  • Cnpmjs.org

大名鼎鼎的 cnpm,想必各位早就感受到了它的速度之“快”,没错,它的 register 服务就是淘宝镜像。主要是基于Koa、MySQL 和简单存储服务的企业专用 npm 注册和 web 服务,其中最强大的功能就是它的同步模块机制(定时同步所有源 registry 的模块、只同步已经存在于数据库的模块、只同步 popular 模块)。

  • Nexus

后端开发的小伙伴应该比较熟悉。Nexus2 主要是用于 maven/gralde 仓库的统一管理,而 Nexus3 则添加了npm插件,可以对 npm 提供支持,其中 npm 仓库有三种类型,分别是 hosted(私有仓库)、proxy(代理仓库)、group(组合仓库)。

总体来讲,抛开 Nexus,虽然 Cnpmjs.org 在部署过程以及总体设计方案上相对于 Verdaccio 复杂的多,但是它提供更高的拓展性,定制性,可以支持多种业务使用场景。接下来,我们分别从 Cnpmjs.org 容器化部署、数据迁移、OSS 容灾备份等内容,层层展开。

Cnpmjs.org 容器化部署

目前,公司的应用部署基本都是容器化部署,内部搭建了 ipaas 平台,应用流程化部署以及一键发布。而 Cnpmjs.org 也附带了 Dockerfile 以及 docker-compose.yml 文件,所以,这里大致讲解下怎么用 docker 部署吧。

  • 首先让我们看看 Dockerfile 文件
FROM node:12
MAINTAINER zian yuanzhian@cai-inc.com

# Working enviroment
ENV \
    CNPM_DIR="/var/app/cnpmjs.org" \
    CNPM_DATA_DIR="/var/data/cnpm_data" 

# shell格式
# 在docker build 时运行
RUN mkdir -p ${CNPM_DIR}

# 指定工作目录:用 WORKDIR 指定的工作目录,会在构建镜像的每一层中都存在
WORKDIR ${CNPM_DIR}

# 复制指令:从上下文目录中复制目录或文件到容器里指定的路径
COPY package.json ${CNPM_DIR}

RUN npm set registry https://registry.npm.taobao.org

RUN npm install --production

COPY .  ${CNPM_DIR}
COPY docs/dockerize/config.js  ${CNPM_DIR}/config/

# 声明端口(7001为register服务、7002为web服务)
EXPOSE 7001/tcp 7002/tcp

# 匿名数据卷:在启动容器时忘记挂载数据卷,会自动挂载到匿名卷。
VOLUME ["/var/data/cnpm_data"]

RUN chmod +x ${CNPM_DIR}/docker-entrypoint_prod.sh

# Entrypoint 
# exec格式
# 在docker run 时运行
# dockerfile存在多个 CMD 命令,仅最后一个生效
# CMD ["node", "dispatch.js"]
CMD ["npm", "run", "prod"]

这里把 CMD 命令修改为["npm", "run", "prod"],因为增加了一层不同环境的 shell 脚本,目前全局变量全都存放在这里。

示例:docker-entrypoint_env.sh

export DB='db_cnpmjs'
export DB_USRNAME='root'
export DB_PASSWORD='123456'
export DB_HOST='127.0.0.1'

export BINDING_HOST='0.0.0.0'

DEBUG=cnpm* node dispatch.js 
  • 再修改下 docker-compose.yml 文件,这里把 mysql-db 这个服务删掉了,原因是可通过 /docs/dockerize/config.js 下的配置文件去连接公司测试环境的 mysql 数据库,则不需要构建生成 mysql-db镜像
version: '3' # docker版本
services: # 配置的容器列表
  web: # 自定义,服务名称
    build: # 基于dockerfile构建镜像(可增加args)
      context: .
      dockerfile: Dockerfile ## 依赖的Dockerfile文件
    image: cnpmjs.org # 镜像名称或id
    volumes:
      - cnpm-files-volume:/var/data/cnpm_data
    ports:
      - "7001:7001"
      - "7002:7002" 

<font color="red">注意点:1、全局配置文件路径: /docs/dockerize/config.js ;2、bindingHost 为 0.0.0.0 。</font>

  • 最后,在控制台敲下docker-compose up -d,即以守护进程模式形式启动应用,然后打开浏览器入http://127.0.0.1:7002,就会看到 web 页面。执行 npm config set registry http://127.0.0.1:7001 可设置为搭建的私库的镜像源地址,这里推荐使用 nrm,可自由切换 npm 源。

展示站点如下图:

<font color="red">注意点:1、当你改变本地代码之后,先执行 docker-compose build 构建新的镜像,然后执行 docker-compose up -d 取代运行中的容器。</font>

数据迁移

由于公司之前用的 Verdaccio 搭建的私库,要切换使用新的 npm 私库,意味着要把之前发布过的私包全部迁移过来。大概统计了下,有400 多个 package,总共有 7000 多个版本,按照正常逻辑,做数据迁移首先会从数据库下手,但是 Verdaccio 并不依赖数据库。刚开始没有一点头绪,大概看了下 Cnpmjs.org 的源码,了解到当我们 publish 模块时, 它是怎么把 npm 模块 的元数据存储到数据库,下面我们一步步来揭开她的面纱。

通过路由文件(/routes/registry.js)我们很容易找到/controllers/registry/package/save.js,这个文件便是我们想要的。

核心代码:

var pkg = this.request.body; // 这里拿到npm模块元数据,即package.json文件经过libnpmpublish模块处理过的json数据
var username = this.user.name; // 当前用户名
var name = this.params.name || this.params[0]; // npm模块名
var filename = Object.keys(pkg._attachments || {})[0]; // npm模块的压缩后的文件名
var version = Object.keys(pkg.versions || {})[0]; // npm模块的最新版本
// upload attachment

// base64解码,获取模块文件二进制数据。从libnpmpublish模块了解到tardata.toString('base64'),即npm模块文件流转base64字符串
var tarballBuffer = Buffer.from(attachment.data, 'base64'); 
// 默认使用fs-cnpm,将npm模块文件保存到本地,默认保存路径:path.join(process.env.HOME, '.cnpmjs.org', 'nfs')
var uploadResult = yield nfs.uploadBuffer(tarballBuffer, options);

var versionPackage = pkg.versions[version];
var dist = {
  shasum: shasum,
  size: attachment.length
};

// if nfs upload return a key, record it
if (uploadResult.url) {
  dist.tarball = uploadResult.url;
} else if (uploadResult.key) {
  dist.key = uploadResult.key;
  dist.tarball = uploadResult.key;
}
var mod = {
  name: name,
  version: version,
  author: username,
  package: versionPackage
};

mod.package.dist = dist;

// 模块数据保存到数据库
var addResult = yield packageService.saveModule(mod);

即只要我们能够拿到 npm 模块的元数据(即 package.json 被处理过的 json 数据),就能把模块文件上传到文件系统或者 OSS 服务,同时数据落库。Verdaccio 有两个 api 可以拿到其私库 npm 模块全量数据和当前 npm 模块的 json 数据,路径分别是/-/verdaccio/packages/-/verdaccio/sidebar/$PKG$,其中有 scope 的模块的请求路径是/-/verdaccio/sidebar/$SCOPE$/$PKG$

思路已经很明确了,开始动起来吧!新增 save_zcy.js 文件,基于原来的/controllers/registry/package/save.js稍加改造下。

核心代码:

// 请求远程文件,并返回二进制流
const handleFiles = function (url) {
  return new Promise((resolve, reject) => {
    try {
      http.get(url, res => {
        res.setEncoding('binary') // 二进制
        let files = ''
        res.on('data', chunk => { // 加载到内存
          files += chunk
        }).on('end', () => { // 加载完
          resolve(files)
        })
      }) 
    } catch (error) {
      reject(error)
    }
  })
};

// 获取远程模块文件的二进制数据
yield handleFiles(dist.tarball).then(res => {
  // 利用 Buffer 转为对象
  const tardata = Buffer.from(res, 'binary')
  pkg._attachments = {};
  pkg._attachments[filename] = {
    'content_type': 'application/octet-stream',
    'data': tardata.toString('base64'), // 从缓冲区读取数据,使用base64编码并转换成字符串
    'length': tardata.length,
  };
}, error => {
  this.status = 400;
  this.body = {
    error,
    reason: error,
  };
  return;
});

接下来我们把控制器 save_zcy.js 接入到 registry 服务的 app 路由上。

// 新增 fetchPackageZcy、savePackageZcy 控制器
app.get('/:name/:version', syncByInstall, fetchPackageZcy, savePackageZcy, getOneVersion);
app.get('/:name', syncByInstall, fetchPackageZcy, savePackageZcy, listAllVersions);

控制器 fetchPackageZcy 作用是请求上面的 api(/-/verdaccio/sidebar/$SCOPE$/$PKG$ 或 /-/verdaccio/sidebar/$PKG$)来拉取对应模块的 json 数据。

213BFDE6-B389-4376-A959-DC9E2F71FDF7.png

Ok,接下来我们写一个定时任务,每隔一段时间执行 npm install [name],这样原来私库的 npm 包都能够 install 并进入到上面的控制器逻辑,大功告成!

OSS 容灾备份

首先,简单说明下为什么要做 OSS 容灾备份,有以下几点。

  • 如果服务器上磁盘损坏,易丢失文件,有一定的风险
  • 若服务器磁盘爆满,可自动降级上传模块文件到 OSS

基于以上几点,我们整理了下容灾备份方案:

  • package publish

<img data-original="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/06b600e8297a434087f43bcad08c7f6d~tplv-k3u1fbpfcp-zoom-1.image" style="zoom:33%;" />

即发布模块文件时本地存储,同时上传到 oss 作为备份,用到的插件分别是 fs-cnpmoss-cnpm

  • package install

<img data-original="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/df3ff3801cf74755a0c09be7d2a19029~tplv-k3u1fbpfcp-zoom-1.image" style="zoom:33%;" />

即下载模块文件时,先判断是否是私包(即是包名否有带 scope),如果不是私包代理到上游 registry,若是私包先判断服务器本地是否有该私包文件,如果不存在先去 oss 下载到本地 nfs 目录下,如果存在则直接从 nfs 目录找到模块文件,然后读取并写到 downloads 目录下,最后调用 fs.createReadStream 方法流读取该文件。

isEnsureFileExists 即判断模块文件本地是否存在,代码如下:

const mkdirp = require('mkdirp');
const fs = require('fs');

function ensureFileExists(filepath) {
  return function (callback) {
    fs.access(filepath, fs.constants.F_OK, callback);
  };
}

注意,在 oss 下载模块文件到 nfs 之前,一定要先创建模块文件目录,方法如下:

const mkdirp = require('mkdirp');

function ensureDirExists(filepath) {
  return function (callback) {
    mkdirp(path.dirname(filepath), callback);
  };
}

邮件通知

Cnpmjs.org 本来就带有邮件通知的功能,但只应用错误日志上报。由于我们的私包大部分都是业务组件、工具等,有时候发布正式版本的业务组件需要通知到业务组件的使用方。目前,我们采用 maintainers 来维护,包含模块的维护者及使用者。

示例:

"maintainers": [
  {
    "name": "yuanzhian",
    "email": "yuanzhian@cai-inc.com"
  }
]

邮箱配置如下:

mail: {
  enable: true,
  appname: 'cnpmjs.org',
  from: process.env.EMAIL_HOST,
  host: 'smtp.mxhichina.com',
  service: 'qiye.aliyun', // 使用了内置传输发送邮件,查看支持列表:https://nodemailer.com/smtp/well-known/
  port: 465, // SMTP 端口
  secureConnection: true, // 使用了 SSL
  auth: {
     user: process.env.EMAIL_HOST,
     pass: process.env.EMAIL_PSD, // 
   }
 }

写在文末

未来,我们还可以在 Cnpmjs.org 上做很多定制化开发,比如接入公司内部权限系统、web 页面重构、对接业务组件在线文档等等。如果你正好也需要搭建 npm 私有库,希望这篇文章对你有所帮助。

推荐阅读

分分钟教会你搭建企业级的 npm 私有仓库

编写高质量可维护的代码:组件的抽象与粒度

招贤纳士

政采云前端团队(ZooTeam),一个年轻富有激情和创造力的前端团队,隶属于政采云产品研发部,Base 在风景如画的杭州。团队现有 40 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。

如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变既定的节奏,将会是“5 年工作时间 3 年工作经验”;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 ZooTeam@cai-inc.com

查看原文

i鱿鱼 赞了文章 · 2020-11-30

万能瀑布流

常见的瀑布流实现大部分只适用于子块尺寸固定或内部有图片异步加载的情况。

而对于子块有图片这种可能引起尺寸变化的情况,通常的做法是写死图片高度,或检测内部的 img 元素从而在 onload 事件中进行重排。

由于我们业务中尺寸变化情况更为复杂,如子块本身异步初始化、内部数据异步获取,且这种尺寸变化时机不可确定,为满足这种需求所以调研完成了一个通用万能的瀑布流实现。

以下代码部分以 Vue.js 为例,思路和机制是通用的。

基础瀑布流

先不考虑子块尺寸变化的因素,完成基础的瀑布流布局功能。

基本属性

瀑布流布局的配置有三个,列数 columnCount,块水平间距 gutterWidth、块垂直间距 gutterHeight

当然也可是使用列宽代替列数,但通常情况下,这样就要求使用方进行列宽计算,有更高的使用成本
props: {
  columnCount: Number,
  gutterWidth: Number,
  gutterHeight: Number,
}

基本结构

对于类列表的结构,在组件开发中通常由两种形式:

  1. 组件内循环 slot
  2. 拆分为容器组件和子块组件,组件间做关联逻辑

组件内循环 slot 的方式如下:

// Waterfall.vue
<template>
  <div>
    <slot v-for="data in list" v-bind="data">
  </div>
</template>
// 使用方--父级组件
<waterfall :list="list">
  <template v-slot="data">
    <ecology-card :ecology-info="data" />
  </template>
</waterfall>

其实现思路是,使用者将列表数据传入组件,组件内部循环出对应个数的 slot,并将每一项数据传入 slot,使用方根据传回的数据进行自定义渲染。

这种方式使用起来比较违反视觉直觉,在使用者角度,不能直接的感受到循环结构,但开发角度,逻辑更封闭,实现复杂逻辑更为简便。

由于瀑布流组件只提供布局功能,应提供更直观的视觉感受,同时在我们的业务需求中,子块部分不尽相同,需要更灵活的自定义子块内容的方式。

所以采取第二种实现方式,拆分设计为 Waterfall.vue 瀑布流容器和 WaterfallItem.vue 瀑布流子块两个组件。

// 使用方
<waterfall>
  <waterfall-item>
    <a-widget /> // 业务组件
  </waterfall-item>
  <waterfall-item>
    <b-image /> // 业务组件
  </waterfall-item>
</waterfall>
// Waterfall.vue
<script>
  render (h) {
    return h('div', this.$slots.default)
  }
<script>

<style>
.waterfall {
  position: relative;
  width: 100%;
  min-height: 100%;
  overflow-x: hidden;
}
</style>

Waterfall.vue 组件只需要与父组件同宽高,并且将插入内部的元素原样渲染。

增删

为了保证在新增或删除子块时使重新布局的成本最小化,我选择由 WaterfallItem.vue 告知 Waterfall.vue 自己的新增和移除。

// Waterfall.vue
data () {
  return {
    children: []
  }
},
methods: {
  add (child) {
    const index = this.$children.indexOf(child)
    this.children[index] = child
    this.resize(index, true)
  },
  delete (child) {
    const index = this.$children.indexOf(child)
    this.children[index].splice(index, 1)
    this.resize(index, false)
  }
}
// WaterfallItem.vue
created () {
  this.$parent.add(this)
},
destoryed () {
  this.$parent.delete(this)
}

那么下面就要开始进行布局逻辑方法的编写。

瀑布流布局受两个因素影响,每个子块的宽和高,我们需要在适当的时候重新获取这两个维度的数据,其中块宽即列宽。

布局要素:列宽

列宽受两个因素的影响,容器宽度和期望的列数,那么列宽明显就是一个计算属性,而容器宽度需要在初始化和窗口变化时重新获取。

// Waterfall.vue
data () {
  return {
    // ...
    containerWidth: 0
  }
},
computed: {
  colWidth () {
    return (this.containerWidth - this.gutterWidth * (cols -1))/this.cols
  }
},
methods: {
  //...
  getContainerWidth () {
    this.containerWidth = this.$el.clientWidth
  }
},
mounted () {
  this.getContainerWidth()
  window.addEventListener('resize', this.getContainerWidth)
},
destory () {
  window.removeEventListener('resize', this.getContainerWidth)
}

也不要忘记在组件销毁时移除监听。

布局要素:块高

子块高的获取时机有两个:获取新增的块的高度和列宽变化时重新获取所有。

data () {
  return {
    //...
    childrenHeights: []
  }
},
resize (index, update) {
  this.$nextTick(() => {
    if (!update) {
      this.childrenHeights.splice(index, 1)
    } else {
      const childrenHeights = this.childrenHeights.slice(0, index)
      for (let i = index; i < this.children.length; i++) {
        childrenHeights.push(this.$children[i].$el.getBoundingClientRect().height)
      }
      this.childrenHeights = childrenHeights
    }
  })
},
watch: {
  colWidth () {
    this.resize(0, true)
  }
}
  • 在删除块时只需要删除对应块 DOM 的尺寸,不需要更新其他块的高度。
  • 新增块或列宽变化时,子块 DOM 未必实际渲染完成,所以需要添加 $nextTick 等待 DOM 的实际渲染,从而可以获得尺寸。
  • 列宽变化时,重新获取所有块的高度。

布局计算

布局思路如下:

  1. 记录每列的高度,取最短的列放入下一个块,并更新此列高度。
  2. 如果最短的列高度为 0,那么取块最少的列为目标列,因为可能块高为 0,块垂直间距为 0,导致一直向第一列添加块。
  3. 在此过程中根据列数和列宽获取每个块的布局位置。
// Waterfall.vue
computed: {
  //...
  layouts () {
    const colHeights = new Array(this.columnCount).fill(0)
    const colItemCounts = new Array(this.columnCount).fill(0)
    const positions = []
    this.childrenHeights.forEach(height => {
      let col, left, top
      const minHeightCol = colHeights.indexOf(min(colHeights))
      const minCountCol = colItemCounts.indexOf(min(colItemCounts))
      if (colHeights[minHeightCol] === 0) {
        col = minCountCol
        top = 0
      } else {
        col = minHeightCol
        top = colHeights[col] + this.gutterHeight
      }
      colHeights[col] = top + height
      colItemCounts[col] += 1
      left = (this.colWidth + this.gutterWidth) * col
      positions.push({ left, top })
    })
    const totalHeight = max(colHeights)
    return {
      positions,
      totalHeight
    }
  },
  positions () {
    return this.layouts.positions || []
  },
  totalHeight () {
    return this.layouts.totalHeight || 0
  }
}

同时需要注意的一点是,在整个布局的高度发生改变的时候,可能会伴随着滚动条的出现和消失,这会引起布局区域宽度变化,所以需要对 totalHeight 增加监听。

watch: {
  totalHeight () {
    this.$nextTick(() => {
      this.getContainerWidth()
    })
  }
}

totalHeight 发生变化时,重新获取容器宽度,这也是为什么 getContainerWidth 方法中使用 clientWidth 值的原因,因为 clientWidth 不包含滚动条的宽度。

同时在 totalHeight 发生改变后要使用 $nextTick 后获取宽度,因为 totalHeight 是我们的计算值,此刻,布局数据变化引发的视图渲染还未发生,在 $nextTick 回调等待视图渲染更新完成,再获取 clientWidth

同时我们也不需要关注 totalHeight(newValue, oldValue)newValueoldValue 是否相等,来而避免后续计算,因为若相等是不会触发 totalHeightwatch 行为的。

同理,也不需要判断 totalHeight 变化前后 clientWidth 是否一致来决定是否要对 containerWidth 重新赋值,从而避免引发后续的列宽、布局计算,因为 Vue.js 内都做了优化,只需重新获取并赋值,避免无用的“优化”代码。

排列

计算完成的位置和列宽需要应用到 WaterfallItem.vue

<template>
  <div class="waterfall-item" :style="itemStyle">
    <slot />
  </div>
</template>

<script>
export default {
  created () {
    this.$parent.add(this)
  },
  computed: {
    itemStyle () {
      const index = this.$parent.$children.indexOf(this)
      const { left, top } = this.$parent.positions[index] || {}
      const width = this.$parent.colWidth
      return {
        transform: `translate3d(${left}px,${top}px,0)`,
        width: `${width}px`
      }
    }
  },
  destoryed () {
    this.$parent.delete(this)
  }
}
</script>

<style>
.waterfall-item {
  box-sizing: border-box;
  border: 1px solid black;
  position: absolute;
  left: 0;
  right: 0;
}
</style>

结果

至此,基础瀑布流逻辑也就结束了,使用现代浏览器点此预览

预览中定时向 Waterfall 中插入高度随机的 WaterfallItem。

完成限定子块高度在初始渲染时就固定的瀑布流后,怎么能做一个无论什么时候子块尺寸变化,都能进行感知并重新布局的瀑布流呢?

万能瀑布流

如何感知尺寸变化

根据这篇文章知,可以利用滚动事件去探知元素的尺寸变化。

简要来说:

scrollTop 为例,在滚动方向为向右和向下,已经滚动到 scrollTop 最大值前提下

  • 当内容(子元素)高度固定且大于容器时

    • 容器高度变大时,已滚动到最下方,容器只能上边界向上扩展,上边界到内容区上边界距离变小,scrollTop 变小触发滚动。
    • 容器高度变小时,容器底边向上缩小,容器上边界到内容区上边界距离不变,scrollTop 不变,不触发滚动。
  • 当内容为 200% 的容器尺寸时

    • 容器高度变大时,内容区 200% 同步变化,容器向下扩展空间充足,所以下边界向下扩展,上边界不动,上边界到内容区上边界距离不变,scrollTop 不变。
    • 当容高度变小时,内容区下边界二倍于容器收缩,容器下边界收缩空间不足,导致上边界相对内容区上移,scrollTop 变小触发滚动。

所以我们可以使用:

  • 内容区尺寸固定且远大于容器尺寸,检测容器的尺寸增大。
  • 内容区尺寸为容器尺寸的 200%,检测容器的尺寸减小。

改动

那么 WaterfallItem.vue 需要调整如下

<template>
  <div class="waterfall-item" :style="itemStyle">
    <div class="waterfall-item__shadow" ref="bigger" @scroll="sizeChange">
      <div class="waterfall-item__holder--bigger">
      </div>
    </div>
    <div class="waterfall-item__shadow" ref="smaller" @scroll="sizeChange">
      <div class="waterfall-item__holder--smaller">
      </div>
    </div>
    <slot />
  </div>
</template>

<script>
  mounted () {
    this.$nextTick(() => {
      this.$refs.bigger.scrollTop = '200000'
      this.$refs.smaller.scrollTop = '200000'
    })
  },
  methods: {
    sizeChange () {
      this.$parent.update(this)
    }
  }
</script>

<style>
  .waterfall-item {
    position: absolute;
    left: 0;
    right: 0;
    overflow: hidden;
    box-sizing: border-box;
    border: 1px solid black ;
  }
  .waterfall-item__shadow {
    height: 100%;
    left: 0;
    overflow: auto;
    position: absolute;
    top: 0;
    transform: translateX(200%);
    width: 100%;
  }
  .waterfall-item__holder--bigger {
    height: 200000px;
  }
  .waterfall-item__holder--smaller {
    height: 200%;
  }
</style>
  • slot 为用户的真实 DOM,其撑开 waterfall-item 的高度。
  • 两个分别检测尺寸增加和减小的 waterfall-item__shadowwaterfall-item 同高,从而使得用户 DOM 的尺寸变化映射到 waterfall-item__shadow 上。
  • 渲染完成后使 waterfall-item__shadow 滚动到极限位置。
  • 用户 DOM 的尺寸变化触发 waterfall-item__shadowscroll 事件,在事件回调中通知 Waterfall.vue 组件更新对应子块高度。
// Waterfall.vue
methods: {
  // ...
  update (child) {
    const index = this.$children.indexOf(child)
    this.childrenHeights.splice(index, 1, this.$children[index].$el.getBoundingClientRect().height)
  }
}

在父组件中只需要更新此元素的高度即可,自会触发后续布局计算。

结果

至此,可动态感知尺寸变化的万能瀑布流也就完成了,使用现代浏览器点此预览

预览中定时修改部分 WaterfallItem 的字体大小,从而触发子块尺寸变化,触发重新布局。

优化

在以上实现之外还可以做一些其他优化,如:

  1. 通知 Waterfall.vue 添加的 add 和更新的 update 方法调用有重复(覆盖)触发的情况,可以合并。
  2. 按需监听尺寸变化,对 WaterfallItem 组件添加新的 props,如:

    • 固定大小的就可以不绑定 scroll 监听,且不渲染 waterfall-item__shadow
    • 只会变化一次的可以对监听使用 once,并在后续更新时不再渲染 waterfall-item__shadow
  3. 在布局计算完成前对 WaterfallItem 添加不可见 visibility: hidden
  4. 在元素过多时,使用虚拟渲染的方式,只渲染在视图范围内的 WaterfallItem。
  5. 对应 keep-alive 的路由渲染,在非激活状态是拿不到容器尺寸的,所以需要在 activateddeactivated 中进行重新布局的停止和激活,避免错误和不必要的开支。
查看原文

赞 28 收藏 16 评论 0

i鱿鱼 赞了文章 · 2020-11-30

GraphQL 落地背后:利弊取舍

此文是作者考虑 GraphQL 在 Node.js 架构中的落地方案后所得。从最初考虑可以(以内置中间件)加入基础服务并提供完整的构建、发布、监控支持,到最终选择不改动基础服务以提供独立包适配,不限制实现技术选型,交由业务团队自由选择的轻量方式落地。中间经历了解除误解,对收益疑惑,对最初定位疑惑,最终完成利弊权衡的过程。

文章会从解除误解,技术选型,利弊权衡的角度,结合智联招聘的开发现状进行交流分享。

文章会以 JavaScript 生态和 JavaScript 客户端调用与服务端开发体验为例。

对入门知识不做详细阐述,可自行查阅学习指南中文(https://graphql.cn/learn/)/英文(https://graphql.org/learn/),规范中文(https://spec.graphql.cn/)/英文(https://github.com/graphql/graphql-spec/tree/master/spec),中文文档有些滞后,但不影响了解 GraphQL。

全貌

GraphQL 是一种 API 规范。不是拿来即用的库或框架。不同对 GraphQL 的实现在客户端的用法几乎没有区别,但在服务端的开发方式则天差地别。

GraphQL 模型

一套运行中的 GraphQL 分为三层:

  • 左侧是客户端和发出的 Document 和其他参数。
  • 中间是主要由 Schema 和 Resolver 组成的 GraphQL 引擎服务。
  • 右侧是 Resolver 对接的数据源。

仅仅有客户端是无法工作的。

初识

GraphQL 的实现能让客户端获取以结构化的方式,从服务端结构化定义的数据中只获取想要的部分的能力。

符合 GraphQL 规范的实现我称之为 GraphQL 引擎。

这里的服务端不仅指网络服务,用 GraphQL 作为中间层数据引擎提供本地数据的获取也是可行的,GraphQL 规范并没有对数据源和获取方式加以限制。

  • 操作模型:GraphQL 规范中对数据的操作做了定义,有三种,query(查询)、mutation(变更)、subscription(订阅)。

客户端

我们把客户端调用时发送的数据称为 Query Document(查询文档),是段结构化的字符串,形如:

# 客户端发送
query {
  contractedAuthor: {
    name
    articles {
      time
      title
    }
  }
  updateTime
}
# 或
mutation {
  # xxxxxx
}

需要注意的是 Query Document 名称中的 Query 和操作模型中的 query 是没有关系的,像上述示例所示,Query Document 也可以包含 mutation 操作。所以为了避免误解,后文将把 Query Document(查询文档)称为 Document 或文档。一个 Document 中可包含单个或多个操作,每个操作都可以查询补丁数量的跟字段。

其中 query 下的 updateTime、contractedAuthor 这种操作下的第一层字段又称之为 root field(根字段)。其他具体规范请自行查阅文档。

Schema

服务端使用名为 GraphQL Schema Language(或 Schema Definition LanguageSDL )的语言定义 Schema 来描述服务端数据。

# 服务端 schema
type Query {
  contractedAuthor: Author
  unContractedAuthor: Author
  updateTime: String
}

type Mutation{
  # xxx
}

type Subscription {
  # xxx
}

type Author {
  name: String
  articles: [Article]
}

type Article {
  time: String
  title: String
  content: String
}

schema {
  query: Query
  mutation: Mutation
  subscription: Subscription
}

可以看到,由于 GraphQL 是语言无关的,所以 SDL 带有自己简单的类型系统。具体与 JavaScript、Go 其他语言的类型如何结合,要看各语言的实现。

从上面的 Schema 中我们可以得到如下的一个数据结构,这就是服务可提供的完整的数据的 Graph(图):

{
  query: {
    contractedAuthor: {
      name: String
      articles: [{
        time: String
        title: String
        content: String
      }]
    }
    unContractedAuthor: {
      name: String
      articles: [{
        time: String
        title: String
        content: String
      }]
    }
    updateTime: String
  }
  mutation: {
    # xxx
  }
  subscription: {
    # xxx
  }
}

在 Schema 定义中存在三种特殊的类型 Query、Mutation、Subscription,也称之为 root types(根类型),与 Document 中的操作模型一一对应的。

结合 Document 和 Schema,可以直观的感受到 Document 和 Schema 结构的一致,且 Document 是 Schema 结构的一部分,那么数据就会按照 Document 的这部分返回,会得到如下的数据:

{
  errors: [],
  data: {
    contractedAuthor: {
      name: 'zpfe',
      articles: [
        {
          time: '2020-04-10',
          title: '深入理解GraphQL'
        },
        {
          time: '2020-04-11',
          title: 'GraphQL深入理解'
        }
      ]
    },
    updateTime: '2020-04-11'
  }
}

预期数据会返回在 data 中,当有错误时,会出现 errors 字段并按照规范规定的格式展示错误。

跑起来的 Schema

现在 Document 和 Schema 结构对应上了,那么数据如何来呢?

  • Selection Sets 选择集:

    query {
      contractedAuthor: {
        name
        articles {
          time
          title
        }
        honour {
          time
          name
        }
      }
      updateTime
    }
    

    如上的查询中存在以下选择集:

    # 顶层
    {
      contractedAuthor
      updateTime
    }
    # 二层
    {
      name
      articles
      honour
    }
    # articles:三层 1
    {
      time
      title
    }
    # honour:三层 2
    {
      time
      name
    }
    
  • Field 字段:类型中的每个属性都是一个字段。

省略一些如校验、合并的细节,数据获取的过程如下:

  • 执行请求:GraphQL 引擎拿到 Document 并解析并处理之后,得到一个新的结构化的 Document(当然原本的 Document 也是结构化的,只不过是个字符串)。
  • 执行操作:引擎会首先分析客户端的目标操作,如是 query 时,则会去 Schema 中找到 Query 类型部分执行,由前文所说 Query、Mutation、Subscription 是特殊的操作类型,所以如 query、mutation、subscription 字段是不会出现在返回结果中的,返回结果中的第一层字段是前文提到的 root field(根字段)。
  • 执行选择集:此时已经明确的知道客户端希望获取的 Selection Sets(选择集)。query 操作下,引擎一般会以广度优先、同层选择集并行执行获取选择集数据,规范没有明确规定。mutation 下,因为涉及到数据修改,规范规定要按照由上到下按顺序、深度优先的方式获取选择集数据。
  • 执行字段:

    • 确定了选择集的执行顺序后开始真正的字段值的获取,非常简化的讲,Schema 中的类型应该对其每个字段提供一个叫做 Resolver 的解析函数用于获取字段的值。那么可执行的 Schema 就形如:

      type Query {
        contractedAuthor () => Author
      }
      type Author {
        name () => String
        articles () => [Article]
      }
      type Article {
        time () => String
        title () => String
        content () => String
      }
      

      其中每个类型方法都是一个 Resolver。

    • 在执行字段 Resolver 之后会得字段的值,如果值的类型为对象,则会继续执行其下层字段的 Resolver,如 contractedAuthor() 后得到值类型为 Author,会继续执行 name ()articles() 以获取 name 和 articles 的值,直到得到类型为标量(String、Int等)的值。
    • 同时虽然规范中没有规定 Resolver 缺少的情况,但引擎实现时,一般会实现一个向父层字段(即字段所在对象)取与自己同名的属性的值的 Resolver。如未提供 Artical 对象 time 字段的 Resolver,则会直接取 artical.time。

至此由 Schema 和 Resolver 组合而成的可执行 Schema 就诞生了,Schema 跑了起来,GraphQl 引擎也就跑了起来。

GrahpQL 服务端开发的核心就是定义 Schema (结构)和实现相应的 Resolver(行为)

其他定义

当然,在使用 GraphQL 的过程中,还可以:

  • 使用 Variables(变量)复用同一段 Document 来动态传参。
  • 使用 Fragments(片段)降低 Document 的复杂度。
  • 使用 Field Alias(字段别名)进行简单的返回结果字段重命名。

这些都没有什么问题。

但是在 Directives(指令)的支持和使用上,规范和实现是有冲突的。

  1. 规范内置指令:规范中只规定了 GraphQL 引擎需要实现 Document 中可用的 @skip(条件跳过)、@include(条件包含),在服务端 Schema 部分可用的 @deprecated(字段已废弃)指令。
  2. 自定义指令支持:在我查到的资料中,Facebook 与 graphql-js(Facebook提供实现)官方有不支持自定义指令的表态1(https://github.com/graphql/graphql-js/issues/446)2(https://github.com/graphql-rust/juniper/issues/156)3(https://github.com/graphql/graphql-js/issues/41)。在 Apollo 实现的 Graphql 生态中则是支持自定义 Schema 端可用的指令,对 Document 端的自定义指令实现暂不支持且不建议支持

而在研究 GraphQL 时发生的的误解在于:

  • 规范、教程提到 query(查询)时,无法确认是指客户端侧客户端发出的 Query Document 整个操作还是,Document 中的 query 操作,亦或是服务端侧定义在 Schema 中的 Query 类型。
  • 或如讲到 Arguments、Variables 等概念,其原则、写法是位于三层的那部分。

实现与选型

GraphQL 的典型实现主要有以下几种:

  • graphql-js:由 Facebook 官方提供的实现。几乎是
  • Apollo GraphQL: Apollo 提供的实现和 GraphQL 生态,内容丰富,不止一套引擎,还提供了纯客户端使用(不局限JavaScript)多种工具。
  • type-graphql:强依赖 TypeScript 开发的实现,主要是输出可执行 Schema。

graphql-js 可以说是其他实现的基础。

可执行 Schema 的创建方式是这几种实现最大的不同,下面将就这部分进行展示。

graphql-js

npm install --save graphql
  • 创建可执行 Schema

    import {
      graphql,
      GraphQLList,
      GraphQLSchema,
      GraphQLObjectType,
      GraphQLString,
    } from 'graphql'
    
    const article = new GraphQLObjectType({
      fields: {
        time: {
          type: GraphQLString,
          description: '写作时间',
          resolve (parentValue) {
            return parent.date
          }
        },
        title: {
          type: GraphQLString,
          description: '文章标题',
        }
      }
    })
    
    const author = new GraphQLObjectType({
      fields: {
        name: {
          type: GraphQLString,
          description: '作者姓名',
        },
        articles: {
          type: GraphQLList(article),
          description: '文章列表',
          resolve(parentValue, args, ctx, info) {
            // return ajax.get('xxxx', { query: args })
          },
        }
      },
    })
    
    const schema = new GraphQLSchema({
      query: new GraphQLObjectType({
        name: 'RootQuery',
        fields: {
          contractedAuthor: {
            type: author,
            description: '签约作者',
            resolve(parentValue, args, ctx, info) {
              // return ajax.get('xxxx', { query: args })
            },
          },
        },
      }),
    })
    

    能明确的看到,graphql-js 实现通过 GraphQLSchema 创建出的 schema 中,field 和 resolver 和他们一一对应的关系,同时此 schema 就是可执行 Schema。

  • 执行

    import { parse, execute, graphql } from 'graphql'
    import { schema } from '上面的schema'
    
    // 实际请求中,document 由 request.body 获取
    const document = `
    query {
      contractedAuthor {
        name
        articles {
          title
        }
      }
    }`
    // 或使用导入的 graphql 方法执行
    const response = await execute({
      schema,
      document: parse(document),
      // 其他变量参数等
    })
    

    传入可执行 schema 和解析后的 Document 即可得到预期数据。

Apollo

Apollo 提供了完整的 GraphQL Node.js 服务框架,但是为了更直观的感受可执行 Schema 的创建过程,使用 Apollo 提供的 graphql-tools 进行可执行 Schema 创建。

npm install graphql-tools graphql

上面是 Apollo 给出的依赖安装命令,可以看到 graphql-tools 需要 graphql-js(graphql)作为依赖 。

  • 创建可执行 Schema

    import { makeExecutableSchema } from 'graphql-tools'
    
    const typeDefs = `
    type Article {
      time: String
      title: String
    }
    
    type Author {
      name: String
      articles: [Article]
    }
    
    type Query {
      contractedAuthor: Author
    }
    
    schema {
      query: Query
    }
    `
    const resolvers = {
      Query: {
        contractedAuthor (parentValue, args, ctx, info) {
          // return ajax.get('xxxx', { query: args })
        }
      },
      Author: {
        articles (parentValue, args, ctx, info) {
          // return ajax.get('xxxx', { query: args })
        }
      },
      Article: {
        time (article) {
          return article.date
        }
      }
    }
    const executableSchema = makeExecutableSchema({
      typeDefs,
      resolvers,
    })
    

    resolvers 部分以类型为维度,以对象方法的形式提供了 Resolver。在生成可执行 Schema 时,会将 Schema 和 Resolver 通过类型映射起来,有一定的理解成本。

type-graphql

这部分涉及 TypeScript,只做不完整的简要展示,详情自行查阅文档。

npm i graphql @types/graphql type-graphql reflect-metadata

可以看到 type-graphql 同样需要 graphql-js(graphql)作为依赖 。

  • 创建可执行 Schema

    import 'reflect-metadata'
    import { buildSchemaSync } from 'type-graphql'
    
    @ObjectType({ description: "Object representing cooking recipe" })
    class Recipe {
      @Field()
      title: string
    }
    
    @Resolver(of => Recipe)
    class RecipeResolver {
    
      @Query(returns => Recipe, { nullable: true })
      async recipe(@Arg("title") title: string): Promise<Recipe> {
        // return await this.items.find(recipe => recipe.title === title);
      }
    
      @Query(returns => [Recipe], { description: "Get all the recipes from around the world " })
      async recipes(): Promise<Recipe[]> {
        // return await this.items;
      }
    
      @FieldResolver()
      title(): string {
        return '标题'
      }
    }
    const schema = buildSchemaSync({
      resolvers: [RecipeResolver]
    })
    

    type-graphql 的核心是类,使用装饰器注解的方式复用类生成 Schema 结构,并由 reflect-metadata 将注解信息提取出来。如由 @ObjectType()@Field 将类 Recipe 映射为含有 title 字段的 schema Recipe 类型。由 @Query 注解将 reciperecipes 方法映射为 schema query 下的根字段。由 @Resolver(of => Recipe)@FieldResolver()title() 方法映射为类型 Recipe 的 title 字段的 Resolver。

关联与差异

同:在介绍 Apollo 和 type-graphql 时,跳过了执行部分的展示,是因为这两种实现生成的可执行 Schema 和 graphql-js 的是通用的,查看这两者最终生成的可执行 Schema 可以发现其类型定义都是使用的由 graphql-js 提供的 GraphQLObjectType 等, 可以选择使用 graphql-js 提供的执行函数(graphql、execute 函数),或 apollo-server 提供的服务执行。

异:

  • 结构:直接可见的是结构上的差异,graphql-js 作为官方实现提供了结构(Schema)和行为(Resolver)不分离的创建方式,没有直接使用 SDL 定义 Schema,好处是理解成本低,上手快;apollo 实现则使用结构和行为分离的方式定义,且使用了 SDL,结构和行为使用类名形成对应关系,有一定的理解成本,好处是 Schema 结构更直观,且使用 SDL 定义 Schema 更快。
  • 功能:

    • graphql-js:graphql-js 是绕不过的基础。提供了生成可执行 Schema 的函数和执行 Schema 生成返回值的函数(graphql、execute 函数),使用执行方法可快速将现有 API 接口快速改造为 GraphQL 接口。适合高度定制 GraphQL 服务或快速改造。
    • apollo:提供了开箱即用的完整的 Node.js 服务;提供了拼接 Schema(本地、远端)的方法,使 GraphQL 服务拆分成为可能;提供了客户端可用的数据获取管理工具。当遇到问题在 apollo 生态中找一找一般都会有收获。
    • type-grahpql:当使用 TypeScript 开发 GraphQL 时,一般要基于 TypeScript 对数据定义模型,也要在 Schema 中定义数据模型,此时 type-graphql 的类型复用的方式就比较适合。同时 type-grahpql 只纯粹的负责生成可执行 Schema,与其他服务实现不冲突,但是这个实现的稳定性还有待观察。

利弊

对 GraphQL 的直观印象就是按需、无冗余,这是显而易见的好处,那么在实际应用中真的这么直观美好么?

  • 声明式的获取数据:结构化的 Document 使得得到数据后,对数据的操作提供了一定便利(如果能打通服务端和客户端的类型公用,使得客户端在开发时提供代码智能提示更好)。
  • 调用合并:经常提到的与 RESTful 相比较优的一点是,当需要获取多个关联数据时,RESTful 接口往往需要多次调用(并发或串行),而基于 GraphQL 的接口调用则可以将调用顺序体现在结构化的查询中,一次获取全部数据,减少了接口往返顺序。但同时也有一些注意事项,要真正减少调用次数,要在前端应用中集中定义好应用全局的数据结构,统一获取,如果仍然让业务组件就近获取(只让业务组件这种真正的使用方知晓数据结构),这个优势并不存在。
  • 无冗余:按需返回数据,在网络性能上确实有一定优化。
  • 文档化:GraphQL 的内省功能可以根据 Schema 生成实时更新的 API 文档,且没有维护成本,对于调用方直观且准确。
  • 数据 Mock:服务端 Schema 中包含数据结构和类型,所以在此基础上实现一个 Mock 服务并不困难,apollo-server 就有实现,可以加快前端开发介入。
  • 强类型(字段校验):由于 JS 语言特性,强类型只能称为字段强类型校验(包括入参类型和返回结果),当数据源返回了比 Schema 多或少的字段时,并不会引发错误,而就算采用了 TypeScript 由于没有运行时校验,也会有同样的问题。但是字段类型校验也会有一定的帮助。
  • 调试:由于我们调用 GraphQL 接口时(如:xxx/graphql/im)无法像 RESTful 接口那样(如:xxx/graphql/im/messagexxx/graphql/im/user)从 URL 直接分辨出业务类型,会给故障排查带来一些不便。

上面提到的点几乎都是出于调用方的视角,可以看到,作为 GraphQL 服务的调用方是比较舒服的。

由于智联招聘前端架构Ada中包含基于 Node.js 的 BFF(Backends For Frontends 面向前端的后端)层,前端开发者有能力针对具体功能点开发一对一的接口,有且已经进行了数据聚合、处理、缓存工作,也在 BFF 层进行过数据模型定义的尝试,同时已经有团队在现有 BFF 中接入了 GraphQL 能力并稳定运行了一段时间。所以也会从 GraphQL 的开发者和两者间的角度谈谈成本和收益。

  • BFF:GraphQL 可以完成数据聚合、字段转换这种符合 BFF 特征的功能,提供了一种 BFF 的实现选择。
  • 版本控制:客户端结构化的查询方式可以让服务追踪到字段的使用情况。且在增加字段时,根据结构化查询按需查询的特点,不会影响旧的调用(虽然 JavaScript 对多了个字段的事情不在意)。对于服务的迭代维护有一定便利。
  • 开发成本:毫无疑问 Resolver(业务行为)的开发在哪种服务模式下都不可缺少,而 Schema 的定义一定是额外的开发成本,且主观感受是 Schema 的开发过程还是比较耗费精力的,数据结构复杂的情况下更为如此。同时考虑到开发人员的能力差异,GraphQL 的使用也会是团队长期的人员成本。像我们在 BFF 层已经有了完全针对功能点一对一的接口的情况下,接口一旦开发完成,后续迭代要么彻底重写、要么不再改动,这种情况下是用不到 GraphQL 的版本控制优势,将每个接口都实现为 GraphQL 接口,收益不高。
  • 迁移改造:提供 GraphQL 接口有多种方式,可以完全重写也可以定义 Schema 后在 Resolver 中调用现有接口,仅仅把 GraphQL 当作网关层。
  • 调用合并:GraphQL 的理念就是将多个查询合并,对应服务端,通常只会提供一个合并后的“大”的接口,那么原本以 URL 为粒度的性能监控、请求追踪就会有问题,可能需要改为以 root field(根字段)为粒度。这也是需要额外考虑的。
  • 文档化:在智联招聘所推行的开发模式中,通常 BFF 接口和前端业务是同一个人进行开发,对接口数据格式是熟知的,且接口调用方唯一、无复用,GraphQL 的文档化这一特性带来的收益也有限。
  • 规范:由于 GraphQL Schema 的存在,使得数据模型的定义成为了必要项。在使用 JavaScript 开发接口服务时,相对其他各种数据模型定义的尝试,提供了定义数据模型的统一实践和强规范,也算是收益之一。同时 Resolver 的存在强化了只在前端做 UI、交互而在 BFF 层处理逻辑的概念。

总结

综合来看,可用的 GraphQL 服务(不考虑拿 GraphQL 做本地数据管理的情况)的重心在服务提供方。作为 GraphQL 的调用方是很爽的,且几乎没有弊端。那么要不要上马 GraphQL 就要重点衡量服务端的成本收益了。就我的体会而言,有以下几种情况:

  1. 服务本身提供的就是针对具体功能的接口,接口只有单一的调用方,不存在想要获取的数据结构不固定的情况,或者说是一次性接口,发布完成后不用再迭代的,那么没必要使用 GraphQL。
  2. 服务本身是基础服务,供多方调用,需求不一但对外有统一的输出模型的情况下(如:Github 开放接口,无法确定每个调用者需求是什么),可以使用 GraphQL。
  3. 在 Node.js(JavaScript)中,由于面向对象、类型的支持程度问题,开发者编程思维问题,实现成本比 Java 等其他语言更高,要谨慎考虑成本。
  4. 没有 BFF 层时,由于 GraphQL 对于实现数据聚合、字段转换提供了范式,可以考虑使用 GraphQL 服务作为 BFF 层,或者结合1、2点,将部分接口实现为 GraphQL,作为 BFF 层的一部分,其他接口还可以采取 RESTful 风格或其他风格,并不冲突。
  5. 当前端开发本身就要基于 Node.js 进行 BFF 层开发,团队对规范、文档有更高优先级的需求时,可以考虑使用 GraphQL 进行开发。
查看原文

赞 30 收藏 17 评论 8

i鱿鱼 赞了文章 · 2020-11-30

如何为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

i鱿鱼 关注了专栏 · 2020-11-30

智联大前端

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

关注 78

i鱿鱼 赞了文章 · 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

认证与成就

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

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-03-02
个人主页被 187 人浏览