智联大前端

智联大前端 查看完整档案

北京编辑  |  填写毕业院校智联招聘  |  zhaopin.com 编辑 www.zhaopin.com 编辑
编辑

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

个人动态

智联大前端 发布了文章 · 2月22日

Koa中间件体系的重构经验

智联招聘的大前端Ada提供的Web服务器可以同时运行在服务器端及本机开发环境,其内核是Web框架Koa。Koa以其对异步编程的良好支持而声名在外,而同样让人称道的还有它的中间件机制。本质上,Koa其实是一个中间件运行时,几乎所有实际功能都是通过中间件的形式注册和实现的。

现状

Ada从1.0.0版本开始引入了独立的@zpfe/koa-middleware模块,用于维护Web服务中所需的中间件。该模块单独导出了所有的中间件,Web服务可以按需自行注册(use)。随着功能不断完善,该模块中逐渐累积了十数个中间件。@zpfe/koa-middleware模块的使用方式大概如下所示:

const app = new Koa()
app.use(middleware1)
app.use(middleware2)
// ...
app.use(middlewareN)

中间件之间隐式约定了执行顺序,但却把执行顺序的控制交给了两个使用方(渲染服务和API服务),这就意味着使用方必须知道每个中间件的技术细节,此为“坏味道”之一。

下图展示了使用方与中间件的耦合情况:

image.png

Koa中间件体系是一个洋葱形结构,每一个中间件都可以看做洋葱的一层皮。最先注册的位于最外层,最后注册的位于最内层。在执行时,会从最外层依次执行到最内层,再倒序依次执行回最外层。下图展示了Koa中间件的执行方式:

image.png

每个中间件都有两次可被执行的机会,而在我们的场景中,大多数中间件实际上只有一段逻辑。随着中间件的数量膨胀,完整的执行轨迹变得过于复杂,增加了调试和理解的成本,此为“坏味道”之二。

基于以上原因,我们决定对@zpfe/koa-middleware模块进行重构,进一步提高其易用性、内聚性和可维护性。

分析

首先逐个分析一下@zpfe/koa-middleware所导出的功能和使用情况,会发现如下模式:

  • 中间件的注册顺序在两个使用方中是一致的;
  • 有一些中间件仅在API服务中注册使用(比如CORS和CSRF);
  • 有一些中间件在两个使用方中所采用的参数或实现是不一样的(比如解析器和入口处理器);
  • 有一些功能实际上并不是中间件(比如请求上下文和熔断器)。

这意味着我们可以收回中间件的注册权,并允许使用方通过参数来控制个别中间件的开启关闭状态、参数、甚至实现。还可以将非中间件功能直接抽离为新的模块。

接下来观察这些中间件的执行顺序,会发现它们可以归属于几种不同的类型:

  • 初始化器:负责初始化数据或功能(比如初始化x-zp-request-id和日志功能);
  • 阻断器:负责中断执行过程(比如CORS和CSRF);
  • 预处理器:负责准备处理请求所需的环境(比如解析器);
  • 处理器:负责处理请求(比如诊断器和入口处理器);
  • 后处理器:负责在请求处理完成之后的整理工作(比如清理临时文件和数据)。

进一步分析每一个分类所包含的中间件,会发现它们的执行方式在分类内部也是高度一致的。除了预处理器和处理器需要异步执行之外,其余几种类型所包含的中间件全都可以按照同步的方式执行。

上文提到Koa中间件会有两次被执行的机会,@zpfe/koa-middleware也确实包含一些这样的中间件(比如日志功能)。刚才在归类中间件时,这样的中间件被拆成了两部分,归属到了不同的分类中。比如,日志功能会被拆分到初始化器(初始化日志功能)和后处理器(记录请求结束的信息)。对于这样的功能,我们可以换一种思路,将它看成一个完整的功能集,但对外输出了两个不同类型的具体功能。如此,我们就可以在同一个文件中编写日志功能的所有代码,并将其初始化功能和后处理功能定义为不同的函数来导出。

原则

经过分析,我们已经对@zpfe/koa-middleware模块的现状有了清晰的认知。现在来总结一下,形成一些有用的指导原则:

  • 单一职责原则(SRP):抽离非中间件功能;
  • 依赖倒置原则(DIP):不对使用方暴露中间件的功能细节;
  • 自清理:请求处理完毕后,中间件必须清理自己产生的数据;
  • 易于测试:可单独测试每个组成元件;
  • 渐进式重构:分阶段进行重构,每一阶段都不破坏现有功能,具备单独发布的能力。

阶段

第一步:抽离非中间件功能

这一步骤比较简单,只需要将这些非中间件功能的文件提取到独立的模块中即可。需要注意的是:

  • 独立模块要符合高内聚低耦合的标准;
  • 单元测试也应一并提取到独立模块中,并适当修改以满足测试标准;
  • 所有使用方逐个切换到独立模块,并适当修改其单元测试;
  • 控制重构范围,将改动限制在非中间件及其使用方的范围之内。

抽离非中间件功能之后,@zpfe/koa-middleware模块现在已经是一个名副其实的中间件模块了。

下图展示了抽离非中间件功能之后的代码结构:

image.png

第二步:封装注册函数

接下来封装一个注册函数,并作为对外的唯一导出项,藉此来简化使用方的代码,并对其隐藏中间件细节。

根据之前的分析,这个注册函数需要通过参数来允许使用方对部分中间件进行配置。下面展示了新的注册函数的主要逻辑:

function registerTo(koaApp, options) {
  koaApp.use(middleware1)
  koaApp.use(middleware2)
 
  if (options.config3) koaApp.use(middleware3)
  if (options.config4) koaApp.use(middleware4(options.config4))
  // ...
  koaApp.use(middlewareN)
}
module.exports = {
  registerTo
}

options参数不仅可以用来控制特定中间件的启用状态,还可以向中间件提供配置。使用方可以这样来使用新的注册函数:

const middleware = require('@zpfe/koa-middleware')
 
const app = new Koa()
middleware.registerTo(app, {
  config3: true,
  config4: function () { /* ... */ }
})

