函数模式
可测试性
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 分钟
- 直接调用函数,而不是通过网关等间接调用
- 创建专门处理这种预热调用的函数,而不是正常业务函数
这种方案只是目前行之有效且比较黑科技的方案,可以使用,但如果你的业务允许“牺牲第一个请求的性能换取大部分性能”,那也完全不必使用该方案,