الفروق الطفيفة في سلاسل ترميز Base64 في JavaScript

ترميز وفك ترميز base64 هو شكل شائع لتحويل المحتوى الثنائي لكي يتم تمثيله كنص آمن على الويب. وتُستخدَم عادةً مع عناوين URL للبيانات، مثل الصور المضمّنة.

ماذا يحدث عند تطبيق تشفير base64 وفك التشفير على السلاسل في JavaScript؟ تستكشف هذه المشاركة الفروقات الدقيقة والصعوبات الشائعة التي يجب تجنبها.

btoa() وatob()

الدالتان الأساسيتان لترميز وفك ترميز base64 في JavaScript هما btoa() وatob(). يتم تحويل btoa() من سلسلة إلى سلسلة بترميز base64، وatob() تفكّ ترميزها مرة أخرى.

يوضح ما يلي مثالاً سريعًا:

// A really plain string that is just code points below 128.
const asciiString = 'hello';

// This will work. It will print:
// Encoded string: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);

للأسف، والتي وردت في مستندات MDN، إنّ هذا الإجراء يعمل فقط مع السلاسل التي تحتوي على أحرف ASCII أو أحرف يمكن تمثيلها ببايت واحد. وبعبارة أخرى، لن يعمل هذا مع يونيكود.

لمعرفة ما يحدث، جرِّب الرمز التالي:

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will not work. It will print:
// DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
try {
  const validUTF16StringEncoded = btoa(validUTF16String);
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
  console.log(error);
}

سيؤدي استخدام أي رمز من الرموز التعبيرية في السلسلة إلى حدوث خطأ. لماذا يسبب يونيكود هذه المشكلة؟

من أجل الفهم، لنعد إلى الوراء قليلاً ونفهم السلاسل، سواء في علوم الكمبيوتر وJavaScript.

سلاسل في Unicode وJavaScript

يونيكود هو المعيار العالمي الحالي لترميز الأحرف، أو ممارسة تخصيص رقم لحرف معيّن حتى يمكن استخدامه في أنظمة الكمبيوتر. ولمزيد من التفاصيل عن يونيكود، يُرجى الاطّلاع على هذه المقالة حول W3C.

إليك بعض الأمثلة على الأحرف في يونيكود والأرقام المرتبطة بها:

  • ساعة - 104 ساعة
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764 مع معدِّل مخفي رقم 65039
  • ⛳ - 9971
  • 🧀 - 129472

تُسمى الأرقام التي تمثل كل حرف "نقاط التعليمات البرمجية". يمكنك التفكير في "نقاط التعليمات البرمجية" كعنوان لكل حرف. في الرموز التعبيرية للقلب الأحمر، توجد في الواقع نقطتين للرمز: واحدة لقلب والأخرى "تختلف" اللون وجعلها حمراء دائمًا.

يتّبع يونيكود طريقتين شائعتين لأخذ نقاط الرمز هذه وتحويلها إلى تسلسلات من وحدات البايت التي يمكن لأجهزة الكمبيوتر تفسيرها باستمرار، وهما: UTF-8 وUTF-16.

في ما يلي طريقة عرض مُبسّطة:

  • في UTF-8، يمكن أن تستخدم نقطة الرمز ما بين 1 إلى أربعة بايت (8 بت لكل بايت).
  • في UTF-16، تكون نقطة الرمز دائمًا عبارة عن وحدتي بايت (16 بت).

والأهم من ذلك أنّ لغة JavaScript تعالج السلاسل بتنسيق UTF-16. يؤدي ذلك إلى إيقاف دوال مثل btoa() التي تعمل بشكل فعّال على افتراض أنّ كل حرف في السلسلة يرتبط ببايت واحد. تم ذكر ذلك صراحةً في MDN:

تنشئ الطريقة btoa() سلسلة ASCII بترميز Base64 من سلسلة ثنائية (أي سلسلة يتم فيها التعامل مع كل حرف في السلسلة على أنّها بايت من البيانات الثنائية).

والآن أنت تعرف أنّ الأحرف في JavaScript غالبًا ما تتطلب أكثر من بايت واحد، ويوضّح القسم التالي كيفية التعامل مع هذه الحالة لترميز وفك ترميز base64.

btoa() وatob() باستخدام Unicode

كما تعلم الآن، يرجع الخطأ الذي يظهر لك إلى احتواء السلسلة على أحرف خارج بايت واحد بترميز UTF-16.

ومن الجيّد أنّ مقالة MDN على base64 تتضمّن بعض الرموز النموذجية المفيدة لحلّ "مشكلة يونيكود" هذه. يمكنك تعديل هذه التعليمة البرمجية للعمل مع المثال السابق:

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);

وتوضّح الخطوات التالية وظيفة هذه التعليمة البرمجية لترميز السلسلة:

  1. استخدِم واجهة TextEncoder لأخذ سلسلة JavaScript بترميز UTF-16 وتحويلها إلى مجموعة من وحدات البايت بترميز UTF-8 باستخدام TextEncoder.encode().
  2. يؤدي هذا إلى عرض Uint8Array، وهي نوع بيانات أقل شيوعًا في الاستخدام في JavaScript وفئة فرعية من TypedArray.
  3. استخدِم Uint8Array وقدِّمه إلى الدالة bytesToBase64() التي تستخدم String.fromCodePoint() للتعامل مع كل بايت في Uint8Array كنقطة رمز وإنشاء سلسلة منها، ما يؤدي إلى إنشاء سلسلة من نقاط الرموز التي يمكن تمثيلها جميعًا كبايت واحد.
  4. خذ هذه السلسلة واستخدم btoa() لترميزها.