现在中间件的注册顺序已经封装在@zpfe/koa-middleware模块的内部了,使用方只需要了解注册函数的使用方法即可,假设以后想要增加一个中间件,也不会对使用方造成大的影响。

下图展示了封装注册函数之后的代码结构:

image.png

值得注意的是这一步骤中的改动只涉及到@zpfe/koa-middleware模块的主文件和使用方,并没有对任何中间件进行修改,遵循了渐进式重构的原则。 补充和更新单元测试后,就可以进行到下一步了。

第三步:重构初始化器

根据之前的分析,中间件分属几种类型,初始化器是其中的第一种。初始化器所包含的中间件应该由它自己来注册和管理,下面展示了初始化器的主要逻辑:

function register(koaApp, options) {
  koaApp.use(middleware1)
  // ...
  koaApp.use(middlewareN)
}
 
module.exports = register

看起来就是@zpfe/koa-middleware模块主文件的翻版,接下来修改@zpfe/koa-middleware模块主文件,将逐个注册初始化器中间件的代码替换为使用初始化器来统一注册:

const initiators = require('./initiators')
 
function registerTo(koaApp, options) {
  initiators(koaApp, { configN: options.configN })
 
  if (options.config3) koaApp.use(middleware3)
  if (options.config4) koaApp.use(middleware4(options.config4))
  // ...
  koaApp.use(middlewareN)
}

现在开始,@zpfe/koa-middleware模块的主文件只与初始化器进行交互,不再与后者所包含的多个中间件进行交互。也就是说,我们已经对外隐藏了初始化器中间件的逻辑细节。接下来要进一步重构这些逻辑时,也就不会超出初始化器的范围了。

初始化器所包含的中间件均以同步的方式执行,可以将它们化简为函数,组织成一个函数队列,按顺序执行。下面展示了修改后的初始化器:

const task1 = require('./tasks1')
const taskN = require('./tasksn')
 
function register(koaApp, options) {
  const tasks = []
  if (options.config1) tasks.push(task1)
  // ...
  if (options.configN) tasks.push(taskN)
 
  async function initiate (ctx, next) {
    tasks.forEach(task => task(ctx))
    return next()
  }
 
  koaApp.use(initiate)
}

所有初始化器类型的中间件都被化简成了同步函数,并根据注册时传入的参数创建了一个任务列表,接着将自身注册为一个按顺序执行任务列表的中间件。

补充和更新单元测试后,初始化器的重构工作就宣告完成了。在这一步骤中,我们将多个中间件合而为一,并将其逻辑封装在其内部,这会让@zpfe/koa-middleware模块的代码更加结构化,也更容易维护。

下图展示了重构初始化器之后的代码结构:

image.png

回顾一下本步骤中的所有重构操作,我们会发现并没有涉及到使用方,这就是在第二步重构过程中对外隐藏内部逻辑所带来的好处。 同样地,我们也没有对非初始化器的中间件进行任何改动,这些中间件不在本步骤的重构范围之内,我们会在后续的步骤中进行重构。

第四步:依序重构其余中间件类型

初始化器重构完成之后,就可以按照同样的思路去依次重构其余几种中间件类型:阻断器、预处理器、处理器和后处理器。

这些重构工作完成之后的代码结构如下图所示:

image.png

需要注意的依然是要控制重构范围,完成一种类型的重构(包含单元测试)之后,再开始下一个类型。

第五步:整体检查

现在重构工作已经接近尾声。对使用方而言,@zpfe/koa-middleware模块只公开了一个函数,极大地提高了易用性;对@zpfe/koa-middleware模块自身而言,其内部结构更加合理、执行顺序更容易预测、也更容易进行单元测试。

在宣告重构完成之前,我们还需要对@zpfe/koa-middleware模块进行一次整体检查,寻找遗漏的“坏味道”,以及在渐进式重构过程当中逐渐累积出来的“坏味道”。

现在的@zpfe/koa-middleware模块包含五个中间件,每个中间件的注册函数能通过参数来控制自己的内部功能。@zpfe/koa-middleware模块的主文件负责将使用方传入的参数整理成每个中间件所期望的参数格式,如下所示:

function registerTo(koaApp, options) {
  initiators(koaApp, { configN: options.configN })
  blockers(koaApp, { configO: options.configO })
  preProcessors(koaApp, { configP: options.configP })
  processors(koaApp, { configQ: options.configQ })
  postProcessors(koaApp, { configR: options.configR })
}

既然每个中间件都需要从注册函数的options参数中获取自己所需要的数据,那么完全可以将options参数的结构按照中间件进行分类,分类之后的注册函数看上去会更加简明:

function registerTo(koaApp, options) {
  initiators(koaApp, options.initiators)
  blockers(koaApp, options.blockers)
  preProcessors(koaApp, options.preProcessors)
  processors(koaApp, options.processors)
  postProcessors(koaApp, options.postProcessors)
} 

在之前的分析中,我们已经知道初始化器会产生一些数据,并且希望这些数据能由它们自己来清理,这就意味着在后处理器有对应的任务来清理数据。将同一个功能的初始化和清理逻辑拆分到两个文件中,也是一种“坏味道”。

处理这种情况的方法很简单,首先找出所有具备这样特征的功能,为它们创建独立的代码文件。然后将其初始化逻辑和清理逻辑移动到该文件中,并分别导出。 如此一来,每个功能都会变得更加内聚。

重构完成之后的代码结构如下图所示:

image.png

总结

回顾一下整个重构过程,会发现我们做的第一件事情并不是编码,而是对现状进行深入的剖析。在这个过程中,求同存异,一些模式会自然而然地呈现出来,它们都是重构的“素材”。

在真正进行编码时,我们采取了渐进式的策略,将整个过程分解成多个步骤。争取做到每一个步骤完成后,整个模块都能达到发布标准。这就意味着需要把每一步所涉及的改动都限定到一个可控的范围内,并且每个步骤都需要包含完整的测试。

以上,就是重构与重写的区别。

注:本文最初于2018年8月8日发表于智联大前端内部Wiki。
查看原文

赞 0 收藏 0 评论 0

智联大前端 关注了用户 · 2月18日

zdzen @zdzen

var newJsCoder =zd;

