ICERUST氷錆Thoughts on software,
systems, and experiments.
บันทึกเรื่องซอฟต์แวร์
ระบบ และการทดลอง

··อ่าน 20 นาที

Elysia / ElysiaJS

ทำไม ElysiaJS ถึงเร็ว? เบื้องหลังการสร้าง Handler เฉพาะ Route

  • Elysia
  • ElysiaJS
  • Bun
  • JavaScript
  • TypeScript
  • Performance
  • AOT
  • JIT

อัปเดตล่าสุดเมื่อ

ElysiaJS กำลังเตรียมเข้าสู่เวอร์ชัน 2.0.0 โดยมีversion beta ให้ลองใช้แล้ว ระหว่างรอ version เต็ม ผมเลยอยากชวนมาดูกลไกเบื้องหลังความเร็วของมันกันครับ

เคยสงสัยกันไหมครับว่าทำไม ElysiaJS ถึงเร็ว?

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

แนวคิดเบื้องหลังคือการใช้ static analysis ร่วมกับ การสร้างโค้ดขณะรัน (dynamic code generation) ซึ่งมีอธิบายไว้ในงานวิจัย Elysia: Optimizing JavaScript Web Framework ที่ตีพิมพ์ในเอกสารรวมบทความของงานประชุม ACM/SIGAPP SAC ปี 2024 ส่วนในบทความนี้ เราจะค่อย ๆ ดูแนวคิดเหล่านี้ผ่านตัวอย่าง route และโค้ดของ Elysia ครับ

โค้ดที่ใช้อธิบายอ้างอิง Elysia 1.4.30 และลิงก์ source code ระบุ version ไว้ให้ตรวจตามได้ สำหรับการสร้างโค้ดตอน build หรือ build-time AOT จะอ้างอิง Elysia 2 beta แยกในหัวข้อที่เกี่ยวข้อง เพราะแต่ละ version มีรายละเอียดการทำงานต่างกัน

หัวข้อในบทความนี้ — อ่านตามลำดับ หรือกดข้ามไปยังส่วนที่สนใจได้เลยครับ

เพื่อให้เห็นว่า Elysia ลดงานตรงไหนได้บ้าง ลองเริ่มจากขั้นตอนที่ framework อย่าง Express หรือ Fastify ต้องทำเมื่อรับ request กันก่อนครับ

Framework จัดการ request อย่างไร?

นอกจากจับคู่ HTTP method และ path แล้ว การจัดการ request ยังอาจมีขั้นตอนอื่น ๆ ทั้งก่อนและหลังเรียก handler เช่น parsing, validation, hooks, error handling และการเตรียม HTTP response ขั้นตอนที่มีจริงขึ้นอยู่กับ framework, middleware และการตั้งค่าของแอป เช่น Express ต้องเพิ่มการตรวจข้อมูลผ่านโค้ดหรือ middleware เอง แต่ละ request จึงอาจต้องใช้เวลาและทรัพยากรกับขั้นตอนเหล่านี้ด้วย แม้ business logic จะมีแค่ไม่กี่บรรทัด

แต่ละ framework จัดลำดับงานต่างกัน ลองดูภาพด้านล่าง: Express เรียก middleware ตามลำดับที่เราลงทะเบียนไว้ ส่วน Fastify มีขั้นตอนหลักกำหนดไว้แล้ว และให้เราเพิ่มโค้ดผ่าน hooks ในแต่ละช่วง

Express · ตามลำดับที่ลงทะเบียน

ตัวอย่าง request flow ของ ExpressRequest ผ่าน middleware ที่ตรงกับ path ตามลำดับที่ลงทะเบียน เมื่อเรียก next จะไปขั้นถัดไปจนถึง route handler แต่ middleware สามารถส่ง response และจบการตอบกลับก่อนถึง handler ได้RequestMiddleware 1Middleware 2Route handlerResponsenext()next()res.send() → จบก่อนถึง handler
ตัวอย่างเมื่อ middleware และ route ตรงกับ request; parser ก็เป็น middleware ที่เลือกติดตั้งได้ เส้นสีแดงแสดงการตอบกลับแล้วไม่เรียก next() ภาพละเส้นทาง error ไว้
อ้างอิง: Express: Using middleware

Fastify · lifecycle ที่กำหนดไว้

