2023-09-04

NextJS app router 實作 body-parser 功能

NextJS app router 實作 body-parser 功能

承上篇,NextJS 的 app router 所使用的 request 和 response 分別是 NextRequest 和 NextResponse 類型;又分別是 JS 裡 Request 和 Response 類型的延伸。所以解析 http body 要遵循 Request 和 Response 類型的 api 用法。

底下的工具,借用 qs 套件的功能解析 urlencoded 的格式。實作了三個功能:

  1. getQueryString(): 解析 query string parameters
  2. getBodyData(): 解析表單資料(包含 multipart 格式)
  3. getMultipartData(): 解析上傳的表單
// *** request-parser.js import qs from "qs"; import fs from "node:fs/promises"; import { v4 as uuidV4 } from "uuid"; /** 解析 query string */ export function getQueryString(request) { return qs.parse(request.url.split("?")[1]); } /** 解析 urlencoded, json, multipart */ export async function getBodyData(request) { let result = {}; const method = request.method.toUpperCase(); const type = request.headers.get("Content-Type"); if (method !== "GET") { if (type === "application/x-www-form-urlencoded") { const txt = await request.text(); result = txt.length ? qs.parse(txt) : {}; } else if (type === "application/json") { try { result = await request.json(); } catch (ex) {} } else { const { fields } = await getMultipartData(request); if (fields) result = { ...fields }; } } return result; } /** 解析 multipart */ export async function getMultipartData( request, // NextRequest 物件 acceptedMimetypes = [], // 篩選類型設定,預設為不篩選 useUuidFilename = true, // 使用隨機 uuid 為主檔名 uploadDir = "./tmp" // 上傳的資料夾 ) { let result = { fields: undefined, files: undefined, error: undefined, }; const method = request.method.toUpperCase(); let type = request.headers.get("Content-Type"); if (!type) return result; type = type.split(";")[0]; // 取得 mimetype if (method !== "GET") { if (type === "multipart/form-data") { const formData = await request.formData(); try { await fs.access(uploadDir, fs.constants.F_OK); } catch (ex) { // 建立上傳的資料夾 await fs.mkdir(uploadDir, { recursive: true }); } for (const [k, v] of formData.entries()) { if (typeof v === "string") { // 處理文字欄位 result.fields = result.fields || {}; if (!result.fields[k]) { result.fields[k] = v; } else { if (result.fields[k] instanceof Array) { result.fields[k].push(v); } else { result.fields[k] = [result.fields[k], v]; } } } else if (v instanceof Blob) { // 處理檔案欄位 result.files = result.files || {}; const { size, type, name, lastModified } = v; if (acceptedMimetypes.length) { if (!acceptedMimetypes.includes(type)) { continue; } let filename = name; if (useUuidFilename) { let tmpName = name.toLowerCase(); let mainName = uuidV4(); let extName = ""; if (tmpName.indexOf(".") !== -1) { const frs = tmpName.split("."); extName = "." + frs[frs.length - 1]; } filename = mainName + extName; } const path = uploadDir + "/" + filename; const fileData = { size, type, lastModified, originalName: name, filename, path, }; if (!result.files[k]) { result.files[k] = fileData; } else { if (result.files[k] instanceof Array) { result.files[k].push(fileData); } else { result.files[k] = [result.files[k], fileData]; } } await fs.writeFile(path, v.stream()); } } } } } return result; }

以下是在 route.js 中的用法範例。

import { getBodyData, getQueryString, getMultipartData, } from "@/modules/request-parser"; const responseInit = { status: 200, headers: { "Content-Type": "application/json", }, } export async function GET(request) { let output = getQueryString(request); return new Response(JSON.stringify(output), responseInit); } export async function POST(request) { let body = await getBodyData(request); return new Response(JSON.stringify(body), responseInit); } export async function PUT(request) { let multi = await getMultipartData(request, ["image/jpeg"]); return new Response(JSON.stringify(multi), responseInit); }

2023-09-03

NextJS app router 實作 session 功能

NextJS app router 實作 session 功能

NextJS 的 app router 所使用的 request 和 response 分別是 NextRequest 和 NextResponse 類型;又分別是 JS 裡 Request 和 Response 類型的延伸。而不像 NodeJS 裡的 IncomingMessage 和 ServerResponse,有著彈性的功能。

比較大的問題是,ExpressJS 裡的 request 和 response 是從頭「流」到尾的機制。我們可以很方便在 middlewares 處理完資料,掛載資料到 request 或 response.locals 的屬性上。

然而 NextJS 的 middleware 卻不是這樣運作的,request 物件可能是透過 clone 的方式再往下一個階段傳遞。所以在 middleware 取得的 request 和在 route.js 裡拿到的 request 是不相同(不同參照)的物件。如此就無法藉由 request 物件來傳遞資料。沒有接收 response 物件的情況下,更難使用 response 物件。

後來才找到 Passing data from middleware to API route,利用 middleware 的 response.rewrite(),將資料放在 cookie 裡,往 end-point 傳遞。

shin-session-obj.js 為實作取得 session 物件的 getSessionObj() 函式。回傳值中若 new_session_id 不為空字串,表示要設定新的 session-id。為了簡明,裡面用 Object 來實作 session 的功能,當然你也可以 DB 或 files 來實作,不過就不在此說明。

// modules/shin-session-obj.js import { validate, v4 as uuidV4 } from "uuid"; let sessions; export function getSessionObj(request) { let new_session_id = ""; if (!request) return {}; // 沒有 request 時 let sid = request.cookies.get("shin-session-id")?.value; // 沒有 session_id 時, 或 session_id 不是 uuid 時, 設定新的 session-id if (!sid || !validate(sid)) { new_session_id = uuidV4(); sid = new_session_id; console.log({new_session_id}) } // 初始化 sessions if (!sessions) { sessions = {}; } // 初始化 session if (!sessions[sid]) { sessions[sid] = {}; } return { session: sessions[sid], new_session_id, }; }

在 middleware 會呼叫 getSessionObj() 以測試是否取得 new_session_id。若取得,則在複製的 request 物件裡設定 cookie 內容為我們要傳下傳遞的 session-id;同時,在 response 物件內設定要用戶端儲存的 session-id(儲存於 cookie)。

import { NextResponse } from "next/server"; import { getSessionObj } from "@/modules/shin-session-obj"; export async function middleware(nextRequest, nextFetchEvent) { const {session, new_session_id} = getSessionObj(nextRequest); if(new_session_id) { const clonedRequest = nextRequest.clone(); clonedRequest.headers.append("Cookie", `shin-session-id=${new_session_id}`); const response = NextResponse.rewrite(nextRequest.url.toString(), { request: clonedRequest, }); response.cookies.set({ name: "shin-session-id", value: new_session_id, path: "/", }); return response; } }

在任何的 route.js 就可以直接使用 getSessionObj() 取得對應的 session 物件。

import { getSessionObj } from "@/modules/shin-session-obj"; export async function GET(request) { const {session} = getSessionObj(request); if(session){ session.count = session.count || 0; session.count++; } const response = new Response(JSON.stringify({session}), { status: 200, headers: { "Content-Type": "application/json", }, }); return response; }

要注意的是此實作,主要是為了說明實作方式,在正式環境使用記憶體存放 session 資料有許多問題。最好還是以 DB 或檔案來存放 session 資料。

2023-04-08

建立 fastapi 專案開發環境

建立 fastapi 專案開發環境

這篇主要是建立基本環境,和執行環境的說明。

安裝所需套件

pip install fastapi pip install uvicorn # ASGI server pip install gunicorn # WSGI server

在專案中撰寫測試的程式:

from fastapi import FastAPI app = FastAPI() @app.get('/hello') def hello(): return {"say": "hello"}

直接使用 uvicorn 執行:

uvicorn main:app --reload --port 8888

執行後可以在 http://localhost:8888/docs 看到 openAPI document。

gnuicorn 搭配 uvicorn 執行:

gunicorn main:app -b 0.0.0.0:8888 -k uvicorn.workers.UvicornWorker # 下式為背景執行 gunicorn main:app -b 0.0.0.0:8888 -k uvicorn.workers.UvicornWorker --daemon # 背景執行時若要停下來,可以使用下式查看 pid 再使用 kill 移除 proccess ps -ef | grep gunicorn

如果環境中有安裝 PM2 (Nodejs 行程管理工具) 也可以使用 PM2 啟動行程。

pm2 --name=gunicorn start "gunicorn main:app -b 0.0.0.0:8888 -k uvicorn.workers.UvicornWorker"

2023-04-07

使用 conda 建立 python 虛擬環境

使用 conda 建立 python 虛擬環境

查看所有的虛擬環境

conda env list

使用預設的 python 版本建立環境,下式的 myenv 為虛擬環境的名稱。

conda create -n myenv

使用指定的 python 版本建立環境。

conda create -n myenv python=3.10

啟用某個環境

conda activate myenv # conda deactivate #退出某個環境 pip install --upgrade pip # 確保 pip 是最新的版本

安裝套件

pip install fastapi pip install uvicorn # pip install python-multipart # uvicorn 安裝時會自動安裝 python-multipart

2023-03-26

將 React app 和 Node.js 寫的 API 放在同一台伺服器

將 React app 和 Node.js 寫的 API 放在同一台伺服器

要前後端分離,同時要將 React app 和 API 放在同一台伺服器相同的 port,以避免誇來源的問題。

此時可以用一台 Node express server 解決。

React app 發佈至 Express server 靜態資料夾

第一種方式是,把發佈後的 React 放在 build 資料夾(別的名稱也可以),並將 build 設定為靜態資料夾。

其餘後端的 API 可以定義在服務靜態 html 檔之前。

const express = require("express"); const fs = require("fs/promises"); const app = express(); app.use(express.static("build")); let html = ""; fs.readFile(__dirname + "/build/index.html").then((txt) => { html = txt.toString(); }); // app.use('/api', YOUR_ROUTER); // 可以把 api 掛在這裡 app.use("/api", (req, res) => { res.json({ name: "shinder", say: "hello" }); }); app.use((req, res) => { res.send(html); }); const port = 3005; app.listen(port, () => { console.log(`啟動 ${port}`); });

使用反向代理伺服器

另一種做法是使用反向代理伺服器,可以利用 http-proxy-middleware 套件,建立 proxy。

const express = require("express"); const { createProxyMiddleware } = require("http-proxy-middleware"); const app = express(); app.use(express.static("build")); // app.use('/api', YOUR_ROUTER); // 可以把 api 掛在這裡 app.use("/api", (req, res) => { res.json({ name: "shinder", say: "hello" }); }); app.use( "/", createProxyMiddleware({ target: "http://localhost:3000", changeOrigin: true, }) ); const port = 3005; app.listen(port, () => { console.log(`啟動 ${port}`); });

2023-02-10

MacOS Ventura ssh 問題

Mac mini 升級 Ventura 第一個發現的災難,無法使用原先設定好的 auth key 自動登入

基本上是修改設定檔:
sudo nano /etc/ssh/ssh_config

在設定檔後加入這兩行:
HostkeyAlgorithms +ssh-rsa
PubkeyAcceptedAlgorithms +ssh-rsa

2022-06-29

限制 CloudFront CDN 的讀取

限制 CloudFront CDN 的讀取

使用 CloudFront 的 CDN 協助非公開 S3 資料的傳輸 (尤其是影片檔和大量的圖檔)

這兩篇介紹的很詳儘:

https://deliciousbrains.com/wp-offload-media/doc/cloudfront-setup/ https://deliciousbrains.com/wp-offload-media/doc/serve-private-media-signed-cloudfront-urls/

A. Create a free HTTPS Certificate

建立免費的 SSL/TLS 認證,建立此認證的用意是讓 CloudFront 可以使用,並指定你自己的網域而不是 CloudFront 提供的預設網域。 注意事項: 1.在 Certificate Manager 建立認證。 2. 要指定區域為 us-east-1 (N. Virginia) 3. Request a public certificate 4. 網域使用包含子網域的設定,例如「.shinder.cc」 5. 使用 DNS Validation 的方式驗證網域

