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

Dependency Injection

ส่ง dependency เข้า​มา​จาก​ภายนอก แทนที่​จะ​ให้ class สร้าง​ขึ้น​เอง

Dependency Injection (DI) เป็น​เทคนิค​ที่​เอื้อ​ให้​ระบบ​เชิง​วัตถุ​เป็น​แบบ loosely coupled มัน​เกี่ยวข้อง​อย่าง​ใกล้​ชิด​กับ Dependency Inversion Principle — DIP บอกว่า module ระดับ​สูง​ไม่​ควร​ขึ้น​กับ​รายละเอียด​ของ module ระดับ​ต่ำ​โดยตรง ส่วน DI คือ เทคนิค​เชิง​ปฏิบัติ ที่​ทำให้​หลักการ​นั้น​เป็น​จริง​ใน code

ใน​ระบบ​ง่าย ๆ การ​อ้างอิง​ถึง object ที่​ทำงาน​ร่วม​กัน (collaborating objects) มัก​ถูก​สร้าง​ขึ้น​ตรง ๆ ภายใน class ที่​ต้อง​ใช้​มัน เช่น new SmtpEmailSender() อยู่​กลาง method ผล​คือ tightly coupled ระหว่าง class เหล่า​นี้ ทำให้​ทดสอบ refactor และ​ดูแล​รักษา​ยาก​ขึ้น เพราะ​จะ​สลับ implementation หรือ​ใส่ mock แทน​ไม่​ได้​เลย​ถ้า​ไม่​แก้ code class ที่​ใช้งาน

Dependency Injection คือ​เทคนิค​ที่ object ซึ่ง​ทำงาน​ร่วม​กัน​ถูก ส่ง​เข้า​มา ยัง class ที่​ต้อง​ใช้ และ​ตัว class เอง​เขียน code โดย​อ้าง​กับ interface หรือ base class แทนที่​จะ​อ้าง​กับ class implementation ที่​เจาะจง ตาม Wikipedia เทคนิค​นี้​ประกอบ​ด้วย​สี่บทบาท: service (class ที่​มี​ความ​สามารถ​ที่​ต้องการ​ใช้), client (class ที่​ใช้ service นั้น), interface (สัญญา​ที่ client รู้จัก โดย​ไม่​ต้อง​รู้​ว่า service ตัว​จริง implement อย่างไร) และ injector (หรือ​เรียก​ว่า assembler, container, provider, factory — ตัว​ที่​ประกอบ​และ​ส่ง service เข้าไป​ให้ client)

Martin Fowler อธิบาย​ไว้​ใน​บทความ​ชื่อ​ดัง​ปี 2004 ว่า DI เป็น​กรณี​เฉพาะ​ของ​หลักการ​ที่​กว้าง​กว่า​คือ Inversion of Control — แทนที่ code ของ​เรา​จะ​เป็น​ฝ่าย​ควบคุม​ว่า​จะ​สร้าง​และ​ค้นหา dependency อย่างไร​เอง (control flow แบบ​เดิม) เรา​กลับ “พลิก​กลับ” ให้ framework หรือ container เป็น​ฝ่าย​สร้าง​และ​ส่ง​มัน​เข้า​มา​ให้​แทน Fowler ยัง​เปรียบเทียบ DI กับ​อีก​แนวทาง​หนึ่ง​คือ Service Locator ซึ่ง​ให้ client เรียกหา service เอง​จาก​ตัว​ค้นหา​กลาง และ​สรุป​ว่า​ทาง​เลือก​ไหน​สำคัญ​น้อย​กว่า​หลักการ​ที่แท้​จริง คือ​การ แยก​การ​ตั้ง​ค่า (configuration) ออก​จาก​การ​ใช้งาน (use) ของ service

มี​หลาย​วิธี​ใน​การ​ฉีด dependency เข้าไป​ใน class ผ่าน​หนึ่ง​ใน​ช่อง​ทาง​เหล่า​นี้: constructor, property หรือ method

