เมื่อผมเขียน code ที่ต้องติดต่อกับบริการภายนอก (External Services) ผมพบว่ามันมีค่ามากหากเราแยก code ส่วนที่เข้าถึงบริการเหล่านั้นออกไปอยู่ใน object ต่างหาก ในบทความนี้ผมจะแสดงวิธีทำ Refactoringcode ที่ปนกันยุ่งเหยิงให้กลายเป็นรูปแบบที่แยกส่วนกันอย่างชัดเจน
17 กุมภาพันธ์ 2015
Martin Fowler
ลักษณะประการหนึ่งของระบบ software คือ พวกมันไม่ได้อยู่เพียงลำพัง เพื่อที่จะทำสิ่งที่พึงประสงค์ได้ ปกติแล้วพวกมันต้องคุยกับ software ส่วนอื่นๆ ที่เขียนโดยคนละคน คนที่เราไม่รู้จัก และคนที่ไม่รู้จักหรือไม่ได้ใส่ใจ software ที่เรากำลังเขียนอยู่เลย
เมื่อเราเขียน software ที่ต้องมีการร่วมมือกับภายนอกแบบนี้ ผมคิดว่าเป็นประโยชน์อย่างยิ่งที่จะนำหลักการของการสร้าง module ที่ดี (Modularity) และการห่อหุ้ม (Encapsulation) มาใช้ มีรูปแบบการทำงาน (Patterns) ทั่วไปที่ผมเห็นและพบว่ามีคุณค่าในการทำเช่นนี้
ในบทความนี้ ผมจะยกตัวอย่างง่ายๆ และพาทุกคนทำ Refactoring เพื่อแนะนำการสร้าง module ในแบบที่ผมต้องการ

𝐓𝐇𝐄 𝐒𝐓𝐀𝐑𝐓𝐈𝐍𝐆 𝐂𝐎𝐃𝐄 (code เริ่มต้น)
หน้าที่ของ code ตัวอย่างคือการอ่านข้อมูลเกี่ยวกับวิดีโอจากไฟล์ JSON จากนั้นนำไปเพิ่มข้อมูลจาก YouTube เข้าไป คำนวณข้อมูลเพิ่มเติมบางอย่าง และสุดท้ายส่งกลับข้อมูลออกมาในรูปแบบ JSON
นี่คือ code เริ่มต้น:
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
ปัญหาก็คือทุกอย่างรวมอยู่ใน function เดียว ทั้งการอ่านไฟล์ การติดต่อบริการภายนอก และตรรกะในการประมวลผลข้อมูล
𝐏𝐔𝐓𝐓𝐈𝐍𝐆 𝐓𝐇𝐄 𝐂𝐎𝐃𝐄 𝐔𝐍𝐃𝐄𝐑 𝐓𝐄𝐒𝐓 (การนำ code เข้าสู่การทดสอบ)
ขั้นตอนแรกของการทำ Refactoring คือการสร้างความมั่นใจว่าเราจะไม่ทำอะไรพังโดยไม่ตั้งใจ การทำ Refactoring คือการต่อชุดของการเปลี่ยนแปลงเล็กๆ หลายๆ ขั้นเข้าด้วยกัน โดยที่แต่ละขั้นยังคงรักษาพฤติกรรมเดิมไว้ การรักษาขั้นตอนให้เล็กช่วยเพิ่มโอกาสที่เราจะไม่ทำพลาด แต่ผมรู้ตัวเองดีว่าผมสามารถทำพลาดได้แม้แต่ในการเปลี่ยนแปลงที่ง่ายที่สุด ดังนั้นผมจึงต้องมี tests เพื่อดักจับข้อผิดพลาด
แต่ code แบบนี้ไม่ง่ายที่จะทดสอบ มันคงจะดีถ้าเราสามารถเขียน test เพื่อตรวจสอบค่า monthly views ที่คำนวณออกมา เพราะถ้ามีอะไรผิดพลาด ค่านี้ก็จะผิดตามไปด้วย แต่ปัญหาก็คือผมกำลังเข้าถึงข้อมูลสดๆ จาก YouTube และผู้คนก็ดูวิดีโออยู่ตลอดเวลา ยอดวิวจาก YouTube จะเปลี่ยนไปเรื่อยๆ ทำให้การทดสอบของผมพังแบบไม่แน่นอน (non-deterministic)
ดังนั้นงานแรกของผมคือการกำจัดความไม่แน่นอนนั้นทิ้งไป ผมทำได้โดยการนำ Test Double มาใช้ ซึ่งเป็น object ที่ดูเหมือน YouTube แต่ตอบสนองในรูปแบบที่กำหนดค่าไว้ล่วงหน้าได้ แต่น่าเสียดายที่ตรงนี้ผมต้องเจอกับ "วิกฤตการณ์ code เก่า" (The Legacy Code Dilemma)
วิกฤตการณ์
codeเก่า: เมื่อเราเปลี่ยนcodeเราควรมีการทดสอบรองรับอยู่แล้ว แต่การจะสร้างการทดสอบขึ้นมาได้ เรามักจะต้องเปลี่ยนcodeก่อน -- Michael Feathers
ในเมื่อผมต้องทำการเปลี่ยนแปลงนี้โดยไม่มี test รองรับ ผมจึงต้องทำการเปลี่ยนแปลงที่เล็กและง่ายที่สุดเท่าที่คิดออก เพื่อนำการติดต่อกับ YouTube ไปไว้หลังรอยต่อ (seam) ที่ผมสามารถใส่ test double เข้าไปได้ ขั้นแรกคือการใช้ Extract Method เพื่อแยกส่วนการติดต่อกับ 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
การทำเช่นนี้บรรลุผลสองอย่าง อย่างแรกคือมันดึง code ที่จัดการกับ Google API ออกมาเป็นเมธอดแยกต่างหาก ซึ่งเป็นการแยกส่วน code ที่ดีในตัวมันเอง อย่างที่สองซึ่งเร่งด่วนกว่า คือมันสร้าง "รอยต่อ" ที่ผมสามารถใช้เพื่อแทนที่พฤติกรรมสำหรับการทดสอบได้ library minitest ของ Ruby ช่วยให้ผมสามารถ stub เมธอดแยกรายตัวใน object ได้ง่ายๆ
class VideoServiceTester < Minitest::Test
def setup
vs = VideoService.new
vs.stub(:call_youtube, stub_call_youtube) do
@videos = JSON.parse(vs.video_list)
@microservices = @videos.detect{|v| 'wgdBVIX9ifA' == v['youtubeID']}
end
end
def stub_call_youtube
JSON.parse(File.read('test/data/youtube-video-list.json'))
end
def test_monthly_views
assert_in_delta 5880, @microservices['monthlyViews'], 1
end
end
ด้วยการแยกเมธอดและ stub มัน ผมทำให้การทดสอบนี้ทำงานได้อย่างแน่นอน (deterministic) แต่อย่างน้อยก็สำหรับวันนี้ เพื่อให้มันทำงานได้ในวันพรุ่งนี้ด้วย ผมต้องทำแบบเดียวกันกับการเรียก Date.today
𝐒𝐄𝐏𝐀𝐑𝐀𝐓𝐈𝐍𝐆 𝐓𝐇𝐄 𝐑𝐄𝐌𝐎𝐓𝐄 𝐂𝐀𝐋𝐋 𝐈𝐍𝐓𝐎 𝐀 𝐂𝐎𝐍𝐍𝐄𝐂𝐓𝐈𝐎𝐍 𝐎𝐁𝐉𝐄𝐂𝐓 (การแยกการเรียกใช้ระยะไกลไปยัง Connection Object)
การแยกความรับผิดชอบด้วยการใส่ code ไว้ใน function ที่ต่างกันถือเป็นระดับแรกของการแยกส่วน แต่เมื่อความรับผิดชอบมีความแตกต่างกันมาก เช่น ระหว่างตรรกะทางธุรกิจ (Domain Logic) และการติดต่อกับผู้ให้บริการข้อมูลภายนอก ผมชอบที่จะเพิ่มระดับการแยกส่วนให้ไปอยู่ในคลาสที่แยกจากกัน

