基於 Scala 的產品開發實踐 | 掘金技術徵文

張_逸發表於2017-04-28

原本地址:基於Scala的產品開發實踐
部落格地址:zhangyi.farbox.com/

我們的產品架構

整體架構

我們的產品代號為Mort(這個代號來自電影《馬達加斯加》那隻萌萌的大眼猴),是基於大資料平臺的商業智慧(BI)產品。產品架構如下所示:

基於 Scala 的產品開發實踐  | 掘金技術徵文

我們選擇了Spark作為我們的大資料分析平臺。基於目前的應用場景,主要使用了Spark SQL,目前使用的版本為Spark 1.5.0。我們有計劃去同步升級Spark最新版本。

在研發期間,我們從Spark 1.4升級到1.5,經過效能測評的Benchmark,效能確有顯著提高。Spark 1.6版本在記憶體管理方面有明顯的改善,Execution Memory與Store Memory的比例可以動態分配,但經過測試,產品的主要效能瓶頸其實是CPU,因為產品的資料分析功能屬於計算密集型。這是我們暫時沒有考慮升級1.6的主因。

從第一次升級Spark的效能測評,以及我們對這一年來Spark版本演進的觀察,我們對Spark的未來充滿信心,尤其是Tungsten專案計劃,會在記憶體管理、程式碼生成以及快取管理等多方面都會有所提高,對於我們產品而言,算是“坐享其成”了。

由於我們要分析的維度和指標是由客戶指定的,這就需要資料分析的聚合操作是靈活可定製的。因此,我們的產品寫了一個簡單的語法Parser,用以組裝Spark SQL的SQL語句,用以執行分析,最後將DataFrame轉換為我們期待的資料結構返回給前端。

但是,這種設計方案其實牽涉到兩層解析的效能損耗,一個是我們自己的語法Parser,另一個是Spark SQL提供的Parser(通過它將其解析為DataFrame的API呼叫)。我們考慮在將來會調整方案,直接將客戶定製的聚合操作解析為對DataFrame的API呼叫(可能會使用新版本Spark的DataSet)。

微服務架構

我們的產品需要支援多種資料來源,對資料來源的訪問是由另外一個standalone的服務CData完成的,通過它可以隔離這種資料來源的多樣性。這相當於一個簡單的微服務架構,目前僅提供兩個服務,一個服務用於資料分析,一個服務用於對客戶資料來源的處理:

基於 Scala 的產品開發實踐  | 掘金技術徵文

未來,我們的產品不止限於現有的兩個服務,例如我正在考慮將定期的郵件匯出服務獨立出來,保證該服務的獨立性,避免受到其他功能執行的影響。因為這個功能一旦失敗,可能會對客戶的業務產生重要影響。

然而,我們還是在理智地控制服務的粒度。我們不希望因為盲目地追求微服務架構,而帶來運維上的成本。

後設資料架構

我們的產品需要儲存後設資料(Metadata),用以支援Report、Dashboard以及資料分析,主要的資料模型結果如圖所示:

基於 Scala 的產品開發實踐  | 掘金技術徵文

針對後設資料的處理邏輯,我們將之分為職責清晰的三層架構。自上而下分別為REST路由層、應用服務層和後設資料資源庫層。

基於 Scala 的產品開發實踐  | 掘金技術徵文

  • REST路由層:將後設資料視為資源,響應客戶端的HTTP請求,並利用Spray Route將請求路由到對應的動詞上。路由層為核心資源提供Router的trait。這些Router只負責處理客戶端請求,以及服務端的響應,不應包含具體的業務邏輯。傳遞的訊息格式為Json格式,由Spray實現訊息到Json資料的序列化與反序列化。

  • 應用服務層:每個應用服務對應後設資料資源的操作用例。由於Mort對後設資料的操作並沒有非常複雜的業務邏輯,因此這些服務實際上成為了Router到Repository的中轉站,目的是為了隔離REST路由層對後設資料資源庫的依賴。每個服務都被細分為Creator、Editor、Fetcher與Destroyer這樣四個細粒度的trait,並放在對應服務的同一個scala檔案中。同時,應用服務要負責保障後設資料操作的資料完整性和一致性,因而引入了橫切關注點(Cross Concern Points)中的事務管理。同時,對操作的驗證以及許可權和授權操作也會放到應用服務中。

  • 後設資料資源庫層:每個資源庫物件都是一個Scala Object,並對應著資料庫中的後設資料表。這些物件中的CRUD操作都是原子操作。事實上我們可以認為每個資源庫物件就是後設資料的訪問入口。在其實現中,實際上封裝了scalikejdbc的訪問邏輯。

