Spark強大的函數擴展功能

在數據分析領域中,沒有人能預見所有的數據運算,以至于將它們都內置好,一切準備完好,用戶只需要考慮用,萬事大吉。擴展性是一個平臺的生存之本,一個封閉的平臺如何能夠擁抱變化?在對數據進行分析時,無論是算法也好,分析邏輯也罷,最好的重用單位自然還是:函數

故而,對于一個大數據處理平臺而言,倘若不能支持函數的擴展,確乎是不可想象的。Spark首先是一個開源框架,當我們發現一些函數具有通用的性質,自然可以考慮contribute給社區,直接加入到Spark的源代碼中。我們欣喜地看到隨著Spark版本的演化,確實涌現了越來越多對于數據分析師而言稱得上是一柄柄利器的強大函數,例如博客文章《Spark 1.5 DataFrame API Highlights: Date/Time/String Handling, Time Intervals, and UDAFs》介紹了在1.5中為DataFrame提供了豐富的處理日期、時間和字符串的函數;以及在Spark SQL 1.4中就引入的Window Function

然而,針對特定領域進行數據分析的函數擴展,Spark提供了更好地置放之處,那就是所謂的“UDF(User Defined Function)”。

UDF的引入極大地豐富了Spark SQL的表現力。一方面,它讓我們享受了利用Scala(當然,也包括Java或Python)更為自然地編寫代碼實現函數的福利,另一方面,又能精簡SQL(或者DataFrame的API),更加寫意自如地完成復雜的數據分析。尤其采用SQL語句去執行數據分析時,UDF幫助我們在SQL函數與Scala函數之間左右逢源,還可以在一定程度上化解不同數據源具有歧異函數的尷尬。想想不同關系數據庫處理日期或時間的函數名稱吧!

用Scala編寫的UDF與普通的Scala函數沒有任何區別,唯一需要多執行的一個步驟是要讓SQLContext注冊它。例如:

def len(bookTitle: String):Int = bookTitle.length

sqlContext.udf.register("len", len _)

val booksWithLongTitle = sqlContext.sql("select title, author from books where len(title) > 10")

編寫的UDF可以放到SQL語句的fields部分,也可以作為where、groupBy或者having子句的一部分。

既然是UDF,它也得保持足夠的特殊性,否則就完全與Scala函數泯然眾人也。這一特殊性不在于函數的實現,而是思考函數的角度,需要將UDF的參數視為數據表的某個列。例如上面len函數的參數bookTitle,雖然是一個普通的字符串,但當其代入到Spark SQL的語句中,實參title實際上是表中的一個列(可以是列的別名)。

當然,我們也可以在使用UDF時,傳入常量而非表的列名。讓我們稍稍修改一下剛才的函數,讓長度10作為函數的參數傳入:

def lengthLongerThan(bookTitle: String, length: Int): Boolean = bookTitle.length > length

sqlContext.udf.register("longLength", lengthLongerThan _)

val booksWithLongTitle = sqlContext.sql("select title, author from books where longLength(title, 10)")

若使用DataFrame的API,則可以以字符串的形式將UDF傳入:

val booksWithLongTitle = dataFrame.filter("longLength(title, 10)")

DataFrame的API也可以接收Column對象,可以用$符號來包裹一個字符串表示一個Column。$是定義在SQLContext對象implicits中的一個隱式轉換。此時,UDF的定義也不相同,不能直接定義Scala函數,而是要用定義在org.apache.spark.sql.functions中的udf方法來接收一個函數。這種方式無需register:

import org.apache.spark.sql.functions._

val longLength = udf((bookTitle: String, length: Int) => bookTitle.length > length)

import sqlContext.implicits._
val booksWithLongTitle = dataFrame.filter(longLength($"title", $"10"))

注意,代碼片段中的sqlContext是之前已經實例化的SQLContext對象。

不幸,運行這段代碼會拋出異常:

cannot resolve '10' given input columns id, title, author, price, publishedDate;

因為采用$來包裹一個常量,會讓Spark錯以為這是一個Column。這時,需要定義在org.apache.spark.sql.functions中的lit函數來幫助:

val booksWithLongTitle = dataFrame.filter(longLength($"title", lit(10)))

普通的UDF卻也存在一個缺陷,就是無法在函數內部支持對表數據的聚合運算。例如,當我要對銷量執行年度同比計算,就需要對當年和上一年的銷量分別求和,然后再利用同比公式進行計算。此時,UDF就無能為力了。

該UDAF(User Defined Aggregate Function)粉墨登場的時候了。

Spark為所有的UDAF定義了一個父類UserDefinedAggregateFunction。要繼承這個類,需要實現父類的幾個抽象方法:

def inputSchema: StructType

def bufferSchema: StructType

def dataType: DataType

def deterministic: Boolean

def initialize(buffer: MutableAggregationBuffer): Unit

def update(buffer: MutableAggregationBuffer, input: Row): Unit

def merge(buffer1: MutableAggregationBuffer, buffer2: Row): Unit

def evaluate(buffer: Row): Any

可以將inputSchema理解為UDAF與DataFrame列有關的輸入樣式。例如年同比函數需要對某個可以運算的指標與時間維度進行處理,就需要在inputSchema中定義它們。

  def inputSchema: StructType = {
    StructType(StructField("metric", DoubleType) :: StructField("timeCategory", DateType) :: Nil)
  }

代碼創建了擁有兩個StructFieldStructTypeStructField的名字并沒有特別要求,完全可以認為是兩個內部結構的列名占位符。至于UDAF具體要操作DataFrame的哪個列,取決于調用者,但前提是數據類型必須符合事先的設置,如這里的DoubleTypeDateType類型。這兩個類型被定義在org.apache.spark.sql.types中。

bufferSchema用于定義存儲聚合運算時產生的中間數據結果的Schema,例如我們需要存儲當年與上一年的銷量總和,就需要定義兩個StructField

  def bufferSchema: StructType = {
    StructType(StructField("sumOfCurrent", DoubleType) :: StructField("sumOfPrevious", DoubleType) :: Nil)
  }

dataType標明了UDAF函數的返回值類型,deterministic是一個布爾值,用以標記針對給定的一組輸入,UDAF是否總是生成相同的結果。

顧名思義,initialize就是對聚合運算中間結果的初始化,在我們這個例子中,兩個求和的中間值都被初始化為0d:

  def initialize(buffer: MutableAggregationBuffer): Unit = {
    buffer.update(0, 0.0)
    buffer.update(1, 0.0)
  }

update函數的第一個參數為bufferSchema中兩個Field的索引,默認以0開始,所以第一行就是針對“sumOfCurrent”的求和值進行初始化。

UDAF的核心計算都發生在update函數中。在我們這個例子中,需要用戶設置計算同比的時間周期。這個時間周期值屬于外部輸入,但卻并非inputSchema的一部分,所以應該從UDAF對應類的構造函數中傳入。我為時間周期定義了一個樣例類,且對于同比函數,我們只要求輸入當年的時間周期,上一年的時間周期可以通過對年份減1來完成:

case class DateRange(startDate: Timestamp, endDate: Timestamp) {
  def in(targetDate: Date): Boolean = {
    targetDate.before(endDate) && targetDate.after(startDate)
  }
}

class YearOnYearBasis(current: DateRange) extends UserDefinedAggregateFunction {
  def update(buffer: MutableAggregationBuffer, input: Row): Unit = {
    if (current.in(input.getAs[Date](1))) {
      buffer(0) = buffer.getAs[Double](0) + input.getAs[Double](0)
    }
    val previous = DateRange(subtractOneYear(current.startDate), subtractOneYear(current.endDate))
    if (previous.in(input.getAs[Date](1))) {
      buffer(1) = buffer.getAs[Double](0) + input.getAs[Double](0)
    }
  }
}  

update函數的第二個參數input: Row對應的并非DataFrame的行,而是被inputSchema投影了的行。以本例而言,每一個input就應該只有兩個Field的值。倘若我們在調用這個UDAF函數時,分別傳入了銷量銷售日期兩個列的話,則input(0)代表的就是銷量,input(1)代表的就是銷售日期。

merge函數負責合并兩個聚合運算的buffer,再將其存儲到MutableAggregationBuffer中:

  def merge(buffer1: MutableAggregationBuffer, buffer2: Row): Unit = {
    buffer1(0) = buffer1.getAs[Double](0) + buffer2.getAs[Double](0)
    buffer1(1) = buffer1.getAs[Double](1) + buffer2.getAs[Double](1)
  }

最后,由evaluate函數完成對聚合Buffer值的運算,得到最后的結果:

  def evaluate(buffer: Row): Any = {
    if (buffer.getDouble(1) == 0.0)
      0.0
    else
      (buffer.getDouble(0) - buffer.getDouble(1)) / buffer.getDouble(1) * 100
  }

假設我們創建了這樣一個簡單的DataFrame:

    val conf = new SparkConf().setAppName("TestUDF").setMaster("local[*]")
    val sc = new SparkContext(conf)
    val sqlContext = new SQLContext(sc)
    
    import sqlContext.implicits._

    val sales = Seq(
      (1, "Widget Co", 1000.00, 0.00, "AZ", "2014-01-01"),
      (2, "Acme Widgets", 2000.00, 500.00, "CA", "2014-02-01"),
      (3, "Widgetry", 1000.00, 200.00, "CA", "2015-01-11"),
      (4, "Widgets R Us", 2000.00, 0.0, "CA", "2015-02-19"),
      (5, "Ye Olde Widgete", 3000.00, 0.0, "MA", "2015-02-28")
    )

    val salesRows = sc.parallelize(sales, 4)
    val salesDF = salesRows.toDF("id", "name", "sales", "discount", "state", "saleDate")
    salesDF.registerTempTable("sales")

那么,要使用之前定義的UDAF,則需要實例化該UDAF類,然后再通過udf進行注冊:

    val current = DateRange(Timestamp.valueOf("2015-01-01 00:00:00"), Timestamp.valueOf("2015-12-31 00:00:00"))
    val yearOnYear = new YearOnYearBasis(current)

    sqlContext.udf.register("yearOnYear", yearOnYear)
    val dataFrame = sqlContext.sql("select yearOnYear(sales, saleDate) as yearOnYear from sales")
    dataFrame.show()

在使用上,除了需要對UDAF進行實例化之外,與普通的UDF使用沒有任何區別。但顯然,UDAF更加地強大和靈活。如果Spark自身沒有提供符合你需求的函數,且需要進行較為復雜的聚合運算,UDAF是一個不錯的選擇。

通過Spark提供的UDF與UDAF,你可以慢慢實現屬于自己行業的函數庫,讓Spark SQL變得越來越強大,對于使用者而言,卻能變得越來越簡單。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380

推薦閱讀更多精彩內容