วิธีตั้งค่า Laravel Broadcasting ด้วย Reverb ให้ใช้งานจริงได้

ติดตั้ง Reverb ด้วยคำสั่งเดียว แล้วตัดสินใจสามเรื่องก่อนขึ้น production คือ event จะส่งข้อมูลมาเลยหรือแค่สะกิดให้โหลดใหม่ ผู้ใช้จะฟัง channel ไหน และแยกทางเข้าของเบราว์เซอร์กับของ Laravel ออกจากกัน

เผยแพร่
เวลาอ่าน
6 นาที
แท็ก
#Laravel#Queue
ในโพสต์นี้ 7 หัวข้อ
  1. ติดตั้งแล้วลองยิง event จาก tinker
  2. event ส่งได้สองแบบ คือสะกิดกับส่งข้อมูลมาเลย
  3. ให้ผู้ใช้ฟัง channel ของตัวเองเมื่อดูแลข้อมูลหลายก้อน
  4. แยกทางเข้าของเบราว์เซอร์กับ Laravel ออกจากกัน
  5. จำกัดโดเมนที่ต่อ Reverb ได้ด้วย allowed_origins
  6. ให้ Supervisor ดูแล Reverb แค่หนึ่ง process
  7. เรื่องที่อาจเจอเมื่อใช้งานจริง

โพสต์ก่อนหน้าอธิบายว่า broadcasting มีชิ้นส่วนอะไรบ้าง โพสต์นี้ลงมือตั้งค่าฝั่ง Laravel ตั้งแต่ติดตั้งจนรันบน production ตัวอย่างหลายข้อมาจากระบบสั่งซื้อของตัวแทนจำหน่ายที่เราทำ dashboard หลังบ้าน การจองสต็อก และแชตบน Reverb

ติดตั้งแล้วลองยิง event จาก tinker

php artisan install:broadcasting --reverb

คำสั่งนี้ตั้ง BROADCAST_CONNECTION=reverb ใน .env ให้ด้วย จากนั้นเปิดสอง terminal

php artisan reverb:start --debug
php artisan queue:work

ยังไม่ต้องมีหน้าเว็บก็ลองได้ สั่งส่ง event แบบไม่ต้องสร้าง class แล้วดูว่า Reverb รับข้อความหรือเปล่า

php artisan tinker --execute 'Broadcast::private("orders.1")->as("Ping")->with(["ok" => true])->sendNow();'

ถ้าคำสั่งจบโดยไม่มี exception แปลว่า Laravel ส่งถึง Reverb แล้ว ถ้าต่อไม่ติดจะได้ BroadcastException ทันที ส่วน terminal ของ Reverb จะยังไม่แสดงอะไรจนกว่าจะมีคนฟัง channel นั้น แยกปัญหาแบบนี้ง่ายกว่าเปิดเบราว์เซอร์ไล่ทีเดียวทั้งเส้น

event ส่งได้สองแบบ คือสะกิดกับส่งข้อมูลมาเลย

ก่อนเขียน event จริงให้เลือกก่อนว่าจะส่งอะไรไป เพราะเรื่องนี้กำหนดว่าหน้าเว็บต้องเขียนยังไง และโหลดเซิร์ฟเวอร์แค่ไหน

สะกิดให้โหลดใหม่ ส่งข้อมูลมาเลย
payload เล็ก เช่น { tile, at } ข้อความล่าสุด ตัวเลข สถานะ
หน้าเว็บทำอะไร เรียก API เดิมอีกรอบ เอาข้อมูลไปใส่ state
สิทธิ์ API เช็กให้ตามปกติทุกครั้ง ทุกคนใน channel เห็น payload เดียวกัน
จุดที่ต้องระวัง คนเปิด 1,000 แท็บ ก็คือ 1,000 request เพดานขนาด และต้องมีทางโหลดใหม่เมื่อข้อมูลขาด
เหมาะกับ dashboard หน้ารายการ แชต badge จำนวนที่ยังไม่อ่าน ข้อความที่ค่อย ๆ พิมพ์ออกมา

เริ่มจากแบบสะกิดไว้ก่อน เขียนง่ายที่สุด และไม่เสี่ยงส่งข้อมูลเกินสิทธิ์ เพราะข้อมูลยังออกจาก API ที่มี Policy คุมอยู่ หน้า dashboard ในระบบที่เราทำส่งแค่ว่า tile ไหนเปลี่ยน แล้วให้หน้าเว็บโหลด prop นั้นใหม่