关注 1

智联大前端 发布了文章 · 1月27日

使用 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

智联大前端 发布了文章 · 1月12日

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

智联大前端 关注了用户 · 1月7日

i鱿鱼 @iyu_5a9925b6226f4

关注 1

智联大前端 发布了文章 · 1月6日

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

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

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

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

快速部署 Sentry 服务

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

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

准备工作

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

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

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

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

docker 镜像加速

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

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

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

sudo systemctl daemon-reload
sudo systemctl restart docker

一键部署

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

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

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

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

welcome

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

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

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

docker 数据存储位置修改

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

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

服务定制

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

服务组成与运行机制

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

docker ps

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

  • nginx:1.16

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

    • sentry_onpremise_worker_1

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

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

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

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

    • sentry_onpremise_sentry-cleanup_1

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

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

    • sentry_onpremise_relay_1

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

    • sentry_onpremise_symbolicator-cleanup_1

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

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

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

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

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

      • 消费 Kafka 给 ClickHouse sessions
    • sentry_onpremise_snuba-replacer_1

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

    • sentry_onpremise_smtp_1

      • 邮件服务
  • memcached:1.5-alpine

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

    • sentry_onpremise_symbolicator_1

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

    • sentry_onpremise_postgres_1

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

    • sentry_onpremise_kafka_1

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

    • sentry_onpremise_redis_1

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

    • sentry_onpremise_zookeeper_1

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

    • sentry_onpremise_ClickHouse_1

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

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

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

文件结构与作用

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

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

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

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

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

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

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

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

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

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

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

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

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

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

控制磁盘占用

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

SENTRY_EVENT_RETENTION_DAYS=7

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

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

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

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

单点登录 CAS 登录接入

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

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

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

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

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

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

cas

修改默认时区

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

时区设置

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

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

获取真实 IP

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

角色权限修改

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

在角色层面又具有:

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

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

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

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

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

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

其他配置修改

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

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

前端接入和使用

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

SDK 接入

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

创建1

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

创建2

npm i @sentry/browser @sentry/integrations

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

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

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

dsn

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

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

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

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

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

如何使用监控数据

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

issues

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

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

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

issue

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

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

招聘

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

查看原文

赞 18 收藏 9 评论 2

智联大前端 发布了文章 · 2020-11-30

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

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

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

可演进的工程化机制

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

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

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

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

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

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

image

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

支持多框架

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

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

image

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

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

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

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

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

服务器端研发能力

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

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

image

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

Serverless架构

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

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

image

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

image

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

工作台

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

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

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

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

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

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

image

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

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

移动端研发能力

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

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

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

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

image

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

能力扩充

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

image

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

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

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

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

质量保障

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

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

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

image

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

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

后记

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

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

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

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

招聘

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

查看原文

赞 14 收藏 4 评论 7

智联大前端 关注了用户 · 2020-09-04

cnryb @cnryb

关注 1

智联大前端 关注了用户 · 2020-08-31

俊不朗先生 @junbulangxiansheng

关注 1

智联大前端 发布了文章 · 2020-08-31

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

背景

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

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

Electron现有的相关类库:

打包工具:

更新服务器:

现状

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

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

目前版本升级时的效果:

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

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

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

调研

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

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

实施

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

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

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

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

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

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

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

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

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

踩坑

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

  • autoUpdater.setFeedURL出错。

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

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

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

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

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

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

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

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

总结

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

招聘

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

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

image

查看原文

赞 12 收藏 5 评论 8

智联大前端 关注了用户 · 2020-04-14

toBeTheLight @tobethelight

熟练掌握 TS(0.5/1)
学习经济学(1/1)
学习数学知识(0/n)
学习编译原理(0/1)
文笔更好一些(1/1)

关注 26533

智联大前端 关注了用户 · 2020-04-14

windiechai @windiechai

程序员,前端架构师,智联招聘大前端负责人。
技术书籍 x 8。
微软MVP x 8。
Windows (Mobile) App x 10。

关注 3

智联大前端 发布了文章 · 2020-04-14

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

智联大前端 关注了用户 · 2020-04-06

刘小夕 @liuyan666

本人微信公众号: 前端宇宙

写文不易,Star支持一下?

【github】https://github.com/YvetteLau/...

关注 1872

智联大前端 发布了文章 · 2019-12-11

万能瀑布流

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

而对于子块有图片这种可能引起尺寸变化的情况,通常的做法是写死图片高度,或检测内部的 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

智联大前端 发布了文章 · 2019-08-05

读《图解设计模式》的所思所想

困惑

作者试图从另一个角度阐述设计模式,所以对 23 种具体设计模式进行了重新分类,但整本书读下来比较困惑,在于几点:

  1. 分类标准不统一,有实现思路、实现内容、模式目的等标准,甚至还有“适应设计模式”这种分类,颇有些无从分类的“自暴自弃”的味道。同时在这种分类方式下,还存在一个问题,即某设计模式的实现是会用到另一个设计模式的,甚至其些设计模式的书中实现类图会基本相同,但是却属于不同分类,带来了新的困惑,好像要强迫你在学习一个设计模式时,要忘掉其他设计模式的存在。
  2. 缺乏对具体设计模式适用场景的充分阐述,知何却不知为何。
  3. 作为入门书,未对更低层的原则进行科普,即使知道了各具体模式可以达成哪些具体目的,却无法融汇到统一的思想出口。
  4. ?

但是总觉得还有一个抓不到的原因,那么再深入探究一下,到底是什么令我产生困惑呢?这就需要了解设计模式的起源。

根源

设计模式(design pattern)是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。它描述了在软件设计过程中的一些不断重复发生的问题,以及该问题的解决方案。

设计模式是问题的方案。

设计模式是经验的总结。

首先,正确的学习方式应该是带着问题找答案。如果答案被直接摆在面前却不知问题,不论是谁都会产生“这是啥?!”的困惑。但更重要的是,经验是具有普适性的,具体的设计模式其实体现的是具体的思想方式,这种思想方式与语言无关,同时在单一语言中也一定有多种实现形式,那么此时就进入到了抽象和具象的冲突。若无顿悟的天分,接收到思想的抽象概念描述时,会有一种脑子懂了,却无从下手的感觉,同时若以书中有限的应用示例来描述,又无法完全体会到思想的方方面面。

