重構了後端服務,我學到了這些東西

我是Kurio(來自印度尼西亞的一款新聞聚合器)的軟件工程師。Kurio是一款聚合器應用程序,我們的主要工作是:收集發佈合作伙伴網站上的新聞或文章,並通過我們的應用程序將其提供給用戶。

與其他新聞聚合器一樣,我們爲用戶提供了多種新聞內容,例如按我們的top_stories邏輯進行排序的新聞、按照趨勢進行分類的新聞以及來自特定發佈商的新聞。

image

移動端的Kurio新聞佈局

Feed的構建過程由我們的Feed服務負責處理。

這個服務是Kurio的三大主要項目之一,之前的版本已經運行了很長一段時間。因此,它變得非常複雜,有時也會難以理解。這也使得添加新功能變得非常困難。因此,我們決定重建我們的Feed服務。希望通過這個新版本的Feed服務,我們可以輕鬆添加新功能或者使其更易於維護。

在這個新項目中,我們創建了新的架構,並混合了舊架構,具備了動態和靈活性。我們知道,新聞源可以是任意類型的對象,比如文章、視頻、音頻等等。使用Go語言實現這些真的很有挑戰性,因爲Go語言是一種靜態類型的編程語言,它沒有Java或其他編程語言的泛型類型。

瞭解流程

首先我們需要了解以前的系統是如何工作的。從編譯、測試和部署開始,直到收到用戶請求,我們需要知道整個過程的工作原理。

因爲這是一個核心的服務,而我剛剛來這裏一年,我真的不知道它是如何運作的,尤其是多年來整個系統添加了很多額外的功能和補丁,很難通過閱讀代碼來了解它。所以,我們需要了解流程和規則,然後基於這些流程和規則構建新的流程和規則。

例如,當用戶打開應用程序時,會得到由這項服務提供的top_stories新聞源。或者是一些規則,例如:在top_stories新聞源中向用戶顯示的內容是有限制的。或者類似於:不要向用戶展示他們不關注的主題,或者根據用戶的屬性(性別、年齡等)顯示新聞。

列出這些規則和流程是一件簡單的事情,難就難在如何將其轉換爲代碼。一般來說,我們的流程非常簡單,如下所示。

image

用戶獲取新聞的流程

基本上主要是兩個大功能,獲取個性化新聞和獲取默認新聞。最難的是獲取個性化新聞,因爲我們必須將它與個性化引擎相結合。此外,我們必須遵循一些與上面提到的個性化內容相關的規則(提取用戶興趣和屬性,然後根據用戶的興趣構建新聞源)。

設計和討論

我們之所以要重構這個服務,是因爲當我們要添加新功能時,之前系統的代碼架構無法很好地擴展。如果要在未來開發新功能會非常痛苦,因爲我們不得不重構很多東西。

所以我們真正需要的是修復架構。設計一個新的架構真的很難。我們需要問自己很多問題,比如:“這樣做會怎樣?”、“爲什麼要這樣?”、“爲什麼不是這樣?”我們希望新架構能夠解決“未來”的問題,並提供向後兼容性。爲此,我們進行了大約一個月的討論,針對每個大功能進行了技術棧和流程方面的討論。

最終,我們決定嘗試一些函數式的開發方式。我們放棄了之前使用的代碼架構,發明了一種新的代碼架構,帶有函數式編程(使用高階函數模式)的味道,但又不像Lisp或Clojure那麼動態。

因此,在我們的代碼中可以找到很多HOF(高階函數)模式,如下所示:

func something(params, func(params)) (func(params)){
}

但因爲我們使用的是Go語言,一種靜態類型的編程語言,所以當創建了很多函數時就會有很多痛點,必須進行大量的類型檢查和轉換,而這耗費了大量時間。

因此,我們意識到Go語言不適合用來解決我們的問題,但在我們這10個後端工程師當中,只有一個人瞭解Clojure(函數式編程),而學習新的編程語言就意味着我們需要額外的時間。經過長時間的討論,我們決定繼續使用Go語言,不僅是因爲我們所有的後端工程師都很瞭解Go語言,也是因爲Go語言已經在很多微服務中得到驗證。

瞭解基礎

在將流程和設計轉換爲代碼時,我意識到我們必須對基礎有一個真正的瞭解。一開始,我並沒有真正理解高階函數的工作原理。在閱讀代碼時感到很困惑,怎麼總是一個函數接收一個函數作爲參數然後再返回一個函數呢?不過要感謝谷歌,我現在終於明白了。

我們還需要了解Go語言本身的基礎知識,比如使用指針作爲函數接收器、DateTime的基礎知識,以及很多其他基礎的東西。如果我們對這些東西不瞭解,只會增加完成這個項目的時間。

先運行,後優化

  1. 優化的第一條規則——不要優化
  2. 優化的第二條規則——還不到優化的時候
  3. 優化前先分析

因此,在開發這個服務時,我們的第一個目標是確保至少可以運行它。我們沒有去考慮性能問題,並試圖忽略任何有關優化的事情,例如使用Go例程。

