Hero Image
[開發] API-first approach

高效協作的基石:深入理解 API-First 開發方法 在現代軟體開發的浪潮中,追求更快的交付速度、更敏捷的反應能力,以及更優質的使用者體驗,是所有團隊的共同目標。傳統的瀑布式開發流程,因其僵化和漫長的等待週期,已逐漸無法滿足市場的快速變化。為此,「API-First」開發方法應運而生,它不僅僅是一種技術實踐,更是一種推動團隊高效協作的開發哲學。 本文將深入探討 API-First 的核心概念,比較其與傳統開發流程的差異,分析其優劣,並提供在前端專案中實踐 API Mock 的具體教學。 什麼是 API-First Approach? API-First (API 優先) 是一種軟體開發策略,其核心精神是將 API (應用程式介面) 視為整個產品的核心與一等公民 (First-class Citizen)。 在開發啟動之初,不再是先設計資料庫或撰寫後端邏輯,而是由前後端團隊,甚至包含產品、設計團隊,共同協商並定義出一份清晰、嚴謹的 API 契約 (API Contract)。這份契約詳細描述了 API 的路由、請求方法、參數、數據格式以及回傳的響應內容。 這份契約一旦確立,就成為前後端各自開發的共同依據。後端團隊依照契約實作業務邏輯與資料庫;前端團隊則可以立即使用這份契約,搭配 Mock (模擬) 技術來打造使用者介面,無需等待後端 API 的實際完成。 最常用來定義 API 契約的工具是 OpenAPI 規範 (前身為 Swagger)。它提供了一套標準化的格式 (通常是 YAML 或 JSON),讓人類和機器都能輕鬆理解 API 的功能與結構。 核心理念: 先定義好軟體元件之間如何溝通 (API),再各自實現內部邏輯。 典範轉移:API-First vs. 傳統開發流程 為了更清晰地理解 API-First 帶來的變革,我們從幾個關鍵維度來比較它與傳統開發流程的差異。 比較維度 傳統開發流程 (Database-First) API-First 開發流程 開發啟動點 資料庫先行:設計資料庫結構 -> 開發後端 API -> 前端串接。 契約先行:前後端共同定義 API 契約。 團隊依賴性 強烈的上下游依賴:前端強烈依賴後端,後端依賴資料庫設計。任何上游變動都會造成下游阻塞。 並行開發:前後端解耦,可同時進行開發。前端依賴的是「契約」,而非「已完成的後端程式」。 前後端溝通 後端主導:後端開發完 API 後,提供給前端使用,前端常處於被動接收的角色。溝通多發生在整合階段。 協同合作:開發初期就共同協商 API 規格。前端甚至能主導介面規格,因為他們最了解介面需要什麼資料。 迭代速度 緩慢且僵化:任何需求變更都可能需要從資料庫層級開始修改,牽一髮動全身,迭代週期長。 快速且敏捷:前端可以基於 Mock 數據快速迭代 UI/UX,不受後端進度限制,非常適合敏捷開發中的快速原型和頻繁調整。 錯誤發現時機 後期整合階段:許多介面不匹配、資料格式錯誤的問題,直到最後整合測試時才會浮現,修復成本高。 開發初期:由於 API 契約先行,規格不一致的問題在設計階段就被解決。介面和使用者體驗問題也能在早期透過原型被發現。 API-First 的優點與挑戰 採用 API-First 方法能帶來顯著的好處,但同時也伴隨著一些需要克服的挑戰。

Hero Image
[防火牆] 地理位置規則(GeoIP)

最近我在 GCP 上發現一個惱人的問題:中南美洲的網路流量,竟然每個月讓我多噴了好幾十塊台幣!對於我這種「免費仔」來說,這簡直是不能接受的額外開銷。雖然金額不大,但積少成多也是錢啊! 為什麼要擋流量? 你可能會問,區區幾十塊台幣也要計較?當然要!特別是當這些流量不是你預期的合法訪客時。很多時候,來自特定地區的流量可能是: 惡意掃描:網路攻擊者在尋找網站漏洞。 DDoS 攻擊嘗試:分散式阻斷服務攻擊的前奏或小規模嘗試。 殭屍網路活動:受感染的電腦發出的無意義流量。 這些流量不僅浪費你的錢,還會增加你 GCP 主機的負擔,影響正常服務的效能和穩定性。對我們這些斤斤計較流量費用的使用者來說,擋掉不必要的流量,是省錢、維護效能和安全的好方法。 GCP NGFW 雖好,但口袋不夠深? GCP 自家的 Cloud Next Generation Firewall (NGFW) 確實提供了強大的地理位置封鎖功能。透過 NGFW,你可以輕鬆設定規則,阻擋來自特定國家或地區的 IP 流量。這聽起來很棒,對吧? 然而,問題就在於:GCP NGFW 是要收費的! 它通常會依據你處理的流量來計費,這對於想要最大化免費額度,或是對預算極為敏感的「免費仔」來說,並不是一個理想的方案。我們需要一個開源且免費的替代方案。 開源方案:iptables + GeoLite2 的完美組合 別擔心,我們還是有辦法!對於運行 Linux 主機或 VM 的使用者來說,最直接、最經濟實惠的方法就是利用 Linux 內建的防火牆工具 iptables (或新一代的 nftables),並結合 MaxMind GeoLite2 資料庫。 iptables/nftables:這是 Linux 核心層級的防火牆,可以直接在 IP 層面對流量進行精準的控制。這表示在流量到達你的應用程式之前,就可以直接被阻擋掉,達到最佳的省錢效果。 MaxMind GeoLite2 資料庫:這是一個免費的地理位置 IP 資料庫。它能告訴你每個 IP 位址的歸屬國家或地區,是我們判斷是否需要阻擋的依據。 自動化腳本:省時省力,告別手動設定 手動去找出中南美洲的所有 IP 範圍,然後一條一條地加到防火牆規則裡?光是想想都覺得頭皮發麻!幸好,開源社群已經為我們準備了貼心的工具。 我發現 friendly-bits/geoip-shell 這個 GitHub 專案是個非常棒的選擇。它能幫助你: 自動生成防火牆規則:根據 MaxMind GeoLite2 資料庫,這個腳本會自動產生針對特定國家或地區的 IP 封鎖規則,省去你手動查詢和輸入的麻煩。 排程自動更新 IP:IP 位址的地理歸屬會隨時間變化。geoip-shell 支援設定自動排程,定期更新 GeoLite2 資料庫,並重新生成防火牆規則,確保你的封鎖列表始終是最新的,讓你的防禦滴水不漏。 藉由這個組合,我們就能在不花一毛錢的情況下,精確地阻擋來自中南美洲的流量,有效控制你的 GCP 費用,同時讓你的主機跑得更輕盈、更安全。 # 安裝 geoip-shell # 根據其 GitHub 說明進行安裝 # 生成封鎖南美洲的規則 sudo geoip-shell block --countries AR,BO,BR,CL,CO,EC,GF,GY,PY,PE,SR,UY,VE

Hero Image
[.NET] C# 將 PDF 轉為列印文件送出至印表機

PdfiumViewer 是開源的 C# 控件,用於顯示和列印 PDF 文件。它基於 Chromium 瀏覽器使用的 PDF 渲染引擎 Pdfium 所開發。 而Pdfium 是 Chromium 瀏覽器使用的 PDF 渲染引擎,由 Google 和 Mozilla 共同開發。它是一個開放原始碼的函式庫,用於 PDF 文件的解碼、渲染和編輯。 PdfiumViewer 提供以下功能: 顯示 PDF 文件的所有頁面。 支持縮放、旋轉、翻頁等操作。 支持列印 PDF 文件。 在 c# 中使用 PdfiumViewer 可以將 PDF 轉換為列印文件,送至印表機進行列印。 安裝 Nuget 套件: PdfiumViewer PdfiumViewer.Native.x86.v8-xfa 引用 using PdfiumViewer; using System.Drawing.Printing; using System.IO; 程式範例 從記憶體列印: var pdfBytes = new byte[] { }; // todo: 取得 PDF by docid var printerName = ""; // todo 取得印表機名稱 // 列印 using (MemoryStream memoryStream = new MemoryStream(pdfBytes)) { var pageSettings = new PageSettings() { Margins = new Margins(0, 0, 0, 0) }; var printerSettings = new PrinterSettings(); if (!string.IsNullOrEmpty(printerName)) printerSettings.PrinterName = printerName; using (var document = PdfDocument.Load(memoryStream)) { using (PrintDocument printDocument = document.CreatePrintDocument()) { printDocument.PrinterSettings = printerSettings; printDocument.DefaultPageSettings = pageSettings; printDocument.PrintController = new StandardPrintController(); printDocument.Print(); } } } 從檔案列印: var file = ""; // todo 取得檔案路徑 var printerName = ""; // todo 取得印表機名稱 // 列印 var pageSettings = new PageSettings() { Margins = new Margins(0, 0, 0, 0) }; var printerSettings = new PrinterSettings(); if (!string.IsNullOrEmpty(printerName)) printerSettings.PrinterName = printerName; using (var document = PdfDocument.Load(file)) { using (PrintDocument printDocument = document.CreatePrintDocument()) { printDocument.PrinterSettings = printerSettings; printDocument.DefaultPageSettings = pageSettings; printDocument.PrintController = new StandardPrintController(); printDocument.Print(); } } Reference Github-PdfiumViewer Nuget-PdfiumViewer Nuget-PdfiumViewer.Native.x86.v8-xfa

Hero Image
[授權] OAuth 2.0 Authorization Framework 授權許可

授權許可 (Authorization Grant) 理解授權許可前需要先瞭解解幾個名詞: 授權碼 (Authorization Code):由授權伺服器產生,用於授權第三方應用程式存取資源的授權憑證。 存取權杖 (Access Token):由授權伺服器產生,用於允許第三方應用程式存取資源的授權憑證,時效通常較短。 刷新令牌 (Refresh Token):由授權伺服器產生,用於重新產生存取權杖的授權憑證,時效通常較長。 客戶端憑證:第三方應用程式本身用於 Basic Access Authentication 的驗證資訊,具體來說是 client_id、client_secret。 OAth 2.0 根據授權許可的方式分為四種: 授權碼許可(Authorization Code Grant) 隱含許可(Implicit Grant) 資源擁有者密碼憑證許可(Resource Owner Password Credentials Grant) 客戶端憑證許可(Client Credentials Grant) 授權碼許可(Authorization Code Grant) 第三方應用程式向授權伺服器請求授權碼,並將授權碼傳遞給資源伺服器以存取資源的授權流程,這是最常用的模式。 網站使用者在授權伺服器的登入站點申請授權碼。 授權伺服器將授權碼告知第三方應用程式 第三方應用程式使用客戶端憑證和收到的授權碼 向 授權伺服器 發起請求獲得 token。 隱含許可(Implicit Grant) 因省略對第三方應用的授權碼直接以前端網址列取得 token ,故稱隱含式許可,但直接以前端網址參數的方式傳送 token 給網站使用者,是非常不安全的作法,一般 token 的有效期間設定為 session 期間有效 (關閉網頁即失效)。 網站使用者從授權伺服器登入。 授權伺服器直接在網址列帶入 access_token 轉跳第三方應用程式。 資源擁有者密碼憑證許可(Resource Owner Password Credentials Grant) 讓第三方應用直接以網站使用者密碼取得 token,必須要是高度信任的第三方應用才能用此方法。 第三方應用程式向使用者請求其帳戶密碼(使用者憑證)。 第三方應用程式以使用者帳戶密碼向授權伺服器請求 token。 客戶端憑證許可(Client Credentials Grant) 此種 token 針發放對象為第三方應用而非用戶,與用戶認証無關,由第三方應用傳送其自己的 user credentials 獲得 token。

Hero Image
[K8s] 安裝

Prerequest 已安裝 Debian 11,並且 ssh 可連線 disable swap sed -i '/\/swap/s/^/#/' /etc/fstab swapoff -a Container Runtime (CRI-O) Forwarding IPv4 and letting iptables cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf overlay br_netfilter EOF modprobe overlay modprobe br_netfilter # sysctl params required by setup, params persist across reboots cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward = 1 EOF # Apply sysctl params without reboot sysctl --system #!/bin/bash OS=Debian_11 VERSION=1.27 echo 'deb http://deb.debian.org/debian buster-backports main' > /etc/apt/sources.list.d/backports.list apt update apt install -y -t buster-backports libseccomp2 || apt update -y -t buster-backports libseccomp2 apt install -y gnupg gnupg2 curl echo "deb [signed-by=/usr/share/keyrings/libcontainers-archive-keyring.gpg] https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/$OS/ /" > /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list echo "deb [signed-by=/usr/share/keyrings/libcontainers-crio-archive-keyring.gpg] https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable:/cri-o:/$VERSION/$OS/ /" > /etc/apt/sources.list.d/devel:kubic:libcontainers:stable:cri-o:$VERSION.list mkdir -p /usr/share/keyrings curl -L https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/$OS/Release.key | gpg --dearmor -o /usr/share/keyrings/libcontainers-archive-keyring.gpg curl -L https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable:/cri-o:/$VERSION/$OS/Release.key | gpg --dearmor -o /usr/share/keyrings/libcontainers-crio-archive-keyring.gpg apt-get update apt-get install -y cri-o cri-o-runc systemctl daemon-reload systemctl enable crio systemctl start crio Install kubeadm apt-get update apt-get install -y apt-transport-https ca-certificates curl mkdir -p /etc/apt/keyrings curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-archive-keyring.gpg echo "deb [signed-by=/etc/apt/keyrings/kubernetes-archive-keyring.gpg] https://apt.kubernetes.io/ kubernetes-xenial main" | sudo tee /etc/apt/sources.list.d/kubernetes.list apt-get update apt-get install -y kubelet kubeadm kubectl apt-mark hold kubelet kubeadm kubectl Creating a cluster with kubeadm # 設定 k8s server上網路 cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf net.bridge.bridge-nf-call-ip6tables = 1 net.bridge.bridge-nf-call-iptables = 1 EOF sudo sysctl --system kubeadm init --pod-network-cidr=10.85.0.0/16 --cri-socket=unix:///var/run/crio/crio.sock ## kubectl 設定 ### root ```bash export KUBECONFIG=/etc/kubernetes/admin.conf non-root user make user sudor

Hero Image
[.NET] WebView2 單一檔案部屬

有些部屬環境要求能單一執行檔,如果要使用自己的 DLL 就會有問題,研究了內嵌 DLL 的作法應用於 WebView2 專案上。 以 .NET Framework 4.7.2 的 WinForm 專案為例,目標環境為 windows x64。 Dependency 安裝 Nuget 上的 Microsoft.Web.WebView2。 把這些資料夾底下的 Dll 複製到專案資料夾下,並加入版控 packages\Microsoft.Web.WebView2.1.0.1823.32\runtimes packages\Microsoft.Web.WebView2.1.0.1823.32\lib\net45 參考移除上述 Dll 參考(移除 Nuget 參考),改直接參考專案資料夾下的 Dll。 把參考的 Dll 調整為內嵌資源 AssemblyHelper.cs public class AssemblyHelper { public string Name { get { return this.assembly.GetName().Name; } } public string AppDataPath { get; set; } private Assembly assembly; public AssemblyHelper() { assembly = Assembly.GetCallingAssembly(); AppDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); AppDataPath = Path.Combine(AppDataPath, Name); } /// <summary> /// Extract embeded dll to target path /// </summary> /// <param name="resourceName">Dll embed path</param> /// <param name="targetPath">Dll extract distination</param> public void ExtractEmbeddedDLL(string resourceName, string targetPath) { var targetDir = Path.GetDirectoryName(targetPath); if (!string.IsNullOrEmpty(targetDir) && !Directory.Exists(targetDir)) Directory.CreateDirectory(targetDir); using (Stream resourceStream = assembly.GetManifestResourceStream(resourceName)) { using (FileStream fileStream = new FileStream(targetPath, FileMode.Create)) { resourceStream.CopyTo(fileStream); } } } /// <summary> /// 設置解析組件路徑的事件處理常式 /// </summary> public void EnableEmbededManifestDll() => AppDomain.CurrentDomain.AssemblyResolve += OnResolveAssembly; /// <summary> /// Assembly 解析行為 /// </summary> /// <param name="sender"></param> /// <param name="args"></param> /// <returns></returns> public static Assembly OnResolveAssembly(object sender, ResolveEventArgs args) { Assembly assembly = Assembly.GetCallingAssembly(); string project = Assembly.GetEntryAssembly().GetName().Name; string manifestItem = $"{project}.{new AssemblyName(args.Name).Name}.dll"; using (Stream stream = assembly.GetManifestResourceStream(manifestItem)) { if (stream == null) return null; byte[] assemblyRawBytes = new byte[stream.Length]; stream.Read(assemblyRawBytes, 0, assemblyRawBytes.Length); return Assembly.Load(assemblyRawBytes); } } } Program.cs internal static class Program { [STAThread] static void Main() { try { var asm = new AssemblyHelper(); // Load an extracted DLL dynamically asm.EnableEmbededManifestDll(); var loaderDllFolderPath = Path.Combine(asm.AppDataPath, "runtimes\\win-x64\\native"); var dll = Path.Combine(loaderDllFolderPath, "WebView2Loader.dll"); var loaderDllEmbedPath = $"{asm.Name}.runtimes.win_x64.native.WebView2Loader.dll"; asm.ExtractEmbeddedDLL(loaderDllEmbedPath, dll); // 將需注入 DLL 的邏輯抽離 Main 才能跑 run(loaderDllFolderPath); } catch (Exception ex) { MessageBox.Show(ex.Message); } } private static void run(string loaderDllFolderPath) { CoreWebView2Environment.SetLoaderDllFolderPath(loaderDllFolderPath); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new Form1()); } } 隱藏暫存檔 假設 webview2 元件變數是wv:

Hero Image
[Code] 格式化民國年

這個方法用 proxy 擴充 dayjs,讓 dayjs 支援格式化民國年。 JS 程式碼 // dayx.js import day from "dayjs"; const prototype = Object.getPrototypeOf(day()); const yyy = (dt) => (dt.getFullYear() - 1911).toString().padStart(3, "0"); const handler = { get: function (target, prop, receiver) { // age, dte, tme if (prop === "age") return day().diff(receiver, "years", false); if (prop === "dte") return receiver.format("YYYMMDD"); if (prop === "tme") return receiver.format("HHmmss"); // format if (prop === "format") return (format) => { const formattingTokens = /Y{4,}|Y{3}|[^Y{3}]+/g; const arr = format.match(formattingTokens); const format2 = arr .map((x) => (x === "YYY" ? yyy(target.$d) : x)) .join(""); console.log("ktformat", format2); return new day(target.$d).format(format2); }; // Return the original property value if (typeof target[prop] !== "function") return target[prop]; // Wrap the original function with custom behavior return function (...args) { const result = target[prop].apply(target, args); const isReturnDayjs = Object.getPrototypeOf(result) === prototype; return isReturnDayjs ? p(result, handler) : result; }; }, set(obj, prop, v, receiver) { if (prop === "dte") { v = v.padStart(7, "0"); const yyy = parseInt(v.substring(0, 3)) + 1911; obj.$d.setFullYear(yyy); obj.$d.setMonth(parseInt(v.substring(3, 5)) - 1); obj.$d.setDate(parseInt(v.substring(5, 7))); return receiver; } if (prop === "tme") { v = v.padEnd(6, "0"); obj.$d.setHours(parseInt(v.substring(0, 2))); obj.$d.setMinutes(parseInt(v.substring(2, 4))); obj.$d.setSeconds(parseInt(v.substring(4, 6))); return receiver; } return Reflect.set(...arguments); }, }; const p = (...args) => new Proxy(day(...args), handler); export default p; 型別定義 // dayx.d.ts import dayjs from "dayjs"; declare module "dayjs" { interface Dayjs { age: number; dte: string; tme: string; } } export = dayjs; export as namespace dayjs; 使用方式 可直接設定民國年,此外時間的加減乘除都可以參照 dayjs 文件