حقن التبعيات ودورة حياة الـ Bean

نطاقات الـ Bean

18 دقيقة الدرس 5 من 13

نطاقات الـ Bean

لكل bean في Spring نطاق (scope) — قاعدة تحكم عدد النسخ التي ينشئها Spring ومدة حياة كل نسخة. اختيار النطاق الخاطئ هو أحد أكثر مصادر الأخطاء الخفية شيوعًا في تطبيقات Spring، لذا فإنّ الفهم العميق لكل نطاق أمر لا غنى عنه لأي مطوّر محترف.

النطاقات الستة المدمجة

يأتي Spring مع ستة نطاقات مدمجة. اثنان منها متاحان دائمًا (singleton وprototype)؛ وأربعة تتطلب ApplicationContext مدركًا للويب (request وsession وapplication وwebsocket). يغطي هذا الدرس الأربعة التي ستصادفها يوميًا.

Singleton — النطاق الافتراضي

عندما لا يُعلَن عن نطاق، يستخدم Spring النطاق singleton: ينشئ الحاوي نسخةً واحدةً بالضبط من الـ bean لكل ApplicationContext، ويخزّنها في ذاكرة التخزين المؤقت، ويحقن تلك المرجعية ذاتها في كل فئة تعتمد عليها. تُنشأ النسخة عند بدء تشغيل السياق (التهيئة الفورية) وتُدمَّر عند إغلاقه.

import org.springframework.stereotype.Service; // لا توجد تعليمة @Scope — singleton هو الافتراضي @Service public class ExchangeRateService { private double eurToUsd = 1.08; // حالة مشتركة — كل مستدعٍ يرى هذا public double convert(double euros) { return euros * eurToUsd; } public void updateRate(double rate) { this.eurToUsd = rate; // تعديل الحالة المشتركة } }
الحالة القابلة للتعديل في الـ singleton تُشكّل خطرًا على التزامن. لأن الكائن ذاته يُشارَك عبر جميع الخيوط، فإن الحقول غير المتزامنة مثل eurToUsd أعلاه يمكن قراءتها وكتابتها في آنٍ واحد. صمّم الـ singleton ليكون عديم الحالة أو غير قابل للتعديل فعليًا — أو تزامن صراحةً. معظم الخدمات (المستودعات، عملاء HTTP، الحاسبات) عديمة الحالة بطبيعتها وتُعدّ singleton ممتازة.

يمكنك أيضًا تعريف النطاق صراحةً للتوضيح:

import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Service; @Service @Scope("singleton") // مطابق للافتراضي؛ مكتوب هنا للتوثيق public class ExchangeRateService { ... }

Prototype — نسخة جديدة في كل مرة

مع نطاق prototype ينشئ Spring نسخةً جديدةً تمامًا في كل مرة يُطلَب فيها الـ bean — سواء عبر applicationContext.getBean() أو عبر الحقن. لا يخزّن Spring نسخ prototype ولا يستدعي تابع @PreDestroy الخاص بها؛ وتنظيف دورة الحياة يقع على عاتق المستدعي.

import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component; @Component @Scope("prototype") public class CsvReportBuilder { private final StringBuilder buffer = new StringBuilder(); public CsvReportBuilder appendRow(String... columns) { buffer.append(String.join(",", columns)).append("\n"); return this; } public String build() { return buffer.toString(); } }

كل استدعاء لـ getBean(CsvReportBuilder.class) (أو كل نقطة حقن تطلب واحدةً) تحصل على buffer نظيف خاص بها. استخدم prototype للـ bean التي تحمل حالةً قابلةً للتعديل خاصة بعملية معينة — منشئو التقارير، كائنات الأوامر، المحللون — حيث إن مشاركة نسخة واحدة ستُسبّب تلف البيانات.

حقن prototype داخل singleton هو فخ كلاسيكي. عندما يحقن Spring نسخة prototype في حقل singleton فإنه يفعل ذلك مرةً واحدة عند بدء التشغيل، فيظل الـ singleton يحمل دائمًا نفس نسخة prototype — مما يُفسد الغرض منها. الحل هو حقن ApplicationContext أو استخدام lookup method عبر @Lookup حتى يجلب الـ singleton نسخة prototype جديدة عند كل استخدام. ستشاهد هذا النمط في الدرس التالي.

نطاق Request — نسخة واحدة لكل طلب HTTP

في تطبيق الويب ينشئ نطاق request نسخةً واحدةً من الـ bean طوال عمر طلب HTTP واحد. تُنشأ النسخة عند وصول الطلب وتُحذف عند إرسال الاستجابة. يمكن حقنها في الـ singleton بأمان عبر وكيل نطاقي (scoped proxy) — يضع Spring وكيلًا في وقت الترجمة؛ وفي وقت التشغيل يُفوّض الوكيل إلى النسخة الخاصة بالطلب الحالي.

