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

Explicit Dependencies Principle

class ควร​ร้องขอ​สิ่ง​ที่​ต้อง​พึ่งพา​อย่าง​เปิดเผย

เคย​อยาก​ทำ​อาหาร​ตาม​สูตร แต่​พบ​ว่า​สูตร​ลืม​ใส่​วัตถุดิบ​บาง​อย่าง​ใน​รายการ​ซื้อ​ของ​ไหม? เช่น​เดียว​กับ​วัตถุดิบ​ใน​สูตร​อาหาร การ​เข้าใจ dependency ของ class/package/library/framework ก่อน​ทำงาน​กับ​มัน​เป็น​เรื่อง​สำคัญ เมื่อ​วัตถุดิบ​ทั้งหมด​ถูก​ระบุ​อย่าง​ชัดเจน การ​ทำ​ตาม​สูตร​ก็​ง่าย​ขึ้น

Implicit dependency (การ​พึ่งพา​โดย​ปริยาย) และ transitive dependency (การ​พึ่งพา​ทาง​อ้อม) ทำให้การ​ทำงาน​กับ class/package/library/framework ยาก​ขึ้น นี่​คือ​เหตุผล​ที่​มี Explicit Dependencies Principle ซึ่ง​กล่าว​ว่า:

method และ class ควร​ร้องขอ object ผู้​ร่วม​งาน​ที่​จำเป็นต่อ​การ​ทำงาน​อย่าง​ถูกต้อง อย่าง​เปิดเผย (โดย​ทั่วไป​ผ่าน parameter ของ method หรือ constructor)

หาก class ของ​คุณ​ต้อง​พึ่ง class อื่น​เพื่อ​ทำงาน class อื่น​เหล่า​นั้น​คือ dependency — dependency เหล่า​นี้​เป็น implicit หาก​มัน​มี​อยู่​เพียง​ใน code ภายใน class ของ​คุณ ไม่​ปรากฏ​ใน public interface ส่วน explicit dependency มัก​ปรากฏ​ใน constructor ของ object (สำหรับ dependency ระดับ class) หรือ​ใน​รายการ parameter ของ method หนึ่ง (สำหรับ dependency ที่​เฉพาะ​ที่มากกว่า)

รูปแบบ​ทั่วไป​ของ implicit dependency ที่​พบ​บ่อย​ที่สุด​มี​สาม​แบบ:

  1. การ​สร้าง instance ภายใน (new โดยตรง) — method เรียก new SqlLogger() เอง​กลาง function แทนที่​จะ​รับ logger จาก​ภายนอก
  2. การ​เข้าถึง static member หรือ singleton แบบ global — เช่น class Context ที่​เก็บ state ร่วม​ให้​ทั้ง app เข้าถึง​ได้​จาก​ทุก​ที่ (ดู Static Cling)
  3. Service Locator — class ร้องขอ ServiceLocator.Get<ILogger>() จาก​ภายใน method ซึ่ง​แม้​จะ​ดูเหมือน​ใช้ dependency injection แต่​ยัง​คง​ซ่อน dependency จาก public API เพราะ​ไม่มี​ใคร​รู้​ว่า class ต้อง​พึ่ง​อะไร​จนกว่า​จะ​ไล่​อ่าน code ภายใน​หรือ​รัน​แล้ว​พัง — Mark Seemann เรียก​สิ่ง​นี้​ว่า​ปัญหา​ของ “dishonest API” ใน Service Locator is an Anti-Pattern ของ​เขา

ทาง​แก้​ที่​ยึด Explicit Dependencies Principle คือ constructor injection และ method injection: ให้ dependency ปรากฏ​เป็น parameter ทำให้ compiler และ​คน​อ่าน code เห็น​ตรง​กัน​ว่า class ต้องการ​อะไร​บ้าง​ก่อน​จะ​ทำงาน​ได้​ถูกต้อง — แนวทาง​นี้​คือ​รากฐาน​ของ​แนว​ปฏิบัติ Dependency Injection และ​เป็น​เงื่อนไข​เบื้องต้น​ของ​การ​ทำ Inversion of Control ให้​สำเร็จ

แผนภาพ​ต่อ​ไป​นี้​เปรียบเทียบ​ทิศทางการ​ค้นหา dependency ระหว่าง​สอง​วิธี:

flowchart LR
    subgraph Implicit
        A1[Method Body] -->|new or static call| B1[Hidden Dependency]
    end
    subgraph Explicit
        C1[Constructor Parameter] --> D1[Class Field]
        D1 --> E1[Method Body]
        F1[Caller / Composition Root] --> C1
    end

ฝั่ง​ซ้าย method ต้อง​รู้จัก​และ​สร้าง dependency ของ​ตัวเอง — ทิศทาง​พึ่งพา​ซ่อน​อยู่​ใน code ฝั่ง​ขวา ผู้​เรียก (หรือ composition root) เป็น​คน​จัดหา dependency ให้​ตั้งแต่​ตอน​สร้าง object ทิศทาง​พึ่งพา​จึง​มอง​เห็น​ได้​จาก signature ของ constructor เอง