عملية فك التشفير هي نفسها ولكن عكسها.

هذه الخطوة مفيدة لأنّ الخطوة بين Uint8Array وسلسلة تضمن أنّه يتم تمثيل السلسلة في JavaScript كترميز UTF-16، وهو ترميز ثنائي البايت، ولكن قيمة نقطة الرمز التي تمثلها كل وحدة بايت تكون أقل من 128 دائمًا.

تعمل هذه التعليمة البرمجية بشكل جيد في معظم الظروف، لكنها ستفشل في صمت في حالات أخرى.

حالة تعذُّر كتم الصوت

استخدِم الرمز نفسه ولكن بسلسلة مختلفة:

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is invalid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
// '\uDE75' is code unit that is one half of a surrogate pair.
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);

إذا استخدمت الحرف الأخير بعد فك ترميز ( ) وراجعت قيمته السداسية العشرية، فستجد أنها \uFFFD وليس قيمة \uDE75 الأصلية. إنها لا تفشل أو تطرح خطأ، ولكن بيانات الإدخال والإخراج قد تغيرت بدون تنبيه. ولماذا؟

تختلف السلاسل حسب واجهة برمجة تطبيقات JavaScript

كما هو موضّح سابقًا، تعالج لغة JavaScript السلاسل على أنّها UTF-16. لكن سلاسل UTF-16 لها خاصية فريدة.

خذ الرمز التعبيري للجبن كمثال. يتضمّن الرمز التعبيري (🧀) نقطة رمز يونيكود 129472. للأسف، الحد الأقصى لقيمة الرقم المكون من 16 بت هو 65535! إذًا، كيف يمثّل الترميز UTF-16 هذا الرقم الأعلى بكثير؟

يستند مفهوم UTF-16 إلى الأزواج البديلة. يمكنك التفكير في الأمر بهذه الطريقة:

  • يحدد الرقم الأول في الزوج "الكتاب" الذي تريد البحث فيه. ويسمى هذا "البديل".
  • والرقم الثاني في الزوج هو الإدخال في "الكتاب".

كما تتخيل، قد يكون في بعض الأحيان مشكلة أن يكون الرقم الذي يمثل الكتاب فقط، وليس الإدخال الفعلي في هذا الكتاب. في UTF-16، يُعرف ذلك باسم البديل الوحيد.

يمثل هذا تحديًا خاصًا في JavaScript، لأن بعض واجهات برمجة التطبيقات تعمل على الرغم من وجود رموز بديلة منفردة بينما تفشل بعض واجهات برمجة التطبيقات.

في هذه الحالة، أنت تستخدم TextDecoder عند فك الترميز مرة أخرى من base64. وعلى وجه الخصوص، تحدِّد الإعدادات التلقائية TextDecoder ما يلي:

يتم ضبط القيمة التلقائية على false، ما يعني أنّ برنامج فك الترميز يستبدل البيانات المشوهة بحرف بديل.

الحرف الذي لاحظته سابقًا، والذي يتم تمثيله كـ \uFFFD في السداسي العشري، هو الحرف البديل. في UTF-16، تُعتبر السلاسل التي تحتوي على حروف بديلة وحيدة "غير صحيحة" أو "غير صحيحة".

هناك العديد من معايير الويب (أمثلة 1 و2 و3 و4) تحدد بدقة الوقت الذي تؤثر فيه سلسلة مشوهة في سلوك واجهة برمجة التطبيقات، ولكن لا سيما TextDecoder هو إحدى واجهات برمجة التطبيقات تلك. من الممارسات الجيدة التأكد من تكوين السلاسل بشكل جيد قبل إجراء معالجة النص.

التحقّق من وجود سلاسل صحيحة

والمتصفّحات الحديثة جدًا لها الآن وظيفة لهذا الغرض: isWellFormed().

التوافق مع المتصفح

  • 111
  • 111
  • 119
  • 16.4

المصدر

يمكنك تحقيق نتيجة مماثلة باستخدام encodeURIComponent()، الذي يطرح خطأ URIError إذا كانت السلسلة تحتوي على شريك وحيد.

تستخدم الدالة التالية isWellFormed() إذا كانت متاحة وencodeURIComponent() إذا لم تكن متاحة. يمكن استخدام رمز مشابه لإنشاء رمز polyfill لـ isWellFormed().

// Quick polyfill since older browsers do not support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

وضع كل العناصر معًا

الآن بعد أن عرفت كيفية التعامل مع كل من يونيكود والبدائل الفردية، يمكنك تجميع كل شيء معًا لإنشاء رمز برمجي يعالج جميع الحالات ويفعل ذلك بدون استبدال النص الصامت.

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Quick polyfill since Firefox and Opera do not yet support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

const validUTF16String = 'hello⛳❤️🧀';
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

if (isWellFormed(validUTF16String)) {
  // This will work. It will print:
  // Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
  const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);

  // This will work. It will print:
  // Decoded string: [hello⛳❤️🧀]
  const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
  console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
  // Not reached in this example.
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // Not reached in this example.
} else {
  // This is not a well-formed string, so we handle that case.
  console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

هناك العديد من التحسينات التي يمكن إجراؤها على هذه التعليمة البرمجية، مثل التعميم إلى polyfill، وتغيير معلمات TextDecoder لطرحها بدلاً من استبدال البدائل الفردية بدون تنبيه، والمزيد.

باستخدام هذه المعلومات والتعليمات البرمجية، يمكنك أيضًا اتخاذ قرارات واضحة حول كيفية التعامل مع السلاسل التي بها خلل، مثل رفض البيانات أو تمكين استبدال البيانات بشكل صريح، أو ربما طرح الخطأ لتحليله لاحقًا.

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