重構存取外部服務的程式碼
當我撰寫處理外部服務的程式碼時,我發現將存取程式碼分隔成個別物件很有價值。在此,我將展示如何將一些凝結的程式碼重構成這種分隔的常見模式。
2015 年 2 月 17 日
軟體系統的其中一個特點是它們並非獨立存在。為了執行一些有用的功能,它們通常需要與其他軟體程式溝通,而這些程式是由不同的人編寫的,我們並不認識這些人,而他們也不認識或關心我們編寫的軟體。
當我們編寫執行此類外部協作的軟體時,我認為運用良好的模組化和封裝特別有用。我看到一些常見的模式,且發現它們在執行此類作業時很有價值。
在本文中,我將舉一個簡單的範例,並逐步說明重構,以導入我所尋找的模組化類型。
起始程式碼
範例程式碼的工作是從 JSON 檔案中讀取一些關於影片的資料,使用 YouTube 的資料豐富其內容,計算一些簡單的進一步資料,然後以 JSON 格式傳回資料。
以下是起始程式碼。
class VideoService…
def video_list @video_list = JSON.parse(File.read('videos.json')) ids = @video_list.map{|v| v['youtubeID']} client = GoogleAuthorizer.new( token_key: 'api-youtube', application_name: 'Gateway Youtube Example', application_version: '0.1' ).api_client youtube = client.discovered_api('youtube', 'v3') request = { api_method: youtube.videos.list, parameters: { id: ids.join(","), part: 'snippet, contentDetails, statistics', } } response = JSON.parse(client.execute!(request).body) ids.each do |id| video = @video_list.find{|v| id == v['youtubeID']} youtube_record = response['items'].find{|v| id == v['id']} video['views'] = youtube_record['statistics']['viewCount'].to_i days_available = Date.today - Date.parse(youtube_record['snippet']['publishedAt']) video['monthlyViews'] = video['views'] * 365.0 / days_available / 12 end return JSON.dump(@video_list) end
此範例的語言為 Ruby
我首先要說明的是,此範例中的程式碼並不多。如果整個程式碼庫只有這個指令碼,那麼您就不必太擔心模組化。我需要一個小型範例,但如果我們檢視一個真實的系統,任何讀者的眼睛都會發直。因此,我必須請您將此程式碼想像成一個包含數萬行程式碼的系統中的典型程式碼。
存取 YouTube API 是透過 GoogleAuthorizer 物件進行,就本文而言,我會將其視為外部 API。它處理連線到 Google 服務(例如 YouTube)的繁瑣細節,特別是處理授權問題。如果您想了解它的運作方式,請參閱 我最近撰寫的一篇文章,關於存取 Google API。
這段程式碼怎麼了?您可能無法理解這段程式碼執行的所有操作,但您應該能夠看出它混雜了不同的考量,我已透過為以下程式碼範例上色來建議。為了進行任何變更,您必須了解 如何存取 YouTube 的 API、 YouTube 如何建構其資料 以及 一些網域邏輯。
class VideoService…
def video_list @video_list = JSON.parse(File.read('videos.json')) ids = @video_list.map{|v| v['youtubeID']} client = GoogleAuthorizer.new( token_key: 'api-youtube', application_name: 'Gateway Youtube Example', application_version: '0.1' ).api_client youtube = client.discovered_api('youtube', 'v3') request = { api_method: youtube.videos.list, parameters: { id: ids.join(","), part: 'snippet, contentDetails, statistics', } } response = JSON.parse(client.execute!(request).body) ids.each do |id| video = @video_list.find{|v| id == v['youtubeID']} youtube_record = response['items'].find{|v| id == v['id']} video['views'] = youtube_record['statistics']['viewCount'].to_i days_available = Date.today - Date.parse(youtube_record['snippet']['publishedAt']) video['monthlyViews'] = video['views'] * 365.0 / days_available / 12 end return JSON.dump(@video_list) end
像我這樣的軟體專家經常談論「關注點分離」- 這基本上表示不同的主題應在不同的模組中。我這樣做的主要原因是理解:在一個模組化良好的程式中,每個模組都應該只有一個主題,所以我無需了解我不需要理解的任何內容。如果 YouTube 的資料格式變更,我不應該必須了解應用程式的網域邏輯才能重新排列存取程式碼。即使我進行的變更從 YouTube 取得一些新資料並在一些網域邏輯中使用,我也應該能夠將我的任務拆分為那些部分,並個別處理每個部分,將我需要在腦中不斷思考的程式碼行數減至最少。
我的重構任務是將這些考量拆分到不同的模組中。完成後,Video Service 中唯一的程式碼應該是未上色的程式碼 - 協調這些其他責任的程式碼。
將程式碼置於測試中
重構的第一步總是相同的。您需要有信心,您不會無意間損壞任何東西。重構就是將一大組小步驟串聯在一起,所有步驟都保留行為。透過將步驟縮小,我們可以增加不搞砸的機率。但我足夠了解自己,知道我甚至會搞砸最簡單的變更,所以為了獲得我需要的信心,我必須有測試來找出我的錯誤。
但像這樣的程式碼並不容易測試。如果能撰寫一個測試來斷言計算出的每月觀看次數欄位,那會很好。畢竟,如果其他地方出錯,這將會提供不正確的答案。但問題是我正在存取即時 YouTube 資料,而人們習慣觀看影片。YouTube 中的觀看次數欄位會定期變更,導致我的測試以非決定性方式呈現紅色
因此,我的第一個任務是移除那部分的脆弱性。我可以透過導入測試替身來達成,這是一個看起來像 YouTube 的物件,但會以決定性方式回應。很不幸地,我遇到了傳統程式碼困境。
傳統程式碼困境:當我們變更程式碼時,我們應該有測試就緒。要就緒測試,我們通常必須變更程式碼。
考量到我必須在沒有測試的情況下進行變更,我需要進行我能想到最小的變更,才能將與 YouTube 的互動置於我能導入測試替身的接縫處。因此,我的第一步是使用萃取方法,將與 YouTube 的互動從例程的其他部分萃取到它自己的方法中。
class VideoService…
def video_list
@video_list = JSON.parse(File.read('videos.json'))
ids = @video_list.map{|v| v['youtubeID']}
response = call_youtube ids
ids.each do |id|
video = @video_list.find{|v| id == v['youtubeID']}
youtube_record = response['items'].find{|v| id == v['id']}
video['views'] = youtube_record['statistics']['viewCount'].to_i
days_available = Date.today - Date.parse(youtube_record['snippet']['publishedAt'])
video['monthlyViews'] = video['views'] * 365.0 / days_available / 12
end
return JSON.dump(@video_list)
end
def call_youtube ids
client = GoogleAuthorizer.new(
token_key: 'api-youtube',
application_name: 'Gateway Youtube Example',
application_version: '0.1'
).api_client
youtube = client.discovered_api('youtube', 'v3')
request = {
api_method: youtube.videos.list,
parameters: {
id: ids.join(","),
part: 'snippet, contentDetails, statistics',
}
}
return JSON.parse(client.execute!(request).body)
end
這麼做達成了兩件事。首先,它將 Google API 處理程式碼漂亮地拉到它自己的方法中(大部分),將它從任何其他類型的程式碼中隔離出來。這本身就有價值。其次,更重要的是,它設定了一個接縫,我可以使用它來替換測試行為。Ruby 內建的 minitest 函式庫允許我輕鬆地存根物件上的個別方法。
class VideoServiceTester < Minitest::Test def setup vs = VideoService.new vs.stub(:call_youtube, stub_call_youtube) do @videos = JSON.parse(vs.video_list) @µS = @videos.detect{|v| 'wgdBVIX9ifA' == v['youtubeID']} @evo = @videos.detect{|v| 'ZIsgHs0w44Y' == v['youtubeID']} end end def stub_call_youtube JSON.parse(File.read('test/data/youtube-video-list.json')) end def test_microservices_monthly_json assert_in_delta 5880, @µS ['monthlyViews'], 1 assert_in_delta 20, @evo['monthlyViews'], 1 end # further tests as needed…
透過分離出 YouTube 呼叫並存根它,我可以讓這個測試以決定性方式運作。至少今天可以,要讓它明天也能運作,我需要對呼叫 Date.today
執行相同動作。
class VideoServiceTester…
def setup
Date.stub(:today, Date.new(2015, 2, 2)) do
vs = VideoService.new
vs.stub(:call_youtube, stub_call_youtube) do
@videos = JSON.parse(vs.video_list)
@µS = @videos.detect{|v| 'wgdBVIX9ifA' == v['youtubeID']}
@evo = @videos.detect{|v| 'ZIsgHs0w44Y' == v['youtubeID']}
end
end
end
將遠端呼叫分隔成連線物件
透過將程式碼放入不同的函式中來分離關注點是第一層分離。但當關注點像網域邏輯和處理外部資料提供者一樣不同時,我比較喜歡將分離層級提高到不同的類別中。

