Barrior

Barrior 查看完整档案

深圳编辑  |  填写毕业院校京东  |  凹凸实验室 编辑 github.com/Barrior 编辑
编辑

开源项目 JParticles 作者
—— JParticles(JavaScript Particles的缩写)是一款基于 Canvas 的不依赖于其他库的轻量级 JavaScript 粒子运动特效库,提供一些比较绚丽实用的特效应用于 WEB 界面,希望能达到锦上添花的作用,给用户带来些许惊喜。
—— https://jparticles.js.org/

个人动态

Barrior 收藏了文章 · 6月22日

图形处理:给 Canvas 文本填充线性渐变

前言

在 Canvas 中对文本填充水平或垂直的线性渐变可以轻易实现,而带角度的渐变就复杂很多;就好像下面这样,假设文本矩形宽为 W, 高为 H, 左上角坐标为 X, Y

渐变示例.jpg

猜想与答案

给出两个答案:

猜想.jpg

正确答案是图二,因为这样得出来的坐标生成的渐变最紧接文本矩形边界,它的运动轨迹如下动图:

LinearGradient.gif

(图来源:Do you really know CSS linear-gradients)

渐变起点与终点坐标的计算

所以,渐变的起点与终点坐标该怎么计算呢?答:

  1. 先求得起点与终点的长度(距离)。
  2. 根据长度与文本矩形的中心点坐标分别计算出起点与终点坐标。

线性渐变长度的计算 W3C 给出了一个公式(A 表示角度):

gradientLineLength = abs(W * sin(A)) + abs(H * cos(A))

不过,该公式主要应用于 CSS 的线性渐变设置,即以 12 点钟方向为 0°,顺时针旋转。

而我们需要的是以 3 点钟方向为 0°,逆时针旋转,即公式为:

gradientLineLength = abs(W * cos(A)) + abs(H * sin(A))

// 半长:
halfGradientLineLength = (abs(W * cos(A)) + abs(H * sin(A))) / 2

那么这个公式是怎么来的呢?以下是笔者的求解:

几何图.jpg

由图可得以下方程组:

math-1.jpg

因此可推导出:

math-2.jpg

化简后为:

math-3.jpg

所以 c1 + c2 为:

math-4.jpg

由三角函数平方公式知:cos(A) * cos(A) = 1 - sin(A) * sin(A), 代入 c1 + c2

math-5.jpg

第一步化简后:

math-6.jpg

最后的结果就是:

math-7.jpg

因为 sin, cos 在函数周期内存在负值(见下面角度对应的三角函数周期图),所以线性渐变的长度需要取绝对值。

至此,我们知道了线性渐变长度,文本矩形的中心点坐标很好算,即:

centerX = X + W / 2
centerY = Y + H / 2

所以,起点与终点的坐标分别为:

startX = centerX - cos(A) * halfGradientLineLength
startY = centerY + sin(A) * halfGradientLineLength

endX = centerX + cos(A) * halfGradientLineLength
endY = centerY - sin(A) * halfGradientLineLength

看看最终效果

最终效果.gif

经验注释

进行三角函数计算时,应尽量避免先用 tan, 因为 tan 在其周期内存在无穷值,需要做特定的条件判断,而 sin, cos 没有此类问题,代码书写更为简洁清晰并且不会因疏忽产生错误,见下面三角函数与角度的对应关系周期图。

角度对应的三角函数周期图.png

参阅

Do you really know CSS linear-gradients?

MDN linear-gradient

W3C - CSS Images Module Level 3 # linear-gradients

查看原文

Barrior 发布了文章 · 6月22日

图形处理:给 Canvas 文本填充线性渐变

前言

在 Canvas 中对文本填充水平或垂直的线性渐变可以轻易实现,而带角度的渐变就复杂很多;就好像下面这样,假设文本矩形宽为 W, 高为 H, 左上角坐标为 X, Y

渐变示例.jpg

猜想与答案

给出两个答案:

猜想.jpg

正确答案是图二,因为这样得出来的坐标生成的渐变最紧接文本矩形边界,它的运动轨迹如下动图:

LinearGradient.gif

(图来源:Do you really know CSS linear-gradients)

渐变起点与终点坐标的计算

所以,渐变的起点与终点坐标该怎么计算呢?答:

  1. 先求得起点与终点的长度(距离)。
  2. 根据长度与文本矩形的中心点坐标分别计算出起点与终点坐标。

线性渐变长度的计算 W3C 给出了一个公式(A 表示角度):

gradientLineLength = abs(W * sin(A)) + abs(H * cos(A))

不过,该公式主要应用于 CSS 的线性渐变设置,即以 12 点钟方向为 0°,顺时针旋转。

而我们需要的是以 3 点钟方向为 0°,逆时针旋转,即公式为:

gradientLineLength = abs(W * cos(A)) + abs(H * sin(A))

// 半长:
halfGradientLineLength = (abs(W * cos(A)) + abs(H * sin(A))) / 2

那么这个公式是怎么来的呢?以下是笔者的求解:

几何图.jpg

由图可得以下方程组:

math-1.jpg

因此可推导出:

math-2.jpg

化简后为:

math-3.jpg

所以 c1 + c2 为:

math-4.jpg

由三角函数平方公式知:cos(A) * cos(A) = 1 - sin(A) * sin(A), 代入 c1 + c2

math-5.jpg

第一步化简后:

math-6.jpg

最后的结果就是:

math-7.jpg

因为 sin, cos 在函数周期内存在负值(见下面角度对应的三角函数周期图),所以线性渐变的长度需要取绝对值。

至此,我们知道了线性渐变长度,文本矩形的中心点坐标很好算,即:

centerX = X + W / 2
centerY = Y + H / 2

所以,起点与终点的坐标分别为:

startX = centerX - cos(A) * halfGradientLineLength
startY = centerY + sin(A) * halfGradientLineLength

endX = centerX + cos(A) * halfGradientLineLength
endY = centerY - sin(A) * halfGradientLineLength

看看最终效果

最终效果.gif

经验注释

进行三角函数计算时,应尽量避免先用 tan, 因为 tan 在其周期内存在无穷值,需要做特定的条件判断,而 sin, cos 没有此类问题,代码书写更为简洁清晰并且不会因疏忽产生错误,见下面三角函数与角度的对应关系周期图。

角度对应的三角函数周期图.png

参阅

Do you really know CSS linear-gradients?

MDN linear-gradient

W3C - CSS Images Module Level 3 # linear-gradients

查看原文

赞 4 收藏 4 评论 0

Barrior 关注了用户 · 3月4日

高阳Sunny @sunny

SegmentFault 思否 CEO
C14Z.Group Founder
Forbes China 30U30

独立思考 敢于否定

曾经是个话痨... 要做一个有趣的人!

任何问题可以给我发私信或者发邮件 sunny@sifou.com

关注 2112

Barrior 关注了专栏 · 2019-12-12

凹凸实验室

凹凸实验室(Aotu.io,英文简称O2) 始建于2015年10月,是一个年轻基情的技术团队。 O2面向多终端技术体系,致力于构建沉淀与分享包括但不限于交互、页面制作技巧、前端开发、原生APP开发等方面的专业知识及案例。

关注 1980

Barrior 关注了用户 · 2019-12-12

凹凸实验室 @o2team

凹凸实验室(Aotu.io,英文简称O2) 始建于2015年10月,是一个年轻基情的技术团队。

O2面向多终端技术体系,致力于构建沉淀与分享包括但不限于交互、页面制作技巧、前端开发、原生APP开发等方面的专业知识及案例。

求简历:aotu@jd.com

关注 758

Barrior 收藏了文章 · 2019-12-12

羚珑项目自动化测试方案实践

分享内容及技术栈

本文将分享结合 京东智能设计平台羚珑 项目自身情况搭建的测试工作流的实践经验,针对于 Node.js 服务端应用的工具方法和接口的单元测试、集成测试等。实践经验能给你带来:

  1. 利用 Jest 搭建一套开发体验友好的测试工作流。
  2. 书写一个高效的单元测试用例,及集成测试用例。
  3. 利用封装技术实现模块间的分离,简化测试代码。
  4. 使用 SuperTest 完成应用进程与测试进程的合并。
  5. 创建高效的数据库内存服务,实现彼此隔离的测试套件运行机制。
  6. 了解模拟(Mock)、快照(snapshot)与测试覆盖率等功能的使用。
  7. 理解 TDD 与 BDD。
  8. ...

文中涉及的基础技术栈有(需要了解的知识):

  1. TypeScript: JavaScript 语言的超集,提供类型系统和新 ES 语法支持。
  2. SuperTest: HTTP 代理及断言工具。
  3. MongoDB: NoSQL 分布式文件存储数据库。
  4. Mongoose: MongoDB 对象关系映射操作库(ORM)。
  5. Koa: 基础 Web 应用程序框架。
  6. Jest: 功能丰富的 JavaScript 测试框架。
  7. lodash: JavaScript 工具函数库。

关于羚珑

羚珑Logo

羚珑 是京东旗下智能设计平台,提供在线设计服务,主要包括大类如:

  • 图片设计:快速合成广告图,主图,公众号配图,海报,传单,物流面单等线上与线下设计服务。
  • 视频设计:快速合成主图视频,抖音短视频,自定义视频等设计服务。
  • 页面设计:快速搭建活动页,营销页,小游戏,小程序等设计服务。
  • 实用工具:批量抠图、改尺寸、配色、加水印等。

基于行业领先技术,为商家、用户提供丰富的设计能力,实现快速产出。

羚珑架构及测试框架选型

先介绍下羚珑项目的架构,方便后续的描述和理解。羚珑项目采用前后端分离的机制,前端采用 React Family 的基础架构,再加上 Next.js 服务端渲染以提供更好的用户体验及 SEO 排名。后端架构则如下图所示,流程大概是 浏览器或第三方应用访问项目 Nginx 集群,Nginx 集群再通过负载均衡转发到羚珑应用服务器,应用服务器再通过对接外部服务或内部服务等,或读写缓存、数据库,逻辑处理后通过 HTTP 返回到前端正确的数据。

羚珑服务端架构图

主流测试框架对比

接下来,根据项目所需我们对比下当下 Node.js 端主流的测试框架。

JestMochaAVAJasmine
GitHub Stars28.5K18.7K17.1K14.6K
GitHub Used by1.5M926K46.6K5.3K
文档友好优秀良好良好良好
模拟功能(Mock)支持外置外置外置
快照功能(Snapshot)支持外置支持外置
支持 TypeScriptts-jestts-mochats-nodejasmine-ts
详细的错误输出支持支持支持未知
支持并行与串行支持外置支持外置
每个测试进程隔离支持不支持支持未知

*文档友好:文档结构组织有序,API 阐述完整,以及示例丰富。

分析:

  1. 之所以 Mocha GitHub 使用率很高,很有可能是因为出现的最早(2011年),并由 Node.js 届顶级开发者 TJ 领导开发的(后转向Go语言),所以早期项目选择了 Mocha 做为测试框架,而 JestAVA 则是后起之秀(2014年),并且 Stars 数量都在攀升,预计新项目都会在这两个框架中挑选。
  2. 相比外置功能,内置支持可能会与框架融合的更好,理念更趋近,维护更频繁,使用更省心。
  3. Jest 模拟功能可以实现方法模拟,定时器模拟,模块/文件依赖模拟,在实际编写测试用例中,模拟模块功能(mock modules)被常常用到,它可以确保测试用例快速响应并且不会变化无常。下文也会谈到如何使用它,为什么需要使用它。

综上,我们选择了 Jest 作为基础测试框架。

从0到1落地实践

Jest 框架配置

接下来,我们从 0 到 1 开始实践,首先是搭建测试流,虽然 Jest 可以达到开箱即用,然而项目架构不尽相同,大多时候需要根据实际情况做些基础配置工作。以下是根据羚珑项目提取出来的简化版项目目录结构,如下。

├─ dist                 # TS 编译结果目录
├─ src                  # TS 源码目录
│   ├─ app.ts              # 应用主文件,类似 Express 框架的 /app.js 文件
│   └─ index.ts            # 应用启动文件,类似 Express 框架的 /bin/www 文件
├─ test                 # 测试文件目录
│   ├─ @fixtures           # 测试固定数据
│   ├─ @helpers            # 测试工具方法
│   ├─ module1             # 模块1的测试套件集合
│   │  └─ test-suite.ts       # 测试套件,一类测试用例集合
│   └─ module2             # 模块2的测试套件集合
├─ package.json           
└─ yarn.lock

