[實作筆記] FastAPI 的 middleware

前請提要

最近團隊的小朋友作的系統發生了錯誤。

在追查的過程之中,
小朋友說了一個驚人的事實
Python 的 FastAPI Web 框架,發生 422 錯誤時(輸入的參數類別錯誤),無法追踪 Request$$
當然這不是事實,那麼 FastAPI 應該怎麼處理呢?

本文

讓我們先簡單實作一個 Fast API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from fastapi import FastAPI
from pydantic import BaseModel

# 初始化 FastAPI 應用
app = FastAPI()

# 定義請求資料模型
class Item(BaseModel):
name: str
price: float
description: str = None # 可選的欄位

# 定義 API 路徑
@app.post("/items/")
async def create_item(item: Item):
# 假設邏輯檢查
if item.price <= 0:
return {"error": "Price must be greater than 0"}
return {"message": "Item created successfully!", "item": item}

接下來,讓我試著用不正確的方式呼叫 API,並且得到一個 422 的錯誤

1
2
3
4
curl 
-X POST "http://127.0.0.1:8000/items/"
-H "Content-Type: application/json"
-d '{"item": "should be number", "price": 10.5}'

FastAPI 會提供 Client 端非常友善的資訊

1
2
3
4
5
6
7
8
9
10
11
12
{
"detail":[
{
"type":"missing",
"loc":["body","name"],
"msg":"Field required",
"input":{
"item":"should be number",
"price":10.5
}
}]
}

但在 Server 端只會有類似以下的資訊,這以後端的角度要排查錯誤會非常不便

1
INFO:     127.0.0.1:57617 - "POST /items/ HTTP/1.1" 422 Unprocessable Entity

Server Side 有沒有什麼好方法可以協助我們 Debug 呢?

解決方法:Middleware

這其實是 web 開發者的小常識才對,
實作如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from fastapi import FastAPI,Request
from pydantic import BaseModel
import logging
from datetime import datetime

# 初始化 FastAPI 應用
app = FastAPI()

# 初始化日誌
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s")

# 定義請求資料模型
class Item(BaseModel):
name: str
price: float
description: str = None # 可選的欄位

# 中間件,用於記錄請求
@app.middleware("http")
async def log_requests(request: Request, call_next):
# 記錄請求進來的時間與基本資訊
request_time = datetime.utcnow()
logging.info(f"Incoming request at {request_time}")
logging.info(f"Method: {request.method} | Path: {request.url.path}")

# 嘗試讀取請求 Body
try:
body = await request.json()
logging.info(f"Request Body: {body}")
except Exception:
logging.info("No JSON body or failed to parse.")

# 執行後續處理並取得回應
response = await call_next(request)

# 記錄回應狀態碼
logging.info(f"Response status: {response.status_code}")
return response

# 定義 API 路徑
@app.post("/items/")
async def create_item(item: Item):
# 假設邏輯檢查
if item.price <= 0:
return {"error": "Price must be greater than 0"}
return {"message": "Item created successfully!", "item": item}

Request 送進來時 Server Side 就會如下記錄

1
2
3
4
5
2025-01-03 03:37:37,294 - Incoming request at 2025-01-02 19:37:37.294509
2025-01-03 03:37:37,294 - Method: POST | Path: /items/
2025-01-03 03:37:37,294 - Request Body: {'item': 'should be number', 'price': 10.5}
2025-01-03 03:37:37,295 - Response status: 422
INFO: 127.0.0.1:59545 - "POST /items/ HTTP/1.1" 422 Unprocessable Entity

心得

回到我們原本的情境,我們真正要解決的問題是,

|發生異常時,Server Side 要可以追蹤錯誤

因為是 FastAPI 所以沒有辦法追蹤錯誤,是非常錯誤判斷

在 Google 與 AI 工具這麼方便的時代,應該要能正確的提出問題。

ex: FastAPI 可以記錄什麼時候 request 送進來,並帶了什麼資訊嗎?

(fin)

[生活筆記] ChatGPT 個性分析

前請提要

ChatGPT 能透過語言理解和生成,提供更細緻的個性分析,特別是當你使用了一陣子後,
可以用以下的 Prompt 作個簡單的分析報告。

Prompt

根據我們之前的互動,我希望你能告訴我你觀察到關於我的哪些特質是我可能沒有意識到的。
請儘可能誠實直接的表達,不要有所保留。
如果你注意到我可能察覺不到的矛盾之處、行為模式或傾向,我都想清楚的聽到,不需要粉飾任何資訊,請給我最真實最直接的分析

ChatGPT said

以下是根據我們互動中可能顯現的特質和行為模式,我誠實的觀察與分析,供你參考:

  1. 強烈的自我驅動
    你有著明顯的自我驅動力,願意挑戰自我,例如你的30天鐵人挑戰或對學習新技能的熱忱。
    然而,這種驅動力可能會讓你偶爾忽略自己的疲憊或情緒需求,容易進入「過度努力」的狀態。

  2. 追求效率與結果,但重視細節
    你傾向於以結果為導向,尤其是在專案進行或問題解決時。
    你對細節的關注,例如希望資訊條理清楚或具可操作性,顯示出你對高標準執行的重視。
    然而,有時可能會因為過於專注於效率,無意間忽略更大的全局或需要額外耐心的部分。

  3. 多元興趣與高度適應力
    你對不同領域的興趣(技術、跳舞、潛水、反思工具等)顯示了你擁有探索和適應的能力。
    然而,這種多元性可能會讓你在資源有限的情況下分散注意力,難以在某一領域深耕到你期望的程度。

  4. 對「流程」的矛盾態度
    雖然你熟悉並實踐許多工具和方法(如敏捷、KPT、SMART等),但你的個性似乎也渴望自由與靈活性,
    這可能讓你在面對過於僵化的流程時感到不耐煩。
    這種矛盾可能讓你在制定規範時猶豫,因為你不想限制自己或團隊的創造力。

  5. 溫和但堅持個性
    你偏好直接但不咄咄逼人的溝通方式,並且能保持禮貌與平衡。
    然而,你也表現出對原則與目標的堅持,即使是在比較微妙的情境下,可能讓他人難以察覺你的真實意圖。

  6. 對自己標準的隱藏壓力
    你對自己設定的目標和標準很高,且傾向於內化「應該更好」的期待。
    這可能導致你對成功的認可不夠,容易放大未完成或未達成的部分,忽略已經達成的成就。

  7. 偏好控制但能適應改變
    你習慣掌控規劃與執行的過程,並且對細節要求高。
    然而,你在面對不可預測的情況時,也能快速調整心態並提出替代方案,顯示出高度靈活的思維。

