ข้าม​ไป​ยัง​เนื้อหา

Inversion of Control

ให้ framework คุม​การ​ไหล​ของ​การ​ควบคุม ไม่ใช่ code เรา

Inversion of Control (IoC) คือ​หลักการ​ออกแบบ​ที่​ว่า การ​ไหล​ของ​การ​ควบคุม (flow of control) ใน​โปรแกรม​ถูก​ส่ง​มอบ​ให้​กับ “แหล่ง​ภายนอก” — โดย​ทั่วไป​คือ framework หรือ container — แทนที่​จะ​ให้ code ของ application เป็น​ผู้​ควบคุม​ลำดับ​การ​ทำงาน​เอง​ทั้งหมด นี่​คือ​แก่น​ของ​สิ่ง​ที่​เรียก​ว่า Hollywood Principle — “อย่า​โทร​หา​เรา เรา​จะ​โทร​หา​คุณ​เอง”

Martin Fowler อธิบาย​ความ​แตก​ต่าง​นี้​ผ่าน​มุมมอง library เทียบ​กับ framework: เมื่อ​เรา​ใช้ library (library) code ของ​เรา​เป็น​ผู้​เรียก function ของ library ควบคุม​จังหวะ​และ​ลำดับ​เอง แต่​เมื่อ​เรา​ใช้ framework code ของ​เรา​กลับ​เป็น​ฝ่าย “ถูก​เรียก” — เรา​เขียน​ส่วน​ขยาย (extension point) แล้ว​ปล่อย​ให้ framework เป็น​ผู้​ตัดสิน​ใจ​ว่า​จะ​เรียก​เมื่อไร​และ​อย่างไร ทิศทางการ​ควบคุม​จึง “กลับ​ทิศ” (inverted) เมื่อ​เทียบ​กับ​การ​เขียน​โปรแกรม​เชิง​กระบวนการ​แบบ​ดั้งเดิม

IoC ไม่ใช่​เทคนิค​เดียว แต่​เป็น​หลักการกว้าง ๆ ที่​ปรากฏ​ได้​หลาย​รูปแบบ เช่น event loop ที่​รอ​เรียก event handler ของ​เรา, GUI framework ที่​เรียก callback เมื่อ​ผู้​ใช้​คลิก, Template Method pattern ที่​ตัว algorithm หลัก​คุม​ลำดับ​แล้ว​เรียก hook method ของ subclass, หรือ​ที่​พบ​บ่อย​ที่สุด​ใน​โลก enterprise คือ IoC container (หรือ​ที่​รู้จัก​กัน​ว่า Dependency Injection container) ซึ่ง​เป็น factory เฉพาะ​ทาง​ที่​ทำ​หน้าที่​ประกอบ (wire) และ​ฉีด (inject) dependency ให้​กับ object ต่าง ๆ แทนที่ object เหล่า​นั้น​จะ​สร้าง dependency ของ​ตัวเอง​แบบ hard-code