这里有两个小点:

  1. @ 开头的目录,我们定义为特殊文件目录,用于提供些测试辅助工具方法、配置文件等,平级的其他目录则是测试用例所在的目录,按业务模块或功能划分。以 @ 开头可以清晰的显示在同级目录最上方,很容易开发定位,凑巧也方便了编写正则匹配。
  2. test-suite.ts 是项目内最小测试文件单元,我们称之为测试套件,表示同一类测试用例的集合,可以是某个通用函数的多个测试用例集合,也可以是一个系列的单元测试用例集合。

首先安装测试框架。

yarn add --dev jest ts-jest @types/jest

因为项目是用 TypeScript 编写,所以这里同时安装 ts-jest @types/jest。然后在根目录新建 jest.config.js 配置文件,并做如下小许配置。

module.exports = {
  // preset: 'ts-jest',
  globals: {
    'ts-jest': {
      tsConfig: 'tsconfig.test.json',
    },
  },
  testEnvironment: 'node',
  roots: ['<rootDir>/src/', '<rootDir>/test/'],
  testMatch: ['<rootDir>/test/**/*.ts'],
  testPathIgnorePatterns: ['<rootDir>/test/@.+/'],
  moduleNameMapper: {
    '^~/(.*)': '<rootDir>/src/$1',
  },
}

preset: 预设测试运行环境,多数情况设置为 ts-jest 即可,如果需要为 ts-jest 指定些参数,如上面指定 TS 配置为 tsconfig.test.json,则需要像上面这样的写法,将 ts-jest 挂载到 globals 属性上,更多配置可以移步其官方文档,这里

testEnvironment: 基于预设再设置测试环境,Node.js 需要设置为 node,因为默认值为浏览器环境 jsdom

roots: 用于设定测试监听的目录,如果匹配到的目录的文件有所改动,就会自动运行测试用例。<rootDir> 表示项目根目录,即与 package.json 同级的目录。这里我们监听 srctest 两个目录。

testMatch:Glob 模式设置匹配的测试文件,当然也可以是正则模式,这里我们匹配 test 目录下的所有文件,匹配到的文件才会当做测试用例执行。

testPathIgnorePatterns: 设置已经匹配到的但需要被忽略的文件,这里我们设置以 @ 开头的目录及其所有文件都不当做测试用例。

moduleNameMapper: 这个与 TS pathsWebpack alias 雷同,用于设置目录别名,可以减少引用文件时的出错率并且提高开发效率。这里我们设置以 ~ 开头的模块名指向 src 目录。

第一个单元测试用例

搭建好测试运行环境,于是便可着手编写测试用例了,下面我们编写一个接口单元测试用例,比方说测试首页轮播图接口的正确性。我们将测试用例放在 test/homepage/carousel.ts 文件内,代码如下。

import { forEach, isArray } from 'lodash’
import { JFSRegex, URLRegex } from '~/utils/regex'
import request from 'request-promise'

const baseUrl = 'http://ling-dev.jd.com/server/api'

// 声明一个测试用例
test('轮播图个数应该返回 5,并且数据正确', async () => {
  // 对接口发送 HTTP 请求
  const res = await request.get(baseUrl + '/carousel/pictures')
  
  // 校验返回状态码为 200
  expect(res.statusCode).toBe(200)
  
  // 校验返回数据是数组并且长度为 5
  expect(isArray(res.body)).toBe(true)
  expect(res.body.length).toBe(5)
  
  // 校验数据每一项都是包含正确的 url, href 属性的对象
  forEach(res.body, picture => {
    expect(picture).toMatchObject({
      url: expect.stringMatching(JFSRegex),
      href: expect.stringMatching(URLRegex),
    })
  })
})

编写好测试用例后,第一步需要启动应用服务器:

应用服务运行截图

第二步运行测试,在命令行窗口输入:npx jest,如下图可以看到用例测试通过。

测试用例通过截图

当然最佳实践则是把命令封装到 package.json 里,如下:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
  }
}

之后便可使用 yarn test 来运行测试,通过 yarn test:watch 来启动监听式测试服务。

SuperTest 增强

虽然上面已经完成基本的测试流程开发,但很明显的一个问题是每次运行测试,我们需要先启动应用服务,共启动两个进程,并且需要提前配置 ling-dev.jd.com 指向 127.0.0.1:3800,这是一个繁琐的过程。所以我们引入了 SuperTest,它可以把应用服务集成到测试服务一起启动,并且不需要指定 HTTP 请求的主机地址。

我们封装一个公共的 request 方法,将它放在 @helpers/agent.ts 文件内,如下。

import http from 'http'
import supertest from 'supertest'
import app from '~/app'

export const request = supertest(http.createServer(app.callback()))

解释:

  1. 使用 app.callback() 而不是 app.listen(),是因为它可以将同一个 app 同时作为 HTTP 和 HTTPS 或多个地址。app.callback() 返回适用于 http.createServer() 方法的回调函数来处理请求。
  2. 之后,http.createServer() 创建一个未监听的 HTTP 对象给 SuperTest,当然 SuperTest 内部也会调用 listen(0) 这样的特殊端口,让操作系统提供可用的随机端口来启动应用服务器。

所以上面的测试用例我们可以改写成这样:

import { forEach, isArray } from 'lodash’
import { JFSRegex, URLRegex } from '~/utils/regex'

// 引入公共的 request 方法
import { request } from '../@helpers/agent'

test('轮播图个数应该返回 5,并且数据正确', async () => {
  const res = await request.get('/api/carousel/pictures')
  expect(res.status).toBe(200)
  // 同样的校验...
})

因为 SuperTest 内部已经帮我们包装好了主机地址并自动启动应用服务,所以请求接口时只需书写具体的接口,如 /api/carousel/pictures,也只需运行一条命令 yarn test,就可以完成整个测试工作。

数据库内存服务

项目架构中可以看到数据库使用的是 MongoDB,在测试时,几乎所有的接口都需要与数据库连接。此时可通过环境变量区分并新建 test 数据库,用于运行测试用例。有点不好的是测试套件执行完成后需要对 test 数据库进行清空,以避免脏数据影响下个测试套件,尤其是在并发运行时,需要保持数据隔离。

使用 MongoDB Memory Server 是更好的选择,它会启动独立的 MongoDB 实例(每个实例大约占用非常低的 7MB 内存),而测试套件将运行在这个独立的实例里。假如并发为 3,那就创建 3 个实例分别运行 3 个测试套件,这样可以很好的保持数据隔离,并且数据都保存在内存中,这使得运行速度会非常快,当测试套件完成后则自动销毁实例。

MongoDB Memory Server

接下来我们把 MongoDB Memory Server 引入实际测试中,最佳方式是把它写进 Jest 环境配置里,这样只需要一次书写,自动运行在每个测试套件中。所以替换 jest.config.js 配置文件的 testEnvironment 为自定义环境 <rootDir>/test/@helpers/jest-env.js

编写自定义环境 @helpers/jest-env.js

const NodeEnvironment = require('jest-environment-node')
const { MongoMemoryServer } = require('mongodb-memory-server')
const child_process = require('child_process')

// 继承 Node 环境
class CustomEnvironment extends NodeEnvironment {
  // 在测试套件启动前,获取本地开发 MongoDB Uri 并注入 global 对象
  async setup() {
    const uri = await getMongoUri()
    this.global.testConfig = {
      mongo: { uri },
    }
    await super.setup()
  }
}

async function getMongoUri() {
  // 通过 which mongod 命令拿到本地 MongoDB 二进制文件路径
  const mongodPath = await new Promise((resolve, reject) => {
    child_process.exec(
      'which mongod',
      { encoding: 'utf8' },
      (err, stdout, stderr) => {
        if (err || stderr) {
          return reject(
            new Error('找不到系统的 mongod,请确保 `which mongod` 可以指向 mongod')
          )
        }
        resolve(stdout.trim())
      }
    )
  })

  // 使用本地 MongoDB 二进制文件创建内存服务实例
  const mongod = new MongoMemoryServer({
    binary: { systemBinary: mongodPath },
  })
  
  // 得到创建成功的实例 Uri 地址
  const uri = await mongod.getConnectionString()
  return uri
}

// 导出自定义环境类
module.exports = CustomEnvironment

Mongoose 中便可以这样连接:

await mongoose.connect((global as any).testConfig.mongo.uri, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
})

当然在 package.json 里需要禁用 MongoDB Memory Server 去下载二进制包,因为上面已经使用了本地二进制包。

"config": {
  "mongodbMemoryServer": {
    "version": "4.0",
    // 禁止在 yarn install 时下载二进制包
    "disablePostinstall": "1",
    "md5Check": "1"
  }
}

登录功能封装与使用

大多时候接口是需要登录后才能访问的,所以我们需要把整块登录功能抽离出来,封装成通用方法,同时借此初始化一些测试专用数据。

为了使 API 易用,我希望登录 API 长这样:

import { login } from '../@helpers/login'

// 调用登录方法,根据传递的角色创建用户,并返回该用户登录的 request 对象。
// 支持多参数,根据参数不同自动初始化测试数据。
const request = await login({
  role: 'user',
})

// 使用已登录的 request 对象访问需要登录的用户接口,
// 应当是登录态,并正确返回当前登录的用户信息。
const res = await request.get('/api/user/info')

开发登录方法:

// @helpers/agent.ts 
// 新添加 makeAgent 方法
export function makeAgent() {
  // 使用 supertest.agent 支持 cookie 持久化
  return supertest.agent(http.createServer(app.callback()))
}
// @helpers/login.ts
import { assign, cloneDeep, pick } from 'lodash'
import { makeAgent } from './agent'

export async function login(userData: UserDataType): Promise<RequestAgent> {
  userData = cloneDeep(userData)
  
  // 如果没有用户名,自动创建用户名
  if (!userData.username) {
    userData.username = chance.word({ length: 8 })
  }

  // 如果没有昵称,自动创建昵称
  if (!userData.nickname) {
    userData.nickname = chance.word({ length: 8 })
  }

  // 得到支持 cookie 持久化的 request 对象
  const request: any = makeAgent()

  // 发送登录请求,这里为测试专门设计一个登录接口
  // 包含正常登录功能,但还会根据传参不同初始化测试专用数据
  const res = await request.post('/api/login-test').send(userData)

  // 将登录返回的数据赋值到 request 对象上
  assign(request, pick(res.body, ['user', 'otherValidKey...']))

  // 返回 request 对象
  return request as RequestAgent
}

实际用例中就像上面示例方式使用。

模拟功能使用

从项目架构中可以看到项目也会调用较多外部服务。比方说创建文件夹的接口,内部代码需要调用外部服务去鉴定文件夹名称是否包含敏感词,就像这样:

import { detectText } from '~/utils/detect'

// 调用外部服务检测文件夹名称是否包含敏感词
const { ok, sensitiveWords } = await detectText(folderName)
if (!ok) {
  throw new Error(`检测到敏感词: ${sensitiveWords}`)
}

实际测试的时候并不需要所有测试用例运行时都调用外部服务,这样会拖慢测试用例的响应时间以及不稳定性。我们可以建立个更好的机制,新建一个测试套件专门用于验证 detectText 工具方法的正确性,而其他测试套件运行时 detectText 方法直接返回 OK 即可,这样既保证了 detectText 方法被验证到,也保证了其他测试套件得到快速响应。

模拟功能(Mock)就是为这样的情景而诞生的。我们只需要在 detectText 方法的路径 utils/detect.ts 同级新建__mocks__/detect.ts 模拟文件即可,内容如下,直接返回结果:

export async function detectText(
  text: string
): Promise<{ ok: boolean; sensitive: boolean; sensitiveWords?: string }> {
  // 删除所有代码,直接返回 OK
  return { ok: true, sensitive: false }
}

之后每个需要模拟的测试套件顶部加上下面一句代码即可。

jest.mock('~/utils/detect.ts')

在验证 detectText 工具方法的测试套件里,则只需 jest.unmock 即可恢复真实的方法。