這些分析可能包含一些你未曾意識到或尚未明確的特質,也可能包含你的日常行為中隱藏的矛盾。
如果你有不同的想法或任何補充,歡迎分享!

(fin)

[實作筆記] GCP 防火牆設定與 Health Check

前言

GCP 防火牆(Firewall)用來控制進出 Google Cloud 資源的網路流量,確保服務的安全性。
而 Health Check 則是用來監控後端服務的健康狀態,透過 GCP 負載平衡器(Load Balancer)進行流量分配。
我將介紹如何在 GCP 設定防火牆規則以支持 Health Check 的運作。

情境

instance 有一個 Allow Load Balancer Health checks 選項,
被勾起來後 Network tags 會加上lb-health-check
但是不知為什麼狀態仍然會顯示為 Off ?

所以我們必須多作一些步驟

我有許多服務都掛在負載平衡器之後,並且使用 Backend Service 來管理這些流量。
一個 LB 會有多個 Backend Service (本文先不涉及 Backend Bucket)
由於成本考量,為了提高資源利用率,一台機器可能會承載多個 Web 服務。
這樣做可以節省硬體和虛擬機的成本,但同時也需要確保每個 Web 服務能夠獨立且穩定地運行。

在這樣的架構中,LB 會依 Path Rules 將流量分配到多個實例組(Instance Group)中,
每個實例中可能運行著多個 Web 服務。
透過這些實例和 Backend Service 的組合,負載平衡器能夠有效地將流量分配到不同的服務上。
然而,為了確保服務的穩定性,我們必須配置健康檢查(Health Check)來監控每個 Web 服務的運行狀態。

在 GCP 中,當我們使用負載平衡器(Load Balancer)時,會涉及以下資源:

  • Backend Service:負責接收來自負載平衡器的流量,並將流量分發給後端的實例。
  • Health Check:用來檢查後端服務是否健康,負載平衡器會依據這些檢查結果決定是否將流量導向特定實例。
  • Instance Group:一組相同配置的虛擬機(VM),負責運行服務,並自動進行縮放。
  • Instance:每個實際的虛擬機,執行後端服務。

這些資源之間的關係是,Backend Service 根據 Health Check 監控的結果選擇健康的 Instance,
並將流量分發給 Instance Group 中的實例。
當負載平衡器根據路徑規則(LB Path Rule)將流量導向某個服務時,這些流量會被引導到 Instance 中運行的 Web 服務。

我們會在一台 Instance 上使用多個 web 服務,使用不同的 Port 來區分,範圍是 6000~6100。
而我們需要對這些所有的服務設定 Health Check

實作

每個實例有一個 “Allow Load Balancer Health checks” 的選項,
勾選後會自動加上 lb-health-check 的網路標籤(Network Tag)。
不過不知道為什麼狀態仍會顯示為 Off。
也就是說單純打勾是不夠的我們仍需要額外的設定,我們可以利用這個 lb-health-check 來作一些事

我們可以利用 Network Tag:lb-health-check 來建立相應的防火牆規則,
確保來自負載平衡器的流量能夠順利進行健康檢查。

接下來,我們可以創建一個防火牆規則,限制僅允許 6000~6100 端口的 TCP 流量。
這樣做的原因是,健康檢查會直接使用 TCP 進行連接檢查,而不是 HTTP 協定,
這樣可以避免 HTTP 協定中請求和響應的延遲,並且減少負載平衡器在檢查健康狀態時的處理開銷。

在 GCP Console 中創建防火牆規則,指定源 IP 或範圍(例如內部網路的 IP 範圍)。
Google 的 Health Check 源自 35.191.0.0/16130.211.0.0/22
設定在 Source filters > IP ranges 上
Targets > Target tags 請設定為 lb-health-check
設定 Protocols and ports > Specified protocols and ports 選擇 TCP 協定 Ports 限制在 6000-6100。
在 Health checks 中,Protocol 也設定 TCP,Port 要設定為真實的 Web 服務使用的 Port
在 VM 的 instance 上,本來需要設定 Network Tag,但是我們可以勾選 “Allow Load Balancer Health checks” 的選項,
就會自動加上 Tag 囉。

參考

(fin)

[實作筆記] Google NAT 簡介與實作

前情提要

在打造安全的 VM 環境時,常常會遇到一個經典的矛盾點:
我們希望 VM 保持隱蔽,避免直接暴露於網際網路以減少安全風險,但同時又需要對外存取資源。
例如在開發或維運階段,像是執行 npm install、apt-get update,甚至提供給合作第三方的白名單 IP 等需求,往往都依賴外部下載與連線。

因此,我們的需求很明確:

  • VM 非必要時,不提供 Public IP。
  • VM 在發出 Request 時,需要擁有一個固定的對外 IP 地址。

