Lynx.js Workshop ตอนที่ 2: ดึงข้อมูล IoT แบบ Real-time

Lynx.js Workshop ตอนที่ 2: ดึงข้อมูล IoT แบบ Real-time

ShowkhunIT

Lynx.js Workshop ตอนที่ 2: ดึงข้อมูล IoT แบบ Real-time

อ้างอิงโค้ดจริงจาก: โปรเจกต์ frontend-mobile ใน kangana1024/showkhun-workshop (branch step-14-mobile-alerts)


ในตอนที่แล้วเราต่อโครงแอปขึ้นมาได้ แต่มันยังเป็น “หุ่นโชว์” อยู่เลย — สวยแต่ไม่มีข้อมูลอะไรไหลเข้ามา 😅

ตอนนี้เราจะเสกชีวิตให้มัน! ทำให้ Dashboard ของเรา ดึงข้อมูล sensor จาก backend จริง แล้วอัปเดตค่าสด ๆ ต่อหน้าต่อตาผ่าน WebSocket — แบบที่เปิดแอปทิ้งไว้ ตัวเลขอุณหภูมิก็ขยับเองโดยไม่ต้องกด refresh เลย มาลุยกัน!


สิ่งที่จะได้เรียนรู้

  • ทำไม mobile app ต้องมี typed API client แยกเป็น layer ไม่ยิง fetch กระจาย
  • สร้าง REST client ที่มี timeout + abort + error envelope จากโค้ดจริง
  • เข้าใจว่าทำไมเราต้องใช้ทั้ง REST และ WebSocket (คนละหน้าที่กัน)
  • RealtimeClient ที่ auto-reconnect แบบ exponential backoff และ re-subscribe ห้องเองอัตโนมัติ
  • ประกอบทุกอย่างเข้าด้วยกันใน hook useDashboard แล้ว merge ข้อมูล live เข้า list
  • render เป็น SensorCard ที่อัปเดตค่าเอง

WHY: ทำไมต้องแยก API client เป็น layer?

ก่อนจะเขียน คำถามแรกคือ — ทำไมไม่ยิง fetch('...') ในหน้า component ไปเลยล่ะ?

ลองนึกถึงร้านอาหารครับ ถ้าลูกค้าทุกโต๊ะเดินเข้าครัวไปทำอาหารเองหมด ครัวจะวุ่นวายแค่ไหน ทุกโต๊ะต้องรู้สูตร รู้ว่าวัตถุดิบอยู่ไหน ถ้าวันหนึ่งเปลี่ยนสูตร ต้องไปไล่บอกทุกโต๊ะ

API client ก็คือ “พนักงานเสิร์ฟ” — หน้า component แค่บอกว่า “ขอ list devices” พนักงานก็จัดการเรื่อง URL, timeout, การแปลง error ให้หมด หน้าจอไม่ต้องรู้รายละเอียดพวกนี้เลย ถ้าวันหนึ่ง backend เปลี่ยน path เราแก้ที่เดียวจบ

ในโปรเจกต์เรา layer นี้อยู่ในโฟลเดอร์ src/api/ แบ่งเป็น 4 ไฟล์:

src/api/
├── config.ts     # base URL + helper สร้าง WebSocket URL
├── types.ts      # type ที่ตรงกับ JSON ของ backend เป๊ะ
├── client.ts     # typed REST client (fetch + timeout + error)
└── realtime.ts   # WebSocket client + auto-reconnect

Step 1: config — base URL ที่ไม่ฝังลงโค้ด

จำจากตอนที่ 1 ได้ไหมครับว่าเรา inject SHOWKHUN_API_BASE_URL ตอน build ไฟล์ config.ts คือคนอ่านค่านั้นมาใช้ แล้วยังมี helper สำคัญ — แปลง URL ของ REST ให้กลายเป็น URL ของ WebSocket:

/** Base URL ของ backend เช่น http://localhost:3000 */
export const API_BASE_URL: string = readBaseUrl()

/** prefix ของ REST API */
export const API_PREFIX = '/api/v1'

/** timeout เริ่มต้น (ms) ของทุก request */
export const REQUEST_TIMEOUT_MS = 10_000

/**
 * แปลง REST URL เป็น WebSocket URL โดยสลับ scheme
 * https -> wss, นอกนั้น -> ws
 */