Constructor injection คือ​การ​ส่ง instance ของ dependency เข้า​มา​ทาง constructor ของ class ซึ่ง constructor จะ​เก็บ dependency นั้น​ไว้​ใน private field เพื่อ​นำ​ไป​ใช้​ภายใน class ตาม​ต้องการ นี่​ยัง​เป็น​ตัวอย่าง​ของ Strategy design pattern อีก​ด้วย class ที่​ยึด Explicit Dependencies Principle จะ​ได้​ประโยชน์​จาก constructor injection ได้​อย่าง​ง่ายดาย เพราะ​แค่​ดู signature ของ constructor ก็​รู้ทันที​ว่า class ต้อง​พึ่งพา​อะไร​บ้าง โดย​ไม่​ต้อง​ไป​อ่าน implementation ทั้ง file

ข้อดี​สำคัญ​คือ constructor injection รับประกัน​ว่า object จะ​ถูก​สร้าง​ได้​ก็​ต่อ​เมื่อ dependency ที่​จำเป็น​ทั้งหมด​ถูก​จัดหา​ให้​ครบ จึง​ขจัด​ความ​เป็น​ไป​ได้ที่ object จะ​อยู่​ใน​สถานะ​ไม่​ถูกต้อง (ถูก​สร้าง​ขึ้น​มา​แล้ว​แต่​ยัง​ไม่​ได้​ตั้ง​ค่า dependency)

Property injection คล้าย​กัน แต่​แทนที่​จะ​ให้ instance ผ่าน constructor กลับ​กำหนด dependency ผ่าน property ของ class เทคนิค​นี้​มี​ประโยชน์​ใน​สถานการณ์​ที่​การ​สร้าง class ไม่​สามารถ​รับ parameter ได้ เช่น​ใน​ระบบ ASP.NET web forms หรือ framework บาง​ตัว​ที่​ควบคุม​การ​สร้าง object เอง ข้อ​เสีย​คือ code ฝั่ง​ผู้​เรียก​ต้อง​กำหนด​ค่า dependency property เอง​เพื่อ​ให้ class ทำงาน​ได้​ถูกต้อง หาก​ลืม​ตั้ง​ค่า จะ​ได้ null และ​พัง​ตอน runtime แทนที่​จะ​พัง​ตอน compile หรือ​ตอน​สร้าง object

Method injection คือ​การ​ส่ง object ที่​ทำงาน​ร่วม​กัน​เข้า​มา​เป็น parameter ของ method มัน​มี​ประโยชน์​ที่สุด​กับ public method ที่​มี dependency ซึ่ง​ไม่​ได้​ถูก​ใช้​ที่​อื่น​ใน class จน​การ​ใช้ constructor หรือ property injection จะ​เกิน​ความ​จำเป็น ใน​ทาง​ปฏิบัติ class ที่​ยึด Single Responsibility Principle มัก​ไม่​ค่อย​มี method ที่​มี​ชุด dependency ต่าง​จาก method อื่น ๆ ใน class มาก​นัก เพราะ​นั่น​มัก​เป็น​สัญญาณ​ของ cohesion ที่​ต่ำ และ/หรือ​มี​ความ​รับผิดชอบ​หลาย​อย่าง

Wikipedia ยัง​กล่าว​ถึง​รูปแบบ​ที่​สี่​คือ interface injection ซึ่ง client ต้อง implement interface พิเศษ​ที่​มี method สำหรับ​รับ dependency เข้า​มา แนวทาง Avalon framework ใช้​วิธี​นี้ แต่​ใน​โลก .NET สมัย​ใหม่​แทบ​ไม่​พบ​การ​ใช้งาน​รูปแบบ​นี้​แล้ว เพราะ constructor injection ครอบคลุม​กรณี​ส่วน​ใหญ่​ได้​ดี​กว่า

