JavaScript'te base64 kodlama dizelerinin nüansları

base64 kodlama ve kod çözme, ikili içeriği web için güvenli metin olarak temsil edilecek şekilde dönüştürmenin yaygın bir biçimidir. Satır içi resimler gibi veri URL'leri için yaygın bir şekilde kullanılır.

JavaScript'teki dizelere base64 kodlaması ve kodu çözdüğünüzde ne olur? Bu yayında, nüanslar ve kaçınılması gereken yaygın tehlikeler ele alınmaktadır.

btoa() ve atob()

JavaScript'te base64 kodlama ve kodu çözmenin temel işlevleri btoa() ve atob()'dir. btoa() bir dizeden base64 kodlamalı bir dizeye gider ve atob() kodu tekrar çözer.

Aşağıda kısa bir örnek verilmiştir:

// 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 belgelerinde belirtildiği gibi bu özellik yalnızca ASCII karakterleri veya tek bir baytla temsil edilebilen karakterler içeren dizelerle çalışır. Diğer bir deyişle, bu yöntem Unicode'da işe yaramaz.

Ne olduğunu 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 bu soruna neden neden neden oluyor?

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

Unicode ve JavaScript dizeleri

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

Unicode'da kullanılan karakterlere ve ilişkili sayılarına ilişkin bazı örnekler:

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

Her bir karakteri temsil eden sayılara "kod noktaları" adı verilir. "Kod noktalarını" her karaktere verilen bir adres olarak düşünebilirsiniz. Kırmızı kalp emojisinde aslında iki kod noktası vardır: biri kalp için, diğeri rengi "değişken" ve daima kırmızı yapar.

Unicode'da bu kod noktalarını alıp bilgisayarların tutarlı bir şekilde yorumlayabileceği bayt dizileri halinde dönüştürmek için yaygın olarak kullanılan iki yöntem vardır: UTF-8 ve UTF-16.

Fazla basitleştirilmiş bir görünüm şudur:

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

Daha da önemlisi, JavaScript dizeleri UTF-16 olarak işler. Bu, dizedeki her karakterin tek bir bayta eşlendiği varsayımına dayanılarak çalışan btoa() gibi işlevleri bozar. Bu, MDN'de açıkça belirtilir:

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

JavaScript'teki karakterlerin genellikle bir bayttan fazlasını gerektirdiğini artık biliyorsunuz. Bir sonraki bölümde, bu durumun base64 kodlama ve kod çözme için nasıl ele alınacağı gösterilmektedir.

Unicode ile btoa() ve atob()

Artık bildiğiniz gibi, atılan hatanın nedeni, UTF-16'da tek bir baytın dışında kalan karakterler içeren dizemizdir.

Neyse ki, base64 hakkındaki MDN makalesinde bu "Unicode problemi"ni çözmeye yönelik bazı faydalı örnek kodlar bulunmaktadır. 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 dizeyi kodlamak için neler yaptığı açıklanmaktadır:

  1. UTF-16 olarak kodlanmış JavaScript dizesini almak ve TextEncoder.encode() aracılığıyla UTF-8 kodlu bayt akışına dönüştürmek için TextEncoder arayüzünü kullanın.
  2. Bu komut Uint8Array sonucunu döndürür. Bu, JavaScript'te daha az kullanılan bir veri türü ve TypedArray'in alt sınıfıdır.
  3. Bu Uint8Array değerini alıp bytesToBase64() işlevine sağlayın. bytesToBase64() işlevi, Uint8Array içindeki her baytı bir kod noktası olarak değerlendirmek ve bundan bir dize oluşturmak için String.fromCodePoint() kullanır. Böylece, tümü tek bir bayt olarak temsil edilebilecek bir kod noktaları dizesi oluşturulur.
  4. Bu dizeyi alın ve base64 kodlaması için btoa() kullanın.

Kod çözme işlemi aynıdır, ancak tersi gerçekleşir.

Bu, Uint8Array ile bir dize arasındaki adımın, JavaScript'teki dizenin UTF-16, iki baytlık kodlama olarak temsil edilmesini sağlarken her iki baytın temsil ettiği kod noktasının her zaman 128'den küçük olmasını garanti etmesi nedeniyle işe yarar.

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

Sessiz hata durumu

Aynı kodu farklı bir dizeyle kullan:

// 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ıp on altılık değerini kontrol ederseniz bu karakterin, orijinal \uDE75 yerine \uFFFD olduğunu görürsünüz. Bu durumda herhangi bir hata olmaz veya hata çıkmaz ancak giriş ve çıkış verileri sessizce değişti. Neden?

Dizeler JavaScript API'sine göre değişiklik gösterir

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

Örnek olarak peynir emojisini ele alalım. Emoji (🧀) 129472 Unicode kod noktasına sahip. Maalesef 16 bitlik bir sayı için maksimum değer 65535'tir. Peki UTF-16 bu kadar yüksek sayıyı nasıl temsil eder?

UTF-16'da vekil çiftleri adı verilen bir kavram bulunur. Şöyle düşünebilirsiniz:

  • Çiftteki ilk sayı, arama yapılacak "kitabı" belirtir. Buna "vekil" denir.
  • Çiftteki ikinci sayı "kitap"taki giriştir.

Tahmin edebileceğiniz gibi, söz konusu kitaptaki asıl girişi değil yalnızca kitabı temsil eden numarayı kullanmak sorun yaratabilir. UTF-16'da bu, yalnız vekil olarak bilinir.

Bazı API'ler yalnızca vekillerine sahip olmasına rağmen çalışırken bazıları başarısız olduğundan bu durum özellikle JavaScript'te zorlayıcıdır.

Bu durumda, base64'ten kodu çözerken TextDecoder kullanırsınız. Özellikle, TextDecoder için varsayılanlar aşağıdakileri belirtir:

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

Daha önce gözlemlediğiniz ve on altılık olarak \uFFFD ile ifade edilen karakter, değiştirme karakteridir. UTF-16'da tek başına vekiller içeren dizeler "bozuk" veya "iyi biçimlendirilmemiş" olarak kabul edilir.

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

İyi biçimlendirilmiş dizeler olup olmadığını kontrol edin.

Çok yeni tarayıcılarda artık bu amaçla bir işlev mevcuttur: isWellFormed().

Tarayıcı Desteği

  • 111
  • 111
  • 119
  • 16,4

Kaynak

Dize tek başına vekil içeriyorsa URIError hatası veren encodeURIComponent() aracını kullanarak da benzer bir sonuç elde edebilirsiniz.

Aşağıdaki işlev, varsa isWellFormed(), yoksa encodeURIComponent() kullanır. isWellFormed() için çoklu dolgu oluşturmak üzere benzer bir kod 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

Artık hem Unicode'u hem de tek başına vekilleri nasıl işleyeceğinizi öğrendiğinize göre, tüm durumları ele alan ve bunu sessiz metin değiştirmeden yapan 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 yapılabilecek pek çok optimizasyon vardır. Örneğin, bir çoklu dolguya genelleştirme, tek başına vekilleri sessizce değiştirmek yerine atılacak TextDecoder parametrelerini değiştirme ve daha fazlası.

Bu bilgi ve kodla, bozuk dizeleri nasıl ele alacağınız (ör. verileri reddetme veya veri değiştirmeyi açıkça etkinleştirme) ya da daha sonraki analiz için hata bildirme gibi açık kararlar alabilirsiniz.

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