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

Service Locator

registry กลาง​ที่ object ร้องขอ dependency ตอน runtime — มัก​เป็น antipattern

Service Locator แก้​ปัญหา​ที่​ดูเหมือน​เรียบ​ง่าย: object หนึ่ง​ต้องการ​ใช้ service อื่น (เช่น repository, logger, gateway) แล้ว​จะ “หา” service นั้น​มา​ได้​อย่างไร​โดย​ไม่​ต้อง new ขึ้น​เอง​ตรง ๆ และ​ไม่​ต้อง​ผูก​กับ implementation class เจาะจง แนวคิด​ของ pattern นี้​คือ​สร้าง registry กลาง ที่​รู้จัก​และ​เก็บ instance ของ service ทุก​ตัว​ใน application ไว้ ใคร​ก็ตาม​ที่​ต้องการ dependency ก็​แค่​ถาม (pull) จาก registry นี้ ณ จุด​ที่​ต้อง​ใช้งาน แทนที่​จะ​รับ​มัน​มา​แบบ​ชัดเจน​ผ่าน constructor

Martin Fowler บันทึก​แนวคิด​นี้​ไว้​ตั้งแต่​ปี 2004 ควบคู่​กับ Dependency Injection โดย​ชี้​ว่า​ทั้ง​สอง​แบบ​ต่าง​ก็​เป็นการ “แยก concern ของ​การ​ประกอบ (assembly) ออก​จาก​การ​ใช้งาน (use)” — ต่าง​กัน​ตรง​ที่ DI เป็นการ push dependency เข้าไป​ให้​ตั้งแต่​ตอน​สร้าง object (inversion of control ที่แท้​จริง) ส่วน Service Locator เป็นการ pull dependency ออก​มา​จาก registry ตอน​ที่ code ข้าง​ใน​ทำงาน​ไป​แล้ว

ปัญหา​คือ​การ pull แบบ​นี้​ทำให้ dependency กลาย​เป็น​สิ่ง​ที่​มอง​ไม่​เห็น​จาก API — constructor ที่​ไม่มี parameter ดูเหมือน​ไม่​ต้องการ​อะไร​เลย ทั้ง​ที่​จริง​พึ่งพา service หลาย​ตัว​ซ่อน​อยู่​ใน method ละเมิด Explicit Dependencies Principle โดยตรง แม้​เคย​ได้​รับ​ความ​นิยม​มาก​ใน​ยุค Java EE และ .NET ยุค​ก่อน​ที่ DI container จะ​เป็น​มาตรฐาน แต่​ประสบการณ์​การ​ใช้งาน​จริง​ใน​วง​กว้าง​แสดง​ว่า​มัน​สร้าง​ปัญหา​การ​ทดสอบ​และ​การ​บำรุง​รักษา​มากกว่าที่​แก้ จึง​ถูก​นัก​เขียน​หลาย​คน (เช่น Mark Seemann) จัด​เป็น antipattern — กรณี​ทั่วไป​ควร​ใช้ dependency injection แทน

classDiagram
    class Client {
      +CompleteOrder()
    }
    class ServiceLocator {
      -services Map
      +GetService(type) object
      +Register(type, instance)
    }
    class IOrderRepository {
      +Save(order)
    }
    class SqlOrderRepository {
      +Save(order)
    }
    class InMemoryOrderRepository {
      +Save(order)
    }
    Client --> ServiceLocator
    ServiceLocator o-- IOrderRepository
    IOrderRepository <|.. SqlOrderRepository
    IOrderRepository <|.. InMemoryOrderRepository

Client ไม่รู้จัก SqlOrderRepository โดยตรง — มัน​รู้จัก​แค่ ServiceLocator และ​เรียก GetService() เพื่อ​ขอ instance ของ IOrderRepository ณ จุด​ที่​ต้อง​ใช้ ServiceLocator เก็บ map ระหว่าง type (หรือ key) กับ instance ที่​ถูก register ไว้​ล่วงหน้า มัน​จึง​ทำ​หน้าที่​เหมือน DI container ตัว​หนึ่ง แต่​ต่าง​กัน​ตรง​จุด​ที่ client code ต้อง “รู้จัก​และ​เรียกหา” registry เอง แทนที่ registry จะ​ฉีด instance ให้​ตั้งแต่​แรก