export function websocketUrl(): string {
  const httpUrl = `${API_BASE_URL}${API_PREFIX}/ws`
  if (httpUrl.startsWith('https://')) {
    return `wss://${httpUrl.slice('https://'.length)}`
  }
  if (httpUrl.startsWith('http://')) {
    return `ws://${httpUrl.slice('http://'.length)}`
  }
  return httpUrl
}

ทำไมต้อง derive WebSocket URL จาก REST? เพราะ backend ตัวเดียวกันเสิร์ฟทั้งสอง protocol — ถ้า REST ชี้ไป https://... ตัว WS ก็ต้องเป็น wss://... ให้ตรงกันโดยอัตโนมัติ จะได้ไม่มีทางตั้งสองที่ให้หลุดกัน


Step 2: types ที่ตรงกับ backend เป๊ะ

นี่คือเคล็ดลับที่ทำให้ TypeScript ช่วยเราจับ bug ได้ — เรานิยาม type ให้ field ตรงกับ JSON ที่ Go ส่งมา ทุกตัวอักษร (ตรงกับ json:"..." tag ฝั่ง Go) เลยไม่ต้อง remap อะไรเลย:

export type DeviceStatus = 'online' | 'offline' | 'error' | 'maintenance'

export interface Device {
  id: string
  device_id: string
  name: string
  type: DeviceType
  status: DeviceStatus
  enabled: boolean
  last_seen_at?: string
  created_at: string
  updated_at: string
  // ... location, tags, group_id ฯลฯ
}

// LiveReading คือ payload ที่มากับ message ชนิด sensor_data ทาง WebSocket
export interface LiveReading {
  device_id: string
  fields: Record<string, number>   // เช่น { temperature: 28.5, humidity: 60 }
  tags?: Record<string, string>
  timestamp: string
}

// WSMessage คือ envelope ที่ hub ฝั่ง backend broadcast ออกมา
export interface WSMessage<T = unknown> {
  type: string         // เช่น 'sensor_data'
  room?: string        // เช่น 'device:sensor-01'
  payload: T
}

backend ห่อ response ทุกตัวด้วย envelope มาตรฐาน เราเลยมี type ของ envelope ไว้ด้วย:

export interface DataEnvelope<T> {
  data: T
}

export interface ListEnvelope<T> {
  data: T[]
  pagination: Pagination
}

อุปมา: type พวกนี้เหมือน “แม่พิมพ์” — ถ้าวันหนึ่ง backend เปลี่ยนชื่อ field แต่เราลืมแก้ฝั่งนี้ TypeScript จะเตือนทันทีตอน npm run typecheck ไม่ต้องไปเจอ bug ตอน runtime บนเครื่องลูกค้า


Step 3: REST client ที่มี timeout + abort

ทีนี้มาดูหัวใจของ client.ts ตัวจริง — ฟังก์ชัน request ที่ทุก endpoint เรียกใช้ มันทำสามอย่างที่ production app ควรมี: ตั้ง timeout, รองรับ abort ของ caller, แปลง error envelope ให้เป็น ApiError

ก่อนอื่นดู ApiError ที่ช่วยให้หน้าจอ branch ตาม status ได้:

/** ApiError พก HTTP status + ข้อความจาก error envelope ของ backend มาด้วย
 *  หน้าจอจะได้เช็ก notFound / status ได้ */
export class ApiError extends Error {
  readonly status: number

  constructor(status: number, message: string, fields?: Record<string, string>) {
    super(message)
    this.name = 'ApiError'
    this.status = status
  }

  get notFound(): boolean {
    return this.status === 404
  }
}

แล้วนี่คือ request (ตัดให้เห็น flow หลัก):

async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
  const controller = new AbortController()
  // ตั้งนาฬิกาจับเวลา — เกิน 10 วิ ก็ abort เอง
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)

  // ถ้า caller ส่ง signal มาด้วย (เช่นหน้าจอถูกปิด) ก็ abort ตามไป
  if (opts.signal) {
    opts.signal.addEventListener('abort', () => controller.abort(), { once: true })
  }

  try {
    const response = await fetch(buildUrl(path, opts.query), {
      method: opts.method ?? 'GET',
      headers: { Accept: 'application/json' },
      signal: controller.signal,
    })

    const text = await response.text()
    const parsed: unknown = text ? JSON.parse(text) : undefined

    if (!response.ok) {
      // แปลง error envelope ของ backend เป็น ApiError ที่หน้าจอเข้าใจ
      if (isErrorBody(parsed)) {
        throw new ApiError(response.status, parsed.error.message, parsed.error.fields)
      }
      throw new ApiError(response.status, `request failed (${response.status})`)
    }

    return parsed as T
  } catch (err) {
    if (err instanceof ApiError) throw err
    if (err instanceof DOMException && err.name === 'AbortError') {
      throw new ApiError(0, 'request timed out')
    }
    throw new ApiError(0, 'network error')
  } finally {
    clearTimeout(timer) // เก็บกวาดนาฬิกาเสมอ
  }
}