jest.unmock('~/utils/detect.ts')

当然应该把 jest.mock 写在 setupFiles 配置里,因为需要模拟的测试套件占绝大多数,写在配置里会让它们在运行前自动加载该文件,这样开发就不必每处测试套件都加上一段同样的代码,可以有效提高开发效率。

// jest.config.js
setupFiles: ['<rootDir>/test/@helpers/jest-setup.ts']
// @helpers/jest-setup.ts
jest.mock('~/utils/detect.ts')

模拟功能还有方法模拟,定时器模拟等,可以查阅其文档了解更多示例。

快照功能使用

快照功能(Snapshot)可以帮我们测试大型对象,从而简化测试用例。

举个例子,项目的模板解析接口,该接口会将 PSD 模板文件进行解析,然后吐出一个较大的 JSON 数据,如果挨个校验对象的属性是否正确可能很不理想,所以可以使用快照功能,就是第一次运行测试用例时,会把 JSON 数据存储到本地文件,称之为快照文件,第二次运行时,就会将第二次返回的数据与快照文件进行比较,如果两个快照匹配,则表示测试成功,反之测试失败。

而使用方式很简单:

// 请求模板解析接口
const res = await request.post('/api/secret/parser')

// 断言快照是否匹配
expect(res.body).toMatchSnapshot()

更新快照也是敏捷的,运行命令 jest --updateSnapshot 或在监听模式输入 u 来更新。

集成测试

集成测试的概念是在单元测试的基础上,将所有模块按照一定要求或流程关系进行串联测试。比方说,一些模块虽然能够单独工作,但并不能保证连接起来也能正常工作,一些局部反映不出来的问题,在全局上很可能暴露出来。

因为测试框架 Jest 对于每个测试套件是并行运行的,而套件内的用例则是串行运行的,所以编写集成测试很方便,下面我们用文件夹的使用流程示例如何完成集成测试的编写。

import { request } from '../@helpers/agent'
import { login } from '../@helpers/login'

const urlCreateFolder = '/api/secret/folder'      // POST
const urlFolderDetails = '/api/secret/folder'     // GET
const urlFetchFolders = '/api/secret/folders'     // GET
const urlDeleteFolder = '/api/secret/folder'      // DELETE
const urlRenameFolder = '/api/secret/folder/rename'     // PUT

const folders: ObjectAny[] = []
let globalReq: ObjectAny

test('没有权限创建文件夹应该返回 403 错误', async () => {
  const res = await request.post(urlCreateFolder).send({
    name: '我的文件夹',
  })
  expect(res.status).toBe(403)
})

test('确保创建 3 个文件夹', async () => {
  // 登录有权限创建文件夹的用户,比如设计师
  globalReq = await login({ role: 'designer' })
  
  for (let i = 0; i < 3; i++) {
    const res = await globalReq.post(urlCreateFolder).send({
      name: '我的文件夹' + i,
    })
    
    // 将创建成功的文件夹置入 folders 常量里
    folders.push(res.body)
    
    expect(res.status).toBe(200)
    // 更多验证规则...
  }
})

test('重命名第 2 个文件夹', async () => {
  const res = await globalReq.put(urlRenameFolder).send({
    id: folders[1].id,
    name: '新文件夹名称',
  })
  expect(res.status).toBe(200)
})

test('第 2 个文件夹的名称应该是【新文件夹名称】', async () => {
  const res = await globalReq.get(urlFolderDetails).query({
    id: folders[1].id,
  })
  expect(res.status).toBe(200)
  expect(res.body.name).toBe('新文件夹名称')
  // 更多验证规则...
})

test('获取文件夹列表应该返回 3 条数据', async () => {
  // 与上雷同,鉴于代码过多,先行省略...
})

test('删除最后一个文件夹', async () => {
  // 与上雷同,鉴于代码过多,先行省略...
})

test('再次获取文件夹列表应该返回 2 条数据', async () => {
  // 与上雷同,鉴于代码过多,先行省略...
})

测试覆盖率

测试覆盖率是对测试完成程度的评测,基于文件被测试的情况来反馈测试的质量。

运行命令 jest --coverage 即可生成测试覆盖率报告,打开生成的 coverage/lcov-report/index.html 文件,各项指标一览无余。因为 Jest 内部使用 Istanbul 生成覆盖率报告,所以各项指标依然参考 Istanbul

测试覆盖率报告

持续集成

写完这么多测试用例之后,或者是开发完功能代码后,我们是不是希望每次将代码推送到托管平台,如 GitLab,托管平台能自动帮我们运行所有测试用例,如果测试失败就邮件通知我们修复,如果测试通过则把开发分支合并到主分支?

答案是必须的。这就与持续集成(Continuous Integration)不谋而合,通俗的讲就是经常性地将代码合并到主干分支,每次合并前都需要运行自动化测试以验证代码的正确性。

所以我们配置一些自动化测试任务,按顺序执行安装、编译、测试等命令,测试命令则是运行编写好的测试用例。一个 GitLab 的配置任务(.gitlab-ci.yml)可能像下面这样,仅作参考。

# 每个 job 之前执行的命令
before_script:
  - echo "`whoami` ($0 $SHELL)"
  - echo "`which node` (`node -v`)"
  - echo $CI_PROJECT_DIR

# 定义 job 所属 test 阶段及执行的命令等
test:
  stage: test
  except:
    - test
  cache:
    paths:
      - node_modules/
  script:
    - yarn
    - yarn lint
    - yarn test

# 定义 job 所属 deploy 阶段及执行的命令等
deploy-stage:
  stage: deploy
  only:
    - test
  script:
    - cd /app 
    - make BRANCH=origin/${CI_COMMIT_REF_NAME} deploy-stage

持续集成的好处:

  1. 快速发现错误。
  2. 防止分支大幅偏离主干分支。
  3. 让产品可以快速迭代,同时还能保持高质量。

TDD与BDD引入

TDD 全称测试驱动开发(Test-driven development),是敏捷开发中的一种设计方法论,强调先将需求转换为具体的测试用例,然后再开发代码以使测试通过。

BDD 全称行为驱动开发(Behavior-driven development),也是一种敏捷开发设计方法论,它没有强调具体的形式如何,而是强调【作为什么角色,想要什么功能,以便收益什么】这样的用户故事指定行为的论点。

两者都是很好的开发模式,结合实际情况,我们的测试更像是 BDD,不过并没有完全摒弃 TDD,我们的建议是如果觉得先写测试可以帮助更快的写好代码,那就先写测试,如果觉得先写代码再写测试,或一边开发一边测试更好,则采用自己的方式,而结果是编码功能和测试用例都需要完成,并且运行通过,最后通过 Code Review 对代码质量做进一步审查与把控。

笔者称之为【师夷长技,聚于自身】:结合项目自身的实际情况,灵活变通,形成一套适合自身项目发展的模式驱动开发。

结论

自动化测试提供了一种有保障的机制检测整个系统,可以频繁地进行回归测试,有效提高系统稳定性。当然编写与维护测试用例需要耗费一定的成本,需要考虑投入与产出效益之间的平衡。

欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

13-横_1575377567139.jpg

查看原文

Barrior 赞了文章 · 2019-12-12

羚珑项目自动化测试方案实践

分享内容及技术栈

本文将分享结合 京东智能设计平台羚珑 项目自身情况搭建的测试工作流的实践经验,针对于 Node.js 服务端应用的工具方法和接口的单元测试、集成测试等。实践经验能给你带来:

  1. 利用 Jest 搭建一套开发体验友好的测试工作流。
  2. 书写一个高效的单元测试用例,及集成测试用例。
  3. 利用封装技术实现模块间的分离,简化测试代码。
  4. 使用 SuperTest 完成应用进程与测试进程的合并。
  5. 创建高效的数据库内存服务,实现彼此隔离的测试套件运行机制。
  6. 了解模拟(Mock)、快照(snapshot)与测试覆盖率等功能的使用。
  7. 理解 TDD 与 BDD。
  8. ...

文中涉及的基础技术栈有(需要了解的知识):

  1. TypeScript: JavaScript 语言的超集,提供类型系统和新 ES 语法支持。
  2. SuperTest: HTTP 代理及断言工具。
  3. MongoDB: NoSQL 分布式文件存储数据库。
  4. Mongoose: MongoDB 对象关系映射操作库(ORM)。
  5. Koa: 基础 Web 应用程序框架。
  6. Jest: 功能丰富的 JavaScript 测试框架。
  7. lodash: JavaScript 工具函数库。

关于羚珑

羚珑Logo

羚珑 是京东旗下智能设计平台,提供在线设计服务,主要包括大类如:

  • 图片设计:快速合成广告图,主图,公众号配图,海报,传单,物流面单等线上与线下设计服务。
  • 视频设计:快速合成主图视频,抖音短视频,自定义视频等设计服务。
  • 页面设计:快速搭建活动页,营销页,小游戏,小程序等设计服务。
  • 实用工具:批量抠图、改尺寸、配色、加水印等。

基于行业领先技术,为商家、用户提供丰富的设计能力,实现快速产出。

羚珑架构及测试框架选型

先介绍下羚珑项目的架构,方便后续的描述和理解。羚珑项目采用前后端分离的机制,前端采用 React Family 的基础架构,再加上 Next.js 服务端渲染以提供更好的用户体验及 SEO 排名。后端架构则如下图所示,流程大概是 浏览器或第三方应用访问项目 Nginx 集群,Nginx 集群再通过负载均衡转发到羚珑应用服务器,应用服务器再通过对接外部服务或内部服务等,或读写缓存、数据库,逻辑处理后通过 HTTP 返回到前端正确的数据。

羚珑服务端架构图

主流测试框架对比

接下来,根据项目所需我们对比下当下 Node.js 端主流的测试框架。

JestMochaAVAJasmine
GitHub Stars28.5K18.7K17.1K14.6K
GitHub Used by1.5M926K46.6K5.3K
文档友好优秀良好良好良好
模拟功能(Mock)支持外置外置外置
快照功能(Snapshot)支持外置支持外置
支持 TypeScriptts-jestts-mochats-nodejasmine-ts
详细的错误输出支持支持支持未知
支持并行与串行支持外置支持外置
每个测试进程隔离支持不支持支持未知

*文档友好:文档结构组织有序,API 阐述完整,以及示例丰富。

分析:

  1. 之所以 Mocha GitHub 使用率很高,很有可能是因为出现的最早(2011年),并由 Node.js 届顶级开发者 TJ 领导开发的(后转向Go语言),所以早期项目选择了 Mocha 做为测试框架,而 JestAVA 则是后起之秀(2014年),并且 Stars 数量都在攀升,预计新项目都会在这两个框架中挑选。
  2. 相比外置功能,内置支持可能会与框架融合的更好,理念更趋近,维护更频繁,使用更省心。
  3. Jest 模拟功能可以实现方法模拟,定时器模拟,模块/文件依赖模拟,在实际编写测试用例中,模拟模块功能(mock modules)被常常用到,它可以确保测试用例快速响应并且不会变化无常。下文也会谈到如何使用它,为什么需要使用它。

综上,我们选择了 Jest 作为基础测试框架。

从0到1落地实践

Jest 框架配置

接下来,我们从 0 到 1 开始实践,首先是搭建测试流,虽然 Jest 可以达到开箱即用,然而项目架构不尽相同,大多时候需要根据实际情况做些基础配置工作。以下是根据羚珑项目提取出来的简化版项目目录结构,如下。

├─ dist                 # TS 编译结果目录
├─ src                  # TS 源码目录
│   ├─ app.ts              # 应用主文件,类似 Express 框架的 /app.js 文件
│   └─ index.ts            # 应用启动文件,类似 Express 框架的 /bin/www 文件
├─ test                 # 测试文件目录
│   ├─ @fixtures           # 测试固定数据
│   ├─ @helpers            # 测试工具方法
│   ├─ module1             # 模块1的测试套件集合
│   │  └─ test-suite.ts       # 测试套件,一类测试用例集合
│   └─ module2             # 模块2的测试套件集合
├─ package.json           
└─ yarn.lock

