الشبكات وHTTP

العميل HttpClient الحديث

15 دقيقة الدرس 4 من 13

العميل HttpClient الحديث

قبل Java 11، كان إجراء طلب HTTP من كود Java يستلزم إما استخدام الواجهة القديمة المرهقة HttpURLConnection (كثيرة الإسهاب، حاجبة، ومليئة بالمزالق) أو الاعتماد على مكتبة خارجية مثل Apache HttpClient أو OkHttp. جاءت Java 11 بحزمة java.net.http — عميل HTTP حديث ومتوافق مع المعايير، مدمج في JDK. يدعم HTTP/1.1 وHTTP/2، وWebSockets، وإدخال/إخراج غير محجوب بالكامل عبر CompletableFuture، فضلًا عن واجهة بناء متدفقة ونظيفة.

يتناول هذا الدرس القطعتين الأساسيتين في هذه الواجهة: بناء HttpClient وإنشاء HttpRequest. أما إرسال الطلبات ومعالجة الاستجابات فهو موضوع الدرس التالي.

التصميم الجوهري: كائنات قيمة غير قابلة للتغيير تُبنى بالبنّاءات

يتمحور التصميم بأكمله حول نوعين غير قابلين للتغيير:

  • HttpClient — محرك HTTP آمن للخيوط وقابل لإعادة الاستخدام. أنشئ نسخة واحدة لكل تطبيق (أو لكل نطاق منطقي) وأعد استخدامها في جميع الطلبات. تُدفع تكلفة الإنشاء الثقيلة مرة واحدة فقط.
  • HttpRequest — وصف غير قابل للتغيير لطلب واحد (URI، طريقة، رؤوس، جسم). أنشئ نسخة جديدة لكل استدعاء.

يُنشأ كلاهما باستخدام فئات Builder داخلية متشعّبة تتبع نمط البنّاء القياسي في Java الذي تعرفه بالفعل من واجهات Streams وCollections. بمجرد البناء، تصبح النسخ غير قابلة للتغيير وبالتالي آمنة للمشاركة بين الخيوط.

لماذا عدم قابلية التغيير؟ يحمل HttpClient تجمّعات الاتصالات وسياق SSL وحالة المنفّذ. إن جعله غير قابل للتغيير يُزيل أخطاء الحالة المشتركة غير المقصودة في الكود المتزامن — تُعدّه مرة واحدة وتثق أنه لن يتغيّر أبدًا.

إنشاء HttpClient

أبسط عميل ممكن يستخدم جميع القيم الافتراضية:

import java.net.http.HttpClient; HttpClient client = HttpClient.newHttpClient();

تمنحك القيم الافتراضية HTTP/2 مع الرجوع إلى HTTP/1.1، وسياق SSL القياسي لـ JVM، وتجمّع خيوط في الخلفية. هذا يكفي لكثير من التطبيقات. عندما تحتاج إلى تخصيص السلوك، استخدم البنّاء:

import java.net.http.HttpClient; import java.net.http.HttpClient.Redirect; import java.net.http.HttpClient.Version; import java.time.Duration; import java.util.concurrent.Executors; HttpClient client = HttpClient.newBuilder() .version(Version.HTTP_2) // تفضيل HTTP/2 مع الرجوع إلى 1.1 .followRedirects(Redirect.NORMAL) // اتباع 3xx (ليس عبر البروتوكولات) .connectTimeout(Duration.ofSeconds(10)) // فشل سريع على المضيفات غير المتاحة .executor(Executors.newVirtualThreadPerTaskExecutor()) // Java 21+: خيوط افتراضية .build();

خيارات تهيئة HttpClient الرئيسية

  • version()Version.HTTP_2 (افتراضي) أو Version.HTTP_1_1. يُعدّد HTTP/2 طلبات متعددة عبر اتصال TCP واحد، مما يُقلّل زمن الاستجابة عند التوسع. يتفاوض العميل عبر ALPN ويرجع بسلاسة.
  • followRedirects()NEVER أو ALWAYS أو NORMAL (يتبع جميع إعادات التوجيه باستثناء التخفيض من HTTPS إلى HTTP). NORMAL هو الخيار الآمن في الإنتاج.
  • connectTimeout() — المدة الزمنية للانتظار قبل اتصال TCP الأولي. يمنع تعليق الخيوط إلى أجل غير مسمى عند عناوين IP محجوبة بجدار الحماية. ملاحظة: هذا مهلة الاتصال فقط؛ مهلات القراءة/الكتابة تُضبط لكل طلب.
  • executor() — تجمّع الخيوط للعمليات غير المتزامنة. في Java 21+ تمرير Executors.newVirtualThreadPerTaskExecutor() يتيح آلاف الاستدعاءات المتزامنة بتكلفة منخفضة دون ضبط تجمّع خيوط منصّة.
  • sslContext() — مرر SSLContext مخصصًا عند الحاجة إلى TLS المتبادل، أو مخزن ثقة مخصص، أو شهادات موقّعة ذاتيًا في بيئة الاختبار.
  • authenticator()Authenticator لتحديات HTTP Basic/Digest (نادر في واجهات APIs الحديثة؛ معظمها تستخدم رموز bearer في الرؤوس).
  • cookieHandler() — أضف CookieManager لسير عمل ملفات تعريف الارتباط. الافتراضي: لا تُخزَّن ملفات تعريف.
  • proxy() — وجّه الحركة عبر وكيل مؤسسي: ProxySelector.of(new InetSocketAddress("proxy.corp.com", 8080)).
تعامل مع HttpClient كنسخة مفردة على مستوى التطبيق. إنشاء نسخة جديدة لكل طلب يُهدر إعداد تجمّع الاتصالات ويُعطّل إعادة استخدام اتصال HTTP/2. أحقنه عبر حاوية DI بالإطار أو خزّنه في حقل static final إن لزم.

بناء HttpRequest

يجمع HttpRequest كل ما يصف استدعاء HTTP واحدًا: URI الهدف، وطريقة HTTP، والرؤوس، وناشر جسم اختياري، ومهلة اختيارية لكل طلب. أنشئه بـ HttpRequest.newBuilder():

import java.net.URI; import java.net.http.HttpRequest; import java.time.Duration; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/products/42")) .GET() // GET الصريح (وهو أيضًا الافتراضي) .header("Accept", "application/json") .header("Authorization", "Bearer " + token) .timeout(Duration.ofSeconds(30)) .build();

ضبط timeout() على الطلب يعني: إذا لم يُكمل الخادم الاستجابة خلال تلك النافذة الزمنية، يرمي العميل HttpTimeoutException. هذا مختلف عن مهلة الاتصال على العميل — تحتاج عادةً إلى كليهما.

طرق HTTP وناشرو الجسم

استخدم طرق البنّاء الخاصة بكل طريقة. GET وDELETE لا يحملان جسمًا. POST وPUT وPATCH تحمل جسمًا يُعبَّر عنه كـ HttpRequest.BodyPublisher:

import java.net.http.HttpRequest.BodyPublishers; import java.nio.file.Path; // POST بجسم JSON من String HttpRequest postRequest = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/products")) .POST(BodyPublishers.ofString("{\"name\":\"Widget\",\"price\":9.99}")) .header("Content-Type", "application/json") .header("Accept", "application/json") .timeout(Duration.ofSeconds(30)) .build(); // PUT بجسم من ملف (بثّ — لا تحميل كامل في الذاكرة) Path filePath = Path.of("/data/payload.json"); HttpRequest putRequest = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/products/42")) .PUT(BodyPublishers.ofFile(filePath)) .header("Content-Type", "application/json") .build(); // DELETE — لا يلزم ناشر جسم HttpRequest deleteRequest = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/products/42")) .DELETE() .header("Authorization", "Bearer " + token) .build();

أكثر مصانع BodyPublisher المدمجة فائدةً في BodyPublishers:

  • ofString(String) — جسم نصي بترميز UTF-8. الخيار الأكثر شيوعًا لحمولات JSON.
  • ofFile(Path) — يبثّ ملفًا من القرص مباشرةً دون تخزين مؤقت في الكومة. استخدمه للرفع الكبير.
  • ofByteArray(byte[]) — بايتات خام. مفيد عندما تكون الحمولة مُسلسَلة مسبقًا.
  • noBody() — يُعلّم صراحةً الطلب بغياب الجسم. يعادل عدم تمرير ناشر جسم.

ضبط رؤوس متعددة وتجاوز الرؤوس

يوفّر البنّاء كلًا من header(name, value) (الذي يُضيف رأسًا، مع دعم الرؤوس متعددة القيم) وheaders(name, value, name, value, ...) للضبط الجماعي. لاستبدال قيمة مضبوطة سابقًا استخدم setHeader(name, value):

HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/search")) .GET() // ضبط جماعي للرؤوس — أزواج اسم/قيمة بالتسلسل .headers( "Accept", "application/json", "Accept-Language", "en-US,ar;q=0.9", "X-Client-Id", "my-service-v2" ) .timeout(Duration.ofSeconds(20)) .build();
لا تضبط رؤوسًا مقيّدة مثل Host أو Content-Length أو Connection يدويًا. يحسبها JDK ويُطبّقها. محاولة ضبطها ترمي IllegalArgumentException وقت بناء الطلب. راجع Javadoc الخاص بـ HttpRequest للقائمة الكاملة للرؤوس المقيّدة.

إعادة استخدام قالب الطلب مع copy()

لأن HttpRequest غير قابل للتغيير، لا يمكن تعديله بعد البناء. غير أنك تستطيع نسخ بنّاء من طلب موجود وتجاوز حقول محددة:

HttpRequest base = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/products/42")) .header("Authorization", "Bearer " + token) .header("Accept", "application/json") .timeout(Duration.ofSeconds(30)) .build(); // اشتقاق متغيّر POST دون إعادة كتابة كامل التهيئة المشتركة HttpRequest createRequest = HttpRequest.newBuilder(base, (name, value) -> true) .uri(URI.create("https://api.example.com/v1/products")) .POST(BodyPublishers.ofString("{\"name\":\"Gadget\"}")) .build();

يستنسخ منشئ النسخ HttpRequest.newBuilder(existingRequest, headerFilter) جميع الإعدادات؛ لامدا التصفية تحدد أي الرؤوس تُحتجز (true يحتفظ بالجميع).

تجميع الأجزاء: نمط خدمة بسيط

public class ProductApiClient { private static final HttpClient HTTP = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) .followRedirects(HttpClient.Redirect.NORMAL) .connectTimeout(Duration.ofSeconds(10)) .build(); private static final String BASE_URL = "https://api.example.com/v1"; private final String bearerToken; public ProductApiClient(String bearerToken) { this.bearerToken = bearerToken; } private HttpRequest.Builder authorisedBuilder(String path) { return HttpRequest.newBuilder() .uri(URI.create(BASE_URL + path)) .header("Accept", "application/json") .header("Authorization", "Bearer " + bearerToken) .timeout(Duration.ofSeconds(30)); } public HttpRequest buildGetProduct(long id) { return authorisedBuilder("/products/" + id).GET().build(); } public HttpRequest buildCreateProduct(String jsonBody) { return authorisedBuilder("/products") .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) .header("Content-Type", "application/json") .build(); } }

يفصل هذا النمط وصف الطلب عن إرساله، مما يجعل طرق البنّاء قابلة للاختبار بسهولة دون أي استدعاءات شبكة.

الخلاصة

HttpClient محرك قابل لإعادة الاستخدام وآمن للخيوط — أنشئه مرة واحدة وشاركه. HttpRequest وصف غير قابل للتغيير لكل استدعاء — أنشئ نسخة جديدة في كل مرة. هيّئ العميل بالإصدار وسياسة إعادة التوجيه ومهلة الاتصال والمنفّذ. هيّئ الطلب بـ URI والطريقة والرؤوس وناشر الجسم والمهلة لكل طلب. استخدم BodyPublishers لتوفير الأجسام من نصوص أو ملفات أو مصفوفات بايت. بعد تجهيز كلا الكائنين، الخطوة التالية هي الإرسال ومعالجة الاستجابة.