Next.js雖然是一個frontend的框架,但也支援處理一些server request,這在Next.js 中被稱為Route Handlers,本篇文章首先會簡單介紹Next.js的API路由和其功能。然後,文章講解了Next.js的API路由不支援TypeScript的裝飾器模式的問題,並對裝飾器模式做了一定的介紹。最後,筆者將會提出了一種使用高階函數模擬裝飾器模式並在API路由上添加速率限制的解決方案。
本篇是針對Next.js 13之後推出的app router而非舊版的page router。在App router下要創建API路由時,我們只需要在頁面目錄中建立一個API目錄,然後在API目錄下建立 route.ts 文件。每個文件都相當於一個API endpoint,Next.js會針對其 export 出的 GET 、 POST 、 PATCH 、 DELETE 來處理這些request,詳細的文件可以參考官方文件。
例如上方的資料夾架構如果我在 route.ts 這個檔案裏面 export 了以下的 GET function :
export async function GET() {
const redisCheck = await check();
return Response.json(
{
redisCheck,
},
{ status: 200 },
);
}這樣當我們發 GET 請求到 /api/health-check 的時候就會拿到一個有redisCheck的response。配合新的server component或server action讓使用Next.js的開發者可以無需另外架設backend直接完成一個全端專案(有種回到php時代的感覺)。
而本站就是用Next.js進行開發的,在做某些server端功能的時候,想說需要限制使用者一段時間內的使用次數,故而想要在route handlers 上面加上 rate limit 的限制。這邊主要是想用 user 的 ip 來做為身分識別,並搭配 redis 作為 cache 紀錄user的使用量。所以這個rateLimit的基本邏輯是先從 header 裡面拿到 user 的 ip 再與 method 與 endpointPath 組成 key 並呼叫 redis 的 incr 去增加該筆 key 的值,如果 increase 後的值為1代表該筆key是新加入的,則幫他設定一個expire time,之後檢查如果該筆紀錄已經超過設定的最大值,則提早 return 429代表超過了rate limit。
async function rateLimit (req: Request, max: number, window: string) {
const ip = req.headers.get('x-forwarded-for') || 'no-ip';
const key = `${ip}:${method}:${endpointPath}`;
const count = await incr(key);
if (count === 1) {
await expire(key, parseTime(window));
}
if (count > max) {
return new Response('Rate limit exceeded', { status: 429 });
}
// rest of codes
};其實上述了邏輯稱為 Fix window counter 並不是一個好的window algorithm,例如設定 max = 10 、 window = 1min ,在第一個window的第1秒發一個request觸發counter開始計時,到第59秒時在連續發9個request,而60秒counter計時完畢,又可重新發送時,再發滿下一個window的10個request,結果就是在2秒內發了19個request。不過本篇主要是想要將rate limie函數以decorator的方式加到Nest.js的route handler上,所以先暫且用這個簡單的 window algorithm。
另外ip位址的取得在一般情況下可以從 req.ip 拿到,但如果有經過nginx或其他的proxy server,user的真實ip位置就會被設到 x-forwarded-for 這個header。到這邊為止,其實rate limit的功能已經大致完成了,但如果每次需要用的時候都要copy paste這個邏輯過去,或是在handler的最前端加上這個function,感覺有點不太好用,在維護性上面也不高。
由於之前的server端都是用 Nest.js 去寫的,所以很自然地想到了像是 useGuard 之類的decoration pattern,關於decorator的介紹可以看這篇(typescript decorator),利用裝飾器的好處是可以簡單地加上或移除掉該限制,不過由於decorator只能加在class上面,所以需要稍微改寫一下,將原本的 GET 、 POST 等function,包進一個class中。
首先先定義一個抽象的calssController來規範之後的handler要有甚麼method,並加上default的method
export abstract class Controller {
public static async GET(req: Request): Promise<Response> {
// Default implementation
return new Response('', { status: 404 });
}
public static async POST(req: Request): Promise<Response> {
// Default implementation
return new Response('', { status: 404 });
}
public static async PUT(req: Request): Promise<Response> {
// Default implementation
return new Response('', { status: 404 });
}
public static async DELETE(req: Request): Promise<Response> {
// Default implementation
return new Response('', { status: 404 });
}
static readonly path: string;
}由於這邊只是為了要利用到class可以加decorator的特性,並不需要實例化,所以Controller裡面的method跟property都是static的,接著修改前面的RateLimit function如下
function RateLimit({ max, window }: Options) {
return function (target: typeof Controller, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (req: Request) {
const ip = req.headers.get('x-forwarded-for') || 'no-ip';
const key = `${ip}:${propertyKey}:${target.path}`;
const count = await incr(key);
if (count === 1) {
await expire(key, parseTime(window));
}
if (count > max) {
return new Response('Rate limit exceeded', { status: 429 });
}
const result = await originalMethod.call(this, req);
return result;
};
};
}採用decorator factory的方式,接收max跟window作為參數,其中descriptor.value是裝飾對象的屬性值,到時候這個decorator會裝飾在GET、POST...等function上,這時候value就是原本的function,先把它存起來,接者改寫原本的function,在其中加入前述的rate limit logic,其中propertyKey是裝飾對象的名稱,也就是GET、POST...等;而target是被裝飾的類別,也就是到時候會繼承Controller的subclass,我們在Controller中有定義了一個static的path property,就用做分別不同endpoint的名稱。
如果rate limit的條件通過了,就會呼叫原本存起來的方法originalMethod並回傳結果,反之則early return 429狀態碼回去。
這時我們就可以將這個decorator用在handler上面:
// src/api/plaground/rate-limit/route.ts
import { Controller } from '@/app/api/utils/Controller';
import { RateLimit } from '@/app/api/utils/RateLimit';
class RouteHandler extends Controller {
static path = 'playground/rate-limit';
@RateLimit({ max: 10, window: '1m' })
public static async GET() {
return Response.json('ok', { status: 200 });
}
}
export const GET = RouteHandler.GET;
export const dynamic = 'force-dynamic';
下面是一個playground,該endpoint設定了1分鐘內最多只能存取10次,timer會在首次發請求時開始計時,並記錄目前發送請求的次數,可以試試看10次之後的request會拿到什麼結果、如過離第一次發送請求超過了60秒,再發送會拿到什麼結果吧!