所以设计模式的学习应该是快速的阅读书籍,在对模式有轮廓性认识后,带着问题,不断实践练习的一个过程,要在实践中得出自己的体会,将从书中得到的融到自己的骨子里。这也是造成前面讲的困惑的根本原因了,实践不够呀。

这其中还有另一个教训,我曾经陷入了为什么能用这种设计模式而不能用另一种设计模式的思维旋涡,一样,只靠想,不依托实践,这些问题是解决不了的。所以不要把时间浪费在纠结的思考中。

也是因此,后续内容不会是面面俱到的长篇累牍,只会对设计模式的脉络做基于目的的简要阐述。

目的与手段

维护一个软件的长期良性发展是究极目标,即提高可维护性,降低维护成本。可以从抽象等级分为 4 个层次。

  1. 目标:维护性。
  2. 标准:扩展性、重用性、高内聚、低耦合。
  3. 原则:7 大基本原则。
  4. 模式:23 + N 种设计模式。

应该通过提升扩展性、重用性等达到高内聚、低耦合的特性。

在这个过程中应遵循 7 大原则,同时这些原则又是设计模式的基础,是设计模式为何如此设计的依据。

而模式则是更具体的思想范式,设计模式不仅仅局限于 23 种,跟随技术水平的发展,也伴生出了新的问题,也就总结出了针对新问题的 N 种模式。

7 大基本原则

设计模式往往是基于类,接口来讲的,而 JS 并非基于类的语言,支持度不够,同时我们又不应该将模式的思想拘泥于类中,所以可以将下述原则的应用个体,如类、接口,放到函数或模块等其他维度上体会。

  • 单一职责原则:

    • 单一职责原则规定一个类应该有且仅有一个引起它变化的原因,否则类应该被拆分。
    • 我们不必要拘泥于类,该原则的根本目的是控制职责所在的个体复杂度。只需要明白单一个体只需要做好一件事,个体越简单则可读性越好,职责划分越明确,则改动发生时,越不会影响其他个体。
    • 比如这种职责拆分可以发生在函数粒度,也可以发生在函数的聚合层面(类或者更外层的函数),职责和个体理想状态下应该是一对一的。
    • 这个原则要求我们能清晰的认识到代码逻辑中的多重职责,从而才能进行划分。
  • 接口隔离原则:

    • 客户端不应该被迫依赖于它不使用的方法。一个类对另一个类的依赖应该建立在最小的接口上。
    • 即对于依赖者,被依赖者应该只提供他关心的功能。当体现在接口上时,就是接口隔离原则,将有冗余的接口拆分。
    • 可以避免由于依赖者的增多导致接口膨胀,影响到其他的依赖者。
    • 相对于单一职责原则可以理解为单一职责原则是对内做最少承诺,而接口隔离原则是对外做最少的承诺。
  • 依赖倒置原则:

    • 高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
    • 即面向接口编程。我们只需要对低层进行接口定义,高层只需要关注有哪些接口并进行调用,低层实现时只要实现了这些接口,那么传给高层的实例发生变化时,高层就不需要修改。降低了耦合度。
  • 开闭原则:

    • 软件实体应当对扩展开放,对修改关闭。
    • 在软件修改时,尽量通过扩展而实现而不是通过修改来实现,避免对现有逻辑的影响。
  • 合成复用原则:

    • 在复用时,要尽量先使用组合(实例化是就存在)或者聚合(通过 API 调用添加为成员变量)等关联关系来实现,其次才考虑使用继承关系来实现。
    • 继承强耦合,组合聚合是弱耦合。
  • 里氏替换原则:

    • 继承必须确保超类所拥有的性质在子类中仍然成立。即子类可以扩展父类的功能,但不能改变父类原有的功能。
  • 迪米特法则:

    • 只与你的直接朋友交谈,不跟“陌生人”说话,又叫最少知识原则。即一个类对自己依赖的类知道的越少越好。
    • 耦合是无法完全避免的。
    • 被依赖的类不论多复杂,都应该将细节封装在内部,对外暴露 API。
    • 应该避免类中出现非直接的朋友关系(直接朋友关系:成员变量、参数、返回值)的依赖。

可以看出这些原则都是为了个体间的低耦合而努力。