ทำไม timeout ถึงสำคัญบนมือถือ? เพราะมือถืออยู่บนเน็ตที่ขาด ๆ หาย ๆ ถ้าไม่ตั้ง timeout แล้วเน็ตหลุด หน้าจอจะค้างหมุนติ้ว ๆ ตลอดกาล — มีนาฬิกาจับเวลา 10 วิ ถ้าไม่ตอบก็ขึ้น “request timed out” ให้ผู้ใช้กดลองใหม่ได้

ส่วนหน้า public ของ client ก็เป็นแค่ method สวย ๆ ที่ห่อ request ไว้ เช่น list devices กับส่งคำสั่ง:

export const api = {
  async listDevices(params = {}, signal?: AbortSignal) {
    const env = await request<ListEnvelope<Device>>('/devices', { query: params, signal })
    return { devices: env.data, pagination: env.pagination }
  },

  async sendCommand(id: string, cmd: CommandPayload, signal?: AbortSignal) {
    const env = await request<DataEnvelope<{ device_id: string; action: string; status: string }>>(
      `/devices/${encodeURIComponent(id)}/commands`,
      { method: 'POST', body: cmd, signal },
    )
    return env.data
  },
  // ... getReadings, listAlertRules ฯลฯ
}

WHY: REST อย่างเดียวไม่พอเหรอ?

มาถึงคำถามสำคัญของตอนนี้ — ในเมื่อมี REST แล้ว ทำไมต้องมี WebSocket อีก?

ลองนึกภาพการอ่านข่าว 📰

  • REST = ซื้อหนังสือพิมพ์ — ได้ snapshot ณ ตอนนั้น อยากได้ข่าวใหม่ต้องไปซื้อฉบับใหม่ (poll ซ้ำ) เปลืองและช้า
  • WebSocket = สมัครให้คนเอาข่าวด่วนมาส่งถึงบ้าน — มีอะไรใหม่ เขาวิ่งมาบอกทันที ไม่ต้องไปถามเอง

ในแอปเราเลยใช้ทั้งคู่ตามจุดแข็ง:

graph TD
    A[📱 Dashboard เปิดขึ้น] -->|1. REST: ขอ list devices| B[🖥️ Backend]
    B -->|ได้ list ครบ| A
    A -->|2. subscribe ห้องของแต่ละ device| C[🔌 WebSocket Hub]
    C -->|sensor_data ค่าใหม่| A
    A -->|merge เข้า list ไม่ต้อง fetch ใหม่| A
  • ตอนเปิดหน้า → ใช้ REST ดึง list device มาทีเดียว (ได้ snapshot ครบ)
  • จากนั้น → WebSocket ส่งค่าใหม่ของแต่ละ device เข้ามาเรื่อย ๆ เรา merge ทับเฉพาะตัวที่เปลี่ยน

Step 4: RealtimeClient — auto-reconnect ที่ฉลาด

นี่คือชิ้นที่พี่โชว์ชอบที่สุด เพราะมันแก้ปัญหาจริงของ mobile — เน็ตหลุด บนมือถือเป็นเรื่องปกติ (เข้าลิฟต์ เข้าอุโมงค์ สลับ WiFi/4G) RealtimeClient ใน realtime.ts จัดการเรื่องนี้ให้หมดด้วย exponential backoff:

const INITIAL_BACKOFF_MS = 1_000   // เริ่มลองใหม่หลัง 1 วิ
const MAX_BACKOFF_MS = 30_000      // เพดานสูงสุด 30 วิ

export class RealtimeClient {
  private socket: WebSocket | null = null
  private desiredRooms = new Set<string>()  // ห้องที่อยากฟัง
  private backoff = INITIAL_BACKOFF_MS
  private closedByUser = false

