เมื่อผมเขียน code ที่ต้องติดต่อกับบริการภายนอก (External Services) ผมพบว่ามันมีค่ามากหากเราแยก code ส่วนที่เข้าถึงบริการเหล่านั้นออกไปอยู่ใน object ต่างหาก ในบทความนี้ผมจะแสดงวิธีทำ Refactoringcode ที่ปนกันยุ่งเหยิงให้กลายเป็นรูปแบบที่แยกส่วนกันอย่างชัดเจน

17 กุมภาพันธ์ 2015

Martin Fowler


ลักษณะประการหนึ่งของระบบ software คือ พวกมันไม่ได้อยู่เพียงลำพัง เพื่อที่จะทำสิ่งที่พึงประสงค์ได้ ปกติแล้วพวกมันต้องคุยกับ software ส่วนอื่นๆ ที่เขียนโดยคนละคน คนที่เราไม่รู้จัก และคนที่ไม่รู้จักหรือไม่ได้ใส่ใจ software ที่เรากำลังเขียนอยู่เลย

เมื่อเราเขียน software ที่ต้องมีการร่วมมือกับภายนอกแบบนี้ ผมคิดว่าเป็นประโยชน์อย่างยิ่งที่จะนำหลักการของการสร้าง module ที่ดี (Modularity) และการห่อหุ้ม (Encapsulation) มาใช้ มีรูปแบบการทำงาน (Patterns) ทั่วไปที่ผมเห็นและพบว่ามีคุณค่าในการทำเช่นนี้

ในบทความนี้ ผมจะยกตัวอย่างง่ายๆ และพาทุกคนทำ Refactoring เพื่อแนะนำการสร้าง module ในแบบที่ผมต้องการ

22025c0a-e290-4931-93f6-12b8eca9ca52

𝐓𝐇𝐄 𝐒𝐓𝐀𝐑𝐓𝐈𝐍𝐆 𝐂𝐎𝐃𝐄 (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) และการติดต่อกับผู้ให้บริการข้อมูลภายนอก ผมชอบที่จะเพิ่มระดับการแยกส่วนให้ไปอยู่ในคลาสที่แยกจากกัน

22025c0a-e290-4931-93f6-12b8eca9ca52

  • รูปที่ 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 เดียว ซึ่งสะดวกมากหากเราต้องเรียกใช้บริการเดิมซ้ำหลายครั้งระหว่างการทดสอบ

b13c4fbf-9044-43ad-872c-ff56b44c0b46

  • รูปที่ 2: ขั้นตอนสำคัญแรกคือการแยก code เชื่อมต่อ YouTube ไปไว้ใน connection object*

𝐒𝐄𝐏𝐀𝐑𝐀𝐓𝐈𝐍𝐆 𝐓𝐇𝐄 𝐘𝐎𝐔𝐓𝐔𝐁𝐄 𝐃𝐀𝐓𝐀 𝐒𝐓𝐑𝐔𝐂𝐓𝐔𝐑𝐄 𝐈𝐍𝐓𝐎 𝐀 𝐆𝐀𝐓𝐄𝐖𝐀𝐘 (การแยกโครงสร้างข้อมูล YouTube ไปไว้ใน Gateway)

เมื่อผมแยกการเชื่อมต่อพื้นฐานกับ YouTube ออกไปแล้ว ผมสามารถจัดการกับ code ที่ต้องเจาะลึกเข้าไปในโครงสร้างข้อมูลของ YouTube ได้ ปัญหาก็คือ code จำนวนมากจำเป็นต้องรู้ว่าการจะได้จำนวนยอดวิวต้องไปดูที่ส่วน statistics แต่การจะเอาวันที่เผยแพร่ต้องไปดูที่ส่วน snippet ซึ่งโครงสร้างเหล่านี้จัดระเบียบตามสิ่งที่ YouTube ต้องการ ไม่ใช่สิ่งที่ผมต้องการ

ผมพบว่าแนวคิดเรื่อง Bounded Context ของ Eric Evans เป็นวิธีที่ดีในการคิดเรื่องนี้ YouTube จัดระเบียบข้อมูลตามบริบทของพวกเขา ในขณะที่ผมต้องการจัดระเบียบตามบริบทของผม ผมจำเป็นต้องแยกพวกเขาออกจากกันด้วยสิ่งที่ Eric เรียกว่า Anti-Corruption Layer (ชั้นป้องกันการเน่าเสีย)

2cbc1e43-1f5a-4c50-abeb-4fae651652d1

ในตัวอย่างนี้ นั่นหมายถึง 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

6b184822-e846-46bf-9df4-a40fb2b1ad7b

  • รูปที่ 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 สุดท้าย)

ec94480a-9ef3-47f5-bab2-f11c45b4fd28

  • รูปที่ 5:object ที่ถูกสร้างขึ้นผ่านการ Refactoring นี้และความสัมพันธ์ของพวกมัน*

แล้วผมบรรลุอะไรบ้าง? การทำ Refactoring มักจะลดขนาดของ code แต่ในกรณีนี้ code กลับเพิ่มขึ้นเกือบเท่าตัวจาก 26 เป็น 54 บรรทัด หากปัจจัยอื่นเท่ากัน code ที่น้อยกว่าย่อมดีกว่า แต่ในที่นี้ผมคิดว่าการสร้าง module ที่ดีขึ้นซึ่งได้รับจากการแยกความรับผิดชอบนั้นคุ้มค่ากับขนาดที่เพิ่มขึ้น

คุณจะสังเกตเห็นว่าผมลงเอยด้วย object สี่ประเภท: Coordinator (ผู้ประสานงาน), Domain Object (object ธุรกิจ), Gateway, และ Connection (การเชื่อมต่อ) นี่คือการจัดวางความรับผิดชอบที่พบบ่อย แม้ว่าในกรณีที่ต่างกันอาจมีการเปลี่ยนแปลงในโครงสร้างความสัมพันธ์บ้าง

หัวใจสำคัญของการทำ Refactoring คือการเปลี่ยนแปลง code ผ่านชุดของการแปลงเล็กๆ ที่ยังคงรักษาพฤติกรรมเดิมไว้ ไม่ใช่แค่การย้าย code ไปมา การก้าวทีละขั้นช่วยให้คุณไปได้เร็วขึ้นเพราะคุณจะไม่ทำอะไรพัง และไม่ต้องเสียเวลาไปกับการไล่หาบั๊ก (Debugging)

References