ถือกรอบการทำงาน – สำรวจรูปแบบการฉีดการพึ่งพา
เผยแพร่แล้ว: 2022-03-11มุมมองแบบดั้งเดิมเกี่ยวกับการผกผันของการควบคุม (IoC) ดูเหมือนจะวาดเส้นที่ชัดเจนระหว่างสองแนวทางที่แตกต่างกัน: ตัวระบุตำแหน่งบริการและรูปแบบการพึ่งพา (DI)
แทบทุกโครงการที่ฉันรู้จักมีกรอบงาน DI ผู้คนสนใจพวกเขาเพราะพวกเขาส่งเสริมการมีเพศสัมพันธ์แบบหลวม ๆ ระหว่างไคลเอนต์และการพึ่งพาของพวกเขา (โดยปกติผ่านการฉีดคอนสตรัคเตอร์) ด้วยรหัสต้นแบบน้อยที่สุดหรือไม่มีเลย แม้ว่าสิ่งนี้จะดีสำหรับการพัฒนาอย่างรวดเร็ว แต่บางคนพบว่ามันสามารถทำให้โค้ดติดตามและแก้จุดบกพร่องได้ยาก “ความมหัศจรรย์เบื้องหลัง” มักจะเกิดขึ้นได้จากการไตร่ตรอง ซึ่งอาจทำให้เกิดปัญหาใหม่ทั้งชุด
ในบทความนี้ เราจะมาสำรวจรูปแบบทางเลือกที่เหมาะสมกับ Java 8+ และ Kotlin codebases โดยยังคงไว้ซึ่งประโยชน์ส่วนใหญ่ของกรอบงาน DI ในขณะที่ตรงไปตรงมาเหมือนกับตัวระบุตำแหน่งบริการ โดยไม่ต้องใช้เครื่องมือภายนอก
แรงจูงใจ
- หลีกเลี่ยงการพึ่งพาภายนอก
- หลีกเลี่ยงการสะท้อน
- ส่งเสริมการฉีดคอนสตรัคเตอร์
- ลดพฤติกรรมรันไทม์
ตัวอย่าง
ในตัวอย่างต่อไปนี้ เราจะสร้างโมเดลการใช้งานทีวี ซึ่งสามารถใช้แหล่งที่มาต่างๆ เพื่อรับเนื้อหาได้ เราจำเป็นต้องสร้างอุปกรณ์ที่สามารถรับสัญญาณจากแหล่งต่างๆ (เช่น ภาคพื้นดิน เคเบิล ดาวเทียม เป็นต้น) เราจะสร้างลำดับชั้นของคลาสต่อไปนี้:
มาเริ่มกันที่การนำ DI แบบดั้งเดิมไปใช้กัน โดยที่เฟรมเวิร์กเช่น Spring กำลังเดินสายทุกอย่างให้เรา:
public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }
เราสังเกตเห็นบางสิ่ง:
- คลาสทีวีแสดงถึงการพึ่งพา TvSource กรอบงานภายนอกจะเห็นสิ่งนี้และใส่ตัวอย่างการใช้งานที่เป็นรูปธรรม (ภาคพื้นดินหรือสายเคเบิล)
- รูปแบบการฉีดคอนสตรัคเตอร์ช่วยให้ทำการทดสอบได้ง่าย เนื่องจากคุณสามารถสร้างอินสแตนซ์ทีวีด้วยการนำไปใช้งานทางเลือกได้อย่างง่ายดาย
เรากำลังเริ่มต้นได้ดี แต่เราตระหนักดีว่าการนำเฟรมเวิร์ก DI สำหรับสิ่งนี้มาใช้อาจเป็นเรื่องมากเกินไป นักพัฒนาบางรายได้รายงานปัญหาการดีบักปัญหาการก่อสร้าง (การติดตามสแต็กแบบยาว การขึ้นต่อกันที่ไม่สามารถติดตามได้) ลูกค้าของเรายังแสดงด้วยว่าเวลาในการผลิตนั้นนานกว่าที่คาดไว้เล็กน้อย และผู้จัดทำโปรไฟล์ของเราแสดงการชะลอตัวในการเรียกร้องที่ไตร่ตรอง
อีกทางเลือกหนึ่งคือการใช้รูปแบบตัวระบุตำแหน่งบริการ ตรงไปตรงมา ไม่ใช้การไตร่ตรอง และอาจเพียงพอสำหรับ codebase เล็กๆ ของเรา อีกทางเลือกหนึ่งคือปล่อยให้คลาสอยู่คนเดียวและเขียนโค้ดตำแหน่งที่ขึ้นต่อกันรอบๆ คลาสเหล่านั้น
หลังจากประเมินทางเลือกหลายๆ ทางแล้ว เราเลือกใช้มันเป็นลำดับชั้นของอินเทอร์เฟซผู้ให้บริการ การพึ่งพาแต่ละครั้งจะมีผู้ให้บริการที่เกี่ยวข้องซึ่งจะมีหน้าที่รับผิดชอบในการค้นหาการพึ่งพาของคลาสและสร้างอินสแตนซ์ที่ฉีด นอกจากนี้เรายังจะทำให้ผู้ให้บริการมีส่วนต่อประสานภายในเพื่อความสะดวกในการใช้งาน เราจะเรียกมันว่า Mixin Injection เนื่องจากผู้ให้บริการแต่ละรายผสมกับผู้ให้บริการรายอื่นเพื่อค้นหาการพึ่งพา
รายละเอียดว่าทำไมฉันถึงตัดสินใจเลือกโครงสร้างนี้มีการอธิบายอย่างละเอียดในรายละเอียดและเหตุผล แต่นี่เป็นเวอร์ชันสั้น:
- มันแยกพฤติกรรมตำแหน่งที่ขึ้นต่อกัน
- การขยายอินเทอร์เฟซไม่ตกอยู่ในปัญหาเพชร
- อินเทอร์เฟซมีการใช้งานเริ่มต้น
- การพึ่งพาที่ขาดหายไปป้องกันการคอมไพล์ (คะแนนโบนัส!)
ไดอะแกรมต่อไปนี้แสดงให้เห็นว่าการขึ้นต่อกันและผู้ให้บริการโต้ตอบกันอย่างไร และการใช้งานมีภาพประกอบด้านล่าง นอกจากนี้เรายังเพิ่มวิธีการหลักเพื่อแสดงให้เห็นว่าเราสามารถเขียนการพึ่งพาและสร้างวัตถุทีวีได้อย่างไร ตัวอย่างเวอร์ชันที่ยาวกว่านี้สามารถพบได้ใน GitHub นี้
public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }
หมายเหตุเล็กน้อยเกี่ยวกับตัวอย่างนี้:
- คลาสทีวีขึ้นอยู่กับ TvSource แต่ไม่ทราบการใช้งานใดๆ
- TV.Provider ขยาย TvSource.Provider เนื่องจากต้องใช้เมธอด tvSource() เพื่อสร้าง TvSource และสามารถใช้ได้แม้ว่าจะไม่ได้ใช้งานที่นั่นก็ตาม
- แหล่งสัญญาณภาคพื้นดินและเคเบิลสามารถใช้แทนกันได้กับทีวี
- อินเทอร์เฟซ Terrestrial.Provider และ Cable.Provider ให้การใช้งาน TvSource ที่เป็นรูปธรรม
- วิธีการหลักมีการใช้งาน MainContext ของ TV.Provider ที่เป็นรูปธรรมที่ใช้เพื่อรับอินสแตนซ์ของทีวี
- โปรแกรมต้องใช้ TvSource.Provider ในเวลาคอมไพล์เพื่อสร้างอินสแตนซ์ของทีวี ดังนั้นเราจึงรวม Cable.Provider ไว้เป็นตัวอย่าง
รายละเอียดและเหตุผล
เราได้เห็นรูปแบบการใช้งานจริงและเหตุผลบางประการที่อยู่เบื้องหลัง คุณอาจไม่มั่นใจว่าคุณควรจะใช้มันในตอนนี้ และคุณจะพูดถูก มันไม่ใช่กระสุนเงิน โดยส่วนตัวแล้วฉันเชื่อว่ามันเหนือกว่ารูปแบบตัวระบุตำแหน่งบริการในหลายๆ ด้าน อย่างไรก็ตาม เมื่อเปรียบเทียบกับเฟรมเวิร์ก DI แล้ว เราต้องประเมินว่าข้อดีมีมากกว่าค่าใช้จ่ายในการเพิ่มโค้ดสำเร็จรูปหรือไม่
ผู้ให้บริการขยายผู้ให้บริการรายอื่นเพื่อค้นหาการพึ่งพาของพวกเขา
เมื่อผู้ให้บริการขยายบริการอื่น การขึ้นต่อกันจะถูกผูกไว้ด้วยกัน นี่เป็นพื้นฐานพื้นฐานสำหรับการตรวจสอบความถูกต้องซึ่งป้องกันการสร้างบริบทที่ไม่ถูกต้อง
จุดปวดหลักของรูปแบบตัวระบุตำแหน่งบริการคือคุณต้องเรียกใช้เมธอด GetService<T>()
ทั่วไปที่จะแก้ไขการพึ่งพาของคุณได้ ในเวลารวบรวม คุณไม่รับประกันว่าการขึ้นต่อกันจะถูกลงทะเบียนในตัวระบุตำแหน่ง และโปรแกรมของคุณอาจล้มเหลวขณะรันไทม์
รูปแบบ DI ไม่ได้กล่าวถึงสิ่งนี้เช่นกัน การแก้ปัญหาการขึ้นต่อกันมักจะทำผ่านการสะท้อนโดยเครื่องมือภายนอกที่ส่วนใหญ่ซ่อนจากผู้ใช้ ซึ่งจะล้มเหลวในขณะใช้งานจริงหากไม่ตรงตามการขึ้นต่อกัน เครื่องมือเช่น CDI ของ IntelliJ (มีเฉพาะในเวอร์ชันที่ต้องชำระเงิน) ให้การตรวจสอบแบบคงที่ในระดับหนึ่ง แต่ดูเหมือนว่ามีเพียง Dagger ที่มีตัวประมวลผลล่วงหน้าของคำอธิบายประกอบเท่านั้นที่สามารถแก้ไขปัญหานี้ได้ด้วยการออกแบบ
คลาสรักษาการฉีดคอนสตรัคเตอร์ทั่วไปของรูปแบบ DI
สิ่งนี้ไม่จำเป็น แต่เป็นที่ต้องการของชุมชนนักพัฒนาอย่างแน่นอน ด้านหนึ่ง คุณสามารถดูคอนสตรัคเตอร์และเห็นการพึ่งพาของคลาสได้ทันที ในทางกลับกัน มันทำให้ประเภทของการทดสอบหน่วยที่หลายคนยึดถือ ซึ่งก็คือการสร้างตัวแบบภายใต้การทดสอบด้วยการจำลองการขึ้นต่อกันของตัวแบบ

นี่ไม่ได้หมายความว่าไม่รองรับรูปแบบอื่น อันที่จริง เราอาจพบว่า Mixin Injection ช่วยลดความซับซ้อนในการสร้างกราฟการพึ่งพาที่ซับซ้อนสำหรับการทดสอบ เนื่องจากคุณจำเป็นต้องใช้คลาสบริบทที่ขยายผู้ให้บริการของหัวเรื่องเท่านั้น MainContext
ด้านบนเป็นตัวอย่างที่สมบูรณ์แบบที่อินเทอร์เฟซทั้งหมดมีการใช้งานเริ่มต้น ดังนั้นจึงสามารถมีการใช้งานที่ว่างเปล่าได้ การแทนที่การขึ้นต่อกันจำเป็นต้องแทนที่วิธีการของผู้ให้บริการเท่านั้น
ลองดูการทดสอบต่อไปนี้สำหรับคลาสทีวี จำเป็นต้องสร้างตัวอย่างทีวี แต่แทนที่จะเรียกตัวสร้างคลาส มันใช้อินเทอร์เฟซ TV.Provider TvSource.Provider ไม่มีการใช้งานเริ่มต้น ดังนั้นเราจึงจำเป็นต้องเขียนเอง
public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }
ตอนนี้ มาเพิ่มการพึ่งพาอื่นในคลาสทีวีกัน การพึ่งพา CathodeRayTube ใช้เวทมนตร์เพื่อทำให้ภาพปรากฏบนหน้าจอทีวี มันแยกออกจากการใช้งานทีวีเพราะเราอาจต้องการเปลี่ยนไปใช้ LCD หรือ LED ในอนาคต
public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
หากคุณทำเช่นนี้ คุณจะสังเกตเห็นว่าการทดสอบที่เราเพิ่งเขียนนั้นยังคงรวบรวมและผ่านตามที่คาดไว้ เราได้เพิ่มการพึ่งพาใหม่ให้กับทีวี แต่เราได้จัดเตรียมการใช้งานเริ่มต้นไว้ด้วย ซึ่งหมายความว่าเราไม่ต้องเยาะเย้ยถ้าเราเพียงต้องการใช้การนำไปใช้จริง และการทดสอบของเราสามารถสร้างวัตถุที่ซับซ้อนด้วยระดับของรายละเอียดการเยาะเย้ยที่เราต้องการ
สิ่งนี้มีประโยชน์เมื่อคุณต้องการเยาะเย้ยบางสิ่งที่เฉพาะเจาะจงในลำดับชั้นของคลาสที่ซับซ้อน (เช่น เฉพาะเลเยอร์การเข้าถึงฐานข้อมูล) รูปแบบนี้ช่วยให้ตั้งค่าประเภทการทดสอบที่เข้ากับคนง่าย ซึ่งบางครั้งต้องการใช้การทดสอบแบบโดดเดี่ยว
โดยไม่คำนึงถึงความชอบของคุณ คุณสามารถมั่นใจได้ว่าคุณสามารถหันไปใช้การทดสอบรูปแบบใดก็ได้ที่เหมาะกับความต้องการของคุณในแต่ละสถานการณ์
หลีกเลี่ยงการพึ่งพาภายนอก
อย่างที่คุณเห็น ไม่มีการอ้างอิงหรือกล่าวถึงส่วนประกอบภายนอก นี่เป็นกุญแจสำคัญสำหรับหลายโครงการที่มีขนาดหรือแม้แต่ข้อจำกัดด้านความปลอดภัย นอกจากนี้ยังช่วยให้มีการทำงานร่วมกันเนื่องจากเฟรมเวิร์กไม่จำเป็นต้องผูกมัดกับเฟรมเวิร์ก DI ที่เฉพาะเจาะจง ใน Java มีความพยายามเช่น JSR-330 Dependency Injection สำหรับ Java Standard ซึ่งลดปัญหาความเข้ากันได้
หลีกเลี่ยงการสะท้อน
การใช้งานตัวระบุตำแหน่งบริการมักจะไม่อาศัยการไตร่ตรอง แต่การใช้งาน DI ทำได้ (ยกเว้น Dagger 2) ที่โดดเด่น สิ่งนี้มีข้อเสียเปรียบหลักในการชะลอการเริ่มต้นแอปพลิเคชันเนื่องจากเฟรมเวิร์กจำเป็นต้องสแกนโมดูลของคุณ แก้ไขกราฟการพึ่งพา สร้างวัตถุของคุณสะท้อนแสง ฯลฯ
Mixin Injection กำหนดให้คุณต้องเขียนโค้ดเพื่อสร้างอินสแตนซ์บริการของคุณ คล้ายกับขั้นตอนการลงทะเบียนในรูปแบบตัวระบุตำแหน่งบริการ งานพิเศษเล็กๆ น้อยๆ นี้ช่วยขจัดการโทรที่สะท้อนออกมาโดยสิ้นเชิง ทำให้โค้ดของคุณเร็วขึ้นและตรงไปตรงมา
สองโครงการที่เพิ่งได้รับความสนใจและได้รับประโยชน์จากการหลีกเลี่ยงการสะท้อนคือ Graal's Substrate VM และ Kotlin/Native ทั้งคอมไพล์เป็น bytecode ดั้งเดิม และสิ่งนี้ต้องการให้คอมไพเลอร์ทราบล่วงหน้าเกี่ยวกับการโทรสะท้อนใดๆ ที่คุณจะทำ ในกรณีของ Graal ระบุไว้ในไฟล์ JSON ที่เขียนยาก ไม่สามารถตรวจสอบแบบสแตติก ไม่สามารถปรับโครงสร้างใหม่ได้ง่ายๆ โดยใช้เครื่องมือโปรดของคุณ การใช้ Mixin Injection เพื่อหลีกเลี่ยงการสะท้อนในตอนแรกเป็นวิธีที่ยอดเยี่ยมในการได้รับประโยชน์จากการรวบรวมแบบเนทีฟ
ลดพฤติกรรมรันไทม์
ด้วยการใช้งานและขยายอินเทอร์เฟซที่จำเป็น คุณจะสร้างกราฟการพึ่งพาทีละชิ้น ผู้ให้บริการแต่ละรายอยู่ถัดจากการใช้งานที่เป็นรูปธรรม ซึ่งนำระเบียบและตรรกะมาสู่โปรแกรมของคุณ เลเยอร์ประเภทนี้จะคุ้นเคยหากคุณเคยใช้ลวดลายมิกซ์ซินหรือลวดลายเค้กมาก่อน
ณ จุดนี้ มันอาจจะคุ้มค่าที่จะพูดถึงคลาส MainContext เป็นรากของกราฟการพึ่งพาและรู้ภาพรวม คลาสนี้มีอินเทอร์เฟซผู้ให้บริการทั้งหมดและเป็นกุญแจสำคัญในการเปิดใช้งานการตรวจสอบแบบคงที่ หากเรากลับไปที่ตัวอย่างและลบ Cable.Provider ออกจากรายการเครื่องมือ เราจะเห็นสิ่งนี้อย่างชัดเจน:
static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider
สิ่งที่เกิดขึ้นที่นี่คือแอปไม่ได้ระบุ TvSource ที่เป็นรูปธรรมที่จะใช้ และคอมไพเลอร์ตรวจพบข้อผิดพลาด ด้วยตัวระบุตำแหน่งบริการและ DI ที่อิงจากการสะท้อน ข้อผิดพลาดนี้อาจไม่มีใครสังเกตเห็นจนกว่าโปรแกรมจะขัดข้องขณะรันไทม์ แม้ว่าการทดสอบหน่วยทั้งหมดจะผ่าน! ฉันเชื่อว่าสิ่งเหล่านี้และประโยชน์อื่นๆ ที่เราได้แสดงให้เห็นมีค่ามากกว่าข้อเสียของการเขียนต้นแบบที่จำเป็นในการทำให้รูปแบบใช้งานได้
จับการอ้างอิงแบบวงกลม
กลับไปที่ตัวอย่าง CathodeRayTube และเพิ่มการพึ่งพาแบบวงกลม สมมติว่าเราต้องการให้อินสแตนซ์ของทีวีฉีดเข้าไป ดังนั้นเราจึงขยาย TV ผู้ให้บริการ:
public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
คอมไพเลอร์ไม่อนุญาตให้มีการสืบทอดแบบวนซ้ำ และเราไม่สามารถกำหนดความสัมพันธ์ประเภทนี้ได้ เฟรมเวิร์กส่วนใหญ่ล้มเหลวขณะรันไทม์เมื่อเกิดเหตุการณ์นี้ขึ้น และนักพัฒนามักจะหลีกเลี่ยงมันเพียงเพื่อให้โปรแกรมทำงาน แม้ว่ารูปแบบการต่อต้านนี้สามารถพบได้ในโลกแห่งความเป็นจริง แต่ก็มักจะเป็นสัญญาณของการออกแบบที่ไม่ดี เมื่อโค้ดไม่สามารถคอมไพล์ได้ เราควรได้รับการสนับสนุนให้มองหาวิธีแก้ปัญหาที่ดีกว่าก่อนที่จะสายเกินไปที่จะเปลี่ยนแปลง
รักษาความเรียบง่ายในการสร้างวัตถุ
ข้อโต้แย้งข้อหนึ่งที่สนับสนุน SL มากกว่า DI คือตรงไปตรงมาและแก้ปัญหาได้ง่ายขึ้น จากตัวอย่างที่เห็นได้ชัดเจนว่าการสร้างอินสแตนซ์การพึ่งพาจะเป็นเพียงแค่การเรียกใช้เมธอดของผู้ให้บริการ การติดตามแหล่งที่มาของการพึ่งพานั้นง่ายพอๆ กับการก้าวเข้าสู่การเรียกใช้เมธอดและดูว่าคุณจะลงเอยที่ใด การดีบักทำได้ง่ายกว่าทางเลือกทั้งสองทาง เนื่องจากคุณสามารถไปยังที่ที่การพึ่งพาอาศัยกันนั้นสร้างอินสแตนซ์ได้โดยตรงจากผู้ให้บริการ
อายุการใช้งาน
ผู้อ่านที่เอาใจใส่อาจสังเกตเห็นว่าการใช้งานนี้ไม่ได้แก้ไขปัญหาอายุการใช้งาน วิธีการเรียกผู้ให้บริการทั้งหมดจะสร้างอินสแตนซ์ของวัตถุใหม่ ซึ่งคล้ายกับขอบเขต Prototype ของ Spring
ข้อควรพิจารณานี้และอื่นๆ อยู่นอกขอบเขตของบทความนี้เล็กน้อย เนื่องจากฉันเพียงต้องการนำเสนอสาระสำคัญของรูปแบบโดยไม่ทำให้รายละเอียดเสียสมาธิ อย่างไรก็ตาม การใช้งานและการนำไปใช้อย่างเต็มรูปแบบในผลิตภัณฑ์จะต้องคำนึงถึงโซลูชันเต็มรูปแบบพร้อมการสนับสนุนตลอดอายุการใช้งาน
บทสรุป
ไม่ว่าคุณจะเคยชินกับการพึ่งพาเฟรมเวิร์กการฉีดหรือเขียนตัวระบุตำแหน่งบริการของคุณเอง คุณอาจต้องการสำรวจทางเลือกอื่นนี้ ลองใช้รูปแบบมิกซ์อินที่เราเพิ่งเห็นและดูว่าคุณสามารถสร้างโค้ดของคุณให้ปลอดภัยและให้เหตุผลได้ง่ายขึ้นหรือไม่