Sveltekit การทำงานกับ remote function [Part 2]
query.batch
query.batch ทำงานคล้ายๆ รุ่นพี่ query ปกติเลย แต่ query.batch เกิดมาเพื่อเป็นฮีโร่แก้ปัญหาเวลาเราต้องเรียก query ข้อมูลพร้อมกันหลายๆ ตัว (batches) ภายใต้ microtask เดียวกัน 🦸♂️ ซึ่งมันช่วยปราบปัญหา N+1 ได้แบบอยู่หมัด!
แทนที่ข้อมูลแต่ละชิ้นจะต้องแยกกันยิง request ไปหา database มันกลับใช้วิธีรวมกลุ่ม (group) query ทั้งหมดมัดรวมกัน แล้วส่งไปทีเดียวจบเลย! 📦
พอมาถึงฝั่ง server (ไฟล์ *.remote.js) สิ่งที่มันได้รับจะไม่ใช่ตัวแปรเดี่ยวๆ แต่จะมาเป็น array ของตัวแปรทั้งหมดแทน 🍱 และทริคคือ ตอนเราส่งผลลัพธ์กลับไป เราจำเป็นต้องส่งกลับไปในรูปแบบของฟังก์ชัน หน้าตาแบบนี้ (ค่าตัวแปรรับเข้า(input): Input, ค่าตำแหน่ง(index): number) => Output
ตัวอย่างการใช้งาน
import * as v from 'valibot';
import { query } from '$app/server';
import * as db from '$lib/server/database';
export const getWeather = query.batch(v.string(), async (cityIds) => {
const weather = await db.sql`
SELECT * FROM weather WHERE city_id = ANY(${cityIds})
`;
// ส่ง cityIds ที่เป็น array ไปให้ database ค้นหาและส่งค่ากลับมา 🔍
// ออกมาเป็น weather array ที่มีค่าข้อมูลของ id เมือง, ชื่อเมือง และสภาพอากาศ 🌤️
const lookup = new Map(weather.map(w => [w.city_id, w]));
// สร้าง Map ใหม่ 🗺️ โดยการ map array ให้เป็น array แบบ 2 มิติ [key, value]
// cityId คือค่าที่เรา input เข้ามา แต่เป็น object เดี่ยวๆ ไม่ใช่ array เหมือนที่รับเข้ามาผ่าน argument ด้านบน ⚠️
// การ return ยังเป็น functions ที่จะใช้ Map.get(key) เพื่อดึงค่าข้อมูล weather ของเมืองนั้นๆ ออกไป 📤
return (cityId) => lookup.get(cityId);
});
<script>
import CityWeather from './CityWeather.svelte';
import { getWeather } from './weather.remote';
let { cities } = $props(); // รับค่าของ cities จาก props ที่ส่งเข้ามา (อาจจะมีมากกว่า 5 ข้อมูล) 🏙️
let limit = $state(5); // ตั้ง limit ว่าจะเอาข้อมูลกี่ตัว 📏
</script>
<h2>Weather 🌤️</h2>
<!-- ทำการ slice เพื่อดึงข้อมูล 5 ตัวหน้าสุดออกมา -->
{#each cities.slice(0, limit) as city}
<h3>{city.name}</h3>
<!-- ส่ง city id เข้าไปยัง remote function แบบ 1-1 แต่ remote function จะเอาไปมัดรวมเป็น batch (array ของ city.id) ให้อัตโนมัติ ✨ -->
<!-- และส่งค่า weather ที่ได้รับกลับมาเข้าไปยัง component CityWeather ต่อไป -->
<CityWeather weather={await getWeather(city.id)} />
{/each}
{#if cities.length > limit}
<button onclick={() => limit += 5}>Load more ➕</button>
{/if}
ขั้นตอนการทำงาน
ฝั่ง Client: สั่งงานแบบเดี่ยวๆ แต่แอบรอเพื่อน (Microtask Grouping)
- จากโค้ดในหน้า
.svelteเรามีการใช้#eachวนลูปสร้าง<CityWeather>และเรียกใช้getWeather(city.id)ทีละเมือง - แทนที่ฟังก์ชัน
getWeatherจะวิ่งไปหา Server ทันทีทีละรอบ (ซึ่งถ้ามี 5 เมือง ก็จะยิง 5 Requests) ตัว SvelteKit จะทำการ "หน่วงเวลา" ไว้สั้นๆ ในระดับ Microtask เพื่อรอดูว่าในจังหวะนั้นมีการเรียกgetWeatherตัวอื่นๆ อีกไหม
- จากโค้ดในหน้า
การมัดรวม (Batching) ก่อนส่ง
- เมื่อ SvelteKit เห็นว่ามีการเรียก
getWeather5 ครั้งพร้อมๆ กัน มันจะทำการมัดรวม (Batch) ค่าcity.idทั้ง 5 ตัวนั้นเข้าด้วยกันให้กลายเป็น Array ก้อนเดียว เช่น['bkk', 'cnx', 'hdy', ...]แล้วส่ง Request ก้อนนี้ไปหา Server แค่ครั้งเดียว! 📦
- เมื่อ SvelteKit เห็นว่ามีการเรียก
ฝั่ง Server: รับก้อน Array ไปทำงานรอบเดียว
- สิ่งที่รับเข้ามา: พารามิเตอร์
cityIdsจะไม่ได้รับค่าเป็น string เดี่ยวๆ แล้ว แต่จะได้รับเป็น Array['bkk', 'cnx', 'hdy']ตามที่ Client มัดรวมมาให้ - การ validation จะเป็นการตรวจสอบแยกเดี่ยวของแต่ละข้อมูลภายใน Array ที่ส่งเข้ามา
- การ Query (แบบประหยัดพลังงาน): เราเอา Array นี้ไปดึงข้อมูลจาก Database ในรอบเดียว (ผ่านคำสั่ง
ANY(${cityIds})หรือIN (...)) ทำให้ Database ทำงานแค่ 1 ครั้ง ได้ผลลัพธ์สภาพอากาศของทุกเมืองกลับมาพร้อมกันเลย 🌩️
- สิ่งที่รับเข้ามา: พารามิเตอร์
ฝั่ง Server: จัดเรียงข้อมูลให้ค้นหาง่าย (Mapping)
const lookup = new Map(weather.map(w => [w.city_id, w]));- พอได้ข้อมูลมาจาก Database มันมักจะมาเป็น Array ธรรมดา เราจึงเอามาจัดลง Map (พจนานุกรมข้อมูล) โดยให้
city_idเป็น Key และw(ข้อมูลสภาพอากาศ) เป็น Value เพื่อให้เดี๋ยวตอนดึงข้อมูลกลับไป จะได้ค้นหาได้ไวๆ ⚡ (ไม่ต้องไปนั่งลูปหาใหม่ทีละตัว)
- พอได้ข้อมูลมาจาก Database มันมักจะมาเป็น Array ธรรมดา เราจึงเอามาจัดลง Map (พจนานุกรมข้อมูล) โดยให้
ฝั่ง Server: จุดพีค! การ Return ออกมาเป็น "ฟังก์ชัน"
return (cityId) => lookup.get(cityId);- สังเกตว่าเราไม่ได้ return ข้อมูลกลับไปตรงๆ แต่เรา return ฟังก์ชัน กลับไปแทน! 🤯
- เพราะ SvelteKit ฝั่ง Client ต้องการคนช่วย "แจกจ่าย" ข้อมูลให้ถูกคน มันจะเอาฟังก์ชันนี้ไปทำงานต่อ โดยจะโยน
city.idเดี่ยวๆ เข้ามาถามฟังก์ชันนี้ว่า "สภาพอากาศของเมืองนี้คืออะไร?" ฟังก์ชันนี้ก็จะไปหยิบค่าจากตู้lookupที่เราสร้างไว้ในข้อ 4 ส่งคืนให้
ฝั่ง Client: แจกจ่ายผลลัพธ์กลับสู่ Component
- เมื่อ SvelteKit ฝั่ง Client ได้รับฟังก์ชันแจกจ่ายของมาแล้ว มันก็จะทำหน้าที่แกะกล่อง Batch ออก แล้วส่งผลลัพธ์ (ข้อมูลสภาพอากาศ) กลับคืนไปให้
<CityWeather>แต่ละตัวตามที่เคยขอไว้แบบ 1 ต่อ 1 อย่างแม่นยำเป๊ะ! 🎯
- เมื่อ SvelteKit ฝั่ง Client ได้รับฟังก์ชันแจกจ่ายของมาแล้ว มันก็จะทำหน้าที่แกะกล่อง Batch ออก แล้วส่งผลลัพธ์ (ข้อมูลสภาพอากาศ) กลับคืนไปให้
query.live
ตัวนี้ก็อยู่แก๊งเดียวกับ query แต่ทีเด็ดคือมันพกความสามารถในการดึงข้อมูลแบบ real-time จาก server มาด้วย! 🔥
จุดที่ต้องจำไว้คือ callback ของ query.live จะเป็นฟังก์ชันสไตล์ generator function และของที่มันคายกลับมาให้เราจะอยู่ในรูปร่างหน้าตาของ AsyncIterable แทน
ตัวอย่างการใช้งาน
// getDateLive.remote.js
import { query } from '$app/server';
export const getTime = query.live(async function* () {
while (true) {
yield new Date();
await new Promise((f) => setTimeout(f, 1000));
}
});
ในช่วงที่กำลังทำ server-side rendering อยู่ ตัว await getTime() ที่โดนเรียกจากฝั่ง client จะคายค่าจาก yield ออกมาให้เราก่อนเป็นออเดิร์ฟจานแรก 🍽️ แล้วมันก็จะพักเบรกการทำซ้ำทันที ซึ่งข้อมูลจานแรกนี่แหละ จะถูกหยิบมาใช้ต่อตอนทำ hydration นั่นเอง 💧
พอข้ามมาฝั่ง client side เจ้าตัว query.live จะทำตัวเป็นสายสืบที่เชื่อมต่ออยู่ตลอดเวลาตราบใดที่มันยังเกาะติดอยู่กับ component 🕵️♀️ และถ้ามีหลาย instance เกิดขึ้น พวกมันก็จะใจดีแชร์ท่อเชื่อมต่อเส้นเดียวกันทั้งหมด 🤝
แต่เมื่อไหร่งานเลี้ยงเลิกรา ไม่มีใครเรียกใช้มันแล้ว การเชื่อมต่อก็จะถูกตัดฉับ! การ stream หยุดลง และระบบทำซ้ำก็จะหยุดตามไปด้วย 💤
นอกจากนี้ เจ้า live query ที่ถูกส่งข้ามมาฝั่ง client ยังใจดีแถม property และ method พิเศษมาให้ใช้เพิ่มอีก 2 ตัวคือ:
connected🔌reconnect()🔄
<!-- src/routes/time/+page.svelte -->
<script>
import { getTime } from './time.remote.js';
const time = getTime();
</script>
<p>{await time}</p>
<p>connected: {time.connected}</p>
<button onclick={() => time.reconnect()}>Reconnect 🔄</button>
ถ้าวันดีคืนดีเกิดเหตุไม่คาดฝัน เน็ตหลุด หรือเกิด error จนคุยกับ remote function ไม่รู้เรื่อง 💔 ค่า connected จะเปลี่ยนเป็น false ทันที แต่ไม่ต้องตกใจไป! Sveltekit จะพยายามฮึดสู้กู้การเชื่อมต่อให้เราอัตโนมัติ (ใช้วิธีเช็คซ้ำแบบ exponential backoff คือยิ่งต่อไม่ติด ก็จะยิ่งทิ้งช่วงรอนานขึ้นเรื่อยๆ)
ซึ่งในจังหวะนั้น ค่า window.navigator.onLine ของเบราว์เซอร์ก็จะกลายเป็น false ไปด้วยนะ 📉 แต่ถ้า window.navigator.onLine กลับมาเป็น true และระบบเชื่อมต่อกลับไปหา remote function บน server ได้สำเร็จ ค่า connected ก็จะกลับมายิ้มแฉ่งเป็น true อีกครั้ง! 🌈
และที่สำคัญ สำหรับ query.live จะไม่มี method refresh() ให้เรียกนะ ❌ เพราะตัวมันเองสุดยอดอยู่แล้ว สามารถอัปเดตข้อมูลตัวเองได้ตลอดเวลาแบบ self-updating เลย 😎
การจัดการ Stream ด้วย for...await
แต่ถ้าใครสายฮาร์ดคอร์ 🎸 อยากล้วงลึกเข้าไปจัดการข้อมูลในระดับ stream เอง แทนที่จะนั่งรอข้อมูล reactive แบบง่ายๆ ผ่าน current คุณก็สามารถสร้าง async function มารับช่วงต่อ แล้วเปิดใช้ for...await กวาดข้อมูลข้างในมาเล่นได้เลย! 🌪️ เพราะว่าเนื้อแท้แล้ว ของที่ remote function แบบ live โยนกลับมาให้ ก็ยังมีหน้าตาเป็น async-iterable อยู่ดีนั่นแหละ
<!-- src/routes/time/+page.svelte -->
<script>
async function logTimes() {
for await (const value of getTime()) {
console.log(value);
if (someCondition) break;
}
}
</script>
ซึ่งผู้ใช้งานที่ดึง live query ตัวเดียวกันมาเล่นในช่วงเวลาเดียวกัน (ไม่ว่าจะใช้ท่า await, current หรือเปิดลูป for...await) ระบบจะจับยัดให้ใช้ท่อการเชื่อมต่อเส้นเดียวกันทั้งหมดเลยทันที
ข้อควรระวังฝั่ง Client
⚠️ สำหรับท่า for...await นั้น ข้อมูลชิ้นแรกสุดที่จะวิ่งเข้ามาหาเรา จะเป็นข้อมูลอัปเดตตัว "ล่าสุด" ที่โดนดึงมาจาก instance อื่นๆ (most-recently-received value) 🆕
และถ้าสมมติว่าฝั่ง server สาดข้อมูลมาเร็วจัดจน Client ย่อยไม่ทัน ระบบจะเก็บไว้เฉพาะค่าล่าสุดที่ค้างอยู่เท่านั้น 📌 (เพราะคอนเซปต์ของ Live Stream ไม่ใช่ Event Log นะครับ มันเลยไม่ได้เก็บประวัติย้อนหลังทั้งหมดไว้ให้)
การทำงานบนฝั่ง Server
🖥️ คำสั่ง for...await บนเซิร์ฟเวอร์จะทำงานแบบแชร์รอบกัน (Shared Iteration) ร่วมกันในแต่ละ Request ส่งผลให้แม้ว่าจะมีผู้ใช้หลายคนแห่มาเรียกใช้พร้อมๆ กันใน Request เดียวกัน มันก็ฉลาดพอที่จะไม่สั่งให้ตัว Generator ต้องวิ่งเต้นรันซ้ำซ้อนให้เปลืองแรง 🧠
ข้อควรระวังสำคัญ
🚨🚨 สำคัญ!! ห้ามไปเผลอทำแคช (Cache) ผลลัพธ์ของ Live Query ใน Service Worker เด็ดขาด! เพราะมันจะทำให้ข้อมูลที่ถูกก๊อปปี้ไป ดันนั่งสตรีมตัวเองต่อไปเรื่อยๆ อย่างไม่รู้จักเหน็ดเหนื่อย แม้ว่าเราจะกดปิดหน้าเว็บทิ้งไปแล้วก็ตาม 😱
และอย่าลืมเช็คให้ชัวร์ด้วยว่า ระบบดักแคชของคุณได้เตะเอา Responses ที่แปะป้าย Cache-Control ว่า no-store ออกไปจากการทำแคชเรียบร้อยแล้ว 🧹
เป็นยังไงกันบ้างครับเพื่อนๆ กับความทรงพลังของ query.batch() และ query.live() 🤯 บอกเลยว่า SvelteKit รอบนี้เขาทำการบ้านมาดีมากจริงๆ ช่วยให้ชีวิต Dev อย่างเราง่ายขึ้นเยอะ โค้ดคลีนขึ้น แถมประหยัดทรัพยากรฝั่ง Server ไปได้อีกเพียบ! 🎉
แต่เดี๋ยวก่อน... มหากาพย์ Remote function ของเรายังไม่จบลงแค่นี้นะครับ! 🎬 ที่ผ่านมาเราเน้นแต่เรื่องการ "ดึงข้อมูล" มาแสดงผลกันไปแล้ว แล้วถ้าเราอยากจะ "ส่งข้อมูล" หรือบันทึกอะไรบางอย่างกลับไปที่ Server ล่ะ ต้องทำยังไง? 🤔
ในบทความหน้า เราจะเปลี่ยนฟีลจากตระกูล query ไปทำความรู้จักกับ Remote function อีกประเภทหนึ่งที่เกิดมาเพื่อจัดการเรื่องการส่งข้อมูล (Mutation) โดยเฉพาะ! นั่นก็คือ form 📝 ครับผม! มันจะมาช่วยลบภาพความยุ่งยากของการจัดการฟอร์มแบบเดิมๆ ออกไปได้ยังไง เตรียมตัวรอติดตามกันในบทความถัดไปได้เลยครับ!
แล้วเจอกัน 😉✨
Comments
No comments yet. Start the discussion.