B. Create an Amazon CloudFront Origin Access Identity

建立 OAI 是讓 CloudFront 可以透過 OAI 去讀取 S3 的資料,並同步。這個動作要在設定 CloudFront distribution 前設定,才會是最方便處理的。OAI 的位置在 CloudFront 頁面左側 "Security" > "Origin access identity"。

C. Create an Amazon CloudFront distribution

建立時指定 S3 bucket,並指定使用 OAI,更新 S3 bucket 存取政策。 設定自訂的網域 Alternative domain name (CNAME),並輸入設定第一個動作申請的 SSL 認證。

D. Add a Custom Domain (CNAME) to the distribution

設定 DNS,以自訂的網域指向 CloudFront 預設的網域。

E. Create an Amazon CloudFront Key Pair

在主帳號的下拉選單,點選 Security Credentials 進入 CloudFront Key Pairs 分頁,以建立 key pair。 此組 key pair 用來產生可讀取 CDN 的網域或 cookie。

F. Create a New Amazon CloudFront Behavior for Private Media

在 CloudFront distribution 項目內設定 behavior 設定可讀取的路徑(資料夾前綴), Restrict Viewer Access

G. 使用 aws-cloudfront-sign

最後使用 aws-cloudfront-sign 套件,來產生簽證的 URL。

2022-03-16

無法使用 FileZilla 透過 sftp 連至遠端 ubuntu

 參考
Filezilla sftp Error: Disconnected: No supported authentication methods available (server sent: publickey)

解決方式,以 ssh 登入遠端
查看檔案
/etc/ssh/sshd_config

裡面的設定修改為
PasswordAuthentication yes

重啟伺服器

service sshd restart

或者重啟 ubuntu

2021-10-29

SQL 兩點經緯度求距離

SQL 兩點經緯度求距離

參考資料 Fastest Way to Find Distance Between Two Lat/Long Points裡的 Binary Worrier 解法。

使用 MariaDB 10,測試的 DB table 為 locations

CREATE TABLE `locations` ( `id` int(11) NOT NULL, `name` varchar(255) NOT NULL, `lat` double NOT NULL COMMENT '緯度', `lng` double NOT NULL COMMENT '經度' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO `locations` (`id`, `name`, `lat`, `lng`) VALUES (1, '南勢角捷運站', 24.990508728072932, 121.509155455015), (2, '景安站', 24.993901994056493, 121.50479966768725), (3, '台大門口', 25.016772139171792, 121.53351504607843); ALTER TABLE `locations` ADD PRIMARY KEY (`id`); ALTER TABLE `locations` MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4;
-- 測試用的 SQL -- 以南勢角捷運站為參考位置,求 3 公里以內的景點 SELECT *, ( 6371 * ACOS( COS( RADIANS(24.990508728072932) ) * COS( RADIANS( `lat` ) ) * COS( RADIANS( `lng` ) - RADIANS(121.509155455015) ) + SIN( RADIANS(24.990508728072932) ) * SIN( RADIANS( `lat` ) ) ) ) AS `distance` from `locations` HAVING `distance` < 3 OR `distance` IS NULL ORDER BY `distance`;

2021-10-16

Nginx 作為均衡負載伺服器 (HTTP load balancer)

Nginx 作為均衡負載伺服器 (HTTP load balancer)

這裡直接改 /etc/nginx/sites-enabled/default 的設定,其中 upstream 用來定義工作的群組。

upstream myapp1 { ip_hash; server 10.140.0.2:3000 weight=1; server 10.140.0.3:3000 weight=2; } server { listen 80; location / { proxy_pass http://myapp1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

ip_hash 是決定使用 ip 雜湊運算,讓用戶端的連線固定連到某一台,這樣有利於 session 或 cookie 的運作。

weight 的設定為權重,預設為 1。目前的設定,在沒有 ip_hash 的設定時,3 requests 會有 1 個給 10.140.0.2,另外 2 個給 10.140.0.2。有 ip_hash 的設定時,則是以用戶來區分。

FB 留言