class ที่​มี implicit dependency มี​ต้นทุน​ดูแล​รักษา​สูง​กว่า class ที่​มี explicit dependency ด้วย​เหตุผล​หลาย​ข้อ:

  • ทดสอบ​ยาก​กว่า เพราะ coupled กับ​ผู้​ร่วม​งาน​แน่น​กว่า — ไม่มี​จุด​ใด​ให้ inject test double เข้าไป​แทนที่ dependency จริง​ได้ ต้อง​พึ่ง global state หรือ monkey-patch ซึ่ง​เปราะบาง​และ​มัก​ทำให้ unit test รัน​พร้อม​กัน​ไม่​ได้
  • วิเคราะห์ side effect ยาก​กว่า เพราะ​ต้อง​ค้น​ทั้ง codebase ของ class เพื่อ​หา​การ​สร้าง instance หรือ​การ​เรียก static method — ผู้​พัฒนา​ไม่​สามารถ​เชื่อ public interface ได้​อีก​ต่อ​ไป ต้อง​อ่าน implementation ทุก​ครั้ง
  • เปราะบาง​กว่า — การ​เปลี่ยนแปลง​ที่ dependency ที่​ซ่อน​อยู่ (เช่น​เปลี่ยน schema ของ config global) อาจ​ทำให้ class ที่​ดูเหมือน​ไม่​เกี่ยวข้อง​พัง​โดย​ไม่มี​สัญญาณ​เตือน​ที่ compile time เพราะ error จะ​กลาย​เป็น runtime error แทน

ใน​ทาง​กลับ​กัน class ที่​มี explicit dependency ซื่อสัตย์​กว่า มัน​บอก​อย่าง​ชัดเจน​ว่า​ต้องการ​อะไร​เพื่อ​ทำงาน มัก​ยึด Principle of Least Surprise ด้วย​การ​ไม่​ไป​กระทบ​ส่วน​ของ application ที่​ไม่​ได้​แสดง​ว่า​จำเป็น​ต้อง​กระทบ และ explicit dependency สลับ​เปลี่ยน​กับ implementation อื่น​ได้​ง่าย ไม่​ว่า​ใน production หรือ​ระหว่าง​ทดสอบ​และ debug จึง​ดูแล​ง่าย​กว่า​และ​เปิด​รับ​การ​เปลี่ยนแปลง​มากกว่า

Microsoft เอง​ก็​ให้​แนวทางใน​ทิศทาง​เดียวกัน​สำหรับ .NET: “Avoid direct instantiation of dependent classes within services. Direct instantiation couples the code to a particular implementation” และ “Avoid using the service locator pattern” เพราะ​มัน​ปะปน​แนวทาง Inversion of Control สอง​แบบ​เข้า​ด้วย​กัน​จน​คาด​เดา​พฤติกรรม​ยาก (ดู Dependency injection guidelines ของ .NET)

หลักการ​นี้​เกี่ยวข้อง​อย่าง​ใกล้​ชิด​กับ Dependency Inversion Principle และ Hollywood Principle — DIP บอกว่า​ให้​พึ่งพา abstraction ไม่ใช่ concrete implementation ส่วน Explicit Dependencies Principle บอก​วิธี​ที่​ทำให้การ​พึ่งพา abstraction นั้น​มอง​เห็น​ได้​จาก​ภายนอก class ทั้ง​สอง​หลักการ​ทำงาน​ร่วม​กัน: DIP กำหนด “พึ่ง​อะไร” ส่วน Explicit Dependencies Principle กำหนด “ต้อง​ประกาศ​ให้​เห็น​ตรง​ไหน”

class PersonalizedResponse ต่อ​ไป​นี้​ผูก​แน่น​กับ file system, system clock และ​ลูกค้า​ราย​หนึ่ง​ผ่าน class Context ที่​เป็น global — ทั้งหมด​ถูก​ซ่อน​ไว้​และ​ไม่​ปรากฏ​ใน interface:

Bad: dependency ถูก​ซ่อน​อยู่​ภายใน มอง​ไม่​เห็น​จาก​ภายนอก

public static class Context
{
public static Customer CurrentCustomer { get; set; }
public static void Log(string message)
{
using (StreamWriter logFile = new StreamWriter(
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"logfile.txt")))
{
logFile.WriteLine(message);
}
}
}
public class PersonalizedResponse
{
public string GetResponse()
{
// เรียก static method ตรง ๆ — มองจาก signature ไม่มีทางรู้ว่า method นี้ทำ I/O
Context.Log("Generating personalized response.");
string formatString = "Good {0}, {1} {2}! Would you like a {3} widget today?";
string timeOfDay = "afternoon";
if (DateTime.Now.Hour < 12)
{
timeOfDay = "morning";
}
if (DateTime.Now.Hour > 17)
{
timeOfDay = "evening";
}
// ดึงลูกค้าจาก global state แทนที่จะรับเป็น parameter
return String.Format(formatString, timeOfDay,
Context.CurrentCustomer.Title,
Context.CurrentCustomer.Fullname,
Context.CurrentCustomer.FavoriteColor);
}
}

