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

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}

ขั้นตอนการทำงาน

  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 ไปทำงานรอบเดียว

    • สิ่งที่รับเข้ามา: พารามิเตอร์ 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]));
    
    • พอได้ข้อมูลมาจาก Database มันมักจะมาเป็น Array ธรรมดา เราจึงเอามาจัดลง Map (พจนานุกรมข้อมูล) โดยให้ city_id เป็น Key และ w (ข้อมูลสภาพอากาศ) เป็น Value เพื่อให้เดี๋ยวตอนดึงข้อมูลกลับไป จะได้ค้นหาได้ไวๆ ⚡ (ไม่ต้องไปนั่งลูปหาใหม่ทีละตัว)
  5. ฝั่ง Server: จุดพีค! การ Return ออกมาเป็น "ฟังก์ชัน"

    return (cityId) => lookup.get(cityId);
    
    • สังเกตว่าเราไม่ได้ 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 แทน

ตัวอย่างการใช้งาน

// 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.