laravel + vue3 改成 Sqids 方式建立短網址

實作網址: https://twingo.polinwei.com/pages/ShortUrl

image 14

使用套件說明:

https://sqids.org/zh-tw/php

Sqids 是一個開源函式庫,可以從數字生成短的唯一識別碼。這些識別碼是 URL 安全的,可以編碼多個數字,並且不包含常見的髒話。

Sqids用途

Sqids 的主要用途是單純形象化的。如果您想在應用中使用識別碼 ID代替數字,Sqids 可能是一個不錯的選擇。

適用於

  • 編碼主鍵如果使用關聯式資料庫
  • 節省資料庫查詢通過編碼多個對象
  • 臨時登錄令牌無髒話,URL 安全

不適用於

  • 敏感數據這不是加密函式庫
  • 用戶識別碼 ID如果有人找到編碼字母表,會暴露用戶計數

短網址防猜測的套件比較

HashidsSqids
專案狀態停滯✅ 官方新方案
安全性尚可✅ 改進碰撞與猜測
編碼一致性可能差異✅ 跨語言一致
官方推薦

安裝 sqids ( Laravel )

PHP
composer require sqids/sqids

設定 Sqids (config/sqids.php)

PHP
<?php

return [
    'alphabet' => env(
        'SQIDS_ALPHABET',
        'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    ),
    'min_length' => env('SQIDS_MIN_LENGTH', 6),
];

建立 Sqids Service(關鍵抽象層)

這一步很重要:避免未來再換編碼器時大改程式

PHP
<?php

namespace App\Services;

use Sqids\Sqids;

class SqidsService
{
    protected Sqids $sqids;

    public function __construct()
    {
        $this->sqids = new Sqids(
            config('sqids.alphabet'),
            config('sqids.min_length')
        );
    }

    public function encode(int $id): string
    {
        return $this->sqids->encode([$id]);
    }

    public function decode(string $code): ?int
    {
        $numbers = $this->sqids->decode($code);
        return $numbers[0] ?? null;
    }
}

ShortUrl Model(改用 Sqids)

不存 code、只存 id

PHP
use App\Services\SqidsService;

class ShortUrl extends Model
{
    protected $fillable = [
        'original_url',
        'domain',
        'expired_at',
    ];

    protected $appends = [
        'code',
        'short_url',
    ];

    public function getCodeAttribute(): string
    {
        return app(SqidsService::class)->encode($this->id);
    }

    public function getShortUrlAttribute(): string
    {
        $domain = $this->domain ?? config('app.url');
        return rtrim($domain, '/') . '/' . $this->code;
    }
}

Redirect Controller(Sqids 解碼)

PHP
use App\Models\ShortUrl;
use App\Services\SqidsService;
use Illuminate\Support\Facades\Cache;

class RedirectController extends Controller
{
    public function __invoke(
        string $code,
        SqidsService $sqids
    ) {
        $id = $sqids->decode($code);
        abort_if(!$id, 404);

        $short = Cache::remember(
            "short_url:{$id}",
            3600,
            fn () => ShortUrl::findOrFail($id)
        );

        if ($short->expired_at && now()->gt($short->expired_at)) {
            abort(410);
        }

        Cache::increment("short_url:{$id}:clicks");

        return redirect()->away($short->original_url);
    }
}

定期回寫 DB(Queue / Schedule)

建立 Job 類別

首先,需要建立處理邏輯的 Job。在終端機執行:建立 Job: SyncShortUrlClicksJob

Bash
php artisan make:job SyncShortUrlClicksJob

這會在 app/Jobs/SyncShortUrlClicks.php 產生檔案。請在 handle 方法中撰寫程式邏輯,程式碼內容如下:

PHP
<?php

namespace App\Jobs;

use App\Models\Util\ShortUrl;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

// 同步短網址點擊次數的工作類別
class SyncShortUrlClicksJob implements ShouldQueue
{
    use Queueable;

    /**
     * Create a new job instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        // 分批處理短網址,將快取中的點擊次數同步到資料庫
        ShortUrl::chunk(100, function ($shortUrls) {
            foreach ($shortUrls as $shortUrl) {
                $cacheKey = "short_url:{$shortUrl->id}:clicks";
                $cachedClicks = cache()->get($cacheKey, 0);
                if ($cachedClicks > 0) {
                    $shortUrl->increment('clicks', $cachedClicks);
                    cache()->forget($cacheKey);
                }
            }
        });
    }
}

設定排程程式碼

在 Laravel 11 與 12 中,所有的排程任務都定義在 routes/console.php 檔案中,不再像舊版本需要去修改 app/Console/Kernel.php。打開 routes/console.php,加入程式碼:

PHP
use App\Jobs\SyncShortUrlClicksJob;
use Illuminate\Support\Facades\Schedule;

// 每分鐘執行一次 SyncShortUrlClicks Job
Schedule::job(new SyncShortUrlClicksJob)->everyMinute();

或是

PHP
use App\Jobs\SyncShortUrlClicksJob;
use Illuminate\Support\Facades\Schedule;
use Illuminate\Support\Facades\Artisan;

// 1. 定義 Artisan 指令
Artisan::command('shorturl:sync-clicks', function () {
    // 這裡可以直接派發 Job
    SyncShortUrlClicksJob::dispatch();
    $this->info('點擊數據同步 Job 已派發!');
})->purpose('同步短網址的點擊次數');

// 2. 設定排程:使用指令名稱呼叫
Schedule::command('shorturl:sync-clicks')->everyMinute();  

建議

如果你希望平常也能手動輸入指令來同步數據(例如:php artisan shorturl:sync-clicks),那麼使用 方法一 是最佳選擇。它讓你的開發與維運(Debug)變得非常容易。

本地端測試排程

在正式環境中,需要設定伺服器的 Cron Job。但在開發階段,可以使用以下指令手動觸發排程器,觀察它是否正確執行:

Bash
php artisan schedule:work

執行後,排程器會每分鐘自動呼叫一次該 Job。

正式環境部署 (Cron Job)

為了讓排程在伺服器上自動運作,你需要在伺服器的 Crontab 中加入這一行:

Bash
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

常見配置選項

如果希望排程更具彈性,可以參考下表調整方法:

方法說明
->everyFiveMinutes()每 5 分鐘執行一次
->hourly()每小時執行一次
->onOneServer()如果有多台伺服器,僅在其中一台執行(避免重複)
->withoutOverlapping()確保前一個任務完成前,不會啟動下一個相同的任務

短網址轉址與 SqidsService 並存在 RedirectController 裡過渡期作法

一、結論先講(重點)

可以在 RedirectController 同時支援:

  • 舊的:ShortUrl::where('code', $code)
  • 新的:Sqids(由 id encode / decode)

這種做法通常用在:

  • code → Sqids 過渡期
  • 系統已有舊短網址
  • 不中斷服務、不中斷 SEO

正確做法

新短網址 → Sqids
舊短網址 → code 欄位

推薦的 RedirectController 寫法(並存版)

條件假設

  • 舊資料表有 code 欄位
  • 新資料用 id + Sqids
  • Sqids 已寫成 SqidsService 或 trait
PHP
use App\Http\Controllers\Controller;
use App\Models\Util\ShortUrl;
use App\Services\SqidsService;
use Illuminate\Support\Facades\Cache;

class ShortUrlRedirectController extends Controller
{
    
    // 短網址重定向, 將物件 controller 當作為 function 使用
    public function __invoke(string $code, SqidsService $sqids)
    {
        $shortUrl = null;

        /**
         * ① 先嘗試用 Sqids 解碼(新制)
         */
        if ($id = $sqids->decode($code)) {
            $shortUrl = Cache::remember(
                "short_url:id:{$id}",
                3600,
                fn () => ShortUrl::find($id)
            );
        }

        /**
         * ② Sqids 失敗 → fallback 舊 code
         */
        if (!$shortUrl) {
            $shortUrl = Cache::remember(
                "short_url:code:{$code}",
                3600,
                fn () => ShortUrl::where('code', $code)->first()
            );
        }

        abort_if(!$shortUrl, 404);

        /**
         * ③ 過期檢查
         */
        if ($shortUrl->expired_at && now()->gt($shortUrl->expired_at)) {
            abort(410);
        }

        /**
         * ④ Redis 點擊統計(統一 key)
         */
        Cache::increment("short_url:{$shortUrl->id}:clicks");

        return redirect()->away($shortUrl->original_url);
        
        
    }
}

