ไทย

ฝึกฝนการจัดการทรัพยากรแบบชัดเจน (Explicit Resource Management) ของ JavaScript ด้วย `using` และ `await using` เรียนรู้วิธีทำความสะอาดอัตโนมัติ ป้องกันทรัพยากรรั่วไหล และเขียนโค้ดที่สะอาดและแข็งแกร่งยิ่งขึ้น

พลังพิเศษใหม่ของ JavaScript: เจาะลึกการจัดการทรัพยากรแบบชัดเจน

ในโลกที่ไม่เคยหยุดนิ่งของการพัฒนาซอฟต์แวร์ การจัดการทรัพยากรอย่างมีประสิทธิภาพถือเป็นรากฐานสำคัญของการสร้างแอปพลิเคชันที่แข็งแกร่ง เชื่อถือได้ และมีประสิทธิภาพสูง เป็นเวลาหลายทศวรรษที่นักพัฒนา JavaScript ต้องพึ่งพารูปแบบที่ทำด้วยตนเองอย่าง try...catch...finally เพื่อให้แน่ใจว่าทรัพยากรที่สำคัญ เช่น file handles, การเชื่อมต่อเครือข่าย หรือเซสชันฐานข้อมูล ได้รับการปล่อยอย่างถูกต้อง แม้ว่าจะเป็นวิธีที่ใช้งานได้ แต่แนวทางนี้มักจะยืดยาว เกิดข้อผิดพลาดได้ง่าย และอาจกลายเป็นเรื่องยุ่งยากได้อย่างรวดเร็ว ซึ่งเป็นรูปแบบที่บางครั้งเรียกว่า "พีระมิดแห่งหายนะ" (pyramid of doom) ในสถานการณ์ที่ซับซ้อน

ขอแนะนำการเปลี่ยนแปลงกระบวนทัศน์สำหรับภาษานี้: Explicit Resource Management (ERM) หรือการจัดการทรัพยากรแบบชัดเจน ฟีเจอร์อันทรงพลังนี้ซึ่งได้รับการสรุปในมาตรฐาน ECMAScript 2024 (ES2024) และได้รับแรงบันดาลใจจากโครงสร้างที่คล้ายกันในภาษาอย่าง C#, Python และ Java ได้นำเสนอวิธีการจัดการการทำความสะอาดทรัพยากรที่เป็นแบบประกาศ (declarative) และอัตโนมัติ ด้วยการใช้คีย์เวิร์ดใหม่อย่าง using และ await using ตอนนี้ JavaScript ได้มอบโซลูชันที่สวยงามและปลอดภัยกว่ามากสำหรับความท้าทายด้านการเขียนโปรแกรมที่ไม่เคยล้าสมัย

คู่มือฉบับสมบูรณ์นี้จะพาคุณเดินทางผ่านโลกของการจัดการทรัพยากรแบบชัดเจนของ JavaScript เราจะสำรวจปัญหาที่มันแก้ไข วิเคราะห์แนวคิดหลักของมัน เดินผ่านตัวอย่างที่ใช้งานได้จริง และค้นพบรูปแบบขั้นสูงที่จะช่วยให้คุณสามารถเขียนโค้ดที่สะอาดและยืดหยุ่นมากขึ้น ไม่ว่าคุณจะกำลังพัฒนาอยู่ที่ใดในโลก

รูปแบบดั้งเดิม: ความท้าทายของการทำความสะอาดทรัพยากรด้วยตนเอง

ก่อนที่เราจะชื่นชมความสง่างามของระบบใหม่ เราต้องเข้าใจถึงความเจ็บปวดของระบบเก่าเสียก่อน รูปแบบคลาสสิกสำหรับการจัดการทรัพยากรใน JavaScript คือบล็อก try...finally

ตรรกะของมันเรียบง่าย: คุณได้รับทรัพยากรในบล็อก try และคุณปล่อยมันในบล็อก finally บล็อก finally รับประกันว่าจะทำงานเสมอ ไม่ว่าโค้ดในบล็อก try จะสำเร็จ ล้มเหลว หรือจบการทำงานก่อนกำหนดก็ตาม

ลองพิจารณาสถานการณ์ทั่วไปทางฝั่งเซิร์ฟเวอร์: การเปิดไฟล์ เขียนข้อมูลลงไป แล้วตรวจสอบให้แน่ใจว่าไฟล์ถูกปิดแล้ว

ตัวอย่าง: การดำเนินการกับไฟล์อย่างง่ายด้วย try...finally


const fs = require('fs/promises');

async function processFile(filePath, data) {
  let fileHandle;
  try {
    console.log('Opening file...');
    fileHandle = await fs.open(filePath, 'w');
    console.log('Writing to file...');
    await fileHandle.write(data);
    console.log('Data written successfully.');
  } catch (error) {
    console.error('An error occurred during file processing:', error);
  } finally {
    if (fileHandle) {
      console.log('Closing file...');
      await fileHandle.close();
    }
  }
}

โค้ดนี้ทำงานได้ แต่มันก็เผยให้เห็นจุดอ่อนหลายประการ:

ตอนนี้ ลองจินตนาการถึงการจัดการทรัพยากรหลายรายการ เช่น การเชื่อมต่อฐานข้อมูลและ file handle โค้ดจะกลายเป็นความยุ่งเหยิงที่ซ้อนกันอย่างรวดเร็ว:


async function logQueryResultToFile(query, filePath) {
  let dbConnection;
  try {
    dbConnection = await getDbConnection();
    const result = await dbConnection.query(query);

    let fileHandle;
    try {
      fileHandle = await fs.open(filePath, 'w');
      await fileHandle.write(JSON.stringify(result));
    } finally {
      if (fileHandle) {
        await fileHandle.close();
      }
    }
  } finally {
    if (dbConnection) {
      await dbConnection.release();
    }
  }
}

การซ้อนกันแบบนี้ยากต่อการบำรุงรักษาและขยายขนาด มันเป็นสัญญาณที่ชัดเจนว่าจำเป็นต้องมี abstraction ที่ดีกว่านี้ นี่คือปัญหาที่ Explicit Resource Management ถูกออกแบบมาเพื่อแก้ไขอย่างแม่นยำ

การเปลี่ยนกระบวนทัศน์: หลักการของการจัดการทรัพยากรแบบชัดเจน

Explicit Resource Management (ERM) นำเสนอสัญญา (contract) ระหว่างอ็อบเจกต์ทรัพยากรและ JavaScript runtime แนวคิดหลักนั้นเรียบง่าย: อ็อบเจกต์สามารถประกาศได้ว่าควรจะทำความสะอาดตัวเองอย่างไร และภาษาก็มีไวยากรณ์เพื่อทำการทำความสะอาดนั้นโดยอัตโนมัติเมื่ออ็อบเจกต์นั้นหลุดออกจากขอบเขต (scope)

สิ่งนี้ทำได้ผ่านส่วนประกอบหลักสองอย่าง:

  1. The Disposable Protocol: วิธีมาตรฐานสำหรับอ็อบเจกต์ในการกำหนดตรรกะการทำความสะอาดของตัวเองโดยใช้ symbols พิเศษ: Symbol.dispose สำหรับการทำความสะอาดแบบซิงโครนัส และ Symbol.asyncDispose สำหรับการทำความสะอาดแบบอะซิงโครนัส
  2. การประกาศ `using` และ `await using`: คีย์เวิร์ดใหม่ที่ผูกทรัพยากรเข้ากับขอบเขตของบล็อก (block scope) เมื่อออกจากบล็อก เมธอดการทำความสะอาดของทรัพยากรจะถูกเรียกใช้โดยอัตโนมัติ

แนวคิดหลัก: `Symbol.dispose` และ `Symbol.asyncDispose`

หัวใจของ ERM คือ Symbols ที่รู้จักกันดีสองตัวใหม่ อ็อบเจกต์ที่มีเมธอดซึ่งใช้หนึ่งใน symbols เหล่านี้เป็น key จะถือว่าเป็น "disposable resource" (ทรัพยากรที่ใช้แล้วทิ้งได้)

การกำจัดแบบซิงโครนัสด้วย `Symbol.dispose`

สัญลักษณ์ Symbol.dispose ระบุเมธอดการทำความสะอาดแบบซิงโครนัส ซึ่งเหมาะสำหรับทรัพยากรที่การทำความสะอาดไม่ต้องการการดำเนินการแบบอะซิงโครนัสใด ๆ เช่น การปิด file handle แบบซิงโครนัส หรือการปล่อยการล็อกในหน่วยความจำ (in-memory lock)

ลองสร้าง wrapper สำหรับไฟล์ชั่วคราวที่ทำความสะอาดตัวเอง


const fs = require('fs');
const path = require('path');

class TempFile {
  constructor(content) {
    this.path = path.join(__dirname, `temp_${Date.now()}.txt`);
    fs.writeFileSync(this.path, content);
    console.log(`Created temp file: ${this.path}`);
  }

  // This is the synchronous disposable method
  [Symbol.dispose]() {
    console.log(`Disposing temp file: ${this.path}`);
    try {
      fs.unlinkSync(this.path);
      console.log('File deleted successfully.');
    } catch (error) {
      console.error(`Failed to delete file: ${this.path}`, error);
      // It's important to handle errors within dispose, too!
    }
  }
}

ตอนนี้ instance ใด ๆ ของ `TempFile` ถือเป็น disposable resource มันมีเมธอดที่ใช้ `Symbol.dispose` เป็น key ซึ่งมีตรรกะในการลบไฟล์ออกจากดิสก์

การกำจัดแบบอะซิงโครนัสด้วย `Symbol.asyncDispose`

การดำเนินการทำความสะอาดสมัยใหม่จำนวนมากเป็นแบบอะซิงโครนัส การปิดการเชื่อมต่อฐานข้อมูลอาจเกี่ยวข้องกับการส่งคำสั่ง `QUIT` ผ่านเครือข่าย หรือ client ของคิวข้อความอาจต้องล้างบัฟเฟอร์ขาออก สำหรับสถานการณ์เหล่านี้ เราใช้ `Symbol.asyncDispose`

เมธอดที่เกี่ยวข้องกับ `Symbol.asyncDispose` จะต้องคืนค่าเป็น `Promise` (หรือเป็นฟังก์ชัน `async`)

ลองจำลองการเชื่อมต่อฐานข้อมูลจำลองที่ต้องถูกปล่อยคืนสู่ pool แบบอะซิงโครนัส


// A mock database pool
const mockDbPool = {
  getConnection: () => {
    console.log('DB connection acquired.');
    return new MockDbConnection();
  }
};

class MockDbConnection {
  query(sql) {
    console.log(`Executing query: ${sql}`);
    return Promise.resolve({ success: true, rows: [] });
  }

  // This is the asynchronous disposable method
  async [Symbol.asyncDispose]() {
    console.log('Releasing DB connection back to the pool...');
    // Simulate a network delay for releasing the connection
    await new Promise(resolve => setTimeout(resolve, 50));
    console.log('DB connection released.');
  }
}

ตอนนี้ instance ของ `MockDbConnection` ใดๆ ก็ตามคือ async disposable resource มันรู้วิธีที่จะปล่อยตัวเองแบบอะซิงโครนัสเมื่อไม่จำเป็นต้องใช้งานอีกต่อไป

ไวยากรณ์ใหม่: การใช้งาน `using` และ `await using`

เมื่อเราได้กำหนดคลาสที่ใช้แล้วทิ้งได้ (disposable classes) ของเราแล้ว ตอนนี้เราสามารถใช้คีย์เวิร์ดใหม่เพื่อจัดการพวกมันโดยอัตโนมัติได้ คีย์เวิร์ดเหล่านี้สร้างการประกาศที่มีขอบเขตเป็นบล็อก (block-scoped) เช่นเดียวกับ `let` และ `const`

การทำความสะอาดแบบซิงโครนัสด้วย `using`

คีย์เวิร์ด `using` ใช้สำหรับทรัพยากรที่ implement `Symbol.dispose` เมื่อการทำงานของโค้ดออกจากบล็อกที่มีการประกาศ `using` เมธอด `[Symbol.dispose]()` จะถูกเรียกโดยอัตโนมัติ

มาใช้คลาส `TempFile` ของเรากัน:


function processDataWithTempFile() {
  console.log('Entering block...');
  using tempFile = new TempFile('This is some important data.');

  // You can work with tempFile here
  const content = fs.readFileSync(tempFile.path, 'utf8');
  console.log(`Read from temp file: "${content}"`);

  // No cleanup code needed here!
  console.log('...doing more work...');
} // <-- tempFile.[Symbol.dispose]() is called automatically right here!

processDataWithTempFile();
console.log('Block has been exited.');

ผลลัพธ์ที่ได้จะเป็นดังนี้:

Entering block...
Created temp file: /path/to/temp_1678886400000.txt
Read from temp file: "This is some important data."
...doing more work...
Disposing temp file: /path/to/temp_1678886400000.txt
File deleted successfully.
Block has been exited.

ดูสิว่ามันสะอาดแค่ไหน! วงจรชีวิตทั้งหมดของทรัพยากรอยู่ภายในบล็อก เราประกาศมัน เราใช้มัน และเราก็ลืมมันไปได้เลย ภาษาจะเป็นผู้จัดการการทำความสะอาดให้เอง นี่คือการปรับปรุงครั้งใหญ่ในด้านความสามารถในการอ่านและความปลอดภัย

การจัดการทรัพยากรหลายรายการ

คุณสามารถมีการประกาศ `using` หลายรายการในบล็อกเดียวกันได้ พวกมันจะถูกกำจัดในลำดับย้อนกลับของการสร้าง (พฤติกรรมแบบ LIFO หรือ "stack-like")


{
  using resourceA = new MyDisposable('A'); // Created first
  using resourceB = new MyDisposable('B'); // Created second
  console.log('Inside block, using resources...');
} // resourceB is disposed of first, then resourceA

การทำความสะอาดแบบอะซิงโครนัสด้วย `await using`

คีย์เวิร์ด `await using` เป็นคู่ของ `using` ในเวอร์ชันอะซิงโครนัส มันใช้สำหรับทรัพยากรที่ implement `Symbol.asyncDispose` เนื่องจากการทำความสะอาดเป็นแบบอะซิงโครนัส คีย์เวิร์ดนี้จึงสามารถใช้ได้เฉพาะภายในฟังก์ชัน `async` หรือที่ระดับบนสุดของโมดูลเท่านั้น (หากรองรับ top-level await)

มาใช้คลาส `MockDbConnection` ของเรากัน:


async function performDatabaseOperation() {
  console.log('Entering async function...');
  await using db = mockDbPool.getConnection();

  await db.query('SELECT * FROM users');

  console.log('Database operation complete.');
} // <-- await db.[Symbol.asyncDispose]() is called automatically here!

(async () => {
  await performDatabaseOperation();
  console.log('Async function has completed.');
})();

ผลลัพธ์แสดงให้เห็นถึงการทำความสะอาดแบบอะซิงโครนัส:

Entering async function...
DB connection acquired.
Executing query: SELECT * FROM users
Database operation complete.
Releasing DB connection back to the pool...
(waits 50ms)
DB connection released.
Async function has completed.

เช่นเดียวกับ `using` ไวยากรณ์ `await using` จะจัดการวงจรชีวิตทั้งหมด แต่จะ `await` กระบวนการทำความสะอาดแบบอะซิงโครนัสอย่างถูกต้อง มันยังสามารถจัดการกับทรัพยากรที่สามารถกำจัดได้แบบซิงโครนัสเท่านั้นได้ด้วย โดยมันจะไม่ await ทรัพยากรเหล่านั้น

รูปแบบขั้นสูง: `DisposableStack` และ `AsyncDisposableStack`

บางครั้ง การกำหนดขอบเขตแบบบล็อกง่ายๆ ของ `using` อาจไม่ยืดหยุ่นพอ จะทำอย่างไรถ้าคุณต้องการจัดการกลุ่มของทรัพยากรที่มีอายุขัยไม่ผูกติดอยู่กับบล็อกคำสั่งเดียว? หรือจะทำอย่างไรถ้าคุณกำลังทำงานร่วมกับไลบรารีเก่าที่ไม่ได้สร้างอ็อบเจกต์ที่มี `Symbol.dispose`?

สำหรับสถานการณ์เหล่านี้ JavaScript ได้เตรียมคลาสผู้ช่วยสองคลาสไว้ให้: `DisposableStack` และ `AsyncDisposableStack`

`DisposableStack`: ตัวจัดการการทำความสะอาดที่ยืดหยุ่น

A `DisposableStack` คืออ็อบเจกต์ที่จัดการชุดของการดำเนินการทำความสะอาด มันเองก็เป็น disposable resource ดังนั้นคุณจึงสามารถจัดการอายุขัยทั้งหมดของมันได้ด้วยบล็อก `using`

มันมีเมธอดที่มีประโยชน์หลายอย่าง:

ตัวอย่าง: การจัดการทรัพยากรตามเงื่อนไข

ลองจินตนาการถึงฟังก์ชันที่เปิดไฟล์บันทึก (log file) เฉพาะเมื่อเงื่อนไขบางอย่างเป็นจริง แต่คุณต้องการให้การทำความสะอาดทั้งหมดเกิดขึ้นในที่เดียวตอนท้าย


function processWithConditionalLogging(shouldLog) {
  using stack = new DisposableStack();

  const db = stack.use(getDbConnection()); // Always use the DB

  if (shouldLog) {
    const logFileStream = fs.createWriteStream('app.log');
    // Defer the cleanup for the stream
    stack.defer(() => {
      console.log('Closing log file stream...');
      logFileStream.end();
    });
    db.logTo(logFileStream);
  }

  db.doWork();

} // <-- The stack is disposed, calling all registered cleanup functions in LIFO order.

`AsyncDisposableStack`: สำหรับโลกอะซิงโครนัส

อย่างที่คุณอาจเดาได้ `AsyncDisposableStack` เป็นเวอร์ชันอะซิงโครนัส มันสามารถจัดการได้ทั้ง disposables แบบซิงโครนัสและอะซิงโครนัส เมธอดการทำความสะอาดหลักของมันคือ `.disposeAsync()` ซึ่งจะคืนค่า `Promise` ที่จะ resolve เมื่อการดำเนินการทำความสะอาดแบบอะซิงโครนัสทั้งหมดเสร็จสิ้น

ตัวอย่าง: การจัดการทรัพยากรแบบผสมผสาน

ลองสร้าง request handler สำหรับเว็บเซิร์ฟเวอร์ที่ต้องการการเชื่อมต่อฐานข้อมูล (การทำความสะอาดแบบ async) และไฟล์ชั่วคราว (การทำความสะอาดแบบ sync)


async function handleRequest() {
  await using stack = new AsyncDisposableStack();

  // Manage an async disposable resource
  const dbConnection = await stack.use(getAsyncDbConnection());

  // Manage a sync disposable resource
  const tempFile = stack.use(new TempFile('request data'));

  // Adopt a resource from an old API
  const legacyResource = getLegacyResource();
  stack.adopt(legacyResource, () => legacyResource.shutdown());

  console.log('Processing request...');
  await doWork(dbConnection, tempFile.path);

} // <-- stack.disposeAsync() is called. It will correctly await async cleanup.

`AsyncDisposableStack` เป็นเครื่องมือที่ทรงพลังสำหรับการจัดการตรรกะการตั้งค่าและรื้อถอนที่ซับซ้อนในลักษณะที่สะอาดและคาดเดาได้

การจัดการข้อผิดพลาดที่แข็งแกร่งด้วย `SuppressedError`

หนึ่งในการปรับปรุงที่ละเอียดอ่อนแต่สำคัญที่สุดของ ERM คือวิธีการจัดการข้อผิดพลาด จะเกิดอะไรขึ้นถ้ามีข้อผิดพลาดถูกโยน (throw) ภายในบล็อก `using` และมีข้อผิดพลาดอีกตัวถูกโยนระหว่างการกำจัดอัตโนมัติที่ตามมา?

ในโลกของ `try...finally` แบบเก่า ข้อผิดพลาดจากบล็อก `finally` มักจะเขียนทับหรือ "ระงับ" (suppress) ข้อผิดพลาดดั้งเดิมที่สำคัญกว่าจากบล็อก `try` ซึ่งมักทำให้การดีบักทำได้ยากอย่างเหลือเชื่อ

ERM แก้ปัญหานี้ด้วย error type ใหม่ที่เป็น global: `SuppressedError` หากเกิดข้อผิดพลาดระหว่างการกำจัดในขณะที่ข้อผิดพลาดอื่นกำลังแพร่กระจายอยู่ ข้อผิดพลาดจากการกำจัดจะถูก "ระงับ" ข้อผิดพลาดดั้งเดิมจะถูกโยนออกไป แต่ตอนนี้มันจะมีคุณสมบัติ `suppressed` ซึ่งบรรจุข้อผิดพลาดจากการกำจัดไว้


class FaultyResource {
  [Symbol.dispose]() {
    throw new Error('Error during disposal!');
  }
}

try {
  using resource = new FaultyResource();
  throw new Error('Error during operation!');
} catch (e) {
  console.log(`Caught error: ${e.message}`); // Error during operation!
  if (e.suppressed) {
    console.log(`Suppressed error: ${e.suppressed.message}`); // Error during disposal!
    console.log(e instanceof SuppressedError); // false
    console.log(e.suppressed instanceof Error); // true
  }
}

พฤติกรรมนี้ช่วยให้แน่ใจว่าคุณจะไม่สูญเสียบริบทของความล้มเหลวเดิม ซึ่งนำไปสู่ระบบที่แข็งแกร่งและดีบักได้ง่ายขึ้นมาก

กรณีการใช้งานจริงในระบบนิเวศของ JavaScript

การประยุกต์ใช้ Explicit Resource Management นั้นกว้างขวางและเกี่ยวข้องกับนักพัฒนาทั่วโลก ไม่ว่าพวกเขาจะทำงานบน back-end, front-end หรือในการทดสอบ

การรองรับบนเบราว์เซอร์และ Runtime

ในฐานะที่เป็นฟีเจอร์ที่ทันสมัย สิ่งสำคัญคือต้องรู้ว่าคุณสามารถใช้ Explicit Resource Management ได้ที่ไหน ณ ปลายปี 2023 / ต้นปี 2024 การรองรับนั้นแพร่หลายในเวอร์ชันล่าสุดของสภาพแวดล้อม JavaScript ที่สำคัญ:

สำหรับสภาพแวดล้อมที่เก่ากว่า คุณจะต้องพึ่งพา transpilers เช่น Babel พร้อมด้วยปลั๊กอินที่เหมาะสมเพื่อแปลงไวยากรณ์ `using` และ polyfill symbols และ stack classes ที่จำเป็น

บทสรุป: สู่ยุคใหม่แห่งความปลอดภัยและความชัดเจน

Explicit Resource Management ของ JavaScript เป็นมากกว่าแค่ syntactic sugar; มันคือการปรับปรุงพื้นฐานของภาษาที่ส่งเสริมความปลอดภัย ความชัดเจน และความสามารถในการบำรุงรักษา ด้วยการทำให้กระบวนการทำความสะอาดทรัพยากรที่น่าเบื่อและเกิดข้อผิดพลาดได้ง่ายเป็นไปโดยอัตโนมัติ มันช่วยให้นักพัฒนาสามารถมุ่งเน้นไปที่ตรรกะทางธุรกิจหลักของพวกเขาได้

ประเด็นสำคัญที่ควรจำ:

เมื่อคุณเริ่มโครงการใหม่หรือปรับปรุงโค้ดที่มีอยู่ ลองพิจารณานำรูปแบบใหม่ที่ทรงพลังนี้ไปใช้ มันจะทำให้ JavaScript ของคุณสะอาดขึ้น แอปพลิเคชันของคุณเชื่อถือได้มากขึ้น และชีวิตของคุณในฐานะนักพัฒนาก็ง่ายขึ้นเล็กน้อย นี่คือมาตรฐานระดับโลกอย่างแท้จริงสำหรับการเขียน JavaScript ที่ทันสมัยและเป็นมืออาชีพ