Request lifecycle ของ FastifyIncoming request → Routing + instance logger → onRequest → preParsing → Parsing → preValidation → Validation → preHandler → User handler → Reply → preSerialization → Serialization → onSend → Outgoing response → onResponse ภาพแสดงเส้นทางปกติที่ไม่มี error หรือการตอบกลับก่อนถึง handlerIncoming requestRouting + instance loggeronRequestpreParsingParsingpreValidationValidationpreHandlerUser handlerReply → preSerializationSerialization → onSendOutgoing responseonResponse
เส้นทางปกติแบบย่อ: hooks บางช่วงตอบกลับก่อนได้ และ error จะแยกไปเส้นทางจัดการข้อผิดพลาด การ serialize ขึ้นอยู่กับ payload โดย onResponse ทำงานหลังส่ง response แล้ว
อ้างอิง: Fastify: Lifecycle

งานส่วนหนึ่งคือการเตรียมข้อมูลให้ handler เช่น parse query string, decode JSON body หรือแปลง headers เป็น object ใน context แต่ละขั้นตอนใช้เวลาและทรัพยากรต่างกัน เช่น การอ่าน headers ที่ HTTP server เตรียมไว้แล้ว กับการ parse body เพื่อแปลงข้อมูลให้อยู่ในรูปที่ handler ใช้งานได้

ลองสมมติว่าหลังเลือก route แล้ว เรามีฟังก์ชันกลางที่เตรียมข้อมูลทุกส่วนก่อนเรียก handler โค้ดต่อไปนี้เป็นแบบจำลองเพื่อเทียบแนวคิด ไม่ใช่ implementation ของ Express หรือ Fastify:

async function centralHandler(request) {
  const context = {
    request,
    body: await parseBody(request),
    query: parseQuery(request.url),
    headers: prepareHeaders(request.headers),
    cookie: parseCookies(request.headers.get('cookie'))
  }

  return mapResponse(await routeHandler(context))
}

ชื่อฟังก์ชันในตัวอย่างแทนงานแต่ละขั้น โดยสมมติว่า parseBody รองรับ request ที่ไม่มี body และ mapResponse แปลงผลจาก handler เป็น response ส่วน routing, validation และ error handling ละไว้เพื่อให้เห็นเฉพาะงานเตรียม context

ถ้า handler อ่านแค่ query.id ฟังก์ชันกลางนี้ก็ยังเรียกขั้นตอนเตรียม body, headers และ cookies ทุกครั้ง การตัดงานที่ไม่จำเป็นออกจึงมีโอกาสลดทั้ง CPU time และการสร้าง object ต่อ request

framework จริงมีวิธีหลีกเลี่ยงงานเหล่านี้อยู่แล้ว เช่น เลือก middleware เฉพาะ route หรือ parse เมื่อมีการอ่านค่า เราจะกลับมาเทียบกับ Express 5 หลังจากเห็นกลไกของ Elysia

แนวทางของ Elysia คือใช้ข้อมูลของ route มาตัดสินใจก่อนว่า request pipeline ต้องมีขั้นตอนไหนบ้าง

ElysiaJS ทำต่างออกไปอย่างไร?

ในโหมดที่เปิด code generation Elysia วิเคราะห์ handler และ hooks แล้วใช้ผลร่วมกับ schema, HTTP method และ adapter เพื่อประกอบ request pipeline ของ route นั้น เงื่อนไขเลือกงานเหล่านี้อยู่ใน compose.ts ของ version 1.4.30

สมมติว่าเรามี API ที่รับ /user?id=42 แล้วตอบกลับด้วยค่า id:

import { Elysia } from 'elysia'

const app = new Elysia()
  .get('/user', ({ query }) => query.id)

สำหรับ /user?id=42 ตัวอย่างนี้คืนค่า '42' เป็น string เพราะไม่ได้กำหนด schema ให้แปลง id เป็นตัวเลข โค้ดนี้แสดงเฉพาะการอ่านค่าจาก query เพื่อให้เข้าใจขั้นตอนการทำงานได้ง่าย ส่วน API ที่นำไปใช้งานจริงควรกำหนด validation และระบุให้ชัดเจนว่าจะจัดการอย่างไรเมื่อไม่มี id

เมื่อไม่มี hooks หรือ schema ที่ต้องใช้ headers หรือ cookies เพิ่มเติม route นี้ก็ไม่จำเป็นต้องเตรียมข้อมูลเหล่านั้นใน context ส่วนการ parse body ยังขึ้นอยู่กับ HTTP method ด้วย โดย Elysia version ที่อ้างอิงจะไม่สร้างขั้นตอน parse body สำหรับ GET และ HEAD อยู่แล้ว การข้ามขั้นตอนนี้ในตัวอย่างจึงไม่ได้เป็นผลจาก static analysis เพียงอย่างเดียว

Route ใช้แค่ query ต้องเตรียมอะไรบ้าง?

ฟังก์ชันกลาง (แบบจำลอง)เรียกขั้นตอนเตรียมข้อมูลทุกส่วน

GET /user?id=42

  • body
  • query ใช้จริง ✓
  • headers
  • cookies

Handler อ่าน query.id

Pipeline ของ Elysiaเตรียมเฉพาะข้อมูลที่ route นี้ใช้

GET /user?id=42

  • query ใช้จริง ✓

Handler อ่าน query.id

ได้ข้อมูลที่ handler ต้องใช้เหมือนกัน แต่เตรียมข้อมูลน้อยลง

ฝั่งซ้ายเป็นแบบจำลองในบทความ ส่วนฝั่งขวาอ้างอิง Elysia 1.4.30 และสมมติว่าไม่มี hooks หรือ schema ที่ใช้ข้อมูลส่วนอื่น Request ยังมี headers ได้แม้ไม่เตรียมใน context ภาพนี้ไม่ได้เปรียบเทียบความเร็วหรือแทนพฤติกรรมของ framework อื่นทุกตัว

ภาพนี้เปรียบเทียบฟังก์ชันที่ทำทุกขั้นตอนกับ pipeline ที่เลือกทำเฉพาะขั้นตอนที่แต่ละ route ต้องใช้ ช่วยให้เห็นว่าข้ามขั้นตอนไหนได้บ้าง แต่ไม่ได้บอกว่าเร็วขึ้นกี่เท่า

แล้ว Elysia รู้ได้อย่างไรว่า handler ต้องใช้อะไร?

Sucrose: อ่านโค้ดก่อนเรียกใช้งาน

Sucrose คือตัวช่วยของ Elysia ที่อ่านโค้ดฟังก์ชัน แล้วจดว่า “ฟังก์ชันนี้ต้องใช้ข้อมูลส่วนไหนบ้าง” เพื่อให้ Elysia เลือกเตรียมข้อมูลให้เหมาะกับ route นั้นครับ

ลองกลับมาดู route เดิม:

app.get('/user', ({ query }) => query.id)

ฟังก์ชัน ({ query }) => query.id คือ handler ส่วน context คือ object ที่ Elysia ส่งให้ handler ใช้จัดการ request ในตัวอย่างนี้เราใช้ { query } ดึงเฉพาะข้อมูล query ออกมาจาก context

เมื่อ request /user?id=42 เข้ามา Elysia ต้องแปลงข้อความ id=42 ใน URL ให้เป็นข้อมูลประมาณ { id: '42' } ก่อน handler จึงจะอ่าน query.id ได้ แต่ Elysia จะรู้ได้อย่างไรว่าต้องเตรียม query? ตรงนี้คือหน้าที่ของ Sucrose

ขั้นแรก: เปลี่ยนฟังก์ชันเป็นข้อความ แล้วอ่านรูปแบบโค้ด

JavaScript สามารถเรียก toString() บนฟังก์ชันได้ ลองแยก handler ออกมาให้เห็นชัด ๆ:

const handler = ({ query }) => query.id

handler.toString()
// ได้ข้อความประมาณ "({ query }) => query.id"

Sucrose ตรวจข้อความนี้แล้วพบว่า parameter ดึง query ออกมาใช้ จึงบันทึกผลประมาณนี้ โดยย่อเฉพาะบางช่อง:

{
  query: true,
  body: false,
  headers: false,
  cookie: false
}

true หมายถึงให้ถือว่าต้องใช้ข้อมูลส่วนนั้น อาจมาจากการพบว่าโค้ดใช้โดยตรง หรือเผื่อไว้เมื่อวิเคราะห์ได้ไม่แน่ชัด ส่วน false หมายถึงการวิเคราะห์นี้ยังไม่ได้ระบุว่าต้องใช้ ในตัวอย่างนี้ Sucrose จึงบอกได้ว่า “ต้องใช้ query” โดยยังไม่ต้องเรียก handler และยังไม่ต้องรู้ว่า request จริงจะส่ง id เป็นค่าอะไร ดูกลไกได้ใน sucrose.ts

ผลนี้ระบุการใช้ query ทั้งส่วน ไม่ได้บอกว่าจะ parse เฉพาะ key id เสมอไป

ขั้นต่อมา: นำผลวิเคราะห์ไปเลือกงานที่ต้องทำ

เมื่อ Sucrose บอกแล้วว่าโค้ดใช้ข้อมูลส่วนไหน Elysia จะนำผลนั้นไปใช้ใน ส่วนที่สร้างฟังก์ชันจัดการ request ซึ่งอยู่ใน compose.ts ส่วนนี้พิจารณาข้อมูลอื่นร่วมด้วย เช่น schema และ HTTP method เพื่อเลือกว่าฟังก์ชันของ route นั้นต้องมีขั้นตอนอะไรบ้าง