code นี้​ทดสอบ​ไม่​ได้​เลย​หาก​ไม่​เซ็ต global state ก่อน ผลลัพธ์​ยัง​ขึ้น​กับ​เวลา​จริง​ของ​เครื่อง (DateTime.Now) ทำให้ test ไม่ deterministic และ​หาก​ลืม​เซ็ต Context.CurrentCustomer ก่อน​เรียก จะ​ได้ NullReferenceException ที่ runtime แทนที่​จะ​เป็น compile error

เมื่อ refactor ให้ dependency ชัดเจน logger และ​เวลา​ถูก​ดึง​มา​เป็น parameter ของ constructor ส่วน​ลูกค้า​ที่​ถูก​กระทำ​ถูก​ดึง​มา​เป็น parameter ของ method ผล​คือ code ที่​ใช้ได้​ก็​ต่อ​เมื่อ​สิ่ง​ที่​มัน​ต้องการ​ถูก​จัดหา​ให้​แล้ว​เท่านั้น:

Good: dependency ถูก​ประกาศ​ชัดเจน​ใน constructor และ parameter

public interface ILogger { void Log(string message); }
public class SimpleFileLogger : ILogger
{
public void Log(string message)
{
using (StreamWriter logFile = new StreamWriter(
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"logfile.txt")))
{
logFile.WriteLine(message);
}
}
}
public interface IDateTime { DateTime Now { get; } }
public class SystemDateTime : IDateTime
{
public DateTime Now { get { return DateTime.Now; } }
}
public class PersonalizedResponse
{
private readonly ILogger _logger;
private readonly IDateTime _dateTime;
// dependency ระดับ class: ประกาศผ่าน constructor
public PersonalizedResponse(ILogger logger, IDateTime dateTime)
{
this._dateTime = dateTime;
this._logger = logger;
}
// dependency ระดับ method: ประกาศผ่าน parameter เพราะเฉพาะ method นี้เท่านั้นที่ใช้ customer
public string GetResponse(Customer customer)
{
_logger.Log("Generating personalized response.");
string formatString = "Good {0}, {1} {2}! Would you like a {3} widget today?";
string timeOfDay = "afternoon";
if (_dateTime.Now.Hour < 12) { timeOfDay = "morning"; }
if (_dateTime.Now.Hour > 17) { timeOfDay = "evening"; }
return String.Format(formatString, timeOfDay,
customer.Title, customer.Fullname, customer.FavoriteColor);
}
}

ตอน​นี้ PersonalizedResponse ทดสอบ​ได้​ง่าย​ด้วย fake ILogger และ IDateTime ที่​คืน​ค่า​เวลา​คงที่ ไม่​ต้อง​แตะ global state ใด ๆ และ compiler จะ​บังคับ​ให้​ผู้​เรียก​ส่ง dependency ทั้งหมด​มา​ให้​ก่อน​จะ​สร้าง instance ได้​เลย ความ​ผิดพลาด​จาก dependency ที่​ขาด​หาย​จึง​กลาย​เป็น compile error แทนที่​จะ​เป็น runtime error

การ​เลือก​กำหนด dependency ที่​ระดับ class หรือ​ระดับ method ขึ้น​อยู่​กับ​วิธี​ที่ class ใช้​มัน จำนวน method ที่​อ้าง​ถึง​สิ่ง​นั้น ฯลฯ — ใน​ตัวอย่าง​นี้​ทุก​อย่าง​อาจ​ส่ง​ผ่าน​เป็น parameter ของ method ก็ได้ แต่​แสดง​ทั้ง​สอง​แบบ​ไว้​เพื่อ​การ​เปรียบเทียบ

  • class เรียก new SomeService() กลาง method แทนที่​จะ​รับ SomeService (หรือ interface ของ​มัน) เป็น parameter
  • class เข้าถึง static property/method หรือ singleton แบบ global (เช่น Context.CurrentCustomer, ConfigurationManager.AppSettings) เพื่อ​ดึง​ข้อมูล​ที่​จำเป็นต่อ​การ​ทำงาน — ดู Static Cling
  • class เรียก ServiceLocator.Resolve<T>() หรือ​เทียบเท่า​จาก​ภายใน method แทนที่​จะ​รับ T มา​ทาง constructor
  • Constructor รับ parameter น้อย​ผิด​ปกติ​เมื่อ​เทียบ​กับ​สิ่ง​ที่ method ข้าง​ใน​ทำ​จริง — เป็น​สัญญาณ​ว่า​มี dependency ซ่อน​อยู่​ที่​ไม่​ได้​ประกาศ
  • หน่วย​ทดสอบ​ของ class ต้อง set up global state หรือ mock static class ก่อน​รัน​เสมอ แทนที่​จะ inject test double ตรง ๆ ผ่าน constructor
  • อ่าน public interface ของ class แล้ว​ยัง​ทาย​ไม่​ได้​ว่า​มัน​ทำ I/O, เขียน file, หรือ​ยิง network call — เพราะ​สิ่ง​เหล่า​นั้น​ถูก​ซ่อน​อยู่​ใน code ภายใน