- รูปที่ 1: ในตอนเริ่มต้น คลาส Video Service มีหน้าที่รับผิดชอบถึงสี่อย่าง*
ขั้นตอนแรกของผมคือการสร้างคลาสใหม่และใช้ Move Method
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 ไว้ในการสร้าง object เดียว ซึ่งสะดวกมากหากเราต้องเรียกใช้บริการเดิมซ้ำหลายครั้งระหว่างการทดสอบ

- รูปที่ 2: ขั้นตอนสำคัญแรกคือการแยก
codeเชื่อมต่อ YouTube ไปไว้ในconnection object*
𝐒𝐄𝐏𝐀𝐑𝐀𝐓𝐈𝐍𝐆 𝐓𝐇𝐄 𝐘𝐎𝐔𝐓𝐔𝐁𝐄 𝐃𝐀𝐓𝐀 𝐒𝐓𝐑𝐔𝐂𝐓𝐔𝐑𝐄 𝐈𝐍𝐓𝐎 𝐀 𝐆𝐀𝐓𝐄𝐖𝐀𝐘 (การแยกโครงสร้างข้อมูล YouTube ไปไว้ใน Gateway)
เมื่อผมแยกการเชื่อมต่อพื้นฐานกับ YouTube ออกไปแล้ว ผมสามารถจัดการกับ code ที่ต้องเจาะลึกเข้าไปในโครงสร้างข้อมูลของ YouTube ได้ ปัญหาก็คือ code จำนวนมากจำเป็นต้องรู้ว่าการจะได้จำนวนยอดวิวต้องไปดูที่ส่วน statistics แต่การจะเอาวันที่เผยแพร่ต้องไปดูที่ส่วน snippet ซึ่งโครงสร้างเหล่านี้จัดระเบียบตามสิ่งที่ YouTube ต้องการ ไม่ใช่สิ่งที่ผมต้องการ
ผมพบว่าแนวคิดเรื่อง Bounded Context ของ Eric Evans เป็นวิธีที่ดีในการคิดเรื่องนี้ YouTube จัดระเบียบข้อมูลตามบริบทของพวกเขา ในขณะที่ผมต้องการจัดระเบียบตามบริบทของผม ผมจำเป็นต้องแยกพวกเขาออกจากกันด้วยสิ่งที่ Eric เรียกว่า Anti-Corruption Layer (ชั้นป้องกันการเน่าเสีย)

