云函数指南
当你开发移动端应用时,可能会有下列需求:
- 应用在多平台客户端(Android、iOS、浏览器等)中很多逻辑都是一样的,希望将这部分逻辑抽取出来只维护一份。
- 有些逻辑希望能够较灵活的调整(比如某些个性化列表的排序规则),但又不希望频繁的更新和发布移动客户端。
- 有些逻辑需要的数据量很大,或者运算成本高(比如某些统计汇总需求),不希望在移动客户端进行运算,因为这样会消耗大量的网络流量和手机运算能力。
- 当应用执行特定操作时,由云端系统自动触发一段逻辑(称为 Hook 函数),例如用户注册后对该用户增加一些信息记录用于统计;或某业务数据发生变化后希望做一些别的业务操作。
- 客户端提供能够越过 ACL 或表权限的限制,对数据进行操作。
- 需要定时运行任务,比如每天凌晨清理垃圾注册账号等。
这时,你可以使用云引擎的云函数。 云函数是一段部署在服务端的 JavaScript、Python、PHP、C#、Go 代码,可以很好地完成上述需求。
如果还不知道如何创建云引擎项目、本地调试并部署到云端,请阅读云引擎快速入门。
云函数
示例项目中文件定义了一个很简单的 hello
云函数。
在云端进行计算的一个重要理由是,你不需要将大量的数据发送到设备上做计算,而是将这些计算放到服务端,并返回结果这一点点信息就好。
现在让我们看一个较复杂的例子来展示云函数的用途。
例如,你写了一个应用,让用户对电影评分,一个评分对象大概是这样:
{
"movie": "夏洛特烦恼",
"stars": 5,
"comment": "夏洛一梦,笑成麻花"
}
stars
表示评分,1-5。如果你想查找《夏洛特烦恼》这部电影的平均分,你可以找出这部电影的所有评分,并在设备上根据这个查询结果计算平均分。但是这样一来,尽管你只是需要平均分这样一个数字,却不得不耗费大量的带宽来传输所有的评分。通过云引擎,我们可以简单地传入电影名称,然后返回电影的平均分。
云函数接收 JSON 格式的请求对象,我们可以用它来传入电影名称。
各语言的 SDK 都在云引擎运行环境上有效,可以直接使用,所以我们可以使用它来查询所有的评分。
结合在一起,实现 averageStars
函数的代码如下:
- JavaScript
- Python
- PHP
- Java
- C#
- Go
AV.Cloud.define('averageStars', function (request) {
var query = new AV.Query('Review');
query.equalTo('movie', request.params.movie);
return query.find().then(function (results) {
var sum = 0;
for (var i = 0; i < results.length; i++) {
sum += results[i].get('stars');
}
return sum / results.length;
});
});
@engine.define
def averageStars(movie, **params):
reviews = leancloud.Query(Review).equal_to('movie', movie).find()
result = sum(x.get('stars') for x in reviews)
return result
客户端 SDK 调用时,云函数的名称默认为 Python 代码中函数的名称。有时需要设置云函数的名称与 Python 代码中的函数名称不相同,可以在 engine.define
后面指定云函数名称,比如:
@engine.define('averageStars')
def my_custom_average_start(movie, **params):
pass
use \LeanCloud\Engine\Cloud;
use \LeanCloud\Query;
use \LeanCloud\CloudException;
Cloud::define("averageStars", function($params, $user) {
$query = new Query("Review");
$query->equalTo("movie", $params["movie"]);
try {
$reviews = $query->find();
} catch (CloudException $ex) {
// 查询失败,将错误输出到日志
error_log($ex->getMessage());
return 0;
}
$sum = 0;
forEach($reviews as $review) {
$sum += $review->get("stars");
}
if (count($reviews) > 0) {
return $sum / count($reviews);
} else {
return 0;
}
});
@EngineFunction("averageStars")
public static float getAverageStars(@EngineFunctionParam("movie") String movie)
throws LCException {
LCQuery<LCObject> query = new LCQuery("Review");
query.whereEqualTo("movie", movie);
List<LCObject> reviews = query.find();
int sum = 0;
if (reviews == null && reviews.isEmpty()) {
return 0;
}
for (LCObject review : reviews) {
sum += review.getInt("star");
}
return sum / reviews.size();
}
[LCEngineFunction("averageStars")]
public static float AverageStars([LCEngineFunctionParam("movie")] string movie) {
if (movie == "夏洛特烦恼") {
return 3.8f;
}
return 0;
}
然后在程序启动的入口函数中添加如下代码来启用刚才编写的云函数:
public class Startup {
...
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services) {
// 开启日志(可选)
LCLogger.LogDelegate = (level, log) => {
switch (level) {
case LCLogLevel.Debug:
Console.WriteLine($"[DEBUG] {log}");
break;
case LCLogLevel.Warn:
Console.WriteLine($"[WARN] {log}");
break;
case LCLogLevel.Error:
Console.WriteLine($"[ERROR] {log}");
break;
default:
break;
}
};
// 初始化
LCEngine.Initialize(services);
services.AddControllersWithViews();
}
...
}
type Review struct {
leancloud.Object
Movie string `json:"movie"`
Stars int `json:"stars"`
Comment string `json:"comment"`
}
leancloud.Define("averageStars", func(req *leancloud.FunctionRequest) (interface{}, error) {
reviews := make([]Review, 10) // 预留一小部分空间
if err := client.Class("Review").NewQuery().EqualTo("movie", req.Params["movie"].(string)).Find(&reviews); err != nil {
return nil, err
}
sum := 0
for _, v := range reviews {
sum += v.Stars
}
return sum / len(reviews), nil
})
参数和返回值
- JavaScript
- Python
- PHP
- Java
- C#
- Go
Request
会作为参数传入到云函数中,Request
上的属性包括:
params: object
:客户端发送的参数对象,当使用rpc
调用时,也可能是AV.Object
。currentUser?: AV.User
:客户端所关联的用户(根据客户端发送的X-LC-Session
头)。sessionToken?: string
:客户端发来的sessionToken
(X-LC-Session
头)。meta: object
:有关客户端的更多信息,目前只有一个remoteAddress
属性表示客户端的 IP。
另外,AV.Cloud.define
还接受一个可选参数 options
(位置在函数名称和调用函数之间)。
这个 options
对象上的属性包括:
fetchUser: boolean
:是否自动抓取客户端的用户信息,默认为真。设置为假时,Request
将不会有currentUser
属性。internal: boolean
:是否只允许在云引擎内(使用AV.Cloud.run
且未开启remote
选项)或使用Master Key
(使用AV.Cloud.run
时传入useMasterKey
)调用,不允许客户端直接调用。默认为假。
例如,假设我们不希望客户端直接调用上述函数,也不关心客户端用户信息,那么上述函数的定义可以改写为:
AV.Cloud.define('averageStars', { fetchUser: false, internal: true }, function (request) {
// 定义同上
});
如果云函数返回了一个 Promise,那么云函数会使用 Promise 成功结束后的结果作为成功响应;如果 Promise 中发生了错误,云函数会使用这个错误作为错误响应,对于使用 AV.Cloud.Error
构造的异常对象,我们认为是客户端错误,不会在标准输出打印消息,对于其他异常则会在标准输出打印调用栈,以便排查错误。
我们推荐大家使用链式的 Promise 写法来完成业务逻辑,这样会极大地方便异步任务的处理和异常处理,请注意一定要将 Promise 串联起来并在云函数中 return 以保证上述逻辑正确工作,推荐阅读 JavaScript Promise 迷你书 来深入地了解 Promise。
在 2.0 之前的早期版本中,云函数接受 request
和 response
两个参数,我们会继续兼容这种用法到下一个大版本,希望开发者尽快迁移到 Promise 风格的云函数上。之前版本的文档见Node SDK v1 API 文档。
调用云函数时的参数会直接传递给云函数,因此直接声明这些参数即可。另外调用云函数时可能会根据不同情况传递不同的参数,这时如果定义云函数时没有声明这些参数,会触发 Python 异常,因此建议声明一个额外的关键字参数(关于关键字参数,请参考 此篇文档 中「关键字参数」一节)来保存多余的参数。
@engine.define
def my_cloud_func(foo, bar, baz, **params):
pass
除了调用云函数的参数之外,还可以通过 engine.current
对象,来获取到调用此云函数的客户端的其他信息。engine.current
对象 上的属性包括:
engine.current.user: leancloud.User
:客户端所关联的用户(根据客户端发送的X-LC-Session
头)。engine.current.session_token: str
:客户端发来的sessionToken
(X-LC-Session
头)。engine.current.meta: dict
:有关客户端的更多信息,目前只有一个remote_address
属性表示客户端的 IP。
传递给云函数的参数依次为:
$params: array
:客户端发送的参数。$user: User
:客户端所关联的用户(根据客户端发送的X-LC-Session
头)。$meta: array
:有关客户端的更多信息,目前只有一个$meta['remoteAddress']
属性表示客户端的 IP。
云函数中可以获取的参数和上下文信息有:
@EngineFunctionParam
:客户端发送的参数。EngineRequestContext
有关客户端的更多信息,其中EngineRequestContext.getSessionToken()
会返回客户端所关联用户的 sessionToken(根据客户端发送的X-LC-Session
头),EngineRequestContext.getRemoteAddress()
会返回客户端的实际地址。
云函数中可以获取的参数和上下文信息有:
LCEngineFunctionParam
:客户端发送的参数。LCEngineRequestContext
有关客户端的更多信息,其中LCEngineRequestContext.SessionToken
会返回客户端所关联用户的 sessionToken(根据客户端发送的X-LC-Session
头),LCEngineRequestContext.RemoteAddress
会返回客户端的实际地址。
leancloud.FunctionRequest
会作为参数传入到云函数中,leancloud.FunctionRequest
上的属性包括:
Params
包含客户端发送的参数CurrentUser
包含客户端所关联的用户(根据客户端发送的X-LC-Session
头)。可以在Define
定义云函数时,在最后传入可选参数WithoutFetchUser()
禁止获取当前调用的用户。SessionToken
包含客户端发来的sessionToken
根据客户端发送的X-LC-Session
头)。可以在Define
定义云函数时,在最后传入可选参数WithoutFetchUser()
禁止获取当前调用的sessionToken
。Meta
包含有关客户端的更多信息,目前只有一个remoteAddress
属性表示客户端的 IP。
SDK 调用云函数
LeanCloud 各个语言版本的 SDK 都提供了调用云函数的接口:
- Unity
- Android
- iOS
try {
Dictionary<string, object> response = await LCCloud.Run("averageStars", parameters: new Dictionary<string, object> {
{ "movie", "夏洛特烦恼" }
});
// 处理结果
} catch (LCException e) {
// 处理异常
}
// 构建传递给服务端的参数字典
Map<String, String> dicParameters = new HashMap<String, String>();
dicParameters.put("movie", "夏洛特烦恼");
// 调用指定名称的云函数 averageStars,并且传递参数(默认不使用缓存)
LCCloud.callFunctionInBackground("averageStars", dicParameters).subscribe(new Observer<Object>() {
@Override
public void onSubscribe(Disposable disposable) {
}
@Override
public void onNext(Object object) {
// succeed.
}
@Override
public void onError(Throwable throwable) {
// failed.
}
@Override
public void onComplete() {
}
});
// Java SDK 还提供了一个支持缓存的版本,和 LCQuery 一样,开发者在请求的时候,可以指定 CachePolicy 以及缓存的
// 最长期限,这样可以避免短时间一直直接请求云端服务器。
// 下面的 请求表示优先使用上次缓存的结果,缓存有效期为 30 秒(30000 毫秒)。
LCCloud.callFunctionWithCacheInBackground("averageStars", dicParameters, LCQuery.CachePolicy.CACHE_ELSE_NETWORK, 30000)
.subscribe(new Observer<Object>() {
@Override
public void onSubscribe(Disposable disposable) {
}
@Override
public void onNext(Object object) {
// succeed.
}
@Override
public void onError(Throwable throwable) {
// failed.
}
@Override
public void onComplete() {
}
});
// 构建传递给服务端的参数字典
NSDictionary *dicParameters = [NSDictionary dictionaryWithObject:@"夏洛特烦恼"
forKey:@"movie"];
// 调用指定名称的云函数 averageStars,并且传递参数
[LCCloud callFunctionInBackground:@"averageStars"
withParameters:dicParameters
block:^(id object, NSError *error) {
if(error == nil){
// 处理结果
} else {
// 处理报错
}
}];
RPC 调用云函数
在这种调用方式下,云引擎会自动为 HTTP Response Body 做序列化, 而 SDK 调用之后拿回的返回结果就是一个完整的 LCObject 或包含这样的对象的数据结构:
- Unity
- Android
- iOS
try {
LCObject response = await LCCloud.RPC("averageStars", parameters: new Dictionary<string, object> {
{ "movie", "夏洛特烦恼" }
});
// 处理结果
} catch (LCException e) {
// 处理异常
}
// 构建参数
Map<String, Object> dicParameters = new HashMap<>();
dicParameters.put("movie", "夏洛特烦恼");
LCCloud.<LCObject>callRPCInBackground("averageStars", dicParameters).subscribe(new Observer<LCObject>() {
@Override
public void onSubscribe(Disposable disposable) {
}
@Override
public void onNext(LCObject avObject) {
// succeed.
}
@Override
public void onError(Throwable throwable) {
// failed
}
@Override
public void onComplete() {
}
});
// Java SDK 还提供了一个支持缓存的版本,和 LCQuery 一样,开发者在请求的时候,可以指定 CachePolicy 以及缓存的
// 最长期限,这样可以避免短时间一直直接请求云端服务器。
// 下面的请求表示优先使用上次缓存的结果,缓存有效期为 30 秒(30000 毫秒)。
LCCloud.<LCObject>callRPCWithCacheInBackground("averageStars", dicParameters, LCQuery.CachePolicy.CACHE_ELSE_NETWORK, 30000)
.subscribe(new Observer<LCObject>() {
@Override
public void onSubscribe(Disposable disposable) {
}
@Override
public void onNext(LCObject avObject) {
// succeed.
}
@Override
public void onError(Throwable throwable) {
// failed
}
@Override
public void onComplete() {
}
});
NSDictionary *dicParameters = [NSDictionary dictionaryWithObject:@"夏洛特烦恼"
forKey:@"movie"];
[LCCloud rpcFunctionInBackground:@"averageStars"
withParameters:parameters
block:^(id object, NSError *error) {
if(error == nil){
// 处理结果
}
else {
// 处理报错
}
}];
云函数错误响应码
可以根据 HTTP status codes 自定义错误响应码。
- JavaScript
- Python
- PHP
- Java
- C#
- Go
AV.Cloud.define('customErrorCode', function (request) {
throw new AV.Cloud.Error('自定义错误信息。', { code: 123 });
});
from leancloud import LeanEngineError
@engine.define
def custom_error_code(**params):
raise LeanEngineError(123, '自定义错误信息。')
Cloud::define("customErrorCode", function($params, $user) {
throw new FunctionError("自定义错误信息。", 123);
});
@EngineFunction()
public static void customErrorCode() throws Exception {
throw new LCException(123, "自定义错误信息。");
}
[LCEngineFunction("throwLCException")]
public static void ThrowLCException() {
throw new LCException(123, "自定义错误信息。");
}
leancloud.Define("customErrorCode", func(req *leancloud.FunctionRequest) (interface{}, error) {
return nil, leancloud.CloudError{123, "自定义错误信息。"}
})
客户端收到的响应:{ "code": 123, "error": "自定义错误信息。" }
。
云函数超时
云函数超时时间为 15 秒,如果超过阈值,客户端将收到 HTTP status code 为 503
的响应,body 为 The request timed out on the server
。
注意即使已经响应,此时云函数可能仍在执行,但执行完毕后的响应是无意义的(不会发给客户端,会在日志中打印一个 Can't set headers after they are sent
的异常)。
除了 503
错误外,有些情况下客户端也可能收到其他报错,如 524
或 141
。
超时的处理方案
我们建议将代码中的任务转化为异步队列处理,以优 化运行时间,避免云函数或定时任务发生超时。
例如:
- 在存储服务中创建一个队列表,包含
status
列; - 接到任务后,向队列表保存一条记录,
status
值设置为处理中
,然后将请求结束掉,将队列对象的id
发给客户端。 - 当业务处理完毕,根据处理结果更新刚才的队列对象状态,将
status
字段设置为完成
或者失败
; - 在任何时候,在控制台通过队列
id
可以获取某个任务的执行结果,判断任务状态。
不过,对于 before 类 hook 函数,改为异步处理通常没有意义。 虽然改为异步后能保证 before 类函数能够运行完成,不会因超时而报错。 但是,只要 before 类 hook 函数不能及时抛出异常,就无法起到中断操作执行的作用。 对于超时的 before 类 hook 函数,如果无法优化代码,压缩执行时间的话,那只能改为 after 类函数。 例如,假设某个 beforeSave 函数需要调用耗时较久的第三方的自然语言处理接口判断用户提交的评论是否来自真实用户,导致超时, 那么可以改为 afterSave 函数,在保存评论后再调用第三方接口,如果判断是水军评论,那么再行删除。
Hook 函数
Hook 函数本质上是云函数,但它有固定的名称,定义之后会 由系统 在特定事件或操作(如数据保存前、保存后,数据更新前、更新后等等)发生时 自动触发,而不是由开发者来控制其触发时机。需要注意:
- 通过控制台进行数据导入时不会触发任何 hook 函数。
- 使用 Hook 函数需要 防止死循环调用。
_Installation
表暂不支持 Hook 函数。- Hook 函数只对当前应用的 Class 生效,对绑定后的目标 Class 无效。
对于 before
类的 Hook,如果返回了一个错误的话,这个操作就会被中断,因此你可以在这些 Hook 中主动抛出一个错误来拒绝掉某些操作。对于 after
类的 Hook,返回错误并不会影响操作的执行(因为其实操作已经执行完了)。
为了认证 Hook 调用者的身份,我们的 SDK 内部会确认请求确实是从云引擎内网的云存储组件发出的,如果认证失败,可能会出现 Hook key check failed
的提示,如果在本地调试时出现这样的错误,请确保是通过命令行工具启动调试的。
BeforeSave
在创建新对象之前,可以对数据做一些清理或验证。例如,一条电影评论不能过长,否则界面上显示不开,需要将其截断至 140 个字符:
- JavaScript
- Python
- PHP
- Java
- C#
- Go
AV.Cloud.beforeSave('Review', function (request) {
var comment = request.object.get('comment');
if (comment) {
if (comment.length > 140) {
// 截断并添加 '…'
request.object.set('comment', comment.substring(0, 140) + '…');
}
} else {
// 不保存数据,并返回错误
throw new AV.Cloud.Error('No comment provided!');
}
});
上面的代码示例中,request.object
是被操作的 AV.Object
。除了 object
之外,request
上还有一个属性:
currentUser?: AV.User
:发起操作的用户。
类似地,其他 hook 的 request
参数上也包括 object
和 currentUser
这两个属性。
@engine.before_save('Review') # Review 为需要 hook 的 class 的名称
def before_review_save(review):
comment = review.get('comment')
if not comment:
raise leancloud.LeanEngineError(message='No comment provided!')
if len(comment) > 140:
review.comment.set('comment', comment[:140] + '…')
Cloud::beforeSave("Review", function($review, $user) {
$comment = $review->get("comment");
if ($comment) {
if (strlen($comment) > 140) {
// 截断并添加 '…'
$review->set("comment", substr($comment, 0, 140) . "…");
}
} else {
// 不保存数据,并返回错误
throw new FunctionError("No comment provided!", 101);
}
});
@EngineHook(className = "Review", type = EngineHookType.beforeSave)
public static LCObject reviewBeforeSaveHook(LCObject review) throws Exception {
if (StringUtil.isEmpty(review.getString("comment"))) {
throw new Exception("No comment provided!");
} else if (review.getString("comment").length() > 140) {
review.put("comment", review.getString("comment").substring(0, 140) + "…");
}
return review;
}
[LCEngineClassHook("Review", LCEngineObjectHookType.BeforeSave)]
public static LCObject ReviewBeforeSave(LCObject review) {
if (string.IsNullOrEmpty(review["comment"])) {
throw new Exception("No comment provided!");
}
string comment = review["comment"] as string;
if (comment.Length > 140) {
review["comment"] = string.Format($"{comment.Substring(0, 140)}...");
}
return review;
}
leancloud.BeforeSave("Review", func(req *ClassHookRequest) (interface{}, error) {
review := new(Review)
if err := req.Object.Clone(review); err != nil {
return nil, err
}
if len(review.Comment) > 140 {
review.Comment = review.Comment[:140]
}
return review, nil
})
AfterSave
在创建新对象后触发指定操作,比如当一条留言创建后再更新一下所属帖子的评论总数:
- JavaScript
- Python
- PHP
- Java
- C#
- Go
AV.Cloud.afterSave('Comment', function (request) {
var query = new AV.Query('Post');
return query.get(request.object.get('post').id).then(function (post) {
post.increment('comments');
return post.save();
});
});
import leancloud
@engine.after_save('Comment') # Comment 为需要 hook 的 class 的名称
def after_comment_save(comment):
post = leancloud.Query('Post').get(comment.id)
post.increment('commentCount')
try:
post.save()
except leancloud.LeanCloudError:
raise leancloud.LeanEngineError(message='An error occurred while trying to save the post.')
Cloud::afterSave("Comment", function($comment, $user) {
$query = new Query("Post");
$post = $query->get($comment->get("post")->getObjectId());
$post->increment("commentCount");
try {
$post->save();
} catch (CloudException $ex) {
throw new FunctionError("An error occurred while trying to save the post: " . $ex->getMessage());
}
});
@EngineHook(className = "Review", type = EngineHookType.afterSave)
public static void reviewAfterSaveHook(LCObject review) throws Exception {
LCObject post = review.getLCObject("post");
post.fetch();
post.increment("comments");
post.save();
}
[LCEngineClassHook("Review", LCEngineObjectHookType.AfterSave)]
public static async Task ReviewAfterSave(LCObject review) {
LCObject post = review["post"] as LCObject;
await post.Fetch();
post.Increment("comments", 1);
await post.Save();
}