Skip to content

2.云引擎 1 云函数

jwfing edited this page Jun 2, 2018 · 1 revision

云函数是云引擎({{productName}})的一个子模块,请确保在阅读本文档之前已经阅读了 云引擎服务概览

当你开发移动端应用时,可能会有下列需求:

  • 应用在多平台客户端(Android、iOS、Windows Phone、浏览器等)中很多逻辑都是一样的,希望将这部分逻辑抽取出来只维护一份。
  • 有些逻辑希望能够较灵活的调整(比如某些个性化列表的排序规则),但又不希望频繁的更新和发布移动客户端。
  • 有些逻辑需要的数据量很大,或者运算成本高(比如某些统计汇总需求),不希望在移动客户端进行运算,因为这样会消耗大量的网络流量和手机运算能力。
  • 当应用执行特定操作时,由云端系统自动触发一段逻辑(称为 Hook 函数),例如用户注册后对该用户增加一些信息记录用于统计;或某业务数据发生变化后希望做一些别的业务操作。
  • 客户端提供能够越过 ACL 或表权限的限制,对数据进行操作。
  • 需要定时任务,比如每天凌晨清理垃圾注册账号等。

这时,你可以使用云引擎的云函数,云函数是一段部署在服务端的代码,编写 JavaScript、Python、PHP 或 Java 代码,并部署到我们的平台上,可以很好的完成上述需求。

如果还不知道如何创建云引擎项目、本地调试并部署到云端,请阅读 云引擎快速入门

多语言支持

云引擎支持多种语言的运行环境,你可以选择自己熟悉的语言开发应用:

切换云引擎环境

云引擎应用有「生产环境」和「预备环境」之分,在客户端的切换方法为:

AVCloud.setProductionMode(false); // 调用预备环境

免费版云引擎 应用只有「生产环境」,因此请不要切换到预备环境。

云函数

示例项目 中 "$PROJECT_DIR/src/main/java/cn/leancloud/demo/todo/Cloud.java" 文件定义了一个很简单的 hello 云函数。现在让我们看一个较复杂的例子来展示云引擎的用途。在云端进行计算的一个重要理由是,你不需要将大量的数据发送到设备上做计算,而是将这些计算放到服务端,并返回结果这一点点信息就好。

例如,你写了一个应用,让用户对电影评分,一个评分对象大概是这样:

{
  "movie": "夏洛特烦恼",
  "stars": 5,
  "comment": "夏洛一梦,笑成麻花"
}

stars 表示评分,1-5。如果你想查找《夏洛特烦恼》这部电影的平均分,你可以找出这部电影的所有评分,并在设备上根据这个查询结果计算平均分。但是这样一来,尽管你只是需要平均分这样一个数字,却不得不耗费大量的带宽来传输所有的评分。通过云引擎,我们可以简单地传入电影名称,然后返回电影的平均分。

Cloud 函数接收 JSON 格式的请求对象,我们可以用它来传入电影名称。整个 Storage-Core SDK 都在云引擎运行环境上有效,可以直接使用,所以我们可以使用它来查询所有的评分。结合在一起,实现 averageStars 函数的代码如下:

@EngineFunction("averageStars")
public static float getAverageStars(@EngineFunctionParam("movie") String movie)
  throws AVException {
AVQuery<AVObject> query = new AVQuery("Review");
query.whereEqualTo("movie", movie);
List<AVObject> reviews = query.find();
int sum = 0;
if (reviews == null && reviews.isEmpty()) {
  return 0;
}
for (AVObject review : reviews) {
  sum += review.getInt("star");
}
return sum / reviews.size();
}

参数和返回值

云函数的信息是通过 @EngineFunctionParam 来指定传入参数的名字和对应的类型的. AVUser.getCurrentUser() 则可以获取与每个请求关联(根据客户端发送的 LC-Session 头)的用户信息 EngineRequestContext 则可以获取额外的一些 metaData 信息

SDK 调用云函数

LeanCloud 各个语言版本的 SDK 都提供了调用云函数的接口。

// AVCloud 提供了一系列的静态方法来实现客户端调用云函数
// 构建参数
Map<String, String> dicParameters = new HashMap<String, String>();
dicParameters.put("movie", "夏洛特烦恼");