  private open(): void {
    let socket = new WebSocket(websocketUrl())
    this.socket = socket

    socket.onopen = () => {
      this.backoff = INITIAL_BACKOFF_MS    // ต่อติดแล้ว reset backoff
      this.setState('open')
      // กลับมาต่อใหม่ทีไร subscribe ห้องเดิมทั้งหมดให้อัตโนมัติ
      this.desiredRooms.forEach((room) => this.sendCommand('subscribe', room))
    }

    socket.onmessage = (event) => this.handleMessage(event.data)

    socket.onclose = () => {
      this.teardownSocket()
      if (!this.closedByUser) this.scheduleReconnect()  // ไม่ใช่ผู้ใช้สั่งปิด = ลองใหม่
    }
  }

  private scheduleReconnect(): void {
    this.setState('reconnecting')
    const delay = this.backoff
    // เพิ่มเวลารอเป็นเท่าตัวทุกครั้งที่พลาด แต่ไม่เกินเพดาน
    this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS)
    setTimeout(() => { if (!this.closedByUser) this.open() }, delay)
  }
}

อุปมา exponential backoff: เหมือนโทรหาเพื่อนแล้วไม่รับ — เราไม่กดโทรรัว ๆ ทุกวินาที (เพื่อนยิ่งหงุดหงิด) แต่เว้นนานขึ้นเรื่อย ๆ 1 วิ → 2 วิ → 4 วิ … เพื่อไม่ให้ทั้งเรา (มือถือ) และเพื่อน (server) เหนื่อยเกินไป

อีกจุดที่ดีงามคือ re-subscribe อัตโนมัติ — เราเก็บรายชื่อห้องที่อยากฟังไว้ใน desiredRooms พอ socket กลับมาต่อใหม่ มันก็ subscribe ห้องเดิมให้เองใน onopen ผู้ใช้ไม่รู้สึกเลยว่าเน็ตเพิ่งหลุดไป

ส่วนการ “ฟังห้อง” ก็ทำผ่าน room name ของ backend ตรง ๆ:

/** ชื่อห้อง live ของ device ตัวเดียว (ตรงกับ ws.DeviceRoom ฝั่ง backend) */
export function deviceRoom(deviceId: string): string {
  return `device:${deviceId}`
}

แล้วตอนรับ message ก็ decode envelope แล้วยิง callback เฉพาะชนิด sensor_data:

private handleMessage(data: unknown): void {
  if (typeof data !== 'string') return
  const msg = JSON.parse(data) as WSMessage
  if (msg.type === 'sensor_data') {
    const payload = msg.payload as LiveReading
    if (payload && typeof payload.device_id === 'string') {
      this.handlers.onSensorData?.(payload, msg.room ?? '')
    }
  }
}

Step 5: ประกอบทุกอย่างใน hook useDashboard

ทีนี้เราเอา REST + WebSocket มาผูกกันใน custom hook ตัวเดียว ให้หน้าจอเรียกใช้ง่าย ๆ นี่คือ useDashboard ของจริง (ตัดให้เห็น flow):

export function useDashboard(): DashboardState {
  const [devices, setDevices] = useState<Device[]>([])
  // เก็บค่าล่าสุดต่อ device แยกจาก list หลัก
  const [live, setLive] = useState<Record<string, { fields: Record<string, number>; updatedAt: string }>>({})
  const [connection, setConnection] = useState<ConnectionState>('connecting')

  const clientRef = useRef<RealtimeClient | null>(null)

  // 1) โหลด list device ผ่าน REST แล้ว subscribe ห้องของทุกตัว
  const load = useCallback(async (isRefresh: boolean) => {
    const { devices: list } = await api.listDevices({ limit: 100, sort: 'name' })
    setDevices(list)
    clientRef.current?.setRooms(list.map((d) => deviceRoom(d.device_id)))
  }, [])

  // 2) เปิด WebSocket ตอน mount — ค่าใหม่เข้ามาก็ merge เก็บไว้ใน live
  useEffect(() => {
    const client = new RealtimeClient({
      onStateChange: setConnection,
      onSensorData: (reading) => {
        setLive((prev) => ({
          ...prev,
          [reading.device_id]: { fields: reading.fields, updatedAt: reading.timestamp },
        }))
      },
    })
    clientRef.current = client
    client.connect()
    void load(false)

    return () => { client.close() }   // ปิด socket ตอน unmount
  }, [load])

  // 3) รวม device (จาก REST) กับ live (จาก WS) เป็น list เดียวให้หน้าจอ
  const items: DeviceLive[] = devices.map((device) => {
    const l = live[device.device_id]
    return { device, fields: l?.fields, updatedAt: l?.updatedAt }
  })

  return { /* loading, error, */ connection, items, refresh: () => load(true) }
}

