咱们近一两年来天天在各类公众号、前端大会上听见这个词。一听到什么serverless,什么微服务,总感受本身像是一个外星科技的观光客,外星人的东西再好,咱们单位没有这种基础设施我该怎么用呢?我一个小小的切图仔,难道还要我造一堆serverless的设施出来?html
既然地球没有,那咱们只能去外星偷学一波技能,正所谓“师夷长技”,没准哪一天地球招人的时候也须要有serverless开发经验的工程师呢。前端
晦涩的外星语言我就不在这里多作赘述了,相信你们对什么BaaS, FaaS这种词都看了不下数十遍了,用地球人的话来讲就是:有了serverless,你们在写应用时不再用去关心什么服务器、后端运维了,咱们只须要专一在写业务逻辑代码就好了。下面再重述一下几条老生常谈的优点node
听到这可能有些人就会想了,serverless这么厉害,是否是后端和运维同事都得下岗了呢?这个问题能够说是见仁见智的,我能够表达一个我的的想法:在大规模使用serverless架构的前提下,对于有serverless基础设施的公司来讲,serverless能将后端从业务中解放出来,更加明确地划分不一样工程师的职责;而对于依赖于他人serverless服务的公司来讲,JS全栈工程师就已经足以胜任全部业务开发的职责了。git
在真正体验以前,先来对比一下现有的几家Serverless服务商github
100万
次请求以及400000GB-秒
的计算时间$0.00001667 / GB-秒
Azure Functions - 微软于2016年推出了他们的serverless解决方案Azure Functions。web
100万
次请求以及400000GB-秒
的计算时间$0.000016 / GB-秒
Google Cloud Functions - 谷歌于2017年推出的解决方案,早期落后于亚马逊与微软,可是在近年来修复了很多问题,有迎头遇上的趋势。数据库
200万
次请求以及400000GB-秒
的计算时间$0.0000004 / GB-秒
(额外征收内存
与CPU
的费用)100万
次请求以及400000GB-秒
的计算时间$0.000017 / GB-秒
100万
次请求以及400000GB-秒
的计算时间$0.00001617 / GB-秒
(按当前汇率 1 : 6.87计算)从价格
的角度来看,较为贵的一家是谷歌,尽管提供了200万
次的免费请求额度,谷歌对于内存
与CPU
的额外收费会显著提升使用他家服务的开支。另外四家的价格都是较为接近的,其中以微软最低,IBM最高,亚马逊和阿里云处于中游。npm
从实用
的角度来看,亚马逊和微软的服务仍然以完善的设施(触发器种类多、支持语言多...) 和丰富的社区支持在多数评测中占据了上风,而谷歌与刚崭露头角的IBM、阿里云依然是处于跟跑的状态。这五家中Lambda能够说是体验serverless的最佳选择了。编程
咱们选择serverless框架来快速建立、部署Lambda服务。json
这里的serverless指的是一个在GitHub上超过3万星的一个cli工具。经过serverless cli,咱们能够快速生成Lambda服务模版,标准化、工程化服务的开发以及一键部署服务至多套的环境与节点,极大地缩短了服务开发至上线的时间。
若是你是一个更加信赖纯净的AWS设施的人,愿意跟随着原汁原味的AWS Lambda开发文档来开发的话,那也是极好的。只是可能个人服务今天就上线了,你的要等到后天。
接下来的两个小时,咱们要在Lambda上部署一套常见的用户服务,包含如下四个接口
/api/user/signup
- 建立一个新用户并录入数据库/api/user/login
- 登入并返回JSON Web Token来让用户访问私有接口/api/public
- 公共接口,无须登入的用户也可访问/api/private
- 私有接口,只有登入后的用户才能访问npm install -g serverless
设置AWS Credentials
咱们选择的语言是JS,数据库是DynamoDb,从serverless的示例库中很快能够找到这样的模版aws-node-rest-api-with-dynamodb
复制模版至本地做为起步工程
serverless install -u https://github.com/serverless/examples/tree/master/aws-node-rest-api-with-dynamodb
复制代码
这个工程包含了一个Todo列表的CRUD操做服务。核心文件有:
serverless.yml
定义了该服务所提供的Lambda函数
、触发函数的触发器
以及运行该函数所须要的其余AWS资源
。
package.json
定义了该服务所依赖的其余库。
todos
目录下包含了全部的函数文件。咱们能够看到函数都是较为直白的,每个文件都是相似如下的结构:
const AWS = require('aws-sdk'); // 引入AWS SDK
const dynamoDb = new AWS.DynamoDB.DocumentClient(); // 创建dynamoDb实例
// 经过 module.exports 来导出该函数
module.exports.create = (event, context, callback) => {
const data = JSON.parse(event.body); // 解析event来得到请求数据
/* 业务逻辑 */
callback(); // 用callback来返回响应数据
}
复制代码
当咱们运行npm install
与serverless deploy
将该起步工程部署到云端后,就能够经过Api地址(例:xxxxxx.execute-api.us-east-1.amazonaws.com/dev/todos)来运行和访问这些函数。
根据这个工程原有的函数 ,建立相似的函数文件并不是难事,咱们在工程中建立如下4个文件:
但仅仅建立函数文件是不够的,咱们须要同时在serverless.yml
中为这几个函数添加定义。以signup
函数为例,在functions
中添加如下内容:
signup:
handler: user/signup.signup #定义了函数文件的路径
events:
- http: #定义了函数触发器种类为http (AWS API Gateway)
path: api/user/signup #定义了请求路径
method: post #定义了请求method种类
cors: true #开启跨域
复制代码
这样咱们就完整地定义了4个函数。接下来咱们来看这四个函数具体的实现方法。
GET
返回一条无须登录便可访问的信息
public
函数是4个函数中最为简易的一个,由于该函数是彻底公开的,咱们不须要对该函数作任何校验。以下,简单地返回一条信息即可:
// user/public.js
module.exports.public = (event, context, callback) => {
const response = {
statusCode: 200,
body: JSON.stringify({
message: '任何人均可以阅读此条信息。'
})
}
return callback(null, response);
};
复制代码
注:
callback
第一个参数传入的为错误,第二个参数传入的为响应数据。
public
函数# 部署单个函数
serverless deploy function -f public
复制代码
或# 部署全部函数
serverless deploy
复制代码
serverless deploy
后的log中或在AWS API Gateway控制台
- 阶段(stage)
中找到)curl -X GET https://xxxxxx.execute-api.us-west-2.amazonaws.com/dev/api/public
复制代码
{
"message": "任何人均可以阅读此条信息。"
}
复制代码
POST
建立一个新用户并录入数据库,返回成功或失败信息
signup
函数的运行须要DynamoDB
这一资源,因此第一步咱们须要在serverless.yml
文件中对resources
进行以下修改
# serverless.yml
resources:
Resources:
UserDynamoDbTable:
Type: 'AWS::DynamoDB::Table' #资源种类为DynamoDB表
DeletionPolicy: Retain #当删除CloudFormation Stack(serverless remove)时保留该表
Properties:
AttributeDefinitions: #定义表的属性
-
AttributeName: username #属性名
AttributeType: S #属性类型为字符串
KeySchema: #描述表的主键
-
AttributeName: username #键对应的属性名
KeyType: HASH #键类型为哈希
ProvisionedThroughput: #表的预置吞吐量
ReadCapacityUnits: 1 #读取量为1单元
WriteCapacityUnits: 1 #写入量为1单元
#用serverless变量来定义表名,表名为环境变量中的定义的DYNAMODB_TABLE
TableName: ${self:provider.environment.DYNAMODB_TABLE}
复制代码
resources
一栏中填写的内容是使用yaml
语法写的AWS CloudFormation的模版。
DynamoDB表在CloudFormation中更为详细定义文档请参考连接。
signup
是一个方法为POST
的接口,所以须要从触发事件的body
中获取请求数据。
// user/signup.js
module.exports.signup = (event, context, callback) => {
// 获取请求数据并解析JSON字符串
const data = JSON.parse(event.body);
const { username, password } = data;
/* ... 校验 username 与 passowrd */
}
复制代码
获取完了请求数据后,咱们须要构造出新用户的数据,并把数据录入DynamoDB
// user/signup.js
// 引入nodejs加密库
const crypto = require('crypto');
// 建立dynamoDB实例
const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();
module.exports.signup = (event, context, callback) => {
// ...获取并校验 username 与 password
// 生成新用户的数据
// 生成salt来确保哈希后密码的惟一性
const salt = crypto.randomBytes(16).toString('hex');
// 用sha512哈希函数加密,生成仅可单向验证的哈希密码
const hashedPassword = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512').toString('hex');
const timestamp = new Date().getTime(); // 生成当前时间戳
const params = {
TableName: process.env.DYNAMODB_TABLE, // 从环境变量中获取DynamoDB表名
Item: {
username, // 用户名
salt, // 保存salt用于登录时单向校验密码
password: hashedPassword, // 哈希密码
createdAt: timestamp, // 生成时间
updatedAt: timestamp // 更新时间
}
}
// 录入至dynamoDb
dynamoDb.put(params, (error) => {
// 返回失败信息
if (error) {
// log错误信息,可在AWS CloudWatch服务中查看
console.error(error);
callback(null, {
statusCode: 500,
body: JSON.stringify({
message: '建立用户失败!'
})
});
} else {
// 返回成功信息
callback(null, {
statusCode: 200,
body: JSON.stringify({
message: '建立用户成功!'
})
});
}
}
复制代码
DynamoDB在Nodejs中更详细的CRUD操做文档请参考连接
signup
函数# 部署单个函数
serverless deploy function -f signup
复制代码
或# 部署全部函数
serverless deploy
复制代码
cURL
工具执行如下命令来发送请求(替换成你的API地址,API地址可在运行serverless deploy
后的log中或在AWS API Gateway控制台
- 阶段(stage)
中找到)curl -X POST https://xxxxxx.execute-api.us-west-2.amazonaws.com/dev/api/user/signup --data '{ "username": "new_user", "password": "12345678" }'
复制代码
{
"message": "success"
}
复制代码
GET
校验用户名密码并返回JSON Web Token来让登录用户访问私有接口
在用户调用了Login接口并经过验证后,咱们须要为用户返回一个JSON Web Token
,以供用户来调用须要权限的服务。设置JSON Web Token须要如下几步操做:
npm install jsonwebtoken --save
安装jsonwebtoken库并添加至项目依赖secret.json
文件来存放密钥,这里咱们采用对称加密的方式来定义一个私有密钥。// secret.json
{
"secret": "私有密钥"
}
复制代码
serverless.yml
的provider
下做以下变动# serverless.yml
provider:
environment:
# 使用serverless变量语法将文件中的密钥赋值给环境变量PRIVATE_KEY
PRIVATE_KEY: ${file(./secret.json):secret}
复制代码
login
是一个方法为GET
的接口,所以须要从触发事件的queryStringParameters
中获取请求数据。
// user/login.js
module.exports.login = (event, context, callback) => {
// 获取请求数据
const { username, password } = event.queryStringParameters;
/* ... 校验 username 与 passowrd */
}
复制代码
// user/login.js
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();
module.exports.login = (event, context, callback) => {
// ...获取并校验 username 与 password
// 验证帐号密码并返回JSON Web Token
// 构造DynamoDB请求数据,根据主键username获取数据
const params = {
TableName: process.env.DYNAMODB_TABLE,
Key: {
username
}
};
// 从DynamoDB中获取数据
dynamoDb.get(params, (error, data) => {
if (error) {
// log错误信息,可在AWS CloudWatch服务中查看
console.error(error);
// 返回错误信息
callback(null, {
statusCode: 500,
body: JSON.stringify({
message: '登录失败!'
})
});
return;
}
// 从回调参数中获取DynamoDB返回的用户数据
const user = data.Item;
if (
// 确认username存在
user &&
// 确认哈希密码匹配
user.password === crypto.pbkdf2Sync(password, user.salt, 10000, 512, 'sha512').toString('hex')
) {
// 返回登录成功信息
const response = {
statusCode: 200,
body: JSON.stringify({
username, // 返回username
// 返回JSON Web Token
token: jwt.sign(
{
username // 嵌入username数据
},
process.env.PRIVATE_KEY // 使用私有密钥签发token
)
})
};
callback(null, response);
} else {
// 返回错误信息
callback(null, {
statusCode: 401,
body: JSON.stringify({
message: '用户名或密码错误!'
})
});
}
});
};
复制代码
login
函数# 部署单个函数
serverless deploy function -f login
复制代码
或# 部署全部函数
serverless deploy
复制代码
serverless deploy
后的log中或在AWS API Gateway控制台
- 阶段(stage)
中找到)curl -X GET 'https://xxxxxx.execute-api.us-west-2.amazonaws.com/dev/api/user/login?username=new_user&password=12345678'
复制代码
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5ld191c2VyIiwiaWF0IjoxNTYxODI1NTgyfQ.Iv0ulooGayulxf_MkkpBO1xEw1gilThT62ysuz-rQE0",
"username": "new_user"
}
复制代码
校验请求中所包含的JSON Web Token是否有效
在编写private
函数以前,咱们须要提供另外一个函数auth
来校验用户提交请求中的JSON Web Token是否与咱们所签发的一致。
user/auth.js
文件serverless.yml
的functions
中添加如下内容:auth:
handler: user/auth.auth
# auth是一个仅会在服务内被调用的函数,所以没有任何触发器
复制代码
为了可以让AWS API Gateway
触发器正确地识别函数有无权限执行,咱们必须在auth
函数中返回一个含IAM ( AWS 服务与权限管控系统) 权限策略信息的响应数据,来使得有权限的函数能够经过AWS API Gateway
成功触发。在user/auth.js
内定义一个以下的方法:
// user/auth.js
const generatePolicy = (principalId, effect, resource) => {
const authResponse = {};
authResponse.principalId = principalId; // 用于标记用户身份信息
if (effect && resource) {
const policyDocument = {};
policyDocument.Version = '2012-10-17'; // 定义版本信息
policyDocument.Statement = [];
const statementOne = {};
statementOne.Action = 'execute-api:Invoke'; // 定义操做类型,这里为API调用操做
statementOne.Effect = effect; // 可用值为ALLOW或DENY,用于指定该策略所产生的结果是容许仍是拒绝
statementOne.Resource = resource; // 传入ARN(AWS资源名)来指定操做所须要的资源
policyDocument.Statement[0] = statementOne;
authResponse.policyDocument = policyDocument; // 将定义完成的策略加入响应数据
}
return authResponse;
};
复制代码
关于IAM策略更为详细的配置文档请查看连接
咱们在解析JSON Web Token时默认请求遵循OAuth2.0中的Bearer Token格式。
// user/auth.js
const jwt = require('jsonwebtoken');
/* ...定义generatePolicy方法 */
module.exports.auth = (event, context, callback) => {
// 获取请求头中的Authorization
const { authorizationToken } = event;
if (authorizationToken) {
// 解析Authorization
const split = event.authorizationToken.split(' ');
if (split[0] === 'Bearer' && split.length === 2) {
try {
const token = split[1];
// 使用私有密钥校验JSON Web Token
const decoded = jwt.verify(token, process.env.PRIVATE_KEY);
// 使用generatePolicy生成包含容许API调用的IAM权限策略的响应数据
const response = generatePolicy(decoded.username, 'Allow', event.methodArn);
return callback(null, response);
} catch (error) {
// JSON Web Token 校验失败,返回错误
return callback('Unauthorized');
}
} else {
// Authorization 格式校验失败,返回错误
return callback('Unauthorized');
}
} else {
// 请求头未含Authorzation,返回错误
return callback('Unauthorized');
}
};
复制代码
GET
返回一条须要登录才可访问的信息
private
函数的实现与以前的public
函数十分相似,惟一的区别就是咱们须要在函数的http (AWS API Gateway)
触发器中加入刚刚定义的auth
做为权限校验函数。
在serverless.yml
中对先前定义的private
函数做以下变动:
# serverless.yml
functions:
private:
handler: user/private.private
events:
- http:
path: api/private
method: get
authorizer: auth #设置authorizer为auth函数
cors: true
复制代码
// user/private.js
module.exports.private = (event, context, callback) => {
// 从触发事件中获取请求的用户信息
const username = event.requestContext.authorizer.principalId;
// 返回消息
const response = {
statusCode: 200,
body: JSON.stringify({
message: `你好,${username}!只有登录后的用户才能够阅读此条信息。`
})
}
return callback(null, response);
};
复制代码
private
函数# 部署单个函数
serverless deploy function -f private
复制代码
或# 部署全部函数
serverless deploy
复制代码
serverless deploy
后的log中或在AWS API Gateway控制台
- 阶段(stage)
中找到)curl -X GET -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5ld191c2VyIiwiaWF0IjoxNTYxODI1NTgyfQ.Iv0ulooGayulxf_MkkpBO1xEw1gilThT62ysuz-rQE0" https://xxxxxx.execute-api.us-west-2.amazonaws.com/dev/api/private
复制代码
{
"message": "你好,new_user!只有登录后的用户才能够阅读此条信息。"
}
复制代码
serverless install -u https://github.com/yuanfux/aws-lambda-user
cd aws-lambda-user
npm install
serverless deploy
咱们看到了AWS Lambda的实用、便捷与低价,但咱们可否看到隐藏在其背后的一些问题。
代码维护
函数式的开发模式注定了代码之间的复用与共享会成为一个难题,也注定了代码量会随着服务的增多而膨胀。serverless会使得函数数量与代码量呈线性增加的关系,以下图
当服务达到必定数量后,维护一个无限膨胀的代码库所须要的额外人力与开支也是不可小视的。
冷启动
冷启动也是一个老生常谈的话题了,用简单的话来讲就是当你的函数一段时间未被运行后,系统就会回收运行你函数的容器资源。这样带来负面影响就是,当下一次调用这个函数时,就须要从新配置一个容器来运行你的函数,结果就是函数的调用会被延迟。来看一下函数调用时间间隔与冷启动几率的关系:
那么具体的延迟时间是多少呢?延迟时间受许多因素的影响,好比你选择的编程语言、函数的内存配置、函数的文件大小等等。可是一个较为广泛的建议就是 Lambda 不适合用做对延迟极其敏感的服务( < 50ms)。
本地开发
运行Lambda函数依赖于许多外部的库和应用(aws-sdk, API Gateway、DynamoDB...),所以想要在一个彻底本地的环境运行这样的函数是十分困难的。若是咱们每次修改函数后都须要部署并依赖于 AWS CloudWatch 中输出的运行日志来调试与开发 Lambda 函数,那想必效率是极低的。幸亏serverless cli
提供了必定的插件来支持基本的本地开发(serverless-offline、serverless-dynamodb-local...)。但不用serverless cli
开发Lambda的用户可能就须要研读AWS-SAM文档并走过一段更为漫长的配置过程了。
迁移
经过开发AWS Lambda你可能已经发现了,咱们所写的代码与AWS这个云服务商是具备强关联性的。尽管有serverless cli
这种通用的serverless应用框架来帮助咱们抹平不一样服务商之间的代码差别,想从一个服务商迁移至另外一个服务商依然是一件繁重的体力劳动,甚至包含着大量的代码重构。用serverless就像抽烟,可能一开始你享受到了烟的美妙,以为抽几根无所谓,想戒时定然能戒。但随着你越陷越深,你会发现戒烟是一个至关痛苦的过程。
计价方式
AWS Lambda收费的最小单位是100ms,也就是意味着你的函数哪怕只执行了1ms也会看成100ms来计费。这种计费方式甚至会致使使用高内存的函数甚至比低内存的要便宜!咱们来看下AWS Lambda的计费表:
举一个较为极端的例子:假设咱们设置了一个内存为448MB的函数,它运行时间为101ms,那么每次执行咱们都须要支付0.000000729 x 2 = $0.000001458。而若是咱们将这个函数的内存提升到512MB,使它的运行时间下降100ms之内,那么每次执行咱们只须要支付$0.000000834。仅仅是一个设置,咱们就下降了整整(1458 - 834) / 1458 ≈ 42.8% 的成本!
找到性价比最高的内存设置意味着额外的工做量,很难想象AWS在这个问题上竟然没有为客户提供一个合理的解决方案。
捆绑消费
在使用AWS Lambda的时几乎全部的周边服务(API Gateway、 CloudWatch、DynamoDB...)都是须要额外收费的。其中一个很明显的特征就是捆绑消费,你可能很难想象 CloudWatch 是在使用 Lambda 时被强制使用的一个服务;而 API Gateway 也是在搭建http服务时几乎没法逃过一个收费站,其$3.5/百万次请求的高额价格甚至远远高于使用 Lambda 的价格。
成长型吸血鬼
小明刚刚迁移了他每月花费$5搭建在某VPS商的我的博客到AWS Lambda上,他发现全部的服务都没有超过AWS的免费线,Lambda为小明每月省下了$5。对于像小明这样的小流量、小内存服务来讲,Lambda的的确确会省下一笔至关可观的成本;但对于占用大流量、大内存的服务来讲,Lambda的按调用量收费反而会在无形之间累加出一笔高额费用。使用Lambda就像在住酒店,而租服务器则像租房。酒店设施齐全且便捷,偶尔住几天可能比租房还便宜,但天天每夜住咱也住不起。
虽然Lambda有以上值得权衡的问题,但它所带来对于开发效率的提升是前所未有的,它所带来对于服务开发及运维层面的成本削减也是肉眼可见的。大家可能不知道学了serverless的前端是什么概念,咱们通常只会用两个字来形容这种人:全能!我常常说一句话,今天学serverless,明天就能开公司。今天看了这篇文章若是你还不能在Lambda上写serverless服务,我当场就把这个电脑屏幕吃掉。