REST路由層和應用服務層需要接收和返回的訊息非常相似,甚至在某些場景中,訊息結構完全相同,但我們仍然定義了兩套訊息體系(皆被定義為Case Class)。邏輯層與訊息之間的關係如下圖所示:

基於 Scala 的產品開發實踐  | 掘金技術徵文

在REST路由層,所有的訊息皆以Request或Response作為類的字尾名,並被定義為Scala的Case Class。在應用服務層以及後設資料資源庫層使用的訊息物件則被單獨定義在Messages模組中。此外,後設資料資源庫層還會訪問由ScalikeJDBC生成的Model物件。

我們的技術選型

開發語言的選型

我們選擇的語言是Scala。選擇它的一個主因是因為Spark;另一個原因呢?或許是因為我確實不想再寫Java程式碼了。

其實有時候我覺得語言的選型是沒有什麼道理的。除了特殊的應用場景,幾乎所有的程式設計語言都能滿足如今的軟體開發需求。所以我悲哀地看到,語言的紛爭成了宗教的紛爭。

在我們團隊,有熟悉Java的、有熟悉JavaScript包括NodeJS的,有熟悉Clojure的,當然也有熟悉Scala的。除了NodeJS,後端開發幾乎都在JVM平臺下。

我對語言選型的判斷標準是:實用、高效、簡潔、可維護。我對Java沒有成見,但我始終認為:即使引入了Lambda以及Method Reference,Java 8在語法方面還是太冗長了。

Scala似乎從誕生開始,一直爭議很大。早在2014年1月ThoughtWorks的Tech Radar中,就講Scala列入了Adopt圈中,但卻在其中特別標註了“the good parts”:

基於 Scala 的產品開發實踐  | 掘金技術徵文

在2016年Stack Overflow釋出的開發人員調查結果中,我們也收穫了一些信心。在最愛語言的調查中,Scala排在了第四名:

基於 Scala 的產品開發實踐  | 掘金技術徵文

在引領技術趨勢的調查中,我們選用的React與Spark分列冠亞軍:

基於 Scala 的產品開發實踐  | 掘金技術徵文

在Top Paying Tech調查中,在美國學習Spark和Scala所值不菲,居然並列冠軍:

基於 Scala 的產品開發實踐  | 掘金技術徵文

其實有了微服務,在不影響程式碼維護性的情況下,使用多語言進行開發也成為了可能。或許在將來,我們產品的可能會用clojure或者Ruby來寫DSL,用NodeJS負責後設資料(以避免Spray + JSON4S不太好的Json物件序列化)。

說明:將後設資料管理單獨獨立為一個NodeJS服務,已經列到了後續架構演進的計劃中。針對後設資料管理,我們會統一成JavaScript技術棧,從前端到後端再到資料庫,統一為React+ES6、NodeJS和MongoDB。

坦白說,我沒有強烈的語言傾向性。

資料集的選型

我們還有一個最初的技術選型,後來被認為是失敗的選擇。

CData服務需要將客戶的資料來源經過簡單的ETL匯入到系統中,我們稱之為資料集(DataSet)。最初在進行技術選型時,我先後考慮過MySQL、Cassandra、HBase。後面兩種都屬於列式儲存的NoSQL資料庫。團隊中沒有一個人有Cassandra的經驗,至於HBase,雖然支援高效的資料查詢,但對聚合運算的支援明顯不足,不適合我們的場景。再加上團隊中有一位成員比較熟悉MySQL,我最終決定使用MySQL。

然而,我們的產品需要支援大資料,當資料量上升到一定級別時,就需要系統很好地支援水平擴充套件,通過增加更多機器來滿足效能上的需求。評估我們的架構,後端平臺可以簡單劃分為三個層次:Web應用服務層(Spray + Nginix)、資料分析層(MESOS + Spark)以及儲存層(主要用於儲存分析資料DataSet,MySQL)。顯然,MySQL會成為水平伸縮的最大障礙。

還好我們醒悟得早,在專案初期就否定了這個方案,而改為採用HDFS+Parquet。

Parquet檔案是一種列式資料儲存結構,對於主要為分析型查詢方式的BI資料操作,能夠提供更好的查詢效能。同時,Parquet檔案儲存的內容以二進位制形式存放,相較於文字形式容量更小,可以節省更多的儲存空間。
Spark SQL提供了對訪問Parquet檔案很好的整合。將Parquet檔案存放到HDFS中,然後再通過Spark SQL訪問,可以保證在儲存層與資料分析層都能很好地支援分散式處理,從而保證系統的水平伸縮。當對大規模資料集進行分析處理時,可以通過水平增加更多的節點來滿足高效能的實時查詢要求。

我們曾經比較了Parquet方案與MySQL方案,在同等配置下前者的效能要遠遠優於後者,且Spark對Parquet的支援也要好於MySQL。

為了更好地提升效能,我們還計劃在HDFS層之上引入Tachyon,充分發揮記憶體的優勢,減少磁碟IO帶來的效能損耗。

前端的技術選型

前端的技術選型則為React + Redux。選擇React的原因很簡單,一方面我們認為這種component方式的前端開發,可以極大地提高UI控制元件的重用,另一方面,我們認為React這種虛擬DOM的方式在效能上存在一定優勢。此外,React的學習曲線也不高,很容易上手。我們招了3個大學還未畢業的實習生,JS基礎非常薄弱,在我們的培養下,一週後就可以慢慢開始完成React Component開發的小Story了。

在最初的團隊,我們僅有一位前端開發。他選擇了使用CoffeeScript來開發React,但是在專案早期,我們還是忍痛去掉了這些程式碼,改為使用ES 6。畢竟隨著ES 6乃至ES 7的普及,JS的標準已經變得越來越合理,CoffeeScript的生存空間似乎被壓縮了。

在前端技術選型方面,我們經歷了好幾次演變。從CoffeeScript到ES 6,從Reflux到Redux,每次變化都在一定程度上增加了工作量。我在文章《技術選型的理想與現實》中講述的就是這個故事。

在《技術選型的理想與現實》這篇文章中,我講到我們選擇了Reflux。然而到現在,最終還是遷移到了Redux。我們一開始並沒有用好Redux,最近的一次重構才讓程式碼更符合Redux的最佳實踐。

結論

技術負責人一個非常重要的能力要求就是——善於做出好的技術決策。選擇技術時,並不能一味追求新技術,也不能以自我為中心,選擇“我”認為好的技術。而應該根據產品的需求場景、可能的技術風險、團隊成員能力,並通過分析未來的技術發展趨勢綜合地判斷。

技術決策不可能一成不變,需要與時俱進。如果發現決策錯誤,應該及時糾正,不要遲疑,更不要擔心會影響自己的技術聲譽。

我們的技術實踐

與大多數團隊相比,因為我們使用了小眾的Scala,可以算得上是“撈偏門”了,所以總結的技術實踐未必具有普適性,但對於同為Scala的友朋,或許值得借鑑一二。Scala社群發出的聲音還是太小,有點孤獨——“鸚其鳴也,求其友聲”。

這些實踐不是書本上的創作,而是在產品研發中逐漸演化而來,甚至一些實踐會非常細節。不過,那個優秀的產品不是靠這些細節堆砌出來的呢?

Scala語言的技術實踐

兩年前我還在ThoughtWorks的時候,與同事楊雲(大魔頭)在一個Scala的大資料專案,利用工作之餘,我結合了一些文件整理了一份Scala編碼規範,放在了github上:Scala編碼規範與最佳實踐

我們的產品後端全部由Scala進行開發。對於編寫Scala程式碼,我的要求很低,只有兩點:

  • 寫出來的程式碼儘可能有scala範兒,不要看著像Java程式碼
  • 不要用Scala中理解太費勁兒的語法,否則不利於維護

對於Scala程式設計,我們還總結了幾條小原則:

  • 將業務儘量分佈到小的trait中,然後通過object來組合
  • 多用函式或偏函式對邏輯進行抽象
  • 用隱式轉換體現關注點分離,既保證了職責的單一性,又保證了API的流暢性
  • 用getOrElse來封裝需要兩個分支的模式匹配
  • 對於隱式引數或支援型別轉換的隱式呼叫,應儘量讓import語句離呼叫近一些;對於增加方法的隱式轉換(相當於C#的擴充套件方法),則應將import放在檔案頭,保持呼叫程式碼的乾淨
  • 在一個模組中,儘量將隱式轉換定義放到implicits名稱空間下,除非是特別情況需要放到package object中
  • 在不影響可讀性的情況下,且無需封裝任何行為,可以考慮使用tuple,而非case class
  • 在合適的地方使用lazy關鍵字

AKKA的技術實踐

我們產品用的AKKA並不夠深入,僅僅使用了AKKA的基本功能。主要用於處理前端發來的資料分析訊息,相當於一個dispatcher,也承擔了部分訊息處理的職責,例如對訊息包含的後設資料進行解析,生成SQL語句,用以傳送給Spark的SqlContext。分析的結果則以Future的方式返回給Spray。

幾條AKKA實踐的小原則:

  • actor接收的訊息可以分為command和event兩類。命名時,前者用動賓短語,表現為命令請求;後者則使用過去時態,體現fact的本質。
  • 產品需要支援多種資料來源,不同資料來源的處理邏輯放到不同的模組中,我們利用actor來解耦

以下是為AKKA的ActorRefFactory定義的工廠方法:

trait ActorSupport {
  implicit val requestTimeout: Timeout = ActorConfig.requestTimeout 

  def actorOf(className: String)(implicit refFactory: ActorRefFactory, trackID: TrackID = random): ActorRef = refFactory.actorOf(new Props(Props.defaultDeploy, Class.forName(className).asInstanceOf[Class[Actor]], List.empty), id(className))
  def actorOf[T <: Actor : ClassTag](implicit refFactory: ActorRefFactory, trackID: TrackID = random): ActorRef = refFactory.actorOf(Props[T], id(classTag[T].toString))
  def actorOf[T <: Actor : ClassTag](initial: ActorRefFactory)(implicit trackID: TrackID = random): ActorRef = initial.actorOf(Props[T], id(classTag[T].toString))
}複製程式碼

通過向自定義的工廠方法actorOf()傳入Actor的名稱來建立Actor:

def importDataSetData(dataSetId: ID) {
  val importDataSetDataActor = actorOf(actorByPersistence("import"))(actorRefFactory)   
  importDataSetDataActor ! ImportDataSet(dataSetId)
}

def createDataSetPersistence: Future[Any] = {
  val createDataSetPersistenceActor = actorOf(actorByPersistence("create"))(actorRefFactory) 
  createDataSetPersistenceActor ? dataSet
}複製程式碼
  • 注意actor的sender不能離開當前的ActorContext
  • 採用類似Template Method模式的方式去擴充套件Actor
trait ActorExceptionHandler extends MortActor { 
  self: Actor =>override 

  def receive: Receive = {
    case any: Any =>
      try {
        super.receive(any) 
      } catch {
        case notFound: ActorNotFound =>
          val errorMsg: String = s"invalid parameters: ${notFound.toString}" 
          log.error(errorMsg) 
          exceptionSender ! ExecutionFailed(BadRequestException(s"invalid parameters ${notFound.getMessage}"), errorMsg)
        case e: Throwable => 
          exceptionSender ! ExecutionFailed(withTrackID(e, context.self.path.toString), e.getMessage) 
      } 
  }

  def exceptionSender = sender
}複製程式碼

或者以類似Decorator模式擴充套件Actor

trait DelegationActor extends MortActor {
  this: Actor =>private 
  val executionResultHandler: Receive = {
    case _: ExecutionResult => 
  }
  override def receive: Receive = {
    case any: Any =>
      try { 
        (mortReceive orElse executionResultHandler) (any) 
      } catch {
        case e: Throwable => 
          log.error(e, "")
          self ! ExecutionFailed(e)
          throw e 
      } finally { 
        any match {
          case _: ExecutionResult => self ! PoisonPillcase _ => 
        } 
      } 
  }
}複製程式碼
  • 考慮建立符合專案要求的SupervisorStrategy
  • 儘量利用actor之間的協作來傳遞訊息,這樣就可以儘量使用tell而不是ask

Spark SQL的技術實踐

目前的產品特性還未用到更高階的Spark功能。針對一些特殊的客戶,我們計劃採用Spark Streaming來進行流處理,除此之外,核心的資料分析功能都是使用Spark SQL。

以下是我們的一些總結:

  • 要學會使用Spark Web UI來幫助我們分析執行指標;另外,Spark本身提供了與Monitoring有關的REST介面,可以整合到自己的系統中;
  • 考慮在叢集環境下使用Kryo serialization;
  • 讓參與運算的資料與運算儘可能地近,在SparkConf中注意設定spark.locality值。注意,需要在不同的部署環境下修改不同的locality值;
  • 考慮Spark SQL與效能有關的配置項,例如spark.sql.inMemoryColumnarStorage.batchSizespark.sql.shuffle.partitions
  • Spark SQL自身對SQL執行定義了執行計劃,而且從執行結果來看,對SQL執行的中間結果進行了快取,提高了執行的效能。例如我針對相同量級的資料在相同環境下,連續執行了如下三條SQL語句:

第一次執行的SQL語句:

SELECT UniqueCarrier,Origin,count(distinct(Year)) AS Year FROM airline GROUP BY UniqueCarrier,Origin複製程式碼

第二次執行的SQL語句:

SELECT UniqueCarrier,Dest,count(distinct(Year)) AS Year FROM airline GROUP BY UniqueCarrier,Dest複製程式碼

第三次執行的SQL語句:

SELECT Dest , Origin , count(distinct(Year)) AS Year FROM airline GROUP BY Dest , Origin複製程式碼

觀察執行的結果如下所示:

基於 Scala 的產品開發實踐  | 掘金技術徵文

觀察執行count操作的job,顯然第一次執行SQL時的耗時最長,達到2s,而另外兩個job執行的時間則不到一秒。

針對複雜的資料分析,要學會充分利用Spark提供的函式擴充套件機制:UDF((User Defined Function)與UDAF(User Defined Aggregation Function);詳細內容,請閱讀文章《Spark強大的函式擴充套件功能》。

React+Redux的技術實踐

我們一開始並沒有用好React+Redux。隨著對它們的逐漸熟悉,結合社群的一些實踐,我們慢慢體會到了其中的一些好處,也摸索出一些好的實踐。

  • 遵循元件設計的原則,我們將React元件分為Component與Container兩種,前者為純元件。

元件設計的原則

  • 一個純元件利用props接受所有它需要的資料,類似一個函式的入參,除此之外它不會被任何其它因素影響;
  • 一個純元件通常沒有內部狀態。它用來渲染的資料完全來自於輸入props,使用相同的props來渲染相同的純元件多次,
  • 將得到相同的UI。不存在隱藏的內部狀態導致渲染不同。
  • 在React中儘可能使用extends而不是mixin;
  • 對State進行正規化化,不要定義巢狀的State結構,不同資料的相互引用都通過ID來查詢。正規化化的state可以更有效地利用Store裡儲存空間;
  • 如果不能更改後端返回的模型,可以考慮使用normalizr;但在我們的專案中,為了滿足這一要求,我們專門修改了後端的API。因為採用了之前介紹的後設資料架構,這個修改主要影響到了REST路由層和應用服務層的部分程式碼;
  • 遵循Redux的三大基本原則;

Redux的三大基本原則

  • 單一資料來源
  • State 是隻讀的
  • 使用純函式來執行修改

在我們的專案中,將所有向後臺傳送非同步請求的操作都封裝到service中,action會呼叫這些服務。我們使用了redux-actions的createAction建立dispatch需要的訊息:

export const loadDataSource = (id) => {
  return dispatch => {
    return DataSourceServices.getDataSource(id) 
              .then(dataSource => { dispatch(createAction(DataSourceActionTypes.DATA_SOURCE_RECEIVED)(dataSource)) }) 
  }
}複製程式碼

在Reducer中,通過redux-actions的handleAction來處理action,避免使用醜陋的switch語句:

export const dataSources = handleActions({   
  [DataSourceActionTypes.DATA_SOURCES_RECEIVED]: (state, {payload}) => {
    const newState = reduce(payload, (result, dataSource) => {
      set(result, dataSource.id, dataSource)
      return result 
    }, state)
    return assign({}, newState) 
  }, 
  [DataSourceActionTypes.DATA_SOURCE_RECEIVED]: (state, {payload}) => {
    set(state, payload.id, payload)
    return assign({}, state) 
  }, 
  [DataSourceActionTypes.DATA_SOURCE_DELETED]: (state, {payload}) => {
    return omit(state, payload) }
}, {})複製程式碼

在Container元件中,如果Store裡面的模型物件需要根據id進行filter或merge之類的操作,則交給selector對其進行封裝。於是Container元件中就可以這樣來呼叫:

@connect(state => {
  return {
    dataSourcesOfDirectory: DataSourcesSelectors.getDataSourcesOfDirectory(state), 
    dataSetsOfDataSource: DataSetsSelectors.getDataSetsOfDataSource(state), 
    selectedDataSource: DataSourcesSelectors.getSelectedDataSource(state), 
    currentDirectory: DataSourcesSelectors.getCurrentDirectory(state), memories: state.next.commons.memories 
  }
}, {
  loadDataSourcesOfDirectory: DataSourcesActions.loadDataSourcesOfDirectory, 
  selectDataSource: selectedDataSourceAction.selectDataSource, 
  cleanSelectedDataSource: selectedDataSourceAction.cleanSelectedDataSource, 
  loadDataSetsOfDataSource: DataSetsActions.loadDataSetsOfDataSource, 
  updateDataSource: DataSourcesActions.updateDataSource, 
  deleteDataSource: DataSourcesActions.deleteDataSource, 
  navigate: commonActions.navigate, 
  memory: memoryActions.memory, 
  cleanMemory: memoryActions.cleanMemory,   
  goToNewDataSource: NavigationActions.goToNewDataSource
})複製程式碼
  • 使用eslint來檢查程式碼是否遵循ES編寫規範;為了避免團隊成員編寫的程式碼不遵守這個規範,甚至可以在git push之前將lint檢查加入到hook中:
echo "npm run lint" > .git/hooks/pre-pushchmod +x .git/hooks/pre-push複製程式碼

Spray與REST的技術實踐

我們的一些總結:

  • 站在資源(名詞)的角度去思考REST服務,並遵循REST的規範;
  • 考慮GET、PUT、POST、DELETE的安全性與冪等性;
  • 必須為REST服務編寫API文件,並及時更新;
  • 使用REST CLIENT對REST服務進行測試,而不能盲目地信任Spray提供的ScalatestRouteTest對客戶端請求的模擬,因為這種模擬其實省略了對Json物件的序列化與反序列化;
  • 為核心的REST服務提供健康服務檢查;

基於 Scala 的產品開發實踐  | 掘金技術徵文

  • 在Spray中,儘量將自定義的HttpService定義為trait,這樣更利於對它的測試;在自定義的HttpService中,採用cake pattern(使用Self Type)的方式將HttpService注入;
  • 我個人不太喜歡Spray以DSL方式編寫REST服務,因為它可能讓函式的巢狀層次太深;如果在一個HttpService(在我們的專案中,皆命名為Router)中,提供的服務較多,建議將各個REST動作都抽取為一個返回Route物件的私有函式,然後利用RouteConcatenation的~運算子拼接起來,以便於閱讀:
def reportRoute(implicit userId: ID) = pathPrefix("reports") {   
  getReport ~ getViewsOfReport ~ createReport ~ updateReport ~ deleteReport ~ getVirtualField ~ getVirtualFields ~ fuzzyMatch ~ createVirtualField
}複製程式碼
  • Spray預設對Json序列化的支援是使用的是Json4s,為此Spray提供了Json4sSupport trait;如果需要支援更多自定義型別的Json序列化,需要重寫隱式值json4sFormats;建議將這些隱式定義放到Object中,交由Router引用,而不是定義為trait去繼承。因為並非Router都使用Json格式,由於trait定義的繼承傳遞性,可能會導致未使用Json格式的Router出現錯誤;
  • Json4s可以支援Scala的大多數型別,包括Option等,但不能很好地支援Scala列舉以及複雜的巢狀遞迴結構,包括多型。這時需要自定義Serializer。具體細節請閱讀我的文章《Spray中對複雜Json的序列化與反序列化》。

掘金技術徵文第三期:聊聊你的最佳實踐

相關文章