函数模式

可测试性

Serverless 函数是分布式的,我们不知道也无需知道函数是部署或运行在哪台机器上,所以我们需要对每个函数进行单元测试。Serverless 应用是由一组函数组成的,函数内部可能依赖了一些别的后端服务(BaaS),所以我们也需要对 Serverless 应用进行集成测试。运行函数的 FaaS 和 BaaS 在本地也难以模拟。除此之外,不同平台提供的 FaaS 环境可能不一致,不平台提供的 BaaS 服务的 SDK 或接口也可能不一致,这不仅给我们的测试带来了一些问题,也增加了应用迁移成本。根据 Mike Cohn 提出的测试金字塔,单元测试的成本最低,效率最高;UI 测试(集成)测试的成本最高,效率最低,所以我们要尽可能多的进行单元测试,从而减少集成测试。这对 Serverless 的函数测试同样适用。

const db = require("db").connect();
const mailer = require("mailer");

module.exports.saveUser = (event, context, callback) => {
  const user = {
    email: event.email,
    created_at: Date.now()
  };

  db.saveUser(user, function(err) {
    if (err) {
      callback(err);
    } else {
      mailer.sendWelcomeEmail(event.email);
      callback();
    }
  });
};

这个例子主要存在两个问题:

  • 业务逻辑和 FaaS 耦合在一起。主要就是业务逻辑都在 saveUser 这个函数里,而 saveUser 参数的 event 和 conent 对象,是 FaaS 平台提供的。
  • 业务逻辑和 BaaS 耦合在一起。具体来说,就是函数内使用了 db 和 mailer 这两个后端服务,测试函数必须依赖于 db 和 mailer。

基于将业务逻辑和函数依赖的 FaaS 和 BaaS 分离的原则,对上面的代码进行重构:

class Users {
  constructor(db, mailer) {
    this.db = db;
    this.mailer = mailer;
  }

  save(email, callback) {
    const user = {
      email: email,
      created_at: Date.now()
    };

    this.db.saveUser(user, function(err) {
      if (err) {
        callback(err);
      } else {
        this.mailer.sendWelcomeEmail(email);
        callback();
      }
    });
  }
}

module.exports = Users;
const db = require("db").connect();
const mailer = require("mailer");
const Users = require("users");

let users = new Users(db, mailer);

module.exports.saveUser = (event, context, callback) => {
  users.save(event.email, callback);
};

在重构后的代码中,我们将业务逻辑全都放在了 Users 这个类里面,Users 不依赖任何外部服务。测试的时候,我们也可以不传入真实的 db 或 mailer,而是传入模拟的服务。

// 模拟 mailer
const mailer = {
  sendWelcomeEmail: email => {
    console.log(`Send email to ${email} success!`);
  }
};

冷启动

当驱动函数执行的事件到来的时候,首先需要下载代码,然后启动一个容器,在容器里面再启动一个运行环境,最后才是执行代码。前几步统称为冷启动(Cold Start)。传统的应用没有冷启动的过程。

  • 增加函数的内存可以减少冷启动时间
  • C#、Java 等编程语言的能启动时间大约是 Node.js、Python 的 100 倍

FaaS 同样针对冷启动进行了优化,当第一次请求(驱动函数执行的事件)来临,成功启动运行环境并执行函数之后,运行环境会保留一段时间,以便用于下一次函数执行。这样就能减少冷启动的次数,从而缩短函数运行时间。当请求达到一个运行环境的限制时,FaaS 平台会自动扩展下一个运行环境。

以 AWS Lambda 为例,在执行函数之后,Lambda 会保持执行上下文一段时间,预期用于另一次 Lambda 函数调用。其效果是,服务在 Lambda 函数完成后冻结执行上下文,如果再次调用 Lambda 函数时 AWS Lambda 选择重用上下文,则解冻上下文供重用。

执行上下文重用

const mysql = require("mysql");

module.exports.saveUser = (event, context, callback) => {
  // 初始化数据库连接
  const connection = mysql.createConnection({
    /* ... */
  });
  connection.connect();

  connection.query("...");
};

上面例子实现的功能就是在 saveUser 函数中初始化一个数据库连接。这样的问题就是,每次函数执行的时候,都会重新初始化数据库连接,而连接数据库又是一个比较耗时的操作。显然这样对函数的性能是没有好处的。

既然在短时间内,函数的执行上下文可以重复利用,那么我们就可以将数据库连接放在函数之外:

const mysql = require("mysql");

// 初始化数据库连接
const connection = mysql.createConnection({
  /* ... */
});
connection.connect();

module.exports.saveUser = (event, context, callback) => {
  connection.query("...");
};

这样就只有第一次运行环境启动的时候,才会初始化数据库连接。后续请求来临、执行函数的时候,就可以直接利用执行上下文中的 connection,从而提后续高函数的性能。大部分情况下,通过牺牲一个请求的性能,换取大部分请求的性能,是完全可以够接受的。

函数预热

既然函数的运行环境会保留一段时间,那么我们也可以通过主动调用函数的方式,隔一段时间就冷启动一个运行环境,这样就能使得其他正常的请求都是热启动,从而避免冷启动时间对函数性能的影响。

这是目前比较有效的方式,但也需要有一些注意的地方:

  • 不要过于频繁调用函数,至少频率要大于 5 分钟
  • 直接调用函数,而不是通过网关等间接调用
  • 创建专门处理这种预热调用的函数,而不是正常业务函数

这种方案只是目前行之有效且比较黑科技的方案,可以使用,但如果你的业务允许“牺牲第一个请求的性能换取大部分性能”,那也完全不必使用该方案,

上一页
下一页