标签 Node.js 下的文章

关于 Node.js 测试

单元测试是大型 Web 项目必不可少的部分. 测试和其他部分一样, 从一开始就要设计好实现, 否则可能就可能不断给自己买埋坑. Node.js 的测试一般由测试框架和断言库两部分组成.

测试框架用的比较多的是 mocha, 我也用过一阵子的 ava. mocha 大家可能都比较熟悉, ava 可能实际使用的不多. 这两个其实差别也有点大, ava 的话是每个文件都开一个进程执行, 并且文件内的测试默认是并发执行, 听起来就很快. ava 的写法也比 mocha 简洁很多. ava 自带断言库, 并且支持异步断言(assert 通过插件也可以实现, 但没有 ava 简洁). 但 ava 对 typescript 支持不好, 需要 TypeScript 文件先编译成 JavaScript 文件后才能进行测试. 而 mocha 就不存在这个问题.

就如同 ava 官网介绍的, 面向未来的测试框架. 我个人还是非常看好 ava 的, 一旦它支持 TypeScript 了, 我立马就会转过来.

断言库的话一开始实习公司用的是 should. 我不知道为什么很多人喜欢用 should, api 长又难记. 我对它一直都很抗拒, 后面公司的断言库都是使用 assert(chai) 来进行. 除了 should 和 assert 之外还有一个比较有名的断言库就是 expect. 萝卜青菜各有所爱, 我个人偏爱是 assert, 现在主要是使用 power-assert, api 非常简洁, 而且错误提示做的一级棒.

// assert
assert.lengthOf(foo, 3)
// expect
expect(foo).to.have.length(3)
// should
foo.should.have.length(3)
// power-assert
assert(foo.length === 3)