ลำดับขั้นตอนที่ได้นี้เรียกว่า pipeline เช่น เตรียม query → เรียก handler → แปลงผลเป็น response โดย Elysia สร้างโค้ดที่รวมขั้นตอนเหล่านี้ไว้ในฟังก์ชันสำหรับ route นั้น

สำหรับตัวอย่างนี้ ถ้าไม่มี hooks หรือ schema ที่ต้องใช้ headers และ cookies เพิ่มเติม pipeline ก็ไม่จำเป็นต้องมีขั้นตอนเตรียมข้อมูลสองส่วนนี้ใน context ทำให้แต่ละ request มีขั้นตอนที่ต้องทำน้อยลง

เราจึงแยกหน้าที่แต่ละส่วนได้แบบนี้:

  • Sucrose: อ่านโค้ดแล้วบอกว่า “พบการใช้ query”
  • ส่วนที่สร้างฟังก์ชันจัดการ request: ใช้ผลวิเคราะห์มาตัดสินใจว่า “ต้องใส่ขั้นตอนเตรียม query” แล้วสร้างโค้ดสำหรับขั้นตอนนั้นไว้ในฟังก์ชันของ route
  • เมื่อจัดการ request จริง: ฟังก์ชันที่สร้างไว้เตรียม query จาก URL ของ request นั้น แล้วเรียก handler

Sucrose ยังอ่านโค้ดของ hooks ซึ่งเป็นฟังก์ชันที่เราให้ Elysia เรียกในช่วงต่าง ๆ ของการจัดการ request ด้วย เช่น ถ้ามี beforeHandle ที่อ่าน headers.authorization เพื่อตรวจสิทธิ์ ผลวิเคราะห์รวมก็ต้องระบุว่าใช้ headers แม้ handler หลักจะอ่านแค่ query ส่วน schema หรือข้อกำหนดรูปแบบข้อมูล ก็เป็นอีกเหตุผลที่ pipeline อาจต้องเตรียมข้อมูลมาตรวจสอบ

แล้วถ้าโค้ดไม่ได้บอกชัด ๆ ว่าใช้ข้อมูลอะไร?

ลองเทียบสองแบบนี้:

// เห็นชัดว่า handler ใช้ query แล้วส่งค่า id ต่อไป
({ query }) => service(query.id)

// ส่ง context ทั้งก้อนไปให้ service
(context) => service(context)

แบบหลัง service อาจอ่าน body, headers หรือ cookie ข้างในอีกที เมื่อ Sucrose ตรวจพบการส่ง context ทั้งก้อนแบบนี้ มันจะเผื่อว่าฟังก์ชันปลายทางอาจใช้ข้อมูลทุกส่วนที่มันติดตาม จึงมีโอกาสตัดขั้นตอนเตรียมข้อมูลออกได้น้อยลง ดูโค้ดตรวจการส่ง context ต่อ

วิธีอ่านรูปแบบโค้ดโดยไม่เรียกฟังก์ชันนี้เรียกว่า static analysis โดย Sucrose ทำงานเฉพาะด้านการตรวจว่ามีการใช้ข้อมูลอะไรบ้าง ไม่ได้อาศัย type information ของ TypeScript และไม่ได้เข้าใจ JavaScript ครบทุกกรณี ผลวิเคราะห์ขึ้นอยู่กับรูปข้อความที่ toString() คืนมาด้วย เช่น บน Node.js v26.7.0 ที่ทดสอบกับ Elysia 1.4.30 รูป (context) => service(context) ถูกตรวจพบว่ามีการส่ง context ต่อ แต่รูป context => service(context) ไม่ถูกตรวจพบ จึงควรตรวจพฤติกรรมหลัง build บน runtime ที่ใช้จริง การเขียนให้เห็นชัดว่าใช้ข้อมูลส่วนไหนช่วยทั้งคนอ่านและตัววิเคราะห์

คำว่า “static” ตรงนี้อธิบาย วิธีวิเคราะห์ ส่วน วิเคราะห์เมื่อไร เป็นอีกเรื่องหนึ่ง อาจเกิดระหว่างเตรียมแอปหรือเมื่อเรียก route ครั้งแรก ขึ้นอยู่กับ version และโหมดที่ใช้ หัวข้อ JIT กับ AOT ด้านล่างจะอธิบายเพิ่มเติมว่า Elysia เตรียมฟังก์ชันจัดการ request เมื่อไร

จากผลวิเคราะห์ สู่ handler เฉพาะ route

Elysia ใช้ผลจาก Sucrose ร่วมกับการตั้งค่าของ route เพื่อสร้างฟังก์ชันจัดการ request สำหรับ route นั้น

ในตัวอย่างนี้ handler ใช้แค่ query ฟังก์ชันจึงเตรียม query แล้วส่งให้ handler จากนั้นแปลงค่าที่ handler คืนมาเป็น response โค้ดด้านล่างจำลองขั้นตอนเหล่านี้ โดยยังไม่รวมการทำงานแบบ async:

function tailoredHandler(request) {
  const context = {
    request,
    query: parseQuery(request.url)
  }

  return mapResponse(routeHandler(context))
}

ตัวอย่างนี้เป็น pseudocode ที่เขียนขึ้นเพื่ออธิบายการทำงาน ไม่ใช่โค้ดที่ Elysia สร้างขึ้นจริง โดยละส่วนของ hooks, validation, error handling และการรองรับ async handler ไว้

ฟังก์ชันนี้ทำหน้าที่ครอบ handler ของเรา โดยมีเฉพาะขั้นตอนที่ route ต้องใช้ เมื่อสร้างเสร็จแล้ว ก็ใช้ฟังก์ชันเดิมจัดการ request ครั้งต่อ ๆ ไปที่เข้ามายัง route นี้ได้

Elysia สร้าง pipeline แล้วนำมาใช้กับแต่ละ request อย่างไร?

Handler ที่เราเขียน

({ query }) => query.id

Sucrose วิเคราะห์ว่าใช้ query แล้ว Elysia สร้าง pipeline สำหรับ route นี้

Request เข้า

GET /user

  1. ครั้งที่ 1?id=42ส่งแล้ว
  2. ครั้งที่ 2?id=43ส่งแล้ว
  3. ครั้งที่ 3?id=44ส่งแล้ว
Pipeline ของ /userสร้างแล้ว · ใช้ชุดเดิม

1 อ่าน query

query.id

2 เรียก handler

return query.id

สองขั้นตอนเดิม
ทำกับข้อมูลใหม่ทุก request

ค่าที่คืน

ชนิด string

  1. ครั้งที่ 1'42'เสร็จแล้ว
  2. ครั้งที่ 2'43'เสร็จแล้ว
  3. ครั้งที่ 3'44'เสร็จแล้ว

สร้าง pipeline 1 ครั้ง

ตอบ request แล้ว 3 ครั้ง

สร้าง pipeline ไว้ใช้ซ้ำ แล้วอ่าน query ใหม่ทุก request

ตัวอย่าง route ที่ใช้เฉพาะ query และคืนค่าเป็น string โดยไม่มี hooks หรือ schema ที่ใช้ข้อมูลอื่น เวลาที่สร้าง pipeline ขึ้นอยู่กับโหมด: ตอน build, ตอนเริ่มแอป หรือเมื่อรับ request แรก ภาพแสดงลำดับการทำงาน ไม่ได้แสดงความเร็วจริง

เมื่อมี request ใหม่เข้ามา Elysia จะเรียก pipeline ที่สร้างไว้แล้ว โดยสร้าง context และอ่าน query ใหม่ทุกครั้ง เช่น /user?id=43 ก็ได้ '43' กลับมา แต่ละ request จึงยังต้องผ่านการประมวลผล ต่างจาก response caching ที่เก็บคำตอบไว้ใช้ในครั้งถัดไป

การสร้างฟังก์ชันแบบนี้เรียกว่า code generation โดย Elysia ดูว่า route ต้องใช้ข้อมูลอะไร มี schema หรือ hooks อะไรบ้าง แล้วสร้างโค้ดให้มีขั้นตอนที่ต้องใช้ เช่น ถ้า route กำหนด schema ไว้ ก็เพิ่มขั้นตอนตรวจข้อมูลตาม schema ลงในฟังก์ชันนั้น เมื่อรับ request แต่ละครั้ง ขั้นตอนนี้ก็ยังทำงานเพื่อตรวจข้อมูลที่ส่งเข้ามา

JIT กับ AOT: Elysia สร้าง pipeline ตอนไหน?

เวลาที่ Elysia สร้าง pipeline ขึ้นอยู่กับ version และการตั้งค่า บทความนี้เปรียบเทียบสามแบบต่อไปนี้:

  1. สร้างตอน build แอป — Elysia 2 beta มี AOT build plugin สำหรับสร้างโค้ดของ handler และ schema ที่รองรับ แล้วเก็บไว้ในไฟล์ที่ได้จากการ build เมื่อเปิดแอปก็ใช้โค้ดที่เตรียมไว้ วิธีนี้เรียกว่า build-time AOT (Ahead-of-Time) ตามประกาศ Elysia 2 beta

  2. สร้างระหว่างเตรียมแอป — ใน Elysia 1.4.30 เมื่อใช้ aot: true ร่วมกับ precompile: true Elysia จะสร้าง pipeline ขณะลงทะเบียน route จึงย้ายงานส่วนนี้มาอยู่ก่อนรับ request สำหรับ route ที่ลงทะเบียนไว้ตั้งแต่เริ่มแอป Request แรกจึงไม่ต้องรอสร้าง pipeline ดูโค้ดที่เลือกว่าจะ compile ทันทีหรือรอ และเอกสาร precompile

  3. สร้างเมื่อมี request แรกของ route — ใน Elysia 1.4.30 เมื่อใช้ aot: true และ precompile: false แอปจะรอจนมีการเรียก route นั้น แล้วจึงสร้าง pipeline มาใช้งาน Request ถัดไปใช้ฟังก์ชันที่สร้างไว้แล้ว วิธีรอสร้างเมื่อจะใช้นี้เรียกว่า lazy compilation ดูการทำงานใน Elysia 1.4.30

ชื่อ aot ใน Elysia 1.4.30 อาจทำให้สับสน เพราะใช้เปิดการสร้างฟังก์ชันเฉพาะ route ส่วน precompile เป็นตัวกำหนดว่าจะสร้างเมื่อไร ดังนั้น การตั้ง aot: true ใน version นี้ไม่ได้หมายถึงการสร้างโค้ดไว้ตอน build แบบ AOT build plugin ของ Elysia 2 beta

หน้าเอกสาร configuration ของ Elysia ระบุว่า aot มีค่าเริ่มต้นเป็น false และอธิบายว่า true คือการ precompile ทุก route แต่ใน source code ของ 1.4.30 ค่าเริ่มต้นของ aot คือ true (ปิดได้ด้วย ELYSIA_AOT=false) และการสร้างก่อนเริ่มรับ request ถูกกำหนดด้วย precompile บทความนี้จึงอธิบายตาม source code

สำหรับสองแบบของ Elysia 1.4.30 ฟังก์ชันยังถูกสร้างขณะแอปรันอยู่ โดย Elysia นำโค้ด JavaScript ที่สร้างขึ้นมาเปลี่ยนเป็นฟังก์ชันด้วย Function(...) ซึ่งเป็น constructor เดียวกับ new Function(...) เอกสารภายในของ Elysia เรียกกลไกนี้ว่า JIT (Just-in-Time) ทั้งนี้ บาง route อาจมีวิธีจัดการเฉพาะ เช่น route ที่คืนค่าคงที่อาจใช้ความสามารถของ runtime โดยตรง

ส่วน JIT ของ JavaScript engine มีหน้าที่แปลง JavaScript เป็น machine code และปรับประสิทธิภาพจากข้อมูลที่ได้ระหว่างรัน เช่นที่ทีม WebKit อธิบายไว้สำหรับ JavaScriptCore การสร้าง pipeline เสร็จจึงไม่ได้แปลว่า engine ปรับโค้ดเสร็จด้วย แม้เปิด precompile แล้ว ผลวัดตอนเริ่มแอปกับผลหลัง warm-up หรือการรันซ้ำก่อนเก็บผล ก็ยังอาจต่างกัน

ความเร็วของแอปยังขึ้นอยู่กับ runtime ที่ใช้ด้วย หากเปรียบเทียบ Elysia บน Bun กับ Express บน Node.js ตัวเลขที่ได้จะรวมความต่างของทั้ง framework และ runtime การใช้ runtime เดียวกันจึงช่วยให้เห็นผลของ framework ชัดขึ้น แต่ยังต้องกำหนดงานและการตั้งค่าให้ใกล้เคียงกันด้วย

แล้ว Express 5 ทำงานต่างกันอย่างไร?

ถ้าใช้ Express 5 เราก็เขียน route ที่รับ /user?id=42 แล้วตอบกลับด้วยข้อความ 42 ได้แบบนี้:

import express from 'express'

const app = express()

app.get('/user', (req, res) => {
  res.send(String(req.query.id))
})

Express เรียก middleware และ handler ของ route ตามลำดับที่เราลงทะเบียนไว้ แต่ละ middleware จะส่ง response กลับเลยก็ได้ หรือเรียก next() เพื่อให้ขั้นตอนถัดไปทำงานต่อ ถ้าต้องใช้ middleware สำหรับ parse ข้อมูล เราก็เลือกใช้กับทั้งแอปหรือเฉพาะบาง route ได้ ดูตัวอย่างใน คู่มือ middleware

โค้ดนี้ไม่ได้เพิ่ม middleware สำหรับ parse JSON body หรือ cookies จึงไม่มีการ parse ข้อมูลสองส่วนนี้ ส่วน query จะถูก parse เมื่อเราอ่าน req.query เพราะใน Express 5 ค่านี้เป็น getter ที่เรียก query parser ตามที่แอปตั้งค่าไว้ (ค่าเริ่มต้นเปลี่ยนจาก "extended" เป็น "simple") โดย getter นี้ไม่ได้เก็บผลไว้ จึง parse ใหม่ทุกครั้งที่อ่าน req.query ดูรายละเอียดใน เอกสารการเปลี่ยนแปลงใน Express 5 และ โค้ดของ getter ในเวอร์ชัน 5.1.0

จุดที่ต่างกันคือวิธีเตรียมขั้นตอนจัดการ request ครับ Elysia วิเคราะห์โค้ดและดูการตั้งค่าของ route แล้วสร้างฟังก์ชันที่รวมขั้นตอนที่ route นั้นต้องใช้ ส่วน Express เรียก middleware และ handler ตามที่เราเขียนและลงทะเบียนไว้

ตัวอย่างนี้ช่วยให้เห็นวิธีทำงานของทั้งคู่ แต่ยังบอกไม่ได้ว่าใครเร็วกว่า ถ้าจะวัดความเร็ว ต้องกำหนดให้ทั้งคู่ทำงานเหมือนกันก่อน รวมถึงกรณีที่ไม่ได้ส่ง id หรือส่ง id มาหลายค่า เช่น /user?id=42&id=43 ซึ่งตัวอย่างสองตัวนี้ตอบไม่เหมือนกัน:

Request Elysia 1.4.30 (body ที่ตอบกลับ) Express 5.1.0 (body ที่ตอบกลับ)
/user body ว่าง (query.id เป็น undefined) undefined (String(undefined))
/user?id=42&id=43 43 (ไม่มี schema กำหนดเป็น array จึงใช้ค่าสุดท้าย) 42,43 (req.query.id เป็น array ['42', '43'] แล้วแปลงเป็น string)

ตารางนี้เทียบเฉพาะ body โดยใช้ตัวอย่างข้างต้นและ query parser ค่าเริ่มต้น ตรวจพฤติกรรมของ Elysia ได้ใน query parser ของ 1.4.30 แม้กรณี /user?id=42 จะได้ body เป็น 42 เหมือนกัน แต่ Content-Type ยังต่างกัน: ในการทดสอบบน Node.js v26.7.0 ผ่าน app.handle() ของ Elysia (Web Standard adapter) ได้ text/plain ส่วน Express ผ่าน HTTP ได้ text/html; charset=utf-8 จึงต้องปรับ headers ให้ตรงกันด้วยก่อนนำไปเทียบความเร็ว

การสร้าง handler ต้องใช้เวลาและหน่วยความจำ

การสร้างฟังก์ชันจัดการ request ต้องใช้เวลา ถ้า Elysia รอสร้างฟังก์ชันตอนที่ route ถูกเรียกครั้งแรก (lazy compilation) request แรกก็ต้องรอขั้นตอนนี้ด้วย การเปิด precompile: true จะให้ Elysia ทำงานส่วนนี้ตั้งแต่เริ่มแอปแทน จึงควรแยกวัดเวลาเริ่มแอป เวลาตอบกลับของ request แรก และเวลาตอบกลับหลังเรียกใช้งานไปสักระยะแล้ว (warm-up)

โค้ดที่สร้างขึ้นต้องเก็บไว้ในหน่วยความจำเพื่อใช้กับ request ถัดไป ส่วนโค้ดที่ใช้วิเคราะห์และสร้างฟังก์ชันก็ทำให้ framework มีขนาดใหญ่ขึ้น จะใช้เวลาและหน่วยความจำเพิ่มเท่าไรขึ้นอยู่กับจำนวน route, schema และโหมดที่ใช้ นอกจากนี้ ผู้พัฒนา framework ยังต้องหาข้อผิดพลาดทั้งในส่วนที่สร้างโค้ดและในโค้ดที่สร้างออกมา อ่านเพิ่มเติมได้ในหัวข้อ overhead ของ Elysia

สำหรับ build-time AOT ของ Elysia 2 beta มีเรื่องที่ต้องดูแลเพิ่ม เพราะ plugin จะลองรันแอประหว่าง build (dry-run) โค้ดที่ทำงานตอนเริ่มแอปจึงอาจทำงานในขั้นตอนนี้ด้วย เช่น การเปิด connection pool ของฐานข้อมูล ซึ่งอาจทำให้ process ไม่ยอมจบหลัง build เสร็จ เราจึงต้องจัดการการเชื่อมต่อเหล่านี้ รวมถึงตรวจว่าการเรียก API ของ Elysia เช่น การลงทะเบียน route และ hooks เหมือนกันทั้งตอน build และตอนรันจริง เพื่อให้ตรงกับข้อมูลที่ plugin บันทึกไว้ ตามข้อกำหนด Build Time Environment

