2023-10-22

RemixJS 2 處理從用戶端來的資料

RemixJS 2 處理從用戶端來的資料

handle-request-data.ts 包含了幾個處理要求端來的資料,後來改用 qs 套件比較符合 expressjs 的風格。 其中處理上傳檔案的部份為存成檔案,目前建議存到雲端服務,例如 AWS S3。 這些函式同樣可以使用在 NextJS app router 上。

// *** handle-request-data.ts import qs from "qs"; import fs from "node:fs/promises"; import { v4 as uuidV4 } from "uuid"; export const getQueryStringObject:any = (request: Request) => { const q = request.url.indexOf("?"); if (q < 0) { return {}; } else { return qs.parse(request.url.slice(q+1)); } // *** 以下為使用 Object 的版本 // const url = new URL(request.url); // return Object.fromEntries(url.searchParams); }; export const getBodyObject = async (request: Request) => { const contentType = request.headers.get("Content-Type")?.split(";")[0]; switch (contentType) { case "multipart/form-data": const formData:any = await request.formData(); return qs.parse(new URLSearchParams(formData).toString()); // *** 以下為使用 Object 的版本 // return Object.fromEntries(formData); case "application/x-www-form-urlencoded": const txt = await request.text(); return qs.parse(txt); // *** 以下為使用 Object 的版本 // const usp = new URLSearchParams(txt); // return Object.fromEntries(usp); case "application/json": const json = await request.json(); return json; } }; type FileDataType = { size: number; type: string; lastModified: number; originalName: string; filename: string; path: string; }; type MultipartDataResultType = { fields: { [index: string]: string | string[] }; files: { [index: string]: FileDataType | FileDataType[] }; error: any; }; // *** 處理檔案上傳 export async function getMultipartData( request: Request, acceptedMimeTypes = ["image/jpeg", "image/png"], // 篩選類型設定 useUuidFilename = true, // 使用隨機 uuid 為主檔名 uploadDir = "./tmp" // 上傳的資料夾 ) { let result: MultipartDataResultType = { fields: {}, files: {}, error: {}, }; 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()) { console.log({ k, v }); if (typeof v === "string") { // 處理文字欄位 if (!result.fields[k]) { result.fields[k] = v; } else { if (result.fields[k] instanceof Array) { const strArray = result.fields[k] as string[]; strArray.push(v); } else { const val = result.fields[k] as string; result.fields[k] = [val, 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: FileDataType = { size, type, lastModified, originalName: name, filename, path, }; if (!result.files[k]) { result.files[k] = fileData; } else { if (result.files[k] instanceof Array) { const fdArray = result.files[k] as FileDataType[]; fdArray.push(fileData); } else { const val = result.files[k] as FileDataType; result.files[k] = [val, fileData]; } } await fs.writeFile(path, v.stream() as any); } } } } } return result; }

RemixJS 2 裡的 link 和 meta 標籤

RemixJS 2 裡的 link 和 meta 標籤

SSR 的特色是可以讓每個頁面擁有各自的 SEO 設定,同時也可以有各自的 link 和 meta 設定。

可以在 root.tsx 使用 links() 設定整個網站都通用的 link 標籤。

