MaxCompute_Studio_UDF_开发指南

背景及目的

本文简单地介绍了一下如何新建工程,添加代码,打包,上传资源包和注册方法,对初次接触的用户提供帮助。

UDF 开发流程如下:
undefined

创建 UDF 简单介绍

  1. 在IntelliJ IDEA中安装MaxCompute Studio的插件, 过程不多重复了, 基本方法就是去插件中心搜索MaxCompute studio,然后安装一下就好了。具体如何安装查看MaxCompute官方文档。
  2. 插件安装完毕后,需要将自己所在的package的信息加进来:
    undefined
  3. 接下来,创建新工程,选择"MaxCompute Java",如下图所示:
    undefined
  4. 新建成功后,查看pom文件,发现相关依赖已经加进来了,如图:
    undefined
  5. 新建UDF的Java类
    注意:请去src->main-Java里新建,不要在example里新建,否则下一步注册方法的时候找不到main class

undefined

  1. 编写代码吧,比如:
    undefined
  2. 打包,在这个Java类文件上,右键,选择Run Maven,选择clean install,如图:
    undefined
  3. 查看打出的包,如图所示:
    undefined
  4. 下一步就是降这个包上传到服务端了,在IntelliJ IDEA中,MaxCompute->add Resource,如图:
    undefined
  5. 确定要上传的包,点击OK上传。
    undefined
  6. 接下来就是注册方法了,在IntelliJ IDEA中,MaxCompute->Create function,如图:
    undefined
  7. 选择Resource并确定Main class的名称(这就是上面说为什么要在main下面写Java类的原因了,如果写在example里,main class这是无法加载出来的),输入方法的名字,并点击OK进行确认,如下图:
    undefined
  8. 注册方法成功后,会有提示框,如下图:
    undefined
  9. 最后一步,去DataWorks控制台,跑起来吧:
    undefined
  10. 总结
    使得MaxCompute Studio,使我们创建UDF变得简单和方便了很多,本文只是简单的介绍了一下最基础的使用,如果您第一次接触UDF,可以照着以上的步骤跑起来。

通过 UDF 实现JSON解析

需求
大家在做数据分析时,有JSON数据解析的需求。输入数据是一个字段,如:
[{"id":"123","name":"jack","owner":"yixiu"},{"id":"456","name":" daniel","owner":"sixiang"}]
输入数据是一个json数组,需要解析成如下的两行数据(关系为1对多):

id name owner
123 jack yixiu
456 daniel sixiang

实现
这个需求可以通过UDTF实现,但是UDTF中不能通过fastjson完成数据解析,因为json、protobuf 类的包经常免不了要用反射的,会被沙箱挡住。解决方案是使用ODPS内置的gson完成json数据解析。

Gson gson = new Gson();   
List<PreProject> projectList = gson.fromJson(projectListString, new TypeToken<List<PreProject>>(){}.getType());

使用 UDJ 自定义SQL的Join操作

简介
目前MaxCompute内置了多种Join操作,包括inner/right outer/left outer/full outer/semi/anti-semi join等。对于普通用户而言,这些内置的join操作功能已经相当强大,能够满足很大一部分需求,但是其标准的join实现,依然无法满足很多跨表操作的需求。

本文通过一个样例场景,介绍了UDF框架中新近引入的一种新扩展机制:UDJ(User Defined Join),来实现灵活的跨表操作。这也是基于MaxCompute新一代体系机构发展NewSQL数据处理框架的重要一步。

背景
广义上我们通常用UDF(User Defined Function)来描述用户代码框架。现有的UDF/UDTF/UDAF接口主要是针对在个数据表上的操作而设计。但是一旦涉及多表的用户自定义操作,用户经常需要依赖于内置join + 各种UDF/UDTF, 并且配合比较复杂的SQL语句来完成。甚至在一些多表操作的场景上,用户不得不放弃SQL而转向传统的完全自定义MR,才能完成所需的计算。

这两种方式对于用户的门槛都比较高。而且对于计算平台而言,多个复杂的join和散布在SQL语言各处的用户代码揉合在一起,带来的是多处的“逻辑黑盒”,并不利于产生最优的执行计划。而使用MR,不仅更大程度上剥夺了系统进行执行优化的可能性,而且由于MR绝大部分代码由Java完成,在执行效率上会远低于MaxCompute基于LLVM 代码生成器产生的深度优化native运行时。