模式的一句话描述

  1. 迭代器模式:为了在不暴露数据的内部结构的前提下对外提供可替换的迭代方式。此模式隐藏内部细节,且可替换迭代方式。这种思路可推广至迭代以外的其他能力。
  2. 适配器模式:为了使不兼容的接口协同工作,将现有接口包装为需要的接口。在处理代码边界即第三方依赖时也可使用,能在第三方依赖被换掉时降低改动成本。
  3. 模板方法模式:在流程结构确定,而步骤的具体实现不定或有差异时使用。即定义好模板,但将具体处理的实现交给子类,扩展新的能力时只需实现新的子类。
  4. 工厂方法模式:创建接口不变的情况下,由用户决定什么哪个实例时,使用。产品和工厂一一对应,扩展新的产品时需要增加新的产品类和对应的工厂类。
  5. 单例模式:单一类只允许生成一个实例时使用。
  6. 原型模式:避免较高的实例化成本时使用。通过复制生成实例,核心在于复制现有实例,避免重走实例化的过程。
  7. 建造者模式:最终产出物的组成部分相同,但需要组装过程可替换时使用。通过组装生成复杂实例,并将组装过程抽离至独立的类,核心在于侧重组装,那么实现不同的组装过程类就能产出不同的产出物。
  8. 抽象工厂模式:与工厂方法模式类似,当工厂类产出多个产品时可以使用抽象工厂模式。注意区别是是工厂产出一个还是多个产品。
  9. 桥接模式:在对外提供的功能接口内有多个维度的变化时使用,将类的对外接口和实现分为独立的两个类,对外接口通过在内部组合使用实现类来完成具体实现,可减少维度引起的类数量的爆炸增长。
  10. 策略模式:当我们完成任务的策略需要可被替换时使用。将通过策略完成任务的过程拆分为调用和实现,实现部分提供成系统的方法簇,即具体策略,以参数形式传给调用部分,从而实现策略可替换。
  11. 组合模式:当需要提供给用户的是多个对象,且对象间是部分-整体的层次结构,且不希望用户关心对象间差异,只要一套访问接口时使用。通过多个子类实现相同接口实现。
  12. 装饰器模式:给一个对象追加更多功能,且不改变提供给用户的接口时使用。
  13. 访问者模式:在不改变数据结构的前提下可以添加对数据结构的新的操作时使用。通过将数据结构与操作分离的方式实现。
  14. 责任链模式:个体需要被多个对象处理,但处理对象间有没有耦合关系时,为了避免增加系统复杂度时使用。通过将多个处理对象组成一条责任链,然后将待处理目标沿着这条链传递进行处理实现。Koa 中间件机制就是一种实践。
  15. 门面模式:简化用户对复杂系统中子系统的联系,对外提供简单易用的接口时使用。通过包装更高层的类,由它调度子系统实现。
  16. 中介者模式:简化复杂系统中子系统之间的联系,将交互封装一个中介对象,降低子系统对象间的耦合。可以体会下与门面模式的不同。
  17. 观察者模式:一个对象改变时需要导致其他对象也改变,且不关心其他对象具体是谁时可以使用。通过观察对象管理监听他的所有观察者,并在发生变化时通知所有观察者实现。
  18. 备忘录模式:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便以后当需要时能将该对象恢复到原先保存的状态时使用。
  19. 状态模式:对象与外部互动导致状态变化,从而行为不同时使用。通过将不同状态下的行为封装为不同的类,允许在状态改变时通过切换状态类改变行为实现。可以理解为策略模式的一种特殊应用。是一种内置了多种“策略”,根据状态变化切换“策略”的模式。
  20. 享元模式:有大量的实例需要公用或重复使用时使用。我们可以把这些实例当做享元,并管理起来。可以当做同时使用了多个单例模式的类,因为存在管理能力,所以会受外部因素影响,在返回前修改单例的状态。
  21. 代理模式:在我们不想让用户直接使用对象的情况下使用,如加以访问控制。很简单,实现方式就是加一层代理来间接引用对象。
  22. 命令模式:需要使命令发起者和执行者不可见,甚至需要对命令加以管理时使用。此模式通过将命令封装为一个类,将命令执行者作为命令的依赖,分离命令调用者和命令实现者,同时由于命令实例的存在又可以对命令加以管理。
  23. 解释器模式:将发生频率足够高的问题的各个实例表述为一个简单语言的句子,并构建一个解释器解释语言中的句子。通过对定义语句语法节点,并针对每类语法节点声明类,对语句节点遍历解析实现。

我们可以从上述描述中看到重复的几个关键词:拆分、不关心、不破坏,还是在为了个体间的低耦合而努力。

而且要注意的是,模式并非完美,有些模式实现时甚至会增加内部耦合,增加系统复杂度,所以要关注目的,关注目的,关注目的,关注是否降低了所关注的可能变化的点的耦合度。

结语

最后以三个问题结束这篇文章。

学什么?

我们学设计模式,是为了学习如何合理的组织我们的代码,如何解耦,如何真正的达到对修改封闭对扩展开放的效果,而不是去背诵那些类的继承模式,然后自己记不住,回过头来就骂设计模式把你的代码搞复杂了,要反设计模式。

如何用?

为了合理的利用设计模式,我们应该明白一个概念,叫做扩展点。扩展点不是天生就有的,而是设计出来的。我们设计一个软件的架构的时候,我们也要同时设计一下哪些地方以后可以改,哪些地方以后不能改。

如何用的好?

“我亦无他,惟手熟尔。”

参考

查看原文

赞 24 收藏 18 评论 0

智联大前端 发布了文章 · 2019-07-22

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

智联大前端 发布了文章 · 2019-07-04

读《进化:从孤胆极客到高效团队》

2016年,我从一家中型的IT公司离职,这短短一年经历了人生的起起伏伏。因为表现突出转正后从一名基层开发被领导提拔为部门前端方向负责人,所谓的技术leader。而就在我真的达到公司的事就是我的事的精神状态短短三个月之后,因为工作负载、团队管理、沟通这一系列的摩擦累积,我的心态彻底崩溃了。临走时,一个工作有来往的领导推荐我读这本书,说是这本书对他有很大启发同时也希望我认识到自己欠缺的是什么。

以下是我之后从业生涯中从书中总结出来的心得,愿大家少走弯路,沉淀自我。

什么样的公司需要极客?

答案:只有一个人的公司。

大众的眼里极客的特质通常是自我、孤傲、杰出。极客在那些在不为人知的角落用一已之力,创建出伟大的成就。

但是那些知名的人物,真如大家所想象的那样都是极客吗?
林纳斯开发出了Linux系统,比尔盖茨创造出Windows系统,乔布斯创造了苹果,雷军创造了小米。
创造这些伟大的成就的人,一开始确实可能是在独自埋头苦干,但是真正把他们推向偶像高度的却是他们从极客走向团队之后,引领团队的力量所取得的成就。

程序员的特质

程序员这个职业从业者的大都是喜欢独自钻研各种外人眼里天书般的代码,能胜任这份工作都是有着出众的智商,耐得住孤独,探索真理。正因为这样我们往往高估自己评定事物的能力,与外人接触时总是无意中透露出自己出众的能力,与人合作时总会害怕别人出错影响自己的目标。

与个性恰恰相反的是,一款应用的推出一定是由一个团队,包含前端、后端、数据库、UI、产品、测试甚至是同一个公司的其它部门,这些人之间紧密配合,互相朝着同一个目标工作,最终才产出一款被用户称赞的应用。

写代码其实是创造一款应用过程中最容易的事情,输入正确的参数返回预期的结果。真正困难的事情是在这么多人力协作过程中的配合,产品没有描述清楚方案,开发看不懂需求,前端与后端对接错了接口,分工出现差错,承诺没有交付,最终的上线可能离初始评估时间长几倍,甚至最终上线后还存在各种问题。一群思想各异的人,要在一起工作,必须建立起团队文化,让大家轻松、愉快、正确的把事情做成。有人的地方就有江湖,冲突总是不可避免的。