圖 1:一開始,影片服務類別包含四個職責
因此,我的第一步是建立一個新類別,並使用 移動方法。
class VideoService…
def call_youtube ids YoutubeConnection.new.list_videos ids end
class YoutubeConnection…
def list_videos ids client = GoogleAuthorizer.new( token_key: 'api-youtube', application_name: 'Gateway Youtube Example', application_version: '0.1' ).api_client youtube = client.discovered_api('youtube', 'v3') request = { api_method: youtube.videos.list, parameters: { id: ids.join(","), part: 'snippet, contentDetails, statistics', } } return JSON.parse(client.execute!(request).body) end
這樣,我也可以變更 stub,讓它傳回測試替身,而不再只是對方法進行 stub。
class VideoServiceTester…
def setup
Date.stub(:today, Date.new(2015, 2, 2)) do
YoutubeConnection.stub(:new, YoutubeConnectionStub.new) do
@videos = JSON.parse(VideoService.new.video_list)
@µS = @videos.detect{|v| 'wgdBVIX9ifA' == v['youtubeID']}
@evo = @videos.detect{|v| 'ZIsgHs0w44Y' == v['youtubeID']}
end
end
end
class YoutubeConnectionStub…
def list_videos ids JSON.parse(File.read('test/data/youtube-video-list.json')) end
在執行此重構時,我必須小心,因為我閃亮的全新測試不會偵測到我在 stub 後面所犯的任何錯誤,因此我必須手動確保生產程式碼仍然運作。(是的,既然你問了,我在執行此操作時確實犯了一個錯誤(遺漏 list-videos 的引數)。我需要進行這麼多測試是有原因的。)
使用個別類別能獲得更佳的關注點分離,也能為測試提供更好的接縫 - 我可以將所有需要進行 stub 的內容包裝到單一物件建立中,如果我們需要在測試期間對同一個服務物件進行多次呼叫,這會特別方便。
將對 YouTube 的呼叫移至連線物件後,影片服務上的方法就沒有價值了,所以我對它進行 內嵌方法。
class VideoService…
def video_list @video_list = JSON.parse(File.read('videos.json')) ids = @video_list.map{|v| v['youtubeID']} response = YoutubeConnection.new.list_videos ids ids.each do |id| video = @video_list.find{|v| id == v['youtubeID']} youtube_record = response['items'].find{|v| id == v['id']} video['views'] = youtube_record['statistics']['viewCount'].to_i days_available = Date.today - Date.parse(youtube_record['snippet']['publishedAt']) video['monthlyViews'] = video['views'] * 365.0 / days_available / 12 end return JSON.dump(@video_list) end def call_youtube ids YoutubeConnection.new.list_videos ids end
我不喜歡我的 stub 必須解析 json 字串。總的來說,我喜歡將連線物件保持為 謙遜物件,因為它們執行的任何行為都不會受到測試。所以我比較喜歡將解析拉到呼叫方。
class VideoService…
def video_list
@video_list = JSON.parse(File.read('videos.json'))
ids = @video_list.map{|v| v['youtubeID']}
response = JSON.parse(YoutubeConnection.new.list_videos(ids))
ids.each do |id|
video = @video_list.find{|v| id == v['youtubeID']}
youtube_record = response['items'].find{|v| id == v['id']}
video['views'] = youtube_record['statistics']['viewCount'].to_i
days_available = Date.today - Date.parse(youtube_record['snippet']['publishedAt'])
video['monthlyViews'] = video['views'] * 365.0 / days_available / 12
end
return JSON.dump(@video_list)
end
class YoutubeConnection…
def list_videos ids
client = GoogleAuthorizer.new(
token_key: 'api-youtube',
application_name: 'Gateway Youtube Example',
application_version: '0.1'
).api_client
youtube = client.discovered_api('youtube', 'v3')
request = {
api_method: youtube.videos.list,
parameters: {
id: ids.join(","),
part: 'snippet, contentDetails, statistics',
}
}
return JSON.parse(client.execute!(request).body)
end
class YoutubeConnectionStub…
def list_videos ids
JSON.parse(File.read('test/data/youtube-video-list.json'))
end

