บทความ

ทำ LINE webhook ใน Laravel ยังไง ให้ตอบทันทีแล้วค่อยประมวลผลใน queue

รับ webhook จาก LINE แบบเช็กลายเซ็น เก็บ body ดิบ ตอบ 200 แล้วส่งงานต่อให้ queue job ที่รันซ้ำได้โดยไม่สร้างข้อมูลซ้ำ พร้อมงานเก็บตกรายชั่วโมงที่มีเพดาน ไม่ให้ job ที่พังทุกรอบเติม failed_jobs จนดิสก์เต็ม

เผยแพร่
เวลาอ่าน
8 นาที

webhook คือ URL ในระบบของเราที่บริการภายนอกเรียกเข้ามาเมื่อมีอะไรเกิดขึ้น เช่น Payment Gateway แจ้งว่าลูกค้าจ่ายเงินแล้ว หรือ LINE แจ้งว่ามีคนส่งข้อความเข้า LINE Official Account (OA) ของร้าน โพสต์นี้ใช้ LINE Messaging API เป็นตัวอย่าง แต่หลักเดียวกันใช้กับ webhook ของเจ้าไหนก็ได้

ในระบบหนึ่งที่เราดูแล ลูกค้าส่งรูปสลิปโอนเงินเข้า LINE OA แล้วระบบสร้างรายการสลิปให้พนักงานตรวจ ฟังดูง่าย รับ webhook โหลดรูป บันทึก จบ พอใช้งานจริงไปสักพัก เราก็เจอทั้ง event ที่มาซ้ำ รูปที่โหลดไม่ได้ และ job ที่พังซ้ำ ๆ จนตาราง failed_jobs โตเป็นหลาย GB โค้ดในโพสต์นี้เขียนใหม่เป็นตัวอย่างทั่วไปบน Laravel 13 จากสิ่งที่เราเจอในระบบนั้น

LINE รอแค่ 200 งานหนักไปทำทีหลัง

reference ของ Messaging API บอกว่า server ของเราต้องตอบ status 200 เมื่อได้รับ request ส่วนเอกสารเรื่องรับข้อความ แนะนำให้ประมวลผล event แบบ asynchronous และเตือนว่าถ้า server รับ webhook ไม่ได้นาน ๆ LINE อาจหยุดส่ง webhook มาให้

โหลดรูปจาก LINE เขียนไฟล์ แล้วสร้างรายการ ใช้เวลาเป็นวินาทีและพังได้หลายจุด ถ้าทำทั้งหมดก่อนตอบ LINE ก็ต้องรอ และถ้าพังตรงไหน request นั้นก็ตอบ 500 ไปทั้งก้อน เราจึงแบ่งเป็นสองช่วง ช่วงแรกรับ request ให้เร็วที่สุด ช่วงหลังให้ queue ทำงานจริง

sequenceDiagram
    participant L as LINE
    participant C as Controller
    participant DB as webhook_calls
    participant Q as Queue job
    L->>C: POST /webhooks/line
    C->>C: เช็ก X-Line-Signature
    C->>DB: เก็บ body ดิบ
    C->>Q: dispatch
    C-->>L: 200 OK
    Q->>L: โหลดรูปจาก api-data.line.me
    Q->>DB: บันทึกสลิป แล้วติดว่าเสร็จแล้ว

controller ตอบ LINE ทันที งานที่เหลือไปทำใน queue

เก็บ body ดิบไว้ก่อนทำอย่างอื่น

สิ่งแรกที่ controller ทำคือเขียน body ของ request ลงตารางตามที่ได้มาทุกตัวอักษร ถ้า job พัง เรายังมี payload ไว้ส่งเข้า queue ใหม่ เปิดดูตอน debug หรือเอาไปเขียน test ก็ได้

Schema::create('webhook_calls', function (Blueprint $table) {
    $table->id();
    $table->string('source', 20);
    $table->longText('body');
    $table->unsignedTinyInteger('attempts')->default(0);
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();

    $table->index(['source', 'processed_at']);
});