团队中的冲突

如果你找身边的同事请教问题,对方表示这个很简单,这你也不会,往往大家就不倾向于问问题,而是把问题隐藏掉,或者用另一种很蹩脚的办法处理,可以想象这样的代码或者解决方案会累积多少隐藏问题。

对有三年以上开发经验的同行来说在职业发展过程,大都会经历内心膨胀期:你似乎已经无所不能,身边的人已经远远不如你,你什么都懂。再然后看这个不顺眼,看那个不舒服,团队中的矛盾危机重重,最终你选择干掉这个团队离职自己找一个更高档次的公司,那些表面看起来高大上的公司总是感觉更有诱惑力。但是不幸的是,上家公司的遭遇还会在这次表面看起来不错的公司重演。要么团队内部方向不一致,要么领导不够信任你,要么项目做不起来,大家一盘散沙。你感觉这个团队的氛围无可救药,下次跳槽计划又开始萌芽,这是一个死循环。

如果你在团体中承担的是比较重要的岗位,你应该尝试将一个坏的团队氛围变的朝好的方向发展,而不是盼望天上掉下一个各方面都对你胃口的同事,这一定是不可能的。

怎么营造好的团队氛围办法?

答案:HRT原则。

团队中的所有冲突无非就这几点原因:不够谦虚、不够尊重、不够信任。
HRT原则指的就是谦虚(Humility)、尊重(Respect)、信任(Trust),其中任意一项的缺失都是造成团队冲突的主要原因。

如果你在团队有影响力,并且你尝试一下改变自己的行为方式,多一些包容,多一些理解,用更容易让人接受的方式与人相处,好的处事方式会潜移默化的影响其他人。
试着在讨论问题的时候,先同意对方的观点,再讲出怎么做会更好。
发现别人做错了事情,表明这样的事情以前你也做错过,再示范正确的方式,这样可以让别人更容易接受并改正。
分配任务时放手让别人去干,肯定别人的能力,在发现问题时再出手指正,而不是一开始就认为对方根本做不了,只是实在没人了才交给对方。
多一些包容,少一些指责,找出解决问题的办法,而不是揪出引发问题的人。
当一个团队氛围和谐,大家的精力都放在事上而不是停留在争吵内耗,团队产生的价值随人数累加而不是抵消。
在一个有价值输出的团队,你才更有可能成为别人的偶像。

如何成为别人眼里的偶像?

答案:做出有价值的事情,自然会有人把你当偶像。

要想成为别人眼里的偶像,我们应该学会将团队利用起来,大家共同把事情做出来。
团队成就属于每一个人,但当你站在更有担当的角度,引领团队前进,你就会在一定范围内成为别人的偶像。
随着团队的产出扩大,相应的你的影响力也会扩大,我们每个人都有机会成为别人眼里的偶像。

个人见解

团队的本质不是事而是人,团队里的人彼此尊重、互相信任、谦虚有风度,大家融为一体互相帮助,顺带着把公司交待的事给办了,工作也会是一种享受了。

查看原文

赞 56 收藏 26 评论 12

智联大前端 发布了文章 · 2019-05-15

单元测试规范

原则

单元测试文件必须拥有良好的结构和格式;
测试用例的分组名称和用例名称必须清晰易懂;
测试用例必须能描述测试目标的行为;
优先测试代码逻辑(过程)而非执行结果;
单元测试的各项覆盖率指标必须在95%以上;

技术

Jest:https://facebook.github.io/jest

结构

编写单元测试所涉及的文件应存放于以下两个目录:

  • __mocks__/:模拟文件目录
  • [name].mock.json:【例】单个模拟文件
  • __tests__/:单元测试目录
  • [target].test.js:【例】单个单元测试文件,[target]与目标文件名保持一致,当目标文件名为index时,采用其上层目录或模块名。

[target].test.js 文件

应按照如下结构编写测试文件,注意其中的空行:

/* eslint global-require: 0 */
const thirdPartyModule = require('thrid-party-module')


describe('@zpfe/module-name' () => {
  const mocks = {}
  
  beforeAll(() => {})
  
  beforeEach(() => {})
  
  test('描述行为', () => {
    mocks.fake.mockReturnValue('控制模拟行为的代码置于最上方')
    
    const target = require('../target.js')
    const result = target.foo('执行目标待测功能')
    
    expcet(result).toBe('断言置于最下方')
  })
})

保证每个describe内部只有mock对象、生命周期钩子函数和test函数,将模拟对象都添加到mocks对象的适当位置,将初始化操作都添加到适当的生命周期函数中。

mocks 对象

常量mocks的结构如下:

const mocks = {
  zpfe: {
    // @zpfe模块,若有,将包名转换为驼峰式以便访问,比如:koaMiddleware
    log: {
      info: jest.fn()
    }
  },
  dependencies: {
    thirdPartyModule1: {
    // 第三方依赖模块,若有
  },
  files: {
    // 本地依赖文件
    router: jest.fn()
  },
  others: {
    // 公共假对象
    ctx: jest.fn()
  }
}

请注意,mocks对象的价值在于保存模拟依赖项及部分复用对象,请勿添加不涉及模拟也没有被复用的内容。

生命周期函数

在beforeAll中设置依赖模拟,比如:

beforeAll(() => {
  jest.mock('@zpfe/log', () => mocks.zpfe.log)
  jest.mock('../router.js', () => mocks.files.router)
  jest.spyOn(console, 'log')
})

在beforeEach中进行每个单元测试运行前需要的重置工作,比如:

beforeEach(() => {
  process.env.NODE_ENV = 'production'
})

describe 函数

若模块包含多个文件,则每个文件对应专门的测试文件,其describe应这样写:

describe('@zpfe/module-name: file-name' () => {})

目标对象

提倡在每个test函数中require目标文件,若综合评估之后,能确定将require目标文件的代码提取到生命周期钩子函数中也不会产生干扰或混乱,则可以考虑提取,比如:

describe('@zpfe/module-name' () => {
  let moduleName
  
  beforeEach(() => {
    moduleName = require('../target.js')
  })
})

test 函数

请用空行分隔test函数内不同目的的代码块(比如模拟、执行目标、和断言)。

请勿在测试中编写try...catch...,应明确断言是否抛出异常,并根据需要断言抛出的错误信息及日志记录情况。

命名

describe表示分组,其名称应属于下列几种情况之一:

  • 模块名,比如:@zpfe/module-name
  • 组件名,比如:a-input
  • 隶属于模块或组件的文件名,比如:a-input/nativa-control
  • 功能名,比如:props
  • 条件名,比如:当 NODE_ENV = production 时

test表示测试用例,其名称应当明确表示其行为,比如:当 disabled 属性被设置为非 Boolean 类型时,抛出异常。不允许将describe的命名规则应用到test。

良好的命名有助于组织测试用例,使其更能充当文档之用。当某个测试用例失败时,良好的结构和命名能让读者快速了解其影响范围,比如:

[FAILED] a-input > props -> disabled -> 当传入非 Boolean 类型的值时,抛出异常

模拟

请查阅Jest文档,以详细了解Jest所提供的各类模拟API。

临时替换默认实现

若在mocks对象中初始化了实现,又需要在测试用例当中临时修改其实现,可以这样做:

const mocks = {
  others: {
    foo: jest.fn(() => 'foo')
  }
}
test('demo', () => { 
  mocks.others.foo.mockImplementationOnce(() => 'bar') 
})

mockImementOnce会临时修改默认实现,且只生效一次,故不会影响其他测试用例。

若需要在测试用例当中临时修改模拟函数的实现,且模拟函数会被多次调用,就应该使用另外一种方式实现,比如:

const mocks = {
  others: {
    foo: jest.fn()
  }
}
beforeEach(() => {
  mocks.others.foo.mockImplementation(() => 'foo')
})
test('demo', () => {
  mocks.others.foo.mockImplementation(() => 'bar')
})

即在mocks对象中只定义模拟函数,不定义具体实现,在beforeEach钩子函数中定义具体实现,使得每个测试用例都会重新初始化该实现,接着在具体测试用力中使用mockImementation彻底替换掉默认实现。

断言

请查阅Jest文档,以详细了解Jest所提供的各类断言API。

断言参数

若需要断言调用函数时的参数传递,可使用:

expect(mocks.zpfe.log.info).toHaveBeenCalledWith('观察C ZooKeeper客户端')

若需要部分匹配参数,可使用:

expect(mocks.zpfe.log.info).toHaveBeenCalledWith(expect.stringContaining('观察'), expect.objectContaining({ key: 'value' }))

调试

在VS Code中,打开测试文件,选中调试配置【调试 Jest 测试】,按【F5】即可。

Vue 组件测试

技术

@vue/test-utils:https://vue-test-utils.vuejs.org

结构

单元测试文件在__tests__目录内的组织形式应与目标文件在src目录保持一致,并按照如下顺序结构组织组件的单元测试文件:

describe('组件:a-component-name', () => {
  const mocks = {}
 
  beforeAll()


  // 仅针对 props 定义进行基础测试,不测试 props 如何使用
  describe('props', () => {
    describe('prop-name', () => {
      test('类型应为 xxx')
      test('默认值应为 xxx')
      test('有效性校验')      
    })
  })
  
  // 仅针对可被用户使用的嵌套组件族进行嵌套校验测试
  describe('受限嵌套', () => {
    test('当父组件不为 xxx 时,抛出异常')
    test('当子组件不为 xxx 时,抛出异常')
  }) 
  
  // 仅针对 slots 渲染位置进行基础测试
  describe('slots', () => {
    test('default')
    test('named-slot')
  })  
  
  // 根据实际情况,结合 props 和 slots 进行各种场景下的渲染测试
  describe('render', () => {
    test('使用 prop-name 来渲染 xxx')
  })
  
  // 测试所有公开方法,不测试私有方法
  describe('methods', () => {
    describe('method-name', () => {
      test('行为')
    })
  })
  
  // 触发并测试所有事件是否正常触发
  // 若 props 中包含 value,则 events 中必须包含 input
  describe('events', () => {
    describe('event-name', () => {
      test('当 xxx 时,触发此事件')
    })
  }) 
  
  // 测试UI交互是否能正常响应(忽略与 events 测试雷同,则可忽略)
  describe('交互', () => {
    test('当点击 xxx 时, 如此这般')
  })
}

挂载组件

按照如下规则挂载组件:

在挂载时传递 props;
挂载产生的对象应命名为 e;
若组件需要使用原生DOM方法,请启用 attachToDocument;

比如:

const target = mount(ComponentName, {
  propsData: {
    foo: 'bar'
  },
  attachToDocument: true
})

组件依赖关系

除非互相依赖的组件之间定义了嵌套校验,否则优先考虑模拟子组件来进行父组件的测试。比如:

const target = mount(APaginationWithJumper, {
  stubs: {
    'dependent-component': true
  }
}
// 通过 target.find('dependent-component-stub').vm来模拟或控制其行为
查看原文

赞 5 收藏 1 评论 0

智联大前端 发布了文章 · 2019-05-14

通过HTTP Header控制缓存

我们经常通过缓存技术来加快网站的访问速度,从而提升用户体验。HTTP协议中也规定了一些和缓存相关的Header,来允许浏览器或共享高速缓存缓存资源。这些Header包括:

  • Last-Modified 和 If-Modified-Since
  • ETag 和 If-None-Match
  • Expires
  • Cache-Control

以上Header又可以分成两种类型:

  • 协商缓存:浏览器发送验证到服务器,由服务器决定是否从缓存中读取,如 1 和 2 。
  • 强缓存:浏览器验证缓存的有效性,然后决定是否从缓存中读取数据,如 3 和 4 。

本文将会分别介绍这四种配置的作用以及可能产生的影响。

1、Last-Modified 和 If-Modified-Since

Last-Modified:服务器在响应请求时,告知浏览器资源的最后修改时间。

If-Modified-Since:浏览器再次发送请求时,会通过此Header通知服务器在上次请求时所得到的资源最后修改时间。服务器会将If-Modified-Since与被请求资源的最后修改时间进行比对。若资源的最后修改时间晚于If-Modified-Since,表示资源已被改动,则响最新的资源,返回200状态码;若资源的最后修改时间早于或等于If-Modified-Since,表示浏览器端的资源已经是最新版本,响应304状态码,通知浏览器继续使用缓存中的资源。

2、ETag 和 If-None-Match

ETag:服务器分配给资源的唯一标识符,资源被修改后,ETag也会随之发生变化。

If-None-Match:浏览器再次发送请求时,会通过此Header通知服务器已缓存资源的ETag。服务器会将If-None-Match与被请求资源的最新ETag进行比对。若不相同,表示资源已被改动,则响应最新的资源,返回200状态码;若值相同,则直接响应304状态码,通知浏览器继续使用缓存中的资源。

3、Expires

服务器可以通过此Header向浏览器传递一个具体的时间(格林威治格式,例如:Thu, 19 Jul 2018 07:43:05 GMT) ,来明确地宣告资源的有效期。在资源过期之前,浏览器不再发送请求,而是直接从缓存中读取数据。只有当资源过期之后,浏览器才会再次向服务器请求该资源。

4、Cache-Control

服务器使用此Header来向客户端建议缓存策略,它有一下几个可选值:

max-age=秒:告知浏览器缓存的有效时长,在该时间内浏览器将直接从缓存中读取数据。

s-maxage=秒:作用同max-age,但是只对共享高速缓存(如CDN)有效,对浏览器无效。

no-cache:告知浏览器不要直接使用缓存,而是必须向服务器发送请求。

no-store:告知浏览器不要缓存本次请求和响应的任何信息。

public:宣告任何缓存媒介都可以缓存该响应。

private:宣告该响应只允许个体客户端(如浏览器)去缓存,而不允许共享高速缓存(如CDN)去缓存。

在上面的介绍中我们了解到浏览器会根据max-age设置的时间进行缓存。而通过研究发现CDN也会识别源站响应头中Cache-Control属性,根据max-age设置的时间进行缓存,但是,如果源站同时设置了s-maxage和max-age,那么CDN会优先采用s-maxage。

下面通过图例来展示一下这些可选值的效果。

首先了解一下浏览器是怎样根据max-age进行缓存的:

clipboard.png

从上图不难发现,服务器在Header中返回了Cache-Control: max-age=100后,浏览器成功缓存100秒,该时间段内的请求都从直接以本地缓存来响应。

那么,服务器在Header中返回Cache-Control:s-maxage=100时,又会对浏览器产生什么样的影响呢?

clipboard.png

如上图所示,浏览器没有采取任何缓存策略,这是因为s-maxage面向的是共享高速缓。

上面这两个例子很容易理解,在现实世界中,为了加快网站响应速度,我们可能会在浏览器和服务器之间引入CDN服务。浏览器的请求会先到达CDN,然后CDN判断是从缓存中读取数据还是回源到服务器。接下来,让我们看看max-age和s-maxage会对CDN的缓存策略带来哪些影响。

clipboard.png

可以看出CDN也会利用max-age来缓存,所以在100秒内强制刷新浏览器时,CDN会直接用缓存来响应。

如果服务器使用了s-maxage又会如何呢?

clipboard.png

不难发现CDN对max-age和s-maxage采取了同样的缓存策略,但浏览器并不会根据s-maxage来进行缓存。

CDN供应商的特殊规则

我们分别测试了阿里云和腾讯云的CDN对Cache-Control的支持情况,发现他们都有一些独特的规则。

阿里云CDN可以在控制台里设置Cache-Control,该设置会覆盖源服务器的Cache-Control。

腾讯云CDN虽然没有再控制台提供覆盖Cache-Control的功能,但其规则却一点也不简单,在使用的时候一定要特别注意:

  • 服务器和CDN均不对缓存进行配置时,CDN会采用默认的缓存机制(静态文件缓存30天,动态请求不缓存);
  • CDN配置缓存机制(但并未开启高级缓存配置)且服务器设置Cache-Control: s-maxage=200,max-age=100时,CDN会按照其控制台设置的规则进行缓存,浏览器则按照max-age进行缓存;
  • 服务器不设置Cache-Control时,CDN会自动在响应的Header中添加Cache-Control: max-age=600,这就会让浏览器将该资源缓存600秒;
  • 服务器设置为禁用缓存时,CDN和浏览器均不进行缓存;
  • 服务器设置Cache-Control: s-maxage=200,max-age=100并开启CDN的高级缓存配置时,CDN会从s-maxage和控制台中设置的缓存时间中选择最小值来作为缓存时间,而浏览器则始终使用max-age;
  • 服务器设置Cache-Control:max-age=100并开启CDN的高级缓存配置时,CDN会从max-age和控制台中设置的缓存时间中选择最小值来作为缓存时间,不影响浏览器的缓存策略。

组合使用

如果同时设置了这些Header,浏览器和高速共享缓存会按照下面的优先级进行缓存:

Cache-Control > Expires > ETag > Last-Modified

也就是说,Cache-Control不仅是强缓存,而且拥有最高的优先级,我们可以为不经常发生变化的资源应用该Header来提升响应时间。

在Ada中使用缓存

Ada提供了UI脚手架和API脚手架,这两类脚手架的服务器端入口文件分别为index.server.js和index.js,我们只需要在入口文件的请求处理函数中为响应添加适当的Header,即可通知客户端进行响应的缓存,比如:

// 设置CDN缓存300秒,浏览器缓存200秒
ctx.response.headers.set('Cache-Control', public,s-maxage=300,max-age=200)
在为请求添加缓存Header之前,应该先为其制定适当的缓存策略,需要考虑该URL是否适合缓存(数据是否特定于用户)以及需要缓存的时长等等。

总结
通过使用这些HTTP Header,我们可以主动影响浏览器甚至CDN的缓存策略,从而减少请求数量,提升网页性能,减轻服务器压力。

Ada的灵活机制能让我们为不同的URL设置不同的缓存策略,能够更有针对性地进行主动缓存。

查看原文

赞 58 收藏 37 评论 0