ส่วนแชตเปลี่ยนมาส่งข้อมูลจริง เพราะทุกข้อความที่ส่ง ถ้าทุกคนในห้องต้องเรียก API กลับมา จะกลายเป็น request จำนวนมากในเวลาเดียวกัน

Reverb จำกัดขนาด request ที่ Laravel ส่ง event เข้าไปด้วย max_request_size ค่าเริ่มต้น 10,000 bytes นับทั้ง request ภาษาไทยส่งได้น้อยกว่าที่คิดมาก เพราะ json_encode เปลี่ยน ก เป็น \u0e01 แล้ว SDK ของ Pusher ที่ Laravel ใช้ก็ encode JSON ซ้ำอีกชั้น ตัวอักษรไทยหนึ่งตัวจึงกิน 7 bytes ส่วน double quote กิน 4 bytes ตอนที่ลองกับ Reverb 1.12 ด้วย event ชื่อสั้น ๆ ได้ตัวเลขนี้

ข้อความใน payload ส่งได้มากสุด
ภาษาอังกฤษ 9,544 ตัวอักษร
ภาษาไทยปนเว้นวรรค ราว 1,450 ตัวอักษร
ภาษาไทยล้วน 1,363 ตัวอักษร
double quote ล้วน 2,386 ตัวอักษร

ถ้าเกิน Reverb จะตอบ 413 และ Laravel โยน BroadcastException ถ้าครอบด้วย rescue() ไว้ event จะหายไปเงียบ ๆ เหลือแค่บรรทัดใน log การวัดด้วย strlen(json_encode($payload)) จึงยังไม่พอ payload ภาษาไทยที่วัดได้ 8,999 bytes ก็ยังส่งไม่ผ่าน ให้วัดแบบที่ส่งจริงคือ encode สองชั้น ถ้าเกินก็ส่งแค่บอกให้หน้าเว็บโหลดใหม่

private function payloadFor(Order $order, User $user): array
{
    $payload = [
        'order_id' => $order->id,
        'refetch' => false,
        'order' => new OrderResource($order)->resolve(),
    ];

    if (strlen(json_encode(json_encode($payload))) <= 9000) {
        return $payload;
    }

    return [...$payload, 'refetch' => true, 'order' => null];
}

เผื่อไว้ 1,000 bytes สำหรับชื่อ event, ชื่อ channel และ header ของ request ถ้าข้อมูลใหญ่เป็นปกติ เพิ่มเพดานได้ด้วย REVERB_MAX_REQUEST_SIZE แต่ payload ที่ใหญ่ขึ้นก็ต้องส่งถึงทุกคนใน channel ด้วย

ให้ผู้ใช้ฟัง channel ของตัวเองเมื่อดูแลข้อมูลหลายก้อน

วิธีตั้งชื่อ channel ที่เห็นบ่อยคือหนึ่ง channel ต่อหนึ่งเรื่อง เช่น company.{id} ก็ใช้ได้ดีกับหน้าที่ดูทีละบริษัท แต่แต่ละ channel ต้องขอสิทธิ์ที่ /broadcasting/auth แยกกัน พนักงานที่ดูแลหลายร้อยบริษัทจึงเรียก request เท่านั้นทุกครั้งที่เปิดหน้าเว็บ

ถ้าให้ผู้ใช้ฟังทีละบริษัท เปิดหน้าครั้งเดียวต้องขอสิทธิ์หลายร้อยครั้ง

ทั้งสองแบบส่งข้อมูลเรื่องเดียวกัน ต่างกันที่จำนวนครั้งที่ขอสิทธิ์ตอนเปิดหน้า

ทางที่เบากว่าคือให้ทุกคนฟัง user.{id} ของตัวเอง channel เดียว แล้วให้ฝั่ง Laravel หาว่าเรื่องนี้ใครต้องรู้ จากนั้นส่ง event แยกให้ทีละคน แต่ละคนจึงได้ payload ของตัวเองด้วย เช่นจำนวนที่ยังไม่อ่าน