// 调用云函数 averageStars
AVCloud.callFunctionInBackground("averageStars", dicParameters)
  .subscribe(ObserverBuilder.buildSingleObserver(new FunctionCallback() {
    public void done(Object object, AVException e) {
        if (e == null) {
            // 处理返回结果
        } else {
            // 处理报错
        }
    }
  }));

通过 REST API 调用云函数

请查看我们的 云引擎 REST API 使用指南

云引擎调用云函数

在云引擎中可以使用 AVCloud.callFunction 调用 @EngineFunction 定义的云函数:

Map<String, Object> params = new HashMap<String, Object>();
params.put("movie", "夏洛特烦恼");
try {
  float result = AVCloud.callFunction("averageStars", params);
} catch (AVException e) {
  e.printStackTrace();
}

RPC 调用云函数

RPC 调用云函数是指:云引擎会在这种调用方式下自动为 Http Response Body 做序列化,而 SDK 调用之后拿回的返回结果就是一个完整的 AVObject

// 构建参数
Map<String, String> dicParameters = new HashMap<String, String>();
dicParameters.put("movie", "夏洛特烦恼");

AVCloud.rpcFunctionInBackground("averageStars", dicParameters,
    new FunctionCallback<AVObject>() {
      @Override
      public void done(AVObject object, AVException e) {
        Assert.assertNull(e);
      }
    });

云函数错误响应码

错误响应码允许自定义。可以在云函数中间 throw AVException 来指定 code 和 error 消息,如果是普通的 Exception,code 值则是默认的1 。

@EngineFunction("me")
public static AVUser getCurrentUser() throws Exception {
AVUser u = AVUser.getCurrentUser();
if (u == null) {
  throw new AVException(211, "Could not find user");
} else {
  return u;
}
}

客户端收到的响应:{"code":211,"error":"Could not find user"}

@EngineFunction()
public static void customErrorCode() throws Exception {
throw new AVException(123,"custom error message");
}

客户端收到的响应: {"code":123,"error":"自定义错误信息"}

Hook 函数

Hook 函数本质上是云函数,但它有固定的名称,定义之后会由系统在特定事件或操作(如数据保存前、保存后,数据更新前、更新后等等)发生时自动触发,而不是由开发者来控制其触发时机。

需要注意:

  • 使用 Hook 函数需要 防止死循环调用
  • _Installation 表暂不支持 Hook 函数。
  • Hook 函数只对当前应用的 Class 生效,对绑定后的目标 Class 无效。

对于 before 类的 Hook(也包括 onLogin),如果返回了一个错误的话,这个操作就会被中断,你可以在这些 Hook 中主动抛出一个错误来拒绝掉某些操作;对于 after 类的 Hook(也包括 onVerified),返回错误并不会影响操作的执行(因为其实操作已经执行完了)。

为了认证 Hook 调用者的身份,我们的 SDK 内部会确认请求确实是从云引擎内网的云存储组件发出的,如果认证失败,可能会出现 Hook key check failed 的提示,如果在本地调试时出现这样的错误,请确保是通过命令行工具启动调试的。

beforeSave

在创建新对象之前,可以对数据做一些清理或验证。例如,一条电影评论不能过长,否则界面上显示不开,需要将其截断至 140 个字符:

@EngineHook(className = "Review", type = EngineHookType.beforeSave)
  public static AVObject reviewBeforeSaveHook(AVObject review) throws Exception {
    if (AVUtils.isBlankString(review.getString("comment"))) {
      throw new Exception("No Comment");
    } else if (review.getString("comment").length() > 140) {
      review.put("comment", review.getString("comment").substring(0, 137) + "...");
    }
    return review;
  }

afterSave

在创建新对象后触发指定操作,比如当一条留言创建后再更新一下所属帖子的评论总数:

@EngineHook(className = "Review", type = EngineHookType.afterSave)
  public static void reviewAfterSaveHook(AVObject review) throws Exception {
    AVObject post = review.getAVObject("post");
    post.fetch();
    post.increment("comments");
    post.save();
  }

再如,在用户注册成功之后,给用户增加一个新的属性 from 并保存:

@EngineHook(className = "_User", type = EngineHookType.afterSave)
  public static void userAfterSaveHook(AVUser user) throws Exception {
    user.put("from", "LeanCloud");
    user.save();
  }

beforeUpdate

在更新已存在的对象前执行操作,这时你可以知道哪些字段已被修改,还可以在特定情况下拒绝本次修改:

@EngineHook(className = "Review", type = EngineHookType.beforeUpdate)
  public static AVObject reviewBeforeUpdateHook(AVObject review) throws Exception {
    List<String> updateKeys = EngineRequestContext.getUpdateKeys();
    for (String key : updateKeys) {
      if ("comment".equals(key) && review.getString("comment").length()>140) {
        throw new Exception("comment 长度不得超过 140 字符");
      }
    }
    return review;
  }

注意:传入的对象是一个尚未保存到数据库的临时对象,并不保证与最终储存到数据库的对象完全相同,这是因为修改中可能包含自增、数组增改、关系增改等原子操作。

afterUpdate

在更新已存在的对象后执行特定的动作,比如每次修改文章后记录下日志:

  @EngineHook(className = "Article", type = EngineHookType.afterUpdate)
  public static void articleAfterUpdateHook(AVObject article) throws Exception {
    LogUtil.avlog.d("updated article,the id is:" + article.getObjectId());
  }

beforeDelete

在删除一个对象之前做一些检查工作,比如在删除一个相册 Album 前,先检查一下该相册中还有没有照片 Photo:

@EngineHook(className = "Album", type = EngineHookType.beforeDelete)
public static AVObject albumBeforeDeleteHook(AVObject album) throws Exception {
AVQuery query = new AVQuery("Photo");
query.whereEqualTo("album", album);
int count = query.count();
if (count > 0) {
  throw new Exception("无法删除非空相簿");
} else {
  return album;
}
}

afterDelete

在被删一个对象后执行操作,例如递减计数、删除关联对象等等。同样以相册为例,这次我们不在删除相册前检查是否还有照片,而是在删除后,同时删除相册中的照片:

  @EngineHook(className = "Album", type = EngineHookType.afterDelete)
  public static void albumAfterDeleteHook(AVObject album) throws Exception {
    AVQuery query = new AVQuery("Photo");
    query.whereEqualTo("album", album);
    List<AVObject> result = query.find();
    if (result != null && !result.isEmpty()) {
      AVObject.deleteAll(result);
    }
  }

onVerified

当用户通过邮箱或者短信验证时,对该用户执行特定操作。比如:

  @EngineHook(className = "_User", type = EngineHookType.onVerified)
  public static void userOnVerifiedHook(AVUser user) throws Exception {
    LogUtil.avlog.d("onVerified: sms,user:" + user.getObjectId());
  }

函数的第一个参数是验证类型。短信验证为 sms,邮箱验证为 email。另外,数据库中相关的验证字段,如 emailVerified 不需要修改,系统会自动更新。

onLogin

在用户登录之时执行指定操作,比如禁止在黑名单上的用户登录:

  @EngineHook(className = "_User", type = EngineHookType.onVerified)
  public static AVUser userOnLoginHook(AVUser user) throws Exception {
    if ("noLogin".equals(user.getUsername())) {
      throw new Exception("Forbidden");
    } else {
      return user;
    }
  }

实时通信 Hook 函数

请阅读 实时通信概览 · 云引擎 Hook 来了解以下函数的相关参数和用法。

_messageReceived

在消息达到服务器、群组成员已解析完成、发送给收件人之前触发。例如,提前过滤掉聊天内容中的一些广告类的关键词:

  @IMHook(type = IMHookType.messageReceived)
  public static Map<String, Object> onMessageReceived(Map<String, Object> params) {
    // 打印整个 Hook 函数的参数
    System.out.println(params);
    Map<String, Object> result = new HashMap<String, Object>();
    // 获取消息内容
    String content = (String)params.get("content");
    // 转化成 Map 格式
    Map<String,Object> contentMap = (Map<String,Object>)JSON.parse(content);
    // 读取文本内容
    String text = (String)(contentMap.get("_lctext").toString());
    // 过滤广告内容
    String processedContent = text.replace("XX中介", "**");
    // 将过滤之后的内容发还给服务端
    result.put("content",processedContent);
    return result;
  }

