2017年7月

Vue 双向绑定原理

以前写 AngularJS 的时候曾经分析过其双向绑定脏检查机制, 转向 Vue 开发也挺久了但一直没有去研究其源码, 也就前段时间去研究下了其 nextTick 的实现. 今天主要看了 Vue 双向绑定的机制, 感觉 Vue 的代码比起 AngularJS 要容易看很多了, 不过东西也挺多的, 代码质量不错, 写篇文章巩固下.

源码分析

Vue 实现双向绑定有一个非常重要的方法就是 defineReactive, 搞懂这个方法对理解 Vue 的双向绑定帮助非常大. 文章源码篇幅比较多, 但其实代码并不难理解.

defineReactive 大致过程如下:

(注: 结合我看的最新的源码, 图中观察整体 data 应该是用的 observe 方法而不是 defineProperty, 这两个方法区别在于是否需要劫持当前对象自身的 getter, setter.)

defineReactive

源码如下:

- 阅读剩余部分 -

JavaScript 准确判断数组

JavaScript 中判断数组看起来有很多方式:

[].constructor === Array
[].__proto__ === Array.prototype
Reflect.getPrototypeOf([]) === Array.prototype
Object.prototype.toString.call([]) === '[object Array]'
[].pop.toString() === 'function pop() { [native code] }'
[] instanceof Array
Array.isArray([])

正常情况下以上所有方法都是可以用来判断的, 但只有最后一种最准确. 其他方法都没法完全适用. 为什么这么说呢?

  1. 使用 constructor 或者 proto 或者 getPrototypeOf 方法

    实例化后的数组有一个 constructor 属性指向 Array. 这里稍微补充一个点, 在浏览器环境中, [].__proto__ === [].constructor.prototype === Array.prototype. 因此我们也可以通过 [].__proto__ === Array.prototype 来判断. 但 __proto__ 已经是一个已经被废弃的属性了, 不建议使用. 取而代之我们可以用 Reflect 来处理, 即 Reflect.getPrototypeOf([]) === Array.prototype. 它们是等效的. 那么用上述方法判断有什么问题呢?

    我们举个反例:

    const foo = Object.create(null)
    foo.constructor = Array
    console.log(foo.constructor === Array) // true
    foo.__proto__ = Array.prototype
    console.log(foo.__proto__ === Array.prototype) // true
    Reflect.setPrototypeOf(foo, Array.prototype)
    console.log(Reflect.getPrototypeof(foo) === Array.prototype) // true

    也就是说, 这个方法的问题在于 constructor__proto__(对象原型) 可以被重写. 虽然一般不会有人这样去写代码, 但是我们应该知道这些方法判断是有隐患的.

  2. 使用 Object.prototype.toString.call 方法

    JavaScript 内建对象一般都存在几个 Symbol 值, 这些在前面关于 JavaScript 元编程的文章已经有提及. 在调用 Object.prototype.toString() 方法时, 它会检查传入的参数是否拥有 [Symbol.toStringTag] 这个属性并且其为字符串, 如果满足的话, 则会被拼接返回, 下面的例子我们让他返回了跟数组一样的结果.

    class Bar {
     get [Symbol.toStringTag] () {
       return 'Array'
     }
    }
    console.log(Object.prototype.toString.call(new Bar()) === '[object Array]') // true

    因此使用 Object.prototype.toString.call 来判断数组也不是稳妥的, 但在 Array.isArray 不受支持的环境下, 这个方法还是被较多的用来判断数组的.

  3. 使用 [].pop.toString()

    这种方法主要是利用了内建函数隐藏的特点. 但这个是完全可以被伪造的. 但这种伪造就比较刁钻了.

    const foo = {
     pop: {
       toString () {
         return 'function pop() { [native code] }'
       }
     }
    }
    console.log(foo.pop.toString() === [].pop.toString())    // true
  4. instanceof 方法

    该方法实际上是不断的查找左值的原型, 如果找到和右值匹配的, 则返回 true. 该方法大多数情况是没问题的, 但是有一个情况例外:

    例子来源: instanceof_vs_isArray

    var iframe = document.createElement('iframe');
    document.body.appendChild(iframe);
    xArray = window.frames[window.frames.length-1].Array;
    var arr = new xArray(1,2,3); // [1,2,3]
    
    // Correctly checking for Array
    Array.isArray(arr);  // true
    // Considered harmful, because doesn't work though iframes
    arr instanceof Array; // false

    也就是说, 不同的 windows 下的 Array 是不相等的. 同样的, 以下代码判断也不行:

    const newWindow = window.open()
    const foo = new newWindow.window.Array()
    console.log(foo instanceof Array) // false

    相同的, 在 cluster, worker 中也一样. instanceof 有其局限性.

  5. Array.isArray 方法

    这个方法才是判断数组的最佳方法. 在不支持 Array.isArray 的情况下, 一般会使用 toString.call 来作为 Polyfill. Array.isArray 是 ES5 标准, 古董浏览器可能不支持.

- 阅读剩余部分 -

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

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

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

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

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

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)

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

- 阅读剩余部分 -