正如 power-assert 官网说的, NO API is the best API. power-assert 简直不要太好, 特别是当断言错误时他的提醒更是极致. 就像下面这样:

    assert(types[index].name === bob.name)
           |    ||      |    |   |   |
           |    ||      |    |   |   "bob"
           |    ||      |    |   Person{name:"bob",age:5}
           |    ||      |    false
           |    |11     "alice"
           |    Person{name:"alice",age:3}
           ["string",98.6,true,false,null,undefined,#Array#,#Object#,NaN,Infinity,/^not/,#Person#]

    --- [string] bob.name
    +++ [string] types[index].name
    @@ -1,3 +1,5 @@
    -bob
    +alice

反观 assert, 原生的 assert 的问题在于功能太少. 所以一般会使用 chai. 而 expect 和 should, 总之我是写不下去.

因为我现在都是在写 TypeScript. 所以现在选定的测试套件是 mocha + power-assert.

测试遇到的问题

我个人参与了很多后端项目的开发, 特别是前期开发部分. 早期的测试文件也是由我来写, 我也认真思考过如何写一个比较清晰可维护的测试目录结构. 这过程也踩了不少坑, 这里简单介绍下.

测试前置数据

很多业务代码的测试是需要一些前置数据的, 一开始, 前置数据我们都是直接在该测试文件的 before 钩子完成, 然后在 after 钩子进行销毁. 但随着项目越来越大, 这种方式的弊端开始显现, 很多文件其实 before 部分是大体一致的, 这样对于代码修改起来就十分不便, 另外频繁的创建销毁也增加了开销.

很自然的就可以想到, 可以有一个全局的钩子, 批量创建测试需要的所有数据, 然后最后再统一销毁. 一开始没有了解到 mocha 的全局钩子, 我们是 mocha 运行一个文件, 该文件导入其他测试文件来进行的. 但其实 mocha 本身有全局钩子的功能, 利用全局钩子就可以干这种事情了. 这样就解决了上述的问题.

单进程测试应用奔溃

但其实还有一个一直被大多数人忽略的问题, 一般我们也是在测试里面启动项目, 然后对项目进行测试, 如果项目运行奔溃, 我们的测试进程也就跟着奔溃了. 结果就是 after 钩子无法被执行. 数据库有残余的测试数据插入. 对后面的再次测试也可能会产生影响.

要解决这个问题. 只能开多进程解决. 我们可以利用 cluster 来实现. mocha 自带延迟执行的功能, 我们需要延缓 mocha 的运行否则其全局钩子可能出问题.

测试覆盖率和持续集成

关于测试覆盖率报告, 我一直都是使用 nyc 来进行. nyc 的优点在于不需要进行额外配置, 兼容 babel, typescript. 测试覆盖统计也很准确, 使用起来非常简单. 并且也可以自定义测试覆盖率报告的输出格式. nyc 输出测试覆盖报告后我们可以再通过 codecov 来上传测试覆盖率报告.

持续集成方面, travis-ci 和 circle-ci 我都使用过. 简单的来说, travis-ci 用的比较多, 书写格式比较简洁, 用法比较简单, 文档也很容易看, 但测试速度贼慢. 而 circle-ci 支持多种格式, 支持 docker, 测试速度快, 但配置起来可能比较麻烦. 我目前基本都在使用 circle-ci, 因为 travis-ci 实在慢并且好像是要收费??

- 阅读剩余部分 -

高并发下的抽奖机设计实现

最近在公司负责一个营销系统的后端实现. 其中有一部分就是涉及到抽奖机. 为了尽可能让抽奖机能容纳更大流量的瞬时访问, 减少不必要的开销, 同时让抽奖机具备一定的可扩展性, 我花了很多时间去想着改进它. 总结下在这个抽奖机中的一些经验.

如何判断活动是否已经开始或者已经结束?

活动有一个开始时间和结束时间. 当用户参与一个活动的时候, 每次从数据库查询出活动时间和结束时间然后和当前时间比对这样显然是可以的. 但是由于我们并不能确定用户请求该接口的时机, 所以每次都要去查询. 这样带来的开销就比较大, 况且你还要考虑如果是一个秒杀活动, 高流量下你还要每个访问都查询数据库就显得不实际了.

因此我们可以考虑存放缓存. 当活动创建时, 保存活动时间到缓存.

async setActivityIdLifeCycle (activityId: number, startAt: string, endAt: string): Promise<void> {
  await Promise.all([
    this.set(this.key('activity', activityId, 'startAt'), startAt),
    this.set(this.key('activity', activityId, 'endAt'), endAt)
  ])
}

然后在 ActivityRepository 中有 isActivityProceeding 方法来判断活动是否已经开始.

public async isActivityProceeding (activityId: number): Promise<void> {
  let [startAt, endAt] = await cache.getActivityIdLifeCycle(activityId)

  if (startAt === null || endAt === null) {
    const activity = await this.findOneById(activityId)
    if (activity !== null) {
      await cache.setActivityIdLifeCycle(activityId, activity.startAt.toDateString(), activity.endAt.toDateString())
    } else {
      console.error('活动异常 - quv43')
      throw new BadRequestError('活动异常')
    }
    [startAt, endAt] = [activity.startAt.toDateString(), activity.endAt.toDateString()]
  }

  const [started, ended] = [Date.now() > Date.parse(startAt), Date.now() > Date.parse(endAt)]
  if (started === false) {
    throw new BadRequestError('活动尚未开始')
  } else if (started === true && ended === true) {
    throw new BadRequestError('活动已结束')
  }
}

repository 中先查询缓存, 如果查不到则去数据库查询并设置缓存. 最后比较下活动时间, 如果活动还不在进行就抛出错误.

有些人可能没有写过 repository 这一层. 这一层个人觉得还是非常有必要的, 它的作用就是数据访问修改, service 不直接操作 model 而是由 repository 处理, 同时 service 也不关心数据来源是数据库还是缓存. 我们在 repository 中可以对一些操作进行缓存处理从而提高查询性能, 查询性能这个也不是由 service 来考虑的.

如何判断采用那个游戏策略?

一个活动对应一个游戏, 当用户抽奖时, 我们显然是要找到对应的那个游戏. 和上面一样, 我们只要保存 activityId 和游戏 label 的对应关系到缓存即可. 这点和上面类似, 因为这些都是在用户参与时刻出发, 而参与这个动作是短时高频的, 因此我们应该尽可能利用缓存.

用户抽奖行为的 service 如下:

public async lottery (userId: number, activityId: number): Promise<Object> {
  await this.activityRepository.isActivityProceeding(activityId)
  const label = await this.activityRepository.getLabelOfActivity(activityId)
  const lottery = LotteryContainer.get(label, activityId)
  const result = await lottery.go(userId)
  return result
}

这个方法需要考虑高并发. 首先调用了上面的方法来判断活动是否开始, service 这一层不关心性能不关心缓存, 这些都是 repository 考虑的事情. 接着获取 label, 这里 label 的获取其实也有缓存的操作, 如果缓存有则取缓存, 没有的话就读数据库同时设置缓存. 接着获取抽奖策略调用抽奖方法得到结果.

如何设计可扩展的游戏抽奖逻辑

由于以后可能有更多的游戏加入, 而游戏的内部处理也是稍微比较复杂的. 对于这种情况, 我们应该把游戏抽奖单独开来, 不要放到如上的 service 文件里面. 否则上述的 service 就可能很快就变得复杂以及难以维护.

使用面向对象的编程, 我们可以很容易想到, 每个游戏可以就是一个类, 对于一个普通的随机抽奖游戏, 可以类内部拥有一个属性 activityId 用来指明它是属于活动的, 以及一个奖品池用来保存根据 activityId 查询出来的其具有的奖品信息. 类具有抽奖方法, 该方法就从奖品池随机选中一个完成抽奖. 所以每个活动只能实例化一个类.

那么怎么确保其只被实例化一次呢?

我通过实现一个容器来实现这点:

// service/lottery/LotteryContainer.ts
import { LotteryInterface } from './LotteryInterface'

export class LotteryContainer {

  static strategies: Map<string, new (...args: any[]) => LotteryInterface> = new Map<string, { new (...args: any[]): LotteryInterface }>()
  static activityToLottery: Map<string, LotteryInterface> = new Map<string, LotteryInterface>()

  static set (label: string, strategy: new (...args: any[]) => LotteryInterface): void {
    this.strategies.set(label, strategy)
  }

  static get (label: string, activityId: number): LotteryInterface {
    if (this.activityToLottery.has(label + activityId)) {
      return this.activityToLottery.get(label + activityId)
    } else {
      if (this.strategies.has(label)) {
        this.activityToLottery.set(label + activityId, (new (this.strategies.get(label))(activityId)))
        return this.activityToLottery.get(label + activityId)
      } else {
        throw new Error(`游戏未加载: ${label}`)
      }
    }
  }
}

// 载入游戏
import '../../service/lottery/game/BigTurnTable'

这个容器其实有两个属性 strategiesactivityToLottery. stratetgies 用来记录游戏 label 和游戏类的映射, activityToLottery 用来记录活动 activityId 和游戏实例的映射. 当我第一次调用这个方法的时候.

const lottery = LotteryContainer.get(label, activityId)

由于没有任何类被实例化过, 一个 activityId 将传入 label 对应游戏类进行实例化. 当以后使用相同的 labelactivityId 再次调用的时候, 只会返回这个实例本身而不会重复实例化.

那么关键的 strategies 是怎么建立起来的呢? 这时候我有一个专门的文件夹, 里面存放的就是一个个游戏类, 他们实现同一个接口. 并通过以下语句来注入到容器中.

// service/lottery/game/BigTurnTable.ts
import { LotteryContainer } from 'app/service/lottery/LotteryContainer'
export class BigTurnTable extends LotteryMachine implements LotteryInterface {
    ...
}
LotteryContainer.set('BigTurnTable', BigTurnTable)

然后在 service/lottery/LotteryContainer 中也就是前面我说的容器文件里面, 我们在最下方加载这个文件

import 'app/service/lottery/game/BigTurnTable'

这样就可以了.

那么这么设计有什么好处呢?

首先, service 的代码保持了简洁. 当我们添加一个新的游戏的时候, 我们可以完全不需要变更 service 里面的代码, 唯一需要变动的就是创建一个新的游戏类, 基于给定接口实现方法, 然后加载游戏. 也就是上面这两段代码. 这其实就类似于策略模式. 另外, 只要新增游戏的时候, 游戏的 label 和我们注册的 label 保持一直, 那么就能被正常的实例化调用.

其实我一直提倡一个尽可能 service 里面要轻, 比如数据库文件 I/O 等我们可以抽象出 repository 或者 dao, 对于多次在 service 中使用的第三方服务, 我们应该把服务抽象成一个 library. 这样代码就会变得简洁并且可维护.

怎么处理类构造函数中的异步调用?

看回这段用户抽奖的 service:

public async lottery (userId: number, activityId: number): Promise<Object> {
  await this.activityRepository.isActivityProceeding(activityId)
  const label = await this.activityRepository.getLabelOfActivity(activityId)
  const lottery = LotteryContainer.get(label, activityId)
  const result = await lottery.go(userId)
  return result
}

可以看到我获取到对应 lottery 之后是立即调用了 go 方法. 其实, 游戏类实例化的时候是会去立即建立奖品池的, 而奖品池的建立需要数据库查询的过程, 是一个异步的操作. 因此我们怎么确保这里调用 go 方法不会出现时序问题呢?

其实很简单, 只要我们在游戏类中有一个专门的标志位默认为 false, 只有当游戏类的游戏池建立之后该标志位才为 true. 我们在调用 go 方法之前先判断标志位, 如果为 false, 则 await 那个异步方法等它完成即可.

的确, 这是最简单的方法, 但是有个问题, 我们所 await 的异步方法和我们在构造器中对的异步方法会被同时执行! 也就是说, 如果你想象游戏一开始, 同时又几千人抽奖, 那么可能这个异步方法会被调用很多次直至其标志位为 true 为止. 因此我们需要改进它.

我习惯把数据库配置和连接写入一个专门的文件, 然后这个文件里面导出一个数据库连接(promise). 之后我需要连接数据库我就导入这个连接然后 await 它后者直接 then 接在他后面. 这种情况, 无论如何我都只会建立一条数据库连接. 因为我使用的都是同一个 Promise, 当它被 resolved 的时候, 所以 await 它的地方都会得到相同的连接.

但在类的方法, 我们每次 await 都是一个新的方法新的上下文. 因此这个方法并不适用于类方法中. 后来我又想了很多方法去改进优化, 但都没有解决多次查询数据库的问题. 直到最后我突然意识到利用 lodashonce 函数. 因此结果就变成这样了.

// 通用抽奖机, 适用于完全随机抽奖的情况, 完全随机抽奖的游戏继承这个类即可
export class LotteryMachine {
  public readonly activityId: number
  public prizes: LotteryPrizeInterface[] = []
  private initialized: boolean = false
  private prizeRepository: PrizeRepository
  private recordRepository: RecordRepository
  private activityRepository: ActivityRepository
  private opptyRepository: OpptyRepository

  constructor (activityId: number) {
    this.activityId = activityId
    this.initialize().catch(e => console.log(`[Lottery] activity ${this.activityId} initialize failure`, e))
  }

  @Once()
  public async initialize (): Promise<void> {
    this.prizeRepository = getConnection().getCustomRepository(PrizeRepository)
    this.recordRepository = getConnection().getCustomRepository(RecordRepository)
    this.activityRepository = getConnection().getCustomRepository(ActivityRepository)
    this.opptyRepository = getConnection().getCustomRepository(OpptyRepository)

    const prizes = await this.prizeRepository.getAllPrizes(this.activityId)
    this.computed(prizes)

    this.initialized = true
    console.log(`[Lottery] activity ${this.activityId} initialized`)
  }

  ...
  // 此处省略一堆方法
}
// 大转盘游戏, 继承了上述抽奖机类
export class BigTurnTable extends LotteryMachine implements LotteryInterface {

  public readonly activityId: string

  constructor (activityId: string) {
    super(activityId)
    this.activityId = activityId
  }

  /** 抽奖 */
  public async go (userId: number): Promise<Object> {
    await this.initialize()
    return await this.lottery(userId)
  }
}

抽奖机类里面, 我们 @Once() 装饰了我们在构造函数中调用的 initialize 方法. 这虽然不能解决每次调用类的方法都是一个新方法的问题. 但是这些新方法有一个共同点就是有两个外部变量 nvalue , 他们是装饰器装饰时也就是 once 方法产生的, n 是我们装饰的方法的执行次数, 当判断函数被执行过一次时, 这些方法直接返回 value, value 就是装饰的方法的返回结果, 也就是我们写的这个 Promise. 因此第一次调用这个方法的时候我们会得到一个 Promise, 第二三四次都会得到这个 Promise. 这个装饰器不会帮我们处理 Promise 的结果, 只是返回这个 Promise 给我们. 所以这也就是我可以任性的在抽奖方法中进行 await 的原因.

如果还不明白为什么这样子就可以解决的话, 那可以去看看 lodashoncebefore 的源码. 这里利用的是 JS 的闭包来实现的.

怎么处理奖品被抽光的情况?

我们的抽奖逻辑是使用 Math.random() 生成随机数, 奖品池是一个对象数组, 每个对象包含了奖品 ID, 奖品总数, 奖品中奖数, 中奖率区间. 中奖率区间是一个二维数组, 例如 [0, 0.2], [0.2, 0.5] 这样.

当发现用户抽到的奖品, 奖品中奖数等于奖品总数的时候, 我们应该怎么处理好呢?

我认为比较平衡的方式是重新抽奖. 因此这个 random 方法的处理我是这样写的:

private random (step: number = 0): LotteryPrizeInterface {
  const seed = Math.random()
  const item = this.prizes.find(prize => prize.odds[0] < seed && seed <= prize.odds[1])
  if (item.rewardCount < item.count) {
    return item
  } else if (step > MAX_RANDOM_SIZE) {
    throw new BadRequestError('抽奖失败, 请稍后重试')
  } else {
    return this.random(step++)
  }
}

可以看到我还加了一个 MAX_RANDOM_SIZE 的判断, 目的是为了避免无限循环. 如果奖品抽光了, 这里就无限死循环了. 虽然产品经理跟我说会有一个奖品其数量足够大, 但我认为添加这个判断还是必要的.

由于我们的设计是一定中奖, 不存在中不到奖的情况, 因此如上处理. 如果有不中奖的情况, 那就可以有很多别的实现方式了.

上述方法只是随机获取一个奖品. 当获取奖品后, 我们还要执行创建中奖记录和修改奖品池的处理, 处理如下:

protected async gogogo (userId: number, step: number = 0, entityManager: EntityManager): Promise<Record> {
  const item = this.random()

  const prize = await this.prizeRepository.findOneById(item.id)

  if (prize) {
    item.awardCount++

    await this.prizeRepository.updatePrizeAwardCount(prize.id, entityManager)
    await this.activityRepository.updateActivityAwardCount(this.activityId, entityManager)

    const record = await this.recordRepository.createRecord({
      userId: userId,
      prizeId: prize.id,
      activityId: this.activityId,
      awardAt: new Date().toDateString()
    }, entityManager)

    return Object.assign(record, { prize: omit(prize, ['odds']) })
  } else if (!prize && step <= MAX_RETRY_SIZE) {
    console.log(`[Lottery] activity ${this.activityId} 抽奖异常, 尝试重新抽奖...`)
    return await this.gogogo(userId, step++, entityManager)
  } else {
    throw new BadRequestError('抽奖失败, 请稍后重试')
  }
}

这里我又有 MAX_RETRY_SIZE 这个常量, 他的作用其实是和上面那个 MAX_RANDOM_SIZE 类似的, 也是为了避免无限死循环的情况. 但其实这里我只是一个预防, 正常情况是永远不会重复调用 gogogo 方法的. 但是防患于未然嘛...

怎样确保抽奖不出错?

前面我们都是想办法优化进入抽奖的时间开销. 那么更关键的是, 我们怎么确保抽奖在高并发的情况不出错呢? 上面的 gogogo 方法其实是被该方法调用的

@Transaction()
protected async lottery (userId: number, step: number = 0, @TransactionEntityManager() entityManager?: EntityManager): Promise<Record> {
  // lock start ->
  // 考虑到一般不会出现同时抽奖的情况, 因此对抽奖这个操作加锁, 这也避免了脏缓存问题
  const lockstitch = await lock().lock(`LOTTERY-${userId}-${this.activityId}`, DEFAULT_LOCK_TTL)
  let record

  // 确保有抽奖机会才进行
  const oppty = await this.opptyRepository.getOppty(userId, this.activityId)
  if (oppty <= 0) {
    await lockstitch.unlock()
    throw new BadRequestError('没有抽奖机会')
  }

  try {
    // 使用事务处理避免中途出错导致出现脏数据
    await this.opptyRepository.updateOpptyCount(userId, this.activityId, -1, entityManager)
    record = await this.gogogo(userId, step, entityManager)
  } catch (e) {
    console.log(`[Lottery] activity ${this.activityId} 抽奖异常 - gg92fg`, e)
    // 失败补偿
    await this.opptyRepository.updateOpptyCount(userId, this.activityId, 1, entityManager)
    throw e
  } finally {
    await lockstitch.unlock()
  }
  // lock end <-

  return record
}

这里使用了一个事务, 避免中途出错导致引入脏数据. 这点非常重要. 同时, 一方面一般不会出现用户同时抽奖的情况, 另一方面用户同时抽奖的考虑有点复杂, 因此我们直接上锁.

const lockstitch = await lock().lock(`LOTTERY-${userId}-${this.activityId}`, DEFAULT_LOCK_TTL)

这个锁, 让一个用户一个活动的抽奖同时有且只能进行一次. 后面的操作我们就可以省去很多考虑.

- 阅读剩余部分 -

使用 TypeScript 开发后端应用

大多数人接触 TypeScript 开发会遇到的一个问题就是很多原先使用的库都是用 JavaScript 写的. 虽然在 TypeScript 项目中也可以使用他们, 但是有些库的用法还是面向过程形式的, 就和我们在 TypeScript 中用面向对象的写法有点格格不入. 有些库由于比较小众可能不存在声明定义文件, 这就导致了编辑器没法进行静态类型检查.

使用 TypeScript 应该抛弃原型链, 尽可能多的使用面向对象的方式来编程. 尽可能多的使用装饰器. 如果这么做显然就和我们传统的使用 JavaScript 编程有了差异. 那么, 怎样优雅的使用 TypeScript 呢?

TypeScript 生态不乏很多优秀的库, 例如关系型数据库 TypeORM, 依赖注入 inverisifyJS 和 TypeDI, 同时支持 Koa 和 Express 的 routing-controller, 参数校验 class-validator.

如下同时使用 routing-controller 和 class-validator.

// src/controller/ProblemController.ts
import { ProblemService } from 'app/service'
import { Problem } from 'app/entity'
import { Get, JsonController, Param, QueryParams } from 'routing-controllers'
import { Inject, Service } from 'typedi'

export class ProblemQuery { // 这是一个类, 也可以当接口使用, 这里使用的装饰器在下面的 QueryParams 中会被使用
  @IsNumberString() public limit: string = '20'
  @IsNumberString() public offset: string = '0'
  @IsIn(['ASC', 'DESC', 'asc', 'desc']) public order: 'ASC' | 'DESC' = 'ASC'
  @IsString() public sortby: string = 'id'
}

@Service()                          // 用于进行依赖注入
@JsonController('/v1/problems')     // 定义一个路由, 基址为 /v1/problems
export class ProblemsController {

  @Inject()
  private problemService: ProblemService    // 依赖注入 ProblemService

  @Get('/')                         // 匹配 Get /v1/problems 的请求
  public async index (@QueryParams() query: ProblemQuery): Promise<{ data: [Problem[], number]; }> {    // @QueryParams 也是依赖注入, 同时这里结合 class-validator 进行校验
    const data = await this.problemService.list(query.offset, query.limit, query.sortby, query.order)
    return {
      data
    }
  }
}

在这个文件中, 路由和控制器结合在一起, 非常简介. 如果我们要使用中间件的话, 也只需要再 @Get('/') 下面或者 @JsonController('/v1/problems') 下面再加上一个 @UseBefore(middleware) 就好了. 如果需要对某个路由进行限流, 也只是一个装饰器就搞定的事情.

上述的 ProblemService 文件如下:

// src/service/ProblemService.ts
import { Problem } from 'app/entity'
import { ProblemRepository } from 'app/repository'
import { Service } from 'typedi'
import { OrmCustomRepository } from 'typeorm-typedi-extensions'

@Service()  // 这里 ProblemService 可以被依赖注入的关键
export class ProblemService {

  @OrmCustomRepository(ProblemRepository)   // 同样可以依赖注入其他服务
  private problemRepository: ProblemRepository

  public async list (offset: number, limit: number, sortby: string, order: 'ASC' | 'DESC'): Promise<[Problem[], number]> {
    return this.problemRepository.getList(offset, limit, sortby, order)
  }
}

这个文件是一个 Service, 一般在 Service 里面, 我们会调用多种服务例如数据库增删查改, 各种外部 API 调用, 缓存处理等等. 当业务比较复杂时, 一般会把数据库这一层再进行一层抽象, 也就是有了 ProblemRepository 这个文件:

// src/problem/ProblemRepository.ts
import { Problem } from 'app/entity'
import 'reflect-metadata'
import { Service } from 'typedi'
import { EntityRepository, Repository } from 'typeorm'

@Service()
@EntityRepository(Problem)
export class ProblemRepository extends Repository<Problem> {

  public getList (offset: number, limit: number, sortby: string, order: string): Promise<[Problem[], number]> {
    return this.createQueryBuilder('problem')
               .orderBy(`problem.${sortby}`, order as 'ASC'|'DESC')
               .innerJoinAndSelect('problem.user', 'user')
               .setFirstResult(offset)
               .setMaxResults(limit)
               .getManyAndCount()
    }
}

这里就是使用 TypeORM 和数据库交互的代码了. Repository 这层在 Java 中用的很多. 如果不是一般的 CURD 系统的话建议都是加上这一层的.

在下一层就是 Entity 了, 也就是 Model.

import { Submission, User } from 'app/entity'
import { Column, Entity, JoinColumn, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm'

@Entity()
export class Problem {
  @PrimaryGeneratedColumn()
  id: number

  @Column()
  title: string

  @OneToOne(type => User)
  @JoinColumn()
  user: User

  @OneToMany(type => Submission, submission => submission.problem)
  submissions: Submission[]
}

TypeORM 用装饰器来定义数据库模型, 这种写法非常清晰简洁, 个人非常喜欢这种写法.

当然你也可以继续用 Sequelize, 可以辅助使用 typescript-sequelize 这个库来像上面这样定义 Model. 使用 typescript-sequelize 定义的 Model 不需要单独写接口, 就好像上面 TypeORM 定义一样. 两者我都研究过, TypeORM 的静态类型检查强过 Sequelize 并且我不需要额外引入一个库来像上面这样类似的定义 Model 因此我选择了 TypeORM.

以上四个例子 controller -> service -> repository -> entity. 这应该是一个能够适应一定规模的分层结构.

可以看到无处不是装饰器和依赖注入, 非常简洁.

对于非关系型数据库例如 mongodb 目前我没有看到比较好的 ORM/ODM. TypeORM 的作者写了一个 TypeODM 但是作者说只有 TypeORM 稳定后才会去开发, 现在 TypeODM 功能还很不齐全.

我也在公司项目中使用了 mongoose + typescript 的组合进行开发. 没有使用第三方扩展, 直接用 mongoose 的后果是, 我定义 model 之后还得再单独写一个接口. 这就显得有点累赘. 希望后面有一些更好的解决方案出来.

- 阅读剩余部分 -

浏览器和 Node.js 的事件循环机制

Libuv

Libuv 是 Node.js 关键的组成部分. 它包含了定时器, 非阻塞的网络 I/O, 异步文件系统访问, 子进程等功能. 它封装了 Libev, Libeio 以及 IOCP, 保证了跨平台的通用性. Libuv 的组成大致如下:

libuv_architecture

Node.js 事件循环

事件循环(event loop)是 Node.js 实现非阻塞 I/O 的关键. 尽管 JavaScript 的执行是单线程的, 但事件循环将操作尽可能的交由系统内核去执行. 而由于现代系统内核都是多线程的, 因此可以在后台同时处理多个操作. 当操作中的一个任务完成时, 系统内核告诉 Node.js 将回调添加到轮询队列中以等待执行.

Node.js 的事件循环可以用下图表示

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

上面的每一阶段都有一个先进先出的回调队列等待执行. 当事件循环进入该阶段时, 它将执行该阶段应该做的操作, 然后执行该阶段的队列中的回调, 直到队列耗尽或最大数量的回调执行. 之后事件循环将进入下一阶段.

长时间的回调的执行可能使得 poll 阶段的运行时间比 timers 的阀值更长.

  • timers

    this phase executes callbacks scheduled by setTimeout() and setInterval().

    该阶段会执行到期的定时器如 setTimeout 和 setInterval 的回调.

  • I/O callbacks

    executes almost all callbacks with the exception of close callbacks, the ones scheduled by timers, and setImmediate().

    该阶段执行某些系统操作的回调比如 TCP 错误. 当 TCP socket 连接出现 CONNREFUSED 错误时, *nix 系统会把它加入到 I/O callbacks 等待处理.

  • idle, prepare

    only used internally.

  • poll

    retrieve new I/O events; node will block here when appropriate.

    poll 阶段有两个重要任务:

    1. 执行到达阀值的计时器的回调
    2. 处理轮询队列(poll 队列)中的事件

    该阶段会首先检查计时器, 如果没有计时器需要被调度, 则

    • 如果轮询队列不为空, 事件循环将循环遍历轮询队列, 并同步的执行回调直到队列为空或者达到系统限制.
    • 如果轮询队列为空
    • 如果 immediateList 队列不为空, 则进入 check 阶段来执行 setImmediate 的任务
    • 如果 immediateList 队列为空, 则等待任务被加入到轮询队列然后执行, 一旦检测到则立即执行其回调

    当轮询队列为空时, 事件循环会去检查那些已经到达阀值的定时器, 如果有定时器已经准备好, 则事件循环将重新进入 timer 阶段去执行定时器回调.

  • check

    setImmediate() callbacks are invoked here.

    该阶段允许用户在 poll 阶段后立即执行一段回调. 如果 poll 阶段变为空闲并且脚本已经通过 setImmediate 排队, 那么事件循环会继续进入 check 阶段而不是继续等待

    setImmediate 实际上是一个在事件循环中的一个单独阶段运行的特殊定时器, 它使用了 libuv 的 API 来调度回调在轮询阶段完成后执行.

    一般的, 事件循环将进入 poll 阶段进行等待, 等待传入连接, 请求等等. 除非有任务通过 setImmediate 调度并且当前 poll 阶段为空闲, 事件循环将结束 poll 阶段进入 check 阶段.

  • close callbacks

    e.g. socket.on('close', ...).

    如果一个 socket 被突然关闭, 例如通过 socket.destroy(), 那么 close 事件将会在该阶段被触发. 否则它将通过 process.nextTick() 发出.

因此, 理解每个阶段的任务之后, 我们来理解 setTimeout, setImmediate 的执行时机就不难了.

  • setTimeout/setInterval 会在 timers 阶段执行, poll 阶段为空闲时且 immediateList 队列为空时. 它会检测 timer, 如果有准备好的 timer, 则切入 timers 阶段去执行. setTimeout 是被设计在超过阀值时后要运行的脚本.
  • setImmediate 只在 check 阶段执行, 通常是 poll 阶段为空闲时且 immediateList 不为空时切入 check 阶段执行. setImmediate 被设计用来在 poll 阶段执行完成后执行一段用户代码.

如果我们在非 I/O 回调中执行以下脚本, 那么这两个定时器(setImmediate 是是一个在事件循环中的一个单独阶段运行的特殊定时器)的执行顺序是不确定的. 因为它受到进程性能的限制.

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

但在 I/O 回调中他们的顺序就是确定的, setImmediate 总会先于 setTimeout 先执行. 因为 poll 阶段同步的执行 poll 队列的回调, 此时 setTimeout 和 setImmediate 各自注册了定时器, 之后当轮询队列为空时, 检查 immediateList 队列从而进入 check 阶段执行 immediateTask, 而后回到 poll 阶段之后才检测 timer 并进入 timers 阶段.

process.nextTick 是比较特殊的, 因为它并不是事件循环的一部分. 事实上, nextTickQueue 会在当前操作处理后执行, 无论它当前处在事件循环的哪一个阶段. process.nextTick 的问题在于他可能会导致其他阶段的任务被饿死, 并阻止事件循环进入到 poll 阶段. 既然如此, 为什么要设计这样一个 API 呢?

- 阅读剩余部分 -

Node.js 垃圾回收机制

在计算机科学中, 垃圾回收是一种自动的内存管理机制. 当一个电脑上的动态内容不再需要时, 就应该予以释放, 以让出内存, 这种内存资源管理, 成为垃圾回收. 垃圾回收期可以让程序员减轻许多负担, 也减少程序员犯错的机会. 垃圾回收最早用于 LISP 语言, 由 John McCarthy 提出.

常用的两种内存回收机制

引用计数法

引用计数法是最简单的垃圾回收算法. 此算法把 "对象是否不再需要" 简化定义为 "对象有没有被其他对象引用". 如果没有引用指向对象, 对象就被垃圾回收机制回收.

引用计数法的限制在遇到循环引用的情况, 如下:

function f () {
  const o = {}
  const o2 = {}
  o.a = o2  // o 引用 o2
  o2.a = o  // o2 引用 o
}
f()

在该例子中, 由于 oo2 互相引用, 导致他们两者都没办法被回收.

IE 6, 7 使用引用计数法对 DOM 对象进行垃圾回收, 所以容易导致因循环引用产生的内存泄漏问题.

标记清除法 (mark-sweep)

标记清除法是最早开发出的 GC 算法. 标记清除法把 "对象是否不再需要" 定义为 "对象是否可以获得". 它定期的从根 (在 JavaScript 中, 根就是全局对象) 开始将可能被引用的对象用递归的方式进行标记, 然后将没有标记到的对象作为垃圾回收.

标记清除法的限制在于无法从根对象查询到的对象都将被清除, 但是这对于大多数人来说并不是问题.

从 2012 年开始, 所有现代浏览器都使用了标记清除垃圾回收算法. 所有对 JavaScript 垃圾回收算法的改进都是基于标记清除法的改进.

V8 的垃圾回收机制

接下来我们主要讨论下 Node.js, 或者说是 V8 的垃圾回收表现.

在 V8 中, 栈用于存储原始类型和对象的引用, 堆用于存储引用类型如对象, 字符串或闭包. 引用类型在没有引用之后, 会通过 V8 的 GC 自动回收. 值类型如果是处于闭包中, 要等闭包没有引用才会被 GC 回收, 非闭包的情况下等待 V8 的新生代切换的时候回收.

V8 堆的分配

V8 采用了一个分代垃圾回收器, 并将堆又分为了几个不同的区域:

  • 新生区: 存活时间较短的对象, 新分配对象. 新生区大小一般在 1-8 MB. 新生区的垃圾回收非常频繁也非常快.
  • 老生指针区: 包含大多数可能存在指向其他对象的指针的对象. 大多数在新生区存活一段时间之后的对象会被挪到这里.
  • 老生数据区: 存放只包含原始数据的对象(这些对象没有指向其他对象的指针). 字符串, 封箱的数字以及未封箱的双精度数字数组, 在新生区经历一次 Scavenge 后会被移动到这里.
  • 大对象区: 存放体积超过 1MB 大小的对象. 每个对象都有自己 mmap 产生的内存, 垃圾回收器从不移动大对象.
  • Code 区: 代码对象, 也就是包含 JIT 之后指令的对象, 会被分配到这里.
  • Misc 区: 因为存放的是相同大小的元素, 所以内存结构很简单.

leaks-post-021.jpeg

V8 的内存回收算法

V8 分别对新生区和老生区采用不同的垃圾回收算法来提升垃圾回收的效率.

  • Scavenge Collection

    新生代使用半空间(semi-space) 的分配策略, 其中新对象最初分配在新生代的活跃半空间内也叫为 From 区(某些特定类型的对象, 如可执行的代码对象是分配在老生区的).

    在 Scavenge 的具体实现上, 主要采用了 Cheney 算法. 关于 V8 的具体实现, 有两种说法 (不确定哪一种):

    1. 当开始垃圾回收的时候, 会检查 From 区的存活对象, 这些存活对象会被复制到 To 区中, 而非存活对象占用的空间将会被释放. 完成复制后, From 区和 To 区空间的角色发生兑换. 在一定条件下, 存活周期较长的对象会晋升到老生代中. (来源: 《深入浅出 Node.js》)

    2. 一旦 To 区已满, 一个 Scavenge 操作将交换 From 区和 To 区, 然后将 From 区中活跃的对象复制到 To 区或晋升到老生区中. (来源: https://alinode.aliyun.com/blog/14, https://phptouch.com/2016/06/07/does-my-nodejs-application-leak-memory-4/)

      如下图: 蓝色代表存活对象, 灰色代表非存活对象.

      leaks-post-025.jpeg

    新生代对象的 Scavenge 操作的持续时间取决于新生代中存活对象的数量. 在大部分新生代对象存活时间不长的情况下, 一个 Scavenge 操作非常快(< 1ms). 然而, 如果大多数对象都需要被 Scavenge 的时候, Scavenge 操作的持续时间显然会更长.

    Scavenge 操作对于快速回收, 紧缩小片内存效果很好, 但对于大片内存则消耗过大. 因为 Scavenge 操作需要出区和入区两个区域. 老生代所保存的对象大多数是生存周期很长的甚至是常驻内存的对象, 而且老生代占用的内存较多. 因此该算法明显不适合老生代.

    另外, 对于如何判断一个对象是否是存活的, V8 实际上在写缓冲区有一个列表记录所有老生区对象指向新生区的情况. 新对象诞生的时候并不会有指向它的指针, 而当老生区中的对象出现指向新生区对象的指针的时候, 我们便记录下来这样的跨区指向. 这种操作也叫写屏障.

  • Mark-Sweep Collection & Mark-Compact

    在 V8 老生代的垃圾回收采用的是标记清除(Mark-Sweep) 和 标记紧缩(Mark-Compact) 结合的策略. 当老生代的活动对象增长超过一个预设的限制的时候, 将对堆栈执行一个大回收. 老生代垃圾回收使用了 Mark-Sweep 策略, 并采用了几种优化方法来改善延迟和内存消耗. V8 采用了一种增量标记的方式标记活跃对象, 将完整的标记拆分成很多小的步骤, 每做一步旧停下来让 JavaScript 的应用线程执行一会.

    标记完成后, 所有对象都已经被标记, 即不是活跃对象就是死亡对象. 清除时, 垃圾回收期会扫描连续存放的死对象把其变为空闲空间. 这个任务是由专门的清扫线程同步执行. 最后, 为减少老生代对象产生的内存碎片, 还要执行内存紧缩. 内存紧缩可能是非常耗时的, 并且仅当内存碎片成为问题的时候才进行.

    在 Chrome 中, 带内存紧缩的完整垃圾回收只有在 Chrome 空闲足够长的时间才被执行.

- 阅读剩余部分 -

使用 Koa2 开发小结

RSS 订阅器项目是我最近花时间比较多的一个项目了. 在这个项目中我使用了大量的新技术, 很多技术都是我第一次使用. 后端是基于 Koa2 和 Mongoose 的 RESTful API.

在这个项目开展前, 我已经有半年多没接触后端了. 上一次后端还是用 PHP 以及 Laravel 框架开发的 LNMP 架构. 在动工前, 我也没有正式的使用过 Node 以及其部署, 对于 Koa2 的 async await 的异步书写方式也只是久仰大名而已.

这篇博客主要想说一说自己在使用 Node.js 和 Koa2 开发后端过程中的一些总结和收获.

中间件

Koa2 本身是一个非常轻的框架, 我们需要使用大量的中间件去完善它, 例如 koa-bodyparser , koa-etag, koa-router, koa-sslify 等等.

同时, 肯定免不了自己写中间件, 例如我自己就写了 7 个中间件, 分别是处理缓存, 处理 cookies, 强制 www, 配合前端 HTML5Mode, 错误处理, UA 判断, JWT 和 XSRF 处理.

module.exports = function () {
    return async(ctx, next) => {
        if (/^\/(mark|square|feed|feeds|post|posts|me|search)/.test(ctx.request.url)) {
            if (ctx.mobile) {
                await send(ctx, './public/index.html')
            }
            else {
                await send(ctx, './public/pc.html')
            }
        }
        await next()
    }
}

上述就是配合前端 HTML5Mode 以及根据 UA 指向不同入口文件的中间件.

- 阅读剩余部分 -