import org.springframework.context.annotation.Scope; import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import org.springframework.web.context.WebApplicationContext; @Component @Scope( value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS ) public class RequestContext { private String correlationId; private String authenticatedUser; public void setCorrelationId(String id) { this.correlationId = id; } public String getCorrelationId() { return correlationId; } public void setAuthenticatedUser(String u) { this.authenticatedUser = u; } public String getAuthenticatedUser() { return authenticatedUser; } }

يُعبئ فلتر أو معترض singleton كائن RequestContext عند بداية كل طلب، ويمكن لأي خدمة في اتجاه المصب حقنه لقراءة المستخدم الحالي أو معرّف الارتباط — دون تمرير معاملات عبر كل استدعاء تابع. هذا بديل نظيف لـ ThreadLocal.

ضع دائمًا proxyMode = ScopedProxyMode.TARGET_CLASS عند حقن bean بنطاق request أو session داخل singleton. بدون وكيل لا يستطيع Spring إنشاء الـ singleton لأن الـ bean بنطاق request غير موجودة بعد عند بدء تشغيل السياق — وستحصل على BeanCreationException.

نطاق Session — نسخة واحدة لكل جلسة HTTP

يربط نطاق session نسخةً واحدةً من الـ bean بجلسة HTTP (الجلسة التي ينشئها حاوي servlet، وتُعرَّف بملف تعريف ارتباط أو إعادة كتابة URL). تظل النسخة قائمةً عبر طلبات متعددة من المستخدم ذاته حتى تنتهي صلاحية الجلسة أو تُبطَل.

import org.springframework.context.annotation.Scope; import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import org.springframework.web.context.WebApplicationContext; import java.util.ArrayList; import java.util.List; @Component @Scope( value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS ) public class ShoppingCart { private final List<CartItem> items = new ArrayList<>(); public void add(CartItem item) { items.add(item); } public void remove(CartItem item) { items.remove(item); } public List<CartItem> getItems() { return List.copyOf(items); } public int size() { return items.size(); } }

يحقن المتحكم الذي يعالج POST /cart/add كائن ShoppingCart كتبعية عادية. يُوجّه الوكيل الخاص بـ Spring كل استدعاء بشفافية إلى سلة التسوق التابعة لجلسة المستخدم الحالي.

الـ bean ذات نطاق session يجب أن تكون قابلة للتسلسل في البيئات المجمّعة. عندما تُنسَّخ الجلسات عبر العقد (مثل تخزين الجلسات في Redis) يُسلسَل الـ bean ويُسترجع. نفّذ الواجهة java.io.Serializable وأضف serialVersionUID إن كان تطبيقك سيعمل خلف موازن تحميل.

اختيار النطاق الصحيح — دليل القرار

  • الخدمات عديمة الحالة، المستودعات، الإعدادات، عملاء HTTPsingleton. آمن للخيوط بحكم تصميمه، والخيار الأقل تكلفةً.
  • الكائنات ذات الحالة الخاصة بعملية معينة (منشئو التقارير، المحللون، كائنات الأوامر) → prototype. كل مستدعٍ يحصل على لوحة بيضاء نظيفة.
  • البيانات المشتركة عبر الطلب (معرّف الارتباط، سياق التدقيق، المستأجر الحالي) → request مع وكيل نطاقي.
  • حالة المستخدم عبر طلبات متعددة (سلة التسوق، خطوة المعالج، تفضيلات المستخدم) → session مع وكيل نطاقي.

الوكيل النطاقي من الداخل

عندما تحدد proxyMode = ScopedProxyMode.TARGET_CLASS يستخدم Spring مكتبة CGLIB لتوليد فئة فرعية من الـ bean الخاصة بك عند بدء التشغيل. تلك الفئة الفرعية هي الـ singleton الفعلي المخزَّن في السياق. كل استدعاء تابع عليها يستشير متغير ThreadLocal لتحديد النسخة الحقيقية المرتبطة بالطلب أو الجلسة الحالية، ثم يُحيل الاستدعاء إليها. النتيجة: متحكمك singleton لا يحمل أبدًا مرجعًا قديمًا، وتكتب كود الحقن العادي دون أي شيفرة خيوط معقدة.

الخلاصة

يحدد نطاق الـ bean عمر النسخة. singleton هو الافتراضي الصحيح للمتعاونين عديمي الحالة؛ أبعد الحالة المشتركة عن الـ singleton أو تزامن عليها بعناية. prototype يوفر نسخةً جديدةً عند كل طلب ويناسب الكائنات ذات الحالة الجوهرية قصيرة العمر. نطاقا request وsession يربطان الـ bean مباشرةً بدورة حياة طلب أو جلسة HTTP ويلغيان الحاجة إلى ThreadLocal أو إدارة سمات الجلسة يدويًا — لكن اقرن دائمًا هذين النطاقين بـ ScopedProxyMode.TARGET_CLASS عند الحقن في الـ singleton.