class NotifyOrderWatchers implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $orderId)
    {
        $this->afterCommit();
    }

    public function handle(): void
    {
        $order = Order::with('watchers')->find($this->orderId);

        $order->watchers->each(fn (User $user) => rescue(
            fn () => event(new OrderStatusUpdated($user->id, $this->payloadFor($order, $user))),
        ));
    }
}

ในโค้ดนี้มีสามอย่างที่ตั้งใจเลือก

  • job อยู่ใน queue ส่วน event ใช้ ShouldBroadcastNow การหาผู้รับและคำนวณ payload ทำใน job อยู่แล้ว ถ้า event เข้า queue ซ้ำอีกชั้น ข้อความจะไปต่อท้ายงานอื่นในคิวโดยไม่ได้อะไรเพิ่ม
  • afterCommit() กันไม่ให้ job อ่านข้อมูลก่อน transaction จะ commit
  • rescue() ถ้า Reverb ล่ม หรือส่งให้คนหนึ่งไม่สำเร็จ คนที่เหลือยังได้รับ และ job ไม่ fail ถ้ายิง event จากใน request โดยตรง ให้ event implements ShouldRescue แทน ผู้ใช้จะได้ไม่เห็น error 500 เพียงเพราะ WebSocket server มีปัญหา

แบบนี้ก็มีต้นทุน เพราะคำนวณ payload เป็นรายคน ห้องที่มีคนเยอะ หรือค่าที่ต้อง sum จากหลายตาราง ควรวัดเวลาจริงของ job ด้วย

แยกทางเข้าของเบราว์เซอร์กับ Laravel ออกจากกัน

ค่าเริ่มต้นของ config/broadcasting.php ให้ Laravel ส่ง event ไปที่ REVERB_HOST ซึ่งเป็นโดเมนสาธารณะ event ทุกตัวจึงออกจากเครื่องไปผ่าน DNS, Cloudflare และ nginx แล้ววนกลับมาหา Reverb ที่อยู่เครื่องเดียวกัน ถ้ามี Cloudflare ก็เป็นอีกจุดหนึ่งที่พังได้ ทางที่ตรงกว่าคือแยกเป็นสองทาง

Reverb มีทางเข้าสองทาง ทางแรกให้เบราว์เซอร์ ทางที่สองให้ Laravel

เบราว์เซอร์เข้าทาง nginx เท่านั้น ส่วน Laravel ส่ง event ตรงเข้า Reverb ในเครื่อง

เพิ่มค่าชุด REVERB_PUBLISH_* ใน config (ชื่อนี้เราตั้งเอง ไม่ได้มาจาก Laravel)

'reverb' => [
    'driver' => 'reverb',
    'key' => env('REVERB_APP_KEY'),
    'secret' => env('REVERB_APP_SECRET'),
    'app_id' => env('REVERB_APP_ID'),
    'options' => [
        'host' => env('REVERB_PUBLISH_HOST', '127.0.0.1'),
        'port' => env('REVERB_PUBLISH_PORT', env('REVERB_SERVER_PORT', 8080)),
        'scheme' => env('REVERB_PUBLISH_SCHEME', 'http'),
        'useTLS' => env('REVERB_PUBLISH_SCHEME', 'http') === 'https',
    ],
],

พอ Laravel ไม่ได้ผ่าน nginx แล้ว nginx ก็เปิดให้แค่ /app/ ไม่ต้องเปิด /apps ออกนอกเครื่อง จะใช้โดเมนเดียวกับเว็บก็ได้ ไม่ต้องตั้งโดเมนย่อยเพิ่ม

location ^~ /app/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Host $http_host;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_read_timeout 3600s;
    proxy_buffering off;
}

ต้องเขียนเป็น ^~ /app/ เท่านั้น อย่าใช้ regex อย่าง ~ /app เพราะ regex ที่ไม่ได้ยึดต้น path จะจับ /build/assets/app-<hash>.js ไฟล์หลักของ Vite ไปด้วย Reverb ตอบ 404 กลับมา ถ้า Cloudflare cache 404 นั้นไว้ เว็บจะขาวทั้งเว็บ และกลับมาเป็นอีกทุกครั้งที่ deploy เพราะ hash เปลี่ยน ส่วน proxy_read_timeout ค่าเริ่มต้นของ nginx คือ 60 วินาที สายที่เปิดค้างจึงควรตั้งยาวกว่านั้น