การ​ทำงาน​แบ่ง​เป็น​สอง​ช่วง​ชัดเจน คือ​ช่วง ลง​ทะเบียน (registration) ตอน startup และ​ช่วง ค้นหา (resolution) ตอน runtime ทุก​ครั้ง​ที่ code ต้องการ dependency

sequenceDiagram
    participant App as Application Startup
    participant SL as ServiceLocator
    participant Client as CheckoutService
    participant Repo as SqlOrderRepository
    App->>SL: Register IOrderRepository กับ SqlOrderRepository
    Client->>SL: GetService of IOrderRepository
    SL->>SL: ค้นหาใน registry ภายใน
    SL-->>Client: คืน instance ของ SqlOrderRepository
    Client->>Repo: Save order
  1. ตอน startup application register ทุก implementation ที่​ต้องการ​ไว้​ใน locator ล่วงหน้า (คล้าย​ขั้นตอน composition root ของ DI container)
  2. ตอน​ทำงาน​จริง เมื่อ CheckoutService ต้องการ IOrderRepository มัน​เรียก ServiceLocator.GetService<IOrderRepository>() โดยตรง​จาก​ภายใน method ไม่ใช่​รับ​ผ่าน constructor
  3. locator ค้นหา​ใน map ภายใน ถ้า​เจอ​ก็​คืน instance กลับ ถ้า​ไม่​เจอ — เพราะ​ลืม register หรือ​พิมพ์ type ผิด — จะ​ได้ exception ตอน runtime ไม่ใช่​ตอน compile
  4. CheckoutService ใช้ instance ที่​ได้​มา​ทำงาน​ต่อ โดยที่ signature ของ constructor ไม่​เคย​บอก​เลย​ว่า class นี้​ต้อง​พึ่งพา IOrderRepository

จุด​สำคัญ​คือ locator เอง “รู้จัก​วิธี​สร้าง/หา service ทุก​ตัว” เหมือน DI container แต่ การ​ควบคุม​ยัง​อยู่​ที่ client — client ต้อง​เป็น​ฝ่าย​เอื้อม​มือ​ไป​หยิบ ไม่ใช่ container เป็น​ฝ่าย​ยื่น​ให้ จึง​ทำให้​หลาย​คน​มอง​ว่า​นี่​คือ Inversion of Control ที่​ไม่​สมบูรณ์ — ยัง​คง Control ไว้​ที่ client เหมือน​เดิม เพียง​แต่​เปลี่ยน​จาก new SqlOrderRepository() เป็น ServiceLocator.GetService<IOrderRepository>()

// version Service Locator — แสดงให้เห็นปัญหา ไม่แนะนำให้ใช้ใน code ใหม่
public static class ServiceLocator
{
private static readonly Dictionary<Type, object> _services = new();
public static void Register<TService>(TService instance)
=> _services[typeof(TService)] = instance!;
public static TService GetService<TService>()
{
if (_services.TryGetValue(typeof(TService), out var service))
return (TService)service;
// ปัญหาหลัก: exception นี้เกิดตอน runtime ไม่ใช่ตอน compile
throw new InvalidOperationException(
$"ไม่พบ service ชนิด {typeof(TService).Name} ใน registry — ลืม Register หรือเปล่า");
}
}
public interface IOrderRepository
{
void Save(Order order);
}
public class SqlOrderRepository : IOrderRepository
{
public void Save(Order order) { /* บันทึกลงฐานข้อมูลจริง */ }
}
// ✗ dependency ซ่อน — constructor ว่างดูเหมือนไม่ต้องการอะไรเลย
public class CheckoutService
{
public void CompleteOrder(Order order)
{
// ดึง dependency จาก registry กลาง ณ จุดใช้งาน — มองไม่เห็นจาก signature ภายนอก
var repo = ServiceLocator.GetService<IOrderRepository>();
repo.Save(order);
}
}
// ตอน startup ต้อง register ทุก service ไว้ก่อน มิเช่นนั้น CheckoutService จะพังตอน runtime
ServiceLocator.Register<IOrderRepository>(new SqlOrderRepository());
var checkout = new CheckoutService();
checkout.CompleteOrder(new Order());
// ✓ ทางเลือกที่แนะนำ — Dependency Injection ผ่าน constructor
public class CheckoutServiceWithDi
{
private readonly IOrderRepository _repo;
// ประกาศให้เห็นชัดว่าต้องการอะไร — compiler บังคับให้ผู้เรียกส่งมาให้ครบ
public CheckoutServiceWithDi(IOrderRepository repo) => _repo = repo;
public void CompleteOrder(Order order) => _repo.Save(order);
}
// ทดสอบง่ายกว่ามาก — ไม่ต้อง setup/teardown registry กลางที่ใช้ร่วมกันทั้ง app
var fakeRepo = new Mock<IOrderRepository>();
var sut = new CheckoutServiceWithDi(fakeRepo.Object);
sut.CompleteOrder(new Order());
fakeRepo.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);