Dependency injection เกี่ยวข้อง​กับ Inversion of Control container ซึ่ง​ช่วย​จัดการ​แบบ​รวม​ศูนย์​และ​อัตโนมัติ​ว่า instance ใด​ควร​ถูก​จัดหา​ให้​เมื่อ​ต้อง​สร้าง object ใน .NET สมัย​ใหม่ container ใน​ตัว​คือ IServiceProvider — เรา​ลง​ทะเบียน service ไว้​ที่​จุด​เริ่มต้น​ของ app (composition root) ผ่าน IServiceCollection แล้ว container จะ​ไล่​วิเคราะห์ constructor ของ​แต่ละ class เพื่อ​ประกอบ graph dependency ทั้งหมด​ให้​เอง

แผนภาพ​ด้าน​ล่าง​แสดง​ว่า composition root (จุด​ประกอบ dependency แห่ง​เดียว​ของ app) เป็น​ฝ่าย​สร้าง​ทั้ง implementation จริง​และ class ที่​ใช้งาน แล้ว​ฉีด​ผ่าน interface กลาง ทำให้​ใน code ทดสอบ​สามารถ​สลับ​ไป​ประกอบ​ด้วย fake implementation แทน​ได้​โดย​ไม่​ต้อง​แก้ OrderService เลย

flowchart LR
    Root[Composition Root]
    Order[OrderService]
    Iface[IEmailSender]
    Sender[SmtpEmailSender]
    Test[Unit Test]
    Fake[FakeEmailSender]

    Root -->|new SmtpEmailSender| Sender
    Root -->|inject via constructor| Order
    Sender -->|implements| Iface
    Order -->|depends on| Iface
    Test -->|new FakeEmailSender| Fake
    Test -->|inject via constructor| Order
    Fake -->|implements| Iface

Constructor injection: ประกาศ dependency ที่​จำเป็น​ไว้​ตรง ๆ ผ่าน interface

public interface IEmailSender
{
void Send(string to, string subject, string body);
}
public class OrderService
{
private readonly IEmailSender _email;
// dependency ถูกส่งเข้ามา ไม่ใช่สร้างเองด้วย new
public OrderService(IEmailSender email)
{
_email = email ?? throw new ArgumentNullException(nameof(email));
}
public void CompleteOrder(string customerEmail)
{
// ... บันทึกออเดอร์ ...
_email.Send(customerEmail, "ขอบคุณที่สั่งซื้อ", "ออเดอร์ของคุณเสร็จสมบูรณ์แล้ว");
}
}

ลง​ทะเบียน service กับ container ที่ composition root แล้ว​ปล่อย​ให้ container ประกอบ graph ให้​เอง

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
// จุดเดียวที่ผูก interface เข้ากับ implementation จริง
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<OrderService>();
using var host = builder.Build();
// container สร้าง OrderService พร้อมฉีด SmtpEmailSender ให้อัตโนมัติ
var orderService = host.Services.GetRequiredService<OrderService>();

ใน​ระบบ ASP.NET Core และ .NET generic host แต่ละ service ที่​ลง​ทะเบียน​จะ​มี lifetime กำกับ​อยู่ ซึ่ง​กำหนด​ว่า container จะ​สร้าง instance ใหม่​บ่อย​แค่​ไหน: Singleton (สร้าง​ครั้ง​เดียว​ใช้​ตลอด​อายุ app), Scoped (สร้าง​ใหม่​ต่อ request หรือ​ต่อ scope) และ Transient (สร้าง​ใหม่​ทุก​ครั้ง​ที่​ถูก resolve) การ​เลือก lifetime ผิด​เป็น bug ที่​พบ​บ่อย เช่น การ​ฉีด scoped service เข้าไป​ใน singleton จะ​ทำให้​อายุ​ของ​มัน​ถูก​ยืด​ออก​ไป​โดย​ไม่​ตั้งใจ (เรียก​ว่า captive dependency)