圖 2:第一步將 youtube 連線程式碼分隔到一個 連線 物件。
將 YouTube 資料結構分隔成閘道
現在,我已經將與 YouTube 的基本連線分隔並可以進行 stub,我可以處理深入探討 YouTube 資料結構的程式碼。這裡的問題是,許多程式碼需要知道,要取得觀看次數資料,你必須查看結果的「統計資料」部分,但要取得發布日期,你必須深入探討「片段」部分。這種深入探討在遠端來源的資料中很常見,它的組織方式對他們來說有意義,但對我來說沒有。這完全是合理的行為,他們無法洞察我的需求,我已經夠努力了。
我發現一個思考此問題的好方法是 Eric Evans 的 受限脈絡 概念。YouTube 會根據其脈絡組織資料,而我想根據不同的脈絡組織我的資料。將兩個受限脈絡結合在一起的程式碼會變得複雜,因為它會將兩個不同的詞彙混合在一起。我需要使用 Eric 所謂的反腐敗層將它們分開,這是一個明確的界線。他對反腐敗層的說明是中國長城,而與任何這樣的牆壁一樣,我們需要閘口讓某些事物在它們之間通過。在軟體術語中,閘口允許我穿過牆壁,從 YouTube 受限脈絡取得我需要的資料。但閘口應該以在我的脈絡中而非在他們的脈絡中有意義的方式表達。

在此簡單範例中,表示閘道物件可以提供發布日期和觀看次數,而不需要客戶端知道這些資料是如何儲存在 YouTube 資料結構中。閘道物件會將 YouTube 的內容轉換為我的內容。
我從建立閘道物件開始,並使用從連線取得的回應初始化該物件。
class VideoService…
def video_list @video_list = JSON.parse(File.read('videos.json')) ids = @video_list.map{|v| v['youtubeID']} youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids)) ids.each do |id| video = @video_list.find{|v| id == v['youtubeID']} youtube_record = youtube.record(id) video['views'] = youtube_record['statistics']['viewCount'].to_i days_available = Date.today - Date.parse(youtube_record['snippet']['publishedAt']) video['monthlyViews'] = video['views'] * 365.0 / days_available / 12 end return JSON.dump(@video_list) end
類別 YoutubeGateway…
def initialize responseJson @data = JSON.parse(responseJson) end def record id @data['items'].find{|v| id == v['id']} end
我在此建立最簡單的行為,即使我最終不打算使用閘道的記錄方法,事實上,除非我停下來喝杯茶,否則我不認為它會持續半小時。
現在,我將服務中用於觀看次數的深入探討邏輯移至閘道,並建立一個單獨的閘道項目類別來表示每個影片記錄。
class VideoService…
def video_list
@video_list = JSON.parse(File.read('videos.json'))
ids = @video_list.map{|v| v['youtubeID']}
youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids))
ids.each do |id|
video = @video_list.find{|v| id == v['youtubeID']}
youtube_record = youtube.record(id)
video['views'] = youtube.item(id)['views']
days_available = Date.today - Date.parse(youtube_record['snippet']['publishedAt'])
video['monthlyViews'] = video['views'] * 365.0 / days_available / 12
end
return JSON.dump(@video_list)
end
類別 YoutubeGateway…
def item id { 'views' => record(id)['statistics']['viewCount'].to_i } end
我對發布日期執行相同的動作
class VideoService…
def video_list @video_list = JSON.parse(File.read('videos.json')) ids = @video_list.map{|v| v['youtubeID']} youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids)) ids.each do |id| video = @video_list.find{|v| id == v['youtubeID']} youtube_record = youtube.record(id) video['views'] = youtube.item(id)['views'] days_available = Date.today - youtube.item(id)['published'] video['monthlyViews'] = video['views'] * 365.0 / days_available / 12 end return JSON.dump(@video_list) end
類別 YoutubeGateway…
def item id
{
'views' => record(id)['statistics']['viewCount'].to_i,
'published' => Date.parse(record(id)['snippet']['publishedAt'])
}
end
由於我使用閘道中根據金鑰查詢的記錄,因此我想在內部資料結構中更佳反映該用法,我可以透過將清單替換為雜湊來執行此動作
類別 YoutubeGateway…
def initialize responseJson @data = JSON.parse(responseJson)['items'] .map{|i| [ i['id'], i ] } .to_h end def item id { 'views' => @data[id]['statistics']['viewCount'].to_i, 'published' => Date.parse(@data[id]['snippet']['publishedAt']) } end def record id @data['items'].find{|v| id == v['id']} end

圖 4:將資料處理分隔到閘道物件
完成後,我已執行我想要執行的金鑰分隔。YouTube 連線物件會處理對 YouTube 的呼叫,並傳回它提供給 YouTube 閘道物件的資料結構。服務程式碼現在完全關於我希望如何查看資料,而不是如何將資料儲存在不同的服務中。
class VideoService…
def video_list @video_list = JSON.parse(File.read('videos.json')) ids = @video_list.map{|v| v['youtubeID']} youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids)) ids.each do |id| video = @video_list.find{|v| id == v['youtubeID']} video['views'] = youtube.item(id)['views'] days_available = Date.today - youtube.item(id)['published'] video['monthlyViews'] = video['views'] * 365.0 / days_available / 12 end return JSON.dump(@video_list) end
將網域邏輯分隔成網域物件
儘管現在 YouTube 的所有互動都已分派到個別物件,但影片服務仍會將其網域邏輯(如何計算每月觀看次數)與編排本地儲存資料與服務中資料之間的關係混在一起。如果我為影片引入網域物件,我可以將其分隔出來。
我的第一步是將影片資料的雜湊單純包裝在物件中。
類別影片…
def initialize aHash @data = aHash end def [] key @data[key] end def []= key, value @data[key] = value end def to_h @data end
class VideoService…
def video_list @video_list = JSON.parse(File.read('videos.json')).map{|h| Video.new(h)} ids = @video_list.map{|v| v['youtubeID']} youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids)) ids.each do |id| video = @video_list.find{|v| id == v['youtubeID']} video['views'] = youtube.item(id)['views'] days_available = Date.today - youtube.item(id)['published'] video['monthlyViews'] = video['views'] * 365.0 / days_available / 12 end return JSON.dump(@video_list.map{|v| v.to_h}) end
若要將計算邏輯移至新的影片物件,我首先需要將其轉換為適合移動的正確形狀 - 我可以透過將所有內容分派到影片服務中的一個單一方法(其中包含影片網域物件和 YouTube 閘道項目作為引數)來執行此動作。第一個步驟是在閘道項目上使用
class VideoService…
def video_list @video_list = JSON.parse(File.read('videos.json')).map{|h| Video.new(h)} ids = @video_list.map{|v| v['youtubeID']} youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids)) ids.each do |id| video = @video_list.find{|v| id == v['youtubeID']} youtube_item = youtube.item(id) video['views'] = youtube_item['views'] days_available = Date.today - youtube_item['published'] video['monthlyViews'] = video['views'] * 365.0 / days_available / 12 end return JSON.dump(@video_list.map{|v| v.to_h}) end
完成後,我可以輕鬆地將計算邏輯萃取到其自己的方法中。
class VideoService…
def video_list @video_list = JSON.parse(File.read('videos.json')).map{|h| Video.new(h)} ids = @video_list.map{|v| v['youtubeID']} youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids)) ids.each do |id| video = @video_list.find{|v| id == v['youtubeID']} youtube_item = youtube.item(id) enrich_video video, youtube_item end return JSON.dump(@video_list.map{|v| v.to_h}) end def enrich_video video, youtube_item video['views'] = youtube_item['views'] days_available = Date.today - youtube_item['published'] video['monthlyViews'] = video['views'] * 365.0 / days_available / 12 end
然後,很容易套用Move Method將其移至影片中。
class VideoService…
def video_list
@video_list = JSON.parse(File.read('videos.json')).map{|h| Video.new(h)}
ids = @video_list.map{|v| v['youtubeID']}
youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids))
ids.each do |id|
video = @video_list.find{|v| id == v['youtubeID']}
youtube_item = youtube.item(id)
video.enrich_with_youtube youtube_item
end
return JSON.dump(@video_list.map{|v| v.to_h})
end
類別影片…
def enrich_with_youtube youtube_item @data['views'] = youtube_item['views'] days_available = Date.today - youtube_item['published'] @data['monthlyViews'] = @data['views'] * 365.0 / days_available / 12 end
完成後,我可以移除影片雜湊的更新。
類別影片…
def []= key, value @data[key] = value end
現在我有適當的物件,我可以簡化服務方法中使用識別碼的編排。我從 youtube_item
上使用 內嵌暫存 開始,然後用影片物件上的方法呼叫取代對列舉索引的參照。
class VideoService…
def video_list @video_list = JSON.parse(File.read('videos.json')).map{|h| Video.new(h)} ids = @video_list.map{|v| v['youtubeID']} youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids)) ids.each do |id| video = @video_list.find{|v| id == v['youtubeID']} youtube_item = youtube.item(id) video.enrich_with_youtube(youtube.item(video.youtube_id)) end return JSON.dump(@video_list.map{|v| v.to_h}) end
類別影片…
def youtube_id
@data['youtubeID']
end
這允許我直接使用物件進行列舉。
class VideoService…
def video_list
@video_list = JSON.parse(File.read('videos.json')).map{|h| Video.new(h)}
ids = @video_list.map{|v| v['youtubeID']}
youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids))
@video_list.each {|v| v.enrich_with_youtube(youtube.item(v.youtube_id))}
return JSON.dump(@video_list.map{|v| v.to_h})
end
並移除影片中雜湊的存取器
類別影片…
def [] key @data[key] end
class VideoService…
def video_list
@video_list = JSON.parse(File.read('videos.json')).map{|h| Video.new(h)}
ids = @video_list.map{|v| v.youtube_id}
youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids))
@video_list.each {|v| v.enrich_with_youtube(youtube.item(v.youtube_id))}
return JSON.dump(@video_list.map{|v| v.to_h})
end
我可以將影片物件的內部雜湊替換為欄位,但我認為不值得,因為它主要是以雜湊載入,而其最終輸出是 JSON 化雜湊。內嵌文件 是網域物件的合理形式。
對最終物件的思考