_receiverOffline

在消息发送完成时触发、对话中某些用户却已经下线,此时可以根据发送的消息来生成离线消息推送的标题等等。例如截取所发送消息的前 6 个字符作为推送的标题:

@IMHook(type = IMHookType.receiversOffline)
   public static Map<String, Object> onReceiversOffline(Map<String, Object> params) {
     String alert = (String)params.get("content");
     if(alert.length() > 6){
       alert = alert.substring(0, 6);
     }
     System.out.println(alert);
     Map<String, Object> result = new HashMap<String, Object>();
     JSONObject object = new JSONObject();
     object.put("badge", "Increment");
     object.put("sound", "default");
     object.put("_profile", "dev");
     object.put("alert", alert);
     result.put("pushMessage", object.toString());
     return result;
  }

_messageSent

消息发送完成之后触发,例如消息发送完后,在云引擎中打印一下日志:

@IMHook(type = IMHookType.messageSent)
  public static Map<String, Object> onMessageSent(Map<String, Object> params) {
    System.out.println(params);
    Map<String, Object> result = new HashMap<String, Object>();
    // ...
    return result;
  }

_conversationStart

创建对话,在签名校验(如果开启)之后、实际创建之前触发。例如对话创建时,在云引擎中打印一下日志:

@IMHook(type = IMHookType.conversationStart)
  public static Map<String, Object> onConversationStart(Map<String, Object> params) {
    System.out.println(params);
    Map<String, Object> result = new HashMap<String, Object>();
    // 如果创建者是 black 可以拒绝创建对话
    if ("black".equals(params.get("initBy"))) {
      result.put("reject", true);
      // 这个数字是由开发者自定义的
      result.put("code", 9890);
    }
    return result;
  }

_conversationStarted

创建对话完成触发。例如对话创建之后,在云引擎打印一下日志:

@IMHook(type = IMHookType.conversationStarted)
  public static Map<String, Object> onConversationStarted(Map<String, Object> params) throws Exception {
    System.out.println(params);
    Map<String, Object> result = new HashMap<String, Object>();
    String convId = (String)params.get("convId");
    System.out.println(convId);
    return result;
  }

_conversationAdd

向对话添加成员,在签名校验(如果开启)之后、实际加入之前,包括主动加入和被其他用户加入两种情况都会触发,**注意在创建对话时传入了其他用户的 Client Id 作为 Member 参数,不会触发 {{hook_conversation_add}} **。例如在云引擎中打印成员加入时的日志:

@IMHook(type = IMHookType.conversationAdd)
  public static Map<String, Object> onConversationAdd(Map<String, Object> params) {
    System.out.println(params);
    String[] members = (String[])params.get("members");
    Map<String, Object> result = new HashMap<String, Object>();
    System.out.println("members");
    System.out.println(members);
    // 以下代码表示此次操作的发起人如果是 black 就拒绝此次操作,members 不会被加入到当前对话中
    if ("black".equals(params.get("initBy"))) {
      result.put("reject", true);
      // 这个数字是由开发者自定义
      result.put("code", 9890);
    }
    return result;
  }

_conversationRemove

从对话中踢出成员,在签名校验(如果开启)之后、实际踢出之前触发,用户自己退出对话不会触发。例如在踢出某一个成员时,在云引擎日志中打印出该成员的 Client Id:

@IMHook(type = IMHookType.conversationRemove)
  public static Map<String, Object> onConversationRemove(Map<String, Object> params) {
    System.out.println(params);
    String[] members = (String[])params.get("members");
    Map<String, Object> result = new HashMap<String, Object>();
    System.out.println("members");
    // 以下代码表示此次操作的发起人如果是 black 就拒绝此次操作,members 不会被删除
    if ("black".equals(params.get("initBy"))) {
      result.put("reject", true);
      // 这个数字是由开发者自定义
      result.put("code", 9892);
    }
    return result;
  }

_conversationUpdate