สังเกต​ว่า CheckoutServiceWithDi ทดสอบ​ได้​ตรง​ไป​ตรง​มา — สร้าง instance พร้อม mock แล้ว​เรียก​ใช้ ไม่​ต้อง register อะไร​กับ global state ก่อน ใน​ขณะ​ที่ CheckoutService version Service Locator ทุก test ต้อง register IOrderRepository ไว้​ใน ServiceLocator static ก่อน​เสมอ และ​ต้อง​ระวัง​ไม่​ให้ test แต่ละ​ตัว​รบกวน state ที่​ใช้​ร่วม​กัน

  • โครงสร้าง​แบบ plugin/add-in ที่ implementation จริง​ยัง​ไม่รู้จัก​ตอน compile time และ​ต้อง​ค้นหา/โหลด​แบบ dynamic ตอน runtime
  • code legacy ที่ framework เป็น​ผู้​สร้าง object ให้​เอง (เช่น ASP.NET WebForms Page, บาง handler ของ message queue) จน​ไม่มี​ทาง​ควบคุม constructor ได้​โดยตรง — Service Locator เป็น​สะพาน​ชั่วคราว​ไป​สู่ dependency ที่​ต้องการ
  • เครื่องมือ/script ขนาด​เล็ก​มาก​ที่​การ​ตั้ง DI container เต็ม​รูปแบบ​สิ้น​เปลือง​เกิน​ความ​จำเป็น​ของ​งาน
  • ต้องอินทิเกรตกับ framework ภายนอก​ที่​บังคับ​ให้​เข้าถึง service แบบ static/global อยู่​แล้ว (เช่น บาง game engine หรือ SDK ของ​บุคคล​ที่​สาม)
  • project ใหม่​ทุก​กรณี​ที่​มี DI container ให้​ใช้​อยู่​แล้ว (เช่น Microsoft.Extensions.DependencyInjection ของ ASP.NET Core) — ใช้ constructor injection แทน​ได้​เสมอ
  • ใน​ชั้น domain logic หรือ business logic ที่​ควร​ทดสอบ​ได้​ง่าย​และ​ไม่​ควร​รู้จัก infrastructure concern ใด ๆ เลย รวม​ถึง registry กลาง
  • เมื่อ​ทีม​ให้​ความ​สำคัญ​กับ unit test ที่​รวดเร็ว​และ​แยก​จาก​กัน (isolated) — global state ที่ share ข้าม test จะ​สร้าง​ปัญหา flaky test
  • เมื่อ​ทีม​ต้องการ​ให้ compiler ช่วย​จับ dependency ที่​ขาด​หาย​ตั้งแต่​ตอน build ไม่ใช่​รอ​เจอ exception ตอน production
ด้านรายละเอียด
ข้อดีสลับ implementation ตอน runtime ได้​โดย​ไม่​ต้อง compile ใหม่
ข้อดีมี​ประโยชน์​เป็น​สะพาน​ใน code legacy ที่​ยัง​ไม่มี composition root หรือ DI framework
ข้อดีรองรับ registry หลาย​ชุด​แยก​ตาม context หรือ scope (เช่น test locator แยก​จาก production locator)
ข้อ​เสียซ่อน dependency ไว้​ใน method — ละเมิด Explicit Dependencies Principle
ข้อ​เสียerror ที่​ควร​จับ​ได้​ตอน compile กลาย​เป็น exception ตอน runtime
ข้อ​เสียทุก class ต้อง​รู้จัก​และ​ผูก​กับ locator เอง — เพิ่ม coupling กับ infrastructure ทั่ว​ทั้ง app
ข้อ​เสียทดสอบ​ยาก​ขึ้น เพราะ​ต้อง setup/teardown global registry ระหว่าง test แต่ละ​ตัว
ข้อ​เสียเพิ่ม​ความ​เสี่ยง​ตอน refactor เพราะ compiler ช่วย​บอก​ไม่​ได้​ว่า​ใคร​พึ่งพา service ตัว​ไหน​บ้าง