JavaScript'te base64 kodlama dizelerinin nüansları

Base64 kodlama ve kod çözme, ikili içeriği web'de güvenli metin olarak temsil etmek için kullanılan yaygın bir yöntemdir. Genellikle satır içi resimler gibi veri URL'leri için kullanılır.

JavaScript'te dizelere base64 kodlama ve kod çözme işlemi uyguladığınızda ne olur? Bu yayında, bu konudaki incelikleri ve kaçınılması gereken yaygın hataları ele alıyoruz.

btoa() ve atob()

JavaScript'te base64 kodlama ve kodu çözme işlemlerinde kullanılan temel işlevler btoa() ve atob() şeklindedir. btoa(), bir dizeden base64 kodlu bir dizeye gider ve atob() kodunu çözer.

Aşağıda kısa bir örnek gösterilmektedir:

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

Maalesef MDN dokümanlarındaki belirtildiği gibi, bu yöntem yalnızca ASCII karakterleri veya tek baytla temsil edilebilecek karakterler içeren dizelerle çalışır. Başka bir deyişle, bu Unicode ile çalışmaz.

Ne olacağını görmek için aşağıdaki kodu deneyin:

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

Dizedeki emojilerden herhangi biri hataya neden olur. Unicode neden bu soruna yol açar?

Anlamak için, bir adım geri gidip hem bilgisayar bilimi hem de JavaScript'teki dizeleri anlayalım.

Unicode ve JavaScript'teki dizeler

Unicode, karakter kodlaması veya belirli bir karaktere bilgisayar sistemlerinde kullanılabilmesi için bir sayı atama işlemi için geçerli küresel standarttır. Unicode hakkında daha ayrıntılı bilgi edinmek için bu W3C makalesini ziyaret edin.

Unicode'daki karakterlere ve ilişkili sayılara dair bazı örnekler:

  • h - 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 65039 numaralı gizli değiştiriciyle 2764
  • ⛳ - 9971
  • 🧀 - 129.472

Her karakteri temsil eden sayılara "kod noktası" denir. "Kod noktalarını" her karakterin adresi olarak düşünebilirsiniz. Kırmızı kalp emojisinde aslında iki kod noktası vardır: biri kalp, diğeri ise rengi "değişken" hale getirip her zaman kırmızı yapar.

Unicode'da bu kod noktalarını alıp bilgisayarların tutarlı bir şekilde yorumlayabileceği bayt dizileri haline getirmenin iki yaygın yolu vardır: UTF-8 ve UTF-16.

Basitleştirilmiş bir görünüm şu şekildedir:

  • UTF-8'de bir kod noktası bir ila dört bayt (bayt başına 8 bit) kullanabilir.
  • UTF-16'da bir kod noktası her zaman iki bayttır (16 bit).

JavaScript, dizeleri UTF-16 olarak işler. Bu, dizedeki her karakterin tek bir baytla eşlendiği varsayımı altında etkili bir şekilde çalışan btoa() gibi işlevleri bozar. Bu durum MDN'de açıkça belirtilmiştir:

btoa() yöntemi, bir ikili dizeden Base64 kodlu bir ASCII dizesi oluşturur (yani, dizedeki her karakterin bir ikili veri baytı olarak değerlendirildiği bir dize).

JavaScript'teki karakterlerin genellikle birden fazla bayt gerektirdiğini öğrendiniz. Sonraki bölümde, base64 kodlama ve kod çözme için bu durumun nasıl ele alınacağı gösterilmektedir.

Unicode ile btoa() ve atob()

Şimdi bildiğiniz gibi, UTF-16'da tek bir baytın dışında kalan karakterler içeren dizemizden dolayı hata oluştu.

Neyse ki base64 ile ilgili MDN makalesinde bu "Unicode sorununu" çözmek için bazı yararlı örnek kodlar yer alıyor. Bu kodu, önceki örnekle çalışacak şekilde değiştirebilirsiniz:

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

Aşağıdaki adımlarda, bu kodun dize kodlamak için ne yaptığı açıklanmaktadır:

  1. UTF-16 ile kodlanmış JavaScript dizesini alıp TextEncoder.encode() kullanarak UTF-8 ile kodlanmış bayt akışına dönüştürmek için TextEncoder arayüzünü kullanın.
  2. Bu, JavaScript'te daha az kullanılan ve TypedArray'in alt sınıfı olan Uint8Array veri türünü döndürür.
  3. Bu Uint8Array değerini alıp bytesToBase64() işlevine gönderin. Bu işlev, Uint8Array'teki her baytı kod noktası olarak değerlendirip ondan bir dize oluşturmak için String.fromCodePoint() kullanır. Bu işlem sonucunda, tümü tek bir bayt olarak temsil edilebilen bir kod noktası dizesi elde edilir.
  4. Bu dizeyi alıp btoa() kullanarak base64 olarak kodlayın.

Kod çözme işlemi aynıdır ancak ters yönde gerçekleşir.

Bu, Uint8Array ile dize arasındaki adım, JavaScript'teki dize UTF-16, iki baytlık kodlama olarak temsil edilirken her iki baytın temsil ettiği kod noktasının her zaman 128'den az olmasını garanti ettiği için işe yarar.

Bu kod çoğu durumda iyi çalışır ancak bazı durumlarda sessizce başarısız olur.

Sessiz arıza durumu

Aynı kodu farklı bir dizeyle kullanın:

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

Kod çözdükten sonra bu son karakteri ( ) alır ve onaltılık değerini kontrol ederseniz, orijinal \uDE75 yerine \uFFFD olduğunu görürsünüz. Bu durumda hata vermez veya hata vermez ancak giriş ve çıkış verileri sessizce değişir. Neden?

Dizelerin biçimi JavaScript API'ye göre değişir.

Daha önce açıklandığı gibi, JavaScript dizeleri UTF-16 olarak işler. Ancak UTF-16 dizelerinin benzersiz bir özelliği vardır.

Örneğin, peynir emojisini ele alalım. Emojinin (🧀) Unicode kod noktası 129472'tür. Maalesef 16 bitlik bir sayı için maksimum değer 65.535'tir. Peki UTF-16 bu çok daha yüksek sayıyı nasıl temsil eder?

UTF-16'da yer tutucu çiftler adı verilen bir kavram vardır. Şöyle düşünebilirsiniz:

  • Çiftteki ilk sayı, aramanın hangi "kitapta" yapılacağını belirtir. Buna "vekil" adı verilir.
  • Çiftteki ikinci sayı, "kitap"taki giriştir.

Tahmin edebileceğiniz gibi, bazen kitabı temsil eden sayının bulunmasına rağmen söz konusu kitaptaki gerçek girişin bulunmaması sorun yaratabilir. UTF-16'da bu, tek vekil olarak bilinir.

Bazı API'ler tek bir vekil olmasına rağmen çalışırken diğerleri başarısız olduğundan bu durum özellikle JavaScript'te zordur.

Bu durumda, base64'ten kodu çözerken TextDecoder kullanırsınız. Özellikle, TextDecoder için varsayılan değerler şunu belirtir:

Varsayılan olarak false değerine ayarlanır. Bu, kod çözücünün hatalı biçimlendirilmiş verileri bir değiştirme karakteriyle değiştirdiği anlamına gelir.

Daha önce gördüğünüz ve onaltılık sistemde \uFFFD olarak gösterilen � karakteri, bu değiştirme karakteridir. UTF-16'da tek vekil içeren dizeler "bozuk" veya "iyi biçimlendirilmemiş" olarak kabul edilir.

Yanlış biçimlendirilmiş bir dizenin API davranışını ne zaman etkilediğini tam olarak belirten çeşitli web standartları (ör. 1, 2, 3, 4) vardır. Ancak TextDecoder bu API'lerden biridir. Metin işleme yapmadan önce dizelerin doğru biçimlendirildiğinden emin olmak iyi bir uygulamadır.

Düzgün biçimlendirilmiş dizeleri kontrol etme

Son sürüm tarayıcılarda bu amaç için bir işlev bulunmaktadır: isWellFormed().

Tarayıcı desteği

  • Chrome: 111.
  • Edge: 111.
  • Firefox: 119.
  • Safari: 16.4.

Kaynak

Benzer bir sonuca encodeURIComponent() kullanarak da ulaşabilirsiniz. Bu işlev, dize tek bir vekil karakter içeriyorsa URIError hatası verir.

Aşağıdaki işlev, mevcutsa isWellFormed(), yoksa encodeURIComponent() kullanır. Benzer kod, isWellFormed() için bir polyfill oluşturmak amacıyla kullanılabilir.

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

Her şeyi bir araya getirin

Hem Unicode'u hem de tek başına yer tutucuları nasıl kullanacağınızı öğrendiniz. Artık her durumu ele alan ve bunu sessiz metin değiştirme olmadan yapan bir kod oluşturmak için her şeyi bir araya getirebilirsiniz.

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

Bu kodda, bir polyfill olarak genelleştirme, tek başına yer tutucuları sessizce değiştirmek yerine TextDecoder parametrelerini throw olarak değiştirme ve daha fazlası gibi birçok optimizasyon yapılabilir.

Bu bilgi ve kod sayesinde, hatalı dizelerin nasıl ele alınacağı konusunda net kararlar verebilirsiniz. Örneğin, verileri reddedebilir veya veri değişimini açıkça etkinleştirebilir ya da daha sonra analiz yapmak için bir hata yapabilirsiniz.

Bu gönderi, base64 kodlama ve kod çözme için değerli bir örnek olmanın yanı sıra, özellikle metin verileri kullanıcı tarafından oluşturulan veya harici kaynaklardan geldiğinde dikkatli metin işlemenin neden özellikle önemli olduğuna dair bir örnek sunar.