จำกัดโดเมนที่ต่อ Reverb ได้ด้วย allowed_origins

ค่าเริ่มต้นใน config/reverb.php คือ ['*'] ก่อนขึ้น production ให้ใส่เฉพาะโดเมนของเรา

'allowed_origins' => ['app.example.com', 'localhost'],

ใส่แค่ชื่อโดเมน ไม่ต้องมี https:// และไม่ต้องมีพอร์ต ถ้าใส่มาจะไม่มีวันตรง ถ้าต้องการทุก subdomain ใส่ *.example.com ได้ แต่แบบนี้จะไม่รวมโดเมนหลัก

origin ที่ไม่ตรงยังผ่าน handshake ได้ 101 ตามปกติ แล้วจึงได้ error 4009 Origin not allowed กับถูกตัดสาย การเช็กด้วย curl แล้วเห็น 101 จึงยังไม่ได้บอกว่าโดเมนผ่าน ต้องดูจากเบราว์เซอร์ และค่านี้กันได้เฉพาะเบราว์เซอร์ ข้อมูลใน private channel ยังต้องพึ่งการตรวจสิทธิ์ใน routes/channels.php

ให้ Supervisor ดูแล Reverb แค่หนึ่ง process

[program:reverb]
command=php artisan reverb:start
directory=/var/www/app/current
autostart=true
autorestart=true
user=www-data
numprocs=1
stopwaitsecs=30
stdout_logfile=/var/www/app/current/storage/logs/reverb.log
stdout_logfile_maxbytes=1MB

numprocs ต้องเป็น 1 เพราะ Reverb เก็บว่าใครต่ออยู่ channel ไหนไว้ใน memory ของ process ถ้าเปิดสองตัว แต่ละตัวจะเห็นแค่สายของตัวเอง ถ้าเครื่องเดียวรับไม่ไหวจริง ๆ ค่อยเปิด REVERB_SCALING_ENABLED แล้วใช้ Redis เชื่อมหลายเครื่องเข้าด้วยกัน

ตอน deploy ให้รีสตาร์ต Reverb พร้อม queue worker ทุกครั้ง เพราะ process ที่ค้างอยู่ยังถือโค้ดเก่า ทั้งเรื่องลำดับและคำสั่งอยู่ในโพสต์เรื่องลำดับการ deploy

เรื่องที่อาจเจอเมื่อใช้งานจริง

  • ย้ายของเดิมมา Reverb ทีเดียวทั้งระบบแล้วเสี่ยงเกินไป ตอนย้ายแชต เราออกแบบให้ของเดิมกับ Reverb ทำงานคู่กันก่อน แล้วเก็บสวิตช์ไว้ในตาราง setting เปิดทีละกลุ่มผู้ใช้ เริ่มจากบัญชีทดสอบ ถ้ามีปัญหาก็สลับกลับได้โดยไม่ต้อง deploy
  • ข้อความที่ส่งถี่มากท่วมหน้าเว็บ อย่างคำตอบของ AI ที่ค่อย ๆ พิมพ์ออกมา ถ้าส่งทุก token จะเปลือง และอาจชนเพดานขนาด เราจึงรวมข้อความทุก 250 มิลลิวินาทีแล้วส่งเป็นก้อน และตอนจบส่งความยาวทั้งหมดไปด้วย หน้าเว็บที่ได้ไม่ครบจะได้โหลดใหม่จาก API
  • เช็กว่าต่อครบแล้วจริงไหมจากหน้าเว็บ หน้าแอดมินเล็ก ๆ ที่มีปุ่มส่ง event แล้วแสดงข้อความที่กลับมา ช่วยได้มากกว่าการไล่ log ใช้เช็กได้ทุก environment หลัง deploy
  • เซิร์ฟเวอร์ทดสอบยังไม่มี Reverb ตั้ง BROADCAST_CONNECTION=null แล้ว event จะไม่ไปไหน ระบบที่เหลือทำงานปกติ ถ้าหน้าเว็บเขียนให้กลับไป polling ได้เมื่อยังต่อไม่ติด ก็ค่อย ๆ เปิดทีละเครื่องได้

ตอนถัดไปของซีรีส์ Broadcasting และ Reverb ใน Laravel

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →