สร้าง ซื้อ หรือยืมมา มันคือทางเลือกที่เราทุกคนต้องเจอเวลาพัฒนาซอฟต์แวร์ หลายครั้งที่เราทำงานบนแอปพลิเคชัน เรามักคิดว่าเราจะประหยัดเวลาและความพยายามได้ด้วยการซื้อ vendor library บางตัว, ใช้ open source บางตัว, หรือแม้แต่ใช้โค้ดก้อนใหญ่ๆ จาก library ที่มาพร้อมกับ platform ที่เราใช้ (J2EE, .NET และอื่นๆ) มีหลายสิ่งที่ต้องพิจารณาเมื่อเลือกที่จะรวมโค้ดที่เราเปลี่ยนแปลงไม่ได้ เราต้องรู้ว่ามันเสถียรแค่ไหน, เพียงพอหรือไม่, และใช้งานง่ายแค่ไหน และเมื่อเราตัดสินใจใช้โค้ดของคนอื่นในที่สุด เราก็มักจะเจอปัญหาอีกอย่าง เราลงเอยด้วยแอปพลิเคชันที่ดูเหมือนไม่มีอะไรเลยนอกจากการเรียก library ของคนอื่นซ้ำไปซ้ำมา แล้วเราจะเปลี่ยนแปลงโค้ดแบบนั้นได้ยังไง?
สิ่งล่อใจแรกคือการบอกว่าเราไม่จำเป็นต้องมี Test จริงๆ เพราะยังไงเราก็ไม่ได้ทำอะไรสำคัญอะไรนัก แค่เรียก method นู่นนี่นั่น และโค้ดของเราก็เรียบง่ายมากๆ ง่ายมากๆ อะไรจะผิดพลาดได้ล่ะ?
โปรเจกต์ legacy หลายโปรเจกต์เริ่มต้นจากจุดเล็กๆ แบบนั้น โค้ดเพิ่มขึ้นเรื่อยๆ และทุกอย่างก็ไม่ได้เรียบง่ายอย่างที่คิดอีกต่อไป เมื่อเวลาผ่านไป เราอาจยังเห็นส่วนของโค้ดที่ไม่ได้แตะ API อยู่บ้าง แต่มันถูกฝังอยู่ใน patchwork ของโค้ดที่ทดสอบไม่ได้ เราต้องรันแอปพลิเคชันทุกครั้งที่เปลี่ยนอะไรบางอย่างเพื่อให้แน่ใจว่ามันยังทำงานได้ และเราก็กลับมาติดกับดักเดิมของนักพัฒนา legacy system นั่นคือการเปลี่ยนแปลงเต็มไปด้วยความไม่แน่นอน เราไม่ได้เขียนโค้ดทั้งหมด แต่เราต้องดูแลรักษามัน
ระบบที่เต็มไปด้วยการเรียก library นั้นจัดการยากกว่าระบบที่สร้างขึ้นเองในหลายๆ ด้าน เหตุผลแรกคือมักจะมองเห็นได้ยากว่าจะปรับปรุงโครงสร้างให้ดีขึ้นได้ยังไง เพราะสิ่งที่เห็นมีแต่การเรียก API เท่านั้น อะไรก็ตามที่ควรจะเป็น hint เกี่ยวกับ design นั้นไม่มีเลย เหตุผลที่สองที่ระบบที่ใช้ API หนักๆ นั้นยากคือเราไม่ได้เป็นเจ้าของ API ถ้าเราเป็นเจ้าของ เราสามารถ rename interface, class และ method เพื่อให้มันชัดเจนขึ้นสำหรับเรา หรือเพิ่ม method ให้ class เพื่อให้ส่วนอื่นๆ ของโค้ดสามารถเข้าถึงได้
นี่คือตัวอย่าง นี่คือรายการโค้ดที่เขียนได้แย่มากสำหรับ mailing list server เราไม่แน่ใจด้วยซ้ำว่ามันใช้งานได้
import java.io.IOException;
import java.util.Properties;
import javax.mail.*;
import javax.mail.internet.*;
public class MailingListServer
{
public static final String SUBJECT_MARKER = "[list]";
public static final String LOOP_HEADER = "X-Loop";
public static void main (String [] args) {
if (args.length != 8) {
System.err.println ("Usage: java MailingList <popHost> " +
"<smtpHost> <pop3user> <pop3password> "
+
"<smtpuser> <smtppassword> <listname> "
+
"<relayinterval>");
return;
}
HostInformation host = new HostInformation (
args [0], args [1], args [2], args [3],
args [4], args [5]);
String listAddress = args[6];
int interval = new Integer (args [7]).intValue ();
Roster roster = null;
try {
roster = new FileRoster("roster.txt");
} catch (Exception e) {
System.err.println ("unable to open roster.txt");
return;
}
try {
do {
try {
Properties properties = System.getProperties ();
Session session = Session.getDefaultInstance (
properties, null);
Store store = session.getStore ("pop3");
store.connect (host.pop3Host, -1,
host.pop3User, host.pop3Password);
Folder defaultFolder = store.getDefaultFolder();
if (defaultFolder == null) {
System.err.println("Unable to open default
folder");
return;
}
Folder folder = defaultFolder.getFolder ("INBOX");
if (folder == null) {
System.err.println("Unable to get: "
+
defaultFolder);
return;
}
folder.open (Folder.READ_WRITE);
process(host, listAddress, roster, session,
store, folder);
} catch (Exception e) {
System.err.println(e);
System.err.println ("(retrying mail check)");
}
System.err.print (".");
try { Thread.sleep (interval * 1000); }
catch (InterruptedException e) {}
} while (true);
}
catch (Exception e) {
e.printStackTrace ();
}
}
private static void process(
HostInformation host, String listAddress, Roster roster,
Session session,Store store, Folder folder)
throws MessagingException {
try {
if (folder.getMessageCount() != 0) {
Message[] messages = folder.getMessages ();
doMessage(host, listAddress, roster, session,
folder, messages);
}
} catch (Exception e) {
System.err.println ("message handling error");
e.printStackTrace (System.err);
}
finally {
folder.close (true);
store.close ();
}
}
private static void doMessage(
HostInformation host,
String listAddress,
Roster roster,
Session session,
Folder folder,
Message[] messages) throws
MessagingException,
AddressException, IOException,
NoSuchProviderException {
FetchProfile fp = new FetchProfile ();
fp.add (FetchProfile.Item.ENVELOPE);
fp.add (FetchProfile.Item.FLAGS);
fp.add ("X-Mailer");
folder.fetch (messages, fp);
for (int i = 0; i < messages.length; i++) {
Message message = messages [i];
if (message.getFlags ().contains (Flags.Flag.DELETED))
continue;
System.out.println("message received: "
+ message.getSubject ());
if (!roster.containsOneOf (message.getFrom ()))
continue;
MimeMessage forward = new MimeMessage (session);
InternetAddress result = null;
Address [] fromAddress = message.getFrom ();
if (fromAddress != null && fromAddress.length > 0)
result =
new InternetAddress (fromAddress [0].toString
());
InternetAddress from = result;
forward.setFrom (from);
forward.setReplyTo (new Address [] {
new InternetAddress (listAddress) });
forward.addRecipients (Message.RecipientType.TO,
listAddress);
forward.addRecipients (Message.RecipientType.BCC,
roster.getAddresses ());
String subject = message.getSubject();
if (-1 == message.getSubject().indexOf (SUBJECT_MARKER))
subject = SUBJECT_MARKER + " " + message.getSubject();
forward.setSubject (subject);
forward.setSentDate (message.getSentDate ());
forward.addHeader (LOOP_HEADER, listAddress);
Object content = message.getContent ();
if (content instanceof Multipart)
forward.setContent ((Multipart)content);
else
forward.setText ((String)content);
Properties props = new Properties ();
props.put ("mail.smtp.host", host.smtpHost);
Session smtpSession =
Session.getDefaultInstance (props, null);
Transport transport = smtpSession.getTransport ("smtp");
transport.connect (host.smtpHost,
host.smtpUser, host.smtpPassword);
transport.sendMessage (forward, roster.getAddresses ());
message.setFlag (Flags.Flag.DELETED, true);
}
}
}
มันเป็นโค้ดชิ้นเล็กๆ แต่ก็ไม่ชัดเจนเท่าไหร่ มองยากว่าโค้ดบรรทัดไหนที่ไม่แตะ API โค้ดนี้จัดโครงสร้างให้ดีขึ้นได้ไหม? มันสามารถจัดโครงสร้างในแบบที่ทำให้การเปลี่ยนแปลงง่ายขึ้นได้ไหม?
ได้สิ
ขั้นแรกคือการระบุแกนหลักในการคำนวณของโค้ด: โค้ดส่วนนี้จริงๆ แล้วทำอะไรให้เราบ้าง?
การลองเขียนคำอธิบายสั้นๆ ว่ามันทำอะไรอาจช่วยได้:
โค้ดนี้อ่านข้อมูลการตั้งค่าจาก command line และรายการที่อยู่อีเมลจากไฟล์ มันตรวจสอบเมลเป็นระยะ เมื่อพบเมล มันจะส่งต่อเมลนั้นไปยังทุกที่อยู่อีเมลที่อยู่ในไฟล์
ดูเหมือนว่าโปรแกรมนี้หลักๆ แล้วเกี่ยวกับ input และ output แต่ก็มีอะไรเพิ่มขึ้นมาอีกนิดหน่อย เรากำลังรัน thread ในโค้ด มันหลับแล้วตื่นขึ้นมาเป็นระยะเพื่อตรวจสอบเมล นอกจากนี้ เราไม่ได้แค่ส่งข้อความเมลขาเข้าออกไปอีกครั้ง แต่เราสร้างข้อความใหม่จากข้อความขาเข้า เราต้องตั้งค่าฟิลด์ทั้งหมด จากนั้นตรวจสอบและปรับเปลี่ยน subject line เพื่อให้แสดงว่าข้อความมาจาก mailing list ดังนั้นเรากำลังทำงานที่มีสาระจริงๆ
ถ้าเราลองแยกความรับผิดชอบของโค้ดดู เราอาจได้อะไรประมาณนี้:
1. เราต้องการอะไรสักอย่างที่สามารถรับข้อความขาเข้าแต่ละฉบับและป้อนเข้าสู่ระบบของเรา
2. เราต้องการอะไรสักอย่างที่สามารถส่งข้อความเมลออกไป
3. เราต้องการอะไรสักอย่างที่สามารถสร้างข้อความใหม่สำหรับข้อความขาเข้าแต่ละฉบับ โดยอ้างอิงจาก roster ของรายชื่อผู้รับ
4. เราต้องการอะไรสักอย่างที่หลักอยู่เกือบตลอดเวลา แต่ตื่นขึ้นมาเป็นระยะเพื่อดูว่ามีเมลเพิ่มหรือไม่
ทีนี้เมื่อเรามองดูความรับผิดชอบเหล่านั้น ดูเหมือนว่าบางอย่างผูกติดกับ Java Mail API มากกว่าอย่างอื่นไหม? ความรับผิดชอบข้อ 1 และ 2 ผูกติดกับ mail API แน่นอน ข้อ 3 นั้นซับซ้อนกว่าเล็กน้อย class ข้อความที่เราต้องการเป็นส่วนหนึ่งของ mail API แต่เราอาจทดสอบความรับผิดชอบนี้แยกต่างหากได้โดยการสร้าง dummy incoming message ข้อ 4 นั้นไม่เกี่ยวข้องกับเมลเลย มันแค่ต้องการ thread ที่ถูกตั้งให้ตื่นขึ้นมาในช่วงเวลาที่กำหนด
Figure 15.1 แสดงดีไซน์เล็กๆ ที่แยกความรับผิดชอบเหล่านี้ออกจากกัน
Figure 15.1 Mailing list server ที่ดีกว่า
ListDriver ขับเคลื่อนระบบ มันมี thread ที่หลักอยู่เกือบตลอดเวลาและตื่นขึ้นมาเป็นระยะเพื่อตรวจสอบเมล ListDriver ตรวจสอบเมลโดยสั่งให้ MailReceiver ตรวจสอบเมล MailReceiver อ่านเมลและส่งข้อความทีละฉบับไปยัง MessageForwarder. MessageForwarder สร้างข้อความสำหรับผู้รับในรายชื่อแต่ละคนและส่งเมลโดยใช้ MailSender.
ดีไซน์นี้ค่อนข้างดี MessageProcessor และ MailService interface มีประโยชน์เพราะมันช่วยให้เราทดสอบ class ต่างๆ ได้อย่างอิสระ โดยเฉพาะอย่างยิ่ง มันเยี่ยมมากที่สามารถทำงานกับ MessageFowarder class ใน test harness โดยไม่ต้องส่งเมลจริงๆ ซึ่งทำได้ง่ายถ้าเราสร้าง FakeMailSender class ที่ implement MailService interface
เกือบทุกระบบมี core logic บางส่วนที่สามารถแยกออกจากการเรียก API ได้ แม้ว่ากรณีนี้จะเล็ก แต่จริงๆ แล้วมันแย่กว่ากรณีส่วนใหญ่ด้วยซ้ำ MessageForwarder คือส่วนของระบบที่มีความรับผิดชอบที่เป็นอิสระจากกลไกการส่งและรับเมลมากที่สุด แต่มันก็ยังใช้ class ข้อความของ JavaMail API อยู่ ดูเหมือนว่าจะไม่มีที่ว่างสำหรับ plain old Java classes มากนัก แต่ถึงอย่างนั้น การแยกส่วนระบบออกเป็นสี่ class และสอง interface ในแผนภาพก็ทำให้เรามี layering บ้าง ตรรกะหลักของ mailing list อยู่ใน MessageForwarder class และเราสามารถนำมันมาอยู่ภายใต้ test ได้แล้ว ในโค้ดเดิม มันถูกฝังและเข้าถึงได้ยาก มันแทบเป็นไปไม่ได้ที่จะแตกระบบออกเป็นชิ้นส่วนเล็กๆ โดยไม่ลงเอยด้วยบางชิ้นที่อยู่ใน "ระดับสูงกว่า" ชิ้นอื่น
เมื่อเรามีระบบที่ดูเหมือนไม่มีอะไรเลยนอกจากการเรียก API การจินตนาการว่ามันเป็น object ใหญ่ก้อนเดียวแล้วใช้หลักการแยกความรับผิดชอบใน Chapter 20 , This Class Is Too Big and I Don't Want It to Get Any Bigger ก็ช่วยได้ เราอาจไม่สามารถขยับไปสู่ดีไซน์ที่ดีกว่าได้ในทันที แต่แค่การระบุความรับผิดชอบก็ทำให้การตัดสินใจที่ดีขึ้นง่ายขึ้นเมื่อเราก้าวต่อไป
โอเค นั่นคือสิ่งที่ดีไซน์ที่ดีกว่าหน้าตาเป็นอย่างไร ดีที่รู้ว่ามันเป็นไปได้ แต่กลับมาที่ความเป็นจริง: เราจะก้าวต่อไปยังไง? มีสองแนวทางหลัก:
1. Skin and Wrap the API
2. Responsibility-Based Extraction
เมื่อเรา Skin and Wrap the API เราจะสร้าง interface ที่สะท้อน API ให้ใกล้เคียงที่สุด แล้วสร้าง wrapper รอบ class ของ library เพื่อลดโอกาสผิดพลาด เราสามารถ Preserve Signatures (312) ระหว่างทำงาน ข้อดีอย่างหนึ่งของการ skin และ wrap API คือเราสามารถไม่มีการ dependency ใดๆ กับโค้ด API ต้นทาง wrapper ของเราสามารถ delegate ไปยัง API จริงใน production code และเราใช้ fake ในระหว่าง test ได้
เราใช้เทคนิคนี้กับโค้ด mailing list ได้ไหม?
นี่คือโค้ดใน mailing list server ที่ส่งข้อความเมลจริงๆ:
...
Session smtpSession = Session.getDefaultInstance (props, null);
Transport transport = smtpSession.getTransport ("smtp");
transport.connect (host.smtpHost, host.smtpUser,
host.smtpPassword);
transport.sendMessage (forward, roster.getAddresses ());
...
ถ้าเราต้องการตัด dependency กับ Transport class เราสามารถสร้าง wrapper ให้มันได้ แต่ในโค้ดนี้ เราไม่ได้สร้าง Transport object ขึ้นมาเอง เราได้มันมาจาก Session class เราสร้าง wrapper สำหรับ Session? ไม่ได้จริงๆ— Session เป็น final class ใน Java final class ไม่สามารถ subclass ได้ (บ่น บ่น)
โค้ด mailing list นี้ไม่เหมาะกับการ skin จริงๆ API ค่อนข้างซับซ้อน แต่ถ้าเราไม่มี refactoring tools เลย มันก็อาจเป็นทางเลือกที่ปลอดภัยที่สุด
โชคดีที่มี refactoring tools สำหรับ Java ดังนั้นเราสามารถทำอย่างอื่นที่เรียกว่า Responsibility-Based Extraction ใน Responsibility-Based Extraction เราจะระบุความรับผิดชอบในโค้ดและเริ่ม extract method สำหรับความรับผิดชอบเหล่านั้น
ความรับผิดชอบใน snippet โค้ดก่อนหน้านี้คืออะไร? เป้าหมายโดยรวมของมันคือการส่งข้อความ มันต้องการอะไรเพื่อทำสิ่งนี้? มันต้องการ SMTP session และ transport ที่เชื่อมต่อแล้ว ในโค้ดต่อไปนี้ เราได้ extract ความรับผิดชอบในการส่งข้อความออกเป็น method ของตัวเองและเพิ่ม method นั้นลงใน class ใหม่: MailSender.
import javax.mail.*;
import javax.mail.internet.InternetAddress;
import java.util.Properties;
public class MailSender
{
private HostInformation host;
private Roster roster;
public MailSender (HostInformation host, Roster roster) {
this.host = host;
this.roster = roster;
}
public void sendMessage (Message message) throws Exception {
Transport transport
= getSMTPSession ().getTransport ("smtp");
transport.connect (host.smtpHost,
host.smtpUser, host.smtpPassword);
transport.sendMessage (message, roster.getAddresses ());
}
private Session getSMTPSession () {
Properties props = new Properties ();
props.put ("mail.smtp.host", host.smtpHost);
return Session.getDefaultInstance (props, null);
}
}
เราจะเลือกยังไงระหว่าง Skin and Wrap the API กับ Responsibility-Based Extraction ? นี่คือข้อดีข้อเสีย:
Skin and Wrap the API เหมาะกับสถานการณ์เหล่านี้:
• API ค่อนข้างเล็ก
• คุณต้องการแยก dependency จาก third-party library ออกไปอย่างสมบูรณ์
• คุณไม่มี tests และคุณไม่สามารถเขียนมันได้เพราะคุณไม่สามารถ test ผ่าน API ได้
เมื่อเรา skin และ wrap API เรามีโอกาสนำโค้ดทั้งหมดของเรามาอยู่ภายใต้ test ยกเว้น delegation layer บางๆ จาก wrapper ไปยัง class API จริง
Responsibility-Based Extraction เหมาะกับสถานการณ์เหล่านี้:
• API ซับซ้อนกว่า
• คุณมี tool ที่รองรับการ extract method อย่างปลอดภัย หรือคุณมั่นใจว่าสามารถ extract ด้วยมือได้อย่างปลอดภัย
การชั่งน้ำหนักข้อดีข้อเสียของเทคนิคเหล่านี้ค่อนข้างยุ่งยาก Skin and Wrap the API ใช้แรงงานมากกว่า แต่มันมีประโยชน์มากเมื่อเราต้องการแยกตัวเราออกจาก third-party libraries และความต้องการนั้นเกิดขึ้นบ่อยครั้ง ดู Chapter 14 , Dependencies on Libraries Are Killing Me สำหรับรายละเอียด เมื่อเราใช้ Responsibility-Based Extraction เราอาจลงเอยด้วยการ extract ตรรกะของเราบางส่วนไปพร้อมกับโค้ด API เพื่อให้เราสามารถ extract method ที่มีชื่อในระดับสูงกว่าได้ ถ้าทำแบบนั้น โค้ดของเราสามารถขึ้นอยู่กับ interface ระดับสูงแทนที่จะเป็นการเรียก API ระดับต่ำ แต่อาจไม่สามารถนำโค้ดที่เรา extract มาไว้ภายใต้ test ได้
หลายทีมใช้ทั้งสองเทคนิค: wrapper บางๆ สำหรับการทดสอบ และ wrapper ระดับสูงเพื่อนำเสนอ interface ที่ดีกว่าสำหรับแอปพลิเคชันของพวกเขา