Service Locator เป็นรูปแบบสถาปัตยกรรมที่ให้ทะเบียนบริการกลาง โค้ดไคลเอนต์ขอบริการผ่านตัวระบุตำแหน่งแบบสแตติก โดยไม่ต้องสร้างโดยตรงหรือรับผ่านคอนสตรัคเตอร์ Service Locator มักถูกพิจารณาว่าเป็นทางเลือกของ Dependency Injection: ใช้งานได้ง่ายกว่า แต่ซ่อนการพึ่งพาและทำให้การทดสอบซับซ้อน รูปแบบนี้ถูกใช้งานผ่านคลาส Singleton ทั่วโลกที่มีการลงทะเบียนและแก้ไขบริการ รายละเอียดเพิ่มเติม — ใน การเปรียบเทียบ DI และ Service Locator โดย Martin Fowler
ประเด็นสำคัญ
Service Locator เป็นรูปแบบที่รวมศูนย์การสร้างและการให้บริการ มันขึ้นอยู่กับคลาส Singleton (Locator) ที่มีทะเบียนบริการ: พจนานุกรมที่คีย์เป็นประเภทบริการ (หรือตัวระบุ) และค่าเป็นการใช้งานที่เป็นรูปธรรม โค้ดไคลเอนต์เรียก ServiceLocator.resolve(ServiceProtocol.self) และได้รับอินสแตนซ์ที่พร้อมใช้งาน รูปแบบไม่ได้กำหนดว่าบริการถูกสร้างขึ้นอย่างไร — โรงงาน คอนเทนเนอร์ DI หรือ new ภายในตัวระบุตำแหน่ง
โครงสร้างของรูปแบบ — ทะเบียน (พจนานุกรมประเภท [String: Any]), ตัวระบุตำแหน่ง (คลาสสแตติกที่มี register และ resolve), บริการ (บริการที่กำลังลงทะเบียน) ทะเบียนสามารถเก็บโรงงาน (closure/lambda สำหรับสร้างวัตถุ) หรืออินสแตนซ์ที่พร้อมใช้งาน ตัวระบุตำแหน่งสามารถเป็นทั่วโลก (หนึ่งต่อแอปพลิเคชัน) หรือแบบมีขอบเขต (ต่อคุณลักษณะ/โมดูล) การแก้ไขบริการคือการค้นหาในพจนานุกรมตามประเภท Swift และ Kotlin ใช้ประเภทเป็นคีย์ผ่านเมตาไทป์: ObjectIdentifier(ServiceProtocol.self)
| ส่วนประกอบ | ความรับผิดชอบ | Swift/Kotlin |
|---|---|---|
| ServiceLocator | การเข้าถึงบริการทั่วโลก | class ServiceLocator |
| Registry | ที่เก็บโรงงาน/อินสแตนซ์ | [ObjectIdentifier: Any] |
| Service | การใช้งานที่เป็นรูปธรรม | NetworkService() |
ประวัติของรูปแบบ — Service Locator ถูกอธิบายในหนังสือ Java Patterns (1998) และต่อมาใน Core J2EE Patterns (2001) บทความปี 2004 ของ Martin Fowler เปรียบเทียบ Service Locator กับ DI โดยระบุว่า Service Locator เป็น "ทางเลือกที่ง่ายกว่า แต่แย่กว่าสำหรับการทดสอบ" ในการพัฒนามือถือ Service Locator ถูกใช้ในโปรเจกต์ Android ยุคแรกและแอป iOS ก่อนการมาถึงของ Dagger และ Swinject ปัจจุบันรูปแบบนี้พบได้บ่อยในโปรเจกต์เดิมและต้นแบบ
Service Locator ใน Swift — การใช้งานผ่านคุณสมบัติสแตติกและพจนานุกรมที่ปลอดภัยต่อเธรด ใช้ทะเบียนที่มี ObjectIdentifier(Protocol.self) เป็นคีย์และโรงงาน (() -> Any) เป็นค่า การเริ่มต้นแบบขี้เกียจ (lazy var) เป็นแนวปฏิบัติมาตรฐาน: บริการจะถูกสร้างเมื่อมีการร้องขอครั้งแรก Swift ต้องการการแปลงประเภทอย่างชัดเจนระหว่าง resolve: guard let service = locator.resolve(ServiceProtocol.self) else { return }
final class ServiceLocator {
static let shared = ServiceLocator()
private var registry: [ObjectIdentifier: Any] = [:]
private let lock = NSLock()
func register<T>(_ type: T.Type, factory: @escaping () -> T) {
lock.lock()
registry[ObjectIdentifier(type)] = factory
lock.unlock()
}
func resolve<T>(_ type: T.Type) -> T {
lock.lock()
defer { lock.unlock() }
guard let factory = registry[ObjectIdentifier(type)] as? () -> T else {
fatalError("Service \(type) not registered")
}
return factory()
}
func reset() {
lock.lock()
registry.removeAll()
lock.unlock()
}
}
// การลงทะเบียน
ServiceLocator.shared.register(NetworkServiceProtocol.self) { NetworkService() }
// การใช้งาน
let network = ServiceLocator.shared.resolve(NetworkServiceProtocol.self)
การจัดการขอบเขต — ตัวระบุตำแหน่งสามารถเก็บโรงงาน (transient — วัตถุใหม่ทุกครั้ง) หรืออินสแตนซ์ที่พร้อมใช้งาน (singleton) สำหรับโรงงาน closure จะถูกลงทะเบียนและเรียกทุกครั้งที่ resolve สำหรับ singleton closure จะเก็บอินสแตนซ์ที่สร้างไว้ การเพิ่มขอบเขต: .transient, .singleton, .weak (การอ้างอิงแบบอ่อน — วัตถุมีชีวิตตราบเท่าที่มีใครถือการอ้างอิง) ขอบเขต weak สะดวกสำหรับ UIViewController ของ UIKit เพื่อหลีกเลี่ยงการรั่วไหลเมื่อ pop/dismiss
Service Locator ใน Kotlin — การใช้งานที่กะทัดรัดผ่าน object (Singleton) พร้อมฟังก์ชัน inline reified เพื่อความปลอดภัยของประเภท Kotlin อนุญาตให้สร้างตัวระบุตำแหน่งที่กระชับ: val service by locator พร้อมตัวแทน ทำให้โค้ดสะอาดขึ้น Reified generics () แทนที่ ObjectIdentifier — ประเภทได้มาจากเจเนอริก ตัวระบุตำแหน่ง Kotlin มักใช้ ConcurrentHashMap เพื่อความปลอดภัยต่อเธรดโดยไม่ต้องล็อกอย่างชัดเจน
object ServiceLocator {
private val registry = ConcurrentHashMap<Class<*>, () -> Any>()
inline fun <reified T: Any> register(noinline factory: () -> T) {
registry[T::class.java] = factory
}
@Suppress("UNCHECKED_CAST")
inline fun <reified T: Any> resolve(): T {
val factory = registry[T::class.java]
?: throw IllegalStateException("Service ${T::class.simpleName} not registered")
return factory() as T
}
fun clear() {
registry.clear()
}
}
// การลงทะเบียน
ServiceLocator.register<ApiService> { RetrofitApiService() }
// การใช้งานในคลาส
class UserRepository {
private val api: ApiService = ServiceLocator.resolve()
private val db: Database = ServiceLocator.resolve()
}
// ตัวแทนสำหรับการแก้ไขแบบขี้เกียจ
class LocatorDelegate<reified T: Any> : Lazy<T> {
override val value: T get() = ServiceLocator.<T>resolve()
override fun isInitialized(): Boolean = true
}
inline fun <reified T: Any> locator(): Lazy<T> = LocatorDelegate()
// การใช้งาน: val api by locator()
Service Locator ใน Android — พบในโปรเจกต์เก่าก่อน Dagger Jetpack Hilt และ Koin ได้แทนที่ Service Locator ในชุมชน Android อย่างไรก็ตาม ตัวระบุตำแหน่งยังคงเกี่ยวข้องสำหรับการทดสอบหน่วย: ServiceLocator stub ง่าย ๆ พร้อมบริการ mock โดยไม่ต้องใช้ Hilt ข้อดี: ไม่ต้องรอการคอมไพล์ Dagger สำหรับการทดสอบ ข้อเสีย: หากลืมแทนที่ตัวระบุตำแหน่งในการทดสอบ การทดสอบจะใช้บริการจริง
ความชัดเจนของการพึ่งพา — ความแตกต่างหลัก DI ประกาศการพึ่งพาอย่างชัดเจน: init(service: ServiceProtocol) — IDE ใด ๆ ก็แสดงการพึ่งพาของคลาส Service Locator ซ่อนมัน: dependencies = ServiceLocator.resolve() — ซ่อนอยู่ภายในเมธอด ด้วย DI คุณสามารถเห็นการพึ่งพาทั้งหมดของคลาสได้ทันที ด้วย Service Locator คุณต้องอ่านทั้งเนื้อหาของคลาส สิ่งนี้ทำให้โค้ด Service Locator คาดเดาได้น้อยลง: การเปลี่ยนทะเบียนสามารถทำลายคลาสใด ๆ ที่ใช้ตัวระบุตำแหน่ง
| คุณลักษณะ | Service Locator | Dependency Injection |
|---|---|---|
| การมองเห็นการพึ่งพา | ซ่อนอยู่ในเนื้อหาเมธอด | ชัดเจนในคอนสตรัคเตอร์ |
| การทดสอบ | กำหนดค่าทะเบียนทั่วโลก | Mock ในคอนสตรัคเตอร์ |
| ความเป็นโมดูล | ทะเบียนทั่วโลก — ไม่เป็นโมดูล | โมดูลที่มีคอนเทนเนอร์แยก |
| ความซับซ้อน | การใช้งานง่าย 50-100 บรรทัด | ต้องใช้ Dagger/Swinject |
| เวลาในการออกสู่ตลาด | เริ่มต้นรวดเร็ว | ต้องตั้งค่าคอนเทนเนอร์ |
เมื่อใด Service Locator สมเหตุสมผล — ต้นแบบและ MVP (เริ่มต้นรวดเร็วโดยไม่ต้องกำหนดค่า) โปรเจกต์เดิมที่ไม่สามารถเพิ่มเฟรมเวิร์ก DI ได้ (การ build ที่ซับซ้อน ข้อจำกัดของ linter) ไลบรารีเครื่องมือ (การบันทึก, การรายงานข้อขัดข้อง) — มันเป็นทั่วโลกอยู่แล้ว สำหรับแอปพลิเคชันจริงที่มีทีม 3+ นักพัฒนา DI เป็นที่แนะนำ: การพึ่งพาที่ชัดเจนลดจำนวนข้อผิดพลาดระหว่างการปรับโครงสร้างและทำให้การเริ่มต้นนักพัฒนาใหม่ง่ายขึ้น
การพึ่งพาที่ซ่อนอยู่ — คลาสที่ใช้ ServiceLocator.resolve() ภายในเมธอดไม่สามารถวิเคราะห์แบบสแตติกได้ IDE ไม่แสดงการพึ่งพา คอมไพเลอร์ไม่ตรวจสอบว่าบริการถูกลงทะเบียนหรือไม่ ข้อผิดพลาด "Service not registered" เกิดขึ้นเฉพาะตอนรันไทม์ การปรับโครงสร้างกลายเป็นอันตราย: การลบบริการออกจากทะเบียนสามารถทำลายคลาสใด ๆ ในแอปพลิเคชัน DI แก้ปัญหานี้ผ่านการตรวจสอบเวลาคอมไพล์ (Dagger) หรือคอนสตรัคเตอร์ที่ชัดเจน
ปัญหาการทดสอบ — แต่ละการทดสอบต้องกำหนดค่า ServiceLocator.shared ด้วยการพึ่งพาทั้งหมด หลังการทดสอบ — รีเซ็ตสถานะ ระหว่างการดำเนินการทดสอบแบบขนาน สถานะทั่วโลกของ ServiceLocator.shared นำไปสู่สภาวะการแข่งขัน: การทดสอบหนึ่งลงทะเบียน mock การทดสอบอื่นได้รับ mock ของคนอื่น วิธีแก้ไข: ตัวระบุตำแหน่งแบบมีขอบเขต (หนึ่งต่อการทดสอบ) หรือ ThreadLocal DI แก้ปัญหานี้ตั้งแต่ต้น: แต่ละการทดสอบสร้างอินสแตนซ์ของตัวเองด้วยการพึ่งพาแบบ mock
// ปัญหาการทดสอบ Service Locator
class LoginViewModelTests: XCTestCase {
override func setUp() {
super.setUp()
// การตั้งค่าทะเบียนทั่วโลกสำหรับการทดสอบ
ServiceLocator.shared.register(AuthServiceProtocol.self) { MockAuthService() }
}
override func tearDown() {
ServiceLocator.shared.reset()
super.tearDown()
}
func testLogin() {
let viewModel = LoginViewModel() // ใช้ ServiceLocator ภายใน
// การทดสอบ...
}
}
ทางเลือกของ Service Locator — DI (Dagger, Swinject), Factory Method, Ambient Context Factory Method — รูปแบบง่าย ๆ ไม่มีสถานะทั่วโลก: คลาสโรงงานสร้างบริการและส่งผ่านคอนสตรัคเตอร์ Ambient Context — ทางเลือกที่ปลอดภัยต่อเธรดสำหรับความกังวลข้ามส่วน (การบันทึก, การอนุญาต) ทางเลือกที่ดีที่สุดคือ Constructor Injection พร้อมโรงงานแบบแมนนวลโดยไม่มีเฟรมเวิร์ก DI: การสร้างการพึ่งพาอย่างชัดเจนในโรงงานพร้อมการส่งผ่านคอนสตรัคเตอร์ให้ความชัดเจนของ DI โดยไม่มีความซับซ้อนของการตั้งค่า Dagger/Swinject
คำถามที่พบบ่อย
นักพัฒนาหลายคนถือว่า Service Locator เป็นรูปแบบต้านเพราะมันซ่อนการพึ่งพา ทำให้การทดสอบซับซ้อน และสร้างการเชื่อมโยงที่ซ่อนอยู่ระหว่างคลาส อย่างไรก็ตาม ในต้นแบบ โปรเจกต์เล็ก และสำหรับบริการทั่วโลก (การบันทึก, การวิเคราะห์) Service Locator อาจสมเหตุสมผล การตัดสินใจขึ้นอยู่กับบริบท: สำหรับแอปพลิเคชันจริงที่มีทีม — DI สำหรับนักพัฒนาคนเดียวบนต้นแบบ — Service Locator
คอนเทนเนอร์ DI (Dagger, Swinject) ฉีดการพึ่งพาไปยังวัตถุโดยอัตโนมัติ — วัตถุไม่รู้เกี่ยวกับการมีอยู่ของคอนเทนเนอร์ Service Locator — วัตถุเองขอการพึ่งพาจากทะเบียน คอนเทนเนอร์ DI ปฏิบัติตามหลักการ IoC; Service Locator ละเมิดมัน: วัตถุจัดการการได้รับการพึ่งพาของตัวเอง คอนเทนเนอร์ DI ทำงานก่อนการสร้างวัตถุ (ผ่านคอนสตรัคเตอร์) Service Locator — ที่ใดก็ได้ในโค้ด
Service Locator สมเหตุสมผลใน iOS สำหรับ: บริการทั่วโลก (Analytics, Logger, Crashlytics) ต้นแบบที่การตั้งค่า Swinject มากเกินไป และสำหรับการทดสอบหน่วยของโมดูลเดิมขนาดใหญ่ สำหรับโปรเจกต์ iOS ใหม่ แนะนำให้ใช้ Swinject หรือ DI แบบแมนนวลผ่านคอนสตรัคเตอร์ SwiftUI กับ @Environment — ก็เป็นรูปแบบของ DI ที่หลีกเลี่ยง Service Locator
ใช้คอลเล็กชันที่ปลอดภัยต่อเธรด (NSLock ใน Swift, ConcurrentHashMap ใน Kotlin) สำหรับการทดสอบ — ThreadLocal หรือตัวระบุตำแหน่งแบบมีขอบเขต ทางเลือก: พื้นที่เก็บข้อมูล async-local — บริการผูกกับ coroutine/actor ทางออกที่ดีที่สุดคือหลีกเลี่ยง Service Locator สำหรับการทดสอบแบบขนานและใช้ DI พร้อมการสร้างวัตถุอย่างชัดเจนสำหรับแต่ละการทดสอบ
Service Locator มักถูกใช้งานเป็น Singleton แต่ไม่จำเป็น คุณสามารถสร้างอินสแตนซ์ตัวระบุตำแหน่งสำหรับโมดูล (feature-scoped locator) และส่งผ่านคอนสตรัคเตอร์ ตัวระบุตำแหน่งแบบมีขอบเขตคุณลักษณะแก้ปัญหาสถานะทั่วโลกแต่ไม่แก้ปัญหาการพึ่งพาที่ซ่อนอยู่ รูปแบบนี้เรียกว่า Ambient Context หรือ Scoped Locator
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