DEV Community

Cover image for Sveltekit การทำงานกับ remote function [Part 2]
Nutchapon Makelai
Nutchapon Makelai

Posted on

Sveltekit การทำงานกับ remote function [Part 2]

สวัสดีครับเพื่อนๆ! 👋 กลับมาเจอกันอีกแล้วตามสัญญา! หลังจากบทความที่แล้วเราได้ทำความรู้จักกับพระเอกอย่าง query แบบเบสิกกันไปพอหอมปากหอมคอ วันนี้เราจะพามาอัปเลเวลความแอดวานซ์ขึ้นไปอีกขั้นครับ 🚀

ใครที่เคยปวดหัวกับปัญหา N+1 ดึงข้อมูลซ้ำซ้อนจน Database ร้องขอชีวิต หรือกำลังอยากทำข้อมูลแบบ Real-time อัปเดตสดๆ ลื่นๆ... ปูเสื่อรอเลยครับ! เพราะวันนี้เราจะมาเจาะลึกทีเด็ดของ SvelteKit Remote function อีก 2 ท่า นั่นก็คือ query.batch() ฮีโร่ผู้มาช่วยมัดรวมรีเควสต์ และ query.live() สายสตรีมมิ่งข้อมูลแบบสดๆ! 🔥 จะเจ๋งและว้าวแค่ไหน ลุยกันเลยครับ! 👇


📑 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]

        //หลังจากนั้นเราจะ return โดยให้สังเกตว่า cityId คือค่าที่เรา input เข้ามา แต่เป็น object เดี่ยวๆ
        //ไม่ใช่ array เหมือนที่รับเข้ามาผ่าน argument ด้านบนนะ ⚠️
        //และการ return ยังเป็น functions ที่จะใช้ Map.get(key) เพื่อดึงค่าข้อมูล weather ของเมืองนั้นๆ ออกไป 📤
    return (cityId) => lookup.get(cityId); 
});
Enter fullscreen mode Exit fullscreen mode
<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 getWeather(city.id)} weather="{await"/>
{/each}