เห็น pattern สวย ๆ มั้ยครับ? list หลักมาจาก REST, ค่าสดมาจาก WebSocket, แล้วเรา merge ตอน render — ค่าใหม่เข้ามาทีก็อัปเดตเฉพาะ device ตัวนั้น ไม่ต้องยิง REST ใหม่ทั้งก้อน ประหยัดทั้ง network และ battery


Step 6: render เป็น SensorCard

ส่วนสุดท้ายคือเอา items มาวาด ตัว DashboardScreen มี indicator บอกสถานะ connection (Live / Reconnecting…) และ pull-to-refresh ผ่าน bindscrolltoupper ของ <scroll-view>:

<scroll-view
  className='Dashboard__scroll'
  scroll-orientation='vertical'
  upper-threshold={4}
  bindscrolltoupper={onScrollToUpper}   // เลื่อนชนขอบบน = refresh
>
  <view className='Screen__body'>
    {items.map((item) => (
      <SensorCard key={item.device.id} item={item} />
    ))}
  </view>
</scroll-view>

แล้ว SensorCard ก็แสดงชื่อ device, status badge, และวน fields ออกมาเป็นตัวเลข ถ้ายังไม่มีค่า live ก็ขึ้น “Waiting for live data…”:

export function SensorCard(props: SensorCardProps) {
  const { device, fields, updatedAt } = props.item
  const entries = fields ? Object.entries(fields) : []

  return (
    <Card bindtap={props.onTap}>
      <view className='SensorCard__head'>
        <text className='SensorCard__name'>{device.name}</text>
        <Badge label={device.status} tone={deviceStatusTone(device.status)} />
      </view>

      {entries.length > 0 ? (
        <view className='SensorCard__metrics'>
          {entries.map(([field, value]) => (
            <view key={field} className='SensorCard__metric'>
              <text className='SensorCard__metricValue'>{formatValue(value)}</text>
              <text className='SensorCard__metricLabel'>{humanize(field)}</text>
            </view>
          ))}
        </view>
      ) : (
        <text className='SensorCard__waiting'>Waiting for live data…</text>
      )}
    </Card>
  )
}

พอ WebSocket ส่ง sensor_data เข้ามา → live ใน hook อัปเดต → React re-render → ตัวเลขใน SensorCard__metricValue ขยับเอง ครบลูปพอดี!

   ╔══════════════════════════════╗
   ║                              ║
   ║   ٩(◕‿◕)۶                    ║
   ║                              ║
   ║   "ตัวเลขขยับเองโดยไม่ต้องกด" ║
   ║                              ║
   ╚══════════════════════════════╝

สรุป: เราทำอะไรไปบ้างในตอนนี้

  • เข้าใจว่าทำไมต้องแยก API client เป็น layer — หน้าจอแค่สั่ง ไม่ต้องรู้ URL/timeout/error
  • มี typed REST client ที่ field ตรงกับ backend เป๊ะ มี timeout 10 วิ, abort, และ ApiError
  • รู้ว่า REST = snapshot, WebSocket = ข่าวด่วน ใช้คู่กันตามจุดแข็ง
  • ได้ RealtimeClient ที่ auto-reconnect แบบ exponential backoff และ re-subscribe ห้องให้เอง
  • ผูกทุกอย่างใน hook useDashboard ที่ merge ค่า live เข้า list โดยไม่ fetch ซ้ำ
  • render เป็น SensorCard ที่อัปเดตค่าสด ๆ ต่อหน้าต่อตา

ตอนนี้แอปเรา “มีชีวิต” แล้วครับ — มีข้อมูลจริงไหลเข้ามาแบบ real-time


Next Step

ตอนหน้าเป็นตอนสุดท้าย เราจะยกระดับเป็น advanced integration — สั่งงาน device ผ่าน command + dialog ยืนยัน, วาด chart จาก time-series ด้วย SVG ที่ปลอดภัยจาก injection, จัดการ alert rules, theme dark/light ผ่าน Context และ pattern production อื่น ๆ ที่ดึงจากโค้ดจริง

Happy Building! 🌱