แอปจริงจะเร็วขึ้นแค่ไหน?

ถ้า route ตอบข้อมูลที่อยู่ในหน่วยความจำได้เลย และแทบไม่มีงานอื่นให้ทำ เวลาที่ CPU ใช้จัดการ request ส่วนใหญ่อาจหมดไปกับขั้นตอนของ framework การลดขั้นตอนเหล่านี้จึงอาจช่วยให้แอปรองรับ request ต่อวินาทีได้มากขึ้นอย่างชัดเจน หรือที่เรียกว่า throughput สูงขึ้น

แต่ถ้า request ใช้เวลาส่วนใหญ่รอฐานข้อมูลหรือ API ภายนอก การลดงานของ framework อาจช่วยให้ตอบกลับเร็วขึ้นเพียงเล็กน้อย สมมติ request ใช้เวลาทั้งหมด 20 ms โดยเป็นเวลาที่ framework ใช้จัดการ request 1 ms ถ้าลดส่วนนี้เหลือ 0.5 ms และส่วนอื่นใช้เวลาเท่าเดิม เวลารวมจะเหลือ 19.5 ms หรือลดลง 2.5% ตัวเลขนี้เป็นเพียงตัวอย่างคำนวณ ไม่ใช่ผล benchmark ของ Elysia

เมื่อมี request เข้ามาพร้อมกันมาก ๆ ผลอาจต่างออกไป ถ้า CPU ทำงานเต็มจน request ต้องรอคิว การลดงานที่ต้องทำในแต่ละ request ก็อาจช่วยลดเวลารอด้วย จึงควรวัดทั้งจำนวน request ที่รองรับได้ต่อวินาที (throughput) และเวลาตอบกลับ (latency) โดยดูค่า p95/p99 ควบคู่กับค่าเฉลี่ย เช่น ถ้า p95 อยู่ที่ 50 ms หมายความว่า 95% ของ request ที่วัดใช้เวลาไม่เกิน 50 ms ส่วน p99 ก็ใช้หลักเดียวกันกับ 99% ของ request

เวลาประเมิน framework สำหรับ API ของเรา ผมจะเช็กอย่างน้อยสี่เรื่อง:

  • แต่ละแอปทำงานเหมือนกันหรือไม่: ต้องส่งข้อมูลตอบกลับ, status และ headers ตามข้อกำหนดเดียวกัน รวมถึงตรวจข้อมูลและยืนยันตัวตนด้วยเงื่อนไขเดียวกัน ทั้งกรณีที่ส่งข้อมูลถูกและผิด
  • ทดสอบด้วยอะไรและตั้งค่าอย่างไร: ระบุ version ของ framework และ runtime, adapter, สเปกเครื่อง, จำนวน request ที่ส่งพร้อมกัน และการใช้ connection เดิมซ้ำ เพื่อให้รู้ว่าผลเทียบกันได้แค่ไหน
  • วัดช่วงไหน: แยกเวลาเริ่มแอปและ request แรกออกจากผลหลัง warm-up และแยกให้ออกว่าตัวเลขนั้นวัดการเรียกฟังก์ชันต่อวินาที (operations/second) หรือการรับส่ง request ผ่าน HTTP ต่อวินาที (requests/second)
  • อะไรจำกัดความเร็วของระบบ: ดูการใช้ CPU และหน่วยความจำ สัดส่วน request ที่เกิดข้อผิดพลาด และเวลาตอบกลับ พร้อมตรวจว่าฐานข้อมูลหรือเครื่องมือส่ง request ทดสอบทำงานเต็มกำลังแล้วหรือยัง เพราะอาจเป็นส่วนที่จำกัดความเร็วแทน framework

ผมเคยทดสอบ Elysia กับ Hono โดยใช้ PostgreSQL ไว้ด้วย ถ้าอยากดูตัวอย่างที่แต่ละ request ต้องทำงานกับฐานข้อมูล ลองอ่านต่อได้ครับ

แนวคิดที่น่าสนใจของ Elysia คือการใช้ข้อมูลที่รู้เกี่ยวกับแต่ละ route มาเตรียมงานไว้ล่วงหน้า เพื่อให้ตอนรับ request เหลืองานที่ต้องทำน้อยลง แนวคิดนี้นำกลับไปใช้กับโค้ดของเราได้เหมือนกัน ลองดูว่างานไหนทำซ้ำอยู่ทุกครั้ง งานไหนเตรียมไว้ก่อนได้ แล้ววัดผลว่าการเปลี่ยนแปลงนั้นช่วยแอปของเราได้แค่ไหนครับ

Keep building. Keep learning.สร้างต่อไป เรียนรู้ต่อไป

技術は旅である