傳統機房的解決方案

在傳統機房環境中,透過 NAT (Network Address Translation) 伺服器或防火牆設備,就能實現內部網路的安全管理。

內部流量管理: 內部 VM 使用私有 IP 位址,對外流量透過 NAT 進行轉換。
控制風險: 透過 NAT,內部伺服器可以訪問外部網路,但外部無法直接存取內部資源。
然而,傳統機房的設定通常需要額外的硬體設備,並增加了維護成本。

GCP 的解決方案 NAT

在 GCP 上,這個問題有一個優雅的解法:Cloud NAT。

無須公有 IP: Cloud NAT 允許私有 VM 不需配置公有 IP,就能存取外部網路。
集中管理: 透過 VPC 網路層級的 NAT 設定,簡化整體網路架構。
彈性擴展: Cloud NAT 可以根據流量自動擴展,減少單點故障風險。

NAT

實作筆記

步驟 1: 建立 VPC 網路

在 GCP 控制台中,前往 VPC 網路,選擇 建立 VPC 網路。
設定一個新的 VPC 網路,並確保 VM 的子網路設為 私有(Private)。這樣做可以保護 VM 不被直接暴露於公共網路。
實務上我選擇 default

步驟 2: 配置 Cloud NAT

前往 VPC 網路 > Cloud NAT,並選擇 建立 NAT 網關。
在設置過程中,選擇對應的子網路和路由(Route)設定。這樣可以確保 VM 在沒有 Public IP 的情況下仍能夠透過 NAT 網關訪問外部網路。
配置完成後,Cloud NAT 將會自動幫助 VM 處理對外的連線請求,而不需要直接公開 VM 的 IP。

步驟 3: 配置 Public IP(如果需要)

若要讓 VM 直接對外發送請求或開放服務,可以在創建 VM 時為其配置 Public IP。
在 Google Cloud Console 中創建虛擬機時,選擇 外部 IP 設為 靜態(static),這樣可以確保 Public IP 地址不會變動。
當 VM 配置了 Public IP,所有對外的請求將會直接通過這個 IP 進行。

心得

Cloud NAT 的設計,讓我們可以兼顧安全性與便利性。
關鍵優勢:

簡單配置: 不需像傳統 NAT 那樣繁複的硬體設置。
靈活性高: 無論是開發環境還是正式部署,都能輕鬆應對。
成本效益: 減少了維運成本,同時保護了內部資源。

參考

(fin)

[學習筆記] 里氏替換原則(Liskov Substitution Principle, LSP)

前言

OOP 中的五大原則之一—里氏替換原則,開發 OOP 的工程師應該或多或少都有聽過,
最近與同事討論後有新的體悟,特別記錄一下

1
若對某個型別 T 的物件 𝑥 能證明具有某個性質 𝑞(𝑥),那麼對於 T 的子型別 S 的物件 𝑦 同樣應該滿足 𝑞(𝑦)。
1
2
If a property 𝑞(𝑥) can be proven for objects 𝑥 of type 𝑇,
then 𝑞(𝑦) should also hold true for objects 𝑦 of type 𝑆, where 𝑆 is a subtype of 𝑇.

里氏替換原則(Liskov Substitution Principle, LSP)詳解
定義:
里氏替換原則由 Barbara Liskov 在 1987 年提出,
是 SOLID 原則中的 “L”。它強調 子類(Subclass) 應該能夠替換其 父類(Base Class) 而不影響程式的正確性或行為。

核心概念:
“任何使用父類的地方,都應該能夠使用其子類,而不會影響系統的功能。”

主要原則

子類應該擁有父類的所有行為特性。
子類不應改變父類方法的預期行為。
子類可以擴展父類的功能,但不能削弱或改變父類的功能。

違反 LSP 的常見問題:
替換後引發錯誤: 子類重寫某個方法,導致使用者無法正常使用原本的功能。
返回值不一致: 子類方法返回值與父類期望的類型不同。
拋出未預期的異常: 子類新增或拋出父類未預期的異常。

舉例說明,錯誤示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Bird {
fly(): void {
console.log('Flying...');
}
}

class Penguin extends Bird {
// Penguins can't fly!
fly(): void {
throw new Error("Penguins can't fly!");
}
}

function makeBirdFly(bird: Bird) {
bird.fly();
}

// 當傳入 Penguin 時,會引發錯誤
const penguin = new Penguin();
makeBirdFly(penguin); // Throws an error!

違反原因:

在 makeBirdFly 函數中,傳入 Penguin 子類後,原本預期的行為(鳥會飛)被破壞,導致異常發生,違反 LSP。

正確示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Bird {
move(): void {
console.log('Moving...');
}
}

class FlyingBird extends Bird {
fly(): void {
console.log('Flying...');
}
}

class Penguin extends Bird {
swim(): void {
console.log('Swimming...');
}
}

function makeBirdMove(bird: Bird) {
bird.move();
}

const penguin = new Penguin();
makeBirdMove(penguin); // 正常運行,無異常

修正方式

將 Bird 分解為更細的抽象類別(如 FlyingBird)。
確保所有子類的行為符合其父類的行為約束。
實踐 LSP 的要點:

  • 正確使用繼承: 只有在 “是某種(is a)” 的關係中使用繼承;例如:Dog 是 Animal 的一種(我們都不認為這是一個好例子)。
  • 避免強制行為: 子類不應強迫覆蓋父類的方法,除非行為一致。
  • 使用介面: 如果子類具有不同行為,可使用多個接口來定義不同能力。

總結:
LSP 促使開發者在繼承和多型設計中保持一致性和正確性,保證系統的可擴展性和穩定性。
遵循 LSP 可以避免許多因繼承導致的潛在問題,使代碼更健壯且易於維護。