export const links: LinksFunction = () => [ ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css", }, { rel: "stylesheet", href: "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css", }, ];

在 Layout 架構中,links() 的功能是堆疊起來的,以下式的架構來說明。

app/root.tsx app/routes/address-book.tsx app/routes/address-book.add.tsx

/address-book/add 的頁面最後取得的 link 標籤會依序是三個檔加起來的設定。

meta 標籤

meta 標籤的運作則和 link 標籤不同,在 root.tsx 可以直接設定全站的 meta 標籤。 <Meta /> 是用來載入子元件的 meta 設定。

// root.tsx 片段 export default function App() { const loaderData = useLoaderData<typeof loader>() return ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <Meta /> <Links /> </head> <body> <Outlet /> <ScrollRestoration /> <Scripts /> <LiveReload /> </body> </html> ); }

但在 Layout 的結構中,meta() 並不會疊加之前的設定,而是以最後設定的為主。 另外,在同一個檔案 loader() 會比 meta() 先執行,所以 meta() 可以收到 loader() 回傳的資料。

export const meta: MetaFunction = ({ data, params, location, matches }) => { console.log("address-book meta"); console.log(data); // { shin: "der" } return [ { title: "通訊錄" }, { name: "keyword", content: "address-book" }, { name: "name", content: "shin" }, ]; }; export async function loader({ request, params }: LoaderFunctionArgs) { console.log("address-book loader"); return { shin: "der" }; }

RemixJS 2 loader 函式

RemixJS 2 loader 函式

RemixJS 使用 react ,是架構簡潔的 SSR (Server Side Render) 框架。 它的每個頁面都有可能是 SSR 或 CSR (Client Side Render),所以有特殊的兩個後端執行的函式 loader() 和 action(),頁面內的 components 則無。

loader() 的功能是在以 GET 拜訪時,於後端執行,通常是取得頁面要使用的資料。頁面裡 React component 可以使用 useLoaderData() hook 載入 loader() 回傳的資料。

loader() 並非必要的函式。另外,若沒有要回傳資料必須回傳 null。

// app/routes/address-book.edit.$sid.tsx import { type LoaderFunctionArgs, redirect } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import dayjs from "dayjs"; import db from "~/modules/mysql-connect"; import { RowDataPacket } from "mysql2/promise"; export async function loader({ request, params }: LoaderFunctionArgs) { const sid = params.sid; const sql = `SELECT * FROM address_book WHERE sid=?`; const [rows] = await db.query<RowDataPacket[]>(sql, [sid]); if (rows.length) { rows[0].birthday = dayjs(rows[0].birthday).format("YYYY-MM-DD"); return rows[0]; // useLoaderData() 可以取得此處回傳的資料 } else { return redirect("/address-book"); } } export default function AddressBookEdit() { const data = useLoaderData(); return ( <div className="container"> </div> ); }

Layout 的 loader()

每層的 Layout 都有可能需要個別的資料呈現,所以每層的 loader() 會依序觸發。以檔案 app/routes/address-book.add.tsx 為例。依序會觸發下列檔案的 loader()。

app/root.tsx app/routes/address-book.tsx app/routes/address-book.add.tsx

然而,依邏輯 Layout 不應該有 action(),就算有也不會依機制呼叫。

API功能 (沒有頁面函式時)

如果該路由不是要呈現 HTML 頁面,而是提供 JSON 格式的資料時,可以只定義 loader() 而不定義頁面 react component 函式。

import { type LoaderFunctionArgs, json, } from "@remix-run/node"; export async function loader({ request, params }: LoaderFunctionArgs) { return json({ name: "Shinder", method: request.method }); }

2023-10-21

RemixJS 2 的路由架構

RemixJS 2 的路由架構

NestJS 13 推出 app router 架構快一年了,把原本 "資料夾" 和 "檔案" 設定並存的方式,大刀闊斧改成以資料夾為主的路由設定方式。

反之,RemixJS 2 則是轉向以 "檔案" 為主的路由設定方式。如果以中小型網站的角度去看,RemixJS 似乎是比較方便的做法。以下是整理官方文件的說明。

以檔案為基本架構

檔案資料夾結構

app/ ├── routes/ │ ├── _index.tsx │ ├── about.tsx │ ├── concerts._index.tsx │ ├── concerts.$city.tsx │ ├── concerts.trending.tsx │ └── concerts.tsx └── root.tsx
URL路徑 對應的檔案 Layout (佈局檔)
/ app/routes/_index.tsx app/root.tsx
/about app/routes/about.tsx app/root.tsx
/concerts app/routes/concerts._index.tsx app/routes/concerts.tsx
/concerts/trending app/routes/concerts.trending.tsx app/routes/concerts.tsx
/concerts/salt-lake-city app/routes/concerts.$city.tsx app/routes/concerts.tsx

歸納幾點規則:

  1. 根目錄檔案為 _index.tsx
  2. 檔案名稱中以點「.」做為分隔的符號,表示路徑的分段。 concerts.trending.tsx 表示 /concerts.trending 路徑。
  3. 動態路由使用美金符號為分段開頭,例如上表中的 concerts.$city.tsx。
  4. 經過的佈局檔,會由 root.tsx 為最開頭。以 app/routes/concerts.trending.tsx 為例,經過的佈局檔會為 root.tsx 再來才是 concerts.tsx。佈局檔和葉路由檔會分別被編譯為個別的 js 檔以方便動態載入。

下表為更複雜的狀況:

URL路徑 對應的檔案
/address-book/edit/abc app/routes/address-book.edit.abc.tsx
/address-book/edit/123 app/routes/address-book.edit.$sid.tsx
/address-book/edit/abc/def app/routes/address-book.edit.abc.def.tsx

以資料夾為架構

也可以使用資料夾為架構,不過只能在 routes 目錄下的第一層,更內層的資料夾就沒有效果了。 資料夾的命名規則同上述的檔案命名規則,而對應的頁面檔案名稱需為 route.tsx。

app/ ├── routes/ │ ├── _index/ │ │ ├── signup-form.tsx │ │ └── route.tsx │ ├── about/ │ │ ├── header.tsx │ │ └── route.tsx │ ├── concerts/ │ │ ├── favorites-cookie.ts │ │ └── route.tsx │ ├── concerts.$city/ │ │ └── route.tsx │ ├── concerts._index/ │ │ ├── featured.tsx │ │ └── route.tsx │ └── concerts.trending/ │ ├── card.tsx │ ├── route.tsx │ └── sponsored.tsx └── root.tsx

自訂路由

一般使用預設路由規則即可,在某些情況下要自訂路由對應可以設定 remix.config.js 檔,請參考官方文件說明。

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

FB 留言