การเข้ารหัสและการถอดรหัส 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
ตัวอย่างอักขระใน Unicode และตัวเลขที่เกี่ยวข้องมีดังนี้
- h - 104
- ñ - 241
- ❤ - 2764
- ❤️ - 2764 ที่มีตัวแก้ไขที่ซ่อนอยู่ซึ่งมีหมายเลข 65039
- ⛳ - 9971
- 🧀 - 129472
ตัวเลขที่แสดงถึงอักขระแต่ละตัวเรียกว่า "จุดโค้ด" คุณอาจมอง "จุดโค้ด" เป็นที่อยู่ของอักขระแต่ละตัว ในอีโมจิหัวใจสีแดงนั้น จริงๆ แล้วมีจุดโค้ด 2 จุด ได้แก่ จุดสำหรับหัวใจ 1 จุด และจุดสำหรับ "เปลี่ยน" สีและทำให้สีแดงเสมอ 1 จุด
Unicode มี 2 วิธีทั่วไปในการนำจุดโค้ดเหล่านี้มาทำให้เป็นลำดับไบต์ที่คอมพิวเตอร์ตีความได้อย่างสม่ำเสมอ นั่นคือ UTF-8 และ UTF-16
มุมมองที่เข้าใจง่ายคือ
- ใน UTF-8 จุดโค้ดจะใช้ได้ตั้งแต่ 1-4 ไบต์ (8 บิตต่อไบต์)
- ใน UTF-16 หนึ่ง Code Point จะมีความยาว 2 ไบต์ (16 บิต) เสมอ
สิ่งสำคัญคือ JavaScript จะประมวลผลสตริงเป็น UTF-16 การดำเนินการนี้จะหยุดฟังก์ชันอย่าง btoa()
ซึ่งทำงานอย่างมีประสิทธิภาพโดยสมมติว่าอักขระแต่ละตัวในสตริงแมปกับไบต์เดี่ยว ข้อมูลนี้ระบุไว้อย่างชัดเจนใน MDN
เมธอด
btoa()
จะสร้างสตริง ASCII ที่เข้ารหัส Base64 จากสตริงไบนารี (นั่นคือสตริงที่ระบบจะถือว่าอักขระแต่ละตัวในสตริงเป็นไบต์ของข้อมูลไบนารี)
ตอนนี้คุณทราบแล้วว่าอักขระใน JavaScript มักต้องใช้มากกว่า 1 ไบต์ ส่วนถัดไปจะแสดงวิธีจัดการกรณีนี้สําหรับการเข้ารหัสและถอดรหัส Base64
btoa() และ atob() ที่มี Unicode
ดังที่คุณทราบแล้ว ข้อผิดพลาดที่เกิดขึ้นเกิดจากสตริงที่มีอักขระที่อยู่นอกไบต์เดียวใน UTF-16
แต่โชคดีที่บทความ MDN เกี่ยวกับ Base64 มีตัวอย่างโค้ดที่เป็นประโยชน์ในการแก้ปัญหา "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}]`);
ขั้นตอนต่อไปนี้จะอธิบายว่าโค้ดนี้มีหน้าที่อะไรในการเข้ารหัสสตริง
- ใช้
TextEncoder
อินเทอร์เฟซเพื่อนำสตริง JavaScript ที่เข้ารหัส UTF-16 และแปลงเป็นสตรีบไบต์ที่เข้ารหัส UTF-8 โดยใช้TextEncoder.encode()
- การดำเนินการนี้จะแสดงผลเป็น
Uint8Array
ซึ่งเป็นประเภทข้อมูลที่ไม่ค่อยได้ใช้กันมากนักใน JavaScript และเป็นคลาสย่อยของTypedArray
- นำ
Uint8Array
นั้นไปใส่ในฟังก์ชันbytesToBase64()
ซึ่งใช้String.fromCodePoint()
ในการจัดการไบต์แต่ละไบต์ในUint8Array
เป็นจุดโค้ด และสร้างสตริงจากจุดโค้ด ซึ่งจะส่งผลให้เกิดสตริงของจุดโค้ดที่สามารถแสดงเป็นไบต์เดี่ยวได้ทั้งหมด - นำสตริงนั้นไปใช้กับ
btoa()
เพื่อเข้ารหัส Base64
กระบวนการถอดรหัสจะเหมือนกัน แต่ทําในแบบย้อนกลับ
การดำเนินการนี้ได้ผลเนื่องจากขั้นตอนระหว่าง Uint8Array
กับสตริงรับประกันว่าในขณะที่สตริงใน JavaScript แสดงเป็นการเข้ารหัส UTF-16 แบบ 2 ไบต์ รหัสจุดที่แต่ละไบต์แสดงจะน้อยกว่า 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}]`);
หากนำอักขระสุดท้ายหลังการถอดรหัส (�) มาตรวจสอบค่าฐาน 16 ก็จะพบว่าเป็น \uFFFD
ไม่ใช่ \uDE75
เดิม การดำเนินการไม่ล้มเหลวหรือแสดงข้อผิดพลาด แต่ข้อมูลอินพุตและเอาต์พุตมีการเปลี่ยนแปลงโดยอัตโนมัติ เหตุผล
สตริงจะแตกต่างกันไปตาม JavaScript API
JavaScript จะประมวลผลสตริงเป็น UTF-16 ตามที่อธิบายไว้ก่อนหน้านี้ แต่สตริง UTF-16 มีคุณสมบัติที่ไม่ซ้ำกัน
มาดูตัวอย่างอีโมจิชีสกัน อีโมจิ (🧀) มีจุดโค้ด Unicode ของ
129472
ขออภัย ค่าสูงสุดสำหรับหมายเลข 16 บิตคือ 65535 UTF-16 จะแสดงตัวเลขที่สูงกว่านี้ได้อย่างไร
UTF-16 มีแนวคิดที่เรียกว่าคู่ตัวแทน ลองคิดตามตัวอย่างนี้
- ตัวเลขแรกในคู่หมายถึง "หนังสือ" ที่จะค้นหา ซึ่งเรียกว่า "ตัวแทน"
- ตัวเลขที่สองในคู่คือรายการใน "หนังสือ"
ดังที่คุณอาจทราบ บางครั้งการระบุเฉพาะหมายเลขที่แสดงถึงหนังสือ แต่ไม่ได้ระบุรายการจริงในหนังสือนั้นอาจทำให้เกิดปัญหาได้ ใน UTF-16 เรียกกรณีนี้ว่าตัวแทนเดี่ยว
ซึ่งจะทําได้ยากมากใน JavaScript เนื่องจาก API บางรายการทํางานได้แม้จะมีตัวแทนเพียงรายการเดียว แต่บางรายการทํางานไม่ได้
ในกรณีนี้ คุณใช้ TextDecoder
เมื่อถอดรหัสจาก base64 โดยเฉพาะอย่างยิ่ง ค่าเริ่มต้นสำหรับ
TextDecoder
จะระบุข้อมูลต่อไปนี้
โดยค่าเริ่มต้นจะเป็น false ซึ่งหมายความว่าโปรแกรมถอดรหัสจะแทนที่ข้อมูลที่ไม่เป็นรูปแบบด้วยอักขระแทน
อักขระดังกล่าวที่คุณสังเกตเห็นก่อนหน้านี้ซึ่งแสดงเป็น \uFFFD
ในเลขฐานสิบหก นั่นก็คืออักขระที่ใช้แทนที่ ใน UTF-16 ระบบจะถือว่าสตริงที่มีตัวแทนตัวเดียว "มีรูปแบบไม่ถูกต้อง" หรือ "มีรูปแบบไม่ถูกต้อง"
มาตรฐานเว็บต่างๆ (ตัวอย่าง 1, 2, 3, 4) จะระบุไว้อย่างชัดเจนว่าสตริงที่มีรูปแบบไม่ถูกต้องจะส่งผลต่อลักษณะการทํางานของ API เมื่อใด แต่ที่น่าสังเกตคือ TextDecoder
เป็นหนึ่งใน API เหล่านั้น คุณควรตรวจสอบว่าสตริงอยู่ในรูปแบบที่ถูกต้องก่อนที่จะประมวลผลข้อความ
ตรวจหาสตริงที่มีรูปแบบเหมาะสม
เบราว์เซอร์ล่าสุดมีฟังก์ชันสำหรับวัตถุประสงค์นี้แล้ว ซึ่งก็คือ isWellFormed()
คุณได้ผลลัพธ์ที่คล้ายกันโดยใช้ 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 แล้ว โพสต์นี้ยังแสดงตัวอย่างว่าเหตุใดการประมวลผลข้อความอย่างละเอียดจึงสำคัญอย่างยิ่ง โดยเฉพาะเมื่อข้อมูลข้อความมาจากแหล่งที่มาภายนอกหรือที่ผู้ใช้สร้างขึ้น