{#if cities.length > limit}
    <button onclick={() => limit += 5}>
        Load more ➕
    </button>
{/if}
Enter fullscreen mode Exit fullscreen mode

โดยขั้นตอนการทำงานแบบสรุปจากโค้ดด้านจะเป็นแบบนี้ครับ

1️⃣ ฝั่ง Client: สั่งงานแบบเดี่ยวๆ แต่แอบรอเพื่อน (Microtask Grouping)
จากโค้ดในหน้า .svelte เรามีการใช้ #each วนลูปสร้าง <CityWeather> และเรียกใช้ getWeather(city.id) ทีละเมือง

ซึ่งแทนที่ฟังก์ชัน getWeather จะวิ่งไปหา Server ทันทีทีละรอบ (ซึ่งถ้ามี 5 เมือง ก็จะยิง 5 Requests) ตัว SvelteKit จะทำการ "หน่วงเวลา" ไว้สั้นๆ ในระดับ Microtask เพื่อรอดูว่าในจังหวะนั้นมีการเรียก getWeather ตัวอื่นๆ อีกไหม

2️⃣ การมัดรวม (Batching) ก่อนส่ง
เมื่อ SvelteKit เห็นว่ามีการเรียก getWeather 5 ครั้งพร้อมๆ กัน มันจะทำการ มัดรวม (Batch) ค่า city.id ทั้ง 5 ตัวนั้นเข้าด้วยกันให้กลายเป็น Array ก้อนเดียว เช่น ['bkk', 'cnx', 'hdy', ...] แล้วส่ง Request ก้อนนี้ไปหา Server แค่ ครั้งเดียว! 📦

3️⃣ ฝั่ง Server: รับก้อน Array ไปทำงานรอบเดียว
ตัดภาพมาที่ไฟล์ weather.remote.js บน Server

export const getWeather = query.batch(v.string(), async (cityIds) => { ... })
Enter fullscreen mode Exit fullscreen mode
  • สิ่งที่รับเข้ามา: พารามิเตอร์ cityIds จะไม่ได้รับค่าเป็น string เดี่ยวๆ แล้ว แต่จะได้รับเป็น Array ['bkk', 'cnx', 'hdy'] ตามที่ Client มัดรวมมาให้
  • การ validation จะเป็นการตรวจสอบ แยกเดี่ยว ของแต่ละข้อมูลภายใน Array ที่ส่งเข้ามา
  • การ Query (แบบประหยัดพลังงาน): เราเอา Array นี้ไปดึงข้อมูลจาก Database ในรอบเดียว (ผ่านคำสั่ง ANY(${cityIds}) หรือ IN (...)) ทำให้ Database ทำงานแค่ 1 ครั้ง ได้ผลลัพธ์สภาพอากาศของทุกเมืองกลับมาพร้อมกันเลย 🌩️

4️⃣ ฝั่ง Server: จัดเรียงข้อมูลให้ค้นหาง่าย (Mapping)

const lookup = new Map(weather.map(w => [w.city_id, w]));
Enter fullscreen mode Exit fullscreen mode

พอได้ข้อมูลมาจาก Database มันมักจะมาเป็น Array ธรรมดา เราจึงเอามาจัดลง Map (พจนานุกรมข้อมูล) โดยให้ city_id เป็น Key และ w (ข้อมูลสภาพอากาศ) เป็น Value เพื่อให้เดี๋ยวตอนดึงข้อมูลกลับไป จะได้ค้นหาได้ไวๆ ⚡ (ไม่ต้องไปนั่งลูปหาใหม่ทีละตัว)

5️⃣ ฝั่ง Server: จุดพีค! การ Return ออกมาเป็น "ฟังก์ชัน"

return (cityId) => lookup.get(cityId);
Enter fullscreen mode Exit fullscreen mode

สังเกตว่าเรา ไม่ได้ return ข้อมูลกลับไปตรงๆ แต่เรา return ฟังก์ชัน กลับไปแทน! 🤯 เพราะ SvelteKit ฝั่ง Client ต้องการคนช่วย "แจกจ่าย" ข้อมูลให้ถูกคน มันจะเอาฟังก์ชันนี้ไปทำงานต่อ โดยจะโยน city.id เดี่ยวๆ เข้ามาถามฟังก์ชันนี้ว่า "สภาพอากาศของเมืองนี้คืออะไร?" ฟังก์ชันนี้ก็จะไปหยิบค่าจากตู้ lookup ที่เราสร้างไว้ในข้อ 4 ส่งคืนให้

6️⃣ ฝั่ง Client: แจกจ่ายผลลัพธ์กลับสู่ Component

เมื่อ SvelteKit ฝั่ง Client ได้รับฟังก์ชันแจกจ่ายของมาแล้ว มันก็จะทำหน้าที่แกะกล่อง Batch ออก แล้วส่งผลลัพธ์ (ข้อมูลสภาพอากาศ) กลับคืนไปให้ <CityWeather> แต่ละตัวตามที่เคยขอไว้แบบ 1 ต่อ 1 อย่างแม่นยำเป๊ะ! 🎯


🟢query.live

ตัวนี้ก็อยู่แก๊งเดียวกับ query แต่ทีเด็ดคือมันพกความสามารถในการดึงข้อมูลแบบ real-time จาก server มาด้วย!🔥 แต่จุดที่ต้องจำไว้คือ callback ของ query.live จะเป็นฟังก์ชันสไตล์ generator function และของที่มันคายกลับมาให้เราจะอยู่ในรูปร่างหน้าตาของ AsyncIterable แทน ลองดูตัวอย่างการใช้ query.live ทำนาฬิกาแสดงเวลาทุกๆ 1 วินาทีกัน ⏰

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));
    }
});
Enter fullscreen mode Exit fullscreen mode

ในช่วงที่กำลังทำ 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>
Enter fullscreen mode Exit fullscreen mode

ถ้าวันดีคืนดีเกิดเหตุไม่คาดฝัน เน็ตหลุด หรือเกิด 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 เอง แทนที่จะนั่งรอข้อมูล reactive แบบง่ายๆ ผ่าน current คุณก็สามารถสร้าง async function มารับช่วงต่อ แล้วเปิดใช้ for...await กวาดข้อมูลข้างในมาเล่นได้เลย! 🌪️ เพราะว่าเนื้อแท้แล้ว ของที่ remote function แบบ live โยนกลับมาให้ ก็ยังมีหน้าตาเป็น async-iterable อยู่ดีนั่นแหละ

src/routes/time/+page.svelte

async function logTimes() {
    for await (const value of getTime()) {
        console.log(value);
        if (someCondition) break;
    }
}
Enter fullscreen mode Exit fullscreen mode

ซึ่งผู้ใช้งานที่ดึง 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 📝 ครับผม! มันจะมาช่วยลบภาพความยุ่งยากของการจัดการฟอร์มแบบเดิมๆ ออกไปได้ยังไง เตรียมตัวรอติดตามกันในบทความถัดไปได้เลยครับ! แล้วเจอกัน 😉✨

Top comments (0)