ในตัวอย่างนี้ นั่นหมายถึง objectGateway ที่สามารถให้วันที่เผยแพร่และจำนวนยอดวิวแก่ผมได้ โดยที่ฝั่งเรียกใช้ไม่จำเป็นต้องรู้ว่าข้อมูลนั้นเก็บไว้อย่างไรใน YouTube object```Gateway ทำหน้าที่แปลจากบริบทของ YouTube มาเป็นบริบทของผม
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

- รูปที่ 4: การแยกการจัดการข้อมูลไปไว้ใน
gateway object*
ด้วยการทำเช่นนี้ ผมได้ทำการแยกส่วนที่สำคัญสำเร็จแล้ว object การเชื่อมต่อ YouTube จัดการเรื่องการเรียกใช้ระยะไกล และส่งโครงสร้างข้อมูลให้ object Gateway จัดการต่อ code ของ Video Service ในตอนนี้จึงเหลือเพียงแค่ "ผมอยากเห็นข้อมูลอย่างไร" แทนที่จะเป็น "ข้อมูลถูกเก็บไว้อย่างไรในบริการต่างๆ"
𝐒𝐄𝐏𝐀𝐑𝐀𝐓𝐈𝐍𝐆 𝐓𝐇𝐄 𝐃𝐎𝐌𝐀𝐈𝐍 𝐋𝐎𝐆𝐈𝐂 𝐈𝐍𝐓𝐎 𝐀 𝐃𝐎𝐌𝐀𝐈𝐍 𝐎𝐁𝐉𝐄𝐂𝐓 (การแยกตรรกะทางธุรกิจไปยัง Domain Object)
แม้ว่าการโต้ตอบกับ YouTube ทั้งหมดจะถูกแบ่งออกเป็น object แยกต่างหากแล้ว แต่ Video Service ยังคงผสมผสานตรรกะทางธุรกิจ (วิธีคำนวณยอดวิวรายเดือน) เข้ากับการจัดลำดับความสัมพันธ์ระหว่างข้อมูลที่เก็บไว้ในเครื่องและข้อมูลในบริการ หากผมนำ Domain Object สำหรับวิดีโอมาใช้ ผมจะสามารถแยกส่วนนั้นออกมาได้
ขั้นตอนแรกของผมคือการห่อหุ้ม Hash ของข้อมูลวิดีโอไว้ใน object
class Video...
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
และใน 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
𝐌𝐮𝐬𝐢𝐧𝐠𝐬 𝐨𝐧 𝐭𝐡𝐞 𝐟i𝐧𝐚𝐥 𝐨𝐛𝐣𝐞𝐜𝐭𝐬 (บทสรุปเกี่ยวกับ object สุดท้าย)

- รูปที่ 5:
objectที่ถูกสร้างขึ้นผ่านการRefactoringนี้และความสัมพันธ์ของพวกมัน*
แล้วผมบรรลุอะไรบ้าง? การทำ Refactoring มักจะลดขนาดของ code แต่ในกรณีนี้ code กลับเพิ่มขึ้นเกือบเท่าตัวจาก 26 เป็น 54 บรรทัด หากปัจจัยอื่นเท่ากัน code ที่น้อยกว่าย่อมดีกว่า แต่ในที่นี้ผมคิดว่าการสร้าง module ที่ดีขึ้นซึ่งได้รับจากการแยกความรับผิดชอบนั้นคุ้มค่ากับขนาดที่เพิ่มขึ้น
คุณจะสังเกตเห็นว่าผมลงเอยด้วย object สี่ประเภท: Coordinator (ผู้ประสานงาน), Domain Object (object ธุรกิจ), Gateway, และ Connection (การเชื่อมต่อ) นี่คือการจัดวางความรับผิดชอบที่พบบ่อย แม้ว่าในกรณีที่ต่างกันอาจมีการเปลี่ยนแปลงในโครงสร้างความสัมพันธ์บ้าง
หัวใจสำคัญของการทำ Refactoring คือการเปลี่ยนแปลง code ผ่านชุดของการแปลงเล็กๆ ที่ยังคงรักษาพฤติกรรมเดิมไว้ ไม่ใช่แค่การย้าย code ไปมา การก้าวทีละขั้นช่วยให้คุณไปได้เร็วขึ้นเพราะคุณจะไม่ทำอะไรพัง และไม่ต้องเสียเวลาไปกับการไล่หาบั๊ก (Debugging)