檢查順序的說明

為什麼 Sqids 要先試

  • Sqids 短碼 不可逆、不可猜
  • 舊 code 可能被猜測
  • 新制優先 → 安全性高

為什麼 fallback 不直接 firstOrFail()

PHP
ShortUrl::where('code', $code)->firstOrFail(); ❌

因為:

  • 你已經有 Sqids 嘗試
  • abort 應該只做一次
  • 程式可讀性更好

改成 Trait 寫法(乾淨、安全)

可以改成 trait 嗎?可以,完全沒問題。

但要注意一件事:

Sqids 需要設定(alphabet / min_length)
trait 沒有 constructor 注入,因此要自己處理初始化方式。

Bash
php artisan make:trait HasSqids.php

產生 app/Traits/HasSqids.php 後, 撰寫程式內容如下

PHP
<?php

namespace App\Traits;

use Sqids\Sqids;

trait HasSqids
{
    protected function sqids(): Sqids
    {
        static $sqids = null;

        if ($sqids === null) {
            $sqids = new Sqids(
                config('sqids.alphabet'),
                config('sqids.min_length')
            );
        }

        return $sqids;
    }

    protected function encodeSqid(int $id): string
    {
        return $this->sqids()->encode([$id]);
    }

    protected function decodeSqid(string $code): ?int
    {
        $numbers = $this->sqids()->decode($code);
        return $numbers[0] ?? null;
    }
}

為什麼這樣寫?

  • static cache:避免每次 new Sqids(效能佳)
  • 不依賴 DI container
  • 可在 Model / Controller / Job 使用

Model 使用 Trait(取代 Service)

App\Models\ShortUrl.php

PHP
use App\Traits\HasSqids;

class ShortUrl extends Model
{
    use HasSqids;

    protected $fillable = [
        'original_url',
        'domain',
        'expired_at',
    ];

    protected $appends = ['code', 'short_url'];

    public function getCodeAttribute(): string
    {
        return $this->encodeSqid($this->id);
    }

    public function getShortUrlAttribute(): string
    {
        $domain = $this->domain ?? config('app.url');
        return rtrim($domain, '/') . '/' . $this->code;
    }
}

Controller 使用 Trait

RedirectController.php

PHP
use App\Traits\HasSqids;
use Illuminate\Support\Facades\Cache;

class RedirectController extends Controller
{
    use HasSqids;

    public function __invoke(string $code)
    {
        $id = $this->decodeSqid($code);
        abort_if(!$id, 404);

        $short = Cache::remember(
            "short_url:{$id}",
            3600,
            fn () => ShortUrl::findOrFail($id)
        );

        if ($short->expired_at && now()->gt($short->expired_at)) {
            abort(410);
        }

        Cache::increment("short_url:{$id}:clicks");

        return redirect()->away($short->original_url);
    }
}

常見陷阱(避免踩雷)

❌ 在 trait 裡用 constructor

PHP
public function __construct() ❌

❌ 每次 encode 都 new Sqids

PHP
new Sqids(...) ❌

❌ 把 trait 當成 DI

PHP
$this->sqidsService

以上就是過程,有需要討論再留言囉!!

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *


內容索引