这里有两个小点:

  1. @ 开头的目录,我们定义为特殊文件目录,用于提供些测试辅助工具方法、配置文件等,平级的其他目录则是测试用例所在的目录,按业务模块或功能划分。以 @ 开头可以清晰的显示在同级目录最上方,很容易开发定位,凑巧也方便了编写正则匹配。
  2. test-suite.ts 是项目内最小测试文件单元,我们称之为测试套件,表示同一类测试用例的集合,可以是某个通用函数的多个测试用例集合,也可以是一个系列的单元测试用例集合。

首先安装测试框架。

yarn add --dev jest ts-jest @types/jest

因为项目是用 TypeScript 编写,所以这里同时安装 ts-jest @types/jest。然后在根目录新建 jest.config.js 配置文件,并做如下小许配置。

module.exports = {
  // preset: 'ts-jest',
  globals: {
    'ts-jest': {
      tsConfig: 'tsconfig.test.json',
    },
  },
  testEnvironment: 'node',
  roots: ['<rootDir>/src/', '<rootDir>/test/'],
  testMatch: ['<rootDir>/test/**/*.ts'],
  testPathIgnorePatterns: ['<rootDir>/test/@.+/'],
  moduleNameMapper: {
    '^~/(.*)': '<rootDir>/src/$1',
  },
}

preset: 预设测试运行环境,多数情况设置为 ts-jest 即可,如果需要为 ts-jest 指定些参数,如上面指定 TS 配置为 tsconfig.test.json,则需要像上面这样的写法,将 ts-jest 挂载到 globals 属性上,更多配置可以移步其官方文档,这里

testEnvironment: 基于预设再设置测试环境,Node.js 需要设置为 node,因为默认值为浏览器环境 jsdom

roots: 用于设定测试监听的目录,如果匹配到的目录的文件有所改动,就会自动运行测试用例。<rootDir> 表示项目根目录,即与 package.json 同级的目录。这里我们监听 srctest 两个目录。

testMatch:Glob 模式设置匹配的测试文件,当然也可以是正则模式,这里我们匹配 test 目录下的所有文件,匹配到的文件才会当做测试用例执行。

testPathIgnorePatterns: 设置已经匹配到的但需要被忽略的文件,这里我们设置以 @ 开头的目录及其所有文件都不当做测试用例。

moduleNameMapper: 这个与 TS pathsWebpack alias 雷同,用于设置目录别名,可以减少引用文件时的出错率并且提高开发效率。这里我们设置以 ~ 开头的模块名指向 src 目录。

第一个单元测试用例

搭建好测试运行环境,于是便可着手编写测试用例了,下面我们编写一个接口单元测试用例,比方说测试首页轮播图接口的正确性。我们将测试用例放在 test/homepage/carousel.ts 文件内,代码如下。

import { forEach, isArray } from 'lodash’
import { JFSRegex, URLRegex } from '~/utils/regex'
import request from 'request-promise'

const baseUrl = 'http://ling-dev.jd.com/server/api'

// 声明一个测试用例
test('轮播图个数应该返回 5,并且数据正确', async () => {
  // 对接口发送 HTTP 请求
  const res = await request.get(baseUrl + '/carousel/pictures')
  
  // 校验返回状态码为 200
  expect(res.statusCode).toBe(200)
  
  // 校验返回数据是数组并且长度为 5
  expect(isArray(res.body)).toBe(true)
  expect(res.body.length).toBe(5)
  
  // 校验数据每一项都是包含正确的 url, href 属性的对象
  forEach(res.body, picture => {
    expect(picture).toMatchObject({
      url: expect.stringMatching(JFSRegex),
      href: expect.stringMatching(URLRegex),
    })
  })
})

编写好测试用例后,第一步需要启动应用服务器:

应用服务运行截图

第二步运行测试,在命令行窗口输入:npx jest,如下图可以看到用例测试通过。

测试用例通过截图

当然最佳实践则是把命令封装到 package.json 里,如下:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
  }
}

之后便可使用 yarn test 来运行测试,通过 yarn test:watch 来启动监听式测试服务。

SuperTest 增强

虽然上面已经完成基本的测试流程开发,但很明显的一个问题是每次运行测试,我们需要先启动应用服务,共启动两个进程,并且需要提前配置 ling-dev.jd.com 指向 127.0.0.1:3800,这是一个繁琐的过程。所以我们引入了 SuperTest,它可以把应用服务集成到测试服务一起启动,并且不需要指定 HTTP 请求的主机地址。

我们封装一个公共的 request 方法,将它放在 @helpers/agent.ts 文件内,如下。

import http from 'http'
import supertest from 'supertest'
import app from '~/app'

export const request = supertest(http.createServer(app.callback()))

解释:

  1. 使用 app.callback() 而不是 app.listen(),是因为它可以将同一个 app 同时作为 HTTP 和 HTTPS 或多个地址。app.callback() 返回适用于 http.createServer() 方法的回调函数来处理请求。
  2. 之后,http.createServer() 创建一个未监听的 HTTP 对象给 SuperTest,当然 SuperTest 内部也会调用 listen(0) 这样的特殊端口,让操作系统提供可用的随机端口来启动应用服务器。

所以上面的测试用例我们可以改写成这样:

import { forEach, isArray } from 'lodash’
import { JFSRegex, URLRegex } from '~/utils/regex'

// 引入公共的 request 方法
import { request } from '../@helpers/agent'

test('轮播图个数应该返回 5,并且数据正确', async () => {
  const res = await request.get('/api/carousel/pictures')
  expect(res.status).toBe(200)
  // 同样的校验...
})

因为 SuperTest 内部已经帮我们包装好了主机地址并自动启动应用服务,所以请求接口时只需书写具体的接口,如 /api/carousel/pictures,也只需运行一条命令 yarn test,就可以完成整个测试工作。

数据库内存服务

项目架构中可以看到数据库使用的是 MongoDB,在测试时,几乎所有的接口都需要与数据库连接。此时可通过环境变量区分并新建 test 数据库,用于运行测试用例。有点不好的是测试套件执行完成后需要对 test 数据库进行清空,以避免脏数据影响下个测试套件,尤其是在并发运行时,需要保持数据隔离。

使用 MongoDB Memory Server 是更好的选择,它会启动独立的 MongoDB 实例(每个实例大约占用非常低的 7MB 内存),而测试套件将运行在这个独立的实例里。假如并发为 3,那就创建 3 个实例分别运行 3 个测试套件,这样可以很好的保持数据隔离,并且数据都保存在内存中,这使得运行速度会非常快,当测试套件完成后则自动销毁实例。

MongoDB Memory Server

接下来我们把 MongoDB Memory Server 引入实际测试中,最佳方式是把它写进 Jest 环境配置里,这样只需要一次书写,自动运行在每个测试套件中。所以替换 jest.config.js 配置文件的 testEnvironment 为自定义环境 <rootDir>/test/@helpers/jest-env.js

编写自定义环境 @helpers/jest-env.js

const NodeEnvironment = require('jest-environment-node')
const { MongoMemoryServer } = require('mongodb-memory-server')
const child_process = require('child_process')

// 继承 Node 环境
class CustomEnvironment extends NodeEnvironment {
  // 在测试套件启动前,获取本地开发 MongoDB Uri 并注入 global 对象
  async setup() {
    const uri = await getMongoUri()
    this.global.testConfig = {
      mongo: { uri },
    }
    await super.setup()
  }
}

async function getMongoUri() {
  // 通过 which mongod 命令拿到本地 MongoDB 二进制文件路径
  const mongodPath = await new Promise((resolve, reject) => {
    child_process.exec(
      'which mongod',
      { encoding: 'utf8' },
      (err, stdout, stderr) => {
        if (err || stderr) {
          return reject(
            new Error('找不到系统的 mongod,请确保 `which mongod` 可以指向 mongod')
          )
        }
        resolve(stdout.trim())
      }
    )
  })

  // 使用本地 MongoDB 二进制文件创建内存服务实例
  const mongod = new MongoMemoryServer({
    binary: { systemBinary: mongodPath },
  })
  
  // 得到创建成功的实例 Uri 地址
  const uri = await mongod.getConnectionString()
  return uri
}

// 导出自定义环境类
module.exports = CustomEnvironment

Mongoose 中便可以这样连接:

await mongoose.connect((global as any).testConfig.mongo.uri, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
})

当然在 package.json 里需要禁用 MongoDB Memory Server 去下载二进制包,因为上面已经使用了本地二进制包。

"config": {
  "mongodbMemoryServer": {
    "version": "4.0",
    // 禁止在 yarn install 时下载二进制包
    "disablePostinstall": "1",
    "md5Check": "1"
  }
}

登录功能封装与使用

大多时候接口是需要登录后才能访问的,所以我们需要把整块登录功能抽离出来,封装成通用方法,同时借此初始化一些测试专用数据。

为了使 API 易用,我希望登录 API 长这样:

import { login } from '../@helpers/login'

// 调用登录方法,根据传递的角色创建用户,并返回该用户登录的 request 对象。
// 支持多参数,根据参数不同自动初始化测试数据。
const request = await login({
  role: 'user',
})

// 使用已登录的 request 对象访问需要登录的用户接口,
// 应当是登录态,并正确返回当前登录的用户信息。
const res = await request.get('/api/user/info')

开发登录方法:

// @helpers/agent.ts 
// 新添加 makeAgent 方法
export function makeAgent() {
  // 使用 supertest.agent 支持 cookie 持久化
  return supertest.agent(http.createServer(app.callback()))
}
// @helpers/login.ts
import { assign, cloneDeep, pick } from 'lodash'
import { makeAgent } from './agent'

export async function login(userData: UserDataType): Promise<RequestAgent> {
  userData = cloneDeep(userData)
  
  // 如果没有用户名,自动创建用户名
  if (!userData.username) {
    userData.username = chance.word({ length: 8 })
  }

  // 如果没有昵称,自动创建昵称
  if (!userData.nickname) {
    userData.nickname = chance.word({ length: 8 })
  }

  // 得到支持 cookie 持久化的 request 对象
  const request: any = makeAgent()

  // 发送登录请求,这里为测试专门设计一个登录接口
  // 包含正常登录功能,但还会根据传参不同初始化测试专用数据
  const res = await request.post('/api/login-test').send(userData)

  // 将登录返回的数据赋值到 request 对象上
  assign(request, pick(res.body, ['user', 'otherValidKey...']))

  // 返回 request 对象
  return request as RequestAgent
}

实际用例中就像上面示例方式使用。

模拟功能使用

从项目架构中可以看到项目也会调用较多外部服务。比方说创建文件夹的接口,内部代码需要调用外部服务去鉴定文件夹名称是否包含敏感词,就像这样:

import { detectText } from '~/utils/detect'

// 调用外部服务检测文件夹名称是否包含敏感词
const { ok, sensitiveWords } = await detectText(folderName)
if (!ok) {
  throw new Error(`检测到敏感词: ${sensitiveWords}`)
}

实际测试的时候并不需要所有测试用例运行时都调用外部服务,这样会拖慢测试用例的响应时间以及不稳定性。我们可以建立个更好的机制,新建一个测试套件专门用于验证 detectText 工具方法的正确性,而其他测试套件运行时 detectText 方法直接返回 OK 即可,这样既保证了 detectText 方法被验证到,也保证了其他测试套件得到快速响应。

模拟功能(Mock)就是为这样的情景而诞生的。我们只需要在 detectText 方法的路径 utils/detect.ts 同级新建__mocks__/detect.ts 模拟文件即可,内容如下,直接返回结果:

export async function detectText(
  text: string
): Promise<{ ok: boolean; sensitive: boolean; sensitiveWords?: string }> {
  // 删除所有代码,直接返回 OK
  return { ok: true, sensitive: false }
}

之后每个需要模拟的测试套件顶部加上下面一句代码即可。

jest.mock('~/utils/detect.ts')

在验证 detectText 工具方法的测试套件里,则只需 jest.unmock 即可恢复真实的方法。

jest.unmock('~/utils/detect.ts')

当然应该把 jest.mock 写在 setupFiles 配置里,因为需要模拟的测试套件占绝大多数,写在配置里会让它们在运行前自动加载该文件,这样开发就不必每处测试套件都加上一段同样的代码,可以有效提高开发效率。

// jest.config.js
setupFiles: ['<rootDir>/test/@helpers/jest-setup.ts']
// @helpers/jest-setup.ts
jest.mock('~/utils/detect.ts')

