الفروق الطفيفة في سلاسل ترميز 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 أو أحرف يمكن تمثيلها باستخدام بايت واحد. بعبارة أخرى، لن تعمل هذه الطريقة مع Unicode.

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

// 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);
}

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

لفهم ذلك، لنلقِ نظرة على سلاسل النصوص في كلّ من علم الصعوبات الكمبيوتري وJavaScript.

السلاسل في Unicode وJavaScript

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

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

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

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

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

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

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

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

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

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

دالة btoa() ودالة atob() مع Unicode

كما لاحظت، يحدث الخطأ بسبب أنّ السلسلة تحتوي على أحرف خارج بايت واحد في UTF-16.

لحسن الحظ، تتضمّن مقالة MDN حول قاعدة الترميز ‎basal64 بعض نماذج الرموز البرمجية المفيدة لحلّ "مشكلة ترميز Unicode" هذه. يمكنك تعديل هذه التعليمة البرمجية لاستخدامها مع المثال السابق:

// 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() لترميزها إلى base64.

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

يعمل هذا الإجراء لأنّ الخطوة بين 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 API.

كما هو موضّح سابقًا، تعالج 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().

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

  • Chrome: 111
  • الحافة: 111.
  • Firefox: 119.
  • ‫Safari: 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;
    }
  }
}

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

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

// 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 وفك تشفيرها، تقدم هذه المشاركة مثالاً على أهمية المعالجة الدقيقة للنصوص بشكل خاص، لا سيما عندما تكون البيانات النصية واردة من مصادر خارجية أو ينشئها المستخدم.