ข้อ​ควร​ระวัง​คือ IoC container/dependency injection เป็น​เพียง “วิธีการ​หนึ่ง” ที่​นำ IoC ไป​ปฏิบัติ ไม่ใช่​ตัว​หลักการ​เอง และ IoC เอง​ก็​ไม่​เหมือน​กับ Dependency Inversion เสียที​เดียว — DIP พูด​ถึง “รูปทรง” ของ dependency คือ​ให้ module ระดับ​สูง​และ​ระดับ​ต่ำ​ต่าง​พึ่งพา abstraction ร่วม​กัน ใน​ขณะ​ที่ IoC พูด​ถึง “ทิศทาง” ของ​การ​ควบคุม​ว่า​ใคร​เป็น​ผู้​เรียก​ใคร ทั้ง​สอง​หลักการ​มัก​ทำงาน​ร่วม​กัน​ใน code จริง แต่​แยก​ประเด็น​กัน​ได้​ชัดเจน

  • ลด​การ​ผูก​ติด​กับ implementation ที่​เจาะจง — code ที่​พึ่งพา interface แล้ว​ปล่อย​ให้​แหล่ง​ภายนอก​เป็น​ผู้​จัดหา implementation จะ​ไม่​ผูก​ติด​กับ​รายละเอียด​ของ implementation นั้น ทำให้​สลับ implementation ได้​โดย​ไม่​ต้อง​แก้ code ที่​ใช้งาน
  • ทดสอบ​ได้​ง่าย​ขึ้น — เมื่อ dependency ถูก​ฉีด​เข้า​มา​จาก​ภายนอก​แทนที่​จะ​ถูก​สร้าง​ขึ้น​ภายใน class เรา​สามารถ​แทนที่​ด้วย test double (mock/stub/fake) ได้​โดยตรง​ใน​การ​ทดสอบ​หน่วย
  • ขยาย​ได้​โดย​ไม่​ต้อง​แก้ code เดิม — framework ที่​ออกแบบ​ตาม IoC เปิด​จุด​ขยาย (extension point) ให้​ผู้​ใช้ plugin พฤติกรรม​ใหม่​เข้าไป​ได้ โดย​ไม่​ต้อง​ไป​แก้ source code ของ framework เอง สอดคล้อง​กับ Open/Closed Principle
  • แยก​ความ​รับผิดชอบ​เรื่อง “การ​ประกอบ​ระบบ” ออก​จาก “ตรรกะ​ทาง​ธุรกิจ” — class ที่​ใช้ dependency ไม่​ต้อง​รู้​ว่า dependency ถูก​สร้าง​ขึ้น​อย่างไร​หรือ​มา​จาก​ไหน ทำให้ logic ภายใน class สะอาด​และ​มุ่ง​เน้น​เฉพาะ​หน้าที่​ของ​ตัวเอง
  • เอื้อ​ต่อ​การนำ framework กลับ​มา​ใช้​ซ้ำ — framework ที่​คุม​การ​ไหล​ของ​การ​ควบคุม​สามารถ​ถูก​นำ​ไป​ใช้​กับ application ที่​แตก​ต่าง​กัน​ได้​มากมาย เพราะ​พฤติกรรม​เฉพาะ​เจาะจง​ถูก​ฉีด​เข้า​มา​ทีหลัง​ผ่าน​จุด​ขยาย ไม่​ได้ hard-code ไว้​ใน framework

ใน version นี้ TaskProcessor เป็น​ผู้​สร้าง instance ของ TaskService เอง และ​เรียก​ใช้งาน​โดยตรง การ​ไหล​ของ​การ​ควบคุม​และ​การ​ตัดสิน​ใจ​เรื่อง implementation ทั้งหมด​อยู่​ใน​มือ​ของ TaskProcessor ทำให้​ทดสอบ​ยาก​เพราะ​ไม่มี​ทาง​แทนที่ TaskService ด้วย test double ได้​เลย และ​หาก​ต้องการ​เปลี่ยน implementation ก็​ต้อง​แก้ code ของ TaskProcessor โดยตรง:

public class TaskService
{
public string PerformTask()
{
// ตรรกะของงานอยู่ตรงนี้
return "Task completed";
}
}
public class TaskProcessor
{
// TaskProcessor เป็นผู้สร้าง dependency เอง — ควบคุมทุกอย่างเองทั้งหมด
private readonly TaskService taskService = new TaskService();
public string ProcessTask()
{
return taskService.PerformTask();
}
}

ใน version นี้ ITaskService เป็น interface ส่วน TaskService เป็น implementation จริง TaskProcessor ไม่​สร้าง​และ​ไม่รู้จัก implementation ที่​เจาะจง​อีก​ต่อ​ไป — มัน​รับ ITaskService ผ่าน constructor และ​ปล่อย​ให้ code ภายนอก (หรือ IoC container) เป็น​ผู้​ตัดสิน​ใจ​ว่า​จะ​ฉีด implementation ตัว​ไหน​เข้า​มา การ​ควบคุม​เรื่อง “ใช้ implementation อะไร” จึง​ถูก​กลับ​ทิศ​ออก​จาก TaskProcessor ไป​อยู่​ที่​ผู้​ประกอบ​ระบบ:

// interface ของ service — สิ่งเดียวที่ TaskProcessor รู้จัก
public interface ITaskService
{
string PerformTask();
}
// implementation จริงของ service
public class TaskService : ITaskService
{
public string PerformTask()
{
// ตรรกะของงานอยู่ตรงนี้
return "Task completed";
}
}
// class ที่พึ่งพา ITaskService เท่านั้น ไม่รู้จัก TaskService โดยตรง
public class TaskProcessor
{
private readonly ITaskService taskService;
// ฉีด dependency ผ่าน constructor — การควบคุมถูกกลับทิศออกไปจาก class นี้
public TaskProcessor(ITaskService taskService)
{
this.taskService = taskService;
}
public string ProcessTask()
{
return taskService.PerformTask();
}
}
class Program
{
static void Main()
{
// ผู้ประกอบระบบ (composition root) เป็นผู้ตัดสินใจว่าจะใช้ implementation ไหน
// ในงานจริงมักใช้ IoC container เช่น Microsoft.Extensions.DependencyInjection, Autofac
ITaskService taskService = new TaskService();
TaskProcessor taskProcessor = new TaskProcessor(taskService);
string result = taskProcessor.ProcessTask();
Console.WriteLine(result);
}
}

แผนภาพ​ต่อ​ไป​นี้​เปรียบเทียบ​ทิศทางการ​ควบคุม​แบบ​ดั้งเดิม (ซ้าย: code เรียก library ตรง ๆ) กับ IoC (ขวา: framework เรียก​กลับ​เข้า code ของ​เรา​ผ่าน​จุด​ขยาย และ container เป็น​ผู้​จัดหา dependency ให้):

flowchart LR
    Caller -->|calls directly| UtilityLibrary
    HostFramework -->|invokes| ApplicationHook
    ApplicationHook -->|registers with| HostFramework
    ApplicationHook -->|resolves via| IoCContainer
    IoCContainer -->|provides| ConcreteService
  • class สร้าง instance ของ dependency ด้วย new ตรง ๆ ภายใน​ตัวเอง แทนที่​จะ​รับ​ผ่าน constructor หรือ​ช่อง​ทาง​อื่น​จาก​ภายนอก
  • ต้อง​แก้ไข source code ของ class หรือ framework ทุก​ครั้ง​ที่​ต้องการ​เปลี่ยน​พฤติกรรม​หรือ​เพิ่ม implementation ใหม่ ทั้ง​ที่​ควร​ทำได้​ผ่าน​จุด​ขยาย​เท่านั้น
  • ทดสอบ​หน่วย (unit test) ทำ​ไม่​ได้​จริง​เพราะ​ไม่มี​ทาง​แทนที่ dependency ด้วย test double — ต้อง​พึ่งพา infrastructure จริง เช่น ฐาน​ข้อมูล​หรือ​บริการ​ภายนอก ทุก​ครั้ง​ที่​รัน test
  • code ระดับ​สูง (high-level) เขียน​ขั้นตอน​ควบคุม​ทุก​จังหวะ​ของ code ระดับ​ต่ำ (low-level) เอง​ทั้งหมด ไม่มี​การ​มอบ​อำนาจ​การ​ตัดสิน​ใจ​ให้​ส่วน​ใด​เลย
  • method Main หรือ entry point กลาย​เป็น​จุด​ที่​ต้อง​รู้จัก​และ​ประสาน​งาน​กับ​ทุก class ใน​ระบบ​โดยตรง แทนที่​จะ​มอบหมาย​ให้ container หรือ framework เป็น​ผู้​ประกอบ (compose) ให้