MaxCompute 2.0的全面上线,为计算平台框架的发展提供了更大的灵活度,在这个基础上,我们提出了建设NewSQL生态的目标。NewSQL通过一个扩展的SQL框架,让用户能使用描述性的语言表达其主体逻辑流程,而仅在与分布系统执行流程无关的地方,才引入用户代码。这样的设计,能让用户对计算逻辑从“HOW”(怎样具体完成一个分布式计算流程),转变成“WHAT”(用户从逻辑上描述其想完成的事情和数据操作)。这样的转变,能让用户把更多的精力集中在“WHAT”上面,优化自己的商业处理逻辑上,而把“HOW”交给计算平台,让计算平台进行复杂的系统优化,产生最优的执行计划来完成具体流程。

在这个大背景下,我们在UDF框架中引入了UDJ这种全新的,针对多表数据操作的扩展机制。希望借由这种新的机制,减少用户之前不得不通过MR等方式对分布式系统底层细节的操作,从而达到用户可用性以及系统优化的双赢。

样例场景定义
有这样两个日志表,分别是payment和user_client_log。payment表中保存了用户的支付记录,一笔支付记录包含用户ID、支付时间和支付内容。另一个表user_client_log保存了用户的客户端日志,每一条日志包含了用户ID、日志时间和日志内容。

现要求对于每一条客户端日志,找出该用户在payment表里时间最接近的一条支付记录,将其中的支付内容和日志内容合并输出。举个例子,对于下面的数据:

payment

user_id time content
2656199 2018-2019-01-23 22:30:00 gZhvdySOQb
8881237 2018-2019-01-23 08:30:00 pYvotuLDIT
8881237 2018-2019-01-23 10:32:00 KBuMzRpsko

user_client_log

user_id time content
8881237 2019-01-23 00:30:00 click MpkvilgWSmhUuPn
8881237 2019-01-23 06:14:00 click OkTYNUHMqZzlDyL
8881237 2019-01-23 10:30:00 click OkTYNUHMqZzlDyL

其中user_client_log的一条记录

user_id time content
8881237 2019-01-23 00:30:00 click MpkvilgWSmhUuPn

和payment中时间最接近的一条是

user_id time content
8881237 2019-01-23 08:30:00 pYvotuLDIT

因此,这两条记录合并为:

user_id time content
8881237 2019-01-23 00:30:00 click MpkvilgWSmhUuPn, pay pYvotuLDIT

上面样例数据全部合并的结果如下:

user_id time content
8881237 2019-01-23 00:30:00 click MpkvilgWSmhUuPn, pay pYvotuLDIT
8881237 2019-01-23 06:14:00 click OkTYNUHMqZzlDyL, pay pYvotuLDIT
8881237 2019-01-23 10:30:00 click OkTYNUHMqZzlDyL, pay KBuMzRpsko

在使用UDJ解决这个问题之前,先看一下使用标准join能否解决这个问题。

答案是不能,因为这个问题除了要求按照user_id进行关联以外,还需要知道payment中哪一条记录和user_client_log中的记录的时间差异值最小,如果勉强写SQL伪代码的话类似于:

SELECT
  p.user_id,
  p.time,
  merge(p.pay_info, u.content)
FROM
  payment p RIGHT OUTER JOIN user_client_log u
ON p.user_id = u.user_id and abs(p.time - u.time) = min(abs(p.time - u.time))

这就要求关联时需要知道相同user_id下的p.time与u.time差异最小的值,而聚合函数是不能出现在关联条件上的。因此,这个看似简单的问题,如果使用标准的关联操作是无法实现的。但在分布式系统中,Join操作实际上是将两个表按照某个(或多个)字段进行分组,并将同组数据集中到一个地方,关键在于标准SQL中的Join对于关联后的操作是有限的,这个时候,如果能提供一个通用编程语言接口,用户通过插件式的方式实现这个接口,在这个接口中将关联后的分组数据进行自定义处理并输出就能解决这个问题了。这个就是UDJ要解决的问题。

使用Java编写UDJ代码
下面,我将按步骤来演示如何用UDJ解决这个问题。首先这是一个新功能,我们需要一个比较新的SDK:

<dependency>
  <groupId>com.aliyun.odps</groupId>
  <artifactId>odps-sdk-udf</artifactId>
  <version>0.30.0</version>
  <scope>provided</scope>
</dependency>

新的SDK中包含了一个新的抽象类UDJ,我们通过继承这个类,来实现UDJ的功能:

package com.aliyun.odps.udf.example.udj;

import com.aliyun.odps.Column;
import com.aliyun.odps.OdpsType;
import com.aliyun.odps.Yieldable;
import com.aliyun.odps.data.ArrayRecord;
import com.aliyun.odps.data.Record;
import com.aliyun.odps.udf.DataAttributes;
import com.aliyun.odps.udf.ExecutionContext;
import com.aliyun.odps.udf.UDJ;
import com.aliyun.odps.udf.annotation.Resolve;