修改对话属性、设置或取消对话消息提醒,在实际修改之前触发。例如在更新发生时,在云引擎日志中打印出对话的名称:

@IMHook(type = IMHookType.conversationUpdate)
  public static Map<String, Object> onConversationUpdate(Map<String, Object> params) {
    System.out.println(params);
    Map<String, Object> result = new HashMap<String, Object>();
    Map<String,Object> attr = (Map<String,Object>)params.get("attr");
    System.out.println(attr);
    //Map<String,Object> attrMap = (Map<String,Object>)JSON.parse(attr);
    String name = (String)attr.get("name");
    //System.out.println(attrMap);
    System.out.println(name);
    // 以下代码表示此次操作的发起人如果是 black 就拒绝此次操作,对话的属性不会被修改
    if ("black".equals(params.get("initBy"))) {
      result.put("reject", true);
      result.put("code", 9893);
    }
    return result;
  }

Hook 函数错误响应码

beforeSave 这类的 hook 函数定义错误码,需要这样:

@EngineHook(className = "Review", type = EngineHookType.beforeSave)
  public static AVObject reviewBeforeSaveHook(AVObject review) throws Exception {
    throw new AVException(123,"自定义错误信息");
  }

客户端收到的响应为:Cloud Code validation failed. Error detail : {"code":123, "message": "自定义错误信息"},然后通过截取字符串的方式取出错误信息,再转换成需要的对象。

Hook 函数超时

Hook 函数的超时时间为 3 秒。如果 Hook 函数被其他的云函数调用(比如因为 save 对象而触发 beforeSaveafterSave),那么它们的超时时间会进一步被其他云函数调用的剩余时间限制。

例如,如果一个 beforeSave 函数是被一个已经运行了 13 秒的云函数触发,那么 beforeSave 函数就只剩下 2 秒的时间来运行。同时请参考 云函数超时及处理方案

{% block online_editor %}{% endblock %}

定时任务

定时任务可以按照设定,以一定间隔自动完成指定动作,比如半夜清理过期数据,每周一向所有用户发送推送消息等等。定时任务的最小时间单位是,正常情况下时间误差都可以控制在秒级别。

定时任务是普通的云函数,也会遇到 超时问题,具体请参考 超时处理方案

一个定时器如果在 24 小时内收到了超过 30 次的 400 (Bad Request) 或 502 (Bad Gateway) 的应答,它将会被云引擎禁用,同时系统会向开发者发出相关的禁用通知邮件。在控制台的日志中,对应的错误信息为 {{ errorMessage_shortCircuited }}

部署云引擎之后,进入 控制台 > 存储 > 云引擎 > 定时任务,点击 创建定时器,然后设定执行的函数名称、执行环境等等。

定时器创建后,其状态为未运行,需要点击 启用 来激活。之后其执行日志可以在 日志 页面中查看。

定时任务分为两类:

  • 使用 Cron 表达式安排调度
  • 以秒为单位的简单循环调度

以 Cron 表达式为例,比如每周一早上 8 点准时发送推送消息给用户:

创建定时器的时候,选择 Cron 表达式 并填入 0 0 8 ? * MON

Cron 表达式

Cron 表达式的基本语法为:

<秒> <分钟> <小时> <日期 day-of-month> <月份> <星期 day-of-week> <年>
位置 字段 约束 取值 可使用的特殊字符
1 必须 0-59 , - * /
2 分钟 必须 0-59 , - * /
3 小时 必须 0-23(0 为午夜) , - * /
4 日期 必须 1-31 , - * ? / L W
5 月份 必须 1-12、JAN-DEC , - * /
6 星期 必须 1-7、SUN-SAT , - ? / L #
7 可选 空、1970-2099 , - * /

特殊字符的用法:

字符 含义 用法
* 所有值 代表一个字段的所有可能取值。如将 <分钟> 设为 *,表示每一分钟。
? 不指定值 用于可以使用该字符的两个字段中的一个,在一个表达式中只能出现一次。如任务执行时间为每月 10 号,星期几无所谓,那么表达式中 <日期> 设为 10<星期> 设为 ?
- 范围 <小时>10-12,即10 点、11 点、12 点。
, 分隔多个值 <星期>MON,WED,FRI,即周一、周三、周五。
/ 增量 <秒> 设为 0/15,即从 0 秒开始,以 15 秒为增量,包括 0、15、30、45 秒;5/15 即 5、20、35、50 秒。*/0/ 等效,如 <日期> 设为 1/3,即从每个月的第一天开始,每 3 天(即每隔 2 天)执行一次任务。
L 最后 其含义随字段的不同而不同。 <日期> 中使用 L 代表每月最后一天,如 1 月 31 号、2 月 28 日(非闰年);<星期> 中单独使用 L,则与使用 7SAT 等效,若前面搭配其他值使用,如 6L,则表示每月的最后一个星期五。

注意,在 L 之前不要使用多个值或范围,如 1,2L1-2L,否则会产生错误结果。
W weekday 周一到周五的任意一天,离指定日期最近的非周末的那一天。
<日期>15W 即离 15 号最近的非周末的一天;如果 15 号是周六,任务则会在 14 号周五触发,如果 15 号是周日,则在 16 号周一触发,如果 15 号是周二,则周二当天触发。

<日期>1W,如果 1 号是周六,任务则会在 3 号周一触发,因为不能向前跨月来计算天数。

<日期>W 之前只能使用一个数值,不能使用多个值或范围。LW 可在 <日期> 中组合使用,表示每月最后一个非周末的一天。
# 第 N 次 <星期>6#3 代表每月第三个周五,2#1 为每月头一个周一,4#5 为每月第五个周三;如果当月没有第五周,则 #5 不会产生作用。

各字段以空格或空白隔开。JAN-DEC、SUN-SAT 这些值不区分大小写,比如 MON 和 mon 效果一样。更详细的使用方法请参考 {{quartz_docs_link}} 。

举例如下:

表达式 说明
0 0/5 * * * ? 每隔 5 分钟执行一次
10 0/5 * * * ? 每隔 5 分钟执行一次,每次执行都在分钟开始的 10 秒,例如 10:00:10、10:05:10 等等。
0 30 10-13 ? * WED,FRI 每周三和每周五的 10:30、11:30、12:30、13:30 执行。
0 0/30 8-9 5,20 * ? 每个月的 5 号和 20 号的 8 点和 10 点之间每隔 30 分钟执行一次,也就是 8:00、8:30、9:00 和 9:30。

定时器数量

生产环境和预备环境的定时器数量都限制在 5 个以内,也就是说你总共最多可以创建 10 个定时器。

错误信息

定时器执行后的日志会记录在 控制台 > 存储 > 云引擎 > 其它 > 日志 中,以下为常见的错误信息及原因。

  • timerAction timed-out and no fallback available
    某个定时器触发的云函数,因 15 秒内没有响应而超时(可参考 对云函数调用超时的处理)。
  • {{ errorMessage_shortCircuited }}
    某个定时器触发的云函数,因为太多次超时而停止触发。

Master Key 和超级权限

因为云引擎运行在可信的服务器端环境中,所以你可以全局开启超级权限(Master Key),这样云端会跳过包括 ACL 和 Class 权限在内的检查,让你自由地操作所有云存储中的数据,当然这种方式也允许调用一些仅供 masterKey 使用的 API。开启 Master Key 的方法如下:

// 通常位于 src/**/AppInitListener.java
JavaRequestSignImplementation.instance().setUseMasterKey(true);

如果没有添加这些代码,默认是没有超级权限的,这意味着在云引擎中你也不能修改被 ACL 保护的数据,你需要在进行操作时手动指定 sessionToken,让操作以这个用户的权限来执行:

那么究竟是否应该使用超级权限呢,我们的建议如下:

  • 如果你的云引擎代码中特权操作比较多、操作不属于用户的全局数据比较多,那么建议全局开启 Master Key,并自行做好对于用户请求的权限检查。
  • 如果你的云引擎代码中的请求通常和单个用户自己的数据相关、需要遵守 ACL,那么建议不开启 Master Key,将用户请求的 sessionToken 传入数据修改的相关操作(如 save)。 {% endif %}

关于云引擎上的权限问题,还可以参考 ACL 与数据安全在云引擎中使用 ACL