1. أولاً، ما ينبغي فهمه
في تنفيذ اتّصالات TCP، يوجد مفهوم خاطئ شائع جدّاً.
وهو:
يمكن للطرف المستقبِل استقبال البيانات عبر
Receive/Readبنفس الوحدات التي أرسلها الطرف المرسِل عبرSend/Write
هذا هو المفهوم الخاطئ.
على سبيل المثال، لنفترض أنّ الطرف المرسِل أرسل كما يلي:
Send("LOGIN\n")
Send("GET /items\n")
Send("QUIT\n")
عندئذٍ، يُظنّ خطأً أنّ الطرف المستقبِل سيستقبل البيانات 3 مرّات كما يلي.
Receive() => "LOGIN\n"
Receive() => "GET /items\n"
Receive() => "QUIT\n"
لكن هذا غير مضمون في TCP.
في الواقع، قد يحدث أيّ ممّا يلي.
Receive() => "LOGIN\nGET /items\nQUIT\n"
Receive() => "LOG"
Receive() => "IN\nGET /ite"
Receive() => "ms\nQUIT\n"
Receive() => "LOGIN\nGET /items\n"
Receive() => "QUIT"
Receive() => "\n"
كلّ هذه الحالات طبيعيّة تماماً بالنسبة لـ TCP.
ما يضمنه TCP، ببساطة، هو أنّ «سلسلة البايتات المُرسَلة تصل بالترتيب نفسه، دون تكرار، ودون فقدان». أمّا ما لا يضمنه فهو أنّ «الوحدة التي استخدمها التطبيق عند استدعاء Send تُحفَظ كما هي لتكون وحدة استقبال Receive لدى الطرف المستقبِل».
لذلك، تحتاج التطبيقات التي تستخدم TCP إلى آليّة لدى الطرف المستقبِل تحدِّد «أين تبدأ سلسلة البايتات المُستقبَلة حاليّاً وأين تنتهي كرسالة واحدة».
يُطلَق على هذا اسم التأطير (framing) الخاصّ ببروتوكول التطبيق.
في هذا المقال، سنستعرض المفاهيم الخاطئة الشائعة حول العلاقة بين Send وReceive في TCP، والطريقة الصحيحة للتعامل معها في .NET / C#.
علماً بأنّ الشيفرة الواردة في هذا المقال منشورة على GitHub كمجموعة عيّنات كاملة قابلة للبناء والتشغيل (مكتبة، وعرض توضيحيّ لـ TCP عبر loopback، واختبارات وحدة تُحاكي التجزّؤ والدمج والانقطاع في منتصف الاتصال).
tcp-send-receive-message-framing - komurasoft-blog-samples (GitHub)
2. TCP ينقل «سلسلة بايتات» لا «رسائل»
بدايةً، من المهمّ عدم التفكير في TCP وكأنّه طابور رسائل (message queue).
يتعامل TCP مع البيانات الممرَّرة من التطبيق باعتبارها سلسلة بايتات متّصلة.
على سبيل المثال، حتى لو استدعى الطرف المرسِل Send ثلاث مرّات كما يلي،
Send("ABC")
Send("DEF")
Send("GHI")
فمن منظور TCP، هذا في النهاية مجرّد تدفّق من 9 بايتات كما يلي:
ABCDEFGHI
ولا يبقى داخل هذا التدفّق
ABC | DEF | GHI
أيّ أثر لحدود خاصّة بالتطبيق كهذه.
يقرأ الطرف المستقبِل، في لحظة معيّنة، «ما هو متوفّر حاليّاً في مخزن الاستقبال المؤقّت (buffer)» فقط. لذلك يمكن أن تكون نتيجة الاستقبال كما يلي.
| استدعاءات الطرف المرسِل | مثال على ما يظهر لدى الطرف المستقبِل |
|---|---|
Send("ABC"), Send("DEF") |
Receive() مرّة واحدة يُعطي "ABCDEF" |
Send("ABCDEF") |
Receive() مرّتين يُعطي "AB" ثم "CDEF" |
Send("ABC"), Send("DEF"), Send("GHI") |
Receive() 3 مرّات يُعطي "A"، ثم "BCDEFG"، ثم "HI" |
حرف UTF-8 مثل Send("あ") |
قد يُقسَّم في منتصف حرف متعدّد البايتات |
المهمّ هنا هو أنّه لا يوجد أيّ «خلل» في ذلك.
كثير ممّا يبدو مشكلات مثل «فقدان جزء من البيانات المُستقبَلة أحياناً» أو «التصاق عدّة رسائل ببعضها» أو «تشوّه النصوص (mojibake)» ليس خللاً في TCP، بل خطأ تصميميّ ناتج عن تعامل الطرف المستقبِل مع TCP كأنّه ينقل رسائل بوحدات محدَّدة.
3. لماذا يبدو أنّ الاستقبال يتمّ بنفس وحدة الإرسال؟
سبب استمرار هذا المفهوم الخاطئ هو أنّه، في البيئة المحليّة أو مع بيانات صغيرة، كثيراً ما يبدو الأمر كما هو متوقَّع بمحض الصدفة.
في بيئة التطوير، يسهل توفّر الشروط التالية:
- العميل والخادم على نفس الجهاز، أو على شبكة قريبة
- حجم البيانات صغير
- الطرف الآخر يقرأ البيانات فور استقبالها
- يوجد فائض في موارد المعالج والشبكة
- الاختبار يدويّ، وتذبذب التوقيت قليل
- يُستدعى
Receiveمباشرةً بعدSend
في ظلّ هذه الشروط، قد يبدو أنّه يمكن قراءة استدعاء Receive واحد مقابل كلّ استدعاء Send.
لكنّ الشروط تختلف في بيئة الإنتاج.
- تتراكم البيانات في مخازن الإرسال والاستقبال المؤقّتة لنظام التشغيل
- تُجمَّع عدّة عمليّات إرسال صغيرة معاً
- تُقسَّم عمليّة إرسال كبيرة بسبب قيود قطعة TCP (segment) أو مخزن الاستقبال
- يتأخّر جدولة مؤشّر ترابط (thread) الاستقبال
- تدخل طبقات مثل TLS والبروكسي وموازن الحمل وVPN
- يحدث تأخّر أو ازدحام في الشبكة
- يتأثّر الاتصال بخوارزميّة Nagle أو تأخير ACK
والنتيجة هي خلل مزعج من نوع «كان يعمل في بيئة التطوير، لكنّه ينهار أحياناً في بيئة الإنتاج».
في معالجة الشبكات، هذا «العمل بمحض الصدفة» هو الأخطر على الإطلاق.
4. شيفرة استقبال هشّة شائعة
على سبيل المثال، الشيفرة التالية خطيرة.
byte[] buffer = new byte[4096];
int read = await stream.ReadAsync(buffer, cancellationToken);
if (read == 0)
{
// أنهى الطرف الآخر الاتصال بشكل طبيعيّ
return;
}
string message = Encoding.UTF8.GetString(buffer, 0, read);
await HandleMessageAsync(message, cancellationToken);
تفترض هذه الشيفرة أنّه «يمكن الحصول على رسالة واحدة كاملة من استدعاء واحد لـ ReadAsync»، لكنّ هذا الافتراض غير صحيح في TCP.
توجد ثلاث مشكلات رئيسيّة.
المشكلة الأولى هي تجزّؤ رسالة واحدة.
إرسال: {"command":"login","user":"komura"}\n
استقبال1: {"command":"login",
استقبال2: "user":"komura"}\n
في هذه الحالة، إذا حاولنا تحليل «استقبال1» وحده كـ JSON فسيفشل التحليل.
المشكلة الثانية هي دمج عدّة رسائل معاً.
إرسال1: {"command":"login"}\n
إرسال2: {"command":"get"}\n
استقبال: {"command":"login"}\n{"command":"get"}\n
في هذه الحالة، إذا حاولنا تحليل هذا كعنصر JSON واحد فسيفشل التحليل.
المشكلة الثالثة هي التجزّؤ عند حدود ترميز الأحرف.
قد يتكوّن الحرف الواحد في UTF-8 من عدّة بايتات. لا يوجد ما يضمن تطابق حدود ReadAsync مع حدود الحرف.
لذلك، إذا حوّلنا سلسلة البايتات المُستقبَلة إلى نصّ عبر Encoding.UTF8.GetString فوراً في كلّ مرّة، فقد تنكسر البيانات عند تجزّؤ حرف متعدّد البايتات في منتصفه.
الأساس هو عدم «التحويل إلى نصّ فور الاستقبال»، بل «تجميع البيانات كبايتات حتى تُعرَف حدود الرسالة، ثمّ فكّ الترميز بعد اكتمال رسالة واحدة كاملة».
5. لا يجوز استخدام DataAvailable لتحديد نهاية الرسالة
كثيراً ما تُشاهَد شيفرة كهذه أيضاً.
var ms = new MemoryStream();
byte[] buffer = new byte[4096];
while (stream.DataAvailable)
{
int read = await stream.ReadAsync(buffer, cancellationToken);
if (read == 0)
{
break;
}
ms.Write(buffer, 0, read);
}
byte[] message = ms.ToArray();
هذه أيضاً خطيرة. ما تُمثِّله DataAvailable هو «هل توجد في تلك اللحظة بيانات قابلة للقراءة في مخزن الاستقبال المحليّ المؤقّت»، وليس معناها اكتمال رسالة واحدة على مستوى التطبيق.
على سبيل المثال، لنفترض أنّ رسالة واحدة طولها 100 بايت، فقد تصبح DataAvailable قيمتها true بمجرّد وصول أوّل 40 بايتاً فقط، ثمّ تصبح false مؤقّتاً بعد قراءة تلك الـ40 بايتاً مباشرةً. وقد تصل الـ60 بايتاً المتبقّية بعد ذلك بقليل.
في هذه الحالة، إذا فسَّرنا DataAvailable == false على أنّها «نهاية الرسالة»، فسنُعامِل بيانات غير مكتملة كرسالة واحدة.
يمكن استخدام DataAvailable في تحسين حلقة القراءة أو التحقّق شبه غير الحاصر (non-blocking)، لكنّ استخدامها في تحديد حدود البروتوكول غير آمن.
6. الفكرة الصحيحة هي الفصل بين «الاستقبال» و«التفسير»
يسهل تصميم معالجة الاستقبال في TCP إذا فصلنا بين الأمرين التاليين.
الاستقبال: قراءة سلسلة البايتات الواردة من TCP وتجميعها في المخزن المؤقّت (buffer)
التفسير: استخراج رسالة واحدة على مستوى التطبيق من المخزن المؤقّت
Receive / Read هي، في جوهرها، عمليّة «قراءة بايتات» فحسب. أمّا «أين تنتهي رسالة واحدة» فيجب أن يُحدَّد عبر بروتوكول التطبيق.
توجد أربعة أساليب رئيسيّة.
| الأسلوب | المحتوى | الاستخدامات المناسبة |
|---|---|---|
| ثابت الطول | تُعتبَر رسالة واحدة دائماً بعدد بايتات ثابت | الأجهزة القديمة، الرسائل الثنائيّة، أنظمة التحكّم |
| المحدِّد (delimiter) | تُعتبَر رسالة واحدة كلّ ما يسبق سلسلة بايتات مُحدَّدة مثل \n |
الأوامر، السجلّات، NDJSON، البروتوكولات البسيطة |
| البادئة الطوليّة (length prefix) | تُوضَع بادئة تمثّل طول المحتوى في المقدّمة، ثمّ يُقرأ المحتوى بعدد تلك البايتات | الثنائيّ، JSON، MessagePack، Protocol Buffers وغيرها |
| ذاتيّ الوصف | يُعبَّر عن الطول أو نهاية الرسالة داخل التنسيق نفسه كما في Content-Length أو chunked في HTTP |
البروتوكولات القائمة، الاتّصالات التي تحتاج قابليّة توسّع |
شخصيّاً، عند تصميم بروتوكول خاصّ بي، أبدأ عادةً بالنظر في أسلوب البادئة الطوليّة. والسبب أنّه يسمح بتضمين حرف السطر الجديد أو أيّ بيانات ثنائيّة عشوائيّة في المحتوى، وأنّ تنفيذ جانب الاستقبال واضح، وأنّه يسهل وضع حدّ أقصى للحجم.
7. أساسيّات أسلوب البادئة الطوليّة
في أسلوب البادئة الطوليّة، تُصاغ الرسالة بالشكل التالي.
[طول المحتوى 4 بايتات][المحتوى]
على سبيل المثال، إذا كان المحتوى نصّ JSON بترميز UTF-8 وكان طوله 31 بايتاً، فسيُرسَل كما يلي.
00 00 00 1F 7B 22 63 6F 6D 6D 61 6E 64 ...
^---------^ ^------------------------------^
طول المحتوى المحتوى
يُعالِج الطرف المستقبِل البيانات بالترتيب التالي:
- قراءة 4 بايتات كاملة أوّلاً
- استخراج طول المحتوى من تلك الـ4 بايتات
- التحقّق من أنّ طول المحتوى ليس غير صالح
- قراءة المحتوى بعدد تلك البايتات كاملاً
- معاملة المحتوى المقروء كرسالة واحدة
- قراءة الإطار (frame) التالي
المهمّ هنا هو أنّ ترويسة الـ4 بايتات نفسها قد تتجزّأ أيضاً.
استقبال1: 00 00
استقبال2: 00 1F 7B 22 63 ...
لذلك، لا يُضمَن الحصول على 4 بايتات كاملة من Read واحد لمجرّد كونها ترويسة.
والأمر نفسه ينطبق على المحتوى. من الشائع تماماً أن تكون القيمة المُعادة من Read أصغر من الحجم المطلوب. إذا كان عدد البايتات المطلوب معروفاً، فلا بدّ من كتابة حلقة تقرأ حتى اكتمال العدد المطلوب.
8. مثال تنفيذ الاستقبال في .NET: أسلوب البادئة الطوليّة
فيما يلي مثال لقراءة إطار بأسلوب البادئة الطوليّة في .NET / C#.
هنا، نعتبر أوّل 4 بايتات قيمة int بترتيب big-endian تمثّل طول المحتوى.
using System.Buffers.Binary;
using System.IO;
public static class LengthPrefixedProtocol
{
private const int HeaderSize = 4;
private const int MaxPayloadSize = 1024 * 1024; // 1 ميغابايت. حدِّدها وفق الاستخدام
public static async ValueTask<byte[]?> ReadFrameAsync(
Stream stream,
CancellationToken cancellationToken)
{
byte[] header = new byte[HeaderSize];
int headerBytes = await ReadUntilFullOrEndAsync(
stream,
header,
cancellationToken);
if (headerBytes == 0)
{
// لم ينتهِ الاتصال في منتصف إطار، بل أنهى الطرف الآخر الاتصال بشكل طبيعيّ قبل بدء الإطار التالي
return null;
}
if (headerBytes != HeaderSize)
{
throw new EndOfStreamException("Frame header was truncated.");
}
int payloadLength = BinaryPrimitives.ReadInt32BigEndian(header);
if (payloadLength < 0 || payloadLength > MaxPayloadSize)
{
throw new InvalidDataException(
$"Invalid payload length: {payloadLength} bytes.");
}
byte[] payload = new byte[payloadLength];
int payloadBytes = await ReadUntilFullOrEndAsync(
stream,
payload,
cancellationToken);
if (payloadBytes != payloadLength)
{
throw new EndOfStreamException("Frame payload was truncated.");
}
return payload;
}
private static async ValueTask<int> ReadUntilFullOrEndAsync(
Stream stream,
Memory<byte> buffer,
CancellationToken cancellationToken)
{
int totalRead = 0;
while (totalRead < buffer.Length)
{
int read = await stream.ReadAsync(
buffer[totalRead..],
cancellationToken);
if (read == 0)
{
break;
}
totalRead += read;
}
return totalRead;
}
}
يكون استخدامها من جانب الاستدعاء كما يلي.
while (true)
{
byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
stream,
cancellationToken);
if (payload is null)
{
// أنهى الطرف الآخر الاتصال بشكل نظيف عند حدود الإطار
break;
}
await HandleMessageAsync(payload, cancellationToken);
}
في هذا التنفيذ، لا مشكلة مهما كان عدد البايتات التي يُعيدها ReadAsync في كلّ مرّة. حتى لو أُعيد بايت واحد في كلّ مرّة، تستمرّ الحلقة حتى تُقرَأ الترويسة والمحتوى كاملَين.
وعلى العكس، حتى لو تراكمت بيانات عدّة رسائل في مخزن الاستقبال المؤقّت لنظام التشغيل، فسيُستخرَج الإطار الأوّل فقط وفقاً لطول محتواه، ثمّ يُقرَأ الإطار التالي في الحلقة التالية.
علماً بأنّه في .NET الحاليّ، تتوفّر في بعض البيئات الدالّتان Stream.ReadExactly / ReadExactlyAsync. في تلك الحالة يمكن تفويض عمليّة قراءة عدد البايتات المطلوب إلى الواجهة القياسيّة. مع ذلك، لا يزال يتعيّن على جانب التطبيق تصميم كيفيّة التمييز بين التعامل عند إنهاء الاتصال، والإنهاء الطبيعيّ قبل بدء الإطار، والإنهاء غير الطبيعيّ في منتصف الإطار.
9. مثال تنفيذ جانب الإرسال
يُرسِل جانب الإرسال أيضاً وفق تنسيق الإطار نفسه.
using System.Buffers.Binary;
using System.IO;
public static class LengthPrefixedProtocolWriter
{
private const int HeaderSize = 4;
private const int MaxPayloadSize = 1024 * 1024;
public static async ValueTask WriteFrameAsync(
Stream stream,
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken)
{
if (payload.Length > MaxPayloadSize)
{
throw new InvalidDataException(
$"Payload is too large: {payload.Length} bytes.");
}
byte[] header = new byte[HeaderSize];
BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);
await stream.WriteAsync(header, cancellationToken);
await stream.WriteAsync(payload, cancellationToken);
}
}
في هذه الشيفرة، يُستدعى WriteAsync للترويسة والمحتوى بشكل منفصل. وهنا يظهر مفهوم خاطئ آخر بسهولة: حتى لو استدعى جانب الإرسال WriteAsync مرّتين منفصلتين للترويسة والمحتوى، فإنّ ذلك لا يعني أنّ جانب الاستقبال سيقرأهما في عمليّتين منفصلتين.
قد يبدو الأمر لدى جانب الاستقبال كما يلي.
Read() => [ترويسة 4 بايتات + جزء من المحتوى]
Read() => [بقيّة المحتوى]
أو قد يبدو هكذا.
Read() => [النصف الأوّل من الترويسة 2 بايت]
Read() => [النصف الثاني من الترويسة 2 بايت + كامل المحتوى + ترويسة الإطار التالي]
لهذا السبب بالذات، يجب أن يحكم جانب الاستقبال ليس بناءً على «كم مرّة استُدعِيَت Read»، بل بناءً على «كم بايتاً أمكن قراءته وفقاً لتنسيق الإطار».
10. عند استخدام Socket.Send مباشرةً، افحص القيمة المُعادة في جانب الإرسال أيضاً
عند استخدام NetworkStream.Write / WriteAsync، يمكن التعامل معها أساساً كواجهة تكتب النطاق المحدَّد بالكامل.
في المقابل، عند استخدام Socket.Send مباشرةً، يجب الانتباه إلى القيمة المُعادة.
تُعيد Socket.Send «عدد البايتات التي نجح إرسالها». خصوصاً في المقابس (sockets) غير الحاصرة (non-blocking)، قد ينجح الإرسال بعدد بايتات أقلّ من العدد المطلوب.
لذلك، إذا استُخدِمت Socket.Send مباشرةً، فلا بدّ من وجود معالجة في جانب الإرسال أيضاً تكرِّر العمليّة «حتى يُرسَل كلّ شيء».
using System.Net.Sockets;
public static async ValueTask SendAllAsync(
Socket socket,
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken)
{
while (!buffer.IsEmpty)
{
int sent = await socket.SendAsync(
buffer,
SocketFlags.None,
cancellationToken);
if (sent == 0)
{
throw new IOException("Socket was closed while sending data.");
}
buffer = buffer[sent..];
}
}
مع ذلك، فإنّ «تمّ الإرسال» هنا لا يعني «أنّ التطبيق الآخر عالج تلك الرسالة». نجاح واجهة الإرسال أمرٌ مختلف عن الاستجابة الناجحة على مستوى بروتوكول التطبيق.
على سبيل المثال، إذا أردتَ من الناحية التشغيليّة التأكّد من أنّه «تمّ استلام الطلب» أو «تمّ حفظ الملفّ» أو «تمّ تنفيذ الأمر»، فيجب تعريف إقرار (ACK) أو رسالة استجابة من التطبيق الآخر كجزء من البروتوكول، بدل الاكتفاء بنجاح الإرسال على مستوى TCP.
11. نقاط يجب الانتباه إليها عند استخدام أسلوب المحدِّد (delimiter)
في بروتوكولات النصوص، يُستخدَم أحياناً الفصل بحرف السطر الجديد.
LOGIN komura secret\n
GET item-001\n
QUIT\n
هذا الأسلوب سهل الفهم، ويتناسب جيّداً مع السجلّات وتنسيق الأوامر.
مع ذلك، توجد نقاط يجب الانتباه إليها.
- تحديد قاعدة الهروب (escape) في حال ظهور حرف الفصل داخل المحتوى نفسه
- تحديد كيفيّة التعامل مع
\r\nمقابل\n - تحديد الحدّ الأقصى لطول السطر الواحد
- عدم تجميع بيانات بلا حدود في الذاكرة إلى حين وصول حرف الفصل
- ضمان عدم انكسار الأحرف متعدّدة البايتات في UTF-8 عند تجزّؤها
على وجه الخصوص، يُفضَّل تجنّب الشيفرة التالية.
int read = await stream.ReadAsync(buffer, cancellationToken);
string text = Encoding.UTF8.GetString(buffer, 0, read);
foreach (string line in text.Split('\n'))
{
await HandleLineAsync(line, cancellationToken);
}
هذه الشيفرة لا تراعي احتمال أن يكون آخر جزء من النطاق المُستقبَل في منتصف سطر، ولا احتمال تجزّؤ حرف UTF-8 في منتصفه.
إذا استُخدِم الفصل بحرف السطر الجديد، فلا بدّ على الأقلّ من «تجميع البيانات كبايتات، والبحث عن بايت السطر الجديد، ثمّ فكّ الترميز بعد اكتمال سطر واحد»، أو استخدام واجهة تقرأ الأسطر مباشرةً من التدفّق (stream) مثل StreamReader.ReadLineAsync.
مع ذلك، حتى عند استخدام StreamReader.ReadLineAsync، يجب تصميم الحدّ الأقصى لطول السطر، ومهلة الانتظار، والإلغاء، وطريقة التعامل عند إنهاء الاتصال.
12. نقاط يجب الانتباه إليها عند استخدام الأسلوب ثابت الطول
في الرسائل ثابتة الطول، يُحدَّد مسبقاً مثلاً «دائماً 128 بايت لكلّ رسالة». وهو أسلوب يُشاهَد في الأنظمة التجاريّة القديمة، وأنظمة التحكّم، وربط الأجهزة.
الفكرة نفسها تنطبق حتى في الأسلوب ثابت الطول.
رسالة واحدة = 128 بايت
إذا كان الأمر كذلك، فإنّ جانب الاستقبال يكرِّر القراءة حتى اكتمال 128 بايتاً.
byte[] message = new byte[128];
int read = await ReadUntilFullOrEndAsync(stream, message, cancellationToken);
if (read != message.Length)
{
throw new EndOfStreamException("Fixed-length message was truncated.");
}
await HandleMessageAsync(message, cancellationToken);
هنا أيضاً، لا يُضمَن أن يُعيد ReadAsync الواحد 128 بايتاً.
الأسلوب ثابت الطول سهل التنفيذ لأنّ حدوده واضحة، لكنّه يعاني من مشكلات مثل صعوبة التعامل مع البيانات متغيّرة الطول، وصعوبة التوسّع مستقبلاً، ومعالجة الفراغ المتبقّي، وتغيّر عدد البايتات عند تحويل ترميز الأحرف.
13. تعطيل Nagle لا يحلّ مشكلة حدود الرسائل
عند الرغبة في إرسال بيانات صغيرة فوراً، يُنظَر أحياناً في ضبط Socket.NoDelay = true. هذا إعداد يعطِّل خوارزميّة Nagle.
مع ذلك، فإنّ NoDelay هو إعداد يخصّ تأخير وكفاءة الإرسال، أي «كيفيّة تجميع عمليّات الإرسال الصغيرة»، وليس إعداداً يحفظ «وحدة Send» كـ«وحدة Receive».
بعبارة أخرى، حتى مع ضبط NoDelay = true، لا تُحلّ المشكلات التالية.
- انقسام عمليّة
Sendواحدة إلى عدّة عمليّاتReceive - اجتماع عدّة عمليّات
Sendفي عمليّةReceiveواحدة - التجزّؤ في منتصف حرف ما
- عجز جانب الاستقبال عن تحديد حدود الرسالة
يُعدّ NoDelay مفيداً كضبط لزمن الاستجابة (latency)، لكنّه لا يُغني عن التأطير.
14. الفكرة نفسها تنطبق حتى عند استخدام TLS أو SslStream
حتى عند تشفير الاتصال عبر TLS باستخدام SslStream، فإنّ التعامل من منظور التطبيق يبقى أساساً كما هو.
يوجد في TLS ما يُسمّى بسجلّ TLS (TLS record) كوحدة داخليّة، لكنّه لا يمثّل حدود رسالة التطبيق.
حتى مع SslStream.ReadAsync، لا يُضمَن أن تُعاد رسالة التطبيق الكاملة المتوقَّعة في استدعاء واحد.
لذلك، بصرف النظر عن وجود TLS أو عدمه، يجب تصميم أحد الأساليب التالية على مستوى طبقة التطبيق:
- البادئة الطوليّة
- المحدِّد مثل حرف السطر الجديد
- الطول الثابت
- تنسيق بروتوكول قائم
TLS طبقة تشفير ومصادقة، وليست طبقة تُنشئ حدود الرسائل تلقائيّاً.
15. معالجة الأخطاء التي ينبغي مراعاتها في حلقة الاستقبال
في معالجة استقبال TCP، من المهمّ التعامل بوضوح ليس فقط مع الحالة الطبيعيّة، بل أيضاً مع الانقطاع والإنهاء في منتصف العمليّة.
عندما تكون القيمة المُعادة من Read / Receive تساوي صفراً، فهذا يعني عموماً أنّ الطرف الآخر أنهى الإرسال بشكل طبيعيّ.
مع ذلك، يجب على مستوى بروتوكول التطبيق التمييز بين الحالتين التاليتين.
| الحالة | المعاملة |
|---|---|
| الإنهاء بصفر بايت قبل قراءة الإطار التالي | يمكن معاملته كإنهاء طبيعيّ في بعض الحالات |
| الإنهاء في منتصف ترويسة الإطار أو في منتصف المحتوى | يُعامَل كخطأ لأنّه رسالة غير مكتملة |
في أسلوب البادئة الطوليّة، يمكن التفكير على النحو التالي مثلاً.
انقطاع عند حدود الإطار:
يمكن معاملته كإنهاء طبيعيّ
انقطاع بعد استقبال 2 بايت فقط من ترويسة الأربع بايتات:
خطأ في البروتوكول
انقطاع بعد استقبال 60 بايتاً فقط رغم أنّ طول المحتوى 100 بايت:
خطأ في البروتوكول
إدراج هذا التمييز يُسهِّل كثيراً التحقيق عند مراجعة السجلّات.
فبدلاً من الاكتفاء بـ«أنهى الطرف الآخر الاتصال»، إذا أمكن إخراج رسالة مثل
Frame payload was truncated. expected=100 actual=60
يسهل الاشتباه في إنهاء غير طبيعيّ من الطرف الآخر، أو مهلة زمنيّة، أو عدم تطابق في البروتوكول.
16. حدِّد دائماً الحجم الأقصى
في أسلوب البادئة الطوليّة، يُوضَع طول المحتوى في المقدّمة.
الخطر هنا هو أن يُحدِّد الطرف الآخر طولاً ضخماً.
FF FF FF FF
إذا استُخدِمت هذه القيمة مباشرةً في تخصيص مصفوفة، فسيحاول التطبيق تخصيص كمّية هائلة من الذاكرة ويصبح غير مستقرّ.
لذلك، يجب على جانب الاستقبال دائماً تحديد حجم أقصى.
private const int MaxPayloadSize = 1024 * 1024;
if (payloadLength < 0 || payloadLength > MaxPayloadSize)
{
throw new InvalidDataException(
$"Invalid payload length: {payloadLength} bytes.");
}
يُحدَّد الحجم الأقصى وفقاً لمتطلّبات العمل. قد تكفي 64 كيلوبايت للأوامر، بينما إذا كنتَ ترسل صوراً أو ملفّات، فقد يكون من الأفضل التفكير في أسلوب نقل آخر أو في البثّ (streaming). المهمّ هو تجنّب تصميم يقبل «أيّ حجم نظريّاً بلا حدود».
17. في بروتوكولات النصوص، انظر إلى «عدد البايتات» لا «عدد الأحرف»
ما ينقله TCP ليس سلاسل نصّيّة بل سلاسل بايتات.
لذلك، عند وضع طول المحتوى في أسلوب البادئة الطوليّة، عادةً ما يُوضَع «عدد البايتات» لا «عدد الأحرف».
على سبيل المثال، لنفترض تحويل النصّ التالي إلى UTF-8.
مرحبا
هذا النصّ يتألّف من 5 أحرف، لكنّه في UTF-8 يبلغ 10 بايتات.
إذا وُضِع الطول في البروتوكول على أنّه 5، فسيقرأ جانب الاستقبال المحتوى بـ5 بايتات فقط، فينقطع في منتصف حرف.
يجب على جانب الإرسال دائماً حساب الطول بناءً على مصفوفة البايتات بعد الترميز.
string json = "{\"message\":\"مرحبا\"}";
byte[] payload = Encoding.UTF8.GetBytes(json);
await LengthPrefixedProtocolWriter.WriteFrameAsync(
stream,
payload,
cancellationToken);
يقرأ جانب الاستقبال محتوى الإطار كاملاً كبايتات، ثمّ يحوِّله إلى نصّ.
byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
stream,
cancellationToken);
if (payload is not null)
{
string json = Encoding.UTF8.GetString(payload);
await HandleJsonAsync(json, cancellationToken);
}
بهذا الترتيب، لا تنشأ أيّ مشكلة حتى لو تجزّأت عمليّة Read في منتصف حرف UTF-8.
18. احذر أيضاً من تداخل البيانات على مستوى التطبيق بسبب الكتابة المتزامنة
نقطة أخرى يسهل إغفالها هي الكتابة المتزامنة (concurrent write).
على سبيل المثال، لنفترض أنّ عدّة مهامّ (tasks) تكتب إطارات إلى نفس اتّصال TCP في وقت واحد.
_ = WriteFrameAsync(stream, messageA, cancellationToken);
_ = WriteFrameAsync(stream, messageB, cancellationToken);
إذا لم يُتحكَّم بهذا، فقد يحدث تداخل على مستوى التطبيق كما يلي.
ترويسة A
ترويسة B
محتوى A
محتوى B
يعالج جانب الاستقبال البيانات على افتراض أنّه بعد قراءة ترويسة A سيأتي محتوى A. فإذا تدخّلت ترويسة B في المنتصف، ينكسر البروتوكول.
لذلك، من الآمن تسلسل عمليّات الكتابة (serialize) على اتّصال واحد. على سبيل المثال، باستخدام SemaphoreSlim أو طابور إرسال، بحيث لا تختلط عمليّات الكتابة على مستوى الإطار الواحد.
private readonly SemaphoreSlim _sendLock = new(1, 1);
public async ValueTask SendFrameSafelyAsync(
Stream stream,
byte[] payload,
CancellationToken cancellationToken)
{
await _sendLock.WaitAsync(cancellationToken);
try
{
await LengthPrefixedProtocolWriter.WriteFrameAsync(
stream,
payload,
cancellationToken);
}
finally
{
_sendLock.Release();
}
}
يحافظ TCP على ترتيب البايتات، لكن إذا كتب التطبيق سلاسل بايتات مختلطة من عدّة مهامّ، فسيوصل TCP «ذلك الترتيب المختلط» بشكل صحيح كما هو.
19. تعمَّد في الاختبارات إجراء التجزئة والدمج
عند اختبار معالجة استقبال TCP بالطريقة الاعتياديّة، يسهل إغفال حالة «العمل بمحض الصدفة».
لذلك، ينبغي أن تُنشئ الاختبارات عمداً الأنماط التالية.
| زاوية الاختبار | مثال |
|---|---|
| الوصول بايتاً بايتاً | تُقرَأ الترويسة والمحتوى كلاهما بوحدة بايت واحد في كلّ استدعاء Read |
| انقطاع في منتصف الترويسة | ينتهي الاتصال بعد وصول 2 بايت فقط من ترويسة الأربع بايتات |
| انقطاع في منتصف المحتوى | ينتهي الاتصال بعد وصول 60 بايتاً فقط رغم أنّ طول المحتوى 100 |
| اندماج عدّة إطارات | يقع إطاران في مخزن استقبال داخليّ واحد |
| تحديد حجم ضخم | يُرسَل طول محتوى يتجاوز الحدّ الأقصى |
| محتوى بصفر بايت | التحقّق من قبول أو رفض طول محتوى يساوي صفراً |
| تجزّؤ UTF-8 | تنقسم سلسلة بايتات لحروف يابانيّة أو رموز تعبيريّة (emoji) في منتصفها |
في اختبارات الوحدة، حتى دون استخدام مقبس TCP فعليّ، يمكن تسهيل التحقّق من معالجة الاستقبال باستبدال Stream بتيّار (stream) «لا يمكن قراءته إلا بحجم قطعة محدَّد».
اختبارات التكامل التي تتحقّق فعليّاً عبر TCP ضروريّة أيضاً، لكن إذا فُصِل محلِّل الاستقبال (parser) كمعالجة نقيّة تعمل على Stream، يصبح الاختبار أسهل بكثير.
تُقاس جودة معالجة الشبكات ليس بمعيار «تعمل عند الإرسال العاديّ»، بل بمعيار «تتصرّف كما هو متوقَّع سواء تجزّأت البيانات، أو اندمجت، أو انقطعت في المنتصف».
20. قائمة تحقّق عند إصلاح الشيفرة القائمة
عند مراجعة شيفرة اتّصال TCP قائمة، يسهل اكتشاف المشكلات بالنظر من الزوايا التالية.
| الزاوية | ما يجب التحقّق منه |
|---|---|
| وحدة الاستقبال | هل تُعامَل عمليّة Read / Receive واحدة كرسالة واحدة؟ |
| القيمة المُعادة | هل تُستخدَم دائماً القيمة المُعادة (عدد البايتات) من Read / Receive؟ |
| التجميع | هل تُجمَّع البايتات حتى اكتمال الرسالة؟ |
| الحدود | هل توجد قاعدة واضحة كالطول الثابت أو المحدِّد أو البادئة الطوليّة؟ |
| ترميز الأحرف | هل يتمّ تحويل البيانات إلى نصّ قبل اكتمال الرسالة؟ |
| الحدّ الأقصى | هل يوجد حدّ أقصى للطول أو طول السطر؟ |
| الانقطاع | هل يُميَّز بين الانقطاع عند حدود الإطار والانقطاع في المنتصف؟ |
| الإرسال | هل تُتجاهَل القيمة المُعادة من Socket.Send؟ |
| التزامن | هل تختلط عمليّات الكتابة من عدّة مهامّ على الاتصال نفسه؟ |
| السجلّات | هل يمكن إخراج عدد البايتات المتوقَّع والفعليّ (expected / actual)؟ |
| الاختبارات | هل توجد اختبارات للتجزئة والدمج والانقطاع في المنتصف؟ |
الشيفرة الخطيرة بوجه خاصّ تأخذ شكلاً كالتالي.
int read = socket.Receive(buffer);
string message = Encoding.UTF8.GetString(buffer);
Handle(message);
توجد مشكلات متعدّدة.
- لا تُستخدَم قيمة
read - تُحوَّل المخزن المؤقّت (buffer) بأكمله إلى نصّ
- تُعامَل عمليّة
Receiveواحدة كرسالة واحدة - لا توجد حدود للرسالة
- لا يُراعى تجزّؤ الأحرف في منتصفها
يجب في الحدّ الأدنى التحوّل إلى الفكرة التالية.
إضافة عدد البايتات read الذي حصلنا عليه من Receive فقط إلى مخزن الاستقبال المؤقّت
↓
التحقّق مما إذا كان يمكن استخراج إطار واحد من مخزن الاستقبال وفق البروتوكول
↓
إذا أمكن الاستخراج، تتمّ معالجته
↓
تبقى البايتات الزائدة كبداية للإطار التالي
↓
إذا لم تكن كافية، الانتظار حتى Receive التالي
21. الخلاصة
في اتّصالات TCP، لا يُضمَن استقبال البيانات بنفس الوحدات التي أُرسِلت بها عبر Send. هذا ليس سلوكاً استثنائيّاً، بل هو أساس استخدام TCP.
النقاط الواجب مراعاتها هي كالتالي:
- TCP لا يوفّر رسائل، بل تدفّق بايتات مرتَّبة
- وحدة استدعاء
Send/Writeلا تُحفَظ كوحدةReceive/Readلدى الطرف المستقبِل - قد ينقسم إرسال واحد إلى عدّة عمليّات استقبال، وقد تجتمع عدّة عمليّات إرسال في استقبال واحد
- يجب على جانب الاستقبال تحديد حدود الرسالة كجزء من بروتوكول التطبيق
- في البروتوكولات الخاصّة، غالباً ما يكون أسلوب البادئة الطوليّة هو الأسهل تعاملاً
- يجب أن يشمل التصميم حلقة قراءة عدد البايتات المطلوب كاملاً، والحجم الأقصى، والانقطاع في المنتصف، وترميز الأحرف، والكتابة المتزامنة
- لا يُغني
NoDelayأوDataAvailableعن تحديد حدود الرسائل
تبدو معالجة الشبكات بسيطة إذا نظرنا فقط إلى الحالة الطبيعيّة. لكن في الواقع، لا يُصبح الاتصال مستقرّاً إلا بعد تحديد «أين نضع الحدود» و«كيف ننتظر عند نقص البيانات» و«كيف نُبقي الفائض عند زيادتها» و«كيف نتعامل مع الانقطاع في المنتصف».
إذا كنتَ تستخدم TCP، فإنّ Receive لا يُعيد رسالة، بل يُعيد فقط جزءاً من سلسلة بايتات. مسؤوليّة تحويلها إلى رسالة تقع على عاتق تصميم بروتوكول التطبيق.
المراجع
- مجموعة الشيفرة البرمجيّة النموذجيّة الكاملة لهذا المقال (المكتبة، العرض التوضيحيّ، اختبارات الوحدة) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/tcp-send-receive-message-framing
- RFC 9293: Transmission Control Protocol (TCP) https://www.rfc-editor.org/rfc/rfc9293.html
- Microsoft Learn:
Socket.Receivehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.receive?view=net-10.0 - Microsoft Learn:
NetworkStream.Readhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.read?view=net-10.0 - Microsoft Learn:
Socket.Sendhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.send?view=net-10.0 - Microsoft Learn:
NetworkStream.Writehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.write?view=net-10.0 - Microsoft Learn:
Stream.ReadExactly/ReadExactlyAsynchttps://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactly?view=net-10.0 https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactlyasync?view=net-10.0 - Microsoft Learn:
Socket.NoDelayhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.nodelay?view=net-10.0
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
فهم نموذج OSI حقًا — تشريح طلب HTTP واحد إلى طبقاته السبع
نفهم نموذج OSI من خلال التجربة الفعلية بدلًا من الحفظ. نقوم بتجميع وتشريح إطار Ethernet الذي يحمل طلب HTTP GET واحدًا باستخدام C#، ونتحقق...
التعامل الآمن مع تطبيق أعمال قديم بلا اختبارات ── الممارسة العمليّة لاختبار التوصيف وإعادة الهيكلة
لإجراء تعديلات آمنة على تطبيق أعمال بلا اختبارات، نشرح خطوات اختبار التوصيف (أسلوب Golden Master) الذي يُثبِّت السلوك الحاليّ، وكيفيّة صن...
مطبّات محرك الشبكة ومسار UNC ── الممارسة العمليّة للتعامل مع خادم الملفّات (المجلّد المشترك) في تطبيقات الأعمال
نرتّب الأعطال الشائعة عند إخراج البيانات إلى مجلّد مشترك أو مراقبته من تطبيق أعمال. نشرح سبب عدم ظهور حرف القرص (Z:) من الخدمة، والصلاحيّ...
إلى متى ستستمرّ تطبيقات VB6 في العمل ── وضع دعم بيئة التشغيل والمسار العمليّ للترحيل إلى .NET
إلى متى تستمرّ تطبيقات VB6 في العمل؟ يُنظّم هذا المقال التناقض بين سياسة دعم بيئة تشغيل VB6 (المدعومة حتّى على Windows 11) وحقيقة انتهاء ...
التاريخ والوقت والمناطق الزمنية في تطبيقات الأعمال ── من فخاخ DateTime إلى مبدأ التخزين بتوقيت UTC وتصميم الاختبارات
ينزاح الوقت 9 ساعات بعد نقل الخادم ── نرتّب أسباب أعطال التاريخ والوقت انطلاقًا من خاصيّة Kind في DateTime والتحويل الضمنيّ. نشرح التمييز...
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
ندعم تطوير برامج ويندوز للأعمال، وتكامل الأجهزة، وأدوات التواصل.
الأسئلة الشائعة
أسئلة شائعة حول موضوع هذه المقالة.
- لماذا لا يمكن استقبال البيانات في TCP بنفس الوحدات التي أُرسِلت بها عبر Send؟
- لأنّ ما يضمنه TCP هو أنّ «سلسلة البايتات المُرسَلة تصل بالترتيب نفسه، دون تكرار ودون فقدان»، ولا يضمن أنّ الوحدة التي استُخدِمت عند الإرسال تُحفَظ كما هي لتكون وحدة استقبال لدى الطرف الآخر. TCP لا ينقل رسائل، بل ينقل سلسلة بايتات متّصلة، لذلك من الطبيعيّ أن ينقسم إرسال واحد إلى عدّة عمليّات استقبال، أو أن تجتمع عدّة عمليّات إرسال في عمليّة استقبال واحدة. هذا يستوجب وجود آليّة لدى الطرف المستقبِل لتحديد حدود الرسائل (التأطير / framing).
- ما هي أساليب التأطير (تحديد حدود الرسائل) المتاحة في TCP؟
- توجد أربعة أساليب رئيسيّة. الأسلوب ثابت الطول، حيث تُعتبَر رسالة واحدة دائماً بعدد بايتات ثابت متّفق عليه. أسلوب المحدِّد (delimiter)، حيث تُعتبَر رسالة واحدة كلّ ما يسبق سلسلة بايتات مُحدَّدة مثل حرف السطر الجديد. أسلوب البادئة الطوليّة (length prefix)، حيث تُوضَع بادئة تمثّل طول المحتوى في مقدّمة الرسالة. والأسلوب ذاتيّ الوصف، الذي يُعبِّر عن الطول أو نهاية الرسالة داخل التنسيق نفسه كما يفعل Content-Length في HTTP. عند تصميم بروتوكول خاصّ بك، يُعدّ أسلوب البادئة الطوليّة أوّل خيار للنظر فيه، لأنّه يسمح بتضمين أيّ بيانات ثنائيّة في المحتوى، ويسهل وضع حدّ أقصى للحجم.
- هل يحلّ ضبط Socket.NoDelay على true مشكلة التجزّؤ والدمج في TCP؟
- لا يحلّها. `NoDelay` هو إعداد يعطِّل خوارزميّة Nagle، وهو ضبط يخصّ تأخير وكفاءة عمليّات الإرسال الصغيرة، وليس إعداداً يحفظ وحدة Send كوحدة Receive. حتى مع ضبط `NoDelay` على true، تبقى المشكلات قائمة كما هي: انقسام عمليّة إرسال واحدة إلى عدّة عمليّات استقبال، أو اجتماع عدّة عمليّات إرسال في عمليّة استقبال واحدة، أو التجزّؤ في منتصف حرف ما. هذا الإعداد لا يُغني عن التأطير.
- لماذا تتشوّه البيانات (mojibake) عند استقبالها عبر TCP؟
- لأنّ الحرف الواحد في UTF-8 قد يتكوّن من عدّة بايتات، ولا يوجد ما يضمن تطابق حدود عمليّة القراءة (Read) مع حدود الحرف. إذا حوّلت سلسلة البايتات المُستقبَلة إلى نصّ عبر `Encoding.UTF8.GetString` فوراً في كلّ مرّة، فقد تنكسر البيانات عند تجزّؤ حرف متعدّد البايتات في منتصفه. الحلّ هو تجميع البيانات كبايتات حتى تُعرَف حدود الرسالة، ثمّ فكّ ترميزها بعد اكتمال رسالة واحدة كاملة. كما ينبغي حساب طول المحتوى في البادئة الطوليّة بعدد البايتات بعد الترميز لا بعدد الأحرف.
الملف الشخصي للمؤلف
صفحة الملف الشخصي لمؤلف المقالة.
غو كومورا
مؤسّس شركة كومورا سوفت ذ.م.م.
يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.
روابط عامة