模拟功能还有方法模拟,定时器模拟等,可以查阅其文档了解更多示例。

快照功能使用

快照功能(Snapshot)可以帮我们测试大型对象,从而简化测试用例。

举个例子,项目的模板解析接口,该接口会将 PSD 模板文件进行解析,然后吐出一个较大的 JSON 数据,如果挨个校验对象的属性是否正确可能很不理想,所以可以使用快照功能,就是第一次运行测试用例时,会把 JSON 数据存储到本地文件,称之为快照文件,第二次运行时,就会将第二次返回的数据与快照文件进行比较,如果两个快照匹配,则表示测试成功,反之测试失败。

而使用方式很简单:

// 请求模板解析接口
const res = await request.post('/api/secret/parser')

// 断言快照是否匹配
expect(res.body).toMatchSnapshot()

更新快照也是敏捷的,运行命令 jest --updateSnapshot 或在监听模式输入 u 来更新。

集成测试

集成测试的概念是在单元测试的基础上,将所有模块按照一定要求或流程关系进行串联测试。比方说,一些模块虽然能够单独工作,但并不能保证连接起来也能正常工作,一些局部反映不出来的问题,在全局上很可能暴露出来。

因为测试框架 Jest 对于每个测试套件是并行运行的,而套件内的用例则是串行运行的,所以编写集成测试很方便,下面我们用文件夹的使用流程示例如何完成集成测试的编写。

import { request } from '../@helpers/agent'
import { login } from '../@helpers/login'

const urlCreateFolder = '/api/secret/folder'      // POST
const urlFolderDetails = '/api/secret/folder'     // GET
const urlFetchFolders = '/api/secret/folders'     // GET
const urlDeleteFolder = '/api/secret/folder'      // DELETE
const urlRenameFolder = '/api/secret/folder/rename'     // PUT

const folders: ObjectAny[] = []
let globalReq: ObjectAny

test('没有权限创建文件夹应该返回 403 错误', async () => {
  const res = await request.post(urlCreateFolder).send({
    name: '我的文件夹',
  })
  expect(res.status).toBe(403)
})

test('确保创建 3 个文件夹', async () => {
  // 登录有权限创建文件夹的用户,比如设计师
  globalReq = await login({ role: 'designer' })
  
  for (let i = 0; i < 3; i++) {
    const res = await globalReq.post(urlCreateFolder).send({
      name: '我的文件夹' + i,
    })
    
    // 将创建成功的文件夹置入 folders 常量里
    folders.push(res.body)
    
    expect(res.status).toBe(200)
    // 更多验证规则...
  }
})

test('重命名第 2 个文件夹', async () => {
  const res = await globalReq.put(urlRenameFolder).send({
    id: folders[1].id,
    name: '新文件夹名称',
  })
  expect(res.status).toBe(200)
})

test('第 2 个文件夹的名称应该是【新文件夹名称】', async () => {
  const res = await globalReq.get(urlFolderDetails).query({
    id: folders[1].id,
  })
  expect(res.status).toBe(200)
  expect(res.body.name).toBe('新文件夹名称')
  // 更多验证规则...
})

test('获取文件夹列表应该返回 3 条数据', async () => {
  // 与上雷同,鉴于代码过多,先行省略...
})

test('删除最后一个文件夹', async () => {
  // 与上雷同,鉴于代码过多,先行省略...
})

test('再次获取文件夹列表应该返回 2 条数据', async () => {
  // 与上雷同,鉴于代码过多,先行省略...
})

测试覆盖率

测试覆盖率是对测试完成程度的评测,基于文件被测试的情况来反馈测试的质量。

运行命令 jest --coverage 即可生成测试覆盖率报告,打开生成的 coverage/lcov-report/index.html 文件,各项指标一览无余。因为 Jest 内部使用 Istanbul 生成覆盖率报告,所以各项指标依然参考 Istanbul

测试覆盖率报告

持续集成

写完这么多测试用例之后,或者是开发完功能代码后,我们是不是希望每次将代码推送到托管平台,如 GitLab,托管平台能自动帮我们运行所有测试用例,如果测试失败就邮件通知我们修复,如果测试通过则把开发分支合并到主分支?

答案是必须的。这就与持续集成(Continuous Integration)不谋而合,通俗的讲就是经常性地将代码合并到主干分支,每次合并前都需要运行自动化测试以验证代码的正确性。

所以我们配置一些自动化测试任务,按顺序执行安装、编译、测试等命令,测试命令则是运行编写好的测试用例。一个 GitLab 的配置任务(.gitlab-ci.yml)可能像下面这样,仅作参考。

# 每个 job 之前执行的命令
before_script:
  - echo "`whoami` ($0 $SHELL)"
  - echo "`which node` (`node -v`)"
  - echo $CI_PROJECT_DIR

# 定义 job 所属 test 阶段及执行的命令等
test:
  stage: test
  except:
    - test
  cache:
    paths:
      - node_modules/
  script:
    - yarn
    - yarn lint
    - yarn test

# 定义 job 所属 deploy 阶段及执行的命令等
deploy-stage:
  stage: deploy
  only:
    - test
  script:
    - cd /app 
    - make BRANCH=origin/${CI_COMMIT_REF_NAME} deploy-stage

持续集成的好处:

  1. 快速发现错误。
  2. 防止分支大幅偏离主干分支。
  3. 让产品可以快速迭代,同时还能保持高质量。

TDD与BDD引入

TDD 全称测试驱动开发(Test-driven development),是敏捷开发中的一种设计方法论,强调先将需求转换为具体的测试用例,然后再开发代码以使测试通过。

BDD 全称行为驱动开发(Behavior-driven development),也是一种敏捷开发设计方法论,它没有强调具体的形式如何,而是强调【作为什么角色,想要什么功能,以便收益什么】这样的用户故事指定行为的论点。

两者都是很好的开发模式,结合实际情况,我们的测试更像是 BDD,不过并没有完全摒弃 TDD,我们的建议是如果觉得先写测试可以帮助更快的写好代码,那就先写测试,如果觉得先写代码再写测试,或一边开发一边测试更好,则采用自己的方式,而结果是编码功能和测试用例都需要完成,并且运行通过,最后通过 Code Review 对代码质量做进一步审查与把控。

笔者称之为【师夷长技,聚于自身】:结合项目自身的实际情况,灵活变通,形成一套适合自身项目发展的模式驱动开发。

结论

自动化测试提供了一种有保障的机制检测整个系统,可以频繁地进行回归测试,有效提高系统稳定性。当然编写与维护测试用例需要耗费一定的成本,需要考虑投入与产出效益之间的平衡。

欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

13-横_1575377567139.jpg

查看原文

赞 16 收藏 10 评论 1

Barrior 收藏了文章 · 2019-11-15

服务端高并发分布式架构演进之路

1. 概述

本文以淘宝作为例子,介绍从一百个到千万级并发情况下服务端的架构的演进过程,同时列举出每个演进阶段会遇到的相关技术,让大家对架构的演进有一个整体的认知,文章最后汇总了一些架构设计的原则。

特别说明:本文以淘宝为例仅仅是为了便于说明演进过程可能遇到的问题,并非是淘宝真正的技术演进路径

2. 基本概念

在介绍架构之前,为了避免部分读者对架构设计中的一些概念不了解,下面对几个最基础的概念进行介绍:

  • 分布式
    系统中的多个模块在不同服务器上部署,即可称为分布式系统,如Tomcat和数据库分别部署在不同的服务器上,或两个相同功能的Tomcat分别部署在不同服务器上
  • 高可用
    系统中部分节点失效时,其他节点能够接替它继续提供服务,则可认为系统具有高可用性
  • 集群
    一个特定领域的软件部署在多台服务器上并作为一个整体提供一类服务,这个整体称为集群。如Zookeeper中的Master和Slave分别部署在多台服务器上,共同组成一个整体提供集中配置服务。在常见的集群中,客户端往往能够连接任意一个节点获得服务,并且当集群中一个节点掉线时,其他节点往往能够自动的接替它继续提供服务,这时候说明集群具有高可用性
  • 负载均衡
    请求发送到系统时,通过某些方式把请求均匀分发到多个节点上,使系统中每个节点能够均匀的处理请求负载,则可认为系统是负载均衡的
  • 正向代理和反向代理
    系统内部要访问外部网络时,统一通过一个代理服务器把请求转发出去,在外部网络看来就是代理服务器发起的访问,此时代理服务器实现的是正向代理;当外部请求进入系统时,代理服务器把该请求转发到系统中的某台服务器上,对外部请求来说,与之交互的只有代理服务器,此时代理服务器实现的是反向代理。简单来说,正向代理是代理服务器代替系统内部来访问外部网络的过程,反向代理是外部请求访问系统时通过代理服务器转发到内部服务器的过程。

3. 架构演进

3.1 单机架构

clipboard.png

以淘宝作为例子。在网站最初时,应用数量与用户数都较少,可以把Tomcat和数据库部署在同一台服务器上。浏览器往www.taobao.com发起请求时,首先经过DNS服务器(域名系统)把域名转换为实际IP地址10.102.4.1,浏览器转而访问该IP对应的Tomcat。

随着用户数的增长,Tomcat和数据库之间竞争资源,单机性能不足以支撑业务

3.2 第一次演进:Tomcat与数据库分开部署

clipboard.png

Tomcat和数据库分别独占服务器资源,显著提高两者各自性能。

随着用户数的增长,并发读写数据库成为瓶颈

3.3 第二次演进:引入本地缓存和分布式缓存

clipboard.png

在Tomcat同服务器上或同JVM中增加本地缓存,并在外部增加分布式缓存,缓存热门商品信息或热门商品的html页面等。通过缓存能把绝大多数请求在读写数据库前拦截掉,大大降低数据库压力。其中涉及的技术包括:使用memcached作为本地缓存,使用Redis作为分布式缓存,还会涉及缓存一致性、缓存穿透/击穿、缓存雪崩、热点数据集中失效等问题。

缓存抗住了大部分的访问请求,随着用户数的增长,并发压力主要落在单机的Tomcat上,响应逐渐变慢

3.4 第三次演进:引入反向代理实现负载均衡

clipboard.png

在多台服务器上分别部署Tomcat,使用反向代理软件(Nginx)把请求均匀分发到每个Tomcat中。此处假设Tomcat最多支持100个并发,Nginx最多支持50000个并发,那么理论上Nginx把请求分发到500个Tomcat上,就能抗住50000个并发。其中涉及的技术包括:Nginx、HAProxy,两者都是工作在网络第七层的反向代理软件,主要支持http协议,还会涉及session共享、文件上传下载的问题。

反向代理使应用服务器可支持的并发量大大增加,但并发量的增长也意味着更多请求穿透到数据库,单机的数据库最终成为瓶颈

3.5 第四次演进:数据库读写分离

clipboard.png

把数据库划分为读库和写库,读库可以有多个,通过同步机制把写库的数据同步到读库,对于需要查询最新写入数据场景,可通过在缓存中多写一份,通过缓存获得最新数据。其中涉及的技术包括:Mycat,它是数据库中间件,可通过它来组织数据库的分离读写和分库分表,客户端通过它来访问下层数据库,还会涉及数据同步,数据一致性的问题。

业务逐渐变多,不同业务之间的访问量差距较大,不同业务直接竞争数据库,相互影响性能

3.6 第五次演进:数据库按业务分库

clipboard.png

把不同业务的数据保存到不同的数据库中,使业务之间的资源竞争降低,对于访问量大的业务,可以部署更多的服务器来支撑。这样同时导致跨业务的表无法直接做关联分析,需要通过其他途径来解决,但这不是本文讨论的重点,有兴趣的可以自行搜索解决方案。

随着用户数的增长,单机的写库会逐渐会达到性能瓶颈

3.7 第六次演进:把大表拆分为小表

clipboard.png

比如针对评论数据,可按照商品ID进行hash,路由到对应的表中存储;针对支付记录,可按照小时创建表,每个小时表继续拆分为小表,使用用户ID或记录编号来路由数据。只要实时操作的表数据量足够小,请求能够足够均匀的分发到多台服务器上的小表,那数据库就能通过水平扩展的方式来提高性能。其中前面提到的Mycat也支持在大表拆分为小表情况下的访问控制。