เราเก็บ $request->getContent() แทน $request->all() เพราะลายเซ็นของ LINE คำนวณจาก body ตัวต่อตัว ถ้าแปลงเป็น array แล้ว json_encode() กลับ ช่องว่างหรือ escape ของตัวอักษรไทยอาจเปลี่ยนไป เอามาเช็กลายเซ็นทีหลังก็ไม่ตรงแล้ว

prunable() ลบเฉพาะแถวที่ประมวลผลเสร็จและเก่ากว่า 90 วัน แถวที่ยังค้างเก็บไว้ให้คนมาดู ตั้ง Schedule::command('model:prune')->daily() ไว้ด้วย ไม่อย่างนั้น Laravel ไม่ลบให้

เช็ก X-Line-Signature ก่อนเชื่อ request

URL ของ webhook เปิดให้ทั้งอินเทอร์เน็ตยิงเข้ามาได้ ใครรู้ URL ก็ส่ง event ปลอมมาได้ เช่น event ที่อ้างว่าลูกค้าคนหนึ่งส่งรูปเข้ามา LINE จึงเซ็นทุก request ด้วย channel secret แล้วแนบลายเซ็นมาใน header X-Line-Signature วิธีเช็กตามเอกสาร Verify webhook signature มีสามขั้น

  1. เอา body ดิบมาทำ HMAC-SHA256 โดยใช้ channel secret (แท็บ Basic settings ใน LINE Developers Console) เป็น key
  2. แปลงผลลัพธ์ที่เป็น binary เป็น base64
  3. เทียบกับค่าใน header ถ้าไม่ตรง หรือไม่มี header มาเลย ให้ตอบ error และไม่ประมวลผล event นั้น
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyLineSignature
{
    public function handle(Request $request, Closure $next): Response
    {
        $secret = (string) config('services.line.channel_secret');
        $signature = (string) $request->header('X-Line-Signature');

        $expected = base64_encode(hash_hmac('sha256', $request->getContent(), $secret, true));

        abort_if($secret === '' || ! hash_equals($expected, $signature), 401);

        return $next($request);
    }
}

ในโค้ดสั้น ๆ นี้มีสามจุดที่พลาดได้ง่าย

  • อาร์กิวเมนต์ตัวที่สี่ของ hash_hmac() ต้องเป็น true เพื่อให้ได้ค่า binary ก่อนแปลงเป็น base64 ถ้าลืม จะได้ base64 ของ hex ซึ่งไม่มีวันตรงกับของ LINE
  • เทียบด้วย hash_equals() แทน === เพราะ hash_equals() ใช้เวลาเท่ากันไม่ว่าจะผิดตั้งแต่ตัวแรกหรือตัวสุดท้าย คนยิงจึงเดาลายเซ็นทีละตัวจากเวลาที่ server ตอบไม่ได้
  • ไม่มี channel secret ก็ไม่รับ ถ้าลืมตั้ง LINE_CHANNEL_SECRET middleware จะตอบ 401 ทุก request ดีกว่าเผลอรับทุกอย่างไปเงียบ ๆ

LINE ไม่เปิดเผย IP ที่ใช้ส่ง webhook เราจึงกรองด้วย IP แทนลายเซ็นไม่ได้ ถ้ามี proxy หรือ load balancer อยู่หน้า Laravel ให้เช็กว่าไม่ได้แก้ body หรือ header ระหว่างทาง และถ้ามีคนกด reissue channel secret ใน console ค่าเก่าจะใช้ไม่ได้ทันที ต้องแก้ .env ตาม

ตอบ 200 แล้วส่งงานต่อให้ queue

controller เหลือแค่เก็บ body แล้ว dispatch job ตอนกดปุ่ม Verify ใน console หรือตอน LINE เช็กว่า server ยังอยู่ LINE จะส่ง events ว่างมา อันนี้ตอบ 200 กลับไปได้เลย ไม่ต้องเก็บ

<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessLineWebhook;
use App\Models\WebhookCall;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class LineWebhookController
{
    public function __invoke(Request $request): Response
    {
        if ($request->input('events', []) === []) {
            return response('OK');
        }

        $call = WebhookCall::create([
            'source' => 'line',
            'body' => $request->getContent(),
        ]);

        ProcessLineWebhook::dispatch($call);

        return response('OK');
    }
}

route นี้อยู่ใน routes/web.php จึงต้องยกเว้น CSRF ให้ เพราะ LINE ไม่มี CSRF token ส่งมา ลายเซ็นทำหน้าที่ยืนยันแทน ถ้าโปรเจกต์มี routes/api.php อยู่แล้ว จะย้ายไปไว้ที่นั่นก็ได้ ไม่ต้องยกเว้น CSRF

dispatch() ส่งงานเข้า queue แล้วให้ worker (php artisan queue:work) หยิบไปทำ ต้องตั้ง QUEUE_CONNECTION เป็น database หรือ redis ถ้ายังเป็น sync งานจะรันทันทีก่อนตอบ LINE ซึ่งก็กลับไปเป็นปัญหาเดิม

ถ้าอยู่บน shared hosting ที่รัน worker ค้างไว้ไม่ได้ Laravel มี dispatchAfterResponse() ให้ส่ง response ออกไปก่อน แล้วรัน job ต่อใน PHP process เดิม ไม่ต้องมี worker แต่ job นี้ไม่ได้เข้า queue จริง Dispatcher ของ Laravel เรียก dispatchSync() หลังส่ง response เท่านั้น จึงไม่มี retry พังแล้วไม่ลง failed_jobs และถ้า process ตายกลางทาง งานนั้นก็หายไปเลย ใช้ได้ถ้ามีงานเก็บตกด้านล่างคอยตามให้

LINE ส่ง event เดิมมาซ้ำได้ job จึงต้องรันซ้ำได้

LINE มีตัวเลือก Webhook redelivery ซึ่งปิดไว้เป็นค่าเริ่มต้น ถ้าเปิดไว้และ server ของเราไม่ได้ตอบ 2xx LINE จะส่ง webhook เดิมมาอีกรอบ event ที่ส่งซ้ำเหมือนของเดิมทุกอย่าง ต่างกันแค่ deliveryContext.isRedelivery เป็น true เอกสารบอกไว้เองว่า event เดียวกันอาจมาถึงมากกว่าหนึ่งครั้ง ให้ใช้ webhookEventId ตรวจว่าซ้ำ

ถึงจะไม่ได้เปิด redelivery งานซ้ำก็เกิดจากฝั่งเราได้ ทั้ง job ที่ retry และงานเก็บตกที่ส่ง request เดิมเข้า queue อีกรอบ job ของ webhook จึงต้อง idempotent คือรันกี่รอบก็ได้ผลเหมือนรันครั้งเดียว

สำหรับรูปสลิป เราใช้ message.id เป็น key เพราะหนึ่งข้อความคือหนึ่งสลิป แล้วให้ฐานข้อมูลช่วยกันซ้ำด้วย unique index

Schema::table('slips', function (Blueprint $table) {
    $table->string('line_message_id')->nullable()->unique();
});

job นี้กันงานซ้ำไว้สองชั้น

  • เช็ก exists() ก่อนโหลดรูป รอบที่สองของ event เดิมจะจบตรงนั้น ไม่ต้องไปโหลดรูปจาก LINE ซ้ำ
  • createOrFirst() พึ่ง unique index ถ้าสอง job ทำข้อความเดียวกันพร้อมกันจนผ่าน exists() มาทั้งคู่ ตัวที่ insert ทีหลังจะชน unique แล้ว Laravel ดึงแถวเดิมมาให้แทน ไม่เกิดสลิปสองใบ

อย่าใช้ updateOrCreate() กับงานที่รันซ้ำ เมธอดนี้เขียนค่าทับทุกรอบ ถ้าใส่สถานะเริ่มต้นอย่าง checked_at => null ไว้ในนั้น event ที่มาซ้ำจะรีเซ็ตสลิปที่พนักงานตรวจไปแล้วให้กลับเป็นยังไม่ได้ตรวจ

ข้อความ text ไม่มีไฟล์ให้โหลด ส่วน event ที่ไม่มี message ID อย่าง follow หรือ postback ใช้ webhookEventId เป็น key แทน เก็บลงตารางที่มี unique index แบบเดียวกัน

