تفاوت های ظریف رشته های رمزگذاری base64 در جاوا اسکریپت

رمزگذاری و رمزگشایی base64 شکل رایج تبدیل محتوای باینری است تا به عنوان متن ایمن وب نمایش داده شود. معمولاً برای URL های داده مانند تصاویر درون خطی استفاده می شود.

وقتی کدگذاری و رمزگشایی base64 را روی رشته ها در جاوا اسکریپت اعمال می کنید چه اتفاقی می افتد؟ این پست به بررسی تفاوت‌های ظریف و مشکلات رایجی که باید از آن اجتناب کنید می‌پردازد.

توابع اصلی برای کدگذاری و رمزگشایی base64 در جاوا اسکریپت 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);
}

هر یک از ایموجی های موجود در رشته باعث خطا می شود. چرا یونیکد این مشکل رو ایجاد میکنه؟؟

برای درک، بیایید یک قدم به عقب برگردیم و رشته ها را هم در علوم کامپیوتر و هم در جاوا اسکریپت درک کنیم.

رشته ها در یونیکد و جاوا اسکریپت

یونیکد استاندارد جهانی فعلی برای رمزگذاری کاراکترها یا تمرین اختصاص دادن یک عدد به یک کاراکتر خاص است تا بتوان از آن در سیستم های کامپیوتری استفاده کرد. برای بررسی عمیق‌تر یونیکد، از این مقاله W3C دیدن کنید.

چند نمونه از کاراکترها در یونیکد و اعداد مرتبط با آنها:

  • h - 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764 با یک اصلاح کننده مخفی به شماره 65039
  • ⛳ - 9971
  • 🧀 - 129472

اعدادی که هر کاراکتر را نشان می دهند، «نقاط کد» نامیده می شوند. می توانید «نقاط کد» را به عنوان آدرسی برای هر کاراکتر در نظر بگیرید. در ایموجی قلب قرمز، در واقع دو نقطه رمز وجود دارد: یکی برای قلب و دیگری برای "تغییر" رنگ و قرمز کردن آن همیشه.

یونیکد دو روش متداول برای گرفتن این نقاط کد و تبدیل آنها به دنباله بایت هایی دارد که رایانه ها می توانند به طور مداوم تفسیر کنند: UTF-8 و UTF-16.

یک نمای بیش از حد ساده شده این است:

  • در UTF-8، یک نقطه کد می تواند بین یک تا چهار بایت (8 بیت در هر بایت) استفاده کند.
  • در UTF-16، یک نقطه کد همیشه دو بایت (16 بیت) است.

نکته مهم این است که جاوا اسکریپت رشته ها را به صورت UTF-16 پردازش می کند. این کار توابعی مانند btoa() را می شکند که به طور موثر بر این فرض عمل می کنند که هر کاراکتر در رشته به یک بایت نگاشت می شود. این به صراحت در MDN بیان شده است:

متد btoa() یک رشته ASCII با کد Base64 از یک رشته باینری ایجاد می کند (یعنی رشته ای که در آن هر کاراکتر در رشته به عنوان یک بایت داده باینری در نظر گرفته می شود).

اکنون می دانید که کاراکترهای جاوا اسکریپت اغلب به بیش از یک بایت نیاز دارند، بخش بعدی نحوه مدیریت این مورد را برای رمزگذاری و رمزگشایی base64 نشان می دهد.

btoa() و atob() با یونیکد

همانطور که اکنون می دانید، خطای پرتاب به دلیل رشته ما حاوی کاراکترهایی است که خارج از یک بایت در 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 برای گرفتن رشته جاوا اسکریپت کدگذاری شده UTF-16 و تبدیل آن به جریانی از بایت های کدگذاری شده با UTF-8 با استفاده از TextEncoder.encode() استفاده کنید.
  2. این یک Uint8Array برمی گرداند که یک نوع داده کمتر مورد استفاده در جاوا اسکریپت است و زیر کلاس TypedArray است.
  3. آن Uint8Array بگیرید و در اختیار تابع bytesToBase64() قرار دهید، که از String.fromCodePoint() برای در نظر گرفتن هر بایت در Uint8Array به عنوان یک نقطه کد استفاده می کند و یک رشته از آن ایجاد می کند که منجر به یک رشته از نقاط کد می شود که همه می توانند. به صورت یک بایت نمایش داده شود.
  4. آن رشته را بگیرید و btoa() برای کدگذاری base64 استفاده کنید.

فرآیند رمزگشایی یکسان است، اما برعکس.

این کار به این دلیل کار می کند که گام بین Uint8Array و یک رشته تضمین می کند که در حالی که رشته در جاوا اسکریپت به صورت کدگذاری دو بایتی 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}]`);

اگر آخرین نویسه را پس از رمزگشایی (�) بگیرید و مقدار هگز آن را بررسی کنید، متوجه می شوید که به جای اصلی \uDE75 \uFFFD است. خراب نمی‌شود یا خطایی ایجاد نمی‌کند، اما داده‌های ورودی و خروجی بی‌صدا تغییر کرده است. چرا؟

رشته ها بر اساس JavaScript API متفاوت هستند

همانطور که قبلا توضیح داده شد، جاوا اسکریپت رشته ها را به صورت UTF-16 پردازش می کند. اما رشته های UTF-16 یک ویژگی منحصر به فرد دارند.

ایموجی پنیر را به عنوان مثال در نظر بگیرید. ایموجی (🧀) دارای کد یونیکد 129472 است. متاسفانه حداکثر مقدار برای یک عدد 16 بیتی 65535 است! بنابراین چگونه UTF-16 این عدد بسیار بالاتر را نشان می دهد؟

UTF-16 مفهومی به نام جفت جایگزین دارد. می توانید به این شکل فکر کنید:

  • اولین عدد در جفت مشخص می‌کند که در کدام «کتاب» جستجو شود. این « جانشین » نامیده می‌شود.
  • شماره دوم در جفت ورودی در "کتاب" است.

همانطور که ممکن است تصور کنید، گاهی اوقات داشتن شماره ای که نشان دهنده کتاب است، اما نه ورودی واقعی در آن کتاب، می تواند مشکل ساز باشد. در UTF-16، این به عنوان جانشین تنها شناخته می شود.

این به ویژه در جاوا اسکریپت چالش برانگیز است، زیرا برخی از API ها با وجود داشتن جانشین تنها کار می کنند در حالی که برخی دیگر شکست می خورند.

در این مورد، هنگام رمزگشایی از base64 از TextDecoder استفاده می کنید. به طور خاص، پیش فرض های TextDecoder موارد زیر را مشخص می کند:

پیش‌فرض روی false قرار می‌گیرد، به این معنی که رمزگشا داده‌های نادرست را با یک کاراکتر جایگزین جایگزین می‌کند.

نویسه‌ای که قبلا مشاهده کردید، که به صورت \uFFFD در هگز نشان داده می‌شود، همان کاراکتر جایگزین است. در UTF-16، رشته‌هایی با جانشین‌های تنها «بدشکل» یا «به خوبی شکل نگرفته» در نظر گرفته می‌شوند.

استانداردهای وب مختلفی وجود دارد (مثال 1 ، 2 ، 3 ، 4 ) که دقیقاً مشخص می کند که یک رشته ناقص چه زمانی بر رفتار API تأثیر می گذارد، اما به ویژه TextDecoder یکی از این API ها است. این تمرین خوب است که قبل از انجام پردازش متن مطمئن شوید که رشته ها به خوبی شکل گرفته اند.

رشته های خوش فرم را بررسی کنید

مرورگرهای بسیار جدید اکنون یک تابع برای این منظور دارند: 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 است، مثالی ارائه می‌کند که چرا پردازش دقیق متن از اهمیت ویژه‌ای برخوردار است، به‌ویژه زمانی که داده‌های متنی از منابع تولید شده توسط کاربر یا منابع خارجی می‌آیند.