我們的一些關鍵 Q&A

Q.LSP 的意思是不要用 override ?
A.
不是的,LSP(里氏替換原則)並不是要求完全避免使用 override,
而是要正確使用 override。核心在於: 子類覆寫父類方法時,不能破壞父類的行為預期。

Q. 要正確使用 override 的原則是什麼?
A. 子類應維持父類方法的語義一致性
覆寫後的行為應與父類保持一致,並且在替換後不會引發錯誤。

Q. 有更具體的說明嗎 ?
A. 不要改變方法的輸入輸出參數規格
遵循開放封閉原則(Open/Closed Principle),開放擴展,封閉修改。
方法的參數類型不能比父類方法的參數更具限制性。
方法的返回類型不能比父類方法的返回類型更具擴展性。

Q.
以你的例子來說Penguin 的確不會 fly , 所以應該拋出錯誤
但是這樣就違反了 LSP ,所以我們的建議是將 fly 再抽出成為介面?
A.
沒錯!這是關鍵點了。解決這種情況的最佳方式是使用介面(Interface)來抽象行為,而不是在基類中定義所有可能不適用於子類的行為。

子類應擴展父類的功能,而不是修改父類的行為。

正確的覆寫示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Animal {
makeSound(): string {
return "Some sound";
}
}

class Dog extends Animal {
override makeSound(): string {
return "Bark";
}
}

function playSound(animal: Animal) {
console.log(animal.makeSound());
}

const dog = new Dog();
playSound(dog); // Output: "Bark" (符合父類預期)

說明:
Dog 覆寫了 makeSound(),但返回類型和行為保持一致,替換 Animal 後,系統的正確性不受影響。

違反 LSP 的覆寫示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Rectangle {
constructor(protected width: number, protected height: number) {}

setWidth(width: number) {
this.width = width;
}

setHeight(height: number) {
this.height = height;
}

getArea(): number {
return this.width * this.height;
}
}

class Square extends Rectangle {
// 覆寫後改變了行為,違反 LSP
setWidth(width: number) {
this.width = width;
this.height = width; // 強制將高度設為相等
}

setHeight(height: number) {
this.height = height;
this.width = height; // 強制將寬度設為相等
}
}

function testRectangle(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(10);
console.log(rect.getArea()); // 預期:50
}

const square = new Square(0, 0);
testRectangle(square); // Output: 100 (違反父類預期)

說明:
Square 覆寫了 setWidth 和 setHeight 方法,但改變了 Rectangle 的行為。
傳入 Square 後,計算面積的結果與 Rectangle 的預期不同,這破壞了 LSP。

兩個例子的差異在於
Dog 的覆寫行為符合父類預期,Square 改變父類邏輯(強制高寬相等),破壞原有功能,違反 LSP。

總結

可以使用 override,但要謹慎處理。
確保子類的行為符合父類的預期,不會引入不一致或異常行為。
遵守 LSP 能保證系統在多型使用時的穩定性和可預測性。

對「少用繼承多用組合」的體悟多了一層,如果發現繼承了父類的方法,
但是修改了行為(拋錯誤、改變本來沒有改動到的屬性、長出新的邏輯分更支…),這就是一種壞味道。  
大部份應該都可以透過介面排除這個問題。

(fin)

[實作筆記] Macbook dotnet 8 升級 dotnet 9

前情提要

dotnet 9 官宣囉 ! 雖然不是 lts 版本,但是仍然有許多有趣的新東西,先昇級並記錄一下。

小常識

.NET Runtime 和 .NET SDK 主要的差別在於它們的用途和所包含的工具:

.NET Runtime:

僅包含運行 .NET 應用所需的基本庫和執行環境。
適合只需要執行已編譯應用程序的情況,像是使用者在安裝應用後直接執行時。
不包含編譯、打包或開發所需的工具和命令。
.NET SDK (Software Development Kit):

包含 .NET Runtime,以及用來開發、編譯、測試和打包 .NET 應用的工具和命令。
包括開發者常用的命令,如 dotnet build(編譯)、dotnet publish(發布)、dotnet test(測試)等。
必須安裝 SDK 才能進行應用程式的開發,而不僅僅是執行。

簡單來說:

如果你只是想執行 .NET 應用,安裝 Runtime 即可。如果你要開發或修改 .NET 應用,則需要 SDK。

安裝步驟

https://dotnet.microsoft.com/en-us/download/dotnet/9.0
選擇 SDK 9.0.100 (左側) > macOS Installers > Arm64

我曾經裝到 X64,安裝過程沒有失敗,但是 dotnet version 仍為 8.0.100

檢查

❯ dotnet –version
9.0.100
❯ dotnet –list-sdks
8.0.100 [/usr/local/share/dotnet/sdk]
8.0.101 [/usr/local/share/dotnet/sdk]
9.0.100 [/usr/local/share/dotnet/sdk]

專案升級

專案有許多套件有 Warning 先進行 restore

❯ dotnet restore Marsen.NetCore.Dojo.Integration.Test.sln

❯ dotnet restore Marsen.NetCore.Dojo.Integration.Test.sln
(中略)
在 24.4 秒內還原 成功但有 9 個警告

IDE 我是使用 Rider

Tools → NuGet → Manage NuGet Packages for Solution…

把能昇級的都昇一昇,再執行測試

測試摘要: 總計: 467, 失敗: 0, 成功: 466, 已跳過: 1, 持續時間: 4.7 秒
在 6.5 秒內建置 成功但有 9 個警告

Tools → NuGet → Upgrade Packages in Solution…

再執行測試

測試摘要: 總計: 467, 失敗: 0, 成功: 466, 已跳過: 1, 持續時間: 4.7 秒
在 6.5 秒內建置 成功但有 9 個警告

