ข้ามไปยังเนื้อหา

Buffer

JavaScript string ถูกเก็บภายในเป็น UTF-16 — สองไบต์ต่อหนึ่ง code point ใน Basic Multilingual Plane นั่นก็โอเคสำหรับข้อความ แต่งาน networking และ filesystem ต้องจัดการกับ byte ดิบ: TCP packet, ภาพ JPEG หรือ binary protocol frame ไม่มี encoding โดยธรรมชาติ

Buffer คือคำตอบของ Node: ลำดับ byte ที่มีความยาวคงที่ ถูก expose เป็น subclass ของ Uint8Array มันมีมาก่อน typed array และยังเป็น type มาตรฐานที่คุณจะเห็นตลอดใน stream, HTTP, crypto และ fs API ของ Node

วิธีที่แนะนำคือ Buffer.from() constructor new Buffer() ถูก deprecate แล้ว (DEP0005) — มันยังทำงานได้และพิมพ์ DeprecationWarning แต่อย่าใช้ใน code ใหม่เด็ดขาด

Node.js
Output (verified on Node 24)
buf1: <Buffer 48 65 6c 6c 6f>
buf1 length: 5
buf2: <Buffer de ad be ef>
buf2 length: 4
buf3 (zeroed): <Buffer 00 00 00 00>

Buffer.alloc(size) zero-fill หน่วยความจำก่อนส่งมอบให้คุณ — ปลอดภัย แต่เสียต้นทุนเขียนทับทุกไบต์ Buffer.allocUnsafe(size) ข้าม zero-fill นั้นไปและให้ byte ที่นั่งอยู่ในพื้นที่ memory pool นั้นอยู่แล้วมาให้คุณเลย — เร็วกว่า แต่ byte เหล่านั้นอาจเป็น อะไรก็ได้ รวมถึงเศษข้อมูลที่หลงเหลือจาก buffer ที่ถูกปล่อยไปก่อนหน้านี้ (request body เก่า, เนื้อหาไฟล์เก่า, ความลับเก่า)

Node.js
Output (verified on Node 24)
alloc(8): <Buffer 00 00 00 00 00 00 00 00>
allocUnsafe(8) length: 8
allocUnsafe(8) is a Buffer: true
after fill(0): <Buffer 00 00 00 00 00 00 00 00>
now equal to alloc(8): true

ตัวอย่างประกอบเท่านั้น — byte จริงด้านล่างจะแตกต่างกันทุกครั้งที่รัน ซึ่งนั่นคือประเด็นทั้งหมด:

// @skip-verify nondeterministic memory contents — illustrates allocUnsafe's danger, not something with a fixed expected output
var mystery = Buffer.allocUnsafe(8);
console.log(mystery);
// <Buffer 00 00 00 00 8f 12 40 55> <- or anything else. Never rely on this.

toString(encoding) แปลง Buffer กลับเป็น string ใน encoding ที่ต้องการ

Node.js
Output (verified on Node 24)
utf8  : Node.js
hex   : 4e6f64652e6a73
base64: Tm9kZS5qcw==
decoded: Node.js

