เขียนโค้ดที่ลบง่าย โค้ดที่ขยายยาก (2016)
(programmingisterrible.com)- ทันทีที่เขียนโค้ดขึ้นมา โค้ดก็สร้าง ต้นทุนการบำรุงรักษา ดังนั้นโครงสร้างที่ลบหรือแทนที่ได้ง่ายในภายหลังจึงมักสำคัญกว่าการนำกลับมาใช้ซ้ำ
- ยิ่งมีผู้ใช้ API มาก ต้นทุนการเปลี่ยนแปลงก็ยิ่งสูง และยิ่งพึ่งพา API ของบุคคลที่สามลึกเท่าไร การเปลี่ยนแปลงจากภายนอกก็ยิ่งทำให้ codebase สั่นคลอนมากขึ้นเท่านั้น
- การทำซ้ำ, boilerplate, การแบ่งเลเยอร์, ก้อนโค้ดขนาดใหญ่, การแยกโมดูล และ feature flag ล้วนเป็นเครื่องมือ จัดการ dependency ได้ตามสถานการณ์
- การแยกส่วนที่ดีไม่ใช่การรวมฟังก์ชันร่วมกันไว้ด้วยกัน แต่ใกล้เคียงกับการซ่อน การตัดสินใจด้านการออกแบบ ที่เปลี่ยนยากหรือมีโอกาสเปลี่ยนสูงออกจากกัน
- โค้ดที่ดีไม่ใช่โค้ดที่สมบูรณ์แบบตั้งแต่แรก แต่เป็น legacy code ที่รบกวนน้อยลงแม้เวลาผ่านไป และท้ายที่สุดคือ โค้ดที่ลบง่าย
โค้ดคือต้นทุน และการลบคือการลดต้นทุน
- โค้ดทุกชิ้นสร้าง ต้นทุนการบำรุงรักษา ทันทีที่ถูกเขียนขึ้น และการนำกลับมาใช้ซ้ำอาจลดปริมาณโค้ดได้ แต่ก็อาจทำให้เปลี่ยนความคิดในภายหลังได้ยากขึ้น
- ยิ่งมีโค้ดที่ใช้ API มาก การเปลี่ยน API ก็ยิ่งมีต้นทุนในการเขียนใหม่มากขึ้น
- ยิ่งพึ่งพา API ของบุคคลที่สามมาก ก็ยิ่งได้รับผลกระทบมากเมื่อ API นั้นเปลี่ยน
- ในระบบขนาดใหญ่ ยิ่งเวลาผ่านไป ปัญหาอย่างโค้ดส่วนต่าง ๆ ประกบกันอย่างไร และส่วนใดพึ่งพาส่วนใด ก็ยิ่งยากขึ้น
- หากมองจำนวนบรรทัดโค้ดไม่ใช่เป็น “บรรทัดที่ผลิตได้” แต่เป็น “ต้นทุนที่ใช้ไป” การลบโค้ดก็คือการลดต้นทุนการบำรุงรักษา
- เป้าหมายไม่ใช่แค่การสร้างซอฟต์แวร์ที่นำกลับมาใช้ซ้ำได้ แต่คือการสร้าง ซอฟต์แวร์ที่ทิ้งได้
ขั้นที่ 0: ไม่เขียนโค้ด
- จำนวนบรรทัดโค้ดไม่ได้บอกทุกอย่าง แต่ขนาดอย่าง 50 บรรทัด, 500 บรรทัด, 5,000 บรรทัด, 10,000 บรรทัด, 25,000 บรรทัดนั้นสำคัญ
- monolith 1 ล้านบรรทัดต้องใช้เวลา ต้นทุน และความพยายามในการแทนที่มากกว่า monolith 10,000 บรรทัด
- ยิ่งมีโค้ดมากก็ยิ่งกำจัดยาก แต่การลดโค้ดเพียงหนึ่งบรรทัดแทบไม่ประหยัดอะไรเลย
- โค้ดที่ลบง่ายที่สุดคือโค้ดที่ ไม่ได้เขียนตั้งแต่แรก
ขั้นที่ 1: คัดลอก-วาง
- โค้ดที่นำกลับมาใช้ซ้ำได้ ทำได้ง่ายกว่าหากสร้างหลังจากมีกรณีใช้งานหลายแบบเกิดขึ้นจริงแล้ว แทนที่จะคาดเดาการใช้งานในอนาคตแล้วสร้างไว้ล่วงหน้า
- การลอง คัดลอก-วาง ใน codebase สักสองสามครั้งช่วยให้เข้าใจรูปแบบการใช้งานจริงได้ดี
- ทันทีที่ทำโค้ดบางส่วนให้เป็น API ที่ใช้ร่วมกัน โค้ดนั้นก็เปลี่ยนแปลงได้ยากขึ้น
- โค้ดที่เรียกใช้ฟังก์ชันจะไม่ได้พึ่งพาแค่พฤติกรรมที่มีเอกสารกำกับไว้ แต่ยังพึ่งพาพฤติกรรมทั้งที่ตั้งใจและไม่ตั้งใจซึ่งสังเกตได้จาก implementation ด้วย
- การลบโค้ดภายในฟังก์ชันนั้นง่ายกว่าการลบตัวฟังก์ชันเอง
ขั้นที่ 2: หยุดคัดลอก-วาง
- โค้ดที่ถูกทำซ้ำมากพอแล้ว คือเวลาที่ควรยกขึ้นเป็นฟังก์ชัน
- โค้ดเชิง utility ที่จำเป็นต้องใช้บ่อยบนมาตรฐานไลบรารี เช่น โค้ดเปิดไฟล์ตั้งค่าแล้วคืน hash table หรือโค้ดลบไดเรกทอรี อยู่ในกลุ่มนี้
utilควรเป็นไดเรกทอรีมากกว่าไฟล์เดียว และควรแยก utility ต่างชนิดกันไว้คนละไฟล์- ไฟล์
utilเดี่ยว ๆ จะโตขึ้นเรื่อย ๆ และเมื่อใหญ่เกินไปแล้วก็แยกได้ยาก
- ไฟล์
- โค้ดที่เจาะจงกับแอปพลิเคชันหรือโปรเจกต์น้อยกว่า จะนำกลับมาใช้ซ้ำได้ง่ายกว่า และมีโอกาสเปลี่ยนหรือลบน้อยกว่า
- โค้ดไลบรารีอย่าง logging, API ของบุคคลที่สาม, file handle, process อยู่ในกลุ่มนี้
- list, hash table, collection มักไม่ถูกลบง่าย ๆ ไม่ใช่แค่เพราะมี interface เรียบง่าย แต่เพราะขอบเขตไม่ขยายใหญ่ขึ้นตามเวลา
- หัวใจคือการวางส่วนที่ลบยากให้อยู่ไกลจากส่วนที่ลบง่ายมากที่สุด
ขั้นที่ 3: เขียน boilerplate ให้มากขึ้น
- การสร้างไลบรารีช่วยหลีกเลี่ยงการคัดลอก-วางได้ แต่ในทางปฏิบัติ เรามักต้องเขียน boilerplate จำนวนมากเพื่อใช้ไลบรารีนั้น
- boilerplate คล้ายกับการคัดลอก-วาง ตรงที่ทุกครั้งต้องเปลี่ยนตำแหน่งบางจุดเล็กน้อย
- การทำซ้ำแบบนี้คือการยอมรับความยืดยาวเพื่อแลกกับการลด dependency และได้ความยืดหยุ่น
- ไลบรารีที่ต้องใช้ boilerplate มักเป็นกรณีที่ยากจะผสมนโยบายกับโปรโตคอล เช่น network protocol, wire format, เครื่องมือ parsing
- โปรโตคอลว่าด้วยสิ่งที่โปรแกรมทำได้
- นโยบายว่าด้วยสิ่งที่โปรแกรมควรทำ
- โค้ดแบบนี้มักเป็นข้อกำหนดเพื่อสื่อสารกับคอมพิวเตอร์เครื่องอื่นหรือจัดการไฟล์อื่น จึงลบได้ยาก
- สิ่งสำคัญคือไม่กระจาย business logic ไปปะปนในโค้ดเหล่านี้
- แม้ต้องเขียนบรรทัดมากขึ้น ก็ควรเขียนบรรทัดเหล่านั้นในส่วนที่ลบง่ายกว่า
ขั้นที่ 4: ไม่เขียน boilerplate
- เมื่อ boilerplate มากเกินไป ก็ถึงเวลาสร้างไลบรารีที่ห่อไลบรารียืดหยุ่นไว้ และมีความเห็นชัดเจนเรื่องนโยบาย เวิร์กโฟลว์ และสถานะ
- การสร้าง API ที่ใช้ง่ายนั้นใกล้เคียงกับการทำ boilerplate ให้เป็น ไลบรารี
- Python HTTP client
requestsเป็นตัวอย่างที่ให้ interface เรียบง่ายบนurllib3ที่ยืดยาวกว่าrequestsจัดการเวิร์กโฟลว์ทั่วไปของการใช้ HTTP และซ่อนรายละเอียดเชิงปฏิบัติurllib3ให้ความสามารถอย่าง pipelining และ connection management และไม่ได้ซ่อนรายละเอียดจากผู้ใช้
- การห่อไลบรารีหนึ่งด้วยอีกไลบรารีหนึ่งไม่ใช่แค่การซ่อนรายละเอียด แต่เป็น การแยก concern
- ไม่ควรใส่ business logic ในไดเรกทอรี
utilและควรวางไลบรารีที่ใช้ง่ายทับบนไลบรารีที่ implement ได้เรียบง่าย - บางครั้งการห่อไลบรารีของบุคคลที่สามก็เป็นทางเลือกที่ดี
- เพื่อไม่ให้ทั้งโปรเจกต์ถูกล็อกกับตัวเลือกเฉพาะหนึ่ง ๆ และสามารถสร้างไลบรารีที่เข้ากับโค้ดของตนเองได้
- API ที่ใช้งานดีและ API ที่ขยายได้มักขัดแย้งกัน
- การแบ่งเลเยอร์ไม่ใช่การเขียนโค้ดที่จะลบในภายหลังเท่านั้น แต่ใกล้เคียงกับการทำให้โค้ดที่ลบยากใช้งานได้ดีโดยไม่ทำให้มันปนเปื้อนด้วย business logic
ขั้นที่ 5: เขียนก้อนโค้ดขนาดใหญ่
- แม้จะคัดลอก-วาง, refactor, แบ่งเลเยอร์ และประกอบกันอย่างไร สุดท้ายโค้ดก็ต้องทำบางอย่าง ดังนั้นบางครั้งจึงต้องมี ก้อนโค้ดขนาดใหญ่ ที่ยึดส่วนที่เหลือไว้ด้วยกัน
- business logic อาจมีลักษณะเป็น edge case ไม่รู้จบและการ hack แบบเร่งด่วน
- โค้ดเกมหรือโค้ดของผู้ก่อตั้งสตาร์ทอัพก็อาจมองเป็นโค้ดประเภทเดียวกันที่เลือกทางลัดเพื่อประหยัดเวลาอย่างมาก
- บางครั้งการลบความผิดพลาดใหญ่หนึ่งอย่างง่ายกว่าการกำจัดความผิดพลาดเล็ก ๆ 18 อย่างที่พันกันอยู่
- งานเขียนโปรแกรมจำนวนมากเป็นการสำรวจ ดังนั้นการลองผิดสองสามครั้งแล้ว iterate อาจเร็วกว่าการพยายามทำให้ถูกตั้งแต่แรก
- ตอนสร้างเกมแรก อย่าเริ่มจากการสร้างเอนจิน และก่อนเขียนแอปพลิเคชันก็ไม่ควรเริ่มจากการสร้างเว็บเฟรมเวิร์ก
- Monorepo ก็เป็นการประนีประนอมแบบคล้ายกัน
- เป็นเรื่องยากที่จะรู้ล่วงหน้าว่าควรแบ่งโค้ดอย่างไร และความผิดพลาดใหญ่หนึ่งอย่าง deploy ง่ายกว่า 20 ส่วนที่ผูกกันแน่น
- หากรู้ว่าเป็นโค้ดที่จะถูกทิ้ง ลบ หรือแทนที่ได้ง่ายในไม่ช้า ก็สามารถเลือกทางลัดได้มากขึ้น
- เป้าหมายไม่ใช่การทำซ้ำก้อนโคลนเดิมสิบครั้งเพื่อทำให้ความผิดพลาดสมบูรณ์แบบ แต่คือการทำความผิดพลาดใหม่ทุกครั้ง รับความเสี่ยงใหม่ และสะสมผ่านการ iterate
- สุดท้ายโปรเจกต์จะล้มเหลวหรือกลายเป็น legacy code และความล้มเหลวเกิดบ่อยกว่าความสำเร็จ
- การลบทั้งก้อนมักง่ายกว่าการลบโค้ดทีละชิ้น
ขั้นที่ 6: แบ่งโค้ดเป็นชิ้น ๆ
- ก้อนโคลนขนาดใหญ่สร้างง่ายที่สุด แต่มีต้นทุนการบำรุงรักษาสูงที่สุด
- การเปลี่ยนแปลงที่ดูเรียบง่ายอาจทำให้ต้องแตะเกือบทุกส่วนของ codebase แบบแก้ขัด
- โค้ดที่เคยลบง่ายเมื่อมองทั้งก้อน กลับลบยากเมื่อแยกเป็นชิ้น ๆ
- ควรแบ่งโมดูลตามสิ่งที่ไม่ได้แชร์กับส่วนอื่นและ การตัดสินใจด้านการออกแบบ ที่ต้องซ่อน ไม่ใช่ตามฟังก์ชันร่วมกัน
- ตามเกณฑ์ของ D. Parnas เราสามารถลิสต์การตัดสินใจด้านการออกแบบที่ยากหรือมีโอกาสเปลี่ยนสูง และออกแบบให้แต่ละโมดูลซ่อนการตัดสินใจเหล่านั้นจากโมดูลอื่น
- โมดูลไม่ได้สร้างขึ้นเพื่อการนำกลับมาใช้ซ้ำ แต่เพื่อ ความสามารถในการเปลี่ยนแปลง
- หลักการความรับผิดชอบเดียวอาจมองได้ว่า “แต่ละโมดูลควรจัดการปัญหายากเพียงหนึ่งอย่าง” แต่สิ่งที่สำคัญกว่าคือ “ปัญหายากแต่ละอย่างควรถูกจัดการในโมดูลเดียวเท่านั้น”
- หากโมดูลหนึ่งทำสองอย่าง มักเป็นเพราะการเปลี่ยนส่วนหนึ่งจำเป็นต้องเปลี่ยนอีกส่วนด้วย
- คอมโพเนนต์ที่แย่มากแต่มี interface เรียบง่ายเพียงตัวเดียว อาจง่ายกว่าคอมโพเนนต์สองตัวที่ต้องปรับจูนร่วมกันอย่างละเอียด
การผูกกันแบบหลวมและ interface ร่วม
- ระบบที่สามารถลบบางส่วนได้โดยไม่ต้องเขียนส่วนอื่นใหม่ มักถูกเรียกว่า การผูกกันแบบหลวม
- การผูกกันแบบหลวมใกล้เคียงกับสภาวะที่เมื่อเปลี่ยนความคิดแล้วไม่ต้องแก้โค้ดมากเกินไป
- แม้แต่การ hardcode ตัวแปรหนึ่งครั้ง หรือการใช้ command-line flag แทนตัวแปร ก็อาจเป็นการผูกกันแบบหลวมได้ในบางกรณี
- Microsoft Windows บรรลุเป้าหมายนี้ด้วยการแยก API ภายนอกและ API ภายใน
- API ภายนอกผูกกับวงจรชีวิตของโปรแกรมเดสก์ท็อป
- API ภายในผูกกับ kernel พื้นฐาน
- การซ่อน API ช่วยให้ได้ความยืดหยุ่นโดยไม่ทำให้ซอฟต์แวร์จำนวนมากพัง
- HTTP ก็ให้ตัวอย่างของการผูกกันแบบหลวม
- สามารถวาง cache ไว้หน้า HTTP server ได้
- ย้ายรูปภาพไปยัง CDN แล้วเปลี่ยนแค่ลิงก์ เบราว์เซอร์ก็ไม่พัง
- HTTP error code ให้รหัสเฉพาะกับปัญหาร่วมกัน ทำให้ client จัดการ error จำนวนมากแทนได้
- ควรพิจารณาวิธีจัดการ failure ไปพร้อมกับการแบ่งโค้ดเป็นชิ้นเล็ก ๆ
การจัดการ failure และระดับการผูกกัน
- Erlang/OTP ใช้วิธีจัดการ failure ที่ค่อนข้างเฉพาะตัวด้วย supervision tree
- แต่ละ process ในระบบ Erlang โดยคร่าว ๆ จะถูกเริ่มและเฝ้าดูโดย supervisor
- หาก process มีปัญหา ก็จะ terminate
- เมื่อ process terminate แล้ว supervisor จะเริ่มใหม่
- supervisor ถูกเริ่มโดย bootstrap process และหาก supervisor มี failure bootstrap process จะเริ่มใหม่
- แนวคิดหลักคือการ fail fast แล้ว restart มักเร็วกว่าการจัดการ error
- failure ชั่วคราวบางกรณีถูกกดไว้ได้ด้วยวิธีปิดแล้วเปิดใหม่
- การจัดการ error และการกู้คืนควรทำที่เลเยอร์ด้านนอกของ codebase ซึ่งเป็นที่รู้จักในชื่อ end-to-end principle
- การจัดการ failure ที่ปลายทั้งสองด้านง่ายกว่าตรงกลางของการเชื่อมต่อ และแม้จะจัดการภายใน สุดท้ายก็ยังต้องมีการตรวจสอบระดับบนสุดอยู่ดี
- การจัดการ error เป็นหนึ่งในหลายวิธีที่ทำให้ระบบผูกกันแน่น
IMAP, ระบบไฟล์, SQL, middleware
- IMAP ทำให้การจัดการ error เจ็บปวด เพราะแทบทุกงานอยู่ในรูปแบบข้อยกเว้นที่มีตัวเลือกและการจัดการเฉพาะตัว
- ใน IMAP error อาจปรากฏอยู่กลางผลลัพธ์ของงานอื่นได้
- แทนที่จะใช้ UUID จะสร้าง token เฉพาะเพื่อระบุแต่ละข้อความ และ token นี้ก็อาจเปลี่ยนกลางผลลัพธ์ของงานได้เช่นกัน
- งานจำนวนมากใน IMAP ไม่เป็น atomic
- ใช้เวลามากกว่า 25 ปีกว่าจะมีวิธีย้ายอีเมลจากโฟลเดอร์หนึ่งไปอีกโฟลเดอร์หนึ่งได้อย่างน่าเชื่อถือ
- ยังมี encoding แบบ UTF-7 พิเศษและ encoding แบบ base64 เฉพาะตัวด้วย
- ระบบไฟล์และฐานข้อมูลเป็นกรณีเปรียบเทียบที่ดีกว่าสำหรับ remote storage
- ระบบไฟล์มีชุดการทำงานที่คงที่และมีหลาย object
- SQL ดูเหมือน interface ที่กว้างกว่าระบบไฟล์ แต่เป็นไปตามแพตเทิร์นของการทำงานหลายแบบบน set และหลาย row
- ฐานข้อมูลไม่ได้สลับแทนกันได้เสมอไป แต่การหาสิ่งที่ทำงานกับ SQL ได้ง่ายกว่าภาษา query ที่สร้างเอง
- Finagle ของ Twitter ใช้ API ร่วมสำหรับ service เพื่อให้เพิ่มการจัดการ timeout, กลไก retry และการตรวจสอบ authentication ลงในโค้ด client และ server ได้ง่าย
- ตัวอย่างที่ดีของการผูกกันแบบหลวมมักเป็นตัวอย่างของ interface ที่สม่ำเสมอ ด้วย
- codebase ที่แข็งแรงไม่จำเป็นต้อง modular อย่างสมบูรณ์แบบ แต่ควรมีระยะห่างเพียงพอระหว่างส่วนที่เคลื่อนไหว
- โค้ดที่ผูกกันหลวมไม่ได้แปลว่าจะลบง่ายเสมอไป แต่แทนที่และเปลี่ยนแปลงได้ง่ายกว่ามาก
ขั้นที่ 7: เขียนโค้ดต่อไป
- หากเขียนโค้ดใหม่ได้โดยไม่ต้องยุ่งกับโค้ดเก่า การทดลองไอเดียใหม่ ๆ ก็ง่ายขึ้นมาก
- ประเด็นหลักไม่ใช่ว่าเป็น microservice หรือ monolith แต่คือควรสามารถวางการทดลองหนึ่งหรือสองอย่างบนระบบได้ระหว่างที่กำลังเรียนรู้ว่ามันทำอะไร
- feature flag เป็นวิธีที่ทำให้เปลี่ยนความคิดได้ภายหลัง
- feature flag ไม่ได้ใช้แค่ทดลองฟีเจอร์ แต่ยังทำให้ deploy การเปลี่ยนแปลงได้โดยไม่ต้อง deploy ซอฟต์แวร์ใหม่
- Google Chrome พบว่าส่วนที่ยากที่สุดในรอบ release ปกติคือเวลาที่ใช้ในการ merge feature branch ที่อยู่ยาวนาน
- หากเปิดปิดโค้ดใหม่ได้โดยไม่ต้อง recompile ก็สามารถแบ่งการเปลี่ยนแปลงใหญ่เป็น merge เล็ก ๆ และอาจไม่กระทบโค้ดเดิม
- เมื่อฟีเจอร์ใหม่ปรากฏใน codebase เดียวกันเร็วขึ้น ก็จะมองเห็นผลกระทบของการพัฒนาฟีเจอร์ระยะยาวต่อส่วนอื่นได้ชัดเจนขึ้น
- feature flag ไม่ใช่แค่ command-line switch ธรรมดา แต่เป็นวิธีแยกการ release ฟีเจอร์ออกจากการ merge branch และการ deploy โค้ด
- เมื่อการ deploy ซอฟต์แวร์ใหม่อาจใช้เวลาหลายชั่วโมง หลายวัน หรือหลายสัปดาห์ ความสามารถในการเปลี่ยนความคิดตอน runtime ก็ยิ่งสำคัญขึ้น
โค้ดที่ดีคือ legacy code ที่ไม่ขวางทาง
- สิ่งที่สำคัญกว่าการ iterate คือการมี feedback loop
- แทนที่จะสร้างโมดูลเพื่อการนำกลับมาใช้ซ้ำ หัวใจคือการแยกคอมโพเนนต์เพื่อรองรับการเปลี่ยนแปลง
- การตอบสนองต่อการเปลี่ยนแปลงรวมถึงการลบฟีเจอร์เก่า ไม่ใช่แค่การพัฒนาฟีเจอร์ใหม่
- การเขียนโค้ดที่ขยายได้คือการหวังว่าอีก 3 เดือนข้างหน้า ตัวเลือกแรกยังถูกต้องอยู่
- โค้ดที่ลบได้เริ่มจากสมมติฐานตรงกันข้าม
- การแบ่งเลเยอร์, การแยก isolation, interface ร่วม และการประกอบกัน เป็นวิธีสร้างซอฟต์แวร์ที่เปลี่ยนแปลงได้ตามเวลา มากกว่าการสร้างซอฟต์แวร์ที่ดีในตัวเอง
- ไม่จำเป็นต้องทิ้งทุกอย่าง แต่บางส่วนต้องถูกลบ
- โค้ดที่ดีไม่ใช่โค้ดที่ถูกต้องตั้งแต่แรก แต่เป็น legacy code ที่ไม่ขวางทาง
- โค้ดที่ดีคือ โค้ดที่ลบง่าย
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
คำพูดที่ผมชอบคือ ความเรียบง่ายคือความแข็งแรงทนทาน
คล้ายกับกฎการเปลี่ยนแปลงอย่างต่อเนื่องของ Lehman หมายความว่ายิ่งระบบมีความซับซ้อนต่ำ ก็ยิ่งเปลี่ยนแปลงได้ง่าย
แทนที่จะเขียนโค้ดให้ขยายได้เพื่อเตรียมรับอนาคต ผมคิดว่าการเตรียมอนาคตด้วย โค้ดที่เข้าใจได้โดยสัญชาตญาณ ดีกว่า
เช่น ทำ abstraction เฉพาะเมื่อจำเป็นจริง ๆ, ยอมให้มีการซ้ำซ้อนแบบง่าย ๆ, เริ่มจาก monolith ก่อน และให้ vertical scaling มาก่อน horizontal scaling
ผมเคยสร้างระบบจาก 0→1 มาหลายระบบ และรูปแบบร่วมกันทั้งหมดก็ไปทางนี้
https://en.m.wikipedia.org/wiki/Lehman%27s_laws_of_software_...
การไม่จัดการ edge case ไม่ได้ทำให้โค้ดแข็งแรงขึ้น ต่อให้มันดูเรียบง่ายกว่าก็ตาม
มันเปิดให้ถกเถียงว่า “ความเรียบง่าย” คืออะไรและนำไปใช้กับระบบอย่างไร ซึ่งก็เป็นคำถามที่ซับซ้อนพอให้ Rich Hickey หยิบมาพูดถึง
บางที “สิ่งที่โง่ ๆ นั้นแข็งแรงทนทาน” หรือ “สิ่งที่ตรงไปตรงมานั้นแข็งแรงทนทาน” อาจสื่อเจตนาได้ดีกว่า
แค่เขียนโค้ดที่ทำสิ่งที่จำเป็นก็พอ อย่าสร้างปัญหาเรื่องการขยายตัวที่สมมติขึ้นมา อย่าสร้าง abstraction ฉลาด ๆ เพื่อให้ดูเก่ง เขียนเป็น monolith แล้วเอาขึ้น VM ก็พร้อมรันใน production ได้ทันที
ถ้ามีปัญหาก็ค่อยแก้ตอนนั้น และถ้าเป็นไปได้ควรเป็นหลังจากกระแสเงินสดเป็นบวกแล้ว
สตาร์ทอัพ “AirBnb สำหรับสุนัข” ที่มีผู้ใช้ 0 คนจะกังวลเรื่อง C100K ไปทำไม? ที่ AWS โน้มน้าวให้คุณจ่ายเงินกับ serverless นั้นเพื่อประโยชน์ของคุณ หรือเพื่อรีดเงินจากคุณกันแน่
บทความที่เกี่ยวข้อง:
Write code that is easy to delete, not easy to extend (2016) - https://news.ycombinator.com/item?id=24989351 - พ.ย. 2020 (30 ความคิดเห็น)
Write code that is easy to delete, not easy to extend (2016) - https://news.ycombinator.com/item?id=23914486 - ก.ค. 2020 (109 ความคิดเห็น)
Write code that is easy to delete, not easy to extend - https://news.ycombinator.com/item?id=18761739 - ธ.ค. 2018 (2 ความคิดเห็น)
Write code that is easy to delete, not easy to extend - https://news.ycombinator.com/item?id=11093733 - ก.พ. 2016 (133 ความคิดเห็น)
ถ้าสรุปสั้น ๆ ถึงความผิดพลาดที่เคยทำตอนยังหนุ่ม ตอนนี้ผมกลับมาเชื่อใน การออกแบบเพื่อการลบ
เมื่อก่อนผมคิดว่าสามารถคาดการณ์ทุกสถานการณ์และสร้างผลงานศิลปะสุดเจ๋งที่ตอบสนองทุกความต้องการได้ แต่ไม่มีใครคาดการณ์ความต้องการในอนาคตได้ดีขนาดนั้น
สักวันหนึ่ง สิ่งที่ผมสร้างอาจกลายเป็น “ไอ้ของโง่ ๆ นั่น” สำหรับใครบางคน และไม่ว่าตอนนี้ผมจะภูมิใจกับมันแค่ไหน การที่พวกเขาทุบมันทิ้งทั้งหมดก็อาจเป็นเรื่องสมเหตุสมผล
ดังนั้นการทุ่มแรงให้มันลบออกได้ง่ายจึงดีกว่า แบบนี้มักลด coupling ลงได้บ่อย แต่ประเด็นสำคัญคือมันต่างจากการ decouple แบบนักพัฒนาหนุ่มไฟแรงที่อยากแยกทุกอย่างออกเป็นเฟรมเวิร์กที่ตั้งค่าเชิงเมตาได้
บางครั้ง การผูกกันแน่น ที่เข้าใจง่ายก็อาจดีกว่า
https://news.ycombinator.com/item?id=41219130
ตัวอย่างเช่น สิ่งอย่าง CommonExcelFileParser, CommonExcelFileParserUtilities, HasExcelParseStatus, ProductImportExcelParser, ProductImportExcelParserView, ProductImportExcelParserResultHandler โผล่ขึ้นมา แล้วกลายเป็นฐานของโค้ดรอบ ๆ
คล้ายกับเวลาที่เริ่มโปรเจกต์ frontend ด้วย React หรือ Angular แล้วการย้ายไปอย่างอื่นกลายเป็นภารกิจแบบซิซิฟัส
ในความเป็นจริง ผู้คนมักสร้างทั้งแพลตฟอร์มขึ้นมา และแม้จะมีทางเลือกที่จะก่อปัญหาในอนาคต แต่เพราะ coupling ทำให้ refactor ยากกว่า codebase ที่ abstract น้อยเกินไปมาก
ดูเหมือนผู้คนจะชอบทำเรื่องแบบนี้มากกว่าการใช้ KISS และ YAGNI แล้วสร้างโค้ดที่ลบง่าย ผมเลยไม่รู้ว่าควรทำอย่างไรในสถานการณ์แบบนั้น
requirement ทางธุรกิจเปลี่ยนและเคลื่อนไหวอยู่เสมอ ดังนั้นอย่าพยายามคาดการณ์ แต่ควรเขียนสิ่งที่เปลี่ยนแทนหรือทิ้งได้ง่าย
framework และ library จะแตกต่างออกไปเล็กน้อย แม้ต้องปรับให้เข้ากับความเปลี่ยนแปลงของโลก แต่ทำได้ด้วยจังหวะที่นุ่มนวลกว่ามาก
ปัญหาใหญ่ที่สุดคือในแอปพลิเคชันธุรกิจที่ใช้ framework อย่าง Rails หรือ Asp.Net อยู่แล้ว นักพัฒนายังอยากสร้าง “framework” ขึ้นมาอีก
ถ้าคุณไม่ได้เขียน Linux kernel ก็ไม่ควรเขียนเหมือน Linux kernel
ค่อนข้างแปลกที่บทความนี้ไม่พูดถึง การทดสอบและความสามารถในการสังเกตการณ์ เลย
การทดสอบเองก็มีต้นทุนในการบำรุงรักษา แต่ช่วยลดความเสี่ยงที่จะทำอะไรพังเมื่อเอาบางอย่างออกไป
ยิ่งถ้าคุณเปิดบริการให้ผู้เรียกจากภายนอกใช้งาน ก็ต้องมีทั้งวิธีที่แข็งแรงพอในการทำเครื่องหมายบางการเรียกว่าจะเลิกใช้และค่อยลบในภายหลัง และวิธีสังเกตว่ายังมีการเรียกอยู่ไหม รวมถึงใครเป็นคนเรียก
ไม่นานมานี้เราเพิ่งลบ GraphQL resolver ที่เปิดเผยออกสู่ภายนอกเป็นครั้งแรกแบบกึ่งอัตโนมัติ โดยมี metrics อยู่แล้วว่า resolver แต่ละตัวถูกใช้บ่อยแค่ไหน จึง parse มันเพื่อดึงรายการ resolver ที่ยังลบไม่ได้ออกมาได้
GraphQL มี annotation
deprecatedอยู่แล้ว แต่บริการของเราไม่ได้จัดการ annotation นั้นเป็นพิเศษดังนั้นเราจึงเพิ่มความสามารถในการสังเกตการณ์ที่บอกเมื่อมีการเรียกฟังก์ชันที่ deprecated แล้ว แล้วปล่อยให้รันใน production นานพอ จากนั้นจึงลบโค้ดที่เปิดเผยต่อภายนอกได้อย่างปลอดภัย
พอเริ่มทำให้ซับซ้อนเกินไป ทุกอย่างก็จะกลายเป็นกองยุ่งเหยิงที่เชื่อมถึงกันหมด และนักพัฒนาจะไม่รู้ว่าการเปลี่ยนแปลงจะกระทบอะไรบ้าง
แน่นอนว่ามีหลายวิธีที่จะทำให้พังได้ อาจทำตามหลัก “best practices” โง่ ๆ หรือทำ “microservices” ในแบบที่ไม่รู้ว่าใคร consume service ไหนอยู่ก็ได้ แต่ถ้าเป็นอย่างนั้นก็ไม่ได้ทำให้มันลบง่าย
การ consume จากภายนอกเป็นตัวอย่างที่ดี การเตือน consumer อย่างสมเหตุสมผลว่า service จะถูกเลิกใช้นั้นเป็นเรื่องที่เหมาะสม แต่ถ้าอยากปิดจริง ๆ เมื่อไรก็ปิดไม่ได้ นั่นก็ไม่ใช่ระบบที่ออกแบบมาให้ลบได้ง่าย
ถ้าแนวทางนั้นเหมาะ ก็ทำแบบนั้นได้ เพียงแต่การคาดหวังให้การทดสอบและ observability บอกว่ามีอะไรพังหรือไม่ อาจไม่ค่อยได้ผลนัก
ไม่ได้ต่อต้านการทดสอบเอง แต่คงพูดได้ยากว่ามันเป็นกลไกป้องกันที่ยอดเยี่ยมสำหรับบอกว่าเราไปทำอะไรพังในโซ่ที่ยาวและซับซ้อนหรือไม่ เพราะในทางปฏิบัติ การมีขอบเขตการทดสอบที่จะปกป้องได้จริงนั้นทำได้ยากมาก
ถ้าลบโค้ดบางส่วน ก็ลบเทสต์บางส่วนได้เช่นกัน
อาจมองได้ว่าบทความพูดถึงแต่โค้ด และรวมผลกระทบที่เกี่ยวข้องกับการทดสอบไว้โดยปริยาย
การที่บทความไม่ได้พูดถึงการทดสอบ ไม่ได้แปลว่าสามารถสรุปได้ว่าไม่ควรเขียนเทสต์
เมื่อดูส่วนนี้แล้วรู้สึกว่าชื่อเรื่องไม่ได้ถูกเสมอไป: โค้ดที่ลบง่ายมักเป็น โค้ดที่ขยายได้ง่าย ด้วยเช่นกัน
เพราะมันถูกแบ่งเป็นชั้น เป็นโมดูล และแยกชิ้นส่วนต่าง ๆ ออกจากกันผ่าน abstraction อย่าง interface หรือ contract ของ type อื่น ๆ
ผมเคยบอกนักศึกษาฟิสิกส์เชิงคำนวณว่า การคำนวณที่ดีที่สุด คือการคำนวณที่ไม่จำเป็นต้องทำตั้งแต่แรก
ส่วนตัวผมแบ่งโค้ดออกเป็นสองส่วนคือ business logic กับ implementation จริง
business logic โดยธรรมชาติอาจมีความซ้ำกันได้ แต่รายละเอียดเชิงเทคนิคไม่ควรซ้ำกันมากเกินไป
implementation จริงจะรกแค่ไหนก็ได้ ตราบใดที่ไม่ใส่ business logic ลงไปโดยตรงและคงความเป็นอิสระจากแอปพลิเคชัน
ถ้าทำไว้อย่างนั้น เมื่อรู้ว่าบางอย่างเละเทะและทำงานไม่ดี เราจะมีทางเลือกในการลบ implementation ทั้งหมดทิ้ง แทนที่จะต้องไล่ย้อนจาก implementation ไปหาสเปกจริงแล้วฝืนแก้
ประโยคในย่อหน้าแรกที่ว่า “ปัญหาของ การใช้โค้ดซ้ำ คือมันขัดขวางการเปลี่ยนใจในภายหลัง” เป็นความผิดพลาดชัดเจน
พูดโดยทั่วไปแล้วมันผิด ถ้าเปลี่ยนใจแล้วโค้ดถูก copy-paste ไว้สิบที่ ก็ต้องแก้สิบที่
ตรงกันข้าม ถ้ามันอยู่ในฟังก์ชัน ก็แก้ครั้งเดียวพอ ต่อให้ภายหลังพบว่าหนึ่งในสิบจุดที่เรียกไม่ควรถูกเปลี่ยน ก็สามารถ copy-paste ตอนนั้น หรือทำให้ฟังก์ชันทั่วไปขึ้นได้
เหมือนการข้ามถนนโดยไม่มอง การ copy-paste แทบจะเป็นความคิดที่แย่เสมอ
แต่ abstraction ที่แย่จะนำไปสู่การชำระหนี้ทางเทคนิคหลายเดือน
แน่นอนว่าคำตอบคือ “อย่าสร้าง abstraction ที่แย่” แต่ทุกคนก็รู้ว่ามันเป็นอย่างไรในทีมและท่ามกลางความต้องการของผลิตภัณฑ์ที่เปลี่ยนไป
มี git submodule ที่รวม widget UI ร่วมกันอยู่ ตอนนี้การเปลี่ยนหนึ่งในนั้นแทบเป็นไปไม่ได้ จนการคัดลอก component เข้ามาในโปรเจกต์แล้วแก้ใน local ง่ายกว่า
นี่เป็นปัญหา โค้ดที่แชร์ร่วมกัน ควรถูกทำให้น้อยที่สุดเท่าที่เป็นไปได้ และการแชร์เองทำให้การเปลี่ยนแปลงยากขึ้น
ถ้าทุกอย่างอยู่ในฟังก์ชันเดียว นักพัฒนาส่วนใหญ่จะพยายามเปลี่ยนฟังก์ชันนั้นให้รองรับทั้ง 10 กรณี ทั้งที่ตั้งแต่แรกมันไม่ควรเป็นฟังก์ชันเดียว
การแก้โค้ดที่ถูก copy-paste ไว้สิบที่ ง่ายกว่าการแก้ ปมที่ผูกผิด ซึ่งถูกมัดไว้ครั้งเดียวแล้วไปยึดชิ้นส่วนต่าง ๆ ของระบบเอาไว้มาก
มองเผิน ๆ อาจดูเหมือนขัดแย้งกับตัวเองในประเด็นนี้ แต่ถ้าอ่านช้า ๆ จะเห็นว่าเขาใช้การ copy-paste เป็นสัญญาณบอกว่าโค้ดใดควรถูก abstract และอะไรคือ pattern ที่ควรทำตามจริง ๆ
แปลกดีที่หลักบัญญัติสารพัดเกี่ยวกับซอฟต์แวร์ หรือหลักการที่แทบจะเป็น ศาสนา ถูกพูดซ้ำไปซ้ำมา
บนกระดาษทุกอย่างดูยอดเยี่ยมและเหมือนสามัญสำนึก แต่ผ่านไป 50 ปี ซอฟต์แวร์ใน 90% ของกรณีก็ยังเป็นขยะ
ถึงอย่างนั้นก็ยังหยิบเรื่องพวกนี้ขึ้นมาราวกับเป็น insight อัจฉริยะหรือกระสุนเงินอยู่เรื่อย ๆ
เรื่องนี้มี corollary ที่ยอดเยี่ยม: โค้ดที่แย่ อยู่ได้นาน เพราะมันลบออกได้ยากกว่ามาก