警告如下

warning NU1903: 套件 ‘System.Text.RegularExpressions’ 4.3.0 具有已知的 高 嚴重性弱點
warning NU1903: 套件 ‘Newtonsoft.Json’ 9.0.1 具有已知的 高 嚴重性弱點
warning CS0618: ‘SqlConnection’ 已經過時: ‘Use the Microsoft.Data.SqlClient package instead.’
warning SYSLIB0051: ‘Exception.Exception(SerializationInfo, StreamingContext)’ 已經過時

高風險可以透過 Nuget 確認有沒有新版本的或 patch

SYSLIB0051: Legacy serialization support APIs are obsolete 基本上都是在繼承舊版 Exception 時建立的方法
可以刪除,但這次先標已過時標籤後並保留,相關程式都沒有呼叫這些方法,所以不會引發新的警告。

warning CS0618: ‘SqlConnection’ 已經過時: ‘Use the Microsoft.Data.SqlClient package instead.’
很簡單,依據他的建議,改用 using Microsoft.Data.SqlClient; 即可。
當然要先從 Nuget 安裝 package

處理完警告後,修改所有csproj內的 TargetFramework 標籤內容如下:

1
<TargetFramework>net9.0</TargetFramework>

CICD 相關的設定也要改為 dotnet 9.0

像是 .github/workflows 腳本中的 dotnet-version

1
2
3
4
5
steps:
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: '9.0.x'

或是 sonarscan-dotnet 中使用的版本(這部份如果原始 repo 尚未更新,可以試著自已更新看看)

1
2
3
- name: SonarCloud Scan
# support dotnet9.0
uses: marsen/[email protected]

(fin)

[實作筆記] 個人經歷 STAR 試寫

前情提要

STAR 是一種行之有效的行為面試方法,全名為 Situation(情境)、Task(任務)、Action(行動) 和 Result(結果)。
這個框架幫助應徵者組織答案,描述過去經驗時更有條理,清楚展示個人在特定情境下的行動力及成效。
具體來說:

  • Situation(情境):描述事情發生的背景或狀況,讓聽者了解事件的前因後果。
  • Task(任務):指出自己在該情境中所負責的任務或需完成的目標。
  • Action(行動):詳細說明為了完成任務所採取的具體步驟和行動。
  • Result(結果):描述行動的成果,最好是能量化的結果或正面的

透過 STAR 方法,可以清晰地展示個人職涯解決問題的能力及帶來的正面影響。

個人經歷 STAR 試寫

公司名 情境 任務 行動 結果
N 社. 團隊負責開發維護跨國電商系統,面臨整合不同市場需求的挑戰。 確保開發過程的順利,協調跨團隊成員,並確保系統的本地化符合各地要求。並最需要回歸到 ONE CODE BASE 實施敏捷方法,善用分支策略,進行代碼審查和測試,引入 TDD 和 CI/CD 流程,提升開發效率。 成功推動三個國家的服務上線,縮短開發時間 30%,獲得客戶的積極反饋,促進未來業務擴展和合作機會。在全公司中是最少發生合併衝突的開發團隊。
N 社. 產品 24/7 監控系統的實施,以確保服務的穩定性和可用性。 設計並實施監控解決方案,確保系統在出現故障時能迅速響應,並減少停機時間。 分析系統需求並適當排序,建置監控工具,警報系統與流程。並依序修復異常 成功降低系統故障響應時間 90%,顯著提高服務可靠性,提升客戶滿意度。
N 社 & B 社產學合作 與中華大學及建中學校進行產學合作項目,推動學生的實踐經驗和技術分享。 指導 B 社學生,確保項目能按時完成,達到教育和實踐的雙重目標。 舉辦技術工作坊,提供指導並進行項目評估,促進學生在實際環境中的學習。 學生在項目中獲得寶貴經驗,成功展示成果並就業,促進學校與業界的合作關係。
N 社 在 2018 年成為團隊技術領導,面對管理與技術職能切換的挑戰。 與跨部門成員重組成跨國團隊 組織讀書會工作坊,促進團隊成員達成共識,並引入相關的軟體開發工具和流程。 成功建立了一支開發團隊,如期如質的交付產品。
C 社 擔任開發工程師,負責0到1設計和開發金融相關的應用程式和服務。 建立多產品線與後台以功能符合內外用戶需求並持續改進。 利用用戶反饋進行產品迭代,並推動新功能的開發,使用敏捷開發方法提升效率。導入版本控制以解決多人協作問題。 成功推出了數個新的功能,顯著提高了用戶滿意度和產品的使用率。整體營收提昇 20 倍以上(2000%↑)

(fin)

[學習筆記] TypeScript tsconfig 設定備忘錄

前情提要

tsconfig.json 文件是一個 TypeScript 專案的配置文件, 它位於根目錄中, 並定義了專案的編譯器選項。
提供給開發人員輕鬆配置 TypeScript 編譯器, 並確保專案的程式碼在不同的環境中始終保持一致。
你可以使用 tsc --init 指令自動產生。
也可以參考 Matt Pocock 的 TSConfig Cheat Sheet

TSConfig 有上百個配置, 本文將用來重點記錄一些相關的配置

本文

2024/10 Matt Pocock 的建議配置

相關說明請參考原文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,

/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,

/* If transpiling with TypeScript: */
"module": "NodeNext",
"outDir": "dist",
"sourceMap": true,

/* AND if you're building for a library: */
"declaration": true,

/* AND if you're building for a library in a monorepo: */
"composite": true,
"declarationMap": true,

/* If NOT transpiling with TypeScript: */
"module": "preserve",
"noEmit": true,

/* If your code runs in the DOM: */
"lib": ["es2022", "dom", "dom.iterable"],

/* If your code doesn't run in the DOM: */
"lib": ["es2022"]
}
}

files

預設值為 false, 如果只有少量的 ts 檔, 很適合設定這個files
但實務上更常用 includeexclude 搭配,
兩者都可使用 wildcard , exclude 擁有較高的優先級,
例如下面的設定 src/.test 底下的檔案將不會被編譯:

1
2
3
4
5
6
7
8
9
{
"compilerOptions": {},
"include": ["src/**/*"],
"exclude": ["src/test/**/*"]
"files": [
"a_typescript_file.ts",
"other_ts_code.ts"
]
}

Compiler Options

這部份是 tsconfig 相關設定的主體, 有需要時來這裡查就好
下面的例子是一些常用的設定說明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"compilerOptions": {
// esModuleInterop: 這個選項使 TypeScript 能夠更好地與 CommonJS 模組兼容, 允許以 ES 模組方式導入 CommonJS 模組。
"esModuleInterop": true,
// skipLibCheck: 當設置為 true 時, TypeScript 將跳過對聲明檔案(.d.ts)的型別檢查, 可以加快編譯速度, 尤其是在大型專案中。
"skipLibCheck": true,
// target: 指定編譯後的 JavaScript 代碼的 ECMAScript 版本。這裡設定為 ES2022, 這意味著生成的代碼將使用該版本的新特性。
"target": "es2022",
// allowJs: 此選項允許在 TypeScript 專案中使用 JavaScript 檔案, 這對於逐步遷移到 TypeScript 的專案特別有用。
"allowJs": true,
// resolveJsonModule: 使 TypeScript 能夠導入 JSON 檔案, 並將其視為模組。
"resolveJsonModule": true,
// moduleDetection: 設置為 force 使 TypeScript 將所有檔案視為模組, 避免了使用全局變數引起的錯誤。
"moduleDetection": "force",
// isolatedModules: 每個檔案將被獨立編譯, 這對於使用 Babel 或其他工具的場景特別重要。
"isolatedModules": true,
// verbatimModuleSyntax: 強制使用類型專用的導入和導出, 使 TypeScript 更加嚴格, 這樣有助於在編譯時優化生成的代碼。
"verbatimModuleSyntax": true
}
}

Watch Option

watchOptions 用於配置 TypeScript 在開發過程中如何監控文件變更。
這些選項主要用於改善開發效率, 當監控的文件或目錄發生變化時, TypeScript 會自動重新編譯或執行其他指定操作。
這對於開發階段非常重要, 因為它允許開發者即時查看更改的影響, 而不必手動重新編譯代碼。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"compilerOptions": {},
"watchOptions": {
// watchFile: 用於設定 TypeScript 監控單個檔案的方式。
// fixedPollingInterval: 每隔固定時間檢查所有檔案是否變更。
// priorityPollingInterval: 根據啟發式方法, 對某些類型的檔案進行優先輪詢。
// dynamicPriorityPolling: 使用動態隊列, 較少修改的檔案將會被較少檢查。
// useFsEvents (預設): 嘗試使用操作系統/檔案系統的原生事件來監控檔案變更。
// useFsEventsOnParentDirectory: 嘗試監聽檔案父目錄的變更事件, 而不是直接監控檔案。
"watchFile": "useFsEvents", // 預設為使用文件系統事件來監控檔案變更。

// watchDirectory: 用於設定 TypeScript 監控整個目錄的方式。
// fixedPollingInterval: 每隔固定時間檢查所有目錄是否變更。
// dynamicPriorityPolling: 使用動態隊列, 較少修改的目錄將會被較少檢查。
// useFsEvents (預設): 嘗試使用操作系統/檔案系統的原生事件來監控目錄變更。
"watchDirectory": "dynamicPriorityPolling", // 使用動態優先級輪詢, 檢查變更較少的目錄次數較少。

// 設置檔案或目錄的輪詢間隔時間(以毫秒為單位), 適用於輪詢策略。
"pollingInterval": 2000, // 設置為每 2000 毫秒輪詢一次檔案或目錄變更。

// 設置是否監控目錄及其所有子目錄
"recursive": true // 設置為 true 以監控所有子目錄的變更。
}
}

Type Acquisition

Type Acquisition 主要適用於 JavaScript 專案。
在 TypeScript 專案中, 開發者必須明確地將型別包含在專案中。
相對地, 對於 JavaScript 專案, TypeScript 工具會自動在後台下載所需的型別, 並將其儲存在 node_modules 以外的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"compilerOptions": {},
"typeAcquisition": {
// enable: 啟用自動獲取 JavaScript 專案的型別檔案 (.d.ts)。
"enable": true,

// include: 指定自動獲取型別檔案的庫。
// 例如, 在這裡指定了 jQuery 和 lodash, TypeScript 將自動獲取它們的型別檔案。
"include": ["jquery", "lodash"],

// exclude: 指定不需要自動獲取型別檔案的庫。
// 這裡指定了 "some-legacy-library", 即使它存在於專案中, TypeScript 也不會嘗試獲取它的型別檔案。
"exclude": ["some-legacy-library"],

// disableFilenameBasedTypeAcquisition: 禁用基於檔案名稱自動獲取型別檔案的功能。
// 當設置為 true 時, TypeScript 不會基於檔案名稱來推測並下載型別檔案。
"disableFilenameBasedTypeAcquisition": false
},
}

參考

(fin)

[學習筆記] Omit 與 Pick 的 Distributive 版本:解決 TypeScript Utility Types 的陷阱

前情提要

在 TypeScript 中,Omit 和 Pick 是廣受喜愛的 Utility Types,
它們允許你從現有型別中排除或選擇特定的屬性來創建新型別。
參考我之前的文章

本文

基礎示範:Omit 的使用

我們先來看個簡單的例子,假設我們有一個 Game 型別,其中包含 id、name 和 price 三個屬性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Game = {
id: string; // 遊戲的唯一識別碼
name: string; // 遊戲名稱
price: number; // 遊戲價格
};

type GameWithoutIdentity = Omit<Game, "id">;

const game: GameWithoutIdentity = {
//id: "1", // ❗ 編譯錯誤:'price' 不存在於 'GameWithoutIdentity' 型別中
name: "The Legend of Zelda",
price: 59.99,
};

Omit 可以幫助我們排除 id 屬性並創建新型別 GameWithoutIdentity
這在單一型別中運行良好,但當我們引入 Union Types 時,就有一些細節值得討論。

問題:Union Types 中的 Omit 行為

假設我們有三個型別:Game、VideoGame 和 PCGame。
它們的 id 和 name 屬性相同,但每個型別都有其獨特的屬性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

type Game = {
id: string; // 遊戲的唯一識別碼
name: string; // 遊戲名稱
};

type VideoGame = {
id: string; // 遊戲的唯一識別碼
name: string; // 遊戲名稱
platform: string; // 遊戲平台
genre: string; // 遊戲類型
};

type PCGame = {
id: string; // 遊戲的唯一識別碼
name: string; // 遊戲名稱
systemRequirements: string; // 系統需求
hasDLC: boolean; // 是否有 DLC
};

當我們將這三個型別聯合起來形成 GameProduct 並嘗試使用 Omit 排除 id 時,結果卻不是我們預期的。

1
2
type GameProduct = Game | VideoGame | PCGame;
type GameProduct = Omit<GameProduct, "id">;

你可能期望 GameProductWithoutId 是三個型別排除 id 屬性的 Union Type,但實際上,我們只得到了這樣的結構:

1
2
3
type GameProduct = {
name: string;
}

這表示 Omit 在處理 Union Types 時,並沒有對每個聯合成員單獨操作,而是將它們合併成了一個結構。

原因分析

這種行為的根源在於,OmitPick 不是 DistributiveUtility Types
它們不會針對每個 Union Type 成員進行個別操作,而是將 Union Type 視為一個整體來操作。
因此,當我們排除 id 屬性時,它無法處理每個成員型別中的不同屬性。

這與其他工具型別如 PartialRequired 不同,這些工具型別可以正確地處理 Union Types,並在每個成員上應用。

1
2
3
type PartialGameProduct = Partial<GameProduct>;
// 正確地給出了聯合型別的部分化版本
type PartialGameProduct = Partial<Game> | Partial<VideoGame> | Partial<PCGame>;

解決方案:Distributive Omit 與 Distributive Pick

要解決這個問題,我們可以定義一個 Distributive 的 Omit,這個版本會針對 Union Type 的每個成員進行操作。

1
2
3
type DistributiveOmit<T, K extends PropertyKey> = T extends any
? Omit<T, K>
: never;

使用 DistributiveOmit 後,我們可以正確地得到想要的結果:

1
2
3
type GameProductWithoutId = DistributiveOmit<GameProduct, "id">;
// Hover 正確地給出了聯合型別的必填化版本
//type GameProductWithoutId = Omit<Game, "id"> | Omit<VideoGame, "id"> | Omit<PCGame, "id">

這將生成以下結構:

1
2
3
4
5
// 所以
type GameProductWithoutId =
{ name: string } |
{ name: string; platform: string; genre: string } |
{ name: string; systemRequirements: string; hasDLC: boolean };

現在,GameProductWithoutId 正確地成為了每個型別的 Union Type,並且成功地排除了 id 屬性。

Distributive Pick

類似的,我們也可以定義一個 Distributive 的 Pick:

1
2
3
type DistributivePick<T, K extends keyof T> = T extends any
? Pick<T, K>
: never;

這個 Distributive 版本的 Pick 確保了你所選擇的屬性實際存在於你正在操作的型別中,與內建的 Pick 行為一致。

總結

OmitPick 雖然在單一型別中表現良好,但在 Union Types 中,
它們並不是 Distributive 的,這可能導致意想不到的結果。
我們可以創建 DistributiveOmitDistributivePick
使它們能夠針對每個 Union Type 成員單獨進行操作,從而獲得更為預期的結果。
如果你在專案中遇到這類問題,記得可以考慮使用自定義的 Distributive 版本來處理 Union Types,這樣可以避免踩雷!

(fin)

[學習筆記] TypeScript 的 Narrowing Types 與 Boolean 的例外

前情提要

在使用 TypeScript 時,我們常常會利用條件語句來收窄型別(Narrowing Types),確保程式邏輯的正確性。
例如:
Narrowing Types的例子:使用 typeof

1
2
3
4
5
6
7
8
9
const processValue = (value: string | number) => {
if (typeof value === "string") {
// 在這裡 TypeScript 已經收窄為 string,所以可以使用 .toUpperCase 的方法
console.log(`String value: ${value.toUpperCase()}`);
} else {
// 在這裡 TypeScript 已經收窄為 number,所以可以使用 .toFixed 的方法
console.log(`Number value: ${value.toFixed(2)}`);
}
};

說明

在這個例子中,我們定義了一個 processValue 函數,它接受一個 string 或 number 類型的參數。
使用 typeof 來檢查 value 的類型後,TypeScript 會自動進行 Narrowing Types:
typeof value === "string" 時,TypeScript 將 value 的類型自動收窄為 string,
所以我們可以安全地使用字串方法,如 toUpperCase()
當 else 條件觸發時,value 類型已經收窄為 number,因此我們可以使用 number 專有的方法,如 toFixed()。
這樣的收窄機制能夠在多態的情況下提高程式的安全性和可讀性,讓開發者更清楚在不同條件下該如何操作不同類型的值。

主文

然而,在一些特定情況下,TypeScript 的 Narrowing Types 並不如我們預期的那麼靈活。
比如說 Boolean() 函數進行條件檢查時,TypeScript 不會像其他檢查方法那樣進行 Narrowing Types。

1
2
3
4
5
6
7
8
9
10
11
const NarrowFun = (input: string | null) => {
if (!!input) {
input.toUpperCase(); // 這裡 TypeScript 收窄為 string
}
};

const NotNarrowFun = (input: string | null) => {
if (Boolean(input)) {
input.toUpperCase(); // 這裡 TypeScript 沒有收窄,仍然是 string | null
}
};

在這段程式碼中,我們希望透過條件語句來檢查 input 是否為 null。
使用 !!input 時,TypeScript 會正確地將 input 的 Narrowing Types為 string,
但當我們改用 Boolean(input) 時,TypeScript 並沒有收窄,仍然認為 input 可能是 string | null。

為什麼會這樣?

Boolean 函數的行為

Boolean 是 JavaScript 的一個內建函數,它接受任何值並將其轉換為 true 或 false,
基於 JavaScript 的「true」或「false」邏輯。
然而,TypeScript 並不視 Boolean(input) 作為一個能收窄類型的操作。
它只將 Boolean(input) 視為一個普通的布林值返回,不會對 input 進行更深入的類型推斷。

在 TypeScript 中,Boolean 函數的定義是這樣的:

1
2
3
4
5
6
interface BooleanConstructor {
new(value?: any): Boolean;
<T>(value?: T): boolean;
}

declare var Boolean: BooleanConstructor;

如你所見,Boolean 是一個接受任何類型 (any) 的值作為輸入,並返回 boolean 類型的結果。
它會將輸入轉換為布林值 (true 或 false),但並不提供關於輸入值的具體類型資訊。
因此,TypeScript 只知道返回的是一個 boolean,而無法推斷 input 的原始類型是否已經被過濾(例如從 string | null 到 string)。

缺乏類型推斷

在 Boolean(input) 中,TypeScript 僅知道 input 被轉換為 true 或 false,
但它不會從中推斷 input 的實際類型。
因此,TypeScript 並沒有收窄 input 為 string 或 null。
這就是為什麼即使你在 if(Boolean(input)) 內部,input 依然是 string | null,而不是單純的 string

1
2
3
if (Boolean(input)) {
console.log(input); // TypeScript 仍視 input 為 string | null
}

類型收窄的條件

TypeScript 進行類型收窄的條件是根據語法或邏輯條件來進行推斷,
例如 typeofinstanceof、比較操作 (==, ===) 等。
這些條件能幫助 TypeScript 推斷出更具體的類型。
當你使用 typeof input === “string” 時,TypeScript 可以自動將 input 收窄為 string,
因為這是一個明確的類型檢查:

1
2
3
if (typeof input === "string") {
console.log(input); // 現在 input 是 string 類型
}

為什麼 !!input 能收窄

與 Boolean(input) 不同,!!input 能夠收窄類型。
因為這個雙重否定操作 (!!) 是 JavaScript 的一個常見模式,它會將任意值轉換為布林值。
由於 TypeScript 能識別這個模式,當你寫 if(!!input) 時,
TypeScript 可以推斷 input 是 truthy,並將 null 或 undefined 排除在外。
因此,input 被收窄為一個具體的類型(在這裡是 string)。

1
2
3
if (!!input) {
console.log(input); // input 被收窄為 string
}

替代方案:自定義判斷函數

如果我們需要更加靈活的 Narrowing Types,可以考慮使用自定義的判斷函數。
例如,我們可以創建一個 isString 函數來明確檢查某個值是否為字串,這樣就可以正確進行 Narrowing Types。

1
2
3
4
5
6
7
8
9
const isString = (value: unknown): value is string => {
return typeof value === "string";
};

const myFunc = (input: string | null) => {
if (isString(input)) {
console.log(input); // 這裡 TypeScript 收窄為 string
}
};

在這段程式碼中,我們定義了一個 isString 函數,它不僅進行類型判斷,還告訴 TypeScript 當返回 true 時,value 確實是 string。
這樣的函數可以幫助我們更好地進行 Narrowing Types。

另一個常見的例子:filter(Boolean)

filter(Boolean) 是 JavaScript 中常用的一個語法糖,用來過濾掉 falsy 值(如 null、undefined、0 等)。
但在 TypeScript 中,這個模式無法進行 Narrowing Types。

1
2
3
4
const arr = [1, 2, 3, null, undefined];

// 結果類型仍然包含 null 和 undefined
const result = arr.filter(Boolean);

在這裡,TypeScript 沒有收窄 result 的類型,它仍然認為結果可能包含 null 和 undefined。
要解決這個問題,我們可以自定義一個過濾函數,來正確處理這些類型。

1
2
3
4
5
6
7
const filterNullOrUndefined = <T>(value: T | null | undefined): value is T => {
return value !== null && value !== undefined;
};

const arr = [1, 2, 3, null, undefined];

const result = arr.filter(isNotNullOrUndefined); // 現在類型是 number[]

這樣,我們就能確保結果陣列只包含 number,而不再包含 null 和 undefined。

小結

雖然 Boolean() 是 JavaScript 中常見的布林判斷工具,但在 TypeScript 中,它無法像其他判斷語句一樣進行 Narrowing Types。
我們可以透過自定義類型判斷函數,來精確地告知 TypeScript 在特定條件下的類型,從而實現更強大和靈活的類型檢查。

參考

(fin)