圖 5:透過此重構建立的物件及其相依性
class VideoService...
def video_list @video_list = JSON.parse(File.read('videos.json')).map{|h| Video.new(h)} ids = @video_list.map{|v| v.youtube_id} youtube = YoutubeGateway.new(YoutubeConnection.new.list_videos(ids)) @video_list.each {|v| v.enrich_with_youtube(youtube.item(v.youtube_id))} return JSON.dump(@video_list.map{|v| v.to_h}) end
class YoutubeConnection
def list_videos ids
client = GoogleAuthorizer.new(
token_key: 'api-youtube',
application_name: 'Gateway Youtube Example',
application_version: '0.1'
).api_client
youtube = client.discovered_api('youtube', 'v3')
request = {
api_method: youtube.videos.list,
parameters: {
id: ids.join(","),
part: 'snippet, contentDetails, statistics',
}
}
return client.execute!(request).body
end
end
class YoutubeGateway
def initialize responseJson
@data = JSON.parse(responseJson)['items']
.map{|i| [ i['id'], i ] }
.to_h
end
def item id
{
'views' => @data[id]['statistics']['viewCount'].to_i,
'published' => Date.parse(@data[id]['snippet']['publishedAt'])
}
end
end
class Video
def initialize aHash
@data = aHash
end
def to_h
@data
end
def youtube_id
@data['youtubeID']
end
def enrich_with_youtube youtube_item
@data['views'] = youtube_item['views']
days_available = Date.today - youtube_item['published']
@data['monthlyViews'] = @data['views'] * 365.0 / days_available / 12
end
end
所以我達到了什麼?重構通常會減少程式碼大小,但在此情況下,它幾乎從 26 行增加到 54 行。其他條件相同,程式碼越少越好。但這裡我認為透過區分關注點而獲得的更好模組化通常值得增加大小。這也是教學(即玩具)範例的大小可能會模糊重點的地方。26 行程式碼並不容易理解,但如果我們討論的是以這種風格撰寫的 2600 行,那麼模組化就非常值得增加任何程式碼大小。而且通常當您對較大的程式碼庫執行此類事情時,任何此類增加都會小得多,因為您可以發現更多機會透過消除重複來減少程式碼大小。
您會注意到我已完成四種類型的物件:協調器、網域物件、閘道器和連線。這是責任的常見安排,儘管不同的案例會看到相依性配置的合理變化。責任和相依性的最佳安排因特定需求而異。需要頻繁變更的程式碼應與很少變更的程式碼分開,或僅因不同原因而變更。廣泛重複使用的程式碼不應依賴僅用於特定案例的程式碼。這些驅動因素因情況而異,並決定相依性模式。
一個常見的變更是反轉網域物件和閘道器之間的相依性,將閘道器變成 對應器。這允許網域物件獨立於其填充方式,但代價是對應器瞭解網域物件並取得其內部資料的存取權。如果網域物件用於許多情境,那麼這可能是一個有價值的安排。
另一個變更可能是將呼叫連線的程式碼從協調器轉移到閘道器。這簡化了協調器,但使閘道器變得更複雜。這是否是一個好主意取決於協調器是否變得太複雜,或者許多協調器使用相同的閘道器導致在設定連線時重複程式碼。
我也認為我可能會將連線的一些行為移到呼叫端,特別是如果呼叫端是閘道器物件。閘道器知道它需要什麼資料,因此應在呼叫的參數中提供部分清單。但這真的只是一個問題,一旦我們有其他客戶端呼叫 list_videos
,所以我會傾向於等到那天。
無論您案例的詳細資料為何,我認為很重要的一件事是,對所涉及物件的角色制定一致的命名政策。我偶爾會聽到有人說您不應該將模式名稱放入程式碼中,但我不同意。模式名稱通常有助於傳達不同元素所扮演的角色,因此拒絕這個機會是很愚蠢的。在團隊內,您的程式碼一定會顯示常見的模式,而命名應該反映這一點。我在 P of EAA 中創造了 Gateway 模式後,使用「gateway」。我在這裡使用「connection」來顯示與外部系統的原始連結,並打算在我的未來寫作中使用該慣例。這個命名慣例並非通用,雖然如果您使用我的命名慣例,我的自尊心會得到極大的滿足,但重點不在於您應該使用哪個命名慣例,而是您應該選擇某個慣例。
當我將方法分解成像這樣的物件群組時,自然會對測試的後果產生疑問。我在影片服務中對原始方法進行了單元測試,現在我是否應該為三個新類別撰寫測試?我的傾向是,只要現有測試充分涵蓋行為,就無需立即新增更多測試。當我們新增更多行為時,我們應該新增更多測試,如果這個行為新增到新物件中,那麼新的測試將專注於它們。隨著時間的推移,這可能意味著目前針對影片服務的一些測試看起來不恰當,應該移動。但所有這些都是在未來,應該在未來處理。
在測試中我特別關注的是使用我在 YouTube 連線上放置的存根。像這樣的存根很容易失控,然後它們實際上會減慢變更速度,因為簡單的生產程式碼變更會導致更新許多測試。這裡的重點是注意測試程式碼中的重複,並像處理生產程式碼中的重複一樣認真地處理它。
這種關於組織測試替身的思考自然而然地引導出組裝服務物件的更廣泛問題。現在我已將單一服務物件中的行為分割成三個服務物件和一個網域實體(使用Evans 分類),自然會產生一個問題,關於服務物件應該如何實例化、設定和組裝。目前影片服務直接為其依賴項執行此操作,但這對於較大的系統來說很容易失控。為了處理這種複雜性,通常會使用服務定位器和依賴性注入等技術。我現在不會討論這個,但這可能是後續文章的主題。
這個範例使用物件,很大一部分原因是我對物件導向風格比函式風格更熟悉。但我預期責任的基本劃分會相同,但界線是由函式(或可能是命名空間)設定,而不是類別和方法。其他一些細節會改變,影片物件會是一個資料結構,而豐富它會建立新的資料結構,而不是就地修改。以函式風格來看這一點會是一篇有趣的文章。
重構是一種透過一系列微小的行為保留轉換來變更程式碼的特定方式。它不只是移動程式碼。
最後,我想重新強調關於重構的一個重要一般觀點。重構不是你應該用於任何程式碼庫的重組術語。它特別表示套用一系列非常小的行為保留變更的方法。我們在這裡看到幾個範例,我故意引入我知道不久後會移除的程式碼,只是為了採取保留行為的小步驟。
這裡的重點在於透過採取小步驟,你最終會走得更快,因為你不會破壞任何東西,從而避免除錯。大多數人發現這違反直覺,當 Kent Beck 第一次向我展示他是如何重構時,我肯定有這種感覺。但我很快發現它的效果有多好。
重大修訂
2015 年 2 月 17 日:首次發布