import java.util.ArrayList;
import java.util.Iterator;

/** For each record of right table, find the nearest record of left table and
 * merge two records.
 */
@Resolve("->string,bigint,string")
public class PayUserLogMergeJoin extends UDJ {

  private Record outputRecord;

  /** Will be called prior to the data processing phase. User could implement
   * this method to do initialization work.
   */
  @Override
  public void setup(ExecutionContext executionContext, DataAttributes dataAttributes) {
    //
    outputRecord = new ArrayRecord(new Column[]{
      new Column("user_id", OdpsType.STRING),
      new Column("time", OdpsType.BIGINT),
      new Column("content", OdpsType.STRING)
    });
  }

  /** Override this method to implement join logic.
   * @param key Current join key
   * @param left Group of records of left table corresponding to the current key
   * @param right Group of records of right table corresponding to the current key
   * @param output Used to output the result of UDJ
   */
  @Override
  public void join(Record key, Iterator<Record> left, Iterator<Record> right, Yieldable<Record> output) {
    outputRecord.setString(0, key.getString(0));
    if (!right.hasNext()) {
      // Empty right group, do nothing.
      return;
    } else if (!left.hasNext()) {
      // Empty left group. Output all records of right group without merge.
      while (right.hasNext()) {
        Record logRecord = right.next();
        outputRecord.setBigint(1, logRecord.getDatetime(0).getTime());
        outputRecord.setString(2, logRecord.getString(1));
        output.yield(outputRecord);
      }
      return;
    }
    ArrayList<Record> pays = new ArrayList<>();
    // The left group of records will be iterated from the start to the end
    // for each record of right group, but the iterator cannot be reset.
    // So we save every records of left to an ArrayList.
    left.forEachRemaining(pay -> pays.add(pay.clone()));
    while (right.hasNext()) {
      Record log = right.next();
      long logTime = log.getDatetime(0).getTime();
      long minDelta = Long.MAX_VALUE;
      Record nearestPay = null;
      // Iterate through all records of left, and find the pay record that has
      // the minimal difference in terms of time.
      for (Record pay: pays) {
        long delta = Math.abs(logTime - pay.getDatetime(0).getTime());
        if (delta < minDelta) {
          minDelta = delta;
          nearestPay = pay;
        }
      }
      // Merge the log record with nearest pay record and output to the result.
      outputRecord.setBigint(1, log.getDatetime(0).getTime());
      outputRecord.setString(2, mergeLog(nearestPay.getString(1), log.getString(1)));
      output.yield(outputRecord);
    }
  }

  String mergeLog(String payInfo, String logContent) {
    return logContent + ", pay " + payInfo;
  }

  @Override
  public void close() {

  }
}

注解:在本例中没有处理记录中的NULL值,为了使程序简洁便于演示,我们这里假设数据中没有NULL值。

从代码中可以看到,在每次调用UDJ的join方法时,两张表中各有一组对应着同一个key数据,提供给了我们。因此,只需遍历右表(user_client_log)的分组,对于每一个log记录,遍历一遍左表(payment)的分组,找出时间相差最小的记录,将日志内容合并然后输出即可。

这里我们假设同一个用户的支付记录数是比较少的,所以我们可以预先将左表分组全部加载到内存(一般情况下不会有人一天产生的支付数连内存里都放不下)。但是这个假设不成立时怎么办?这里先放下不表,文章后面一节“使用SORT BY预排序”会解决这个问题。

在MaxCompute中创建UDJ

编写完UDJ的Java代码以后,还需要将这部分代码插件式的嵌入到MaxCompute SQL中进行执行。再此之前,需要将代码注册到MaxCompute。假设上述代码打包成了odps-udf-example.jar,我们通过add jar命令将其当做jar资源上传到MaxCompute:

add jar odps-udf-example.jar;

然后通过create function语句注册UDJ函数,指定UDJ在SQL中的函数名pay_user_log_merge_join,以及关联上它对应的jar资源odps-udf-example.jar和在jar包中的类名com.aliyun.odps.udf.example.udj.PayUserLogMergeJoin:

create function pay_user_log_merge_join
  as 'com.aliyun.odps.udf.example.udj.PayUserLogMergeJoin'
  using 'odps-udf-example.jar';

使用MaxCompute SQL进行UDJ查询

UDJ注册好了以后,就可以在MaxCompute SQL中使用了。

首先为了便于演示,我们先创建源表:

CREATE TABLE payment (
  user_id string,
  time datetime,
  pay_info string
);

CREATE TABLE user_client_log (
  user_id string,
  time datetime,
  content string
);