โหลดรูปจาก LINE ได้ 202 ให้ลองใหม่ ได้ 404 หรือ 410 ให้ข้าม

webhook ส่งมาแค่ message ID ตัวไฟล์ต้องโหลดเองจาก Get content ที่ https://api-data.line.me/v2/bot/message/{messageId}/content (โดเมน api-data ไม่ใช่ api เหมือน endpoint อื่น) และใช้ได้เฉพาะไฟล์ที่ contentProvider.type เป็น line สถานะที่ต้องรับมือมีดังนี้

สถานะ ความหมายตามเอกสาร job ทำอะไร
200 ได้ไฟล์ ชนิดไฟล์อยู่ใน header Content-Type บันทึก
202 LINE ยังเตรียมไฟล์ไม่เสร็จ เจอบ่อยกับวิดีโอหรือเสียงไฟล์ใหญ่ throw ให้ queue ลองใหม่
404 ไม่มีข้อความนี้ ข้าม
410 ผู้ใช้ยกเลิกข้อความ (unsend) ไปแล้ว ข้าม
อื่น ๆ เช่น 401, 429, 5xx token ผิด ยิงถี่เกิน หรือ LINE มีปัญหา throw ให้ queue ลองใหม่
<?php

namespace App\Services;

use App\Exceptions\LineContentNotReady;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

class LineContent
{
    /**
     * ได้ null เมื่อ LINE ไม่มีไฟล์นี้ให้โหลดแล้ว (404, 410)
     */
    public function get(string $messageId): ?Response
    {
        $response = Http::withToken((string) config('services.line.channel_access_token'))
            ->timeout(30)
            ->get("https://api-data.line.me/v2/bot/message/{$messageId}/content");

        return match ($response->status()) {
            202 => throw new LineContentNotReady("LINE is still preparing content {$messageId}"),
            404, 410 => null,
            default => $response->throw(),
        };
    }
}

$response->throw() คืน response เดิมเมื่อสำเร็จ และ throw RequestException เมื่อเป็น 4xx หรือ 5xx job จึงพังแล้วกลับเข้า queue ตาม #[Tries(3)] กับ #[Backoff(60, 300)] (รอ 1 นาที แล้ว 5 นาที) event ที่บันทึกไปแล้วก่อนหน้าในรอบนั้นจะผ่าน exists() ไปเลย อย่าเอา body ของ response ที่ไม่ใช่ 200 ไปบันทึกเป็นรูป เพราะ body ของ error เป็น JSON บอกสาเหตุ บันทึกไปก็ได้ไฟล์ .jpg ที่เปิดไม่ขึ้น

เอกสารบอกด้วยว่า LINE ลบไฟล์ทิ้งเองหลังผ่านไประยะหนึ่ง และไม่รับประกันว่าเก็บไว้นานแค่ไหน อีกเหตุผลที่ควรโหลดทันทีหลังได้ webhook แทนที่จะรอรันเป็นรอบตอนกลางคืน ถ้าลูกค้ายกเลิกข้อความหลังเราเก็บรูปไปแล้ว LINE จะส่ง event unsend ตามมา และแนะนำให้จัดการข้อความนั้นตามที่ผู้ใช้ตั้งใจ คือไม่ให้ใครเห็นหรือเอาไปใช้ต่อ ระบบสลิปอาจซ่อนรายการนั้น หรือติดป้ายให้พนักงานรู้

งานรายชั่วโมงเก็บตกรายการที่หลุด

ถึงจะมี queue งานก็ยังหลุดได้ worker อาจหยุดระหว่าง deploy job อาจ retry ครบสามรอบตอนที่ LINE ล่มพอดี หรือ process ของ dispatchAfterResponse() ตายกลางทาง เพราะเราเก็บทุก request ไว้พร้อม processed_at งานเก็บตกจึงเหลือแค่หาแถวที่ยังไม่เสร็จแล้วส่งเข้า queue อีกรอบ