ใน​การ​ทดสอบ​หน่วย (unit test) เรา​สามารถ​สร้าง OrderService เอง​โดย​ส่ง fake หรือ mock ของ IEmailSender เข้าไป​ตรง ๆ โดย​ไม่​ต้อง​พึ่ง container เลย นี่​คือ​ประโยชน์​หลัก​ที่ DI มอบ​ให้​กับ​ความ​สามารถ​ใน​การ​ทดสอบ

ประโยชน์

  • Loose coupling — class รู้จัก​กัน​ผ่าน interface เท่านั้น ไม่​ผูก​กับ implementation ที่​เจาะจง ทำให้​สลับ implementation ได้​โดย​ไม่​แตะ code ที่​ใช้งาน
  • ทดสอบ​ได้​ง่าย​ขึ้น — ส่ง mock หรือ stub เข้าไป​แทน dependency จริง​ได้​โดยตรง​ใน test ไม่​ต้อง​พึ่ง​ฐาน​ข้อมูล​หรือ​บริการ​ภายนอก​จริง
  • แยก​ความ​รับผิดชอบ​ชัดเจน — การ​ประกอบ (configuration) ของ graph dependency แยก​ออก​จาก​การ​ใช้งาน (use) ตาม​ที่ Fowler เน้น​ย้ำ ทำให้​จุด​ตั้ง​ค่า​ทั้งหมด​รวม​อยู่​ที่ composition root แห่ง​เดียว
  • รองรับ​การ​เปลี่ยนแปลง​ใน​อนาคต — เพิ่ม implementation ใหม่ (เช่น​เปลี่ยน​จาก SMTP เป็น​คิว​ข้อความ) โดย​ไม่​กระทบ class ที่​เรียก​ใช้ สอดคล้อง​กับ Open/Closed Principle

ข้อ​ควร​ระวัง

  • Inversion of control ทำให้ debug ยาก​ขึ้น — Fowler เตือน​ไว้​ตรง ๆ ว่าการ​ที่ control flow ถูก​พลิก​กลับ​ให้ container เป็น​ผู้​ประกอบ ทำให้​ตาม​รอย call stack ยาก​ขึ้น​เมื่อ​เกิด​ปัญหา เพราะ​ไม่​เห็น​จุด new ตรง ๆ ใน code
  • Constructor ที่ parameter ยาว​เกิน​ไป​คือ​กลิ่น code ไม่ใช่​ปัญหา​ของ DI เอง แต่​มัก​เป็น​สัญญาณ​ว่า class นั้น​ละเมิด Single Responsibility Principle ควร​แตก class มากกว่า​จะ​ยัด dependency เพิ่ม​เข้าไป​เรื่อย ๆ
  • อย่า​สับสน​กับ Service Locator — การ​ให้ class เรียกหา dependency เอง​จาก container กลาง (Service Locator) ยัง​คง​ซ่อน dependency ไว้​ใน code implementation ทำให้ signature ของ class ไม่​บอก​ความ​จริง​ว่า​มัน​ต้อง​พึ่ง​อะไร​บ้าง ต่าง​จาก DI ที่ dependency ปรากฏ​ชัด​ใน parameter
  • Lifetime mismatch — ใน​ระบบ​ที่​มี container จัดการ scope เช่น ASP.NET Core การ​ฉีด scoped service เข้า singleton โดย​ไม่​ตั้งใจ​จะ​ทำให้​เกิด captive dependency ซึ่ง​อาจ​นำ​ไป​สู่ bug ที่​ตาม​รอย​ยาก .NET จึง​มี​การ​ตรวจสอบ scope validation ใน​โหมด development เพื่อ​จับ​ปัญหา​นี้​ตั้งแต่​เนิ่น ๆ
  • มี “magic” มาก​เกิน​ไป — การ​พึ่ง reflection และ auto-wiring หนัก​เกิน​ไป​อาจ​ทำให้ IDE navigate code ไม่​ได้​ตรง​ไป​ตรง​มา และ​เพิ่ม​ต้นทุน​การ​เรียนรู้​ให้​ทีม​ใหม่