然后制造一些演示数据:

INSERT OVERWRITE TABLE payment VALUES
('1335656', datetime '2018-02-13 19:54:00', 'PEqMSHyktn'),
('2656199', datetime '2018-02-13 12:21:00', 'pYvotuLDIT'),
('2656199', datetime '2018-02-13 20:50:00', 'PEqMSHyktn'),
('2656199', datetime '2018-02-13 22:30:00', 'gZhvdySOQb'),
('8881237', datetime '2018-02-13 08:30:00', 'pYvotuLDIT'),
('8881237', datetime '2018-02-13 10:32:00', 'KBuMzRpsko'),
('9890100', datetime '2018-02-13 16:01:00', 'gZhvdySOQb'),
('9890100', datetime '2018-02-13 16:26:00', 'MxONdLckwa')
;

INSERT OVERWRITE TABLE user_client_log VALUES
('1000235', datetime '2018-02-13 00:25:36', 'click FNOXAibRjkIaQPB'),
('1000235', datetime '2018-02-13 22:30:00', 'click GczrYaxvkiPultZ'),
('1335656', datetime '2018-02-13 18:30:00', 'click MxONdLckpAFUHRS'),
('1335656', datetime '2018-02-13 19:54:00', 'click mKRPGOciFDyzTgM'),
('2656199', datetime '2018-02-13 08:30:00', 'click CZwafHsbJOPNitL'),
('2656199', datetime '2018-02-13 09:14:00', 'click nYHJqIpjevkKToy'),
('2656199', datetime '2018-02-13 21:05:00', 'click gbAfPCwrGXvEjpI'),
('2656199', datetime '2018-02-13 21:08:00', 'click dhpZyWMuGjBOTJP'),
('2656199', datetime '2018-02-13 22:29:00', 'click bAsxnUdDhvfqaBr'),
('2656199', datetime '2018-02-13 22:30:00', 'click XIhZdLaOocQRmrY'),
('4356142', datetime '2018-02-13 18:30:00', 'click DYqShmGbIoWKier'),
('4356142', datetime '2018-02-13 19:54:00', 'click DYqShmGbIoWKier'),
('8881237', datetime '2018-02-13 00:30:00', 'click MpkvilgWSmhUuPn'),
('8881237', datetime '2018-02-13 06:14:00', 'click OkTYNUHMqZzlDyL'),
('8881237', datetime '2018-02-13 10:30:00', 'click OkTYNUHMqZzlDyL'),
('9890100', datetime '2018-02-13 16:01:00', 'click vOTQfBFjcgXisYU'),
('9890100', datetime '2018-02-13 16:20:00', 'click WxaLgOCcVEvhiFJ')
;

在MaxCompute SQL中使用刚刚创建好的UDJ函数:

SELECT r.user_id, from_unixtime(time/1000) as time, content FROM (
  SELECT user_id, time as time, pay_info FROM payment
) p JOIN (
  SELECT user_id, time as time, content FROM user_client_log
) u
ON p.user_id = u.user_id
USING pay_user_log_merge_join(p.time, p.pay_info, u.time, u.content)
r
AS (user_id, time, content);

UDJ的语法与标准Join语法类似,这里多了一个USING子句,其中pay_user_log_merge_join是注册的UDJ在SQL中的函数名;后面的(p.time, p.pay_info, u.time, u.content)是UDJ中分别用到的左右表的列;r是UDJ结果的别名,用于其他地方引用UDJ的结果;(user_id, time, content)是UDJ产生的结果的列名。

运行上面这条SQL,可以看到,我们通过使用UDJ完美的解决了这个问题。结果为:

+---------+------------+---------+
| user_id | time       | content |
+---------+------------+---------+
| 1000235 | 2018-02-13 00:25:36 | click FNOXAibRjkIaQPB |
| 1000235 | 2018-02-13 22:30:00 | click GczrYaxvkiPultZ |
| 1335656 | 2018-02-13 18:30:00 | click MxONdLckpAFUHRS, pay PEqMSHyktn |
| 1335656 | 2018-02-13 19:54:00 | click mKRPGOciFDyzTgM, pay PEqMSHyktn |
| 2656199 | 2018-02-13 08:30:00 | click CZwafHsbJOPNitL, pay pYvotuLDIT |
| 2656199 | 2018-02-13 09:14:00 | click nYHJqIpjevkKToy, pay pYvotuLDIT |
| 2656199 | 2018-02-13 21:05:00 | click gbAfPCwrGXvEjpI, pay PEqMSHyktn |
| 2656199 | 2018-02-13 21:08:00 | click dhpZyWMuGjBOTJP, pay PEqMSHyktn |
| 2656199 | 2018-02-13 22:29:00 | click bAsxnUdDhvfqaBr, pay gZhvdySOQb |
| 2656199 | 2018-02-13 22:30:00 | click XIhZdLaOocQRmrY, pay gZhvdySOQb |
| 4356142 | 2018-02-13 18:30:00 | click DYqShmGbIoWKier |
| 4356142 | 2018-02-13 19:54:00 | click DYqShmGbIoWKier |
| 8881237 | 2018-02-13 00:30:00 | click MpkvilgWSmhUuPn, pay pYvotuLDIT |
| 8881237 | 2018-02-13 06:14:00 | click OkTYNUHMqZzlDyL, pay pYvotuLDIT |
| 8881237 | 2018-02-13 10:30:00 | click OkTYNUHMqZzlDyL, pay KBuMzRpsko |
| 9890100 | 2018-02-13 16:01:00 | click vOTQfBFjcgXisYU, pay gZhvdySOQb |
| 9890100 | 2018-02-13 16:20:00 | click WxaLgOCcVEvhiFJ, pay MxONdLckwa |
+---------+------------+---------+

使用SORT BY预排序

前面的UDJ代码我们提过一笔,为了找到payment中相差最小的一条记录,我们需要反复对payment表的iterator进行遍历,所以事先我们将相同user_id的payment记录全部加载到了ArrayList。当同一个用户的支付行为比较少时,这方式是没有问题的。但在其它场景中,有时候同组内的数据可能非常大,大到无法在内存中放下,这个时候我们可以利用UDJ的预排序功能。回到这个样例,但是假设一个土豪用户产生了巨量的支付, 导致我们无法将payment放在内存中。仔细想一下这个问题,发现组内所有数据如果已经按照time排好了序,那么这个问题就好解了,我们只需要比较两边iterator最"顶端"的数据,就可以实现这个功能。

@Override
public void join(Record key, Iterator<Record> left, Iterator<Record> right, Yieldable<Record> output) {
  outputRecord.setString(0, key.getString(0));
  if (!right.hasNext()) {
    return;
  } else if (!left.hasNext()) {
    while (right.hasNext()) {
      Record logRecord = right.next();
      outputRecord.setBigint(1, logRecord.getDatetime(0).getTime());
      outputRecord.setString(2, logRecord.getString(1));
      output.yield(outputRecord);
    }
    return;
  }
  long prevDelta = Long.MAX_VALUE;
  Record logRecord = right.next();
  Record payRecord = left.next();
  Record lastPayRecord = payRecord.clone();
  while (true) {
    long delta = logRecord.getDatetime(0).getTime() - payRecord.getDatetime(0).getTime();
    if (left.hasNext() && delta > 0) {
      // The delta of time between two records is decreasing, we can still
      // explore the left group to try to gain a smaller delta.
      lastPayRecord = payRecord.clone();
      prevDelta = delta;
      payRecord = left.next();
    } else {
      // Hit to the point of minimal delta. Check with the last pay record,
      // output the merge result and prepare to process the next record of
      // right group.
      Record nearestPay = Math.abs(delta) < prevDelta ? payRecord : lastPayRecord;
      outputRecord.setBigint(1, logRecord.getDatetime(0).getTime());
      String mergedString = mergeLog(nearestPay.getString(1), logRecord.getString(1));
      outputRecord.setString(2, mergedString);
      output.yield(outputRecord);
      if (right.hasNext()) {
        logRecord = right.next();
        prevDelta = Math.abs(
          logRecord.getDatetime(0).getTime() - lastPayRecord.getDatetime(0).getTime()
        );
      } else {
        break;
      }
    }
  }
}

SQL语句中,我们只需要对之前的例子稍作修改,在UDJ语句尾部增加SORT BY子句,指定UDJ组内左右表分别都按照各自的time字段进行排序(如果你跟着样例在运行,UDJ代码由于也进行了修改,所以请不要忘了更新UDJ的jar包)

SELECT r.user_id, from_unixtime(time/1000) as time, content FROM (
  SELECT user_id, time as time, pay_info FROM payment
) p JOIN (
  SELECT user_id, time as time, content FROM user_client_log
) u
ON p.user_id = u.user_id
USING pay_user_log_merge_join(p.time, p.pay_info, u.time, u.content)
r
AS (user_id, time, content)
SORT BY p.time, u.time;

可以看到,使用SORT BY子句对UDJ的数据进行预排序后,在这个问题中最多只需要同时缓存3条记录,就可以实现和之前算法的相同的功能。

猜你喜欢

转载自yq.aliyun.com/articles/689560