use App\Jobs\ProcessLineWebhook;
use App\Models\WebhookCall;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Artisan::command('line:sweep', function () {
    WebhookCall::query()
        ->where('source', 'line')
        ->whereNull('processed_at')
        ->where('attempts', '<', 5)
        ->where('created_at', '<', now()->subMinutes(15))
        ->lazyById()
        ->each(function (WebhookCall $call) {
            $call->increment('attempts');
            ProcessLineWebhook::dispatch($call);
        });
})->purpose('ส่ง LINE webhook ที่ยังประมวลผลไม่เสร็จเข้า queue อีกรอบ');

Schedule::command('line:sweep')->hourly()->withoutOverlapping();

เงื่อนไขสามข้อในคำสั่งนี้ตั้งใจใส่ทุกข้อ

  • เก่ากว่า 15 นาที ไม่ไปแย่งงานกับ job ที่ยังรออยู่ในคิว
  • attempts น้อยกว่า 5 ทุกรอบที่เก็บตกนับเพิ่มหนึ่ง แถวที่ครบห้ารอบแล้วยังไม่เสร็จคือรายการที่ต้องมีคนมาเปิดดู ทำเป็นหน้าในแอดมินหรือแจ้งเตือนแยกไว้
  • lazyById() ไล่ทีละช่วงตาม id ข้อมูลที่เปลี่ยนระหว่างวนจึงไม่ทำให้ข้ามแถว ต่างจาก each() ที่แบ่งหน้าด้วย offset

job ที่พังทุกรอบจะเติม failed_jobs จนดิสก์เต็ม

ทุกครั้งที่ job พังครบจำนวนรอบ Laravel เก็บ payload ของ job กับ exception พร้อม stack trace ลงตาราง failed_jobs หนึ่งแถว พังครั้งเดียวไม่เป็นไร แต่ job ที่พังทุกรอบ แล้วมีงานเก็บตกส่งกลับเข้าคิวไม่รู้จบ จะเติมแถวใหม่ไปเรื่อย ๆ ผมเคยเจอ failed_jobs ของระบบหนึ่งโตถึง 837,970 แถว ขนาด 6.3 GB ในราว 18 เดือน ต้นเหตุคือ job ของ webhook อ้างถึง user ที่มีคนลบออกไปแล้ว ทุก job จึงพังตั้งแต่บรรทัดแรก

job ที่ทนกว่านี้มีหลักไม่กี่ข้อ

  • อย่าให้ job พังเพราะข้อมูลที่แค่มีก็ดี ถ้า job อยากรู้แค่ว่าใครเป็นผู้สร้างรายการ ให้หาด้วย first() แล้วรับค่า null ได้ แทน firstOrFail() ที่ throw ทุกรอบที่หาไม่เจอ
  • model ใน job หายไปแล้ว ให้ทิ้ง job #[DeleteWhenMissingModels] ที่ใส่ไว้บน ProcessLineWebhook บอก Laravel ว่าถ้าแถวของ WebhookCall หายไปตอน job ยังรออยู่ในคิว ให้ลบ job ทิ้งเงียบ ๆ แทนที่จะ fail ใน Laravel เวอร์ชันก่อนหน้าใช้ property public $deleteWhenMissingModels = true;
  • งานเก็บตกต้องมีเพดาน อย่าง attempts < 5 ด้านบน
  • ตัด failed_jobs ทิ้งเป็นรอบ เก็บไว้แค่พอให้ทันเปิดดู
Schedule::command('queue:prune-failed --hours=168')->daily();

คำสั่งนี้ลบ failed job ที่เก่ากว่า 7 วัน แต่บน MySQL ที่ใช้ InnoDB ลบแถวด้วย DELETE แล้วไฟล์ของตารางไม่หดลง ถ้าตารางบวมไปแล้วและไม่ต้องการ job พวกนั้นอีก ใช้ TRUNCATE TABLE failed_jobs ถึงจะได้พื้นที่ดิสก์คืน

อีกเรื่องคือต้องเห็นตั้งแต่วันแรกที่เริ่มพัง เปิดดู php artisan queue:failed หรือหน้า Failed Jobs ของ Horizon เป็นประจำ หรือส่ง exception เข้า error tracker อย่าง watchtower-laravel ที่รวม exception เดิมเป็นรายการเดียวพร้อมจำนวนครั้ง exception ที่ขึ้นเป็นพันครั้งในวันเดียวจะเด่นขึ้นมาเอง

ทดสอบด้วย payload ที่เซ็นเอง

ใน test เราเซ็น body เองด้วย secret ของ test แล้ว fake คำตอบของ LINE ทุกกรณี Http::preventStrayRequests() ทำให้ test พังทันทีถ้ามี request ไหนหลุดไปหา LINE จริง

use App\Jobs\ProcessLineWebhook;
use App\Models\Slip;
use App\Models\WebhookCall;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Testing\TestResponse;

beforeEach(function () {
    config(['services.line.channel_secret' => 'test-secret']);
    Http::preventStrayRequests();
    Storage::fake();
});

function lineImageBody(string $messageId = '100001'): string
{
    return json_encode(['destination' => 'U0000', 'events' => [[
        'type' => 'message',
        'webhookEventId' => '01JTESTEVENT00000000000000',
        'deliveryContext' => ['isRedelivery' => false],
        'source' => ['type' => 'user', 'userId' => 'U0001'],
        'message' => ['type' => 'image', 'id' => $messageId, 'contentProvider' => ['type' => 'line']],
    ]]]);
}

function postToLineWebhook(string $body, ?string $signature = null): TestResponse
{
    $signature ??= base64_encode(hash_hmac('sha256', $body, 'test-secret', true));

    return test()->call('POST', '/webhooks/line', server: [
        'CONTENT_TYPE' => 'application/json',
        'HTTP_X_LINE_SIGNATURE' => $signature,
    ], content: $body);
}

test('a signed request is stored and queued', function () {
    Queue::fake();

    postToLineWebhook(lineImageBody())->assertOk();

    expect(WebhookCall::count())->toBe(1);
    Queue::assertPushed(ProcessLineWebhook::class);
});

test('a forged signature is rejected and nothing is stored', function () {
    postToLineWebhook(lineImageBody(), 'forged')->assertUnauthorized();

    expect(WebhookCall::count())->toBe(0);
});

test('the same event twice makes one slip and fetches the image once', function () {
    Http::fake(['api-data.line.me/*' => Http::response('jpeg', 200, ['Content-Type' => 'image/jpeg'])]);
    $call = WebhookCall::create(['source' => 'line', 'body' => lineImageBody()]);

    ProcessLineWebhook::dispatchSync($call);
    ProcessLineWebhook::dispatchSync($call);

    expect(Slip::count())->toBe(1);
    Http::assertSentCount(1);
});

test('an unsent image is skipped and the call is marked done', function () {
    Http::fake(['api-data.line.me/*' => Http::response(['message' => 'The content is gone'], 410)]);
    $call = WebhookCall::create(['source' => 'line', 'body' => lineImageBody()]);

    ProcessLineWebhook::dispatchSync($call);

    expect(Slip::count())->toBe(0)
        ->and($call->fresh()->processed_at)->not->toBeNull();
});

test ที่สามคือหัวใจของโพสต์นี้ รัน job เดิมสองรอบแล้วต้องได้สลิปใบเดียว และโหลดรูปจาก LINE ครั้งเดียว ควรเพิ่มอีกสองกรณีด้วย คือ LINE ตอบ 202 แล้ว job ต้อง throw และ line:sweep ไม่หยิบแถวที่ attempts ครบแล้ว

ตอบลูกค้ากลับใน LINE

บันทึกสลิปได้แล้ว ถ้าอยากแจ้งลูกค้าว่าร้านได้รับสลิปแล้ว ให้ส่ง push message ผ่าน Messaging API วิธีส่งจาก Laravel อยู่ใน LINE Notify ปิดแล้ว ส่งแจ้งเตือนเข้า LINE จาก Laravel ใช้อะไรแทน ถ้าอยากให้ลูกค้าเปิดดูสถานะสลิปเองใน LINE ใช้ LIFF ได้ ดูLogin ผ่าน LINE LIFF ใน Laravel ยังไงให้ปลอดภัย

อ่านต่อ

ล่าสุด

ดูทั้งหมด →