这种做法显著的增加了数据库运维的难度,对DBA的要求较高。数据库设计到这种结构时,已经可以称为分布式数据库,但是这只是一个逻辑的数据库整体,数据库里不同的组成部分是由不同的组件单独来实现的,如分库分表的管理和请求分发,由Mycat实现,SQL的解析由单机的数据库实现,读写分离可能由网关和消息队列来实现,查询结果的汇总可能由数据库接口层来实现等等,这种架构其实是MPP(大规模并行处理)架构的一类实现。

目前开源和商用都已经有不少MPP数据库,开源中比较流行的有Greenplum、TiDB、Postgresql XC、HAWQ等,商用的如南大通用的GBase、睿帆科技的雪球DB、华为的LibrA等等,不同的MPP数据库的侧重点也不一样,如TiDB更侧重于分布式OLTP场景,Greenplum更侧重于分布式OLAP场景,这些MPP数据库基本都提供了类似Postgresql、Oracle、MySQL那样的SQL标准支持能力,能把一个查询解析为分布式的执行计划分发到每台机器上并行执行,最终由数据库本身汇总数据进行返回,也提供了诸如权限管理、分库分表、事务、数据副本等能力,并且大多能够支持100个节点以上的集群,大大降低了数据库运维的成本,并且使数据库也能够实现水平扩展。

数据库和Tomcat都能够水平扩展,可支撑的并发大幅提高,随着用户数的增长,最终单机的Nginx会成为瓶颈

3.8 第七次演进:使用LVS或F5来使多个Nginx负载均衡

clipboard.png

由于瓶颈在Nginx,因此无法通过两层的Nginx来实现多个Nginx的负载均衡。图中的LVS和F5是工作在网络第四层的负载均衡解决方案,其中LVS是软件,运行在操作系统内核态,可对TCP请求或更高层级的网络协议进行转发,因此支持的协议更丰富,并且性能也远高于Nginx,可假设单机的LVS可支持几十万个并发的请求转发;F5是一种负载均衡硬件,与LVS提供的能力类似,性能比LVS更高,但价格昂贵。由于LVS是单机版的软件,若LVS所在服务器宕机则会导致整个后端系统都无法访问,因此需要有备用节点。可使用keepalived软件模拟出虚拟IP,然后把虚拟IP绑定到多台LVS服务器上,浏览器访问虚拟IP时,会被路由器重定向到真实的LVS服务器,当主LVS服务器宕机时,keepalived软件会自动更新路由器中的路由表,把虚拟IP重定向到另外一台正常的LVS服务器,从而达到LVS服务器高可用的效果。

此处需要注意的是,上图中从Nginx层到Tomcat层这样画并不代表全部Nginx都转发请求到全部的Tomcat,在实际使用时,可能会是几个Nginx下面接一部分的Tomcat,这些Nginx之间通过keepalived实现高可用,其他的Nginx接另外的Tomcat,这样可接入的Tomcat数量就能成倍的增加。

由于LVS也是单机的,随着并发数增长到几十万时,LVS服务器最终会达到瓶颈,此时用户数达到千万甚至上亿级别,用户分布在不同的地区,与服务器机房距离不同,导致了访问的延迟会明显不同

3.9 第八次演进:通过DNS轮询实现机房间的负载均衡

clipboard.png

在DNS服务器中可配置一个域名对应多个IP地址,每个IP地址对应到不同的机房里的虚拟IP。当用户访问www.taobao.com时,DNS服务器会使用轮询策略或其他策略,来选择某个IP供用户访问。此方式能实现机房间的负载均衡,至此,系统可做到机房级别的水平扩展,千万级到亿级的并发量都可通过增加机房来解决,系统入口处的请求并发量不再是问题。

随着数据的丰富程度和业务的发展,检索、分析等需求越来越丰富,单单依靠数据库无法解决如此丰富的需求

3.10 第九次演进:引入NoSQL数据库和搜索引擎等技术

clipboard.png

当数据库中的数据多到一定规模时,数据库就不适用于复杂的查询了,往往只能满足普通查询的场景。对于统计报表场景,在数据量大时不一定能跑出结果,而且在跑复杂查询时会导致其他查询变慢,对于全文检索、可变数据结构等场景,数据库天生不适用。因此需要针对特定的场景,引入合适的解决方案。如对于海量文件存储,可通过分布式文件系统HDFS解决,对于key value类型的数据,可通过HBase和Redis等方案解决,对于全文检索场景,可通过搜索引擎如ElasticSearch解决,对于多维分析场景,可通过Kylin或Druid等方案解决。

当然,引入更多组件同时会提高系统的复杂度,不同的组件保存的数据需要同步,需要考虑一致性的问题,需要有更多的运维手段来管理这些组件等。

引入更多组件解决了丰富的需求,业务维度能够极大扩充,随之而来的是一个应用中包含了太多的业务代码,业务的升级迭代变得困难

3.11 第十次演进:大应用拆分为小应用

clipboard.png

按照业务板块来划分应用代码,使单个应用的职责更清晰,相互之间可以做到独立升级迭代。这时候应用之间可能会涉及到一些公共配置,可以通过分布式配置中心Zookeeper来解决。

不同应用之间存在共用的模块,由应用单独管理会导致相同代码存在多份,导致公共功能升级时全部应用代码都要跟着升级

3.12 第十一次演进:复用的功能抽离成微服务

clipboard.png

如用户管理、订单、支付、鉴权等功能在多个应用中都存在,那么可以把这些功能的代码单独抽取出来形成一个单独的服务来管理,这样的服务就是所谓的微服务,应用和服务之间通过HTTP、TCP或RPC请求等多种方式来访问公共服务,每个单独的服务都可以由单独的团队来管理。此外,可以通过Dubbo、SpringCloud等框架实现服务治理、限流、熔断、降级等功能,提高服务的稳定性和可用性。

不同服务的接口访问方式不同,应用代码需要适配多种访问方式才能使用服务,此外,应用访问服务,服务之间也可能相互访问,调用链将会变得非常复杂,逻辑变得混乱

3.13 第十二次演进:引入企业服务总线ESB屏蔽服务接口的访问差异

clipboard.png

通过ESB统一进行访问协议转换,应用统一通过ESB来访问后端服务,服务与服务之间也通过ESB来相互调用,以此降低系统的耦合程度。这种单个应用拆分为多个应用,公共服务单独抽取出来来管理,并使用企业消息总线来解除服务之间耦合问题的架构,就是所谓的SOA(面向服务)架构,这种架构与微服务架构容易混淆,因为表现形式十分相似。个人理解,微服务架构更多是指把系统里的公共服务抽取出来单独运维管理的思想,而SOA架构则是指一种拆分服务并使服务接口访问变得统一的架构思想,SOA架构中包含了微服务的思想。

业务不断发展,应用和服务都会不断变多,应用和服务的部署变得复杂,同一台服务器上部署多个服务还要解决运行环境冲突的问题,此外,对于如大促这类需要动态扩缩容的场景,需要水平扩展服务的性能,就需要在新增的服务上准备运行环境,部署服务等,运维将变得十分困难

3.14 第十三次演进:引入容器化技术实现运行环境隔离与动态服务管理

clipboard.png

目前最流行的容器化技术是Docker,最流行的容器管理服务是Kubernetes(K8S),应用/服务可以打包为Docker镜像,通过K8S来动态分发和部署镜像。Docker镜像可理解为一个能运行你的应用/服务的最小的操作系统,里面放着应用/服务的运行代码,运行环境根据实际的需要设置好。把整个“操作系统”打包为一个镜像后,就可以分发到需要部署相关服务的机器上,直接启动Docker镜像就可以把服务起起来,使服务的部署和运维变得简单。

在大促的之前,可以在现有的机器集群上划分出服务器来启动Docker镜像,增强服务的性能,大促过后就可以关闭镜像,对机器上的其他服务不造成影响(在3.14节之前,服务运行在新增机器上需要修改系统配置来适配服务,这会导致机器上其他服务需要的运行环境被破坏)。

使用容器化技术后服务动态扩缩容问题得以解决,但是机器还是需要公司自身来管理,在非大促的时候,还是需要闲置着大量的机器资源来应对大促,机器自身成本和运维成本都极高,资源利用率低

3.15 第十四次演进:以云平台承载系统

clipboard.png

系统可部署到公有云上,利用公有云的海量机器资源,解决动态硬件资源的问题,在大促的时间段里,在云平台中临时申请更多的资源,结合Docker和K8S来快速部署服务,在大促结束后释放资源,真正做到按需付费,资源利用率大大提高,同时大大降低了运维成本。

所谓的云平台,就是把海量机器资源,通过统一的资源管理,抽象为一个资源整体,在之上可按需动态申请硬件资源(如CPU、内存、网络等),并且之上提供通用的操作系统,提供常用的技术组件(如Hadoop技术栈,MPP数据库等)供用户使用,甚至提供开发好的应用,用户不需要关系应用内部使用了什么技术,就能够解决需求(如音视频转码服务、邮件服务、个人博客等)。在云平台中会涉及如下几个概念:

  • IaaS:基础设施即服务。对应于上面所说的机器资源统一为资源整体,可动态申请硬件资源的层面;
  • PaaS:平台即服务。对应于上面所说的提供常用的技术组件方便系统的开发和维护;
  • SaaS:软件即服务。对应于上面所说的提供开发好的应用或服务,按功能或性能要求付费。
至此,以上所提到的从高并发访问问题,到服务的架构和系统实施的层面都有了各自的解决方案,但同时也应该意识到,在上面的介绍中,其实是有意忽略了诸如跨机房数据同步、分布式事务实现等等的实际问题,这些问题以后有机会再拿出来单独讨论

4. 架构设计总结

  • 架构的调整是否必须按照上述演变路径进行?
    不是的,以上所说的架构演变顺序只是针对某个侧面进行单独的改进,在实际场景中,可能同一时间会有几个问题需要解决,或者可能先达到瓶颈的是另外的方面,这时候就应该按照实际问题实际解决。如在政府类的并发量可能不大,但业务可能很丰富的场景,高并发就不是重点解决的问题,此时优先需要的可能会是丰富需求的解决方案。
  • 对于将要实施的系统,架构应该设计到什么程度?
    对于单次实施并且性能指标明确的系统,架构设计到能够支持系统的性能指标要求就足够了,但要留有扩展架构的接口以便不备之需。对于不断发展的系统,如电商平台,应设计到能满足下一阶段用户量和性能指标要求的程度,并根据业务的增长不断的迭代升级架构,以支持更高的并发和更丰富的业务。
  • 服务端架构和大数据架构有什么区别?
    所谓的“大数据”其实是海量数据采集清洗转换、数据存储、数据分析、数据服务等场景解决方案的一个统称,在每一个场景都包含了多种可选的技术,如数据采集有Flume、Sqoop、Kettle等,数据存储有分布式文件系统HDFS、FastDFS,NoSQL数据库HBase、MongoDB等,数据分析有Spark技术栈、机器学习算法等。总的来说大数据架构就是根据业务的需求,整合各种大数据组件组合而成的架构,一般会提供分布式存储、分布式计算、多维分析、数据仓库、机器学习算法等能力。而服务端架构更多指的是应用组织层面的架构,底层能力往往是由大数据架构来提供。
  • 有没有一些架构设计的原则?

    • N+1设计。系统中的每个组件都应做到没有单点故障;
    • 回滚设计。确保系统可以向前兼容,在系统升级时应能有办法回滚版本;
    • 禁用设计。应该提供控制具体功能是否可用的配置,在系统出现故障时能够快速下线功能;
    • 监控设计。在设计阶段就要考虑监控的手段;
    • 多活数据中心设计。若系统需要极高的高可用,应考虑在多地实施数据中心进行多活,至少在一个机房断电的情况下系统依然可用;
    • 采用成熟的技术。刚开发的或开源的技术往往存在很多隐藏的bug,出了问题没有商业支持可能会是一个灾难;
    • 资源隔离设计。应避免单一业务占用全部资源;
    • 架构应能水平扩展。系统只有做到能水平扩展,才能有效避免瓶颈问题;
    • 非核心则购买。非核心功能若需要占用大量的研发资源才能解决,则考虑购买成熟的产品;
    • 使用商用硬件。商用硬件能有效降低硬件故障的机率;
    • 快速迭代。系统应该快速开发小功能模块,尽快上线进行验证,早日发现问题大大降低系统交付的风险;
    • 无状态设计。服务接口应该做成无状态的,当前接口的访问不依赖于接口上次访问的状态。
查看原文

Barrior 赞了文章 · 2019-11-15

服务端高并发分布式架构演进之路

1. 概述

本文以淘宝作为例子,介绍从一百个到千万级并发情况下服务端的架构的演进过程,同时列举出每个演进阶段会遇到的相关技术,让大家对架构的演进有一个整体的认知,文章最后汇总了一些架构设计的原则。

特别说明:本文以淘宝为例仅仅是为了便于说明演进过程可能遇到的问题,并非是淘宝真正的技术演进路径

2. 基本概念

在介绍架构之前,为了避免部分读者对架构设计中的一些概念不了解,下面对几个最基础的概念进行介绍:

  • 分布式
    系统中的多个模块在不同服务器上部署,即可称为分布式系统,如Tomcat和数据库分别部署在不同的服务器上,或两个相同功能的Tomcat分别部署在不同服务器上
  • 高可用
    系统中部分节点失效时,其他节点能够接替它继续提供服务,则可认为系统具有高可用性
  • 集群
    一个特定领域的软件部署在多台服务器上并作为一个整体提供一类服务,这个整体称为集群。如Zookeeper中的Master和Slave分别部署在多台服务器上,共同组成一个整体提供集中配置服务。在常见的集群中,客户端往往能够连接任意一个节点获得服务,并且当集群中一个节点掉线时,其他节点往往能够自动的接替它继续提供服务,这时候说明集群具有高可用性
  • 负载均衡
    请求发送到系统时,通过某些方式把请求均匀分发到多个节点上,使系统中每个节点能够均匀的处理请求负载,则可认为系统是负载均衡的
  • 正向代理和反向代理
    系统内部要访问外部网络时,统一通过一个代理服务器把请求转发出去,在外部网络看来就是代理服务器发起的访问,此时代理服务器实现的是正向代理;当外部请求进入系统时,代理服务器把该请求转发到系统中的某台服务器上,对外部请求来说,与之交互的只有代理服务器,此时代理服务器实现的是反向代理。简单来说,正向代理是代理服务器代替系统内部来访问外部网络的过程,反向代理是外部请求访问系统时通过代理服务器转发到内部服务器的过程。

3. 架构演进

3.1 单机架构

clipboard.png

以淘宝作为例子。在网站最初时,应用数量与用户数都较少,可以把Tomcat和数据库部署在同一台服务器上。浏览器往www.taobao.com发起请求时,首先经过DNS服务器(域名系统)把域名转换为实际IP地址10.102.4.1,浏览器转而访问该IP对应的Tomcat。

随着用户数的增长,Tomcat和数据库之间竞争资源,单机性能不足以支撑业务

3.2 第一次演进:Tomcat与数据库分开部署

clipboard.png

Tomcat和数据库分别独占服务器资源,显著提高两者各自性能。

随着用户数的增长,并发读写数据库成为瓶颈

3.3 第二次演进:引入本地缓存和分布式缓存

clipboard.png

在Tomcat同服务器上或同JVM中增加本地缓存,并在外部增加分布式缓存,缓存热门商品信息或热门商品的html页面等。通过缓存能把绝大多数请求在读写数据库前拦截掉,大大降低数据库压力。其中涉及的技术包括:使用memcached作为本地缓存,使用Redis作为分布式缓存,还会涉及缓存一致性、缓存穿透/击穿、缓存雪崩、热点数据集中失效等问题。

缓存抗住了大部分的访问请求,随着用户数的增长,并发压力主要落在单机的Tomcat上,响应逐渐变慢

3.4 第三次演进:引入反向代理实现负载均衡

clipboard.png

在多台服务器上分别部署Tomcat,使用反向代理软件(Nginx)把请求均匀分发到每个Tomcat中。此处假设Tomcat最多支持100个并发,Nginx最多支持50000个并发,那么理论上Nginx把请求分发到500个Tomcat上,就能抗住50000个并发。其中涉及的技术包括:Nginx、HAProxy,两者都是工作在网络第七层的反向代理软件,主要支持http协议,还会涉及session共享、文件上传下载的问题。

反向代理使应用服务器可支持的并发量大大增加,但并发量的增长也意味着更多请求穿透到数据库,单机的数据库最终成为瓶颈

3.5 第四次演进:数据库读写分离

clipboard.png

把数据库划分为读库和写库,读库可以有多个,通过同步机制把写库的数据同步到读库,对于需要查询最新写入数据场景,可通过在缓存中多写一份,通过缓存获得最新数据。其中涉及的技术包括:Mycat,它是数据库中间件,可通过它来组织数据库的分离读写和分库分表,客户端通过它来访问下层数据库,还会涉及数据同步,数据一致性的问题。

业务逐渐变多,不同业务之间的访问量差距较大,不同业务直接竞争数据库,相互影响性能

3.6 第五次演进:数据库按业务分库

clipboard.png

把不同业务的数据保存到不同的数据库中,使业务之间的资源竞争降低,对于访问量大的业务,可以部署更多的服务器来支撑。这样同时导致跨业务的表无法直接做关联分析,需要通过其他途径来解决,但这不是本文讨论的重点,有兴趣的可以自行搜索解决方案。

随着用户数的增长,单机的写库会逐渐会达到性能瓶颈

3.7 第六次演进:把大表拆分为小表

clipboard.png

比如针对评论数据,可按照商品ID进行hash,路由到对应的表中存储;针对支付记录,可按照小时创建表,每个小时表继续拆分为小表,使用用户ID或记录编号来路由数据。只要实时操作的表数据量足够小,请求能够足够均匀的分发到多台服务器上的小表,那数据库就能通过水平扩展的方式来提高性能。其中前面提到的Mycat也支持在大表拆分为小表情况下的访问控制。

这种做法显著的增加了数据库运维的难度,对DBA的要求较高。数据库设计到这种结构时,已经可以称为分布式数据库,但是这只是一个逻辑的数据库整体,数据库里不同的组成部分是由不同的组件单独来实现的,如分库分表的管理和请求分发,由Mycat实现,SQL的解析由单机的数据库实现,读写分离可能由网关和消息队列来实现,查询结果的汇总可能由数据库接口层来实现等等,这种架构其实是MPP(大规模并行处理)架构的一类实现。

目前开源和商用都已经有不少MPP数据库,开源中比较流行的有Greenplum、TiDB、Postgresql XC、HAWQ等,商用的如南大通用的GBase、睿帆科技的雪球DB、华为的LibrA等等,不同的MPP数据库的侧重点也不一样,如TiDB更侧重于分布式OLTP场景,Greenplum更侧重于分布式OLAP场景,这些MPP数据库基本都提供了类似Postgresql、Oracle、MySQL那样的SQL标准支持能力,能把一个查询解析为分布式的执行计划分发到每台机器上并行执行,最终由数据库本身汇总数据进行返回,也提供了诸如权限管理、分库分表、事务、数据副本等能力,并且大多能够支持100个节点以上的集群,大大降低了数据库运维的成本,并且使数据库也能够实现水平扩展。

数据库和Tomcat都能够水平扩展,可支撑的并发大幅提高,随着用户数的增长,最终单机的Nginx会成为瓶颈

3.8 第七次演进:使用LVS或F5来使多个Nginx负载均衡

clipboard.png

由于瓶颈在Nginx,因此无法通过两层的Nginx来实现多个Nginx的负载均衡。图中的LVS和F5是工作在网络第四层的负载均衡解决方案,其中LVS是软件,运行在操作系统内核态,可对TCP请求或更高层级的网络协议进行转发,因此支持的协议更丰富,并且性能也远高于Nginx,可假设单机的LVS可支持几十万个并发的请求转发;F5是一种负载均衡硬件,与LVS提供的能力类似,性能比LVS更高,但价格昂贵。由于LVS是单机版的软件,若LVS所在服务器宕机则会导致整个后端系统都无法访问,因此需要有备用节点。可使用keepalived软件模拟出虚拟IP,然后把虚拟IP绑定到多台LVS服务器上,浏览器访问虚拟IP时,会被路由器重定向到真实的LVS服务器,当主LVS服务器宕机时,keepalived软件会自动更新路由器中的路由表,把虚拟IP重定向到另外一台正常的LVS服务器,从而达到LVS服务器高可用的效果。

此处需要注意的是,上图中从Nginx层到Tomcat层这样画并不代表全部Nginx都转发请求到全部的Tomcat,在实际使用时,可能会是几个Nginx下面接一部分的Tomcat,这些Nginx之间通过keepalived实现高可用,其他的Nginx接另外的Tomcat,这样可接入的Tomcat数量就能成倍的增加。

由于LVS也是单机的,随着并发数增长到几十万时,LVS服务器最终会达到瓶颈,此时用户数达到千万甚至上亿级别,用户分布在不同的地区,与服务器机房距离不同,导致了访问的延迟会明显不同

3.9 第八次演进:通过DNS轮询实现机房间的负载均衡

clipboard.png

在DNS服务器中可配置一个域名对应多个IP地址,每个IP地址对应到不同的机房里的虚拟IP。当用户访问www.taobao.com时,DNS服务器会使用轮询策略或其他策略,来选择某个IP供用户访问。此方式能实现机房间的负载均衡,至此,系统可做到机房级别的水平扩展,千万级到亿级的并发量都可通过增加机房来解决,系统入口处的请求并发量不再是问题。

随着数据的丰富程度和业务的发展,检索、分析等需求越来越丰富,单单依靠数据库无法解决如此丰富的需求

3.10 第九次演进:引入NoSQL数据库和搜索引擎等技术

clipboard.png

当数据库中的数据多到一定规模时,数据库就不适用于复杂的查询了,往往只能满足普通查询的场景。对于统计报表场景,在数据量大时不一定能跑出结果,而且在跑复杂查询时会导致其他查询变慢,对于全文检索、可变数据结构等场景,数据库天生不适用。因此需要针对特定的场景,引入合适的解决方案。如对于海量文件存储,可通过分布式文件系统HDFS解决,对于key value类型的数据,可通过HBase和Redis等方案解决,对于全文检索场景,可通过搜索引擎如ElasticSearch解决,对于多维分析场景,可通过Kylin或Druid等方案解决。

当然,引入更多组件同时会提高系统的复杂度,不同的组件保存的数据需要同步,需要考虑一致性的问题,需要有更多的运维手段来管理这些组件等。

引入更多组件解决了丰富的需求,业务维度能够极大扩充,随之而来的是一个应用中包含了太多的业务代码,业务的升级迭代变得困难

3.11 第十次演进:大应用拆分为小应用

clipboard.png

按照业务板块来划分应用代码,使单个应用的职责更清晰,相互之间可以做到独立升级迭代。这时候应用之间可能会涉及到一些公共配置,可以通过分布式配置中心Zookeeper来解决。

不同应用之间存在共用的模块,由应用单独管理会导致相同代码存在多份,导致公共功能升级时全部应用代码都要跟着升级

3.12 第十一次演进:复用的功能抽离成微服务

clipboard.png

如用户管理、订单、支付、鉴权等功能在多个应用中都存在,那么可以把这些功能的代码单独抽取出来形成一个单独的服务来管理,这样的服务就是所谓的微服务,应用和服务之间通过HTTP、TCP或RPC请求等多种方式来访问公共服务,每个单独的服务都可以由单独的团队来管理。此外,可以通过Dubbo、SpringCloud等框架实现服务治理、限流、熔断、降级等功能,提高服务的稳定性和可用性。

不同服务的接口访问方式不同,应用代码需要适配多种访问方式才能使用服务,此外,应用访问服务,服务之间也可能相互访问,调用链将会变得非常复杂,逻辑变得混乱

3.13 第十二次演进:引入企业服务总线ESB屏蔽服务接口的访问差异

clipboard.png

通过ESB统一进行访问协议转换,应用统一通过ESB来访问后端服务,服务与服务之间也通过ESB来相互调用,以此降低系统的耦合程度。这种单个应用拆分为多个应用,公共服务单独抽取出来来管理,并使用企业消息总线来解除服务之间耦合问题的架构,就是所谓的SOA(面向服务)架构,这种架构与微服务架构容易混淆,因为表现形式十分相似。个人理解,微服务架构更多是指把系统里的公共服务抽取出来单独运维管理的思想,而SOA架构则是指一种拆分服务并使服务接口访问变得统一的架构思想,SOA架构中包含了微服务的思想。

业务不断发展,应用和服务都会不断变多,应用和服务的部署变得复杂,同一台服务器上部署多个服务还要解决运行环境冲突的问题,此外,对于如大促这类需要动态扩缩容的场景,需要水平扩展服务的性能,就需要在新增的服务上准备运行环境,部署服务等,运维将变得十分困难

3.14 第十三次演进:引入容器化技术实现运行环境隔离与动态服务管理

clipboard.png

目前最流行的容器化技术是Docker,最流行的容器管理服务是Kubernetes(K8S),应用/服务可以打包为Docker镜像,通过K8S来动态分发和部署镜像。Docker镜像可理解为一个能运行你的应用/服务的最小的操作系统,里面放着应用/服务的运行代码,运行环境根据实际的需要设置好。把整个“操作系统”打包为一个镜像后,就可以分发到需要部署相关服务的机器上,直接启动Docker镜像就可以把服务起起来,使服务的部署和运维变得简单。

在大促的之前,可以在现有的机器集群上划分出服务器来启动Docker镜像,增强服务的性能,大促过后就可以关闭镜像,对机器上的其他服务不造成影响(在3.14节之前,服务运行在新增机器上需要修改系统配置来适配服务,这会导致机器上其他服务需要的运行环境被破坏)。

使用容器化技术后服务动态扩缩容问题得以解决,但是机器还是需要公司自身来管理,在非大促的时候,还是需要闲置着大量的机器资源来应对大促,机器自身成本和运维成本都极高,资源利用率低

3.15 第十四次演进:以云平台承载系统

clipboard.png

系统可部署到公有云上,利用公有云的海量机器资源,解决动态硬件资源的问题,在大促的时间段里,在云平台中临时申请更多的资源,结合Docker和K8S来快速部署服务,在大促结束后释放资源,真正做到按需付费,资源利用率大大提高,同时大大降低了运维成本。

所谓的云平台,就是把海量机器资源,通过统一的资源管理,抽象为一个资源整体,在之上可按需动态申请硬件资源(如CPU、内存、网络等),并且之上提供通用的操作系统,提供常用的技术组件(如Hadoop技术栈,MPP数据库等)供用户使用,甚至提供开发好的应用,用户不需要关系应用内部使用了什么技术,就能够解决需求(如音视频转码服务、邮件服务、个人博客等)。在云平台中会涉及如下几个概念:

  • IaaS:基础设施即服务。对应于上面所说的机器资源统一为资源整体,可动态申请硬件资源的层面;
  • PaaS:平台即服务。对应于上面所说的提供常用的技术组件方便系统的开发和维护;
  • SaaS:软件即服务。对应于上面所说的提供开发好的应用或服务,按功能或性能要求付费。
至此,以上所提到的从高并发访问问题,到服务的架构和系统实施的层面都有了各自的解决方案,但同时也应该意识到,在上面的介绍中,其实是有意忽略了诸如跨机房数据同步、分布式事务实现等等的实际问题,这些问题以后有机会再拿出来单独讨论

4. 架构设计总结

  • 架构的调整是否必须按照上述演变路径进行?
    不是的,以上所说的架构演变顺序只是针对某个侧面进行单独的改进,在实际场景中,可能同一时间会有几个问题需要解决,或者可能先达到瓶颈的是另外的方面,这时候就应该按照实际问题实际解决。如在政府类的并发量可能不大,但业务可能很丰富的场景,高并发就不是重点解决的问题,此时优先需要的可能会是丰富需求的解决方案。
  • 对于将要实施的系统,架构应该设计到什么程度?
    对于单次实施并且性能指标明确的系统,架构设计到能够支持系统的性能指标要求就足够了,但要留有扩展架构的接口以便不备之需。对于不断发展的系统,如电商平台,应设计到能满足下一阶段用户量和性能指标要求的程度,并根据业务的增长不断的迭代升级架构,以支持更高的并发和更丰富的业务。
  • 服务端架构和大数据架构有什么区别?
    所谓的“大数据”其实是海量数据采集清洗转换、数据存储、数据分析、数据服务等场景解决方案的一个统称,在每一个场景都包含了多种可选的技术,如数据采集有Flume、Sqoop、Kettle等,数据存储有分布式文件系统HDFS、FastDFS,NoSQL数据库HBase、MongoDB等,数据分析有Spark技术栈、机器学习算法等。总的来说大数据架构就是根据业务的需求,整合各种大数据组件组合而成的架构,一般会提供分布式存储、分布式计算、多维分析、数据仓库、机器学习算法等能力。而服务端架构更多指的是应用组织层面的架构,底层能力往往是由大数据架构来提供。
  • 有没有一些架构设计的原则?

    • N+1设计。系统中的每个组件都应做到没有单点故障;
    • 回滚设计。确保系统可以向前兼容,在系统升级时应能有办法回滚版本;
    • 禁用设计。应该提供控制具体功能是否可用的配置,在系统出现故障时能够快速下线功能;
    • 监控设计。在设计阶段就要考虑监控的手段;
    • 多活数据中心设计。若系统需要极高的高可用,应考虑在多地实施数据中心进行多活,至少在一个机房断电的情况下系统依然可用;
    • 采用成熟的技术。刚开发的或开源的技术往往存在很多隐藏的bug,出了问题没有商业支持可能会是一个灾难;
    • 资源隔离设计。应避免单一业务占用全部资源;
    • 架构应能水平扩展。系统只有做到能水平扩展,才能有效避免瓶颈问题;
    • 非核心则购买。非核心功能若需要占用大量的研发资源才能解决,则考虑购买成熟的产品;
    • 使用商用硬件。商用硬件能有效降低硬件故障的机率;
    • 快速迭代。系统应该快速开发小功能模块,尽快上线进行验证,早日发现问题大大降低系统交付的风险;
    • 无状态设计。服务接口应该做成无状态的,当前接口的访问不依赖于接口上次访问的状态。
查看原文

赞 511 收藏 381 评论 79

Barrior 收藏了文章 · 2019-10-28

Mongoose-modified-at 时间自动记录插件介绍

Mongoose-modified-at 是一款自动更新字段变化时间并记录到数据库中的 Mongoose 插件,类似 Mongoose 自带的 timestamps 功能。

使用场景

让我们考虑一个场景,我们有个文章发布与展示的需求,数据模型如下。

const schema = new mongoose.Schema({
  // 文章标题
  title: String,
  // 是否为草稿
  is_draft: Boolean,
  // 是否推荐
  is_recommended: Boolean,
  // 更多字段...
})

当我们在展示最新文章列表时,应该是以文章第一次发布的时间倒序展示,因为文章可以存为草稿,多次编辑,所以不能用 Mongoose 提供的 createdAtupdatedAt 作为第一次发布的时间,正确的做法是在每次文章创建或更新时,确定用户是发布文章而不是存为草稿,然后记录此次时间,用该时间作为第一次发布的时间。

要实现该功能我们需要在代码逻辑层进行处理,这样可行不过有点耦合,或者自己封装一个 Mongoose 中间件来做这件事,不过现在你可以把这件事交给一个经受测试、API 优雅的插件 ModifiedAt 来处理。

首先安装插件。

npm install mongoose-modified-at --save

然后在 Schema 初始化时做简单的配置即可,如下。

import modifiedAt from 'mongoose-modified-at'

// 在 mongoose.model 调用之前
schema.plugin(modifiedAt, {
  // 函数名将作为字段名写入数据库
  publishedAt(doc) {
    // 当函数返回值为 true 时,则记录该时间
    return !doc.is_draft
  },
  // 推荐文章也是如此
  recommendedAt(doc) {
    return doc.is_recommended
  },
})

const Article = mongoose.model('Article', schema)

当文档保存或更新携带着 is_draft 字段并且值为 false 时,插件就会记录此次时间到你声明的 publishedAt 字段上一起写入数据库。

示例如下:

await Article.create({
  title: 'Document Title',
  is_draft: false,
  is_recommended: true,
  // 更多字段...
})

结果如下(数据库):

{
  "title": "Document Title",
  "is_draft": false,
  "is_recommended": true,
  "publishedAt": ISODate("2019-09-27T03:11:07.880Z"),
  "recommendedAt": ISODate("2019-09-27T03:11:07.880Z"),
  // 更多字段...
}

附加案例

作为渐进式项目,我们的开发一般也是渐进式的,虽然我们会不自觉地超前考虑,但是还是不能完全考虑到未来需求的变化,假如我们对某个项目的功能已经完成并稳定上线了,后来比如我们需要做数据统计分析的工作,这项工作的分析维度对时间的精度要求比较高,所以要是我们在开发时并没有考虑到要添加这些时间字段(因为可能对业务不是必须的),而现在需要加上这些字段,要是去原来的代码基础上改动添加,如果改动的地方少还好,如果有完善的测试用例还好,否则这也许会改的心惊胆战,因为你需要确保每一处改动不会产生错误影响。所以此时,使用无侵入式的中间件插件 ModifiedAt 那就省心很多了,只需在模型出口简单配置,无需改动逻辑层代码,即可实现刚刚想要的功能。

API介绍

上面是 ModifiedAt 的富 API 形式,即对象格式,全部参数选项如下。

schema.plugin(modifiedAt, {
  // 设置监听字段
  fields: ['name', 'status', 'another'],
  // 设置后缀
  suffix: '_your_suffix',
  // 设置路径默认行为
  select: true,
  // 自定义字段
  customField(doc) {
    // 做一些你想做的事,然后返回 Boolean 值,告诉插件是否记录时间
  },
})

🍎 参数解释:

  • fields: 设置监听字段,在文档创建或更新时,如果存在被监听的字段,则自动以 字段名 + 后缀 的形式作为字段,并记录此次更新时间到该字段上。可选,Array 类型。
  • suffix: 设置后缀,默认值为 _modifiedAt。可选,String 类型。
  • select: 设置路径默认行为,默认为 true参考 Mongoose 文档。可选,Boolean 类型。
  • customField: 自定义字段,此字段不会加后缀,以函数形式添加到参数中,用于自定义功能,函数接收唯一文档参数,当函数返回值为真值时,则记录此次时间到该字段上。

简化API

🚀 为了增加 API 的简洁易用同时避免过度重载,ModifiedAt 只增加了一种简化传参格式,如下。

schema.plugin(modifiedAt, ['name', 'status'])

意思是将 fields 选项提取出来作为参数,写入数据库的结果如下。

{
  "name": "Tom",
  "status": 1,
  "name_modifiedAt": ISODate("2019-09-27T03:13:17.888Z"),
  "status_modifiedAt": ISODate("2019-09-27T03:13:17.888Z"),
}

支持异步

你需要 Node.js 版本支持 async/await 即可。

import P from 'bluebird'

const petSchema = new mongoose.Schema({
  name: String,
  age: Number,
  sex: String,
  // 1:表示采购中,2:已购买,3:已售出
  status: Number,
})

petSchema.plugin(modifiedAt, {
  // 记录购买于哪时
  async boughtAt(doc) {
    // 延时 1s
    await P.delay(1000)
    return doc.status === 2
  },
  // 记录售出于哪时
  soldAt(doc) {
    return doc.status === 3
  },
})

支持 Mongoose 4.x

如果你现在使用的是 Mongoose 4.x,那么你需要使用插件 1.x 版本,文档可在这里查看

npm install mongoose-modified-at@1 --save

“100%”测试覆盖率

29 个测试用例,777 行测试代码,“100%” 测试覆盖率。

image.png

细节

更多细节处理请移步至 GitHub 文档,这里

最后

1、支持一下,烦请点个 Star 吧,贡献就点个 Fork 吧 😘

2、本文同步发表于凹凸实验室博客或微信公众号,欢迎关注我们,么么哒 😍

clipboard.png

查看原文

认证与成就

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

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-07-11
个人主页被 1.6k 人浏览