其中最底层就是实现复杂业务的后端微服务(Backend)。然后 FaaS 层通过一系列函数实现业务逻辑,并为前端直接提供服务。对于前端开发者来说,前端可以通过编写函数的方式来实现服务端的逻辑。对于后端开发者来说,后端变得更靠后了。如果业务比较较淡,FaaS 层能够实现,甚至也不需要微服务这一层了。
同时不管是在后端、FaaS 还是前端,我们都可以去调用云计算平台提供的 BaaS 服务,大大降低开发难度、减少开发成本。小程序云开发,就是直接在前端调用 BaaS 服务的例子。
基于 Serverless 开发模式和传统开发模式最大的不同,就是传统开发中,我们是基于应用的开发。开发完成后,我们需要对应用进行单元测试和集成测试。而基于 Serverless,开发的是一个个函数,那么我们应该如何对 Serverless 函数进行测试?Serverless 函数的测试和普通的单元测试又有什么区别?
Serverless 函数是分散式的,我们不知道也无需知道函数是部署或运行在哪台机器上,所以我们需要对每个函数进行单元测试。Serverless 应用是由一组函数组成的,函数内部可能依赖了一些别的后端服务(BaaS),所以我们也需要对 Serverless 应用进行集成测试。
运行函数的 FaaS 和 BaaS 在本地也难以模拟。除此之外,不同平台提供的 FaaS 环境可能不一致,不平台提供的 BaaS 服务的 SDK 或介面也可能不一致,这不仅给我们的测试带来了一些问题,也增加了应用迁移成本。
根据 Mike Cohn 提出的测试金字塔,单元测试的成本最低,效率最高;UI 测试(集成)测试的成本最高,效率最低,所以我们要尽可能多的进行单元测试,从而减少集成测试。这对 Serverless 的函数测试同样适用。
图片来源: https:// martinfowler.com/bliki/ TestPyramid.html 为了能更简单对函数进行单元测试,我们需要做的就是将业务逻辑和函数依赖的 FaaS(如函数计算) 和 BaaS (如云资料库)分离 。当 FaaS 和 BaaS 分离出去之后,我们就可以像编写传统的单元测试一样,对函数的业务逻辑进行测试。然后再编写集成测试,验证函数和其他服务的集成是否正常工作。
一个糟糕的例子
下面是一个使用 Node.js 实现的函数的例子。该函数做的事情就是,首先将用户信息存储到资料库中,然后给用户发送邮件。
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
的例子。
// 模拟 mailer
const mailer = {
sendWelcomeEmail : ( email ) => {
console . log ( `Send email to ${ email } success!` );
},
};
这样只要对 Users
进行充分的单元测试,就能确保业务代码如期运行。
然后再传入真实的 db
和 mailer
,进行简单的集成测试,就能知道整个函数是否能够正常工作。
重构后的代码还有一个好处是方便函数的迁移。当我们想要把函数从一个平台迁移到另一个平台的时候,只需要根据不同平台提供的参数,修改一下 Users
的调用方式就可以了,而不用再去修改业务逻辑。
小结
综上所述,对函数进行测试,就需要牢记金字塔原则,并遵循以下原则:
将业务逻辑和函数依赖的 FaaS 和 BaaS 分离
对业务逻辑进行充分的单元测试
将函数进行集成测试验证代码是否正常工作
函数的性能
使用 Serverless 进行开发,还有一个大家都关心的问题就是函数的性能怎么样。
对于传统的应用,我们的程序启动起来之后,就常驻在内存中;而 Serverless 函数则不是这样。
当驱动函数执行的事件到来的时候,首先需要下载代码,然后启动一个容器,在容器里面再启动一个运行环境,最后才是执行代码。前几步统称为冷启动(Cold Start)。传统的应用没有冷启动的过程。
下面是函数生命周期的示意图:
图片来源: https://www. youtube.com/watch? v=oQFORsso2go&feature=youtu.be&t=8m5s 冷启动时间的长短,就是函数性能的关键因素。优化函数的性能,也就需要从函数生命周期的各个阶段去优化。
不同编程语言对冷启动时间的影响
在此之前,已经有很多人测试过不同编程语言对冷启动时间的影响,比如:
Compare coldstart time with different languages, memory and code sizes -by Yan Cui
Cold start / Warm start with AWS Lambda - by Erwan Alliaume
Serverless: Cold Start War - by Mikhail Shilkov
图片来源: Cold start / Warm start with AWS Lambda从这些测试中能够得到一些统一的结论:
增加函数的内存可以减少冷启动时间
C#、Java 等编程语言的能启动时间大约是 Node.js、Python 的 100 倍
基于上述结论,如果想要 Java 的冷启动时间达到 Node.js 那么小,可以为 Java 分配更大的内存。但更大的内存意味著更多的成本。
函数冷启动的时机
刚开始接触 Serverless 的开发者可能有一个误区,就是每次函数执行,都需要冷启动。其实并不是这样。
当第一次请求(驱动函数执行的事件)来临,成功启动运行环境并执行函数之后,运行环境会保留一段时间,以便用于下一次函数执行。这样就能减少冷启动的次数,从而缩短函数运行时间。当请求达到一个运行环境的限制时,FaaS 平台会自动扩展下一个运行环境。
以 AWS Lambda 为例,在执行函数之后,Lambda 会保持执行上下文一段时间,预期用于另一次 Lambda 函数调用。其效果是,服务在 Lambda 函数完成后冻结执行上下文,如果再次调用 Lambda 函数时 AWS Lambda 选择重用上下文,则解冻上下文供重用。
下面以两个小测试来说明上述内容。
我使用阿里云的函数计算实现了一个 Serverless 函数,并通过 HTTP 事件来驱动。然后使用不同并发数向函数发起 100 个请求。
首先是一个并发的情况:
可以看到第一个请求时间为 302ms,其他请求时间基本都在 50ms 左右。基本就能确定,第一个请求对应的函数是冷启动,剩余 99 个请求,都是热启动,直接重复利用了第一个请求的运行环境。
接下来是并发数为 10 的情况:
可以发现,前 10 个请求,耗时基本在 200ms-300ms,其余请求耗时在 50ms 左右。于是可以得出结论,前 10 个并发请求都是冷启动,同时启动了 10 个运行环境;后面 90 个请求都是热启动。
这也就印证了之前的结论,函数不是每次都冷启动,而是会在一定时间内复用之前的运行环境。
执行上下文重用
上面的结论对我们提高函数性能有什么帮助呢?当然是有的。既然运行环境能够保留,那就意味著我们能对运行环境中的执行上下文进行重复利用。
来看一个例子:
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 分钟
直接调用函数,而不是通过网关等间接调用
创建专门处理这种预热调用的函数,而不是正常业务函数
这种方案只是目前行之有效且比较黑科技的方案,可以使用,但如果你的业务允许「牺牲第一个请求的性能换取大部分性能」,那也完全不必使用该方案,
小结
总体而言,优化函数的性能就是优化冷启动时间。上述方案都是开发者方面的优化,当然还一方面主要是 FaaS 平台的性能优化。
总结一下上述方案,主要是以下几点:
选用 Node.js / Python 等冷启动时间短的编程语言
为函数分配合适的运行内存
执行上下文重用
为函数预热
总结
作为前端工程师,我们一直在探讨前端的边界是什么。现在的前端开发早已不是以往的前端开发,前端不仅可以做网页,还可以做小程序,做 APP,做桌面程序,甚至做服务端。而前端之所以在不断拓展自己的边界、不断探索更多的领域,则是希望自己能够产生更大的价值。最好是用我们熟悉的工具、熟悉的方式来创造价值。
而 Serverless 架构的诞生,则可以最大程度帮助前端工程师实现自己的理想。使用 Serverless,我们不需要再过多关注服务端的运维,不需要关心我们不熟悉的领域,我们只需要专注于业务的开发、专注于产品的实现。我们需要关心的事情变少了,但我们能做的事情更多了。
Serverless 也必将对前端的开发模式产生巨大的变革,前端工程师的职能也将再度回归到应用工程师的职能。
如果要用一句话来总结 Serverless,那就是 Less is More。
推荐阅读: