為何Elysia
這陣子都在玩 serverless,之前都是租主機或是自架,排除錢,麻煩的就是處理devops的人力與時間,接觸到 cloudfalre之後發現他有一堆服務對個人而言幾乎是免費,就在找適合寫API的框架,畢竟過去用 serverless的經驗,像是 aws lambda 都是一個function一個endpoint讓人煩躁,一開始用 clodfalre的兒子 hono,寫起來很像 express,遇到權限也是走類似middleware的方式。
看到 elysiajs,就被他 chain的寫法吸引,也非常 functional,於是就被 Laravel 那種懶人 pattern 框住,想知道最佳實踐的 project folder structure,結果...沒有。
學習最好的方式就是開始動手,然後再開始分層設計。
起手式
可以先開啟一個檔案都放在一塊。
import { Elysia } from 'elysia'
new Elysia()
.get('/', 'Hello Elysia')
.get('/user/:id', ({ params: { id }}) => id)
.post('/form', ({ body }) => body)
.listen(3000)
如果越寫越多怎麼辦?
拆route(controller)
elysia的世界,每個東西都是 component,所以我們只要 **new Elysia()**之後就可以拆分。
新route
開一個檔案 /routes/new.ts 放 route。
export const newController = new Elysia({
"prefix": "new"
})
.get('/', 'Hello Elysia')
.get('/user/:id', ({ params: { id }}) => id)
.post('/form', ({ body }) => body)
prefix 是這個 route的前綴, API就會是在 /new 底下,此時export newController 出去。
use component
原來的地方
import { Elysia } from 'elysia'
import { newController } from "./routes/new"
new Elysia()
.use(newController)
.listen(3000)
JWT and Guard
以上其實還不夠powerfull,可以看看不用 middleware 方式怎麼寫一個 Guard與檢查 header上的 token。
檢查JWT
一樣開一個檔案來實作檢查 JWT 的 Elysia component。
import jwt from "@elysiajs/jwt";
import Elysia from "elysia";
export const adminJwtCheck = new Elysia()
.use(
jwt({ name: "adminJwt", secret: Bun.env.ADMIN_JWT_SECRET ?? "verysecret" }),
)
.derive(
{
as: "global",
},
async ({ request, adminJwt }) => {
const auth = request.headers.get("authorization");
const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null;
if (!token) return { admin: null };
try {
const jwtData = await adminJwt.verify(token);
if (!jwtData) return { admin: null };
return {
admin: {
id: jwtData.id,
name: jwtData.name,
email: jwtData.email,
},
};
} catch (error) {
return { admin: null };
}
},
);
來拆解第一個 .use
JWT
import jwt from "@elysiajs/jwt";
new Elysia()
.use(
jwt({ name: "adminJwt", secret: Bun.env.ADMIN_JWT_SECRET ?? "verysecret" }),
)
宣告一個 jwt instance,名稱為 adminJwt ,可以在之後的 chain 呼叫。
secret 就是 JWT簽署的密鑰,Bun可以用這種方式呼叫到 .env檔案內的設定
Derive
Append new value to context directly before validation. It's stored in the same stack as transform.
Unlike state and decorate that assigned value before the server started. derive assigns a property when each request happens. Allowing us to extract a piece of information into a property instead.
我們希望他是全域的,可以幫我在驗證前解析 request header上的token。
.derive(
{
as: "global",
},
async ({ request, adminJwt }) => {
const auth = request.headers.get("authorization");
const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null;
if (!token) return { admin: null };
try {
const jwtData = await adminJwt.verify(token);
if (!jwtData) return { admin: null };
return {
admin: {
id: jwtData.id,
name: jwtData.name,
email: jwtData.email,
},
};
} catch (error) {
return { admin: null };
}
},
);
adminJwt 就是在上一個 JWT 定義給 chain可以呼叫的 JWT instance,用於 verify token,解出 payload,並且回傳 payload 裡面資料,讓我在後續的 chain 可以得到 user。
Guard
這邊來看一下如何結合 JWT check和 Guard。在此之前還要介紹另一個 chain method。
Decorate
decorate assigns an additional property to Context directly at call time.
import { Elysia } from 'elysia'
class Logger {
log(value: string) {
console.log(value)
}
}
new Elysia()
.decorate('logger', new Logger())
// ✅ defined from the previous line
.get('/', ({ logger }) => {
logger.log('hi')
return 'hi'
})
可以再進去 .get() 之前把 context 加入一個變數用。
回到如果檢查 JWT與保護 route
export interface AdminJwtPayload {
readonly id: string;
readonly name: string;
readonly email: string;
}
export const AdminController = new Elysia({
prefix: "/admins",
tags: ["Admin"],
})
.use(adminJwtCheck)
.decorate("admin", {} as AdminJwtPayload)
.guard(
{
beforeHandle({ admin }) {
if (!admin) error(401, { message: "Unauthorized" });
},
},
(app) =>
app
.get(
"me",
async ({ admin }) => {
return admin;
},
{
detail: {
summary: "Get current admin",
description: "Get current admin's information",
security: [
{
adminAuth: [],
},
],
},
response: {
200: AdminMeResponse,
401: UnauthorizedResponse,
},
},
),
use JWT check
此時 use adminJwtCheck,就能夠將 request的 token 檢查,回傳 admin,到 chain的 context,可能是有東西,或是 null。
.use(adminJwtCheck)
下方
.decorate("admin", {} as AdminJwtPayload)
為了可以重複使用 JWT check,我只好違反 Best Practice,以至於後面的 chain method拿不到應該回傳的 admin,我就做了一個騙騙 typescript的 type safe檢查,有更好的方式,可以留言給我><。
guard
這時候把要保護的 route 放到 guard 的底下,讓他先處理 beforeHandle,再決定要不要讓他往下走。
有看到嗎? admin 就這樣出現了。
如果 admin不存在,就直接用 error 出去,如果有,代表 JWT check 的token解出的 payload 被正確的解出。
.guard(
{
beforeHandle({ admin }) {
if (!admin) error(401, { message: "Unauthorized" });
},
},
(app) =>
app
.get(
"me",
async ({ admin }) => {
return admin;
},
{
detail: {
summary: "Get current admin",
description: "Get current admin's information",
security: [
{
adminAuth: [],
},
],
},
response: {
200: AdminMeResponse,
401: UnauthorizedResponse,
},
},
),
admin 回傳 null,是FP裡面一個很好的做法,讓 JWT check 成為一個 calculations 而非 action。
補充 FP
FP 將程式分為
- Actions
指的是需要與外部世界互動的操作,通常會產生副作用(side effect),例如改變狀態或執行 I/O 操作。 - Calculations
是純函數,僅依賴輸入參數來計算結果,不會有任何副作用。 - Data
靜態的資訊或事實,用於描述系統的狀態或提供計算所需的基礎資料。
需要減少 Actions,讓程式有更多的 Calculations和 Data,過往用 middleware方式可能在裡面就 throw Error,這樣我們還得換檔案才能知道發生什麼事情,現在使用 Calculations,我們可以在主要的檔案裡面可以判讀回來的結果,做必要的處理。
最後
又廢話寫了一大堆,但如果仍讓看到文章的人能夠嘗試 Elysia 這好東西,也就足夠,另外學習FP的觀念和操作同時也能讓寫code的功力更上一層樓。
結果我沒講到寫驗證和產文件 (swagger)....掉拍了
更新!可以參考這篇 優雅的三拍,驗證格式與寫好文件 - elysiajs
文字工作者,寫作時間常常在人類與電腦之間拉鋸,相信閱讀,相信文字與思想所構築的美麗境界