base64 มาตรฐานอาจมี +, / และ padding = ต่อท้าย — ทั้งสามตัวต้อง escape ใน URL, cookie หรือ HTTP header base64url (Node 15.7+) คือ encoding เดียวกันแต่ใช้ alphabet ที่ปลอดภัยสำหรับ URL (-/_ แทน +//) และไม่มี padding ซึ่งเป็น format เดียวกับที่ JWT ใช้สำหรับส่วน header และ payload

Node.js
Output (verified on Node 24)
header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSJ9
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSJ9.SIGNATURE
standard base64: //++
base64url      : __--
decoded payload: { sub: '1234567890', name: 'Ada' }
Node.js
Output (verified on Node 24)
ascii chars: 5
ascii bytes: 5
thai chars: 6
thai bytes (utf8): 18
emoji .length: 7
emoji bytes (utf8): 9

buf.slice(start, end) (หรือ buf.subarray) คืนค่า view ของ memory เดียวกัน — แก้ไข slice ก็แก้ไขต้นฉบับด้วย ใช้ Buffer.from(slice) เพื่อได้ copy อิสระ

Node.js
Output (verified on Node 24)
slice: Hello
buf after mutation: Jello, World!
copy: world
buf unchanged: Jello, World!

network stream ส่งมอบ byte เป็น chunk ตามที่ OS หยิบยื่นมาให้ — มันไม่รู้เลยว่าขอบเขตของตัวอักษร UTF-8 อยู่ตรงไหน ถ้าตัวอักษร multi-byte (เช่นตัวอักษรไทยส่วนใหญ่ที่ใช้ 3 ไบต์ต่อตัวใน UTF-8) ถูกตัดข้ามสอง chunk การเรียก .toString('utf8') บนแต่ละ chunk แยกกัน จะทำให้มันเสียหาย: แต่ละครึ่งเป็น byte sequence ที่ไม่สมบูรณ์ ทั้งสองครึ่งจึงถอดรหัสออกมาเป็นตัวอักษรทดแทนของ Unicode (�)

StringDecoder จาก node:string_decoder แก้ปัญหานี้: มัน buffer ไบต์ที่ตกค้างไม่สมบูรณ์และต่อเข้ากับ chunk ถัดไป ทำให้ตัวอักษร multi-byte จะถูกถอดรหัสก็ต่อเมื่อมันสมบูรณ์แล้วเท่านั้น

Node.js
Output (verified on Node 24)
full string: สวัสดี
total bytes: 18
--- naive: toString() per chunk ---
chunk1.toString(): "ส�"
chunk2.toString(): "��ัสดี"
naive concat: ส���ัสดี
--- correct: StringDecoder buffers partial characters ---
decoded: สวัสดี
matches original: true
flowchart LR
  A["Buffer.alloc(n)"] -->|"zero-fills first"| Z["all bytes 0x00 — safe to read immediately"]
  B["Buffer.allocUnsafe(n)"] -->|"skips zero-fill"| G["whatever was in the memory pool — must overwrite before reading"]
  G -->|".fill() or write every byte"| Z
  S["multi-byte char split across two chunks"] -->|"toString() per chunk"| Corrupt["each half is incomplete -> replacement character"]
  S -->|"StringDecoder.write() per chunk"| Correct["partial bytes carried to next chunk -> correct string"]
alloc แลก zero-fill write เพื่อความปลอดภัย; allocUnsafe แลกความปลอดภัยเพื่อความเร็ว; StringDecoder คือทางแก้สำหรับขอบเขต chunk ที่ตัดตัวอักษรขาด
ทางเลือกข้อดีต้นทุน
Buffer.alloc(n)ปลอดภัยเสมอ — memory ที่ zero แบบ deterministicเสียต้นทุน zero-fill แม้ว่าคุณกำลังจะเขียนทับทุกอย่างอยู่แล้วก็ตาม
Buffer.allocUnsafe(n)ข้าม zero-fill — เร็วกว่าใน hot pathเนื้อหายังไม่ถูกกำหนดจนกว่าคุณจะเขียนทับ; ลืมทำแล้วรั่วไหลข้อมูล heap เก่า
view จาก .slice()/.subarray()zero-copy — ไม่มีการจอง memory ใหม่แก้ไข view ก็แก้ไข buffer ต้นฉบับด้วย ซึ่งพลาดได้ง่ายโดยไม่ตั้งใจ
.toString() ต่อ chunkcode ง่ายที่สุดเท่าที่จะทำได้ทำให้ตัวอักษร multi-byte ที่ถูกตัดข้าม chunk เสียหายแบบเงียบ ๆ
StringDecoderถูกต้องข้ามขอบเขต chunkมี object เพิ่มอีกหนึ่งตัวที่ต้องพกไปกับ code จัดการ stream ของคุณ
  • “Buffer.allocUnsafe ก็แค่ Buffer.alloc ที่เร็วกว่า” — ความจริง: มันเร็วกว่าเพราะข้ามการ zero memory; byte ที่ได้กลับมาคือข้อมูลที่หลงเหลืออยู่ ไม่ใช่ zero จนกว่าคุณจะเขียนทับมันเอง
  • “base64 กับ base64url คือสิ่งเดียวกันแค่ชื่อต่างกัน” — ความจริง: base64url แทน -/_ ด้วย +// และตัด padding ออก โดยเฉพาะเพื่อให้ผลลัพธ์ปลอดภัยสำหรับฝังใน URL, cookie หรือ header โดยไม่ต้อง escape
  • “chunk.toString('utf8') ปลอดภัยเสมอสำหรับ text stream” — ความจริง: มันปลอดภัยก็ต่อเมื่อขอบเขต chunk ไม่เคยตัดตัวอักษร multi-byte — ซึ่ง network stream ไม่สามารถรับประกันได้
  • “buf.slice() คืนค่า copy เหมือน Array.prototype.slice()” — ความจริง: Buffer#slice/#subarray คืนค่า view เหนือ memory เดียวกัน; แก้ไข slice ก็แก้ไขต้นฉบับด้วย
  • Buffer.allocUnsafe() ถูกใช้ในที่ที่ buffer ถูกอ่าน (log, ส่งออก, hash) ก่อนที่ทุกไบต์จะถูกเขียนหรือ .fill() อย่างชัดเจนหรือเปล่า?
  • stream handler เรียก chunk.toString('utf8') บนแต่ละ chunk แยกกัน แทนที่จะสะสมด้วย Buffer.concat() หรือถอดรหัสด้วย StringDecoder หรือเปล่า? แบบนี้จะพังทันทีที่ตัวอักษร multi-byte (ไทย, emoji, CJK) คร่อมขอบเขต chunk
  • code คำนวณขนาด string ด้วย str.length แทนที่จะเป็น Buffer.byteLength(str, 'utf8') ก่อนจอง buffer ขนาดคงที่หรือตั้งค่า header Content-Length หรือเปล่า? input ที่ไม่ใช่ ASCII จะล้นหรือถูกตัดทอน
  • JWT หรือ URL token ถูกสร้างด้วย base64 มาตรฐานแทนที่จะเป็น base64url ซึ่งต้อง escape +///= ด้วยมือและพลาดง่ายหรือเปล่า?

🐛 หา bug ในโค้ดนี้

const { Transform } = require('node:stream');
class Uppercase extends Transform {
_transform(chunk, encoding, callback) {
// chunk is a raw Buffer — this decodes each piece independently
var text = chunk.toString('utf8');
callback(null, text.toUpperCase());
}
}
เฉลย

_transform รับ Buffer chunk ดิบโดยไม่มีการรับประกันว่าขอบเขต chunk จะเคารพขอบเขตตัวอักษร UTF-8 การเรียก chunk.toString('utf8') บนแต่ละ chunk แยกกันจะทำให้ตัวอักษร multi-byte (ไทย, emoji, CJK) ที่บังเอิญถูกตัดข้ามสอง chunk เสียหาย — ความล้มเหลวแบบเดียวกับที่แสดงใน demo StringDecoder ด้านบน ที่แต่ละครึ่งถอดรหัสออกมาเป็น � แทนที่จะเป็นตัวอักษรจริง วิธีแก้คือเก็บ instance ของ StringDecoder ไว้ใน class (this._decoder = new StringDecoder('utf8')) แล้วเรียก this._decoder.write(chunk) ใน _transform บวกกับ this._decoder.end() ใน _flush เพื่อ flush ไบต์ที่ตกค้างท้ายสุด

ทำไม Buffer.byteLength(str) มักแตกต่างจาก str.length?
Buffer.from("deadbeef", "hex") สร้างอะไรออกมา?
เกิดอะไรขึ้นเมื่อคุณแก้ไข slice ที่คืนค่ามาจาก buf.slice()?
โค้ดนี้พิมพ์อะไร? var safe = Buffer.alloc(4); var unsafe = Buffer.allocUnsafe(4); console.log(safe.equals(Buffer.from([0, 0, 0, 0])));
UTF-8 byte stream ส่งตัวอักษรไทยขนาด 3 ไบต์โดยแบ่งเป็น 2 ไบต์ใน chunk หนึ่งและ 1 ไบต์ใน chunk ถัดไป chunk.toString('utf8') บนแต่ละ chunk แยกกันให้ผลอะไร?
encoding ใดคือตัวเลือกที่ถูกต้องสำหรับฝัง token ตรง ๆ ใน URL query string โดยไม่ต้อง escape ด้วยมือ?