在開發完代碼後,我們就可以編譯並運行它,所有請求都能被正常處理,響應也很正常。當然,初始版本速度非常慢。與之前的系統相比,它慢了十倍。以前的系統在使用staging服務器時單個API請求大約需要500毫秒,而新版本需要50000毫秒(約50秒)或更久。

優化代碼也是我們最重要的任務之一。爲了優化我們的代碼,我們遵循了以下步驟:

  1. 找出需要長時間處理的循環代碼,將其轉換爲使用Go例程,提高並行性或使用管道。
  2. 分析系統並檢測所有速度慢的功能,對其進行優化。所幸的是,在Go語言中進行分析很容易。藉助pprof(https://blog.golang.org/profiling-go-programs)工具,我們可以對系統進行分析並檢測所有速度慢的功能。我們甚至可以檢測出我們所使用的哪個庫最慢,這樣我們就可以使用具有類似功能的另一個庫替換它們。
  3. 如果有必要,增加緩存。

構建服務時,我們的規則是隻在確實需要使用緩存的情況下使用緩存。緩存就像一種藥物,它會讓我們上癮,因爲當我們的系統看起來很慢時,會把緩存看成是解決問題的靈丹妙藥。通常,在開發大型併發項目時沉迷於使用“緩存”的人,首先想到的是“緩存”,而不是先考慮優化(基準測試、分析)功能(邏輯/算法)。

對於我們的情況,我們通過兩種方式來使用緩存:

  • 去重管理:因爲新聞源可能是來自很多存儲庫(數據庫和服務)的內容(文章、新聞)列表,所以內容可能會重複。因此,我們將緩存作爲臨時存儲來處理重複數據。
  • 存儲庫緩存:因爲新聞源可能是來自很多存儲庫(數據庫和服務)的內容(文章、新聞)列表,多個用戶有可能請求相同的內容。因此,爲了避免從存儲庫中獲取相同的內容,我們緩存了存儲庫結果。

通過這種優化,我們至少可以像在以前的系統中那樣改進新系統的性能(staging服務器的響應時間約爲400毫秒,生產服務器的響應時間約爲180毫秒)。

小心地做出變更

基於語義版本控制,在不添加新特性和不破壞API的情況下進行重新構建就不算是一個新的版本。基本上,在這個新重建的系統中,我們的目標是改變架構,而不是API規範。因此,無論我們在系統中進行做出哪些變更,都不能更改API。因爲即使是非常微小的變化也會影響到所有相關的服務。

爲了讓它成爲一個新版本,我們對錯誤響應消息正文進行了一些修改。

原始錯誤響應消息正文:

{  "error": "Error Message"}

新版本的錯誤響應消息正文:

{ 
  "error": {    
  "message": "Error Message",    
  "errors": [      
    // any stack-trace errors      
  ]  
  }
}

因爲進行了這些變更,我們還需要處理其他使用了我們API服務的相關服務。所幸的是,只有兩種服務使用了我們的API服務,所以我們只需要更新兩個應用程序:儀表盤應用程序和移動網關API。此外,因爲只有響應錯誤發生了重大變更,所以只需要修改應用程序的一小部分即可。

永遠不要忽略了測試

在重新構建這個服務時,我們至少進行了三次測試,然後才發佈到生產環境中:單元測試、集成測試和負載測試。

在所有這些類型的測試中,單元測試是最小的測試。有些人似乎低估了單元測試的重要性,因爲它只是一個單元,一個小功能。但是,在重建這個新服務時,我體會到了單元測試的重要性。

在Sprint開始時,我們忽略了單元測試,因爲我們希望專注在代碼架構的設計上。所以我們開發了一些沒有任何測試的功能。我們這樣做是因爲我們仍然在構建一些實驗性的代碼架構,爲了避免進行不必要的單元測試重構,我們在這個時候沒有創建任何單元測試。

但是,在完成代碼架構設計之後,我們忘了爲在Sprint開始時創建的功能添加單元測試。直到我們將它部署到staging服務器並與另一個真實服務進行了集成測試。我們在應用程序中發現了很多錯誤。然後我們查看了源代碼,發現我們的功能有很多條件都沒有覆蓋到。

知道了這個問題後,我們意識到我們還沒有測試過這個功能。它還沒有通過單元測試。如果我們從一開始就進行單元測試,那麼修復這個問題並重新部署它就不需要做額外的工作。在進行單元測試時,我們可以考慮很多不同情況,並在部署應用程序之前修復它們。

結論

雖然我們做的是幕後工作,並且對我們的用戶沒有可見的影響,但我們確實學到了很多東西。我學到了很多關於如何從頭開始構建高併發系統的知識。完成這項任務後,我知道了爲什麼當我們在面試後端開發職位時,總會被問及邏輯和算法問題。這是因爲在構建高併發系統時,性能是非常重要的方面,任何算法都會影響到系統的響應時間。

英文原文:https://dzone.com/articles/we-rebuilt-our-backend-feed-service-here-what-i-le

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章